diff --git a/.asf.yaml b/.asf.yaml index 8d43adeff39f..13ac27f20db1 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -18,7 +18,7 @@ # # The format of this file is documented at -# https://cwiki.apache.org/confluence/display/INFRA/Git+-+.asf.yaml+features +# https://github.com/apache/infrastructure-asfyaml/blob/main/README.md github: description: "Apache Iceberg" diff --git a/.baseline/checkstyle/checkstyle-suppressions.xml b/.baseline/checkstyle/checkstyle-suppressions.xml index 1e79b1a7aa4b..c3a07bf6453f 100644 --- a/.baseline/checkstyle/checkstyle-suppressions.xml +++ b/.baseline/checkstyle/checkstyle-suppressions.xml @@ -53,11 +53,6 @@ - - - - - diff --git a/.baseline/checkstyle/checkstyle.xml b/.baseline/checkstyle/checkstyle.xml index 943d299b338f..f94848450a9b 100644 --- a/.baseline/checkstyle/checkstyle.xml +++ b/.baseline/checkstyle/checkstyle.xml @@ -450,6 +450,11 @@ + + + + + diff --git a/.github/ISSUE_TEMPLATE/iceberg_bug_report.yml b/.github/ISSUE_TEMPLATE/iceberg_bug_report.yml index b383de4e766b..2e507128434f 100644 --- a/.github/ISSUE_TEMPLATE/iceberg_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/iceberg_bug_report.yml @@ -34,7 +34,8 @@ body: description: What Apache Iceberg version are you using? multiple: false options: - - "1.10.0 (latest release)" + - "1.10.1 (latest release)" + - "1.10.0" - "1.9.2" - "1.9.1" - "1.9.0" diff --git a/.github/ISSUE_TEMPLATE/iceberg_question.yml b/.github/ISSUE_TEMPLATE/iceberg_question.yml index b5966dccc7ac..f42bc898a081 100644 --- a/.github/ISSUE_TEMPLATE/iceberg_question.yml +++ b/.github/ISSUE_TEMPLATE/iceberg_question.yml @@ -25,7 +25,7 @@ body: - type: markdown attributes: value: | - Feel free to ask your question on [Slack](https://join.slack.com/t/apache-iceberg/shared_invite/zt-3kclosz6r-3heAW3d~_PHefmN2A_~cAg) as well. + Feel free to ask your question on [Slack](https://join.slack.com/t/apache-iceberg/shared_invite/zt-3tkrk9gpf-1eFZ8ozS2In0~zM_BeZiRQ) as well. Do **NOT** share any sensitive information like passwords, security tokens, private URLs etc. - type: textarea diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6acbbacda25d..d897cc11f5ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,12 +24,16 @@ updates: schedule: interval: "weekly" day: "sunday" + cooldown: + default-days: 7 - package-ecosystem: "gradle" directory: "/" schedule: interval: "weekly" day: "sunday" open-pull-requests-limit: 50 + cooldown: + default-days: 7 ignore: - dependency-name: "*" update-types: ["version-update:semver-major"] @@ -39,6 +43,8 @@ updates: interval: "weekly" day: "sunday" open-pull-requests-limit: 5 + cooldown: + default-days: 7 ignore: - dependency-name: "*" update-types: ["version-update:semver-major"] diff --git a/.github/workflows/api-binary-compatibility.yml b/.github/workflows/api-binary-compatibility.yml index 2b6f8ec323e4..58a04c9427e0 100644 --- a/.github/workflows/api-binary-compatibility.yml +++ b/.github/workflows/api-binary-compatibility.yml @@ -35,6 +35,9 @@ on: - 'api/**' - '.palantir/revapi.yml' +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -43,7 +46,7 @@ jobs: revapi: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # fetch-depth of zero ensures that the tags are pulled in and we're not in a detached HEAD state # revapi depends on the tags, specifically the tag from git describe, to find the relevant override @@ -51,14 +54,16 @@ jobs: # # See https://github.com/actions/checkout/issues/124 fetch-depth: 0 - - uses: actions/setup-java@v5 + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: 17 + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: | echo "Using the old version tag, as per git describe, of $(git describe)"; - run: ./gradlew revapi --rerun-tasks - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs diff --git a/.github/workflows/asf-allowlist-check.yml b/.github/workflows/asf-allowlist-check.yml new file mode 100644 index 000000000000..8d7952a9d29b --- /dev/null +++ b/.github/workflows/asf-allowlist-check.yml @@ -0,0 +1,46 @@ +# +# 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. +# + +# Verifies all GitHub Actions refs are on the ASF allowlist. +# Actions not on the allowlist silently fail with "Startup failure" — no logs, +# no notifications, and PRs may appear green because no checks ran. +# See https://github.com/apache/infrastructure-actions/issues/574 +name: "ASF Allowlist Check" + +on: + pull_request: + paths: + - ".github/**" + push: + branches: + - main + paths: + - ".github/**" + +permissions: + contents: read + +jobs: + asf-allowlist-check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: apache/infrastructure-actions/allowlist-check@4e9c961f587f72b170874b6f5cd4ac15f7f26eb8 # main diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000000..98685f3ced21 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,56 @@ +# +# 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. +# + +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '16 4 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze Actions + runs-on: ubuntu-slim + permissions: + contents: read + security-events: write + packages: read + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + with: + languages: actions + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + with: + category: "/language:actions" diff --git a/.github/workflows/cve-scan.yml b/.github/workflows/cve-scan.yml new file mode 100644 index 000000000000..a255104013d2 --- /dev/null +++ b/.github/workflows/cve-scan.yml @@ -0,0 +1,168 @@ +# +# 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. +# + +name: "CVE Scan" +on: + push: + branches: + - 'main' + - '0.*' + - '1.*' + - '2.*' + tags: + - 'apache-iceberg-**' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + + # ------------------------------------------------------------------ + # Trivy CVE scan — scans bundled jars for known vulnerabilities. + # + # Runs on push to main/release branches only (not PRs). Results are + # uploaded as SARIF to the GitHub Security tab for ongoing tracking. + # The scan is informational and does not block the build. + # ------------------------------------------------------------------ + cve-scan: + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - distribution: kafka-connect-runtime + build-task: >- + -DkafkaVersions=3 + :iceberg-kafka-connect:iceberg-kafka-connect-runtime:distZip + scan-path: kafka-connect/kafka-connect-runtime/build/distributions + unpack: true + - distribution: aws-bundle + build-task: :iceberg-aws-bundle:shadowJar + scan-path: aws-bundle/build/libs + unpack: false + - distribution: azure-bundle + build-task: :iceberg-azure-bundle:shadowJar + scan-path: azure-bundle/build/libs + unpack: false + - distribution: gcp-bundle + build-task: :iceberg-gcp-bundle:shadowJar + scan-path: gcp-bundle/build/libs + unpack: false + - distribution: spark-runtime-3.4_2.12 + build-task: >- + -DsparkVersions=3.4 + :iceberg-spark:iceberg-spark-runtime-3.4_2.12:shadowJar + scan-path: spark/v3.4/spark-runtime/build/libs + unpack: false + - distribution: spark-runtime-3.5_2.12 + build-task: >- + -DsparkVersions=3.5 + :iceberg-spark:iceberg-spark-runtime-3.5_2.12:shadowJar + scan-path: spark/v3.5/spark-runtime/build/libs + unpack: false + - distribution: spark-runtime-4.0_2.13 + build-task: >- + -DsparkVersions=4.0 + :iceberg-spark:iceberg-spark-runtime-4.0_2.13:shadowJar + scan-path: spark/v4.0/spark-runtime/build/libs + unpack: false + - distribution: spark-runtime-4.1_2.13 + build-task: >- + -DsparkVersions=4.1 + :iceberg-spark:iceberg-spark-runtime-4.1_2.13:shadowJar + scan-path: spark/v4.1/spark-runtime/build/libs + unpack: false + - distribution: flink-runtime-1.20 + build-task: >- + -DflinkVersions=1.20 + :iceberg-flink:iceberg-flink-runtime-1.20:shadowJar + scan-path: flink/v1.20/flink-runtime/build/libs + unpack: false + - distribution: flink-runtime-2.0 + build-task: >- + -DflinkVersions=2.0 + :iceberg-flink:iceberg-flink-runtime-2.0:shadowJar + scan-path: flink/v2.0/flink-runtime/build/libs + unpack: false + - distribution: flink-runtime-2.1 + build-task: >- + -DflinkVersions=2.1 + :iceberg-flink:iceberg-flink-runtime-2.1:shadowJar + scan-path: flink/v2.1/flink-runtime/build/libs + unpack: false + - distribution: open-api-test-fixtures-runtime + build-task: :iceberg-open-api:shadowJar + scan-path: open-api/build/libs + unpack: false + name: ${{ matrix.distribution }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: zulu + java-version: 21 + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 # zizmor: ignore[cache-poisoning] -- cache writes are restricted to the default branch by setup-gradle + - name: Build ${{ matrix.distribution }} + run: | + ./gradlew -DsparkVersions= -DflinkVersions= \ + ${{ matrix.build-task }} \ + -Pquick=true -x test -x javadoc + - name: Prepare scan directory + run: | + mkdir -p /tmp/cve-scan + if [ "${{ matrix.unpack }}" = "true" ]; then + unzip ${{ matrix.scan-path }}/*.zip -d /tmp/cve-scan + else + cp ${{ matrix.scan-path }}/iceberg-${{ matrix.distribution }}-*.jar /tmp/cve-scan/ + fi + - name: Run Trivy vulnerability scan + uses: lhotari/sandboxed-trivy-action@f01374b6cc3bf7264ab238293e94f6db7ada6dd0 # v1.0.2 + with: + scan-type: 'rootfs' + scan-ref: '/tmp/cve-scan' + scanners: 'vuln' + severity: 'HIGH,CRITICAL' + limit-severities-for-sarif: true + exit-code: '0' + format: 'sarif' + output: 'trivy-results.sarif' + - name: Print Trivy scan results + if: always() + run: | + if [ -f trivy-results.sarif ]; then + echo "## Trivy CVE Scan Results — ${{ matrix.distribution }}" + jq -r '.runs[].results[] | "- \(.ruleId): \(.message.text)"' trivy-results.sarif 2>/dev/null || echo "No findings or unable to parse SARIF." + else + echo "No SARIF file found — scan may have failed to install." + fi + - name: Upload Trivy results to GitHub Security tab + if: always() + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: 'trivy-results.sarif' + category: ${{ matrix.distribution }} diff --git a/.github/workflows/delta-conversion-ci.yml b/.github/workflows/delta-conversion-ci.yml index cc6b2d89331e..5b5eaddbd3cc 100644 --- a/.github/workflows/delta-conversion-ci.yml +++ b/.github/workflows/delta-conversion-ci.yml @@ -37,9 +37,11 @@ on: - '.github/workflows/java-ci.yml' - '.github/workflows/jmh-benchmarks-ci.yml' - '.github/workflows/kafka-connect-ci.yml' + - '.github/workflows/cve-scan.yml' - '.github/workflows/labeler.yml' - '.github/workflows/license-check.yml' - '.github/workflows/open-api.yml' + - '.github/workflows/pr-title-check.yml' - '.github/workflows/publish-snapshot.yml' - '.github/workflows/recurring-jmh-benchmarks.yml' - '.github/workflows/site-ci.yml' @@ -61,6 +63,10 @@ on: - 'CONTRIBUTING.md' - '**/LICENSE' - '**/NOTICE' + - 'doap.rdf' + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -70,26 +76,23 @@ jobs: delta-conversion-scala-2-12-tests: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - run: ./gradlew -DsparkVersions=3.5 -DscalaVersion=2.12 -DkafkaVersions= -DflinkVersions= :iceberg-delta-lake:check -Pquick=true -x javadoc - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs @@ -99,26 +102,23 @@ jobs: delta-conversion-scala-2-13-tests: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - run: ./gradlew -DsparkVersions=3.5 -DscalaVersion=2.13 -DkafkaVersions= -DflinkVersions= :iceberg-delta-lake:check -Pquick=true -x javadoc - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml index 48da4785dadb..2bcda0bbc090 100644 --- a/.github/workflows/docs-ci.yml +++ b/.github/workflows/docs-ci.yml @@ -25,15 +25,21 @@ on: - format/** - .github/workflows/docs-ci.yml +permissions: + contents: read + jobs: build-docs: runs-on: ${{ matrix.os }} strategy: + max-parallel: 15 matrix: os: [ubuntu-latest, macos-latest] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: 3.x - name: Build Iceberg documentation diff --git a/.github/workflows/flink-ci.yml b/.github/workflows/flink-ci.yml index 8b8c9426e1ca..a33136b68412 100644 --- a/.github/workflows/flink-ci.yml +++ b/.github/workflows/flink-ci.yml @@ -37,9 +37,11 @@ on: - '.github/workflows/java-ci.yml' - '.github/workflows/jmh-benchmarks-ci.yml' - '.github/workflows/kafka-connect-ci.yml' + - '.github/workflows/cve-scan.yml' - '.github/workflows/labeler.yml' - '.github/workflows/license-check.yml' - '.github/workflows/open-api.yml' + - '.github/workflows/pr-title-check.yml' - '.github/workflows/publish-snapshot.yml' - '.github/workflows/recurring-jmh-benchmarks.yml' - '.github/workflows/site-ci.yml' @@ -61,6 +63,10 @@ on: - 'CONTRIBUTING.md' - '**/LICENSE' - '**/NOTICE' + - 'doap.rdf' + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -71,28 +77,26 @@ jobs: # Test all flink versions with scala 2.12 for general validation. flink-scala-2-12-tests: runs-on: ubuntu-24.04 + timeout-minutes: 60 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] flink: ['1.20', '2.0', '2.1'] env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - run: ./gradlew -DsparkVersions= -DkafkaVersions= -DflinkVersions=${{ matrix.flink }} :iceberg-flink:iceberg-flink-${{ matrix.flink }}:check :iceberg-flink:iceberg-flink-runtime-${{ matrix.flink }}:check -Pquick=true -x javadoc -DtestParallelism=auto - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs diff --git a/.github/workflows/hive-ci.yml b/.github/workflows/hive-ci.yml index 282cf9e8454d..2f0b981ea6a0 100644 --- a/.github/workflows/hive-ci.yml +++ b/.github/workflows/hive-ci.yml @@ -37,9 +37,11 @@ on: - '.github/workflows/java-ci.yml' - '.github/workflows/jmh-benchmarks-ci.yml' - '.github/workflows/kafka-connect-ci.yml' + - '.github/workflows/cve-scan.yml' - '.github/workflows/labeler.yml' - '.github/workflows/license-check.yml' - '.github/workflows/open-api.yml' + - '.github/workflows/pr-title-check.yml' - '.github/workflows/publish-snapshot.yml' - '.github/workflows/recurring-jmh-benchmarks.yml' - '.github/workflows/site-ci.yml' @@ -62,6 +64,10 @@ on: - 'CONTRIBUTING.md' - '**/LICENSE' - '**/NOTICE' + - 'doap.rdf' + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -71,26 +77,23 @@ jobs: hive2-tests: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - run: ./gradlew -DsparkVersions= -DflinkVersions= -DkafkaVersions= -Pquick=true :iceberg-mr:check -x javadoc - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs diff --git a/.github/workflows/java-ci.yml b/.github/workflows/java-ci.yml index 5505bb25db83..f807c78f5618 100644 --- a/.github/workflows/java-ci.yml +++ b/.github/workflows/java-ci.yml @@ -37,9 +37,11 @@ on: - '.github/workflows/hive-ci.yml' - '.github/workflows/jmh-benchmarks-ci.yml' - '.github/workflows/kafka-connect-ci.yml' + - '.github/workflows/cve-scan.yml' - '.github/workflows/labeler.yml' - '.github/workflows/license-check.yml' - '.github/workflows/open-api.yml' + - '.github/workflows/pr-title-check.yml' - '.github/workflows/publish-snapshot.yml' - '.github/workflows/recurring-jmh-benchmarks.yml' - '.github/workflows/site-ci.yml' @@ -57,6 +59,10 @@ on: - 'CONTRIBUTING.md' - '**/LICENSE' - '**/NOTICE' + - 'doap.rdf' + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -66,26 +72,23 @@ jobs: core-tests: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - run: ./gradlew check -DsparkVersions= -DflinkVersions= -DkafkaVersions= -Pquick=true -x javadoc - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs @@ -95,25 +98,46 @@ jobs: build-checks: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: ./gradlew -DallModules build -x test -x javadoc -x integrationTest build-javadoc: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: ./gradlew -Pquick=true javadoc + + check-runtime-deps: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: zulu + java-version: 17 + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + - run: ./gradlew checkAllRuntimeDeps -q -DallModules=true diff --git a/.github/workflows/jmh-benchmarks.yml b/.github/workflows/jmh-benchmarks.yml index 0536fd7c88c7..e2c9522a757c 100644 --- a/.github/workflows/jmh-benchmarks.yml +++ b/.github/workflows/jmh-benchmarks.yml @@ -28,13 +28,20 @@ on: description: 'The branch name' required: true spark_version: - description: 'The spark project version to use, such as iceberg-spark-3.5' - default: 'iceberg-spark-3.5' + description: 'The Spark version, such as 4.1' + default: '4.1' + required: true + scala_version: + description: 'The Scala version, such as 2.13' + default: '2.13' required: true benchmarks: description: 'A list of comma-separated double-quoted Benchmark names, such as "IcebergSourceFlatParquetDataReadBenchmark", "IcebergSourceFlatParquetDataFilterBenchmark"' required: true +permissions: + contents: read + jobs: matrix: runs-on: ubuntu-24.04 @@ -42,13 +49,16 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} foundlabel: ${{ steps.set-matrix.outputs.foundlabel }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ github.event.inputs.repo }} ref: ${{ github.event.inputs.ref }} + persist-credentials: false - id: set-matrix + env: + BENCHMARKS: ${{ github.event.inputs.benchmarks }} run: | - matrix=$(echo '[${{ github.event.inputs.benchmarks }}]' | jq '.[] | select(endswith("Benchmark")) | .') + matrix=$(echo "[$BENCHMARKS]" | jq '.[] | select(endswith("Benchmark")) | .') matrix=$(echo $matrix | sed 's/ /,/g' | sed 's/"/\"/g') echo "matrix=[$matrix]" >> $GITHUB_OUTPUT echo "foundlabel=$(echo "[$matrix]" | jq 'if . | length > 0 then true else false end')" >> $GITHUB_OUTPUT @@ -57,12 +67,20 @@ jobs: needs: matrix runs-on: ubuntu-24.04 steps: - - run: | - echo "Repo: ${{ github.event.inputs.repo }}" - echo "Ref: ${{ github.event.inputs.ref }}" - echo "Benchmarks: ${{ needs.matrix.outputs.matrix }}" - echo "Spark Project Version: ${{ needs.matrix.outputs.spark_version }}" - echo "Found Benchmarks? ${{ needs.matrix.outputs.foundlabel }}" + - env: + REPO: ${{ github.event.inputs.repo }} + REF: ${{ github.event.inputs.ref }} + BENCHMARKS: ${{ needs.matrix.outputs.matrix }} + SPARK_VERSION: ${{ github.event.inputs.spark_version }} + SCALA_VERSION: ${{ github.event.inputs.scala_version }} + FOUND_LABEL: ${{ needs.matrix.outputs.foundlabel }} + run: | + echo "Repo: $REPO" + echo "Ref: $REF" + echo "Benchmarks: $BENCHMARKS" + echo "Spark Version: $SPARK_VERSION" + echo "Scala Version: $SCALA_VERSION" + echo "Found Benchmarks? $FOUND_LABEL" run-benchmark: if: ${{ needs.matrix.outputs.foundlabel == 'true' }} @@ -70,34 +88,34 @@ jobs: runs-on: ubuntu-24.04 strategy: fail-fast: false + max-parallel: 15 matrix: benchmark: ${{ fromJson(needs.matrix.outputs.matrix) }} env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ github.event.inputs.repo }} ref: ${{ github.event.inputs.ref }} - - uses: actions/setup-java@v5 + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: 17 - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - name: Run Benchmark - run: ./gradlew :iceberg-spark:${{ github.event.inputs.spark_version }}:jmh -PjmhIncludeRegex=${{ matrix.benchmark }} -PjmhOutputPath=benchmark/${{ matrix.benchmark }}.txt + env: + SPARK_VERSION: ${{ github.event.inputs.spark_version }} + SCALA_VERSION: ${{ github.event.inputs.scala_version }} + BENCHMARK: ${{ matrix.benchmark }} + run: ./gradlew -DsparkVersions=${SPARK_VERSION} -DscalaVersion=${SCALA_VERSION} :iceberg-spark:iceberg-spark-${SPARK_VERSION}_${SCALA_VERSION}:jmh -PjmhIncludeRegex=${BENCHMARK} -PjmhOutputPath=benchmark/${BENCHMARK}.txt - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ always() }} with: - name: benchmark-results + name: benchmark-${{ matrix.benchmark }} path: | **/benchmark/*.txt diff --git a/.github/workflows/kafka-connect-ci.yml b/.github/workflows/kafka-connect-ci.yml index 168d64a6c1d5..8f1951c7564a 100644 --- a/.github/workflows/kafka-connect-ci.yml +++ b/.github/workflows/kafka-connect-ci.yml @@ -40,9 +40,11 @@ on: - '.github/workflows/labeler.yml' - '.github/workflows/license-check.yml' - '.github/workflows/open-api.yml' + - '.github/workflows/pr-title-check.yml' - '.github/workflows/publish-snapshot.yml' - '.github/workflows/recurring-jmh-benchmarks.yml' - '.github/workflows/site-ci.yml' + - '.github/workflows/cve-scan.yml' - '.github/workflows/spark-ci.yml' - '.github/workflows/stale.yml' - '.gitignore' @@ -61,6 +63,10 @@ on: - 'CONTRIBUTING.md' - '**/LICENSE' - '**/NOTICE' + - 'doap.rdf' + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -71,23 +77,20 @@ jobs: kafka-connect-tests: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] + jvm: [17, 21] env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - run: | ./gradlew -DsparkVersions= -DflinkVersions= -DkafkaVersions=3 \ @@ -96,7 +99,7 @@ jobs: :iceberg-kafka-connect:iceberg-kafka-connect:check \ :iceberg-kafka-connect:iceberg-kafka-connect-runtime:check \ -Pquick=true -x javadoc - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 4d8a367df591..3735367053ce 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -18,7 +18,7 @@ # name: "Pull Request Labeler" -on: pull_request_target +on: pull_request_target # zizmor: ignore[dangerous-triggers] permissions: contents: read @@ -26,9 +26,8 @@ permissions: jobs: triage: - runs-on: ubuntu-24.04 + runs-on: ubuntu-slim steps: - - uses: actions/labeler@v6 + - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" sync-labels: true diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 7640d40f10b2..cc285eabe101 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -20,10 +20,15 @@ name: "Run License Check" on: pull_request +permissions: + contents: read + jobs: rat: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - run: | dev/check-license diff --git a/.github/workflows/open-api.yml b/.github/workflows/open-api.yml index dedae02067e2..28cd2ad89dfa 100644 --- a/.github/workflows/open-api.yml +++ b/.github/workflows/open-api.yml @@ -32,18 +32,25 @@ on: - '.github/workflows/open-api.yml' - 'open-api/**' +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: openapi-spec-validator: - runs-on: ubuntu-24.04 + runs-on: ubuntu-slim steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false - name: Install dependencies working-directory: ./open-api run: make install diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 000000000000..05a2213de203 --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,44 @@ +# 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. +name: PR Title Check + +on: + pull_request: + types: [opened, edited, reopened] + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: {} + +jobs: + check-pr-title: + runs-on: ubuntu-slim + steps: + - name: Check PR Title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + PATTERN='^(Revert ")?[A-Za-z][A-Za-z0-9._+/&,-]*( [A-Za-z0-9][A-Za-z0-9._+/&,-]*)*: .+' + if ! echo "$PR_TITLE" | grep -Eq "$PATTERN"; then + echo "::error::PR title must follow 'Module: Description' format. Got: '$PR_TITLE'" + echo "Examples: 'Core: Fix ...', 'Spark: Add ...', 'Flink 2.1: Add ...', 'API, Core: Update ...'" + exit 1 + fi + + echo "PR title is valid: '$PR_TITLE'" diff --git a/.github/workflows/publish-iceberg-rest-fixture-docker.yml b/.github/workflows/publish-iceberg-rest-fixture-docker.yml index 4b6b4696bec7..264e402deaac 100644 --- a/.github/workflows/publish-iceberg-rest-fixture-docker.yml +++ b/.github/workflows/publish-iceberg-rest-fixture-docker.yml @@ -27,6 +27,9 @@ on: - cron: '0 2 * * *' # run at 2 AM UTC workflow_dispatch: +permissions: + contents: read + env: DOCKER_IMAGE_TAG: iceberg-rest-fixture DOCKER_IMAGE_VERSION: latest @@ -36,28 +39,37 @@ jobs: build: if: github.repository_owner == 'apache' runs-on: ubuntu-latest + environment: docker-publish steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: 21 + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - name: Build Iceberg Open API project run: ./gradlew :iceberg-open-api:shadowJar - name: Login to Docker Hub + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} run: | - docker login -u ${{ secrets.DOCKERHUB_USER }} -p ${{ secrets.DOCKERHUB_TOKEN }} + echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USER" --password-stdin - name: Set the tagged version # for tag 'apache-iceberg-1.7.1', publish image 'apache/iceberg-rest-fixture:1.7.1' if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + env: + REF: ${{ github.ref }} run: | - echo "DOCKER_IMAGE_VERSION=`echo ${{ github.ref }} | tr -d -c 0-9.`" >> "$GITHUB_ENV" + echo "DOCKER_IMAGE_VERSION=$(echo "$REF" | tr -d -c 0-9.)" >> "$GITHUB_ENV" - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and Push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ./ file: ./docker/iceberg-rest-fixture/Dockerfile diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 14e87044132d..a8557c44f32b 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -25,20 +25,29 @@ on: # we're publishing a new snapshot every night at 00:00 UTC - cron: '0 0 * * *' +permissions: + contents: read + jobs: publish-snapshot: if: github.repository_owner == 'apache' runs-on: ubuntu-24.04 + environment: maven-publish steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # we need to fetch all tags so that getProjectVersion() in build.gradle correctly determines the next SNAPSHOT version from the newest tag fetch-depth: 0 - - uses: actions/setup-java@v5 + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: 17 - - run: | + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 + - env: + NEXUS_USER: ${{ secrets.NEXUS_USER }} + NEXUS_PW: ${{ secrets.NEXUS_PW }} + run: | ./gradlew printVersion - ./gradlew -DallModules publishApachePublicationToMavenRepository -PmavenUser=${{ secrets.NEXUS_USER }} -PmavenPassword=${{ secrets.NEXUS_PW }} - ./gradlew -DflinkVersions= -DsparkVersions=3.4,3.5,4.0 -DscalaVersion=2.13 -DkafkaVersions=3 publishApachePublicationToMavenRepository -PmavenUser=${{ secrets.NEXUS_USER }} -PmavenPassword=${{ secrets.NEXUS_PW }} + ./gradlew -DallModules publishApachePublicationToMavenRepository -PmavenUser="$NEXUS_USER" -PmavenPassword="$NEXUS_PW" + ./gradlew -DflinkVersions= -DsparkVersions=3.4,3.5,4.0 -DscalaVersion=2.13 -DkafkaVersions=3 publishApachePublicationToMavenRepository -PmavenUser="$NEXUS_USER" -PmavenPassword="$NEXUS_PW" diff --git a/.github/workflows/recurring-jmh-benchmarks.yml b/.github/workflows/recurring-jmh-benchmarks.yml index fa5a2e79e1e6..88bb10566e43 100644 --- a/.github/workflows/recurring-jmh-benchmarks.yml +++ b/.github/workflows/recurring-jmh-benchmarks.yml @@ -19,18 +19,23 @@ name: "Recurring JMH Benchmarks" on: + workflow_dispatch: schedule: # * is a special character in YAML so you have to quote this string # this schedules a workflow to run at specific UTC times using POSIX cron syntax -> https://crontab.guru/ # we're running benchmarks every Sunday at 00:00 UTC - cron: '0 0 * * 0' +permissions: + contents: read + jobs: run-benchmark: if: github.repository_owner == 'apache' runs-on: ubuntu-24.04 strategy: fail-fast: false + max-parallel: 15 matrix: # TODO: "IcebergSortCompactionBenchmark" seems to run indefinitely benchmark: ["SparkParquetReadersFlatDataBenchmark", "SparkParquetReadersNestedDataBenchmark", @@ -41,33 +46,27 @@ jobs: "IcebergSourceNestedParquetDataReadBenchmark", "IcebergSourceNestedParquetDataWriteBenchmark", "IcebergSourceParquetEqDeleteBenchmark", "IcebergSourceParquetMultiDeleteFileBenchmark", "IcebergSourceParquetPosDeleteBenchmark", "IcebergSourceParquetWithUnrelatedDeleteBenchmark"] - spark_version: ['iceberg-spark-3.5'] + spark: ['4.1'] + scala: ['2.13'] env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - repository: ${{ github.event.inputs.repo }} - ref: ${{ github.event.inputs.ref }} - - uses: actions/setup-java@v5 + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: 17 - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - run: echo -e "$(ip addr show eth0 | grep "inet\b" | awk '{print $2}' | cut -d/ -f1)\t$(hostname -f) $(hostname -s)" | sudo tee -a /etc/hosts - name: Run Benchmark - run: ./gradlew :iceberg-spark:${{ matrix.spark_version }}:jmh -PjmhIncludeRegex=${{ matrix.benchmark }} -PjmhOutputPath=benchmark/${{ matrix.benchmark }}.txt -PjmhJsonOutputPath=benchmark/${{ matrix.benchmark }}.json + run: ./gradlew -DsparkVersions=${{ matrix.spark }} -DscalaVersion=${{ matrix.scala }} :iceberg-spark:iceberg-spark-${{ matrix.spark }}_${{ matrix.scala }}:jmh -PjmhIncludeRegex=${{ matrix.benchmark }} -PjmhOutputPath=benchmark/${{ matrix.benchmark }}.txt -PjmhJsonOutputPath=benchmark/${{ matrix.benchmark }}.json - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: ${{ always() }} with: - name: benchmark-results + name: benchmark-${{ matrix.benchmark }} path: | **/benchmark/* diff --git a/.github/workflows/site-ci.yml b/.github/workflows/site-ci.yml index 7efbf94ba884..fbd18caeb6da 100644 --- a/.github/workflows/site-ci.yml +++ b/.github/workflows/site-ci.yml @@ -26,17 +26,28 @@ on: - site/** - format/** workflow_dispatch: + +permissions: + contents: read + jobs: deploy: - runs-on: ubuntu-latest + runs-on: ubuntu-slim + permissions: + contents: write steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: 3.x - name: Deploy Iceberg documentation + env: + GITHUB_TOKEN: ${{ github.token }} run: | git config --global user.name 'GitHub Actions' git config --global user.email 'actions@github.com' + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" make deploy working-directory: ./site diff --git a/.github/workflows/spark-ci.yml b/.github/workflows/spark-ci.yml index 3488dfdfea52..2761491c3e01 100644 --- a/.github/workflows/spark-ci.yml +++ b/.github/workflows/spark-ci.yml @@ -38,9 +38,11 @@ on: - '.github/workflows/java-ci.yml' - '.github/workflows/jmh-benchmarks-ci.yml' - '.github/workflows/kafka-connect-ci.yml' + - '.github/workflows/cve-scan.yml' - '.github/workflows/labeler.yml' - '.github/workflows/license-check.yml' - '.github/workflows/open-api.yml' + - '.github/workflows/pr-title-check.yml' - '.github/workflows/publish-snapshot.yml' - '.github/workflows/recurring-jmh-benchmarks.yml' - '.github/workflows/site-ci.yml' @@ -61,6 +63,10 @@ on: - 'CONTRIBUTING.md' - '**/LICENSE' - '**/NOTICE' + - 'doap.rdf' + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -70,34 +76,31 @@ jobs: spark-tests: runs-on: ubuntu-24.04 strategy: + max-parallel: 15 matrix: - jvm: [11, 17, 21] - spark: ['3.4', '3.5', '4.0'] + jvm: [17, 21] + spark: ['3.4', '3.5', '4.0', '4.1'] scala: ['2.12', '2.13'] exclude: # Spark 3.5 is the first version not failing on Java 21 (https://issues.apache.org/jira/browse/SPARK-42369) # Full Java 21 support is coming in Spark 4 (https://issues.apache.org/jira/browse/SPARK-43831) - - jvm: 11 - spark: '4.0' - jvm: 21 spark: '3.4' - spark: '4.0' scala: '2.12' + - spark: '4.1' + scala: '2.12' env: SPARK_LOCAL_IP: localhost steps: - - uses: actions/checkout@v6 - - uses: actions/setup-java@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: zulu java-version: ${{ matrix.jvm }} - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: ${{ runner.os }}-gradle- + - uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 - uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 with: tool-cache: false @@ -108,7 +111,7 @@ jobs: :iceberg-spark:iceberg-spark-extensions-${{ matrix.spark }}_${{ matrix.scala }}:check \ :iceberg-spark:iceberg-spark-runtime-${{ matrix.spark }}_${{ matrix.scala }}:check \ -Pquick=true -x javadoc - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: failure() with: name: test logs diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 9a7134d5465c..e3fd0f56dd5d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -30,9 +30,9 @@ permissions: jobs: stale: if: github.repository_owner == 'apache' - runs-on: ubuntu-24.04 + runs-on: ubuntu-slim steps: - - uses: actions/stale@v10.1.1 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: # stale issues stale-issue-label: 'stale' diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 000000000000..0df5e1362ac5 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,46 @@ +# +# 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. +# + +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +permissions: {} + +jobs: + zizmor: + name: Run zizmor 🌈 + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run zizmor 🌈 + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + with: + advanced-security: false + min-severity: medium + min-confidence: medium diff --git a/.gitignore b/.gitignore index f931c10e9407..98ccfc356d8b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,6 @@ out build dependencies.lock **/dependencies.lock -gradle/ gradle/wrapper/gradle-wrapper.jar # rat library install location @@ -38,6 +37,8 @@ spark/v3.5/spark/benchmark/* spark/v3.5/spark-extensions/benchmark/* spark/v4.0/spark/benchmark/* spark/v4.0/spark-extensions/benchmark/* +spark/v4.1/spark/benchmark/* +spark/v4.1/spark-extensions/benchmark/* */benchmark/* __pycache__/ @@ -57,6 +58,7 @@ coverage.xml # vscode/eclipse files .classpath +.factorypath .project .settings bin/ @@ -71,5 +73,11 @@ derby.log # jenv .java-version +# uv +.python-version + # sdkman .sdkmanrc + +# git hooks like pre-commit +.githooks/ diff --git a/.palantir/revapi.yml b/.palantir/revapi.yml index 4ba3d8250d35..cd4afe6fdc8d 100644 --- a/.palantir/revapi.yml +++ b/.palantir/revapi.yml @@ -1370,6 +1370,14 @@ acceptedBreaks: new: "class org.apache.iceberg.encryption.EncryptingFileIO" justification: "New method for Manifest List reading" org.apache.iceberg:iceberg-core: + - code: "java.class.defaultSerializationChanged" + old: "class org.apache.iceberg.avro.SupportsIndexProjection" + new: "class org.apache.iceberg.avro.SupportsIndexProjection" + justification: "Serialization across versions is not guaranteed" + - code: "java.class.defaultSerializationChanged" + old: "class org.apache.iceberg.hadoop.SerializableConfiguration" + new: "class org.apache.iceberg.hadoop.SerializableConfiguration" + justification: "Serialization across versions is not guaranteed" - code: "java.class.noLongerInheritsFromClass" old: "class org.apache.iceberg.rest.auth.OAuth2Manager" new: "class org.apache.iceberg.rest.auth.OAuth2Manager" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..6378b2a9c534 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,172 @@ + + +# Apache Iceberg — Agent Instructions + +Project conventions, architecture, and coding patterns synthesized from 58,000+ review comments across 4,300+ merged PRs. + +## Architecture + +### Module Boundaries + +- **API** (`api/`): Public interfaces and types. Changes affect every engine and catalog. API breaks are almost never acceptable. +- **Core** (`core/`): Table spec implementation. Must be engine-agnostic. No Spark/Flink references. Properties should apply to all catalogs. +- **Data** (`data/`): Generic data layer (DeleteFilter, BaseDeleteLoader, readers/writers). Behavior should be general, not engine-specific. +- **Spark** (`spark/`): Spark integration only. Tests here validate integration, not core behavior. +- **Flink** (`flink/`): Same principle as Spark — integration tests only. +- **REST Catalog** (`open-api/`): OpenAPI spec for catalog interop. Precision in spec text is critical. +- **AWS/GCP/Azure**: Cloud-specific catalog implementations. Don't leak cloud-specific assumptions into core. + +The `api/` module has the strongest stability guarantees — breaking changes are almost never allowed. Other modules with public APIs (`iceberg-data`, `iceberg-parquet`, and others marked in `build.gradle`) may have breaking changes in minor releases, but they must be justified and all changes are tracked via `revapi`. New interface methods in any of these modules must include default implementations. + +### High-Sensitivity Areas + +- **`TableMetadata`**: Changes ripple through all engines and catalogs. Use `TableMetadata.Builder`; produce proper metadata updates for REST. +- **`SnapshotProducer` / `MergingSnapshotProducer`**: The commit path. Validations must use established patterns. +- **`ManifestGroup` / `ManifestReader`**: Container reuse causes bugs in parallel code. Callers must `copyWithoutStats` if holding references. +- **Serialization** (parsers): Never use Jackson annotations. Custom `XxxParser.toJson/fromJson` only. JSON keys use kebab-case. Optional fields only written when present. +- **REST spec**: Check for ambiguity, over-constraining, missing client-side guidance. POST for deltas, PUT for full-state replacement. +- **Scan planning**: Metrics must not leak across `TableScan` refinements. Timers must be thread-safe (parallel manifest scanning). + +## Design Patterns + +- **Refinement**: `TableScan` methods return new independent scans. State must not leak between refinements. +- **`CloseableIterable`** over `Stream`: Iceberg's standard lazy collection. Always close iterables. +- **Null over `Optional`**: Use `null` for missing values. `Optional` is not used. +- **Builder pattern**: For complex creation. Never require passing `null` for optional parameters. +- **Package-private by default**: Only make things public with demonstrated need. +- **Postel's Law**: Accept case-insensitive input, produce canonical output. +- **`Tasks.foreach`**: For bulk operations with parallelism, retry, and error handling. +- **Immutable metadata**: `TableMetadata`, `Schema`, `PartitionSpec`, `SortOrder` produce new instances via builders. +- **Metadata updates for REST**: All mutations must produce serializable `MetadataUpdate` objects. +- **`SerializableTable`**: Wrap table references for Spark/Flink serialization. Don't serialize the catalog. +- **Validate at boundaries**: `Preconditions` at public entry points; internal methods assume invariants hold. +- **Spec version gating**: Version 2+ features must check `formatVersion >= 2` with clear errors for v1 tables. + +## Coding Conventions + +### API Design + +- New public methods require strong justification. Prefer package-private. +- Never break APIs. Add default implementations to new interface methods. +- Don't introduce deprecated methods in brand-new interfaces. +- Use `@SuppressWarnings({"unchecked", "rawtypes"})` internally rather than widening public signatures. +- Prefer builders over multi-argument create methods. +- Keep the `Table` API small. Utility methods go in helper classes. +- Operations should be idempotent. Return final state from `apply()`, not intermediate changes. +- Minimize third-party types (JTS, Guava) in public APIs. `StructLike` equality requires `StructLikeWrapper`. + +### Naming + +- Method names describe specific behavior: `selectInIdOrder` not `selectOrdered`. +- Avoid `get` prefix — use `find`, `fetch`, `load`, `parse`, `create`, or drop it. +- Variable names indicate meaning, not type. Property names use kebab-case. +- Capitalize `ID` consistently. `toString()` must produce parseable output. +- Avoid `Factory` suffix unless the class is a true factory. + +### Code Style + +- 2 spaces indent, 4 spaces continuation. Empty newline after control flow blocks. +- Use `this.` for instance field assignment. `Preconditions` calls first in methods. +- No `final` on locals. No one-argument-per-line unless necessary. +- Magic numbers should be named constants. No personal pronouns in comments. +- `} else {` on same line. Minimize variable scope. `try-with-resources` for all `AutoCloseable`. +- Prefer method references over lambdas. Wrap lines at the highest semantic level. +- Always use imports — never use fully-qualified class names inline. + +### Code Placement + +- Extract reusable logic to the right utility layer (`TypeUtil`, `SchemaUtil`, `DateTimeUtil`, etc.). +- Engine-specific concepts must not leak into core. Follow existing patterns before introducing new ones. +- Parsers: `XxxParser.toJson/fromJson`. Config: `XxxProperties`. Check existing utilities first. +- Avoid expanding Guava — use JDK equivalents. Use `CloseableGroup` for multi-resource lifecycle. +- Keep things internal until proven needed. + +### Serialization + +- Never use Jackson annotations. Custom `XxxParser.toJson`/`fromJson` only. +- JSON keys: kebab-case. Optional fields: only write when non-null. Required fields: validate in constructors. +- `Preconditions.checkArgument` for validation. `MoreObjects.toStringHelper` for `toString()`. +- Wrap `IOException` in `UncheckedIOException`. Use `Locale.ROOT` for case conversions. +- Use `Iterable`/`CloseableIterable` over `Stream`. Use `null` not `Optional`. Chain exception causes. +- Use Iceberg's `Pair` instead of `Map.Entry`. + +### Error Handling + +- Messages: direct, actionable, with specific values. Capitalize first word. +- Don't swallow exceptions. Use `closeQuietly` for cleanup that shouldn't mask real failures. +- Use `ConcurrentMap` for shared mutable state. `Preconditions.checkArgument` over NPE. +- Close iterables in `finally`. Separate `Preconditions` calls for each condition. +- Forward compatibility: don't fail on unknown reserved bytes. + +### Performance + +- Watch for hidden materialization in streaming pipelines (copy/collect steps). +- Builders over rebuild patterns in hot paths. Lazy over eager evaluation. +- `ByteBuffer` over `byte[]`. Direct-access arrays for dense integer keys. +- Avoid streams/closures in tight loops. Cache per-class, not per-call. + +### Configuration + +- New features default to off. Question whether a property is even needed. +- Properties must work across all catalogs. Zero/negative disables caches/features. +- Versions go to version catalog. Engine-specific properties don't belong in `TableProperties`. + +### Testing + +- Minimal test setup: `PartitionSpec.unpartitioned()` when partitioning isn't needed. +- Test classes and methods should be package private unless required by inheritance. +- Compute expected values, don't hardcode. Tests belong in the module that owns the code. +- Write the most direct test for the bug. Parameterized tests for type variations. +- JUnit 5 + AssertJ: `@Test` (no `test` prefix), `assertThat`, `assertThatThrownBy`. +- `waitUntilAfter` for time-dependent tests. Separate tests over combined. + +### REST / OpenAPI Spec + +- Spec describes behavior, not implementation. RFC 2119: "MUST" = absolute, "SHOULD" = may reject. +- Include client-side guidance. Consistent encoding terminology. 409 for "already exists", not 400. +- Favor the client: required response fields reduce client complexity. + +## Commands + +- **Build (no tests):** `./gradlew build -x test -x integrationTest` +- **Single test class:** `./gradlew :iceberg-core:test --tests org.apache.iceberg.TestTableMetadata` +- **Single test method:** `./gradlew :iceberg-core:test --tests "org.apache.iceberg.TestTableMetadata.testJsonSerialization"` +- **Spark-versioned module:** `./gradlew :iceberg-spark:iceberg-spark-4.1_2.13:test --tests "org.apache.iceberg.spark.source.TestSparkReaderDeletes"` +- **Format code:** `./gradlew spotlessApply` +- **Check formatting:** `./gradlew spotlessCheck` +- **API compatibility:** `./gradlew revApiCheck` + +## PR & Commit Conventions + +- PR titles follow `Module: Description` format (e.g., `Core: Fix ...`, `Spark: Add ...`, `Docs: Update ...`). +- One concern per PR. Unrelated whitespace, import, or formatting changes go in separate PRs. +- Keep first version of a PR minimal — defer recovery, optimization, and edge cases to follow-ups. +- Commit messages describe the *what* and *why*, not implementation details. +- Apache License header required on all new files (enforced by spotless pre-commit hook). + +## Boundaries + +- **Never** modify `.asf.yaml`, `LICENSE`, `NOTICE`, or `versions.props` without explicit discussion. +- **Never** add Jackson annotations to serialization classes — always use custom `XxxParser` classes. +- **Never** break public API without an approved `revapi.yml` exception. +- **Never** add Hadoop dependencies where `FileIO` abstractions exist. +- **Never** commit secrets, credentials, or cloud-specific tokens. +- **Ask first** before adding new third-party dependencies (license compatibility matters). +- **Ask first** before promoting package-private classes/methods to public. diff --git a/LICENSE b/LICENSE index 80cfd3652e69..573a126294a6 100644 --- a/LICENSE +++ b/LICENSE @@ -209,7 +209,7 @@ This product includes a gradle wrapper. Copyright: 2010-2019 Gradle Authors. Home page: https://github.com/gradle/gradle -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -219,7 +219,7 @@ This product includes code from Apache Avro. Copyright: 2014-2017 The Apache Software Foundation. Home page: https://avro.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -229,10 +229,11 @@ This product includes code from Apache Parquet. * DynConstructors.java * IOUtil.java readFully and tests * ByteBufferInputStream implementations and tests +* implementation of VectorizedByteStreamSplitValuesReader Copyright: 2014-2017 The Apache Software Foundation. Home page: https://parquet.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -242,7 +243,7 @@ This product includes code from Cloudera Kite. Copyright: 2013-2017 Cloudera Inc. Home page: https://kitesdk.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -256,7 +257,7 @@ This product includes code from Presto. Copyright: 2016 Facebook and contributors Home page: https://prestodb.io/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -266,7 +267,7 @@ This product includes code from Apache iBATIS. Copyright: 2004 Clinton Begin Home page: https://ibatis.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -276,7 +277,7 @@ This product includes code from Apache Hive. Copyright: 2011-2018 The Apache Software Foundation Home page: https://hive.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -289,10 +290,12 @@ This product includes code from Apache Spark. * implementation of SetAccumulator. * Connector expressions. * implementation of VectorizedDeltaEncodedValuesReader +* implementation of VectorizedDeltaLengthByteArrayValuesReader +* implementation of VectorizedDeltaByteArrayValuesReader Copyright: 2011-2018 The Apache Software Foundation Home page: https://spark.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -303,7 +306,7 @@ This product includes code from Delta Lake. Copyright: 2020 The Delta Lake Project Authors. Home page: https://delta.io/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -313,7 +316,7 @@ This product includes code from Apache Commons. Copyright: 2020 The Apache Software Foundation Home page: https://commons.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -323,7 +326,7 @@ This product includes code from Apache HttpComponents Client. Copyright: 1999-2022 The Apache Software Foundation. Home page: https://hc.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -333,7 +336,11 @@ This product includes code from Apache Flink. * Parameter provider annotation for parameterized tests in Parameters.java * Parameter field annotation for parameterized tests in Parameter.java * Primary key validation logic in FlinkSchemaUtil.java +* Avro to RowData conversion logic in AvroToRowDataConverters.java +* RowData to Avro conversion logic in RowDataToAvroConverters.java +* Avro schema conversion logic in AvroSchemaConverter.java +* Joda optional dependency encapsulation in JodaConverter.java Copyright: 1999-2022 The Apache Software Foundation. Home page: https://flink.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 \ No newline at end of file +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 \ No newline at end of file diff --git a/NOTICE b/NOTICE index e8cb91d8a205..d4e26dab2364 100644 --- a/NOTICE +++ b/NOTICE @@ -1,6 +1,6 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). diff --git a/README.md b/README.md index 582e108b76ea..238a75e0b430 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Community discussions happen primarily on the [dev mailing list][dev-list] or on ### Building -Iceberg is built using Gradle with Java 11, 17, or 21. +Iceberg is built using Gradle with Java 17 or 21. * To invoke a build and run tests: `./gradlew build` * To skip tests: `./gradlew build -x test -x integrationTest` @@ -94,6 +94,10 @@ sudo setenforce Enforcing --- +#### Documentation + +For information about building the documentation, see [here](site/README.md). + ### Engine Compatibility See the [Multi-Engine Support](https://iceberg.apache.org/multi-engine-support/) page to know about Iceberg compatibility with different Spark, Flink and Hive versions. diff --git a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java index e91d07721cd8..8bb88db83e94 100644 --- a/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java +++ b/aliyun/src/main/java/org/apache/iceberg/aliyun/AliyunClientFactories.java @@ -18,13 +18,20 @@ */ package org.apache.iceberg.aliyun; +import com.aliyun.credentials.models.CredentialModel; +import com.aliyun.credentials.provider.OIDCRoleArnCredentialProvider; import com.aliyun.oss.OSS; import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.common.auth.BasicCredentials; +import com.aliyun.oss.common.auth.Credentials; +import com.aliyun.oss.common.auth.CredentialsProvider; import java.util.Map; import org.apache.iceberg.common.DynConstructors; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.util.PropertyUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class AliyunClientFactories { @@ -81,17 +88,83 @@ private static AliyunClientFactory loadClientFactory( } static class DefaultAliyunClientFactory implements AliyunClientFactory { + private static final Logger LOG = LoggerFactory.getLogger(DefaultAliyunClientFactory.class); private AliyunProperties aliyunProperties; DefaultAliyunClientFactory() {} + /** + * Check if RRSA environment variables are present. RRSA requires + * ALIBABA_CLOUD_OIDC_PROVIDER_ARN, ALIBABA_CLOUD_ROLE_ARN and ALIBABA_CLOUD_OIDC_TOKEN_FILE to + * be set. RRSA stands for RAM Roles for Service Accounts. It works by letting pods assume + * specific RAM (Resource Access Management) roles when they need to call cloud APIs — no + * hard-coded credentials are needed, which reduces risk of credential leaks. Here is the + * document for RRSA: + * https://www.alibabacloud.com/help/en/ack/ack-managed-and-ack-dedicated/user-guide/use-rrsa-to-authorize-pods-to-access-different-cloud-services + */ + boolean isRrsaEnvironmentAvailable() { + String oidcProviderArn = System.getenv("ALIBABA_CLOUD_OIDC_PROVIDER_ARN"); + String roleArn = System.getenv("ALIBABA_CLOUD_ROLE_ARN"); + String oidcTokenFile = System.getenv("ALIBABA_CLOUD_OIDC_TOKEN_FILE"); + return !Strings.isNullOrEmpty(oidcProviderArn) + && !Strings.isNullOrEmpty(roleArn) + && !Strings.isNullOrEmpty(oidcTokenFile); + } + @Override public OSS newOSSClient() { Preconditions.checkNotNull( aliyunProperties, "Cannot create aliyun oss client before initializing the AliyunClientFactory."); - if (Strings.isNullOrEmpty(aliyunProperties.securityToken())) { + String endpoint = aliyunProperties.ossEndpoint(); + + // Check if RRSA environment is available + if (isRrsaEnvironmentAvailable()) { + try { + LOG.info( + "Detected RRSA environment variables, creating OSS client with RRSA credentials for endpoint: {}", + endpoint); + + // Use OIDCRoleArnCredentialProvider directly with built-in caching and auto-refresh + final OIDCRoleArnCredentialProvider oidcProvider = + OIDCRoleArnCredentialProvider.builder().build(); + + CredentialsProvider ossCredProvider = + new CredentialsProvider() { + private volatile Credentials currentCredentials; + + @Override + public void setCredentials(Credentials credentials) {} + + @Override + public Credentials getCredentials() { + try { + LOG.debug("Getting credentials using RRSA"); + // getCredentials() returns cached credentials and auto-refreshes when needed + CredentialModel cred = oidcProvider.getCredentials(); + long expirationSeconds = 0; + if (cred.getExpiration() > 0) { + expirationSeconds = + (cred.getExpiration() - System.currentTimeMillis()) / 1000; + } + this.currentCredentials = + new BasicCredentials( + cred.getAccessKeyId(), + cred.getAccessKeySecret(), + cred.getSecurityToken(), + expirationSeconds); + return this.currentCredentials; + } catch (Exception e) { + throw new RuntimeException("Failed to get RRSA credentials", e); + } + } + }; + return new OSSClientBuilder().build(endpoint, ossCredProvider); + } catch (Exception e) { + throw new RuntimeException("Failed to create RRSA OSS client", e); + } + } else if (Strings.isNullOrEmpty(aliyunProperties.securityToken())) { return new OSSClientBuilder() .build( aliyunProperties.ossEndpoint(), diff --git a/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSInputStream.java b/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSInputStream.java index 64e625997760..b530f24783d2 100644 --- a/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSInputStream.java +++ b/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSInputStream.java @@ -157,7 +157,7 @@ private void closeStream() throws IOException { } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSOutputStream.java b/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSOutputStream.java index 9fd4962e872b..68fad57c9697 100644 --- a/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSOutputStream.java +++ b/aliyun/src/main/java/org/apache/iceberg/aliyun/oss/OSSOutputStream.java @@ -164,7 +164,7 @@ private void cleanUpStagingFiles() { } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java b/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java index d4bd5fd24f51..3fd4cfe87a29 100644 --- a/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java +++ b/aliyun/src/test/java/org/apache/iceberg/aliyun/TestAliyunClientFactories.java @@ -25,6 +25,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.SetEnvironmentVariable; public class TestAliyunClientFactories { @@ -76,6 +77,64 @@ public void testLoadCustom() { .isInstanceOf(CustomFactory.class); } + /** + * Test RRSA environment detection. + * + *

This test requires the following environment variables to be set: + * + *

    + *
  • ALIBABA_CLOUD_OIDC_PROVIDER_ARN + *
  • ALIBABA_CLOUD_ROLE_ARN + *
  • ALIBABA_CLOUD_OIDC_TOKEN_FILE + *
+ */ + @Test + @SetEnvironmentVariable( + key = "ALIBABA_CLOUD_OIDC_PROVIDER_ARN", + value = "acs:ram::123456789:oidc-provider/ack-rrsa-test") + @SetEnvironmentVariable( + key = "ALIBABA_CLOUD_ROLE_ARN", + value = "acs:ram::123456789:role/test-rrsa-role") + @SetEnvironmentVariable(key = "ALIBABA_CLOUD_OIDC_TOKEN_FILE", value = "/tmp/oidc-token") + public void testRRSAEnvironmentDetection() { + Map properties = Maps.newHashMap(); + properties.put(AliyunProperties.OSS_ENDPOINT, "https://oss-cn-hangzhou.aliyuncs.com"); + + AliyunClientFactories.DefaultAliyunClientFactory factory = + new AliyunClientFactories.DefaultAliyunClientFactory(); + factory.initialize(properties); + assertThat(factory.isRrsaEnvironmentAvailable()).isTrue(); + + OSS client = factory.newOSSClient(); + assertThat(client).as("OSS client should be created with RRSA").isNotNull(); + + // Try to actually use the client - this should trigger credential retrieval + // With fake credentials, this should fail + try { + client.doesBucketExist("test-bucket"); + // If we get here with fake creds, something is wrong + throw new AssertionError( + "Expected operation to fail with fake RRSA credentials, but it succeeded"); + } catch (Exception e) { + // Expected - fake RRSA credentials should cause failure + assertThat(e).isNotNull(); + } finally { + client.shutdown(); + } + } + + @Test + public void testIsRrsaEnvironmentAvailableWithoutEnvVars() { + // Verify that isRrsaEnvironmentAvailable returns false when env vars are not set + AliyunClientFactories.DefaultAliyunClientFactory factory = + new AliyunClientFactories.DefaultAliyunClientFactory(); + + // Assuming RRSA env vars are not set in test environment + assertThat(factory.isRrsaEnvironmentAvailable()) + .as("RRSA should not be available without environment variables") + .isFalse(); + } + public static class CustomFactory implements AliyunClientFactory { AliyunProperties aliyunProperties; diff --git a/aliyun/src/test/java/org/apache/iceberg/aliyun/oss/TestOSSURI.java b/aliyun/src/test/java/org/apache/iceberg/aliyun/oss/TestOSSURI.java index e388ee932589..cc44564ae6b7 100644 --- a/aliyun/src/test/java/org/apache/iceberg/aliyun/oss/TestOSSURI.java +++ b/aliyun/src/test/java/org/apache/iceberg/aliyun/oss/TestOSSURI.java @@ -50,10 +50,10 @@ public void testEncodedString() { @Test public void invalidBucket() { - assertThatThrownBy(() -> new OSSURI("https://test_bucket/path/to/file")) + assertThatThrownBy(() -> new OSSURI("https://test#bucket/path/to/file")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining( - OSS_RESOURCE_MANAGER.getFormattedString("BucketNameInvalid", "test_bucket")); + OSS_RESOURCE_MANAGER.getFormattedString("BucketNameInvalid", "test#bucket")); } @Test diff --git a/api/src/main/java/org/apache/iceberg/BatchScan.java b/api/src/main/java/org/apache/iceberg/BatchScan.java index 4823d7f180aa..76577eee0261 100644 --- a/api/src/main/java/org/apache/iceberg/BatchScan.java +++ b/api/src/main/java/org/apache/iceberg/BatchScan.java @@ -18,6 +18,9 @@ */ package org.apache.iceberg; +import java.util.function.Supplier; +import org.apache.iceberg.io.FileIO; + /** API for configuring a batch scan. */ public interface BatchScan extends Scan> { /** @@ -68,4 +71,10 @@ public interface BatchScan extends Scan fileIO() { + return table()::io; + } } diff --git a/api/src/main/java/org/apache/iceberg/BatchScanAdapter.java b/api/src/main/java/org/apache/iceberg/BatchScanAdapter.java index d8c5dda88558..8af1144decc5 100644 --- a/api/src/main/java/org/apache/iceberg/BatchScanAdapter.java +++ b/api/src/main/java/org/apache/iceberg/BatchScanAdapter.java @@ -20,16 +20,18 @@ import java.util.Collection; import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.metrics.MetricsReporter; /** An adapter that allows using {@link TableScan} as {@link BatchScan}. */ -class BatchScanAdapter implements BatchScan { +public class BatchScanAdapter implements BatchScan { private final TableScan scan; - BatchScanAdapter(TableScan scan) { + public BatchScanAdapter(TableScan scan) { this.scan = scan; } @@ -38,6 +40,11 @@ public Table table() { return scan.table(); } + @Override + public Supplier fileIO() { + return scan.fileIO(); + } + @Override public BatchScan useSnapshot(long snapshotId) { return new BatchScanAdapter(scan.useSnapshot(snapshotId)); diff --git a/api/src/main/java/org/apache/iceberg/FileContent.java b/api/src/main/java/org/apache/iceberg/FileContent.java index 2c9a2fa51bd2..9d5ab8ceeec9 100644 --- a/api/src/main/java/org/apache/iceberg/FileContent.java +++ b/api/src/main/java/org/apache/iceberg/FileContent.java @@ -18,19 +18,35 @@ */ package org.apache.iceberg; -/** Content type stored in a file, one of DATA, POSITION_DELETES, or EQUALITY_DELETES. */ +import java.util.Locale; + +/** Content type stored in a file. */ public enum FileContent { DATA(0), POSITION_DELETES(1), - EQUALITY_DELETES(2); + EQUALITY_DELETES(2), + DATA_MANIFEST(3), + DELETE_MANIFEST(4); + + private static final FileContent[] VALUES = FileContent.values(); private final int id; + private final String lowerCaseName; FileContent(int id) { this.id = id; + this.lowerCaseName = name().toLowerCase(Locale.ROOT); } public int id() { return id; } + + public String lowerCaseName() { + return lowerCaseName; + } + + public static FileContent fromId(int id) { + return VALUES[id]; + } } diff --git a/api/src/main/java/org/apache/iceberg/ManageSnapshots.java b/api/src/main/java/org/apache/iceberg/ManageSnapshots.java index 12cd5021fa3e..36e98075656f 100644 --- a/api/src/main/java/org/apache/iceberg/ManageSnapshots.java +++ b/api/src/main/java/org/apache/iceberg/ManageSnapshots.java @@ -207,10 +207,10 @@ default ManageSnapshots createBranch(String name) { ManageSnapshots setMaxSnapshotAgeMs(String branchName, long maxSnapshotAgeMs); /** - * Updates the retention policy for a reference. + * Updates the retention policy for a reference. The reference can be a branch or a tag. * - * @param name branch name - * @param maxRefAgeMs retention age in milliseconds of the tag reference itself + * @param name branch or tag name + * @param maxRefAgeMs retention age in milliseconds of the reference itself * @return this for method chaining * @throws IllegalArgumentException if the reference does not exist */ diff --git a/api/src/main/java/org/apache/iceberg/PartitionSpec.java b/api/src/main/java/org/apache/iceberg/PartitionSpec.java index bbd59aa083de..90d2dc259dd1 100644 --- a/api/src/main/java/org/apache/iceberg/PartitionSpec.java +++ b/api/src/main/java/org/apache/iceberg/PartitionSpec.java @@ -315,7 +315,7 @@ private ListMultimap lazyFieldsBySourceId() { public Set identitySourceIds() { Set sourceIds = Sets.newHashSet(); for (PartitionField field : fields()) { - if ("identity".equals(field.transform().toString())) { + if (field.transform().isIdentity()) { sourceIds.add(field.sourceId()); } } @@ -402,21 +402,22 @@ private void checkAndAddPartitionName(String name, Integer sourceColumnId) { Types.NestedField schemaField = this.caseSensitive ? schema.findField(name) : schema.caseInsensitiveFindField(name); if (checkConflicts) { - if (sourceColumnId != null) { - // for identity transform case we allow conflicts between partition and schema field name - // as - // long as they are sourced from the same schema field - Preconditions.checkArgument( - schemaField == null || schemaField.fieldId() == sourceColumnId, - "Cannot create identity partition sourced from different field in schema: %s", - name); - } else { - // for all other transforms we don't allow conflicts between partition name and schema - // field name + if (sourceColumnId == null) { Preconditions.checkArgument( schemaField == null, "Cannot create partition from name that exists in schema: %s", name); + } else { + boolean sourceFieldExists = schema.findField(sourceColumnId) != null; + // For identity transforms, require the partition name to match the source column when it + // still exists in the schema. When the source was dropped, the spec may be historical; + // skip the identity name check in that case. + if (sourceFieldExists) { + Preconditions.checkArgument( + schemaField == null || schemaField.fieldId() == sourceColumnId, + "Cannot create identity partition sourced from different field in schema: %s", + name); + } } } Preconditions.checkArgument(!name.isEmpty(), "Cannot use empty partition name: %s", name); diff --git a/api/src/main/java/org/apache/iceberg/PartitionStatistics.java b/api/src/main/java/org/apache/iceberg/PartitionStatistics.java new file mode 100644 index 000000000000..b965f32161da --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/PartitionStatistics.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.types.Types; + +/** Interface for partition statistics returned from a {@link PartitionStatisticsScan}. */ +public interface PartitionStatistics extends StructLike { + Types.NestedField EMPTY_PARTITION_FIELD = + Types.NestedField.required(1, "partition", Types.StructType.of()); + Types.NestedField SPEC_ID = Types.NestedField.required(2, "spec_id", Types.IntegerType.get()); + Types.NestedField DATA_RECORD_COUNT = + Types.NestedField.required(3, "data_record_count", Types.LongType.get()); + Types.NestedField DATA_FILE_COUNT = + Types.NestedField.required(4, "data_file_count", Types.IntegerType.get()); + Types.NestedField TOTAL_DATA_FILE_SIZE_IN_BYTES = + Types.NestedField.required(5, "total_data_file_size_in_bytes", Types.LongType.get()); + Types.NestedField POSITION_DELETE_RECORD_COUNT = + Types.NestedField.optional(6, "position_delete_record_count", Types.LongType.get()); + Types.NestedField POSITION_DELETE_FILE_COUNT = + Types.NestedField.optional(7, "position_delete_file_count", Types.IntegerType.get()); + Types.NestedField EQUALITY_DELETE_RECORD_COUNT = + Types.NestedField.optional(8, "equality_delete_record_count", Types.LongType.get()); + Types.NestedField EQUALITY_DELETE_FILE_COUNT = + Types.NestedField.optional(9, "equality_delete_file_count", Types.IntegerType.get()); + Types.NestedField TOTAL_RECORD_COUNT = + Types.NestedField.optional(10, "total_record_count", Types.LongType.get()); + Types.NestedField LAST_UPDATED_AT = + Types.NestedField.optional(11, "last_updated_at", Types.LongType.get()); + Types.NestedField LAST_UPDATED_SNAPSHOT_ID = + Types.NestedField.optional(12, "last_updated_snapshot_id", Types.LongType.get()); + // Using default value for v3 field to support v3 reader reading file written by v2 + Types.NestedField DV_COUNT = + Types.NestedField.required("dv_count") + .withId(13) + .ofType(Types.IntegerType.get()) + .withInitialDefault(Literal.of(0)) + .withWriteDefault(Literal.of(0)) + .build(); + + static Schema schema(Types.StructType unifiedPartitionType, int formatVersion) { + Preconditions.checkState(!unifiedPartitionType.fields().isEmpty(), "Table must be partitioned"); + Preconditions.checkState(formatVersion > 0, "Invalid format version: %d", formatVersion); + + if (formatVersion <= 2) { + return v2Schema(unifiedPartitionType); + } + + return v3Schema(unifiedPartitionType); + } + + private static Schema v2Schema(Types.StructType unifiedPartitionType) { + return new Schema( + Types.NestedField.required( + EMPTY_PARTITION_FIELD.fieldId(), EMPTY_PARTITION_FIELD.name(), unifiedPartitionType), + SPEC_ID, + DATA_RECORD_COUNT, + DATA_FILE_COUNT, + TOTAL_DATA_FILE_SIZE_IN_BYTES, + POSITION_DELETE_RECORD_COUNT, + POSITION_DELETE_FILE_COUNT, + EQUALITY_DELETE_RECORD_COUNT, + EQUALITY_DELETE_FILE_COUNT, + TOTAL_RECORD_COUNT, + LAST_UPDATED_AT, + LAST_UPDATED_SNAPSHOT_ID); + } + + private static Schema v3Schema(Types.StructType unifiedPartitionType) { + return new Schema( + Types.NestedField.required( + EMPTY_PARTITION_FIELD.fieldId(), EMPTY_PARTITION_FIELD.name(), unifiedPartitionType), + SPEC_ID, + DATA_RECORD_COUNT, + DATA_FILE_COUNT, + TOTAL_DATA_FILE_SIZE_IN_BYTES, + Types.NestedField.required( + POSITION_DELETE_RECORD_COUNT.fieldId(), + POSITION_DELETE_RECORD_COUNT.name(), + Types.LongType.get()), + Types.NestedField.required( + POSITION_DELETE_FILE_COUNT.fieldId(), + POSITION_DELETE_FILE_COUNT.name(), + Types.IntegerType.get()), + Types.NestedField.required( + EQUALITY_DELETE_RECORD_COUNT.fieldId(), + EQUALITY_DELETE_RECORD_COUNT.name(), + Types.LongType.get()), + Types.NestedField.required( + EQUALITY_DELETE_FILE_COUNT.fieldId(), + EQUALITY_DELETE_FILE_COUNT.name(), + Types.IntegerType.get()), + TOTAL_RECORD_COUNT, + LAST_UPDATED_AT, + LAST_UPDATED_SNAPSHOT_ID, + DV_COUNT); + } + + /* The positions of each statistics within the full schema of partition statistics. */ + int PARTITION_POSITION = 0; + int SPEC_ID_POSITION = 1; + int DATA_RECORD_COUNT_POSITION = 2; + int DATA_FILE_COUNT_POSITION = 3; + int TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION = 4; + int POSITION_DELETE_RECORD_COUNT_POSITION = 5; + int POSITION_DELETE_FILE_COUNT_POSITION = 6; + int EQUALITY_DELETE_RECORD_COUNT_POSITION = 7; + int EQUALITY_DELETE_FILE_COUNT_POSITION = 8; + int TOTAL_RECORD_COUNT_POSITION = 9; + int LAST_UPDATED_AT_POSITION = 10; + int LAST_UPDATED_SNAPSHOT_ID_POSITION = 11; + int DV_COUNT_POSITION = 12; + + /** Returns the partition of these partition statistics */ + StructLike partition(); + + /** Returns the spec ID of the partition of these partition statistics */ + Integer specId(); + + /** Returns the number of data records in the partition */ + Long dataRecordCount(); + + /** Returns the number of data files in the partition */ + Integer dataFileCount(); + + /** Returns the total size of data files in bytes in the partition */ + Long totalDataFileSizeInBytes(); + + /** + * Returns the number of positional delete records in the partition. Also includes dv record count + * as per spec + */ + Long positionDeleteRecordCount(); + + /** Returns the number of positional delete files in the partition */ + Integer positionDeleteFileCount(); + + /** Returns the number of equality delete records in the partition */ + Long equalityDeleteRecordCount(); + + /** Returns the number of equality delete files in the partition */ + Integer equalityDeleteFileCount(); + + /** Returns the total number of records in the partition */ + Long totalRecords(); + + /** Returns the timestamp in milliseconds when the partition was last updated */ + Long lastUpdatedAt(); + + /** Returns the ID of the snapshot that last updated this partition */ + Long lastUpdatedSnapshotId(); + + /** Returns the number of delete vectors in the partition */ + Integer dvCount(); +} diff --git a/api/src/main/java/org/apache/iceberg/PartitionStatisticsScan.java b/api/src/main/java/org/apache/iceberg/PartitionStatisticsScan.java new file mode 100644 index 000000000000..18d8b2031821 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/PartitionStatisticsScan.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.CloseableIterable; + +/** API for configuring partition statistics scan. */ +public interface PartitionStatisticsScan { + + /** + * Create a new scan from this scan's configuration that will use the given snapshot by ID. + * + * @param snapshotId a snapshot ID + * @return a new scan based on this with the given snapshot ID + * @throws IllegalArgumentException if the snapshot cannot be found + */ + PartitionStatisticsScan useSnapshot(long snapshotId); + + /** + * Create a new scan from the results of this, where partitions are filtered by the {@link + * Expression}. + * + * @param filter a filter expression + * @return a new scan based on this with results filtered by the expression + */ + PartitionStatisticsScan filter(Expression filter); + + /** + * Create a new scan from this with the schema as its projection. + * + * @param schema a projection schema + * @return a new scan based on this with the given projection + */ + PartitionStatisticsScan project(Schema schema); + + /** + * Scans a partition statistics file belonging to a particular snapshot + * + * @return an Iterable of partition statistics + */ + CloseableIterable scan(); +} diff --git a/api/src/main/java/org/apache/iceberg/RowDelta.java b/api/src/main/java/org/apache/iceberg/RowDelta.java index f5f10b70f9ce..d64f32ffd933 100644 --- a/api/src/main/java/org/apache/iceberg/RowDelta.java +++ b/api/src/main/java/org/apache/iceberg/RowDelta.java @@ -54,7 +54,7 @@ public interface RowDelta extends SnapshotUpdate { */ default RowDelta removeRows(DataFile file) { throw new UnsupportedOperationException( - getClass().getName() + " does not implement deleteFile"); + getClass().getName() + " does not implement removeRows"); } /** diff --git a/api/src/main/java/org/apache/iceberg/Scan.java b/api/src/main/java/org/apache/iceberg/Scan.java index 9785ce6603ad..e3c6849501a2 100644 --- a/api/src/main/java/org/apache/iceberg/Scan.java +++ b/api/src/main/java/org/apache/iceberg/Scan.java @@ -20,8 +20,10 @@ import java.util.Collection; import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.metrics.MetricsReporter; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -208,4 +210,9 @@ default ThisT minRowsRequested(long numRows) { throw new UnsupportedOperationException( this.getClass().getName() + " doesn't implement minRowsRequested"); } + + /** Returns the {@link FileIO} instance to use when reading data files for this scan. */ + default Supplier fileIO() { + throw new UnsupportedOperationException("fileIO() is not implemented: added in 1.11.0"); + } } diff --git a/api/src/main/java/org/apache/iceberg/Schema.java b/api/src/main/java/org/apache/iceberg/Schema.java index e497b8e69afc..3e59998be476 100644 --- a/api/src/main/java/org/apache/iceberg/Schema.java +++ b/api/src/main/java/org/apache/iceberg/Schema.java @@ -22,6 +22,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.Deque; import java.util.List; import java.util.Locale; @@ -35,6 +36,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableBiMap; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -103,6 +105,10 @@ public Schema(List columns, Set identifierFieldIds, TypeUt this(DEFAULT_SCHEMA_ID, columns, identifierFieldIds, getId); } + public Schema(List columns, TypeUtil.GetID getId) { + this(DEFAULT_SCHEMA_ID, columns, ImmutableSet.of(), getId); + } + public Schema(int schemaId, List columns) { this(schemaId, columns, ImmutableSet.of()); } @@ -629,4 +635,33 @@ public static void checkCompatibility(Schema schema, int formatVersion) { formatVersion, Joiner.on("\n- ").join(problems.values()))); } } + + /** + * Indexes all fields from schemas. + * + *

This method favors field definitions from higher schema IDs to handle type promotions. + * + * @param schemas the collection of schemas to index + * @return a map of field IDs to fields + */ + public static Map indexFields(Collection schemas) { + if (schemas.size() == 1) { + Schema schema = Iterables.getOnlyElement(schemas); + return schema.lazyIdToField(); + } + + Map fields = Maps.newHashMap(); + + for (Schema schema : sortAndDeduplicate(schemas)) { + fields.putAll(schema.lazyIdToField()); + } + + return fields; + } + + private static Set sortAndDeduplicate(Collection schemas) { + Set sortedSchemas = Sets.newTreeSet(Comparator.comparingInt(Schema::schemaId)); + sortedSchemas.addAll(schemas); + return sortedSchemas; + } } diff --git a/api/src/main/java/org/apache/iceberg/Snapshot.java b/api/src/main/java/org/apache/iceberg/Snapshot.java index 9bbb57ed4824..8a74dca6d053 100644 --- a/api/src/main/java/org/apache/iceberg/Snapshot.java +++ b/api/src/main/java/org/apache/iceberg/Snapshot.java @@ -112,7 +112,9 @@ public interface Snapshot extends Serializable { * * @param io a {@link FileIO} instance used for reading files from storage * @return all data files added to the table in this snapshot. + * @deprecated will be removed in 2.0.0; use SnapshotChanges#builderFor(Table) instead */ + @Deprecated Iterable addedDataFiles(FileIO io); /** @@ -124,7 +126,9 @@ public interface Snapshot extends Serializable { * * @param io a {@link FileIO} instance used for reading files from storage * @return all data files removed from the table in this snapshot. + * @deprecated will be removed in 2.0.0; use SnapshotChanges#builderFor(Table) instead */ + @Deprecated Iterable removedDataFiles(FileIO io); /** @@ -135,7 +139,9 @@ public interface Snapshot extends Serializable { * * @param io a {@link FileIO} instance used for reading files from storage * @return all delete files added to the table in this snapshot + * @deprecated will be removed in 2.0.0; use SnapshotChanges#builderFor(Table) instead */ + @Deprecated default Iterable addedDeleteFiles(FileIO io) { throw new UnsupportedOperationException( this.getClass().getName() + " doesn't implement addedDeleteFiles"); @@ -149,7 +155,9 @@ default Iterable addedDeleteFiles(FileIO io) { * * @param io a {@link FileIO} instance used for reading files from storage * @return all delete files removed from the table in this snapshot + * @deprecated will be removed in 2.0.0; use SnapshotChanges#builderFor(Table) instead */ + @Deprecated default Iterable removedDeleteFiles(FileIO io) { throw new UnsupportedOperationException( this.getClass().getName() + " doesn't implement removedDeleteFiles"); @@ -175,7 +183,7 @@ default Integer schemaId() { /** * The row-id of the first newly added row in this snapshot. All rows added in this snapshot will * have a row-id assigned to them greater than this value. All rows with a row-id less than this - * value were created in a snapshot that was added to the table (but not necessarily commited to + * value were created in a snapshot that was added to the table (but not necessarily committed to * this branch) in the past. * * @return the first row-id to be used in this snapshot or null when row lineage is not supported diff --git a/api/src/main/java/org/apache/iceberg/Table.java b/api/src/main/java/org/apache/iceberg/Table.java index 97ea9ba76526..3c0689e89288 100644 --- a/api/src/main/java/org/apache/iceberg/Table.java +++ b/api/src/main/java/org/apache/iceberg/Table.java @@ -83,6 +83,18 @@ default IncrementalChangelogScan newIncrementalChangelogScan() { throw new UnsupportedOperationException("Incremental changelog scan is not supported"); } + /** + * Create a new {@link PartitionStatisticsScan} for this table. + * + *

Once a partition statistics scan is created, it can be refined to project columns and filter + * data. + * + * @return a partition statistics scan for this table + */ + default PartitionStatisticsScan newPartitionStatisticsScan() { + throw new UnsupportedOperationException("Partition statistics scan is not supported"); + } + /** * Return the {@link Schema schema} for this table. * diff --git a/api/src/main/java/org/apache/iceberg/actions/DeleteOrphanFiles.java b/api/src/main/java/org/apache/iceberg/actions/DeleteOrphanFiles.java index 4e8f80fa833f..ab12a3b7c1e3 100644 --- a/api/src/main/java/org/apache/iceberg/actions/DeleteOrphanFiles.java +++ b/api/src/main/java/org/apache/iceberg/actions/DeleteOrphanFiles.java @@ -142,6 +142,11 @@ default DeleteOrphanFiles equalAuthorities(Map newEqualAuthoriti interface Result { /** Returns locations of orphan files. */ Iterable orphanFileLocations(); + + /** Returns the total number of orphan files. */ + default long orphanFilesCount() { + return 0; + } } /** diff --git a/api/src/main/java/org/apache/iceberg/actions/RewriteTablePath.java b/api/src/main/java/org/apache/iceberg/actions/RewriteTablePath.java index 4f65d46f57ef..8d823aa804d7 100644 --- a/api/src/main/java/org/apache/iceberg/actions/RewriteTablePath.java +++ b/api/src/main/java/org/apache/iceberg/actions/RewriteTablePath.java @@ -18,6 +18,8 @@ */ package org.apache.iceberg.actions; +import java.util.concurrent.ExecutorService; + /** * An action that rewrites the table's metadata files to a staging directory, replacing all source * prefixes in absolute paths with a specified target prefix. There are two modes: @@ -99,6 +101,18 @@ default RewriteTablePath createFileList(boolean createFileList) { return this; } + /** + * Passes an alternative executor service that will be used for version file and manifest list + * rewriting. If this method is not called, these operations will be performed sequentially. + * + * @param executorService an executor service to parallelize metadata rewriting + * @return this for method chaining + */ + default RewriteTablePath executeWith(ExecutorService executorService) { + throw new UnsupportedOperationException( + "This implementation does not support providing an ExecutorService."); + } + /** The action result that contains a summary of the execution. */ interface Result { /** Staging location of rewritten files */ diff --git a/api/src/main/java/org/apache/iceberg/catalog/Catalog.java b/api/src/main/java/org/apache/iceberg/catalog/Catalog.java index 897acd2e3ba6..3438f445ec2a 100644 --- a/api/src/main/java/org/apache/iceberg/catalog/Catalog.java +++ b/api/src/main/java/org/apache/iceberg/catalog/Catalog.java @@ -338,6 +338,8 @@ default void invalidateTable(TableIdentifier identifier) {} /** * Register a table with the catalog if it does not exist. * + *

For overwrite support, see {@link #registerTable(TableIdentifier, String, boolean)}. + * * @param identifier a table identifier * @param metadataFileLocation the location of a metadata file * @return a Table instance @@ -347,6 +349,25 @@ default Table registerTable(TableIdentifier identifier, String metadataFileLocat throw new UnsupportedOperationException("Registering tables is not supported"); } + /** + * Register a table with the catalog. + * + * @param identifier a table identifier + * @param metadataFileLocation the location of a metadata file + * @param overwrite whether to overwrite an existing table registration + * @return a Table instance + * @throws AlreadyExistsException if {@code overwrite} is false and the table already exists in + * the catalog + */ + default Table registerTable( + TableIdentifier identifier, String metadataFileLocation, boolean overwrite) { + if (!overwrite) { + return registerTable(identifier, metadataFileLocation); + } + + throw new UnsupportedOperationException("Registering tables with overwrite is not supported"); + } + /** * Instantiate a builder to either create a table or start a create/replace transaction. * diff --git a/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java b/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java index fe29f8918531..b54570485357 100644 --- a/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java +++ b/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java @@ -162,6 +162,9 @@ public Object wrappedIdentity() { /** * Register a table if it does not exist. * + *

For overwrite support, see {@link #registerTable(SessionContext, TableIdentifier, String, + * boolean)}. + * * @param context session context * @param ident a table identifier * @param metadataFileLocation the location of a metadata file @@ -170,6 +173,29 @@ public Object wrappedIdentity() { */ Table registerTable(SessionContext context, TableIdentifier ident, String metadataFileLocation); + /** + * Register a table. + * + * @param context session context + * @param ident a table identifier + * @param metadataFileLocation the location of a metadata file + * @param overwrite whether to overwrite an existing table registration + * @return a Table instance + * @throws AlreadyExistsException if {@code overwrite} is false and the table already exists in + * the catalog + */ + default Table registerTable( + SessionContext context, + TableIdentifier ident, + String metadataFileLocation, + boolean overwrite) { + if (!overwrite) { + return registerTable(context, ident, metadataFileLocation); + } + + throw new UnsupportedOperationException("Registering tables with overwrite is not supported"); + } + /** * Check whether table exists. * diff --git a/api/src/main/java/org/apache/iceberg/catalog/TableIdentifier.java b/api/src/main/java/org/apache/iceberg/catalog/TableIdentifier.java index cbb5dc8d8fd2..9b9fbdcbb0b7 100644 --- a/api/src/main/java/org/apache/iceberg/catalog/TableIdentifier.java +++ b/api/src/main/java/org/apache/iceberg/catalog/TableIdentifier.java @@ -80,7 +80,9 @@ public String name() { public TableIdentifier toLowerCase() { String[] newLevels = - Arrays.stream(namespace().levels()).map(String::toLowerCase).toArray(String[]::new); + Arrays.stream(namespace().levels()) + .map(s -> s.toLowerCase(Locale.ROOT)) + .toArray(String[]::new); String newName = name().toLowerCase(Locale.ROOT); return TableIdentifier.of(Namespace.of(newLevels), newName); } diff --git a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java b/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java index ca470eec7171..52a715f807fa 100644 --- a/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java +++ b/api/src/main/java/org/apache/iceberg/catalog/ViewCatalog.java @@ -106,6 +106,19 @@ default boolean viewExists(TableIdentifier identifier) { */ default void invalidateView(TableIdentifier identifier) {} + /** + * Register a view with the catalog if it does not exist. + * + * @param identifier a view identifier + * @param metadataFileLocation the location of a metadata file + * @return a View instance + * @throws AlreadyExistsException if a table/view with the same identifier already exists in the + * catalog. + */ + default View registerView(TableIdentifier identifier, String metadataFileLocation) { + throw new UnsupportedOperationException("Registering views is not supported"); + } + /** * Initialize a view catalog given a custom name and a map of catalog properties. * diff --git a/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java index 106e20d3bce1..0e195665f3c2 100644 --- a/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java +++ b/api/src/main/java/org/apache/iceberg/catalog/ViewSessionCatalog.java @@ -106,6 +106,21 @@ default boolean viewExists(SessionCatalog.SessionContext context, TableIdentifie */ default void invalidateView(SessionCatalog.SessionContext context, TableIdentifier identifier) {} + /** + * Register a view if it does not exist. + * + * @param context session context + * @param ident a view identifier + * @param metadataFileLocation the location of a metadata file + * @return a View instance + * @throws AlreadyExistsException if a table/view with the same identifier already exists in the + * catalog. + */ + default View registerView( + SessionCatalog.SessionContext context, TableIdentifier ident, String metadataFileLocation) { + throw new UnsupportedOperationException("Registering views is not supported"); + } + /** * Initialize a view catalog given a custom name and a map of catalog properties. * diff --git a/api/src/main/java/org/apache/iceberg/encryption/EncryptedKey.java b/api/src/main/java/org/apache/iceberg/encryption/EncryptedKey.java index c7e594efd147..c7e987e84b7d 100644 --- a/api/src/main/java/org/apache/iceberg/encryption/EncryptedKey.java +++ b/api/src/main/java/org/apache/iceberg/encryption/EncryptedKey.java @@ -18,10 +18,11 @@ */ package org.apache.iceberg.encryption; +import java.io.Serializable; import java.nio.ByteBuffer; import java.util.Map; -public interface EncryptedKey { +public interface EncryptedKey extends Serializable { String keyId(); ByteBuffer encryptedKeyMetadata(); diff --git a/api/src/main/java/org/apache/iceberg/encryption/EncryptingFileIO.java b/api/src/main/java/org/apache/iceberg/encryption/EncryptingFileIO.java index 2ab335a4d6c6..c19da0f01e5b 100644 --- a/api/src/main/java/org/apache/iceberg/encryption/EncryptingFileIO.java +++ b/api/src/main/java/org/apache/iceberg/encryption/EncryptingFileIO.java @@ -30,8 +30,10 @@ import org.apache.iceberg.ManifestFile; import org.apache.iceberg.ManifestListFile; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.FileInfo; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.io.SupportsPrefixOperations; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; @@ -46,7 +48,11 @@ public static EncryptingFileIO combine(FileIO io, EncryptionManager em) { return combine(encryptingIO.io, em); } - return new EncryptingFileIO(io, em); + if (io instanceof SupportsPrefixOperations) { + return new WithSupportsPrefixOperations((SupportsPrefixOperations) io, em); + } else { + return new EncryptingFileIO(io, em); + } } private final FileIO io; @@ -142,6 +148,11 @@ public void deleteFile(String path) { io.deleteFile(path); } + @Override + public Map properties() { + return io.properties(); + } + @Override public void close() { io.close(); @@ -206,4 +217,25 @@ public EncryptionKeyMetadata copy() { return new SimpleKeyMetadata(metadataBuffer.duplicate()); } } + + static class WithSupportsPrefixOperations extends EncryptingFileIO + implements SupportsPrefixOperations { + + private final SupportsPrefixOperations prefixIo; + + WithSupportsPrefixOperations(SupportsPrefixOperations io, EncryptionManager em) { + super(io, em); + this.prefixIo = io; + } + + @Override + public Iterable listPrefix(String prefix) { + return prefixIo.listPrefix(prefix); + } + + @Override + public void deletePrefix(String prefix) { + prefixIo.deletePrefix(prefix); + } + } } diff --git a/api/src/main/java/org/apache/iceberg/exceptions/NoSuchWarehouseException.java b/api/src/main/java/org/apache/iceberg/exceptions/NoSuchWarehouseException.java new file mode 100644 index 000000000000..94ae50cd1c25 --- /dev/null +++ b/api/src/main/java/org/apache/iceberg/exceptions/NoSuchWarehouseException.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.exceptions; + +import com.google.errorprone.annotations.FormatMethod; + +/** Exception raised when attempting to load a warehouse that does not exist. */ +public class NoSuchWarehouseException extends RuntimeException { + @FormatMethod + public NoSuchWarehouseException(String message, Object... args) { + super(String.format(message, args)); + } + + @FormatMethod + public NoSuchWarehouseException(Throwable cause, String message, Object... args) { + super(String.format(message, args), cause); + } +} diff --git a/api/src/main/java/org/apache/iceberg/expressions/BoundAggregate.java b/api/src/main/java/org/apache/iceberg/expressions/BoundAggregate.java index 03c371df25df..72f611df201d 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/BoundAggregate.java +++ b/api/src/main/java/org/apache/iceberg/expressions/BoundAggregate.java @@ -51,6 +51,11 @@ Aggregator newAggregator() { this.getClass().getName() + " does not implement newAggregator()"); } + boolean containsNan(DataFile file, int fieldId) { + Long nanCount = safeGet(file.nanValueCounts(), fieldId); + return nanCount != null && nanCount > 0; + } + @Override public BoundReference ref() { return term().ref(); diff --git a/api/src/main/java/org/apache/iceberg/expressions/ExpressionUtil.java b/api/src/main/java/org/apache/iceberg/expressions/ExpressionUtil.java index d3dc00d914c7..af24ce40cac8 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/ExpressionUtil.java +++ b/api/src/main/java/org/apache/iceberg/expressions/ExpressionUtil.java @@ -37,7 +37,6 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.transforms.Transforms; -import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.DateTimeUtil; import org.apache.iceberg.variants.PhysicalType; @@ -69,7 +68,6 @@ public class ExpressionUtil { "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2}(.\\d{7,9})?)?([-+]\\d{2}:\\d{2}|Z)"); static final int LONG_IN_PREDICATE_ABBREVIATION_THRESHOLD = 10; - private static final int LONG_IN_PREDICATE_ABBREVIATION_MIN_GAIN = 5; private ExpressionUtil() {} @@ -148,21 +146,28 @@ public static String toSanitizedString( } /** - * Extracts an expression that references only the given column IDs from the given expression. + * Returns an expression that retains only predicates which reference one of the given field IDs. * - *

The result is inclusive. If a row would match the original filter, it must match the result - * filter. - * - * @param expression a filter Expression - * @param schema a Schema + * @param expression a filter expression + * @param schema schema for binding references * @param caseSensitive whether binding is case sensitive - * @param ids field IDs used to match predicates to extract from the expression - * @return an Expression that selects at least the same rows as the original using only the IDs + * @param ids field IDs to retain predicates for + * @return expression containing only predicates that reference the given IDs */ public static Expression extractByIdInclusive( Expression expression, Schema schema, boolean caseSensitive, int... ids) { - PartitionSpec spec = identitySpec(schema, ids); - return Projections.inclusive(spec, caseSensitive).project(Expressions.rewriteNot(expression)); + if (ids == null || ids.length == 0) { + return Expressions.alwaysTrue(); + } + + ImmutableSet.Builder retainIds = ImmutableSet.builder(); + for (int id : ids) { + retainIds.add(id); + } + + return ExpressionVisitors.visit( + Expressions.rewriteNot(expression), + new RetainPredicatesByFieldIdVisitor(schema, caseSensitive, retainIds.build())); } /** @@ -212,6 +217,10 @@ public static boolean selectsPartitions(Expression expr, Table table, boolean ca */ public static boolean selectsPartitions( Expression expr, PartitionSpec spec, boolean caseSensitive) { + if (spec.isUnpartitioned()) { + return false; + } + return equivalent( Projections.inclusive(spec, caseSensitive).project(expr), Projections.strict(spec, caseSensitive).project(expr), @@ -261,6 +270,61 @@ public static UnboundTerm unbind(Term term) { throw new UnsupportedOperationException("Cannot unbind unsupported term: " + term); } + private static class RetainPredicatesByFieldIdVisitor + extends ExpressionVisitors.ExpressionVisitor { + private final Schema schema; + private final boolean caseSensitive; + private final Set retainFieldIds; + + RetainPredicatesByFieldIdVisitor( + Schema schema, boolean caseSensitive, Set retainFieldIds) { + this.schema = schema; + this.caseSensitive = caseSensitive; + this.retainFieldIds = retainFieldIds; + } + + @Override + public Expression alwaysTrue() { + return Expressions.alwaysTrue(); + } + + @Override + public Expression alwaysFalse() { + return Expressions.alwaysFalse(); + } + + @Override + public Expression not(Expression result) { + return Expressions.not(result); + } + + @Override + public Expression and(Expression leftResult, Expression rightResult) { + return Expressions.and(leftResult, rightResult); + } + + @Override + public Expression or(Expression leftResult, Expression rightResult) { + return Expressions.or(leftResult, rightResult); + } + + @Override + public Expression predicate(BoundPredicate pred) { + return retainFieldIds.contains(pred.ref().fieldId()) ? pred : Expressions.alwaysTrue(); + } + + @Override + public Expression predicate(UnboundPredicate pred) { + Expression bound = Binder.bind(schema.asStruct(), pred, caseSensitive); + if (bound instanceof BoundPredicate) { + return retainFieldIds.contains(((BoundPredicate) bound).ref().fieldId()) + ? pred + : Expressions.alwaysTrue(); + } + return Expressions.alwaysTrue(); + } + } + private static class ExpressionSanitizer extends ExpressionVisitors.ExpressionVisitor { private final long now; @@ -307,15 +371,13 @@ public Expression predicate(BoundPredicate pred) { } else if (pred.isLiteralPredicate()) { BoundLiteralPredicate bound = (BoundLiteralPredicate) pred; return new UnboundPredicate<>( - pred.op(), - unbind(pred.term()), - (T) sanitize(bound.term().type(), bound.literal(), now, today)); + pred.op(), unbind(pred.term()), (T) sanitize(bound.literal(), now, today)); } else if (pred.isSetPredicate()) { BoundSetPredicate bound = (BoundSetPredicate) pred; Iterable iter = () -> bound.literalSet().stream() - .map(lit -> (T) sanitize(bound.term().type(), lit, now, today)) + .map(lit -> (T) sanitize((Literal) lit, now, today)) .iterator(); return new UnboundPredicate<>(pred.op(), unbind(pred.term()), iter); } @@ -392,7 +454,7 @@ public String or(String leftResult, String rightResult) { } private String value(BoundLiteralPredicate pred) { - return sanitize(pred.term().type(), pred.literal().value(), nowMicros, today); + return sanitize(pred.literal(), nowMicros, today); } @Override @@ -424,7 +486,7 @@ public String predicate(BoundPredicate pred) { + " IN " + abbreviateValues( pred.asSetPredicate().literalSet().stream() - .map(lit -> sanitize(pred.term().type(), lit, nowMicros, today)) + .map(lit -> sanitize((Literal) lit, nowMicros, today)) .collect(Collectors.toList())) .stream() .collect(Collectors.joining(", ", "(", ")")); @@ -433,7 +495,7 @@ public String predicate(BoundPredicate pred) { + " NOT IN " + abbreviateValues( pred.asSetPredicate().literalSet().stream() - .map(lit -> sanitize(pred.term().type(), lit, nowMicros, today)) + .map(lit -> sanitize((Literal) lit, nowMicros, today)) .collect(Collectors.toList())) .stream() .collect(Collectors.joining(", ", "(", ")")); @@ -502,61 +564,24 @@ public String predicate(UnboundPredicate pred) { private static List abbreviateValues(List sanitizedValues) { if (sanitizedValues.size() >= LONG_IN_PREDICATE_ABBREVIATION_THRESHOLD) { - Set distinctValues = ImmutableSet.copyOf(sanitizedValues); - if (distinctValues.size() - <= sanitizedValues.size() - LONG_IN_PREDICATE_ABBREVIATION_MIN_GAIN) { - List abbreviatedList = Lists.newArrayListWithCapacity(distinctValues.size() + 1); - abbreviatedList.addAll(distinctValues); + List distinctValues = ImmutableSet.copyOf(sanitizedValues).asList(); + int abbreviatedSize = + Math.min(distinctValues.size(), LONG_IN_PREDICATE_ABBREVIATION_THRESHOLD); + List abbreviatedList = Lists.newArrayListWithCapacity(abbreviatedSize + 1); + abbreviatedList.addAll(distinctValues.subList(0, abbreviatedSize)); + if (abbreviatedSize < sanitizedValues.size()) { abbreviatedList.add( String.format( Locale.ROOT, "... (%d values hidden, %d in total)", - sanitizedValues.size() - distinctValues.size(), + sanitizedValues.size() - abbreviatedSize, sanitizedValues.size())); - return abbreviatedList; } + return abbreviatedList; } return sanitizedValues; } - private static String sanitize(Type type, Literal lit, long now, int today) { - return sanitize(type, lit.value(), now, today); - } - - private static String sanitize(Type type, Object value, long now, int today) { - switch (type.typeId()) { - case INTEGER: - case LONG: - return sanitizeNumber((Number) value, "int"); - case FLOAT: - case DOUBLE: - return sanitizeNumber((Number) value, "float"); - case DATE: - return sanitizeDate((int) value, today); - case TIME: - return "(time)"; - case TIMESTAMP: - return sanitizeTimestamp((long) value, now); - case TIMESTAMP_NANO: - return sanitizeTimestamp(DateTimeUtil.nanosToMicros((long) value / 1000), now); - case STRING: - return sanitizeString((CharSequence) value, now, today); - case VARIANT: - return sanitizeVariant((Variant) value, now, today); - case UNKNOWN: - return "(unknown)"; - case BOOLEAN: - case UUID: - case DECIMAL: - case FIXED: - case BINARY: - // for boolean, uuid, decimal, fixed, unknown, and binary, match the string result - return sanitizeSimpleString(value.toString()); - } - throw new UnsupportedOperationException( - String.format("Cannot sanitize value for unsupported type %s: %s", type, value)); - } - private static String sanitize(Literal literal, long now, int today) { if (literal instanceof Literals.StringLiteral) { return sanitizeString(((Literals.StringLiteral) literal).value(), now, today); @@ -735,14 +760,4 @@ private static String sanitizeVariantValue( } return builder.toString(); } - - private static PartitionSpec identitySpec(Schema schema, int... ids) { - PartitionSpec.Builder specBuilder = PartitionSpec.builderFor(schema); - - for (int id : ids) { - specBuilder.identity(schema.findColumnName(id)); - } - - return specBuilder.build(); - } } diff --git a/api/src/main/java/org/apache/iceberg/expressions/InclusiveMetricsEvaluator.java b/api/src/main/java/org/apache/iceberg/expressions/InclusiveMetricsEvaluator.java index aa0441f49011..81cbbe785519 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/InclusiveMetricsEvaluator.java +++ b/api/src/main/java/org/apache/iceberg/expressions/InclusiveMetricsEvaluator.java @@ -327,6 +327,14 @@ public Boolean eq(Bound term, Literal lit) { public Boolean notEq(Bound term, Literal lit) { // because the bounds are not necessarily a min or max value, this cannot be answered using // them. notEq(col, X) with (X, Y) doesn't guarantee that X is a value in col. + // However, when min == max and the file has no nulls or NaN values, we can safely prune + // if that value equals the literal. + T value = uniqueValue(term); + + if (value != null && lit.comparator().compare(value, lit.value()) == 0) { + return ROWS_CANNOT_MATCH; + } + return ROWS_MIGHT_MATCH; } @@ -381,6 +389,14 @@ public Boolean in(Bound term, Set literalSet) { public Boolean notIn(Bound term, Set literalSet) { // because the bounds are not necessarily a min or max value, this cannot be answered using // them. notIn(col, {X, ...}) with (X, Y) doesn't guarantee that X is a value in col. + // However, when min == max and the file has no nulls or NaN values, we can safely prune + // if that value is in the exclusion set. + T value = uniqueValue(term); + + if (value != null && literalSet.contains(value)) { + return ROWS_CANNOT_MATCH; + } + return ROWS_MIGHT_MATCH; } @@ -490,6 +506,34 @@ private boolean containsNaNsOnly(Integer id) { && nanCounts.get(id).equals(valueCounts.get(id)); } + /** + * Returns the column's single value if all rows contain the same value. Defined as a column + * with no nulls, no NaNs, and lower bound equals upper bound. Returns null otherwise. + */ + private T uniqueValue(Bound term) { + int id = term.ref().fieldId(); + if (mayContainNull(id)) { + return null; + } + + T lower = lowerBound(term); + T upper = upperBound(term); + + if (lower == null || upper == null || NaNUtil.isNaN(lower) || NaNUtil.isNaN(upper)) { + return null; + } + + if (nanCounts != null && nanCounts.containsKey(id) && nanCounts.get(id) != 0) { + return null; + } + + if (!lower.equals(upper)) { + return null; + } + + return lower; + } + private T lowerBound(Bound term) { if (term instanceof BoundReference) { return parseLowerBound((BoundReference) term); diff --git a/api/src/main/java/org/apache/iceberg/expressions/Literals.java b/api/src/main/java/org/apache/iceberg/expressions/Literals.java index 3a45eb804f35..c54dff72f87e 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/Literals.java +++ b/api/src/main/java/org/apache/iceberg/expressions/Literals.java @@ -30,6 +30,7 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.Comparator; +import java.util.Locale; import java.util.Objects; import java.util.UUID; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -47,6 +48,7 @@ class Literals { private Literals() {} private static final OffsetDateTime EPOCH = Instant.ofEpochSecond(0).atOffset(ZoneOffset.UTC); + private static final BaseEncoding BASE16_ENCODING = BaseEncoding.base16(); private static final LocalDate EPOCH_DAY = EPOCH.toLocalDate(); /** @@ -587,6 +589,32 @@ public Literal to(Type type) { BigDecimal decimal = new BigDecimal(value().toString()); return (Literal) new DecimalLiteral(decimal); + case FIXED: + try { + ByteBuffer buffer = + ByteBuffer.wrap( + BASE16_ENCODING.decode(value().toString().toUpperCase(Locale.ROOT))); + Types.FixedType fixed = (Types.FixedType) type; + if (buffer.remaining() == fixed.length()) { + return (Literal) new FixedLiteral(buffer); + } + return null; + } catch (IllegalArgumentException e) { + // Invalid hex string + return null; + } + + case BINARY: + try { + return (Literal) + new BinaryLiteral( + ByteBuffer.wrap( + BASE16_ENCODING.decode(value().toString().toUpperCase(Locale.ROOT)))); + } catch (IllegalArgumentException e) { + // Invalid hex string + return null; + } + default: return null; } @@ -670,7 +698,7 @@ Object writeReplace() throws ObjectStreamException { @Override public String toString() { byte[] bytes = ByteBuffers.toByteArray(value()); - return "X'" + BaseEncoding.base16().encode(bytes) + "'"; + return "X'" + BASE16_ENCODING.encode(bytes) + "'"; } } @@ -716,7 +744,7 @@ protected Type.TypeID typeId() { @Override public String toString() { byte[] bytes = ByteBuffers.toByteArray(value()); - return "X'" + BaseEncoding.base16().encode(bytes) + "'"; + return "X'" + BASE16_ENCODING.encode(bytes) + "'"; } } } diff --git a/api/src/main/java/org/apache/iceberg/expressions/ManifestEvaluator.java b/api/src/main/java/org/apache/iceberg/expressions/ManifestEvaluator.java index fc3d394203ff..21762946ca33 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/ManifestEvaluator.java +++ b/api/src/main/java/org/apache/iceberg/expressions/ManifestEvaluator.java @@ -269,6 +269,14 @@ public Boolean eq(BoundReference ref, Literal lit) { public Boolean notEq(BoundReference ref, Literal lit) { // because the bounds are not necessarily a min or max value, this cannot be answered using // them. notEq(col, X) with (X, Y) doesn't guarantee that X is a value in col. + // However, when lower == upper and the manifest has no nulls or NaN values, we can safely + // prune if that value equals the literal. + T value = uniqueValue(ref); + + if (value != null && lit.comparator().compare(value, lit.value()) == 0) { + return ROWS_CANNOT_MATCH; + } + return ROWS_MIGHT_MATCH; } @@ -313,6 +321,14 @@ public Boolean in(BoundReference ref, Set literalSet) { public Boolean notIn(BoundReference ref, Set literalSet) { // because the bounds are not necessarily a min or max value, this cannot be answered using // them. notIn(col, {X, ...}) with (X, Y) doesn't guarantee that X is a value in col. + // However, when lower == upper and the manifest has no nulls or NaN values, we can safely + // prune if that value is in the exclusion set. + T value = uniqueValue(ref); + + if (value != null && literalSet.contains(value)) { + return ROWS_CANNOT_MATCH; + } + return ROWS_MIGHT_MATCH; } @@ -400,6 +416,43 @@ public Boolean notStartsWith(BoundReference ref, Literal lit) { return ROWS_MIGHT_MATCH; } + /** + * Returns the partition field's single value if all partitions contain the same value. Defined + * as a partition field with no nulls, no NaNs (for floating-point types), and lower bound + * equals upper bound. Returns null otherwise. + */ + private T uniqueValue(BoundReference ref) { + int pos = Accessors.toPosition(ref.accessor()); + PartitionFieldSummary fieldStats = stats.get(pos); + + if (fieldStats.containsNull()) { + return null; + } + + Type.TypeID typeId = ref.type().typeId(); + if (Type.TypeID.FLOAT.equals(typeId) || Type.TypeID.DOUBLE.equals(typeId)) { + if (fieldStats.containsNaN() == null || fieldStats.containsNaN()) { + return null; + } + } + + ByteBuffer lowerBound = fieldStats.lowerBound(); + ByteBuffer upperBound = fieldStats.upperBound(); + + if (lowerBound == null || upperBound == null) { + return null; + } + + T lower = Conversions.fromByteBuffer(ref.type(), lowerBound); + T upper = Conversions.fromByteBuffer(ref.type(), upperBound); + + if (ref.comparator().compare(lower, upper) != 0) { + return null; + } + + return lower; + } + private boolean allValuesAreNull(PartitionFieldSummary summary, Type.TypeID typeId) { // containsNull encodes whether at least one partition value is null, // lowerBound is null if all partition values are null diff --git a/api/src/main/java/org/apache/iceberg/expressions/MaxAggregate.java b/api/src/main/java/org/apache/iceberg/expressions/MaxAggregate.java index d37af7470df2..2948ffa421ae 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/MaxAggregate.java +++ b/api/src/main/java/org/apache/iceberg/expressions/MaxAggregate.java @@ -40,6 +40,10 @@ protected MaxAggregate(BoundTerm term) { @Override protected boolean hasValue(DataFile file) { + // Can't determine max from metadata when NaN values are present since it could be -NaN or +NaN + if (containsNan(file, fieldId)) { + return false; + } boolean hasBound = safeContainsKey(file.upperBounds(), fieldId); Long valueCount = safeGet(file.valueCounts(), fieldId); Long nullCount = safeGet(file.nullValueCounts(), fieldId); diff --git a/api/src/main/java/org/apache/iceberg/expressions/MinAggregate.java b/api/src/main/java/org/apache/iceberg/expressions/MinAggregate.java index 667b66d6500d..cf13f9256228 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/MinAggregate.java +++ b/api/src/main/java/org/apache/iceberg/expressions/MinAggregate.java @@ -40,6 +40,10 @@ protected MinAggregate(BoundTerm term) { @Override protected boolean hasValue(DataFile file) { + // Can't determine min from metadata when NaN values are present since it could be -NaN or +NaN + if (containsNan(file, fieldId)) { + return false; + } boolean hasBound = safeContainsKey(file.lowerBounds(), fieldId); Long valueCount = safeGet(file.valueCounts(), fieldId); Long nullCount = safeGet(file.nullValueCounts(), fieldId); diff --git a/api/src/main/java/org/apache/iceberg/expressions/StrictMetricsEvaluator.java b/api/src/main/java/org/apache/iceberg/expressions/StrictMetricsEvaluator.java index c225f21da8a8..f57ba8bc2793 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/StrictMetricsEvaluator.java +++ b/api/src/main/java/org/apache/iceberg/expressions/StrictMetricsEvaluator.java @@ -22,6 +22,7 @@ import java.nio.ByteBuffer; import java.util.Collection; +import java.util.Comparator; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -29,6 +30,7 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.Schema; import org.apache.iceberg.expressions.ExpressionVisitors.BoundExpressionVisitor; +import org.apache.iceberg.types.Comparators; import org.apache.iceberg.types.Conversions; import org.apache.iceberg.types.Types.StructType; import org.apache.iceberg.util.NaNUtil; @@ -462,13 +464,77 @@ public Boolean notIn(BoundReference ref, Set literalSet) { @Override public Boolean startsWith(BoundReference ref, Literal lit) { + int id = ref.fieldId(); + if (isNestedColumn(id)) { + return ROWS_MIGHT_NOT_MATCH; + } + + if (canContainNulls(id)) { + return ROWS_MIGHT_NOT_MATCH; + } + + if (lowerBounds != null + && lowerBounds.containsKey(id) + && upperBounds != null + && upperBounds.containsKey(id)) { + String prefix = (String) lit.value(); + Comparator comparator = Comparators.charSequences(); + CharSequence lower = Conversions.fromByteBuffer(ref.type(), lowerBounds.get(id)); + CharSequence upper = Conversions.fromByteBuffer(ref.type(), upperBounds.get(id)); + + // if lower is shorter than the prefix then lower doesn't start with the prefix + if (lower.length() < prefix.length()) { + return ROWS_MIGHT_NOT_MATCH; + } + + if (comparator.compare(lower.subSequence(0, prefix.length()), prefix) == 0) { + // if upper is shorter than the prefix then upper can't start with the prefix + if (upper.length() < prefix.length()) { + return ROWS_MIGHT_NOT_MATCH; + } + + if (comparator.compare(upper.subSequence(0, prefix.length()), prefix) == 0) { + // both bounds start with the prefix, so all rows must start with the prefix + return ROWS_MUST_MATCH; + } + } + } + return ROWS_MIGHT_NOT_MATCH; } @Override public Boolean notStartsWith(BoundReference ref, Literal lit) { - // TODO: Handle cases that definitely cannot match, such as notStartsWith("x") when the bounds - // are ["a", "b"]. + int id = ref.fieldId(); + if (isNestedColumn(id)) { + return ROWS_MIGHT_NOT_MATCH; + } + + if (containsNullsOnly(id)) { + return ROWS_MUST_MATCH; + } + + String prefix = (String) lit.value(); + Comparator comparator = Comparators.charSequences(); + + if (lowerBounds != null && lowerBounds.containsKey(id)) { + CharSequence lower = Conversions.fromByteBuffer(ref.type(), lowerBounds.get(id)); + // truncate lower bound so that its length is not greater than the length of prefix + int length = Math.min(prefix.length(), lower.length()); + if (comparator.compare(lower.subSequence(0, length), prefix) > 0) { + return ROWS_MUST_MATCH; + } + } + + if (upperBounds != null && upperBounds.containsKey(id)) { + CharSequence upper = Conversions.fromByteBuffer(ref.type(), upperBounds.get(id)); + // truncate upper bound so that its length is not greater than the length of prefix + int length = Math.min(prefix.length(), upper.length()); + if (comparator.compare(upper.subSequence(0, length), prefix) < 0) { + return ROWS_MUST_MATCH; + } + } + return ROWS_MIGHT_NOT_MATCH; } diff --git a/api/src/main/java/org/apache/iceberg/expressions/VariantExpressionUtil.java b/api/src/main/java/org/apache/iceberg/expressions/VariantExpressionUtil.java index dca11d5e4662..daec5216f0ef 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/VariantExpressionUtil.java +++ b/api/src/main/java/org/apache/iceberg/expressions/VariantExpressionUtil.java @@ -24,13 +24,11 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DateTimeUtil; import org.apache.iceberg.variants.PhysicalType; import org.apache.iceberg.variants.VariantValue; class VariantExpressionUtil { - // TODO: Implement PhysicalType.TIME - // TODO: Implement PhysicalType.TIMESTAMPNTZ_NANO and PhysicalType.TIMESTAMPTZ_NANO - // TODO: Implement PhysicalType.UUID private static final Map NO_CONVERSION_NEEDED = ImmutableMap.builder() .put(Types.IntegerType.get(), PhysicalType.INT32) @@ -40,6 +38,10 @@ class VariantExpressionUtil { .put(Types.DateType.get(), PhysicalType.DATE) .put(Types.TimestampType.withoutZone(), PhysicalType.TIMESTAMPNTZ) .put(Types.TimestampType.withZone(), PhysicalType.TIMESTAMPTZ) + .put(Types.TimestampNanoType.withoutZone(), PhysicalType.TIMESTAMPNTZ_NANOS) + .put(Types.TimestampNanoType.withZone(), PhysicalType.TIMESTAMPTZ_NANOS) + .put(Types.TimeType.get(), PhysicalType.TIME) + .put(Types.UUIDType.get(), PhysicalType.UUID) .put(Types.StringType.get(), PhysicalType.STRING) .put(Types.BinaryType.get(), PhysicalType.BINARY) .put(Types.UnknownType.get(), PhysicalType.NULL) @@ -47,7 +49,7 @@ class VariantExpressionUtil { private VariantExpressionUtil() {} - @SuppressWarnings("unchecked") + @SuppressWarnings({"unchecked", "CyclomaticComplexity"}) static T castTo(VariantValue value, Type type) { if (value == null) { return null; @@ -111,6 +113,40 @@ static T castTo(VariantValue value, Type type) { } break; + case TIMESTAMP: + if (value.type() == PhysicalType.TIMESTAMPTZ_NANOS + || value.type() == PhysicalType.TIMESTAMPNTZ_NANOS) { + return (T) + (Long) DateTimeUtil.nanosToMicros(((Number) value.asPrimitive().get()).longValue()); + } else if (value.type() == PhysicalType.DATE) { + return (T) + (Long) + DateTimeUtil.microsFromTimestamp( + DateTimeUtil.dateFromDays(((Number) value.asPrimitive().get()).intValue()) + .atStartOfDay()); + } + break; + case TIMESTAMP_NANO: + if (value.type() == PhysicalType.TIMESTAMPTZ || value.type() == PhysicalType.TIMESTAMPNTZ) { + return (T) + (Long) DateTimeUtil.microsToNanos(((Number) value.asPrimitive().get()).longValue()); + } else if (value.type() == PhysicalType.DATE) { + return (T) + (Long) + DateTimeUtil.nanosFromTimestamp( + DateTimeUtil.dateFromDays(((Number) value.asPrimitive().get()).intValue()) + .atStartOfDay()); + } + break; + case DATE: + if (value.type() == PhysicalType.TIMESTAMPTZ || value.type() == PhysicalType.TIMESTAMPNTZ) { + return (T) + (Integer) DateTimeUtil.microsToDays(((Number) value.asPrimitive().get()).longValue()); + } else if (value.type() == PhysicalType.TIMESTAMPTZ_NANOS + || value.type() == PhysicalType.TIMESTAMPNTZ_NANOS) { + return (T) + (Integer) DateTimeUtil.nanosToDays(((Number) value.asPrimitive().get()).longValue()); + } } return null; diff --git a/api/src/main/java/org/apache/iceberg/io/FileRange.java b/api/src/main/java/org/apache/iceberg/io/FileRange.java index f6d5d9b41cca..78acf6374e42 100644 --- a/api/src/main/java/org/apache/iceberg/io/FileRange.java +++ b/api/src/main/java/org/apache/iceberg/io/FileRange.java @@ -18,7 +18,6 @@ */ package org.apache.iceberg.io; -import java.io.EOFException; import java.nio.ByteBuffer; import java.util.concurrent.CompletableFuture; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -28,13 +27,10 @@ public class FileRange { private final long offset; private final int length; - public FileRange(CompletableFuture byteBuffer, long offset, int length) - throws EOFException { + public FileRange(CompletableFuture byteBuffer, long offset, int length) { Preconditions.checkNotNull(byteBuffer, "byteBuffer can't be null"); - Preconditions.checkArgument( - length() >= 0, "Invalid length: %s in range (must be >= 0)", length); - Preconditions.checkArgument( - offset() >= 0, "Invalid offset: %s in range (must be >= 0)", offset); + Preconditions.checkArgument(length >= 0, "Invalid length: %s in range (must be >= 0)", length); + Preconditions.checkArgument(offset >= 0, "Invalid offset: %s in range (must be >= 0)", offset); this.byteBuffer = byteBuffer; this.offset = offset; diff --git a/api/src/main/java/org/apache/iceberg/stats/FieldStatistic.java b/api/src/main/java/org/apache/iceberg/stats/FieldStatistic.java deleted file mode 100644 index 7715359ea245..000000000000 --- a/api/src/main/java/org/apache/iceberg/stats/FieldStatistic.java +++ /dev/null @@ -1,110 +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. - */ -package org.apache.iceberg.stats; - -import static org.apache.iceberg.types.Types.NestedField.optional; - -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.Types; - -public enum FieldStatistic { - VALUE_COUNT(0, "value_count"), - NULL_VALUE_COUNT(1, "null_value_count"), - NAN_VALUE_COUNT(2, "nan_value_count"), - AVG_VALUE_SIZE(3, "avg_value_size"), - MAX_VALUE_SIZE(4, "max_value_size"), - LOWER_BOUND(5, "lower_bound"), - UPPER_BOUND(6, "upper_bound"), - EXACT_BOUNDS(7, "exact_bounds"); - - private final int offset; - private final String fieldName; - - FieldStatistic(int offset, String fieldName) { - this.offset = offset; - this.fieldName = fieldName; - } - - public int offset() { - return offset; - } - - public String fieldName() { - return fieldName; - } - - public static FieldStatistic fromOffset(int offset) { - switch (offset) { - case 0: - return VALUE_COUNT; - case 1: - return NULL_VALUE_COUNT; - case 2: - return NAN_VALUE_COUNT; - case 3: - return AVG_VALUE_SIZE; - case 4: - return MAX_VALUE_SIZE; - case 5: - return LOWER_BOUND; - case 6: - return UPPER_BOUND; - case 7: - return EXACT_BOUNDS; - default: - throw new IllegalArgumentException("Invalid statistic offset: " + offset); - } - } - - public static Types.StructType fieldStatsFor(Type type, int fieldId) { - return Types.StructType.of( - optional( - fieldId + VALUE_COUNT.offset(), - VALUE_COUNT.fieldName(), - Types.LongType.get(), - "Total value count, including null and NaN"), - optional( - fieldId + NULL_VALUE_COUNT.offset(), - NULL_VALUE_COUNT.fieldName(), - Types.LongType.get(), - "Total null value count"), - optional( - fieldId + NAN_VALUE_COUNT.offset(), - NAN_VALUE_COUNT.fieldName(), - Types.LongType.get(), - "Total NaN value count"), - optional( - fieldId + AVG_VALUE_SIZE.offset(), - AVG_VALUE_SIZE.fieldName(), - Types.IntegerType.get(), - "Avg value size of variable-length types (String, Binary)"), - optional( - fieldId + MAX_VALUE_SIZE.offset(), - MAX_VALUE_SIZE.fieldName(), - Types.IntegerType.get(), - "Max value size of variable-length types (String, Binary)"), - optional(fieldId + LOWER_BOUND.offset(), LOWER_BOUND.fieldName(), type, "Lower bound"), - optional(fieldId + UPPER_BOUND.offset(), UPPER_BOUND.fieldName(), type, "Upper bound"), - optional( - fieldId + EXACT_BOUNDS.offset(), - EXACT_BOUNDS.fieldName(), - Types.BooleanType.get(), - "Whether the upper/lower bound is exact or not")); - } -} diff --git a/api/src/main/java/org/apache/iceberg/transforms/Transforms.java b/api/src/main/java/org/apache/iceberg/transforms/Transforms.java index aacd4d430069..a3a6a3f6321d 100644 --- a/api/src/main/java/org/apache/iceberg/transforms/Transforms.java +++ b/api/src/main/java/org/apache/iceberg/transforms/Transforms.java @@ -29,7 +29,7 @@ * Factory methods for transforms. * *

Most users should create transforms using a {@link PartitionSpec#builderFor(Schema)} partition - * spec builder}. + * spec builder. * * @see PartitionSpec#builderFor(Schema) The partition spec builder. */ diff --git a/api/src/main/java/org/apache/iceberg/types/TypeUtil.java b/api/src/main/java/org/apache/iceberg/types/TypeUtil.java index b1c556be0667..22e461a39ef9 100644 --- a/api/src/main/java/org/apache/iceberg/types/TypeUtil.java +++ b/api/src/main/java/org/apache/iceberg/types/TypeUtil.java @@ -19,6 +19,7 @@ package org.apache.iceberg.types; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -102,6 +103,19 @@ public static Schema select(Schema schema, Set fieldIds) { return new Schema(ImmutableList.of(), schema.getAliases()); } + /** + * Selects fields from a schema by ID and returns them ordered by field ID. + * + *

Unlike {@link #select(Schema, Set)}, which preserves the field ordering of the input schema, + * this method always returns columns sorted by field ID. + */ + public static Schema selectInIdOrder(Schema schema, Set fieldIds) { + Schema selected = select(schema, fieldIds); + List sorted = Lists.newArrayList(selected.columns()); + sorted.sort(Comparator.comparingInt(Types.NestedField::fieldId)); + return new Schema(sorted); + } + public static Types.StructType select(Types.StructType struct, Set fieldIds) { Preconditions.checkNotNull(struct, "Struct cannot be null"); Preconditions.checkNotNull(fieldIds, "Field ids cannot be null"); @@ -601,6 +615,52 @@ public interface GetID { int get(int oldId); } + /** + * Creates a function that reassigns specified field IDs. + * + *

This is useful for merging schemas where some field IDs in one schema might conflict with + * IDs already in use by another schema. The function will reassign the provided IDs to new unused + * IDs, while preserving other IDs. + * + * @param conflictingIds the set of conflicting field IDs that should be reassigned + * @param allUsedIds the set of field IDs that are already in use and cannot be reused + * @return a function that reassigns conflicting field IDs while preserving others + */ + public static GetID reassignConflictingIds(Set conflictingIds, Set allUsedIds) { + return new ReassignConflictingIds(conflictingIds, allUsedIds); + } + + private static class ReassignConflictingIds implements GetID { + private final Set conflictingIds; + private final Set allUsedIds; + private final AtomicInteger nextId; + + private ReassignConflictingIds(Set conflictingIds, Set allUsedIds) { + this.conflictingIds = conflictingIds; + this.allUsedIds = allUsedIds; + this.nextId = new AtomicInteger(); + } + + @Override + public int get(int oldId) { + if (conflictingIds.contains(oldId)) { + return nextAvailableId(); + } else { + return oldId; + } + } + + private int nextAvailableId() { + int candidateId = nextId.incrementAndGet(); + + while (allUsedIds.contains(candidateId)) { + candidateId = nextId.incrementAndGet(); + } + + return candidateId; + } + } + public static class SchemaVisitor { public void beforeField(Types.NestedField field) {} diff --git a/api/src/main/java/org/apache/iceberg/util/CharSequenceSet.java b/api/src/main/java/org/apache/iceberg/util/CharSequenceSet.java index cfdac0104c47..c13cbfd0cc28 100644 --- a/api/src/main/java/org/apache/iceberg/util/CharSequenceSet.java +++ b/api/src/main/java/org/apache/iceberg/util/CharSequenceSet.java @@ -18,183 +18,53 @@ */ package org.apache.iceberg.util; -import java.io.Serializable; -import java.util.Collection; -import java.util.Iterator; -import java.util.Set; -import java.util.stream.Collectors; -import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; -import org.apache.iceberg.relocated.com.google.common.collect.Iterators; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.relocated.com.google.common.collect.Streams; -public class CharSequenceSet implements Set, Serializable { +public class CharSequenceSet extends WrapperSet { private static final ThreadLocal WRAPPERS = ThreadLocal.withInitial(() -> CharSequenceWrapper.wrap(null)); - public static CharSequenceSet of(Iterable charSequences) { - return new CharSequenceSet(charSequences); - } - - public static CharSequenceSet empty() { - return new CharSequenceSet(ImmutableList.of()); - } - - private final Set wrapperSet; - - private CharSequenceSet(Iterable charSequences) { - this.wrapperSet = - Sets.newHashSet(Iterables.transform(charSequences, CharSequenceWrapper::wrap)); + private CharSequenceSet() { + // needed for serialization } - @Override - public int size() { - return wrapperSet.size(); + private CharSequenceSet(Iterable charSequences) { + super( + Iterables.transform( + charSequences, + obj -> { + Preconditions.checkNotNull(obj, "Invalid object: null"); + return CharSequenceWrapper.wrap(obj); + })); } - @Override - public boolean isEmpty() { - return wrapperSet.isEmpty(); + public static CharSequenceSet of(Iterable charSequences) { + return new CharSequenceSet(charSequences); } - @Override - public boolean contains(Object obj) { - if (obj instanceof CharSequence) { - CharSequenceWrapper wrapper = WRAPPERS.get(); - boolean result = wrapperSet.contains(wrapper.set((CharSequence) obj)); - wrapper.set(null); // don't hold a reference to the value - return result; - } - return false; + public static CharSequenceSet empty() { + return new CharSequenceSet(); } @Override - public Iterator iterator() { - return Iterators.transform(wrapperSet.iterator(), CharSequenceWrapper::get); + protected Wrapper wrapper() { + return WRAPPERS.get(); } @Override - public Object[] toArray() { - return Iterators.toArray(iterator(), CharSequence.class); + protected Wrapper wrap(CharSequence file) { + return CharSequenceWrapper.wrap(file); } @Override - @SuppressWarnings("unchecked") - public T[] toArray(T[] destArray) { - int size = wrapperSet.size(); - if (destArray.length < size) { - return (T[]) toArray(); - } - - Iterator iter = iterator(); - int ind = 0; - while (iter.hasNext()) { - destArray[ind] = (T) iter.next(); - ind += 1; - } - - if (destArray.length > size) { - destArray[size] = null; - } - - return destArray; + protected Class elementClass() { + return CharSequence.class; } @Override public boolean add(CharSequence charSequence) { - return wrapperSet.add(CharSequenceWrapper.wrap(charSequence)); - } - - @Override - public boolean remove(Object obj) { - if (obj instanceof CharSequence) { - CharSequenceWrapper wrapper = WRAPPERS.get(); - boolean result = wrapperSet.remove(wrapper.set((CharSequence) obj)); - wrapper.set(null); // don't hold a reference to the value - return result; - } - return false; - } - - @Override - @SuppressWarnings("CollectionUndefinedEquality") - public boolean containsAll(Collection objects) { - if (objects != null) { - return Iterables.all(objects, this::contains); - } - return false; - } - - @Override - public boolean addAll(Collection charSequences) { - if (charSequences != null) { - return Iterables.addAll( - wrapperSet, Iterables.transform(charSequences, CharSequenceWrapper::wrap)); - } - return false; - } - - @Override - public boolean retainAll(Collection objects) { - if (objects != null) { - Set toRetain = - objects.stream() - .filter(CharSequence.class::isInstance) - .map(CharSequence.class::cast) - .map(CharSequenceWrapper::wrap) - .collect(Collectors.toSet()); - - return Iterables.retainAll(wrapperSet, toRetain); - } - - return false; - } - - @Override - @SuppressWarnings("CollectionUndefinedEquality") - public boolean removeAll(Collection objects) { - if (objects != null) { - return objects.stream().filter(this::remove).count() != 0; - } - - return false; - } - - @Override - public void clear() { - wrapperSet.clear(); - } - - @SuppressWarnings("CollectionUndefinedEquality") - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } else if (!(other instanceof Set)) { - return false; - } - - Set that = (Set) other; - - if (size() != that.size()) { - return false; - } - - try { - return containsAll(that); - } catch (ClassCastException | NullPointerException unused) { - return false; - } - } - - @Override - public int hashCode() { - return wrapperSet.stream().mapToInt(CharSequenceWrapper::hashCode).sum(); - } - - @Override - public String toString() { - return Streams.stream(iterator()).collect(Collectors.joining("CharSequenceSet({", ", ", "})")); + // method is needed to not break API compatibility + return super.add(charSequence); } } diff --git a/api/src/main/java/org/apache/iceberg/util/CharSequenceWrapper.java b/api/src/main/java/org/apache/iceberg/util/CharSequenceWrapper.java index 854264c1ae21..59e8eb712dc6 100644 --- a/api/src/main/java/org/apache/iceberg/util/CharSequenceWrapper.java +++ b/api/src/main/java/org/apache/iceberg/util/CharSequenceWrapper.java @@ -18,12 +18,11 @@ */ package org.apache.iceberg.util; -import java.io.Serializable; import org.apache.iceberg.types.Comparators; import org.apache.iceberg.types.JavaHashes; /** Wrapper class to adapt CharSequence for use in maps and sets. */ -public class CharSequenceWrapper implements CharSequence, Serializable { +public class CharSequenceWrapper implements CharSequence, WrapperSet.Wrapper { public static CharSequenceWrapper wrap(CharSequence seq) { return new CharSequenceWrapper(seq); } @@ -39,6 +38,7 @@ private CharSequenceWrapper(CharSequence wrapped) { this.wrapped = wrapped; } + @Override public CharSequenceWrapper set(CharSequence newWrapped) { this.wrapped = newWrapped; this.hashCode = 0; @@ -46,6 +46,7 @@ public CharSequenceWrapper set(CharSequence newWrapped) { return this; } + @Override public CharSequence get() { return wrapped; } diff --git a/api/src/test/java/org/apache/iceberg/TestFileContent.java b/api/src/test/java/org/apache/iceberg/TestFileContent.java new file mode 100644 index 000000000000..bd5e44ed3cf3 --- /dev/null +++ b/api/src/test/java/org/apache/iceberg/TestFileContent.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.stream.IntStream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +class TestFileContent { + + @ParameterizedTest + @EnumSource(FileContent.class) + void fromId(FileContent content) { + assertThat(FileContent.fromId(content.id())).isEqualTo(content); + } + + static IntStream invalidContentTypeIds() { + return IntStream.of(-1, FileContent.values().length); + } + + @ParameterizedTest + @MethodSource("invalidContentTypeIds") + void fromIdInvalid(int id) { + assertThatThrownBy(() -> FileContent.fromId(id)) + .isInstanceOf(ArrayIndexOutOfBoundsException.class) + .hasMessageContaining(String.valueOf(id)); + } +} diff --git a/api/src/test/java/org/apache/iceberg/TestPartitionSpecValidation.java b/api/src/test/java/org/apache/iceberg/TestPartitionSpecValidation.java index b8e16a9ee45e..a1709d2a2e06 100644 --- a/api/src/test/java/org/apache/iceberg/TestPartitionSpecValidation.java +++ b/api/src/test/java/org/apache/iceberg/TestPartitionSpecValidation.java @@ -242,6 +242,22 @@ public void testSettingPartitionTransformsWithCustomTargetNamesThatAlreadyExist( "Cannot create identity partition sourced from different field in schema: another_ts"); } + @Test + public void testStalePartitionSourceIdWithReusedColumnName() { + int newFieldId = 2; + int droppedFieldId = 1; + Schema schema = + new Schema(NestedField.required(newFieldId, "category", Types.StringType.get())); + PartitionSpec spec = + PartitionSpec.builderFor(schema) + .withSpecId(0) + .add(droppedFieldId, 1000, "category", Transforms.alwaysNull()) + .build(); + assertThat(spec.fields()).hasSize(1); + assertThat(spec.fields().get(0).sourceId()).isEqualTo(droppedFieldId); + assertThat(spec.fields().get(0).name()).isEqualTo("category"); + } + @Test public void testMissingSourceColumn() { assertThatThrownBy(() -> PartitionSpec.builderFor(SCHEMA).year("missing").build()) diff --git a/api/src/test/java/org/apache/iceberg/TestSchema.java b/api/src/test/java/org/apache/iceberg/TestSchema.java index 4b164f963d4a..7abc3505d52e 100644 --- a/api/src/test/java/org/apache/iceberg/TestSchema.java +++ b/api/src/test/java/org/apache/iceberg/TestSchema.java @@ -21,10 +21,12 @@ import static org.apache.iceberg.Schema.DEFAULT_VALUES_MIN_FORMAT_VERSION; import static org.apache.iceberg.Schema.MIN_FORMAT_VERSIONS; import static org.apache.iceberg.TestHelpers.MAX_FORMAT_VERSION; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; +import java.util.Map; import java.util.stream.IntStream; import java.util.stream.Stream; import org.apache.iceberg.expressions.Literal; @@ -217,4 +219,97 @@ public void testSupportedWriteDefault(int formatVersion) { assertThatCode(() -> Schema.checkCompatibility(WRITE_DEFAULT_SCHEMA, formatVersion)) .doesNotThrowAnyException(); } + + @Test + public void testIndexFieldsSingleSchema() { + Schema schema = + new Schema( + 1, + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Map fields = Schema.indexFields(ImmutableList.of(schema)); + + assertThat(fields).hasSize(2); + assertThat(fields.get(1).name()).isEqualTo("id"); + assertThat(fields.get(2).name()).isEqualTo("data"); + } + + @Test + public void testIndexFieldsHigherSchemaIdTakesPrecedence() { + Schema schema1 = + new Schema( + 1, + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema schema2 = + new Schema( + 2, + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "data", Types.IntegerType.get())); + + Map fields = Schema.indexFields(ImmutableList.of(schema2, schema1)); + + assertThat(fields).hasSize(2); + assertThat(fields.get(2).type()).isEqualTo(Types.IntegerType.get()); + assertThat(fields.get(2).isOptional()).isFalse(); + } + + @Test + public void testIndexFieldsDuplicateSchemaIds() { + Schema schema1 = + new Schema( + 1, + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema schema1Duplicate = + new Schema( + 1, + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "different", Types.IntegerType.get())); + + Map fields = + Schema.indexFields(ImmutableList.of(schema1, schema1Duplicate)); + + assertThat(fields).hasSize(2); + assertThat(fields.get(2).name()).isEqualTo("data"); + } + + @Test + public void testIndexFieldsNestedSchema() { + Schema schema1 = + new Schema( + 1, + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional( + 2, + "person", + Types.StructType.of( + Types.NestedField.optional(3, "name", Types.StringType.get()), + Types.NestedField.optional(4, "age", Types.IntegerType.get())))); + + Schema schema2 = + new Schema( + 2, + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional( + 2, + "person", + Types.StructType.of( + Types.NestedField.optional(3, "name", Types.StringType.get()), + Types.NestedField.optional(4, "age", Types.IntegerType.get()), + Types.NestedField.optional(5, "email", Types.StringType.get())))); + + Map fields = Schema.indexFields(ImmutableList.of(schema1, schema2)); + + assertThat(fields).hasSize(5); + assertThat(fields.get(1).name()).isEqualTo("id"); + assertThat(fields.get(2).name()).isEqualTo("person"); + assertThat(fields.get(3).name()).isEqualTo("name"); + assertThat(fields.get(4).name()).isEqualTo("age"); + assertThat(fields.get(5).name()).isEqualTo("email"); + assertThat(((Types.StructType) fields.get(2).type()).fields()).hasSize(3); + } } diff --git a/api/src/test/java/org/apache/iceberg/catalog/TestTableIdentifier.java b/api/src/test/java/org/apache/iceberg/catalog/TestTableIdentifier.java index ca9569436bab..13781ccaa7f4 100644 --- a/api/src/test/java/org/apache/iceberg/catalog/TestTableIdentifier.java +++ b/api/src/test/java/org/apache/iceberg/catalog/TestTableIdentifier.java @@ -22,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.DefaultLocale; public class TestTableIdentifier { @@ -52,6 +53,13 @@ public void testToLowerCase() { .isEqualTo(TableIdentifier.of("Catalog", "dB", "TBL").toLowerCase()); } + @Test + @DefaultLocale(language = "tr") + public void testToLowerCaseIsLocaleIndependent() { + assertThat(TableIdentifier.of("information", "db", "tbl")) + .isEqualTo(TableIdentifier.of("INFORMATION", "DB", "TBL").toLowerCase()); + } + @Test public void testInvalidTableName() { assertThatThrownBy(() -> TableIdentifier.of(Namespace.empty(), "")) diff --git a/api/src/test/java/org/apache/iceberg/encryption/TestEncryptingFileIO.java b/api/src/test/java/org/apache/iceberg/encryption/TestEncryptingFileIO.java new file mode 100644 index 000000000000..983203e2db80 --- /dev/null +++ b/api/src/test/java/org/apache/iceberg/encryption/TestEncryptingFileIO.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.encryption; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.util.Map; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.FileInfo; +import org.apache.iceberg.io.SupportsPrefixOperations; +import org.junit.jupiter.api.Test; + +public class TestEncryptingFileIO { + + @Test + public void delegateEncryptingIOWithAndWithoutMixins() { + EncryptionManager em = mock(EncryptionManager.class); + + FileIO fileIONoMixins = mock(FileIO.class); + assertThat(EncryptingFileIO.combine(fileIONoMixins, em)) + .isInstanceOf(EncryptingFileIO.class) + .extracting(EncryptingFileIO::encryptionManager) + .isEqualTo(em); + + FileIO fileIOWithMixins = + mock(FileIO.class, withSettings().extraInterfaces(SupportsPrefixOperations.class)); + assertThat(EncryptingFileIO.combine(fileIOWithMixins, em)) + .isInstanceOf(EncryptingFileIO.WithSupportsPrefixOperations.class) + .extracting(EncryptingFileIO::encryptionManager) + .isEqualTo(em); + } + + @Test + public void prefixOperationsDelegation() { + EncryptionManager em = mock(EncryptionManager.class); + SupportsPrefixOperations delegate = mock(SupportsPrefixOperations.class); + + EncryptingFileIO.WithSupportsPrefixOperations fileIO = + (EncryptingFileIO.WithSupportsPrefixOperations) EncryptingFileIO.combine(delegate, em); + + String prefix = "prefix"; + Iterable fileInfos = mock(Iterable.class); + when(delegate.listPrefix(prefix)).thenReturn(fileInfos); + assertThat(fileIO.listPrefix(prefix)).isEqualTo(fileInfos); + + fileIO.deletePrefix(prefix); + verify(delegate).deletePrefix(prefix); + } + + @Test + public void properties() { + EncryptionManager em = mock(EncryptionManager.class); + FileIO io = mock(FileIO.class); + when(io.properties()).thenReturn(Map.of("key", "value")); + + assertThat(EncryptingFileIO.combine(io, em).properties()) + .containsExactly(Map.entry("key", "value")); + } +} diff --git a/api/src/test/java/org/apache/iceberg/expressions/TestExpressionUtil.java b/api/src/test/java/org/apache/iceberg/expressions/TestExpressionUtil.java index ca08951b1f53..fdf3d9dcd1b0 100644 --- a/api/src/test/java/org/apache/iceberg/expressions/TestExpressionUtil.java +++ b/api/src/test/java/org/apache/iceberg/expressions/TestExpressionUtil.java @@ -19,6 +19,8 @@ package org.apache.iceberg.expressions; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.nio.ByteBuffer; import java.time.LocalDate; @@ -33,6 +35,7 @@ import java.util.stream.IntStream; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Types; @@ -66,6 +69,24 @@ public class TestExpressionUtil { private static final Types.StructType FLOAT_TEST = Types.StructType.of(Types.NestedField.optional(1, "test", Types.FloatType.get())); + /** Schema with struct, list, and map columns for {@link #testExtractByIdInclusiveNestedTypes}. */ + private static final Schema NESTED_EXTRACT_SCHEMA = + new Schema( + Types.NestedField.required(1, "top_id", Types.LongType.get()), + Types.NestedField.optional( + 2, + "st", + Types.StructType.of( + Types.NestedField.required(3, "inner_i", Types.IntegerType.get()))), + Types.NestedField.optional( + 4, "arr", Types.ListType.ofRequired(5, Types.IntegerType.get())), + Types.NestedField.optional( + 6, + "mp", + Types.MapType.ofRequired(7, 8, Types.StringType.get(), Types.IntegerType.get()))); + + private static final Types.StructType NESTED_EXTRACT_STRUCT = NESTED_EXTRACT_SCHEMA.asStruct(); + @Test public void testUnchangedUnaryPredicates() { for (Expression unary : @@ -115,6 +136,16 @@ public void testSanitizeLongIn() { .as("Sanitized string should be abbreviated") .isEqualTo("test IN ((2-digit-int), (3-digit-int), ... (8 values hidden, 10 in total))"); + Object[] tooLongStringsList = + IntStream.range(0, ExpressionUtil.LONG_IN_PREDICATE_ABBREVIATION_THRESHOLD + 5) + .mapToObj(i -> "string_" + i) + .toArray(); + + assertThat(ExpressionUtil.toSanitizedString(Expressions.in("test", tooLongStringsList))) + .as("Sanitized string should be abbreviated") + .isEqualTo( + "test IN ((hash-14128790), (hash-1056a62b), (hash-22fd6340), (hash-3f9d20e4), (hash-136200f0), (hash-25fc9033), (hash-681d31e2), (hash-6c1796d4), (hash-382d143e), (hash-272f4e5b), ... (5 values hidden, 15 in total))"); + // The sanitization resulting in an expression tree does not abbreviate List expectedValues = Lists.newArrayList(); expectedValues.addAll(Collections.nCopies(5, "(2-digit-int)")); @@ -812,6 +843,146 @@ public void testSanitizeStringFallback() { } } + @Test + public void testExtractByIdInclusive() { + Expression alwaysTrue = Expressions.alwaysTrue(); + Expression idEq = Expressions.equal("id", 5L); + Expression valEq = Expressions.equal("val", 5); + + assertThat( + ExpressionUtil.equivalent( + alwaysTrue, + ExpressionUtil.extractByIdInclusive( + Expressions.and(idEq, valEq), SCHEMA, true, new int[0]), + STRUCT, + true)) + .isTrue(); + + assertThat( + ExpressionUtil.equivalent( + alwaysTrue, + ExpressionUtil.extractByIdInclusive( + Expressions.and(idEq, valEq), SCHEMA, true, (int[]) null), + STRUCT, + true)) + .isTrue(); + + assertThat( + ExpressionUtil.equivalent( + idEq, ExpressionUtil.extractByIdInclusive(idEq, SCHEMA, true, 1), STRUCT, true)) + .isTrue(); + + assertThat( + ExpressionUtil.equivalent( + alwaysTrue, + ExpressionUtil.extractByIdInclusive(valEq, SCHEMA, true, 1), + STRUCT, + true)) + .isTrue(); + + assertThat( + ExpressionUtil.equivalent( + idEq, + ExpressionUtil.extractByIdInclusive(Expressions.and(idEq, valEq), SCHEMA, true, 1), + STRUCT, + true)) + .isTrue(); + + Expression orBothId = Expressions.or(Expressions.equal("id", 1L), Expressions.equal("id", 2L)); + assertThat( + ExpressionUtil.equivalent( + orBothId, + ExpressionUtil.extractByIdInclusive(orBothId, SCHEMA, true, 1), + STRUCT, + true)) + .isTrue(); + } + + @Test + public void testExtractByIdInclusiveNestedTypes() { + Expression alwaysTrue = Expressions.alwaysTrue(); + Expression structPred = Expressions.equal("st.inner_i", 1); + Expression listPred = Expressions.equal("arr.element", 42); + Expression mapKeyPred = Expressions.equal("mp.key", "k"); + Expression mapValuePred = Expressions.equal("mp.value", 7); + Expression topPred = Expressions.equal("top_id", 9L); + + assertThat( + ExpressionUtil.equivalent( + structPred, + ExpressionUtil.extractByIdInclusive(structPred, NESTED_EXTRACT_SCHEMA, true, 3), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + assertThat( + ExpressionUtil.equivalent( + alwaysTrue, + ExpressionUtil.extractByIdInclusive(structPred, NESTED_EXTRACT_SCHEMA, true, 1), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + + assertThat( + ExpressionUtil.equivalent( + listPred, + ExpressionUtil.extractByIdInclusive(listPred, NESTED_EXTRACT_SCHEMA, true, 5), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + assertThat( + ExpressionUtil.equivalent( + alwaysTrue, + ExpressionUtil.extractByIdInclusive(listPred, NESTED_EXTRACT_SCHEMA, true, 1), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + + assertThat( + ExpressionUtil.equivalent( + mapKeyPred, + ExpressionUtil.extractByIdInclusive(mapKeyPred, NESTED_EXTRACT_SCHEMA, true, 7), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + assertThat( + ExpressionUtil.equivalent( + mapValuePred, + ExpressionUtil.extractByIdInclusive(mapValuePred, NESTED_EXTRACT_SCHEMA, true, 8), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + assertThat( + ExpressionUtil.equivalent( + alwaysTrue, + ExpressionUtil.extractByIdInclusive(mapKeyPred, NESTED_EXTRACT_SCHEMA, true, 8), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + + Expression mixed = Expressions.and(structPred, Expressions.and(listPred, topPred)); + assertThat( + ExpressionUtil.equivalent( + structPred, + ExpressionUtil.extractByIdInclusive(mixed, NESTED_EXTRACT_SCHEMA, true, 3), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + assertThat( + ExpressionUtil.equivalent( + listPred, + ExpressionUtil.extractByIdInclusive(mixed, NESTED_EXTRACT_SCHEMA, true, 5), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + assertThat( + ExpressionUtil.equivalent( + topPred, + ExpressionUtil.extractByIdInclusive(mixed, NESTED_EXTRACT_SCHEMA, true, 1), + NESTED_EXTRACT_STRUCT, + true)) + .isTrue(); + } + @Test public void testIdenticalExpressionIsEquivalent() { Expression[] exprs = @@ -1062,6 +1233,22 @@ public void testSelectsPartitions() { .isFalse(); } + @Test + public void testSelectsPartitionsWithUnpartitionedTable() { + Table table = mock(Table.class); + Map specs = + ImmutableMap.of( + 0, + PartitionSpec.unpartitioned(), + 1, + PartitionSpec.builderFor(SCHEMA).identity("val").build()); + when(table.specs()).thenReturn(specs); + + assertThat(ExpressionUtil.selectsPartitions(Expressions.lessThan("id", 1L), table, true)) + .as("Should return false for unpartitioned table (no partition boundaries to select)") + .isFalse(); + } + @Test public void testSanitizeVariantArray() { Expression bound = diff --git a/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveManifestEvaluator.java b/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveManifestEvaluator.java index 068c862e2bda..78e6064eb427 100644 --- a/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveManifestEvaluator.java +++ b/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveManifestEvaluator.java @@ -66,7 +66,10 @@ public class TestInclusiveManifestEvaluator { optional(12, "no_nan_or_null", Types.DoubleType.get()), optional(13, "all_nulls_missing_nan_float", Types.FloatType.get()), optional(14, "all_same_value_or_null", Types.StringType.get()), - optional(15, "no_nulls_same_value_a", Types.StringType.get())); + optional(15, "no_nulls_same_value_a", Types.StringType.get()), + optional(16, "single_value_with_nan", Types.FloatType.get()), + optional(17, "single_value_nan_unknown", Types.FloatType.get()), + optional(18, "single_value_no_nan", Types.FloatType.get())); private static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA) @@ -84,6 +87,9 @@ public class TestInclusiveManifestEvaluator { .identity("all_nulls_missing_nan_float") .identity("all_same_value_or_null") .identity("no_nulls_same_value_a") + .identity("single_value_with_nan") + .identity("single_value_nan_unknown") + .identity("single_value_no_nan") .build(); private static final int INT_MIN_VALUE = 30; @@ -128,7 +134,21 @@ public class TestInclusiveManifestEvaluator { toByteBuffer(Types.FloatType.get(), 20F)), new TestHelpers.TestFieldSummary(true, null, null), new TestHelpers.TestFieldSummary(true, STRING_MIN, STRING_MIN), - new TestHelpers.TestFieldSummary(false, STRING_MIN, STRING_MIN)), + new TestHelpers.TestFieldSummary(false, STRING_MIN, STRING_MIN), + new TestHelpers.TestFieldSummary( + false, + true, + toByteBuffer(Types.FloatType.get(), 5.0F), + toByteBuffer(Types.FloatType.get(), 5.0F)), + new TestHelpers.TestFieldSummary( + false, + toByteBuffer(Types.FloatType.get(), 5.0F), + toByteBuffer(Types.FloatType.get(), 5.0F)), + new TestHelpers.TestFieldSummary( + false, + false, + toByteBuffer(Types.FloatType.get(), 5.0F), + toByteBuffer(Types.FloatType.get(), 5.0F))), null); @Test @@ -353,7 +373,7 @@ public void testIntegerLt() { shouldRead = ManifestEvaluator.forRowFilter(lessThan("id", INT_MAX_VALUE), SPEC, true).eval(FILE); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @Test @@ -395,7 +415,7 @@ public void testIntegerGt() { shouldRead = ManifestEvaluator.forRowFilter(greaterThan("id", INT_MAX_VALUE - 4), SPEC, true).eval(FILE); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @Test @@ -418,7 +438,7 @@ public void testIntegerGtEq() { shouldRead = ManifestEvaluator.forRowFilter(greaterThanOrEqual("id", INT_MAX_VALUE - 4), SPEC, true) .eval(FILE); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @Test @@ -753,4 +773,84 @@ public void testIntegerNotIn() { ManifestEvaluator.forRowFilter(notIn("no_nulls", "abc", "def"), SPEC, true).eval(FILE); assertThat(shouldRead).as("Should read: notIn on no nulls column").isTrue(); } + + @Test + public void testNotEqWithSingleValue() { + boolean shouldRead = + ManifestEvaluator.forRowFilter(notEqual("no_nulls", "a"), SPEC, true).eval(FILE); + assertThat(shouldRead).as("Should read: manifest has range of values").isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notEqual("no_nulls_same_value_a", "a"), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should not read: manifest contains single value equal to literal") + .isFalse(); + shouldRead = + ManifestEvaluator.forRowFilter(notEqual("no_nulls_same_value_a", "b"), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should read: manifest contains single value not equal to literal") + .isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notEqual("all_same_value_or_null", "a"), SPEC, true) + .eval(FILE); + assertThat(shouldRead).as("Should read: manifest has nulls which match != predicate").isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notEqual("single_value_with_nan", 5.0F), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should read: manifest has NaN values which match != predicate") + .isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notEqual("single_value_nan_unknown", 5.0F), SPEC, true) + .eval(FILE); + assertThat(shouldRead).as("Should read: manifest has unknown NaN info").isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notEqual("single_value_no_nan", 5.0F), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should not read: manifest contains single float value with no NaNs") + .isFalse(); + } + + @Test + public void testNotInWithSingleValue() { + boolean shouldRead = + ManifestEvaluator.forRowFilter(notIn("no_nulls", "a", "b"), SPEC, true).eval(FILE); + assertThat(shouldRead).as("Should read: manifest has range of values").isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notIn("no_nulls_same_value_a", "a", "b"), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should not read: manifest contains single value in exclusion list") + .isFalse(); + shouldRead = + ManifestEvaluator.forRowFilter(notIn("no_nulls_same_value_a", "b", "c"), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should read: manifest contains single value not in exclusion list") + .isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notIn("all_same_value_or_null", "a", "b"), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should read: manifest has nulls which match NOT IN predicate") + .isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notIn("single_value_with_nan", 5.0F, 10.0F), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should read: manifest has NaN values which match NOT IN predicate") + .isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notIn("single_value_nan_unknown", 5.0F, 10.0F), SPEC, true) + .eval(FILE); + assertThat(shouldRead).as("Should read: manifest has unknown NaN info").isTrue(); + shouldRead = + ManifestEvaluator.forRowFilter(notIn("single_value_no_nan", 5.0F, 10.0F), SPEC, true) + .eval(FILE); + assertThat(shouldRead) + .as("Should not read: manifest contains single float value with no NaNs") + .isFalse(); + } } diff --git a/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluator.java b/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluator.java index 2f4fbf395739..5f0ca2659fbf 100644 --- a/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluator.java +++ b/api/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluator.java @@ -474,7 +474,7 @@ public void testIntegerLt() { assertThat(shouldRead).as("Should read: one possible id").isTrue(); shouldRead = new InclusiveMetricsEvaluator(SCHEMA, lessThan("id", INT_MAX_VALUE)).eval(FILE); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @Test @@ -513,7 +513,7 @@ public void testIntegerGt() { shouldRead = new InclusiveMetricsEvaluator(SCHEMA, greaterThan("id", INT_MAX_VALUE - 4)).eval(FILE); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @Test @@ -535,7 +535,7 @@ public void testIntegerGtEq() { shouldRead = new InclusiveMetricsEvaluator(SCHEMA, greaterThanOrEqual("id", INT_MAX_VALUE - 4)) .eval(FILE); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @Test @@ -970,4 +970,172 @@ public void testNotNullInNestedStruct() { .as("Should not read: optional_address.optional_street2 is optional") .isFalse(); } + + @Test + public void testNotEqWithSingleValue() { + DataFile rangeOfValues = + new TestDataFile( + "range_of_values.avro", + Row.of(), + 10, + ImmutableMap.of(3, 10L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "aaa")), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "zzz"))); + + boolean shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notEqual("required", "aaa")).eval(rangeOfValues); + assertThat(shouldRead) + .as("Should read: file has range of values, cannot exclude based on literal") + .isTrue(); + + DataFile singleValueFile = + new TestDataFile( + "single_value.avro", + Row.of(), + 10, + ImmutableMap.of(3, 10L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc")), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc"))); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notEqual("required", "abc")).eval(singleValueFile); + assertThat(shouldRead) + .as("Should not read: file contains single value equal to literal") + .isFalse(); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notEqual("required", "def")).eval(singleValueFile); + assertThat(shouldRead) + .as("Should read: file contains single value not equal to literal") + .isTrue(); + + DataFile singleValueWithNulls = + new TestDataFile( + "single_value_nulls.avro", + Row.of(), + 10, + ImmutableMap.of(3, 10L), + ImmutableMap.of(3, 2L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc")), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc"))); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notEqual("required", "abc")) + .eval(singleValueWithNulls); + assertThat(shouldRead).as("Should read: file has nulls which match != predicate").isTrue(); + + DataFile singleValueWithNaN = + new TestDataFile( + "single_value_nan.avro", + Row.of(), + 10, + ImmutableMap.of(9, 10L), + ImmutableMap.of(9, 0L), + ImmutableMap.of(9, 2L), + ImmutableMap.of(9, toByteBuffer(Types.FloatType.get(), 5.0F)), + ImmutableMap.of(9, toByteBuffer(Types.FloatType.get(), 5.0F))); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notEqual("no_nans", 5.0F)).eval(singleValueWithNaN); + assertThat(shouldRead).as("Should read: file has NaN values which match != predicate").isTrue(); + + DataFile singleValueNaNBounds = + new TestDataFile( + "single_value_nan_bounds.avro", + Row.of(), + 10, + ImmutableMap.of(9, 10L), + ImmutableMap.of(9, 0L), + ImmutableMap.of(9, 0L), + ImmutableMap.of(9, toByteBuffer(Types.FloatType.get(), Float.NaN)), + ImmutableMap.of(9, toByteBuffer(Types.FloatType.get(), Float.NaN))); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notEqual("no_nans", 5.0F)).eval(singleValueNaNBounds); + assertThat(shouldRead).as("Should read: bounds are NaN").isTrue(); + } + + @Test + public void testNotInWithSingleValue() { + DataFile rangeOfValues = + new TestDataFile( + "range_of_values.avro", + Row.of(), + 10, + ImmutableMap.of(3, 10L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "aaa")), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "zzz"))); + + boolean shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notIn("required", "aaa", "bbb")).eval(rangeOfValues); + assertThat(shouldRead) + .as("Should read: file has range of values, cannot exclude based on literal") + .isTrue(); + + DataFile singleValueFile = + new TestDataFile( + "single_value.avro", + Row.of(), + 10, + ImmutableMap.of(3, 10L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc")), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc"))); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notIn("required", "abc", "def")) + .eval(singleValueFile); + assertThat(shouldRead) + .as("Should not read: file contains single value in exclusion list") + .isFalse(); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notIn("required", "def", "ghi")) + .eval(singleValueFile); + assertThat(shouldRead) + .as("Should read: file contains single value not in exclusion list") + .isTrue(); + + DataFile singleValueWithNulls = + new TestDataFile( + "single_value_nulls.avro", + Row.of(), + 10, + ImmutableMap.of(3, 10L), + ImmutableMap.of(3, 2L), + ImmutableMap.of(3, 0L), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc")), + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc"))); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notIn("required", "abc", "def")) + .eval(singleValueWithNulls); + assertThat(shouldRead).as("Should read: file has nulls which match NOT IN predicate").isTrue(); + + DataFile singleValueWithNaN = + new TestDataFile( + "single_value_nan.avro", + Row.of(), + 10, + ImmutableMap.of(9, 10L), + ImmutableMap.of(9, 0L), + ImmutableMap.of(9, 2L), + ImmutableMap.of(9, toByteBuffer(Types.FloatType.get(), 5.0F)), + ImmutableMap.of(9, toByteBuffer(Types.FloatType.get(), 5.0F))); + + shouldRead = + new InclusiveMetricsEvaluator(SCHEMA, notIn("no_nans", 5.0F, 10.0F)) + .eval(singleValueWithNaN); + assertThat(shouldRead) + .as("Should read: file has NaN values which match NOT IN predicate") + .isTrue(); + } } diff --git a/api/src/test/java/org/apache/iceberg/expressions/TestMiscLiteralConversions.java b/api/src/test/java/org/apache/iceberg/expressions/TestMiscLiteralConversions.java index e2611ddb281f..53aec44ac7ce 100644 --- a/api/src/test/java/org/apache/iceberg/expressions/TestMiscLiteralConversions.java +++ b/api/src/test/java/org/apache/iceberg/expressions/TestMiscLiteralConversions.java @@ -107,6 +107,62 @@ public void testFixedToBinary() { .isEqualTo(lit.value().duplicate()); } + @Test + public void testStringToFixed() { + // Test valid hex string to fixed conversion + Literal hexString = Literal.of("000102"); + Literal fixedLit = hexString.to(Types.FixedType.ofLength(3)); + assertThat(fixedLit).as("Should convert valid hex string to fixed").isNotNull(); + assertThat(fixedLit.value().array()) + .as("Should decode hex string correctly") + .isEqualTo(new byte[] {0, 1, 2}); + + // Test lowercase hex string + Literal lowercaseHex = Literal.of("0a0b0c"); + Literal lowercaseFixed = lowercaseHex.to(Types.FixedType.ofLength(3)); + assertThat(lowercaseFixed).as("Should convert lowercase hex string to fixed").isNotNull(); + assertThat(lowercaseFixed.value().array()) + .as("Should decode lowercase hex string correctly") + .isEqualTo(new byte[] {10, 11, 12}); + + // Test wrong length returns null + Literal wrongLength = Literal.of("0001"); + assertThat(wrongLength.to(Types.FixedType.ofLength(3))) + .as("Should return null for wrong length") + .isNull(); + + // Test invalid hex string returns null + Literal invalidHex = Literal.of("GGHHII"); + assertThat(invalidHex.to(Types.FixedType.ofLength(3))) + .as("Should return null for invalid hex string") + .isNull(); + } + + @Test + public void testStringToBinary() { + // Test valid hex string to binary conversion + Literal hexString = Literal.of("000102"); + Literal binaryLit = hexString.to(Types.BinaryType.get()); + assertThat(binaryLit).as("Should convert valid hex string to binary").isNotNull(); + assertThat(binaryLit.value().array()) + .as("Should decode hex string correctly") + .isEqualTo(new byte[] {0, 1, 2}); + + // Test lowercase hex string + Literal lowercaseHex = Literal.of("0a0b0c"); + Literal lowercaseBinary = lowercaseHex.to(Types.BinaryType.get()); + assertThat(lowercaseBinary).as("Should convert lowercase hex string to binary").isNotNull(); + assertThat(lowercaseBinary.value().array()) + .as("Should decode lowercase hex string correctly") + .isEqualTo(new byte[] {10, 11, 12}); + + // Test invalid hex string returns null + Literal invalidHex = Literal.of("GGHHII"); + assertThat(invalidHex.to(Types.BinaryType.get())) + .as("Should return null for invalid hex string") + .isNull(); + } + @Test public void testInvalidBooleanConversions() { testInvalidConversions( diff --git a/api/src/test/java/org/apache/iceberg/expressions/TestStrictMetricsEvaluator.java b/api/src/test/java/org/apache/iceberg/expressions/TestStrictMetricsEvaluator.java index f34cd730df77..b55f4efb1726 100644 --- a/api/src/test/java/org/apache/iceberg/expressions/TestStrictMetricsEvaluator.java +++ b/api/src/test/java/org/apache/iceberg/expressions/TestStrictMetricsEvaluator.java @@ -32,7 +32,9 @@ import static org.apache.iceberg.expressions.Expressions.notIn; import static org.apache.iceberg.expressions.Expressions.notNaN; import static org.apache.iceberg.expressions.Expressions.notNull; +import static org.apache.iceberg.expressions.Expressions.notStartsWith; import static org.apache.iceberg.expressions.Expressions.or; +import static org.apache.iceberg.expressions.Expressions.startsWith; import static org.apache.iceberg.types.Conversions.toByteBuffer; import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; @@ -72,8 +74,8 @@ public class TestStrictMetricsEvaluator { "struct", Types.StructType.of( Types.NestedField.optional(16, "nested_col_no_stats", Types.IntegerType.get()), - Types.NestedField.optional( - 17, "nested_col_with_stats", Types.IntegerType.get())))); + Types.NestedField.optional(17, "nested_col_with_stats", Types.IntegerType.get()), + Types.NestedField.optional(18, "nested_string_col", Types.StringType.get())))); private static final int INT_MIN_VALUE = 30; private static final int INT_MAX_VALUE = 79; @@ -172,6 +174,40 @@ public class TestStrictMetricsEvaluator { // upper bounds ImmutableMap.of(5, toByteBuffer(StringType.get(), "bbb"))); + // String-focused file: required column 3 has no nulls and string bounds ["abc", "abd"] + private static final DataFile STRING_FILE = + new TestDataFile( + "string_file.avro", + Row.of(), + 50, + // any value counts, including nulls + ImmutableMap.of(3, 50L), + // null value counts + ImmutableMap.of(), + // nan value counts + null, + // lower bounds + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abc")), + // upper bounds + ImmutableMap.of(3, toByteBuffer(StringType.get(), "abd"))); + + // String file with wider range: required column 3 has no nulls and bounds ["aa", "dC"] + private static final DataFile STRING_FILE_2 = + new TestDataFile( + "string_file_2.avro", + Row.of(), + 50, + // any value counts, including nulls + ImmutableMap.of(3, 50L), + // null value counts + ImmutableMap.of(), + // nan value counts + null, + // lower bounds + ImmutableMap.of(3, toByteBuffer(StringType.get(), "aa")), + // upper bounds + ImmutableMap.of(3, toByteBuffer(StringType.get(), "dC"))); + @Test public void testAllNulls() { boolean shouldRead = new StrictMetricsEvaluator(SCHEMA, notNull("all_nulls")).eval(FILE); @@ -684,4 +720,205 @@ SCHEMA, lessThanOrEqual("struct.nested_col_with_stats", INT_MAX_VALUE)) new StrictMetricsEvaluator(SCHEMA, notNull("struct.nested_col_with_stats")).eval(FILE); assertThat(shouldRead).as("notNull nested column should not match").isFalse(); } + + @Test + public void testNotStartsWithAllNulls() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("all_nulls", "a")).eval(FILE); + assertThat(shouldRead).as("Should match: all null values satisfy notStartsWith").isTrue(); + } + + @Test + public void testNotStartsWithBoundsAbovePrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "aaa")).eval(STRING_FILE); + assertThat(shouldRead).as("Should match: all values are above the prefix range").isTrue(); + } + + @Test + public void testNotStartsWithBoundsBelowPrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "zzz")).eval(STRING_FILE); + assertThat(shouldRead).as("Should match: all values are below the prefix range").isTrue(); + } + + @Test + public void testNotStartsWithBoundsOverlapPrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "ab")).eval(STRING_FILE); + assertThat(shouldRead).as("Should not match: bounds overlap the prefix range").isFalse(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "abc")).eval(STRING_FILE); + assertThat(shouldRead).as("Should not match: lower bound starts with the prefix").isFalse(); + } + + @Test + public void testNotStartsWithWiderRange() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "e")).eval(STRING_FILE_2); + assertThat(shouldRead).as("Should match: all values are below the prefix").isTrue(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "a")).eval(STRING_FILE_2); + assertThat(shouldRead).as("Should not match: lower bound starts with the prefix").isFalse(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "c")).eval(STRING_FILE_2); + assertThat(shouldRead).as("Should not match: prefix is within the bounds range").isFalse(); + } + + @Test + public void testNotStartsWithNoStats() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "a")).eval(FILE); + assertThat(shouldRead).as("Should not match: no bounds available for column").isFalse(); + } + + @Test + void testStartsWithBothBoundsMatchPrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "ab")).eval(STRING_FILE); + assertThat(shouldRead).as("Should match: both bounds start with the prefix").isTrue(); + } + + @Test + void testStartsWithSingleCharPrefixBothBoundsMatch() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "a")).eval(STRING_FILE); + assertThat(shouldRead) + .as("Should match: both bounds start with the single char prefix") + .isTrue(); + } + + @Test + void testStartsWithOnlyLowerBoundMatchesPrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "abc")).eval(STRING_FILE); + assertThat(shouldRead) + .as("Should not match: upper bound does not start with the prefix") + .isFalse(); + } + + @Test + void testStartsWithBoundsDoNotMatchPrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "zzz")).eval(STRING_FILE); + assertThat(shouldRead).as("Should not match: no bounds start with the prefix").isFalse(); + } + + @Test + void testStartsWithWiderRange() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "a")).eval(STRING_FILE_2); + assertThat(shouldRead) + .as("Should not match: upper bound does not start with the prefix") + .isFalse(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "e")).eval(STRING_FILE_2); + assertThat(shouldRead).as("Should not match: no bounds start with the prefix").isFalse(); + } + + @Test + void testStartsWithNoStats() { + boolean shouldRead = new StrictMetricsEvaluator(SCHEMA, startsWith("required", "a")).eval(FILE); + assertThat(shouldRead).as("Should not match: no bounds available for column").isFalse(); + } + + @Test + public void testNotStartsWithSomeNullsBoundsOutsidePrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("some_nulls", "zzz")).eval(FILE_2); + assertThat(shouldRead).as("Should match: all values are below the prefix").isTrue(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("some_nulls", "aaa")).eval(FILE_2); + assertThat(shouldRead).as("Should match: all values are above the prefix").isTrue(); + } + + @Test + public void testNotStartsWithPrefixLongerThanBounds() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "aaaaaaa")).eval(STRING_FILE); + assertThat(shouldRead).as("Should match: all values are above the long prefix").isTrue(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "zzzzzzz")).eval(STRING_FILE); + assertThat(shouldRead).as("Should match: all values are below the long prefix").isTrue(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "abcdef")).eval(STRING_FILE); + assertThat(shouldRead).as("Should not match: prefix overlaps with bound range").isFalse(); + } + + @Test + void testNotStartsWithEmptyPrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("required", "")).eval(STRING_FILE); + assertThat(shouldRead).as("Should not match: all strings start with empty prefix").isFalse(); + } + + @Test + void testNotStartsWithExactBoundMatch() { + // FILE_3 has column 5 (some_nulls) with exact bounds ["bbb", "bbb"] + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("some_nulls", "bbb")).eval(FILE_3); + assertThat(shouldRead).as("Should not match: bounds exactly equal the prefix").isFalse(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("some_nulls", "zzz")).eval(FILE_3); + assertThat(shouldRead).as("Should match: all values are below the prefix").isTrue(); + + shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("some_nulls", "aaa")).eval(FILE_3); + assertThat(shouldRead).as("Should match: all values are above the prefix").isTrue(); + } + + @Test + public void testNotStartsWithNestedColumn() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, notStartsWith("struct.nested_string_col", "a")) + .eval(FILE); + assertThat(shouldRead).as("notStartsWith nested column should not match").isFalse(); + } + + @Test + void testStartsWithAllNulls() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("all_nulls", "a")).eval(FILE); + assertThat(shouldRead) + .as("Should not match: all null values do not satisfy startsWith") + .isFalse(); + } + + @Test + void testStartsWithSomeNulls() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("some_nulls", "b")).eval(FILE_2); + assertThat(shouldRead) + .as("Should not match: some nulls means not all rows can satisfy startsWith") + .isFalse(); + } + + @Test + void testStartsWithPrefixLongerThanBounds() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "abcdef")).eval(STRING_FILE); + assertThat(shouldRead).as("Should not match: prefix is longer than the bounds").isFalse(); + } + + @Test + void testStartsWithEmptyPrefix() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("required", "")).eval(STRING_FILE); + assertThat(shouldRead).as("Should match: all strings start with empty prefix").isTrue(); + } + + @Test + void testStartsWithNestedColumn() { + boolean shouldRead = + new StrictMetricsEvaluator(SCHEMA, startsWith("struct.nested_string_col", "a")).eval(FILE); + assertThat(shouldRead).as("Should not match: nested column is not supported").isFalse(); + } } diff --git a/api/src/test/java/org/apache/iceberg/io/TestFileRange.java b/api/src/test/java/org/apache/iceberg/io/TestFileRange.java new file mode 100644 index 000000000000..dc4ede9ec3b4 --- /dev/null +++ b/api/src/test/java/org/apache/iceberg/io/TestFileRange.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.io; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.EOFException; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +public class TestFileRange { + + @Test + public void validRange() throws EOFException { + CompletableFuture future = new CompletableFuture<>(); + FileRange range = new FileRange(future, 10L, 100); + assertThat(range.offset()).isEqualTo(10L); + assertThat(range.length()).isEqualTo(100); + assertThat(range.byteBuffer()).isSameAs(future); + } + + @Test + public void negativeLength() { + CompletableFuture future = new CompletableFuture<>(); + assertThatThrownBy(() -> new FileRange(future, 0L, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid length: -1 in range (must be >= 0)"); + } + + @Test + public void negativeOffset() { + CompletableFuture future = new CompletableFuture<>(); + assertThatThrownBy(() -> new FileRange(future, -1L, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid offset: -1 in range (must be >= 0)"); + } + + @Test + public void nullByteBuffer() { + assertThatThrownBy(() -> new FileRange(null, 0L, 0)) + .isInstanceOf(NullPointerException.class) + .hasMessage("byteBuffer can't be null"); + } +} diff --git a/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java b/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java index d4742f518754..dd8afebab84d 100644 --- a/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java +++ b/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java @@ -317,6 +317,31 @@ public void testSelect() { assertThat(actualDepthTwo.asStruct()).isEqualTo(expectedDepthTwo.asStruct()); } + @Test + public void testSelectInIdOrder() { + Schema schema = + new Schema( + required(1, "id", Types.IntegerType.get()), + required(3, "b", Types.IntegerType.get()), + required(2, "a", Types.IntegerType.get())); + + Schema result = TypeUtil.selectInIdOrder(schema, Sets.newHashSet(2, 3)); + + assertThat(result.columns()).hasSize(2); + assertThat(result.columns().get(0).fieldId()).isEqualTo(2); + assertThat(result.columns().get(1).fieldId()).isEqualTo(3); + + // verify that different input orderings produce the same result + Schema schemaReversed = + new Schema( + required(2, "a", Types.IntegerType.get()), + required(3, "b", Types.IntegerType.get()), + required(1, "id", Types.IntegerType.get())); + + Schema resultReversed = TypeUtil.selectInIdOrder(schemaReversed, Sets.newHashSet(2, 3)); + assertThat(resultReversed.asStruct()).isEqualTo(result.asStruct()); + } + @Test public void testProjectMap() { // We can't partially project keys because it changes key equality diff --git a/api/src/test/java/org/apache/iceberg/util/TestCharSequenceSet.java b/api/src/test/java/org/apache/iceberg/util/TestCharSequenceSet.java index 324742c07a2d..093d2a0c6b87 100644 --- a/api/src/test/java/org/apache/iceberg/util/TestCharSequenceSet.java +++ b/api/src/test/java/org/apache/iceberg/util/TestCharSequenceSet.java @@ -19,10 +19,12 @@ package org.apache.iceberg.util; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Arrays; import java.util.Collections; import java.util.Set; +import org.apache.iceberg.TestHelpers; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; @@ -42,15 +44,115 @@ public void testSearchingInCharSequenceCollection() { @Test public void nullString() { - assertThat(CharSequenceSet.of(Arrays.asList((String) null))).contains((String) null); + assertThatThrownBy(() -> CharSequenceSet.of(Arrays.asList((String) null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); assertThat(CharSequenceSet.empty()).doesNotContain((String) null); } + @Test + public void emptySet() { + assertThat(CharSequenceSet.empty()).isEmpty(); + assertThat(CharSequenceSet.empty()).doesNotContain("a", "b", "c"); + } + + @Test + public void insertionOrderIsMaintained() { + CharSequenceSet set = CharSequenceSet.empty(); + set.addAll(ImmutableList.of("d", "a", "c")); + set.add("b"); + set.add("d"); + + assertThat(set).hasSize(4).containsExactly("d", "a", "c", "b"); + } + + @Test + public void clear() { + CharSequenceSet set = CharSequenceSet.of(ImmutableList.of("a", "b")); + set.clear(); + assertThat(set).isEmpty(); + } + + @Test + public void addAll() { + CharSequenceSet empty = CharSequenceSet.empty(); + assertThatThrownBy(() -> empty.add(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThatThrownBy(() -> empty.addAll(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid collection: null"); + + assertThatThrownBy(() -> empty.addAll(Collections.singletonList(null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThatThrownBy(() -> empty.addAll(Arrays.asList("a", null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + CharSequenceSet set = CharSequenceSet.empty(); + set.addAll(ImmutableList.of("b", "a", "c", "a")); + assertThat(set).hasSize(3).containsExactly("b", "a", "c"); + } + + @Test + public void contains() { + CharSequenceSet set = CharSequenceSet.of(ImmutableList.of("b", "a")); + assertThatThrownBy(() -> set.contains(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThat(set).hasSize(2).containsExactly("b", "a").doesNotContain("c").doesNotContain("d"); + + assertThatThrownBy(() -> CharSequenceSet.of(Arrays.asList("c", "b", null, "a"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + } + + @Test + public void containsAll() { + CharSequenceSet set = CharSequenceSet.of(ImmutableList.of("b", "a")); + assertThatThrownBy(() -> set.containsAll(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid collection: null"); + + assertThatThrownBy(() -> set.containsAll(Collections.singletonList(null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThatThrownBy(() -> set.containsAll(Arrays.asList("a", null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThat(set.containsAll(ImmutableList.of("a", "b"))).isTrue(); + assertThat(set.containsAll(ImmutableList.of("b", "a", "c"))).isFalse(); + assertThat(set.containsAll(ImmutableList.of("b"))).isTrue(); + } + @Test public void testRetainAll() { + CharSequenceSet empty = CharSequenceSet.empty(); + assertThatThrownBy(() -> empty.retainAll(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid collection: null"); + + assertThatThrownBy(() -> empty.retainAll(Collections.singletonList(null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThatThrownBy(() -> empty.retainAll(Arrays.asList("123", null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThatThrownBy(() -> empty.retainAll(ImmutableList.of("456", "789", 123))) + .isInstanceOf(ClassCastException.class) + .hasMessage("Cannot cast java.lang.Integer to java.lang.CharSequence"); + CharSequenceSet set = CharSequenceSet.of(ImmutableList.of("123", "456")); - assertThat(set.retainAll(ImmutableList.of("456", "789", 123))) + assertThat(set.retainAll(ImmutableList.of("456", "789", "555"))) .overridingErrorMessage("Set should be changed") .isTrue(); @@ -61,24 +163,74 @@ public void testRetainAll() { .overridingErrorMessage("Set should not be changed") .isFalse(); - assertThat(set.retainAll(ImmutableList.of(123, 456))) + assertThat(set.retainAll(ImmutableList.of("555", "789"))) .overridingErrorMessage("Set should be changed") .isTrue(); assertThat(set).isEmpty(); } + @Test + public void toArray() { + CharSequenceSet set = CharSequenceSet.of(ImmutableList.of("b", "a")); + assertThat(set.toArray()).hasSize(2).containsExactly("b", "a"); + + CharSequence[] array = new CharSequence[1]; + assertThat(set.toArray(array)).hasSize(2).containsExactly("b", "a"); + + array = new CharSequence[0]; + assertThat(set.toArray(array)).hasSize(2).containsExactly("b", "a"); + + array = new CharSequence[5]; + assertThat(set.toArray(array)).hasSize(5).containsExactly("b", "a", null, null, null); + + array = new CharSequence[2]; + assertThat(set.toArray(array)).hasSize(2).containsExactly("b", "a"); + } + + @Test + public void remove() { + CharSequenceSet set = CharSequenceSet.of(ImmutableSet.of("a", "b", "c")); + assertThatThrownBy(() -> set.remove(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + set.remove("a"); + assertThat(set).containsExactly("b", "c"); + set.remove("b"); + assertThat(set).containsExactly("c"); + set.remove("c"); + assertThat(set).isEmpty(); + } + @Test public void testRemoveAll() { + CharSequenceSet empty = CharSequenceSet.empty(); + assertThatThrownBy(() -> empty.removeAll(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid collection: null"); + + assertThatThrownBy(() -> empty.removeAll(Collections.singletonList(null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThatThrownBy(() -> empty.removeAll(Arrays.asList("123", null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("Invalid object: null"); + + assertThatThrownBy(() -> empty.removeAll(ImmutableList.of("123", 456))) + .isInstanceOf(ClassCastException.class) + .hasMessage("Cannot cast java.lang.Integer to java.lang.CharSequence"); + CharSequenceSet set = CharSequenceSet.of(ImmutableList.of("123", "456")); - assertThat(set.removeAll(ImmutableList.of("456", "789", 123))) + assertThat(set.removeAll(ImmutableList.of("456", "789"))) .overridingErrorMessage("Set should be changed") .isTrue(); assertThat(set).hasSize(1).contains("123"); set = CharSequenceSet.of(ImmutableList.of("123", "456")); - assertThat(set.removeAll(ImmutableList.of(123, 456))) + assertThat(set.removeAll(ImmutableList.of("333", "789"))) .overridingErrorMessage("Set should not be changed") .isFalse(); @@ -119,4 +271,17 @@ public void testEqualsAndHashCode() { .isEqualTo(set3.hashCode()) .isEqualTo(set4.hashCode()); } + + @Test + public void kryoSerialization() throws Exception { + CharSequenceSet charSequences = CharSequenceSet.of(ImmutableList.of("c", "b", "a")); + assertThat(TestHelpers.KryoHelpers.roundTripSerialize(charSequences)).isEqualTo(charSequences); + } + + @Test + public void javaSerialization() throws Exception { + CharSequenceSet charSequences = CharSequenceSet.of(ImmutableList.of("c", "b", "a")); + CharSequenceSet deserialize = TestHelpers.deserialize(TestHelpers.serialize(charSequences)); + assertThat(deserialize).isEqualTo(charSequences); + } } diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/ArrowFormatModels.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/ArrowFormatModels.java new file mode 100644 index 000000000000..d70e12be7817 --- /dev/null +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/ArrowFormatModels.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.arrow.vectorized; + +import org.apache.arrow.vector.NullCheckingForGet; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.parquet.ParquetFormatModel; + +public class ArrowFormatModels { + public static void register() { + FormatModelRegistry.register( + ParquetFormatModel.create( + ColumnarBatch.class, + Object.class, + (schema, fileSchema, engineSchema, idToConstant) -> + ArrowReader.VectorizedCombinedScanIterator.buildReader( + schema, + fileSchema, + NullCheckingForGet.NULL_CHECKING_ENABLED /* setArrowValidityVector */))); + } + + private ArrowFormatModels() {} +} diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/ArrowReader.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/ArrowReader.java index 06b7baec27d5..68a27bdfb8eb 100644 --- a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/ArrowReader.java +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/ArrowReader.java @@ -29,7 +29,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.stream.StreamSupport; -import org.apache.arrow.vector.NullCheckingForGet; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.types.Types.MinorType; import org.apache.iceberg.CombinedScanTask; @@ -40,13 +39,14 @@ import org.apache.iceberg.encryption.EncryptedFiles; import org.apache.iceberg.encryption.EncryptedInputFile; import org.apache.iceberg.encryption.EncryptionManager; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableGroup; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.mapping.NameMappingParser; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.parquet.TypeWithSchemaVisitor; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; @@ -189,8 +189,7 @@ public void close() throws IOException { * Reads the data file and returns an iterator of {@link VectorSchemaRoot}. Only Parquet data file * format is supported. */ - private static final class VectorizedCombinedScanIterator - implements CloseableIterator { + static final class VectorizedCombinedScanIterator implements CloseableIterator { private final Iterator fileItr; private final Map inputFiles; @@ -324,19 +323,8 @@ CloseableIterator open(FileScanTask task) { InputFile location = getInputFile(task); Preconditions.checkNotNull(location, "Could not find InputFile associated with FileScanTask"); if (task.file().format() == FileFormat.PARQUET) { - Parquet.ReadBuilder builder = - Parquet.read(location) - .project(expectedSchema) - .split(task.start(), task.length()) - .createBatchedReaderFunc( - fileSchema -> - buildReader( - expectedSchema, - fileSchema, /* setArrowValidityVector */ - NullCheckingForGet.NULL_CHECKING_ENABLED)) - .recordsPerBatch(batchSize) - .filter(task.residual()) - .caseSensitive(caseSensitive); + ReadBuilder builder = + FormatModelRegistry.readBuilder(FileFormat.PARQUET, ColumnarBatch.class, location); if (reuseContainers) { builder.reuseContainers(); @@ -345,7 +333,14 @@ CloseableIterator open(FileScanTask task) { builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); } - iter = builder.build(); + iter = + builder + .project(expectedSchema) + .split(task.start(), task.length()) + .recordsPerBatch(batchSize) + .caseSensitive(caseSensitive) + .filter(task.residual()) + .build(); } else { throw new UnsupportedOperationException( "Format: " + task.file().format() + " not supported for batched reads"); @@ -376,7 +371,7 @@ private InputFile getInputFile(FileScanTask task) { * @param fileSchema Schema of the data file. * @param setArrowValidityVector Indicates whether to set the validity vector in Arrow vectors. */ - private static ArrowBatchReader buildReader( + static ArrowBatchReader buildReader( Schema expectedSchema, MessageType fileSchema, boolean setArrowValidityVector) { return (ArrowBatchReader) TypeWithSchemaVisitor.visit( diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorHolder.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorHolder.java index 0245b6bba2d1..f8c0d6dd69b8 100644 --- a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorHolder.java +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorHolder.java @@ -73,7 +73,7 @@ private VectorHolder(Types.NestedField field) { icebergField = field; } - VectorHolder(FieldVector vec, Types.NestedField field, NullabilityHolder nulls) { + private VectorHolder(FieldVector vec, Types.NestedField field, NullabilityHolder nulls) { columnDescriptor = null; vector = vec; isDictionaryEncoded = false; diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedArrowReader.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedArrowReader.java index 2cc7cde4541a..e9ebed2826f4 100644 --- a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedArrowReader.java +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedArrowReader.java @@ -588,10 +588,18 @@ public Optional visit( int bitWidth = intLogicalType.getBitWidth(); if (bitWidth == 8 || bitWidth == 16 || bitWidth == 32) { + // Iceberg has no unsigned integer type. Reading UINT32 into a 32-bit signed value would + // silently produce negative results for inputs above Integer.MAX_VALUE. UINT8 and UINT16 + // both fit losslessly in a signed int32 and are allowed, matching the policy in + // BaseParquetReaders for the non-vectorized path. + Preconditions.checkArgument( + intLogicalType.isSigned() || bitWidth < 32, "Cannot read UINT32 as an int value"); ((IntVector) vector).allocateNew(batchSize); return Optional.of( new LogicalTypeVisitorResult(vector, ReadType.INT, (int) IntVector.TYPE_WIDTH)); } else if (bitWidth == 64) { + Preconditions.checkArgument( + intLogicalType.isSigned(), "Cannot read UINT64 as a long value"); ((BigIntVector) vector).allocateNew(batchSize); return Optional.of( new LogicalTypeVisitorResult(vector, ReadType.LONG, (int) BigIntVector.TYPE_WIDTH)); diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedReaderBuilder.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedReaderBuilder.java index 15b55fb48d4a..3fbd797c26fb 100644 --- a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedReaderBuilder.java +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/VectorizedReaderBuilder.java @@ -154,6 +154,16 @@ public VectorizedReader struct( return null; } + @Override + public VectorizedReader variant( + Types.VariantType iVariant, GroupType variant, VectorizedReader result) { + if (iVariant != null) { + throw new UnsupportedOperationException( + "Vectorized reads are not supported yet for variant fields"); + } + return null; + } + @Override public VectorizedReader primitive( org.apache.iceberg.types.Type.PrimitiveType expected, PrimitiveType primitive) { diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedByteStreamSplitValuesReader.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedByteStreamSplitValuesReader.java new file mode 100644 index 000000000000..58ea38231c4a --- /dev/null +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedByteStreamSplitValuesReader.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.arrow.vectorized.parquet; + +import java.io.EOFException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.arrow.vector.FieldVector; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; + +/** + * A {@link VectorizedValuesReader} implementation for the encoding type BYTE_STREAM_SPLIT. This is + * adapted from Parquet's ByteStreamSplitValuesReader. + * + * @see + * Parquet format encodings: BYTE_STREAM_SPLIT + */ +public class VectorizedByteStreamSplitValuesReader extends ValuesReader + implements VectorizedValuesReader { + + private final int elementSizeInBytes; + private int totalBytesInStream; + private ByteBufferInputStream dataStream; + private ByteBuffer decodedDataStream; + + public VectorizedByteStreamSplitValuesReader(int elementSizeInBytes) { + this.elementSizeInBytes = elementSizeInBytes; + } + + @Override + public void initFromPage(int ignoredValueCount, ByteBufferInputStream in) { + this.totalBytesInStream = in.available(); + this.dataStream = in; + } + + @Override + public int readInteger() { + ensureDecoded(); + return decodedDataStream.getInt(); + } + + @Override + public long readLong() { + ensureDecoded(); + return decodedDataStream.getLong(); + } + + @Override + public float readFloat() { + ensureDecoded(); + return decodedDataStream.getFloat(); + } + + @Override + public double readDouble() { + ensureDecoded(); + return decodedDataStream.getDouble(); + } + + @Override + public Binary readBinary(int len) { + ensureDecoded(); + byte[] bytes = new byte[len]; + decodedDataStream.get(bytes); + return Binary.fromConstantByteArray(bytes); + } + + @Override + public void readIntegers(int total, FieldVector vec, int rowId) { + readBatch(total, vec, rowId); + } + + @Override + public void readLongs(int total, FieldVector vec, int rowId) { + readBatch(total, vec, rowId); + } + + @Override + public void readFloats(int total, FieldVector vec, int rowId) { + readBatch(total, vec, rowId); + } + + @Override + public void readDoubles(int total, FieldVector vec, int rowId) { + readBatch(total, vec, rowId); + } + + @Override + public void skip() { + throw new UnsupportedOperationException("skip is not supported"); + } + + private void readBatch(int total, FieldVector vec, int rowId) { + ensureDecoded(); + int bytesToRead = total * elementSizeInBytes; + long destOffset = (long) rowId * elementSizeInBytes; + ByteBuffer slice = decodedDataStream.slice(); + slice.limit(bytesToRead); + vec.getDataBuffer().setBytes(destOffset, slice); + decodedDataStream.position(decodedDataStream.position() + bytesToRead); + } + + private void ensureDecoded() { + if (decodedDataStream == null) { + Preconditions.checkState( + totalBytesInStream % elementSizeInBytes == 0, + "Stream size %s is not a multiple of element size %s", + totalBytesInStream, + elementSizeInBytes); + this.decodedDataStream = decode(totalBytesInStream / elementSizeInBytes); + } + } + + private ByteBuffer decode(int valuesCount) { + ByteBuffer encoded; + try { + encoded = dataStream.slice(totalBytesInStream).slice(); + } catch (EOFException e) { + throw new UncheckedIOException("Failed to read bytes from stream", e); + } + byte[] decoded = new byte[encoded.limit()]; + int destByteIndex = 0; + for (int srcValueIndex = 0; srcValueIndex < valuesCount; srcValueIndex++) { + for (int stream = 0; stream < elementSizeInBytes; stream++, destByteIndex++) { + decoded[destByteIndex] = encoded.get(srcValueIndex + stream * valuesCount); + } + } + return ByteBuffer.wrap(decoded).order(ByteOrder.LITTLE_ENDIAN); + } +} diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaByteArrayValuesReader.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaByteArrayValuesReader.java new file mode 100644 index 000000000000..6972a22fd9c1 --- /dev/null +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaByteArrayValuesReader.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.arrow.vectorized.parquet; + +import java.io.IOException; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; + +/** + * A {@link VectorizedValuesReader} implementation for DELTA_BYTE_ARRAY encoding. This encoding + * stores delta-encoded prefix lengths followed by suffixes encoded as DELTA_LENGTH_BYTE_ARRAY. Each + * value is reconstructed by taking the prefix of the previous value and appending the suffix. This + * is adapted from Spark's VectorizedDeltaByteArrayReader. + * + * @see + * Parquet format encodings: DELTA_BYTE_ARRAY + */ +public class VectorizedDeltaByteArrayValuesReader extends ValuesReader + implements VectorizedValuesReader { + + private int[] prefixLengths; + private VectorizedDeltaLengthByteArrayValuesReader suffixReader; + private Binary previous = Binary.EMPTY; + private int currentRow = 0; + + @Override + public void initFromPage(int valueCount, ByteBufferInputStream in) throws IOException { + VectorizedDeltaEncodedValuesReader prefixLengthReader = + new VectorizedDeltaEncodedValuesReader(); + prefixLengthReader.initFromPage(valueCount, in); + this.prefixLengths = prefixLengthReader.readIntegers(prefixLengthReader.totalValueCount(), 0); + this.suffixReader = new VectorizedDeltaLengthByteArrayValuesReader(); + this.suffixReader.initFromPage(valueCount, in); + } + + @Override + public int readInteger() { + return prefixLengths[currentRow] + suffixReader.lengthForCurrentRow(); + } + + @Override + public Binary readBinary(int len) { + int prefixLength = prefixLengths[currentRow]; + Binary suffix = suffixReader.readBinary(len - prefixLength); + this.currentRow++; + + if (prefixLength == 0) { + this.previous = suffix; + return suffix; + } + + byte[] prefixBytes = previous.getBytes(); + byte[] suffixBytes = suffix.getBytes(); + byte[] out = new byte[prefixLength + suffixBytes.length]; + System.arraycopy(prefixBytes, 0, out, 0, prefixLength); + System.arraycopy(suffixBytes, 0, out, prefixLength, suffixBytes.length); + this.previous = Binary.fromConstantByteArray(out); + return previous; + } + + @Override + public void skip() { + throw new UnsupportedOperationException("skip is not supported"); + } +} diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaEncodedValuesReader.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaEncodedValuesReader.java index 115518e1fb50..446b83c7f10d 100644 --- a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaEncodedValuesReader.java +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaEncodedValuesReader.java @@ -29,7 +29,6 @@ import org.apache.parquet.column.values.bitpacking.BytePackerForLong; import org.apache.parquet.column.values.bitpacking.Packer; import org.apache.parquet.io.ParquetDecodingException; -import org.apache.parquet.io.api.Binary; /** * A {@link VectorizedValuesReader} implementation for the encoding type DELTA_BINARY_PACKED. This @@ -91,18 +90,6 @@ public void initFromPage(int valueCount, ByteBufferInputStream in) throws IOExce firstValue = BytesUtils.readZigZagVarLong(this.inputStream); } - /** DELTA_BINARY_PACKED only supports INT32 and INT64 */ - @Override - public byte readByte() { - throw new UnsupportedOperationException("readByte is not supported"); - } - - /** DELTA_BINARY_PACKED only supports INT32 and INT64 */ - @Override - public short readShort() { - throw new UnsupportedOperationException("readShort is not supported"); - } - @Override public int readInteger() { readValues(1, null, 0, INT_SIZE, (f, i, v) -> intVal = (int) v); @@ -121,10 +108,8 @@ public void skip() { throw new UnsupportedOperationException("skip is not supported"); } - /** DELTA_BINARY_PACKED only supports INT32 and INT64 */ - @Override - public Binary readBinary(int len) { - throw new UnsupportedOperationException("readBinary is not supported"); + int totalValueCount() { + return totalValueCount; } @Override @@ -132,21 +117,20 @@ public void readIntegers(int total, FieldVector vec, int rowId) { readValues(total, vec, rowId, INT_SIZE, (f, i, v) -> f.getDataBuffer().setInt(i, (int) v)); } - @Override - public void readLongs(int total, FieldVector vec, int rowId) { - readValues(total, vec, rowId, LONG_SIZE, (f, i, v) -> f.getDataBuffer().setLong(i, v)); + int[] readIntegers(int total, int rowId) { + int[] result = new int[total]; + readValues( + total, + null, + rowId, + INT_SIZE, + (vec, idx, val) -> result[(int) (idx / INT_SIZE)] = (int) val); + return result; } - /** DELTA_BINARY_PACKED only supports INT32 and INT64 */ @Override - public void readFloats(int total, FieldVector vec, int rowId) { - throw new UnsupportedOperationException("readFloats is not supported"); - } - - /** DELTA_BINARY_PACKED only supports INT32 and INT64 */ - @Override - public void readDoubles(int total, FieldVector vec, int rowId) { - throw new UnsupportedOperationException("readDoubles is not supported"); + public void readLongs(int total, FieldVector vec, int rowId) { + readValues(total, vec, rowId, LONG_SIZE, (f, i, v) -> f.getDataBuffer().setLong(i, v)); } private void readValues( diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaLengthByteArrayValuesReader.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaLengthByteArrayValuesReader.java new file mode 100644 index 000000000000..9cec5b6e62bf --- /dev/null +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedDeltaLengthByteArrayValuesReader.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.arrow.vectorized.parquet; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.api.Binary; + +/** + * A {@link VectorizedValuesReader} implementation for DELTA_LENGTH_BYTE_ARRAY encoding. This + * encoding stores delta-encoded byte array lengths followed by the concatenated byte array data. + * This is adapted from Spark's VectorizedDeltaLengthByteArrayReader. + * + * @see + * Parquet format encodings: DELTA_LENGTH_BYTE_ARRAY + */ +public class VectorizedDeltaLengthByteArrayValuesReader extends ValuesReader + implements VectorizedValuesReader { + + private ByteBufferInputStream dataStream; + private int[] lengths; + private int currentRow = 0; + + @Override + public void initFromPage(int valueCount, ByteBufferInputStream in) throws IOException { + VectorizedDeltaEncodedValuesReader lengthReader = new VectorizedDeltaEncodedValuesReader(); + lengthReader.initFromPage(valueCount, in); + this.lengths = lengthReader.readIntegers(lengthReader.totalValueCount(), 0); + this.dataStream = in; + } + + int lengthForCurrentRow() { + return lengths[currentRow]; + } + + @Override + public Binary readBinary(int len) { + try { + ByteBuffer buffer = dataStream.slice(len); + this.currentRow++; + if (buffer.hasArray()) { + return Binary.fromConstantByteArray( + buffer.array(), buffer.arrayOffset() + buffer.position(), len); + } else { + byte[] bytes = new byte[len]; + buffer.get(bytes); + return Binary.fromConstantByteArray(bytes); + } + } catch (IOException e) { + throw new UncheckedIOException("Failed to read binary data", e); + } + } + + @Override + public int readInteger() { + return lengths[currentRow]; + } + + @Override + public void skip() { + throw new UnsupportedOperationException("skip is not supported"); + } +} diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedPageIterator.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedPageIterator.java index be1a3324ae43..4a06f64b5af9 100644 --- a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedPageIterator.java +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedPageIterator.java @@ -34,6 +34,7 @@ import org.apache.parquet.column.values.RequiresPreviousReader; import org.apache.parquet.column.values.ValuesReader; import org.apache.parquet.io.ParquetDecodingException; +import org.apache.parquet.schema.PrimitiveType; public class VectorizedPageIterator extends BasePageIterator { private final boolean setArrowValidityVector; @@ -100,6 +101,17 @@ protected void initDataReader(Encoding dataEncoding, ByteBufferInputStream in, i case DELTA_BINARY_PACKED: valuesReader = new VectorizedDeltaEncodedValuesReader(); break; + case DELTA_LENGTH_BYTE_ARRAY: + valuesReader = new VectorizedDeltaLengthByteArrayValuesReader(); + break; + case DELTA_BYTE_ARRAY: + valuesReader = new VectorizedDeltaByteArrayValuesReader(); + break; + case BYTE_STREAM_SPLIT: + valuesReader = + new VectorizedByteStreamSplitValuesReader( + byteStreamSplitElementSize(desc.getPrimitiveType())); + break; default: throw new UnsupportedOperationException( "Cannot support vectorized reads for column " @@ -371,6 +383,22 @@ protected void nextDictEncodedVal( } } + private static int byteStreamSplitElementSize(PrimitiveType type) { + switch (type.getPrimitiveTypeName()) { + case INT32: + case FLOAT: + return VectorizedValuesReader.INT_SIZE; + case INT64: + case DOUBLE: + return VectorizedValuesReader.LONG_SIZE; + case FIXED_LEN_BYTE_ARRAY: + return type.getTypeLength(); + default: + throw new UnsupportedOperationException( + "Byte stream split encoding is not supported for type " + type.getPrimitiveTypeName()); + } + } + private int getActualBatchSize(int expectedBatchSize) { return Math.min(expectedBatchSize, triplesCount - triplesRead); } diff --git a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedValuesReader.java b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedValuesReader.java index 7c23149b18ab..85216dd94af0 100644 --- a/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedValuesReader.java +++ b/arrow/src/main/java/org/apache/iceberg/arrow/vectorized/parquet/VectorizedValuesReader.java @@ -37,44 +37,68 @@ interface VectorizedValuesReader { int DOUBLE_SIZE = 8; /** Read a single boolean */ - boolean readBoolean(); + default boolean readBoolean() { + throw new UnsupportedOperationException("readBoolean is not supported"); + } /** Read a single byte */ - byte readByte(); + default byte readByte() { + throw new UnsupportedOperationException("readByte is not supported"); + } /** Read a single short */ - short readShort(); + default short readShort() { + throw new UnsupportedOperationException("readShort is not supported"); + } /** Read a single integer */ - int readInteger(); + default int readInteger() { + throw new UnsupportedOperationException("readInteger is not supported"); + } /** Read a single long */ - long readLong(); + default long readLong() { + throw new UnsupportedOperationException("readLong is not supported"); + } /** Read a single float */ - float readFloat(); + default float readFloat() { + throw new UnsupportedOperationException("readFloat is not supported"); + } /** Read a single double */ - double readDouble(); + default double readDouble() { + throw new UnsupportedOperationException("readDouble is not supported"); + } /** * Read binary data of some length * * @param len The number of bytes to read */ - Binary readBinary(int len); + default Binary readBinary(int len) { + throw new UnsupportedOperationException("readBinary is not supported"); + } /** Read `total` integers into `vec` starting at `vec[rowId]` */ - void readIntegers(int total, FieldVector vec, int rowId); + default void readIntegers(int total, FieldVector vec, int rowId) { + throw new UnsupportedOperationException("readIntegers is not supported"); + } /** Read `total` longs into `vec` starting at `vec[rowId]` */ - void readLongs(int total, FieldVector vec, int rowId); + default void readLongs(int total, FieldVector vec, int rowId) { + throw new UnsupportedOperationException("readLongs is not supported"); + } /** Read `total` floats into `vec` starting at `vec[rowId]` */ - void readFloats(int total, FieldVector vec, int rowId); + default void readFloats(int total, FieldVector vec, int rowId) { + throw new UnsupportedOperationException("readFloats is not supported"); + } /** Read `total` doubles into `vec` starting at `vec[rowId]` */ - void readDoubles(int total, FieldVector vec, int rowId); + default void readDoubles(int total, FieldVector vec, int rowId) { + throw new UnsupportedOperationException("readDoubles is not supported"); + } /** * Initialize the reader from a page. See {@link ValuesReader#initFromPage(int, diff --git a/arrow/src/test/java/org/apache/iceberg/arrow/vectorized/TestArrowReader.java b/arrow/src/test/java/org/apache/iceberg/arrow/vectorized/TestArrowReader.java index 34e83de15207..cf3eb2700265 100644 --- a/arrow/src/test/java/org/apache/iceberg/arrow/vectorized/TestArrowReader.java +++ b/arrow/src/test/java/org/apache/iceberg/arrow/vectorized/TestArrowReader.java @@ -21,6 +21,7 @@ import static org.apache.iceberg.Files.localInput; import static org.apache.parquet.schema.Types.primitive; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.io.IOException; @@ -41,6 +42,7 @@ import java.util.concurrent.TimeUnit; import java.util.function.BiFunction; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.apache.arrow.vector.BigIntVector; import org.apache.arrow.vector.BitVector; import org.apache.arrow.vector.DateDayVector; @@ -101,6 +103,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Test cases for {@link ArrowReader}. @@ -383,6 +388,111 @@ public void testTimestampMillisAreReadCorrectly() throws Exception { assertThat(totalRowsRead).as("Should read all rows").isEqualTo(millisValues.size()); } + @ParameterizedTest + @MethodSource("rejectedUnsignedIntegerCases") + public void testUnsignedIntegerColumnThrowsException( + int unsignedBitWidth, + PrimitiveType.PrimitiveTypeName physicalType, + Schema schema, + String expectedMessage) + throws Exception { + Table table = createSingleRowUnsignedIntTable(schema, physicalType, unsignedBitWidth, 100L); + + assertThatThrownBy( + () -> { + try (VectorizedTableScanIterable vectorizedReader = + new VectorizedTableScanIterable(table.newScan(), 1024, false)) { + for (ColumnarBatch batch : vectorizedReader) { + batch.createVectorSchemaRootFromVectors().close(); + } + } + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(expectedMessage); + } + + @ParameterizedTest + @MethodSource("acceptedUnsignedSmallIntegerCases") + public void testUnsignedSmallIntegerColumnRoundtrips(int unsignedBitWidth, int value) + throws Exception { + Schema schema = new Schema(Types.NestedField.optional(1, "col", Types.IntegerType.get())); + Table table = + createSingleRowUnsignedIntTable( + schema, PrimitiveType.PrimitiveTypeName.INT32, unsignedBitWidth, value); + + int totalRows = 0; + try (VectorizedTableScanIterable vectorizedReader = + new VectorizedTableScanIterable(table.newScan(), 1024, false)) { + for (ColumnarBatch batch : vectorizedReader) { + VectorSchemaRoot root = batch.createVectorSchemaRootFromVectors(); + assertThat(((IntVector) root.getVector("col")).get(0)) + .as("UINT%d value should round-trip through int", unsignedBitWidth) + .isEqualTo(value); + totalRows += root.getRowCount(); + root.close(); + } + } + + assertThat(totalRows).isEqualTo(1); + } + + private static Stream rejectedUnsignedIntegerCases() { + return Stream.of( + Arguments.of( + 32, + PrimitiveType.PrimitiveTypeName.INT32, + new Schema(Types.NestedField.optional(1, "col", Types.IntegerType.get())), + "Cannot read UINT32 as an int value"), + Arguments.of( + 64, + PrimitiveType.PrimitiveTypeName.INT64, + new Schema(Types.NestedField.optional(1, "col", Types.LongType.get())), + "Cannot read UINT64 as a long value")); + } + + private static Stream acceptedUnsignedSmallIntegerCases() { + return Stream.of(Arguments.of(8, 250), Arguments.of(16, 50000)); + } + + private Table createSingleRowUnsignedIntTable( + Schema schema, PrimitiveType.PrimitiveTypeName physicalType, int unsignedBitWidth, long value) + throws IOException { + tables = new HadoopTables(); + Table table = tables.create(schema, tempDir.toURI() + "/uint" + unsignedBitWidth); + + MessageType parquetSchema = + new MessageType( + "test", + primitive(physicalType, Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.intType(unsignedBitWidth, false)) + .id(1) + .named("col")); + + File testFile = + new File(tempDir, "unsigned-int" + unsignedBitWidth + "-" + System.nanoTime() + ".parquet"); + try (ParquetWriter writer = + ExampleParquetWriter.builder(new Path(testFile.toURI())).withType(parquetSchema).build()) { + SimpleGroupFactory factory = new SimpleGroupFactory(parquetSchema); + Group group = factory.newGroup(); + if (physicalType == PrimitiveType.PrimitiveTypeName.INT64) { + group.add("col", value); + } else { + group.add("col", (int) value); + } + writer.write(group); + } + + DataFile dataFile = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(testFile.getAbsolutePath()) + .withFileSizeInBytes(testFile.length()) + .withFormat(FileFormat.PARQUET) + .withRecordCount(1) + .build(); + table.newAppend().appendFile(dataFile).commit(); + return table; + } + /** * Run the following verifications: * diff --git a/arrow/src/test/java/org/apache/iceberg/arrow/vectorized/TestVectorizedReaderBuilder.java b/arrow/src/test/java/org/apache/iceberg/arrow/vectorized/TestVectorizedReaderBuilder.java new file mode 100644 index 000000000000..e3d76515bcc7 --- /dev/null +++ b/arrow/src/test/java/org/apache/iceberg/arrow/vectorized/TestVectorizedReaderBuilder.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.arrow.vectorized; + +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.parquet.TypeWithSchemaVisitor; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.types.Types.IntegerType; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.types.Types.VariantType; +import org.apache.iceberg.variants.Variant; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Type; +import org.apache.parquet.schema.Types; +import org.junit.jupiter.api.Test; + +public class TestVectorizedReaderBuilder { + + @Test + public void testVariantNotSupportedInVectorizedReads() { + Schema icebergSchema = + new Schema( + NestedField.required(1, "id", IntegerType.get()), + NestedField.optional(2, "data", VariantType.get())); + + MessageType parquetSchema = parquetSchemaWithVariant(); + + VectorizedReaderBuilder builder = + new VectorizedReaderBuilder( + icebergSchema, parquetSchema, false, ImmutableMap.of(), readers -> null); + + assertThatThrownBy( + () -> TypeWithSchemaVisitor.visit(icebergSchema.asStruct(), parquetSchema, builder)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Vectorized reads are not supported yet for variant fields"); + } + + @Test + public void testVariantSkippedWhenNotInProjection() { + Schema icebergSchema = new Schema(NestedField.required(1, "id", IntegerType.get())); + + MessageType parquetSchema = parquetSchemaWithVariant(); + + VectorizedReaderBuilder builder = + new VectorizedReaderBuilder( + icebergSchema, parquetSchema, false, ImmutableMap.of(), readers -> null); + + assertThatNoException() + .describedAs("Variant not in projection should not throw") + .isThrownBy( + () -> TypeWithSchemaVisitor.visit(icebergSchema.asStruct(), parquetSchema, builder)); + } + + private static MessageType parquetSchemaWithVariant() { + return Types.buildMessage() + .addField( + Types.primitive(PrimitiveTypeName.INT32, Type.Repetition.REQUIRED).id(1).named("id")) + .addField( + Types.buildGroup(Type.Repetition.OPTIONAL) + .as(LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION)) + .addField( + Types.primitive(PrimitiveTypeName.BINARY, Type.Repetition.REQUIRED) + .named("metadata")) + .addField( + Types.primitive(PrimitiveTypeName.BINARY, Type.Repetition.REQUIRED) + .named("value")) + .id(2) + .named("data")) + .named("table"); + } +} diff --git a/aws-bundle/LICENSE b/aws-bundle/LICENSE index e720d50b8d6a..d8484c933f9e 100644 --- a/aws-bundle/LICENSE +++ b/aws-bundle/LICENSE @@ -203,326 +203,636 @@ -------------------------------------------------------------------------------- -This binary artifact contains code from the following projects: +This product bundles Apache Commons Codec. --------------------------------------------------------------------------------- - -Group: commons-codec Name: commons-codec Version: 1.17.1 Project URL: https://commons.apache.org/proper/commons-codec/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: commons-logging Name: commons-logging Version: 1.2 -Project URL: http://commons.apache.org/proper/commons-logging/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-buffer Version: 4.1.124.Final -Project URL: https://netty.io/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec Version: 4.1.124.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Apache Commons Logging. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-codec-http Version: 4.1.124.Final -Project URL: https://netty.io/ +Project URL: http://commons.apache.org/proper/commons-logging/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-http2 Version: 4.1.124.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Parquet (bundled by AWS Analytics Accelerator S3). -Group: io.netty Name: netty-common Version: 4.1.124.Final -Project URL: https://netty.io/ +Copyright: 2014-2024 The Apache Software Foundation +Project URL: https://parquet.apache.org/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-handler Version: 4.1.124.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Thrift (bundled by Parquet). -Group: io.netty Name: netty-resolver Version: 4.1.124.Final -Project URL: https://netty.io/ +Copyright: 2006-2017 The Apache Software Foundation. +Project URL: https://thrift.apache.org/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport Version: 4.1.124.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Netty. -Group: io.netty Name: netty-transport-classes-epoll Version: 4.1.124.Final Project URL: https://netty.io/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-native-unix-common Version: 4.1.124.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Apache HttpComponents (core and client). --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents Name: httpclient Version: 4.5.13 -Project URL: http://hc.apache.org/httpcomponents-client -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://hc.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.apache.httpcomponents Name: httpcore Version: 4.4.16 -Project URL: http://hc.apache.org/httpcomponents-core-ga -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Reactive Streams. --------------------------------------------------------------------------------- - -Group: org.reactivestreams Name: reactive-streams Version: 1.0.4 Project URL: http://reactive-streams.org -License: CC0 - http://creativecommons.org/publicdomain/zero/1.0/ - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: annotations Version: 2.33.0 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +License: MIT-0 --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: apache-client Version: 2.33.0 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: arns Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: auth Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: aws-core Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: aws-json-protocol Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: aws-query-protocol Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: aws-xml-protocol Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: checksums Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +| Copyright 2014 Reactive Streams +| +| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: checksums-spi Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: crt-core Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles AWS SDK for Java. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: dynamodb Version: 2.33.0 Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: endpoints-spi Version: 2.33.0 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: glue Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-auth Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-auth-aws Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-auth-aws-crt Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-auth-aws-eventstream Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-auth-spi Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-client-spi Version: 2.33.0 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: iam Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: identity-spi Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles AWS SDK CRT for Java. -Group: software.amazon.awssdk Name: json-utils Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/awslabs/aws-crt-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: kms Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles AWS EventStream for Java. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: lakeformation Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/awslabs/aws-eventstream-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: metrics-spi Version: 2.33.0 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles AWS AccessGrants Plugin for Java. -Group: software.amazon.awssdk Name: netty-nio-client Version: 2.33.0 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/aws/aws-s3-accessgrants-plugin-java-v2 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: profiles Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles AWS Analytics Accelerator S3. -Group: software.amazon.awssdk Name: protocol-core Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/awslabs/analytics-accelerator-s3 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: regions Version: 2.33.0 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Caffeine by Ben Manes. -Group: software.amazon.awssdk Name: s3 Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Copyright: 2014-2020 Ben Manes and contributors +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: sdk-core Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles failsafe (bundled by AWS Analytics Accelerator S3). --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: sso Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Copyright: Jonathan Halterman and friends +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: sts Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Jackson JSON Processor. -Group: software.amazon.awssdk Name: third-party-jackson-core Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Copyright: 2007-2020 Tatu Saloranta and other contributors +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: utils Version: 2.33.0 -Project URL: https://aws.amazon.com/sdkforjava -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles checkerframework checker-qual. + +Copyright: 2004-2020 the Checker Framework developers +Project URL: https://github.com/typetools/checker-framework +License: MIT + +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Google Error Prone Annotations. + +Copyright: Copyright 2011-2019 The Error Prone Authors +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk.crt Name: aws-crt Version: 0.38.9 -Project URL: https://github.com/awslabs/aws-crt-java -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles JCTools (via Netty). -Group: software.amazon.eventstream Name: eventstream Version: 1.0.1 -Project URL: https://github.com/awslabs/aws-eventstream-java -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/JCTools/JCTools +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.s3.accessgrants Name: aws-s3-accessgrants-java-plugin Version: 2.3.0 -Project URL: https://github.com/aws/aws-s3-accessgrants-plugin-java-v2 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- -Group: software.amazon.s3.analyticsaccelerator Name: analyticsaccelerator-s3 Version: 1.3.0 -Project URL: https://github.com/awslabs/analytics-accelerator-s3 -License: Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles the Mozilla Public Suffix List (via Apache HttpComponents). + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + +This product bundles FastDoubleParser (via Jackson JSON Processor, via AWS SDK third-party-jackson-core). + +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles fast_float (bundled by FastDoubleParser). + +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles bigint (bundled by FastDoubleParser). + +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/aws-bundle/NOTICE b/aws-bundle/NOTICE index 20aa59905990..39738b74a297 100644 --- a/aws-bundle/NOTICE +++ b/aws-bundle/NOTICE @@ -1,106 +1,74 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -NOTICE for Group: software.amazon.awssdk Name: annotations Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: apache-client Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: arns Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: auth Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: aws-core Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: aws-json-protocol Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: aws-query-protocol Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: aws-xml-protocol Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: checksums Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: checksums-spi Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: crt-core Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: dynamodb Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: endpoints-spi Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: glue Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: http-auth Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: http-auth-aws Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: http-auth-aws-crt Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: http-auth-aws-eventstream Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: http-auth-spi Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: http-client-spi Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: iam Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: identity-spi Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: json-utils Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: kms Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: lakeformation Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: metrics-spi Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: netty-nio-client Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: profiles Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: protocol-core Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: regions Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: retries Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: retries-spi Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: s3 Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: sdk-core Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: sso Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: sts Version: 2.33.0 -NOTICE for Group: software.amazon.awssdk Name: utils Version: 2.33.0 -NOTICE for Group: software.amazon.s3.accessgrants Name: aws-s3-accessgrants-java-plugin Version: 2.3.0 -NOTICE for Group: software.amazon.s3.analyticsaccelerator Name: analyticsaccelerator-s3 Version: 1.3.0 - -AWS SDK for Java 2.0 -Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - -This product includes software developed by -Amazon Technologies, Inc (http://www.amazon.com/). - -********************** -THIRD PARTY COMPONENTS -********************** -This software includes third party software subject to the following copyrights: -- XML parsing and utility functions from JetS3t - Copyright 2006-2009 James Murty. -- PKCS#1 PEM encoded private key parsing and utility functions from oauth.googlecode.com - Copyright 1998-2010 AOL Inc. -- Apache Commons Lang - https://github.com/apache/commons-lang -- Netty Reactive Streams - https://github.com/playframework/netty-reactive-streams -- Jackson-core - https://github.com/FasterXML/jackson-core -- Jackson-dataformat-cbor - https://github.com/FasterXML/jackson-dataformats-binary - -The licenses for these third party components are included in LICENSE.txt +This product bundles AWS SDK for Java with the following in its NOTICE file: +| +| AWS SDK for Java 2.0 +| Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +| +| This product includes software developed by +| Amazon Technologies, Inc (). +| +| ********************** +| THIRD PARTY COMPONENTS +| ********************** +| This software includes third party software subject to the following copyrights: +| +| - XML parsing and utility functions from JetS3t - Copyright 2006-2009 James Murty. +| - PKCS#1 PEM encoded private key parsing and utility functions from oauth.googlecode.com - Copyright 1998-2010 AOL Inc. +| - Apache Commons Lang - +| - Netty Reactive Streams - +| - Jackson-core - +| - Jackson-dataformat-cbor - +| +| The licenses for these third party components are included in LICENSE.txt +| -------------------------------------------------------------------------------- -NOTICE for Group: software.amazon.awssdk Name: third-party-jackson-core Version: 2.33.0 - -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers. - -## Licensing - -Jackson 2.x core and extension components are licensed under Apache License 2.0 -To find the details that apply to this artifact see the accompanying LICENSE file. - -## Credits - -A list of contributors may be found from CREDITS(-2.x) file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. -------------------------------------------------------------------------------- -NOTICE for Group: io.netty Name: netty-buffer Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-codec Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-codec-http Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-codec-http2 Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-common Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-handler Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-resolver Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-transport Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-transport-classes-epoll Version: 4.1.124.Final -NOTICE for Group: io.netty Name: netty-transport-native-unix-common Version: 4.1.124.Final - +This product bundles Netty with the following in its NOTICE file: | The Netty Project | ================= | @@ -364,3 +332,8 @@ NOTICE for Group: io.netty Name: netty-transport-native-unix-common Version: | * license/LICENSE.brotli4j.txt (Apache License 2.0) | * HOMEPAGE: | * https://github.com/hyperxpro/Brotli4j + +-------------------------------------------------------------------------------- + +This product bundles AWS Analytics Accelerator S3 with the following in its NOTICE file: +| Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/aws-bundle/build.gradle b/aws-bundle/build.gradle index 5b9054812a50..541d5ae7a541 100644 --- a/aws-bundle/build.gradle +++ b/aws-bundle/build.gradle @@ -23,6 +23,14 @@ project(":iceberg-aws-bundle") { tasks.jar.dependsOn tasks.shadowJar + configurations { + implementation { + exclude group: 'org.slf4j' + exclude group: 'org.apache.logging.slf4j' + exclude group: 'org.apache.logging.log4j' + } + } + dependencies { implementation platform(libs.awssdk.bom) implementation libs.awssdk.s3accessgrants @@ -52,12 +60,6 @@ project(":iceberg-aws-bundle") { include 'NOTICE' } - dependencies { - exclude(dependency('org.slf4j:.*')) - exclude(dependency('org.apache.logging.log4j:.*')) - exclude(dependency('org.apache.logging.slf4j:.*')) - } - // relocate AWS-specific versions relocate 'org.apache.http', 'org.apache.iceberg.aws.shaded.org.apache.http' relocate 'io.netty', 'org.apache.iceberg.aws.shaded.io.netty' @@ -66,4 +68,6 @@ project(":iceberg-aws-bundle") { jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/aws-bundle/runtime-deps.txt b/aws-bundle/runtime-deps.txt new file mode 100644 index 000000000000..b0c8cb946054 --- /dev/null +++ b/aws-bundle/runtime-deps.txt @@ -0,0 +1,70 @@ +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.google.errorprone:error_prone_annotations:2.10.0 +commons-codec:commons-codec:1.17.1 +commons-logging:commons-logging:1.2 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-codec-base:4.2.13.Final +io.netty:netty-codec-compression:4.2.13.Final +io.netty:netty-codec-http2:4.2.13.Final +io.netty:netty-codec-http:4.2.13.Final +io.netty:netty-codec-marshalling:4.2.13.Final +io.netty:netty-codec-protobuf:4.2.13.Final +io.netty:netty-codec:4.2.13.Final +io.netty:netty-common:4.2.13.Final +io.netty:netty-handler:4.2.13.Final +io.netty:netty-resolver:4.2.13.Final +io.netty:netty-transport-classes-epoll:4.2.13.Final +io.netty:netty-transport-native-unix-common:4.2.13.Final +io.netty:netty-transport:4.2.13.Final +org.apache.httpcomponents:httpclient:4.5.13 +org.apache.httpcomponents:httpcore:4.4.16 +org.checkerframework:checker-qual:3.19.0 +org.reactivestreams:reactive-streams:1.0.4 +software.amazon.awssdk.crt:aws-crt:0.45.1 +software.amazon.awssdk:annotations:2.44.4 +software.amazon.awssdk:apache-client:2.44.4 +software.amazon.awssdk:arns:2.44.4 +software.amazon.awssdk:auth:2.44.4 +software.amazon.awssdk:aws-core:2.44.4 +software.amazon.awssdk:aws-json-protocol:2.44.4 +software.amazon.awssdk:aws-query-protocol:2.44.4 +software.amazon.awssdk:aws-xml-protocol:2.44.4 +software.amazon.awssdk:checksums-spi:2.44.4 +software.amazon.awssdk:checksums:2.44.4 +software.amazon.awssdk:cloudwatch-metric-publisher:2.44.4 +software.amazon.awssdk:cloudwatch:2.44.4 +software.amazon.awssdk:crt-core:2.44.4 +software.amazon.awssdk:dynamodb:2.44.4 +software.amazon.awssdk:endpoints-spi:2.44.4 +software.amazon.awssdk:glue:2.44.4 +software.amazon.awssdk:http-auth-aws-crt:2.44.4 +software.amazon.awssdk:http-auth-aws-eventstream:2.44.4 +software.amazon.awssdk:http-auth-aws:2.44.4 +software.amazon.awssdk:http-auth-spi:2.44.4 +software.amazon.awssdk:http-auth:2.44.4 +software.amazon.awssdk:http-client-spi:2.44.4 +software.amazon.awssdk:iam:2.44.4 +software.amazon.awssdk:identity-spi:2.44.4 +software.amazon.awssdk:json-utils:2.44.4 +software.amazon.awssdk:kms:2.44.4 +software.amazon.awssdk:lakeformation:2.44.4 +software.amazon.awssdk:metrics-spi:2.44.4 +software.amazon.awssdk:netty-nio-client:2.44.4 +software.amazon.awssdk:profiles:2.44.4 +software.amazon.awssdk:protocol-core:2.44.4 +software.amazon.awssdk:regions:2.44.4 +software.amazon.awssdk:retries-spi:2.44.4 +software.amazon.awssdk:retries:2.44.4 +software.amazon.awssdk:s3:2.44.4 +software.amazon.awssdk:s3control:2.44.4 +software.amazon.awssdk:sdk-core:2.44.4 +software.amazon.awssdk:smithy-rpcv2-protocol:2.44.4 +software.amazon.awssdk:sso:2.44.4 +software.amazon.awssdk:sts:2.44.4 +software.amazon.awssdk:third-party-jackson-core:2.44.4 +software.amazon.awssdk:third-party-jackson-dataformat-cbor:2.44.4 +software.amazon.awssdk:utils-lite:2.44.4 +software.amazon.awssdk:utils:2.44.4 +software.amazon.eventstream:eventstream:1.0.1 +software.amazon.s3.accessgrants:aws-s3-accessgrants-java-plugin:2.4.1 +software.amazon.s3.analyticsaccelerator:analyticsaccelerator-s3:1.3.1 diff --git a/aws/src/integration/java/org/apache/iceberg/aws/TestKeyManagementClient.java b/aws/src/integration/java/org/apache/iceberg/aws/TestKeyManagementClient.java index 83bacf2601cd..ef84b23f8f27 100644 --- a/aws/src/integration/java/org/apache/iceberg/aws/TestKeyManagementClient.java +++ b/aws/src/integration/java/org/apache/iceberg/aws/TestKeyManagementClient.java @@ -22,6 +22,7 @@ import java.nio.ByteBuffer; import java.util.Map; +import org.apache.iceberg.TestHelpers; import org.apache.iceberg.encryption.KeyManagementClient; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.AfterAll; @@ -31,6 +32,7 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.NullSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +40,7 @@ import software.amazon.awssdk.services.kms.model.CreateKeyRequest; import software.amazon.awssdk.services.kms.model.CreateKeyResponse; import software.amazon.awssdk.services.kms.model.DataKeySpec; +import software.amazon.awssdk.services.kms.model.EncryptionAlgorithmSpec; import software.amazon.awssdk.services.kms.model.KeySpec; import software.amazon.awssdk.services.kms.model.ScheduleKeyDeletionRequest; import software.amazon.awssdk.services.kms.model.ScheduleKeyDeletionResponse; @@ -91,13 +94,42 @@ public void testKeyWrapping() { try (AwsKeyManagementClient keyManagementClient = new AwsKeyManagementClient()) { keyManagementClient.initialize(ImmutableMap.of()); - ByteBuffer key = ByteBuffer.wrap(new String("super-secret-table-master-key").getBytes()); + ByteBuffer key = ByteBuffer.wrap("super-secret-table-master-key".getBytes()); ByteBuffer encryptedKey = keyManagementClient.wrapKey(key, keyId); assertThat(keyManagementClient.unwrapKey(encryptedKey, keyId)).isEqualTo(key); } } + @ParameterizedTest + @MethodSource("org.apache.iceberg.TestHelpers#serializers") + public void testSerialization( + TestHelpers.RoundTripSerializer roundTripSerializer) + throws Exception { + try (AwsKeyManagementClient keyManagementClient = new AwsKeyManagementClient()) { + keyManagementClient.initialize( + ImmutableMap.of( + AwsProperties.KMS_ENCRYPTION_ALGORITHM_SPEC, + EncryptionAlgorithmSpec.RSAES_OAEP_SHA_256.toString(), + AwsProperties.KMS_DATA_KEY_SPEC, + DataKeySpec.AES_128.toString())); + assertThat(keyManagementClient.encryptionAlgorithmSpec()) + .isEqualTo(EncryptionAlgorithmSpec.RSAES_OAEP_SHA_256); + assertThat(keyManagementClient.dataKeySpec()).isEqualTo(DataKeySpec.AES_128); + + AwsKeyManagementClient result = roundTripSerializer.apply(keyManagementClient); + + ByteBuffer key = ByteBuffer.wrap("super-secret-table-master-key".getBytes()); + ByteBuffer encryptedKey = result.wrapKey(key, keyId); + + assertThat(keyManagementClient.unwrapKey(encryptedKey, keyId)).isEqualTo(key); + assertThat(result.unwrapKey(encryptedKey, keyId)).isEqualTo(key); + assertThat(result.encryptionAlgorithmSpec()) + .isEqualTo(EncryptionAlgorithmSpec.RSAES_OAEP_SHA_256); + assertThat(result.dataKeySpec()).isEqualTo(DataKeySpec.AES_128); + } + } + @ParameterizedTest @NullSource @EnumSource( diff --git a/aws/src/integration/java/org/apache/iceberg/aws/glue/GlueTestBase.java b/aws/src/integration/java/org/apache/iceberg/aws/glue/GlueTestBase.java index 65e37eba4cd3..b02537bf40b2 100644 --- a/aws/src/integration/java/org/apache/iceberg/aws/glue/GlueTestBase.java +++ b/aws/src/integration/java/org/apache/iceberg/aws/glue/GlueTestBase.java @@ -65,6 +65,7 @@ public class GlueTestBase { // iceberg static GlueCatalog glueCatalog; static GlueCatalog glueCatalogWithSkipNameValidation; + static GlueCatalog glueCatalogWithUniqueLocation; static Schema schema = new Schema(Types.NestedField.required(1, "c1", Types.StringType.get(), "c1")); @@ -105,6 +106,16 @@ public static void beforeClass() { GLUE, null, ImmutableMap.of()); + + glueCatalogWithUniqueLocation = new GlueCatalog(); + glueCatalogWithUniqueLocation.initialize( + CATALOG_NAME, + TEST_BUCKET_PATH, + awsProperties, + s3FileIOProperties, + GLUE, + null, + true /* uniqTableLocation */); } @AfterAll diff --git a/aws/src/integration/java/org/apache/iceberg/aws/glue/TestGlueCatalogTable.java b/aws/src/integration/java/org/apache/iceberg/aws/glue/TestGlueCatalogTable.java index 2c9459c5e36c..cb015b79fb9b 100644 --- a/aws/src/integration/java/org/apache/iceberg/aws/glue/TestGlueCatalogTable.java +++ b/aws/src/integration/java/org/apache/iceberg/aws/glue/TestGlueCatalogTable.java @@ -310,6 +310,22 @@ public void testRenameTable() { assertThat(renamedTable.currentSnapshot()).isEqualTo(table.currentSnapshot()); } + @Test + public void testCreateTableInUniqueLocation() { + String namespace = createNamespace(); + String tableName = createTable(namespace); + String newTableName = tableName + "_renamed"; + + glueCatalogWithUniqueLocation.renameTable( + TableIdentifier.of(namespace, tableName), TableIdentifier.of(namespace, newTableName)); + Table renamedTable = + glueCatalogWithUniqueLocation.loadTable(TableIdentifier.of(namespace, newTableName)); + createTable(namespace, tableName); + Table table = glueCatalogWithUniqueLocation.loadTable(TableIdentifier.of(namespace, tableName)); + + assertThat(renamedTable.location()).isNotEqualTo(table.location()); + } + @Test public void testRenameTableFailsToCreateNewTable() { String namespace = createNamespace(); @@ -743,7 +759,8 @@ public void testTableLevelS3Tags() { new AwsProperties(properties), new S3FileIOProperties(properties), GLUE, - null); + null, + false /* uniqTableLocation */); String namespace = createNamespace(); String tableName = getRandomName(); createTable(namespace, tableName); diff --git a/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3FileIO.java b/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3FileIO.java index ebee07e53e4c..e2fe6db0a494 100644 --- a/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3FileIO.java +++ b/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3FileIO.java @@ -986,4 +986,60 @@ private void createBucket(String bucketName) { // do nothing } } + + @Test + public void setCredentialsRefreshesClients() { + StorageCredential initialCredential = + StorageCredential.create( + "s3://custom-uri", + ImmutableMap.of( + "s3.access-key-id", + "initialKeyId", + "s3.secret-access-key", + "initialSecretKey", + "s3.session-token", + "initialSessionToken")); + + S3FileIO fileIO = new S3FileIO(); + fileIO.setCredentials(ImmutableList.of(initialCredential)); + fileIO.initialize(ImmutableMap.of(AwsClientProperties.CLIENT_REGION, "us-east-1")); + + S3Client initialClient = fileIO.client("s3://custom-uri/table1"); + assertThat(initialClient.serviceClientConfiguration()) + .extracting(AwsServiceClientConfiguration::credentialsProvider) + .extracting(IdentityProvider::resolveIdentity) + .satisfies( + future -> { + AwsSessionCredentialsIdentity identity = (AwsSessionCredentialsIdentity) future.get(); + assertThat(identity.accessKeyId()).isEqualTo("initialKeyId"); + assertThat(identity.secretAccessKey()).isEqualTo("initialSecretKey"); + assertThat(identity.sessionToken()).isEqualTo("initialSessionToken"); + }); + + StorageCredential refreshedCredential = + StorageCredential.create( + "s3://custom-uri", + ImmutableMap.of( + "s3.access-key-id", + "refreshedKeyId", + "s3.secret-access-key", + "refreshedSecretKey", + "s3.session-token", + "refreshedSessionToken")); + + fileIO.setCredentials(ImmutableList.of(refreshedCredential)); + + S3Client refreshedClient = fileIO.client("s3://custom-uri/table1"); + assertThat(refreshedClient).isNotSameAs(initialClient); + assertThat(refreshedClient.serviceClientConfiguration()) + .extracting(AwsServiceClientConfiguration::credentialsProvider) + .extracting(IdentityProvider::resolveIdentity) + .satisfies( + x -> { + AwsSessionCredentialsIdentity identity = (AwsSessionCredentialsIdentity) x.get(); + assertThat(identity.accessKeyId()).isEqualTo("refreshedKeyId"); + assertThat(identity.secretAccessKey()).isEqualTo("refreshedSecretKey"); + assertThat(identity.sessionToken()).isEqualTo("refreshedSessionToken"); + }); + } } diff --git a/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3MultipartUpload.java b/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3MultipartUpload.java index cbe3051a6711..746015098a40 100644 --- a/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3MultipartUpload.java +++ b/aws/src/integration/java/org/apache/iceberg/aws/s3/TestS3MultipartUpload.java @@ -21,12 +21,14 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.IOException; +import java.util.Arrays; import java.util.Random; import java.util.UUID; import java.util.function.Supplier; import java.util.stream.IntStream; import org.apache.iceberg.aws.AwsClientFactories; import org.apache.iceberg.aws.AwsIntegTestUtil; +import org.apache.iceberg.io.IOUtil; import org.apache.iceberg.io.PositionOutputStream; import org.apache.iceberg.io.SeekableInputStream; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; @@ -36,6 +38,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import software.amazon.awssdk.services.s3.S3Client; /** Long-running tests to ensure multipart upload logic is resilient */ @@ -141,6 +145,35 @@ public void testParallelUpload() throws IOException { } } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testMultipartUploadWithChunkedEncoding(boolean chunkedEncodingEnabled) + throws IOException { + // Create a new S3FileIO with specified chunked encoding setting + try (S3FileIO testIo = new S3FileIO(() -> s3)) { + testIo.initialize( + ImmutableMap.of( + S3FileIOProperties.MULTIPART_SIZE, + Integer.toString(S3FileIOProperties.MULTIPART_SIZE_MIN), + S3FileIOProperties.CHECKSUM_ENABLED, + "true", + S3FileIOProperties.CHUNKED_ENCODING_ENABLED, + Boolean.toString(chunkedEncodingEnabled))); + + int parts = 10; + long partSize = S3FileIOProperties.MULTIPART_SIZE_MIN; + String suffix = chunkedEncodingEnabled ? "-chunked-enabled" : "-chunked-disabled"; + + String intObjectUri = objectUri + suffix + "-int"; + writeDistinctPartsWithInts(testIo, intObjectUri, parts, partSize); + verifyDistinctPartsWithInts(testIo, intObjectUri, parts, partSize); + + String bytesObjectUri = objectUri + suffix + "-bytes"; + writeDistinctPartsWithBytes(testIo, bytesObjectUri, parts, partSize); + verifyDistinctPartsWithBytes(testIo, bytesObjectUri, parts, partSize); + } + } + private void writeInts(String fileUri, int parts, Supplier writer) { writeInts(fileUri, parts, S3FileIOProperties.MULTIPART_SIZE_MIN, writer); } @@ -177,4 +210,61 @@ private void writeBytes(String fileUri, int parts, Supplier writer) { throw new RuntimeException(e); } } + + private void writeDistinctPartsWithInts(S3FileIO fileIO, String fileUri, int parts, long partSize) + throws IOException { + try (PositionOutputStream outputStream = fileIO.newOutputFile(fileUri).create()) { + for (int part = 0; part < parts; part++) { + int partByte = part + 1; + for (long j = 0; j < partSize; j++) { + outputStream.write(partByte); + } + } + } + + assertThat(fileIO.newInputFile(fileUri).getLength()).isEqualTo(parts * partSize); + } + + private void verifyDistinctPartsWithInts( + S3FileIO fileIO, String fileUri, int parts, long partSize) throws IOException { + try (SeekableInputStream inputStream = fileIO.newInputFile(fileUri).newStream()) { + byte[] readBuffer = new byte[(int) partSize]; + for (int part = 0; part < parts; part++) { + byte expectedByte = (byte) (part + 1); + IOUtil.readFully(inputStream, readBuffer, 0, (int) partSize); + for (int i = 0; i < (int) partSize; i++) { + assertThat(readBuffer[i]).as("part %d, offset %d", part, i).isEqualTo(expectedByte); + } + } + assertThat(inputStream.read()).as("expected end of stream").isEqualTo(-1); + } + } + + private void writeDistinctPartsWithBytes( + S3FileIO fileIO, String fileUri, int parts, long partSize) throws IOException { + try (PositionOutputStream outputStream = fileIO.newOutputFile(fileUri).create()) { + for (int part = 0; part < parts; part++) { + byte[] partBytes = new byte[(int) partSize]; + Arrays.fill(partBytes, (byte) (part + 1)); + outputStream.write(partBytes); + } + } + + assertThat(fileIO.newInputFile(fileUri).getLength()).isEqualTo(parts * partSize); + } + + private void verifyDistinctPartsWithBytes( + S3FileIO fileIO, String fileUri, int parts, long partSize) throws IOException { + try (SeekableInputStream inputStream = fileIO.newInputFile(fileUri).newStream()) { + byte[] readBuffer = new byte[(int) partSize]; + for (int part = 0; part < parts; part++) { + byte expectedByte = (byte) (part + 1); + IOUtil.readFully(inputStream, readBuffer, 0, (int) partSize); + for (int i = 0; i < (int) partSize; i++) { + assertThat(readBuffer[i]).as("part %d, offset %d", part, i).isEqualTo(expectedByte); + } + } + assertThat(inputStream.read()).as("expected end of stream").isEqualTo(-1); + } + } } diff --git a/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/S3SignerServlet.java b/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/S3SignerServlet.java index 038d76b03e4b..5d334eafa582 100644 --- a/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/S3SignerServlet.java +++ b/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/S3SignerServlet.java @@ -18,17 +18,6 @@ */ package org.apache.iceberg.aws.s3.signer; -import static java.lang.String.format; -import static org.apache.iceberg.rest.RESTCatalogAdapter.castRequest; -import static org.apache.iceberg.rest.RESTCatalogAdapter.castResponse; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; - -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.InputStreamReader; -import java.io.Reader; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; @@ -37,23 +26,15 @@ import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.function.Predicate; import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.HttpHeaders; -import org.apache.iceberg.exceptions.RESTException; -import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.relocated.com.google.common.io.CharStreams; -import org.apache.iceberg.rest.RESTUtil; -import org.apache.iceberg.rest.ResourcePaths; -import org.apache.iceberg.rest.responses.ErrorResponse; -import org.apache.iceberg.rest.responses.OAuthTokenResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.apache.iceberg.rest.HttpMethod; +import org.apache.iceberg.rest.RemoteSignerServlet; +import org.apache.iceberg.rest.requests.RemoteSignRequest; +import org.apache.iceberg.rest.responses.ImmutableRemoteSignResponse; +import org.apache.iceberg.rest.responses.RemoteSignResponse; import software.amazon.awssdk.auth.signer.AwsS3V4Signer; import software.amazon.awssdk.auth.signer.params.AwsS3V4SignerParams; import software.amazon.awssdk.http.SdkHttpFullRequest; @@ -65,113 +46,37 @@ * {@link S3SignerServlet} provides a simple servlet implementation to emulate the server-side * behavior of signing S3 requests and handling OAuth. */ -public class S3SignerServlet extends HttpServlet { - - private static final Logger LOG = LoggerFactory.getLogger(S3SignerServlet.class); +public class S3SignerServlet extends RemoteSignerServlet { static final Clock SIGNING_CLOCK = Clock.fixed(Instant.now(), ZoneId.of("UTC")); static final Set UNSIGNED_HEADERS = Sets.newHashSet( Arrays.asList("range", "x-amz-date", "amz-sdk-invocation-id", "amz-sdk-retry")); - private static final String POST = "POST"; - - private static final Set CACHEABLE_METHODS = - Stream.of(SdkHttpMethod.GET, SdkHttpMethod.HEAD).collect(Collectors.toSet()); - - private final Map responseHeaders = - ImmutableMap.of(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); - private final ObjectMapper mapper; - - private List s3SignRequestValidators = Lists.newArrayList(); - - /** - * SignRequestValidator is a wrapper class used for validating the contents of the S3SignRequest - * and thus verifying the behavior of the client during testing. - */ - public static class SignRequestValidator { - private final Predicate requestMatcher; - private final Predicate requestExpectation; - private final String assertMessage; - - public SignRequestValidator( - Predicate requestExpectation, - Predicate requestMatcher, - String assertMessage) { - this.requestExpectation = requestExpectation; - this.requestMatcher = requestMatcher; - this.assertMessage = assertMessage; - } - - void validateRequest(S3SignRequest request) { - if (requestMatcher.test(request)) { - assertThat(requestExpectation.test(request)).as(assertMessage).isTrue(); - } - } - } - - public S3SignerServlet(ObjectMapper mapper) { - this.mapper = mapper; - } - - public S3SignerServlet(ObjectMapper mapper, List s3SignRequestValidators) { - this.mapper = mapper; - this.s3SignRequestValidators = s3SignRequestValidators; - } - - @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) { - execute(request, response); - } - @Override - protected void doHead(HttpServletRequest request, HttpServletResponse response) { - execute(request, response); - } + /** A fake remote signing endpoint for testing purposes. */ + static final String S3_SIGNER_ENDPOINT = "v1/namespaces/ns1/tables/t1/sign"; - @Override - protected void doPost(HttpServletRequest request, HttpServletResponse response) { - execute(request, response); + public S3SignerServlet() { + super(S3_SIGNER_ENDPOINT); } @Override - protected void doDelete(HttpServletRequest request, HttpServletResponse response) { - execute(request, response); - } - - private OAuthTokenResponse handleOAuth(Map requestMap) { - String grantType = requestMap.get("grant_type"); - switch (grantType) { - case "client_credentials": - return castResponse( - OAuthTokenResponse.class, - OAuthTokenResponse.builder() - .withToken("client-credentials-token:sub=" + requestMap.get("client_id")) - .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token") - .withTokenType("Bearer") - .setExpirationInSeconds(10000) - .build()); - - case "urn:ietf:params:oauth:grant-type:token-exchange": - String actor = requestMap.get("actor_token"); - String token = - String.format( - "token-exchange-token:sub=%s%s", - requestMap.get("subject_token"), actor != null ? ",act=" + actor : ""); - return castResponse( - OAuthTokenResponse.class, - OAuthTokenResponse.builder() - .withToken(token) - .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token") - .withTokenType("Bearer") - .setExpirationInSeconds(10000) - .build()); - - default: - throw new UnsupportedOperationException("Unsupported grant_type: " + grantType); + protected void validateSignRequest(RemoteSignRequest request) { + Preconditions.checkArgument( + request.provider() == null || "s3".equalsIgnoreCase(request.provider()), + "Unsupported provider: %s", + request.provider()); + if (HttpMethod.POST.name().equalsIgnoreCase(request.method()) + && request.uri().getQuery().contains("delete")) { + String body = request.body(); + Preconditions.checkArgument( + body != null && !body.isEmpty(), + "Sign request for delete objects should have a request body"); } } - private S3SignResponse signRequest(S3SignRequest request) { + @Override + protected RemoteSignResponse signRequest(RemoteSignRequest request) { AwsS3V4SignerParams signingParams = AwsS3V4SignerParams.builder() .awsCredentials(TestS3RestSigner.CREDENTIALS_PROVIDER.resolveCredentials()) @@ -207,59 +112,6 @@ private S3SignResponse signRequest(S3SignRequest request) { Map> headers = Maps.newHashMap(sign.headers()); headers.putAll(unsignedHeaders); - return ImmutableS3SignResponse.builder().uri(request.uri()).headers(headers).build(); - } - - protected void execute(HttpServletRequest request, HttpServletResponse response) { - response.setStatus(HttpServletResponse.SC_OK); - responseHeaders.forEach(response::setHeader); - - String path = request.getRequestURI().substring(1); - Object requestBody; - try { - // we only need to handle oauth tokens & s3 sign request routes here as those are the only - // requests that are being done by the S3V4RestSignerClient - if (POST.equals(request.getMethod()) - && S3V4RestSignerClient.S3_SIGNER_DEFAULT_ENDPOINT.equals(path)) { - S3SignRequest s3SignRequest = - castRequest( - S3SignRequest.class, mapper.readValue(request.getReader(), S3SignRequest.class)); - s3SignRequestValidators.forEach(validator -> validator.validateRequest(s3SignRequest)); - S3SignResponse s3SignResponse = signRequest(s3SignRequest); - if (CACHEABLE_METHODS.contains(SdkHttpMethod.fromValue(s3SignRequest.method()))) { - // tell the client this can be cached - response.setHeader( - S3V4RestSignerClient.CACHE_CONTROL, S3V4RestSignerClient.CACHE_CONTROL_PRIVATE); - } else { - response.setHeader( - S3V4RestSignerClient.CACHE_CONTROL, S3V4RestSignerClient.CACHE_CONTROL_NO_CACHE); - } - - mapper.writeValue(response.getWriter(), s3SignResponse); - } else if (POST.equals(request.getMethod()) && ResourcePaths.tokens().equals(path)) { - try (Reader reader = new InputStreamReader(request.getInputStream())) { - requestBody = RESTUtil.decodeFormData(CharStreams.toString(reader)); - } - - OAuthTokenResponse oAuthTokenResponse = - handleOAuth((Map) castRequest(Map.class, requestBody)); - mapper.writeValue(response.getWriter(), oAuthTokenResponse); - } else { - response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - mapper.writeValue( - response.getWriter(), - ErrorResponse.builder() - .responseCode(400) - .withType("BadRequestException") - .withMessage(format("No route for request: %s %s", request.getMethod(), path)) - .build()); - } - } catch (RESTException e) { - LOG.error("Error processing REST request", e); - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - } catch (Exception e) { - LOG.error("Unexpected exception when processing REST request", e); - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - } + return ImmutableRemoteSignResponse.builder().uri(request.uri()).headers(headers).build(); } } diff --git a/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/TestS3RestSigner.java b/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/TestS3RestSigner.java index b51d97cc611a..4e5ed3d91870 100644 --- a/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/TestS3RestSigner.java +++ b/aws/src/integration/java/org/apache/iceberg/aws/s3/signer/TestS3RestSigner.java @@ -33,14 +33,15 @@ import java.util.stream.Collectors; import javax.annotation.Nonnull; import org.apache.iceberg.aws.s3.MinioUtil; -import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.auth.OAuth2Properties; import org.apache.iceberg.util.ThreadPools; +import org.eclipse.jetty.compression.gzip.GzipCompression; +import org.eclipse.jetty.compression.server.CompressionHandler; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.handler.gzip.GzipHandler; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -107,8 +108,10 @@ public static void beforeClass() throws Exception { ImmutableS3V4RestSignerClient.builder() .properties( ImmutableMap.of( - S3V4RestSignerClient.S3_SIGNER_URI, + RESTCatalogProperties.SIGNER_URI, httpServer.getURI().toString(), + RESTCatalogProperties.SIGNER_ENDPOINT, + S3SignerServlet.S3_SIGNER_ENDPOINT, OAuth2Properties.CREDENTIAL, "catalog:12345")) .build(), @@ -182,19 +185,13 @@ public void before() throws Exception { } private static Server initHttpServer() throws Exception { - S3SignerServlet.SignRequestValidator deleteObjectsWithBody = - new S3SignerServlet.SignRequestValidator( - (s3SignRequest) -> - "post".equalsIgnoreCase(s3SignRequest.method()) - && s3SignRequest.uri().getQuery().contains("delete"), - (s3SignRequest) -> s3SignRequest.body() != null && !s3SignRequest.body().isEmpty(), - "Sign request for delete objects should have a request body"); - S3SignerServlet servlet = - new S3SignerServlet(S3ObjectMapper.mapper(), ImmutableList.of(deleteObjectsWithBody)); + S3SignerServlet servlet = new S3SignerServlet(); ServletContextHandler servletContext = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); servletContext.addServlet(new ServletHolder(servlet), "/*"); - servletContext.setHandler(new GzipHandler()); + CompressionHandler compressionHandler = new CompressionHandler(); + compressionHandler.putCompression(new GzipCompression()); + servletContext.insertHandler(compressionHandler); Server server = new Server(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); server.setHandler(servletContext); diff --git a/aws/src/main/java/org/apache/iceberg/aws/ApacheHttpClientConfigurations.java b/aws/src/main/java/org/apache/iceberg/aws/ApacheHttpClientConfigurations.java index 3445928d1551..30065c8db510 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/ApacheHttpClientConfigurations.java +++ b/aws/src/main/java/org/apache/iceberg/aws/ApacheHttpClientConfigurations.java @@ -41,6 +41,8 @@ class ApacheHttpClientConfigurations extends BaseHttpClientConfigurations { private Boolean tcpKeepAliveEnabled; private Boolean useIdleConnectionReaperEnabled; private String proxyEndpoint; + private Boolean proxyUseSystemPropertyValues; + private Boolean proxyUseEnvironmentVariableValues; private ApacheHttpClientConfigurations() {} @@ -82,6 +84,12 @@ private void initialize(Map httpClientProperties) { this.proxyEndpoint = PropertyUtil.propertyAsString( httpClientProperties, HttpClientProperties.PROXY_ENDPOINT, null); + this.proxyUseSystemPropertyValues = + PropertyUtil.propertyAsNullableBoolean( + httpClientProperties, HttpClientProperties.PROXY_USE_SYSTEM_PROPERTY_VALUES); + this.proxyUseEnvironmentVariableValues = + PropertyUtil.propertyAsNullableBoolean( + httpClientProperties, HttpClientProperties.PROXY_USE_ENVIRONMENT_VARIABLE_VALUES); } @VisibleForTesting @@ -113,9 +121,26 @@ void configureApacheHttpClientBuilder(ApacheHttpClient.Builder apacheHttpClientB if (useIdleConnectionReaperEnabled != null) { apacheHttpClientBuilder.useIdleConnectionReaper(useIdleConnectionReaperEnabled); } - if (proxyEndpoint != null) { - apacheHttpClientBuilder.proxyConfiguration( - ProxyConfiguration.builder().endpoint(URI.create(proxyEndpoint)).build()); + configureProxy(apacheHttpClientBuilder); + } + + private void configureProxy(ApacheHttpClient.Builder apacheHttpClientBuilder) { + if (proxyEndpoint != null + || proxyUseSystemPropertyValues != null + || proxyUseEnvironmentVariableValues != null) { + ProxyConfiguration.Builder proxyBuilder = ProxyConfiguration.builder(); + + if (proxyEndpoint != null) { + proxyBuilder.endpoint(URI.create(proxyEndpoint)); + } + if (proxyUseSystemPropertyValues != null) { + proxyBuilder.useSystemPropertyValues(proxyUseSystemPropertyValues); + } + if (proxyUseEnvironmentVariableValues != null) { + proxyBuilder.useEnvironmentVariableValues(proxyUseEnvironmentVariableValues); + } + + apacheHttpClientBuilder.proxyConfiguration(proxyBuilder.build()); } } @@ -138,6 +163,8 @@ protected String generateHttpClientCacheKey() { keyComponents.put("tcpKeepAliveEnabled", tcpKeepAliveEnabled); keyComponents.put("useIdleConnectionReaperEnabled", useIdleConnectionReaperEnabled); keyComponents.put("proxyEndpoint", proxyEndpoint); + keyComponents.put("proxyUseSystemPropertyValues", proxyUseSystemPropertyValues); + keyComponents.put("proxyUseEnvironmentVariableValues", proxyUseEnvironmentVariableValues); return keyComponents.entrySet().stream() .map(entry -> entry.getKey() + "=" + Objects.toString(entry.getValue(), "null")) diff --git a/aws/src/main/java/org/apache/iceberg/aws/AssumeRoleAwsClientFactory.java b/aws/src/main/java/org/apache/iceberg/aws/AssumeRoleAwsClientFactory.java index 59a4d8d3ac38..dd955d579761 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/AssumeRoleAwsClientFactory.java +++ b/aws/src/main/java/org/apache/iceberg/aws/AssumeRoleAwsClientFactory.java @@ -81,6 +81,7 @@ public GlueClient glue() { return GlueClient.builder() .applyMutation(this::applyAssumeRoleConfigurations) .applyMutation(httpClientProperties::applyHttpClientConfigurations) + .applyMutation(awsClientProperties::applyRetryConfigurations) .build(); } @@ -99,6 +100,7 @@ public DynamoDbClient dynamo() { .applyMutation(this::applyAssumeRoleConfigurations) .applyMutation(httpClientProperties::applyHttpClientConfigurations) .applyMutation(awsProperties::applyDynamoDbEndpointConfigurations) + .applyMutation(awsClientProperties::applyRetryConfigurations) .build(); } diff --git a/aws/src/main/java/org/apache/iceberg/aws/AwsClientFactories.java b/aws/src/main/java/org/apache/iceberg/aws/AwsClientFactories.java index 0ee4bf26e68e..e6eb8d28d01a 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/AwsClientFactories.java +++ b/aws/src/main/java/org/apache/iceberg/aws/AwsClientFactories.java @@ -148,6 +148,7 @@ public GlueClient glue() { .applyMutation(httpClientProperties::applyHttpClientConfigurations) .applyMutation(awsProperties::applyGlueEndpointConfigurations) .applyMutation(awsClientProperties::applyClientCredentialConfigurations) + .applyMutation(awsClientProperties::applyRetryConfigurations) .build(); } @@ -169,6 +170,7 @@ public DynamoDbClient dynamo() { .applyMutation(httpClientProperties::applyHttpClientConfigurations) .applyMutation(awsClientProperties::applyClientCredentialConfigurations) .applyMutation(awsProperties::applyDynamoDbEndpointConfigurations) + .applyMutation(awsClientProperties::applyRetryConfigurations) .build(); } diff --git a/aws/src/main/java/org/apache/iceberg/aws/AwsClientProperties.java b/aws/src/main/java/org/apache/iceberg/aws/AwsClientProperties.java index cf73e80f44c1..c5850d0c79c1 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/AwsClientProperties.java +++ b/aws/src/main/java/org/apache/iceberg/aws/AwsClientProperties.java @@ -20,6 +20,7 @@ import java.io.Serializable; import java.util.Map; +import java.util.function.Predicate; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.aws.s3.VendedCredentialsProvider; import org.apache.iceberg.common.DynClasses; @@ -28,7 +29,6 @@ import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.rest.RESTUtil; import org.apache.iceberg.util.PropertyUtil; -import org.apache.iceberg.util.SerializableMap; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; @@ -97,7 +97,6 @@ public class AwsClientProperties implements Serializable { private final String refreshCredentialsEndpoint; private final boolean refreshCredentialsEnabled; private final boolean legacyMd5pluginEnabled; - private final Map allProperties; public AwsClientProperties() { this.clientRegion = null; @@ -106,15 +105,18 @@ public AwsClientProperties() { this.refreshCredentialsEndpoint = null; this.refreshCredentialsEnabled = true; this.legacyMd5pluginEnabled = false; - this.allProperties = null; } public AwsClientProperties(Map properties) { - this.allProperties = SerializableMap.copyOf(properties); this.clientRegion = properties.get(CLIENT_REGION); this.clientCredentialsProvider = properties.get(CLIENT_CREDENTIALS_PROVIDER); + // Retain all non-prefixed properties and override with prefixed properties this.clientCredentialsProviderProperties = - PropertyUtil.propertiesWithPrefix(properties, CLIENT_CREDENTIAL_PROVIDER_PREFIX); + PropertyUtil.mergeProperties( + PropertyUtil.filterProperties( + properties, + Predicate.not(property -> property.startsWith(CLIENT_CREDENTIAL_PROVIDER_PREFIX))), + PropertyUtil.propertiesWithPrefix(properties, CLIENT_CREDENTIAL_PROVIDER_PREFIX)); this.refreshCredentialsEndpoint = RESTUtil.resolveEndpoint( properties.get(CatalogProperties.URI), properties.get(REFRESH_CREDENTIALS_ENDPOINT)); @@ -210,8 +212,13 @@ public void applyClientCredentialConfigurati @SuppressWarnings("checkstyle:HiddenField") public AwsCredentialsProvider credentialsProvider( String accessKeyId, String secretAccessKey, String sessionToken) { + if (!Strings.isNullOrEmpty(this.clientCredentialsProvider)) { + clientCredentialsProviderProperties.put( + VendedCredentialsProvider.URI, refreshCredentialsEndpoint); + return credentialsProvider(this.clientCredentialsProvider); + } + if (refreshCredentialsEnabled && !Strings.isNullOrEmpty(refreshCredentialsEndpoint)) { - clientCredentialsProviderProperties.putAll(allProperties); clientCredentialsProviderProperties.put( VendedCredentialsProvider.URI, refreshCredentialsEndpoint); return credentialsProvider(VendedCredentialsProvider.class.getName()); @@ -227,10 +234,6 @@ public AwsCredentialsProvider credentialsProvider( } } - if (!Strings.isNullOrEmpty(this.clientCredentialsProvider)) { - return credentialsProvider(this.clientCredentialsProvider); - } - // Create a new credential provider for each client return DefaultCredentialsProvider.builder().build(); } diff --git a/aws/src/main/java/org/apache/iceberg/aws/AwsKeyManagementClient.java b/aws/src/main/java/org/apache/iceberg/aws/AwsKeyManagementClient.java index 6d2671f4e26d..3b1c13ebe36f 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/AwsKeyManagementClient.java +++ b/aws/src/main/java/org/apache/iceberg/aws/AwsKeyManagementClient.java @@ -20,7 +20,9 @@ import java.nio.ByteBuffer; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.iceberg.encryption.KeyManagementClient; +import org.apache.iceberg.util.SerializableMap; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.kms.KmsClient; import software.amazon.awssdk.services.kms.model.DataKeySpec; @@ -39,14 +41,17 @@ */ public class AwsKeyManagementClient implements KeyManagementClient { - private KmsClient kmsClient; + private final AtomicBoolean isResourceClosed = new AtomicBoolean(false); + + private Map allProperties; private EncryptionAlgorithmSpec encryptionAlgorithmSpec; private DataKeySpec dataKeySpec; + private transient volatile KmsClient kmsClient; + @Override public void initialize(Map properties) { - AwsClientFactory clientFactory = AwsClientFactories.from(properties); - this.kmsClient = clientFactory.kms(); + this.allProperties = SerializableMap.copyOf(properties); AwsProperties awsProperties = new AwsProperties(properties); this.encryptionAlgorithmSpec = awsProperties.kmsEncryptionAlgorithmSpec(); @@ -62,7 +67,7 @@ public ByteBuffer wrapKey(ByteBuffer key, String wrappingKeyId) { .plaintext(SdkBytes.fromByteBuffer(key)) .build(); - EncryptResponse result = kmsClient.encrypt(request); + EncryptResponse result = kmsClient().encrypt(request); return result.ciphertextBlob().asByteBuffer(); } @@ -76,11 +81,9 @@ public KeyGenerationResult generateKey(String wrappingKeyId) { GenerateDataKeyRequest request = GenerateDataKeyRequest.builder().keyId(wrappingKeyId).keySpec(dataKeySpec).build(); - GenerateDataKeyResponse response = kmsClient.generateDataKey(request); - KeyGenerationResult result = - new KeyGenerationResult( - response.plaintext().asByteBuffer(), response.ciphertextBlob().asByteBuffer()); - return result; + GenerateDataKeyResponse response = kmsClient().generateDataKey(request); + return new KeyGenerationResult( + response.plaintext().asByteBuffer(), response.ciphertextBlob().asByteBuffer()); } @Override @@ -92,14 +95,36 @@ public ByteBuffer unwrapKey(ByteBuffer wrappedKey, String wrappingKeyId) { .ciphertextBlob(SdkBytes.fromByteBuffer(wrappedKey)) .build(); - DecryptResponse result = kmsClient.decrypt(request); + DecryptResponse result = kmsClient().decrypt(request); return result.plaintext().asByteBuffer(); } @Override public void close() { - if (kmsClient != null) { - kmsClient.close(); + if (isResourceClosed.compareAndSet(false, true)) { + if (kmsClient != null) { + kmsClient.close(); + } + } + } + + EncryptionAlgorithmSpec encryptionAlgorithmSpec() { + return encryptionAlgorithmSpec; + } + + DataKeySpec dataKeySpec() { + return dataKeySpec; + } + + private KmsClient kmsClient() { + if (kmsClient == null) { + synchronized (this) { + if (kmsClient == null) { + AwsClientFactory clientFactory = AwsClientFactories.from(allProperties); + kmsClient = clientFactory.kms(); + } + } } + return kmsClient; } } diff --git a/aws/src/main/java/org/apache/iceberg/aws/HttpClientProperties.java b/aws/src/main/java/org/apache/iceberg/aws/HttpClientProperties.java index 438ae5bb0431..870d8e23651c 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/HttpClientProperties.java +++ b/aws/src/main/java/org/apache/iceberg/aws/HttpClientProperties.java @@ -61,6 +61,30 @@ public class HttpClientProperties implements Serializable { */ public static final String PROXY_ENDPOINT = "http-client.proxy-endpoint"; + /** + * Used to enable reading proxy configuration from Java system properties (http.proxyHost, + * http.proxyPort, http.nonProxyHosts, etc.). Default is true. + * + *

For more details, see + * https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/http/urlconnection/ProxyConfiguration.html + * and + * https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/http/apache/ProxyConfiguration.html + */ + public static final String PROXY_USE_SYSTEM_PROPERTY_VALUES = + "http-client.proxy-use-system-property-values"; + + /** + * Used to enable reading proxy configuration from environment variables (HTTP_PROXY, HTTPS_PROXY, + * NO_PROXY, etc.). Default is true. + * + *

For more details, see + * https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/http/urlconnection/ProxyConfiguration.html + * and + * https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/http/apache/ProxyConfiguration.html + */ + public static final String PROXY_USE_ENVIRONMENT_VARIABLE_VALUES = + "http-client.proxy-use-environment-variable-values"; + /** * Used to configure the connection timeout in milliseconds for {@link * software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient.Builder}. This flag only diff --git a/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthManager.java b/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthManager.java index e975c9588eaa..60a944b3d6bd 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthManager.java +++ b/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthManager.java @@ -36,7 +36,9 @@ */ public class RESTSigV4AuthManager implements AuthManager { + @SuppressWarnings("deprecation") private final Aws4Signer signer = Aws4Signer.create(); + private final AuthManager delegate; private Map catalogProperties = Map.of(); diff --git a/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthSession.java b/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthSession.java index caee74114fbb..48281841be37 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthSession.java +++ b/aws/src/main/java/org/apache/iceberg/aws/RESTSigV4AuthSession.java @@ -18,12 +18,15 @@ */ package org.apache.iceberg.aws; +import java.io.IOException; +import java.io.UncheckedIOException; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.apache.commons.io.IOUtils; +import org.apache.iceberg.io.CloseableGroup; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.rest.HTTPHeaders; import org.apache.iceberg.rest.HTTPHeaders.HTTPHeader; @@ -57,20 +60,30 @@ public class RESTSigV4AuthSession implements AuthSession { "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; static final String RELOCATED_HEADER_PREFIX = "Original-"; + @SuppressWarnings("deprecation") private final Aws4Signer signer; + private final AuthSession delegate; private final Region signingRegion; private final String signingName; private final AwsCredentialsProvider credentialsProvider; + private final CloseableGroup closeableGroup; + @SuppressWarnings("deprecation") public RESTSigV4AuthSession( Aws4Signer aws4Signer, AuthSession delegateAuthSession, AwsProperties awsProperties) { + this.closeableGroup = new CloseableGroup(); + this.closeableGroup.setSuppressCloseFailure(true); this.signer = Preconditions.checkNotNull(aws4Signer, "Invalid signer: null"); this.delegate = Preconditions.checkNotNull(delegateAuthSession, "Invalid delegate: null"); + this.closeableGroup.addCloseable(this.delegate); Preconditions.checkNotNull(awsProperties, "Invalid AWS properties: null"); this.signingRegion = awsProperties.restSigningRegion(); this.signingName = awsProperties.restSigningName(); this.credentialsProvider = awsProperties.restCredentialsProvider(); + if (credentialsProvider instanceof AutoCloseable closeableCredentialsProvider) { + this.closeableGroup.addCloseable(closeableCredentialsProvider); + } } public AuthSession delegate() { @@ -84,9 +97,14 @@ public HTTPRequest authenticate(HTTPRequest request) { @Override public void close() { - delegate.close(); + try { + closeableGroup.close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } + @SuppressWarnings("deprecation") private HTTPRequest sign(HTTPRequest request) { Aws4SignerParams params = Aws4SignerParams.builder() diff --git a/aws/src/main/java/org/apache/iceberg/aws/S3FileIOAwsClientFactories.java b/aws/src/main/java/org/apache/iceberg/aws/S3FileIOAwsClientFactories.java index 4aec0bda2a13..3306163baffd 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/S3FileIOAwsClientFactories.java +++ b/aws/src/main/java/org/apache/iceberg/aws/S3FileIOAwsClientFactories.java @@ -32,7 +32,7 @@ private S3FileIOAwsClientFactories() {} /** * Attempts to load an AWS client factory class for S3 file IO defined in the catalog property * {@link S3FileIOProperties#CLIENT_FACTORY}. If the property wasn't set, fallback to {@link - * AwsClientFactories#from(Map) to intialize an AWS client factory class} + * AwsClientFactories#from(Map) to initialize an AWS client factory class} * * @param properties catalog properties * @return an instance of a factory class diff --git a/aws/src/main/java/org/apache/iceberg/aws/UrlConnectionHttpClientConfigurations.java b/aws/src/main/java/org/apache/iceberg/aws/UrlConnectionHttpClientConfigurations.java index 273baa674804..fbd845852ca9 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/UrlConnectionHttpClientConfigurations.java +++ b/aws/src/main/java/org/apache/iceberg/aws/UrlConnectionHttpClientConfigurations.java @@ -35,6 +35,8 @@ class UrlConnectionHttpClientConfigurations extends BaseHttpClientConfigurations private Long httpClientUrlConnectionConnectionTimeoutMs; private Long httpClientUrlConnectionSocketTimeoutMs; private String proxyEndpoint; + private Boolean proxyUseSystemPropertyValues; + private Boolean proxyUseEnvironmentVariableValues; private UrlConnectionHttpClientConfigurations() {} @@ -56,6 +58,12 @@ private void initialize(Map httpClientProperties) { this.proxyEndpoint = PropertyUtil.propertyAsString( httpClientProperties, HttpClientProperties.PROXY_ENDPOINT, null); + this.proxyUseSystemPropertyValues = + PropertyUtil.propertyAsNullableBoolean( + httpClientProperties, HttpClientProperties.PROXY_USE_SYSTEM_PROPERTY_VALUES); + this.proxyUseEnvironmentVariableValues = + PropertyUtil.propertyAsNullableBoolean( + httpClientProperties, HttpClientProperties.PROXY_USE_ENVIRONMENT_VARIABLE_VALUES); } @VisibleForTesting @@ -69,9 +77,26 @@ void configureUrlConnectionHttpClientBuilder( urlConnectionHttpClientBuilder.socketTimeout( Duration.ofMillis(httpClientUrlConnectionSocketTimeoutMs)); } - if (proxyEndpoint != null) { - urlConnectionHttpClientBuilder.proxyConfiguration( - ProxyConfiguration.builder().endpoint(URI.create(proxyEndpoint)).build()); + configureProxy(urlConnectionHttpClientBuilder); + } + + private void configureProxy(UrlConnectionHttpClient.Builder urlConnectionHttpClientBuilder) { + if (proxyEndpoint != null + || proxyUseSystemPropertyValues != null + || proxyUseEnvironmentVariableValues != null) { + ProxyConfiguration.Builder proxyBuilder = ProxyConfiguration.builder(); + + if (proxyEndpoint != null) { + proxyBuilder.endpoint(URI.create(proxyEndpoint)); + } + if (proxyUseSystemPropertyValues != null) { + proxyBuilder.useSystemPropertyValues(proxyUseSystemPropertyValues); + } + if (proxyUseEnvironmentVariableValues != null) { + proxyBuilder.useEnvironmentVariablesValues(proxyUseEnvironmentVariableValues); + } + + urlConnectionHttpClientBuilder.proxyConfiguration(proxyBuilder.build()); } } @@ -87,6 +112,8 @@ protected String generateHttpClientCacheKey() { keyComponents.put("connectionTimeoutMs", httpClientUrlConnectionConnectionTimeoutMs); keyComponents.put("socketTimeoutMs", httpClientUrlConnectionSocketTimeoutMs); keyComponents.put("proxyEndpoint", proxyEndpoint); + keyComponents.put("proxyUseSystemPropertyValues", proxyUseSystemPropertyValues); + keyComponents.put("proxyUseEnvironmentVariableValues", proxyUseEnvironmentVariableValues); return keyComponents.entrySet().stream() .map(entry -> entry.getKey() + "=" + Objects.toString(entry.getValue(), "null")) diff --git a/aws/src/main/java/org/apache/iceberg/aws/dynamodb/DynamoDbCatalog.java b/aws/src/main/java/org/apache/iceberg/aws/dynamodb/DynamoDbCatalog.java index 0c991af75076..7c75f99d6d69 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/dynamodb/DynamoDbCatalog.java +++ b/aws/src/main/java/org/apache/iceberg/aws/dynamodb/DynamoDbCatalog.java @@ -53,6 +53,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.LocationUtil; +import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.util.Tasks; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -112,6 +113,7 @@ public class DynamoDbCatalog extends BaseMetastoreCatalog private FileIO fileIO; private CloseableGroup closeableGroup; private Map catalogProperties; + private boolean uniqueTableLocation; public DynamoDbCatalog() {} @@ -123,12 +125,21 @@ public void initialize(String name, Map properties) { properties.get(CatalogProperties.WAREHOUSE_LOCATION), new AwsProperties(properties), AwsClientFactories.from(properties).dynamo(), - initializeFileIO(properties)); + initializeFileIO(properties), + PropertyUtil.propertyAsBoolean( + properties, + CatalogProperties.UNIQUE_TABLE_LOCATION, + CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT)); } @VisibleForTesting void initialize( - String name, String path, AwsProperties properties, DynamoDbClient client, FileIO io) { + String name, + String path, + AwsProperties properties, + DynamoDbClient client, + FileIO io, + boolean uniqTableLocation) { Preconditions.checkArgument( !Strings.isNullOrEmpty(path), "Cannot initialize DynamoDbCatalog because warehousePath must not be null or empty"); @@ -138,6 +149,7 @@ void initialize( this.warehousePath = LocationUtil.stripTrailingSlash(path); this.dynamo = client; this.fileIO = io; + this.uniqueTableLocation = uniqTableLocation; this.closeableGroup = new CloseableGroup(); closeableGroup.addCloseable(dynamo); @@ -177,12 +189,12 @@ protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { } String defaultLocationCol = toPropertyCol(PROPERTY_DEFAULT_LOCATION); + String tableLocation = LocationUtil.tableLocation(tableIdentifier, uniqueTableLocation); if (response.item().containsKey(defaultLocationCol)) { - return String.format( - "%s/%s", response.item().get(defaultLocationCol).s(), tableIdentifier.name()); + return String.format("%s/%s", response.item().get(defaultLocationCol).s(), tableLocation); } else { return String.format( - "%s/%s.db/%s", warehousePath, tableIdentifier.namespace(), tableIdentifier.name()); + "%s/%s.db/%s", warehousePath, tableIdentifier.namespace(), tableLocation); } } diff --git a/aws/src/main/java/org/apache/iceberg/aws/glue/GlueCatalog.java b/aws/src/main/java/org/apache/iceberg/aws/glue/GlueCatalog.java index 47807a2b9f37..94e53cc1ab69 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/glue/GlueCatalog.java +++ b/aws/src/main/java/org/apache/iceberg/aws/glue/GlueCatalog.java @@ -89,6 +89,7 @@ public class GlueCatalog extends BaseMetastoreCatalog private Object hadoopConf; private String catalogName; private String warehousePath; + private boolean uniqueTableLocation; private AwsProperties awsProperties; private S3FileIOProperties s3FileIOProperties; private LockManager lockManager; @@ -144,7 +145,11 @@ public void initialize(String name, Map properties) { new AwsProperties(properties), new S3FileIOProperties(properties), awsClientFactory.glue(), - initializeLockManager(properties)); + initializeLockManager(properties), + PropertyUtil.propertyAsBoolean( + properties, + CatalogProperties.UNIQUE_TABLE_LOCATION, + CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT)); } private LockManager initializeLockManager(Map properties) { @@ -172,7 +177,17 @@ void initialize( LockManager lock, Map catalogProps) { this.catalogProperties = catalogProps; - initialize(name, path, properties, s3Properties, client, lock); + initialize( + name, + path, + properties, + s3Properties, + client, + lock, + PropertyUtil.propertyAsBoolean( + catalogProps, + CatalogProperties.UNIQUE_TABLE_LOCATION, + CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT)); } @VisibleForTesting @@ -182,13 +197,15 @@ void initialize( AwsProperties properties, S3FileIOProperties s3Properties, GlueClient client, - LockManager lock) { + LockManager lock, + boolean uniqTableLocation) { this.catalogName = name; this.awsProperties = properties; this.s3FileIOProperties = s3Properties; this.warehousePath = Strings.isNullOrEmpty(path) ? null : LocationUtil.stripTrailingSlash(path); this.glue = client; this.lockManager = lock; + this.uniqueTableLocation = uniqTableLocation; this.closeableGroup = new CloseableGroup(); this.fileIOTracker = new FileIOTracker(); @@ -278,9 +295,10 @@ protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { tableIdentifier, awsProperties.glueCatalogSkipNameValidation())) .build()); String dbLocationUri = response.database().locationUri(); + String tableLocation = LocationUtil.tableLocation(tableIdentifier, uniqueTableLocation); if (dbLocationUri != null) { dbLocationUri = LocationUtil.stripTrailingSlash(dbLocationUri); - return String.format("%s/%s", dbLocationUri, tableIdentifier.name()); + return String.format("%s/%s", dbLocationUri, tableLocation); } ValidationException.check( @@ -292,7 +310,7 @@ protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { warehousePath, IcebergToGlueConverter.getDatabaseName( tableIdentifier, awsProperties.glueCatalogSkipNameValidation()), - tableIdentifier.name()); + tableLocation); } @Override diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/AnalyticsAcceleratorUtil.java b/aws/src/main/java/org/apache/iceberg/aws/s3/AnalyticsAcceleratorUtil.java index ed1c3a4879e3..36a0a235da5b 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/AnalyticsAcceleratorUtil.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/AnalyticsAcceleratorUtil.java @@ -28,7 +28,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.services.s3.S3AsyncClient; -import software.amazon.awssdk.services.s3.model.HeadObjectResponse; import software.amazon.s3.analyticsaccelerator.ObjectClientConfiguration; import software.amazon.s3.analyticsaccelerator.S3SdkObjectClient; import software.amazon.s3.analyticsaccelerator.S3SeekableInputStream; @@ -36,8 +35,6 @@ import software.amazon.s3.analyticsaccelerator.S3SeekableInputStreamFactory; import software.amazon.s3.analyticsaccelerator.common.ConnectorConfiguration; import software.amazon.s3.analyticsaccelerator.request.ObjectClient; -import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata; -import software.amazon.s3.analyticsaccelerator.util.OpenStreamInformation; import software.amazon.s3.analyticsaccelerator.util.S3URI; class AnalyticsAcceleratorUtil { @@ -58,15 +55,6 @@ private AnalyticsAcceleratorUtil() {} public static SeekableInputStream newStream(S3InputFile inputFile) { S3URI uri = S3URI.of(inputFile.uri().bucket(), inputFile.uri().key()); - HeadObjectResponse metadata = inputFile.getObjectMetadata(); - OpenStreamInformation openStreamInfo = - OpenStreamInformation.builder() - .objectMetadata( - ObjectMetadata.builder() - .contentLength(metadata.contentLength()) - .etag(metadata.eTag()) - .build()) - .build(); S3SeekableInputStreamFactory factory = STREAM_FACTORY_CACHE.get( @@ -74,7 +62,7 @@ public static SeekableInputStream newStream(S3InputFile inputFile) { AnalyticsAcceleratorUtil::createNewFactory); try { - S3SeekableInputStream seekableInputStream = factory.createStream(uri, openStreamInfo); + S3SeekableInputStream seekableInputStream = factory.createStream(uri); return new AnalyticsAcceleratorInputStreamWrapper(seekableInputStream); } catch (IOException e) { throw new RuntimeIOException( diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIO.java b/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIO.java index d5e51ed74ad6..50e507b0cd8b 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIO.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIO.java @@ -18,16 +18,22 @@ */ package org.apache.iceberg.aws.s3; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.apache.iceberg.aws.S3FileIOAwsClientFactories; @@ -94,7 +100,7 @@ public class S3FileIO private static final String DEFAULT_METRICS_IMPL = "org.apache.iceberg.hadoop.HadoopMetricsContext"; private static final String ROOT_PREFIX = "s3"; - private static volatile ExecutorService executorService; + private static volatile ScheduledExecutorService executorService; private String credential = null; private SerializableSupplier s3; @@ -104,8 +110,9 @@ public class S3FileIO private final AtomicBoolean isResourceClosed = new AtomicBoolean(false); private transient StackTraceElement[] createStack; // use modifiable collection for Kryo serde - private List storageCredentials = Lists.newArrayList(); + private volatile List storageCredentials = Lists.newArrayList(); private transient volatile Map clientByPrefix; + private transient volatile ScheduledFuture refreshFuture; /** * No-arg constructor to load the FileIO dynamically. @@ -419,7 +426,11 @@ private Map clientByPrefix() { new PrefixedS3Client( storageCredential.prefix(), propertiesWithCredentials, s3, s3Async)); }); + this.clientByPrefix = localClientByPrefix; + // Note: the s3 clients separately refresh via the VendedCredentialsProvider but are + // not directly referencable from the FileIO + scheduleCredentialRefresh(); } } } @@ -427,14 +438,54 @@ private Map clientByPrefix() { return clientByPrefix; } - private ExecutorService executorService() { + private void scheduleCredentialRefresh() { + storageCredentials.stream() + .map( + storageCredential -> + storageCredential.config().get(S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS)) + .filter(Objects::nonNull) + .map(expiresAtString -> Instant.ofEpochMilli(Long.parseLong(expiresAtString))) + .min(Comparator.naturalOrder()) + .ifPresent( + expiresAt -> { + Instant prefetchAt = expiresAt.minus(5, ChronoUnit.MINUTES); + long delay = Duration.between(Instant.now(), prefetchAt).toMillis(); + this.refreshFuture = + executorService() + .schedule(this::refreshStorageCredentials, delay, TimeUnit.MILLISECONDS); + }); + } + + private void refreshStorageCredentials() { + if (isResourceClosed.get()) { + return; + } + + try (VendedCredentialsProvider provider = VendedCredentialsProvider.create(properties)) { + List refreshed = + provider.fetchCredentials().credentials().stream() + .filter(c -> c.prefix().startsWith(ROOT_PREFIX)) + .map(c -> StorageCredential.create(c.prefix(), c.config())) + .collect(Collectors.toList()); + + if (!refreshed.isEmpty() && !isResourceClosed.get()) { + this.storageCredentials = Lists.newArrayList(refreshed); + scheduleCredentialRefresh(); + } + } catch (Exception e) { + LOG.warn("Failed to refresh storage credentials", e); + } + } + + private ScheduledExecutorService executorService() { if (executorService == null) { synchronized (S3FileIO.class) { if (executorService == null) { executorService = - ThreadPools.newExitingWorkerPool( - "iceberg-s3fileio-delete", - clientForStoragePath(ROOT_PREFIX).s3FileIOProperties().deleteThreads()); + ThreadPools.newExitingScheduledPool( + "iceberg-s3fileio-tasks", + clientForStoragePath(ROOT_PREFIX).s3FileIOProperties().deleteThreads(), + Duration.ofSeconds(10)); } } } @@ -491,10 +542,14 @@ public void close() { clientByPrefix.values().forEach(PrefixedS3Client::close); this.clientByPrefix = null; } + if (refreshFuture != null) { + refreshFuture.cancel(true); + refreshFuture = null; + } } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); @@ -559,8 +614,21 @@ private boolean recoverObject(PrefixedS3Client client, ObjectVersion version, St @Override public void setCredentials(List credentials) { Preconditions.checkArgument(credentials != null, "Invalid storage credentials: null"); + // stop any refresh that might be scheduled + if (refreshFuture != null) { + refreshFuture.cancel(true); + } + // copy credentials into a modifiable collection for Kryo serde this.storageCredentials = Lists.newArrayList(credentials); + + // if the clients are already initialized, we need to close and allow them to be recreated + synchronized (this) { + if (clientByPrefix != null) { + clientByPrefix.values().forEach(PrefixedS3Client::close); + this.clientByPrefix = null; + } + } } @Override diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIOProperties.java b/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIOProperties.java index 6bf582f00bbb..922010d61d27 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIOProperties.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/S3FileIOProperties.java @@ -270,7 +270,7 @@ public class S3FileIOProperties implements Serializable { * This expiration time is currently only used in {@link VendedCredentialsProvider} for refreshing * vended credentials. */ - static final String SESSION_TOKEN_EXPIRES_AT_MS = "s3.session-token-expires-at-ms"; + public static final String SESSION_TOKEN_EXPIRES_AT_MS = "s3.session-token-expires-at-ms"; /** * Enable to make S3FileIO, to make cross-region call to the region specified in the ARN of an @@ -295,6 +295,18 @@ public class S3FileIOProperties implements Serializable { public static final boolean REMOTE_SIGNING_ENABLED_DEFAULT = false; + /** + * Enables or disables chunked encoding for S3 requests. + * + *

This feature is enabled by default to match the AWS SDK default behavior. + * + *

For more details see: + * https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/services/s3/S3Configuration.html#chunkedEncodingEnabled() + */ + public static final String CHUNKED_ENCODING_ENABLED = "s3.chunked-encoding-enabled"; + + public static final boolean CHUNKED_ENCODING_ENABLED_DEFAULT = true; + /** Configure the batch size used when deleting multiple files from a given S3 bucket */ public static final String DELETE_BATCH_SIZE = "s3.delete.batch-size"; @@ -509,6 +521,7 @@ public class S3FileIOProperties implements Serializable { private String stagingDirectory; private ObjectCannedACL acl; private boolean isChecksumEnabled; + private boolean isChunkedEncodingEnabled; private final Set writeTags; private boolean isWriteTableTagEnabled; private boolean isWriteNamespaceTagEnabled; @@ -551,6 +564,7 @@ public S3FileIOProperties() { this.deleteBatchSize = DELETE_BATCH_SIZE_DEFAULT; this.stagingDirectory = System.getProperty("java.io.tmpdir"); this.isChecksumEnabled = CHECKSUM_ENABLED_DEFAULT; + this.isChunkedEncodingEnabled = CHUNKED_ENCODING_ENABLED_DEFAULT; this.writeTags = Sets.newHashSet(); this.isWriteTableTagEnabled = WRITE_TABLE_TAG_ENABLED_DEFAULT; this.isWriteNamespaceTagEnabled = WRITE_NAMESPACE_TAG_ENABLED_DEFAULT; @@ -641,6 +655,9 @@ public S3FileIOProperties(Map properties) { "Cannot support S3 CannedACL " + aclType); this.isChecksumEnabled = PropertyUtil.propertyAsBoolean(properties, CHECKSUM_ENABLED, CHECKSUM_ENABLED_DEFAULT); + this.isChunkedEncodingEnabled = + PropertyUtil.propertyAsBoolean( + properties, CHUNKED_ENCODING_ENABLED, CHUNKED_ENCODING_ENABLED_DEFAULT); this.deleteBatchSize = PropertyUtil.propertyAsInt(properties, DELETE_BATCH_SIZE, DELETE_BATCH_SIZE_DEFAULT); Preconditions.checkArgument( @@ -808,6 +825,10 @@ public boolean isChecksumEnabled() { return this.isChecksumEnabled; } + public boolean isChunkedEncodingEnabled() { + return this.isChunkedEncodingEnabled; + } + public boolean isRemoteSigningEnabled() { return this.isRemoteSigningEnabled; } @@ -994,6 +1015,7 @@ public void applyServiceConfigurations(T builder) { .pathStyleAccessEnabled(isPathStyleAccess) .useArnRegionEnabled(isUseArnRegionEnabled) .accelerateModeEnabled(isAccelerationEnabled) + .chunkedEncodingEnabled(isChunkedEncodingEnabled) .build()); } @@ -1006,6 +1028,7 @@ public void applyServiceConfigurations(T builder) { * S3Client.builder().applyMutation(s3FileIOProperties::applyS3SignerConfiguration) * */ + @SuppressWarnings("deprecation") public void applySignerConfiguration(T builder) { if (isRemoteSigningEnabled) { ClientOverrideConfiguration.Builder configBuilder = @@ -1060,6 +1083,7 @@ public void applyEndpointConfigurations(T bu * S3Client.builder().applyMutation(s3FileIOProperties::applyRetryConfigurations) * */ + @SuppressWarnings("deprecation") public void applyRetryConfigurations(T builder) { ClientOverrideConfiguration.Builder configBuilder = null != builder.overrideConfiguration() diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/S3InputStream.java b/aws/src/main/java/org/apache/iceberg/aws/s3/S3InputStream.java index 4d37ac333030..9c91cc58f8d5 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/S3InputStream.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/S3InputStream.java @@ -290,7 +290,7 @@ public void setSkipSize(int skipSize) { this.skipSize = skipSize; } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/S3OutputStream.java b/aws/src/main/java/org/apache/iceberg/aws/s3/S3OutputStream.java index 684e6549a86b..50963127f52a 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/S3OutputStream.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/S3OutputStream.java @@ -480,7 +480,7 @@ private void createStagingDirectoryIfNotExists() throws IOException, SecurityExc } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/VendedCredentialsProvider.java b/aws/src/main/java/org/apache/iceberg/aws/s3/VendedCredentialsProvider.java index fc42bd789859..75d114d4efbe 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/VendedCredentialsProvider.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/VendedCredentialsProvider.java @@ -29,7 +29,9 @@ import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.rest.ErrorHandlers; import org.apache.iceberg.rest.HTTPClient; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.RESTClient; +import org.apache.iceberg.rest.RESTUtil; import org.apache.iceberg.rest.auth.AuthManager; import org.apache.iceberg.rest.auth.AuthManagers; import org.apache.iceberg.rest.auth.AuthSession; @@ -50,6 +52,7 @@ public class VendedCredentialsProvider implements AwsCredentialsProvider, SdkAut private final CachedSupplier credentialCache; private final String catalogEndpoint; private final String credentialsEndpoint; + private final String planId; private AuthManager authManager; private AuthSession authSession; @@ -65,6 +68,7 @@ private VendedCredentialsProvider(Map properties) { .build(); this.catalogEndpoint = properties.get(CatalogProperties.URI); this.credentialsEndpoint = properties.get(URI); + this.planId = properties.getOrDefault(RESTCatalogProperties.REST_SCAN_PLAN_ID, null); } @Override @@ -89,7 +93,11 @@ private RESTClient httpClient() { synchronized (this) { if (null == client) { authManager = AuthManagers.loadAuthManager("s3-credentials-refresh", properties); - HTTPClient httpClient = HTTPClient.builder(properties).uri(catalogEndpoint).build(); + HTTPClient httpClient = + HTTPClient.builder(properties) + .uri(catalogEndpoint) + .withHeaders(RESTUtil.configHeaders(properties)) + .build(); authSession = authManager.catalogSession(httpClient, properties); client = httpClient.withAuthSession(authSession); } @@ -99,11 +107,11 @@ private RESTClient httpClient() { return client; } - private LoadCredentialsResponse fetchCredentials() { + LoadCredentialsResponse fetchCredentials() { return httpClient() .get( credentialsEndpoint, - null, + null != planId ? Map.of("planId", planId) : null, LoadCredentialsResponse.class, Map.of(), ErrorHandlers.defaultErrorHandler()); diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3ObjectMapper.java b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3ObjectMapper.java index 89145b2465e5..7f1d6c3cc848 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3ObjectMapper.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3ObjectMapper.java @@ -40,6 +40,10 @@ import org.apache.iceberg.rest.responses.ErrorResponse; import org.apache.iceberg.rest.responses.OAuthTokenResponse; +/** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@code RESTObjectMapper} instead. + */ +@Deprecated public class S3ObjectMapper { private static final JsonFactory FACTORY = new JsonFactory(); diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequest.java b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequest.java index 879ce8599352..995f6e7e4860 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequest.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequest.java @@ -18,31 +18,13 @@ */ package org.apache.iceberg.aws.s3.signer; -import java.net.URI; -import java.util.List; -import java.util.Map; -import javax.annotation.Nullable; -import org.apache.iceberg.rest.RESTRequest; +import org.apache.iceberg.rest.requests.RemoteSignRequest; import org.immutables.value.Value; +/** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link RemoteSignRequest} instead. + */ +@Deprecated @Value.Immutable -public interface S3SignRequest extends RESTRequest { - String region(); - - String method(); - - URI uri(); - - Map> headers(); - - Map properties(); - - @Value.Default - @Nullable - default String body() { - return null; - } - - @Override - default void validate() {} -} +@SuppressWarnings("immutables:subtype") +public interface S3SignRequest extends RemoteSignRequest {} diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequestParser.java b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequestParser.java index efb11b3cdf55..5d2a7d684460 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequestParser.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignRequestParser.java @@ -21,108 +21,47 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; -import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Map.Entry; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.apache.iceberg.util.JsonUtil; +import org.apache.iceberg.rest.requests.RemoteSignRequest; +import org.apache.iceberg.rest.requests.RemoteSignRequestParser; +/** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link RemoteSignRequestParser} instead. + */ +@Deprecated public class S3SignRequestParser { - private static final String REGION = "region"; - private static final String METHOD = "method"; - private static final String URI = "uri"; - private static final String HEADERS = "headers"; - private static final String PROPERTIES = "properties"; - private static final String BODY = "body"; - private S3SignRequestParser() {} public static String toJson(S3SignRequest request) { - return toJson(request, false); + return RemoteSignRequestParser.toJson(request, false); } public static String toJson(S3SignRequest request, boolean pretty) { - return JsonUtil.generate(gen -> toJson(request, gen), pretty); + return RemoteSignRequestParser.toJson(request, pretty); } public static void toJson(S3SignRequest request, JsonGenerator gen) throws IOException { - Preconditions.checkArgument(null != request, "Invalid s3 sign request: null"); - - gen.writeStartObject(); - - gen.writeStringField(REGION, request.region()); - gen.writeStringField(METHOD, request.method()); - gen.writeStringField(URI, request.uri().toString()); - headersToJson(HEADERS, request.headers(), gen); - - if (!request.properties().isEmpty()) { - JsonUtil.writeStringMap(PROPERTIES, request.properties(), gen); - } - - if (request.body() != null && !request.body().isEmpty()) { - gen.writeStringField(BODY, request.body()); - } - - gen.writeEndObject(); + RemoteSignRequestParser.toJson(request, gen); } public static S3SignRequest fromJson(String json) { - return JsonUtil.parse(json, S3SignRequestParser::fromJson); + RemoteSignRequest request = RemoteSignRequestParser.fromJson(json); + return ImmutableS3SignRequest.builder().from(request).build(); } public static S3SignRequest fromJson(JsonNode json) { - Preconditions.checkArgument(null != json, "Cannot parse s3 sign request from null object"); - Preconditions.checkArgument( - json.isObject(), "Cannot parse s3 sign request from non-object: %s", json); - - String region = JsonUtil.getString(REGION, json); - String method = JsonUtil.getString(METHOD, json); - java.net.URI uri = java.net.URI.create(JsonUtil.getString(URI, json)); - Map> headers = headersFromJson(HEADERS, json); - - ImmutableS3SignRequest.Builder builder = - ImmutableS3SignRequest.builder().region(region).method(method).uri(uri).headers(headers); - - if (json.has(PROPERTIES)) { - builder.properties(JsonUtil.getStringMap(PROPERTIES, json)); - } - - if (json.has(BODY)) { - builder.body(JsonUtil.getString(BODY, json)); - } - - return builder.build(); + RemoteSignRequest request = RemoteSignRequestParser.fromJson(json); + return ImmutableS3SignRequest.builder().from(request).build(); } static void headersToJson(String property, Map> headers, JsonGenerator gen) throws IOException { - gen.writeObjectFieldStart(property); - for (Entry> entry : headers.entrySet()) { - gen.writeFieldName(entry.getKey()); - - gen.writeStartArray(); - for (String val : entry.getValue()) { - gen.writeString(val); - } - gen.writeEndArray(); - } - gen.writeEndObject(); + RemoteSignRequestParser.headersToJson(property, headers, gen); } static Map> headersFromJson(String property, JsonNode json) { - Map> headers = Maps.newHashMap(); - JsonNode headersNode = JsonUtil.get(property, json); - headersNode - .fields() - .forEachRemaining( - entry -> { - String key = entry.getKey(); - List values = Arrays.asList(JsonUtil.getStringArray(entry.getValue())); - headers.put(key, values); - }); - return headers; + return RemoteSignRequestParser.headersFromJson(property, json); } } diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponse.java b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponse.java index 40c2059488f8..6fbaa90fe7af 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponse.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponse.java @@ -18,18 +18,13 @@ */ package org.apache.iceberg.aws.s3.signer; -import java.net.URI; -import java.util.List; -import java.util.Map; -import org.apache.iceberg.rest.RESTResponse; +import org.apache.iceberg.rest.responses.RemoteSignResponse; import org.immutables.value.Value; +/** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link RemoteSignResponse} instead. + */ +@Deprecated @Value.Immutable -public interface S3SignResponse extends RESTResponse { - URI uri(); - - Map> headers(); - - @Override - default void validate() {} -} +@SuppressWarnings("immutables:subtype") +public interface S3SignResponse extends RemoteSignResponse {} diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponseParser.java b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponseParser.java index 69d6de8f04ac..be63a51b38fb 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponseParser.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3SignResponseParser.java @@ -21,49 +21,37 @@ import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; -import java.util.List; -import java.util.Map; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.util.JsonUtil; +import org.apache.iceberg.rest.responses.RemoteSignResponse; +import org.apache.iceberg.rest.responses.RemoteSignResponseParser; +/** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link RemoteSignResponseParser} + * instead. + */ +@Deprecated public class S3SignResponseParser { - private static final String URI = "uri"; - private static final String HEADERS = "headers"; - private S3SignResponseParser() {} - public static String toJson(S3SignResponse request) { - return toJson(request, false); + public static String toJson(S3SignResponse response) { + return RemoteSignResponseParser.toJson(response, false); } - public static String toJson(S3SignResponse request, boolean pretty) { - return JsonUtil.generate(gen -> toJson(request, gen), pretty); + public static String toJson(S3SignResponse response, boolean pretty) { + return RemoteSignResponseParser.toJson(response, pretty); } public static void toJson(S3SignResponse response, JsonGenerator gen) throws IOException { - Preconditions.checkArgument(null != response, "Invalid s3 sign response: null"); - - gen.writeStartObject(); - - gen.writeStringField(URI, response.uri().toString()); - S3SignRequestParser.headersToJson(HEADERS, response.headers(), gen); - - gen.writeEndObject(); + RemoteSignResponseParser.toJson(response, gen); } public static S3SignResponse fromJson(String json) { - return JsonUtil.parse(json, S3SignResponseParser::fromJson); + RemoteSignResponse result = RemoteSignResponseParser.fromJson(json); + return ImmutableS3SignResponse.builder().from(result).build(); } public static S3SignResponse fromJson(JsonNode json) { - Preconditions.checkArgument(null != json, "Cannot parse s3 sign response from null object"); - Preconditions.checkArgument( - json.isObject(), "Cannot parse s3 sign response from non-object: %s", json); - - java.net.URI uri = java.net.URI.create(JsonUtil.getString(URI, json)); - Map> headers = S3SignRequestParser.headersFromJson(HEADERS, json); - - return ImmutableS3SignResponse.builder().uri(uri).headers(headers).build(); + RemoteSignResponse result = RemoteSignResponseParser.fromJson(json); + return ImmutableS3SignResponse.builder().from(result).build(); } } diff --git a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java index 6385d8875d22..7a463abd3d2d 100644 --- a/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java +++ b/aws/src/main/java/org/apache/iceberg/aws/s3/signer/S3V4RestSignerClient.java @@ -37,6 +37,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.rest.ErrorHandlers; import org.apache.iceberg.rest.HTTPClient; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.RESTClient; import org.apache.iceberg.rest.RESTUtil; import org.apache.iceberg.rest.ResourcePaths; @@ -45,6 +46,9 @@ import org.apache.iceberg.rest.auth.AuthSession; import org.apache.iceberg.rest.auth.OAuth2Properties; import org.apache.iceberg.rest.auth.OAuth2Util; +import org.apache.iceberg.rest.requests.ImmutableRemoteSignRequest; +import org.apache.iceberg.rest.requests.RemoteSignRequest; +import org.apache.iceberg.rest.responses.RemoteSignResponse; import org.apache.iceberg.util.PropertyUtil; import org.immutables.value.Value; import org.slf4j.Logger; @@ -64,13 +68,30 @@ public abstract class S3V4RestSignerClient extends AbstractAws4Signer implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(S3V4RestSignerClient.class); - public static final String S3_SIGNER_URI = "s3.signer.uri"; - public static final String S3_SIGNER_ENDPOINT = "s3.signer.endpoint"; - static final String S3_SIGNER_DEFAULT_ENDPOINT = "v1/aws/s3/sign"; - static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; - static final String CACHE_CONTROL = "Cache-Control"; - static final String CACHE_CONTROL_PRIVATE = "private"; - static final String CACHE_CONTROL_NO_CACHE = "no-cache"; + + public static final String S3_PROVIDER = "s3"; + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * RESTCatalogProperties#SIGNER_URI} instead. + */ + @Deprecated public static final String S3_SIGNER_URI = "s3.signer.uri"; + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * RESTCatalogProperties#SIGNER_URI} instead. + */ + @Deprecated public static final String S3_SIGNER_ENDPOINT = "s3.signer.endpoint"; + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; there is no replacement. + */ + @Deprecated static final String S3_SIGNER_DEFAULT_ENDPOINT = "v1/aws/s3/sign"; + + @VisibleForTesting static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; + + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String CACHE_CONTROL_PRIVATE = "private"; private static final Cache SIGNED_COMPONENT_CACHE = Caffeine.newBuilder().expireAfterWrite(30, TimeUnit.SECONDS).maximumSize(100).build(); @@ -94,13 +115,28 @@ public Supplier> requestPropertiesSupplier() { @Value.Lazy public String baseSignerUri() { - return properties().getOrDefault(S3_SIGNER_URI, properties().get(CatalogProperties.URI)); + // TODO remove in 1.12.0 + if (properties().containsKey(S3_SIGNER_URI)) { + return properties().get(S3_SIGNER_URI); + } + + return properties() + .getOrDefault(RESTCatalogProperties.SIGNER_URI, properties().get(CatalogProperties.URI)); } @Value.Lazy public String endpoint() { - return RESTUtil.resolveEndpoint( - baseSignerUri(), properties().getOrDefault(S3_SIGNER_ENDPOINT, S3_SIGNER_DEFAULT_ENDPOINT)); + // TODO remove in 1.12.0 + String endpointPath; + if (properties().containsKey(S3_SIGNER_ENDPOINT)) { + endpointPath = properties().get(S3_SIGNER_ENDPOINT); + } else { + endpointPath = + properties() + .getOrDefault(RESTCatalogProperties.SIGNER_ENDPOINT, S3_SIGNER_DEFAULT_ENDPOINT); + } + + return RESTUtil.resolveEndpoint(baseSignerUri(), endpointPath); } /** A credential to exchange for a token in the OAuth2 client credentials flow. */ @@ -158,7 +194,9 @@ private RESTClient httpClient() { // Don't include a base URI because this client may be used for contacting different // catalogs. httpClient = - HTTPClient.builder(properties()).withObjectMapper(S3ObjectMapper.mapper()).build(); + HTTPClient.builder(properties()) + .withHeaders(RESTUtil.configHeaders(properties())) + .build(); } } } @@ -194,8 +232,36 @@ private boolean credentialProvided() { @Value.Check protected void check() { Preconditions.checkArgument( - properties().containsKey(S3_SIGNER_URI) || properties().containsKey(CatalogProperties.URI), + properties().containsKey(S3_SIGNER_URI) + || properties().containsKey(RESTCatalogProperties.SIGNER_URI) + || properties().containsKey(CatalogProperties.URI), "S3 signer service URI is required"); + + if (properties().containsKey(S3_SIGNER_URI) + && !properties().containsKey(RESTCatalogProperties.SIGNER_URI)) { + LOG.warn( + "S3 signer URI is configured via deprecated property {}, this won't be supported in future releases. " + + "Please use {} instead.", + S3_SIGNER_URI, + RESTCatalogProperties.SIGNER_URI); + } + + if (properties().containsKey(S3_SIGNER_ENDPOINT) + && !properties().containsKey(RESTCatalogProperties.SIGNER_ENDPOINT)) { + LOG.warn( + "Signer endpoint is configured via deprecated property {}, this won't be supported in future releases. " + + "Please use {} instead.", + S3_SIGNER_ENDPOINT, + RESTCatalogProperties.SIGNER_ENDPOINT); + } + + // TODO change to required in 1.12.0 + if (!properties().containsKey(S3_SIGNER_ENDPOINT) + && !properties().containsKey(RESTCatalogProperties.SIGNER_ENDPOINT)) { + LOG.warn( + "Signer endpoint is not set, this won't be supported in future releases. Using deprecated default: {}", + S3_SIGNER_DEFAULT_ENDPOINT); + } } @Override @@ -208,6 +274,7 @@ protected void processRequestPayload( checkSignerParams(signerParams); } + @SuppressWarnings("deprecation") @Override protected void processRequestPayload( SdkHttpFullRequest.Builder mutableRequest, @@ -237,14 +304,15 @@ public SdkHttpFullRequest sign( AwsS3V4SignerParams signerParams = extractSignerParams(AwsS3V4SignerParams.builder(), executionAttributes).build(); - S3SignRequest remoteSigningRequest = - ImmutableS3SignRequest.builder() + RemoteSignRequest remoteSigningRequest = + ImmutableRemoteSignRequest.builder() .method(request.method().name()) .region(signerParams.signingRegion().id()) .uri(request.getUri()) .headers(request.headers()) .properties(requestPropertiesSupplier().get()) .body(bodyAsString(request)) + .provider(S3_PROVIDER) .build(); Key cacheKey = Key.from(remoteSigningRequest); @@ -256,21 +324,21 @@ public SdkHttpFullRequest sign( } else { Map responseHeaders = Maps.newHashMap(); Consumer> responseHeadersConsumer = responseHeaders::putAll; - S3SignResponse s3SignResponse = + RemoteSignResponse remoteSignResponse = httpClient() .withAuthSession(authSession()) .post( endpoint(), remoteSigningRequest, - S3SignResponse.class, + RemoteSignResponse.class, Map.of(), ErrorHandlers.defaultErrorHandler(), responseHeadersConsumer); signedComponent = ImmutableSignedComponent.builder() - .headers(s3SignResponse.headers()) - .signedURI(s3SignResponse.uri()) + .headers(remoteSignResponse.headers()) + .signedURI(remoteSignResponse.uri()) .build(); if (canBeCached(responseHeaders)) { @@ -347,7 +415,7 @@ interface Key { String uri(); - static Key from(S3SignRequest request) { + static Key from(RemoteSignRequest request) { return ImmutableKey.builder() .method(request.method()) .region(request.region()) diff --git a/aws/src/main/resources/s3-signer-open-api.yaml b/aws/src/main/resources/s3-signer-open-api.yaml index 3d719c515b2a..0b98fcc59eff 100644 --- a/aws/src/main/resources/s3-signer-open-api.yaml +++ b/aws/src/main/resources/s3-signer-open-api.yaml @@ -17,19 +17,22 @@ # under the License. # +# ⚠️ WARNING: this API is deprecated. Use the new remote signing endpoint instead, +# see open-api/rest-catalog-open-api.yaml. + --- openapi: 3.0.3 info: - title: Apache Iceberg S3 Signer API + title: "[DEPRECATED] Apache Iceberg S3 Signer API" license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html version: 0.0.1 description: - Defines the specification for the S3 Signer API. + "[DEPRECATED] Defines the specification for the S3 Signer API." servers: - url: "{scheme}://{host}/{basePath}" - description: Server URL when the port can be inferred from the scheme + description: "[DEPRECATED] Server URL when the port can be inferred from the scheme" variables: scheme: description: The scheme of the URI, either http or https. @@ -41,7 +44,7 @@ servers: description: Optional prefix to be prepended to all routes default: "" - url: "{scheme}://{host}:{port}/{basePath}" - description: Generic base server URL, with all parts configurable + description: "[DEPRECATED] Generic base server URL, with all parts configurable" variables: scheme: description: The scheme of the URI, either http or https. @@ -61,9 +64,10 @@ paths: /v1/aws/s3/sign: post: + deprecated: true tags: - S3 Signer API - summary: Remotely signs S3 requests + summary: "[DEPRECATED] Remotely signs S3 requests" operationId: signS3Request requestBody: description: The request containing the headers to be signed @@ -95,6 +99,7 @@ components: schemas: S3Headers: + deprecated: true type: object additionalProperties: type: array @@ -102,6 +107,7 @@ components: type: string S3SignRequest: + deprecated: true required: - region - uri @@ -133,7 +139,8 @@ components: responses: S3SignResponse: - description: The response containing signed & unsigned headers. The server will also send + description: > + [DEPRECATED] The response containing signed & unsigned headers. The server will also send a Cache-Control header, indicating whether the response can be cached (Cache-Control = ["private"]) or not (Cache-Control = ["no-cache"]). content: diff --git a/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientFactories.java b/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientFactories.java index fe95f9364673..94ef60e05d68 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientFactories.java +++ b/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientFactories.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.util.Map; +import java.util.Optional; import org.apache.iceberg.TestHelpers; import org.apache.iceberg.aws.lakeformation.LakeFormationAwsClientFactory; import org.apache.iceberg.aws.s3.S3FileIOProperties; @@ -38,7 +39,10 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.awscore.AwsClient; import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.retries.api.RetryStrategy; +import software.amazon.awssdk.retries.internal.DefaultAdaptiveRetryStrategy; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.glue.GlueClient; import software.amazon.awssdk.services.glue.model.GetTablesRequest; @@ -245,6 +249,42 @@ public void testWithClassDoesNotImplementCredentialsProvider() { testProviderAndAssertThrownBy(providerClassName, containsMessage); } + @Test + public void testGlueClientSetsAdaptiveRetryPolicy() { + AwsClientFactory factory = + getAwsClientFactoryByCredentialsProvider(DummyValidProvider.class.getName()); + GlueClient glueClient = factory.glue(); + assertAwsClientSetsAdaptiveRetryPolicy(glueClient); + } + + @Test + public void testKmsClientSetsAdaptiveRetryPolicy() { + AwsClientFactory factory = + getAwsClientFactoryByCredentialsProvider(DummyValidProvider.class.getName()); + KmsClient kmsClient = factory.kms(); + assertAwsClientSetsAdaptiveRetryPolicy(kmsClient); + } + + @Test + public void testDynamoClientSetsAdaptiveRetryPolicy() { + AwsClientFactory factory = + getAwsClientFactoryByCredentialsProvider(DummyValidProvider.class.getName()); + DynamoDbClient dynamoClient = factory.dynamo(); + assertAwsClientSetsAdaptiveRetryPolicy(dynamoClient); + } + + /** + * Extract the retry strategy from an AwsClient object, then assert that it's set to the correct + * strategy. This enforces that we correctly applied the retry configurations to the client + * object. + */ + private void assertAwsClientSetsAdaptiveRetryPolicy(AwsClient client) { + Optional retryStrategy = + client.serviceClientConfiguration().overrideConfiguration().retryStrategy(); + assertThat(retryStrategy).isPresent(); + assertThat(retryStrategy.get()).isInstanceOf(DefaultAdaptiveRetryStrategy.class); + } + private void testProviderAndAssertThrownBy(String providerClassName, String containsMessage) { AwsClientFactory defaultAwsClientFactory = getAwsClientFactoryByCredentialsProvider(providerClassName); diff --git a/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientProperties.java b/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientProperties.java index 8c177ab5274e..47bb12c83b40 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientProperties.java +++ b/aws/src/test/java/org/apache/iceberg/aws/TestAwsClientProperties.java @@ -30,6 +30,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; @@ -39,6 +40,17 @@ public class TestAwsClientProperties { + public static class CustomCredentialProvider implements AwsCredentialsProvider { + public static AwsCredentialsProvider create(Map properties) { + return new CustomCredentialProvider(); + } + + @Override + public AwsCredentials resolveCredentials() { + return AwsBasicCredentials.builder().build(); + } + } + @Test public void testApplyClientRegion() { Map properties = Maps.newHashMap(); @@ -180,7 +192,7 @@ public void refreshCredentialsEndpointWithOverridingOAuthToken() { Map properties = ImmutableMap.of( CatalogProperties.URI, - "http://localhost:1234/v1", + "http://localhost:1234/v1/catalog", AwsClientProperties.REFRESH_CREDENTIALS_ENDPOINT, "http://localhost:1234/v1/credentials", OAuth2Properties.TOKEN, @@ -189,17 +201,22 @@ public void refreshCredentialsEndpointWithOverridingOAuthToken() { "specific-token"); AwsClientProperties awsClientProperties = new AwsClientProperties(properties); - Map expectedProperties = - ImmutableMap.builder() - .putAll(properties) - .put("credentials.uri", "http://localhost:1234/v1/credentials") - .build(); - AwsCredentialsProvider provider = awsClientProperties.credentialsProvider("key", "secret", "token"); assertThat(provider).isInstanceOf(VendedCredentialsProvider.class); VendedCredentialsProvider vendedCredentialsProvider = (VendedCredentialsProvider) provider; - assertThat(vendedCredentialsProvider).extracting("properties").isEqualTo(expectedProperties); + assertThat(vendedCredentialsProvider) + .extracting("properties") + .isEqualTo( + ImmutableMap.of( + AwsClientProperties.REFRESH_CREDENTIALS_ENDPOINT, + "http://localhost:1234/v1/credentials", + "credentials.uri", + "http://localhost:1234/v1/credentials", + CatalogProperties.URI, + "http://localhost:1234/v1/catalog", + OAuth2Properties.TOKEN, + "specific-token")); } @Test @@ -231,4 +248,18 @@ public void refreshCredentialsEndpointWithRelativePath() { OAuth2Properties.TOKEN, "oauth-token")); } + + @Test + public void customCredentialsProviderTakesPrecedence() { + AwsClientProperties awsClientProperties = + new AwsClientProperties( + ImmutableMap.of( + AwsClientProperties.REFRESH_CREDENTIALS_ENDPOINT, + "http://localhost:1234/v1/credentials", + AwsClientProperties.CLIENT_CREDENTIALS_PROVIDER, + CustomCredentialProvider.class.getName())); + AwsCredentialsProvider provider = + awsClientProperties.credentialsProvider("key", "secret", "token"); + assertThat(provider).isInstanceOf(CustomCredentialProvider.class); + } } diff --git a/aws/src/test/java/org/apache/iceberg/aws/TestHttpClientConfigurations.java b/aws/src/test/java/org/apache/iceberg/aws/TestHttpClientConfigurations.java index 0f96ac0f6c82..da73a5c1b5a5 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/TestHttpClientConfigurations.java +++ b/aws/src/test/java/org/apache/iceberg/aws/TestHttpClientConfigurations.java @@ -22,6 +22,8 @@ import java.util.Map; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; import software.amazon.awssdk.http.apache.ApacheHttpClient; import software.amazon.awssdk.http.apache.ProxyConfiguration; @@ -137,4 +139,39 @@ public void testApacheDefaultConfigurations() { Mockito.verify(spyApacheHttpClientBuilder, Mockito.never()) .proxyConfiguration(Mockito.any(ProxyConfiguration.class)); } + + @ParameterizedTest + @ValueSource( + strings = { + HttpClientProperties.PROXY_USE_SYSTEM_PROPERTY_VALUES, + HttpClientProperties.PROXY_USE_ENVIRONMENT_VARIABLE_VALUES + }) + public void testApacheProxyFlagTriggersProxyConfig(String propertyKey) { + Map properties = Maps.newHashMap(); + properties.put(propertyKey, "false"); + ApacheHttpClient.Builder spy = Mockito.spy(ApacheHttpClient.builder()); + + ApacheHttpClientConfigurations.create(properties).configureApacheHttpClientBuilder(spy); + + Mockito.verify(spy).proxyConfiguration(Mockito.any(ProxyConfiguration.class)); + } + + @ParameterizedTest + @ValueSource( + strings = { + HttpClientProperties.PROXY_USE_SYSTEM_PROPERTY_VALUES, + HttpClientProperties.PROXY_USE_ENVIRONMENT_VARIABLE_VALUES + }) + public void testUrlConnectionProxyFlagTriggersProxyConfig(String propertyKey) { + Map properties = Maps.newHashMap(); + properties.put(propertyKey, "false"); + UrlConnectionHttpClient.Builder spy = Mockito.spy(UrlConnectionHttpClient.builder()); + + UrlConnectionHttpClientConfigurations.create(properties) + .configureUrlConnectionHttpClientBuilder(spy); + + Mockito.verify(spy) + .proxyConfiguration( + Mockito.any(software.amazon.awssdk.http.urlconnection.ProxyConfiguration.class)); + } } diff --git a/aws/src/test/java/org/apache/iceberg/aws/TestRESTSigV4AuthSession.java b/aws/src/test/java/org/apache/iceberg/aws/TestRESTSigV4AuthSession.java index 1b2aaf2e1c01..9e996ca60089 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/TestRESTSigV4AuthSession.java +++ b/aws/src/test/java/org/apache/iceberg/aws/TestRESTSigV4AuthSession.java @@ -35,7 +35,10 @@ import org.apache.iceberg.rest.requests.CreateNamespaceRequest; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.signer.Aws4Signer; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.utils.SdkAutoCloseable; class TestRESTSigV4AuthSession { @@ -306,4 +309,43 @@ void close() { session.close(); Mockito.verify(delegate).close(); } + + @Test + void closeWithCloseableCredentialsProvider() { + AuthSession delegate = Mockito.mock(AuthSession.class); + CloseableAwsCredentialsProvider credentialsProvider = + Mockito.mock(CloseableAwsCredentialsProvider.class); + closeWithCloseableCredentialsProvider(delegate, credentialsProvider); + } + + @Test + void closeSuppressesFailure() { + AuthSession delegate = Mockito.mock(AuthSession.class); + Mockito.doThrow(new RuntimeException("delegate close failed")).when(delegate).close(); + CloseableAwsCredentialsProvider credentialsProvider = + Mockito.mock(CloseableAwsCredentialsProvider.class); + Mockito.doThrow(new RuntimeException("credentials provider close failed")) + .when(credentialsProvider) + .close(); + closeWithCloseableCredentialsProvider(delegate, credentialsProvider); + } + + private void closeWithCloseableCredentialsProvider( + AuthSession delegate, CloseableAwsCredentialsProvider credentialsProvider) { + AwsProperties properties = Mockito.mock(AwsProperties.class); + when(properties.restSigningRegion()).thenReturn(Region.US_WEST_2); + when(properties.restSigningName()).thenReturn("execute-api"); + when(properties.restCredentialsProvider()).thenReturn(credentialsProvider); + + RESTSigV4AuthSession session = new RESTSigV4AuthSession(signer, delegate, properties); + session.close(); + + Mockito.verify(delegate).close(); + Mockito.verify(credentialsProvider).close(); + } + + interface CloseableAwsCredentialsProvider extends AwsCredentialsProvider, SdkAutoCloseable { + @Override + void close(); + } } diff --git a/aws/src/test/java/org/apache/iceberg/aws/dynamodb/TestDynamoDbCatalog.java b/aws/src/test/java/org/apache/iceberg/aws/dynamodb/TestDynamoDbCatalog.java index b602cea303d8..e172831a2428 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/dynamodb/TestDynamoDbCatalog.java +++ b/aws/src/test/java/org/apache/iceberg/aws/dynamodb/TestDynamoDbCatalog.java @@ -49,14 +49,25 @@ public class TestDynamoDbCatalog { public void before() { dynamo = Mockito.mock(DynamoDbClient.class); dynamoCatalog = new DynamoDbCatalog(); - dynamoCatalog.initialize(CATALOG_NAME, WAREHOUSE_PATH, new AwsProperties(), dynamo, null); + dynamoCatalog.initialize( + CATALOG_NAME, + WAREHOUSE_PATH, + new AwsProperties(), + dynamo, + null, + false /* uniqTableLocation */); } @Test public void testConstructorWarehousePathWithEndSlash() { DynamoDbCatalog catalogWithSlash = new DynamoDbCatalog(); catalogWithSlash.initialize( - CATALOG_NAME, WAREHOUSE_PATH + "/", new AwsProperties(), dynamo, null); + CATALOG_NAME, + WAREHOUSE_PATH + "/", + new AwsProperties(), + dynamo, + null, + false /* uniqTableLocation */); Mockito.doReturn(GetItemResponse.builder().item(Maps.newHashMap()).build()) .when(dynamo) .getItem(any(GetItemRequest.class)); @@ -103,4 +114,49 @@ public void testDefaultWarehouseLocationNoNamespace() { .isInstanceOf(NoSuchNamespaceException.class) .hasMessageContaining("Cannot find default warehouse location:"); } + + @Test + public void testDefaultWarehouseLocationUniqueWithoutDbUri() throws Exception { + try (DynamoDbCatalog catalog = new DynamoDbCatalog()) { + catalog.initialize( + CATALOG_NAME, + WAREHOUSE_PATH, + new AwsProperties(), + dynamo, + null, + true /* uniqTableLocation */); + Mockito.doReturn(GetItemResponse.builder().item(Maps.newHashMap()).build()) + .when(dynamo) + .getItem(any(GetItemRequest.class)); + + String defaultWarehouseLocation = catalog.defaultWarehouseLocation(TABLE_IDENTIFIER); + assertThat(defaultWarehouseLocation).matches(WAREHOUSE_PATH + "/db.db/table-[a-z0-9]{32}"); + } + } + + @Test + public void testDefaultWarehouseLocationUniqueWithDbUri() throws Exception { + try (DynamoDbCatalog catalog = new DynamoDbCatalog()) { + catalog.initialize( + CATALOG_NAME, + WAREHOUSE_PATH, + new AwsProperties(), + dynamo, + null, + true /* uniqTableLocation */); + String dbUri = "s3://bucket2/db"; + Mockito.doReturn( + GetItemResponse.builder() + .item( + ImmutableMap.of( + toPropertyCol(DynamoDbCatalog.defaultLocationProperty()), + AttributeValue.builder().s(dbUri).build())) + .build()) + .when(dynamo) + .getItem(any(GetItemRequest.class)); + + String defaultWarehouseLocation = catalog.defaultWarehouseLocation(TABLE_IDENTIFIER); + assertThat(defaultWarehouseLocation).matches("s3://bucket2/db/table-[a-z0-9]{32}"); + } + } } diff --git a/aws/src/test/java/org/apache/iceberg/aws/glue/TestGlueCatalog.java b/aws/src/test/java/org/apache/iceberg/aws/glue/TestGlueCatalog.java index 2042948eb3c9..82f7e84d563b 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/glue/TestGlueCatalog.java +++ b/aws/src/test/java/org/apache/iceberg/aws/glue/TestGlueCatalog.java @@ -194,6 +194,28 @@ public void testDefaultWarehouseLocationCustomCatalogId() { Mockito.argThat((GetDatabaseRequest req) -> req.catalogId().equals(catalogId))); } + @Test + public void testDefaultWarehouseLocationUnique() { + GlueCatalog catalog = new GlueCatalog(); + catalog.initialize( + CATALOG_NAME, + WAREHOUSE_PATH, + new AwsProperties(), + new S3FileIOProperties(), + glue, + LockManagers.defaultLockManager(), + true /* uniqTableLocation */); + + Mockito.doReturn( + GetDatabaseResponse.builder() + .database(Database.builder().name("db").locationUri("s3://bucket2/db").build()) + .build()) + .when(glue) + .getDatabase(Mockito.any(GetDatabaseRequest.class)); + String location = catalog.defaultWarehouseLocation(TableIdentifier.of("db", "table")); + assertThat(location).matches("s3://bucket2/db/table-[a-z0-9]{32}"); + } + @Test public void testListTables() { Mockito.doReturn( diff --git a/aws/src/test/java/org/apache/iceberg/aws/s3/TestS3FileIOCredentialRefresh.java b/aws/src/test/java/org/apache/iceberg/aws/s3/TestS3FileIOCredentialRefresh.java new file mode 100644 index 000000000000..170857ca84b4 --- /dev/null +++ b/aws/src/test/java/org/apache/iceberg/aws/s3/TestS3FileIOCredentialRefresh.java @@ -0,0 +1,348 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.aws.s3; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockserver.integration.ClientAndServer.startClientAndServer; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.aws.AwsProperties; +import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.HttpMethod; +import org.apache.iceberg.rest.credentials.ImmutableCredential; +import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse; +import org.apache.iceberg.rest.responses.LoadCredentialsResponse; +import org.apache.iceberg.rest.responses.LoadCredentialsResponseParser; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; +import org.mockserver.verify.VerificationTimes; + +public class TestS3FileIOCredentialRefresh { + + private static final int PORT = 3233; + private static final String CREDENTIALS_URI = + String.format("http://127.0.0.1:%d/v1/credentials", PORT); + private static final String CATALOG_URI = String.format("http://127.0.0.1:%d/v1", PORT); + private static ClientAndServer mockServer; + + @BeforeAll + public static void beforeAll() { + mockServer = startClientAndServer(PORT); + } + + @AfterAll + public static void stopServer() { + mockServer.stop(); + } + + @BeforeEach + public void before() { + mockServer.reset(); + } + + @Test + public void credentialRefreshSchedulesNextRefresh() { + String nearExpiryMs = Long.toString(Instant.now().plus(3, ChronoUnit.MINUTES).toEpochMilli()); + + StorageCredential initialCredential = + StorageCredential.create( + "s3://bucket/path", + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "initialAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "initialSecretKey", + S3FileIOProperties.SESSION_TOKEN, + "initialToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + nearExpiryMs)); + + String firstRefreshExpiryMs = + Long.toString(Instant.now().plus(2, ChronoUnit.MINUTES).toEpochMilli()); + String secondRefreshExpiryMs = + Long.toString(Instant.now().plus(1, ChronoUnit.HOURS).toEpochMilli()); + + LoadCredentialsResponse firstRefreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("s3://bucket/path") + .config( + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "firstRefreshedAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "firstRefreshedSecretKey", + S3FileIOProperties.SESSION_TOKEN, + "firstRefreshedToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + firstRefreshExpiryMs)) + .build()) + .build(); + + LoadCredentialsResponse secondRefreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("s3://bucket/path") + .config( + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "secondRefreshedAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "secondRefreshedSecretKey", + S3FileIOProperties.SESSION_TOKEN, + "secondRefreshedToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + secondRefreshExpiryMs)) + .build()) + .build(); + + HttpRequest mockRequest = request("/v1/credentials").withMethod(HttpMethod.GET.name()); + mockServer + .when(mockRequest, org.mockserver.matchers.Times.once()) + .respond( + response(LoadCredentialsResponseParser.toJson(firstRefreshResponse)) + .withStatusCode(200)); + mockServer + .when(mockRequest, org.mockserver.matchers.Times.unlimited()) + .respond( + response(LoadCredentialsResponseParser.toJson(secondRefreshResponse)) + .withStatusCode(200)); + + Map properties = + ImmutableMap.of( + AwsProperties.CLIENT_FACTORY, + StaticClientFactory.class.getName(), + VendedCredentialsProvider.URI, + CREDENTIALS_URI, + CatalogProperties.URI, + CATALOG_URI, + "init-creation-stacktrace", + "false"); + + StaticClientFactory.client = null; + try (S3FileIO fileIO = new S3FileIO()) { + fileIO.initialize(properties); + fileIO.setCredentials(List.of(initialCredential)); + + fileIO.client(); + + // the first refresh returns near-expiry credentials, which should schedule a second refresh + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> mockServer.verify(mockRequest, VerificationTimes.atLeast(2))); + + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List credentials = fileIO.credentials(); + assertThat(credentials).hasSize(1); + assertThat(credentials.get(0).config()) + .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "secondRefreshedAccessKey") + .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "secondRefreshedSecretKey") + .containsEntry(S3FileIOProperties.SESSION_TOKEN, "secondRefreshedToken") + .containsEntry( + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, secondRefreshExpiryMs); + }); + } + } + + @Test + public void credentialRefreshWithinFiveMinuteWindow() { + // Set up credentials expiring within the next 5 minutes so the refresh triggers immediately + String nearExpiryMs = Long.toString(Instant.now().plus(3, ChronoUnit.MINUTES).toEpochMilli()); + + StorageCredential initialCredential = + StorageCredential.create( + "s3://bucket/path", + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "initialAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "initialSecretKey", + S3FileIOProperties.SESSION_TOKEN, + "initialToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + nearExpiryMs)); + + // Mock the credentials endpoint to return refreshed credentials + String refreshedExpiryMs = + Long.toString(Instant.now().plus(1, ChronoUnit.HOURS).toEpochMilli()); + LoadCredentialsResponse refreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("s3://bucket/path") + .config( + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "refreshedAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "refreshedSecretKey", + S3FileIOProperties.SESSION_TOKEN, + "refreshedToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + refreshedExpiryMs)) + .build()) + .build(); + + HttpRequest mockRequest = request("/v1/credentials").withMethod(HttpMethod.GET.name()); + HttpResponse mockResponse = + response(LoadCredentialsResponseParser.toJson(refreshResponse)).withStatusCode(200); + mockServer.when(mockRequest).respond(mockResponse); + + Map properties = + ImmutableMap.of( + AwsProperties.CLIENT_FACTORY, + StaticClientFactory.class.getName(), + VendedCredentialsProvider.URI, + CREDENTIALS_URI, + CatalogProperties.URI, + CATALOG_URI, + "init-creation-stacktrace", + "false"); + + StaticClientFactory.client = null; + try (S3FileIO fileIO = new S3FileIO()) { + fileIO.initialize(properties); + fileIO.setCredentials(List.of(initialCredential)); + + // Trigger clientByPrefix() to build the client map and schedule the refresh. + // Since the credential expires within 5 minutes, the delay is negative/zero + // and the refresh fires immediately. + fileIO.client(); + + // Wait for the scheduled refresh to call the credentials endpoint + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> mockServer.verify(mockRequest, VerificationTimes.atLeast(1))); + + // Verify the credentials were updated with the refreshed values + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List credentials = fileIO.credentials(); + assertThat(credentials).hasSize(1); + assertThat(credentials.get(0).config()) + .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "refreshedAccessKey") + .containsEntry(S3FileIOProperties.SECRET_ACCESS_KEY, "refreshedSecretKey") + .containsEntry(S3FileIOProperties.SESSION_TOKEN, "refreshedToken") + .containsEntry( + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, refreshedExpiryMs); + }); + } + } + + @Test + public void refreshedCredentialsAreKryoSerializable() throws Exception { + // Verify that an S3FileIO whose credentials have been refreshed at runtime can still be + // round-tripped through Kryo. The internal storageCredentials list must be backed by a + // collection that Kryo can serialize and deserialize. + String nearExpiryMs = Long.toString(Instant.now().plus(3, ChronoUnit.MINUTES).toEpochMilli()); + + StorageCredential initialCredential = + StorageCredential.create( + "s3://bucket/path", + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "initialAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "initialSecretKey", + S3FileIOProperties.SESSION_TOKEN, + "initialToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + nearExpiryMs)); + + String refreshedExpiryMs = + Long.toString(Instant.now().plus(1, ChronoUnit.HOURS).toEpochMilli()); + LoadCredentialsResponse refreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("s3://bucket/path") + .config( + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "refreshedAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "refreshedSecretKey", + S3FileIOProperties.SESSION_TOKEN, + "refreshedToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + refreshedExpiryMs)) + .build()) + .build(); + + HttpRequest mockRequest = request("/v1/credentials").withMethod(HttpMethod.GET.name()); + mockServer + .when(mockRequest) + .respond( + response(LoadCredentialsResponseParser.toJson(refreshResponse)).withStatusCode(200)); + + Map properties = + ImmutableMap.of( + AwsProperties.CLIENT_FACTORY, + StaticClientFactory.class.getName(), + VendedCredentialsProvider.URI, + CREDENTIALS_URI, + CatalogProperties.URI, + CATALOG_URI, + "init-creation-stacktrace", + "false"); + + StaticClientFactory.client = null; + try (S3FileIO fileIO = new S3FileIO()) { + fileIO.initialize(properties); + fileIO.setCredentials(List.of(initialCredential)); + + fileIO.client(); + + // Wait for the refresh to update the in-memory credentials + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> + assertThat(fileIO.credentials().get(0).config()) + .containsEntry(S3FileIOProperties.ACCESS_KEY_ID, "refreshedAccessKey")); + + // Round-trip through Kryo and verify the credentials still match + try (S3FileIO deserialized = TestHelpers.KryoHelpers.roundTripSerialize(fileIO)) { + assertThat(deserialized.credentials()).isEqualTo(fileIO.credentials()); + } + } + } +} diff --git a/aws/src/test/java/org/apache/iceberg/aws/s3/TestS3FileIOProperties.java b/aws/src/test/java/org/apache/iceberg/aws/s3/TestS3FileIOProperties.java index 1666de1f1d08..953f73d45d4a 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/s3/TestS3FileIOProperties.java +++ b/aws/src/test/java/org/apache/iceberg/aws/s3/TestS3FileIOProperties.java @@ -566,4 +566,25 @@ public void testApplyRetryConfiguration() { RetryPolicy retryPolicy = builder.overrideConfiguration().retryPolicy().get(); assertThat(retryPolicy.numRetries()).as("retries was not set").isEqualTo(999); } + + @Test + public void testChunkedEncodingEnabledDefaultValue() { + Map properties = Maps.newHashMap(); + S3FileIOProperties s3FileIOProperties = new S3FileIOProperties(properties); + + assertThat(s3FileIOProperties.isChunkedEncodingEnabled()) + .as("chunked encoding should be enabled by default") + .isTrue(); + } + + @Test + public void testChunkedEncodingDisabled() { + Map properties = Maps.newHashMap(); + properties.put(S3FileIOProperties.CHUNKED_ENCODING_ENABLED, "false"); + S3FileIOProperties s3FileIOProperties = new S3FileIOProperties(properties); + + assertThat(s3FileIOProperties.isChunkedEncodingEnabled()) + .as("chunked encoding should be disabled when explicitly set to false") + .isFalse(); + } } diff --git a/aws/src/test/java/org/apache/iceberg/aws/s3/TestVendedCredentialsProvider.java b/aws/src/test/java/org/apache/iceberg/aws/s3/TestVendedCredentialsProvider.java index 8e2f99e0ccbe..d0287dc3080c 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/s3/TestVendedCredentialsProvider.java +++ b/aws/src/test/java/org/apache/iceberg/aws/s3/TestVendedCredentialsProvider.java @@ -26,10 +26,12 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Map; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.HttpMethod; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.credentials.Credential; import org.apache.iceberg.rest.credentials.ImmutableCredential; import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse; @@ -488,6 +490,54 @@ public void invalidTokenInProperties() { mockServer.verify(mockRequest, VerificationTimes.once()); } + @Test + public void planIdQueryParamIsSent() { + String planId = "randomPlanId"; + HttpRequest mockRequest = + request("/v1/credentials") + .withMethod(HttpMethod.GET.name()) + .withQueryStringParameter("planId", planId); + Credential credential = + ImmutableCredential.builder() + .prefix("s3") + .config( + ImmutableMap.of( + S3FileIOProperties.ACCESS_KEY_ID, + "randomAccessKey", + S3FileIOProperties.SECRET_ACCESS_KEY, + "randomSecretAccessKey", + S3FileIOProperties.SESSION_TOKEN, + "sessionToken", + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + Long.toString(Instant.now().plus(1, ChronoUnit.MINUTES).toEpochMilli()))) + .build(); + LoadCredentialsResponse response = + ImmutableLoadCredentialsResponse.builder().addCredentials(credential).build(); + + HttpResponse mockResponse = + response(LoadCredentialsResponseParser.toJson(response)).withStatusCode(200); + mockServer.when(mockRequest).respond(mockResponse); + + Map properties = + ImmutableMap.builder() + .putAll(PROPERTIES) + .put(RESTCatalogProperties.REST_SCAN_PLAN_ID, planId) + .build(); + + try (VendedCredentialsProvider provider = VendedCredentialsProvider.create(properties)) { + AwsCredentials awsCredentials = provider.resolveCredentials(); + verifyCredentials(awsCredentials, credential); + + // resolving credentials multiple times should hit the credentials endpoint again and send the + // planId again + AwsCredentials refreshedCredentials = provider.resolveCredentials(); + assertThat(refreshedCredentials).isNotSameAs(awsCredentials); + verifyCredentials(refreshedCredentials, credential); + } + + mockServer.verify(mockRequest, VerificationTimes.exactly(2)); + } + private void verifyCredentials(AwsCredentials awsCredentials, Credential credential) { assertThat(awsCredentials).isInstanceOf(AwsSessionCredentials.class); AwsSessionCredentials creds = (AwsSessionCredentials) awsCredentials; diff --git a/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3V4RestSignerClient.java b/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3V4RestSignerClient.java index 0bcc77e29fae..aadbf036b567 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3V4RestSignerClient.java +++ b/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3V4RestSignerClient.java @@ -18,13 +18,14 @@ */ package org.apache.iceberg.aws.s3.signer; -import static org.apache.iceberg.aws.s3.signer.S3V4RestSignerClient.S3_SIGNER_URI; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.mockito.Mockito.when; import java.util.Map; import java.util.stream.Stream; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.RESTClient; import org.apache.iceberg.rest.auth.AuthProperties; import org.apache.iceberg.rest.auth.AuthSession; @@ -119,12 +120,21 @@ void authSessionOAuth2(Map properties, String expectedScope, Str public static Stream validOAuth2Properties() { return Stream.of( // No OAuth2 data - Arguments.of(Map.of(S3_SIGNER_URI, "https://signer.com"), "sign", null), + Arguments.of( + Map.of( + RESTCatalogProperties.SIGNER_URI, + "https://signer.com", + RESTCatalogProperties.SIGNER_ENDPOINT, + "v1/sign/s3"), + "sign", + null), // Token only Arguments.of( Map.of( - S3_SIGNER_URI, + RESTCatalogProperties.SIGNER_URI, "https://signer.com", + RESTCatalogProperties.SIGNER_ENDPOINT, + "v1/sign/s3", AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_OAUTH2, OAuth2Properties.TOKEN, @@ -134,8 +144,10 @@ public static Stream validOAuth2Properties() { // Credential only: expect a token to be fetched Arguments.of( Map.of( - S3_SIGNER_URI, + RESTCatalogProperties.SIGNER_URI, "https://signer.com", + RESTCatalogProperties.SIGNER_ENDPOINT, + "v1/sign/s3", AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_OAUTH2, OAuth2Properties.CREDENTIAL, @@ -145,8 +157,10 @@ public static Stream validOAuth2Properties() { // Token and credential: should use token as is, not fetch a new one Arguments.of( Map.of( - S3_SIGNER_URI, + RESTCatalogProperties.SIGNER_URI, "https://signer.com", + RESTCatalogProperties.SIGNER_ENDPOINT, + "v1/sign/s3", AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_OAUTH2, OAuth2Properties.TOKEN, @@ -158,8 +172,10 @@ public static Stream validOAuth2Properties() { // Custom scope Arguments.of( Map.of( - S3_SIGNER_URI, + RESTCatalogProperties.SIGNER_URI, "https://signer.com", + RESTCatalogProperties.SIGNER_ENDPOINT, + "v1/sign/s3", AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_OAUTH2, OAuth2Properties.CREDENTIAL, @@ -169,4 +185,63 @@ public static Stream validOAuth2Properties() { "custom", "token")); } + + @ParameterizedTest + @MethodSource("legacySignerProperties") + void legacySignerProperties( + Map properties, String expectedBaseSignerUri, String expectedEndpoint) + throws Exception { + try (S3V4RestSignerClient client = + ImmutableS3V4RestSignerClient.builder().properties(properties).build()) { + assertThat(client.baseSignerUri()).isEqualTo(expectedBaseSignerUri); + assertThat(client.endpoint()).isEqualTo(expectedEndpoint); + } + } + + @SuppressWarnings("deprecation") + public static Stream legacySignerProperties() { + return Stream.of( + // Only legacy properties + Arguments.of( + Map.of( + CatalogProperties.URI, + "https://catalog.com", + S3V4RestSignerClient.S3_SIGNER_URI, + "https://legacy-signer.com", + S3V4RestSignerClient.S3_SIGNER_ENDPOINT, + "v1/legacy/sign"), + "https://legacy-signer.com", + "https://legacy-signer.com/v1/legacy/sign"), + // Only new properties + Arguments.of( + Map.of( + CatalogProperties.URI, + "https://catalog.com", + RESTCatalogProperties.SIGNER_URI, + "https://new-signer.com", + RESTCatalogProperties.SIGNER_ENDPOINT, + "v1/new/sign"), + "https://new-signer.com", + "https://new-signer.com/v1/new/sign"), + // Mixed properties: legacy properties take precedence + Arguments.of( + Map.of( + CatalogProperties.URI, + "https://catalog.com", + RESTCatalogProperties.SIGNER_URI, + "https://new-signer.com", + RESTCatalogProperties.SIGNER_ENDPOINT, + "v1/new/sign", + S3V4RestSignerClient.S3_SIGNER_URI, + "https://legacy-signer.com", + S3V4RestSignerClient.S3_SIGNER_ENDPOINT, + "v1/legacy/sign"), + "https://legacy-signer.com", + "https://legacy-signer.com/v1/legacy/sign"), + // No signer properties: the catalog URI and the deprecated default endpoint are used + Arguments.of( + Map.of(CatalogProperties.URI, "https://catalog.com"), + "https://catalog.com", + "https://catalog.com/" + S3V4RestSignerClient.S3_SIGNER_DEFAULT_ENDPOINT)); + } } diff --git a/azure-bundle/LICENSE b/azure-bundle/LICENSE index 62069e51acb6..b0964f5e65ba 100644 --- a/azure-bundle/LICENSE +++ b/azure-bundle/LICENSE @@ -203,293 +203,245 @@ -------------------------------------------------------------------------------- -This binary artifact contains code from the following projects: +This product bundles Azure SDK for Java. --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-core-http-netty Version: 1.15.13 -Project URL: https://github.com/Azure/azure-sdk-for-java -License: The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-identity Version: 1.16.2 -Project URL: https://github.com/Azure/azure-sdk-for-java -License: The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-json Version: 1.5.0 -Project URL: https://github.com/Azure/azure-sdk-for-java -License: The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-blob Version: 12.31.1 -Project URL: https://github.com/Azure/azure-sdk-for-java -License: The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-common Version: 12.30.1 -Project URL: https://github.com/Azure/azure-sdk-for-java -License: The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-file-datalake Version: 12.24.1 -Project URL: https://github.com/Azure/azure-sdk-for-java -License: The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-internal-avro Version: 12.16.1 Project URL: https://github.com/Azure/azure-sdk-for-java -License: The MIT License (MIT) - http://opensource.org/licenses/MIT +License: MIT + +| Copyright (c) 2015 Microsoft +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Jackson JSON Processor. --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.18.4 Project URL: http://github.com/FasterXML/jackson -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.18.4 -Project URL: https://github.com/FasterXML/jackson-core -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.18.4 -Project URL: http://github.com/FasterXML/jackson -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.18.4 -Project URL: https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310 -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.github.stephenc.jcip Name: jcip-annotations Version: 1.0-1 -Project URL: http://stephenc.github.com/jcip-annotations -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles FastDoubleParser (via Jackson JSON Processor). + +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles fast_float (bundled by FastDoubleParser). + +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles bigint (bundled by FastDoubleParser). + +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Microsoft Authentication Library for Java. -Group: com.microsoft.azure Name: msal4j Version: 1.21.0 Project URL: https://github.com/AzureAD/microsoft-authentication-library-for-java -License: MIT License - --------------------------------------------------------------------------------- - -Group: com.microsoft.azure Name: msal4j-persistence-extension Version: 1.3.0 -Project URL: https://github.com/AzureAD/microsoft-authentication-extensions-for-java -License: MIT License - --------------------------------------------------------------------------------- - -Group: com.nimbusds Name: content-type Version: 2.3 -Project URL: https://bitbucket.org/connect2id/nimbus-content-type -License: The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.nimbusds Name: lang-tag Version: 1.7 -Project URL: https://bitbucket.org/connect2id/nimbus-language-tags -License: The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.nimbusds Name: nimbus-jose-jwt Version: 10.0.1 -Project URL: https://bitbucket.org/connect2id/nimbus-jose-jwt -License: The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.nimbusds Name: oauth2-oidc-sdk Version: 11.23 -Project URL: https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions -License: Apache License, version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.html - --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-buffer Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +License: MIT + +| Copyright (c) Microsoft Corporation. All rights reserved. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE + +-------------------------------------------------------------------------------- + +This product bundles MSAL4J Persistence Extension. -Group: io.netty Name: netty-codec Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +Project URL: https://github.com/AzureAD/microsoft-authentication-library-for-java +License: MIT + +| Copyright (c) Microsoft Corporation. All rights reserved. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE + +-------------------------------------------------------------------------------- + +This product bundles Netty. -Group: io.netty Name: netty-codec-dns Version: 4.1.112.Final Project URL: https://netty.io/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-http Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Tomcat Native (netty-tcnative-classes and netty-tcnative-boringssl-static, bundled by Reactor Netty). -Group: io.netty Name: netty-codec-http2 Version: 4.1.118.Final -Project URL: https://netty.io/ +Project URL: https://tomcat.apache.org/native-doc/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-socks Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Reactor Core. -Group: io.netty Name: netty-common Version: 4.1.118.Final -Project URL: https://netty.io/ +Project URL: https://github.com/reactor/reactor-core License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-handler Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Reactor Netty. -Group: io.netty Name: netty-handler-proxy Version: 4.1.118.Final -Project URL: https://netty.io/ +Project URL: https://github.com/reactor/reactor-netty License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-resolver Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Reactor Pool (bundled by Reactor Netty). -Group: io.netty Name: netty-resolver-dns Version: 4.1.112.Final -Project URL: https://netty.io/ +Project URL: https://github.com/reactor/reactor-pool License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-resolver-dns-classes-macos Version: 4.1.112.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Aalto XML (bundled by Azure SDK for Java). -Group: io.netty Name: netty-resolver-dns-native-macos Version: 4.1.112.Final -Project URL: https://netty.io/ +Project URL: https://github.com/FasterXML/aalto-xml License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-tcnative-boringssl-static Version: 2.0.70.Final -Project URL: https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles JNA. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-tcnative-classes Version: 2.0.70.Final -Project URL: https://netty.io/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-transport Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-transport-classes-epoll Version: 4.1.118.Final -Project URL: https://netty.io/ +Project URL: https://github.com/java-native-access/jna License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-classes-kqueue Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Reactive Streams. --------------------------------------------------------------------------------- +Project URL: http://www.reactive-streams.org/ +License: MIT-0 -Group: io.netty Name: netty-transport-native-epoll Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +| Copyright 2014 Reactive Streams +| +| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-native-kqueue Version: 4.1.118.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles JCTools (via Netty). --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-transport-native-unix-common Version: 4.1.118.Final -Project URL: https://netty.io/ +Project URL: https://github.com/JCTools/JCTools License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- - -Group: io.projectreactor Name: reactor-core Version: 3.4.41 -Project URL: https://github.com/reactor/reactor-core -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: io.projectreactor.netty Name: reactor-netty-core Version: 1.0.48 -Project URL: https://github.com/reactor/reactor-netty -License: The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: io.projectreactor.netty Name: reactor-netty-http Version: 1.0.48 -Project URL: https://github.com/reactor/reactor-netty -License: The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.java.dev.jna Name: jna Version: 5.13.0 -Project URL: https://github.com/java-native-access/jna -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.java.dev.jna Name: jna-platform Version: 5.13.0 -Project URL: https://github.com/java-native-access/jna -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.minidev Name: accessors-smart Version: 2.5.2 -Project URL: https://urielch.github.io/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.minidev Name: json-smart Version: 2.5.2 -Project URL: https://urielch.github.io/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.ow2.asm Name: asm Version: 9.7.1 -Project URL: http://asm.ow2.io/ -License: BSD-3-Clause - https://asm.ow2.io/license.html -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.reactivestreams Name: reactive-streams Version: 1.0.4 -Project URL: http://www.reactive-streams.org/ -License: MIT-0 - https://spdx.org/licenses/MIT-0.html diff --git a/azure-bundle/NOTICE b/azure-bundle/NOTICE index ee3003102cb2..12325baf97d2 100644 --- a/azure-bundle/NOTICE +++ b/azure-bundle/NOTICE @@ -1,15 +1,13 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -NOTICE for Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.18.4 -NOTICE for Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.18.4 - +This product bundles Jackson JSON Processor with the following in its NOTICE file: | # Jackson JSON processor | | Jackson is a high-performance, Free/Open Source JSON processing library. @@ -17,6 +15,10 @@ NOTICE for Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2 | been in development since 2007. | It is currently developed by a community of developers. | +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| | ## Licensing | | Jackson 2.x core and extension components are licensed under Apache License 2.0 @@ -27,31 +29,21 @@ NOTICE for Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2 | A list of contributors may be found from CREDITS(-2.x) file, which is included | in some artifacts (usually source distributions); but is always available | from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. -------------------------------------------------------------------------------- -NOTICE for Group: io.netty Name: netty-buffer Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-codec Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-codec-dns Version: 4.1.112.Final -NOTICE for Group: io.netty Name: netty-codec-http Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-codec-http2 Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-codec-socks Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-common Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-handler Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-handler-proxy Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-resolver Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-resolver-dns Version: 4.1.112.Final -NOTICE for Group: io.netty Name: netty-resolver-dns-classes-macos Version: 4.1.112.Final -NOTICE for Group: io.netty Name: netty-resolver-dns-native-macos Version: 4.1.112.Final -NOTICE for Group: io.netty Name: netty-tcnative-boringssl-static Version: 2.0.69.Final -NOTICE for Group: io.netty Name: netty-tcnative-classes Version: 2.0.69.Final -NOTICE for Group: io.netty Name: netty-transport Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-transport-classes-epoll Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-transport-classes-kqueue Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-transport-native-epoll Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-transport-native-kqueue Version: 4.1.118.Final -NOTICE for Group: io.netty Name: netty-transport-native-unix-common Version: 4.1.118.Final - +This product bundles Netty with the following in its NOTICE file: | The Netty Project | ================= | diff --git a/azure-bundle/build.gradle b/azure-bundle/build.gradle index 46d1b3daea5b..fde8adbfc539 100644 --- a/azure-bundle/build.gradle +++ b/azure-bundle/build.gradle @@ -23,9 +23,16 @@ project(":iceberg-azure-bundle") { tasks.jar.dependsOn tasks.shadowJar + configurations { + implementation { + exclude group: 'org.slf4j' + } + } + dependencies { implementation platform(libs.azuresdk.bom) implementation "com.azure:azure-storage-file-datalake" + implementation "com.azure:azure-security-keyvault-keys" implementation "com.azure:azure-identity" } @@ -39,10 +46,6 @@ project(":iceberg-azure-bundle") { include 'NOTICE' } - dependencies { - exclude(dependency('org.slf4j:slf4j-api')) - } - // relocate Azure-specific versions relocate 'io.netty', 'org.apache.iceberg.azure.shaded.io.netty' relocate 'com.fasterxml.jackson', 'org.apache.iceberg.azure.shaded.com.fasterxml.jackson' @@ -51,4 +54,6 @@ project(":iceberg-azure-bundle") { jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/azure-bundle/runtime-deps.txt b/azure-bundle/runtime-deps.txt new file mode 100644 index 000000000000..9cf83d5bf8e4 --- /dev/null +++ b/azure-bundle/runtime-deps.txt @@ -0,0 +1,47 @@ +com.azure:azure-core-http-netty:1.16.3 +com.azure:azure-core:1.57.1 +com.azure:azure-identity:1.18.2 +com.azure:azure-json:1.5.1 +com.azure:azure-security-keyvault-keys:4.10.6 +com.azure:azure-storage-blob:12.33.3 +com.azure:azure-storage-common:12.32.2 +com.azure:azure-storage-file-datalake:12.26.3 +com.azure:azure-storage-internal-avro:12.18.2 +com.azure:azure-xml:1.2.1 +com.fasterxml.jackson.core:jackson-annotations:2.18.4 +com.fasterxml.jackson.core:jackson-core:2.18.4.1 +com.fasterxml.jackson.core:jackson-databind:2.18.4 +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.4 +com.microsoft.azure:msal4j-persistence-extension:1.3.0 +com.microsoft.azure:msal4j:1.23.1 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-codec-base:4.2.13.Final +io.netty:netty-codec-compression:4.2.13.Final +io.netty:netty-codec-dns:4.2.13.Final +io.netty:netty-codec-http2:4.2.13.Final +io.netty:netty-codec-http:4.2.13.Final +io.netty:netty-codec-marshalling:4.2.13.Final +io.netty:netty-codec-protobuf:4.2.13.Final +io.netty:netty-codec-socks:4.2.13.Final +io.netty:netty-codec:4.2.13.Final +io.netty:netty-common:4.2.13.Final +io.netty:netty-handler-proxy:4.2.13.Final +io.netty:netty-handler:4.2.13.Final +io.netty:netty-resolver-dns-classes-macos:4.2.13.Final +io.netty:netty-resolver-dns-native-macos:4.2.13.Final +io.netty:netty-resolver-dns:4.2.13.Final +io.netty:netty-resolver:4.2.13.Final +io.netty:netty-tcnative-boringssl-static:2.0.74.Final +io.netty:netty-tcnative-classes:2.0.74.Final +io.netty:netty-transport-classes-epoll:4.2.13.Final +io.netty:netty-transport-classes-kqueue:4.2.13.Final +io.netty:netty-transport-native-epoll:4.2.13.Final +io.netty:netty-transport-native-kqueue:4.2.13.Final +io.netty:netty-transport-native-unix-common:4.2.13.Final +io.netty:netty-transport:4.2.13.Final +io.projectreactor.netty:reactor-netty-core:1.2.13 +io.projectreactor.netty:reactor-netty-http:1.2.13 +io.projectreactor:reactor-core:3.7.14 +net.java.dev.jna:jna-platform:5.17.0 +net.java.dev.jna:jna:5.17.0 +org.reactivestreams:reactive-streams:1.0.4 diff --git a/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/AzuriteContainer.java b/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/AzuriteContainer.java index 3f6fec9cd4b1..46f7f02aeb1d 100644 --- a/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/AzuriteContainer.java +++ b/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/AzuriteContainer.java @@ -49,6 +49,7 @@ public AzuriteContainer(String image) { super(image == null ? DEFAULT_IMAGE + ":" + DEFAULT_TAG : image); this.addExposedPort(DEFAULT_PORT); this.addEnv("AZURITE_ACCOUNTS", ACCOUNT + ":" + KEY); + this.withCommand("azurite", "--blobHost", "0.0.0.0", "--skipApiVersionCheck"); this.setWaitStrategy(new LogMessageWaitStrategy().withRegEx(LOG_WAIT_REGEX)); } diff --git a/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSFileIO.java b/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSFileIO.java index 5e343782ab1c..621813a25574 100644 --- a/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSFileIO.java +++ b/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSFileIO.java @@ -20,6 +20,7 @@ import static org.apache.iceberg.azure.AzureProperties.ADLS_SAS_TOKEN_PREFIX; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; @@ -31,6 +32,7 @@ import com.azure.core.http.rest.PagedIterable; import com.azure.core.http.rest.Response; +import com.azure.storage.blob.models.BlobStorageException; import com.azure.storage.file.datalake.DataLakeFileClient; import com.azure.storage.file.datalake.DataLakeFileSystemClient; import com.azure.storage.file.datalake.DataLakeFileSystemClientBuilder; @@ -42,6 +44,7 @@ import java.util.Iterator; import org.apache.iceberg.TestHelpers; import org.apache.iceberg.azure.AzureProperties; +import org.apache.iceberg.exceptions.NotFoundException; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.FileInfo; import org.apache.iceberg.io.InputFile; @@ -78,6 +81,23 @@ public void testFileOperations() throws IOException { assertThat(fileClient.exists()).isFalse(); } + @Test + public void readMissingLocation() { + String path = "path/to/file"; + String location = AZURITE_CONTAINER.location(path); + ADLSFileIO io = createFileIO(); + DataLakeFileClient fileClient = AZURITE_CONTAINER.fileClient(path); + assertThat(fileClient.exists()).isFalse(); + + InputFile inputFile = io.newInputFile(location); + + assertThatThrownBy(inputFile::newStream) + .isInstanceOf(NotFoundException.class) + .hasCauseInstanceOf(BlobStorageException.class) + .hasMessage( + "Location does not exist: abfs://container@account.dfs.core.windows.net/path/to/file"); + } + @Test public void testBulkDeleteFiles() { String path1 = "path/to/file1"; diff --git a/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java b/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java index 8464e57516ce..1edf48eaec35 100644 --- a/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java +++ b/azure/src/integration/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java @@ -39,6 +39,10 @@ public class TestADLSInputStream extends AzuriteTestBase { private final Random random = new Random(1); private final AzureProperties azureProperties = new AzureProperties(); + private String location() { + return AZURITE_CONTAINER.location(FILE_PATH); + } + private DataLakeFileClient fileClient() { return AZURITE_CONTAINER.fileClient(FILE_PATH); } @@ -55,7 +59,8 @@ public void testRead() throws Exception { setupData(data); try (SeekableInputStream in = - new ADLSInputStream(fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { + new ADLSInputStream( + location(), fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { int readSize = 1024; readAndCheck(in, in.getPos(), readSize, data, false); @@ -90,7 +95,8 @@ public void testReadSingle() throws Exception { setupData(data); try (SeekableInputStream in = - new ADLSInputStream(fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { + new ADLSInputStream( + location(), fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { assertThat(in.read()).isEqualTo(i0); assertThat(in.read()).isEqualTo(i1); } @@ -131,7 +137,8 @@ public void testRangeRead() throws Exception { setupData(expected); try (RangeReadable in = - new ADLSInputStream(fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { + new ADLSInputStream( + location(), fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { // first 1k position = 0; offset = 0; @@ -164,7 +171,8 @@ private void readAndCheckRanges( public void testClose() throws Exception { setupData(randomData(2)); SeekableInputStream closed = - new ADLSInputStream(fileClient(), null, azureProperties, MetricsContext.nullMetrics()); + new ADLSInputStream( + location(), fileClient(), null, azureProperties, MetricsContext.nullMetrics()); closed.close(); assertThatThrownBy(() -> closed.seek(0)) .isInstanceOf(IllegalStateException.class) @@ -178,7 +186,8 @@ public void testSeek() throws Exception { setupData(data); try (SeekableInputStream in = - new ADLSInputStream(fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { + new ADLSInputStream( + location(), fileClient(), null, azureProperties, MetricsContext.nullMetrics())) { in.seek(data.length / 2); byte[] actual = new byte[data.length / 2]; @@ -193,7 +202,8 @@ public void testSeek() throws Exception { public void testSeekNegative() throws Exception { setupData(randomData(2)); SeekableInputStream in = - new ADLSInputStream(fileClient(), null, azureProperties, MetricsContext.nullMetrics()); + new ADLSInputStream( + location(), fileClient(), null, azureProperties, MetricsContext.nullMetrics()); assertThatThrownBy(() -> in.seek(-3)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot seek: position -3 is negative"); diff --git a/azure/src/integration/java/org/apache/iceberg/azure/keymanagement/TestAzureKeyManagementClient.java b/azure/src/integration/java/org/apache/iceberg/azure/keymanagement/TestAzureKeyManagementClient.java new file mode 100644 index 000000000000..88b498d98fa8 --- /dev/null +++ b/azure/src/integration/java/org/apache/iceberg/azure/keymanagement/TestAzureKeyManagementClient.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.azure.keymanagement; + +import static org.apache.iceberg.azure.AzureProperties.AZURE_KEYVAULT_URL; +import static org.assertj.core.api.Assertions.assertThat; + +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.security.keyvault.keys.KeyClient; +import com.azure.security.keyvault.keys.KeyClientBuilder; +import com.azure.security.keyvault.keys.models.KeyType; +import java.nio.ByteBuffer; +import java.time.Duration; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.encryption.KeyManagementClient; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariables; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +@EnabledIfEnvironmentVariables({ + @EnabledIfEnvironmentVariable(named = "AZURE_KEYVAULT_URL", matches = ".*") +}) +public class TestAzureKeyManagementClient { + private static final String ICEBERG_TEST_KEY_NAME = "iceberg-test-key"; + + private static final String KEY_VAULT_URI = System.getenv("AZURE_KEYVAULT_URL"); + + private static KeyManagementClient azureKeyManagementClient; + private static KeyClient keyClient; + + @BeforeAll + public static void beforeClass() { + keyClient = + new KeyClientBuilder() + .vaultUrl(KEY_VAULT_URI) + .credential(new DefaultAzureCredentialBuilder().build()) + .buildClient(); + keyClient.createKey(ICEBERG_TEST_KEY_NAME, KeyType.RSA); + azureKeyManagementClient = new AzureKeyManagementClient(); + azureKeyManagementClient.initialize(ImmutableMap.of(AZURE_KEYVAULT_URL, KEY_VAULT_URI)); + } + + @AfterAll + public static void afterClass() { + if (keyClient != null) { + keyClient.beginDeleteKey(ICEBERG_TEST_KEY_NAME).waitForCompletion(Duration.ofMinutes(3)); + keyClient.purgeDeletedKey(ICEBERG_TEST_KEY_NAME); + } + } + + @Test + public void keyWrapping() { + ByteBuffer key = ByteBuffer.wrap("table-master-key".getBytes()); + + ByteBuffer encryptedKey = azureKeyManagementClient.wrapKey(key, ICEBERG_TEST_KEY_NAME); + ByteBuffer decryptedKey = + azureKeyManagementClient.unwrapKey(encryptedKey, ICEBERG_TEST_KEY_NAME); + + assertThat(decryptedKey).isEqualTo(key); + } + + @Test + public void keyGenerationNotSupported() { + assertThat(azureKeyManagementClient.supportsKeyGeneration()).isFalse(); + } + + @ParameterizedTest + @MethodSource("org.apache.iceberg.TestHelpers#serializers") + public void testSerialization( + TestHelpers.RoundTripSerializer roundTripSerializer) + throws Exception { + try (AzureKeyManagementClient keyManagementClient = new AzureKeyManagementClient()) { + keyManagementClient.initialize(ImmutableMap.of(AZURE_KEYVAULT_URL, KEY_VAULT_URI)); + + AzureKeyManagementClient result = roundTripSerializer.apply(keyManagementClient); + + ByteBuffer key = ByteBuffer.wrap("super-secret-table-master-key".getBytes()); + ByteBuffer encryptedKey = result.wrapKey(key, ICEBERG_TEST_KEY_NAME); + + assertThat(keyManagementClient.unwrapKey(encryptedKey, ICEBERG_TEST_KEY_NAME)).isEqualTo(key); + assertThat(result.unwrapKey(encryptedKey, ICEBERG_TEST_KEY_NAME)).isEqualTo(key); + } + } +} diff --git a/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java b/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java index 38ac573b59fa..383bec30111b 100644 --- a/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java +++ b/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java @@ -48,6 +48,12 @@ public class AzureProperties implements Serializable { public static final String ADLS_SHARED_KEY_ACCOUNT_NAME = "adls.auth.shared-key.account.name"; public static final String ADLS_SHARED_KEY_ACCOUNT_KEY = "adls.auth.shared-key.account.key"; public static final String ADLS_TOKEN = "adls.token"; + public static final String AZURE_KEYVAULT_URL = "azure.keyvault.url"; + public static final String AZURE_KEYVAULT_KEY_WRAP_ALGORITHM = + "azure.keyvault.key-wrap-algorithm"; + + // Must match KeyWrapAlgorithm.RSA_OAEP_256.getValue() from azure-security-keyvault-keys + private static final String DEFAULT_KEY_WRAP_ALGORITHM = "RSA-OAEP-256"; /** * Configure the ADLS token credential provider used to get {@link TokenCredential}. A fully @@ -91,6 +97,8 @@ public class AzureProperties implements Serializable { private boolean adlsRefreshCredentialsEnabled; private String token; private Map allProperties = Collections.emptyMap(); + private String keyWrapAlgorithm; + private String keyVaultUrl; public AzureProperties() {} @@ -124,6 +132,13 @@ public AzureProperties(Map properties) { PropertyUtil.propertyAsBoolean(properties, ADLS_REFRESH_CREDENTIALS_ENABLED, true); this.token = properties.get(ADLS_TOKEN); this.allProperties = SerializableMap.copyOf(properties); + if (properties.containsKey(AZURE_KEYVAULT_URL)) { + this.keyVaultUrl = properties.get(AZURE_KEYVAULT_URL); + } + + this.keyWrapAlgorithm = + properties.getOrDefault( + AzureProperties.AZURE_KEYVAULT_KEY_WRAP_ALGORITHM, DEFAULT_KEY_WRAP_ALGORITHM); } public Optional adlsReadBlockSize() { @@ -189,4 +204,12 @@ public Mono getToken(TokenRequestContext request) { builder.endpoint("https://" + account); } } + + public String keyWrapAlgorithm() { + return this.keyWrapAlgorithm; + } + + public Optional keyVaultUrl() { + return Optional.ofNullable(this.keyVaultUrl); + } } diff --git a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSFileIO.java b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSFileIO.java index d16cca172fea..c6627f21c783 100644 --- a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSFileIO.java +++ b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSFileIO.java @@ -38,6 +38,8 @@ import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.metrics.MetricsContext; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.SerializableFunction; import org.apache.iceberg.util.SerializableMap; import org.apache.iceberg.util.Tasks; import org.apache.iceberg.util.ThreadPools; @@ -57,6 +59,8 @@ public class ADLSFileIO implements DelegateFileIO { private MetricsContext metrics = MetricsContext.nullMetrics(); private SerializableMap properties; private VendedAdlsCredentialProvider vendedAdlsCredentialProvider; + private SerializableFunction clientSupplier; + private transient volatile Map clientCache; /** * No-arg constructor to load the FileIO dynamically. @@ -70,6 +74,23 @@ public ADLSFileIO() {} this.azureProperties = azureProperties; } + /** + * Constructor with custom DataLakeFileSystemClient function. + * + *

Unlike the no-arg constructor, this constructor initializes properties and azureProperties + * immediately, allowing immediate use without calling {@link ADLSFileIO#initialize(Map)}. + * + *

The function receives an {@link ADLSLocation} and should return an appropriate {@link + * DataLakeFileSystemClient} for that location. Clients are cached per storage account and + * container combination. + * + * @param clientSupplier function that creates a client for a given location + */ + public ADLSFileIO(SerializableFunction clientSupplier) { + this.clientSupplier = clientSupplier; + initialize(Maps.newHashMap()); + } + @Override public InputFile newInputFile(String path) { return new ADLSInputFile(path, fileClient(path), azureProperties, metrics); @@ -109,6 +130,22 @@ public DataLakeFileSystemClient client(String path) { @VisibleForTesting DataLakeFileSystemClient client(ADLSLocation location) { + if (clientCache == null) { + synchronized (this) { + if (clientCache == null) { + clientCache = Maps.newConcurrentMap(); + } + } + } + String cacheKey = location.host() + "/" + location.container().orElse(""); + return clientCache.computeIfAbsent(cacheKey, k -> buildClient(location)); + } + + private DataLakeFileSystemClient buildClient(ADLSLocation location) { + if (clientSupplier != null) { + return clientSupplier.apply(location); + } + DataLakeFileSystemClientBuilder clientBuilder = new DataLakeFileSystemClientBuilder().httpClient(HTTP); diff --git a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputFile.java b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputFile.java index 95e57bf04d32..5b07534f1368 100644 --- a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputFile.java +++ b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputFile.java @@ -55,6 +55,6 @@ public long getLength() { @Override public SeekableInputStream newStream() { - return new ADLSInputStream(fileClient(), fileSize, azureProperties(), metrics()); + return new ADLSInputStream(location(), fileClient(), fileSize, azureProperties(), metrics()); } } diff --git a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputStream.java b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputStream.java index 5e19c1acbe29..b1a2d3abfa32 100644 --- a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputStream.java +++ b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSInputStream.java @@ -18,14 +18,18 @@ */ package org.apache.iceberg.azure.adlsv2; +import com.azure.storage.blob.models.BlobErrorCode; +import com.azure.storage.blob.models.BlobStorageException; import com.azure.storage.file.datalake.DataLakeFileClient; import com.azure.storage.file.datalake.models.DataLakeFileOpenInputStreamResult; +import com.azure.storage.file.datalake.models.DataLakeStorageException; import com.azure.storage.file.datalake.models.FileRange; import com.azure.storage.file.datalake.options.DataLakeFileInputStreamOptions; import java.io.IOException; import java.io.InputStream; import java.util.Arrays; import org.apache.iceberg.azure.AzureProperties; +import org.apache.iceberg.exceptions.NotFoundException; import org.apache.iceberg.io.FileIOMetricsContext; import org.apache.iceberg.io.IOUtil; import org.apache.iceberg.io.RangeReadable; @@ -46,6 +50,7 @@ class ADLSInputStream extends SeekableInputStream implements RangeReadable { private static final int SKIP_SIZE = 1024 * 1024; private final StackTraceElement[] createStack; + private final String location; private final DataLakeFileClient fileClient; private Long fileSize; private final AzureProperties azureProperties; @@ -59,10 +64,12 @@ class ADLSInputStream extends SeekableInputStream implements RangeReadable { private final Counter readOperations; ADLSInputStream( + String location, DataLakeFileClient fileClient, Long fileSize, AzureProperties azureProperties, MetricsContext metrics) { + this.location = location; this.fileClient = fileClient; this.fileSize = fileSize; this.azureProperties = azureProperties; @@ -184,6 +191,7 @@ private DataLakeFileOpenInputStreamResult openRange(FileRange range) { try { return fileClient.openInputStream(getInputOptions(range)); } catch (RuntimeException e) { + throwNotFoundIfNotPresent(e, location); LOG.error( "Failed to open input stream for file {}, range {}", fileClient.getFilePath(), range, e); throw e; @@ -199,7 +207,7 @@ public void close() throws IOException { } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); @@ -209,4 +217,20 @@ protected void finalize() throws Throwable { LOG.warn("Unclosed input stream created by:\n\t{}", trace); } } + + private static void throwNotFoundIfNotPresent(Throwable throwable, String location) { + if (isFileNotFoundException(throwable)) { + throw new NotFoundException(throwable, "Location does not exist: %s", location); + } + } + + private static boolean isFileNotFoundException(Throwable exception) { + if (exception instanceof BlobStorageException blobStorageException) { + return BlobErrorCode.BLOB_NOT_FOUND.equals(blobStorageException.getErrorCode()); + } + if (exception instanceof DataLakeStorageException dataLakeStorageException) { + return "PathNotFound".equals(dataLakeStorageException.getErrorCode()); + } + return false; + } } diff --git a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSOutputStream.java b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSOutputStream.java index a5ba54f582eb..dfe1130e0dc3 100644 --- a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSOutputStream.java +++ b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/ADLSOutputStream.java @@ -115,7 +115,7 @@ public void close() throws IOException { } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/VendedAdlsCredentialProvider.java b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/VendedAdlsCredentialProvider.java index 2ede484a732d..0f60fa6b8fec 100644 --- a/azure/src/main/java/org/apache/iceberg/azure/adlsv2/VendedAdlsCredentialProvider.java +++ b/azure/src/main/java/org/apache/iceberg/azure/adlsv2/VendedAdlsCredentialProvider.java @@ -38,7 +38,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.rest.ErrorHandlers; import org.apache.iceberg.rest.HTTPClient; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.RESTClient; +import org.apache.iceberg.rest.RESTUtil; import org.apache.iceberg.rest.auth.AuthManager; import org.apache.iceberg.rest.auth.AuthManagers; import org.apache.iceberg.rest.auth.AuthSession; @@ -54,6 +56,7 @@ public class VendedAdlsCredentialProvider implements Serializable, AutoCloseable private final SerializableMap properties; private final String credentialsEndpoint; private final String catalogEndpoint; + private final String planId; private transient volatile Map sasCredentialByAccount; private transient volatile HTTPClient client; private transient AuthManager authManager; @@ -67,6 +70,7 @@ public VendedAdlsCredentialProvider(Map properties) { this.properties = SerializableMap.copyOf(properties); this.credentialsEndpoint = properties.get(URI); this.catalogEndpoint = properties.get(CatalogProperties.URI); + this.planId = properties.getOrDefault(RESTCatalogProperties.REST_SCAN_PLAN_ID, null); } Mono credentialForAccount(String storageAccount) { @@ -151,7 +155,11 @@ private RESTClient httpClient() { synchronized (this) { if (null == client) { authManager = AuthManagers.loadAuthManager("adls-credentials-refresh", properties); - HTTPClient httpClient = HTTPClient.builder(properties).uri(catalogEndpoint).build(); + HTTPClient httpClient = + HTTPClient.builder(properties) + .uri(catalogEndpoint) + .withHeaders(RESTUtil.configHeaders(properties)) + .build(); authSession = authManager.catalogSession(httpClient, properties); client = httpClient.withAuthSession(authSession); } @@ -165,7 +173,7 @@ private LoadCredentialsResponse fetchCredentials() { return httpClient() .get( credentialsEndpoint, - null, + null != planId ? Map.of("planId", planId) : null, LoadCredentialsResponse.class, Map.of(), ErrorHandlers.defaultErrorHandler()); diff --git a/azure/src/main/java/org/apache/iceberg/azure/keymanagement/AzureKeyManagementClient.java b/azure/src/main/java/org/apache/iceberg/azure/keymanagement/AzureKeyManagementClient.java new file mode 100644 index 000000000000..498c432212c5 --- /dev/null +++ b/azure/src/main/java/org/apache/iceberg/azure/keymanagement/AzureKeyManagementClient.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.azure.keymanagement; + +import com.azure.security.keyvault.keys.KeyClient; +import com.azure.security.keyvault.keys.KeyClientBuilder; +import com.azure.security.keyvault.keys.cryptography.models.KeyWrapAlgorithm; +import com.azure.security.keyvault.keys.cryptography.models.UnwrapResult; +import com.azure.security.keyvault.keys.cryptography.models.WrapResult; +import java.nio.ByteBuffer; +import java.util.Map; +import org.apache.iceberg.azure.AdlsTokenCredentialProviders; +import org.apache.iceberg.azure.AzureProperties; +import org.apache.iceberg.encryption.KeyManagementClient; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.SerializableMap; + +/** Azure key management client which connects to Azure Key Vault. */ +public class AzureKeyManagementClient implements KeyManagementClient { + + private Map allProperties; + + private transient volatile ClientState state; + + @Override + public void initialize(Map properties) { + this.allProperties = SerializableMap.copyOf(properties); + } + + @Override + public ByteBuffer wrapKey(ByteBuffer key, String wrappingKeyId) { + WrapResult wrapResult = + keyClient() + .getCryptographyClient(wrappingKeyId) + .wrapKey(keyWrapAlgorithm(), ByteBuffers.toByteArray(key)); + return ByteBuffer.wrap(wrapResult.getEncryptedKey()); + } + + @Override + public ByteBuffer unwrapKey(ByteBuffer wrappedKey, String wrappingKeyId) { + UnwrapResult unwrapResult = + keyClient() + .getCryptographyClient(wrappingKeyId) + .unwrapKey(keyWrapAlgorithm(), ByteBuffers.toByteArray(wrappedKey)); + return ByteBuffer.wrap(unwrapResult.getKey()); + } + + private KeyClient keyClient() { + return state().keyClient(); + } + + private KeyWrapAlgorithm keyWrapAlgorithm() { + return state().keyWrapAlgorithm(); + } + + private ClientState state() { + if (state == null) { + synchronized (this) { + if (state == null) { + AzureProperties azureProperties = new AzureProperties(allProperties); + KeyClientBuilder keyClientBuilder = new KeyClientBuilder(); + azureProperties.keyVaultUrl().ifPresent(keyClientBuilder::vaultUrl); + KeyClient keyClient = + keyClientBuilder + .credential(AdlsTokenCredentialProviders.from(allProperties).credential()) + .buildClient(); + KeyWrapAlgorithm keyWrapAlgorithm = + KeyWrapAlgorithm.fromString(azureProperties.keyWrapAlgorithm()); + state = new ClientState(keyClient, keyWrapAlgorithm); + } + } + } + return state; + } + + private static class ClientState { + + private final KeyClient keyClient; + private final KeyWrapAlgorithm keyWrapAlgorithm; + + ClientState(KeyClient keyClient, KeyWrapAlgorithm keyWrapAlgorithm) { + this.keyClient = keyClient; + this.keyWrapAlgorithm = keyWrapAlgorithm; + } + + KeyClient keyClient() { + return keyClient; + } + + KeyWrapAlgorithm keyWrapAlgorithm() { + return keyWrapAlgorithm; + } + } +} diff --git a/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java b/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java index 514e7faad435..c301d4de4741 100644 --- a/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java +++ b/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java @@ -26,6 +26,8 @@ import static org.apache.iceberg.azure.AzureProperties.ADLS_SHARED_KEY_ACCOUNT_KEY; import static org.apache.iceberg.azure.AzureProperties.ADLS_SHARED_KEY_ACCOUNT_NAME; import static org.apache.iceberg.azure.AzureProperties.ADLS_WRITE_BLOCK_SIZE; +import static org.apache.iceberg.azure.AzureProperties.AZURE_KEYVAULT_KEY_WRAP_ALGORITHM; +import static org.apache.iceberg.azure.AzureProperties.AZURE_KEYVAULT_URL; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; @@ -39,6 +41,7 @@ import com.azure.core.credential.TokenCredential; import com.azure.core.credential.TokenRequestContext; import com.azure.identity.DefaultAzureCredential; +import com.azure.security.keyvault.keys.cryptography.models.KeyWrapAlgorithm; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.file.datalake.DataLakeFileSystemClientBuilder; import java.io.IOException; @@ -73,11 +76,15 @@ public void testSerializable(TestHelpers.RoundTripSerializer ro .put(ADLS_SHARED_KEY_ACCOUNT_KEY, "secret") .put(AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER, "provider") .put(AzureProperties.ADLS_TOKEN_PROVIDER_PREFIX + "client-id", "clientId") + .put(AZURE_KEYVAULT_URL, "https://test-key-vault.vault.azure.net") + .put(AZURE_KEYVAULT_KEY_WRAP_ALGORITHM, KeyWrapAlgorithm.RSA1_5.getValue()) .build()); AzureProperties serdedProps = roundTripSerializer.apply(props); assertThat(serdedProps.adlsReadBlockSize()).isEqualTo(props.adlsReadBlockSize()); assertThat(serdedProps.adlsWriteBlockSize()).isEqualTo(props.adlsWriteBlockSize()); + assertThat(serdedProps.keyVaultUrl()).isEqualTo(props.keyVaultUrl()); + assertThat(serdedProps.keyWrapAlgorithm()).isEqualTo(props.keyWrapAlgorithm()); } @Test diff --git a/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestADLSFileIO.java b/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestADLSFileIO.java new file mode 100644 index 000000000000..0cda6ba10836 --- /dev/null +++ b/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestADLSFileIO.java @@ -0,0 +1,296 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.azure.adlsv2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.azure.storage.file.datalake.DataLakeFileClient; +import com.azure.storage.file.datalake.DataLakeFileSystemClient; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.util.SerializableFunction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class TestADLSFileIO { + + @Test + public void testConstructorWithClientSupplier() { + DataLakeFileSystemClient mockClient = mock(DataLakeFileSystemClient.class); + SerializableFunction supplier = location -> mockClient; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + + // Verify properties are initialized (should not throw NPE) + assertThat(fileIO.properties()).isNotNull(); + assertThat(fileIO.properties()).isEmpty(); + } + + @Test + public void testConstructorWithClientSupplierAndInitialize() { + DataLakeFileSystemClient mockClient = mock(DataLakeFileSystemClient.class); + SerializableFunction supplier = location -> mockClient; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + fileIO.initialize(ImmutableMap.of("key1", "value1")); + + // Verify properties from initialize are used + assertThat(fileIO.properties()).containsEntry("key1", "value1"); + } + + @Test + public void testClientSupplierIsUsed() { + DataLakeFileSystemClient mockClient = mock(DataLakeFileSystemClient.class); + + SerializableFunction supplier = location -> mockClient; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + fileIO.initialize(ImmutableMap.of()); + + // Call client method to verify supplier is invoked + DataLakeFileSystemClient client = + fileIO.client("abfs://container@account.dfs.core.windows.net/path/to/file"); + + assertThat(client).isEqualTo(mockClient); + } + + @Test + public void testClientSupplierWithoutInitialize() { + DataLakeFileSystemClient mockClient = mock(DataLakeFileSystemClient.class); + DataLakeFileClient mockFileClient = mock(DataLakeFileClient.class); + + when(mockClient.getFileClient("path/to/file")).thenReturn(mockFileClient); + + SerializableFunction supplier = location -> mockClient; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + + // Should work without calling initialize() + // This verifies azureProperties is initialized in constructor + assertThat(fileIO.properties()).isNotNull(); + assertThat(fileIO.properties()).isEmpty(); + + // Should be able to create files without NPE + InputFile inputFile = + fileIO.newInputFile("abfs://container@account.dfs.core.windows.net/path/to/file"); + assertThat(inputFile).isNotNull(); + } + + @Test + public void testNoArgConstructor() { + ADLSFileIO fileIO = new ADLSFileIO(); + + // Properties should be null before initialization + // Initialize with empty map to avoid NPE + fileIO.initialize(ImmutableMap.of()); + + assertThat(fileIO.properties()).isNotNull(); + assertThat(fileIO.properties()).isEmpty(); + } + + @ParameterizedTest + @MethodSource("org.apache.iceberg.TestHelpers#serializers") + public void testSerializationWithClientSupplier( + TestHelpers.RoundTripSerializer roundTripSerializer) + throws IOException, ClassNotFoundException { + // Use an AtomicInteger to track supplier invocations across serialization + AtomicInteger supplierInvocationCount = new AtomicInteger(0); + + SerializableFunction supplier = + location -> { + supplierInvocationCount.incrementAndGet(); + // Return null - we're only testing serialization, not actual client usage + return null; + }; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + fileIO.initialize(ImmutableMap.of("key1", "value1", "key2", "value2")); + + // Verify original FileIO works + assertThat(fileIO.properties()).containsEntry("key1", "value1"); + assertThat(fileIO.properties()).containsEntry("key2", "value2"); + + // Serialize and deserialize + FileIO deserializedFileIO = roundTripSerializer.apply(fileIO); + + // Verify properties are preserved after deserialization + assertThat(deserializedFileIO.properties()).isEqualTo(fileIO.properties()); + + // Verify the deserialized FileIO is an ADLSFileIO and can call client() + assertThat(deserializedFileIO).isInstanceOf(ADLSFileIO.class); + ADLSFileIO deserializedADLSFileIO = (ADLSFileIO) deserializedFileIO; + + // Call client() to verify the supplier was serialized and can be invoked + DataLakeFileSystemClient client = + deserializedADLSFileIO.client("abfs://container@account.dfs.core.windows.net/path"); + // The supplier returns null, so client should be null + assertThat(client).isNull(); + } + + @ParameterizedTest + @MethodSource("org.apache.iceberg.TestHelpers#serializers") + public void testSerializationWithNoArgConstructor( + TestHelpers.RoundTripSerializer roundTripSerializer) + throws IOException, ClassNotFoundException { + ADLSFileIO fileIO = new ADLSFileIO(); + fileIO.initialize(ImmutableMap.of("key1", "value1", "key2", "value2")); + + // Serialize and deserialize + FileIO deserializedFileIO = roundTripSerializer.apply(fileIO); + + // Verify properties are preserved after deserialization + assertThat(deserializedFileIO.properties()).isEqualTo(fileIO.properties()); + } + + @Test + public void testClientSupplierIsCachedPerContainer() { + DataLakeFileSystemClient mockClient1 = mock(DataLakeFileSystemClient.class); + DataLakeFileSystemClient mockClient2 = mock(DataLakeFileSystemClient.class); + AtomicInteger supplierInvocationCount = new AtomicInteger(0); + + SerializableFunction supplier = + location -> { + supplierInvocationCount.incrementAndGet(); + // Return different clients for different containers + return location.container().orElse("").equals("container1") ? mockClient1 : mockClient2; + }; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + + // Same container - should cache + DataLakeFileSystemClient client1 = + fileIO.client("abfs://container1@account.dfs.core.windows.net/path1"); + DataLakeFileSystemClient client2 = + fileIO.client("abfs://container1@account.dfs.core.windows.net/path2"); + + assertThat(supplierInvocationCount.get()).isEqualTo(1); + assertThat(client1).isSameAs(client2); + + // Different container - should call supplier again + DataLakeFileSystemClient client3 = + fileIO.client("abfs://container2@account.dfs.core.windows.net/path3"); + + assertThat(supplierInvocationCount.get()).isEqualTo(2); + assertThat(client3).isSameAs(mockClient2); + } + + @Test + public void testClientCachedPerStorageAccountAndContainer() { + DataLakeFileSystemClient mockClient1 = mock(DataLakeFileSystemClient.class); + DataLakeFileSystemClient mockClient2 = mock(DataLakeFileSystemClient.class); + DataLakeFileSystemClient mockClient3 = mock(DataLakeFileSystemClient.class); + AtomicInteger supplierInvocationCount = new AtomicInteger(0); + + SerializableFunction supplier = + location -> { + supplierInvocationCount.incrementAndGet(); + String host = location.host(); + String container = location.container().orElse(""); + if (host.equals("account1.dfs.core.windows.net") && container.equals("container")) { + return mockClient1; + } else if (host.equals("account2.dfs.core.windows.net") + && container.equals("container")) { + return mockClient2; + } else { + return mockClient3; + } + }; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + + // Same account, same container - should cache + DataLakeFileSystemClient client1 = + fileIO.client("abfs://container@account1.dfs.core.windows.net/path1"); + DataLakeFileSystemClient client2 = + fileIO.client("abfs://container@account1.dfs.core.windows.net/path2"); + + assertThat(supplierInvocationCount.get()).isEqualTo(1); + assertThat(client1).isSameAs(client2); + assertThat(client1).isSameAs(mockClient1); + + // Different account, same container - should call supplier again + DataLakeFileSystemClient client3 = + fileIO.client("abfs://container@account2.dfs.core.windows.net/path3"); + + assertThat(supplierInvocationCount.get()).isEqualTo(2); + assertThat(client3).isSameAs(mockClient2); + assertThat(client3).isNotSameAs(client1); + + // Same account as first, different container - should call supplier again + DataLakeFileSystemClient client4 = + fileIO.client("abfs://other@account1.dfs.core.windows.net/path4"); + + assertThat(supplierInvocationCount.get()).isEqualTo(3); + assertThat(client4).isSameAs(mockClient3); + } + + @Test + public void testClientSupplierCachingIsThreadSafe() throws Exception { + DataLakeFileSystemClient mockClient = mock(DataLakeFileSystemClient.class); + AtomicInteger supplierInvocationCount = new AtomicInteger(0); + + SerializableFunction supplier = + location -> { + supplierInvocationCount.incrementAndGet(); + return mockClient; + }; + + ADLSFileIO fileIO = new ADLSFileIO(supplier); + + // Run multiple threads concurrently calling client() with same container + int numThreads = 10; + Thread[] threads = new Thread[numThreads]; + DataLakeFileSystemClient[] results = new DataLakeFileSystemClient[numThreads]; + + for (int i = 0; i < numThreads; i++) { + final int index = i; + threads[i] = + new Thread( + () -> { + results[index] = + fileIO.client("abfs://container@account.dfs.core.windows.net/path/" + index); + }); + } + + // Start all threads + for (Thread thread : threads) { + thread.start(); + } + + // Wait for all threads to complete + for (Thread thread : threads) { + thread.join(); + } + + // Verify supplier was only called once even with concurrent access (same container) + assertThat(supplierInvocationCount.get()).isEqualTo(1); + + // Verify all threads got the same client + for (DataLakeFileSystemClient result : results) { + assertThat(result).isSameAs(mockClient); + } + } +} diff --git a/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java b/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java index 058bf0372b05..e98061846a88 100644 --- a/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java +++ b/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestADLSInputStream.java @@ -46,7 +46,13 @@ void before() { InternalDataLakeFileOpenInputStreamResult openInputStreamResult = new InternalDataLakeFileOpenInputStreamResult(inputStream, mock()); when(fileClient.openInputStream(any())).thenReturn(openInputStreamResult); - adlsInputStream = new ADLSInputStream(fileClient, 0L, mock(), mock()); + adlsInputStream = + new ADLSInputStream( + "abfs://container@account.dfs.core.windows.net/path/to/file", + fileClient, + 0L, + mock(), + mock()); } @Test diff --git a/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestVendedAdlsCredentialProvider.java b/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestVendedAdlsCredentialProvider.java index 43958d39acd8..6af2e4de21f0 100644 --- a/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestVendedAdlsCredentialProvider.java +++ b/azure/src/test/java/org/apache/iceberg/azure/adlsv2/TestVendedAdlsCredentialProvider.java @@ -34,6 +34,7 @@ import org.apache.iceberg.TestHelpers; import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.credentials.Credential; import org.apache.iceberg.rest.credentials.ImmutableCredential; import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse; @@ -445,4 +446,47 @@ public void serializableTest( mockServer.verify(mockRequest, VerificationTimes.exactly(2)); } + + @Test + public void planIdQueryParamIsSent() { + String planId = "randomPlanId"; + HttpRequest mockRequest = + request("/v1/credentials") + .withMethod(HttpMethod.GET.name()) + .withQueryStringParameter("planId", planId); + Credential credential = + ImmutableCredential.builder() + .prefix(CREDENTIAL_PREFIX) + .config( + ImmutableMap.of( + ADLS_SAS_TOKEN_PREFIX + STORAGE_ACCOUNT, + "randomSasToken", + ADLS_SAS_TOKEN_EXPIRES_AT_MS_PREFIX + STORAGE_ACCOUNT, + Long.toString(Instant.now().minus(1, ChronoUnit.MINUTES).toEpochMilli()))) + .build(); + LoadCredentialsResponse response = + ImmutableLoadCredentialsResponse.builder().addCredentials(credential).build(); + HttpResponse mockResponse = + response(LoadCredentialsResponseParser.toJson(response)).withStatusCode(200); + mockServer.when(mockRequest).respond(mockResponse); + + Map properties = + ImmutableMap.builder() + .putAll(PROPERTIES) + .put(RESTCatalogProperties.REST_SCAN_PLAN_ID, planId) + .build(); + try (VendedAdlsCredentialProvider provider = new VendedAdlsCredentialProvider(properties)) { + String azureSasCredential = provider.credentialForAccount(STORAGE_ACCOUNT).block(); + assertThat(azureSasCredential) + .isEqualTo(credential.config().get(ADLS_SAS_TOKEN_PREFIX + STORAGE_ACCOUNT)); + + // resolving credentials multiple times should hit the credentials endpoint again and send the + // planId again + String refreshedAzureSasCredential = provider.credentialForAccount(STORAGE_ACCOUNT).block(); + assertThat(refreshedAzureSasCredential) + .isEqualTo(credential.config().get(ADLS_SAS_TOKEN_PREFIX + STORAGE_ACCOUNT)); + } + + mockServer.verify(mockRequest, VerificationTimes.exactly(2)); + } } diff --git a/baseline.gradle b/baseline.gradle index 54e67b84f853..6b180effbbbf 100644 --- a/baseline.gradle +++ b/baseline.gradle @@ -52,29 +52,33 @@ subprojects { apply plugin: 'com.palantir.baseline-exact-dependencies' apply plugin: 'com.diffplug.spotless' + String scalaVersion = System.getProperty("scalaVersion") != null ? System.getProperty("scalaVersion") : System.getProperty("defaultScalaVersion") pluginManager.withPlugin('com.diffplug.spotless') { spotless { java { target 'src/main/java/**/*.java', 'src/test/java/**/*.java', 'src/testFixtures/java/**/*.java', 'src/jmh/java/**/*.java', 'src/integration/java/**/*.java' // 1.23.0 has an issue in formatting comments https://github.com/google/google-java-format/issues/1155 - // so we stick to 1.22.0 to produce consistent result for JDK 11/17/21 + // so we stick to 1.22.0 to produce consistent result for JDK 17/21 googleJavaFormat("1.22.0") removeUnusedImports() licenseHeaderFile "$rootDir/.baseline/copyright/copyright-header-java.txt" } - // Configure different scalafmt rules for specific Scala version - if (project.name.startsWith("iceberg-spark") && project.name.endsWith("2.13")) { - scala { - target 'src/**/*.scala' - scalafmt("3.9.7").configFile("$rootDir/.baseline/scala/.scala213fmt.conf") - licenseHeaderFile "$rootDir/.baseline/copyright/copyright-header-java.txt", "package" + if (project.name.startsWith("iceberg-spark")) { + String scalafmtConfigFile = null + // Configure different scalafmt rules for specific Scala version + if (scalaVersion?.startsWith("2.12")) { + scalafmtConfigFile = "$rootDir/.baseline/scala/.scala212fmt.conf" + } else if (scalaVersion?.startsWith("2.13")) { + scalafmtConfigFile = "$rootDir/.baseline/scala/.scala213fmt.conf" } - } else if (project.name.startsWith("iceberg-spark") && project.name.endsWith("2.12")) { - scala { - target 'src/**/*.scala' - scalafmt("3.9.7").configFile("$rootDir/.baseline/scala/.scala212fmt.conf") - licenseHeaderFile "$rootDir/.baseline/copyright/copyright-header-java.txt", "package" + + if (scalafmtConfigFile != null) { + scala { + target 'src/**/*.scala' + scalafmt("3.9.7").configFile(scalafmtConfigFile) + licenseHeaderFile "$rootDir/.baseline/copyright/copyright-header-java.txt", "package" + } } } } @@ -115,6 +119,7 @@ subprojects { '-Xep:InconsistentCapitalization:ERROR', '-Xep:InconsistentHashCode:ERROR', '-Xep:IntLongMath:ERROR', + '-Xep:JavaUtilDate:ERROR', '-Xep:JdkObsolete:ERROR', // prefer method references over lambdas '-Xep:LambdaMethodReference:ERROR', @@ -152,6 +157,8 @@ subprojects { '-Xep:Slf4jThrowable:ERROR', // Added because it errors out compile, but we need to figure out if we want it '-Xep:StrictUnusedVariable:OFF', + // This rule doesn't enforce the use of method references. That's handled by checkstyle. + '-Xep:StringCaseLocaleUsage:ERROR', // Enforce safe string splitting '-Xep:StringSplitter:ERROR', '-Xep:TypeParameterShadowing:OFF', @@ -182,7 +189,6 @@ subprojects { } pluginManager.withPlugin('scala') { - String scalaVersion = System.getProperty("scalaVersion") != null ? System.getProperty("scalaVersion") : System.getProperty("defaultScalaVersion") tasks.withType(ScalaCompile).configureEach { scalaCompile -> if (scalaVersion?.startsWith("2.12")) { scalaCompile.scalaCompileOptions.additionalParameters = [ diff --git a/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreCatalog.java b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreCatalog.java index 34ec7a62d5b5..dd01246cb01f 100644 --- a/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreCatalog.java +++ b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreCatalog.java @@ -23,7 +23,6 @@ import com.google.api.services.bigquery.model.DatasetReference; import com.google.api.services.bigquery.model.ExternalCatalogDatasetOptions; import com.google.api.services.bigquery.model.TableReference; -import com.google.cloud.ServiceOptions; import com.google.cloud.bigquery.BigQueryOptions; import java.io.IOException; import java.io.UncheckedIOException; @@ -58,14 +57,25 @@ public class BigQueryMetastoreCatalog extends BaseMetastoreCatalog implements SupportsNamespaces, Configurable { - // User provided properties. - public static final String PROJECT_ID = "gcp.bigquery.project-id"; - public static final String GCP_LOCATION = "gcp.bigquery.location"; - public static final String LIST_ALL_TABLES = "gcp.bigquery.list-all-tables"; + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link BigQueryProperties#PROJECT_ID} + * instead. + */ + @Deprecated public static final String PROJECT_ID = "gcp.bigquery.project-id"; - private static final Logger LOG = LoggerFactory.getLogger(BigQueryMetastoreCatalog.class); + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * BigQueryProperties#GCP_LOCATION} instead. + */ + @Deprecated public static final String GCP_LOCATION = "gcp.bigquery.location"; + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * BigQueryProperties#LIST_ALL_TABLES} instead. + */ + @Deprecated public static final String LIST_ALL_TABLES = "gcp.bigquery.list-all-tables"; - private static final String DEFAULT_GCP_LOCATION = "us"; + private static final Logger LOG = LoggerFactory.getLogger(BigQueryMetastoreCatalog.class); private String catalogName; private Map catalogProperties; @@ -81,23 +91,17 @@ public BigQueryMetastoreCatalog() {} @Override public void initialize(String name, Map properties) { - Preconditions.checkArgument( - properties.containsKey(PROJECT_ID), - "Invalid GCP project: %s must be specified", - PROJECT_ID); - this.projectId = properties.get(PROJECT_ID); - this.projectLocation = properties.getOrDefault(GCP_LOCATION, DEFAULT_GCP_LOCATION); + BigQueryProperties bigQueryProperties = new BigQueryProperties(properties); + + this.projectId = bigQueryProperties.projectId(); + this.projectLocation = bigQueryProperties.location(); + this.listAllTables = bigQueryProperties.listAllTables(); - BigQueryOptions options = - BigQueryOptions.newBuilder() - .setProjectId(projectId) - .setLocation(projectLocation) - .setRetrySettings(ServiceOptions.getDefaultRetrySettings()) - .build(); + BigQueryOptions bigQueryOptions = bigQueryProperties.metastoreOptions(); try { - client = new BigQueryMetastoreClientImpl(options); + client = new BigQueryMetastoreClientImpl(bigQueryOptions); } catch (IOException e) { throw new UncheckedIOException("Creating BigQuery client failed", e); } catch (GeneralSecurityException e) { @@ -134,8 +138,6 @@ void initialize( CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.io.ResolvingFileIO"), properties, conf); - - this.listAllTables = Boolean.parseBoolean(properties.getOrDefault(LIST_ALL_TABLES, "true")); } @Override diff --git a/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreClientImpl.java b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreClientImpl.java index 9af9844d84de..beee73628975 100644 --- a/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreClientImpl.java +++ b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryMetastoreClientImpl.java @@ -28,7 +28,6 @@ import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.util.Data; import com.google.api.services.bigquery.Bigquery; -import com.google.api.services.bigquery.BigqueryScopes; import com.google.api.services.bigquery.model.Dataset; import com.google.api.services.bigquery.model.DatasetList; import com.google.api.services.bigquery.model.DatasetList.Datasets; @@ -61,6 +60,7 @@ import org.apache.iceberg.BaseMetastoreTableOperations; import org.apache.iceberg.exceptions.AlreadyExistsException; import org.apache.iceberg.exceptions.BadRequestException; +import org.apache.iceberg.exceptions.CommitFailedException; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchIcebergTableException; @@ -70,7 +70,6 @@ import org.apache.iceberg.exceptions.RuntimeIOException; import org.apache.iceberg.exceptions.ServiceFailureException; import org.apache.iceberg.exceptions.ServiceUnavailableException; -import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -127,9 +126,10 @@ public BigQueryMetastoreClientImpl(BigQueryOptions options) throws IOException, GeneralSecurityException { // Initialize client that will be used to send requests. This client only needs to be created // once, and can be reused for multiple requests - HttpCredentialsAdapter httpCredentialsAdapter = - new HttpCredentialsAdapter( - GoogleCredentials.getApplicationDefault().createScoped(BigqueryScopes.all())); + + GoogleCredentials credentials = (GoogleCredentials) options.getCredentials(); + HttpCredentialsAdapter httpCredentialsAdapter = new HttpCredentialsAdapter(credentials); + this.client = new Bigquery.Builder( GoogleNetHttpTransport.newTrustedTransport(), @@ -650,7 +650,7 @@ private static HttpResponse convertExceptionIfUnsuccessful(HttpResponse response case HttpStatusCodes.STATUS_CODE_FORBIDDEN: throw new ForbiddenException("%s", errorMessage); case HttpStatusCodes.STATUS_CODE_PRECONDITION_FAILED: - throw new ValidationException("%s", errorMessage); + throw new CommitFailedException("%s", errorMessage); case HttpStatusCodes.STATUS_CODE_NOT_FOUND: throw new IllegalArgumentException(errorMessage); case HttpStatusCodes.STATUS_CODE_SERVER_ERROR: diff --git a/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryProperties.java b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryProperties.java new file mode 100644 index 000000000000..3e352e66bd4d --- /dev/null +++ b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryProperties.java @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.bigquery; + +import com.google.api.services.bigquery.BigqueryScopes; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ImpersonatedCredentials; +import com.google.cloud.ServiceOptions; +import com.google.cloud.bigquery.BigQueryOptions; +import java.io.IOException; +import java.io.Serializable; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class BigQueryProperties implements Serializable { + + private static final Logger LOG = LoggerFactory.getLogger(BigQueryProperties.class); + + // User provided properties. + public static final String PROJECT_ID = "gcp.bigquery.project-id"; + public static final String GCP_LOCATION = "gcp.bigquery.location"; + public static final String LIST_ALL_TABLES = "gcp.bigquery.list-all-tables"; + + // Service account impersonation properties. + public static final String IMPERSONATE_SERVICE_ACCOUNT = + "gcp.bigquery.impersonate.service-account"; + public static final String IMPERSONATE_LIFETIME_SECONDS = + "gcp.bigquery.impersonate.lifetime-seconds"; + public static final String IMPERSONATE_SCOPES = "gcp.bigquery.impersonate.scopes"; + public static final String IMPERSONATE_DELEGATES = "gcp.bigquery.impersonate.delegates"; + + public static final String DEFAULT_GCP_LOCATION = "us"; + private static final int DEFAULT_LIFETIME_SECONDS = 3600; + private static final List DEFAULT_SCOPES = + ImmutableList.of("https://www.googleapis.com/auth/cloud-platform"); + + private final String projectId; + private final String location; + private final boolean listAllTables; + private final String impersonateServiceAccount; + private final int lifetimeSeconds; + private final List scopes; + private final List delegates; + + @VisibleForTesting + List parseCommaSeparatedList(String input, List defaultValue) { + if (input == null || input.trim().isEmpty()) { + return defaultValue; + } + return Arrays.stream(input.split(",")) + .map(String::trim) + .filter(str -> !str.isEmpty()) + .distinct() + .collect(Collectors.toList()); + } + + @VisibleForTesting + List expandScopes(List inputScopes) { + if (inputScopes == null || inputScopes.isEmpty()) { + return inputScopes; + } + return inputScopes.stream() + .map( + inputScope -> { + if (inputScope.startsWith("https://")) { + return inputScope; + } + if (inputScope.startsWith("http://")) { + return inputScope.replace("http://", "https://"); + } + + return "https://www.googleapis.com/auth/" + inputScope; + }) + .collect(Collectors.toList()); + } + + BigQueryProperties(Map properties) { + Preconditions.checkNotNull(properties, "Properties cannot be null"); + + this.projectId = properties.get(PROJECT_ID); + Preconditions.checkArgument( + projectId != null, "Invalid GCP project: %s must be specified", PROJECT_ID); + + this.location = properties.getOrDefault(GCP_LOCATION, DEFAULT_GCP_LOCATION); + + this.listAllTables = Boolean.parseBoolean(properties.getOrDefault(LIST_ALL_TABLES, "true")); + + // Impersonation properties, optional + this.impersonateServiceAccount = properties.get(IMPERSONATE_SERVICE_ACCOUNT); + + this.lifetimeSeconds = + Integer.parseInt( + properties.getOrDefault( + IMPERSONATE_LIFETIME_SECONDS, String.valueOf(DEFAULT_LIFETIME_SECONDS))); + + List rawScopes = + parseCommaSeparatedList(properties.get(IMPERSONATE_SCOPES), DEFAULT_SCOPES); + this.scopes = expandScopes(rawScopes); + + this.delegates = parseCommaSeparatedList(properties.get(IMPERSONATE_DELEGATES), null); + + if (impersonateServiceAccount != null) { + LOG.info( + "BigQuery impersonation configured for service account: {}, lifetime: {} seconds", + impersonateServiceAccount, + lifetimeSeconds); + } + } + + String projectId() { + return projectId; + } + + String location() { + return location; + } + + boolean listAllTables() { + return listAllTables; + } + + BigQueryOptions metastoreOptions() { + BigQueryOptions.Builder builder = + BigQueryOptions.newBuilder() + .setProjectId(projectId) + .setLocation(location) + .setRetrySettings(ServiceOptions.getDefaultRetrySettings()); + + if (impersonateServiceAccount != null) { + builder.setCredentials(buildImpersonatedCredentials()); + } else { + builder.setCredentials(buildApplicationDefaultCredentials()); + } + + return builder.build(); + } + + private GoogleCredentials buildApplicationDefaultCredentials() { + try { + GoogleCredentials applicationDefaultCredentials = + GoogleCredentials.getApplicationDefault().createScoped(BigqueryScopes.all()); + + LOG.debug( + "Created application default credentials for BigQuery: {}", + applicationDefaultCredentials); + + return applicationDefaultCredentials; + } catch (IOException e) { + throw new UncheckedIOException("Failed to get application default credentials", e); + } + } + + private ImpersonatedCredentials buildImpersonatedCredentials() { + try { + GoogleCredentials sourceCredentials = GoogleCredentials.getApplicationDefault(); + + ImpersonatedCredentials impersonatedCredentials = + ImpersonatedCredentials.create( + sourceCredentials, impersonateServiceAccount, delegates, scopes, lifetimeSeconds); + + // refresh to validate credentials and get intial token + impersonatedCredentials.refresh(); + + LOG.debug( + "Created impersonated credentials for BigQuery: Target={}", impersonateServiceAccount); + + return impersonatedCredentials; + } catch (IOException e) { + throw new UncheckedIOException( + "Failed to create impersonated credentials for " + impersonateServiceAccount, e); + } + } +} diff --git a/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryTableOperations.java b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryTableOperations.java index d57aab50530a..37728aa15794 100644 --- a/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryTableOperations.java +++ b/bigquery/src/main/java/org/apache/iceberg/gcp/bigquery/BigQueryTableOperations.java @@ -21,7 +21,6 @@ import com.google.api.services.bigquery.model.ExternalCatalogTableOptions; import com.google.api.services.bigquery.model.Table; import com.google.api.services.bigquery.model.TableReference; -import java.util.Locale; import java.util.Map; import org.apache.iceberg.BaseMetastoreOperations; import org.apache.iceberg.BaseMetastoreTableOperations; @@ -34,6 +33,7 @@ import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,6 +49,9 @@ final class BigQueryTableOperations extends BaseMetastoreTableOperations { private final FileIO fileIO; private final TableReference tableReference; + /** Table loaded in doRefresh() for reuse in updateTable() to avoid redundant API call. */ + private volatile Table metastoreTable; + BigQueryTableOperations( BigQueryMetastoreClient client, FileIO fileIO, TableReference tableReference) { this.client = client; @@ -61,9 +64,11 @@ final class BigQueryTableOperations extends BaseMetastoreTableOperations { public void doRefresh() { // Must default to null. String metadataLocation = null; + this.metastoreTable = null; try { + this.metastoreTable = client.load(tableReference); metadataLocation = - loadMetadataLocationOrThrow(client.load(tableReference).getExternalCatalogTableOptions()); + loadMetadataLocationOrThrow(metastoreTable.getExternalCatalogTableOptions()); } catch (NoSuchTableException e) { if (currentMetadataLocation() != null) { // Re-throws the exception because the table must exist in this case. @@ -87,7 +92,7 @@ public void doCommit(TableMetadata base, TableMetadata metadata) { if (base == null) { createTable(newMetadataLocation, metadata); } else { - updateTable(base.metadataFileLocation(), newMetadataLocation, metadata); + updateTable(newMetadataLocation, metadata); } commitStatus = BaseMetastoreOperations.CommitStatus.SUCCESS; } catch (CommitFailedException | CommitStateUnknownException e) { @@ -150,44 +155,24 @@ private void addConnectionIfProvided(Table tableBuilder, Map met } /** Update table properties with concurrent update detection using etag. */ - private void updateTable( - String oldMetadataLocation, String newMetadataLocation, TableMetadata metadata) { - Table table = client.load(tableReference); - if (table.getEtag().isEmpty()) { + private void updateTable(String newMetadataLocation, TableMetadata metadata) { + Preconditions.checkState( + metastoreTable != null, + "Table %s must be loaded during refresh before commit", + tableName()); + + if (metastoreTable.getEtag().isEmpty()) { throw new ValidationException( "Etag of legacy table %s is empty, manually update the table via the BigQuery API or" + " recreate and retry", tableName()); } - ExternalCatalogTableOptions options = table.getExternalCatalogTableOptions(); - addConnectionIfProvided(table, metadata.properties()); - - // If `metadataLocationFromMetastore` is different from metadata location of base, it means - // someone has updated metadata location in metastore, which is a conflict update. - String metadataLocationFromMetastore = - options.getParameters().getOrDefault(METADATA_LOCATION_PROP, ""); - if (!metadataLocationFromMetastore.isEmpty() - && !metadataLocationFromMetastore.equals(oldMetadataLocation)) { - throw new CommitFailedException( - "Cannot commit base metadata location '%s' is not same as the current table metadata location '%s' for" - + " %s.%s", - oldMetadataLocation, - metadataLocationFromMetastore, - tableReference.getDatasetId(), - tableReference.getTableId()); - } + ExternalCatalogTableOptions options = metastoreTable.getExternalCatalogTableOptions(); + addConnectionIfProvided(metastoreTable, metadata.properties()); options.setParameters(buildTableParameters(newMetadataLocation, metadata)); - try { - client.update(tableReference, table); - } catch (ValidationException e) { - if (e.getMessage().toLowerCase(Locale.ENGLISH).contains("etag mismatch")) { - throw new CommitFailedException( - "Updating table failed due to conflict updates (etag mismatch). Retry the update"); - } - - throw e; - } + client.update(tableReference, metastoreTable); + this.metastoreTable = null; } // To make the table queryable from Hive, the user would likely be setting the HIVE_ENGINE_ENABLED diff --git a/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/FakeBigQueryMetastoreClient.java b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/FakeBigQueryMetastoreClient.java index 0c6df15091a6..3619f7908c38 100644 --- a/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/FakeBigQueryMetastoreClient.java +++ b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/FakeBigQueryMetastoreClient.java @@ -180,18 +180,11 @@ public Table update(TableReference tableReference, Table table) { String incomingEtag = table.getEtag(); String requiredEtag = existingTable.getEtag(); - // The real patch() uses an If-Match header which is passed separately, - // NOT on the incoming table object. - // The BigQueryTableOperations does NOT set the ETag on the Table object - // it passes to the client update() method. - // For a fake, we assume the ETag check needs to be simulated based on - // state, BUT the real client.update() expects the ETAG as a separate parameter - // (or implicitly via setIfMatch header, which this Fake doesn't see). - // To make the fake usable, we'll assume that if an ETag *is* present - // on the incoming table object, it must match. + // Simulate ETag-based optimistic locking. If the incoming table has an ETag, + // it must match the current ETag in the store. if (incomingEtag != null && !incomingEtag.equals(requiredEtag)) { throw new CommitFailedException( - "Etag mismatch for table: %s. Required: %s, Found: %s", + "Cannot commit: Etag mismatch for table: %s. Required: %s, Found: %s", tableReference, requiredEtag, incomingEtag); } diff --git a/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryCatalog.java b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryCatalog.java index f7594f78ffac..23441d0db184 100644 --- a/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryCatalog.java +++ b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryCatalog.java @@ -20,10 +20,11 @@ import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_BIGQUERY; -import static org.apache.iceberg.gcp.bigquery.BigQueryMetastoreCatalog.PROJECT_ID; +import static org.apache.iceberg.gcp.bigquery.BigQueryProperties.PROJECT_ID; import static org.assertj.core.api.Assertions.assertThat; import java.io.File; +import java.io.IOException; import java.util.List; import java.util.Map; import org.apache.iceberg.CatalogProperties; @@ -169,6 +170,18 @@ public void testRenameTableMissingSourceTable() { super.testRenameTableMissingSourceTable(); } + @Disabled("BigQuery Metastore does not support rename tables") + @Test + public void createTableInUniqueLocation() { + super.createTableInUniqueLocation(); + } + + @Disabled("BigQuery Metastore does not support rename tables") + @Test + public void dropAfterRenameDoesntCorruptTable() throws IOException { + super.dropAfterRenameDoesntCorruptTable(); + } + @Test public void testIsValidIdentifierWithValidSingleLevelNamespace() { assertThat(catalog.isValidIdentifier(TableIdentifier.of("dataset1", "table1"))).isTrue(); diff --git a/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryProperties.java b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryProperties.java new file mode 100644 index 000000000000..83fb526ae739 --- /dev/null +++ b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryProperties.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.bigquery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class TestBigQueryProperties { + + @Test + public void testInitializeWithValidProperties() { + Map properties = + Map.of( + BigQueryProperties.PROJECT_ID, "test-project", + BigQueryProperties.GCP_LOCATION, "us-central1"); + BigQueryProperties bigQueryProperties = new BigQueryProperties(properties); + + assertThat(bigQueryProperties.projectId()).isEqualTo("test-project"); + assertThat(bigQueryProperties.location()).isEqualTo("us-central1"); + } + + @Test + public void testInitializeWithDefaultLocation() { + Map properties = Map.of(BigQueryProperties.PROJECT_ID, "test-project"); + BigQueryProperties bigQueryProperties = new BigQueryProperties(properties); + + assertThat(bigQueryProperties.projectId()).isEqualTo("test-project"); + assertThat(bigQueryProperties.location()).isEqualTo("us"); + } + + @Test + public void testInitializeWithNullProperties() { + assertThatThrownBy(() -> new BigQueryProperties(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Properties cannot be null"); + } + + @Test + public void testInitializeWithoutProjectId() { + Map properties = Map.of(BigQueryProperties.GCP_LOCATION, "us-central1"); + assertThatThrownBy(() -> new BigQueryProperties(properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("gcp.bigquery.project-id"); + } + + @Test + public void testExpandScopesMixedFormats() { + Map properties = Map.of(BigQueryProperties.PROJECT_ID, "test-project"); + BigQueryProperties bigQueryProperties = new BigQueryProperties(properties); + + assertThat( + bigQueryProperties.expandScopes( + Arrays.asList( + "bigquery", + "http://www.googleapis.com/auth/devstorage.read_write", + "https://www.googleapis.com/auth/cloud-platform"))) + .containsExactly( + "https://www.googleapis.com/auth/bigquery", + "https://www.googleapis.com/auth/devstorage.read_write", + "https://www.googleapis.com/auth/cloud-platform"); + } + + @Test + public void testParseCommaSeparatedList() { + Map properties = Map.of(BigQueryProperties.PROJECT_ID, "test-project"); + BigQueryProperties bigQueryProperties = new BigQueryProperties(properties); + + assertThat(bigQueryProperties.parseCommaSeparatedList("a, b, c", null)) + .containsExactly("a", "b", "c"); + assertThat(bigQueryProperties.parseCommaSeparatedList(null, Arrays.asList("default"))) + .containsExactly("default"); + assertThat(bigQueryProperties.parseCommaSeparatedList("", Arrays.asList("default"))) + .containsExactly("default"); + } +} diff --git a/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryTableOperations.java b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryTableOperations.java index 9b8b90e1f83d..c5095aedb000 100644 --- a/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryTableOperations.java +++ b/bigquery/src/test/java/org/apache/iceberg/gcp/bigquery/TestBigQueryTableOperations.java @@ -19,7 +19,7 @@ package org.apache.iceberg.gcp.bigquery; import static org.apache.iceberg.BaseMetastoreTableOperations.METADATA_LOCATION_PROP; -import static org.apache.iceberg.gcp.bigquery.BigQueryMetastoreCatalog.PROJECT_ID; +import static org.apache.iceberg.gcp.bigquery.BigQueryProperties.PROJECT_ID; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; @@ -177,35 +177,33 @@ public void failWhenEtagMismatch() throws Exception { org.apache.iceberg.Table loadedTable = catalog.loadTable(IDENTIFIER); when(client.update(any(), any())) - .thenThrow(new ValidationException("error message etag mismatch")); + .thenThrow(new CommitFailedException("error message etag mismatch")); assertThatThrownBy( () -> loadedTable.updateSchema().addColumn("n", Types.IntegerType.get()).commit()) .isInstanceOf(CommitFailedException.class) - .hasMessageContaining( - "Updating table failed due to conflict updates (etag mismatch). Retry the update"); + .hasMessage("error message etag mismatch"); } @Test - public void failWhenMetadataLocationDiff() throws Exception { + public void failWhenConcurrentModificationDetected() throws Exception { Table tableWithEtag = createTestTable().setEtag("etag"); - Table tableWithNewMetadata = - new Table() - .setEtag("etag") - .setExternalCatalogTableOptions( - new ExternalCatalogTableOptions() - .setParameters(ImmutableMap.of(METADATA_LOCATION_PROP, "a/new/location"))); reset(client); - // Two invocations, for loadTable and commit. - when(client.load(TABLE_REFERENCE)).thenReturn(tableWithEtag, tableWithNewMetadata); + when(client.load(TABLE_REFERENCE)).thenReturn(tableWithEtag); org.apache.iceberg.Table loadedTable = catalog.loadTable(IDENTIFIER); - when(client.update(any(), any())).thenReturn(tableWithEtag); + // Simulate concurrent modification detected via ETag mismatch + when(client.update(any(), any())) + .thenThrow(new CommitFailedException("Cannot commit: Etag mismatch")); + assertThatThrownBy( () -> loadedTable.updateSchema().addColumn("n", Types.IntegerType.get()).commit()) .isInstanceOf(CommitFailedException.class) - .hasMessageContaining("is not same as the current table metadata location"); + .hasMessageContaining("Cannot commit"); + + // Verify table is loaded only once + verify(client, times(1)).load(TABLE_REFERENCE); } @Test diff --git a/build.gradle b/build.gradle index 3220f8ba8d98..249e5ba91dab 100644 --- a/build.gradle +++ b/build.gradle @@ -26,17 +26,17 @@ buildscript { gradlePluginPortal() } dependencies { - classpath 'com.gradleup.shadow:shadow-gradle-plugin:8.3.9' - classpath 'com.palantir.baseline:gradle-baseline-java:5.72.0' - classpath 'com.diffplug.spotless:spotless-plugin-gradle:6.25.0' + classpath 'com.gradleup.shadow:shadow-gradle-plugin:8.3.10' + classpath 'com.palantir.baseline:gradle-baseline-java:6.90.0' + classpath 'com.diffplug.spotless:spotless-plugin-gradle:8.4.0' classpath 'gradle.plugin.org.inferred:gradle-processors:3.7.0' classpath 'me.champeau.jmh:jmh-gradle-plugin:0.7.3' classpath 'gradle.plugin.io.morethan.jmhreport:gradle-jmh-report:0.9.6' classpath "com.github.alisiikh:gradle-scalastyle-plugin:3.5.0" classpath 'org.revapi:gradle-revapi:1.8.0' - classpath 'com.gorylenko.gradle-git-properties:gradle-git-properties:2.5.4' - classpath 'com.palantir.gradle.gitversion:gradle-git-version:3.4.0' - classpath 'org.openapitools:openapi-generator-gradle-plugin:7.17.0' + classpath 'com.gorylenko.gradle-git-properties:gradle-git-properties:2.5.7' + classpath 'com.palantir.gradle.gitversion:gradle-git-version:4.3.0' + classpath 'org.openapitools:openapi-generator-gradle-plugin:7.22.0' } } @@ -51,10 +51,7 @@ try { project.logger.error(e.getMessage()) } -if (JavaVersion.current() == JavaVersion.VERSION_11) { - project.ext.jdkVersion = '11' - project.ext.extraJvmArgs = [] -} else if (JavaVersion.current() == JavaVersion.VERSION_17 || JavaVersion.current() == JavaVersion.VERSION_21) { +if (JavaVersion.current() == JavaVersion.VERSION_17 || JavaVersion.current() == JavaVersion.VERSION_21) { project.ext.jdkVersion = JavaVersion.current().getMajorVersion().toString() project.ext.extraJvmArgs = ["--add-opens", "java.base/java.io=ALL-UNNAMED", "--add-opens", "java.base/java.lang.invoke=ALL-UNNAMED", @@ -78,7 +75,7 @@ if (JavaVersion.current() == JavaVersion.VERSION_11) { "--add-opens", "java.base/sun.security.action=ALL-UNNAMED", "--add-opens", "java.base/sun.util.calendar=ALL-UNNAMED"] } else { - throw new GradleException("This build must be run with JDK 11 or 17 or 21 but was executed with JDK " + JavaVersion.current()) + throw new GradleException("This build must be run with JDK 17 or 21 but was executed with JDK " + JavaVersion.current()) } tasks.withType(AbstractArchiveTask).configureEach { @@ -123,6 +120,15 @@ allprojects { } } +tasks.register('checkAllRuntimeDeps') { + description = 'Validates runtime dependency baselines for all subprojects that have them' + group = 'verification' + + dependsOn subprojects.collect { subproject -> + subproject.tasks.matching { it.name == 'checkRuntimeDeps' } + } +} + subprojects { if (it.name == 'iceberg-bom') { // the BOM does not build anything, the code below expects "source code" @@ -196,6 +202,19 @@ subprojects { exclude group: 'com.sun.jersey.contribs' exclude group: 'org.pentaho', module: 'pentaho-aggdesigner-algorithm' exclude group: 'org.jetbrains', module: 'annotations' + resolutionStrategy { + dependencySubstitution { + substitute module("org.lz4:lz4-java") using module(libs.lz4Java.get().toString()) because("Enforce lz4-java that contains CVE-2025-12183 and CVE-2025-66566 fixes") + substitute module("io.airlift:aircompressor") using module(libs.aircompressor.get().toString()) because("Enforce aircompressor that contains CVE-2025-67721 fix") + substitute module("org.bouncycastle:bcprov-jdk18on") using module(libs.bouncycastle.bcprov.get().toString()) because("Enforce BouncyCastle that contains CVE-2026-5598 fix") + } + eachDependency { details -> + if (details.requested.group == 'io.netty' && details.requested.version?.startsWith('4.1.')) { + details.useVersion(libs.versions.netty.buffer.get()) + details.because("Fix Netty 4.1.x CVEs (CVE-2026-42577, CVE-2026-42579, CVE-2026-42583, CVE-2026-42584, CVE-2026-42587)") + } + } + } } testArtifacts @@ -203,7 +222,7 @@ subprojects { tasks.withType(JavaCompile.class).configureEach { options.encoding = "UTF-8" - options.release = 11 + options.release = 17 } javadoc { @@ -262,9 +281,9 @@ subprojects { tasks.withType(ScalaCompile.class) { scalaCompileOptions.keepAliveMode.set(KeepAliveMode.DAEMON) // `options.release` doesn't seem to work for ScalaCompile :( - sourceCompatibility = "11" - targetCompatibility = "11" - scalaCompileOptions.additionalParameters.add("-release:11") + sourceCompatibility = "17" + targetCompatibility = "17" + scalaCompileOptions.additionalParameters.add("-release:17") } } } @@ -322,6 +341,7 @@ project(':iceberg-api') { testImplementation libs.avro.avro testImplementation libs.esotericsoftware.kryo testImplementation libs.awaitility + testImplementation libs.junit.pioneer } tasks.processTestResources.dependsOn rootProject.tasks.buildInfo @@ -384,7 +404,8 @@ project(':iceberg-core') { testImplementation libs.jetty.servlet testImplementation libs.jakarta.servlet - testImplementation libs.jetty.server + testImplementation libs.jetty.compression.server + testImplementation libs.jetty.compression.gzip testImplementation libs.mockserver.netty testImplementation libs.mockserver.client.java testImplementation libs.sqlite.jdbc @@ -396,6 +417,13 @@ project(':iceberg-core') { exclude group: 'junit' } testImplementation libs.awaitility + + // Lock BouncyCastle versions to avoid version mismatches + // when these dependencies are added transitively. + // Required for TLS tests with MockServer. + testImplementation libs.bouncycastle.bcpkix + testImplementation libs.bouncycastle.bcutil + testImplementation libs.bouncycastle.bcprov } } @@ -436,6 +464,8 @@ project(':iceberg-data') { testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation(testFixtures(project(':iceberg-parquet'))) } test { @@ -456,6 +486,8 @@ project(':iceberg-aliyun') { implementation project(':iceberg-common') compileOnly libs.aliyun.sdk.oss + compileOnly libs.aliyun.credentials.java + compileOnly libs.aliyun.tea compileOnly libs.jaxb.api compileOnly libs.activation compileOnly libs.jaxb.runtime @@ -469,6 +501,7 @@ project(':iceberg-aliyun') { testImplementation platform(libs.jackson.bom) testImplementation "com.fasterxml.jackson.dataformat:jackson-dataformat-xml" testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') + testImplementation libs.junit.pioneer } } @@ -530,6 +563,8 @@ project(':iceberg-aws') { testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation libs.awaitility testImplementation libs.jetty.servlet + testImplementation libs.jetty.compression.server + testImplementation libs.jetty.compression.gzip testImplementation libs.mockito.junit.jupiter } @@ -554,7 +589,8 @@ project(':iceberg-aws') { jvmArgs += project.property('extraJvmArgs') } - def s3SignerSpec = "$projectDir/src/main/resources/s3-signer-open-api.yaml" + // TODO delete once s3-signer-open-api.yaml is removed + def s3SignerSpec = layout.projectDirectory.file("src/main/resources/s3-signer-open-api.yaml") tasks.register('validateS3SignerSpec', org.openapitools.generator.gradle.plugin.tasks.ValidateTask) { inputSpec.set(s3SignerSpec) recommend.set(true) @@ -576,6 +612,7 @@ project(':iceberg-azure') { compileOnly platform(libs.azuresdk.bom) compileOnly "com.azure:azure-storage-file-datalake" + compileOnly "com.azure:azure-security-keyvault-keys" compileOnly "com.azure:azure-identity" testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') @@ -699,10 +736,10 @@ project(':iceberg-bigquery') { implementation project(path: ':iceberg-bundled-guava', configuration: 'shadow') - implementation platform(libs.google.libraries.bom) + compileOnly platform(libs.google.libraries.bom) compileOnly "com.google.cloud:google-cloud-storage" - implementation "com.google.cloud:google-cloud-bigquery" - implementation "com.google.cloud:google-cloud-core" + compileOnly "com.google.cloud:google-cloud-bigquery" + compileOnly "com.google.cloud:google-cloud-core" testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') @@ -730,6 +767,7 @@ project(':iceberg-gcp') { compileOnly platform(libs.google.libraries.bom) compileOnly "com.google.cloud:google-cloud-storage" compileOnly "com.google.cloud:google-cloud-kms" + compileOnly(libs.gcs.analytics.core) testImplementation "com.google.cloud:google-cloud-nio" @@ -742,10 +780,13 @@ project(':iceberg-gcp') { exclude group: 'javax.servlet', module: 'servlet-api' exclude group: 'com.google.code.gson', module: 'gson' } + testImplementation libs.awaitility testImplementation libs.esotericsoftware.kryo testImplementation libs.mockserver.netty testImplementation libs.mockserver.client.java testImplementation libs.mockito.junit.jupiter + testImplementation libs.testcontainers + testImplementation libs.testcontainers.junit.jupiter } sourceSets { @@ -907,6 +948,13 @@ project(':iceberg-parquet') { exclude group: 'org.apache.avro', module: 'avro' } + testFixturesApi(libs.parquet.hadoop) { + exclude group: 'org.apache.avro', module: 'avro' + // already shaded by Parquet + exclude group: 'it.unimi.dsi' + exclude group: 'org.codehaus.jackson' + } + testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') } @@ -953,8 +1001,6 @@ project(':iceberg-arrow') { } project(':iceberg-nessie') { - // Starting from Nessie 0.104.2, JDK 17+ is required for test dependencies - test.onlyIf { JavaVersion.current() != JavaVersion.VERSION_11 } test { useJUnitPlatform() } @@ -1084,7 +1130,8 @@ project(':iceberg-open-api') { testFixturesImplementation libs.slf4j.simple testFixturesImplementation libs.jetty.servlet - testFixturesImplementation libs.jetty.server + testFixturesImplementation libs.jetty.compression.server + testFixturesImplementation libs.jetty.compression.gzip testFixturesImplementation libs.sqlite.jdbc testFixturesCompileOnly libs.apiguardian @@ -1109,7 +1156,7 @@ project(':iceberg-open-api') { .collectEntries { k, v -> { [(k):v, (k.replaceFirst("rck.", "")):v] }} // strip prefix } - def restCatalogSpec = "$projectDir/rest-catalog-open-api.yaml" + def restCatalogSpec = layout.projectDirectory.file("rest-catalog-open-api.yaml") tasks.register('validateRESTCatalogSpec', org.openapitools.generator.gradle.plugin.tasks.ValidateTask) { inputSpec.set(restCatalogSpec) recommend.set(true) diff --git a/bundled-guava/LICENSE b/bundled-guava/LICENSE index 597de019cdd7..4522df9373ad 100644 --- a/bundled-guava/LICENSE +++ b/bundled-guava/LICENSE @@ -203,9 +203,9 @@ -------------------------------------------------------------------------------- -This binary artifact contains Google Guava. +This product bundles Google Guava. Copyright: 2006-2019 The Guava Authors -Home page: https://github.com/google/guava -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 diff --git a/bundled-guava/NOTICE b/bundled-guava/NOTICE index f29e2b820bf3..161f3980172d 100644 --- a/bundled-guava/NOTICE +++ b/bundled-guava/NOTICE @@ -1,6 +1,6 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). diff --git a/common/src/main/java/org/apache/iceberg/common/DynConstructors.java b/common/src/main/java/org/apache/iceberg/common/DynConstructors.java index 500389f832cd..bf857a15ab89 100644 --- a/common/src/main/java/org/apache/iceberg/common/DynConstructors.java +++ b/common/src/main/java/org/apache/iceberg/common/DynConstructors.java @@ -182,6 +182,7 @@ public Builder hiddenImpl(String className, Class... types) { return this; } + @SuppressWarnings("removal") public Builder hiddenImpl(Class targetClass, Class... types) { // don't do any work if an implementation has been found if (ctor != null) { diff --git a/common/src/main/java/org/apache/iceberg/common/DynFields.java b/common/src/main/java/org/apache/iceberg/common/DynFields.java index cc397d329e94..d3c806bd40db 100644 --- a/common/src/main/java/org/apache/iceberg/common/DynFields.java +++ b/common/src/main/java/org/apache/iceberg/common/DynFields.java @@ -48,7 +48,7 @@ private UnboundField(Field field, String name) { this.name = name; } - @SuppressWarnings("unchecked") + @SuppressWarnings({"unchecked", "deprecation"}) public T get(Object target) { try { return (T) field.get(target); @@ -57,6 +57,7 @@ public T get(Object target) { } } + @SuppressWarnings("deprecation") public void set(Object target, T value) { try { field.set(target, value); @@ -299,6 +300,7 @@ public Builder hiddenImpl(String className, String fieldName) { * @see java.lang.Class#forName(String) * @see java.lang.Class#getField(String) */ + @SuppressWarnings("removal") public Builder hiddenImpl(Class targetClass, String fieldName) { // don't do any work if an implementation has been found if (field != null || targetClass == null) { diff --git a/common/src/main/java/org/apache/iceberg/common/DynMethods.java b/common/src/main/java/org/apache/iceberg/common/DynMethods.java index 072a5c8b2a21..5760c8694abe 100644 --- a/common/src/main/java/org/apache/iceberg/common/DynMethods.java +++ b/common/src/main/java/org/apache/iceberg/common/DynMethods.java @@ -51,7 +51,7 @@ public static class UnboundMethod { (method == null || method.isVarArgs()) ? -1 : method.getParameterTypes().length; } - @SuppressWarnings("unchecked") + @SuppressWarnings({"unchecked", "deprecation"}) R invokeChecked(Object target, Object... args) throws Exception { try { if (argLength < 0) { @@ -67,6 +67,7 @@ R invokeChecked(Object target, Object... args) throws Exception { } } + @SuppressWarnings("deprecation") public R invoke(Object target, Object... args) { try { return this.invokeChecked(target, args); @@ -364,6 +365,7 @@ public Builder hiddenImpl(String className, Class... argClasses) { * @see java.lang.Class#forName(String) * @see java.lang.Class#getMethod(String, Class[]) */ + @SuppressWarnings("removal") public Builder hiddenImpl(Class targetClass, String methodName, Class... argClasses) { // don't do any work if an implementation has been found if (method != null) { diff --git a/core/src/jmh/java/org/apache/iceberg/ManifestBenchmark.java b/core/src/jmh/java/org/apache/iceberg/ManifestBenchmark.java new file mode 100644 index 000000000000..cbd372b7a4ba --- /dev/null +++ b/core/src/jmh/java/org/apache/iceberg/ManifestBenchmark.java @@ -0,0 +1,279 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import org.apache.commons.io.FileUtils; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; +import org.openjdk.jmh.annotations.AuxCounters; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that measures manifest read/write performance across compression codecs. + * + *

Entry counts are calibrated per column count via {@link #ENTRY_BASE}. Set to 300_000 for ~8 MB + * manifests (matching the default {@code commit.manifest.target-size-bytes}) or 15_000 for ~400 KB. + * + *

To run this benchmark: + * + *

{@code
+ * # all combinations
+ * ./gradlew :iceberg-core:jmh -PjmhIncludeRegex=ManifestBenchmark
+ *
+ * # single codec
+ * ./gradlew :iceberg-core:jmh -PjmhIncludeRegex=ManifestBenchmark \
+ *     -PjmhParams="codec=gzip"
+ * }
+ */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 6) +@Measurement(iterations = 10) +@BenchmarkMode(Mode.SingleShotTime) +@Timeout(time = 10, timeUnit = TimeUnit.MINUTES) +public class ManifestBenchmark { + + static final int ENTRY_BASE = 300_000; + + private static final int FORMAT_VERSION = 4; + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.required(3, "customer", Types.StringType.get())); + + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).identity("id").identity("data").identity("customer").build(); + + @Param({"gzip", "snappy", "zstd", "uncompressed"}) + private String codec; + + @Param({"true", "false"}) + private String partitioned; + + @Param({"10", "50", "100"}) + private int numCols; + + private PartitionSpec spec; + private Map specsById; + private Map writerProperties; + private List dataFiles; + private int numEntries; + + private String writeBaseDir; + private OutputFile writeOutputFile; + + private String readBaseDir; + private ManifestFile readManifest; + + @Setup(Level.Trial) + public void setupTrial() { + this.spec = Boolean.parseBoolean(partitioned) ? SPEC : PartitionSpec.unpartitioned(); + this.specsById = Map.of(spec.specId(), spec); + this.writerProperties = Map.of(TableProperties.AVRO_COMPRESSION, codec); + // ENTRY_BASE / cols: empirically calibrated — 300_000 → ~8 MB, 15_000 → ~400 KB manifests + this.numEntries = ENTRY_BASE / numCols; + this.dataFiles = generateDataFiles(); + setupReadManifest(); + } + + @Setup(Level.Invocation) + public void setupWriteInvocation() throws IOException { + this.writeBaseDir = Files.createTempDirectory("bench-write-").toAbsolutePath().toString(); + this.writeOutputFile = + org.apache.iceberg.Files.localOutput( + String.format(Locale.ROOT, "%s/manifest.avro", writeBaseDir)); + + for (DataFile file : dataFiles) { + file.path(); + file.fileSizeInBytes(); + file.recordCount(); + } + } + + @TearDown(Level.Trial) + public void tearDownTrial() { + cleanDir(readBaseDir); + readBaseDir = null; + readManifest = null; + dataFiles = null; + } + + @TearDown(Level.Invocation) + public void tearDownInvocation() { + cleanDir(writeBaseDir); + writeBaseDir = null; + writeOutputFile = null; + } + + @AuxCounters(AuxCounters.Type.EVENTS) + @State(Scope.Thread) + @SuppressWarnings("checkstyle:VisibilityModifier") + public static class FileSizeCounters { + public double manifestSizeMB; + + @Setup(Level.Invocation) + public void reset() { + manifestSizeMB = 0; + } + } + + @Benchmark + @Threads(1) + public ManifestFile writeManifest(FileSizeCounters counters) throws IOException { + ManifestWriter writer = + ManifestFiles.write(FORMAT_VERSION, spec, writeOutputFile, 1L, writerProperties); + + try (ManifestWriter w = writer) { + for (DataFile file : dataFiles) { + w.add(file); + } + } + + ManifestFile manifest = writer.toManifestFile(); + counters.manifestSizeMB = manifest.length() / (1024.0 * 1024.0); + return manifest; + } + + @Benchmark + @Threads(1) + public void readManifest(Blackhole blackhole) throws IOException { + TestTables.LocalFileIO fileIO = new TestTables.LocalFileIO(); + try (CloseableIterator it = + ManifestFiles.read(readManifest, fileIO, specsById).iterator()) { + while (it.hasNext()) { + blackhole.consume(it.next()); + } + } + } + + private void setupReadManifest() { + try { + this.readBaseDir = Files.createTempDirectory("bench-read-").toAbsolutePath().toString(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + OutputFile manifestFile = + org.apache.iceberg.Files.localOutput( + String.format(Locale.ROOT, "%s/manifest.avro", readBaseDir)); + + ManifestWriter writer = + ManifestFiles.write(FORMAT_VERSION, spec, manifestFile, 1L, writerProperties); + + try (ManifestWriter w = writer) { + for (DataFile file : dataFiles) { + w.add(file); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + this.readManifest = writer.toManifestFile(); + } + + private List generateDataFiles() { + Random random = new Random(42); + List files = Lists.newArrayListWithCapacity(numEntries); + for (int i = 0; i < numEntries; i++) { + DataFiles.Builder builder = + DataFiles.builder(spec) + .withFormat(FileFormat.PARQUET) + .withPath(String.format(Locale.ROOT, "/path/to/data-%d.parquet", i)) + .withFileSizeInBytes(1024 + i) + .withRecordCount(1000 + i) + .withMetrics(randomMetrics(random, numCols)); + + if (!spec.isUnpartitioned()) { + builder.withPartitionPath( + String.format( + Locale.ROOT, "id=%d/data=val-%d/customer=cust-%d", i % 100, i % 50, i % 200)); + } + + files.add(builder.build()); + } + + return files; + } + + static Metrics randomMetrics(Random random, int cols) { + long rowCount = 100_000L + random.nextInt(1000); + Map columnSizes = Maps.newHashMap(); + Map valueCounts = Maps.newHashMap(); + Map nullValueCounts = Maps.newHashMap(); + Map nanValueCounts = Maps.newHashMap(); + Map lowerBounds = Maps.newHashMap(); + Map upperBounds = Maps.newHashMap(); + for (int i = 0; i < cols; i++) { + columnSizes.put(i, 1_000_000L + random.nextInt(100_000)); + valueCounts.put(i, 100_000L + random.nextInt(100)); + nullValueCounts.put(i, (long) random.nextInt(5)); + nanValueCounts.put(i, (long) random.nextInt(5)); + byte[] lower = new byte[8]; + random.nextBytes(lower); + lowerBounds.put(i, ByteBuffer.wrap(lower)); + byte[] upper = new byte[8]; + random.nextBytes(upper); + upperBounds.put(i, ByteBuffer.wrap(upper)); + } + + return new Metrics( + rowCount, + columnSizes, + valueCounts, + nullValueCounts, + nanValueCounts, + lowerBounds, + upperBounds); + } + + private static void cleanDir(String dir) { + if (dir != null) { + FileUtils.deleteQuietly(new File(dir)); + } + } +} diff --git a/core/src/jmh/java/org/apache/iceberg/RewriteDataFilesBenchmark.java b/core/src/jmh/java/org/apache/iceberg/RewriteDataFilesBenchmark.java index b56521548b21..34b5b58599db 100644 --- a/core/src/jmh/java/org/apache/iceberg/RewriteDataFilesBenchmark.java +++ b/core/src/jmh/java/org/apache/iceberg/RewriteDataFilesBenchmark.java @@ -146,7 +146,8 @@ private void initFiles() throws IOException { List dataFilesReadFromManifests = Lists.newArrayList(); for (ManifestFile dataManifest : table.currentSnapshot().dataManifests(table.io())) { - try (ManifestReader manifestReader = ManifestFiles.read(dataManifest, table.io())) { + try (ManifestReader manifestReader = + ManifestFiles.read(dataManifest, table.io(), table.specs())) { manifestReader .iterator() .forEachRemaining( diff --git a/core/src/main/java/org/apache/iceberg/AllManifestsTable.java b/core/src/main/java/org/apache/iceberg/AllManifestsTable.java index 16f38c0aa2f1..2435de62f0f9 100644 --- a/core/src/main/java/org/apache/iceberg/AllManifestsTable.java +++ b/core/src/main/java/org/apache/iceberg/AllManifestsTable.java @@ -38,6 +38,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; import org.apache.iceberg.util.StructProjection; /** @@ -76,7 +77,8 @@ public class AllManifestsTable extends BaseMetadataTable { Types.NestedField.required(11, "contains_nan", Types.BooleanType.get()), Types.NestedField.optional(12, "lower_bound", Types.StringType.get()), Types.NestedField.optional(13, "upper_bound", Types.StringType.get())))), - REF_SNAPSHOT_ID); + REF_SNAPSHOT_ID, + Types.NestedField.optional(19, "key_metadata", Types.BinaryType.get())); AllManifestsTable(Table table) { this(table, table.name() + ".all_manifests"); @@ -138,7 +140,7 @@ protected CloseableIterable doPlanFiles() { io, schema(), specs, - snap.manifestListLocation(), + new BaseManifestListFile(snap.manifestListLocation(), snap.keyId()), filter, snap.snapshotId()); } else { @@ -161,7 +163,7 @@ static class ManifestListReadTask implements DataTask { private final FileIO io; private final Schema schema; private final Map specs; - private final String manifestListLocation; + private final ManifestListFile manifestList; private final Expression residual; private final long referenceSnapshotId; private DataFile lazyDataFile = null; @@ -171,14 +173,14 @@ static class ManifestListReadTask implements DataTask { FileIO io, Schema schema, Map specs, - String manifestListLocation, + ManifestListFile manifestList, Expression residual, long referenceSnapshotId) { this.dataTableSchema = dataTableSchema; this.io = io; this.schema = schema; this.specs = specs; - this.manifestListLocation = manifestListLocation; + this.manifestList = manifestList; this.residual = residual; this.referenceSnapshotId = referenceSnapshotId; } @@ -191,7 +193,7 @@ public List deletes() { @Override public CloseableIterable rows() { try (CloseableIterable manifests = - InternalData.read(FileFormat.AVRO, io.newInputFile(manifestListLocation)) + InternalData.read(FileFormat.AVRO, io.newInputFile(manifestList)) .setRootType(GenericManifestFile.class) .setCustomType( ManifestFile.PARTITION_SUMMARIES_ELEMENT_ID, GenericPartitionFieldSummary.class) @@ -209,7 +211,8 @@ public CloseableIterable rows() { return CloseableIterable.transform(rowIterable, projection::wrap); } catch (IOException e) { - throw new RuntimeIOException(e, "Cannot read manifest list file: %s", manifestListLocation); + throw new RuntimeIOException( + e, "Cannot read manifest list file: %s", manifestList.location()); } } @@ -218,7 +221,7 @@ public DataFile file() { if (lazyDataFile == null) { this.lazyDataFile = DataFiles.builder(PartitionSpec.unpartitioned()) - .withInputFile(io.newInputFile(manifestListLocation)) + .withInputFile(io.newInputFile(manifestList)) .withRecordCount(1) .withFormat(FileFormat.AVRO) .build(); @@ -271,8 +274,8 @@ Map specsById() { return specs; } - String manifestListLocation() { - return manifestListLocation; + ManifestListFile manifestList() { + return manifestList; } long referenceSnapshotId() { @@ -295,7 +298,8 @@ static StaticDataTask.Row manifestFileToRow( manifest.content() == ManifestContent.DELETES ? manifest.existingFilesCount() : 0, manifest.content() == ManifestContent.DELETES ? manifest.deletedFilesCount() : 0, ManifestsTable.partitionSummariesToRows(spec, manifest.partitions()), - referenceSnapshotId); + referenceSnapshotId, + manifest.keyMetadata() == null ? null : ByteBuffers.toByteArray(manifest.keyMetadata())); } private static class SnapshotEvaluator { diff --git a/core/src/main/java/org/apache/iceberg/AllManifestsTableTaskParser.java b/core/src/main/java/org/apache/iceberg/AllManifestsTableTaskParser.java index 24c28d95466c..e6539f2d714f 100644 --- a/core/src/main/java/org/apache/iceberg/AllManifestsTableTaskParser.java +++ b/core/src/main/java/org/apache/iceberg/AllManifestsTableTaskParser.java @@ -37,6 +37,7 @@ class AllManifestsTableTaskParser { private static final String SCHEMA = "schema"; private static final String SPECS = "partition-specs"; private static final String MANIFEST_LIST_LOCATION = "manifest-list-Location"; + private static final String MANIFEST_LIST_KEY_ID = "manifest-list-key-id"; private static final String RESIDUAL = "residual-filter"; private static final String REFERENCE_SNAPSHOT_ID = "reference-snapshot-id"; @@ -63,7 +64,10 @@ static void toJson(AllManifestsTable.ManifestListReadTask task, JsonGenerator ge generator.writeEndArray(); - generator.writeStringField(MANIFEST_LIST_LOCATION, task.manifestListLocation()); + generator.writeStringField(MANIFEST_LIST_LOCATION, task.manifestList().location()); + if (task.manifestList().encryptionKeyID() != null) { + generator.writeStringField(MANIFEST_LIST_KEY_ID, task.manifestList().encryptionKeyID()); + } generator.writeFieldName(RESIDUAL); ExpressionParser.toJson(task.residual(), generator); @@ -92,6 +96,7 @@ static AllManifestsTable.ManifestListReadTask fromJson(JsonNode jsonNode) { Map specsById = PartitionUtil.indexSpecs(specsBuilder.build()); String manifestListLocation = JsonUtil.getString(MANIFEST_LIST_LOCATION, jsonNode); + String manifestListKeyId = JsonUtil.getStringOrNull(MANIFEST_LIST_KEY_ID, jsonNode); Expression residualFilter = ExpressionParser.fromJson(JsonUtil.get(RESIDUAL, jsonNode)); long referenceSnapshotId = JsonUtil.getLong(REFERENCE_SNAPSHOT_ID, jsonNode); @@ -100,7 +105,7 @@ static AllManifestsTable.ManifestListReadTask fromJson(JsonNode jsonNode) { fileIO, schema, specsById, - manifestListLocation, + new BaseManifestListFile(manifestListLocation, manifestListKeyId), residualFilter, referenceSnapshotId); } diff --git a/core/src/main/java/org/apache/iceberg/stats/BaseContentStats.java b/core/src/main/java/org/apache/iceberg/BaseContentStats.java similarity index 90% rename from core/src/main/java/org/apache/iceberg/stats/BaseContentStats.java rename to core/src/main/java/org/apache/iceberg/BaseContentStats.java index 9ba885568f03..45900b03e299 100644 --- a/core/src/main/java/org/apache/iceberg/stats/BaseContentStats.java +++ b/core/src/main/java/org/apache/iceberg/BaseContentStats.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; +package org.apache.iceberg; import java.io.Serializable; import java.util.List; @@ -24,7 +24,6 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; -import org.apache.iceberg.Schema; import org.apache.iceberg.data.GenericRecord; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -33,14 +32,14 @@ import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; -public class BaseContentStats implements ContentStats, Serializable { +class BaseContentStats implements ContentStats, Serializable { private final List> fieldStats; private final Map> fieldStatsById; private final Types.StructType statsStruct; /** Used by Avro reflection to instantiate this class when reading manifest files. */ - public BaseContentStats(Types.StructType projection) { + BaseContentStats(Types.StructType projection) { this.statsStruct = projection; this.fieldStats = Lists.newArrayListWithCapacity(projection.fields().size()); this.fieldStatsById = Maps.newLinkedHashMapWithExpectedSize(projection.fields().size()); @@ -59,6 +58,7 @@ public BaseContentStats(Types.StructType projection) { fieldStats.add( BaseFieldStats.builder() .fieldId(StatsUtil.fieldIdForStatsField(field.fieldId())) + .statsStruct(structType) .type(type) .build()); } @@ -120,8 +120,7 @@ public T get(int pos, Class javaClass) { @SuppressWarnings({"unchecked", "rawtypes", "CyclomaticComplexity"}) @Override public void set(int pos, T value) { - if (value instanceof GenericRecord) { - GenericRecord record = (GenericRecord) value; + if (value instanceof GenericRecord record) { FieldStats stat = fieldStats.get(pos); BaseFieldStats.Builder builder = BaseFieldStats.buildFrom(stat); Type type = stat.type(); @@ -244,6 +243,7 @@ public Builder withFieldStats(List> fieldStats) { return this; } + @SuppressWarnings("rawtypes") public BaseContentStats build() { Preconditions.checkArgument( null != statsStruct || null != schema, "Either stats struct or table schema must be set"); @@ -253,7 +253,21 @@ public BaseContentStats build() { this.statsStruct = StatsUtil.contentStatsFor(schema).type().asStructType(); } - return new BaseContentStats(statsStruct, stats); + List> resolvedStats = Lists.newArrayListWithCapacity(stats.size()); + for (FieldStats stat : stats) { + int statsFieldId = StatsUtil.statsFieldIdForField(stat.fieldId()); + Types.NestedField statsField = statsStruct.field(statsFieldId); + if (null != statsField && statsField.type().isStructType()) { + resolvedStats.add( + ((BaseFieldStats.Builder) BaseFieldStats.buildFrom(stat)) + .statsStruct(statsField.type().asStructType()) + .build()); + } else { + resolvedStats.add(stat); + } + } + + return new BaseContentStats(statsStruct, resolvedStats); } } } diff --git a/core/src/main/java/org/apache/iceberg/BaseDistributedDataScan.java b/core/src/main/java/org/apache/iceberg/BaseDistributedDataScan.java index c69f71f2fdec..e951ae830737 100644 --- a/core/src/main/java/org/apache/iceberg/BaseDistributedDataScan.java +++ b/core/src/main/java/org/apache/iceberg/BaseDistributedDataScan.java @@ -299,7 +299,12 @@ private DeleteFileIndex planDeletesLocally(List deleteManifests) { builder.planWith(planExecutor()); } + if (shouldIgnoreResiduals()) { + builder.ignoreResiduals(); + } + return builder + .schemasById(schemas()) .specsById(specs()) .filterData(filter()) .caseSensitive(isCaseSensitive()) diff --git a/core/src/main/java/org/apache/iceberg/stats/BaseFieldStats.java b/core/src/main/java/org/apache/iceberg/BaseFieldStats.java similarity index 64% rename from core/src/main/java/org/apache/iceberg/stats/BaseFieldStats.java rename to core/src/main/java/org/apache/iceberg/BaseFieldStats.java index 338d1d04a5f5..11da570b8faa 100644 --- a/core/src/main/java/org/apache/iceberg/stats/BaseFieldStats.java +++ b/core/src/main/java/org/apache/iceberg/BaseFieldStats.java @@ -16,15 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; +package org.apache.iceberg; -import java.io.Serializable; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; import java.util.Objects; +import org.apache.iceberg.avro.SupportsIndexProjection; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; -public class BaseFieldStats implements FieldStats, Serializable { +class BaseFieldStats extends SupportsIndexProjection implements FieldStats { + private static final int[] IDENTITY_MAPPING = identityMapping(); private final int fieldId; private final Type type; private final Long valueCount; @@ -38,6 +43,7 @@ public class BaseFieldStats implements FieldStats, Serializable { private BaseFieldStats( int fieldId, + int[] fromProjectionPos, Type type, Long valueCount, Long nullValueCount, @@ -47,6 +53,7 @@ private BaseFieldStats( T lowerBound, T upperBound, boolean hasExactBounds) { + super(fromProjectionPos != null ? fromProjectionPos : IDENTITY_MAPPING); this.fieldId = fieldId; this.type = type; this.valueCount = valueCount; @@ -59,6 +66,36 @@ private BaseFieldStats( this.hasExactBounds = hasExactBounds; } + private static int[] identityMapping() { + int numStats = FieldStatistic.values().length; + int[] mapping = new int[numStats]; + for (int i = 0; i < numStats; i++) { + mapping[i] = i; + } + + return mapping; + } + + /** + * Computes a position mapping from the column-specific stats struct to the full 8-field struct. + * Each entry maps a projected position to its base position (0-based) using the field ID offsets + * from the column's base stats field ID. + */ + private static int[] projectionMapping(Types.StructType statsStruct, int dataFieldId) { + if (statsStruct == null) { + return null; + } + + int baseStatsFieldId = StatsUtil.statsFieldIdForField(dataFieldId); + int[] mapping = new int[statsStruct.fields().size()]; + for (int i = 0; i < mapping.length; i++) { + // offset is 1-based (matching FieldStatistic.offset()), position is 0-based + mapping[i] = statsStruct.fields().get(i).fieldId() - baseStatsFieldId - 1; + } + + return mapping; + } + @Override public int fieldId() { return fieldId; @@ -94,14 +131,45 @@ public Integer maxValueSize() { return maxValueSize; } + @SuppressWarnings("unchecked") + private static T serializableBound(T bound) { + if (bound instanceof CharBuffer) { + // CharBuffer is not serializable, use String instead + return (T) bound.toString(); + } else if (bound instanceof ByteBuffer) { + // ByteBuffer is not serializable, use byte[] instead + return (T) ByteBuffers.toByteArray((ByteBuffer) bound); + } + + return bound; + } + + @SuppressWarnings("unchecked") @Override public T lowerBound() { - return lowerBound; + if (null != type + && type.typeId().javaClass().equals(ByteBuffer.class) + && lowerBound instanceof byte[]) { + // for serializability we store binary types as byte[] and must convert back to + // ByteBuffer + return (T) ByteBuffer.wrap((byte[]) lowerBound); + } else { + return lowerBound; + } } + @SuppressWarnings("unchecked") @Override public T upperBound() { - return upperBound; + if (null != type + && type.typeId().javaClass().equals(ByteBuffer.class) + && upperBound instanceof byte[]) { + // for serializability we store binary types as byte[] and must convert back to + // ByteBuffer + return (T) ByteBuffer.wrap((byte[]) upperBound); + } else { + return upperBound; + } } @Override @@ -110,36 +178,22 @@ public boolean hasExactBounds() { } @Override - public int size() { - return 7; + protected X internalGet(int pos, Class javaClass) { + return switch (FieldStatistic.fromPosition(pos)) { + case VALUE_COUNT -> javaClass.cast(valueCount); + case NULL_VALUE_COUNT -> javaClass.cast(nullValueCount); + case NAN_VALUE_COUNT -> javaClass.cast(nanValueCount); + case AVG_VALUE_SIZE -> javaClass.cast(avgValueSize); + case MAX_VALUE_SIZE -> javaClass.cast(maxValueSize); + case LOWER_BOUND -> javaClass.cast(lowerBound()); + case UPPER_BOUND -> javaClass.cast(upperBound()); + case EXACT_BOUNDS -> javaClass.cast(hasExactBounds); + default -> throw new UnsupportedOperationException("Unknown field ordinal: " + pos); + }; } @Override - public X get(int pos, Class javaClass) { - switch (FieldStatistic.fromOffset(pos)) { - case VALUE_COUNT: - return javaClass.cast(valueCount); - case NULL_VALUE_COUNT: - return javaClass.cast(nullValueCount); - case NAN_VALUE_COUNT: - return javaClass.cast(nanValueCount); - case AVG_VALUE_SIZE: - return javaClass.cast(avgValueSize); - case MAX_VALUE_SIZE: - return javaClass.cast(maxValueSize); - case LOWER_BOUND: - return javaClass.cast(lowerBound); - case UPPER_BOUND: - return javaClass.cast(upperBound); - case EXACT_BOUNDS: - return javaClass.cast(hasExactBounds); - default: - throw new UnsupportedOperationException("Unknown field ordinal: " + pos); - } - } - - @Override - public void set(int pos, Object value) { + protected void internalSet(int pos, X value) { throw new UnsupportedOperationException("set() not supported"); } @@ -173,8 +227,8 @@ public boolean equals(Object o) { && Objects.equals(nanValueCount, that.nanValueCount) && Objects.equals(avgValueSize, that.avgValueSize) && Objects.equals(maxValueSize, that.maxValueSize) - && Objects.equals(lowerBound, that.lowerBound) - && Objects.equals(upperBound, that.upperBound) + && Objects.deepEquals(lowerBound, that.lowerBound) + && Objects.deepEquals(upperBound, that.upperBound) && hasExactBounds == that.hasExactBounds; } @@ -214,6 +268,7 @@ public static Builder buildFrom(FieldStats value) { public static class Builder { private int fieldId; + private int[] fromProjectionPos; private Type type; private Long valueCount; private Long nullValueCount; @@ -226,6 +281,11 @@ public static class Builder { private Builder() {} + public Builder statsStruct(Types.StructType statsStruct) { + this.fromProjectionPos = projectionMapping(statsStruct, fieldId); + return this; + } + public Builder type(Type newType) { this.type = newType; return this; @@ -257,12 +317,12 @@ public Builder maxValueSize(Integer newMaxValueSize) { } public Builder lowerBound(T newLowerBound) { - this.lowerBound = newLowerBound; + this.lowerBound = serializableBound(newLowerBound); return this; } public Builder upperBound(T newUpperBound) { - this.upperBound = newUpperBound; + this.upperBound = serializableBound(newUpperBound); return this; } @@ -286,7 +346,9 @@ public BaseFieldStats build() { Preconditions.checkArgument( null != type, "Invalid type (required when lower bound is set): null"); Preconditions.checkArgument( - type.typeId().javaClass().isInstance(lowerBound), + type.typeId().javaClass().isInstance(lowerBound) + || (type.typeId().javaClass().equals(ByteBuffer.class) + && lowerBound instanceof byte[]), "Invalid lower bound type, expected a subtype of %s: %s", type.typeId().javaClass().getName(), lowerBound.getClass().getName()); @@ -296,7 +358,9 @@ public BaseFieldStats build() { Preconditions.checkArgument( null != type, "Invalid type (required when lower bound is set): null"); Preconditions.checkArgument( - type.typeId().javaClass().isInstance(upperBound), + type.typeId().javaClass().isInstance(upperBound) + || (type.typeId().javaClass().equals(ByteBuffer.class) + && upperBound instanceof byte[]), "Invalid upper bound type, expected a subtype of %s: %s", type.typeId().javaClass().getName(), upperBound.getClass().getName()); @@ -304,6 +368,7 @@ public BaseFieldStats build() { return new BaseFieldStats<>( fieldId, + fromProjectionPos, type, valueCount, nullValueCount, diff --git a/core/src/main/java/org/apache/iceberg/BaseFile.java b/core/src/main/java/org/apache/iceberg/BaseFile.java index a02e0eff55a2..3c31c50f099f 100644 --- a/core/src/main/java/org/apache/iceberg/BaseFile.java +++ b/core/src/main/java/org/apache/iceberg/BaseFile.java @@ -45,7 +45,7 @@ abstract class BaseFile extends SupportsIndexProjection StructLike, SpecificData.SchemaConstructable, Serializable { - private static final FileContent[] FILE_CONTENT_VALUES = FileContent.values(); + static final Types.StructType EMPTY_STRUCT_TYPE = Types.StructType.of(); static final PartitionData EMPTY_PARTITION_DATA = new PartitionData(EMPTY_STRUCT_TYPE) { @@ -316,7 +316,7 @@ public void put(int i, Object value) { protected void internalSet(int pos, T value) { switch (pos) { case 0: - this.content = value != null ? FILE_CONTENT_VALUES[(Integer) value] : FileContent.DATA; + this.content = value != null ? FileContent.fromId((Integer) value) : FileContent.DATA; return; case 1: // always coerce to String for Serializable diff --git a/core/src/main/java/org/apache/iceberg/BaseIncrementalAppendScan.java b/core/src/main/java/org/apache/iceberg/BaseIncrementalAppendScan.java index 24b9ae1acead..187d537fa9e9 100644 --- a/core/src/main/java/org/apache/iceberg/BaseIncrementalAppendScan.java +++ b/core/src/main/java/org/apache/iceberg/BaseIncrementalAppendScan.java @@ -82,6 +82,7 @@ private CloseableIterable appendFilesFromSnapshots(List manifestEntry -> snapshotIds.contains(manifestEntry.snapshotId()) && manifestEntry.status() == ManifestEntry.Status.ADDED) + .schemasById(schemas()) .specsById(table().specs()) .ignoreDeleted() .columnsToKeepStats(columnsToKeepStats()); diff --git a/core/src/main/java/org/apache/iceberg/BaseMetadataTable.java b/core/src/main/java/org/apache/iceberg/BaseMetadataTable.java index 9f3113f2df8e..420a50be8ab0 100644 --- a/core/src/main/java/org/apache/iceberg/BaseMetadataTable.java +++ b/core/src/main/java/org/apache/iceberg/BaseMetadataTable.java @@ -21,6 +21,7 @@ import java.io.Serializable; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.apache.iceberg.encryption.EncryptionManager; @@ -29,6 +30,7 @@ import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.transforms.Transforms; /** @@ -39,6 +41,16 @@ * needed when reading the table data after deserialization. */ public abstract class BaseMetadataTable extends BaseReadOnlyTable implements Serializable { + private static final Set TIME_TRAVEL_TABLE_TYPES = + ImmutableSet.of( + MetadataTableType.ENTRIES, + MetadataTableType.FILES, + MetadataTableType.DATA_FILES, + MetadataTableType.DELETE_FILES, + MetadataTableType.MANIFESTS, + MetadataTableType.PARTITIONS, + MetadataTableType.POSITION_DELETES); + private final PartitionSpec spec = PartitionSpec.unpartitioned(); private final SortOrder sortOrder = SortOrder.unsorted(); private final BaseTable table; @@ -106,6 +118,10 @@ static Map transformSpecs( abstract MetadataTableType metadataTableType(); + public boolean supportsTimeTravel() { + return TIME_TRAVEL_TABLE_TYPES.contains(metadataTableType()); + } + public BaseTable table() { return table; } diff --git a/core/src/main/java/org/apache/iceberg/BaseMetastoreTableOperations.java b/core/src/main/java/org/apache/iceberg/BaseMetastoreTableOperations.java index bda983a6c170..f1223705c11d 100644 --- a/core/src/main/java/org/apache/iceberg/BaseMetastoreTableOperations.java +++ b/core/src/main/java/org/apache/iceberg/BaseMetastoreTableOperations.java @@ -46,6 +46,7 @@ public abstract class BaseMetastoreTableOperations extends BaseMetastoreOperatio public static final String TABLE_TYPE_PROP = "table_type"; public static final String ICEBERG_TABLE_TYPE_VALUE = "iceberg"; public static final String METADATA_LOCATION_PROP = "metadata_location"; + public static final String METADATA_HASH_PROP = "metadata_hash"; public static final String PREVIOUS_METADATA_LOCATION_PROP = "previous_metadata_location"; private static final String METADATA_FOLDER_NAME = "metadata"; diff --git a/core/src/main/java/org/apache/iceberg/BasePartitionStatistics.java b/core/src/main/java/org/apache/iceberg/BasePartitionStatistics.java new file mode 100644 index 000000000000..4b1a3a6dba93 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/BasePartitionStatistics.java @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import org.apache.iceberg.avro.SupportsIndexProjection; +import org.apache.iceberg.types.Types; + +public class BasePartitionStatistics extends SupportsIndexProjection + implements PartitionStatistics { + + private StructLike partition; + private Integer specId; + private Long dataRecordCount; + private Integer dataFileCount; + private Long totalDataFileSizeInBytes; + private Long positionDeleteRecordCount; + private Integer positionDeleteFileCount; + private Long equalityDeleteRecordCount; + private Integer equalityDeleteFileCount; + private Long totalRecordCount; // Not calculated, as it needs scanning the data. Remains null + private Long lastUpdatedAt; + private Long lastUpdatedSnapshotId; + private Integer dvCount; + + private static final int STATS_COUNT = 13; + + BasePartitionStatistics(StructLike partition, int specId) { + super(STATS_COUNT); + + this.partition = partition; + this.specId = specId; + + this.dataRecordCount = 0L; + this.dataFileCount = 0; + this.totalDataFileSizeInBytes = 0L; + this.positionDeleteRecordCount = 0L; + this.positionDeleteFileCount = 0; + this.equalityDeleteRecordCount = 0L; + this.equalityDeleteFileCount = 0; + this.dvCount = 0; + } + + /** Used by internal readers to instantiate this class with a projection schema. */ + BasePartitionStatistics(Types.StructType projection) { + super(STATS_COUNT); + } + + @Override + public StructLike partition() { + return partition; + } + + @Override + public Integer specId() { + return specId; + } + + @Override + public Long dataRecordCount() { + return dataRecordCount; + } + + @Override + public Integer dataFileCount() { + return dataFileCount; + } + + @Override + public Long totalDataFileSizeInBytes() { + return totalDataFileSizeInBytes; + } + + @Override + public Long positionDeleteRecordCount() { + return positionDeleteRecordCount; + } + + @Override + public Integer positionDeleteFileCount() { + return positionDeleteFileCount; + } + + @Override + public Long equalityDeleteRecordCount() { + return equalityDeleteRecordCount; + } + + @Override + public Integer equalityDeleteFileCount() { + return equalityDeleteFileCount; + } + + @Override + public Long totalRecords() { + return totalRecordCount; + } + + @Override + public Long lastUpdatedAt() { + return lastUpdatedAt; + } + + @Override + public Long lastUpdatedSnapshotId() { + return lastUpdatedSnapshotId; + } + + @Override + public Integer dvCount() { + return dvCount; + } + + @Override + protected T internalGet(int pos, Class javaClass) { + return javaClass.cast(getByPos(pos)); + } + + private Object getByPos(int pos) { + switch (pos) { + case PARTITION_POSITION: + return partition; + case SPEC_ID_POSITION: + return specId; + case DATA_RECORD_COUNT_POSITION: + return dataRecordCount; + case DATA_FILE_COUNT_POSITION: + return dataFileCount; + case TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION: + return totalDataFileSizeInBytes; + case POSITION_DELETE_RECORD_COUNT_POSITION: + return positionDeleteRecordCount; + case POSITION_DELETE_FILE_COUNT_POSITION: + return positionDeleteFileCount; + case EQUALITY_DELETE_RECORD_COUNT_POSITION: + return equalityDeleteRecordCount; + case EQUALITY_DELETE_FILE_COUNT_POSITION: + return equalityDeleteFileCount; + case TOTAL_RECORD_COUNT_POSITION: + return totalRecordCount; + case LAST_UPDATED_AT_POSITION: + return lastUpdatedAt; + case LAST_UPDATED_SNAPSHOT_ID_POSITION: + return lastUpdatedSnapshotId; + case DV_COUNT_POSITION: + return dvCount; + default: + throw new UnsupportedOperationException("Unknown position: " + pos); + } + } + + @Override + protected void internalSet(int pos, T value) { + if (value == null) { + return; + } + + switch (pos) { + case PARTITION_POSITION: + this.partition = (StructLike) value; + break; + case SPEC_ID_POSITION: + this.specId = (int) value; + break; + case DATA_RECORD_COUNT_POSITION: + this.dataRecordCount = (long) value; + break; + case DATA_FILE_COUNT_POSITION: + this.dataFileCount = (int) value; + break; + case TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION: + this.totalDataFileSizeInBytes = (long) value; + break; + case POSITION_DELETE_RECORD_COUNT_POSITION: + this.positionDeleteRecordCount = (long) value; + break; + case POSITION_DELETE_FILE_COUNT_POSITION: + this.positionDeleteFileCount = (int) value; + break; + case EQUALITY_DELETE_RECORD_COUNT_POSITION: + this.equalityDeleteRecordCount = (long) value; + break; + case EQUALITY_DELETE_FILE_COUNT_POSITION: + this.equalityDeleteFileCount = (int) value; + break; + case TOTAL_RECORD_COUNT_POSITION: + this.totalRecordCount = (Long) value; + break; + case LAST_UPDATED_AT_POSITION: + this.lastUpdatedAt = (Long) value; + break; + case LAST_UPDATED_SNAPSHOT_ID_POSITION: + this.lastUpdatedSnapshotId = (Long) value; + break; + case DV_COUNT_POSITION: + this.dvCount = (int) value; + break; + default: + throw new UnsupportedOperationException("Unknown position: " + pos); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/BasePartitionStatisticsScan.java b/core/src/main/java/org/apache/iceberg/BasePartitionStatisticsScan.java new file mode 100644 index 000000000000..1316f015adb3 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/BasePartitionStatisticsScan.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.util.Optional; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.types.Types; + +public class BasePartitionStatisticsScan implements PartitionStatisticsScan { + + private final Table table; + private Long snapshotId; + + public BasePartitionStatisticsScan(Table table) { + this.table = table; + } + + @Override + public PartitionStatisticsScan useSnapshot(long newSnapshotId) { + Preconditions.checkArgument( + table.snapshot(newSnapshotId) != null, "Cannot find snapshot with ID %s", newSnapshotId); + + this.snapshotId = newSnapshotId; + return this; + } + + @Override + public PartitionStatisticsScan filter(Expression newFilter) { + throw new UnsupportedOperationException("Filtering is not supported"); + } + + @Override + public PartitionStatisticsScan project(Schema newSchema) { + throw new UnsupportedOperationException("Projection is not supported"); + } + + @Override + public CloseableIterable scan() { + if (snapshotId == null) { + if (table.currentSnapshot() == null) { + return CloseableIterable.empty(); + } + + snapshotId = table.currentSnapshot().snapshotId(); + } + + Optional statsFile = + table.partitionStatisticsFiles().stream() + .filter(f -> f.snapshotId() == snapshotId) + .findFirst(); + + if (statsFile.isEmpty()) { + return CloseableIterable.empty(); + } + + Types.StructType partitionType = Partitioning.partitionType(table); + Schema schema = PartitionStatistics.schema(partitionType, TableUtil.formatVersion(table)); + + FileFormat fileFormat = FileFormat.fromFileName(statsFile.get().path()); + Preconditions.checkNotNull( + fileFormat != null, "Unable to determine format of file: %s", statsFile.get().path()); + + return InternalData.read(fileFormat, table.io().newInputFile(statsFile.get().path())) + .project(schema) + .setRootType(BasePartitionStatistics.class) + .build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/BaseRewriteManifests.java b/core/src/main/java/org/apache/iceberg/BaseRewriteManifests.java index 28de63f7f6b8..e98027ec4a0a 100644 --- a/core/src/main/java/org/apache/iceberg/BaseRewriteManifests.java +++ b/core/src/main/java/org/apache/iceberg/BaseRewriteManifests.java @@ -229,11 +229,10 @@ private void keepActiveManifests(List currentManifests) { } private void reset() { - cleanUncommitted(newManifests, ImmutableSet.of()); + deleteUncommitted(newManifests, ImmutableSet.of(), true /* clear new manifests */); entryCount.set(0); keptManifests.clear(); rewrittenManifests.clear(); - newManifests.clear(); writers.clear(); } @@ -345,19 +344,10 @@ private WriterWrapper getWriter(Object key, int partitionSpecId) { @Override protected void cleanUncommitted(Set committed) { - cleanUncommitted(newManifests, committed); + deleteUncommitted(newManifests, committed, false); // clean up only rewrittenAddedManifests as they are always owned by the table // don't clean up addedManifests as they are added to the manifest list and are not compacted - cleanUncommitted(rewrittenAddedManifests, committed); - } - - private void cleanUncommitted( - Iterable manifests, Set committedManifests) { - for (ManifestFile manifest : manifests) { - if (!committedManifests.contains(manifest)) { - deleteFile(manifest.path()); - } - } + deleteUncommitted(rewrittenAddedManifests, committed, false); } long getManifestTargetSizeBytes() { diff --git a/core/src/main/java/org/apache/iceberg/BaseScan.java b/core/src/main/java/org/apache/iceberg/BaseScan.java index e40b1b743c76..242a5aaacc09 100644 --- a/core/src/main/java/org/apache/iceberg/BaseScan.java +++ b/core/src/main/java/org/apache/iceberg/BaseScan.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; +import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.iceberg.expressions.Binder; import org.apache.iceberg.expressions.Expression; @@ -102,14 +103,27 @@ public Table table() { return table; } + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link BaseScan#fileIO()} instead. + */ + @Deprecated protected FileIO io() { return table.io(); } + @Override + public Supplier fileIO() { + return table::io; + } + protected Schema tableSchema() { return schema; } + protected Map schemas() { + return table.schemas(); + } + protected TableScanContext context() { return context; } diff --git a/core/src/main/java/org/apache/iceberg/BaseTable.java b/core/src/main/java/org/apache/iceberg/BaseTable.java index 23299a962ce5..4d73c96fec28 100644 --- a/core/src/main/java/org/apache/iceberg/BaseTable.java +++ b/core/src/main/java/org/apache/iceberg/BaseTable.java @@ -27,6 +27,7 @@ import org.apache.iceberg.io.LocationProvider; import org.apache.iceberg.metrics.LoggingMetricsReporter; import org.apache.iceberg.metrics.MetricsReporter; +import org.apache.iceberg.metrics.MetricsReporters; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; /** @@ -38,10 +39,11 @@ * BaseTable using a {@link StaticTableOperations}. This way no Catalog related calls are needed * when reading the table data after deserialization. */ -public class BaseTable implements Table, HasTableOperations, Serializable { +public class BaseTable + implements Table, HasTableOperations, Serializable, SupportsDistributedScanPlanning { private final TableOperations ops; private final String name; - private final MetricsReporter reporter; + private MetricsReporter reporter; public BaseTable(TableOperations ops, String name) { this(ops, name, LoggingMetricsReporter.instance()); @@ -58,6 +60,10 @@ public MetricsReporter reporter() { return reporter; } + public void combineMetricsReporter(MetricsReporter metricsReporter) { + this.reporter = MetricsReporters.combine(this.reporter, metricsReporter); + } + @Override public TableOperations operations() { return ops; @@ -90,6 +96,11 @@ public IncrementalChangelogScan newIncrementalChangelogScan() { return new BaseIncrementalChangelogScan(this); } + @Override + public PartitionStatisticsScan newPartitionStatisticsScan() { + return new BasePartitionStatisticsScan(this); + } + @Override public Schema schema() { return ops.current().schema(); diff --git a/core/src/main/java/org/apache/iceberg/BaseTransaction.java b/core/src/main/java/org/apache/iceberg/BaseTransaction.java index 936706aa8f93..9884ac297079 100644 --- a/core/src/main/java/org/apache/iceberg/BaseTransaction.java +++ b/core/src/main/java/org/apache/iceberg/BaseTransaction.java @@ -39,10 +39,8 @@ import org.apache.iceberg.exceptions.CommitFailedException; import org.apache.iceberg.exceptions.CommitStateUnknownException; import org.apache.iceberg.exceptions.NoSuchTableException; -import org.apache.iceberg.io.BulkDeletionFailureException; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.LocationProvider; -import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.metrics.LoggingMetricsReporter; import org.apache.iceberg.metrics.MetricsReporter; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; @@ -52,7 +50,6 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.util.Tasks; -import org.apache.iceberg.util.ThreadPools; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -379,11 +376,11 @@ private void commitSimpleTransaction() { throw e; } catch (PendingUpdateFailedException e) { - cleanUpOnCommitFailure(); + cleanUp(); throw e.wrapped(); } catch (RuntimeException e) { if (!ops.requireStrictCleanup() || e instanceof CleanableFailure) { - cleanUpOnCommitFailure(); + cleanUp(); } throw e; @@ -420,7 +417,7 @@ private void commitSimpleTransaction() { } } - private void cleanUpOnCommitFailure() { + protected void cleanUp() { // the commit failed and no files were committed. clean up each update. cleanAllUpdates(); @@ -440,22 +437,7 @@ private void cleanAllUpdates() { } private void deleteUncommittedFiles(Iterable paths) { - if (ops.io() instanceof SupportsBulkOperations) { - try { - ((SupportsBulkOperations) ops.io()).deleteFiles(paths); - } catch (BulkDeletionFailureException e) { - LOG.warn( - "Failed to delete {} uncommitted files using bulk deletes", e.numberFailedObjects(), e); - } catch (RuntimeException e) { - LOG.warn("Failed to delete uncommitted files using bulk deletes", e); - } - } else { - Tasks.foreach(paths) - .executeWith(ThreadPools.getWorkerPool()) - .suppressFailureWhenFinished() - .onFailure((file, exc) -> LOG.warn("Failed to delete uncommitted file: {}", file, exc)) - .run(ops.io()::deleteFile); - } + CatalogUtil.deleteFiles(ops.io(), paths, "uncommitted"); } private void applyUpdates(TableOperations underlyingOps) { diff --git a/core/src/main/java/org/apache/iceberg/CachingCatalog.java b/core/src/main/java/org/apache/iceberg/CachingCatalog.java index 913f1a9482e1..5c92fe7db602 100644 --- a/core/src/main/java/org/apache/iceberg/CachingCatalog.java +++ b/core/src/main/java/org/apache/iceberg/CachingCatalog.java @@ -197,6 +197,14 @@ public Table registerTable(TableIdentifier identifier, String metadataFileLocati return table; } + @Override + public Table registerTable( + TableIdentifier identifier, String metadataFileLocation, boolean overwrite) { + Table table = catalog.registerTable(identifier, metadataFileLocation, overwrite); + invalidateTable(identifier); + return table; + } + private Iterable metadataTableIdentifiers(TableIdentifier ident) { ImmutableList.Builder builder = ImmutableList.builder(); diff --git a/core/src/main/java/org/apache/iceberg/CatalogProperties.java b/core/src/main/java/org/apache/iceberg/CatalogProperties.java index c17ed59b2674..6b85ccbc87bc 100644 --- a/core/src/main/java/org/apache/iceberg/CatalogProperties.java +++ b/core/src/main/java/org/apache/iceberg/CatalogProperties.java @@ -155,11 +155,31 @@ private CatalogProperties() {} public static final String LOCK_TABLE = "lock.table"; public static final String APP_ID = "app-id"; + public static final String APP_NAME = "app-name"; public static final String USER = "user"; + /** + * Requests that the catalog provide unique locations for new tables. + * + *

Relevant only for catalogs which support unique table locations. + */ + public static final String UNIQUE_TABLE_LOCATION = "unique-table-location"; + + public static final boolean UNIQUE_TABLE_LOCATION_DEFAULT = false; + public static final String AUTH_SESSION_TIMEOUT_MS = "auth.session-timeout-ms"; public static final long AUTH_SESSION_TIMEOUT_MS_DEFAULT = TimeUnit.HOURS.toMillis(1); public static final String ENCRYPTION_KMS_TYPE = "encryption.kms-type"; + public static final String ENCRYPTION_KMS_TYPE_AWS = "aws"; + public static final String ENCRYPTION_KMS_TYPE_AZURE = "azure"; + public static final String ENCRYPTION_KMS_TYPE_GCP = "gcp"; + public static final String ENCRYPTION_KMS_IMPL = "encryption.kms-impl"; + public static final String ENCRYPTION_KMS_IMPL_AWS = + "org.apache.iceberg.aws.AwsKeyManagementClient"; + public static final String ENCRYPTION_KMS_IMPL_AZURE = + "org.apache.iceberg.azure.keymanagement.AzureKeyManagementClient"; + public static final String ENCRYPTION_KMS_IMPL_GCP = + "org.apache.iceberg.gcp.GcpKeyManagementClient"; } diff --git a/core/src/main/java/org/apache/iceberg/CatalogUtil.java b/core/src/main/java/org/apache/iceberg/CatalogUtil.java index a96234629232..2b400ccebc8b 100644 --- a/core/src/main/java/org/apache/iceberg/CatalogUtil.java +++ b/core/src/main/java/org/apache/iceberg/CatalogUtil.java @@ -26,6 +26,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.common.DynClasses; @@ -34,13 +35,13 @@ import org.apache.iceberg.exceptions.RuntimeIOException; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.hadoop.Configurable; +import org.apache.iceberg.io.BulkDeletionFailureException; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.StorageCredential; import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.metrics.LoggingMetricsReporter; import org.apache.iceberg.metrics.MetricsReporter; -import org.apache.iceberg.relocated.com.google.common.base.Joiner; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; @@ -112,7 +113,12 @@ public static void dropTableData(FileIO io, TableMetadata metadata) { } } - LOG.info("Manifests to delete: {}", Joiner.on(", ").join(manifestsToDelete)); + LOG.info("{} Manifests to delete ", manifestsToDelete.size()); + if (LOG.isDebugEnabled()) { + for (ManifestFile manifest : manifestsToDelete) { + LOG.debug("Deleting manifest file: {}", manifest.path()); + } + } // run all of the deletes @@ -121,26 +127,21 @@ public static void dropTableData(FileIO io, TableMetadata metadata) { if (gcEnabled) { // delete data files only if we are sure this won't corrupt other tables - deleteFiles(io, manifestsToDelete); + deleteFiles(io, manifestsToDelete, metadata.specsById()); } - deleteFiles(io, Iterables.transform(manifestsToDelete, ManifestFile::path), "manifest", true); - deleteFiles(io, manifestListsToDelete, "manifest list", true); + deleteFiles(io, Iterables.transform(manifestsToDelete, ManifestFile::path), "manifest"); + deleteFiles(io, manifestListsToDelete, "manifest list"); deleteFiles( io, Iterables.transform(metadata.previousFiles(), TableMetadata.MetadataLogEntry::file), - "previous metadata", - true); + "previous metadata"); deleteFiles( - io, - Iterables.transform(metadata.statisticsFiles(), StatisticsFile::path), - "statistics", - true); + io, Iterables.transform(metadata.statisticsFiles(), StatisticsFile::path), "statistics"); deleteFiles( io, Iterables.transform(metadata.partitionStatisticsFiles(), PartitionStatisticsFile::path), - "partition statistics", - true); + "partition statistics"); deleteFile(io, metadata.metadataFileLocation(), "metadata"); } @@ -162,7 +163,8 @@ public static void dropViewMetadata(FileIO io, ViewMetadata metadata) { } @SuppressWarnings("DangerousStringInternUsage") - private static void deleteFiles(FileIO io, Set allManifests) { + private static void deleteFiles( + FileIO io, Set allManifests, Map specsById) { // keep track of deleted files in a map that can be cleaned up when memory runs low Map deletedFiles = new MapMaker().concurrencyLevel(ThreadPools.WORKER_THREAD_POOL_SIZE).weakKeys().makeMap(); @@ -176,7 +178,7 @@ private static void deleteFiles(FileIO io, Set allManifests) { LOG.warn("Failed to get deleted files: this may cause orphaned data files", exc)) .run( manifest -> { - try (ManifestReader reader = ManifestFiles.open(manifest, io)) { + try (ManifestReader reader = ManifestFiles.open(manifest, io, specsById)) { List pathsToDelete = Lists.newArrayList(); for (ManifestEntry entry : reader.entries()) { // intern the file path because the weak key map uses identity (==) instead of @@ -197,6 +199,18 @@ private static void deleteFiles(FileIO io, Set allManifests) { }); } + /** + * Helper to delete files. Bulk deletion is used if possible, otherwise deletions are done + * concurrently for non-bulk FileIO. + * + * @param io FileIO for deletes + * @param files files to delete + * @param type type of files being deleted + */ + public static void deleteFiles(FileIO io, Iterable files, String type) { + deleteFiles(io, files, type, true); + } + /** * Helper to delete files. Bulk deletion is used if possible. * @@ -207,28 +221,29 @@ private static void deleteFiles(FileIO io, Set allManifests) { */ public static void deleteFiles( FileIO io, Iterable files, String type, boolean concurrent) { - if (io instanceof SupportsBulkOperations) { + if (io instanceof SupportsBulkOperations bulkIO) { try { - SupportsBulkOperations bulkIO = (SupportsBulkOperations) io; bulkIO.deleteFiles(files); + } catch (BulkDeletionFailureException e) { + LOG.warn("Failed to bulk delete {} {} files", e.numberFailedObjects(), type, e); } catch (RuntimeException e) { LOG.warn("Failed to bulk delete {} files", type, e); } } else { if (concurrent) { - deleteFiles(io, files, type); + concurrentlyDeleteFiles(io, files, type); } else { files.forEach(file -> deleteFile(io, file, type)); } } } - private static void deleteFiles(FileIO io, Iterable files, String type) { + private static void concurrentlyDeleteFiles(FileIO io, Iterable files, String type) { Tasks.foreach(files) .executeWith(ThreadPools.getWorkerPool()) .noRetry() .suppressFailureWhenFinished() - .onFailure((file, exc) -> LOG.warn("Failed to delete {} file {}", type, file, exc)) + .onFailure((file, exc) -> LOG.warn("Failed to delete {} file: {}", type, file, exc)) .run(io::deleteFile); } @@ -582,21 +597,12 @@ public static void deleteRemovedMetadataFiles( // the log, thus we don't include metadata.previousFiles() for deletion - everything else can // be removed removedPreviousMetadataFiles.removeAll(metadata.previousFiles()); - if (io instanceof SupportsBulkOperations) { - ((SupportsBulkOperations) io) - .deleteFiles( - Iterables.transform( - removedPreviousMetadataFiles, TableMetadata.MetadataLogEntry::file)); - } else { - Tasks.foreach(removedPreviousMetadataFiles) - .noRetry() - .suppressFailureWhenFinished() - .onFailure( - (previousMetadataFile, exc) -> - LOG.warn( - "Delete failed for previous metadata file: {}", previousMetadataFile, exc)) - .run(previousMetadataFile -> io.deleteFile(previousMetadataFile.file())); - } + deleteFiles( + io, + removedPreviousMetadataFiles.stream() + .map(TableMetadata.MetadataLogEntry::file) + .collect(Collectors.toSet()), + "metadata"); } } } diff --git a/core/src/main/java/org/apache/iceberg/CherryPickOperation.java b/core/src/main/java/org/apache/iceberg/CherryPickOperation.java index 439524deaf24..8c8026de5a08 100644 --- a/core/src/main/java/org/apache/iceberg/CherryPickOperation.java +++ b/core/src/main/java/org/apache/iceberg/CherryPickOperation.java @@ -22,14 +22,19 @@ import java.io.UncheckedIOException; import java.util.List; import java.util.Map; +import java.util.Objects; import org.apache.iceberg.exceptions.CherrypickAncestorCommitException; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.util.ParallelIterable; import org.apache.iceberg.util.PartitionSet; import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.util.SnapshotUtil; +import org.apache.iceberg.util.ThreadPools; import org.apache.iceberg.util.WapUtil; /** @@ -40,16 +45,12 @@ */ class CherryPickOperation extends MergingSnapshotProducer { - private final FileIO io; - private final Map specsById; private Snapshot cherrypickSnapshot = null; private boolean requireFastForward = false; private PartitionSet replacedPartitions = null; CherryPickOperation(String tableName, TableOperations ops) { super(tableName, ops); - this.io = ops.io(); - this.specsById = ops.current().specsById(); } @Override @@ -71,6 +72,9 @@ public CherryPickOperation cherrypick(long snapshotId) { ValidationException.check( cherrypickSnapshot != null, "Cannot cherry-pick unknown snapshot ID: %s", snapshotId); + SnapshotChanges changes = + SnapshotChanges.builderFor(cherrypickSnapshot, ops().io(), current.specsById()).build(); + if (cherrypickSnapshot.operation().equals(DataOperations.APPEND)) { // this property is set on target snapshot that will get published String wapId = WapUtil.validateWapPublish(current, snapshotId); @@ -82,7 +86,7 @@ public CherryPickOperation cherrypick(long snapshotId) { set(SnapshotSummary.SOURCE_SNAPSHOT_ID_PROP, String.valueOf(snapshotId)); // Pick modifications from the snapshot - for (DataFile addedFile : cherrypickSnapshot.addedDataFiles(io)) { + for (DataFile addedFile : changes.addedDataFiles()) { add(addedFile); } @@ -113,14 +117,14 @@ public CherryPickOperation cherrypick(long snapshotId) { failMissingDeletePaths(); // copy adds from the picked snapshot - this.replacedPartitions = PartitionSet.create(specsById); - for (DataFile addedFile : cherrypickSnapshot.addedDataFiles(io)) { + this.replacedPartitions = PartitionSet.create(current.specsById()); + for (DataFile addedFile : changes.addedDataFiles()) { add(addedFile); replacedPartitions.add(addedFile.specId(), addedFile.partition()); } // copy deletes from the picked snapshot - for (DataFile deletedFile : cherrypickSnapshot.removedDataFiles(io)) { + for (DataFile deletedFile : changes.removedDataFiles()) { delete(deletedFile); } @@ -160,7 +164,8 @@ protected void validate(TableMetadata base, Snapshot snapshot) { // case if (!isFastForward(base)) { validateNonAncestor(base, cherrypickSnapshot.snapshotId()); - validateReplacedPartitions(base, cherrypickSnapshot.parentId(), replacedPartitions, io); + validateReplacedPartitions( + base, cherrypickSnapshot.parentId(), replacedPartitions, ops().io()); WapUtil.validateWapPublish(base, cherrypickSnapshot.snapshotId()); } } @@ -217,21 +222,49 @@ private static void validateReplacedPartitions( parentId == null || isCurrentAncestor(meta, parentId), "Cannot cherry-pick overwrite, based on non-ancestor of the current state: %s", parentId); - try (CloseableIterable newFiles = - SnapshotUtil.newFilesBetween( - parentId, meta.currentSnapshot().snapshotId(), meta::snapshot, io)) { - for (DataFile newFile : newFiles) { - ValidationException.check( - !replacedPartitions.contains(newFile.specId(), newFile.partition()), - "Cannot cherry-pick replace partitions with changed partition: %s", - newFile.partition()); + List snapshots = + Lists.newArrayList( + SnapshotUtil.ancestorsBetween( + meta.currentSnapshot().snapshotId(), parentId, meta::snapshot)); + if (!snapshots.isEmpty()) { + Iterable> addedFileTasks = + Iterables.concat( + Iterables.transform( + snapshots, + snap -> + Iterables.transform( + manifestsCreatedBy(snap, io), + manifest -> addedDataFiles(manifest, io, meta.specsById())))); + + try (CloseableIterable newFiles = + new ParallelIterable<>(addedFileTasks, ThreadPools.getWorkerPool())) { + for (DataFile newFile : newFiles) { + ValidationException.check( + !replacedPartitions.contains(newFile.specId(), newFile.partition()), + "Cannot cherry-pick replace partitions with changed partition: %s", + newFile.partition()); + } + } catch (IOException e) { + throw new UncheckedIOException("Failed to validate replaced partitions", e); } - } catch (IOException ioe) { - throw new UncheckedIOException("Failed to validate replaced partitions", ioe); } } } + private static Iterable manifestsCreatedBy(Snapshot snapshot, FileIO io) { + return Iterables.filter( + snapshot.dataManifests(io), m -> Objects.equals(m.snapshotId(), snapshot.snapshotId())); + } + + private static CloseableIterable addedDataFiles( + ManifestFile manifest, FileIO io, Map specsById) { + CloseableIterable> entries = + ManifestFiles.read(manifest, io, specsById).entries(); + CloseableIterable> added = + CloseableIterable.filter(entries, e -> e.status() == ManifestEntry.Status.ADDED); + return CloseableIterable.transform(added, e -> e.file().copy()); + } + private static Long lookupAncestorBySourceSnapshot(TableMetadata meta, long snapshotId) { String snapshotIdStr = String.valueOf(snapshotId); for (long ancestorId : currentAncestors(meta)) { diff --git a/api/src/main/java/org/apache/iceberg/stats/ContentStats.java b/core/src/main/java/org/apache/iceberg/ContentStats.java similarity index 92% rename from api/src/main/java/org/apache/iceberg/stats/ContentStats.java rename to core/src/main/java/org/apache/iceberg/ContentStats.java index b39db2565163..623a8eb39baf 100644 --- a/api/src/main/java/org/apache/iceberg/stats/ContentStats.java +++ b/core/src/main/java/org/apache/iceberg/ContentStats.java @@ -16,13 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; +package org.apache.iceberg; import java.util.List; -import org.apache.iceberg.StructLike; import org.apache.iceberg.types.Types; -public interface ContentStats extends StructLike { +interface ContentStats extends StructLike { /** A list of all the {@link FieldStats} */ List> fieldStats(); diff --git a/core/src/main/java/org/apache/iceberg/DVUtil.java b/core/src/main/java/org/apache/iceberg/DVUtil.java new file mode 100644 index 000000000000..d5317f202a97 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/DVUtil.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import org.apache.iceberg.deletes.DVFileWriter; +import org.apache.iceberg.deletes.Deletes; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.encryption.EncryptingFileIO; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.IOUtil; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Multimap; +import org.apache.iceberg.relocated.com.google.common.collect.Multimaps; +import org.apache.iceberg.types.Comparators; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.Tasks; + +class DVUtil { + private DVUtil() {} + + static PositionDeleteIndex readDV(DeleteFile deleteFile, FileIO fileIO) { + Preconditions.checkArgument( + ContentFileUtil.isDV(deleteFile), + "Cannot read, not a deletion vector: %s", + deleteFile.location()); + InputFile inputFile = fileIO.newInputFile(deleteFile); + long offset = deleteFile.contentOffset(); + int length = deleteFile.contentSizeInBytes().intValue(); + byte[] bytes = new byte[length]; + try { + IOUtil.readFully(inputFile, offset, bytes, 0, length); + return PositionDeleteIndex.deserialize(bytes, deleteFile); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Merges duplicate DVs for the same data file and writes the merged DV Puffin files. If there is + * exactly 1 DV for a given data file then it is return as is + * + * @param dvsByReferencedFile map of data file location to DVs + * @param mergedOutputLocation output location of the merged DVs + * @param fileIO fileIO to use when reading and writing + * @param specs partition specs + * @param pool executor service for reading DVs + * @return a list containing both any newly merged DVs and any DVs that are already valid + */ + static List mergeAndWriteDVsIfRequired( + Map> dvsByReferencedFile, + String mergedOutputLocation, + FileIO fileIO, + Map specs, + ExecutorService pool) { + List finalDVs = Lists.newArrayList(); + Multimap duplicates = + Multimaps.newListMultimap(Maps.newHashMap(), Lists::newArrayList); + Map> partitions = Maps.newHashMap(); + + for (Map.Entry> entry : dvsByReferencedFile.entrySet()) { + if (entry.getValue().size() > 1) { + duplicates.putAll(entry.getKey(), entry.getValue()); + DeleteFile first = entry.getValue().get(0); + partitions.put(entry.getKey(), Pair.of(specs.get(first.specId()), first.partition())); + } else { + finalDVs.addAll(entry.getValue()); + } + } + + if (duplicates.isEmpty()) { + return finalDVs; + } + + validateCanMerge(duplicates, partitions); + + Map deletes = + readAndMergeDVs(duplicates.values().toArray(DeleteFile[]::new), fileIO, pool); + + finalDVs.addAll(writeDVs(deletes, fileIO, mergedOutputLocation, partitions)); + return finalDVs; + } + + private static void validateCanMerge( + Multimap duplicates, + Map> partitions) { + Map> comparatorsBySpecId = Maps.newHashMap(); + for (Map.Entry> entry : duplicates.asMap().entrySet()) { + String referencedFile = entry.getKey(); + + // validate that each file matches the expected partition + Pair partition = partitions.get(referencedFile); + Long sequenceNumber = Iterables.getFirst(entry.getValue(), null).dataSequenceNumber(); + PartitionSpec spec = partition.first(); + StructLike tuple = partition.second(); + Comparator comparator = + comparatorsBySpecId.computeIfAbsent( + spec.specId(), id -> Comparators.forType(spec.partitionType())); + + for (DeleteFile dv : entry.getValue()) { + Preconditions.checkArgument( + Objects.equals(sequenceNumber, dv.dataSequenceNumber()), + "Cannot merge DVs, mismatched sequence numbers (%s, %s) for %s", + sequenceNumber, + dv.dataSequenceNumber(), + referencedFile); + + Preconditions.checkArgument( + spec.specId() == dv.specId(), + "Cannot merge DVs, mismatched partition specs (%s, %s) for %s", + spec.specId(), + dv.specId(), + referencedFile); + + Preconditions.checkArgument( + comparator.compare(tuple, dv.partition()) == 0, + "Cannot merge DVs, mismatched partition tuples (%s, %s) for %s", + tuple, + dv.partition(), + referencedFile); + } + } + } + + /** + * Reads all DVs, and merge the position indices per referenced data file + * + * @param duplicateDVs list of dvs to read and merge + * @param io the FileIO to use for reading DV files + * @param pool executor service for reading DVs + * @return map of referenced data file location to the merged position delete index + */ + private static Map readAndMergeDVs( + DeleteFile[] duplicateDVs, FileIO io, ExecutorService pool) { + // Read all duplicate DVs in parallel + PositionDeleteIndex[] duplicatedDVPositions = new PositionDeleteIndex[duplicateDVs.length]; + Tasks.range(duplicatedDVPositions.length) + .executeWith(pool) + .stopOnFailure() + .throwFailureWhenFinished() + .run(i -> duplicatedDVPositions[i] = readDV(duplicateDVs[i], io)); + + Map mergedDVs = Maps.newHashMap(); + for (int i = 0; i < duplicatedDVPositions.length; i++) { + DeleteFile dv = duplicateDVs[i]; + PositionDeleteIndex previousDV = mergedDVs.get(duplicateDVs[i].referencedDataFile()); + if (previousDV != null) { + previousDV.merge(duplicatedDVPositions[i]); + } else { + mergedDVs.put(dv.referencedDataFile(), duplicatedDVPositions[i]); + } + } + + return mergedDVs; + } + + // Produces a single Puffin file containing the merged DVs + private static List writeDVs( + Map mergedIndexByFile, + FileIO fileIO, + String dvOutputLocation, + Map> partitions) { + EncryptedOutputFile dvOutputFile = + fileIO instanceof EncryptingFileIO encryptingFileIO + ? encryptingFileIO.newEncryptingOutputFile(dvOutputLocation) + : EncryptedFiles.plainAsEncryptedOutput(fileIO.newOutputFile(dvOutputLocation)); + + try (DVFileWriter dvFileWriter = Deletes.writeDVs(dvOutputFile, path -> null)) { + for (Map.Entry entry : mergedIndexByFile.entrySet()) { + String referencedLocation = entry.getKey(); + PositionDeleteIndex mergedPositions = entry.getValue(); + Pair partition = partitions.get(referencedLocation); + dvFileWriter.delete( + referencedLocation, mergedPositions, partition.first(), partition.second()); + } + dvFileWriter.close(); + return dvFileWriter.result().deleteFiles(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/DataFiles.java b/core/src/main/java/org/apache/iceberg/DataFiles.java index b06325b754dc..4a991f623051 100644 --- a/core/src/main/java/org/apache/iceberg/DataFiles.java +++ b/core/src/main/java/org/apache/iceberg/DataFiles.java @@ -321,6 +321,11 @@ public Builder withSortOrder(SortOrder newSortOrder) { return this; } + public Builder withSortOrderId(int newSortOrderId) { + this.sortOrderId = newSortOrderId; + return this; + } + public Builder withFirstRowId(Long nextRowId) { this.firstRowId = nextRowId; return this; diff --git a/core/src/main/java/org/apache/iceberg/DataScan.java b/core/src/main/java/org/apache/iceberg/DataScan.java index 1acbbbf6826a..336824e4cc91 100644 --- a/core/src/main/java/org/apache/iceberg/DataScan.java +++ b/core/src/main/java/org/apache/iceberg/DataScan.java @@ -53,6 +53,7 @@ protected ManifestGroup newManifestGroup( .caseSensitive(isCaseSensitive()) .select(withColumnStats ? SCAN_WITH_STATS_COLUMNS : SCAN_COLUMNS) .filterData(filter()) + .schemasById(schemas()) .specsById(specs()) .scanMetrics(scanMetrics()) .ignoreDeleted() diff --git a/core/src/main/java/org/apache/iceberg/DataTableScan.java b/core/src/main/java/org/apache/iceberg/DataTableScan.java index 4d23dd525e07..dc130c8064fc 100644 --- a/core/src/main/java/org/apache/iceberg/DataTableScan.java +++ b/core/src/main/java/org/apache/iceberg/DataTableScan.java @@ -74,6 +74,7 @@ public CloseableIterable doPlanFiles() { .caseSensitive(isCaseSensitive()) .select(scanColumns()) .filterData(filter()) + .schemasById(schemas()) .specsById(specs()) .scanMetrics(scanMetrics()) .ignoreDeleted() diff --git a/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java b/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java index 7716a7ba00c8..872fcd212b8a 100644 --- a/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java +++ b/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java @@ -32,6 +32,9 @@ import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutorService; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; import org.apache.iceberg.exceptions.RuntimeIOException; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.expressions.Expression; @@ -366,6 +369,7 @@ static class Builder { private final Iterable deleteFiles; private long minSequenceNumber = 0L; private Map specsById = null; + private Map schemasById = null; private Expression dataFilter = Expressions.alwaysTrue(); private Expression partitionFilter = Expressions.alwaysTrue(); private PartitionSet partitionSet = null; @@ -391,6 +395,11 @@ Builder afterSequenceNumber(long seq) { return this; } + Builder schemasById(Map newSchemasById) { + this.schemasById = newSchemasById; + return this; + } + Builder specsById(Map newSpecsById) { this.specsById = newSpecsById; return this; @@ -471,10 +480,20 @@ private Collection loadDeleteFiles() { return files; } + private Collection schemas() { + if (schemasById != null) { + return schemasById.values(); + } else { + return specsById.values().stream().map(PartitionSpec::schema).collect(Collectors.toList()); + } + } + DeleteFileIndex build() { + Map fieldsById = Schema.indexFields(schemas()); + Function fieldLookup = fieldsById::get; Iterable files = deleteFiles != null ? filterDeleteFiles() : loadDeleteFiles(); - EqualityDeletes globalDeletes = new EqualityDeletes(); + EqualityDeletes globalDeletes = new EqualityDeletes(fieldLookup); PartitionMap eqDeletesByPartition = PartitionMap.create(specsById); PartitionMap posDeletesByPartition = PartitionMap.create(specsById); Map posDeletesByPath = Maps.newHashMap(); @@ -490,7 +509,7 @@ DeleteFileIndex build() { } break; case EQUALITY_DELETES: - add(globalDeletes, eqDeletesByPartition, file); + add(globalDeletes, eqDeletesByPartition, file, fieldLookup); break; default: throw new UnsupportedOperationException("Unsupported content: " + file.content()); @@ -537,7 +556,8 @@ private void add( private void add( EqualityDeletes globalDeletes, PartitionMap deletesByPartition, - DeleteFile file) { + DeleteFile file, + Function fieldLookup) { PartitionSpec spec = specsById.get(file.specId()); EqualityDeletes deletes; @@ -546,10 +566,11 @@ private void add( } else { int specId = spec.specId(); StructLike partition = file.partition(); - deletes = deletesByPartition.computeIfAbsent(specId, partition, EqualityDeletes::new); + Supplier initEqDeletes = () -> new EqualityDeletes(fieldLookup); + deletes = deletesByPartition.computeIfAbsent(specId, partition, initEqDeletes); } - deletes.add(spec, file); + deletes.add(file); } private Iterable>> deleteManifestReaders() { @@ -726,6 +747,8 @@ static class EqualityDeletes { Comparator.comparingLong(EqualityDeleteFile::applySequenceNumber); private static final EqualityDeleteFile[] EMPTY_EQUALITY_DELETES = new EqualityDeleteFile[0]; + private final Function fieldLookup; + // indexed state private long[] seqs = null; private EqualityDeleteFile[] files = null; @@ -733,9 +756,13 @@ static class EqualityDeletes { // a buffer that is used to hold files before indexing private volatile List buffer = Lists.newArrayList(); - public void add(PartitionSpec spec, DeleteFile file) { + EqualityDeletes(Function fieldLookup) { + this.fieldLookup = fieldLookup; + } + + public void add(DeleteFile file) { Preconditions.checkState(buffer != null, "Can't add files upon indexing"); - buffer.add(new EqualityDeleteFile(spec, file)); + buffer.add(new EqualityDeleteFile(fieldLookup, file)); } public DeleteFile[] filter(long seq, DataFile dataFile) { @@ -801,15 +828,15 @@ private static long[] indexSeqs(EqualityDeleteFile[] files) { // an equality delete file wrapper that caches the converted boundaries for faster boundary checks // this class is not meant to be exposed beyond the delete file index private static class EqualityDeleteFile { - private final PartitionSpec spec; + private final Function fieldLookup; private final DeleteFile wrapped; private final long applySequenceNumber; private volatile List equalityFields = null; private volatile Map convertedLowerBounds = null; private volatile Map convertedUpperBounds = null; - EqualityDeleteFile(PartitionSpec spec, DeleteFile file) { - this.spec = spec; + EqualityDeleteFile(Function fieldLookup, DeleteFile file) { + this.fieldLookup = fieldLookup; this.wrapped = file; this.applySequenceNumber = wrapped.dataSequenceNumber() - 1; } @@ -828,7 +855,8 @@ public List equalityFields() { if (equalityFields == null) { List fields = Lists.newArrayList(); for (int id : wrapped.equalityFieldIds()) { - Types.NestedField field = spec.schema().findField(id); + Types.NestedField field = fieldLookup.apply(id); + Preconditions.checkArgument(field != null, "Cannot find field for ID %s", id); fields.add(field); } this.equalityFields = fields; @@ -891,7 +919,7 @@ private Map convertBounds(Map bounds) { if (bounds != null) { for (Types.NestedField field : equalityFields()) { int id = field.fieldId(); - Type type = spec.schema().findField(id).type(); + Type type = field.type(); if (type.isPrimitiveType()) { ByteBuffer bound = bounds.get(id); if (bound != null) { diff --git a/core/src/main/java/org/apache/iceberg/DeletionVector.java b/core/src/main/java/org/apache/iceberg/DeletionVector.java new file mode 100644 index 000000000000..0fc8f259f075 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/DeletionVector.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import org.apache.iceberg.types.Types; + +/** + * Metadata about a deletion vector. + * + *

Tracks where a DV blob can be read. The DV blob follows the format defined by the + * deletion-vector-v1 blob type in the Puffin spec. + */ +interface DeletionVector { + Types.NestedField LOCATION = + Types.NestedField.required( + 155, "location", Types.StringType.get(), "Location of the file containing the DV"); + Types.NestedField OFFSET = + Types.NestedField.required( + 144, "offset", Types.LongType.get(), "Offset in the file where the DV content starts"); + Types.NestedField SIZE_IN_BYTES = + Types.NestedField.required( + 145, + "size_in_bytes", + Types.LongType.get(), + "Length of the referenced DV content stored in the file"); + Types.NestedField CARDINALITY = + Types.NestedField.required( + 156, + "cardinality", + Types.LongType.get(), + "Number of set bits (deleted rows) in the vector"); + + static Types.StructType schema() { + return Types.StructType.of(LOCATION, OFFSET, SIZE_IN_BYTES, CARDINALITY); + } + + /** Returns the location of the file containing the deletion vector. */ + String location(); + + /** Returns the offset in the file where the deletion vector content starts. */ + long offset(); + + /** Returns the size in bytes of the deletion vector content. */ + long sizeInBytes(); + + /** Returns the number of set bits (deleted rows) in the vector. */ + long cardinality(); + + /** Copies this deletion vector. */ + DeletionVector copy(); +} diff --git a/core/src/main/java/org/apache/iceberg/DeletionVectorStruct.java b/core/src/main/java/org/apache/iceberg/DeletionVectorStruct.java new file mode 100644 index 000000000000..0eb7c2fe1eb6 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/DeletionVectorStruct.java @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.Serializable; +import org.apache.iceberg.avro.SupportsIndexProjection; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.types.Types; + +/** Mutable {@link StructLike} implementation of {@link DeletionVector}. */ +class DeletionVectorStruct extends SupportsIndexProjection implements DeletionVector, Serializable { + private static final Types.StructType BASE_TYPE = + Types.StructType.of( + DeletionVector.LOCATION, + DeletionVector.OFFSET, + DeletionVector.SIZE_IN_BYTES, + DeletionVector.CARDINALITY); + + private String location = null; + private long offset = -1L; + private long sizeInBytes = -1L; + private long cardinality = -1L; + + DeletionVectorStruct(Types.StructType type) { + super(BASE_TYPE, type); + } + + private DeletionVectorStruct(DeletionVectorStruct toCopy) { + super(toCopy); + this.location = toCopy.location; + this.offset = toCopy.offset; + this.sizeInBytes = toCopy.sizeInBytes; + this.cardinality = toCopy.cardinality; + } + + private DeletionVectorStruct(String location, long offset, long sizeInBytes, long cardinality) { + super(BASE_TYPE, BASE_TYPE); + this.location = location; + this.offset = offset; + this.sizeInBytes = sizeInBytes; + this.cardinality = cardinality; + } + + @Override + public String location() { + return location; + } + + @Override + public long offset() { + return offset; + } + + @Override + public long sizeInBytes() { + return sizeInBytes; + } + + @Override + public long cardinality() { + return cardinality; + } + + @Override + public DeletionVectorStruct copy() { + return new DeletionVectorStruct(this); + } + + @Override + protected T internalGet(int pos, Class javaClass) { + return javaClass.cast(getByPos(pos)); + } + + private Object getByPos(int pos) { + switch (pos) { + case 0: + return location; + case 1: + return offset; + case 2: + return sizeInBytes; + case 3: + return cardinality; + default: + throw new UnsupportedOperationException("Unknown field ordinal: " + pos); + } + } + + @Override + protected void internalSet(int pos, T value) { + switch (pos) { + case 0: + // always coerce to String for Serializable + this.location = value.toString(); + break; + case 1: + this.offset = (Long) value; + break; + case 2: + this.sizeInBytes = (Long) value; + break; + case 3: + this.cardinality = (Long) value; + break; + default: + // ignore the object, it must be from a newer version of the format + } + } + + static Builder builder() { + return new Builder(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("location", location) + .add("offset", offset) + .add("size_in_bytes", sizeInBytes) + .add("cardinality", cardinality) + .toString(); + } + + static class Builder { + private String location = null; + private long offset = -1L; + private long sizeInBytes = -1L; + private long cardinality = -1L; + + Builder location(String dvLocation) { + this.location = dvLocation; + return this; + } + + Builder offset(long dvOffset) { + this.offset = dvOffset; + return this; + } + + Builder sizeInBytes(long dvSizeInBytes) { + this.sizeInBytes = dvSizeInBytes; + return this; + } + + Builder cardinality(long dvCardinality) { + this.cardinality = dvCardinality; + return this; + } + + DeletionVectorStruct build() { + Preconditions.checkArgument(location != null, "Invalid location: null"); + Preconditions.checkArgument(offset >= 0, "Invalid offset: %s (must be >= 0)", offset); + Preconditions.checkArgument( + sizeInBytes >= 0, "Invalid size in bytes: %s (must be >= 0)", sizeInBytes); + Preconditions.checkArgument( + cardinality >= 0, "Invalid cardinality: %s (must be >= 0)", cardinality); + return new DeletionVectorStruct(location, offset, sizeInBytes, cardinality); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/EntryStatus.java b/core/src/main/java/org/apache/iceberg/EntryStatus.java new file mode 100644 index 000000000000..ceabeb562415 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/EntryStatus.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +/** Status of an entry in a manifest file. */ +enum EntryStatus { + EXISTING(0), + ADDED(1), + DELETED(2), + /** Indicates an entry that has been replaced by a column update or DV change. Added in v4. */ + REPLACED(3); + + private static final EntryStatus[] VALUES = EntryStatus.values(); + + private final int id; + + EntryStatus(int id) { + this.id = id; + } + + public int id() { + return id; + } + + static EntryStatus fromId(int id) { + return VALUES[id]; + } +} diff --git a/core/src/main/java/org/apache/iceberg/FastAppend.java b/core/src/main/java/org/apache/iceberg/FastAppend.java index 3d58e192a061..a6caed574ad4 100644 --- a/core/src/main/java/org/apache/iceberg/FastAppend.java +++ b/core/src/main/java/org/apache/iceberg/FastAppend.java @@ -166,6 +166,8 @@ public List apply(TableMetadata base, Snapshot snapshot) { manifests.addAll(snapshot.allManifests(ops().io())); } + summaryBuilder.merge(buildManifestCountSummary(manifests, 0)); + return manifests; } @@ -181,25 +183,12 @@ public Object updateEvent() { @Override protected void cleanUncommitted(Set committed) { if (newManifests != null) { - boolean hasDeletes = false; - for (ManifestFile manifest : newManifests) { - if (!committed.contains(manifest)) { - deleteFile(manifest.path()); - hasDeletes = true; - } - } - if (hasDeletes) { - this.newManifests.clear(); - } + deleteUncommitted(newManifests, committed, true /* clear manifests */); } // clean up only rewrittenAppendManifests as they are always owned by the table // don't clean up appendManifests as they are added to the manifest list and are not compacted - for (ManifestFile manifest : rewrittenAppendManifests) { - if (!committed.contains(manifest)) { - deleteFile(manifest.path()); - } - } + deleteUncommitted(rewrittenAppendManifests, committed, false); } /** diff --git a/core/src/main/java/org/apache/iceberg/FieldStatistic.java b/core/src/main/java/org/apache/iceberg/FieldStatistic.java new file mode 100644 index 000000000000..85712384254c --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/FieldStatistic.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.optional; + +import java.util.List; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +enum FieldStatistic { + VALUE_COUNT(1, "value_count"), + NULL_VALUE_COUNT(2, "null_value_count"), + NAN_VALUE_COUNT(3, "nan_value_count"), + AVG_VALUE_SIZE(4, "avg_value_size_in_bytes"), + MAX_VALUE_SIZE(5, "max_value_size_in_bytes"), + LOWER_BOUND(6, "lower_bound"), + UPPER_BOUND(7, "upper_bound"), + EXACT_BOUNDS(8, "exact_bounds"); + + private final int offset; + private final String fieldName; + + FieldStatistic(int offset, String fieldName) { + this.offset = offset; + this.fieldName = fieldName; + } + + /** + * The offset from the field ID of the base stats structure + * + * @return The offset from the field ID of the base strats structure + */ + public int offset() { + return offset; + } + + /** + * The ordinal position (0-based) within the stats structure + * + * @return The ordinal position (0-based) within the stats structure + */ + public int position() { + return offset - 1; + } + + /** + * The field name + * + * @return The field name + */ + public String fieldName() { + return fieldName; + } + + /** + * Returns the {@link FieldStatistic} from its ordinal position (0-based) in the stats structure + * + * @param position The ordinal position (0-based) in the stats structure + * @return The {@link FieldStatistic} from its ordinal position (0-based) in the stats structure + */ + public static FieldStatistic fromPosition(int position) { + return switch (position) { + case 0 -> VALUE_COUNT; + case 1 -> NULL_VALUE_COUNT; + case 2 -> NAN_VALUE_COUNT; + case 3 -> AVG_VALUE_SIZE; + case 4 -> MAX_VALUE_SIZE; + case 5 -> LOWER_BOUND; + case 6 -> UPPER_BOUND; + case 7 -> EXACT_BOUNDS; + default -> throw new IllegalArgumentException("Invalid statistic position: " + position); + }; + } + + public static Types.StructType fieldStatsFor(Types.NestedField field, int baseFieldId) { + List fields = Lists.newArrayListWithCapacity(8); + Type type = field.type(); + + fields.add( + optional( + baseFieldId + VALUE_COUNT.offset(), + VALUE_COUNT.fieldName(), + Types.LongType.get(), + "Total value count, including null and NaN")); + + if (field.isOptional()) { + fields.add( + optional( + baseFieldId + NULL_VALUE_COUNT.offset(), + NULL_VALUE_COUNT.fieldName(), + Types.LongType.get(), + "Total null value count")); + } + + if (type.typeId() == Type.TypeID.FLOAT || type.typeId() == Type.TypeID.DOUBLE) { + fields.add( + optional( + baseFieldId + NAN_VALUE_COUNT.offset(), + NAN_VALUE_COUNT.fieldName(), + Types.LongType.get(), + "Total NaN value count")); + } + + if (type.typeId() == Type.TypeID.STRING || type.typeId() == Type.TypeID.BINARY) { + fields.add( + optional( + baseFieldId + AVG_VALUE_SIZE.offset(), + AVG_VALUE_SIZE.fieldName(), + Types.IntegerType.get(), + "Avg value size in bytes of variable-length types (String, Binary)")); + fields.add( + optional( + baseFieldId + MAX_VALUE_SIZE.offset(), + MAX_VALUE_SIZE.fieldName(), + Types.IntegerType.get(), + "Max value size in bytes of variable-length types (String, Binary)")); + } + + fields.add( + optional(baseFieldId + LOWER_BOUND.offset(), LOWER_BOUND.fieldName(), type, "Lower bound")); + fields.add( + optional(baseFieldId + UPPER_BOUND.offset(), UPPER_BOUND.fieldName(), type, "Upper bound")); + fields.add( + optional( + baseFieldId + EXACT_BOUNDS.offset(), + EXACT_BOUNDS.fieldName(), + Types.BooleanType.get(), + "Whether the upper/lower bound is exact or not")); + + return Types.StructType.of(fields); + } +} diff --git a/api/src/main/java/org/apache/iceberg/stats/FieldStats.java b/core/src/main/java/org/apache/iceberg/FieldStats.java similarity index 92% rename from api/src/main/java/org/apache/iceberg/stats/FieldStats.java rename to core/src/main/java/org/apache/iceberg/FieldStats.java index 6411b479af49..e42d774c7cee 100644 --- a/api/src/main/java/org/apache/iceberg/stats/FieldStats.java +++ b/core/src/main/java/org/apache/iceberg/FieldStats.java @@ -16,12 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; +package org.apache.iceberg; -import org.apache.iceberg.StructLike; import org.apache.iceberg.types.Type; -public interface FieldStats extends StructLike { +interface FieldStats extends StructLike { /** The field ID of the statistic */ int fieldId(); diff --git a/core/src/main/java/org/apache/iceberg/FileCleanupStrategy.java b/core/src/main/java/org/apache/iceberg/FileCleanupStrategy.java index b55280a6537f..573aef057ff6 100644 --- a/core/src/main/java/org/apache/iceberg/FileCleanupStrategy.java +++ b/core/src/main/java/org/apache/iceberg/FileCleanupStrategy.java @@ -26,7 +26,9 @@ import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.Tasks; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,13 +80,15 @@ public abstract void cleanFiles( ExpireSnapshots.CleanupLevel cleanupLevel); private static final Schema MANIFEST_PROJECTION = - ManifestFile.schema() - .select( - "manifest_path", - "manifest_length", - "partition_spec_id", - "added_snapshot_id", - "deleted_data_files_count"); + TypeUtil.select( + ManifestFile.schema(), + ImmutableSet.of( + ManifestFile.PATH.fieldId(), + ManifestFile.LENGTH.fieldId(), + ManifestFile.SPEC_ID.fieldId(), + ManifestFile.SNAPSHOT_ID.fieldId(), + ManifestFile.ADDED_FILES_COUNT.fieldId(), + ManifestFile.DELETED_FILES_COUNT.fieldId())); protected CloseableIterable readManifests(Snapshot snapshot) { if (snapshot.manifestListLocation() != null) { diff --git a/core/src/main/java/org/apache/iceberg/FindFiles.java b/core/src/main/java/org/apache/iceberg/FindFiles.java index e21f3972ced6..9d6f84ad0ca9 100644 --- a/core/src/main/java/org/apache/iceberg/FindFiles.java +++ b/core/src/main/java/org/apache/iceberg/FindFiles.java @@ -208,18 +208,18 @@ public CloseableIterable collect() { } // when snapshot is not null - CloseableIterable> entries = - new ManifestGroup(ops.io(), snapshot.dataManifests(ops.io())) - .specsById(ops.current().specsById()) - .filterData(rowFilter) - .filterFiles(fileFilter) - .filterPartitions(partitionFilter) - .ignoreDeleted() - .caseSensitive(caseSensitive) - .planWith(executorService) - .entries(); - - return CloseableIterable.transform(entries, entry -> entry.file().copy(includeColumnStats)); + return new ManifestGroup(ops.io(), snapshot.dataManifests(ops.io())) + .specsById(ops.current().specsById()) + .filterData(rowFilter) + .filterFiles(fileFilter) + .filterPartitions(partitionFilter) + .ignoreDeleted() + .caseSensitive(caseSensitive) + .planWith(executorService) + .entries( + entries -> + CloseableIterable.transform( + entries, entry -> entry.file().copy(includeColumnStats))); } } } diff --git a/core/src/main/java/org/apache/iceberg/GenericManifestEntry.java b/core/src/main/java/org/apache/iceberg/GenericManifestEntry.java index b2ce5fa2aa11..f154c982d1c7 100644 --- a/core/src/main/java/org/apache/iceberg/GenericManifestEntry.java +++ b/core/src/main/java/org/apache/iceberg/GenericManifestEntry.java @@ -26,7 +26,6 @@ class GenericManifestEntry> implements ManifestEntry, IndexedRecord, SpecificData.SchemaConstructable, StructLike { - private static final Status[] STATUS_VALUES = Status.values(); private final org.apache.avro.Schema schema; private Status status = Status.EXISTING; private Long snapshotId = null; @@ -159,7 +158,7 @@ public void setFileSequenceNumber(long newFileSequenceNumber) { public void put(int i, Object v) { switch (i) { case 0: - this.status = STATUS_VALUES[(Integer) v]; + this.status = Status.fromId((Integer) v); return; case 1: this.snapshotId = (Long) v; diff --git a/core/src/main/java/org/apache/iceberg/GenericManifestFile.java b/core/src/main/java/org/apache/iceberg/GenericManifestFile.java index ac93222d01b5..9624484ffe0c 100644 --- a/core/src/main/java/org/apache/iceberg/GenericManifestFile.java +++ b/core/src/main/java/org/apache/iceberg/GenericManifestFile.java @@ -40,8 +40,6 @@ public class GenericManifestFile extends SupportsIndexProjection implements ManifestFile, StructLike, IndexedRecord, SchemaConstructable, Serializable { private static final Schema AVRO_SCHEMA = AvroSchemaUtil.convert(ManifestFile.schema(), "manifest_file"); - private static final ManifestContent[] MANIFEST_CONTENT_VALUES = ManifestContent.values(); - private transient Schema avroSchema; // not final for Java serialization // data fields @@ -343,7 +341,7 @@ protected void internalSet(int basePos, T value) { return; case 3: this.content = - value != null ? MANIFEST_CONTENT_VALUES[(Integer) value] : ManifestContent.DATA; + value != null ? ManifestContent.fromId((Integer) value) : ManifestContent.DATA; return; case 4: this.sequenceNumber = value != null ? (Long) value : 0; diff --git a/core/src/main/java/org/apache/iceberg/IncrementalDataTableScan.java b/core/src/main/java/org/apache/iceberg/IncrementalDataTableScan.java index 408e049ea65c..fc468e378c4a 100644 --- a/core/src/main/java/org/apache/iceberg/IncrementalDataTableScan.java +++ b/core/src/main/java/org/apache/iceberg/IncrementalDataTableScan.java @@ -101,6 +101,7 @@ public CloseableIterable planFiles() { manifestEntry -> snapshotIds.contains(manifestEntry.snapshotId()) && manifestEntry.status() == ManifestEntry.Status.ADDED) + .schemasById(schemas()) .specsById(table().specs()) .ignoreDeleted() .columnsToKeepStats(columnsToKeepStats()); diff --git a/core/src/main/java/org/apache/iceberg/InternalData.java b/core/src/main/java/org/apache/iceberg/InternalData.java index fa39d23e43fe..040905f03214 100644 --- a/core/src/main/java/org/apache/iceberg/InternalData.java +++ b/core/src/main/java/org/apache/iceberg/InternalData.java @@ -114,6 +114,20 @@ public interface WriteBuilder { */ WriteBuilder set(String property, String value); + /** + * Set writer configuration properties from a Map. + * + *

Write configuration affects writer behavior. To add file metadata properties, use {@link + * #meta(Map)}. + * + * @param properties a map of writer config properties + * @return this for method chaining + */ + default WriteBuilder set(Map properties) { + properties.forEach(this::set); + return this; + } + /** * Set a file metadata property. * diff --git a/core/src/main/java/org/apache/iceberg/LocationProviders.java b/core/src/main/java/org/apache/iceberg/LocationProviders.java index 6a75529ddb91..083e8b15f1d6 100644 --- a/core/src/main/java/org/apache/iceberg/LocationProviders.java +++ b/core/src/main/java/org/apache/iceberg/LocationProviders.java @@ -87,7 +87,7 @@ private static String getAndCheckLegacyLocation(Map properties, if (value != null && DEPRECATED_PROPERTIES.contains(key)) { throw new IllegalArgumentException( String.format( - "Property '%s' has been deprecated and will be removed in 2.0, use '%s' instead.", + "Property '%s' has been deprecated and will be removed in 2.0.0, use '%s' instead.", key, TableProperties.WRITE_DATA_LOCATION)); } diff --git a/core/src/main/java/org/apache/iceberg/ManifestEntry.java b/core/src/main/java/org/apache/iceberg/ManifestEntry.java index 4dce92cf5c2f..635231069ffc 100644 --- a/core/src/main/java/org/apache/iceberg/ManifestEntry.java +++ b/core/src/main/java/org/apache/iceberg/ManifestEntry.java @@ -30,6 +30,8 @@ enum Status { ADDED(1), DELETED(2); + private static final Status[] VALUES = Status.values(); + private final int id; Status(int id) { @@ -39,6 +41,10 @@ enum Status { public int id() { return id; } + + static Status fromId(int id) { + return VALUES[id]; + } } // ids for data-file columns are assigned from 1000 diff --git a/core/src/main/java/org/apache/iceberg/ManifestFiles.java b/core/src/main/java/org/apache/iceberg/ManifestFiles.java index 739f0be251df..5ac55f0cf41f 100644 --- a/core/src/main/java/org/apache/iceberg/ManifestFiles.java +++ b/core/src/main/java/org/apache/iceberg/ManifestFiles.java @@ -21,6 +21,7 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.io.IOException; +import java.util.Collections; import java.util.Map; import org.apache.iceberg.ManifestReader.FileType; import org.apache.iceberg.avro.AvroEncoderUtil; @@ -33,6 +34,7 @@ import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.metrics.CacheMetricsReport; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -86,19 +88,40 @@ public static void dropCache(FileIO fileIO) { CONTENT_CACHES.cleanUp(); } + /** Get statistics of the manifest file content cache for a FileIO. */ + public static CacheMetricsReport contentCacheStats(FileIO io) { + return CacheMetricsReport.of(contentCache(io).stats()); + } + /** * Returns a {@link CloseableIterable} of file paths in the {@link ManifestFile}. * * @param manifest a ManifestFile * @param io a FileIO + * @param specsById a Map from spec ID to partition spec * @return a manifest reader */ - public static CloseableIterable readPaths(ManifestFile manifest, FileIO io) { + public static CloseableIterable readPaths( + ManifestFile manifest, FileIO io, Map specsById) { return CloseableIterable.transform( - read(manifest, io, null).select(ImmutableList.of("file_path")).liveEntries(), + read(manifest, io, specsById).select(ImmutableList.of("file_path")).liveEntries(), entry -> entry.file().location()); } + /** + * Returns a {@link CloseableIterable} of file paths in the {@link ManifestFile}. + * + * @param manifest a ManifestFile + * @param io a FileIO + * @return a manifest reader + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link #readPaths(ManifestFile, + * FileIO, Map)} instead. + */ + @Deprecated + public static CloseableIterable readPaths(ManifestFile manifest, FileIO io) { + return readPaths(manifest, io, null); + } + /** * Returns a new {@link ManifestReader} for a {@link ManifestFile}. * @@ -109,7 +132,11 @@ public static CloseableIterable readPaths(ManifestFile manifest, FileIO * @param manifest a ManifestFile * @param io a FileIO * @return a manifest reader + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link #read(ManifestFile, FileIO, + * Map)} instead. Reading partition specs from manifest file metadata will not be supported + * for non-Avro manifest formats. */ + @Deprecated public static ManifestReader read(ManifestFile manifest, FileIO io) { return read(manifest, io, null); } @@ -124,6 +151,14 @@ public static ManifestReader read(ManifestFile manifest, FileIO io) { */ public static ManifestReader read( ManifestFile manifest, FileIO io, Map specsById) { + return read(manifest, io, specsById, true); + } + + static ManifestReader read( + ManifestFile manifest, + FileIO io, + Map specsById, + boolean isCommitted) { Preconditions.checkArgument( manifest.content() == ManifestContent.DATA, "Cannot read a delete manifest with a ManifestReader: %s", @@ -136,6 +171,7 @@ public static ManifestReader read( specsById, inheritableMetadata, manifest.firstRowId(), + isCommitted, FileType.DATA_FILES); } @@ -185,6 +221,25 @@ public static ManifestWriter write( return newWriter(formatVersion, spec, encryptedOutputFile, snapshotId, null); } + /** + * Create a new {@link ManifestWriter} for the given format version with custom writer properties. + * + * @param formatVersion a target format version + * @param spec a {@link PartitionSpec} + * @param encryptedOutputFile an {@link EncryptedOutputFile} where the manifest will be written + * @param snapshotId a snapshot ID for the manifest entries, or null for an inherited ID + * @param writerProperties properties passed through to the underlying file writer + * @return a manifest writer + */ + public static ManifestWriter write( + int formatVersion, + PartitionSpec spec, + EncryptedOutputFile encryptedOutputFile, + Long snapshotId, + Map writerProperties) { + return newWriter(formatVersion, spec, encryptedOutputFile, snapshotId, null, writerProperties); + } + /** * Create a new {@link ManifestWriter} for the given format version. * @@ -201,15 +256,54 @@ static ManifestWriter newWriter( EncryptedOutputFile encryptedOutputFile, Long snapshotId, Long firstRowId) { + return newWriter( + formatVersion, spec, encryptedOutputFile, snapshotId, firstRowId, Collections.emptyMap()); + } + + /** + * Create a new {@link ManifestWriter} for the given format version with custom writer properties. + * + * @param formatVersion a target format version + * @param spec a {@link PartitionSpec} + * @param outputFile an {@link OutputFile} where the manifest will be written + * @param snapshotId a snapshot ID for the manifest entries, or null for an inherited ID + * @param writerProperties properties passed through to the underlying file writer + * @return a manifest writer + */ + public static ManifestWriter write( + int formatVersion, + PartitionSpec spec, + OutputFile outputFile, + Long snapshotId, + Map writerProperties) { + return newWriter( + formatVersion, + spec, + EncryptedFiles.plainAsEncryptedOutput(outputFile), + snapshotId, + null, + writerProperties); + } + + @VisibleForTesting + static ManifestWriter newWriter( + int formatVersion, + PartitionSpec spec, + EncryptedOutputFile encryptedOutputFile, + Long snapshotId, + Long firstRowId, + Map writerProperties) { switch (formatVersion) { case 1: - return new ManifestWriter.V1Writer(spec, encryptedOutputFile, snapshotId); + return new ManifestWriter.V1Writer(spec, encryptedOutputFile, snapshotId, writerProperties); case 2: - return new ManifestWriter.V2Writer(spec, encryptedOutputFile, snapshotId); + return new ManifestWriter.V2Writer(spec, encryptedOutputFile, snapshotId, writerProperties); case 3: - return new ManifestWriter.V3Writer(spec, encryptedOutputFile, snapshotId, firstRowId); + return new ManifestWriter.V3Writer( + spec, encryptedOutputFile, snapshotId, firstRowId, writerProperties); case 4: - return new ManifestWriter.V4Writer(spec, encryptedOutputFile, snapshotId, firstRowId); + return new ManifestWriter.V4Writer( + spec, encryptedOutputFile, snapshotId, firstRowId, writerProperties); } throw new UnsupportedOperationException( "Cannot write manifest for table version: " + formatVersion); @@ -250,6 +344,30 @@ public static ManifestWriter writeDeleteManifest( formatVersion, spec, EncryptedFiles.plainAsEncryptedOutput(outputFile), snapshotId); } + /** + * Create a new {@link ManifestWriter} for the given format version with custom writer properties. + * + * @param formatVersion a target format version + * @param spec a {@link PartitionSpec} + * @param outputFile an {@link OutputFile} where the manifest will be written + * @param snapshotId a snapshot ID for the manifest entries, or null for an inherited ID + * @param writerProperties properties passed through to the underlying file writer + * @return a manifest writer + */ + public static ManifestWriter writeDeleteManifest( + int formatVersion, + PartitionSpec spec, + OutputFile outputFile, + Long snapshotId, + Map writerProperties) { + return writeDeleteManifest( + formatVersion, + spec, + EncryptedFiles.plainAsEncryptedOutput(outputFile), + snapshotId, + writerProperties); + } + /** * Create a new {@link ManifestWriter} for the given format version. * @@ -261,15 +379,34 @@ public static ManifestWriter writeDeleteManifest( */ public static ManifestWriter writeDeleteManifest( int formatVersion, PartitionSpec spec, EncryptedOutputFile outputFile, Long snapshotId) { + return writeDeleteManifest(formatVersion, spec, outputFile, snapshotId, Collections.emptyMap()); + } + + /** + * Create a new {@link ManifestWriter} for the given format version with custom writer properties. + * + * @param formatVersion a target format version + * @param spec a {@link PartitionSpec} + * @param outputFile an {@link EncryptedOutputFile} where the manifest will be written + * @param snapshotId a snapshot ID for the manifest entries, or null for an inherited ID + * @param writerProperties properties passed through to the underlying file writer + * @return a manifest writer + */ + public static ManifestWriter writeDeleteManifest( + int formatVersion, + PartitionSpec spec, + EncryptedOutputFile outputFile, + Long snapshotId, + Map writerProperties) { switch (formatVersion) { case 1: throw new IllegalArgumentException("Cannot write delete files in a v1 table"); case 2: - return new ManifestWriter.V2DeleteWriter(spec, outputFile, snapshotId); + return new ManifestWriter.V2DeleteWriter(spec, outputFile, snapshotId, writerProperties); case 3: - return new ManifestWriter.V3DeleteWriter(spec, outputFile, snapshotId); + return new ManifestWriter.V3DeleteWriter(spec, outputFile, snapshotId, writerProperties); case 4: - return new ManifestWriter.V4DeleteWriter(spec, outputFile, snapshotId); + return new ManifestWriter.V4DeleteWriter(spec, outputFile, snapshotId, writerProperties); } throw new UnsupportedOperationException( "Cannot write manifest for table version: " + formatVersion); @@ -300,6 +437,11 @@ public static ManifestFile decode(byte[] manifestData) throws IOException { return AvroEncoderUtil.decode(manifestData); } + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link #open(ManifestFile, FileIO, + * Map)} instead. + */ + @Deprecated static ManifestReader open(ManifestFile manifest, FileIO io) { return open(manifest, io, null); } diff --git a/core/src/main/java/org/apache/iceberg/ManifestFilterManager.java b/core/src/main/java/org/apache/iceberg/ManifestFilterManager.java index 9b5dce446732..7d146d924667 100644 --- a/core/src/main/java/org/apache/iceberg/ManifestFilterManager.java +++ b/core/src/main/java/org/apache/iceberg/ManifestFilterManager.java @@ -24,6 +24,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.iceberg.exceptions.RuntimeIOException; @@ -74,6 +75,9 @@ public String partition() { private final Set manifestsWithDeletes = Sets.newHashSet(); private final PartitionSet dropPartitions; private final CharSequenceSet deletePaths = CharSequenceSet.empty(); + // count of manifests that were rewritten with different manifest entry status during filtering + private final AtomicInteger replacedManifestsCount = new AtomicInteger(0); + private Expression deleteExpression = Expressions.alwaysFalse(); private long minSequenceNumber = 0; private boolean failAnyDelete = false; @@ -313,6 +317,18 @@ private Set deletedFiles(ManifestFile[] manifests) { return deletedFiles; } + /** + * Returns the count of manifests that were replaced (rewritten) during filtering. + * + *

A manifest is considered replaced when a new manifest was created to replace the original + * one (i.e., the original manifest != filtered manifest). + * + * @return the count of replaced manifests + */ + int replacedManifestsCount() { + return replacedManifestsCount.get(); + } + /** * Deletes filtered manifests that were created by this class, but are not in the committed * manifest set. @@ -329,9 +345,10 @@ void cleanUncommitted(Set committed) { ManifestFile manifest = entry.getKey(); ManifestFile filtered = entry.getValue(); if (!committed.contains(filtered)) { - // only delete if the filtered copy was created + // only delete if the filtered copy was created (manifest was replaced) if (!manifest.equals(filtered)) { deleteFile(filtered.path()); + replacedManifestsCount.decrementAndGet(); } // remove the entry from the cache @@ -342,6 +359,7 @@ void cleanUncommitted(Set committed) { private void invalidateFilteredCache() { cleanUncommitted(SnapshotProducer.EMPTY_SET); + replacedManifestsCount.set(0); } /** @@ -367,7 +385,9 @@ private ManifestFile filterManifest( // manifest without copying data. if a manifest does have a file to remove, this will break // out of the loop and move on to filtering the manifest. if (manifestHasDeletedFiles(evaluator, manifest, reader)) { - return filterManifestWithDeletedFiles(evaluator, manifest, reader); + ManifestFile filtered = filterManifestWithDeletedFiles(evaluator, manifest, reader); + replacedManifestsCount.incrementAndGet(); + return filtered; } else { filteredManifests.put(manifest, manifest); return manifest; diff --git a/core/src/main/java/org/apache/iceberg/ManifestGroup.java b/core/src/main/java/org/apache/iceberg/ManifestGroup.java index a52a9c3e8a7f..39b66d558b34 100644 --- a/core/src/main/java/org/apache/iceberg/ManifestGroup.java +++ b/core/src/main/java/org/apache/iceberg/ManifestGroup.java @@ -26,6 +26,7 @@ import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.function.BiFunction; +import java.util.function.Function; import java.util.function.Predicate; import org.apache.iceberg.expressions.Evaluator; import org.apache.iceberg.expressions.Expression; @@ -89,6 +90,11 @@ class ManifestGroup { this.scanMetrics = ScanMetrics.noop(); } + ManifestGroup schemasById(Map newSchemasById) { + deleteIndexBuilder.schemasById(newSchemasById); + return this; + } + ManifestGroup specsById(Map newSpecsById) { this.specsById = newSpecsById; deleteIndexBuilder.specsById(newSpecsById); @@ -226,6 +232,34 @@ public CloseableIterable> entries() { return CloseableIterable.concat(entries((manifest, entries) -> entries)); } + /** + * Returns a transformed iterable over manifest entries in the set of manifests. + * + *

The provided function is applied to the entries of each manifest to produce the output + * elements. When parallel execution is enabled via {@link #planWith(ExecutorService)}, manifests + * are scanned in parallel and the function is invoked in worker threads. + * + *

Since the underlying {@link ManifestReader} reuses entry objects during iteration, the + * provided function must make defensive copies of any entry data it needs to retain beyond the + * current iteration step. + * + * @param entryTransform a function that transforms each manifest's entries into the desired + * output + * @param the output element type + * @return a {@link CloseableIterable} of transformed elements + */ + public CloseableIterable entries( + Function>, CloseableIterable> entryTransform) { + Iterable> iterables = + entries((manifest, entries) -> entryTransform.apply(entries)); + + if (executorService != null) { + return new ParallelIterable<>(iterables, executorService); + } else { + return CloseableIterable.concat(iterables); + } + } + /** * Returns an iterable for groups of data files in the set of manifests. * diff --git a/core/src/main/java/org/apache/iceberg/ManifestInfo.java b/core/src/main/java/org/apache/iceberg/ManifestInfo.java new file mode 100644 index 000000000000..e87287911426 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/ManifestInfo.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.nio.ByteBuffer; +import org.apache.iceberg.types.Types; + +/** Summary information about a manifest referenced by a root manifest entry. */ +interface ManifestInfo { + Types.NestedField ADDED_FILES_COUNT = + Types.NestedField.required( + 504, "added_files_count", Types.IntegerType.get(), "Number of files added"); + Types.NestedField EXISTING_FILES_COUNT = + Types.NestedField.required( + 505, "existing_files_count", Types.IntegerType.get(), "Number of existing files"); + Types.NestedField DELETED_FILES_COUNT = + Types.NestedField.required( + 506, "deleted_files_count", Types.IntegerType.get(), "Number of deleted files"); + Types.NestedField REPLACED_FILES_COUNT = + Types.NestedField.required( + 520, "replaced_files_count", Types.IntegerType.get(), "Number of replaced files"); + Types.NestedField ADDED_ROWS_COUNT = + Types.NestedField.required( + 512, "added_rows_count", Types.LongType.get(), "Number of rows in added files"); + Types.NestedField EXISTING_ROWS_COUNT = + Types.NestedField.required( + 513, "existing_rows_count", Types.LongType.get(), "Number of rows in existing files"); + Types.NestedField DELETED_ROWS_COUNT = + Types.NestedField.required( + 514, "deleted_rows_count", Types.LongType.get(), "Number of rows in deleted files"); + Types.NestedField REPLACED_ROWS_COUNT = + Types.NestedField.required( + 521, "replaced_rows_count", Types.LongType.get(), "Number of rows in replaced files"); + Types.NestedField MIN_SEQUENCE_NUMBER = + Types.NestedField.required( + 516, + "min_sequence_number", + Types.LongType.get(), + "Minimum sequence number of files in this manifest"); + Types.NestedField DV = + Types.NestedField.optional( + 522, "dv", Types.BinaryType.get(), "Deletion vector for manifest entries"); + Types.NestedField DV_CARDINALITY = + Types.NestedField.optional( + 523, + "dv_cardinality", + Types.LongType.get(), + "Number of entries marked as deleted in the DV"); + + static Types.StructType schema() { + return Types.StructType.of( + ADDED_FILES_COUNT, + EXISTING_FILES_COUNT, + DELETED_FILES_COUNT, + REPLACED_FILES_COUNT, + ADDED_ROWS_COUNT, + EXISTING_ROWS_COUNT, + DELETED_ROWS_COUNT, + REPLACED_ROWS_COUNT, + MIN_SEQUENCE_NUMBER, + DV, + DV_CARDINALITY); + } + + /** Returns the number of files added by this manifest. */ + int addedFilesCount(); + + /** Returns the number of existing files referenced by this manifest. */ + int existingFilesCount(); + + /** Returns the number of deleted files in this manifest. */ + int deletedFilesCount(); + + /** Returns the number of replaced files in this manifest. */ + int replacedFilesCount(); + + /** Returns the number of rows in added files. */ + long addedRowsCount(); + + /** Returns the number of rows in existing files. */ + long existingRowsCount(); + + /** Returns the number of rows in deleted files. */ + long deletedRowsCount(); + + /** Returns the number of rows in replaced files. */ + long replacedRowsCount(); + + /** Returns the minimum sequence number of files in this manifest. */ + long minSequenceNumber(); + + /** Returns the deletion vector bitmap, or null if not present. */ + ByteBuffer dv(); + + /** Returns the number of entries marked as deleted in the DV, or null if not present. */ + Long dvCardinality(); + + /** Copies this manifest info. */ + ManifestInfo copy(); +} diff --git a/core/src/main/java/org/apache/iceberg/ManifestInfoStruct.java b/core/src/main/java/org/apache/iceberg/ManifestInfoStruct.java new file mode 100644 index 000000000000..922047bffedd --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/ManifestInfoStruct.java @@ -0,0 +1,384 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.util.Arrays; +import org.apache.iceberg.avro.SupportsIndexProjection; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; + +/** Mutable {@link StructLike} implementation of {@link ManifestInfo}. */ +class ManifestInfoStruct extends SupportsIndexProjection implements ManifestInfo, Serializable { + private static final Types.StructType BASE_TYPE = + Types.StructType.of( + ManifestInfo.ADDED_FILES_COUNT, + ManifestInfo.EXISTING_FILES_COUNT, + ManifestInfo.DELETED_FILES_COUNT, + ManifestInfo.REPLACED_FILES_COUNT, + ManifestInfo.ADDED_ROWS_COUNT, + ManifestInfo.EXISTING_ROWS_COUNT, + ManifestInfo.DELETED_ROWS_COUNT, + ManifestInfo.REPLACED_ROWS_COUNT, + ManifestInfo.MIN_SEQUENCE_NUMBER, + ManifestInfo.DV, + ManifestInfo.DV_CARDINALITY); + + private int addedFilesCount = -1; + private int existingFilesCount = -1; + private int deletedFilesCount = -1; + private int replacedFilesCount = -1; + private long addedRowsCount = -1L; + private long existingRowsCount = -1L; + private long deletedRowsCount = -1L; + private long replacedRowsCount = -1L; + private long minSequenceNumber = -1L; + private byte[] dv = null; + private Long dvCardinality = null; + + ManifestInfoStruct(Types.StructType type) { + super(BASE_TYPE, type); + } + + private ManifestInfoStruct(ManifestInfoStruct toCopy) { + super(toCopy); + this.addedFilesCount = toCopy.addedFilesCount; + this.existingFilesCount = toCopy.existingFilesCount; + this.deletedFilesCount = toCopy.deletedFilesCount; + this.replacedFilesCount = toCopy.replacedFilesCount; + this.addedRowsCount = toCopy.addedRowsCount; + this.existingRowsCount = toCopy.existingRowsCount; + this.deletedRowsCount = toCopy.deletedRowsCount; + this.replacedRowsCount = toCopy.replacedRowsCount; + this.minSequenceNumber = toCopy.minSequenceNumber; + this.dv = toCopy.dv != null ? Arrays.copyOf(toCopy.dv, toCopy.dv.length) : null; + this.dvCardinality = toCopy.dvCardinality; + } + + private ManifestInfoStruct( + int addedFilesCount, + int existingFilesCount, + int deletedFilesCount, + int replacedFilesCount, + long addedRowsCount, + long existingRowsCount, + long deletedRowsCount, + long replacedRowsCount, + long minSequenceNumber, + byte[] dv, + Long dvCardinality) { + super(BASE_TYPE, BASE_TYPE); + this.addedFilesCount = addedFilesCount; + this.existingFilesCount = existingFilesCount; + this.deletedFilesCount = deletedFilesCount; + this.replacedFilesCount = replacedFilesCount; + this.addedRowsCount = addedRowsCount; + this.existingRowsCount = existingRowsCount; + this.deletedRowsCount = deletedRowsCount; + this.replacedRowsCount = replacedRowsCount; + this.minSequenceNumber = minSequenceNumber; + this.dv = dv; + this.dvCardinality = dvCardinality; + } + + @Override + public int addedFilesCount() { + return addedFilesCount; + } + + @Override + public int existingFilesCount() { + return existingFilesCount; + } + + @Override + public int deletedFilesCount() { + return deletedFilesCount; + } + + @Override + public int replacedFilesCount() { + return replacedFilesCount; + } + + @Override + public long addedRowsCount() { + return addedRowsCount; + } + + @Override + public long existingRowsCount() { + return existingRowsCount; + } + + @Override + public long deletedRowsCount() { + return deletedRowsCount; + } + + @Override + public long replacedRowsCount() { + return replacedRowsCount; + } + + @Override + public long minSequenceNumber() { + return minSequenceNumber; + } + + @Override + public ByteBuffer dv() { + return dv != null ? ByteBuffer.wrap(dv) : null; + } + + @Override + public Long dvCardinality() { + return dvCardinality; + } + + @Override + public ManifestInfoStruct copy() { + return new ManifestInfoStruct(this); + } + + @Override + protected T internalGet(int pos, Class javaClass) { + return javaClass.cast(getByPos(pos)); + } + + private Object getByPos(int pos) { + switch (pos) { + case 0: + return addedFilesCount; + case 1: + return existingFilesCount; + case 2: + return deletedFilesCount; + case 3: + return replacedFilesCount; + case 4: + return addedRowsCount; + case 5: + return existingRowsCount; + case 6: + return deletedRowsCount; + case 7: + return replacedRowsCount; + case 8: + return minSequenceNumber; + case 9: + return dv(); + case 10: + return dvCardinality; + default: + throw new UnsupportedOperationException("Unknown field ordinal: " + pos); + } + } + + @Override + protected void internalSet(int pos, T value) { + switch (pos) { + case 0: + this.addedFilesCount = (Integer) value; + break; + case 1: + this.existingFilesCount = (Integer) value; + break; + case 2: + this.deletedFilesCount = (Integer) value; + break; + case 3: + this.replacedFilesCount = (Integer) value; + break; + case 4: + this.addedRowsCount = (Long) value; + break; + case 5: + this.existingRowsCount = (Long) value; + break; + case 6: + this.deletedRowsCount = (Long) value; + break; + case 7: + this.replacedRowsCount = (Long) value; + break; + case 8: + this.minSequenceNumber = (Long) value; + break; + case 9: + this.dv = ByteBuffers.toByteArray((ByteBuffer) value); + break; + case 10: + this.dvCardinality = (Long) value; + break; + default: + // ignore the object, it must be from a newer version of the format + } + } + + static Builder builder() { + return new Builder(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("added_files_count", addedFilesCount) + .add("existing_files_count", existingFilesCount) + .add("deleted_files_count", deletedFilesCount) + .add("replaced_files_count", replacedFilesCount) + .add("added_rows_count", addedRowsCount) + .add("existing_rows_count", existingRowsCount) + .add("deleted_rows_count", deletedRowsCount) + .add("replaced_rows_count", replacedRowsCount) + .add("min_sequence_number", minSequenceNumber) + .add("dv", dv == null ? "null" : "(binary)") + .add("dv_cardinality", dvCardinality == null ? "null" : dvCardinality) + .toString(); + } + + static class Builder { + private int addedFilesCount = -1; + private int existingFilesCount = -1; + private int deletedFilesCount = -1; + private int replacedFilesCount = -1; + private long addedRowsCount = -1L; + private long existingRowsCount = -1L; + private long deletedRowsCount = -1L; + private long replacedRowsCount = -1L; + private long minSequenceNumber = -1L; + private byte[] dv = null; + private Long dvCardinality = null; + + Builder addedFilesCount(int count) { + this.addedFilesCount = count; + return this; + } + + Builder existingFilesCount(int count) { + this.existingFilesCount = count; + return this; + } + + Builder deletedFilesCount(int count) { + this.deletedFilesCount = count; + return this; + } + + Builder replacedFilesCount(int count) { + this.replacedFilesCount = count; + return this; + } + + Builder addedRowsCount(long count) { + this.addedRowsCount = count; + return this; + } + + Builder existingRowsCount(long count) { + this.existingRowsCount = count; + return this; + } + + Builder deletedRowsCount(long count) { + this.deletedRowsCount = count; + return this; + } + + Builder replacedRowsCount(long count) { + this.replacedRowsCount = count; + return this; + } + + Builder minSequenceNumber(long sequenceNumber) { + this.minSequenceNumber = sequenceNumber; + return this; + } + + Builder dv(ByteBuffer buffer) { + this.dv = buffer != null ? ByteBuffers.toByteArray(buffer) : null; + return this; + } + + Builder dv(byte[] buffer) { + this.dv = buffer; + return this; + } + + Builder dvCardinality(Long cardinality) { + this.dvCardinality = cardinality; + return this; + } + + ManifestInfoStruct build() { + Preconditions.checkArgument( + addedFilesCount >= 0, "Invalid added files count: %s (must be >= 0)", addedFilesCount); + Preconditions.checkArgument( + existingFilesCount >= 0, + "Invalid existing files count: %s (must be >= 0)", + existingFilesCount); + Preconditions.checkArgument( + deletedFilesCount >= 0, + "Invalid deleted files count: %s (must be >= 0)", + deletedFilesCount); + Preconditions.checkArgument( + replacedFilesCount >= 0, + "Invalid replaced files count: %s (must be >= 0)", + replacedFilesCount); + Preconditions.checkArgument( + addedRowsCount >= 0, "Invalid added rows count: %s (must be >= 0)", addedRowsCount); + Preconditions.checkArgument( + existingRowsCount >= 0, + "Invalid existing rows count: %s (must be >= 0)", + existingRowsCount); + Preconditions.checkArgument( + deletedRowsCount >= 0, "Invalid deleted rows count: %s (must be >= 0)", deletedRowsCount); + Preconditions.checkArgument( + replacedRowsCount >= 0, + "Invalid replaced rows count: %s (must be >= 0)", + replacedRowsCount); + Preconditions.checkArgument( + minSequenceNumber >= 0, + "Invalid min sequence number: %s (must be >= 0)", + minSequenceNumber); + Preconditions.checkArgument( + (dv == null) == (dvCardinality == null), + "Invalid DV and cardinality: must both be null or non-null"); + Preconditions.checkArgument( + dvCardinality == null || dvCardinality > 0, + "Invalid DV cardinality: %s (must be positive)", + dvCardinality); + return new ManifestInfoStruct( + addedFilesCount, + existingFilesCount, + deletedFilesCount, + replacedFilesCount, + addedRowsCount, + existingRowsCount, + deletedRowsCount, + replacedRowsCount, + minSequenceNumber, + dv, + dvCardinality); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/ManifestMergeManager.java b/core/src/main/java/org/apache/iceberg/ManifestMergeManager.java index 94eb8a110709..0aec0ac69a9f 100644 --- a/core/src/main/java/org/apache/iceberg/ManifestMergeManager.java +++ b/core/src/main/java/org/apache/iceberg/ManifestMergeManager.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import org.apache.iceberg.ManifestEntry.Status; import org.apache.iceberg.exceptions.RuntimeIOException; @@ -43,6 +44,8 @@ abstract class ManifestMergeManager> { private final int minCountToMerge; private final boolean mergeEnabled; + // track manifests replaced during bin-packing + private final AtomicInteger replacedManifestsCount = new AtomicInteger(0); // cache merge results to reuse when retrying private final Map, ManifestFile> mergedManifests = Maps.newConcurrentMap(); @@ -69,6 +72,10 @@ abstract class ManifestMergeManager> { protected abstract ManifestReader newManifestReader(ManifestFile manifest); + protected ManifestReader newManifestReader(ManifestFile manifest, boolean isCommitted) { + return newManifestReader(manifest); + } + Iterable mergeManifests(Iterable manifests) { Iterator manifestIter = manifests.iterator(); if (!mergeEnabled || !manifestIter.hasNext()) { @@ -86,6 +93,18 @@ Iterable mergeManifests(Iterable manifests) { return merged; } + /** + * Returns the count of manifests that were replaced (merged) during bin-packing. + * + *

When multiple manifests are merged into a single manifest, each of the original manifests is + * considered replaced. + * + * @return the count of replaced manifests + */ + int replacedManifestsCount() { + return replacedManifestsCount.get(); + } + void cleanUncommitted(Set committed) { // iterate over a copy of entries to avoid concurrent modification List, ManifestFile>> entries = @@ -96,8 +115,13 @@ void cleanUncommitted(Set committed) { ManifestFile merged = entry.getValue(); if (!committed.contains(merged)) { deleteFile(merged.path()); - // remove the deleted file from the cache - mergedManifests.remove(entry.getKey()); + List bin = entry.getKey(); + mergedManifests.remove(bin); + for (ManifestFile m : bin) { + if (snapshotId() != m.snapshotId()) { + replacedManifestsCount.decrementAndGet(); + } + } } } } @@ -152,7 +176,7 @@ private Iterable mergeGroup( // not enough to merge, add all manifest files to the output list outputManifests.addAll(bin); } else { - // merge the group + // merge the bin into a single manifest outputManifests.add(createManifest(specId, bin)); } }); @@ -172,7 +196,9 @@ private ManifestFile createManifest(int specId, List bin) { boolean threw = true; try { for (ManifestFile manifest : bin) { - try (ManifestReader reader = newManifestReader(manifest)) { + boolean isCommitted = + manifest.snapshotId() != null && snapshotId() != manifest.snapshotId(); + try (ManifestReader reader = newManifestReader(manifest, isCommitted)) { for (ManifestEntry entry : reader.entries()) { if (entry.status() == Status.DELETED) { // suppress deletes from previous snapshots. only files deleted by this snapshot @@ -200,8 +226,14 @@ private ManifestFile createManifest(int specId, List bin) { ManifestFile manifest = writer.toManifestFile(); - // update the cache + // cache the merged manifest to reuse when retrying and track replaced manifests mergedManifests.put(bin, manifest); + for (ManifestFile m : bin) { + // only count manifests from previous snapshots; in-memory manifests are not replaced + if (snapshotId() != m.snapshotId()) { + replacedManifestsCount.incrementAndGet(); + } + } return manifest; } diff --git a/core/src/main/java/org/apache/iceberg/ManifestReader.java b/core/src/main/java/org/apache/iceberg/ManifestReader.java index 40ea17aaa592..e3c2325ab780 100644 --- a/core/src/main/java/org/apache/iceberg/ManifestReader.java +++ b/core/src/main/java/org/apache/iceberg/ManifestReader.java @@ -43,8 +43,11 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PartitionSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Base reader for data and delete manifest files. @@ -53,6 +56,8 @@ */ public class ManifestReader> extends CloseableGroup implements CloseableIterable { + private static final Logger LOG = LoggerFactory.getLogger(ManifestReader.class); + static final ImmutableList ALL_COLUMNS = ImmutableList.of("*"); private static final Set STATS_COLUMNS = @@ -64,6 +69,11 @@ public class ManifestReader> extends CloseableGroup "upper_bounds", "record_count"); + private static final Schema STATUS_ONLY_PROJECTION = + TypeUtil.select( + ManifestEntry.getSchema(Types.StructType.of()), + ImmutableSet.of(ManifestEntry.STATUS.fieldId())); + protected enum FileType { DATA_FILES(GenericDataFile.class), DELETE_FILES(GenericDeleteFile.class); @@ -82,6 +92,7 @@ private Class fileClass() { private final InputFile file; private final InheritableMetadata inheritableMetadata; private final Long firstRowId; + private final boolean isCommitted; private final FileType content; private final PartitionSpec spec; private final Schema fileSchema; @@ -115,17 +126,33 @@ protected ManifestReader( InheritableMetadata inheritableMetadata, Long firstRowId, FileType content) { + this(file, specId, specsById, inheritableMetadata, firstRowId, true, content); + } + + protected ManifestReader( + InputFile file, + int specId, + Map specsById, + InheritableMetadata inheritableMetadata, + Long firstRowId, + boolean isCommitted, + FileType content) { Preconditions.checkArgument( firstRowId == null || content == FileType.DATA_FILES, "First row ID is not valid for delete manifests"); this.file = file; this.inheritableMetadata = inheritableMetadata; this.firstRowId = firstRowId; + this.isCommitted = isCommitted; this.content = content; if (specsById != null) { this.spec = specsById.get(specId); } else { + LOG.warn( + "Reading partition spec from manifest file metadata is deprecated and will be " + + "removed in the 1.12.0 release. Pass specsById to avoid reading from file metadata: {}", + file.location()); this.spec = readPartitionSpec(file); } @@ -149,9 +176,7 @@ private static > Map readMetadata(Input Map metadata; try { try (CloseableIterable> headerReader = - InternalData.read(FileFormat.AVRO, inputFile) - .project(ManifestEntry.getSchema(Types.StructType.of()).select("status")) - .build()) { + InternalData.read(FileFormat.AVRO, inputFile).project(STATUS_ONLY_PROJECTION).build()) { if (headerReader instanceof AvroIterable) { metadata = ((AvroIterable>) headerReader).getMetadata(); @@ -296,7 +321,7 @@ private CloseableIterable> open(Schema projection) { CloseableIterable> withMetadata = CloseableIterable.transform(reader, inheritableMetadata::apply); - return CloseableIterable.transform(withMetadata, idAssigner(firstRowId)); + return CloseableIterable.transform(withMetadata, idAssigner(firstRowId, isCommitted)); } CloseableIterable> liveEntries() { @@ -386,7 +411,7 @@ static List withStatsColumns(Collection columns) { } private static > Function, ManifestEntry> idAssigner( - Long firstRowId) { + Long firstRowId, boolean isCommitted) { if (firstRowId != null) { return new Function<>() { private long nextRowId = firstRowId; @@ -404,8 +429,13 @@ public ManifestEntry apply(ManifestEntry entry) { return entry; } }; + } else if (!isCommitted) { + // Preserve firstRowId for entries in uncommitted manifests, including EXISTING entries that + // may be merged later + return Function.identity(); } else { - // data file's first_row_id is null when the manifest's first_row_id is null + // committed manifest with null manifest-level firstRowId (pre-v3 upgrade path) + // defensively set the first row ID for every entry to be null return entry -> { if (entry.file() instanceof BaseFile) { ((BaseFile) entry.file()).setFirstRowId(null); diff --git a/core/src/main/java/org/apache/iceberg/ManifestWriter.java b/core/src/main/java/org/apache/iceberg/ManifestWriter.java index 43b8e3ed7095..7d85f991b080 100644 --- a/core/src/main/java/org/apache/iceberg/ManifestWriter.java +++ b/core/src/main/java/org/apache/iceberg/ManifestWriter.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.nio.ByteBuffer; +import java.util.Map; import org.apache.iceberg.encryption.EncryptedOutputFile; import org.apache.iceberg.encryption.EncryptionKeyMetadata; import org.apache.iceberg.encryption.NativeEncryptionKeyMetadata; @@ -47,6 +48,7 @@ public abstract class ManifestWriter> implements FileAp private final GenericManifestEntry reused; private final PartitionSummary stats; private final Long firstRowId; + private final Map writerProperties; private boolean closed = false; private int addedFiles = 0; @@ -58,9 +60,14 @@ public abstract class ManifestWriter> implements FileAp private Long minDataSequenceNumber = null; private ManifestWriter( - PartitionSpec spec, EncryptedOutputFile file, Long snapshotId, Long firstRowId) { + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Long firstRowId, + Map writerProperties) { this.file = file.encryptingOutputFile(); this.specId = spec.specId(); + this.writerProperties = writerProperties; this.writer = newAppender(spec, this.file); this.snapshotId = snapshotId; this.reused = @@ -75,6 +82,10 @@ private ManifestWriter( protected abstract FileAppender> newAppender( PartitionSpec spec, OutputFile outputFile); + protected Map writerProperties() { + return writerProperties; + } + protected ManifestContent content() { return ManifestContent.DATA; } @@ -238,8 +249,13 @@ public void close() throws IOException { static class V4Writer extends ManifestWriter { private final V4Metadata.ManifestEntryWrapper entryWrapper; - V4Writer(PartitionSpec spec, EncryptedOutputFile file, Long snapshotId, Long firstRowId) { - super(spec, file, snapshotId, firstRowId); + V4Writer( + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Long firstRowId, + Map writerProperties) { + super(spec, file, snapshotId, firstRowId, writerProperties); this.entryWrapper = new V4Metadata.ManifestEntryWrapper<>(snapshotId); } @@ -261,6 +277,7 @@ protected FileAppender> newAppender( .meta("partition-spec-id", String.valueOf(spec.specId())) .meta("format-version", "4") .meta("content", "data") + .set(writerProperties()) .overwrite() .build(); } catch (IOException e) { @@ -273,8 +290,12 @@ protected FileAppender> newAppender( static class V4DeleteWriter extends ManifestWriter { private final V4Metadata.ManifestEntryWrapper entryWrapper; - V4DeleteWriter(PartitionSpec spec, EncryptedOutputFile file, Long snapshotId) { - super(spec, file, snapshotId, null); + V4DeleteWriter( + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Map writerProperties) { + super(spec, file, snapshotId, null, writerProperties); this.entryWrapper = new V4Metadata.ManifestEntryWrapper<>(snapshotId); } @@ -296,6 +317,7 @@ protected FileAppender> newAppender( .meta("partition-spec-id", String.valueOf(spec.specId())) .meta("format-version", "4") .meta("content", "deletes") + .set(writerProperties()) .overwrite() .build(); } catch (IOException e) { @@ -313,8 +335,13 @@ protected ManifestContent content() { static class V3Writer extends ManifestWriter { private final V3Metadata.ManifestEntryWrapper entryWrapper; - V3Writer(PartitionSpec spec, EncryptedOutputFile file, Long snapshotId, Long firstRowId) { - super(spec, file, snapshotId, firstRowId); + V3Writer( + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Long firstRowId, + Map writerProperties) { + super(spec, file, snapshotId, firstRowId, writerProperties); this.entryWrapper = new V3Metadata.ManifestEntryWrapper<>(snapshotId); } @@ -336,6 +363,7 @@ protected FileAppender> newAppender( .meta("partition-spec-id", String.valueOf(spec.specId())) .meta("format-version", "3") .meta("content", "data") + .set(writerProperties()) .overwrite() .build(); } catch (IOException e) { @@ -348,8 +376,12 @@ protected FileAppender> newAppender( static class V3DeleteWriter extends ManifestWriter { private final V3Metadata.ManifestEntryWrapper entryWrapper; - V3DeleteWriter(PartitionSpec spec, EncryptedOutputFile file, Long snapshotId) { - super(spec, file, snapshotId, null); + V3DeleteWriter( + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Map writerProperties) { + super(spec, file, snapshotId, null, writerProperties); this.entryWrapper = new V3Metadata.ManifestEntryWrapper<>(snapshotId); } @@ -371,6 +403,7 @@ protected FileAppender> newAppender( .meta("partition-spec-id", String.valueOf(spec.specId())) .meta("format-version", "3") .meta("content", "deletes") + .set(writerProperties()) .overwrite() .build(); } catch (IOException e) { @@ -388,8 +421,12 @@ protected ManifestContent content() { static class V2Writer extends ManifestWriter { private final V2Metadata.ManifestEntryWrapper entryWrapper; - V2Writer(PartitionSpec spec, EncryptedOutputFile file, Long snapshotId) { - super(spec, file, snapshotId, null); + V2Writer( + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Map writerProperties) { + super(spec, file, snapshotId, null, writerProperties); this.entryWrapper = new V2Metadata.ManifestEntryWrapper<>(snapshotId); } @@ -411,6 +448,7 @@ protected FileAppender> newAppender( .meta("partition-spec-id", String.valueOf(spec.specId())) .meta("format-version", "2") .meta("content", "data") + .set(writerProperties()) .overwrite() .build(); } catch (IOException e) { @@ -423,8 +461,12 @@ protected FileAppender> newAppender( static class V2DeleteWriter extends ManifestWriter { private final V2Metadata.ManifestEntryWrapper entryWrapper; - V2DeleteWriter(PartitionSpec spec, EncryptedOutputFile file, Long snapshotId) { - super(spec, file, snapshotId, null); + V2DeleteWriter( + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Map writerProperties) { + super(spec, file, snapshotId, null, writerProperties); this.entryWrapper = new V2Metadata.ManifestEntryWrapper<>(snapshotId); } @@ -446,6 +488,7 @@ protected FileAppender> newAppender( .meta("partition-spec-id", String.valueOf(spec.specId())) .meta("format-version", "2") .meta("content", "deletes") + .set(writerProperties()) .overwrite() .build(); } catch (IOException e) { @@ -463,8 +506,12 @@ protected ManifestContent content() { static class V1Writer extends ManifestWriter { private final V1Metadata.ManifestEntryWrapper entryWrapper; - V1Writer(PartitionSpec spec, EncryptedOutputFile file, Long snapshotId) { - super(spec, file, snapshotId, null); + V1Writer( + PartitionSpec spec, + EncryptedOutputFile file, + Long snapshotId, + Map writerProperties) { + super(spec, file, snapshotId, null, writerProperties); this.entryWrapper = new V1Metadata.ManifestEntryWrapper(); } @@ -485,6 +532,7 @@ protected FileAppender> newAppender( .meta("partition-spec", PartitionSpecParser.toJsonFields(spec)) .meta("partition-spec-id", String.valueOf(spec.specId())) .meta("format-version", "1") + .set(writerProperties()) .overwrite() .build(); } catch (IOException e) { diff --git a/core/src/main/java/org/apache/iceberg/MergingSnapshotProducer.java b/core/src/main/java/org/apache/iceberg/MergingSnapshotProducer.java index 9ed2f4f4c0fb..1a70b4f90b8f 100644 --- a/core/src/main/java/org/apache/iceberg/MergingSnapshotProducer.java +++ b/core/src/main/java/org/apache/iceberg/MergingSnapshotProducer.java @@ -30,13 +30,19 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.events.CreateSnapshotEvent; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.ManifestEvaluator; +import org.apache.iceberg.expressions.Projections; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.base.Predicate; @@ -47,6 +53,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.collect.Streams; import org.apache.iceberg.util.CharSequenceSet; import org.apache.iceberg.util.ContentFileUtil; import org.apache.iceberg.util.DataFileSet; @@ -55,6 +62,7 @@ import org.apache.iceberg.util.PartitionSet; import org.apache.iceberg.util.SnapshotUtil; import org.apache.iceberg.util.Tasks; +import org.apache.iceberg.util.ThreadPools; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -82,15 +90,17 @@ abstract class MergingSnapshotProducer extends SnapshotProducer { private final ManifestFilterManager filterManager; private final ManifestMergeManager deleteMergeManager; private final ManifestFilterManager deleteFilterManager; + private final AtomicInteger dvMergeAttempt = new AtomicInteger(0); // update data private final Map newDataFilesBySpec = Maps.newHashMap(); private Long newDataFilesDataSequenceNumber; - private final Map newDeleteFilesBySpec = Maps.newHashMap(); - private final Set newDVRefs = Sets.newHashSet(); + private final List v2Deletes = Lists.newArrayList(); + private final Map> dvsByReferencedFile = Maps.newLinkedHashMap(); private final List appendManifests = Lists.newArrayList(); private final List rewrittenAppendManifests = Lists.newArrayList(); - private final SnapshotSummary.Builder addedFilesSummary = SnapshotSummary.builder(); + private final SnapshotSummary.Builder addedDataFilesSummary = SnapshotSummary.builder(); + private final SnapshotSummary.Builder addedDeleteFilesSummary = SnapshotSummary.builder(); private final SnapshotSummary.Builder appendedManifestsSummary = SnapshotSummary.builder(); private Expression deleteExpression = Expressions.alwaysFalse(); @@ -222,7 +232,8 @@ protected boolean addsDataFiles() { } protected boolean addsDeleteFiles() { - return !newDeleteFilesBySpec.isEmpty(); + return !v2Deletes.isEmpty() + || dvsByReferencedFile.values().stream().anyMatch(dvs -> !dvs.isEmpty()); } /** Add a data file to the new snapshot. */ @@ -238,7 +249,7 @@ protected void add(DataFile file) { DataFileSet dataFiles = newDataFilesBySpec.computeIfAbsent(spec.specId(), ignored -> DataFileSet.create()); if (dataFiles.add(Delegates.suppressFirstRowId(file))) { - addedFilesSummary.addedFile(spec, file); + addedDataFilesSummary.addedFile(spec, file); hasNewDataFiles = true; } } @@ -265,21 +276,24 @@ private void addInternal(DeleteFile file) { "Cannot find partition spec %s for delete file: %s", file.specId(), file.location()); - - DeleteFileSet deleteFiles = - newDeleteFilesBySpec.computeIfAbsent(spec.specId(), ignored -> DeleteFileSet.create()); - if (deleteFiles.add(file)) { - addedFilesSummary.addedFile(spec, file); - hasNewDeleteFiles = true; - if (ContentFileUtil.isDV(file)) { - newDVRefs.add(file.referencedDataFile()); - } + hasNewDeleteFiles = true; + if (ContentFileUtil.isDV(file)) { + List dvsForReferencedFile = + dvsByReferencedFile.computeIfAbsent( + file.referencedDataFile(), newFile -> Lists.newArrayList()); + dvsForReferencedFile.add(file); + } else { + v2Deletes.add(file); } } protected void validateNewDeleteFile(DeleteFile file) { Preconditions.checkNotNull(file, "Invalid delete file: null"); - switch (formatVersion()) { + validateDeleteFileForVersion(file, formatVersion()); + } + + private static void validateDeleteFileForVersion(DeleteFile file, int formatVersion) { + switch (formatVersion) { case 1: throw new IllegalArgumentException("Deletes are supported in V2 and above"); case 2: @@ -289,21 +303,15 @@ protected void validateNewDeleteFile(DeleteFile file) { ContentFileUtil.dvDesc(file)); break; case 3: - Preconditions.checkArgument( - file.content() == FileContent.EQUALITY_DELETES || ContentFileUtil.isDV(file), - "Must use DVs for position deletes in V%s: %s", - formatVersion(), - file.location()); - break; case 4: Preconditions.checkArgument( file.content() == FileContent.EQUALITY_DELETES || ContentFileUtil.isDV(file), "Must use DVs for position deletes in V%s: %s", - formatVersion(), + formatVersion, file.location()); break; default: - throw new IllegalArgumentException("Unsupported format version: " + formatVersion()); + throw new IllegalArgumentException("Unsupported format version: " + formatVersion); } } @@ -820,7 +828,7 @@ protected void validateAddedDVs( Expression conflictDetectionFilter, Snapshot parent) { // skip if there is no current table state or this operation doesn't add new DVs - if (parent == null || newDVRefs.isEmpty()) { + if (parent == null || dvsByReferencedFile.isEmpty()) { return; } @@ -834,7 +842,12 @@ protected void validateAddedDVs( List newDeleteManifests = history.first(); Set newSnapshotIds = history.second(); - Tasks.foreach(newDeleteManifests) + Iterable matchingManifests = + Iterables.filter( + filterManifestsByPartition(base, conflictDetectionFilter, newDeleteManifests), + ManifestFile::hasAddedFiles); + + Tasks.foreach(matchingManifests) .stopOnFailure() .throwFailureWhenFinished() .executeWith(workerPool()) @@ -853,7 +866,7 @@ private void validateAddedDVs( DeleteFile file = entry.file(); if (newSnapshotIds.contains(entry.snapshotId()) && ContentFileUtil.isDV(file)) { ValidationException.check( - !newDVRefs.contains(file.referencedDataFile()), + !dvsByReferencedFile.containsKey(file.referencedDataFile()), "Found concurrently added DV for %s: %s", file.referencedDataFile(), ContentFileUtil.dvDesc(file)); @@ -864,6 +877,38 @@ private void validateAddedDVs( } } + private Iterable filterManifestsByPartition( + TableMetadata base, Expression conflictDetectionFilter, List manifests) { + if (conflictDetectionFilter == null || conflictDetectionFilter == Expressions.alwaysTrue()) { + return manifests; + } + + // if any concurrent manifest was written with a different partition spec, skip pruning + // to avoid incorrectly excluding manifests when a spec change happened during validation + int defaultSpecId = base.defaultSpecId(); + if (manifests.stream().anyMatch(m -> m.partitionSpecId() != defaultSpecId)) { + return manifests; + } + + Map specsById = base.specsById(); + Map evaluators = Maps.newHashMap(); + return Iterables.filter( + manifests, + manifest -> { + ManifestEvaluator evaluator = + evaluators.computeIfAbsent( + manifest.partitionSpecId(), + specId -> { + PartitionSpec spec = specsById.get(specId); + Expression partitionFilter = + Projections.inclusive(spec, caseSensitive).project(conflictDetectionFilter); + return ManifestEvaluator.forPartitionFilter( + partitionFilter, spec, caseSensitive); + }); + return evaluator.eval(manifest); + }); + } + // returns newly added manifests and snapshot IDs between the starting and parent snapshots private Pair, Set> validationHistory( TableMetadata base, @@ -918,8 +963,16 @@ protected Map summary() { return summaryBuilder.build(); } + // guard buffered deletes against concurrent format upgrade + private void validateDeleteFilesForVersion(int currentFormatVersion) { + for (DeleteFile file : v2Deletes) { + validateDeleteFileForVersion(file, currentFormatVersion); + } + } + @Override public List apply(TableMetadata base, Snapshot snapshot) { + validateDeleteFilesForVersion(base.formatVersion()); // filter any existing manifests List filtered = filterManager.filterManifests( @@ -959,7 +1012,8 @@ public List apply(TableMetadata base, Snapshot snapshot) { // update the snapshot summary summaryBuilder.clear(); - summaryBuilder.merge(addedFilesSummary); + summaryBuilder.merge(addedDataFilesSummary); + summaryBuilder.merge(addedDeleteFilesSummary); summaryBuilder.merge(appendedManifestsSummary); summaryBuilder.merge(filterManager.buildSummary(filtered)); summaryBuilder.merge(deleteFilterManager.buildSummary(filteredDeletes)); @@ -968,13 +1022,32 @@ public List apply(TableMetadata base, Snapshot snapshot) { Iterables.addAll(manifests, mergeManager.mergeManifests(unmergedManifests)); Iterables.addAll(manifests, deleteMergeManager.mergeManifests(unmergedDeleteManifests)); + // update created/kept/replaced manifest count + // replaced manifests come from: + // 1. filterManager - manifests rewritten to remove deleted files + // 2. deleteFilterManager - delete manifests rewritten to remove deleted files + // 3. mergeManager - data manifests merged via bin-packing + // 4. deleteMergeManager - delete manifests merged via bin-packing + // Note: rewrittenAppendManifests are NEW manifests (copies), not replaced ones + int replacedManifestsCount = + filterManager.replacedManifestsCount() + + deleteFilterManager.replacedManifestsCount() + + mergeManager.replacedManifestsCount() + + deleteMergeManager.replacedManifestsCount(); + summaryBuilder.merge(buildManifestCountSummary(manifests, replacedManifestsCount)); + return manifests; } @Override public Object updateEvent() { long snapshotId = snapshotId(); - Snapshot justSaved = ops().refresh().snapshot(snapshotId); + + Snapshot justSaved = ops().current().snapshot(snapshotId); + if (justSaved == null) { + justSaved = ops().refresh().snapshot(snapshotId); + } + long sequenceNumber = TableMetadata.INVALID_SEQUENCE_NUMBER; Map summary; if (justSaved == null) { @@ -991,62 +1064,29 @@ public Object updateEvent() { return new CreateSnapshotEvent(tableName, operation(), snapshotId, sequenceNumber, summary); } - @SuppressWarnings("checkstyle:CyclomaticComplexity") - private void cleanUncommittedAppends(Set committed) { - if (!cachedNewDataManifests.isEmpty()) { - boolean hasDeletes = false; - for (ManifestFile manifest : cachedNewDataManifests) { - if (!committed.contains(manifest)) { - deleteFile(manifest.path()); - hasDeletes = true; - } - } - - if (hasDeletes) { - this.cachedNewDataManifests.clear(); - } - } - - boolean hasDeleteDeletes = false; - for (ManifestFile cachedNewDeleteManifest : cachedNewDeleteManifests) { - if (!committed.contains(cachedNewDeleteManifest)) { - deleteFile(cachedNewDeleteManifest.path()); - hasDeleteDeletes = true; - } - } - - if (hasDeleteDeletes) { - this.cachedNewDeleteManifests.clear(); - } + @Override + protected void cleanUncommitted(Set committed) { + mergeManager.cleanUncommitted(committed); + filterManager.cleanUncommitted(committed); + deleteMergeManager.cleanUncommitted(committed); + deleteFilterManager.cleanUncommitted(committed); + cleanUncommittedAppends(committed); + } + private void cleanUncommittedAppends(Set committed) { + deleteUncommitted(cachedNewDataManifests, committed, true /* clear manifests */); + deleteUncommitted(cachedNewDeleteManifests, committed, true /* clear manifests */); // rewritten manifests are always owned by the table - for (ManifestFile manifest : rewrittenAppendManifests) { - if (!committed.contains(manifest)) { - deleteFile(manifest.path()); - } - } + deleteUncommitted(rewrittenAppendManifests, committed, false); // manifests that are not rewritten are only owned by the table if the commit succeeded if (!committed.isEmpty()) { // the commit succeeded if at least one manifest was committed // the table now owns appendManifests; clean up any that are not used - for (ManifestFile manifest : appendManifests) { - if (!committed.contains(manifest)) { - deleteFile(manifest.path()); - } - } + deleteUncommitted(appendManifests, committed, false); } } - @Override - protected void cleanUncommitted(Set committed) { - mergeManager.cleanUncommitted(committed); - filterManager.cleanUncommitted(committed); - deleteMergeManager.cleanUncommitted(committed); - deleteFilterManager.cleanUncommitted(committed); - cleanUncommittedAppends(committed); - } - private Iterable prepareNewDataManifests() { Iterable newManifests; if (!newDataFilesBySpec.isEmpty()) { @@ -1081,7 +1121,7 @@ private List newDataFilesAsManifests() { } private Iterable prepareDeleteManifests() { - if (newDeleteFilesBySpec.isEmpty()) { + if (!addsDeleteFiles()) { return ImmutableList.of(); } @@ -1096,12 +1136,22 @@ private List newDeleteFilesAsManifests() { // this triggers a rewrite of all delete manifests even if there is only one new delete file // if there is a relevant use case in the future, the behavior can be optimized cachedNewDeleteManifests.clear(); + // On cache invalidation of delete files, clear the summary because any new DV could require a + // merge, + // and the summary cannot be generated until after merging is complete. + addedDeleteFilesSummary.clear(); } if (cachedNewDeleteManifests.isEmpty()) { + List mergedDVs = mergeDVs(); + Map> newDeleteFilesBySpec = + Streams.stream(Iterables.concat(mergedDVs, DeleteFileSet.of(v2Deletes))) + .collect(Collectors.groupingBy(ContentFile::specId)); + newDeleteFilesBySpec.forEach( (specId, deleteFiles) -> { PartitionSpec spec = ops().current().spec(specId); + deleteFiles.forEach(file -> addedDeleteFilesSummary.addedFile(spec, file)); List newDeleteManifests = writeDeleteManifests(deleteFiles, spec); cachedNewDeleteManifests.addAll(newDeleteManifests); }); @@ -1112,6 +1162,35 @@ private List newDeleteFilesAsManifests() { return cachedNewDeleteManifests; } + private List mergeDVs() { + for (Map.Entry> entry : dvsByReferencedFile.entrySet()) { + if (entry.getValue().size() > 1) { + LOG.warn( + "Merging {} duplicate DVs for data file {} in table {}.", + entry.getValue().size(), + entry.getKey(), + tableName); + } + } + + FileIO fileIO = EncryptingFileIO.combine(ops().io(), ops().encryption()); + + String dvOutputLocation = + ops() + .locationProvider() + .newDataLocation( + FileFormat.PUFFIN.addExtension( + String.format( + "merged-dvs-%s-%s", snapshotId(), dvMergeAttempt.incrementAndGet()))); + + return DVUtil.mergeAndWriteDVsIfRequired( + dvsByReferencedFile, + dvOutputLocation, + fileIO, + ops().current().specsById(), + ThreadPools.getDeleteWorkerPool()); + } + private class DataFileFilterManager extends ManifestFilterManager { private DataFileFilterManager() { super(ops().current().specsById(), MergingSnapshotProducer.this::workerPool); @@ -1171,7 +1250,13 @@ protected ManifestWriter newManifestWriter(PartitionSpec manifestSpec) @Override protected ManifestReader newManifestReader(ManifestFile manifest) { - return MergingSnapshotProducer.this.newManifestReader(manifest); + return newManifestReader(manifest, true); + } + + @Override + protected ManifestReader newManifestReader( + ManifestFile manifest, boolean isCommitted) { + return ManifestFiles.read(manifest, ops().io(), ops().current().specsById(), isCommitted); } } diff --git a/core/src/main/java/org/apache/iceberg/MetricsConfig.java b/core/src/main/java/org/apache/iceberg/MetricsConfig.java index 9d1022f5c354..2b55bcbeab22 100644 --- a/core/src/main/java/org/apache/iceberg/MetricsConfig.java +++ b/core/src/main/java/org/apache/iceberg/MetricsConfig.java @@ -84,7 +84,7 @@ public static MetricsConfig forPositionDelete() { * Creates a metrics config from table configuration. * * @param props table configuration - * @deprecated use {@link MetricsConfig#forTable(Table)} + * @deprecated use {@link MetricsConfig#forTable(Table)}. Will be removed in 2.0.0 */ @Deprecated public static MetricsConfig fromProperties(Map props) { @@ -223,7 +223,7 @@ public Set map( * @param order sort order columns, will be promoted to truncate(16) * @return metrics configuration */ - private static MetricsConfig from(Map props, Schema schema, SortOrder order) { + public static MetricsConfig from(Map props, Schema schema, SortOrder order) { int maxInferredDefaultColumns = maxInferredColumnDefaults(props); Map columnModes = Maps.newHashMap(); diff --git a/core/src/main/java/org/apache/iceberg/MetricsUtil.java b/core/src/main/java/org/apache/iceberg/MetricsUtil.java index 96645bf074ae..72c57a8bebcf 100644 --- a/core/src/main/java/org/apache/iceberg/MetricsUtil.java +++ b/core/src/main/java/org/apache/iceberg/MetricsUtil.java @@ -20,11 +20,13 @@ import static org.apache.iceberg.types.Types.NestedField.optional; +import java.nio.ByteBuffer; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -476,4 +478,63 @@ public void set(int pos, T value) { throw new UnsupportedOperationException("StructWithReadableMetrics is read only"); } } + + static ContentStats fromMetrics(Schema schema, Metrics metrics) { + if (null == metrics) { + return null; + } + + BaseContentStats.Builder builder = BaseContentStats.builder().withTableSchema(schema); + Map> map = Maps.newHashMap(); + mergeCountMetric(map, metrics.valueCounts(), BaseFieldStats.Builder::valueCount); + mergeCountMetric(map, metrics.nullValueCounts(), BaseFieldStats.Builder::nullValueCount); + mergeCountMetric(map, metrics.nanValueCounts(), BaseFieldStats.Builder::nanValueCount); + mergeBoundMetric( + map, metrics.lowerBounds(), metrics.originalTypes(), BaseFieldStats.Builder::lowerBound); + mergeBoundMetric( + map, metrics.upperBounds(), metrics.originalTypes(), BaseFieldStats.Builder::upperBound); + + map.values().forEach(fieldStats -> builder.withFieldStats(fieldStats.build())); + + return builder.build(); + } + + private static void mergeCountMetric( + Map> fieldStatsById, + Map counts, + BiFunction, Long, BaseFieldStats.Builder> setter) { + if (counts == null) { + return; + } + + counts.forEach( + (id, value) -> + fieldStatsById.merge( + id, + setter.apply(BaseFieldStats.builder().fieldId(id), value), + (oldVal, newVal) -> setter.apply(oldVal, value))); + } + + private static void mergeBoundMetric( + Map> fieldStatsById, + Map bounds, + Map originalTypes, + BiFunction, Object, BaseFieldStats.Builder> setter) { + if (bounds == null || originalTypes == null) { + return; + } + + bounds.entrySet().stream() + .filter(entry -> originalTypes.get(entry.getKey()) != null) + .forEach( + entry -> { + Integer id = entry.getKey(); + Type type = originalTypes.get(id); + Object boundValue = Conversions.fromByteBuffer(type, entry.getValue()); + fieldStatsById.merge( + id, + setter.apply(BaseFieldStats.builder().fieldId(id).type(type), boundValue), + (oldVal, newVal) -> setter.apply(oldVal.type(type), boundValue)); + }); + } } diff --git a/core/src/main/java/org/apache/iceberg/MicroBatches.java b/core/src/main/java/org/apache/iceberg/MicroBatches.java index d96246f15b02..8fede277dc58 100644 --- a/core/src/main/java/org/apache/iceberg/MicroBatches.java +++ b/core/src/main/java/org/apache/iceberg/MicroBatches.java @@ -206,7 +206,8 @@ public MicroBatchBuilder specsById(Map specs) { public MicroBatch generate(long startFileIndex, long targetSizeInBytes, boolean scanAllFiles) { return generate( startFileIndex, - Iterables.size(snapshot.addedDataFiles(io)), + Iterables.size( + SnapshotChanges.builderFor(snapshot, io, specsById).build().addedDataFiles()), targetSizeInBytes, scanAllFiles); } diff --git a/core/src/main/java/org/apache/iceberg/PartitionData.java b/core/src/main/java/org/apache/iceberg/PartitionData.java index 41bc4c0c1233..41ad72bf0bac 100644 --- a/core/src/main/java/org/apache/iceberg/PartitionData.java +++ b/core/src/main/java/org/apache/iceberg/PartitionData.java @@ -135,7 +135,8 @@ public Object get(int pos) { } if (data[pos] instanceof byte[]) { - return ByteBuffer.wrap((byte[]) data[pos]); + byte[] copied = Arrays.copyOf((byte[]) data[pos], ((byte[]) data[pos]).length); + return ByteBuffer.wrap(copied); } return data[pos]; diff --git a/core/src/main/java/org/apache/iceberg/PartitionStats.java b/core/src/main/java/org/apache/iceberg/PartitionStats.java index 9051c8535c7e..e8a4e18916bc 100644 --- a/core/src/main/java/org/apache/iceberg/PartitionStats.java +++ b/core/src/main/java/org/apache/iceberg/PartitionStats.java @@ -20,6 +20,12 @@ import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +/** + * Class to hold partition statistics values. + * + * @deprecated will be removed in 1.12.0. Use {@link BasePartitionStatistics instead} + */ +@Deprecated public class PartitionStats implements StructLike { private static final int STATS_COUNT = 13; diff --git a/core/src/main/java/org/apache/iceberg/PartitionStatsHandler.java b/core/src/main/java/org/apache/iceberg/PartitionStatsHandler.java index 4e7c1b104ee8..9420095f94a3 100644 --- a/core/src/main/java/org/apache/iceberg/PartitionStatsHandler.java +++ b/core/src/main/java/org/apache/iceberg/PartitionStatsHandler.java @@ -68,30 +68,101 @@ private PartitionStatsHandler() {} private static final Logger LOG = LoggerFactory.getLogger(PartitionStatsHandler.class); // schema of the partition stats file as per spec - public static final int PARTITION_FIELD_ID = 1; - public static final String PARTITION_FIELD_NAME = "partition"; + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#EMPTY_PARTITION_FIELD} + */ + @Deprecated public static final int PARTITION_FIELD_ID = 1; + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#EMPTY_PARTITION_FIELD} + */ + @Deprecated public static final String PARTITION_FIELD_NAME = "partition"; + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#SPEC_ID} + */ + @Deprecated public static final NestedField SPEC_ID = NestedField.required(2, "spec_id", IntegerType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#DATA_RECORD_COUNT} + */ + @Deprecated public static final NestedField DATA_RECORD_COUNT = NestedField.required(3, "data_record_count", LongType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#DATA_FILE_COUNT} + */ + @Deprecated public static final NestedField DATA_FILE_COUNT = NestedField.required(4, "data_file_count", IntegerType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link + * PartitionStatistics#TOTAL_DATA_FILE_SIZE_IN_BYTES} + */ + @Deprecated public static final NestedField TOTAL_DATA_FILE_SIZE_IN_BYTES = NestedField.required(5, "total_data_file_size_in_bytes", LongType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link + * PartitionStatistics#POSITION_DELETE_RECORD_COUNT} + */ + @Deprecated public static final NestedField POSITION_DELETE_RECORD_COUNT = NestedField.optional(6, "position_delete_record_count", LongType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link + * PartitionStatistics#POSITION_DELETE_FILE_COUNT} + */ + @Deprecated public static final NestedField POSITION_DELETE_FILE_COUNT = NestedField.optional(7, "position_delete_file_count", IntegerType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link + * PartitionStatistics#EQUALITY_DELETE_RECORD_COUNT} + */ + @Deprecated public static final NestedField EQUALITY_DELETE_RECORD_COUNT = NestedField.optional(8, "equality_delete_record_count", LongType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link + * PartitionStatistics#EQUALITY_DELETE_FILE_COUNT} + */ + @Deprecated public static final NestedField EQUALITY_DELETE_FILE_COUNT = NestedField.optional(9, "equality_delete_file_count", IntegerType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#TOTAL_RECORD_COUNT} + */ + @Deprecated public static final NestedField TOTAL_RECORD_COUNT = NestedField.optional(10, "total_record_count", LongType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#LAST_UPDATED_AT} + */ + @Deprecated public static final NestedField LAST_UPDATED_AT = NestedField.optional(11, "last_updated_at", LongType.get()); + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#LAST_UPDATED_SNAPSHOT_ID} + */ + @Deprecated public static final NestedField LAST_UPDATED_SNAPSHOT_ID = NestedField.optional(12, "last_updated_snapshot_id", LongType.get()); - // Using default value for v3 field to support v3 reader reading file written by v2 + + /** + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#DV_COUNT} + */ + @Deprecated public static final NestedField DV_COUNT = NestedField.required("dv_count") .withId(13) @@ -107,7 +178,10 @@ private PartitionStatsHandler() {} * @param unifiedPartitionType unified partition schema type. Could be calculated by {@link * Partitioning#partitionType(Table)}. * @return a schema that corresponds to the provided unified partition type. + * @deprecated will be removed in 1.12.0. Use {@link PartitionStatistics#schema(StructType, int)} + * instead. */ + @Deprecated public static Schema schema(StructType unifiedPartitionType, int formatVersion) { Preconditions.checkState(!unifiedPartitionType.fields().isEmpty(), "Table must be partitioned"); Preconditions.checkState( @@ -208,9 +282,7 @@ public static PartitionStatisticsFile computeAndWriteStatsFile(Table table, long Snapshot snapshot = table.snapshot(snapshotId); Preconditions.checkArgument(snapshot != null, "Snapshot not found: %s", snapshotId); - StructType partitionType = Partitioning.partitionType(table); - - Collection stats; + Collection stats; PartitionStatisticsFile statisticsFile = latestStatsFile(table, snapshot.snapshotId()); if (statisticsFile == null) { LOG.info( @@ -225,7 +297,7 @@ public static PartitionStatisticsFile computeAndWriteStatsFile(Table table, long } try { - stats = computeAndMergeStatsIncremental(table, snapshot, partitionType, statisticsFile); + stats = computeAndMergeStatsIncremental(table, snapshot, statisticsFile.snapshotId()); } catch (InvalidStatsFileException exception) { LOG.warn( "Using full compute as previous statistics file is corrupted for incremental compute."); @@ -240,17 +312,18 @@ public static PartitionStatisticsFile computeAndWriteStatsFile(Table table, long return null; } - List sortedStats = sortStatsByPartition(stats, partitionType); + StructType partitionType = Partitioning.partitionType(table); + List sortedStats = sortStatsByPartition(stats, partitionType); return writePartitionStatsFile( table, snapshot.snapshotId(), - schema(partitionType, TableUtil.formatVersion(table)), + PartitionStatistics.schema(partitionType, TableUtil.formatVersion(table)), sortedStats); } @VisibleForTesting static PartitionStatisticsFile writePartitionStatsFile( - Table table, long snapshotId, Schema dataSchema, Iterable records) + Table table, long snapshotId, Schema dataSchema, Iterable records) throws IOException { FileFormat fileFormat = FileFormat.fromString( @@ -275,7 +348,9 @@ static PartitionStatisticsFile writePartitionStatsFile( * * @param schema The {@link Schema} of the partition statistics file. * @param inputFile An {@link InputFile} pointing to the partition stats file. + * @deprecated will be removed in 1.12.0, use {@link PartitionStatisticsScan} instead */ + @Deprecated public static CloseableIterable readPartitionStatsFile( Schema schema, InputFile inputFile) { Preconditions.checkArgument(schema != null, "Invalid schema: null"); @@ -320,17 +395,11 @@ private static PartitionStats recordToPartitionStats(StructLike record) { return stats; } - private static Collection computeAndMergeStatsIncremental( - Table table, - Snapshot snapshot, - StructType partitionType, - PartitionStatisticsFile previousStatsFile) { - PartitionMap statsMap = PartitionMap.create(table.specs()); - // read previous stats, note that partition field will be read as GenericRecord - try (CloseableIterable oldStats = - readPartitionStatsFile( - schema(partitionType, TableUtil.formatVersion(table)), - table.io().newInputFile(previousStatsFile.path()))) { + private static Collection computeAndMergeStatsIncremental( + Table table, Snapshot snapshot, long lastSnapshotWithStats) { + PartitionMap statsMap = PartitionMap.create(table.specs()); + try (CloseableIterable oldStats = + table.newPartitionStatisticsScan().useSnapshot(lastSnapshotWithStats).scan()) { oldStats.forEach( partitionStats -> statsMap.put(partitionStats.specId(), partitionStats.partition(), partitionStats)); @@ -339,8 +408,8 @@ private static Collection computeAndMergeStatsIncremental( } // incrementally compute the new stats, partition field will be written as PartitionData - PartitionMap incrementalStatsMap = - computeStatsDiff(table, table.snapshot(previousStatsFile.snapshotId()), snapshot); + PartitionMap incrementalStatsMap = + computeStatsDiff(table, table.snapshot(lastSnapshotWithStats), snapshot); // convert PartitionData into GenericRecord and merge stats incrementalStatsMap.forEach( @@ -349,7 +418,7 @@ private static Collection computeAndMergeStatsIncremental( Pair.of(key.first(), partitionDataToRecord((PartitionData) key.second())), value, (existingEntry, newEntry) -> { - existingEntry.appendStats(newEntry); + appendStats(existingEntry, newEntry); return existingEntry; })); @@ -387,7 +456,7 @@ static PartitionStatisticsFile latestStatsFile(Table table, long snapshotId) { return null; } - private static PartitionMap computeStatsDiff( + private static PartitionMap computeStatsDiff( Table table, Snapshot fromSnapshot, Snapshot toSnapshot) { Iterable snapshots = SnapshotUtil.ancestorsBetween( @@ -406,10 +475,10 @@ private static PartitionMap computeStatsDiff( return computeStats(table, manifests, true /* incremental */); } - private static PartitionMap computeStats( + private static PartitionMap computeStats( Table table, List manifests, boolean incremental) { StructType partitionType = Partitioning.partitionType(table); - Queue> statsByManifest = Queues.newConcurrentLinkedQueue(); + Queue> statsByManifest = Queues.newConcurrentLinkedQueue(); Tasks.foreach(manifests) .stopOnFailure() .throwFailureWhenFinished() @@ -419,19 +488,20 @@ private static PartitionMap computeStats( statsByManifest.add( collectStatsForManifest(table, manifest, partitionType, incremental))); - PartitionMap statsMap = PartitionMap.create(table.specs()); - for (PartitionMap stats : statsByManifest) { + PartitionMap statsMap = PartitionMap.create(table.specs()); + for (PartitionMap stats : statsByManifest) { mergePartitionMap(stats, statsMap); } return statsMap; } - private static PartitionMap collectStatsForManifest( + private static PartitionMap collectStatsForManifest( Table table, ManifestFile manifest, StructType partitionType, boolean incremental) { List projection = BaseScan.scanColumns(manifest.content()); - try (ManifestReader reader = ManifestFiles.open(manifest, table.io()).select(projection)) { - PartitionMap statsMap = PartitionMap.create(table.specs()); + try (ManifestReader reader = + ManifestFiles.open(manifest, table.io(), table.specs()).select(projection)) { + PartitionMap statsMap = PartitionMap.create(table.specs()); int specId = manifest.partitionSpecId(); PartitionSpec spec = table.specs().get(specId); PartitionData keyTemplate = new PartitionData(partitionType); @@ -442,22 +512,22 @@ private static PartitionMap collectStatsForManifest( PartitionUtil.coercePartition(partitionType, spec, file.partition()); StructLike key = keyTemplate.copyFor(coercedPartition); Snapshot snapshot = table.snapshot(entry.snapshotId()); - PartitionStats stats = + PartitionStatistics stats = statsMap.computeIfAbsent( specId, ((PartitionData) file.partition()).copy(), - () -> new PartitionStats(key, specId)); + () -> new BasePartitionStatistics(key, specId)); if (entry.isLive()) { // Live can have both added and existing entries. Consider only added entries for // incremental compute as existing entries was already included in previous compute. if (!incremental || entry.status() == ManifestEntry.Status.ADDED) { - stats.liveEntry(file, snapshot); + liveEntry(stats, file, snapshot); } } else { if (incremental) { - stats.deletedEntryForIncrementalCompute(file, snapshot); + deletedEntryForIncrementalCompute(stats, file, snapshot); } else { - stats.deletedEntry(snapshot); + deletedEntry(stats, snapshot); } } } @@ -469,26 +539,209 @@ private static PartitionMap collectStatsForManifest( } private static void mergePartitionMap( - PartitionMap fromMap, PartitionMap toMap) { + PartitionMap fromMap, PartitionMap toMap) { fromMap.forEach( (key, value) -> toMap.merge( key, value, (existingEntry, newEntry) -> { - existingEntry.appendStats(newEntry); + appendStats(existingEntry, newEntry); return existingEntry; })); } - private static List sortStatsByPartition( - Collection stats, StructType partitionType) { - List entries = Lists.newArrayList(stats); + private static List sortStatsByPartition( + Collection stats, StructType partitionType) { + List entries = Lists.newArrayList(stats); entries.sort( - Comparator.comparing(PartitionStats::partition, Comparators.forType(partitionType))); + Comparator.comparing(PartitionStatistics::partition, Comparators.forType(partitionType))); return entries; } + /** + * Updates the partition stats from the data/delete file. + * + * @param stats partition statistics to be updated. + * @param file the {@link ContentFile} from the manifest entry. + * @param snapshot the snapshot corresponding to the live entry. + */ + private static void liveEntry(PartitionStatistics stats, ContentFile file, Snapshot snapshot) { + Preconditions.checkArgument(stats.specId() == file.specId(), "Spec IDs must match"); + + switch (file.content()) { + case DATA: + stats.set( + PartitionStatistics.DATA_RECORD_COUNT_POSITION, + stats.dataRecordCount() + file.recordCount()); + stats.set(PartitionStatistics.DATA_FILE_COUNT_POSITION, stats.dataFileCount() + 1); + stats.set( + PartitionStatistics.TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION, + stats.totalDataFileSizeInBytes() + file.fileSizeInBytes()); + break; + case POSITION_DELETES: + stats.set( + PartitionStatistics.POSITION_DELETE_RECORD_COUNT_POSITION, + stats.positionDeleteRecordCount() + file.recordCount()); + if (file.format() == FileFormat.PUFFIN) { + stats.set(PartitionStatistics.DV_COUNT_POSITION, stats.dvCount() + 1); + } else { + stats.set( + PartitionStatistics.POSITION_DELETE_FILE_COUNT_POSITION, + stats.positionDeleteFileCount() + 1); + } + + break; + case EQUALITY_DELETES: + stats.set( + PartitionStatistics.EQUALITY_DELETE_RECORD_COUNT_POSITION, + stats.equalityDeleteRecordCount() + file.recordCount()); + stats.set( + PartitionStatistics.EQUALITY_DELETE_FILE_COUNT_POSITION, + stats.equalityDeleteFileCount() + 1); + break; + default: + throw new UnsupportedOperationException("Unsupported file content type: " + file.content()); + } + + if (snapshot != null) { + updateSnapshotInfo(stats, snapshot.snapshotId(), snapshot.timestampMillis()); + } + + // Note: Not computing the `TOTAL_RECORD_COUNT` for now as it needs scanning the data. + } + + /** + * Updates the modified time and snapshot ID in stats for the deleted manifest entry. + * + * @param stats partition statistics to be updated. + * @param snapshot the snapshot corresponding to the deleted manifest entry. + */ + private static void deletedEntry(PartitionStatistics stats, Snapshot snapshot) { + if (snapshot != null) { + updateSnapshotInfo(stats, snapshot.snapshotId(), snapshot.timestampMillis()); + } + } + + /** + * Decrement the counters in stats as it was included in the previous stats and updates the + * modified time and snapshot ID for the deleted manifest entry. + * + * @param stats partition statistics to be updated. + * @param snapshot the snapshot corresponding to the deleted manifest entry. + */ + private static void deletedEntryForIncrementalCompute( + PartitionStatistics stats, ContentFile file, Snapshot snapshot) { + Preconditions.checkArgument(stats.specId() == file.specId(), "Spec IDs must match"); + + switch (file.content()) { + case DATA: + stats.set( + PartitionStatistics.DATA_RECORD_COUNT_POSITION, + stats.dataRecordCount() - file.recordCount()); + stats.set(PartitionStatistics.DATA_FILE_COUNT_POSITION, stats.dataFileCount() - 1); + stats.set( + PartitionStatistics.TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION, + stats.totalDataFileSizeInBytes() - file.fileSizeInBytes()); + break; + case POSITION_DELETES: + stats.set( + PartitionStatistics.POSITION_DELETE_RECORD_COUNT_POSITION, + stats.positionDeleteRecordCount() - file.recordCount()); + if (file.format() == FileFormat.PUFFIN) { + stats.set(PartitionStatistics.DV_COUNT_POSITION, stats.dvCount() - 1); + } else { + stats.set( + PartitionStatistics.POSITION_DELETE_FILE_COUNT_POSITION, + stats.positionDeleteFileCount() - 1); + } + + break; + case EQUALITY_DELETES: + stats.set( + PartitionStatistics.EQUALITY_DELETE_RECORD_COUNT_POSITION, + stats.equalityDeleteRecordCount() - file.recordCount()); + stats.set( + PartitionStatistics.EQUALITY_DELETE_FILE_COUNT_POSITION, + stats.equalityDeleteFileCount() - 1); + break; + default: + throw new UnsupportedOperationException("Unsupported file content type: " + file.content()); + } + + if (snapshot != null) { + updateSnapshotInfo(stats, snapshot.snapshotId(), snapshot.timestampMillis()); + } + } + + /** + * Appends statistics from given entry to another entry. + * + * @param targetStats partition statistics to be updated. + * @param inputStats the partition statistics used as input. + */ + private static void appendStats(PartitionStatistics targetStats, PartitionStatistics inputStats) { + Preconditions.checkArgument(targetStats.specId() != null, "Invalid spec ID: null"); + Preconditions.checkArgument( + targetStats.specId().equals(inputStats.specId()), "Spec IDs must match"); + + // This is expected to be called on the compute/write path where we use full schemas, hence + // these members can't be null. + targetStats.set( + PartitionStatistics.DATA_RECORD_COUNT_POSITION, + targetStats.dataRecordCount() + inputStats.dataRecordCount()); + targetStats.set( + PartitionStatistics.DATA_FILE_COUNT_POSITION, + targetStats.dataFileCount() + inputStats.dataFileCount()); + targetStats.set( + PartitionStatistics.TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION, + targetStats.totalDataFileSizeInBytes() + inputStats.totalDataFileSizeInBytes()); + targetStats.set( + PartitionStatistics.POSITION_DELETE_RECORD_COUNT_POSITION, + targetStats.positionDeleteRecordCount() + inputStats.positionDeleteRecordCount()); + targetStats.set( + PartitionStatistics.POSITION_DELETE_FILE_COUNT_POSITION, + targetStats.positionDeleteFileCount() + inputStats.positionDeleteFileCount()); + targetStats.set( + PartitionStatistics.EQUALITY_DELETE_RECORD_COUNT_POSITION, + targetStats.equalityDeleteRecordCount() + inputStats.equalityDeleteRecordCount()); + targetStats.set( + PartitionStatistics.EQUALITY_DELETE_FILE_COUNT_POSITION, + targetStats.equalityDeleteFileCount() + inputStats.equalityDeleteFileCount()); + + if (inputStats.dvCount() != null) { + if (targetStats.dvCount() == null) { + targetStats.set(PartitionStatistics.DV_COUNT_POSITION, inputStats.dvCount()); + } else { + targetStats.set( + PartitionStatistics.DV_COUNT_POSITION, targetStats.dvCount() + inputStats.dvCount()); + } + } + + if (inputStats.totalRecords() != null) { + if (targetStats.totalRecords() == null) { + targetStats.set(PartitionStatistics.TOTAL_RECORD_COUNT_POSITION, inputStats.totalRecords()); + } else { + targetStats.set( + PartitionStatistics.TOTAL_RECORD_COUNT_POSITION, + targetStats.totalRecords() + inputStats.totalRecords()); + } + } + + if (inputStats.lastUpdatedAt() != null) { + updateSnapshotInfo( + targetStats, inputStats.lastUpdatedSnapshotId(), inputStats.lastUpdatedAt()); + } + } + + private static void updateSnapshotInfo( + PartitionStatistics stats, long snapshotId, long updatedAt) { + if (stats.lastUpdatedAt() == null || stats.lastUpdatedAt() < updatedAt) { + stats.set(PartitionStatistics.LAST_UPDATED_AT_POSITION, updatedAt); + stats.set(PartitionStatistics.LAST_UPDATED_SNAPSHOT_ID_POSITION, snapshotId); + } + } + private static class InvalidStatsFileException extends RuntimeException { InvalidStatsFileException(Throwable cause) { diff --git a/core/src/main/java/org/apache/iceberg/PartitionsTable.java b/core/src/main/java/org/apache/iceberg/PartitionsTable.java index 09c6e7893b7e..10366db5a55d 100644 --- a/core/src/main/java/org/apache/iceberg/PartitionsTable.java +++ b/core/src/main/java/org/apache/iceberg/PartitionsTable.java @@ -27,7 +27,9 @@ import org.apache.iceberg.expressions.ManifestEvaluator; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.types.Comparators; +import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.ParallelIterable; import org.apache.iceberg.util.PartitionUtil; @@ -37,6 +39,58 @@ /** A {@link Table} implementation that exposes a table's partitions as rows. */ public class PartitionsTable extends BaseMetadataTable { + private static final int PARTITION_FIELD_ID = 1; + + private static final Types.NestedField SPEC_ID = + Types.NestedField.required(4, "spec_id", Types.IntegerType.get()); + private static final Types.NestedField RECORD_COUNT = + Types.NestedField.required( + 2, "record_count", Types.LongType.get(), "Count of records in data files"); + private static final Types.NestedField FILE_COUNT = + Types.NestedField.required(3, "file_count", Types.IntegerType.get(), "Count of data files"); + private static final Types.NestedField TOTAL_DATA_FILE_SIZE_IN_BYTES = + Types.NestedField.required( + 11, + "total_data_file_size_in_bytes", + Types.LongType.get(), + "Total size in bytes of data files"); + private static final Types.NestedField POSITION_DELETE_RECORD_COUNT = + Types.NestedField.required( + 5, + "position_delete_record_count", + Types.LongType.get(), + "Count of records in position delete files"); + private static final Types.NestedField POSITION_DELETE_FILE_COUNT = + Types.NestedField.required( + 6, + "position_delete_file_count", + Types.IntegerType.get(), + "Count of position delete files"); + private static final Types.NestedField EQUALITY_DELETE_RECORD_COUNT = + Types.NestedField.required( + 7, + "equality_delete_record_count", + Types.LongType.get(), + "Count of records in equality delete files"); + private static final Types.NestedField EQUALITY_DELETE_FILE_COUNT = + Types.NestedField.required( + 8, + "equality_delete_file_count", + Types.IntegerType.get(), + "Count of equality delete files"); + private static final Types.NestedField LAST_UPDATED_AT = + Types.NestedField.optional( + 9, + "last_updated_at", + Types.TimestampType.withZone(), + "Commit time of snapshot that last updated this partition"); + private static final Types.NestedField LAST_UPDATED_SNAPSHOT_ID = + Types.NestedField.optional( + 10, + "last_updated_snapshot_id", + Types.LongType.get(), + "Id of snapshot that last updated this partition"); + private final Schema schema; private final boolean unpartitionedTable; @@ -50,47 +104,18 @@ public class PartitionsTable extends BaseMetadataTable { this.schema = new Schema( - Types.NestedField.required(1, "partition", Partitioning.partitionType(table)), - Types.NestedField.required(4, "spec_id", Types.IntegerType.get()), - Types.NestedField.required( - 2, "record_count", Types.LongType.get(), "Count of records in data files"), - Types.NestedField.required( - 3, "file_count", Types.IntegerType.get(), "Count of data files"), - Types.NestedField.required( - 11, - "total_data_file_size_in_bytes", - Types.LongType.get(), - "Total size in bytes of data files"), Types.NestedField.required( - 5, - "position_delete_record_count", - Types.LongType.get(), - "Count of records in position delete files"), - Types.NestedField.required( - 6, - "position_delete_file_count", - Types.IntegerType.get(), - "Count of position delete files"), - Types.NestedField.required( - 7, - "equality_delete_record_count", - Types.LongType.get(), - "Count of records in equality delete files"), - Types.NestedField.required( - 8, - "equality_delete_file_count", - Types.IntegerType.get(), - "Count of equality delete files"), - Types.NestedField.optional( - 9, - "last_updated_at", - Types.TimestampType.withZone(), - "Commit time of snapshot that last updated this partition"), - Types.NestedField.optional( - 10, - "last_updated_snapshot_id", - Types.LongType.get(), - "Id of snapshot that last updated this partition")); + PARTITION_FIELD_ID, "partition", Partitioning.partitionType(table)), + SPEC_ID, + RECORD_COUNT, + FILE_COUNT, + TOTAL_DATA_FILE_SIZE_IN_BYTES, + POSITION_DELETE_RECORD_COUNT, + POSITION_DELETE_FILE_COUNT, + EQUALITY_DELETE_RECORD_COUNT, + EQUALITY_DELETE_FILE_COUNT, + LAST_UPDATED_AT, + LAST_UPDATED_SNAPSHOT_ID); this.unpartitionedTable = Partitioning.partitionType(table).fields().isEmpty(); } @@ -102,16 +127,18 @@ public TableScan newScan() { @Override public Schema schema() { if (unpartitionedTable) { - return schema.select( - "record_count", - "file_count", - "total_data_file_size_in_bytes", - "position_delete_record_count", - "position_delete_file_count", - "equality_delete_record_count", - "equality_delete_file_count", - "last_updated_at", - "last_updated_snapshot_id"); + return TypeUtil.select( + schema, + ImmutableSet.of( + RECORD_COUNT.fieldId(), + FILE_COUNT.fieldId(), + TOTAL_DATA_FILE_SIZE_IN_BYTES.fieldId(), + POSITION_DELETE_RECORD_COUNT.fieldId(), + POSITION_DELETE_FILE_COUNT.fieldId(), + EQUALITY_DELETE_RECORD_COUNT.fieldId(), + EQUALITY_DELETE_FILE_COUNT.fieldId(), + LAST_UPDATED_AT.fieldId(), + LAST_UPDATED_SNAPSHOT_ID.fieldId())); } return schema; } diff --git a/core/src/main/java/org/apache/iceberg/ReachableFileCleanup.java b/core/src/main/java/org/apache/iceberg/ReachableFileCleanup.java index e860c896a477..b5a565b81e7e 100644 --- a/core/src/main/java/org/apache/iceberg/ReachableFileCleanup.java +++ b/core/src/main/java/org/apache/iceberg/ReachableFileCleanup.java @@ -19,6 +19,7 @@ package org.apache.iceberg; import java.io.IOException; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; @@ -81,7 +82,8 @@ public void cleanFiles( if (!manifestsToDelete.isEmpty()) { if (ExpireSnapshots.CleanupLevel.ALL == cleanupLevel) { - Set dataFilesToDelete = findFilesToDelete(manifestsToDelete, currentManifests); + Set dataFilesToDelete = + findFilesToDelete(manifestsToDelete, currentManifests, beforeExpiration.specsById()); LOG.debug("Deleting {} data files", dataFilesToDelete.size()); deleteFiles(dataFilesToDelete, "data"); } @@ -165,9 +167,10 @@ private Set readManifests(Set snapshots) { return manifestFiles; } - // Helper to determine data files to delete private Set findFilesToDelete( - Set manifestFilesToDelete, Set currentManifestFiles) { + Set manifestFilesToDelete, + Set currentManifestFiles, + Map specsById) { Set filesToDelete = ConcurrentHashMap.newKeySet(); Tasks.foreach(manifestFilesToDelete) @@ -180,7 +183,8 @@ private Set findFilesToDelete( "Failed to determine live files in manifest {}. Retrying", item.path(), exc)) .run( manifest -> { - try (CloseableIterable paths = ManifestFiles.readPaths(manifest, fileIO)) { + try (CloseableIterable paths = + ManifestFiles.readPaths(manifest, fileIO, specsById)) { paths.forEach(filesToDelete::add); } catch (IOException e) { throw new RuntimeIOException(e, "Failed to read manifest file: %s", manifest); @@ -208,7 +212,8 @@ private Set findFilesToDelete( } // Remove all the live files from the candidate deletion set - try (CloseableIterable paths = ManifestFiles.readPaths(manifest, fileIO)) { + try (CloseableIterable paths = + ManifestFiles.readPaths(manifest, fileIO, specsById)) { paths.forEach(filesToDelete::remove); } catch (IOException e) { throw new RuntimeIOException(e, "Failed to read manifest file: %s", manifest); diff --git a/core/src/main/java/org/apache/iceberg/RetryableValidationException.java b/core/src/main/java/org/apache/iceberg/RetryableValidationException.java new file mode 100644 index 000000000000..0dbff487454d --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/RetryableValidationException.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import com.google.errorprone.annotations.FormatMethod; +import org.apache.iceberg.exceptions.ValidationException; + +/** + * A {@link ValidationException} that indicates a validation failure that can be fixed and retried. + * + *

This is specifically not a conflict. This is used when a validation failed because the commit + * includes stale values, such as a sequence number or first-row-id that is behind the current table + * state. Retrying the commit with refreshed metadata can resolve the failure. + */ +public class RetryableValidationException extends ValidationException { + @FormatMethod + public RetryableValidationException(String message, Object... args) { + super(message, args); + } + + @FormatMethod + public RetryableValidationException(Throwable cause, String message, Object... args) { + super(cause, message, args); + } + + @FormatMethod + public static void check(boolean test, String message, Object... args) { + if (!test) { + throw new RetryableValidationException(message, args); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/RewriteTablePathUtil.java b/core/src/main/java/org/apache/iceberg/RewriteTablePathUtil.java index 40aec9274474..435f79129204 100644 --- a/core/src/main/java/org/apache/iceberg/RewriteTablePathUtil.java +++ b/core/src/main/java/org/apache/iceberg/RewriteTablePathUtil.java @@ -465,7 +465,7 @@ private static RewriteResult writeDeleteFileEntry( stagingPath(file.location(), sourcePrefix, stagingLocation), posDeleteFile.location())); } - result.toRewrite().add(file); + result.toRewrite().add(file.copy()); return result; case EQUALITY_DELETES: DeleteFile eqDeleteFile = newEqualityDeleteEntry(file, spec, sourcePrefix, targetPrefix); @@ -728,20 +728,38 @@ private static PositionDelete newPositionDeleteRecord( } /** - * Replace path reference + * Rewrite a path by replacing its source prefix with a target prefix. * - * @param path path reference + *

If the path equals the source prefix (representing a directory location), the result will be + * the target prefix with a trailing separator. + * + *

Trailing separators are normalized: "/a" and "/a/" are treated as equivalent for both path + * and sourcePrefix. + * + * @param path absolute path to rewrite * @param sourcePrefix source prefix that will be replaced * @param targetPrefix target prefix that will replace it - * @return new path reference + * @return new path with source prefix replaced by target prefix + * @throws IllegalArgumentException if path is not under or equal to sourcePrefix */ public static String newPath(String path, String sourcePrefix, String targetPrefix) { return combinePaths(targetPrefix, relativize(path, sourcePrefix)); } - /** Combine a base and relative path. */ + /** + * Combine a base path and a relative path. + * + *

If the relative path is empty, returns the absolute path unchanged. Otherwise, ensures a + * separator between the base and relative path. + * + * @param absolutePath the base path + * @param relativePath the relative path to append (may be empty) + * @return the combined path, or absolutePath unchanged if relativePath is empty + */ public static String combinePaths(String absolutePath, String relativePath) { - return maybeAppendFileSeparator(absolutePath) + relativePath; + return relativePath.isEmpty() + ? absolutePath + : maybeAppendFileSeparator(absolutePath) + relativePath; } /** Returns the file name of a path. */ @@ -754,14 +772,29 @@ public static String fileName(String path) { return filename; } - /** Relativize a path. */ + /** + * Compute the relative path from a prefix to a given path. + * + *

If the path is under the prefix, returns the portion after the prefix. If the path equals + * the prefix (representing the root directory itself), returns an empty string. + * + *

Trailing separators are normalized: "/a" and "/a/" are treated as equivalent for both path + * and prefix. This allows flexibility when paths come from different sources that may or may not + * include trailing separators. + * + * @param path absolute path to relativize + * @param prefix prefix path to remove + * @return relative path from prefix to path, or empty string if path equals prefix + * @throws IllegalArgumentException if path is not under or equal to prefix + */ public static String relativize(String path, String prefix) { String toRemove = maybeAppendFileSeparator(prefix); - if (!path.startsWith(toRemove)) { + String normalizedPath = maybeAppendFileSeparator(path); + if (!normalizedPath.startsWith(toRemove)) { throw new IllegalArgumentException( - String.format("Path %s does not start with %s", path, toRemove)); + String.format("Path %s does not start with %s", normalizedPath, toRemove)); } - return path.substring(toRemove.length()); + return normalizedPath.equals(toRemove) ? "" : path.substring(toRemove.length()); } public static String maybeAppendFileSeparator(String path) { diff --git a/core/src/main/java/org/apache/iceberg/SchemaUpdate.java b/core/src/main/java/org/apache/iceberg/SchemaUpdate.java index 73d84b2fba0f..1fa6ebbe8fef 100644 --- a/core/src/main/java/org/apache/iceberg/SchemaUpdate.java +++ b/core/src/main/java/org/apache/iceberg/SchemaUpdate.java @@ -21,6 +21,7 @@ import java.util.Collection; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -29,6 +30,7 @@ import org.apache.iceberg.mapping.MappingUtil; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; @@ -70,7 +72,7 @@ class SchemaUpdate implements UpdateSchema { this(ops, ops.current()); } - /** For testing only. */ + @VisibleForTesting SchemaUpdate(Schema schema, int lastColumnId) { this(null, null, schema, lastColumnId); } @@ -164,7 +166,7 @@ private void internalAddColumn( int newId = assignNewColumnId(); // update tracking for moves - addedNameToId.put(fullName, newId); + addedNameToId.put(caseSensitivityAwareName(fullName), newId); if (parentId != TABLE_ROOT_ID) { idToParent.put(newId, parentId); } @@ -391,7 +393,7 @@ public UpdateSchema caseSensitive(boolean caseSensitivity) { } private boolean isAdded(String name) { - return addedNameToId.containsKey(name); + return addedNameToId.containsKey(caseSensitivityAwareName(name)); } private Types.NestedField findForUpdate(String name) { @@ -405,7 +407,7 @@ private Types.NestedField findForUpdate(String name) { return existing; } - Integer addedId = addedNameToId.get(name); + Integer addedId = addedNameToId.get(caseSensitivityAwareName(name)); if (addedId != null) { return updates.get(addedId); } @@ -414,7 +416,7 @@ private Types.NestedField findForUpdate(String name) { } private Integer findForMove(String name) { - Integer addedId = addedNameToId.get(name); + Integer addedId = addedNameToId.get(caseSensitivityAwareName(name)); if (addedId != null) { return addedId; } @@ -870,4 +872,8 @@ public MoveType type() { private Types.NestedField findField(String fieldName) { return caseSensitive ? schema.findField(fieldName) : schema.caseInsensitiveFindField(fieldName); } + + private String caseSensitivityAwareName(String name) { + return caseSensitive ? name : name.toLowerCase(Locale.ROOT); + } } diff --git a/core/src/main/java/org/apache/iceberg/SerializableTable.java b/core/src/main/java/org/apache/iceberg/SerializableTable.java index dce7697319ff..5b4cd0e55396 100644 --- a/core/src/main/java/org/apache/iceberg/SerializableTable.java +++ b/core/src/main/java/org/apache/iceberg/SerializableTable.java @@ -22,14 +22,12 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.encryption.EncryptionManager; -import org.apache.iceberg.hadoop.HadoopConfigurable; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.LocationProvider; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.SerializableMap; -import org.apache.iceberg.util.SerializableSupplier; /** * A read-only serializable table that can be sent to other nodes in a cluster. @@ -60,6 +58,8 @@ public class SerializableTable implements Table, HasTableOperations, Serializabl private final int defaultSpecId; private final Map specAsJsonMap; private final String sortOrderAsJson; + private final int defaultSortOrderId; + private final Map sortOrderAsJsonMap; private final FileIO io; private final EncryptionManager encryption; private final Map refs; @@ -71,6 +71,7 @@ public class SerializableTable implements Table, HasTableOperations, Serializabl private transient volatile Schema lazySchema = null; private transient volatile Map lazySpecs = null; private transient volatile SortOrder lazySortOrder = null; + private transient volatile Map lazySortOrders = null; protected SerializableTable(Table table) { this.name = table.name(); @@ -83,7 +84,12 @@ protected SerializableTable(Table table) { Map specs = table.specs(); specs.forEach((specId, spec) -> specAsJsonMap.put(specId, PartitionSpecParser.toJson(spec))); this.sortOrderAsJson = SortOrderParser.toJson(table.sortOrder()); - this.io = fileIO(table); + this.defaultSortOrderId = table.sortOrder().orderId(); + this.sortOrderAsJsonMap = Maps.newHashMap(); + table + .sortOrders() + .forEach((id, order) -> sortOrderAsJsonMap.put(id, SortOrderParser.toJson(order))); + this.io = table.io(); this.encryption = table.encryption(); this.locationProviderTry = Try.of(table::locationProvider); this.refs = SerializableMap.copyOf(table.refs()); @@ -124,14 +130,6 @@ private String metadataFileLocation(Table table) { } } - private FileIO fileIO(Table table) { - if (table.io() instanceof HadoopConfigurable) { - ((HadoopConfigurable) table.io()).serializeConfWith(SerializableConfSupplier::new); - } - - return table.io(); - } - private Table lazyTable() { if (lazyTable == null) { synchronized (this) { @@ -251,7 +249,22 @@ public SortOrder sortOrder() { @Override public Map sortOrders() { - return lazyTable().sortOrders(); + if (lazySortOrders == null) { + synchronized (this) { + if (lazySortOrders == null && lazyTable == null) { + ImmutableMap.Builder sortOrders = + ImmutableMap.builderWithExpectedSize(sortOrderAsJsonMap.size()); + sortOrderAsJsonMap.forEach( + (id, json) -> + sortOrders.put(id, SortOrderParser.fromJson(schema(), json, defaultSortOrderId))); + this.lazySortOrders = sortOrders.build(); + } else if (lazySortOrders == null) { + this.lazySortOrders = lazyTable.sortOrders(); + } + } + } + + return lazySortOrders; } @Override @@ -453,31 +466,4 @@ public MetadataTableType type() { return type; } } - - // captures the current state of a Hadoop configuration in a serializable manner - private static class SerializableConfSupplier implements SerializableSupplier { - - private final Map confAsMap; - private transient volatile Configuration conf = null; - - SerializableConfSupplier(Configuration conf) { - this.confAsMap = Maps.newHashMapWithExpectedSize(conf.size()); - conf.forEach(entry -> confAsMap.put(entry.getKey(), entry.getValue())); - } - - @Override - public Configuration get() { - if (conf == null) { - synchronized (this) { - if (conf == null) { - Configuration newConf = new Configuration(false); - confAsMap.forEach(newConf::set); - this.conf = newConf; - } - } - } - - return conf; - } - } } diff --git a/core/src/main/java/org/apache/iceberg/SetStatistics.java b/core/src/main/java/org/apache/iceberg/SetStatistics.java index ceb3fe91ba88..01e06fa16bca 100644 --- a/core/src/main/java/org/apache/iceberg/SetStatistics.java +++ b/core/src/main/java/org/apache/iceberg/SetStatistics.java @@ -18,12 +18,24 @@ */ package org.apache.iceberg; +import static org.apache.iceberg.TableProperties.COMMIT_MAX_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_MAX_RETRY_WAIT_MS_DEFAULT; +import static org.apache.iceberg.TableProperties.COMMIT_MIN_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_MIN_RETRY_WAIT_MS_DEFAULT; +import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES; +import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES_DEFAULT; +import static org.apache.iceberg.TableProperties.COMMIT_TOTAL_RETRY_TIME_MS; +import static org.apache.iceberg.TableProperties.COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT; + import java.util.List; import java.util.Map; import java.util.Optional; +import org.apache.iceberg.exceptions.CommitFailedException; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.Tasks; public class SetStatistics implements UpdateStatistics { + private final TableOperations ops; private final Map> statisticsToSet = Maps.newHashMap(); @@ -50,9 +62,21 @@ public List apply() { @Override public void commit() { - TableMetadata base = ops.current(); - TableMetadata newMetadata = internalApply(base); - ops.commit(base, newMetadata); + Tasks.foreach(ops) + .retry(ops.current().propertyAsInt(COMMIT_NUM_RETRIES, COMMIT_NUM_RETRIES_DEFAULT)) + .exponentialBackoff( + ops.current().propertyAsInt(COMMIT_MIN_RETRY_WAIT_MS, COMMIT_MIN_RETRY_WAIT_MS_DEFAULT), + ops.current().propertyAsInt(COMMIT_MAX_RETRY_WAIT_MS, COMMIT_MAX_RETRY_WAIT_MS_DEFAULT), + ops.current() + .propertyAsInt(COMMIT_TOTAL_RETRY_TIME_MS, COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT), + 2.0 /* exponential */) + .onlyRetryOn(CommitFailedException.class) + .run( + taskOps -> { + TableMetadata base = taskOps.refresh(); + TableMetadata updated = internalApply(base); + taskOps.commit(base, updated); + }); } private TableMetadata internalApply(TableMetadata base) { diff --git a/core/src/main/java/org/apache/iceberg/SnapshotChanges.java b/core/src/main/java/org/apache/iceberg/SnapshotChanges.java new file mode 100644 index 000000000000..38a81bb966c8 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/SnapshotChanges.java @@ -0,0 +1,271 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.ParallelIterable; + +/** + * Helper class for retrieving file changes in a snapshot with caching. + * + *

This class caches the results of file change detection operations, making it efficient to + * query multiple file change types for the same snapshot. By default, manifests are read + * single-threaded. Use {@link Builder#executeWith(ExecutorService)} to enable parallel manifest + * reading. + */ +public class SnapshotChanges { + private final Snapshot snapshot; + private final FileIO io; + private final Map specsById; + private final ExecutorService executorService; + + private List addedDataFiles = null; + private List removedDataFiles = null; + private List addedDeleteFiles = null; + private List removedDeleteFiles = null; + + private SnapshotChanges( + Snapshot snapshot, + FileIO io, + Map specsById, + ExecutorService executorService) { + Preconditions.checkArgument(snapshot != null, "Snapshot cannot be null"); + Preconditions.checkArgument(io != null, "FileIO cannot be null"); + Preconditions.checkArgument(specsById != null, "Partition specs cannot be null"); + this.snapshot = snapshot; + this.io = io; + this.specsById = specsById; + this.executorService = executorService; + } + + /** + * Create a builder for SnapshotChanges using the table's current snapshot. + * + * @param table the table to detect file changes for + * @return a new Builder + */ + public static Builder builderFor(Table table) { + return new Builder(table.currentSnapshot(), table.io(), table.specs()); + } + + static Builder builderFor(Snapshot snapshot, FileIO io, Map specsById) { + return new Builder(snapshot, io, specsById); + } + + private CloseableIterable iterate(Iterable> tasks) { + if (executorService != null) { + return new ParallelIterable<>(tasks, executorService); + } else { + return CloseableIterable.concat(tasks); + } + } + + /** Returns all data files added to the table in this snapshot */ + public Iterable addedDataFiles() { + if (addedDataFiles == null) { + cacheDataFileChanges(); + } + + return addedDataFiles; + } + + /** Returns all data files removed from the table in this snapshot. */ + public Iterable removedDataFiles() { + if (removedDataFiles == null) { + cacheDataFileChanges(); + } + + return removedDataFiles; + } + + /** Returns all delete files added to the table in this snapshot. */ + public Iterable addedDeleteFiles() { + if (addedDeleteFiles == null) { + cacheDeleteFileChanges(); + } + + return addedDeleteFiles; + } + + /** Returns all delete files removed from the table in this snapshot. */ + public Iterable removedDeleteFiles() { + if (removedDeleteFiles == null) { + cacheDeleteFileChanges(); + } + + return removedDeleteFiles; + } + + private void cacheDataFileChanges() { + ImmutableList.Builder adds = ImmutableList.builder(); + ImmutableList.Builder deletes = ImmutableList.builder(); + + Iterable relevantDataManifests = + Iterables.filter( + snapshot.dataManifests(io), + manifest -> Objects.equals(manifest.snapshotId(), snapshot.snapshotId())); + + Iterable>> manifestReadTasks = + Iterables.transform(relevantDataManifests, this::readDataManifest); + + try (CloseableIterable> changedDataFiles = + iterate(manifestReadTasks)) { + for (Pair pair : changedDataFiles) { + switch (pair.first()) { + case ADDED: + adds.add(pair.second()); + break; + case DELETED: + deletes.add(pair.second()); + break; + } + } + } catch (IOException e) { + throw new UncheckedIOException("Failed to close manifest reader", e); + } + + this.addedDataFiles = adds.build(); + this.removedDataFiles = deletes.build(); + } + + private CloseableIterable> readDataManifest( + ManifestFile manifest) { + CloseableIterable> entries = + ManifestFiles.read(manifest, io, specsById).entries(); + + CloseableIterable> relevant = + CloseableIterable.filter(entries, e -> e.status() != ManifestEntry.Status.EXISTING); + + return CloseableIterable.transform( + relevant, + entry -> { + if (entry.status() == ManifestEntry.Status.ADDED) { + return Pair.of(ManifestEntry.Status.ADDED, entry.file().copy()); + } else { + return Pair.of(ManifestEntry.Status.DELETED, entry.file().copyWithoutStats()); + } + }); + } + + private void cacheDeleteFileChanges() { + ImmutableList.Builder adds = ImmutableList.builder(); + ImmutableList.Builder deletes = ImmutableList.builder(); + + Iterable relevantDeleteManifests = + Iterables.filter( + snapshot.deleteManifests(io), + manifest -> Objects.equals(manifest.snapshotId(), snapshot.snapshotId())); + + Iterable>> manifestReadTasks = + Iterables.transform(relevantDeleteManifests, this::readDeleteManifest); + + try (CloseableIterable> changedDeleteFiles = + iterate(manifestReadTasks)) { + for (Pair pair : changedDeleteFiles) { + switch (pair.first()) { + case ADDED: + adds.add(pair.second()); + break; + case DELETED: + deletes.add(pair.second()); + break; + } + } + } catch (IOException e) { + throw new UncheckedIOException("Failed to close manifest reader", e); + } + + this.addedDeleteFiles = adds.build(); + this.removedDeleteFiles = deletes.build(); + } + + private CloseableIterable> readDeleteManifest( + ManifestFile manifest) { + CloseableIterable> entries = + ManifestFiles.readDeleteManifest(manifest, io, specsById).entries(); + + CloseableIterable> relevant = + CloseableIterable.filter(entries, e -> e.status() != ManifestEntry.Status.EXISTING); + + return CloseableIterable.transform( + relevant, + entry -> { + if (entry.status() == ManifestEntry.Status.ADDED) { + return Pair.of(ManifestEntry.Status.ADDED, entry.file().copy()); + } else { + return Pair.of(ManifestEntry.Status.DELETED, entry.file().copyWithoutStats()); + } + }); + } + + public static class Builder { + private Snapshot snapshot; + private final FileIO io; + private final Map specsById; + private ExecutorService executorService = null; + + private Builder(Snapshot snapshot, FileIO io, Map specsById) { + this.snapshot = snapshot; + this.io = io; + this.specsById = specsById; + } + + /** + * Set the snapshot to detect file changes for, overriding the default. + * + * @param snapshotOverride the snapshot to use + * @return this builder for method chaining + */ + public Builder snapshot(Snapshot snapshotOverride) { + this.snapshot = snapshotOverride; + return this; + } + + /** + * Configure an executor service to use for parallel manifest reading. + * + * @param executor the executor service to use for parallel execution + * @return this builder for method chaining + */ + public Builder executeWith(ExecutorService executor) { + this.executorService = executor; + return this; + } + + /** + * Build the SnapshotChanges instance. + * + * @return a new SnapshotChanges instance + */ + public SnapshotChanges build() { + return new SnapshotChanges(snapshot, io, specsById, executorService); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/SnapshotProducer.java b/core/src/main/java/org/apache/iceberg/SnapshotProducer.java index 876e0190d28c..6ba10e8049f6 100644 --- a/core/src/main/java/org/apache/iceberg/SnapshotProducer.java +++ b/core/src/main/java/org/apache/iceberg/SnapshotProducer.java @@ -44,7 +44,7 @@ import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReferenceArray; import java.util.function.Consumer; import java.util.function.Function; @@ -113,6 +113,7 @@ public void accept(String file) { private final AtomicInteger attempt = new AtomicInteger(0); private final List manifestLists = Lists.newArrayList(); private final long targetManifestSizeBytes; + private final Map manifestWriterProps; private MetricsReporter reporter = LoggingMetricsReporter.instance(); private volatile Long snapshotId = null; private TableMetadata base; @@ -141,6 +142,7 @@ protected SnapshotProducer(TableOperations ops) { this.targetManifestSizeBytes = ops.current() .propertyAsLong(MANIFEST_TARGET_SIZE_BYTES, MANIFEST_TARGET_SIZE_BYTES_DEFAULT); + this.manifestWriterProps = manifestWriterProperties(ops.current()); boolean snapshotIdInheritanceEnabled = ops.current() .propertyAsBoolean( @@ -455,8 +457,8 @@ protected TableMetadata refresh() { @Override @SuppressWarnings("checkstyle:CyclomaticComplexity") public void commit() { - // this is always set to the latest commit attempt's snapshot - AtomicReference stagedSnapshot = new AtomicReference<>(); + // this is always set to the latest commit attempt's snapshot id. + AtomicLong newSnapshotId = new AtomicLong(-1L); try (Timed ignore = commitMetrics().totalDuration().start()) { try { Tasks.foreach(ops) @@ -471,7 +473,7 @@ public void commit() { .run( taskOps -> { Snapshot newSnapshot = apply(); - stagedSnapshot.set(newSnapshot); + newSnapshotId.set(newSnapshot.snapshotId()); TableMetadata.Builder update = TableMetadata.buildFrom(base); if (base.snapshot(newSnapshot.snapshotId()) != null) { // this is a rollback operation @@ -509,22 +511,29 @@ public void commit() { throw e; } - // at this point, the commit must have succeeded so the stagedSnapshot is committed - Snapshot committedSnapshot = stagedSnapshot.get(); try { - LOG.info( - "Committed snapshot {} ({})", - committedSnapshot.snapshotId(), - getClass().getSimpleName()); + LOG.info("Committed snapshot {} ({})", newSnapshotId.get(), getClass().getSimpleName()); + + // at this point, the commit must have succeeded. after a refresh, the snapshot is loaded by + // id in case another commit was added between this commit and the refresh. + // it might not be known which commit attempt succeeded in some cases, so this only cleans + // up the one that actually did succeed. + Snapshot saved = ops.refresh().snapshot(newSnapshotId.get()); + if (saved != null) { + if (cleanupAfterCommit()) { + cleanUncommitted(Sets.newHashSet(saved.allManifests(ops.io()))); + } - if (cleanupAfterCommit()) { - cleanUncommitted(Sets.newHashSet(committedSnapshot.allManifests(ops.io()))); - } - // also clean up unused manifest lists created by multiple attempts - for (String manifestList : manifestLists) { - if (!committedSnapshot.manifestListLocation().equals(manifestList)) { - deleteFile(manifestList); + // also clean up unused manifest lists created by multiple attempts + for (String manifestList : manifestLists) { + if (!saved.manifestListLocation().equals(manifestList)) { + deleteFile(manifestList); + } } + } else { + // saved may not be present if the latest metadata couldn't be loaded due to eventual + // consistency problems in refresh. in that case, don't clean up. + LOG.warn("Failed to load committed snapshot, skipping manifest clean-up"); } } catch (Throwable e) { LOG.warn( @@ -601,12 +610,20 @@ protected EncryptedOutputFile newManifestOutputFile() { protected ManifestWriter newManifestWriter(PartitionSpec spec) { return ManifestFiles.write( - ops.current().formatVersion(), spec, newManifestOutputFile(), snapshotId()); + ops.current().formatVersion(), + spec, + newManifestOutputFile(), + snapshotId(), + manifestWriterProps); } protected ManifestWriter newDeleteManifestWriter(PartitionSpec spec) { return ManifestFiles.writeDeleteManifest( - ops.current().formatVersion(), spec, newManifestOutputFile(), snapshotId()); + ops.current().formatVersion(), + spec, + newManifestOutputFile(), + snapshotId(), + manifestWriterProps); } protected RollingManifestWriter newRollingManifestWriter(PartitionSpec spec) { @@ -618,6 +635,25 @@ protected RollingManifestWriter newRollingDeleteManifestWriter(Parti () -> newDeleteManifestWriter(spec), targetManifestSizeBytes); } + private static Map manifestWriterProperties(TableMetadata metadata) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + + String codec = + metadata.property( + TableProperties.MANIFEST_COMPRESSION, TableProperties.MANIFEST_COMPRESSION_DEFAULT); + builder.put(TableProperties.AVRO_COMPRESSION, codec); + + String level = + metadata.property( + TableProperties.MANIFEST_COMPRESSION_LEVEL, + TableProperties.MANIFEST_COMPRESSION_LEVEL_DEFAULT); + if (level != null) { + builder.put(TableProperties.AVRO_COMPRESSION_LEVEL, level); + } + + return builder.build(); + } + protected ManifestReader newManifestReader(ManifestFile manifest) { return ManifestFiles.read(manifest, ops.io(), ops.current().specsById()); } @@ -645,6 +681,34 @@ protected boolean cleanupAfterCommit() { return true; } + /** + * Builds a snapshot summary with manifest counts. + * + * @param manifests the list of manifests in the new snapshot + * @param replacedManifestsCount the count of manifests that were replaced (rewritten) + * @return a summary builder with manifest count metrics set + */ + protected SnapshotSummary.Builder buildManifestCountSummary( + List manifests, int replacedManifestsCount) { + SnapshotSummary.Builder summaryBuilder = SnapshotSummary.builder(); + int manifestsCreated = 0; + int manifestsKept = 0; + + for (ManifestFile manifest : manifests) { + if (snapshotId() == manifest.snapshotId()) { + manifestsCreated++; + } else if (null != manifest.snapshotId()) { + manifestsKept++; + } + } + + summaryBuilder.set(SnapshotSummary.CREATED_MANIFESTS_COUNT, String.valueOf(manifestsCreated)); + summaryBuilder.set(SnapshotSummary.KEPT_MANIFESTS_COUNT, String.valueOf(manifestsKept)); + summaryBuilder.set( + SnapshotSummary.REPLACED_MANIFESTS_COUNT, String.valueOf(replacedManifestsCount)); + return summaryBuilder; + } + protected List writeDataManifests(Collection files, PartitionSpec spec) { return writeDataManifests(files, null /* inherit data seq */, spec); } @@ -654,6 +718,22 @@ protected List writeDataManifests( return writeManifests(files, group -> writeDataFileGroup(group, dataSeq, spec)); } + // Deletes uncommitted manifests; clears list if clearManifests and any deleted. + protected void deleteUncommitted( + Collection manifests, Set committed, boolean clearManifests) { + boolean anyDeleted = false; + for (ManifestFile manifest : manifests) { + if (!committed.contains(manifest)) { + deleteFile(manifest.path()); + anyDeleted = true; + } + } + + if (clearManifests && anyDeleted) { + manifests.clear(); + } + } + private List writeDataFileGroup( Collection files, Long dataSeq, PartitionSpec spec) { RollingManifestWriter writer = newRollingManifestWriter(spec); @@ -682,9 +762,6 @@ private List writeDeleteFileGroup( try (RollingManifestWriter closableWriter = writer) { for (DeleteFile file : files) { - Preconditions.checkArgument( - file instanceof Delegates.PendingDeleteFile, - "Invalid delete file: must be PendingDeleteFile"); if (file.dataSequenceNumber() != null) { closableWriter.add(file, file.dataSequenceNumber()); } else { diff --git a/core/src/main/java/org/apache/iceberg/SnapshotScan.java b/core/src/main/java/org/apache/iceberg/SnapshotScan.java index 8dd1e7df2004..655c4c72e2da 100644 --- a/core/src/main/java/org/apache/iceberg/SnapshotScan.java +++ b/core/src/main/java/org/apache/iceberg/SnapshotScan.java @@ -124,7 +124,8 @@ public ThisT useRef(String name) { Snapshot snapshot = table().snapshot(name); Preconditions.checkArgument(snapshot != null, "Cannot find ref %s", name); TableScanContext newContext = context().useSnapshotId(snapshot.snapshotId()); - return newRefinedScan(table(), SnapshotUtil.schemaFor(table(), name), newContext); + Schema newSchema = useSnapshotSchema() ? SnapshotUtil.schemaFor(table(), name) : tableSchema(); + return newRefinedScan(table(), newSchema, newContext); } public ThisT asOfTime(long timestampMillis) { diff --git a/core/src/main/java/org/apache/iceberg/SortOrderParser.java b/core/src/main/java/org/apache/iceberg/SortOrderParser.java index 31307cf9dc7f..53d7e5090c76 100644 --- a/core/src/main/java/org/apache/iceberg/SortOrderParser.java +++ b/core/src/main/java/org/apache/iceberg/SortOrderParser.java @@ -112,6 +112,10 @@ public static SortOrder fromJson(Schema schema, String json) { return fromJson(json).bind(schema); } + public static SortOrder fromJson(Schema schema, String json, int defaultSortOrderId) { + return JsonUtil.parse(json, node -> fromJson(schema, node, defaultSortOrderId)); + } + public static SortOrder fromJson(Schema schema, JsonNode json, int defaultSortOrderId) { UnboundSortOrder unboundSortOrder = fromJson(json); diff --git a/api/src/main/java/org/apache/iceberg/stats/StatsUtil.java b/core/src/main/java/org/apache/iceberg/StatsUtil.java similarity index 98% rename from api/src/main/java/org/apache/iceberg/stats/StatsUtil.java rename to core/src/main/java/org/apache/iceberg/StatsUtil.java index 1e2bef98a732..39fef3d372d3 100644 --- a/api/src/main/java/org/apache/iceberg/stats/StatsUtil.java +++ b/core/src/main/java/org/apache/iceberg/StatsUtil.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; +package org.apache.iceberg; import static org.apache.iceberg.types.Types.NestedField.optional; @@ -25,7 +25,6 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; -import org.apache.iceberg.Schema; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.TypeUtil; @@ -33,7 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class StatsUtil { +class StatsUtil { private static final Logger LOG = LoggerFactory.getLogger(StatsUtil.class); // the number of reserved field IDs from the reserved field ID space as defined in // https://iceberg.apache.org/spec/#reserved-field-ids @@ -178,7 +177,7 @@ public Types.NestedField field(Types.NestedField field, Types.NestedField fieldR int fieldId = StatsUtil.statsFieldIdForField(field.fieldId()); if (fieldId >= 0) { - Types.StructType structType = FieldStatistic.fieldStatsFor(field.type(), fieldId + 1); + Types.StructType structType = FieldStatistic.fieldStatsFor(field, fieldId); return optional(fieldId, Integer.toString(field.fieldId()), structType); } else { skippedFieldIds.add(field.fieldId()); diff --git a/core/src/main/java/org/apache/iceberg/SupportsDistributedScanPlanning.java b/core/src/main/java/org/apache/iceberg/SupportsDistributedScanPlanning.java new file mode 100644 index 000000000000..6761698951ef --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/SupportsDistributedScanPlanning.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +/** Interface to indicate whether a Table supports distributed scan planning */ +public interface SupportsDistributedScanPlanning { + default boolean allowDistributedPlanning() { + return true; + } +} diff --git a/core/src/main/java/org/apache/iceberg/SystemConfigs.java b/core/src/main/java/org/apache/iceberg/SystemConfigs.java index ad40c17e3076..be59424992ee 100644 --- a/core/src/main/java/org/apache/iceberg/SystemConfigs.java +++ b/core/src/main/java/org/apache/iceberg/SystemConfigs.java @@ -81,7 +81,7 @@ private SystemConfigs() {} Integer::parseUnsignedInt); /** - * @deprecated will be removed in 2.0.0; use name mapping instead + * @deprecated will be removed in 1.12.0; use name mapping instead */ @Deprecated public static final ConfigEntry NETFLIX_UNSAFE_PARQUET_ID_FALLBACK_ENABLED = @@ -91,7 +91,7 @@ private SystemConfigs() {} true, s -> { LOG.warn( - "Fallback ID assignment in Parquet is UNSAFE and will be removed in 2.0.0. Use name mapping instead."); + "Fallback ID assignment in Parquet is UNSAFE and will be removed in 1.12.0. Use name mapping instead."); return Boolean.parseBoolean(s); }); diff --git a/core/src/main/java/org/apache/iceberg/SystemProperties.java b/core/src/main/java/org/apache/iceberg/SystemProperties.java index 7a83d530e2f6..484879bb21b1 100644 --- a/core/src/main/java/org/apache/iceberg/SystemProperties.java +++ b/core/src/main/java/org/apache/iceberg/SystemProperties.java @@ -21,7 +21,7 @@ /** * Configuration properties that are controlled by Java system properties. * - * @deprecated Use {@link SystemConfigs} instead; will be removed in 2.0.0 + * @deprecated Use {@link SystemConfigs} instead; will be removed in 1.12.0 */ @Deprecated public class SystemProperties { diff --git a/core/src/main/java/org/apache/iceberg/TableMetadata.java b/core/src/main/java/org/apache/iceberg/TableMetadata.java index 7dac5d401a80..43a67dd2bef2 100644 --- a/core/src/main/java/org/apache/iceberg/TableMetadata.java +++ b/core/src/main/java/org/apache/iceberg/TableMetadata.java @@ -1250,7 +1250,7 @@ public Builder addSnapshot(Snapshot snapshot) { "Snapshot already exists for id: %s", snapshot.snapshotId()); - ValidationException.check( + RetryableValidationException.check( formatVersion == 1 || snapshot.sequenceNumber() > lastSequenceNumber || snapshot.parentId() == null, @@ -1258,7 +1258,6 @@ public Builder addSnapshot(Snapshot snapshot) { snapshot.sequenceNumber(), lastSequenceNumber); - this.lastUpdatedMillis = snapshot.timestampMillis(); this.lastSequenceNumber = snapshot.sequenceNumber(); snapshots.add(snapshot); snapshotsById.put(snapshot.snapshotId(), snapshot); @@ -1267,7 +1266,7 @@ public Builder addSnapshot(Snapshot snapshot) { if (formatVersion >= MIN_FORMAT_VERSION_ROW_LINEAGE) { ValidationException.check( snapshot.firstRowId() != null, "Cannot add a snapshot: first-row-id is null"); - ValidationException.check( + RetryableValidationException.check( snapshot.firstRowId() != null && snapshot.firstRowId() >= nextRowId, "Cannot add a snapshot, first-row-id is behind table next-row-id: %s < %s", snapshot.firstRowId(), @@ -1316,9 +1315,6 @@ public Builder setRef(String name, SnapshotRef ref) { Snapshot snapshot = snapshotsById.get(snapshotId); ValidationException.check( snapshot != null, "Cannot set %s to unknown snapshot: %s", name, snapshotId); - if (isAddedSnapshot(snapshotId)) { - this.lastUpdatedMillis = snapshot.timestampMillis(); - } if (SnapshotRef.MAIN_BRANCH.equals(name)) { this.currentSnapshotId = ref.snapshotId(); @@ -1326,7 +1322,10 @@ public Builder setRef(String name, SnapshotRef ref) { this.lastUpdatedMillis = System.currentTimeMillis(); } - snapshotLog.add(new SnapshotLogEntry(lastUpdatedMillis, ref.snapshotId())); + // rollback to an existing snapshot will use current timestamp as the time of the change + long timeOfChange = + isAddedSnapshot(snapshotId) ? snapshot.timestampMillis() : this.lastUpdatedMillis; + snapshotLog.add(new SnapshotLogEntry(timeOfChange, ref.snapshotId())); } refs.put(name, ref); diff --git a/core/src/main/java/org/apache/iceberg/TableProperties.java b/core/src/main/java/org/apache/iceberg/TableProperties.java index 64be3db498dc..021ef95d9122 100644 --- a/core/src/main/java/org/apache/iceberg/TableProperties.java +++ b/core/src/main/java/org/apache/iceberg/TableProperties.java @@ -83,6 +83,9 @@ private TableProperties() {} DEFAULT_PARTITION_SPEC, DEFAULT_SORT_ORDER); + /** A table property that documents the business meaning and usage context of this table. */ + public static final String COMMENT = "comment"; + public static final String COMMIT_NUM_RETRIES = "commit.retry.num-retries"; public static final int COMMIT_NUM_RETRIES_DEFAULT = 4; @@ -132,6 +135,10 @@ private TableProperties() {} "write.delete.parquet.page-size-bytes"; public static final int PARQUET_PAGE_SIZE_BYTES_DEFAULT = 1024 * 1024; // 1 MB + public static final String PARQUET_PAGE_VERSION = "write.parquet.page-version"; + public static final String DELETE_PARQUET_PAGE_VERSION = "write.delete.parquet.page-version"; + public static final String PARQUET_PAGE_VERSION_DEFAULT = "v1"; + public static final String PARQUET_PAGE_ROW_LIMIT = "write.parquet.page-row-limit"; public static final String DELETE_PARQUET_PAGE_ROW_LIMIT = "write.delete.parquet.page-row-limit"; public static final int PARQUET_PAGE_ROW_LIMIT_DEFAULT = 20_000; @@ -151,6 +158,12 @@ private TableProperties() {} "write.delete.parquet.compression-level"; public static final String PARQUET_COMPRESSION_LEVEL_DEFAULT = null; + public static final String PARQUET_SHRED_VARIANTS = "write.parquet.shred-variants"; + public static final boolean PARQUET_SHRED_VARIANTS_DEFAULT = false; + public static final String PARQUET_VARIANT_BUFFER_SIZE = + "write.parquet.variant-inference-buffer-size"; + public static final int PARQUET_VARIANT_BUFFER_SIZE_DEFAULT = 100; + public static final String PARQUET_ROW_GROUP_CHECK_MIN_RECORD_COUNT = "write.parquet.row-group-check-min-record-count"; public static final String DELETE_PARQUET_ROW_GROUP_CHECK_MIN_RECORD_COUNT = @@ -188,6 +201,12 @@ private TableProperties() {} public static final String DELETE_AVRO_COMPRESSION_LEVEL = "write.delete.avro.compression-level"; public static final String AVRO_COMPRESSION_LEVEL_DEFAULT = null; + public static final String MANIFEST_COMPRESSION = "write.manifest.compression-codec"; + public static final String MANIFEST_COMPRESSION_DEFAULT = "gzip"; + + public static final String MANIFEST_COMPRESSION_LEVEL = "write.manifest.compression-level"; + public static final String MANIFEST_COMPRESSION_LEVEL_DEFAULT = null; + public static final String ORC_STRIPE_SIZE_BYTES = "write.orc.stripe-size-bytes"; public static final String ORC_BLOOM_FILTER_COLUMNS = "write.orc.bloom.filter.columns"; @@ -247,6 +266,15 @@ private TableProperties() {} public static final String DELETE_PLANNING_MODE = "read.delete-planning-mode"; public static final String PLANNING_MODE_DEFAULT = PlanningMode.AUTO.modeName(); + /** + * When true, declares that the table's identifier fields can be relied upon as a primary key by + * query engines for optimization purposes (e.g. eliminating redundant joins or distinct). This is + * not enforced at write time and does not validate existing data. + */ + public static final String IDENTIFIER_FIELDS_RELY = "identifier-fields.rely"; + + public static final boolean IDENTIFIER_FIELDS_RELY_DEFAULT = false; + public static final String OBJECT_STORE_ENABLED = "write.object-storage.enabled"; public static final boolean OBJECT_STORE_ENABLED_DEFAULT = false; @@ -328,6 +356,10 @@ private TableProperties() {} public static final String SPARK_WRITE_ACCEPT_ANY_SCHEMA = "write.spark.accept-any-schema"; public static final boolean SPARK_WRITE_ACCEPT_ANY_SCHEMA_DEFAULT = false; + public static final String SPARK_WRITE_AUTO_SCHEMA_EVOLUTION = + "write.spark.auto-schema-evolution.enabled"; + public static final boolean SPARK_WRITE_AUTO_SCHEMA_EVOLUTION_DEFAULT = true; + public static final String SPARK_WRITE_ADVISORY_PARTITION_SIZE_BYTES = "write.spark.advisory-partition-size-bytes"; diff --git a/core/src/main/java/org/apache/iceberg/TableScanContext.java b/core/src/main/java/org/apache/iceberg/TableScanContext.java index faa1c264d5f1..e40899556e11 100644 --- a/core/src/main/java/org/apache/iceberg/TableScanContext.java +++ b/core/src/main/java/org/apache/iceberg/TableScanContext.java @@ -35,7 +35,7 @@ /** Context object with optional arguments for a TableScan. */ @Value.Immutable -abstract class TableScanContext { +public abstract class TableScanContext { @Nullable public abstract Long snapshotId(); diff --git a/core/src/main/java/org/apache/iceberg/TrackedFile.java b/core/src/main/java/org/apache/iceberg/TrackedFile.java new file mode 100644 index 000000000000..8a6335972888 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/TrackedFile.java @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.apache.iceberg.types.Types; + +/** A file tracked by a manifest. */ +interface TrackedFile { + Types.NestedField TRACKING = + Types.NestedField.required( + 147, "tracking", Tracking.schema(), "Tracking information for this entry"); + Types.NestedField CONTENT_TYPE = + Types.NestedField.required( + 134, + "content_type", + Types.IntegerType.get(), + "Type of content: 0=DATA, 2=EQUALITY_DELETES, 3=DATA_MANIFEST, 4=DELETE_MANIFEST"); + Types.NestedField LOCATION = + Types.NestedField.required(100, "location", Types.StringType.get(), "Location of the file"); + Types.NestedField FILE_FORMAT = + Types.NestedField.required( + 101, + "file_format", + Types.StringType.get(), + "String file format name: avro, orc, or parquet"); + Types.NestedField RECORD_COUNT = + Types.NestedField.required( + 103, "record_count", Types.LongType.get(), "Number of records in this file"); + Types.NestedField FILE_SIZE_IN_BYTES = + Types.NestedField.required( + 104, "file_size_in_bytes", Types.LongType.get(), "Total file size in bytes"); + Types.NestedField SPEC_ID = + Types.NestedField.optional( + 141, "spec_id", Types.IntegerType.get(), "Spec ID used to partition the file"); + + int PARTITION_ID = 102; + String PARTITION_NAME = "partition"; + String PARTITION_DOC = "Partition data tuple, schema based on the partition spec"; + + int CONTENT_STATS_ID = 146; + String CONTENT_STATS_NAME = "content_stats"; + String CONTENT_STATS_DOC = "Content statistics for this entry"; + + Types.NestedField SORT_ORDER_ID = + Types.NestedField.optional( + 140, "sort_order_id", Types.IntegerType.get(), "ID of the sort order for this file"); + Types.NestedField DELETION_VECTOR = + Types.NestedField.optional( + 148, "deletion_vector", DeletionVector.schema(), "Deletion vector for the data file"); + Types.NestedField MANIFEST_INFO = + Types.NestedField.optional( + 150, + "manifest_info", + ManifestInfo.schema(), + "Metadata fields specific to manifest files"); + Types.NestedField KEY_METADATA = + Types.NestedField.optional( + 131, + "key_metadata", + Types.BinaryType.get(), + "Implementation-specific key metadata for encryption"); + Types.NestedField SPLIT_OFFSETS = + Types.NestedField.optional( + 132, + "split_offsets", + Types.ListType.ofRequired(133, Types.LongType.get()), + "Split offsets for the data file"); + Types.NestedField EQUALITY_IDS = + Types.NestedField.optional( + 135, + "equality_ids", + Types.ListType.ofRequired(136, Types.IntegerType.get()), + "Field ids used to determine row equality in equality delete files"); + + static Types.StructType schemaWithContentStats( + Types.StructType partitionType, Types.StructType contentStatsType) { + return Types.StructType.of( + TRACKING, + CONTENT_TYPE, + LOCATION, + FILE_FORMAT, + RECORD_COUNT, + FILE_SIZE_IN_BYTES, + SPEC_ID, + Types.NestedField.required(PARTITION_ID, PARTITION_NAME, partitionType, PARTITION_DOC), + Types.NestedField.optional( + CONTENT_STATS_ID, CONTENT_STATS_NAME, contentStatsType, CONTENT_STATS_DOC), + SORT_ORDER_ID, + DELETION_VECTOR, + MANIFEST_INFO, + KEY_METADATA, + SPLIT_OFFSETS, + EQUALITY_IDS); + } + + /** Returns the tracking information for this entry. */ + Tracking tracking(); + + /** Returns the type of content stored by this entry. */ + FileContent contentType(); + + /** Returns the location of the file. */ + String location(); + + /** Returns the format of the file. */ + FileFormat fileFormat(); + + /** Returns the number of records in this file. */ + long recordCount(); + + /** Returns the total file size in bytes. */ + long fileSizeInBytes(); + + /** Returns the ID of the partition spec used to partition this file, or null. */ + Integer specId(); + + /** Returns partition for this file as a {@link StructLike}. */ + StructLike partition(); + + /** Returns the content stats for this entry. */ + ContentStats contentStats(); + + /** Returns the ID of the sort order for this file, or null. */ + Integer sortOrderId(); + + /** Returns the deletion vector for this entry, or null if there is no deletion vector. */ + DeletionVector deletionVector(); + + /** Returns the manifest summary information, or null for non-manifest entries. */ + ManifestInfo manifestInfo(); + + /** Returns encryption key metadata, or null if the file is not encrypted. */ + ByteBuffer keyMetadata(); + + /** Returns the list of recommended split locations, or null. */ + List splitOffsets(); + + /** Returns the set of field IDs used for equality comparison in equality delete files. */ + List equalityIds(); + + /** Copies this tracked file. */ + TrackedFile copy(); + + /** + * Copies this tracked file with stats only for specific columns. + * + * @param requestedColumnIds table field IDs for which to keep stats + */ + TrackedFile copyWithStats(Set requestedColumnIds); + + /** Copies this tracked file without stats. */ + default TrackedFile copyWithoutStats() { + return copyWithStats(Collections.emptySet()); + } +} diff --git a/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java b/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java new file mode 100644 index 000000000000..4830f69d6bf1 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java @@ -0,0 +1,361 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import org.apache.iceberg.avro.SupportsIndexProjection; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.iceberg.util.ByteBuffers; + +/** Mutable {@link StructLike} implementation of {@link TrackedFile}. */ +class TrackedFileStruct extends SupportsIndexProjection implements TrackedFile, Serializable { + private static final Types.StructType EMPTY_STRUCT_TYPE = Types.StructType.of(); + private static final PartitionData EMPTY_PARTITION_DATA = + new PartitionData(EMPTY_STRUCT_TYPE) { + @Override + public PartitionData copy() { + return this; // this does not change + } + }; + + private static final Types.StructType BASE_TYPE = + Types.StructType.of( + TrackedFile.TRACKING, + TrackedFile.CONTENT_TYPE, + TrackedFile.LOCATION, + TrackedFile.FILE_FORMAT, + TrackedFile.RECORD_COUNT, + TrackedFile.FILE_SIZE_IN_BYTES, + TrackedFile.SPEC_ID, + Types.NestedField.required( + TrackedFile.PARTITION_ID, + TrackedFile.PARTITION_NAME, + EMPTY_STRUCT_TYPE, + TrackedFile.PARTITION_DOC), + Types.NestedField.optional( + TrackedFile.CONTENT_STATS_ID, + TrackedFile.CONTENT_STATS_NAME, + EMPTY_STRUCT_TYPE, + TrackedFile.CONTENT_STATS_DOC), + TrackedFile.SORT_ORDER_ID, + TrackedFile.DELETION_VECTOR, + TrackedFile.MANIFEST_INFO, + TrackedFile.KEY_METADATA, + TrackedFile.SPLIT_OFFSETS, + TrackedFile.EQUALITY_IDS); + + private FileContent contentType = null; + private String location = null; + private FileFormat fileFormat = null; + private long recordCount = -1L; + private long fileSizeInBytes = -1L; + private Integer specId = null; + private PartitionData partitionData = EMPTY_PARTITION_DATA; + + // optional fields + private Tracking tracking = null; + private ContentStats contentStats = null; + private Integer sortOrderId = null; + private DeletionVector deletionVector = null; + private ManifestInfo manifestInfo = null; + private byte[] keyMetadata = null; + private long[] splitOffsets = null; + private int[] equalityIds = null; + + /** Used by internal readers to instantiate this class with a projection schema. */ + TrackedFileStruct(Types.StructType projection) { + super(BASE_TYPE, projection); + // partition type may be null if the field was not projected + Type partType = projection.fieldType("partition"); + if (partType != null) { + this.partitionData = new PartitionData(partType.asNestedType().asStructType()); + } + } + + /** No-projection constructor for direct construction. */ + TrackedFileStruct() { + super(BASE_TYPE.fields().size()); + } + + /** Constructor that accepts required fields. */ + TrackedFileStruct( + Tracking tracking, + FileContent contentType, + String location, + FileFormat fileFormat, + PartitionData partition, + long recordCount, + long fileSizeInBytes) { + super(BASE_TYPE.fields().size()); + this.tracking = tracking; + this.contentType = contentType; + this.location = location; + this.fileFormat = fileFormat; + this.recordCount = recordCount; + this.fileSizeInBytes = fileSizeInBytes; + if (partition != null) { + this.partitionData = partition; + } + } + + /** Copy constructor. */ + private TrackedFileStruct(TrackedFileStruct toCopy, boolean withStats, Set statsIds) { + super(toCopy); + this.contentType = toCopy.contentType; + this.location = toCopy.location; + this.fileFormat = toCopy.fileFormat; + this.recordCount = toCopy.recordCount; + this.fileSizeInBytes = toCopy.fileSizeInBytes; + this.specId = toCopy.specId; + this.partitionData = toCopy.partitionData.copy(); + this.tracking = toCopy.tracking != null ? toCopy.tracking.copy() : null; + this.sortOrderId = toCopy.sortOrderId; + this.deletionVector = toCopy.deletionVector != null ? toCopy.deletionVector.copy() : null; + + if (withStats && toCopy.contentStats != null) { + ContentStats filtered = BaseContentStats.buildFrom(toCopy.contentStats, statsIds).build(); + this.contentStats = filtered.fieldStats().isEmpty() ? null : filtered; + } else { + this.contentStats = null; + } + + this.manifestInfo = toCopy.manifestInfo != null ? toCopy.manifestInfo.copy() : null; + this.keyMetadata = + toCopy.keyMetadata != null + ? Arrays.copyOf(toCopy.keyMetadata, toCopy.keyMetadata.length) + : null; + this.splitOffsets = + toCopy.splitOffsets != null + ? Arrays.copyOf(toCopy.splitOffsets, toCopy.splitOffsets.length) + : null; + this.equalityIds = + toCopy.equalityIds != null + ? Arrays.copyOf(toCopy.equalityIds, toCopy.equalityIds.length) + : null; + } + + @Override + public Tracking tracking() { + return tracking; + } + + @Override + public FileContent contentType() { + return contentType; + } + + @Override + public String location() { + return location; + } + + @Override + public FileFormat fileFormat() { + return fileFormat; + } + + @Override + public long recordCount() { + return recordCount; + } + + @Override + public long fileSizeInBytes() { + return fileSizeInBytes; + } + + @Override + public Integer specId() { + return specId; + } + + @Override + public StructLike partition() { + return partitionData; + } + + @Override + public ContentStats contentStats() { + return contentStats; + } + + @Override + public Integer sortOrderId() { + return sortOrderId; + } + + @Override + public DeletionVector deletionVector() { + return deletionVector; + } + + @Override + public ManifestInfo manifestInfo() { + return manifestInfo; + } + + @Override + public ByteBuffer keyMetadata() { + return keyMetadata != null ? ByteBuffer.wrap(keyMetadata) : null; + } + + @Override + public List splitOffsets() { + return splitOffsets != null ? ArrayUtil.toUnmodifiableLongList(splitOffsets) : null; + } + + @Override + public List equalityIds() { + return equalityIds != null ? ArrayUtil.toUnmodifiableIntList(equalityIds) : null; + } + + @Override + public TrackedFile copy() { + return new TrackedFileStruct(this, true, null); + } + + @Override + public TrackedFile copyWithStats(Set requestedColumnIds) { + return new TrackedFileStruct(this, true, requestedColumnIds); + } + + @Override + protected T internalGet(int pos, Class javaClass) { + return javaClass.cast(getByPos(pos)); + } + + private Object getByPos(int pos) { + switch (pos) { + case 0: + return tracking; + case 1: + return contentType != null ? contentType.id() : null; + case 2: + return location; + case 3: + return fileFormat != null ? fileFormat.toString() : null; + case 4: + return recordCount; + case 5: + return fileSizeInBytes; + case 6: + return specId; + case 7: + return partitionData; + case 8: + return contentStats; + case 9: + return sortOrderId; + case 10: + return deletionVector; + case 11: + return manifestInfo; + case 12: + return keyMetadata(); + case 13: + return splitOffsets(); + case 14: + return equalityIds(); + default: + throw new UnsupportedOperationException("Unknown field ordinal: " + pos); + } + } + + @Override + protected void internalSet(int pos, T value) { + switch (pos) { + case 0: + this.tracking = (Tracking) value; + break; + case 1: + this.contentType = FileContent.fromId((Integer) value); + break; + case 2: + // always coerce to String for Serializable + this.location = value.toString(); + break; + case 3: + this.fileFormat = FileFormat.fromString(value.toString()); + break; + case 4: + this.recordCount = (Long) value; + break; + case 5: + this.fileSizeInBytes = (Long) value; + break; + case 6: + this.specId = (Integer) value; + break; + case 7: + this.partitionData = (PartitionData) value; + break; + case 8: + this.contentStats = (ContentStats) value; + break; + case 9: + this.sortOrderId = (Integer) value; + break; + case 10: + this.deletionVector = (DeletionVector) value; + break; + case 11: + this.manifestInfo = (ManifestInfo) value; + break; + case 12: + this.keyMetadata = ByteBuffers.toByteArray((ByteBuffer) value); + break; + case 13: + this.splitOffsets = ArrayUtil.toLongArray((List) value); + break; + case 14: + this.equalityIds = ArrayUtil.toIntArray((List) value); + break; + default: + // ignore the object, it must be from a newer version of the format + } + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("content", contentType != null ? contentType.lowerCaseName() : null) + .add("location", location) + .add("file_format", fileFormat) + .add("record_count", recordCount) + .add("file_size_in_bytes", fileSizeInBytes) + .add("spec_id", specId()) + .add("partition", partitionData) + .add("tracking", tracking) + .add("content_stats", contentStats) + .add("sort_order_id", sortOrderId) + .add("deletion_vector", deletionVector) + .add("manifest_info", manifestInfo) + .add("key_metadata", keyMetadata == null ? "null" : "(redacted)") + .add("split_offsets", splitOffsets == null ? "null" : splitOffsets()) + .add("equality_ids", equalityIds == null ? "null" : equalityIds()) + .toString(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/Tracking.java b/core/src/main/java/org/apache/iceberg/Tracking.java new file mode 100644 index 000000000000..8003ed82ea9c --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/Tracking.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.nio.ByteBuffer; +import org.apache.iceberg.types.Types; + +/** Tracking information for a manifest entry. */ +interface Tracking { + Types.NestedField STATUS = + Types.NestedField.required( + 0, + "status", + Types.IntegerType.get(), + "Entry status: 0=existing, 1=added, 2=deleted, 3=replaced"); + Types.NestedField SNAPSHOT_ID = + Types.NestedField.optional( + 1, + "snapshot_id", + Types.LongType.get(), + "Snapshot ID where the file was added or deleted"); + Types.NestedField SEQUENCE_NUMBER = + Types.NestedField.optional( + 3, "sequence_number", Types.LongType.get(), "Data sequence number of the file"); + Types.NestedField FILE_SEQUENCE_NUMBER = + Types.NestedField.optional( + 4, + "file_sequence_number", + Types.LongType.get(), + "File sequence number indicating when the file was added"); + Types.NestedField DV_SNAPSHOT_ID = + Types.NestedField.optional( + 5, + "dv_snapshot_id", + Types.LongType.get(), + "Snapshot ID where the DV was added; null if there is no DV"); + Types.NestedField FIRST_ROW_ID = + Types.NestedField.optional( + 142, "first_row_id", Types.LongType.get(), "ID of the first row in the data file"); + Types.NestedField DELETED_POSITIONS = + Types.NestedField.optional( + 6, + "deleted_positions", + Types.BinaryType.get(), + "Bitmap of positions deleted in this snapshot"); + Types.NestedField REPLACED_POSITIONS = + Types.NestedField.optional( + 7, + "replaced_positions", + Types.BinaryType.get(), + "Bitmap of positions replaced in this snapshot"); + + static Types.StructType schema() { + return Types.StructType.of( + STATUS, + SNAPSHOT_ID, + SEQUENCE_NUMBER, + FILE_SEQUENCE_NUMBER, + DV_SNAPSHOT_ID, + FIRST_ROW_ID, + DELETED_POSITIONS, + REPLACED_POSITIONS); + } + + /** Returns the status of the entry. */ + EntryStatus status(); + + /** Returns whether this entry is live. */ + default boolean isLive() { + return status() == EntryStatus.ADDED || status() == EntryStatus.EXISTING; + } + + /** Returns the snapshot ID where the file was added or deleted. */ + Long snapshotId(); + + /** Returns the data sequence number of the file. */ + Long dataSequenceNumber(); + + /** Returns the file sequence number indicating when the file was added. */ + Long fileSequenceNumber(); + + /** Returns the snapshot ID where the DV was added; null if there is no DV. */ + Long dvSnapshotId(); + + /** Returns the ID of the first row in the data file. */ + Long firstRowId(); + + /** Returns the bitmap of positions deleted in this snapshot. */ + ByteBuffer deletedPositions(); + + /** Returns the bitmap of positions replaced in this snapshot. */ + ByteBuffer replacedPositions(); + + /** Returns the manifest location this entry was read from, or null. */ + String manifestLocation(); + + /** Returns the ordinal position of this entry within the manifest. */ + long manifestPos(); + + /** Copies this tracking information. */ + Tracking copy(); +} diff --git a/core/src/main/java/org/apache/iceberg/TrackingStruct.java b/core/src/main/java/org/apache/iceberg/TrackingStruct.java new file mode 100644 index 000000000000..65513c8d4a7c --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/TrackingStruct.java @@ -0,0 +1,343 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Objects; +import org.apache.iceberg.avro.SupportsIndexProjection; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; + +/** Mutable {@link StructLike} implementation of {@link Tracking}. */ +class TrackingStruct extends SupportsIndexProjection implements Tracking, Serializable { + private static final Types.StructType BASE_TYPE = + Types.StructType.of( + Tracking.STATUS, + Tracking.SNAPSHOT_ID, + Tracking.SEQUENCE_NUMBER, + Tracking.FILE_SEQUENCE_NUMBER, + Tracking.DV_SNAPSHOT_ID, + Tracking.FIRST_ROW_ID, + Tracking.DELETED_POSITIONS, + Tracking.REPLACED_POSITIONS, + MetadataColumns.ROW_POSITION); + + private EntryStatus status = null; + private Long snapshotId = null; + private Long dataSequenceNumber = null; + private Long fileSequenceNumber = null; + private Long dvSnapshotId = null; + private Long firstRowId = null; + private byte[] deletedPositions = null; + private byte[] replacedPositions = null; + + // set by manifest readers, not written to manifests + private String manifestLocation = null; + private long manifestPos = -1L; + + TrackingStruct(Types.StructType type) { + super(BASE_TYPE, type); + } + + private TrackingStruct(TrackingStruct toCopy) { + super(toCopy); + this.status = toCopy.status; + this.snapshotId = toCopy.snapshotId; + this.dataSequenceNumber = toCopy.dataSequenceNumber; + this.fileSequenceNumber = toCopy.fileSequenceNumber; + this.dvSnapshotId = toCopy.dvSnapshotId; + this.firstRowId = toCopy.firstRowId; + this.deletedPositions = + toCopy.deletedPositions != null + ? Arrays.copyOf(toCopy.deletedPositions, toCopy.deletedPositions.length) + : null; + this.replacedPositions = + toCopy.replacedPositions != null + ? Arrays.copyOf(toCopy.replacedPositions, toCopy.replacedPositions.length) + : null; + this.manifestLocation = toCopy.manifestLocation; + this.manifestPos = toCopy.manifestPos; + } + + private TrackingStruct( + EntryStatus status, + Long snapshotId, + Long dataSequenceNumber, + Long fileSequenceNumber, + Long dvSnapshotId, + Long firstRowId, + byte[] deletedPositions, + byte[] replacedPositions) { + super(BASE_TYPE, BASE_TYPE); + this.status = status; + this.snapshotId = snapshotId; + this.dataSequenceNumber = dataSequenceNumber; + this.fileSequenceNumber = fileSequenceNumber; + this.dvSnapshotId = dvSnapshotId; + this.firstRowId = firstRowId; + this.deletedPositions = deletedPositions; + this.replacedPositions = replacedPositions; + } + + void inheritFrom(Tracking manifestTracking) { + if (manifestTracking != null) { + if (snapshotId == null) { + this.snapshotId = manifestTracking.snapshotId(); + } + + // manifests do not distinguish between data and file sequence numbers + Preconditions.checkArgument( + Objects.equals( + manifestTracking.dataSequenceNumber(), manifestTracking.fileSequenceNumber()), + "Manifest data and file sequence numbers must be equal, got %s and %s", + manifestTracking.dataSequenceNumber(), + manifestTracking.fileSequenceNumber()); + + if (status == EntryStatus.ADDED) { + if (dataSequenceNumber == null) { + this.dataSequenceNumber = manifestTracking.fileSequenceNumber(); + } + + if (fileSequenceNumber == null) { + this.fileSequenceNumber = manifestTracking.fileSequenceNumber(); + } + } + } + } + + void setManifestLocation(String location) { + this.manifestLocation = location; + } + + @Override + public EntryStatus status() { + return status; + } + + @Override + public Long snapshotId() { + return snapshotId; + } + + @Override + public Long dataSequenceNumber() { + return dataSequenceNumber; + } + + @Override + public Long fileSequenceNumber() { + return fileSequenceNumber; + } + + @Override + public Long dvSnapshotId() { + return dvSnapshotId; + } + + @Override + public Long firstRowId() { + return firstRowId; + } + + @Override + public ByteBuffer deletedPositions() { + return deletedPositions != null ? ByteBuffer.wrap(deletedPositions) : null; + } + + @Override + public ByteBuffer replacedPositions() { + return replacedPositions != null ? ByteBuffer.wrap(replacedPositions) : null; + } + + @Override + public String manifestLocation() { + return manifestLocation; + } + + @Override + public long manifestPos() { + return manifestPos; + } + + @Override + public TrackingStruct copy() { + return new TrackingStruct(this); + } + + @Override + protected T internalGet(int pos, Class javaClass) { + return javaClass.cast(getByPos(pos)); + } + + private Object getByPos(int pos) { + switch (pos) { + case 0: + return status != null ? status.id() : null; + case 1: + return snapshotId(); + case 2: + return dataSequenceNumber(); + case 3: + return fileSequenceNumber(); + case 4: + return dvSnapshotId; + case 5: + return firstRowId; + case 6: + return deletedPositions(); + case 7: + return replacedPositions(); + case 8: + return manifestPos; + default: + throw new UnsupportedOperationException("Unknown field ordinal: " + pos); + } + } + + @Override + protected void internalSet(int pos, T value) { + switch (pos) { + case 0: + this.status = EntryStatus.fromId((Integer) value); + break; + case 1: + this.snapshotId = (Long) value; + break; + case 2: + this.dataSequenceNumber = (Long) value; + break; + case 3: + this.fileSequenceNumber = (Long) value; + break; + case 4: + this.dvSnapshotId = (Long) value; + break; + case 5: + this.firstRowId = (Long) value; + break; + case 6: + this.deletedPositions = ByteBuffers.toByteArray((ByteBuffer) value); + break; + case 7: + this.replacedPositions = ByteBuffers.toByteArray((ByteBuffer) value); + break; + case 8: + this.manifestPos = (long) value; + break; + default: + // ignore the object, it must be from a newer version of the format + } + } + + static Builder builder() { + return new Builder(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("status", status) + .add("snapshot_id", snapshotId == null ? "null" : snapshotId) + .add("data_sequence_number", dataSequenceNumber == null ? "null" : dataSequenceNumber) + .add("file_sequence_number", fileSequenceNumber == null ? "null" : fileSequenceNumber) + .add("dv_snapshot_id", dvSnapshotId == null ? "null" : dvSnapshotId) + .add("first_row_id", firstRowId == null ? "null" : firstRowId) + .add("deleted_positions", deletedPositions == null ? "null" : "(binary)") + .add("replaced_positions", replacedPositions == null ? "null" : "(binary)") + .toString(); + } + + static class Builder { + private EntryStatus status = null; + private Long snapshotId = null; + private Long dataSequenceNumber = null; + private Long fileSequenceNumber = null; + private Long dvSnapshotId = null; + private Long firstRowId = null; + private byte[] deletedPositions = null; + private byte[] replacedPositions = null; + + Builder status(EntryStatus entryStatus) { + this.status = entryStatus; + return this; + } + + Builder snapshotId(Long id) { + this.snapshotId = id; + return this; + } + + Builder dataSequenceNumber(Long sequenceNumber) { + this.dataSequenceNumber = sequenceNumber; + return this; + } + + Builder fileSequenceNumber(Long sequenceNumber) { + this.fileSequenceNumber = sequenceNumber; + return this; + } + + Builder dvSnapshotId(Long id) { + this.dvSnapshotId = id; + return this; + } + + Builder firstRowId(Long rowId) { + this.firstRowId = rowId; + return this; + } + + Builder deletedPositions(ByteBuffer positions) { + this.deletedPositions = positions != null ? ByteBuffers.toByteArray(positions) : null; + return this; + } + + Builder deletedPositions(byte[] positions) { + this.deletedPositions = positions; + return this; + } + + Builder replacedPositions(ByteBuffer positions) { + this.replacedPositions = positions != null ? ByteBuffers.toByteArray(positions) : null; + return this; + } + + Builder replacedPositions(byte[] positions) { + this.replacedPositions = positions; + return this; + } + + TrackingStruct build() { + Preconditions.checkArgument(status != null, "Invalid status: null"); + return new TrackingStruct( + status, + snapshotId, + dataSequenceNumber, + fileSequenceNumber, + dvSnapshotId, + firstRowId, + deletedPositions, + replacedPositions); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/V2Metadata.java b/core/src/main/java/org/apache/iceberg/V2Metadata.java index 832e5c383fe5..803905f6b42e 100644 --- a/core/src/main/java/org/apache/iceberg/V2Metadata.java +++ b/core/src/main/java/org/apache/iceberg/V2Metadata.java @@ -93,6 +93,7 @@ private Object get(int pos) { case 2: return wrapped.partitionSpecId(); case 3: + checkContentType(wrapped.content()); return wrapped.content().id(); case 4: if (wrapped.sequenceNumber() == ManifestWriter.UNASSIGNED_SEQ) { @@ -428,6 +429,7 @@ public T get(int pos, Class javaClass) { private Object get(int pos) { switch (pos) { case 0: + checkContentType(wrapped.content()); return wrapped.content().id(); case 1: return wrapped.location(); @@ -589,4 +591,20 @@ public F copyWithoutStats() { throw new UnsupportedOperationException("Cannot copy IndexedDataFile wrapper"); } } + + private static void checkContentType(ManifestContent content) { + Preconditions.checkArgument( + content == ManifestContent.DATA || content == ManifestContent.DELETES, + "Unsupported manifest content type for v2: %s", + content); + } + + private static void checkContentType(FileContent content) { + Preconditions.checkArgument( + content == FileContent.DATA + || content == FileContent.POSITION_DELETES + || content == FileContent.EQUALITY_DELETES, + "Unsupported file content type for v2: %s", + content); + } } diff --git a/core/src/main/java/org/apache/iceberg/V3Metadata.java b/core/src/main/java/org/apache/iceberg/V3Metadata.java index 8529d68501d4..4e67d9977e64 100644 --- a/core/src/main/java/org/apache/iceberg/V3Metadata.java +++ b/core/src/main/java/org/apache/iceberg/V3Metadata.java @@ -94,6 +94,7 @@ private Object get(int pos) { case 2: return wrapped.partitionSpecId(); case 3: + checkContentType(wrapped.content()); return wrapped.content().id(); case 4: if (wrapped.sequenceNumber() == ManifestWriter.UNASSIGNED_SEQ) { @@ -454,6 +455,7 @@ public T get(int pos, Class javaClass) { private Object get(int pos) { switch (pos) { case 0: + checkContentType(wrapped.content()); return wrapped.content().id(); case 1: return wrapped.location(); @@ -523,4 +525,20 @@ public Long pos() { return null; } } + + private static void checkContentType(ManifestContent content) { + Preconditions.checkArgument( + content == ManifestContent.DATA || content == ManifestContent.DELETES, + "Unsupported manifest content type for v3: %s", + content); + } + + private static void checkContentType(FileContent content) { + Preconditions.checkArgument( + content == FileContent.DATA + || content == FileContent.POSITION_DELETES + || content == FileContent.EQUALITY_DELETES, + "Unsupported file content type for v3: %s", + content); + } } diff --git a/core/src/main/java/org/apache/iceberg/actions/BaseDeleteOrphanFiles.java b/core/src/main/java/org/apache/iceberg/actions/BaseDeleteOrphanFiles.java index 182c8b191e87..fc87dad49de0 100644 --- a/core/src/main/java/org/apache/iceberg/actions/BaseDeleteOrphanFiles.java +++ b/core/src/main/java/org/apache/iceberg/actions/BaseDeleteOrphanFiles.java @@ -29,5 +29,11 @@ interface BaseDeleteOrphanFiles extends DeleteOrphanFiles { @Value.Immutable - interface Result extends DeleteOrphanFiles.Result {} + interface Result extends DeleteOrphanFiles.Result { + @Override + @Value.Default + default long orphanFilesCount() { + return 0; + } + } } diff --git a/core/src/main/java/org/apache/iceberg/actions/BinPackRewriteFilePlanner.java b/core/src/main/java/org/apache/iceberg/actions/BinPackRewriteFilePlanner.java index e74dbfe04d9d..ee768fcde460 100644 --- a/core/src/main/java/org/apache/iceberg/actions/BinPackRewriteFilePlanner.java +++ b/core/src/main/java/org/apache/iceberg/actions/BinPackRewriteFilePlanner.java @@ -241,13 +241,16 @@ public FileRewritePlan } else if (fileCountRunner.get() < maxFilesToRewrite) { int remainingSize = maxFilesToRewrite - fileCountRunner.get(); int scanTasksToRewrite = Math.min(fileScanTasks.size(), remainingSize); + List tasksToRewrite = + fileScanTasks.subList(0, scanTasksToRewrite); + long rewriteInputSize = inputSize(tasksToRewrite); selectedFileGroups.add( newRewriteGroup( ctx, partition, - fileScanTasks.subList(0, scanTasksToRewrite), - inputSplitSize(inputSize), - expectedOutputFiles(inputSize))); + tasksToRewrite, + inputSplitSize(rewriteInputSize), + expectedOutputFiles(rewriteInputSize))); fileCountRunner.getAndAdd(scanTasksToRewrite); } }); diff --git a/core/src/main/java/org/apache/iceberg/actions/RewriteDataFilesCommitManager.java b/core/src/main/java/org/apache/iceberg/actions/RewriteDataFilesCommitManager.java index 78a6aec36643..727bd6ab84ef 100644 --- a/core/src/main/java/org/apache/iceberg/actions/RewriteDataFilesCommitManager.java +++ b/core/src/main/java/org/apache/iceberg/actions/RewriteDataFilesCommitManager.java @@ -40,6 +40,7 @@ public class RewriteDataFilesCommitManager { private final long startingSnapshotId; private final boolean useStartingSequenceNumber; private final Map snapshotProperties; + private final String branch; // constructor used for testing public RewriteDataFilesCommitManager(Table table) { @@ -60,10 +61,20 @@ public RewriteDataFilesCommitManager( long startingSnapshotId, boolean useStartingSequenceNumber, Map snapshotProperties) { + this(table, startingSnapshotId, useStartingSequenceNumber, snapshotProperties, null); + } + + public RewriteDataFilesCommitManager( + Table table, + long startingSnapshotId, + boolean useStartingSequenceNumber, + Map snapshotProperties, + String branch) { this.table = table; this.startingSnapshotId = startingSnapshotId; this.useStartingSequenceNumber = useStartingSequenceNumber; this.snapshotProperties = snapshotProperties; + this.branch = branch; } /** @@ -94,6 +105,10 @@ public void commitFileGroups(Set fileGroups) { snapshotProperties.forEach(rewrite::set); + if (branch != null) { + rewrite.toBranch(branch); + } + rewrite.commit(); } diff --git a/core/src/main/java/org/apache/iceberg/actions/RewritePositionDeletesCommitManager.java b/core/src/main/java/org/apache/iceberg/actions/RewritePositionDeletesCommitManager.java index 3a75c87396c3..31b7f36af150 100644 --- a/core/src/main/java/org/apache/iceberg/actions/RewritePositionDeletesCommitManager.java +++ b/core/src/main/java/org/apache/iceberg/actions/RewritePositionDeletesCommitManager.java @@ -91,7 +91,7 @@ public void abort(RewritePositionDeletesGroup fileGroup) { Iterable filePaths = Iterables.transform(fileGroup.addedDeleteFiles(), ContentFile::location); - CatalogUtil.deleteFiles(table.io(), filePaths, "position delete", true); + CatalogUtil.deleteFiles(table.io(), filePaths, "position delete"); } public void commitOrClean(Set rewriteGroups) { diff --git a/core/src/main/java/org/apache/iceberg/actions/SizeBasedFileRewritePlanner.java b/core/src/main/java/org/apache/iceberg/actions/SizeBasedFileRewritePlanner.java index fad139078b5d..bcd00541308f 100644 --- a/core/src/main/java/org/apache/iceberg/actions/SizeBasedFileRewritePlanner.java +++ b/core/src/main/java/org/apache/iceberg/actions/SizeBasedFileRewritePlanner.java @@ -107,6 +107,15 @@ public abstract class SizeBasedFileRewritePlanner< public static final long MAX_FILE_GROUP_SIZE_BYTES_DEFAULT = 100L * 1024 * 1024 * 1024; // 100 GB + /** + * This option controls the largest count of data that should be rewritten in a single file group. + * It helps with breaking down the rewriting of very large partitions which may not be rewritable + * otherwise due to the resource constraints of the cluster. + */ + public static final String MAX_FILE_GROUP_INPUT_FILES = "max-file-group-input-files"; + + public static final long MAX_FILE_GROUP_INPUT_FILES_DEFAULT = Long.MAX_VALUE; + private static final long SPLIT_OVERHEAD = 5L * 1024; private final Table table; @@ -116,6 +125,7 @@ public abstract class SizeBasedFileRewritePlanner< private int minInputFiles; private boolean rewriteAll; private long maxGroupSize; + private long maxGroupCount; private int outputSpecId; protected SizeBasedFileRewritePlanner(Table table) { @@ -151,6 +161,7 @@ public void init(Map options) { this.minInputFiles = minInputFiles(options); this.rewriteAll = rewriteAll(options); this.maxGroupSize = maxGroupSize(options); + this.maxGroupCount = maxGroupCount(options); this.outputSpecId = outputSpecId(options); if (rewriteAll) { @@ -168,7 +179,8 @@ protected boolean outsideDesiredFileSizeRange(T task) { protected Iterable> planFileGroups(Iterable tasks) { Iterable filteredTasks = rewriteAll ? tasks : filterFiles(tasks); - BinPacking.ListPacker packer = new BinPacking.ListPacker<>(maxGroupSize, 1, false); + BinPacking.ListPacker packer = + new BinPacking.ListPacker<>(maxGroupSize, 1, false, maxGroupCount); List> groups = packer.pack(filteredTasks, ContentScanTask::length); return rewriteAll ? groups : filterFileGroups(groups); } @@ -337,6 +349,15 @@ private long maxGroupSize(Map options) { return value; } + private long maxGroupCount(Map options) { + long value = + PropertyUtil.propertyAsLong( + options, MAX_FILE_GROUP_INPUT_FILES, MAX_FILE_GROUP_INPUT_FILES_DEFAULT); + Preconditions.checkArgument( + value > 0, "'%s' is set to %s but must be > 0", MAX_FILE_GROUP_INPUT_FILES, value); + return value; + } + private boolean rewriteAll(Map options) { return PropertyUtil.propertyAsBoolean(options, REWRITE_ALL, REWRITE_ALL_DEFAULT); } diff --git a/core/src/main/java/org/apache/iceberg/avro/Avro.java b/core/src/main/java/org/apache/iceberg/avro/Avro.java index 6c7edc25b691..4a5136f58e71 100644 --- a/core/src/main/java/org/apache/iceberg/avro/Avro.java +++ b/core/src/main/java/org/apache/iceberg/avro/Avro.java @@ -182,8 +182,7 @@ public WriteBuilder overwrite(boolean enabled) { } // supposed to always be a private method used strictly by data and delete write builders - private WriteBuilder createContextFunc( - Function, Context> newCreateContextFunc) { + WriteBuilder createContextFunc(Function, Context> newCreateContextFunc) { this.createContextFunc = newCreateContextFunc; return this; } @@ -217,7 +216,7 @@ public FileAppender build() throws IOException { overwrite); } - private static class Context { + static class Context { private final CodecFactory codec; private Context(CodecFactory codec) { @@ -568,7 +567,7 @@ public PositionDeleteWriter buildPositionWriter() throws IOException { } /** A {@link DatumWriter} implementation that wraps another to produce position deletes. */ - private static class PositionDatumWriter implements MetricsAwareDatumWriter> { + static class PositionDatumWriter implements MetricsAwareDatumWriter> { private static final ValueWriter PATH_WRITER = ValueWriters.strings(); private static final ValueWriter POS_WRITER = ValueWriters.longs(); diff --git a/core/src/main/java/org/apache/iceberg/avro/AvroFormatModel.java b/core/src/main/java/org/apache/iceberg/avro/AvroFormatModel.java new file mode 100644 index 000000000000..e0fcf8952604 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/avro/AvroFormatModel.java @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.avro; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import org.apache.avro.Schema; +import org.apache.avro.io.DatumReader; +import org.apache.avro.io.DatumWriter; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.BaseFormatModel; +import org.apache.iceberg.formats.ModelWriteBuilder; +import org.apache.iceberg.formats.ReadBuilder; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.DeleteSchemaUtil; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; + +public class AvroFormatModel + extends BaseFormatModel, DatumReader, Schema> { + + public static AvroFormatModel, Void> forPositionDeletes() { + return new AvroFormatModel<>(PositionDelete.deleteClass(), Void.class, null, null); + } + + public static AvroFormatModel create( + Class type, + Class schemaType, + WriterFunction, S, Schema> writerFunction, + ReaderFunction, S, Schema> readerFunction) { + return new AvroFormatModel<>(type, schemaType, writerFunction, readerFunction); + } + + private AvroFormatModel( + Class type, + Class schemaType, + WriterFunction, S, Schema> writerFunction, + ReaderFunction, S, Schema> readerFunction) { + super(type, schemaType, writerFunction, readerFunction); + } + + @Override + public FileFormat format() { + return FileFormat.AVRO; + } + + @Override + public ModelWriteBuilder writeBuilder(EncryptedOutputFile outputFile) { + return new WriteBuilderWrapper<>(outputFile, writerFunction()); + } + + @Override + public ReadBuilder readBuilder(InputFile inputFile) { + return new ReadBuilderWrapper<>(inputFile, readerFunction()); + } + + private static class WriteBuilderWrapper implements ModelWriteBuilder { + private final Avro.WriteBuilder internal; + private final WriterFunction, S, Schema> writerFunction; + private org.apache.iceberg.Schema schema; + private S engineSchema; + private FileContent content; + + private WriteBuilderWrapper( + EncryptedOutputFile outputFile, WriterFunction, S, Schema> writerFunction) { + this.internal = Avro.write(outputFile.encryptingOutputFile()); + this.writerFunction = writerFunction; + } + + @Override + public ModelWriteBuilder schema(org.apache.iceberg.Schema newSchema) { + this.schema = newSchema; + internal.schema(newSchema); + return this; + } + + @Override + public ModelWriteBuilder engineSchema(S newSchema) { + this.engineSchema = newSchema; + return this; + } + + @Override + public ModelWriteBuilder set(String property, String value) { + internal.set(property, value); + return this; + } + + @Override + public ModelWriteBuilder setAll(Map properties) { + internal.setAll(properties); + return this; + } + + @Override + public ModelWriteBuilder meta(String property, String value) { + internal.meta(property, value); + return this; + } + + @Override + public ModelWriteBuilder meta(Map properties) { + internal.meta(properties); + return this; + } + + @Override + public ModelWriteBuilder content(FileContent newContent) { + this.content = newContent; + return this; + } + + @Override + public ModelWriteBuilder metricsConfig(MetricsConfig metricsConfig) { + internal.metricsConfig(metricsConfig); + return this; + } + + @Override + public ModelWriteBuilder overwrite() { + internal.overwrite(); + return this; + } + + @Override + public ModelWriteBuilder withFileEncryptionKey(ByteBuffer encryptionKey) { + throw new UnsupportedOperationException("Avro does not support file encryption keys"); + } + + @Override + public ModelWriteBuilder withAADPrefix(ByteBuffer aadPrefix) { + throw new UnsupportedOperationException("Avro does not support AAD prefix"); + } + + @Override + public FileAppender build() throws IOException { + switch (content) { + case DATA: + internal.createContextFunc(Avro.WriteBuilder.Context::dataContext); + internal.createWriterFunc( + avroSchema -> writerFunction.write(schema, avroSchema, engineSchema)); + break; + case EQUALITY_DELETES: + internal.createContextFunc(Avro.WriteBuilder.Context::deleteContext); + internal.createWriterFunc( + avroSchema -> writerFunction.write(schema, avroSchema, engineSchema)); + break; + case POSITION_DELETES: + Preconditions.checkState( + schema == null, + "Invalid schema: %s. Position deletes with schema are not supported by the API.", + schema); + Preconditions.checkState( + engineSchema == null, + "Invalid engineSchema: %s. Position deletes with schema are not supported by the API.", + engineSchema); + + internal.createContextFunc(Avro.WriteBuilder.Context::deleteContext); + internal.createWriterFunc(unused -> new Avro.PositionDatumWriter()); + internal.schema(DeleteSchemaUtil.pathPosSchema()); + break; + default: + throw new IllegalArgumentException("Unknown file content: " + content); + } + + return internal.build(); + } + } + + private static class ReadBuilderWrapper implements ReadBuilder { + private final Avro.ReadBuilder internal; + private final ReaderFunction, S, Schema> readerFunction; + private S engineSchema; + private Map idToConstant = ImmutableMap.of(); + + private ReadBuilderWrapper( + InputFile inputFile, ReaderFunction, S, Schema> readerFunction) { + this.internal = Avro.read(inputFile); + this.readerFunction = readerFunction; + } + + @Override + public ReadBuilder split(long newStart, long newLength) { + internal.split(newStart, newLength); + return this; + } + + @Override + public ReadBuilder project(org.apache.iceberg.Schema schema) { + internal.project(schema); + return this; + } + + @Override + public ReadBuilder engineProjection(S schema) { + this.engineSchema = schema; + return this; + } + + @Override + public ReadBuilder caseSensitive(boolean caseSensitive) { + // Filtering is not supported in Avro reader, so case sensitivity does not matter + // This is not an error since filtering is best-effort. + return this; + } + + @Override + public ReadBuilder filter(Expression filter) { + // Filtering is not supported in Avro reader + // This is not an error since filtering is best-effort. + return this; + } + + @Override + public ReadBuilder set(String key, String value) { + // Configuration is not used for Avro reader creation + return this; + } + + @Override + public ReadBuilder reuseContainers() { + internal.reuseContainers(); + return this; + } + + @Override + public ReadBuilder recordsPerBatch(int numRowsPerBatch) { + throw new UnsupportedOperationException("Batch reading is not supported in Avro reader"); + } + + @Override + public ReadBuilder idToConstant(Map newIdToConstant) { + this.idToConstant = newIdToConstant; + return this; + } + + @Override + public ReadBuilder withNameMapping(org.apache.iceberg.mapping.NameMapping nameMapping) { + internal.withNameMapping(nameMapping); + return this; + } + + @Override + public CloseableIterable build() { + // The file schema is passed directly to the DatumReader by the Avro read path, so null is + // passed here + return internal + .createResolvingReader( + icebergSchema -> readerFunction.read(icebergSchema, null, engineSchema, idToConstant)) + .build(); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/avro/AvroSchemaUtil.java b/core/src/main/java/org/apache/iceberg/avro/AvroSchemaUtil.java index e5aef3605ea5..c67a3089a6bf 100644 --- a/core/src/main/java/org/apache/iceberg/avro/AvroSchemaUtil.java +++ b/core/src/main/java/org/apache/iceberg/avro/AvroSchemaUtil.java @@ -129,7 +129,7 @@ public static Schema pruneColumns(Schema schema, Set selectedIds) { } /** - * @deprecated will be removed in 2.0.0; use applyNameMapping and pruneColumns(Schema, Set) + * @deprecated will be removed in 1.12.0; use applyNameMapping and pruneColumns(Schema, Set) * instead. */ @Deprecated diff --git a/core/src/main/java/org/apache/iceberg/avro/SupportsIndexProjection.java b/core/src/main/java/org/apache/iceberg/avro/SupportsIndexProjection.java index fa4ffa5aec13..41b5d3e6084d 100644 --- a/core/src/main/java/org/apache/iceberg/avro/SupportsIndexProjection.java +++ b/core/src/main/java/org/apache/iceberg/avro/SupportsIndexProjection.java @@ -55,6 +55,11 @@ protected SupportsIndexProjection(Types.StructType baseType, Types.StructType pr } } + /** Constructor with a precomputed position mapping */ + protected SupportsIndexProjection(int[] fromProjectionPos) { + this.fromProjectionPos = fromProjectionPos; + } + /** Copy constructor */ protected SupportsIndexProjection(SupportsIndexProjection toCopy) { this.fromProjectionPos = toCopy.fromProjectionPos; diff --git a/core/src/main/java/org/apache/iceberg/avro/ValueReaders.java b/core/src/main/java/org/apache/iceberg/avro/ValueReaders.java index 3ac755bb4a5e..ec46c56c72c3 100644 --- a/core/src/main/java/org/apache/iceberg/avro/ValueReaders.java +++ b/core/src/main/java/org/apache/iceberg/avro/ValueReaders.java @@ -254,7 +254,10 @@ private static Pair> fileFieldReader( Integer projectionPos, ValueReader fieldReader, Map idToConstant) { - if (Objects.equals(fieldId, MetadataColumns.ROW_ID.fieldId())) { + if (projectionPos == null) { + // field is in the file but not projected; keep the reader only for skipping + return Pair.of(null, fieldReader); + } else if (Objects.equals(fieldId, MetadataColumns.ROW_ID.fieldId())) { Long firstRowId = (Long) idToConstant.get(fieldId); return Pair.of(projectionPos, ValueReaders.rowIds(firstRowId, fieldReader)); } else if (Objects.equals(fieldId, MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId())) { @@ -273,7 +276,7 @@ private static Pair> fieldReader( ValueReader fieldReader, Map idToConstant) { Object constant = idToConstant.get(fieldId); - if (projectionPos != null && constant != null) { + if (constant != null) { return Pair.of(projectionPos, ValueReaders.replaceWithConstant(fieldReader, constant)); } @@ -305,6 +308,9 @@ private static ValueReader createMissingFieldReader( Types.NestedField field = expected.field(fieldId); if (constant != null) { + if (fieldId == MetadataColumns.ROW_ID.fieldId()) { + return ValueReaders.rowIds((Long) constant, null); + } return ValueReaders.constant(constant); } else if (field.initialDefault() != null) { return ValueReaders.constant(convert.apply(field.type(), field.initialDefault())); diff --git a/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java b/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java index d6ee4d345cfa..ae830791def2 100644 --- a/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java +++ b/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java @@ -89,6 +89,12 @@ public Table registerTable(TableIdentifier ident, String metadataFileLocation) { return BaseSessionCatalog.this.registerTable(context, ident, metadataFileLocation); } + @Override + public Table registerTable( + TableIdentifier ident, String metadataFileLocation, boolean overwrite) { + return BaseSessionCatalog.this.registerTable(context, ident, metadataFileLocation, overwrite); + } + @Override public boolean tableExists(TableIdentifier ident) { return BaseSessionCatalog.this.tableExists(context, ident); diff --git a/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java index 10895e1de9e6..ce76481d159e 100644 --- a/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java +++ b/core/src/main/java/org/apache/iceberg/catalog/BaseViewSessionCatalog.java @@ -83,6 +83,11 @@ public void invalidateView(TableIdentifier identifier) { BaseViewSessionCatalog.this.invalidateView(context, identifier); } + @Override + public View registerView(TableIdentifier identifier, String metadataFileLocation) { + return BaseViewSessionCatalog.this.registerView(context, identifier, metadataFileLocation); + } + @Override public void initialize(String name, Map properties) { throw new UnsupportedOperationException( diff --git a/core/src/main/java/org/apache/iceberg/data/GenericDataUtil.java b/core/src/main/java/org/apache/iceberg/data/GenericDataUtil.java index 152ef31ac876..9b720c1f865a 100644 --- a/core/src/main/java/org/apache/iceberg/data/GenericDataUtil.java +++ b/core/src/main/java/org/apache/iceberg/data/GenericDataUtil.java @@ -51,6 +51,12 @@ public static Object internalToGeneric(Type type, Object value) { } else { return DateTimeUtil.timestampFromMicros((Long) value); } + case TIMESTAMP_NANO: + if (((Types.TimestampNanoType) type).shouldAdjustToUTC()) { + return DateTimeUtil.timestamptzFromNanos((Long) value); + } else { + return DateTimeUtil.timestampFromNanos((Long) value); + } case FIXED: return ByteBuffers.toByteArray((ByteBuffer) value); } diff --git a/core/src/main/java/org/apache/iceberg/data/avro/RawDecoder.java b/core/src/main/java/org/apache/iceberg/data/avro/RawDecoder.java index 436cba05c73a..9f3cdfdad5f4 100644 --- a/core/src/main/java/org/apache/iceberg/data/avro/RawDecoder.java +++ b/core/src/main/java/org/apache/iceberg/data/avro/RawDecoder.java @@ -64,7 +64,7 @@ public static RawDecoder create( * schema used to decode buffers. The {@code writeSchema} must be the schema that was used to * encode all buffers decoded by this class. * - * @deprecated will be removed in 2.0.0; use {@link #create(org.apache.iceberg.Schema, Function, + * @deprecated will be removed in 1.12.0; use {@link #create(org.apache.iceberg.Schema, Function, * Schema)} instead */ @Deprecated diff --git a/core/src/main/java/org/apache/iceberg/deletes/BaseDVFileWriter.java b/core/src/main/java/org/apache/iceberg/deletes/BaseDVFileWriter.java index 6eabd64514df..b3b6c821f087 100644 --- a/core/src/main/java/org/apache/iceberg/deletes/BaseDVFileWriter.java +++ b/core/src/main/java/org/apache/iceberg/deletes/BaseDVFileWriter.java @@ -19,9 +19,11 @@ package org.apache.iceberg.deletes; import java.io.IOException; +import java.nio.ByteBuffer; import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.function.Supplier; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileMetadata; @@ -29,8 +31,12 @@ import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.StructLike; +import org.apache.iceberg.encryption.EncryptedFiles; import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.encryption.EncryptionKeyMetadata; +import org.apache.iceberg.encryption.NativeEncryptionKeyMetadata; import org.apache.iceberg.io.DeleteWriteResult; +import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.io.OutputFileFactory; import org.apache.iceberg.puffin.Blob; import org.apache.iceberg.puffin.BlobMetadata; @@ -51,7 +57,7 @@ public class BaseDVFileWriter implements DVFileWriter { private static final String REFERENCED_DATA_FILE_KEY = "referenced-data-file"; private static final String CARDINALITY_KEY = "cardinality"; - private final OutputFileFactory fileFactory; + private final Supplier dvOutputFile; private final Function loadPreviousDeletes; private final Map deletesByPath = Maps.newHashMap(); private final Map blobsByPath = Maps.newHashMap(); @@ -59,7 +65,19 @@ public class BaseDVFileWriter implements DVFileWriter { public BaseDVFileWriter( OutputFileFactory fileFactory, Function loadPreviousDeletes) { - this.fileFactory = fileFactory; + this(loadPreviousDeletes, fileFactory::newOutputFile); + } + + public BaseDVFileWriter( + Supplier dvOutputFile, + Function loadPreviousDeletes) { + this(loadPreviousDeletes, () -> EncryptedFiles.plainAsEncryptedOutput(dvOutputFile.get())); + } + + BaseDVFileWriter( + Function loadPreviousDeletes, + Supplier dvOutputFile) { + this.dvOutputFile = dvOutputFile; this.loadPreviousDeletes = loadPreviousDeletes; } @@ -71,6 +89,17 @@ public void delete(String path, long pos, PartitionSpec spec, StructLike partiti positions.delete(pos); } + @Override + public void delete( + String path, + PositionDeleteIndex positionDeleteIndex, + PartitionSpec spec, + StructLike partition) { + Deletes deletes = + deletesByPath.computeIfAbsent(path, key -> new Deletes(path, spec, partition)); + deletes.positions().merge(positionDeleteIndex); + } + @Override public DeleteWriteResult result() { Preconditions.checkState(result != null, "Cannot get result from unclosed writer"); @@ -90,7 +119,12 @@ public void close() throws IOException { return; } - PuffinWriter writer = newWriter(); + EncryptedOutputFile outputFile = dvOutputFile.get(); + EncryptionKeyMetadata keyMetadata = outputFile.keyMetadata(); + PuffinWriter writer = + Puffin.write(outputFile.encryptingOutputFile()) + .createdBy(IcebergBuild.fullVersion()) + .build(); try (PuffinWriter closeableWriter = writer) { for (Deletes deletes : deletesByPath.values()) { @@ -116,7 +150,7 @@ public void close() throws IOException { long puffinFileSize = writer.fileSize(); for (String path : deletesByPath.keySet()) { - DeleteFile dv = createDV(puffinPath, puffinFileSize, path); + DeleteFile dv = createDV(puffinPath, puffinFileSize, path, keyMetadata); dvs.add(dv); } @@ -124,7 +158,8 @@ public void close() throws IOException { } } - private DeleteFile createDV(String path, long size, String referencedDataFile) { + private DeleteFile createDV( + String path, long size, String referencedDataFile, EncryptionKeyMetadata keyMetadata) { Deletes deletes = deletesByPath.get(referencedDataFile); BlobMetadata blobMetadata = blobsByPath.get(referencedDataFile); return FileMetadata.deleteFileBuilder(deletes.spec()) @@ -133,6 +168,7 @@ private DeleteFile createDV(String path, long size, String referencedDataFile) { .withPath(path) .withPartition(deletes.partition()) .withFileSizeInBytes(size) + .withEncryptionKeyMetadata(encryptionKeyMetadata(size, keyMetadata)) .withReferencedDataFile(referencedDataFile) .withContentOffset(blobMetadata.offset()) .withContentSizeInBytes(blobMetadata.length()) @@ -147,9 +183,13 @@ private void write(PuffinWriter writer, Deletes deletes) { blobsByPath.put(path, blobMetadata); } - private PuffinWriter newWriter() { - EncryptedOutputFile outputFile = fileFactory.newOutputFile(); - return Puffin.write(outputFile).createdBy(IcebergBuild.fullVersion()).build(); + private ByteBuffer encryptionKeyMetadata( + long fileSizeInBytes, EncryptionKeyMetadata keyMetadata) { + if (keyMetadata instanceof NativeEncryptionKeyMetadata nativeKeyMetadata) { + return nativeKeyMetadata.copyWithLength(fileSizeInBytes).buffer(); + } + + return keyMetadata.buffer(); } private Blob toBlob(PositionDeleteIndex positions, String path) { diff --git a/core/src/main/java/org/apache/iceberg/deletes/DVFileWriter.java b/core/src/main/java/org/apache/iceberg/deletes/DVFileWriter.java index 2561f7be3d34..88d7e02f8c5b 100644 --- a/core/src/main/java/org/apache/iceberg/deletes/DVFileWriter.java +++ b/core/src/main/java/org/apache/iceberg/deletes/DVFileWriter.java @@ -43,4 +43,21 @@ public interface DVFileWriter extends Closeable { * @return the writer result */ DeleteWriteResult result(); + + /** + * Marks every position that is deleted in positionDeleteIndex as deleted in the given data file. + * Implementations should merge with existing position indices for the provided path + * + * @param path the data file path + * @param positionDeleteIndex the position delete index containing all the positions to delete + * @param spec the data file partition spec + * @param partition the data file partition + */ + default void delete( + String path, + PositionDeleteIndex positionDeleteIndex, + PartitionSpec spec, + StructLike partition) { + positionDeleteIndex.forEach(position -> this.delete(path, position, spec, partition)); + } } diff --git a/core/src/main/java/org/apache/iceberg/deletes/Deletes.java b/core/src/main/java/org/apache/iceberg/deletes/Deletes.java index 46df91982ab7..25047bc173e9 100644 --- a/core/src/main/java/org/apache/iceberg/deletes/Deletes.java +++ b/core/src/main/java/org/apache/iceberg/deletes/Deletes.java @@ -29,6 +29,7 @@ import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; import org.apache.iceberg.StructLike; +import org.apache.iceberg.encryption.EncryptedOutputFile; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; @@ -52,6 +53,11 @@ public class Deletes { private Deletes() {} + public static DVFileWriter writeDVs( + EncryptedOutputFile outputFile, Function loadPreviousDeletes) { + return new BaseDVFileWriter(loadPreviousDeletes, () -> outputFile); + } + public static CloseableIterable filter( CloseableIterable rows, Function rowToDeleteKey, StructLikeSet deleteSet) { if (deleteSet.isEmpty()) { diff --git a/core/src/main/java/org/apache/iceberg/deletes/PositionDelete.java b/core/src/main/java/org/apache/iceberg/deletes/PositionDelete.java index b202e7fcf34e..c3b6cbaa9bff 100644 --- a/core/src/main/java/org/apache/iceberg/deletes/PositionDelete.java +++ b/core/src/main/java/org/apache/iceberg/deletes/PositionDelete.java @@ -31,6 +31,11 @@ public static PositionDelete create() { private PositionDelete() {} + @SuppressWarnings("unchecked") + public static Class> deleteClass() { + return (Class>) (Class) PositionDelete.class; + } + public PositionDelete set(CharSequence newPath, long newPos) { this.path = newPath; this.pos = newPos; diff --git a/core/src/main/java/org/apache/iceberg/deletes/PositionDeleteWriter.java b/core/src/main/java/org/apache/iceberg/deletes/PositionDeleteWriter.java index a8af5e9d0f82..ea3e3896c0ec 100644 --- a/core/src/main/java/org/apache/iceberg/deletes/PositionDeleteWriter.java +++ b/core/src/main/java/org/apache/iceberg/deletes/PositionDeleteWriter.java @@ -51,7 +51,7 @@ public class PositionDeleteWriter implements FileWriter, De private static final Set FILE_AND_POS_FIELD_IDS = ImmutableSet.of(DELETE_FILE_PATH.fieldId(), DELETE_FILE_POS.fieldId()); - private final FileAppender appender; + private final FileAppender> appender; private final FileFormat format; private final String location; private final PartitionSpec spec; @@ -60,14 +60,22 @@ public class PositionDeleteWriter implements FileWriter, De private final CharSequenceSet referencedDataFiles; private DeleteFile deleteFile = null; + /** + * Creates a new position delete writer. + * + * @deprecated since 1.11.0, will be updated in 1.12.0 to accept {@code + * FileAppender>} instead of {@code FileAppender}. + */ + @Deprecated + @SuppressWarnings("unchecked") public PositionDeleteWriter( - FileAppender appender, + FileAppender appender, FileFormat format, String location, PartitionSpec spec, StructLike partition, EncryptionKeyMetadata keyMetadata) { - this.appender = appender; + this.appender = (FileAppender>) appender; this.format = format; this.location = location; this.spec = spec; diff --git a/core/src/main/java/org/apache/iceberg/deletes/RoaringPositionBitmap.java b/core/src/main/java/org/apache/iceberg/deletes/RoaringPositionBitmap.java index 3f7613c6ea58..037a38b114a6 100644 --- a/core/src/main/java/org/apache/iceberg/deletes/RoaringPositionBitmap.java +++ b/core/src/main/java/org/apache/iceberg/deletes/RoaringPositionBitmap.java @@ -79,14 +79,45 @@ public void set(long pos) { } /** - * Sets a range of positions in the bitmap. + * Sets a range of positions in the bitmap. If {@code posStartInclusive} equals {@code + * posEndExclusive}, this method does nothing. * * @param posStartInclusive the start position of the range (inclusive) * @param posEndExclusive the end position of the range (exclusive) + * @throws IllegalArgumentException if posStartInclusive > posEndExclusive */ public void setRange(long posStartInclusive, long posEndExclusive) { - for (long pos = posStartInclusive; pos < posEndExclusive; pos++) { - set(pos); + Preconditions.checkArgument( + posStartInclusive <= posEndExclusive, + "Start position must not exceed end position: [%s, %s)", + posStartInclusive, + posEndExclusive); + + if (posStartInclusive == posEndExclusive) { + return; + } + + validatePosition(posStartInclusive); + validatePosition(posEndExclusive - 1); + + int startKey = key(posStartInclusive); + int endKey = key(posEndExclusive - 1); + allocateBitmapsIfNeeded(endKey + 1); + + if (startKey == endKey) { + long lowStart = Integer.toUnsignedLong(pos32Bits(posStartInclusive)); + long lowEnd = Integer.toUnsignedLong(pos32Bits(posEndExclusive - 1)) + 1; + bitmaps[startKey].add(lowStart, lowEnd); + } else { + long firstLowStart = Integer.toUnsignedLong(pos32Bits(posStartInclusive)); + bitmaps[startKey].add(firstLowStart, 1L << 32); + + for (int key = startKey + 1; key < endKey; key++) { + bitmaps[key].add(0L, 1L << 32); + } + + long lastLowEnd = Integer.toUnsignedLong(pos32Bits(posEndExclusive - 1)) + 1; + bitmaps[endKey].add(0L, lastLowEnd); } } diff --git a/core/src/main/java/org/apache/iceberg/encryption/BaseEncryptedKey.java b/core/src/main/java/org/apache/iceberg/encryption/BaseEncryptedKey.java index 389613ee7937..77dea1c67ae0 100644 --- a/core/src/main/java/org/apache/iceberg/encryption/BaseEncryptedKey.java +++ b/core/src/main/java/org/apache/iceberg/encryption/BaseEncryptedKey.java @@ -21,10 +21,12 @@ import java.nio.ByteBuffer; import java.util.Map; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.SerializableMap; public class BaseEncryptedKey implements EncryptedKey { private final String keyId; - private final ByteBuffer keyMetadata; + private final byte[] keyMetadata; private final String encryptedById; private final Map properties; @@ -33,9 +35,9 @@ public BaseEncryptedKey( Preconditions.checkArgument(keyId != null, "Key id cannot be null"); Preconditions.checkArgument(keyMetadata != null, "Encrypted key metadata cannot be null"); this.keyId = keyId; - this.keyMetadata = keyMetadata; + this.keyMetadata = ByteBuffers.toByteArray(keyMetadata); this.encryptedById = encryptedById; - this.properties = properties; + this.properties = SerializableMap.copyOf(properties); } @Override @@ -45,7 +47,7 @@ public String keyId() { @Override public ByteBuffer encryptedKeyMetadata() { - return keyMetadata; + return ByteBuffer.wrap(keyMetadata); } @Override diff --git a/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java b/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java index 1f0461bc4d97..382d244883d6 100644 --- a/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java +++ b/core/src/main/java/org/apache/iceberg/encryption/EncryptionUtil.java @@ -21,6 +21,7 @@ import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import org.apache.iceberg.CatalogProperties; @@ -53,8 +54,18 @@ public static KeyManagementClient createKmsClient(Map catalogPro kmsType, kmsImpl); - // TODO: Add KMS implementations - Preconditions.checkArgument(kmsType == null, "Unsupported KMS type: %s", kmsType); + if (kmsType != null) { + kmsImpl = + switch (kmsType.toLowerCase(Locale.ROOT)) { + case CatalogProperties.ENCRYPTION_KMS_TYPE_AWS -> + CatalogProperties.ENCRYPTION_KMS_IMPL_AWS; + case CatalogProperties.ENCRYPTION_KMS_TYPE_AZURE -> + CatalogProperties.ENCRYPTION_KMS_IMPL_AZURE; + case CatalogProperties.ENCRYPTION_KMS_TYPE_GCP -> + CatalogProperties.ENCRYPTION_KMS_IMPL_GCP; + default -> throw new IllegalStateException("Unsupported KMS type: " + kmsType); + }; + } KeyManagementClient kmsClient; DynConstructors.Ctor ctor; diff --git a/core/src/main/java/org/apache/iceberg/encryption/StandardEncryptionManager.java b/core/src/main/java/org/apache/iceberg/encryption/StandardEncryptionManager.java index 8edb6a136dc7..bb5126d23e3f 100644 --- a/core/src/main/java/org/apache/iceberg/encryption/StandardEncryptionManager.java +++ b/core/src/main/java/org/apache/iceberg/encryption/StandardEncryptionManager.java @@ -34,6 +34,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.SerializableMap; public class StandardEncryptionManager implements EncryptionManager { // Maximal lifespan of key encryption keys is 2 years according to NIST SP 800-57 (PART 1 REV. 5, @@ -43,45 +44,17 @@ public class StandardEncryptionManager implements EncryptionManager { private final String tableKeyId; private final int dataKeyLength; + private final Map encryptionKeys; + private final KeyManagementClient kmsClient; // used in key encryption key rotation unitests private long testTimeShift; - // unserializable elements of the EncryptionManager - private class TransientEncryptionState { - private final KeyManagementClient kmsClient; - private final Map encryptionKeys; - private final LoadingCache unwrappedKeyCache; - - private TransientEncryptionState(KeyManagementClient kmsClient, List keys) { - this.kmsClient = kmsClient; - this.encryptionKeys = Maps.newLinkedHashMap(); - - if (keys != null) { - for (EncryptedKey key : keys) { - encryptionKeys.put( - key.keyId(), - new BaseEncryptedKey( - key.keyId(), key.encryptedKeyMetadata(), key.encryptedById(), key.properties())); - } - } - - this.unwrappedKeyCache = - Caffeine.newBuilder() - .expireAfterWrite(1, TimeUnit.HOURS) - .build( - keyId -> - kmsClient.unwrapKey( - encryptionKeys.get(keyId).encryptedKeyMetadata(), tableKeyId)); - } - } - - private final transient TransientEncryptionState transientState; - + private transient volatile LoadingCache unwrappedKeyCache; private transient volatile SecureRandom lazyRNG = null; /** - * @deprecated will be removed in 2.0. + * @deprecated will be removed in 1.12.0. */ @Deprecated public StandardEncryptionManager( @@ -107,9 +80,19 @@ public StandardEncryptionManager( dataKeyLength); Preconditions.checkNotNull(kmsClient, "Invalid KMS client: null"); this.tableKeyId = tableKeyId; - this.transientState = new TransientEncryptionState(kmsClient, keys); + this.kmsClient = kmsClient; this.dataKeyLength = dataKeyLength; this.testTimeShift = 0; + + this.encryptionKeys = SerializableMap.copyOf(Maps.newLinkedHashMap()); + if (keys != null) { + for (EncryptedKey key : keys) { + this.encryptionKeys.put( + key.keyId(), + new BaseEncryptedKey( + key.keyId(), key.encryptedKeyMetadata(), key.encryptedById(), key.properties())); + } + } } @Override @@ -132,6 +115,20 @@ public Iterable decrypt(Iterable encrypted) { return Iterables.transform(encrypted, this::decrypt); } + private LoadingCache unwrappedKeyCache() { + if (this.unwrappedKeyCache == null) { + this.unwrappedKeyCache = + Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build( + keyId -> + kmsClient.unwrapKey( + encryptionKeys.get(keyId).encryptedKeyMetadata(), tableKeyId)); + } + + return unwrappedKeyCache; + } + private SecureRandom workerRNG() { if (this.lazyRNG == null) { this.lazyRNG = new SecureRandom(); @@ -141,41 +138,29 @@ private SecureRandom workerRNG() { } /** - * @deprecated will be removed in 2.0. + * @deprecated will be removed in 1.12.0. */ @Deprecated public ByteBuffer wrapKey(ByteBuffer secretKey) { - Preconditions.checkState( - transientState != null, - "Cannot wrap key after called after serialization (missing KMS client)"); - - return transientState.kmsClient.wrapKey(secretKey, tableKeyId); + return kmsClient.wrapKey(secretKey, tableKeyId); } /** - * @deprecated will be removed in 2.0. + * @deprecated will be removed in 1.12.0. */ @Deprecated public ByteBuffer unwrapKey(ByteBuffer wrappedSecretKey) { - Preconditions.checkState(transientState != null, "Cannot unwrap key after serialization"); - - return transientState.kmsClient.unwrapKey(wrappedSecretKey, tableKeyId); + return kmsClient.unwrapKey(wrappedSecretKey, tableKeyId); } Map encryptionKeys() { - Preconditions.checkState( - transientState != null, "Cannot return the encryption keys after serialization"); - - return transientState.encryptionKeys; + return encryptionKeys; } String keyEncryptionKeyID() { - Preconditions.checkState( - transientState != null, "Cannot return the current key after serialization"); - // Find unexpired key encryption key - for (String keyID : transientState.encryptionKeys.keySet()) { - EncryptedKey key = transientState.encryptionKeys.get(keyID); + for (String keyID : encryptionKeys.keySet()) { + EncryptedKey key = encryptionKeys.get(keyID); if (key.encryptedById().equals(tableKeyId)) { // this is a key encryption key String timestampProperty = key.properties().get(KEY_TIMESTAMP); long keyTimestamp = Long.parseLong(timestampProperty); @@ -187,14 +172,14 @@ String keyEncryptionKeyID() { // No unexpired key encryption keys; create one ByteBuffer unwrapped = newKey(); - ByteBuffer wrapped = transientState.kmsClient.wrapKey(unwrapped, tableKeyId); + ByteBuffer wrapped = kmsClient.wrapKey(unwrapped, tableKeyId); Map properties = Maps.newHashMap(); properties.put(KEY_TIMESTAMP, "" + currentTimeMillis()); EncryptedKey key = new BaseEncryptedKey(generateKeyId(), wrapped, tableKeyId, properties); // update internal tracking - transientState.unwrappedKeyCache.put(key.keyId(), unwrapped); - transientState.encryptionKeys.put(key.keyId(), key); + unwrappedKeyCache().put(key.keyId(), unwrapped); + encryptionKeys.put(key.keyId(), key); return key.keyId(); } @@ -209,10 +194,7 @@ private long currentTimeMillis() { } ByteBuffer encryptedByKey(String manifestListKeyID) { - Preconditions.checkState( - transientState != null, "Cannot find key encryption key after serialization"); - - EncryptedKey encryptedKeyMetadata = transientState.encryptionKeys.get(manifestListKeyID); + EncryptedKey encryptedKeyMetadata = encryptionKeys.get(manifestListKeyID); Preconditions.checkState( encryptedKeyMetadata != null, @@ -224,25 +206,21 @@ ByteBuffer encryptedByKey(String manifestListKeyID) { "%s is a key encryption key, not manifest list key metadata", manifestListKeyID); - return transientState.unwrappedKeyCache.get(encryptedKeyMetadata.encryptedById()); + return unwrappedKeyCache().get(encryptedKeyMetadata.encryptedById()); } public String addManifestListKeyMetadata(NativeEncryptionKeyMetadata keyMetadata) { - Preconditions.checkState(transientState != null, "Cannot add key metadata after serialization"); - String manifestListKeyID = generateKeyId(); String keyEncryptionKeyID = keyEncryptionKeyID(); String keyEncryptionKeyTimestamp = - transientState.encryptionKeys.get(keyEncryptionKeyID).properties().get(KEY_TIMESTAMP); + encryptionKeys.get(keyEncryptionKeyID).properties().get(KEY_TIMESTAMP); ByteBuffer encryptedKeyMetadata = EncryptionUtil.encryptManifestListKeyMetadata( - transientState.unwrappedKeyCache.get(keyEncryptionKeyID), - keyEncryptionKeyTimestamp, - keyMetadata); + unwrappedKeyCache().get(keyEncryptionKeyID), keyEncryptionKeyTimestamp, keyMetadata); BaseEncryptedKey key = new BaseEncryptedKey(manifestListKeyID, encryptedKeyMetadata, keyEncryptionKeyID, null); - transientState.encryptionKeys.put(key.keyId(), key); + encryptionKeys.put(key.keyId(), key); return manifestListKeyID; } diff --git a/core/src/main/java/org/apache/iceberg/formats/BaseFormatModel.java b/core/src/main/java/org/apache/iceberg/formats/BaseFormatModel.java new file mode 100644 index 000000000000..7cba465670d9 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/formats/BaseFormatModel.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.formats; + +import java.util.Map; +import org.apache.iceberg.Schema; + +/** + * Base implementation of {@link FormatModel} that provides common functionality for format models. + * + *

This abstract class serves as a foundation for creating format-specific models that handle + * reading and writing data in various file formats. + * + * @param output type used for reading data, and input type for writing data and deletes + * @param the engine-specific schema type describing the input/output data + * @param the file format specific writer type produced by the writer function + * @param the file format specific reader type produced by the reader function + * @param the file schema type used by the underlying file format + */ +public abstract class BaseFormatModel implements FormatModel { + private final Class type; + private final Class schemaType; + private final WriterFunction writerFunction; + private final ReaderFunction readerFunction; + + /** + * Constructs a new BaseFormatModel with the specified configuration. + * + * @param type the row type class for the object model implementation processed by this factory. + * @param schemaType the schema type class for the object model implementation processed by this + * factory. + * @param writerFunction the function used to create writers for this format + * @param readerFunction the function used to create readers for this format + */ + protected BaseFormatModel( + Class type, + Class schemaType, + WriterFunction writerFunction, + ReaderFunction readerFunction) { + this.type = type; + this.schemaType = schemaType; + this.writerFunction = writerFunction; + this.readerFunction = readerFunction; + } + + @Override + public Class type() { + return type; + } + + @Override + public Class schemaType() { + return schemaType; + } + + /** + * Returns the writer function used to create writers for this format. + * + * @return the writer function + */ + protected WriterFunction writerFunction() { + return writerFunction; + } + + /** + * Returns the reader function used to create readers for this format. + * + * @return the reader function + */ + protected ReaderFunction readerFunction() { + return readerFunction; + } + + /** + * A functional interface for creating writers that can write data in a specific format. + * + * @param the file format specific writer type to be created + * @param the engine-specific schema type describing the input data + * @param the file schema type used by the underlying file format + */ + @FunctionalInterface + public interface WriterFunction { + /** + * Creates a writer for the given schemas. + * + * @param icebergSchema the Iceberg schema defining the table structure + * @param fileSchema the file format specific target schema for the output files + * @param engineSchema the engine-specific schema for the input data (optional) + * @return a writer configured for the given schemas + */ + W write(Schema icebergSchema, F fileSchema, S engineSchema); + } + + /** + * A functional interface for creating readers that can read data from a specific format. + * + * @param the file format specific reader type to be created + * @param the engine-specific schema type describing the output data + * @param the file schema type used by the underlying file format + */ + @FunctionalInterface + public interface ReaderFunction { + /** + * Creates a reader for the given schemas. + * + * @param icebergSchema the Iceberg schema defining the table structure + * @param fileSchema the schema that the file was written with, although it can be null + * in the case of Avro files because it is passed in later + * @param engineSchema the engine-specific schema for the output data (optional) + * @param idToConstant a map of field IDs to constant values for partition columns and other + * fields not stored in data files + * @return a reader configured for the given schemas + */ + R read(Schema icebergSchema, F fileSchema, S engineSchema, Map idToConstant); + } +} diff --git a/core/src/main/java/org/apache/iceberg/formats/FileWriterBuilder.java b/core/src/main/java/org/apache/iceberg/formats/FileWriterBuilder.java new file mode 100644 index 000000000000..3eaba6ab3bc4 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/formats/FileWriterBuilder.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.formats; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.deletes.EqualityDeleteWriter; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptionKeyMetadata; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.FileWriter; +import org.apache.iceberg.util.ArrayUtil; + +/** + * A generic builder interface for creating specialized file writers in the Iceberg ecosystem. + * + *

This builder provides a unified configuration API for generating various types of content + * writers: + * + *

    + *
  • {@link DataWriter} for creating data files with table records + *
  • {@link EqualityDeleteWriter} for creating files with equality-based delete records + *
  • {@link PositionDeleteWriter} for creating files with position-based delete records + *
+ * + *

Each concrete implementation configures the underlying file format writer while adding + * content-specific metadata and behaviors. + * + * @param the concrete writer type the builder produces + * @param the type of the engine schema for the input data + */ +public interface FileWriterBuilder, S> { + + /** + * Set a writer configuration property which affects the writer behavior. + * + * @param property a writer config property name + * @param value config value + * @return this for method chaining + */ + FileWriterBuilder set(String property, String value); + + /** + * Adds the new properties to the writer configuration. + * + * @param properties a map of writer config properties + * @return this for method chaining + */ + default FileWriterBuilder setAll(Map properties) { + properties.forEach(this::set); + return this; + } + + /** + * Set a file metadata property in the created file. + * + * @param property a file metadata property name + * @param value config value + * @return this for method chaining + */ + FileWriterBuilder meta(String property, String value); + + /** + * Add the new properties to file metadata for the created file. + * + * @param properties a map of file metadata properties + * @return this for method chaining + */ + default FileWriterBuilder meta(Map properties) { + properties.forEach(this::meta); + return this; + } + + /** Sets the metrics configuration used for collecting column metrics for the created file. */ + FileWriterBuilder metricsConfig(MetricsConfig metricsConfig); + + /** Overwrite the file if it already exists. By default, overwrite is disabled. */ + FileWriterBuilder overwrite(); + + /** + * Sets the encryption key used for writing the file. If the writer does not support encryption, + * then an exception should be thrown. + */ + FileWriterBuilder withFileEncryptionKey(ByteBuffer encryptionKey); + + /** + * Sets the additional authentication data (AAD) prefix used for writing the file. If the writer + * does not support encryption, then an exception should be thrown. + */ + FileWriterBuilder withAADPrefix(ByteBuffer aadPrefix); + + /** Sets the partition specification for the Iceberg metadata. */ + FileWriterBuilder spec(PartitionSpec newSpec); + + /** Sets the partition value for the Iceberg metadata. */ + FileWriterBuilder partition(StructLike partition); + + /** Sets the encryption key metadata for Iceberg metadata. */ + FileWriterBuilder keyMetadata(EncryptionKeyMetadata keyMetadata); + + /** Sets the sort order for the Iceberg metadata. */ + FileWriterBuilder sortOrder(SortOrder sortOrder); + + /** Set the file schema. */ + FileWriterBuilder schema(Schema schema); + + /** + * Sets the engine-specific schema that describes records accepted by the writer. + * + *

Some data types require additional type information from the engine schema that cannot be + * fully expressed by the Iceberg schema or the data itself. For example, an engine's tinyint or + * smallint types are mapped to Iceberg's integer type, but the writer may need the original type + * for proper serialization. Similarly, a variant type may use a shredded representation that + * relies on engine-specific metadata to map back to the Iceberg schema. + * + *

The engine schema must be aligned with the Iceberg schema, but may include representation + * details that Iceberg considers equivalent. + */ + FileWriterBuilder engineSchema(S schema); + + /** + * Sets the equality field ids for the equality delete writer. Only applicable when building an + * {@link EqualityDeleteWriter}. + */ + default FileWriterBuilder equalityFieldIds(List fieldIds) { + return equalityFieldIds(ArrayUtil.toIntArray(fieldIds)); + } + + /** + * Sets the equality field ids for the equality delete writer. Only applicable when building an + * {@link EqualityDeleteWriter}. + */ + FileWriterBuilder equalityFieldIds(int... fieldIds); + + W build() throws IOException; +} diff --git a/core/src/main/java/org/apache/iceberg/formats/FileWriterBuilderImpl.java b/core/src/main/java/org/apache/iceberg/formats/FileWriterBuilderImpl.java new file mode 100644 index 000000000000..d0f9c6da2fbf --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/formats/FileWriterBuilderImpl.java @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.formats; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.deletes.EqualityDeleteWriter; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.encryption.EncryptionKeyMetadata; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.FileWriter; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +abstract class FileWriterBuilderImpl, D, S> + implements FileWriterBuilder { + private final ModelWriteBuilder modelWriteBuilder; + private final String location; + private final FileFormat format; + private final FileContent content; + private Schema schema = null; + private PartitionSpec spec = null; + private StructLike partition = null; + private EncryptionKeyMetadata keyMetadata = null; + private SortOrder sortOrder = null; + private int[] equalityFieldIds = null; + + /** Creates a builder for {@link DataWriter} instances for writing data files. */ + static FileWriterBuilder, S> forDataFile( + FormatModel model, EncryptedOutputFile outputFile) { + return new DataFileWriterBuilder<>(model, outputFile); + } + + /** + * Creates a builder for {@link EqualityDeleteWriter} instances for writing equality delete files. + */ + static FileWriterBuilder, S> forEqualityDelete( + FormatModel model, EncryptedOutputFile outputFile) { + return new EqualityDeleteWriterBuilder<>(model, outputFile); + } + + /** + * Creates a builder for {@link PositionDeleteWriter} instances for writing position delete files. + */ + static FileWriterBuilder, S> forPositionDelete( + FormatModel, S> model, EncryptedOutputFile outputFile) { + return new PositionDeleteWriterBuilder<>(model, outputFile); + } + + private FileWriterBuilderImpl( + FormatModel model, EncryptedOutputFile outputFile, FileContent content) { + this.modelWriteBuilder = model.writeBuilder(outputFile).content(content); + this.location = outputFile.encryptingOutputFile().location(); + this.format = model.format(); + this.content = content; + } + + @Override + public FileWriterBuilderImpl set(String property, String value) { + modelWriteBuilder.set(property, value); + return this; + } + + @Override + public FileWriterBuilderImpl meta(String property, String value) { + modelWriteBuilder.meta(property, value); + return this; + } + + @Override + public FileWriterBuilderImpl metricsConfig(MetricsConfig metricsConfig) { + modelWriteBuilder.metricsConfig(metricsConfig); + return this; + } + + @Override + public FileWriterBuilderImpl overwrite() { + modelWriteBuilder.overwrite(); + return this; + } + + @Override + public FileWriterBuilderImpl withFileEncryptionKey(ByteBuffer encryptionKey) { + modelWriteBuilder.withFileEncryptionKey(encryptionKey); + return this; + } + + @Override + public FileWriterBuilderImpl withAADPrefix(ByteBuffer aadPrefix) { + modelWriteBuilder.withAADPrefix(aadPrefix); + return this; + } + + @Override + public FileWriterBuilderImpl schema(Schema newSchema) { + modelWriteBuilder.schema(newSchema); + this.schema = newSchema; + return this; + } + + @Override + public FileWriterBuilderImpl engineSchema(S newSchema) { + modelWriteBuilder.engineSchema(newSchema); + return this; + } + + @Override + public FileWriterBuilderImpl spec(PartitionSpec newSpec) { + this.spec = newSpec; + return this; + } + + @Override + public FileWriterBuilderImpl partition(StructLike newPartition) { + this.partition = newPartition; + return this; + } + + @Override + public FileWriterBuilderImpl keyMetadata(EncryptionKeyMetadata newKeyMetadata) { + this.keyMetadata = newKeyMetadata; + return this; + } + + @Override + public FileWriterBuilderImpl sortOrder(SortOrder newSortOrder) { + this.sortOrder = newSortOrder; + return this; + } + + @Override + public FileWriterBuilderImpl equalityFieldIds(int... fieldIds) { + if (content != FileContent.EQUALITY_DELETES) { + throw new UnsupportedOperationException( + "Equality field ids not supported for this writer type"); + } + + this.equalityFieldIds = fieldIds; + return this; + } + + ModelWriteBuilder modelWriteBuilder() { + return modelWriteBuilder; + } + + String location() { + return location; + } + + FileFormat format() { + return format; + } + + Schema schema() { + return schema; + } + + PartitionSpec spec() { + return spec; + } + + StructLike partition() { + return partition; + } + + EncryptionKeyMetadata keyMetadata() { + return keyMetadata; + } + + SortOrder sortOrder() { + return sortOrder; + } + + int[] equalityFieldIds() { + return equalityFieldIds; + } + + protected void validate() { + Preconditions.checkState( + content != FileContent.EQUALITY_DELETES || equalityFieldIds != null, + "Invalid delete field ids for equality delete writer: null"); + Preconditions.checkState( + content == FileContent.POSITION_DELETES || schema != null, "Invalid schema: null"); + Preconditions.checkArgument(spec != null, "Invalid partition spec: null"); + Preconditions.checkArgument( + spec.isUnpartitioned() || partition != null, + "Invalid partition, does not match spec: %s", + spec); + } + + /** Builder for creating {@link DataWriter} instances for writing data files. */ + private static class DataFileWriterBuilder + extends FileWriterBuilderImpl, D, S> { + + private DataFileWriterBuilder(FormatModel model, EncryptedOutputFile outputFile) { + super(model, outputFile, FileContent.DATA); + } + + @Override + public DataWriter build() throws IOException { + validate(); + + return new DataWriter<>( + modelWriteBuilder().build(), + format(), + location(), + spec(), + partition(), + keyMetadata(), + sortOrder()); + } + } + + /** + * Builder for creating {@link EqualityDeleteWriter} instances for writing equality delete files. + */ + private static class EqualityDeleteWriterBuilder + extends FileWriterBuilderImpl, D, S> { + + private EqualityDeleteWriterBuilder(FormatModel model, EncryptedOutputFile outputFile) { + super(model, outputFile, FileContent.EQUALITY_DELETES); + } + + @Override + public EqualityDeleteWriter build() throws IOException { + validate(); + + return new EqualityDeleteWriter<>( + modelWriteBuilder() + .schema(schema()) + .meta("delete-type", "equality") + .meta( + "delete-field-ids", + IntStream.of(equalityFieldIds()) + .mapToObj(Objects::toString) + .collect(Collectors.joining(", "))) + .build(), + format(), + location(), + spec(), + partition(), + keyMetadata(), + sortOrder(), + equalityFieldIds()); + } + } + + /** + * Builder for creating {@link PositionDeleteWriter} instances for writing position delete files. + */ + private static class PositionDeleteWriterBuilder + extends FileWriterBuilderImpl, PositionDelete, S> { + + private PositionDeleteWriterBuilder( + FormatModel, S> model, EncryptedOutputFile outputFile) { + super(model, outputFile, FileContent.POSITION_DELETES); + } + + @Override + public PositionDeleteWriter build() throws IOException { + validate(); + + return new PositionDeleteWriter<>( + modelWriteBuilder().meta("delete-type", "position").build(), + format(), + location(), + spec(), + partition(), + keyMetadata()); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/formats/FormatModel.java b/core/src/main/java/org/apache/iceberg/formats/FormatModel.java new file mode 100644 index 000000000000..307a12625cd3 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/formats/FormatModel.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.formats; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.io.InputFile; + +/** + * Interface that provides a unified abstraction for converting between data file formats and + * input/output data representations. + * + *

{@link FormatModel} serves as a bridge between storage formats ({@link FileFormat}) and + * expected input/output data structures, optimizing performance through direct conversion without + * intermediate representations. File format implementations handle the low-level parsing details + * while the object model determines the in-memory representation used for the parsed data. + * Together, these provide a consistent API for consuming data files while optimizing for specific + * processing engines. + * + *

Iceberg provides some built-in object models and processing engines can implement custom + * object models to integrate with Iceberg's file reading and writing capabilities. + * + * @param output type used for reading data, and input type for writing data and deletes + * @param the type of the schema for the input/output data + */ +public interface FormatModel { + /** The file format which is read/written by the object model. */ + FileFormat format(); + + /** + * Return the row type class for the object model implementation processed by this factory. + * + *

The model types act as a contract specifying the expected data structures for both reading + * (converting file formats into output objects) and writing (converting input objects into file + * formats). This ensures proper integration between Iceberg's storage layer and processing + * engines. + * + *

Processing engines can define their own object models by implementing this interface and + * using their own model name. They can register these models with Iceberg by using the {@link + * FormatModelRegistry}. This allows custom data representations to be seamlessly integrated with + * Iceberg's file format handlers. + * + * @return the type of the data structures handled by this model implementation + */ + Class type(); + + /** + * Return the schema type class for the object model implementation processed by this factory. + * + * @return the type of the schema for the data structures handled by this model implementation + */ + Class schemaType(); + + /** + * Creates a writer builder for data files. + * + *

The returned {@link ModelWriteBuilder} configures and creates a writer that converts input + * objects into the file format supported by this factory. + * + * @param outputFile destination for the written data + * @return configured writer builder + */ + ModelWriteBuilder writeBuilder(EncryptedOutputFile outputFile); + + /** + * Creates a file reader builder for the specified input file. + * + *

The returned {@link ReadBuilder} configures and creates a reader that converts data from the + * file format into output objects supported by this factory. + * + * @param inputFile source file to read from + * @return configured reader builder for the specified input + */ + ReadBuilder readBuilder(InputFile inputFile); +} diff --git a/core/src/main/java/org/apache/iceberg/formats/FormatModelRegistry.java b/core/src/main/java/org/apache/iceberg/formats/FormatModelRegistry.java new file mode 100644 index 000000000000..e1e93aa1fd07 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/formats/FormatModelRegistry.java @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.formats; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.deletes.EqualityDeleteWriter; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.Pair; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A registry that manages file-format-specific readers and writers through a unified object model + * factory interface. + * + *

This registry provides access to {@link ReadBuilder}s for data consumption and {@link + * FileWriterBuilder}s for writing various types of Iceberg content files. The appropriate builder + * is selected based on {@link FileFormat} and object model class. + * + *

{@link FormatModel} objects are registered through {@link #register(FormatModel)} and used for + * creating readers and writers. + */ +public final class FormatModelRegistry { + private FormatModelRegistry() {} + + private static final Logger LOG = LoggerFactory.getLogger(FormatModelRegistry.class); + // The list of classes which are used for registering the reader and writer builders + private static final List CLASSES_TO_REGISTER = + ImmutableList.of( + "org.apache.iceberg.data.GenericFormatModels", + "org.apache.iceberg.arrow.vectorized.ArrowFormatModels", + "org.apache.iceberg.flink.data.FlinkFormatModels", + "org.apache.iceberg.spark.source.SparkFormatModels"); + + // Format models indexed by file format and object model class + private static final Map>, FormatModel> MODELS = + Maps.newConcurrentMap(); + + static { + registerSupportedFormats(); + } + + /** + * Registers an {@link FormatModel} in this registry. + * + *

The {@link FormatModel} creates readers and writers for a specific combinations of file + * format (Parquet, ORC, Avro) and object model (for example: "generic", "spark", "flink", etc.). + * Registering custom factories allows integration of new data processing engines for the + * supported file formats with Iceberg's file access mechanisms. + * + *

Each factory must be uniquely identified by its combination of file format and object model + * name. This uniqueness constraint prevents ambiguity when selecting factories for read and write + * operations. + * + * @param formatModel the factory implementation to register + * @throws IllegalArgumentException if a factory is already registered for the combination of + * {@link FormatModel#format()} and {@link FormatModel#type()} + */ + public static synchronized void register(FormatModel formatModel) { + Pair> key = Pair.of(formatModel.format(), formatModel.type()); + + FormatModel existing = MODELS.get(key); + Preconditions.checkArgument( + existing == null, + "Cannot register %s: %s is registered for format=%s type=%s schemaType=%s", + formatModel.getClass(), + existing == null ? null : existing.getClass(), + key.first(), + key.second(), + existing == null ? null : existing.schemaType()); + + MODELS.put(key, formatModel); + } + + /** + * Returns a reader builder for the specified file format and object model. + * + *

The returned {@link ReadBuilder} provides a fluent interface for configuring how data is + * read from the input file and converted to the output objects. + * + * @param format the file format (Parquet, Avro, ORC) that determines the parsing implementation + * @param type the output type + * @param inputFile source file to read data from + * @param the type of data records the reader will produce + * @param the type of the output schema for the reader + * @return a configured reader builder for the specified format and object model + */ + public static ReadBuilder readBuilder( + FileFormat format, Class type, InputFile inputFile) { + FormatModel model = modelFor(format, type); + return model.readBuilder(inputFile); + } + + /** + * Returns a writer builder for generating a {@link DataFile}. + * + *

The returned builder produces a writer that accepts records defined by the specified object + * model and persists them using the provided file format. Unlike basic writers, this writer + * collects file metadata during the writing process and generates a {@link DataFile} that can be + * used for table operations. + * + * @param format the file format used for writing + * @param type the input type + * @param outputFile destination for the written data + * @param the type of data records the writer will accept + * @param the type of the input schema for the writer + * @return a configured data write builder for creating a {@link DataWriter} + */ + public static FileWriterBuilder, S> dataWriteBuilder( + FileFormat format, Class type, EncryptedOutputFile outputFile) { + FormatModel model = modelFor(format, type); + return FileWriterBuilderImpl.forDataFile(model, outputFile); + } + + /** + * Creates a writer builder for generating a {@link DeleteFile} with equality deletes. + * + *

The returned builder produces a writer that accepts records defined by the specified object + * model and persists them using the given file format. The writer persists equality delete + * records that identify rows to be deleted based on the configured equality fields, producing a + * {@link DeleteFile} that can be used for table operations. + * + * @param format the file format used for writing + * @param type the input type + * @param outputFile destination for the written data + * @param the type of data records the writer will accept + * @param the type of the input schema for the writer + * @return a configured delete write builder for creating an {@link EqualityDeleteWriter} + */ + public static FileWriterBuilder, S> equalityDeleteWriteBuilder( + FileFormat format, Class type, EncryptedOutputFile outputFile) { + FormatModel model = modelFor(format, type); + return FileWriterBuilderImpl.forEqualityDelete(model, outputFile); + } + + /** + * Creates a writer builder for generating a {@link DeleteFile} with position-based deletes. + * + *

The returned builder produces a writer that accepts records defined by the specified object + * model and persists them using the given file format. The writer accepts {@link PositionDelete} + * records that identify rows to be deleted by file path and position, producing a {@link + * DeleteFile} that can be used for table operations. + * + *

Note: This method is only applicable to format-version 2 tables. Format-version 3 + * tables use deletion vectors, which are always written in Puffin format. Registered {@link + * FormatModel} implementations for {@link PositionDelete} are not consulted for format-version 3+ + * tables. + * + * @param format the file format used for writing + * @param outputFile destination for the written data + * @return a configured delete write builder for creating a {@link PositionDeleteWriter} + */ + public static FileWriterBuilder, ?> positionDeleteWriteBuilder( + FileFormat format, EncryptedOutputFile outputFile) { + FormatModel, ?> model = + FormatModelRegistry.modelFor(format, PositionDelete.deleteClass()); + return FileWriterBuilderImpl.forPositionDelete(model, outputFile); + } + + @VisibleForTesting + static Map>, FormatModel> models() { + return MODELS; + } + + @SuppressWarnings("unchecked") + private static FormatModel modelFor(FileFormat format, Class type) { + FormatModel model = (FormatModel) MODELS.get(Pair.of(format, type)); + Preconditions.checkArgument( + model != null, "Format model is not registered for format %s and type %s", format, type); + return model; + } + + @SuppressWarnings("CatchBlockLogException") + private static void registerSupportedFormats() { + // Uses dynamic methods to call the `register` for the listed classes + for (String classToRegister : CLASSES_TO_REGISTER) { + try { + DynMethods.builder("register").impl(classToRegister).buildStaticChecked().invoke(); + } catch (NoSuchMethodException e) { + // failing to register a factory is normal and does not require a stack trace + LOG.info( + "Unable to call register for ({}). Check for missing jars on the classpath: {}", + classToRegister, + e.getMessage()); + } + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/formats/ModelWriteBuilder.java b/core/src/main/java/org/apache/iceberg/formats/ModelWriteBuilder.java new file mode 100644 index 000000000000..1a65305b2a3a --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/formats/ModelWriteBuilder.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.formats; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.FileAppender; + +/** + * Builder interface for creating file writers across supported data file formats. The {@link + * FormatModel} implementations provide the appropriate {@link ModelWriteBuilder} instances. + * + *

The {@link ModelWriteBuilder} follows the builder pattern to configure and create {@link + * FileAppender} instances that write data to the target output files. + * + *

This interface is directly exposed to users for parameterizing when only an appender is + * required. + * + * @param the output data type produced by the reader + * @param the type of the schema for the output data type + */ +public interface ModelWriteBuilder { + /** Set the file schema. */ + ModelWriteBuilder schema(Schema schema); + + /** + * Sets the engine's representation accepted by the writer. + * + *

Some data types require additional type information from the engine schema that cannot be + * fully expressed by the Iceberg schema or the data itself. For example, a variant type may use a + * shredded representation that relies on engine-specific metadata to map back to the Iceberg + * schema. + * + *

The engine schema must be aligned with the Iceberg schema, but may include representation + * details that Iceberg considers equivalent. + */ + ModelWriteBuilder engineSchema(S schema); + + /** + * Set a writer configuration property which affects the writer behavior. Writer builders should + * ignore configuration keys not known for them. + * + * @param property a writer config property name + * @param value config value + * @return this for method chaining + */ + ModelWriteBuilder set(String property, String value); + + /** + * Sets multiple writer configuration properties that affect the writer behavior. Writer builders + * should ignore configuration keys not known for them. + * + * @param properties writer config properties to set + * @return this for method chaining + */ + default ModelWriteBuilder setAll(Map properties) { + properties.forEach(this::set); + return this; + } + + /** + * Set a file metadata property in the created file. + * + * @param property a file metadata property name + * @param value config value + * @return this for method chaining + */ + ModelWriteBuilder meta(String property, String value); + + /** + * Sets multiple file metadata properties in the created file. + * + * @param properties file metadata properties to set + * @return this for method chaining + */ + default ModelWriteBuilder meta(Map properties) { + properties.forEach(this::meta); + return this; + } + + /** + * Based on the target file content the generated {@link FileAppender} needs different + * configuration. + */ + ModelWriteBuilder content(FileContent content); + + /** Sets the metrics configuration used for collecting column metrics for the created file. */ + ModelWriteBuilder metricsConfig(MetricsConfig metricsConfig); + + /** Overwrite the file if it already exists. By default, overwrite is disabled. */ + ModelWriteBuilder overwrite(); + + /** + * Sets the encryption key used for writing the file. If the writer does not support encryption, + * then an exception should be thrown. + */ + ModelWriteBuilder withFileEncryptionKey(ByteBuffer encryptionKey); + + /** + * Sets the additional authentication data (AAD) prefix used for writing the file. If the writer + * does not support encryption, then an exception should be thrown. + */ + ModelWriteBuilder withAADPrefix(ByteBuffer aadPrefix); + + /** Finalizes the configuration and builds the {@link FileAppender}. */ + FileAppender build() throws IOException; +} diff --git a/core/src/main/java/org/apache/iceberg/formats/ReadBuilder.java b/core/src/main/java/org/apache/iceberg/formats/ReadBuilder.java new file mode 100644 index 000000000000..2809750970a7 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/formats/ReadBuilder.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.formats; + +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.mapping.NameMapping; + +/** + * Builder interface for creating file readers across supported data file formats. The {@link + * FormatModel} implementations provides appropriate {@link ReadBuilder} instances + * + *

The {@link ReadBuilder} follows the builder pattern to configure and create {@link + * CloseableIterable} instances that read data from source files. Configuration options include + * schema projection, predicate filtering, record batching, and encryption settings. + * + *

This interface is directly exposed to users for parameterizing readers. + * + * @param the output data type produced by the reader + * @param the type of the schema for the output data type + */ +public interface ReadBuilder { + /** + * Restricts the read to the given range: [start, start + length). + * + * @param start the start position for this read + * @param length the length of the range this read should scan + */ + ReadBuilder split(long start, long length); + + /** Set the projection schema. This must be set before the reader is instantiated. */ + ReadBuilder project(Schema schema); + + /** + * Sets the engine's representation of the projected schema. + * + *

When provided, this schema should be consistent with the requested Iceberg projection, while + * allowing representation differences. Examples include: + * + *

    + *
  • using a {@code long} to represent an Iceberg {@code int} column, + *
  • requesting a shredded representation for a variant type, or + *
  • selecting specific concrete classes for Iceberg structs. + *
+ */ + ReadBuilder engineProjection(S schema); + + /** + * Configures whether filtering should be case-sensitive. If the reader supports filtering, it + * must respect this setting. The default value is true. + * + * @param caseSensitive indicates if filtering is case-sensitive + */ + ReadBuilder caseSensitive(boolean caseSensitive); + + /** + * Pushes down the {@link Expression} filter for the reader to prevent reading unnecessary + * records. Some readers may not support filtering, or may only support filtering for certain + * expressions. In this case the reader might return unfiltered or partially filtered rows. It is + * the caller's responsibility to apply the filter again. + * + * @param filter the filter to set + */ + ReadBuilder filter(Expression filter); + + /** + * Set a reader configuration property which affects the reader behavior. Reader builders should + * ignore configuration keys not known for them. + * + * @param key a reader config property name + * @param value config value + * @return this for method chaining + */ + ReadBuilder set(String key, String value); + + /** + * Sets multiple reader configuration properties that affect the reader behavior. Reader builders + * should ignore configuration keys not known for them. + * + * @param properties reader config properties to set + * @return this for method chaining + */ + default ReadBuilder setAll(Map properties) { + properties.forEach(this::set); + return this; + } + + /** Enables reusing the containers returned by the reader. Decreases pressure on GC. */ + ReadBuilder reuseContainers(); + + /** Sets the batch size for vectorized readers. */ + ReadBuilder recordsPerBatch(int rowsPerBatch); + + /** + * Contains the values in the result objects which are coming from metadata and not coming from + * the data files themselves. The keys of the map are the column ids, the values are the constant + * values to be used in the result. + */ + ReadBuilder idToConstant(Map idToConstant); + + /** Sets a mapping from external schema names to Iceberg type IDs. */ + ReadBuilder withNameMapping(NameMapping nameMapping); + + /** Builds the reader. */ + CloseableIterable build(); +} diff --git a/core/src/main/java/org/apache/iceberg/hadoop/HadoopFileIO.java b/core/src/main/java/org/apache/iceberg/hadoop/HadoopFileIO.java index 3740f0abda88..877290f48e3f 100644 --- a/core/src/main/java/org/apache/iceberg/hadoop/HadoopFileIO.java +++ b/core/src/main/java/org/apache/iceberg/hadoop/HadoopFileIO.java @@ -28,11 +28,8 @@ import java.util.function.Function; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; -import org.apache.hadoop.fs.LocalFileSystem; import org.apache.hadoop.fs.Path; import org.apache.hadoop.fs.RemoteIterator; -import org.apache.hadoop.fs.Trash; -import org.apache.hadoop.hdfs.DistributedFileSystem; import org.apache.iceberg.exceptions.RuntimeIOException; import org.apache.iceberg.io.BulkDeletionFailureException; import org.apache.iceberg.io.DelegateFileIO; @@ -69,9 +66,14 @@ public class HadoopFileIO implements HadoopConfigurable, DelegateFileIO { public HadoopFileIO() {} public HadoopFileIO(Configuration hadoopConf) { - this(new SerializableConfiguration(hadoopConf)::get); + this(new SerializableConfiguration(hadoopConf)); } + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * HadoopFileIO#HadoopFileIO(Configuration)} instead. + */ + @Deprecated public HadoopFileIO(SerializableSupplier hadoopConf) { this.hadoopConf = hadoopConf; } @@ -105,7 +107,7 @@ public void deleteFile(String path) { Path toDelete = new Path(path); FileSystem fs = Util.getFs(toDelete, getConf()); try { - deletePath(fs, toDelete, false); + fs.delete(toDelete, false /* not recursive */); } catch (IOException e) { throw new RuntimeIOException(e, "Failed to delete file: %s", path); } @@ -118,7 +120,7 @@ public Map properties() { @Override public void setConf(Configuration conf) { - this.hadoopConf = new SerializableConfiguration(conf)::get; + this.hadoopConf = new SerializableConfiguration(conf); } @Override @@ -128,7 +130,7 @@ public Configuration getConf() { if (hadoopConf == null) { synchronized (this) { if (hadoopConf == null) { - this.hadoopConf = new SerializableConfiguration(new Configuration())::get; + this.hadoopConf = new SerializableConfiguration(new Configuration()); } } } @@ -136,6 +138,10 @@ public Configuration getConf() { return hadoopConf.get(); } + /** + * @deprecated since 1.11.0, will be removed in 1.12.0. + */ + @Deprecated @Override public void serializeConfWith( Function> confSerializer) { @@ -170,7 +176,7 @@ public void deletePrefix(String prefix) { FileSystem fs = Util.getFs(prefixToDelete, getConf()); try { - deletePath(fs, prefixToDelete, true); + fs.delete(prefixToDelete, true /* recursive */); } catch (IOException e) { throw new UncheckedIOException(e); } @@ -214,16 +220,6 @@ private ExecutorService executorService() { return executorService; } - private void deletePath(FileSystem fs, Path toDelete, boolean recursive) throws IOException { - Trash trash = new Trash(fs, getConf()); - if ((fs instanceof LocalFileSystem || fs instanceof DistributedFileSystem) - && trash.isEnabled()) { - trash.moveToTrash(toDelete); - } else { - fs.delete(toDelete, recursive); - } - } - /** * This class is a simple adaptor to allow for using Hadoop's RemoteIterator as an Iterator. * diff --git a/core/src/main/java/org/apache/iceberg/hadoop/HadoopMetricsContext.java b/core/src/main/java/org/apache/iceberg/hadoop/HadoopMetricsContext.java index 31fda83e30ab..4e677c9b739c 100644 --- a/core/src/main/java/org/apache/iceberg/hadoop/HadoopMetricsContext.java +++ b/core/src/main/java/org/apache/iceberg/hadoop/HadoopMetricsContext.java @@ -43,6 +43,7 @@ public HadoopMetricsContext(String scheme) { this.scheme = scheme; } + @SuppressWarnings("deprecation") @Override public void initialize(Map properties) { // FileIO has no specific implementation class, but Hadoop will @@ -158,6 +159,7 @@ public long value() { }; } + @SuppressWarnings("deprecation") private FileSystem.Statistics statistics() { if (statistics == null) { synchronized (this) { diff --git a/core/src/main/java/org/apache/iceberg/hadoop/HadoopStreams.java b/core/src/main/java/org/apache/iceberg/hadoop/HadoopStreams.java index 6a48b14c227d..fd52e325f9b9 100644 --- a/core/src/main/java/org/apache/iceberg/hadoop/HadoopStreams.java +++ b/core/src/main/java/org/apache/iceberg/hadoop/HadoopStreams.java @@ -122,7 +122,7 @@ public int read(byte[] b, int off, int len) throws IOException { return stream.read(b, off, len); } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); @@ -195,7 +195,7 @@ public void close() throws IOException { } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/core/src/main/java/org/apache/iceberg/hadoop/SerializableConfiguration.java b/core/src/main/java/org/apache/iceberg/hadoop/SerializableConfiguration.java index 3e9f17455f81..8c660bc29d24 100644 --- a/core/src/main/java/org/apache/iceberg/hadoop/SerializableConfiguration.java +++ b/core/src/main/java/org/apache/iceberg/hadoop/SerializableConfiguration.java @@ -18,33 +18,34 @@ */ package org.apache.iceberg.hadoop; -import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.ObjectOutputStream; import java.io.Serializable; +import java.util.Map; import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.SerializableSupplier; /** Wraps a {@link Configuration} object in a {@link Serializable} layer. */ -public class SerializableConfiguration implements Serializable { - - private transient Configuration hadoopConf; +public class SerializableConfiguration implements SerializableSupplier { + private final Map confAsMap; + private transient volatile Configuration hadoopConf = null; public SerializableConfiguration(Configuration hadoopConf) { - this.hadoopConf = hadoopConf; - } - - private void writeObject(ObjectOutputStream out) throws IOException { - out.defaultWriteObject(); - hadoopConf.write(out); - } - - private void readObject(ObjectInputStream in) throws ClassNotFoundException, IOException { - in.defaultReadObject(); - hadoopConf = new Configuration(false); - hadoopConf.readFields(in); + this.confAsMap = Maps.newHashMapWithExpectedSize(hadoopConf.size()); + hadoopConf.forEach(entry -> confAsMap.put(entry.getKey(), entry.getValue())); } + @Override public Configuration get() { + if (hadoopConf == null) { + synchronized (this) { + if (hadoopConf == null) { + Configuration newConf = new Configuration(false); + confAsMap.forEach(newConf::set); + this.hadoopConf = newConf; + } + } + } + return hadoopConf; } } diff --git a/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java b/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java index 985127d651b4..2234d418de40 100644 --- a/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java +++ b/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java @@ -48,6 +48,8 @@ import org.apache.iceberg.relocated.com.google.common.base.Objects; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.LocationUtil; +import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.view.BaseMetastoreViewCatalog; import org.apache.iceberg.view.BaseViewOperations; import org.apache.iceberg.view.ViewMetadata; @@ -71,6 +73,7 @@ public class InMemoryCatalog extends BaseMetastoreViewCatalog private String catalogName; private String warehouseLocation; private CloseableGroup closeableGroup; + private boolean uniqueTableLocation; private Map catalogProperties; public InMemoryCatalog() { @@ -88,10 +91,15 @@ public String name() { public void initialize(String name, Map properties) { this.catalogName = name != null ? name : InMemoryCatalog.class.getSimpleName(); this.catalogProperties = ImmutableMap.copyOf(properties); + this.uniqueTableLocation = + PropertyUtil.propertyAsBoolean( + properties, + CatalogProperties.UNIQUE_TABLE_LOCATION, + CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT); String warehouse = properties.getOrDefault(CatalogProperties.WAREHOUSE_LOCATION, ""); this.warehouseLocation = warehouse.replaceAll("/*$", ""); - this.io = new InMemoryFileIO(); + this.io = CatalogUtil.loadFileIO(InMemoryFileIO.class.getName(), properties, null); this.closeableGroup = new CloseableGroup(); closeableGroup.addCloseable(metricsReporter()); closeableGroup.setSuppressCloseFailure(true); @@ -104,8 +112,8 @@ protected TableOperations newTableOps(TableIdentifier tableIdentifier) { @Override protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { - return SLASH.join( - defaultNamespaceLocation(tableIdentifier.namespace()), tableIdentifier.name()); + String tableLocation = LocationUtil.tableLocation(tableIdentifier, uniqueTableLocation); + return SLASH.join(defaultNamespaceLocation(tableIdentifier.namespace()), tableLocation); } private String defaultNamespaceLocation(Namespace namespace) { @@ -211,6 +219,13 @@ public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyExcept return false; } + List childNamespaces = listNamespaces(namespace); + if (!childNamespaces.isEmpty()) { + throw new NamespaceNotEmptyException( + "Namespace %s is not empty. Contains %d child namespace(s).", + namespace, childNamespaces.size()); + } + List tableIdentifiers = listTables(namespace); if (!tableIdentifiers.isEmpty()) { throw new NamespaceNotEmptyException( diff --git a/core/src/main/java/org/apache/iceberg/inmemory/InMemoryFileIO.java b/core/src/main/java/org/apache/iceberg/inmemory/InMemoryFileIO.java index 19208a1e0ce3..5a999438c0f6 100644 --- a/core/src/main/java/org/apache/iceberg/inmemory/InMemoryFileIO.java +++ b/core/src/main/java/org/apache/iceberg/inmemory/InMemoryFileIO.java @@ -24,12 +24,25 @@ import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.SerializableMap; public class InMemoryFileIO implements FileIO { private static final Map IN_MEMORY_FILES = Maps.newConcurrentMap(); private boolean closed = false; + private SerializableMap properties = SerializableMap.copyOf(ImmutableMap.of()); + + @Override + public void initialize(Map props) { + this.properties = SerializableMap.copyOf(props); + } + + @Override + public Map properties() { + return properties.immutableMap(); + } public void addFile(String location, byte[] contents) { Preconditions.checkState(!closed, "Cannot call addFile after calling close()"); diff --git a/core/src/main/java/org/apache/iceberg/io/BufferedFileAppender.java b/core/src/main/java/org/apache/iceberg/io/BufferedFileAppender.java new file mode 100644 index 000000000000..8f8ef8f33b76 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/io/BufferedFileAppender.java @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.io; + +import java.io.IOException; +import java.util.List; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import org.apache.iceberg.Metrics; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; + +/** + * A FileAppender that buffers the first N rows, then creates a delegate appender via a factory. + * + *

The factory receives the buffered rows and is responsible for creating the real appender. Row + * replay is handled internally. All subsequent {@link #add} calls delegate directly to the real + * appender. + * + *

If fewer than {@code bufferSize} rows are written before close, the factory is called with + * whatever rows were buffered. If no rows were written, the factory is not called and no file is + * created on disk. In this case, {@link #metrics()} returns {@code new Metrics(0L)} and {@link + * #length()} returns {@code 0L}. + * + * @param the row type + */ +public class BufferedFileAppender implements FileAppender { + private final int bufferRowCount; + private final Function, FileAppender> appenderFactory; + private final UnaryOperator copyFunc; + private List buffer; + private FileAppender delegate; + private boolean closed = false; + + /** + * @param bufferRowCount number of rows to buffer before creating the delegate appender + * @param appenderFactory given the buffered rows, creates the delegate appender + */ + public BufferedFileAppender( + int bufferRowCount, Function, FileAppender> appenderFactory) { + this(bufferRowCount, appenderFactory, UnaryOperator.identity()); + } + + /** + * @param bufferRowCount number of rows to buffer before creating the delegate appender + * @param appenderFactory given the buffered rows, creates the delegate appender + * @param copyFunc copies a row before buffering (needed when row objects are reused, e.g. Spark + * InternalRow) + */ + public BufferedFileAppender( + int bufferRowCount, + Function, FileAppender> appenderFactory, + UnaryOperator copyFunc) { + Preconditions.checkArgument( + bufferRowCount > 0, "bufferRowCount must be > 0, got %s", bufferRowCount); + Preconditions.checkNotNull(appenderFactory, "appenderFactory must not be null"); + Preconditions.checkNotNull(copyFunc, "copyFunc must not be null"); + this.bufferRowCount = bufferRowCount; + this.appenderFactory = appenderFactory; + this.copyFunc = copyFunc; + this.buffer = Lists.newArrayListWithCapacity(bufferRowCount); + } + + @Override + public void add(D datum) { + Preconditions.checkState(!closed, "Cannot add to a closed appender"); + if (delegate != null) { + delegate.add(datum); + } else { + buffer.add(copyFunc.apply(datum)); + if (buffer.size() >= bufferRowCount) { + initialize(); + } + } + } + + @Override + public Metrics metrics() { + Preconditions.checkState(closed, "Cannot return metrics for unclosed appender"); + if (delegate == null) { + return new Metrics(0L); + } + + return delegate.metrics(); + } + + @Override + public long length() { + if (delegate != null) { + return delegate.length(); + } + + // No bytes written to disk yet; data is buffered in memory + return 0L; + } + + @Override + public List splitOffsets() { + if (delegate != null) { + return delegate.splitOffsets(); + } + + return null; + } + + @Override + public void close() throws IOException { + if (!closed) { + try { + if (delegate == null && buffer != null && !buffer.isEmpty()) { + initialize(); + } + + if (delegate != null) { + delegate.close(); + } + } finally { + this.closed = true; + this.buffer = null; + } + } + } + + private void initialize() { + delegate = appenderFactory.apply(buffer); + Preconditions.checkState(delegate != null, "appenderFactory must not return null"); + try { + buffer.forEach(delegate::add); + } finally { + buffer = null; + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/io/ContentCache.java b/core/src/main/java/org/apache/iceberg/io/ContentCache.java index fd171f7806e2..484306690c67 100644 --- a/core/src/main/java/org/apache/iceberg/io/ContentCache.java +++ b/core/src/main/java/org/apache/iceberg/io/ContentCache.java @@ -140,7 +140,7 @@ public void invalidate(String key) { } /** - * @deprecated since 1.7.0, will be removed in 2.0.0; This method does only best-effort + * @deprecated since 1.7.0, will be removed in 1.12.0; This method does only best-effort * invalidation and is susceptible to a race condition. If the caller changed the state that * could be cached (perhaps files on the storage) and calls this method, there is no guarantee * that the cache will not contain stale entries some time after this method returns. diff --git a/core/src/main/java/org/apache/iceberg/io/IOUtil.java b/core/src/main/java/org/apache/iceberg/io/IOUtil.java index 37962d322d87..13abcc160eca 100644 --- a/core/src/main/java/org/apache/iceberg/io/IOUtil.java +++ b/core/src/main/java/org/apache/iceberg/io/IOUtil.java @@ -49,6 +49,31 @@ public static void readFully(InputStream stream, byte[] bytes, int offset, int l } } + /** + * Reads exactly {@code length} bytes from the input file starting at {@code fileOffset} into the + * buffer. Uses range reads when supported. + * + * @param inputFile the file to read from + * @param fileOffset the position in the file to start reading from + * @param bytes a buffer to write into + * @param offset starting offset in the buffer for the data + * @param length number of bytes to read + * @throws IOException if there is an error while reading or if the end of the stream is reached + * before reading length bytes + */ + public static void readFully( + InputFile inputFile, long fileOffset, byte[] bytes, int offset, int length) + throws IOException { + try (SeekableInputStream stream = inputFile.newStream()) { + if (stream instanceof RangeReadable) { + ((RangeReadable) stream).readFully(fileOffset, bytes, offset, length); + } else { + stream.seek(fileOffset); + readFully(stream, bytes, offset, length); + } + } + } + /** Writes a buffer into a stream, making multiple write calls if necessary. */ public static void writeFully(OutputStream outputStream, ByteBuffer buffer) throws IOException { if (!buffer.hasRemaining()) { diff --git a/core/src/main/java/org/apache/iceberg/io/ResolvingFileIO.java b/core/src/main/java/org/apache/iceberg/io/ResolvingFileIO.java index 9815a459f0fe..c27c609d94f8 100644 --- a/core/src/main/java/org/apache/iceberg/io/ResolvingFileIO.java +++ b/core/src/main/java/org/apache/iceberg/io/ResolvingFileIO.java @@ -154,7 +154,7 @@ public void serializeConfWith( @Override public void setConf(Configuration conf) { - this.hadoopConf = new SerializableConfiguration(conf)::get; + this.hadoopConf = new SerializableConfiguration(conf); } @Override @@ -254,7 +254,7 @@ private static String scheme(String location) { return null; } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/core/src/main/java/org/apache/iceberg/io/WriteResult.java b/core/src/main/java/org/apache/iceberg/io/WriteResult.java index 39efaec7d34a..2ab2fff3c5ae 100644 --- a/core/src/main/java/org/apache/iceberg/io/WriteResult.java +++ b/core/src/main/java/org/apache/iceberg/io/WriteResult.java @@ -23,6 +23,7 @@ import java.util.List; import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.util.CharSequenceSet; @@ -134,4 +135,14 @@ public WriteResult build() { return new WriteResult(dataFiles, deleteFiles, referencedDataFiles, rewrittenDeleteFiles); } } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("dataFiles", dataFiles) + .add("deleteFiles", deleteFiles) + .add("referencedDataFiles", referencedDataFiles) + .add("rewrittenDeleteFiles", rewrittenDeleteFiles) + .toString(); + } } diff --git a/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java b/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java index 0c8fbe41df9e..2d24e5598ac7 100644 --- a/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java +++ b/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java @@ -20,11 +20,11 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.SQLIntegrityConstraintViolationException; import java.sql.SQLNonTransientConnectionException; import java.sql.SQLTimeoutException; import java.sql.SQLTransientConnectionException; @@ -88,6 +88,7 @@ public class JdbcCatalog extends BaseMetastoreViewCatalog private Object conf; private JdbcClientPool connections; private Map catalogProperties; + private boolean uniqueTableLocation; private final Function, FileIO> ioBuilder; private final Function, JdbcClientPool> clientPoolBuilder; private boolean initializeCatalogTables; @@ -120,6 +121,11 @@ public void initialize(String name, Map properties) { this.warehouseLocation = LocationUtil.stripTrailingSlash(inputWarehouseLocation); this.catalogProperties = ImmutableMap.copyOf(properties); + this.uniqueTableLocation = + PropertyUtil.propertyAsBoolean( + properties, + CatalogProperties.UNIQUE_TABLE_LOCATION, + CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT); if (name != null) { this.catalogName = name; @@ -166,13 +172,12 @@ private void atomicCreateTable(String tableName, String sqlCommand, String reaso // check the existence of a table name Predicate tableTest = name -> { - try { - ResultSet result = - dbMeta.getTables( - null /* catalog name */, - null /* schemaPattern */, - name /* tableNamePattern */, - null /* types */); + try (ResultSet result = + dbMeta.getTables( + null /* catalog name */, + null /* schemaPattern */, + name /* tableNamePattern */, + null /* types */)) { return result.next(); } catch (SQLException e) { return false; @@ -188,8 +193,8 @@ private void atomicCreateTable(String tableName, String sqlCommand, String reaso } LOG.debug("Creating table {} {}", tableName, reason); - try { - conn.prepareStatement(sqlCommand).execute(); + try (PreparedStatement stmt = conn.prepareStatement(sqlCommand)) { + stmt.execute(); return true; } catch (SQLException e) { // see if table was created by another thread or process. @@ -230,25 +235,27 @@ private void updateSchemaIfRequired() { connections.run( conn -> { DatabaseMetaData dbMeta = conn.getMetaData(); - ResultSet typeColumn = + try (ResultSet typeColumn = dbMeta.getColumns( - null, null, JdbcUtil.CATALOG_TABLE_VIEW_NAME, JdbcUtil.RECORD_TYPE); - if (typeColumn.next()) { - LOG.debug("{} already supports views", JdbcUtil.CATALOG_TABLE_VIEW_NAME); - schemaVersion = JdbcUtil.SchemaVersion.V1; - return true; - } else { - if (PropertyUtil.propertyAsString( - catalogProperties, - JdbcUtil.SCHEMA_VERSION_PROPERTY, - JdbcUtil.SchemaVersion.V0.name()) - .equalsIgnoreCase(JdbcUtil.SchemaVersion.V1.name())) { - LOG.debug("{} is being updated to support views", JdbcUtil.CATALOG_TABLE_VIEW_NAME); + null, null, JdbcUtil.CATALOG_TABLE_VIEW_NAME, JdbcUtil.RECORD_TYPE)) { + if (typeColumn.next()) { + LOG.debug("{} already supports views", JdbcUtil.CATALOG_TABLE_VIEW_NAME); schemaVersion = JdbcUtil.SchemaVersion.V1; - return conn.prepareStatement(JdbcUtil.V1_UPDATE_CATALOG_SQL).execute(); - } else { - LOG.warn(VIEW_WARNING_LOG_MESSAGE); return true; + } else { + if (PropertyUtil.propertyAsString( + catalogProperties, + JdbcUtil.SCHEMA_VERSION_PROPERTY, + JdbcUtil.SchemaVersion.V0.name()) + .equalsIgnoreCase(JdbcUtil.SchemaVersion.V1.name())) { + LOG.debug( + "{} is being updated to support views", JdbcUtil.CATALOG_TABLE_VIEW_NAME); + schemaVersion = JdbcUtil.SchemaVersion.V1; + return executeV1CatalogUpdate(conn); + } else { + LOG.warn(VIEW_WARNING_LOG_MESSAGE); + return true; + } } } }); @@ -264,6 +271,12 @@ private void updateSchemaIfRequired() { } } + private static boolean executeV1CatalogUpdate(Connection conn) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement(JdbcUtil.V1_UPDATE_CATALOG_SQL)) { + return stmt.execute(); + } + } + @Override protected TableOperations newTableOps(TableIdentifier tableIdentifier) { return new JdbcTableOperations( @@ -280,7 +293,8 @@ protected ViewOperations newViewOps(TableIdentifier viewIdentifier) { @Override protected String defaultWarehouseLocation(TableIdentifier table) { - return SLASH.join(defaultNamespaceLocation(table.namespace()), table.name()); + String tableLocation = LocationUtil.tableLocation(table, uniqueTableLocation); + return SLASH.join(defaultNamespaceLocation(table.namespace()), tableLocation); } @Override @@ -337,7 +351,6 @@ public List listTables(Namespace namespace) { JdbcUtil.namespaceToString(namespace)); } - @SuppressWarnings("checkstyle:CyclomaticComplexity") @Override public void renameTable(TableIdentifier from, TableIdentifier to) { if (from.equals(to)) { @@ -363,9 +376,7 @@ public void renameTable(TableIdentifier from, TableIdentifier to) { int updatedRecords = execute( err -> { - // SQLite doesn't set SQLState or throw SQLIntegrityConstraintViolationException - if (err instanceof SQLIntegrityConstraintViolationException - || (err.getMessage() != null && err.getMessage().contains("constraint failed"))) { + if (JdbcUtil.isConstraintViolation(err)) { throw new AlreadyExistsException("Table already exists: %s", to); } }, @@ -532,6 +543,13 @@ public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyExcept return false; } + List childNamespaces = listNamespaces(namespace); + if (childNamespaces != null && !childNamespaces.isEmpty()) { + throw new NamespaceNotEmptyException( + "Namespace %s is not empty. Contains %d child namespace(s).", + namespace, childNamespaces.size()); + } + List tableIdentifiers = listTables(namespace); if (tableIdentifiers != null && !tableIdentifiers.isEmpty()) { throw new NamespaceNotEmptyException( @@ -713,9 +731,7 @@ public void renameView(TableIdentifier from, TableIdentifier to) { int updatedRecords = execute( err -> { - // SQLite doesn't set SQLState or throw SQLIntegrityConstraintViolationException - if (err instanceof SQLIntegrityConstraintViolationException - || (err.getMessage() != null && err.getMessage().contains("constraint failed"))) { + if (JdbcUtil.isConstraintViolation(err)) { throw new AlreadyExistsException( "Cannot rename %s to %s. View already exists", from, to); } diff --git a/core/src/main/java/org/apache/iceberg/jdbc/JdbcTableOperations.java b/core/src/main/java/org/apache/iceberg/jdbc/JdbcTableOperations.java index 619296ad3336..079faf1c5504 100644 --- a/core/src/main/java/org/apache/iceberg/jdbc/JdbcTableOperations.java +++ b/core/src/main/java/org/apache/iceberg/jdbc/JdbcTableOperations.java @@ -20,7 +20,6 @@ import java.sql.DataTruncation; import java.sql.SQLException; -import java.sql.SQLIntegrityConstraintViolationException; import java.sql.SQLNonTransientConnectionException; import java.sql.SQLTimeoutException; import java.sql.SQLTransientConnectionException; @@ -120,14 +119,6 @@ public void doCommit(TableMetadata base, TableMetadata metadata) { createTable(newMetadataLocation); } - } catch (SQLIntegrityConstraintViolationException e) { - - if (currentMetadataLocation() == null) { - throw new AlreadyExistsException(e, "Table already exists: %s", tableIdentifier); - } else { - throw new UncheckedSQLException(e, "Table already exists: %s", tableIdentifier); - } - } catch (SQLTimeoutException e) { throw new UncheckedSQLException(e, "Database Connection timeout"); } catch (SQLTransientConnectionException | SQLNonTransientConnectionException e) { @@ -137,9 +128,12 @@ public void doCommit(TableMetadata base, TableMetadata metadata) { } catch (SQLWarning e) { throw new UncheckedSQLException(e, "Database warning"); } catch (SQLException e) { - // SQLite doesn't set SQLState or throw SQLIntegrityConstraintViolationException - if (e.getMessage() != null && e.getMessage().contains("constraint failed")) { - throw new AlreadyExistsException("Table already exists: %s", tableIdentifier); + if (JdbcUtil.isConstraintViolation(e)) { + if (currentMetadataLocation() == null) { + throw new AlreadyExistsException(e, "Table already exists: %s", tableIdentifier); + } else { + throw new UncheckedSQLException(e, "Table already exists: %s", tableIdentifier); + } } throw new UncheckedSQLException(e, "Unknown failure"); diff --git a/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java b/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java index 85e59328dbce..259bd7812555 100644 --- a/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java +++ b/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java @@ -21,6 +21,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; import java.util.Collections; import java.util.Map; import java.util.Properties; @@ -45,6 +46,7 @@ final class JdbcUtil { JdbcCatalog.PROPERTY_PREFIX + "init-catalog-tables"; static final String RETRYABLE_STATUS_CODES = "retryable_status_codes"; + private static final String POSTGRES_UNIQUE_VIOLATION_SQLSTATE = "23505"; enum SchemaVersion { V0, @@ -522,6 +524,12 @@ static Properties filterAndRemovePrefix(Map properties, String p return result; } + static boolean isConstraintViolation(SQLException ex) { + return ex instanceof SQLIntegrityConstraintViolationException + || POSTGRES_UNIQUE_VIOLATION_SQLSTATE.equals(ex.getSQLState()) + || (ex.getMessage() != null && ex.getMessage().contains("constraint failed")); + } + private static int update( boolean isTable, SchemaVersion schemaVersion, @@ -606,21 +614,19 @@ private static Map tableOrView( sql.setString(1, catalogName); sql.setString(2, namespaceToString(identifier.namespace())); sql.setString(3, identifier.name()); - ResultSet rs = sql.executeQuery(); - - if (rs.next()) { - tableOrView.put(CATALOG_NAME, rs.getString(CATALOG_NAME)); - tableOrView.put(TABLE_NAMESPACE, rs.getString(TABLE_NAMESPACE)); - tableOrView.put(TABLE_NAME, rs.getString(TABLE_NAME)); - tableOrView.put( - BaseMetastoreTableOperations.METADATA_LOCATION_PROP, - rs.getString(BaseMetastoreTableOperations.METADATA_LOCATION_PROP)); - tableOrView.put( - BaseMetastoreTableOperations.PREVIOUS_METADATA_LOCATION_PROP, - rs.getString(BaseMetastoreTableOperations.PREVIOUS_METADATA_LOCATION_PROP)); + try (ResultSet rs = sql.executeQuery()) { + if (rs.next()) { + tableOrView.put(CATALOG_NAME, rs.getString(CATALOG_NAME)); + tableOrView.put(TABLE_NAMESPACE, rs.getString(TABLE_NAMESPACE)); + tableOrView.put(TABLE_NAME, rs.getString(TABLE_NAME)); + tableOrView.put( + BaseMetastoreTableOperations.METADATA_LOCATION_PROP, + rs.getString(BaseMetastoreTableOperations.METADATA_LOCATION_PROP)); + tableOrView.put( + BaseMetastoreTableOperations.PREVIOUS_METADATA_LOCATION_PROP, + rs.getString(BaseMetastoreTableOperations.PREVIOUS_METADATA_LOCATION_PROP)); + } } - - rs.close(); } return tableOrView; diff --git a/core/src/main/java/org/apache/iceberg/jdbc/JdbcViewOperations.java b/core/src/main/java/org/apache/iceberg/jdbc/JdbcViewOperations.java index 10f46941d694..646e5b860625 100644 --- a/core/src/main/java/org/apache/iceberg/jdbc/JdbcViewOperations.java +++ b/core/src/main/java/org/apache/iceberg/jdbc/JdbcViewOperations.java @@ -20,7 +20,6 @@ import java.sql.DataTruncation; import java.sql.SQLException; -import java.sql.SQLIntegrityConstraintViolationException; import java.sql.SQLNonTransientConnectionException; import java.sql.SQLTimeoutException; import java.sql.SQLTransientConnectionException; @@ -112,13 +111,6 @@ protected void doCommit(ViewMetadata base, ViewMetadata metadata) { createView(newMetadataLocation); } - } catch (SQLIntegrityConstraintViolationException e) { - if (currentMetadataLocation() == null) { - throw new AlreadyExistsException(e, "View already exists: %s", viewIdentifier); - } else { - throw new UncheckedSQLException(e, "View already exists: %s", viewIdentifier); - } - } catch (SQLTimeoutException e) { throw new UncheckedSQLException(e, "Database Connection timeout"); } catch (SQLTransientConnectionException | SQLNonTransientConnectionException e) { @@ -128,9 +120,12 @@ protected void doCommit(ViewMetadata base, ViewMetadata metadata) { } catch (SQLWarning e) { throw new UncheckedSQLException(e, "Database warning"); } catch (SQLException e) { - // SQLite doesn't set SQLState or throw SQLIntegrityConstraintViolationException - if (e.getMessage() != null && e.getMessage().contains("constraint failed")) { - throw new AlreadyExistsException("View already exists: %s", viewIdentifier); + if (JdbcUtil.isConstraintViolation(e)) { + if (currentMetadataLocation() == null) { + throw new AlreadyExistsException(e, "View already exists: %s", viewIdentifier); + } else { + throw new UncheckedSQLException(e, "View already exists: %s", viewIdentifier); + } } throw new UncheckedSQLException(e, "Unknown failure"); diff --git a/core/src/main/java/org/apache/iceberg/metrics/CacheMetricsReport.java b/core/src/main/java/org/apache/iceberg/metrics/CacheMetricsReport.java new file mode 100644 index 000000000000..7ea6961857f4 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/metrics/CacheMetricsReport.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.metrics; + +import com.github.benmanes.caffeine.cache.stats.CacheStats; +import org.immutables.value.Value; + +@Value.Immutable +public abstract class CacheMetricsReport implements MetricsReport { + public abstract long hitCount(); + + public abstract long missCount(); + + public abstract long evictionCount(); + + public static CacheMetricsReport of(CacheStats stats) { + return ImmutableCacheMetricsReport.builder() + .hitCount(stats.hitCount()) + .missCount(stats.missCount()) + .evictionCount(stats.evictionCount()) + .build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/metrics/InMemoryMetricsReporter.java b/core/src/main/java/org/apache/iceberg/metrics/InMemoryMetricsReporter.java index 79b446c0ddbf..2fa9281003e7 100644 --- a/core/src/main/java/org/apache/iceberg/metrics/InMemoryMetricsReporter.java +++ b/core/src/main/java/org/apache/iceberg/metrics/InMemoryMetricsReporter.java @@ -18,6 +18,7 @@ */ package org.apache.iceberg.metrics; +import javax.annotation.Nullable; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; public class InMemoryMetricsReporter implements MetricsReporter { @@ -35,4 +36,13 @@ public ScanReport scanReport() { "Metrics report is not a scan report"); return (ScanReport) metricsReport; } + + @Nullable + public CommitReport commitReport() { + if (metricsReport != null && metricsReport instanceof CommitReport) { + return (CommitReport) metricsReport; + } else { + return null; + } + } } diff --git a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java index 82695eaf7875..3a1e62260aae 100644 --- a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java +++ b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java @@ -23,6 +23,10 @@ import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES_DEFAULT; import static org.apache.iceberg.TableProperties.COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.time.Duration; +import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Collections; @@ -32,10 +36,14 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.function.ToIntFunction; import java.util.stream.Collectors; import org.apache.iceberg.BaseMetadataTable; @@ -45,6 +53,7 @@ import org.apache.iceberg.IncrementalAppendScan; import org.apache.iceberg.MetadataUpdate.UpgradeFormatVersion; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RetryableValidationException; import org.apache.iceberg.Scan; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; @@ -64,6 +73,8 @@ import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.exceptions.NoSuchViewException; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -75,6 +86,7 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; import org.apache.iceberg.rest.requests.UpdateTableRequest; @@ -106,8 +118,152 @@ public class CatalogHandlers { InMemoryPlanningState.getInstance(); private static final ExecutorService ASYNC_PLANNING_POOL = Executors.newSingleThreadExecutor(); + // Advanced idempotency store with TTL and in-flight coalescing. + // + // Note: This is a simple in-memory implementation meant for tests and lightweight usage. + // Production servers should provide a durable store. + private static final ConcurrentMap IDEMPOTENCY_STORE = + Maps.newConcurrentMap(); + private static volatile long idempotencyLifetimeMillis = TimeUnit.MINUTES.toMillis(30); + private CatalogHandlers() {} + @SuppressWarnings("unchecked") + static T withIdempotency(HTTPRequest httpRequest, Supplier action) { + return withIdempotencyInternal(httpRequest, action); + } + + static void withIdempotency(HTTPRequest httpRequest, Runnable action) { + withIdempotencyInternal( + httpRequest, + () -> { + action.run(); + return Boolean.TRUE; + }); + } + + @SuppressWarnings("unchecked") + private static T withIdempotencyInternal(HTTPRequest httpRequest, Supplier action) { + Optional keyHeader = + httpRequest.headers().firstEntry(RESTUtil.IDEMPOTENCY_KEY_HEADER); + if (keyHeader.isEmpty()) { + return action.get(); + } + + String key = keyHeader.get().value(); + + // The "first" request for this Idempotency-Key is the one that wins + // IDEMPOTENCY_STORE.compute(...) + // and creates (or replaces) the IN_PROGRESS entry. Only that request executes the action and + // finalizes the entry; concurrent requests for the same key wait on the latch and then replay + // the finalized result/error. + AtomicBoolean isFirst = new AtomicBoolean(false); + IdempotencyEntry entry = + IDEMPOTENCY_STORE.compute( + key, + (k, current) -> { + if (current == null || current.isExpired()) { + isFirst.set(true); + return IdempotencyEntry.inProgress(); + } + return current; + }); + + // Fast-path: already finalized (another request completed earlier) + if (entry.status == IdempotencyEntry.Status.FINALIZED) { + if (entry.error != null) { + throw entry.error; + } + return (T) entry.responseBody; + } + + if (!isFirst.get()) { + // In-flight coalescing: wait for the first request to finalize + entry.awaitFinalization(); + if (entry.error != null) { + throw entry.error; + } + return (T) entry.responseBody; + } + + // First request: execute the action and finalize the entry + try { + T res = action.get(); + entry.finalizeSuccess(res); + return res; + } catch (RuntimeException e) { + entry.finalizeError(e); + throw e; + } + } + + @VisibleForTesting + static void setIdempotencyLifetimeFromIso(String isoDuration) { + if (isoDuration == null) { + return; + } + try { + idempotencyLifetimeMillis = Duration.parse(isoDuration).toMillis(); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid idempotency lifetime: " + isoDuration, e); + } + } + + private static final class IdempotencyEntry { + enum Status { + IN_PROGRESS, + FINALIZED + } + + private final CountDownLatch latch; + private final long firstSeenMillis; + private volatile Status status; + private volatile Object responseBody; + private volatile RuntimeException error; + + private IdempotencyEntry(Status status) { + this.status = status; + this.latch = new CountDownLatch(1); + this.firstSeenMillis = System.currentTimeMillis(); + } + + static IdempotencyEntry inProgress() { + return new IdempotencyEntry(Status.IN_PROGRESS); + } + + void finalizeSuccess(Object body) { + this.responseBody = body; + this.status = Status.FINALIZED; + this.latch.countDown(); + } + + void finalizeError(RuntimeException cause) { + this.error = cause; + this.status = Status.FINALIZED; + this.latch.countDown(); + } + + void awaitFinalization() { + try { + this.latch.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Interrupted while waiting for idempotent request to complete", ie); + } + } + + boolean isExpired() { + if (this.status != Status.FINALIZED) { + return false; + } + + Instant expiry = + Instant.ofEpochMilli(this.firstSeenMillis).plusMillis(idempotencyLifetimeMillis); + return Instant.now().isAfter(expiry); + } + } + /** * Exception used to avoid retrying commits when assertions fail. * @@ -314,7 +470,8 @@ public static LoadTableResponse registerTable( request.validate(); TableIdentifier identifier = TableIdentifier.of(namespace, request.name()); - Table table = catalog.registerTable(identifier, request.metadataLocation()); + Table table = + catalog.registerTable(identifier, request.metadataLocation(), request.overwrite()); if (table instanceof BaseTable) { return LoadTableResponse.builder() .withTableMetadata(((BaseTable) table).operations().current()) @@ -479,7 +636,17 @@ static TableMetadata commit(TableOperations ops, UpdateTableRequest request) { // apply changes TableMetadata.Builder metadataBuilder = TableMetadata.buildFrom(base); - request.updates().forEach(update -> update.applyTo(metadataBuilder)); + try { + request.updates().forEach(update -> update.applyTo(metadataBuilder)); + } catch (RetryableValidationException e) { + // Validation failed because the commit includes stale values (e.g. sequence + // number or first-row-id behind the current table state). This is not a conflict. + // Server-side retry won't help since the stale values are in the request itself. + // Wrap as CommitFailedException so the client can retry with refreshed metadata. + throw new ValidationFailureException( + new CommitFailedException( + e, "Validation failed, please retry: %s", e.getMessage())); + } TableMetadata updated = metadataBuilder.build(); if (updated.changes().isEmpty()) { @@ -593,6 +760,18 @@ public static void dropView(ViewCatalog catalog, TableIdentifier viewIdentifier) } } + public static LoadViewResponse registerView( + ViewCatalog catalog, Namespace namespace, RegisterViewRequest request) { + request.validate(); + + TableIdentifier identifier = TableIdentifier.of(namespace, request.name()); + View view = catalog.registerView(identifier, request.metadataLocation()); + return ImmutableLoadViewResponse.builder() + .metadata(asBaseView(view).operations().current()) + .metadataLocation(request.metadataLocation()) + .build(); + } + static ViewMetadata commit(ViewOperations ops, UpdateTableRequest request) { AtomicBoolean isRetry = new AtomicBoolean(false); try { @@ -676,7 +855,8 @@ public static PlanTableScanResponse planTableScan( configuredScan, asyncPlanId, table.uuid().toString(), - tasksPerPlanTask.applyAsInt(configuredScan)); + tasksPerPlanTask.applyAsInt(configuredScan), + request.minRowsRequested()); return PlanTableScanResponse.builder() .withPlanId(asyncPlanId) .withPlanStatus(PlanStatus.SUBMITTED) @@ -690,7 +870,8 @@ public static PlanTableScanResponse planTableScan( configuredScan, planId, table.uuid().toString(), - tasksPerPlanTask.applyAsInt(configuredScan)); + tasksPerPlanTask.applyAsInt(configuredScan), + request.minRowsRequested()); List nextPlanTasks = initial.second() == null ? Collections.emptyList() @@ -700,11 +881,6 @@ public static PlanTableScanResponse planTableScan( .withPlanStatus(PlanStatus.COMPLETED) .withPlanId(planId) .withFileScanTasks(initial.first()) - .withDeleteFiles( - initial.first().stream() - .flatMap(task -> task.deletes().stream()) - .distinct() - .collect(Collectors.toList())) .withSpecsById(table.specs()); if (!nextPlanTasks.isEmpty()) { @@ -733,11 +909,6 @@ public static FetchPlanningResultResponse fetchPlanningResult( Pair, String> initial = IN_MEMORY_PLANNING_STATE.initialScanTasksFor(planId); return FetchPlanningResultResponse.builder() .withPlanStatus(PlanStatus.COMPLETED) - .withDeleteFiles( - initial.first().stream() - .flatMap(task -> task.deletes().stream()) - .distinct() - .collect(Collectors.toList())) .withFileScanTasks(initial.first()) .withPlanTasks(IN_MEMORY_PLANNING_STATE.nextPlanTask(initial.second())) .withSpecsById(table.specs()) @@ -762,11 +933,6 @@ public static FetchScanTasksResponse fetchScanTasks( .withFileScanTasks(fileScanTasks) .withPlanTasks(IN_MEMORY_PLANNING_STATE.nextPlanTask(planTask)) .withSpecsById(table.specs()) - .withDeleteFiles( - fileScanTasks.stream() - .flatMap(task -> task.deletes().stream()) - .distinct() - .collect(Collectors.toList())) .build(); } @@ -804,61 +970,82 @@ static void clearPlanningState() { if (request.statsFields() != null) { configuredScan = configuredScan.includeColumnStats(request.statsFields()); } + if (request.minRowsRequested() != null) { + configuredScan = configuredScan.minRowsRequested(request.minRowsRequested()); + } configuredScan = configuredScan.caseSensitive(request.caseSensitive()); return configuredScan; } /** - * Plans file scan tasks for a table scan, grouping them into plan tasks for pagination. + * Plans file scan tasks for a table scan, grouping them into plan tasks for pagination. Note that + * minRowsRequested is used as a hint to the server to not have to return more rows than + * necessary. It is not required for the server to return that many rows since the scan may not + * produce that many rows. The server can also return more rows than requested. * * @param scan the table scan to plan files for * @param planId the unique identifier for this plan * @param tableId the uuid of the table being scanned * @param tasksPerPlanTask number of file scan tasks to group per plan task + * @param minRowsRequested number of rows requested for the scan * @return the initial file scan tasks and the first plan task key */ private static Pair, String> planFilesFor( - Scan scan, String planId, String tableId, int tasksPerPlanTask) { - Iterable planTasks = scan.planFiles(); - String planTaskPrefix = planId + "-" + tableId + "-"; - - // Handle empty table scans - if (!planTasks.iterator().hasNext()) { - String planTaskKey = planTaskPrefix + "0"; - // Add empty scan to planning state so async calls know the scan completed - IN_MEMORY_PLANNING_STATE.addPlanTask(planTaskKey, Collections.emptyList()); - return Pair.of(Collections.emptyList(), planTaskKey); - } - - Iterable> taskGroupings = Iterables.partition(planTasks, tasksPerPlanTask); - int planTaskSequence = 0; - String previousPlanTask = null; - String firstPlanTaskKey = null; - List initialFileScanTasks = null; - for (List taskGrouping : taskGroupings) { - String planTaskKey = planTaskPrefix + planTaskSequence++; - IN_MEMORY_PLANNING_STATE.addPlanTask(planTaskKey, taskGrouping); - if (previousPlanTask != null) { - IN_MEMORY_PLANNING_STATE.addNextPlanTask(previousPlanTask, planTaskKey); - } else { - firstPlanTaskKey = planTaskKey; - initialFileScanTasks = taskGrouping; + Scan scan, + String planId, + String tableId, + int tasksPerPlanTask, + Long minRowsRequested) { + try (CloseableIterable planTasks = scan.planFiles()) { + String planTaskPrefix = planId + "-" + tableId + "-"; + + // Handle empty table scans + if (!planTasks.iterator().hasNext()) { + String planTaskKey = planTaskPrefix + "0"; + // Add empty scan to planning state so async calls know the scan completed + IN_MEMORY_PLANNING_STATE.addPlanTask(planTaskKey, Collections.emptyList()); + return Pair.of(Collections.emptyList(), planTaskKey); } - previousPlanTask = planTaskKey; + Iterable limitedTasks = + null != minRowsRequested + ? Iterables.limit(planTasks, (int) Math.min(minRowsRequested, Integer.MAX_VALUE)) + : planTasks; + Iterable> taskGroupings = + Iterables.partition(limitedTasks, tasksPerPlanTask); + int planTaskSequence = 0; + String previousPlanTask = null; + String firstPlanTaskKey = null; + List initialFileScanTasks = null; + for (List taskGrouping : taskGroupings) { + String planTaskKey = planTaskPrefix + planTaskSequence++; + IN_MEMORY_PLANNING_STATE.addPlanTask(planTaskKey, taskGrouping); + if (previousPlanTask != null) { + IN_MEMORY_PLANNING_STATE.addNextPlanTask(previousPlanTask, planTaskKey); + } else { + firstPlanTaskKey = planTaskKey; + initialFileScanTasks = taskGrouping; + } + + previousPlanTask = planTaskKey; + } + return Pair.of(initialFileScanTasks, firstPlanTaskKey); + } catch (IOException e) { + throw new UncheckedIOException(e); } - return Pair.of(initialFileScanTasks, firstPlanTaskKey); } @SuppressWarnings("FutureReturnValueIgnored") private static void asyncPlanFiles( - Scan scan, String asyncPlanId, String tableId, int tasksPerPlanTask) { + Scan scan, + String asyncPlanId, + String tableId, + int tasksPerPlanTask, + Long minRowsRequested) { IN_MEMORY_PLANNING_STATE.addAsyncPlan(asyncPlanId); CompletableFuture.runAsync( - () -> { - planFilesFor(scan, asyncPlanId, tableId, tasksPerPlanTask); - }, + () -> planFilesFor(scan, asyncPlanId, tableId, tasksPerPlanTask, minRowsRequested), ASYNC_PLANNING_POOL) .whenComplete( (result, exception) -> { diff --git a/core/src/main/java/org/apache/iceberg/rest/ETagProvider.java b/core/src/main/java/org/apache/iceberg/rest/ETagProvider.java index 82fdc5fcaef5..bb8e32b0a814 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ETagProvider.java +++ b/core/src/main/java/org/apache/iceberg/rest/ETagProvider.java @@ -19,6 +19,9 @@ package org.apache.iceberg.rest; import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.TreeMap; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.hash.HashFunction; import org.apache.iceberg.relocated.com.google.common.hash.Hashing; @@ -26,12 +29,22 @@ class ETagProvider { private static final HashFunction MURMUR3 = Hashing.murmur3_32_fixed(); + private static final Joiner.MapJoiner PARAMS_JOINER = Joiner.on(",").withKeyValueSeparator("="); + private static final Joiner COMMA = Joiner.on(','); + private ETagProvider() {} - public static String of(String metadataLocation) { + public static String of(String metadataLocation, Map params) { Preconditions.checkArgument(null != metadataLocation, "Invalid metadata location: null"); Preconditions.checkArgument(!metadataLocation.isEmpty(), "Invalid metadata location: empty"); - return MURMUR3.hashString(metadataLocation, StandardCharsets.UTF_8).toString(); + String stringToHash = metadataLocation; + if (params != null && !params.isEmpty()) { + Map orderedParams = new TreeMap<>(params); + + stringToHash = COMMA.join(metadataLocation, PARAMS_JOINER.join(orderedParams)); + } + + return MURMUR3.hashString(stringToHash, StandardCharsets.UTF_8).toString(); } } diff --git a/core/src/main/java/org/apache/iceberg/rest/Endpoint.java b/core/src/main/java/org/apache/iceberg/rest/Endpoint.java index b4b617b8ec5e..d56a14d18954 100644 --- a/core/src/main/java/org/apache/iceberg/rest/Endpoint.java +++ b/core/src/main/java/org/apache/iceberg/rest/Endpoint.java @@ -66,6 +66,8 @@ public class Endpoint { Endpoint.create("POST", ResourcePaths.V1_TABLE_METRICS); public static final Endpoint V1_TABLE_CREDENTIALS = Endpoint.create("GET", ResourcePaths.V1_TABLE_CREDENTIALS); + public static final Endpoint V1_TABLE_REMOTE_SIGN = + Endpoint.create("POST", ResourcePaths.V1_TABLE_REMOTE_SIGN); // table scan plan endpoints public static final Endpoint V1_SUBMIT_TABLE_SCAN_PLAN = @@ -86,6 +88,8 @@ public class Endpoint { public static final Endpoint V1_DELETE_VIEW = Endpoint.create("DELETE", ResourcePaths.V1_VIEW); public static final Endpoint V1_RENAME_VIEW = Endpoint.create("POST", ResourcePaths.V1_VIEW_RENAME); + public static final Endpoint V1_REGISTER_VIEW = + Endpoint.create("POST", ResourcePaths.V1_VIEW_REGISTER); private static final Splitter ENDPOINT_SPLITTER = Splitter.on(" "); private static final Joiner ENDPOINT_JOINER = Joiner.on(" "); diff --git a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java index 0c21fed4de54..334bfde8abfc 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java +++ b/core/src/main/java/org/apache/iceberg/rest/ErrorHandlers.java @@ -26,9 +26,13 @@ import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchPlanIdException; +import org.apache.iceberg.exceptions.NoSuchPlanTaskException; import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.exceptions.NoSuchViewException; +import org.apache.iceberg.exceptions.NoSuchWarehouseException; import org.apache.iceberg.exceptions.NotAuthorizedException; +import org.apache.iceberg.exceptions.NotFoundException; import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.exceptions.ServiceFailureException; import org.apache.iceberg.exceptions.ServiceUnavailableException; @@ -73,14 +77,44 @@ public static Consumer tableCommitHandler() { return CommitErrorHandler.INSTANCE; } + public static Consumer createTableErrorHandler() { + return CreateTableErrorHandler.INSTANCE; + } + + public static Consumer planErrorHandler() { + return PlanErrorHandler.INSTANCE; + } + + public static Consumer planTaskHandler() { + return PlanTaskErrorHandler.INSTANCE; + } + public static Consumer defaultErrorHandler() { return DefaultErrorHandler.INSTANCE; } + public static Consumer configErrorHandler() { + return ConfigErrorHandler.INSTANCE; + } + public static Consumer oauthErrorHandler() { return OAuthErrorHandler.INSTANCE; } + /** + * Creates a RESTException from an ErrorResponse with a standardized message format. + * + *

The exception message includes the error code, type, and message in a consistent format: + * "Unable to process (code: <code>, type: <type>): <message>" + * + * @param error the error response + * @return a RESTException with formatted message including code, type, and message + */ + private static RESTException createRESTException(ErrorResponse error) { + return new RESTException( + "Unable to process (code: %s, type: %s): %s", error.code(), error.type(), error.message()); + } + /** Table commit error handler. */ private static class CommitErrorHandler extends DefaultErrorHandler { private static final ErrorHandler INSTANCE = new CommitErrorHandler(); @@ -114,6 +148,8 @@ public void accept(ErrorResponse error) { case 404: if (NoSuchNamespaceException.class.getSimpleName().equals(error.type())) { throw new NoSuchNamespaceException("%s", error.message()); + } else if (NotFoundException.class.getSimpleName().equals(error.type())) { + throw new NotFoundException("%s", error.message()); } else { throw new NoSuchTableException("%s", error.message()); } @@ -125,6 +161,63 @@ public void accept(ErrorResponse error) { } } + /** Table create error handler. */ + private static class CreateTableErrorHandler extends CommitErrorHandler { + private static final ErrorHandler INSTANCE = new CreateTableErrorHandler(); + + @Override + public void accept(ErrorResponse error) { + switch (error.code()) { + case 404: + throw new NoSuchNamespaceException("%s", error.message()); + case 409: + throw new AlreadyExistsException("%s", error.message()); + } + + super.accept(error); + } + } + + /** Plan level error handler. */ + private static class PlanErrorHandler extends DefaultErrorHandler { + private static final ErrorHandler INSTANCE = new PlanErrorHandler(); + + @Override + public void accept(ErrorResponse error) { + if (error.code() == 404) { + if (NoSuchNamespaceException.class.getSimpleName().equals(error.type())) { + throw new NoSuchNamespaceException("%s", error.message()); + } else if (NoSuchTableException.class.getSimpleName().equals(error.type())) { + throw new NoSuchTableException("%s", error.message()); + } else { + throw new NoSuchPlanIdException("%s", error.message()); + } + } + + super.accept(error); + } + } + + /** PlanTask level error handler. */ + private static class PlanTaskErrorHandler extends DefaultErrorHandler { + private static final ErrorHandler INSTANCE = new PlanTaskErrorHandler(); + + @Override + public void accept(ErrorResponse error) { + if (error.code() == 404) { + if (NoSuchNamespaceException.class.getSimpleName().equals(error.type())) { + throw new NoSuchNamespaceException("%s", error.message()); + } else if (NoSuchTableException.class.getSimpleName().equals(error.type())) { + throw new NoSuchTableException("%s", error.message()); + } else { + throw new NoSuchPlanTaskException("%s", error.message()); + } + } + + super.accept(error); + } + } + /** View commit error handler. */ private static class ViewCommitErrorHandler extends DefaultErrorHandler { private static final ErrorHandler INSTANCE = new ViewCommitErrorHandler(); @@ -186,7 +279,7 @@ public void accept(ErrorResponse error) { case 409: throw new AlreadyExistsException("%s", error.message()); case 422: - throw new RESTException("Unable to process: %s", error.message()); + throw createRESTException(error); } super.accept(error); @@ -207,6 +300,20 @@ public void accept(ErrorResponse error) { } } + /** Request error handler for config endpoint. */ + private static class ConfigErrorHandler extends DefaultErrorHandler { + private static final ErrorHandler INSTANCE = new ConfigErrorHandler(); + + @Override + public void accept(ErrorResponse error) { + if (error.code() == 404 && error.type() != null) { + throw new NoSuchWarehouseException("%s", error.message()); + } + + super.accept(error); + } + } + /** * Request error handler that handles the common cases that are included with all responses, such * as 400, 500, etc. @@ -247,7 +354,7 @@ public void accept(ErrorResponse error) { throw new ServiceUnavailableException("Service unavailable: %s", error.message()); } - throw new RESTException("Unable to process: %s", error.message()); + throw createRESTException(error); } } @@ -280,7 +387,7 @@ public void accept(ErrorResponse error) { "Malformed request: %s: %s", error.type(), error.message()); } } - throw new RESTException("Unable to process: %s", error.message()); + throw createRESTException(error); } } } diff --git a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java index b30caf1c7db0..46d9177b9571 100644 --- a/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java +++ b/core/src/main/java/org/apache/iceberg/rest/HTTPClient.java @@ -18,7 +18,6 @@ */ package org.apache.iceberg.rest; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import java.io.IOException; @@ -29,6 +28,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import javax.net.ssl.HostnameVerifier; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; @@ -43,6 +43,7 @@ import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpHeaders; @@ -299,6 +300,7 @@ protected T execute( req, responseType, errorHandler, responseHeaders, ParserContext.builder().build()); } + @SuppressWarnings("deprecation") @Override protected T execute( HTTPRequest req, @@ -326,35 +328,36 @@ protected T execute( // Skip parsing the response stream for any successful request not expecting a response body if (emptyBody(response, responseType)) { + if (response.getCode() == HttpStatus.SC_NOT_MODIFIED + && !req.headers().contains(HttpHeaders.IF_NONE_MATCH)) { + // 304-NOT_MODIFIED is used for freshness-aware loading and requires an ETag sent to the + // server via IF_NONE_MATCH header in the request. If no ETag was sent, we shouldn't + // receive a 304. + throw new RESTException( + "Invalid (NOT_MODIFIED) response for request: method=%s, path=%s", + req.method(), req.path()); + } + return null; } - String responseBody = extractResponseBodyAsString(response); - if (!isSuccessful(response)) { // The provided error handler is expected to throw, but a RESTException is thrown if not. + String responseBody = extractResponseBodyAsString(response); throwFailure(response, responseBody, errorHandler); } - if (responseBody == null) { + if (response.getEntity() == null) { throw new RESTException( "Invalid (null) response body for request (expected %s): method=%s, path=%s, status=%d", responseType.getSimpleName(), req.method(), req.path(), response.getCode()); } - try { - ObjectReader reader = objectReaderCache.computeIfAbsent(responseType, mapper::readerFor); - if (parserContext != null && !parserContext.isEmpty()) { - reader = reader.with(parserContext.toInjectableValues()); - } - return reader.readValue(responseBody); - } catch (JsonProcessingException e) { - throw new RESTException( - e, - "Received a success response code of %d, but failed to parse response body into %s", - response.getCode(), - responseType.getSimpleName()); + ObjectReader reader = objectReaderCache.computeIfAbsent(responseType, mapper::readerFor); + if (parserContext != null && !parserContext.isEmpty()) { + reader = reader.with(parserContext.toInjectableValues()); } + return reader.readValue(response.getEntity().getContent()); } catch (IOException e) { throw new RESTException(e, "Error occurred while processing %s request", req.method()); } @@ -399,13 +402,19 @@ static HttpClientConnectionManager configureConnectionManager(Map props) { sessionCatalog.initialize(name, props); } + protected RESTSessionCatalog sessionCatalog() { + return sessionCatalog; + } + @Override public String name() { return sessionCatalog.name(); @@ -236,6 +240,12 @@ public Table registerTable(TableIdentifier ident, String metadataFileLocation) { return delegate.registerTable(ident, metadataFileLocation); } + @Override + public Table registerTable( + TableIdentifier ident, String metadataFileLocation, boolean overwrite) { + return delegate.registerTable(ident, metadataFileLocation, overwrite); + } + @Override public void createNamespace(Namespace ns, Map props) { nsDelegate.createNamespace(ns, props); @@ -325,4 +335,9 @@ public boolean viewExists(TableIdentifier identifier) { public void invalidateView(TableIdentifier identifier) { viewSessionCatalog.invalidateView(identifier); } + + @Override + public View registerView(TableIdentifier identifier, String metadataFileLocation) { + return viewSessionCatalog.registerView(identifier, metadataFileLocation); + } } diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java b/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java index 264c31436088..a5aa10bafc61 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTCatalogProperties.java @@ -18,12 +18,19 @@ */ package org.apache.iceberg.rest; +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.apache.iceberg.CatalogProperties; + public final class RESTCatalogProperties { private RESTCatalogProperties() {} public static final String SNAPSHOT_LOADING_MODE = "snapshot-loading-mode"; - public static final String SNAPSHOT_LOADING_MODE_DEFAULT = SnapshotMode.ALL.name(); + public static final SnapshotMode SNAPSHOT_LOADING_MODE_DEFAULT = SnapshotMode.ALL; + public static final String SNAPSHOTS_QUERY_PARAMETER = "snapshots"; public static final String METRICS_REPORTING_ENABLED = "rest-metrics-reporting-enabled"; public static final boolean METRICS_REPORTING_ENABLED_DEFAULT = true; @@ -35,8 +42,70 @@ private RESTCatalogProperties() {} public static final String PAGE_SIZE = "rest-page-size"; + public static final String NAMESPACE_SEPARATOR = "namespace-separator"; + public static final String NAMESPACE_SEPARATOR_DEFAULT = + RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8; + + // Configure scan planning mode + // Can be set by server in LoadTableResponse.config() for table-level override + public static final String SCAN_PLANNING_MODE = "scan-planning-mode"; + public static final ScanPlanningMode SCAN_PLANNING_MODE_DEFAULT = ScanPlanningMode.CLIENT; + + public static final String REST_SCAN_PLAN_ID = "rest-scan-plan-id"; + + public static final String REST_SCAN_PLANNING_POLL_TIMEOUT_MS = + "rest-scan-planning.poll-timeout-ms"; + public static final long REST_SCAN_PLANNING_POLL_TIMEOUT_MS_DEFAULT = + TimeUnit.MINUTES.toMillis(5); + + // Properties that control the behaviour of the table cache used for freshness-aware table + // loading. + public static final String TABLE_CACHE_EXPIRE_AFTER_WRITE_MS = + "rest-table-cache.expire-after-write-ms"; + public static final long TABLE_CACHE_EXPIRE_AFTER_WRITE_MS_DEFAULT = TimeUnit.MINUTES.toMillis(5); + + public static final String TABLE_CACHE_MAX_ENTRIES = "rest-table-cache.max-entries"; + public static final int TABLE_CACHE_MAX_ENTRIES_DEFAULT = 100; + public enum SnapshotMode { ALL, REFS } + + public enum ScanPlanningMode { + CLIENT, + SERVER; + + public String modeName() { + return name().toLowerCase(Locale.ROOT); + } + + public static ScanPlanningMode fromString(String mode) { + for (ScanPlanningMode planningMode : values()) { + if (planningMode.modeName().equalsIgnoreCase(mode)) { + return planningMode; + } + } + + throw new IllegalArgumentException( + String.format( + "Invalid scan planning mode: %s. Valid values are: %s", + mode, + Arrays.stream(values()) + .map(ScanPlanningMode::modeName) + .collect(Collectors.joining(", ")))); + } + } + + /** + * The base URI of the remote signer endpoint. Optional, defaults to {@link + * CatalogProperties#URI}. + */ + public static final String SIGNER_URI = "signer.uri"; + + /** + * The endpoint path of the remote signer endpoint. If remote signing has been requested, this + * must be set. + */ + public static final String SIGNER_ENDPOINT = "signer.endpoint"; } diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java index 3e0e1750115f..366bbfa9171b 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java @@ -52,11 +52,17 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequestParser; import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest; import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest; +import org.apache.iceberg.rest.requests.ImmutableRegisterViewRequest; +import org.apache.iceberg.rest.requests.ImmutableRemoteSignRequest; import org.apache.iceberg.rest.requests.ImmutableReportMetricsRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequestParser; import org.apache.iceberg.rest.requests.RegisterTableRequest; import org.apache.iceberg.rest.requests.RegisterTableRequestParser; +import org.apache.iceberg.rest.requests.RegisterViewRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequestParser; +import org.apache.iceberg.rest.requests.RemoteSignRequest; +import org.apache.iceberg.rest.requests.RemoteSignRequestParser; import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.ReportMetricsRequestParser; import org.apache.iceberg.rest.requests.UpdateTableRequest; @@ -71,6 +77,7 @@ import org.apache.iceberg.rest.responses.FetchScanTasksResponseParser; import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse; import org.apache.iceberg.rest.responses.ImmutableLoadViewResponse; +import org.apache.iceberg.rest.responses.ImmutableRemoteSignResponse; import org.apache.iceberg.rest.responses.LoadCredentialsResponse; import org.apache.iceberg.rest.responses.LoadCredentialsResponseParser; import org.apache.iceberg.rest.responses.LoadTableResponse; @@ -80,6 +87,8 @@ import org.apache.iceberg.rest.responses.OAuthTokenResponse; import org.apache.iceberg.rest.responses.PlanTableScanResponse; import org.apache.iceberg.rest.responses.PlanTableScanResponseParser; +import org.apache.iceberg.rest.responses.RemoteSignResponse; +import org.apache.iceberg.rest.responses.RemoteSignResponseParser; import org.apache.iceberg.util.JsonUtil; public class RESTSerializers { @@ -131,6 +140,11 @@ public static void registerAll(ObjectMapper mapper) { .addSerializer(ImmutableLoadViewResponse.class, new LoadViewResponseSerializer<>()) .addDeserializer(LoadViewResponse.class, new LoadViewResponseDeserializer<>()) .addDeserializer(ImmutableLoadViewResponse.class, new LoadViewResponseDeserializer<>()) + .addSerializer(RegisterViewRequest.class, new RegisterViewRequestSerializer<>()) + .addDeserializer(RegisterViewRequest.class, new RegisterViewRequestDeserializer<>()) + .addSerializer(ImmutableRegisterViewRequest.class, new RegisterViewRequestSerializer<>()) + .addDeserializer( + ImmutableRegisterViewRequest.class, new RegisterViewRequestDeserializer<>()) .addSerializer(ConfigResponse.class, new ConfigResponseSerializer<>()) .addDeserializer(ConfigResponse.class, new ConfigResponseDeserializer<>()) .addSerializer(LoadTableResponse.class, new LoadTableResponseSerializer<>()) @@ -152,7 +166,15 @@ public static void registerAll(ObjectMapper mapper) { ImmutableLoadCredentialsResponse.class, new LoadCredentialsResponseSerializer<>()) .addDeserializer(LoadCredentialsResponse.class, new LoadCredentialsResponseDeserializer<>()) .addDeserializer( - ImmutableLoadCredentialsResponse.class, new LoadCredentialsResponseDeserializer<>()); + ImmutableLoadCredentialsResponse.class, new LoadCredentialsResponseDeserializer<>()) + .addSerializer(RemoteSignRequest.class, new RemoteSignRequestSerializer<>()) + .addSerializer(ImmutableRemoteSignRequest.class, new RemoteSignRequestSerializer<>()) + .addDeserializer(RemoteSignRequest.class, new RemoteSignRequestDeserializer<>()) + .addDeserializer(ImmutableRemoteSignRequest.class, new RemoteSignRequestDeserializer<>()) + .addSerializer(RemoteSignResponse.class, new RemoteSignResponseSerializer<>()) + .addSerializer(ImmutableRemoteSignResponse.class, new RemoteSignResponseSerializer<>()) + .addDeserializer(RemoteSignResponse.class, new RemoteSignResponseDeserializer<>()) + .addDeserializer(ImmutableRemoteSignResponse.class, new RemoteSignResponseDeserializer<>()); mapper.registerModule(module); } @@ -444,6 +466,24 @@ public T deserialize(JsonParser p, DeserializationContext context) throws IOExce } } + static class RegisterViewRequestSerializer + extends JsonSerializer { + @Override + public void serialize(T request, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + RegisterViewRequestParser.toJson(request, gen); + } + } + + static class RegisterViewRequestDeserializer + extends JsonDeserializer { + @Override + public T deserialize(JsonParser p, DeserializationContext context) throws IOException { + JsonNode jsonNode = p.getCodec().readTree(p); + return (T) RegisterViewRequestParser.fromJson(jsonNode); + } + } + static class ConfigResponseSerializer extends JsonSerializer { @Override public void serialize(T request, JsonGenerator gen, SerializerProvider serializers) @@ -597,6 +637,7 @@ public T deserialize(JsonParser p, DeserializationContext context) throws IOExce } } + @SuppressWarnings("deprecation") private static TableScanResponseContext parseScanResponseContext(DeserializationContext context) throws IOException { @SuppressWarnings("unchecked") @@ -623,4 +664,39 @@ boolean isCaseSensitive() { return caseSensitive; } } + + static class RemoteSignRequestSerializer extends JsonSerializer { + @Override + public void serialize(T request, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + RemoteSignRequestParser.toJson(request, gen); + } + } + + static class RemoteSignRequestDeserializer + extends JsonDeserializer { + @Override + public T deserialize(JsonParser p, DeserializationContext context) throws IOException { + JsonNode jsonNode = p.getCodec().readTree(p); + return (T) RemoteSignRequestParser.fromJson(jsonNode); + } + } + + static class RemoteSignResponseSerializer + extends JsonSerializer { + @Override + public void serialize(T response, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + RemoteSignResponseParser.toJson(response, gen); + } + } + + static class RemoteSignResponseDeserializer + extends JsonDeserializer { + @Override + public T deserialize(JsonParser p, DeserializationContext context) throws IOException { + JsonNode jsonNode = p.getCodec().readTree(p); + return (T) RemoteSignResponseParser.fromJson(jsonNode); + } + } } diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java index fe0d13217b62..9effb875e05f 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java @@ -26,9 +26,11 @@ import java.util.Map; import java.util.Set; import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; +import org.apache.hc.core5.http.HttpHeaders; import org.apache.iceberg.BaseTable; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.CatalogUtil; @@ -41,6 +43,7 @@ import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; import org.apache.iceberg.Transaction; import org.apache.iceberg.Transactions; import org.apache.iceberg.catalog.BaseViewSessionCatalog; @@ -57,15 +60,19 @@ import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.FileIOTracker; import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.metrics.MetricsReporter; import org.apache.iceberg.metrics.MetricsReporters; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.rest.RESTCatalogProperties.SnapshotMode; +import org.apache.iceberg.rest.RESTTableCache.TableWithETag; import org.apache.iceberg.rest.auth.AuthManager; import org.apache.iceberg.rest.auth.AuthManagers; import org.apache.iceberg.rest.auth.AuthSession; @@ -76,7 +83,9 @@ import org.apache.iceberg.rest.requests.CreateViewRequest; import org.apache.iceberg.rest.requests.ImmutableCreateViewRequest; import org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest; +import org.apache.iceberg.rest.requests.ImmutableRegisterViewRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; import org.apache.iceberg.rest.requests.UpdateTableRequest; @@ -108,7 +117,7 @@ public class RESTSessionCatalog extends BaseViewSessionCatalog private static final String DEFAULT_FILE_IO_IMPL = "org.apache.iceberg.io.ResolvingFileIO"; /** - * @deprecated will be removed in 2.0.0. Use {@link + * @deprecated will be removed in 1.12.0. Use {@link * org.apache.iceberg.rest.RESTCatalogProperties#PAGE_SIZE} instead. */ @Deprecated public static final String REST_PAGE_SIZE = "rest-page-size"; @@ -161,6 +170,9 @@ public class RESTSessionCatalog extends BaseViewSessionCatalog private CloseableGroup closeables = null; private Set endpoints; private Supplier> mutationHeaders = Map::of; + private String namespaceSeparator = null; + + private RESTTableCache tableCache; public RESTSessionCatalog() { this( @@ -253,19 +265,38 @@ public void initialize(String name, Map unresolved) { PropertyUtil.propertyAsString( mergedProps, RESTCatalogProperties.SNAPSHOT_LOADING_MODE, - RESTCatalogProperties.SNAPSHOT_LOADING_MODE_DEFAULT) + RESTCatalogProperties.SNAPSHOT_LOADING_MODE_DEFAULT.name()) .toUpperCase(Locale.US)); this.reporter = CatalogUtil.loadMetricsReporter(mergedProps); + this.closeables.addCloseable(reporter); this.reportingViaRestEnabled = PropertyUtil.propertyAsBoolean( mergedProps, RESTCatalogProperties.METRICS_REPORTING_ENABLED, RESTCatalogProperties.METRICS_REPORTING_ENABLED_DEFAULT); + this.namespaceSeparator = + PropertyUtil.propertyAsString( + mergedProps, + RESTCatalogProperties.NAMESPACE_SEPARATOR, + RESTCatalogProperties.NAMESPACE_SEPARATOR_DEFAULT); + + this.tableCache = createTableCache(mergedProps); + this.closeables.addCloseable(this.tableCache); + super.initialize(name, mergedProps); } + protected RESTTableCache createTableCache(Map props) { + return new RESTTableCache(props); + } + + @VisibleForTesting + RESTTableCache tableCache() { + return tableCache; + } + @Override public void setConf(Object newConf) { this.conf = newConf; @@ -318,6 +349,8 @@ public boolean dropTable(SessionContext context, TableIdentifier identifier) { return true; } catch (NoSuchTableException e) { return false; + } finally { + invalidateTable(context, identifier); } } @@ -339,6 +372,8 @@ public boolean purgeTable(SessionContext context, TableIdentifier identifier) { return true; } catch (NoSuchTableException e) { return false; + } finally { + invalidateTable(context, identifier); } } @@ -356,6 +391,8 @@ public void renameTable(SessionContext context, TableIdentifier from, TableIdent client .withAuthSession(contextualSession) .post(paths.rename(), request, null, mutationHeaders, ErrorHandlers.tableErrorHandler()); + + invalidateTable(context, from); } @Override @@ -370,19 +407,30 @@ public boolean tableExists(SessionContext context, TableIdentifier identifier) { return true; } else { // fallback in order to work with 1.7.x and older servers - return super.tableExists(context, identifier); + if (!super.tableExists(context, identifier)) { + invalidateTable(context, identifier); + return false; + } + + return true; } } catch (NoSuchTableException e) { + invalidateTable(context, identifier); return false; } } private static Map snapshotModeToParam(SnapshotMode mode) { - return ImmutableMap.of("snapshots", mode.name().toLowerCase(Locale.US)); + return ImmutableMap.of( + RESTCatalogProperties.SNAPSHOTS_QUERY_PARAMETER, mode.name().toLowerCase(Locale.US)); } private LoadTableResponse loadInternal( - SessionContext context, TableIdentifier identifier, SnapshotMode mode) { + SessionContext context, + TableIdentifier identifier, + SnapshotMode mode, + Map headers, + Consumer> responseHeaders) { Endpoint.check(endpoints, Endpoint.V1_LOAD_TABLE); AuthSession contextualSession = authManager.contextualSession(context, catalogAuth); return client @@ -391,8 +439,9 @@ private LoadTableResponse loadInternal( paths.table(identifier), snapshotModeToParam(mode), LoadTableResponse.class, - Map.of(), - ErrorHandlers.tableErrorHandler()); + headers, + ErrorHandlers.tableErrorHandler(), + responseHeaders); } @Override @@ -410,8 +459,25 @@ public Table loadTable(SessionContext context, TableIdentifier identifier) { MetadataTableType metadataType; LoadTableResponse response; TableIdentifier loadedIdent; + + Map responseHeaders = Maps.newHashMap(); + TableWithETag cachedTable = tableCache.getIfPresent(context.sessionId(), identifier); + try { - response = loadInternal(context, identifier, snapshotMode); + response = + loadInternal( + context, + identifier, + snapshotMode, + headersForLoadTable(cachedTable), + responseHeaders::putAll); + + if (response == null) { + Preconditions.checkNotNull(cachedTable, "Invalid load table response: null"); + + return cachedTable.supplier().get(); + } + loadedIdent = identifier; metadataType = null; @@ -421,14 +487,33 @@ public Table loadTable(SessionContext context, TableIdentifier identifier) { // attempt to load a metadata table using the identifier's namespace as the base table TableIdentifier baseIdent = TableIdentifier.of(identifier.namespace().levels()); try { - response = loadInternal(context, baseIdent, snapshotMode); + responseHeaders.clear(); + cachedTable = tableCache.getIfPresent(context.sessionId(), baseIdent); + + response = + loadInternal( + context, + baseIdent, + snapshotMode, + headersForLoadTable(cachedTable), + responseHeaders::putAll); + + if (response == null) { + Preconditions.checkNotNull(cachedTable, "Invalid load table response: null"); + + return MetadataTableUtils.createMetadataTableInstance( + cachedTable.supplier().get(), metadataType); + } + loadedIdent = baseIdent; } catch (NoSuchTableException ignored) { // the base table does not exist + invalidateTable(context, baseIdent); throw original; } } else { // name is not a metadata table + invalidateTable(context, identifier); throw original; } } @@ -447,7 +532,7 @@ public Table loadTable(SessionContext context, TableIdentifier identifier) { .setPreviousFileLocation(null) .setSnapshotsSupplier( () -> - loadInternal(context, finalIdentifier, SnapshotMode.ALL) + loadInternal(context, finalIdentifier, SnapshotMode.ALL, Map.of(), h -> {}) .tableMetadata() .snapshots()) .discardChanges() @@ -456,29 +541,105 @@ public Table loadTable(SessionContext context, TableIdentifier identifier) { tableMetadata = response.tableMetadata(); } + List credentials = response.credentials(); RESTClient tableClient = client.withAuthSession(tableSession); - RESTTableOperations ops = - newTableOps( - tableClient, - paths.table(finalIdentifier), - Map::of, - mutationHeaders, - tableFileIO(context, tableConf, response.credentials()), - tableMetadata, - endpoints); + Supplier tableSupplier = + createTableSupplier( + finalIdentifier, tableMetadata, context, tableClient, tableConf, credentials); - trackFileIO(ops); + String eTag = responseHeaders.getOrDefault(HttpHeaders.ETAG, null); + if (eTag != null) { + tableCache.put(context.sessionId(), finalIdentifier, tableSupplier, eTag); + } - BaseTable table = - new BaseTable( - ops, - fullTableName(finalIdentifier), - metricsReporter(paths.metrics(finalIdentifier), tableClient)); if (metadataType != null) { - return MetadataTableUtils.createMetadataTableInstance(table, metadataType); + return MetadataTableUtils.createMetadataTableInstance(tableSupplier.get(), metadataType); } - return table; + return tableSupplier.get(); + } + + private Supplier createTableSupplier( + TableIdentifier identifier, + TableMetadata tableMetadata, + SessionContext context, + RESTClient tableClient, + Map tableConf, + List credentials) { + return () -> { + RESTTableOperations ops = + newTableOps( + tableClient, + paths.table(identifier), + Map::of, + mutationHeaders, + tableFileIO(context, tableConf, credentials), + tableMetadata, + endpoints); + + trackFileIO(ops); + + RESTTable table = restTableForScanPlanning(ops, identifier, tableClient, tableConf); + if (table != null) { + return table; + } + + return new BaseTable( + ops, fullTableName(identifier), metricsReporter(paths.metrics(identifier), tableClient)); + }; + } + + private RESTTable restTableForScanPlanning( + TableOperations ops, + TableIdentifier finalIdentifier, + RESTClient restClient, + Map tableConf) { + // Get client-side and server-side scan planning modes + String planningModeClientConfig = properties().get(RESTCatalogProperties.SCAN_PLANNING_MODE); + String planningModeServerConfig = tableConf.get(RESTCatalogProperties.SCAN_PLANNING_MODE); + + // Warn if client and server configs conflict; server config takes precedence + if (planningModeClientConfig != null + && planningModeServerConfig != null + && !planningModeClientConfig.equalsIgnoreCase(planningModeServerConfig)) { + LOG.warn( + "Scan planning mode mismatch for table {}: client config={}, server config={}. " + + "Server config will take precedence.", + finalIdentifier, + planningModeClientConfig, + planningModeServerConfig); + } + + // Determine effective mode: prefer server config if present, otherwise use client config + String effectiveModeConfig = + planningModeServerConfig != null ? planningModeServerConfig : planningModeClientConfig; + RESTCatalogProperties.ScanPlanningMode effectiveMode = + effectiveModeConfig != null + ? RESTCatalogProperties.ScanPlanningMode.fromString(effectiveModeConfig) + : RESTCatalogProperties.SCAN_PLANNING_MODE_DEFAULT; + + if (effectiveMode == RESTCatalogProperties.ScanPlanningMode.SERVER) { + Preconditions.checkState( + endpoints.contains(Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN), + "Server requires server-side scan planning for table %s but does not support endpoint %s", + finalIdentifier, + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN); + + return new RESTTable( + ops, + fullTableName(finalIdentifier), + metricsReporter(paths.metrics(finalIdentifier), restClient), + restClient, + Map::of, + finalIdentifier, + paths, + endpoints, + properties(), + conf); + } + + // Default to client-side planning + return null; } private void trackFileIO(RESTTableOperations ops) { @@ -504,11 +665,22 @@ public Catalog.TableBuilder buildTable( } @Override - public void invalidateTable(SessionContext context, TableIdentifier ident) {} + public void invalidateTable(SessionContext context, TableIdentifier ident) { + tableCache.invalidate(context.sessionId(), ident); + } @Override public Table registerTable( SessionContext context, TableIdentifier ident, String metadataFileLocation) { + return registerTable(context, ident, metadataFileLocation, false); + } + + @Override + public Table registerTable( + SessionContext context, + TableIdentifier ident, + String metadataFileLocation, + boolean overwrite) { Endpoint.check(endpoints, Endpoint.V1_REGISTER_TABLE); checkIdentifierIsValid(ident); @@ -521,6 +693,7 @@ public Table registerTable( ImmutableRegisterTableRequest.builder() .name(ident.name()) .metadataLocation(metadataFileLocation) + .overwrite(overwrite) .build(); AuthSession contextualSession = authManager.contextualSession(context, catalogAuth); @@ -549,6 +722,11 @@ public Table registerTable( trackFileIO(ops); + RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient, tableConf); + if (restTable != null) { + return restTable; + } + return new BaseTable( ops, fullTableName(ident), metricsReporter(paths.metrics(ident), tableClient)); } @@ -580,7 +758,7 @@ public List listNamespaces(SessionContext context, Namespace namespac Map queryParams = Maps.newHashMap(); if (!namespace.isEmpty()) { - queryParams.put("parent", RESTUtil.namespaceToQueryParam(namespace)); + queryParams.put("parent", RESTUtil.namespaceToQueryParam(namespace, namespaceSeparator)); } ImmutableList.Builder namespaces = ImmutableList.builder(); @@ -813,6 +991,11 @@ public Table create() { trackFileIO(ops); + RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient, tableConf); + if (restTable != null) { + return restTable; + } + return new BaseTable( ops, fullTableName(ident), metricsReporter(paths.metrics(ident), tableClient)); } @@ -854,7 +1037,8 @@ public Transaction replaceTransaction() { throw new AlreadyExistsException("View with same name already exists: %s", ident); } - LoadTableResponse response = loadInternal(context, ident, snapshotMode); + LoadTableResponse response = loadInternal(context, ident, snapshotMode, Map.of(), h -> {}); + String fullName = fullTableName(ident); Map tableConf = response.config(); @@ -1003,7 +1187,15 @@ private FileIO newFileIO(SessionContext context, Map properties) private FileIO newFileIO( SessionContext context, Map properties, List storageCredentials) { if (null != ioBuilder) { - return ioBuilder.apply(context, properties); + FileIO fileIO = ioBuilder.apply(context, properties); + if (!storageCredentials.isEmpty() + && fileIO instanceof SupportsStorageCredentials ioWithCredentials) { + ioWithCredentials.setCredentials( + storageCredentials.stream() + .map(c -> StorageCredential.create(c.prefix(), c.config())) + .collect(Collectors.toList())); + } + return fileIO; } else { String ioImpl = properties.getOrDefault(CatalogProperties.FILE_IO_IMPL, DEFAULT_FILE_IO_IMPL); return CatalogUtil.loadFileIO( @@ -1147,7 +1339,7 @@ private static ConfigResponse fetchConfig( queryParams.build(), ConfigResponse.class, RESTUtil.configHeaders(properties), - ErrorHandlers.defaultErrorHandler()); + ErrorHandlers.configErrorHandler()); configResponse.validate(); return configResponse; } @@ -1316,6 +1508,59 @@ public void renameView(SessionContext context, TableIdentifier from, TableIdenti .post(paths.renameView(), request, null, mutationHeaders, ErrorHandlers.viewErrorHandler()); } + @Override + public View registerView( + SessionContext context, TableIdentifier ident, String metadataFileLocation) { + Endpoint.check(endpoints, Endpoint.V1_REGISTER_VIEW); + checkViewIdentifierIsValid(ident); + + Preconditions.checkArgument( + !Strings.isNullOrEmpty(metadataFileLocation), + "Invalid metadata file location: %s", + metadataFileLocation); + + RegisterViewRequest request = + ImmutableRegisterViewRequest.builder() + .name(ident.name()) + .metadataLocation(metadataFileLocation) + .build(); + + AuthSession contextualSession = authManager.contextualSession(context, catalogAuth); + LoadViewResponse response = + client + .withAuthSession(contextualSession) + .post( + paths.registerView(ident.namespace()), + request, + LoadViewResponse.class, + mutationHeaders, + ErrorHandlers.viewErrorHandler()); + + AuthSession tableSession = + authManager.tableSession(ident, response.config(), contextualSession); + RESTViewOperations ops = + newViewOps( + client.withAuthSession(tableSession), + paths.view(ident), + Map::of, + mutationHeaders, + response.metadata(), + endpoints); + + return new BaseView(ops, ViewUtil.fullViewName(name(), ident)); + } + + private static Map headersForLoadTable(TableWithETag tableWithETag) { + if (tableWithETag == null) { + return Map.of(); + } + + String eTag = tableWithETag.eTag(); + Preconditions.checkArgument(eTag != null, "Invalid ETag: null"); + + return Map.of(HttpHeaders.IF_NONE_MATCH, eTag); + } + private class RESTViewBuilder implements ViewBuilder { private final SessionContext context; private final TableIdentifier identifier; diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTTable.java b/core/src/main/java/org/apache/iceberg/rest/RESTTable.java new file mode 100644 index 000000000000..21d84d847781 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/RESTTable.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.BatchScan; +import org.apache.iceberg.BatchScanAdapter; +import org.apache.iceberg.ImmutableTableScanContext; +import org.apache.iceberg.SupportsDistributedScanPlanning; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.metrics.MetricsReporter; + +class RESTTable extends BaseTable implements SupportsDistributedScanPlanning { + private final RESTClient client; + private final Supplier> headers; + private final MetricsReporter reporter; + private final ResourcePaths resourcePaths; + private final TableIdentifier tableIdentifier; + private final Set supportedEndpoints; + private final Map catalogProperties; + private final Object hadoopConf; + + RESTTable( + TableOperations ops, + String name, + MetricsReporter reporter, + RESTClient client, + Supplier> headers, + TableIdentifier tableIdentifier, + ResourcePaths resourcePaths, + Set supportedEndpoints, + Map catalogProperties, + Object hadoopConf) { + super(ops, name, reporter); + this.reporter = reporter; + this.client = client; + this.headers = headers; + this.tableIdentifier = tableIdentifier; + this.resourcePaths = resourcePaths; + this.supportedEndpoints = supportedEndpoints; + this.catalogProperties = catalogProperties; + this.hadoopConf = hadoopConf; + } + + @Override + public TableScan newScan() { + return new RESTTableScan( + this, + schema(), + ImmutableTableScanContext.builder().metricsReporter(reporter).build(), + client, + headers.get(), + operations(), + tableIdentifier, + resourcePaths, + supportedEndpoints, + catalogProperties, + hadoopConf); + } + + @Override + public BatchScan newBatchScan() { + return new BatchScanAdapter(newScan()); + } + + @Override + public boolean allowDistributedPlanning() { + return false; + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTTableCache.java b/core/src/main/java/org/apache/iceberg/rest/RESTTableCache.java new file mode 100644 index 000000000000..912173e9eb04 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/RESTTableCache.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Ticker; +import java.io.Closeable; +import java.time.Duration; +import java.util.Map; +import java.util.function.Supplier; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.PropertyUtil; +import org.immutables.value.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class RESTTableCache implements Closeable { + private static final Logger LOG = LoggerFactory.getLogger(RESTTableCache.class); + + private final Cache tableCache; + + RESTTableCache(Map props) { + this(props, Ticker.systemTicker()); + } + + @VisibleForTesting + RESTTableCache(Map props, Ticker ticker) { + long expireAfterWriteMS = + PropertyUtil.propertyAsLong( + props, + RESTCatalogProperties.TABLE_CACHE_EXPIRE_AFTER_WRITE_MS, + RESTCatalogProperties.TABLE_CACHE_EXPIRE_AFTER_WRITE_MS_DEFAULT); + Preconditions.checkArgument( + expireAfterWriteMS > 0, "Invalid expire after write: zero or negative"); + + long numEntries = + PropertyUtil.propertyAsLong( + props, + RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES, + RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES_DEFAULT); + Preconditions.checkArgument(numEntries >= 0, "Invalid max entries: negative"); + + tableCache = + Caffeine.newBuilder() + .maximumSize(numEntries) + .expireAfterWrite(Duration.ofMillis(expireAfterWriteMS)) + .removalListener( + (compositeKey, table, cause) -> + LOG.debug("Evicted {} from table cache ({})", compositeKey, cause)) + .recordStats() + .ticker(ticker) + .build(); + } + + public TableWithETag getIfPresent(String sessionId, TableIdentifier identifier) { + SessionIdTableId cacheKey = SessionIdTableId.of(sessionId, identifier); + return tableCache.getIfPresent(cacheKey); + } + + public void put( + String sessionId, + TableIdentifier identifier, + Supplier tableSupplier, + String eTag) { + tableCache.put( + SessionIdTableId.of(sessionId, identifier), TableWithETag.of(tableSupplier, eTag)); + } + + public void invalidate(String sessionId, TableIdentifier identifier) { + SessionIdTableId cacheKey = SessionIdTableId.of(sessionId, identifier); + tableCache.invalidate(cacheKey); + } + + @VisibleForTesting + Cache cache() { + return tableCache; + } + + @Override + public void close() { + tableCache.invalidateAll(); + tableCache.cleanUp(); + } + + @Value.Immutable + interface SessionIdTableId { + String sessionId(); + + TableIdentifier tableIdentifier(); + + static SessionIdTableId of(String sessionId, TableIdentifier ident) { + return ImmutableSessionIdTableId.builder() + .sessionId(sessionId) + .tableIdentifier(ident) + .build(); + } + } + + @Value.Immutable + interface TableWithETag { + Supplier supplier(); + + String eTag(); + + static TableWithETag of(Supplier tableSupplier, String eTag) { + return ImmutableTableWithETag.builder().supplier(tableSupplier).eTag(eTag).build(); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTTableOperations.java b/core/src/main/java/org/apache/iceberg/rest/RESTTableOperations.java index d2a6ab618ca8..be763d30fef1 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTTableOperations.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTTableOperations.java @@ -169,7 +169,7 @@ public void commit(TableMetadata base, TableMetadata metadata) { .addAll(metadata.changes()) .build(); requirements = UpdateRequirements.forCreateTable(updates); - errorHandler = ErrorHandlers.tableErrorHandler(); // throws NoSuchTableException + errorHandler = ErrorHandlers.createTableErrorHandler(); break; case REPLACE: diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTTableScan.java b/core/src/main/java/org/apache/iceberg/rest/RESTTableScan.java new file mode 100644 index 000000000000..9fa273ca169f --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/RESTTableScan.java @@ -0,0 +1,389 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalListener; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.DataTableScan; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.TableScanContext; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.requests.PlanTableScanRequest; +import org.apache.iceberg.rest.responses.ErrorResponse; +import org.apache.iceberg.rest.responses.FetchPlanningResultResponse; +import org.apache.iceberg.rest.responses.PlanTableScanResponse; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.Tasks; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class RESTTableScan extends DataTableScan { + private static final Logger LOG = LoggerFactory.getLogger(RESTTableScan.class); + private static final long MIN_SLEEP_MS = 1000; // Initial delay + private static final long MAX_SLEEP_MS = 60 * 1000; // Max backoff delay (1 minute) + private static final int MAX_RETRIES = 10; // Max number of poll retries + private static final double SCALE_FACTOR = 2.0; // Exponential scale factor + private static final String DEFAULT_FILE_IO_IMPL = "org.apache.iceberg.io.ResolvingFileIO"; + private static final Cache FILEIO_TRACKER = + Caffeine.newBuilder() + .weakKeys() + .removalListener( + (RemovalListener) + (scan, io, cause) -> { + if (null != io) { + io.close(); + } + }) + .build(); + + private final RESTClient client; + private final Map headers; + private final TableOperations operations; + private final ResourcePaths resourcePaths; + private final TableIdentifier tableIdentifier; + private final Set supportedEndpoints; + private final ParserContext parserContext; + private final Map catalogProperties; + private final Object hadoopConf; + private String planId = null; + private FileIO scanFileIO = null; + private boolean useSnapshotSchema = false; + + RESTTableScan( + Table table, + Schema schema, + TableScanContext context, + RESTClient client, + Map headers, + TableOperations operations, + TableIdentifier tableIdentifier, + ResourcePaths resourcePaths, + Set supportedEndpoints, + Map catalogProperties, + Object hadoopConf) { + super(table, schema, context); + this.client = client; + this.headers = headers; + this.operations = operations; + this.tableIdentifier = tableIdentifier; + this.resourcePaths = resourcePaths; + this.supportedEndpoints = supportedEndpoints; + this.parserContext = + ParserContext.builder() + .add("specsById", table.specs()) + .add("caseSensitive", context().caseSensitive()) + .build(); + this.catalogProperties = catalogProperties; + this.hadoopConf = hadoopConf; + } + + @Override + protected TableScan newRefinedScan( + Table refinedTable, Schema refinedSchema, TableScanContext refinedContext) { + RESTTableScan scan = + new RESTTableScan( + refinedTable, + refinedSchema, + refinedContext, + client, + headers, + operations, + tableIdentifier, + resourcePaths, + supportedEndpoints, + catalogProperties, + hadoopConf); + scan.useSnapshotSchema = useSnapshotSchema; + return scan; + } + + @Override + public TableScan useRef(String name) { + SnapshotRef ref = table().refs().get(name); + this.useSnapshotSchema = ref != null && ref.isTag(); + return super.useRef(name); + } + + @Override + public TableScan useSnapshot(long snapshotId) { + this.useSnapshotSchema = true; + return super.useSnapshot(snapshotId); + } + + @Override + public Supplier fileIO() { + return () -> { + Preconditions.checkState( + null != scanFileIO, "FileIO is not available: planFiles() must be called first"); + return scanFileIO; + }; + } + + @Override + public CloseableIterable planFiles() { + Long startSnapshotId = context().fromSnapshotId(); + Long endSnapshotId = context().toSnapshotId(); + Long snapshotId = snapshotId(); + List projectedFieldIds = Lists.newArrayList(TypeUtil.getProjectedIds(schema())); + List selectedColumns = + projectedFieldIds.stream().map(schema()::findColumnName).collect(Collectors.toList()); + + List statsFields = null; + if (columnsToKeepStats() != null) { + statsFields = + columnsToKeepStats().stream() + .map(columnId -> schema().findColumnName(columnId)) + .collect(Collectors.toList()); + } + + PlanTableScanRequest.Builder builder = + PlanTableScanRequest.builder() + .withSelect(selectedColumns) + .withFilter(filter()) + .withCaseSensitive(isCaseSensitive()) + .withStatsFields(statsFields) + .withMinRowsRequested(context().minRowsRequested()); + + if (startSnapshotId != null && endSnapshotId != null) { + builder + .withStartSnapshotId(startSnapshotId) + .withEndSnapshotId(endSnapshotId) + .withUseSnapshotSchema(true); + } else if (snapshotId != null) { + builder.withSnapshotId(snapshotId).withUseSnapshotSchema(useSnapshotSchema); + } + + return planTableScan(builder.build()); + } + + private CloseableIterable planTableScan(PlanTableScanRequest planTableScanRequest) { + PlanTableScanResponse response = + client.post( + resourcePaths.planTableScan(tableIdentifier), + planTableScanRequest, + PlanTableScanResponse.class, + headers, + ErrorHandlers.tableErrorHandler(), + stringStringMap -> {}, + parserContext); + + this.planId = response.planId(); + PlanStatus planStatus = response.planStatus(); + this.scanFileIO = + !response.credentials().isEmpty() ? scanFileIO(response.credentials()) : table().io(); + + switch (planStatus) { + case COMPLETED: + return scanTasksIterable(response.planTasks(), response.fileScanTasks()); + case SUBMITTED: + Endpoint.check(supportedEndpoints, Endpoint.V1_FETCH_TABLE_SCAN_PLAN); + return fetchPlanningResult(); + case FAILED: + throw new IllegalStateException(failureMessage(planId, response.errorResponse())); + default: + throw new IllegalStateException( + String.format("Invalid planStatus: %s for planId: %s", planStatus, planId)); + } + } + + private FileIO scanFileIO(List storageCredentials) { + ImmutableMap.Builder builder = + ImmutableMap.builder().putAll(catalogProperties); + if (null != planId) { + builder.put(RESTCatalogProperties.REST_SCAN_PLAN_ID, planId); + } + + Map properties = builder.buildKeepingLast(); + FileIO ioForScan = + CatalogUtil.loadFileIO( + catalogProperties.getOrDefault(CatalogProperties.FILE_IO_IMPL, DEFAULT_FILE_IO_IMPL), + properties, + hadoopConf, + storageCredentials.stream() + .map(c -> StorageCredential.create(c.prefix(), c.config())) + .collect(Collectors.toList())); + FILEIO_TRACKER.put(this, ioForScan); + return ioForScan; + } + + private CloseableIterable fetchPlanningResult() { + long maxWaitTimeMs = + PropertyUtil.propertyAsLong( + catalogProperties, + RESTCatalogProperties.REST_SCAN_PLANNING_POLL_TIMEOUT_MS, + RESTCatalogProperties.REST_SCAN_PLANNING_POLL_TIMEOUT_MS_DEFAULT); + Preconditions.checkArgument( + maxWaitTimeMs > 0, + "Invalid value for %s: %s (must be positive)", + RESTCatalogProperties.REST_SCAN_PLANNING_POLL_TIMEOUT_MS, + maxWaitTimeMs); + + AtomicReference result = new AtomicReference<>(); + try { + Tasks.foreach(planId) + .exponentialBackoff(MIN_SLEEP_MS, MAX_SLEEP_MS, maxWaitTimeMs, SCALE_FACTOR) + .retry(MAX_RETRIES) + .onlyRetryOn(NotCompleteException.class) + .onFailure( + (id, err) -> { + LOG.warn("Planning failed for plan ID: {}", id, err); + cleanupPlanResources(); + }) + .throwFailureWhenFinished() + .run( + id -> { + FetchPlanningResultResponse response = + client.get( + resourcePaths.plan(tableIdentifier, id), + headers, + FetchPlanningResultResponse.class, + headers, + ErrorHandlers.planErrorHandler(), + parserContext); + + switch (response.planStatus()) { + case COMPLETED: + result.set(response); + break; + case SUBMITTED: + throw new NotCompleteException(); + case FAILED: + throw new IllegalStateException(failureMessage(id, response.errorResponse())); + case CANCELLED: + throw new IllegalStateException( + String.format( + Locale.ROOT, "Remote scan planning cancelled for planId: %s", id)); + default: + throw new IllegalStateException( + String.format( + Locale.ROOT, + "Invalid planStatus: %s for planId: %s", + response.planStatus(), + id)); + } + }); + } catch (NotCompleteException e) { + throw new RemotePlanTimeoutException( + String.format( + Locale.ROOT, + "Remote scan planning for planId: %s did not complete within configured limits" + + " (timeout=%d ms, maxRetries=%d)", + planId, + maxWaitTimeMs, + MAX_RETRIES), + e); + } + + FetchPlanningResultResponse response = result.get(); + + this.scanFileIO = + !response.credentials().isEmpty() ? scanFileIO(response.credentials()) : table().io(); + + return scanTasksIterable(response.planTasks(), response.fileScanTasks()); + } + + private static String failureMessage(String planId, ErrorResponse error) { + // If a FAILED response lacks the expected error payload, still return a useful error + // message instead of throwing. + String type = error != null ? error.type() : "unknown"; + int code = error != null ? error.code() : 0; + String message = error != null ? error.message() : "unknown"; + return String.format( + Locale.ROOT, + "Remote scan planning failed for planId: %s: %s (code=%d): %s", + planId, + type, + code, + message); + } + + private CloseableIterable scanTasksIterable( + List planTasks, List fileScanTasks) { + if (planTasks != null && !planTasks.isEmpty()) { + Endpoint.check(supportedEndpoints, Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS); + } + + return CloseableIterable.whenComplete( + new ScanTaskIterable( + planTasks, + fileScanTasks == null ? List.of() : fileScanTasks, + client, + resourcePaths, + tableIdentifier, + headers, + planExecutor(), + parserContext), + this::cancelPlan); + } + + /** Cancels the plan on the server (if supported) and closes the plan-scoped FileIO */ + private void cleanupPlanResources() { + cancelPlan(); + FILEIO_TRACKER.invalidate(this); + this.scanFileIO = null; + } + + @VisibleForTesting + @SuppressWarnings("checkstyle:RegexpMultiline") + public boolean cancelPlan() { + if (planId == null || !supportedEndpoints.contains(Endpoint.V1_CANCEL_TABLE_SCAN_PLAN)) { + return false; + } + + try { + client.delete( + resourcePaths.plan(tableIdentifier, planId), + Map.of(), + null, + headers, + ErrorHandlers.planErrorHandler()); + this.planId = null; + return true; + } catch (Exception e) { + // Plan might have already completed or failed, which is acceptable + return false; + } + } + + private static class NotCompleteException extends RuntimeException {} +} diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java b/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java index ec02a9dc8459..f4fdf3af26e7 100644 --- a/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java +++ b/core/src/main/java/org/apache/iceberg/rest/RESTUtil.java @@ -26,29 +26,33 @@ import org.apache.iceberg.relocated.com.google.common.base.Joiner; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.util.UUIDUtil; +@SuppressWarnings("UnicodeEscape") public class RESTUtil { - private static final char NAMESPACE_SEPARATOR = '\u001f'; - private static final String NAMESPACE_ESCAPED_SEPARATOR = "%1F"; - private static final Joiner NAMESPACE_ESCAPED_JOINER = Joiner.on(NAMESPACE_ESCAPED_SEPARATOR); - private static final Splitter NAMESPACE_ESCAPED_SPLITTER = - Splitter.on(NAMESPACE_ESCAPED_SEPARATOR); + /** The namespace separator as Unicode character */ + private static final char NAMESPACE_SEPARATOR_AS_UNICODE = '\u001f'; + + /** The namespace separator as url encoded UTF-8 character */ + static final String NAMESPACE_SEPARATOR_URLENCODED_UTF_8 = "%1F"; /** * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link * RESTUtil#namespaceToQueryParam(Namespace)}} instead. */ - @Deprecated public static final Joiner NAMESPACE_JOINER = Joiner.on(NAMESPACE_SEPARATOR); + @Deprecated + public static final Joiner NAMESPACE_JOINER = Joiner.on(NAMESPACE_SEPARATOR_AS_UNICODE); /** * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link * RESTUtil#namespaceFromQueryParam(String)} instead. */ - @Deprecated public static final Splitter NAMESPACE_SPLITTER = Splitter.on(NAMESPACE_SEPARATOR); + @Deprecated + public static final Splitter NAMESPACE_SPLITTER = Splitter.on(NAMESPACE_SEPARATOR_AS_UNICODE); public static final String IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"; @@ -179,8 +183,31 @@ public static String decodeString(String encoded) { * separated using the unicode character '\u001f' */ public static String namespaceToQueryParam(Namespace namespace) { + return namespaceToQueryParam(namespace, String.valueOf(NAMESPACE_SEPARATOR_AS_UNICODE)); + } + + /** + * This converts the given namespace to a string and separates each part in a multipart namespace + * using the provided unicode separator. Note that this method is different from {@link + * RESTUtil#encodeNamespace(Namespace)}, which uses a UTF-8 escaped separator. + * + *

{@link #namespaceFromQueryParam(String, String)} should be used to convert the namespace + * string back to a {@link Namespace} instance. + * + * @param namespace The namespace to convert + * @param unicodeNamespaceSeparator The unicode namespace separator to use, such as '\u002e' + * @return The namespace converted to a string where each part in a multipart namespace is + * separated using the given unicode separator + */ + public static String namespaceToQueryParam( + Namespace namespace, String unicodeNamespaceSeparator) { Preconditions.checkArgument(null != namespace, "Invalid namespace: null"); - return RESTUtil.NAMESPACE_JOINER.join(namespace.levels()); + Preconditions.checkArgument( + !Strings.isNullOrEmpty(unicodeNamespaceSeparator), "Invalid separator: null or empty"); + + // decode in case the separator was already encoded with UTF-8 + String separator = URLDecoder.decode(unicodeNamespaceSeparator, StandardCharsets.UTF_8); + return Joiner.on(separator).join(namespace.levels()); } /** @@ -191,13 +218,39 @@ public static String namespaceToQueryParam(Namespace namespace) { * a string. * * @param namespace The namespace to convert - * @return The namespace instance from the given namespace string, where each multipart separator - * ('\u001f') is converted to a separate namespace level + * @return The namespace instance from the given namespace string, where each part in a multipart + * namespace is converted using the unicode separator '\u001f' */ public static Namespace namespaceFromQueryParam(String namespace) { + return namespaceFromQueryParam(namespace, String.valueOf(NAMESPACE_SEPARATOR_AS_UNICODE)); + } + + /** + * This converts a namespace where each part in a multipart namespace has been separated using the + * provided unicode separator to its original {@link Namespace} instance. + * + *

{@link #namespaceToQueryParam(Namespace, String)} should be used to convert the {@link + * Namespace} to a string. + * + * @param namespace The namespace to convert + * @param unicodeNamespaceSeparator The unicode namespace separator to use, such as '\u002e' + * @return The namespace instance from the given namespace string, where each part in a multipart + * namespace is converted using the given unicode namespace separator + */ + public static Namespace namespaceFromQueryParam( + String namespace, String unicodeNamespaceSeparator) { Preconditions.checkArgument(null != namespace, "Invalid namespace: null"); - return Namespace.of( - RESTUtil.NAMESPACE_SPLITTER.splitToStream(namespace).toArray(String[]::new)); + Preconditions.checkArgument( + !Strings.isNullOrEmpty(unicodeNamespaceSeparator), "Invalid separator: null or empty"); + + // decode in case the separator was already encoded with UTF-8 + String separator = URLDecoder.decode(unicodeNamespaceSeparator, StandardCharsets.UTF_8); + Splitter splitter = + namespace.contains(String.valueOf(NAMESPACE_SEPARATOR_AS_UNICODE)) + ? Splitter.on(NAMESPACE_SEPARATOR_AS_UNICODE) + : Splitter.on(separator); + + return Namespace.of(splitter.splitToStream(namespace).toArray(String[]::new)); } /** @@ -210,17 +263,40 @@ public static Namespace namespaceFromQueryParam(String namespace) { * * @param ns namespace to encode * @return UTF-8 encoded string representing the namespace, suitable for use as a URL parameter + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * RESTUtil#encodeNamespace(Namespace, String)} instead. */ + @Deprecated public static String encodeNamespace(Namespace ns) { - Preconditions.checkArgument(ns != null, "Invalid namespace: null"); - String[] levels = ns.levels(); + return encodeNamespace(ns, NAMESPACE_SEPARATOR_URLENCODED_UTF_8); + } + + /** + * Returns a String representation of a namespace that is suitable for use in a URL / URI. + * + *

This function needs to be called when a namespace is used as a path variable (or query + * parameter etc.), to format the namespace per the spec. + * + *

{@link RESTUtil#decodeNamespace(String, String)} should be used to parse the namespace from + * a URL parameter. + * + * @param namespace namespace to encode + * @param separator The namespace separator to be used for encoding. The separator will be used + * as-is and won't be UTF-8 encoded. + * @return UTF-8 encoded string representing the namespace, suitable for use as a URL parameter + */ + public static String encodeNamespace(Namespace namespace, String separator) { + Preconditions.checkArgument(namespace != null, "Invalid namespace: null"); + Preconditions.checkArgument( + !Strings.isNullOrEmpty(separator), "Invalid separator: null or empty"); + String[] levels = namespace.levels(); String[] encodedLevels = new String[levels.length]; for (int i = 0; i < levels.length; i++) { encodedLevels[i] = encodeString(levels[i]); } - return NAMESPACE_ESCAPED_JOINER.join(encodedLevels); + return Joiner.on(separator).join(encodedLevels); } /** @@ -231,10 +307,38 @@ public static String encodeNamespace(Namespace ns) { * * @param encodedNs a namespace to decode * @return a namespace + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * RESTUtil#decodeNamespace(String, String)} instead. */ + @Deprecated public static Namespace decodeNamespace(String encodedNs) { - Preconditions.checkArgument(encodedNs != null, "Invalid namespace: null"); - String[] levels = Iterables.toArray(NAMESPACE_ESCAPED_SPLITTER.split(encodedNs), String.class); + return decodeNamespace(encodedNs, NAMESPACE_SEPARATOR_URLENCODED_UTF_8); + } + + /** + * Takes in a string representation of a namespace as used for a URL parameter and returns the + * corresponding namespace. + * + *

See also {@link #encodeNamespace} for generating correctly formatted URLs. + * + * @param encodedNamespace a namespace to decode + * @param separator The namespace separator to be used as-is for decoding. This should be the same + * separator that was used when calling {@link RESTUtil#encodeNamespace(Namespace, String)} + * @return a namespace + */ + public static Namespace decodeNamespace(String encodedNamespace, String separator) { + Preconditions.checkArgument(encodedNamespace != null, "Invalid namespace: null"); + Preconditions.checkArgument( + !Strings.isNullOrEmpty(separator), "Invalid separator: null or empty"); + + // use legacy splitter for backwards compatibility in case an old clients encoded the namespace + // with %1F + Splitter splitter = + Splitter.on( + encodedNamespace.contains(NAMESPACE_SEPARATOR_URLENCODED_UTF_8) + ? NAMESPACE_SEPARATOR_URLENCODED_UTF_8 + : separator); + String[] levels = Iterables.toArray(splitter.split(encodedNamespace), String.class); // Decode levels in place for (int i = 0; i < levels.length; i++) { diff --git a/core/src/main/java/org/apache/iceberg/rest/RemotePlanTimeoutException.java b/core/src/main/java/org/apache/iceberg/rest/RemotePlanTimeoutException.java new file mode 100644 index 000000000000..e0f01aadd612 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/RemotePlanTimeoutException.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +/** Thrown when server-side scan planning does not complete before the client deadline. */ +class RemotePlanTimeoutException extends RuntimeException { + RemotePlanTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java index 275a87e96815..be2fde22053d 100644 --- a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java +++ b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java @@ -22,6 +22,7 @@ import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.util.PropertyUtil; public class ResourcePaths { private static final Joiner SLASH = Joiner.on("/").skipNulls(); @@ -34,6 +35,8 @@ public class ResourcePaths { public static final String V1_TABLE = "/v1/{prefix}/namespaces/{namespace}/tables/{table}"; public static final String V1_TABLE_CREDENTIALS = "/v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials"; + public static final String V1_TABLE_REMOTE_SIGN = + "/v1/{prefix}/namespaces/{namespace}/tables/{table}/sign"; public static final String V1_TABLE_REGISTER = "/v1/{prefix}/namespaces/{namespace}/register"; public static final String V1_TABLE_METRICS = "/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics"; @@ -48,9 +51,15 @@ public class ResourcePaths { public static final String V1_VIEWS = "/v1/{prefix}/namespaces/{namespace}/views"; public static final String V1_VIEW = "/v1/{prefix}/namespaces/{namespace}/views/{view}"; public static final String V1_VIEW_RENAME = "/v1/{prefix}/views/rename"; + public static final String V1_VIEW_REGISTER = "/v1/{prefix}/namespaces/{namespace}/register-view"; public static ResourcePaths forCatalogProperties(Map properties) { - return new ResourcePaths(properties.get(PREFIX)); + return new ResourcePaths( + properties.get(PREFIX), + PropertyUtil.propertyAsString( + properties, + RESTCatalogProperties.NAMESPACE_SEPARATOR, + RESTCatalogProperties.NAMESPACE_SEPARATOR_DEFAULT)); } public static String config() { @@ -62,9 +71,20 @@ public static String tokens() { } private final String prefix; + private final String namespaceSeparator; + /** + * @deprecated since 1.11.0, will be made private in 1.12.0; use {@link + * ResourcePaths#forCatalogProperties(Map)} instead. + */ + @Deprecated public ResourcePaths(String prefix) { + this(prefix, RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8); + } + + private ResourcePaths(String prefix, String namespaceSeparator) { this.prefix = prefix; + this.namespaceSeparator = namespaceSeparator; } public String namespaces() { @@ -72,15 +92,15 @@ public String namespaces() { } public String namespace(Namespace ns) { - return SLASH.join("v1", prefix, "namespaces", RESTUtil.encodeNamespace(ns)); + return SLASH.join("v1", prefix, "namespaces", pathEncode(ns)); } public String namespaceProperties(Namespace ns) { - return SLASH.join("v1", prefix, "namespaces", RESTUtil.encodeNamespace(ns), "properties"); + return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "properties"); } public String tables(Namespace ns) { - return SLASH.join("v1", prefix, "namespaces", RESTUtil.encodeNamespace(ns), "tables"); + return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "tables"); } public String table(TableIdentifier ident) { @@ -88,13 +108,13 @@ public String table(TableIdentifier ident) { "v1", prefix, "namespaces", - RESTUtil.encodeNamespace(ident.namespace()), + pathEncode(ident.namespace()), "tables", RESTUtil.encodeString(ident.name())); } public String register(Namespace ns) { - return SLASH.join("v1", prefix, "namespaces", RESTUtil.encodeNamespace(ns), "register"); + return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "register"); } public String rename() { @@ -106,18 +126,29 @@ public String metrics(TableIdentifier identifier) { "v1", prefix, "namespaces", - RESTUtil.encodeNamespace(identifier.namespace()), + pathEncode(identifier.namespace()), "tables", RESTUtil.encodeString(identifier.name()), "metrics"); } + public String remoteSign(TableIdentifier identifier) { + return SLASH.join( + "v1", + prefix, + "namespaces", + pathEncode(identifier.namespace()), + "tables", + RESTUtil.encodeString(identifier.name()), + "sign"); + } + public String commitTransaction() { return SLASH.join("v1", prefix, "transactions", "commit"); } public String views(Namespace ns) { - return SLASH.join("v1", prefix, "namespaces", RESTUtil.encodeNamespace(ns), "views"); + return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "views"); } public String view(TableIdentifier ident) { @@ -125,7 +156,7 @@ public String view(TableIdentifier ident) { "v1", prefix, "namespaces", - RESTUtil.encodeNamespace(ident.namespace()), + pathEncode(ident.namespace()), "views", RESTUtil.encodeString(ident.name())); } @@ -133,4 +164,46 @@ public String view(TableIdentifier ident) { public String renameView() { return SLASH.join("v1", prefix, "views", "rename"); } + + public String registerView(Namespace ns) { + return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "register-view"); + } + + public String planTableScan(TableIdentifier ident) { + return SLASH.join( + "v1", + prefix, + "namespaces", + pathEncode(ident.namespace()), + "tables", + RESTUtil.encodeString(ident.name()), + "plan"); + } + + public String plan(TableIdentifier ident, String planId) { + return SLASH.join( + "v1", + prefix, + "namespaces", + pathEncode(ident.namespace()), + "tables", + RESTUtil.encodeString(ident.name()), + "plan", + RESTUtil.encodeString(planId)); + } + + public String fetchScanTasks(TableIdentifier ident) { + return SLASH.join( + "v1", + prefix, + "namespaces", + pathEncode(ident.namespace()), + "tables", + RESTUtil.encodeString(ident.name()), + "tasks"); + } + + private String pathEncode(Namespace ns) { + return RESTUtil.encodeNamespace(ns, namespaceSeparator); + } } diff --git a/core/src/main/java/org/apache/iceberg/rest/ScanTaskIterable.java b/core/src/main/java/org/apache/iceberg/rest/ScanTaskIterable.java new file mode 100644 index 000000000000..4500ecf4dfdb --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/ScanTaskIterable.java @@ -0,0 +1,290 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.iceberg.BaseFileScanTask; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.rest.requests.FetchScanTasksRequest; +import org.apache.iceberg.rest.responses.FetchScanTasksResponse; +import org.apache.iceberg.util.ThreadPools; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class ScanTaskIterable implements CloseableIterable { + + private static final Logger LOG = LoggerFactory.getLogger(ScanTaskIterable.class); + private static final int DEFAULT_TASK_QUEUE_CAPACITY = 1000; + private static final long QUEUE_POLL_TIMEOUT_MS = 100; + // Dummy task acts as a poison pill to indicate that there will be no more tasks + private static final FileScanTask DUMMY_TASK = new BaseFileScanTask(null, null, null, null, null); + private final AtomicReference failure = new AtomicReference<>(null); + private final BlockingQueue taskQueue; + private final ConcurrentLinkedQueue initialFileScanTasks; + private final ConcurrentLinkedQueue planTasks; + private final AtomicInteger activeWorkers = new AtomicInteger(0); + private final AtomicBoolean shutdown = new AtomicBoolean(false); + private final ExecutorService executorService; + private final RESTClient client; + private final ResourcePaths resourcePaths; + private final TableIdentifier tableIdentifier; + private final Map headers; + private final ParserContext parserContext; + + ScanTaskIterable( + List initialPlanTasks, + List initialFileScanTasks, + RESTClient client, + ResourcePaths resourcePaths, + TableIdentifier tableIdentifier, + Map headers, + ExecutorService executorService, + ParserContext parserContext) { + + this.taskQueue = new LinkedBlockingQueue<>(DEFAULT_TASK_QUEUE_CAPACITY); + this.planTasks = new ConcurrentLinkedQueue<>(); + // Initialize initial file scan tasks queue so that multiple workers can poll produce from it. + this.initialFileScanTasks = new ConcurrentLinkedQueue<>(initialFileScanTasks); + + this.client = client; + this.resourcePaths = resourcePaths; + this.tableIdentifier = tableIdentifier; + this.headers = headers; + this.executorService = executorService; + this.parserContext = parserContext; + + if (initialPlanTasks != null && !initialPlanTasks.isEmpty()) { + planTasks.addAll(initialPlanTasks); + } else if (initialFileScanTasks.isEmpty()) { + // Add dummy task to indicate there is no work to be done. + // Queue is empty at this point, so add() will never fail. + taskQueue.add(DUMMY_TASK); + return; + } + + submitFixedWorkers(); + } + + private void submitFixedWorkers() { + for (int i = 0; i < ThreadPools.WORKER_THREAD_POOL_SIZE; i++) { + executorService.execute(new PlanTaskWorker()); + } + } + + @Override + public CloseableIterator iterator() { + return new ScanTasksIterator(); + } + + @Override + public void close() throws IOException {} + + private class PlanTaskWorker implements Runnable { + + @Override + public void run() { + activeWorkers.incrementAndGet(); + + try { + while (!shutdown.get() && !Thread.currentThread().isInterrupted()) { + String planTask = planTasks.poll(); + if (planTask == null) { + // if there are no more plan tasks, see if we can just add any remaining initial + // file scan tasks before exiting. + offerInitialFileScanTasks(); + return; + } + + processPlanTask(planTask); + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + failure.compareAndSet(null, new RuntimeException("PlanWorker was interrupted", e)); + shutdown.set(true); + } catch (Exception e) { + failure.compareAndSet(null, new RuntimeException("Worker failed processing planTask", e)); + shutdown.set(true); + } finally { + handleWorkerExit(); + } + } + + /** + * Offers a task to the queue with timeout, periodically checking for shutdown. Returns true if + * the task was successfully added, false if shutdown was requested. Throws InterruptedException + * if the thread is interrupted while waiting. + */ + private boolean offerWithTimeout(FileScanTask task) throws InterruptedException { + while (!shutdown.get()) { + if (taskQueue.offer(task, QUEUE_POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + return true; + } + } + return false; + } + + private void handleWorkerExit() { + boolean isLastWorker = activeWorkers.decrementAndGet() == 0; + boolean hasWorkLeft = !planTasks.isEmpty() || !initialFileScanTasks.isEmpty(); + boolean isShuttingDown = shutdown.get(); + + if (isLastWorker && (!hasWorkLeft || isShuttingDown)) { + signalCompletion(); + } else if (isLastWorker && hasWorkLeft) { + failure.compareAndSet( + null, + new IllegalStateException("Workers have exited but there is still work to be done")); + shutdown.set(true); + } + } + + private void signalCompletion() { + try { + // Use offer with timeout to avoid blocking indefinitely if queue is full and consumer + // stopped draining. If shutdown is already set, consumer will exit via its shutdown check. + offerWithTimeout(DUMMY_TASK); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + // we should just shut down and not rethrow since we are trying to signal completion + // its fine if we fail to put the dummy task in this case. + shutdown.set(true); + } + } + + private void offerInitialFileScanTasks() throws InterruptedException { + while (!initialFileScanTasks.isEmpty() && !Thread.currentThread().isInterrupted()) { + FileScanTask initialFileScanTask = initialFileScanTasks.poll(); + if (initialFileScanTask != null) { + if (!offerWithTimeout(initialFileScanTask)) { + return; + } + } + } + } + + private void processPlanTask(String planTask) throws InterruptedException { + FetchScanTasksResponse response = fetchScanTasks(planTask); + // immediately add any new plan tasks to the queue so the idle workers can pick them up + if (response.planTasks() != null) { + planTasks.addAll(response.planTasks()); + } + + // before blocking on the task queue, check for shutdown again + if (shutdown.get()) { + return; + } + + // Now since the network IO is done, first add any initial file scan tasks + offerInitialFileScanTasks(); + + if (response.fileScanTasks() != null) { + for (FileScanTask task : response.fileScanTasks()) { + if (!offerWithTimeout(task)) { + return; + } + } + } + } + + private FetchScanTasksResponse fetchScanTasks(String planTask) { + FetchScanTasksRequest request = new FetchScanTasksRequest(planTask); + + return client.post( + resourcePaths.fetchScanTasks(tableIdentifier), + request, + FetchScanTasksResponse.class, + headers, + ErrorHandlers.planTaskHandler(), + stringStringMap -> {}, + parserContext); + } + } + + private class ScanTasksIterator implements CloseableIterator { + private FileScanTask nextTask = null; + + @Override + public boolean hasNext() { + if (nextTask != null) { + return true; + } + + boolean hasNext = false; + while (!shutdown.get()) { + try { + nextTask = taskQueue.poll(QUEUE_POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (nextTask == DUMMY_TASK) { + nextTask = null; + shutdown.set(true); // Mark as done so while loop exits on subsequent calls + break; + } else if (nextTask != null) { + hasNext = true; + break; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + shutdown.set(true); + } + } + + RuntimeException workerFailure = failure.get(); + if (workerFailure != null) { + throw workerFailure; + } + + return hasNext; + } + + @Override + public FileScanTask next() { + if (!hasNext()) { + throw new NoSuchElementException("No more scan tasks available"); + } + FileScanTask result = nextTask; + nextTask = null; + return result; + } + + @Override + public void close() { + shutdown.set(true); + LOG.info( + "ScanTasksIterator is closing. Clearing {} queued tasks and {} plan tasks.", + taskQueue.size(), + planTasks.size()); + taskQueue.clear(); + planTasks.clear(); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java index 084e74651a0c..02d98904942d 100644 --- a/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/TableScanResponseParser.java @@ -77,7 +77,17 @@ public static List parseFileScanTasks( fileScanTaskList.add(fileScanTask); } + if (fileScanTaskList.isEmpty()) { + Preconditions.checkArgument( + deleteFiles == null || deleteFiles.isEmpty(), + "Invalid response: deleteFiles should only be returned with fileScanTasks that reference them"); + } + return fileScanTaskList; + } else { + Preconditions.checkArgument( + deleteFiles == null || deleteFiles.isEmpty(), + "Invalid response: deleteFiles should only be returned with fileScanTasks that reference them"); } return null; diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java index c2b47e6e944f..7a244bff70f6 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java @@ -529,6 +529,7 @@ public Pair refresh(RESTClient client) { .from(config()) .token(response.token()) .tokenType(response.issuedTokenType()) + .expiresAtMillis(OAuth2Util.expiresAtMillis(response.token())) .build(); Map currentHeaders = this.headers; this.headers = RESTUtil.merge(currentHeaders, authHeaders(config.token())); @@ -618,6 +619,7 @@ public static AuthSession fromAccessToken( .from(parent.config()) .token(token) .tokenType(OAuth2Properties.ACCESS_TOKEN_TYPE) + .expiresAtMillis(OAuth2Util.expiresAtMillis(token)) .build()); long startTimeMillis = System.currentTimeMillis(); @@ -699,6 +701,7 @@ private static AuthSession fromTokenResponse( .token(response.token()) .tokenType(issuedTokenType) .credential(credential) + .expiresAtMillis(OAuth2Util.expiresAtMillis(response.token())) .build()); Long expiresAtMillis = session.expiresAtMillis(); diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/TLSConfigurer.java b/core/src/main/java/org/apache/iceberg/rest/auth/TLSConfigurer.java index da22b5cb4c6a..4b3ed3a74c24 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/TLSConfigurer.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/TLSConfigurer.java @@ -19,9 +19,9 @@ package org.apache.iceberg.rest.auth; import java.util.Map; +import javax.annotation.Nullable; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; -import org.apache.hc.client5.http.ssl.HttpsSupport; import org.apache.hc.core5.ssl.SSLContexts; public interface TLSConfigurer { @@ -32,8 +32,16 @@ default SSLContext sslContext() { return SSLContexts.createDefault(); } + /** + * Returns a custom {@link HostnameVerifier} to use for hostname verification, or {@code null} to + * use the default JSSE built-in hostname verifier. + * + *

If a non-null verifier is returned, only the custom verifier is executed and the JSSE + * built-in hostname verifier won't be executed. + */ + @Nullable default HostnameVerifier hostnameVerifier() { - return HttpsSupport.getDefaultHostnameVerifier(); + return null; } default String[] supportedProtocols() { diff --git a/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequest.java b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequest.java index 33b37dae242f..e164a7ec5154 100644 --- a/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequest.java +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequest.java @@ -28,6 +28,11 @@ public interface RegisterTableRequest extends RESTRequest { String metadataLocation(); + @Value.Default + default boolean overwrite() { + return false; + } + @Override default void validate() { // nothing to validate as it's not possible to create an invalid instance diff --git a/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequestParser.java b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequestParser.java index 961b6c185b87..3d18b034532b 100644 --- a/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequestParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterTableRequestParser.java @@ -28,6 +28,7 @@ public class RegisterTableRequestParser { private static final String NAME = "name"; private static final String METADATA_LOCATION = "metadata-location"; + private static final String OVERWRITE = "overwrite"; private RegisterTableRequestParser() {} @@ -47,6 +48,10 @@ public static void toJson(RegisterTableRequest request, JsonGenerator gen) throw gen.writeStringField(NAME, request.name()); gen.writeStringField(METADATA_LOCATION, request.metadataLocation()); + if (request.overwrite()) { + gen.writeBooleanField(OVERWRITE, request.overwrite()); + } + gen.writeEndObject(); } @@ -61,9 +66,14 @@ public static RegisterTableRequest fromJson(JsonNode json) { String name = JsonUtil.getString(NAME, json); String metadataLocation = JsonUtil.getString(METADATA_LOCATION, json); - return ImmutableRegisterTableRequest.builder() - .name(name) - .metadataLocation(metadataLocation) - .build(); + ImmutableRegisterTableRequest.Builder builder = + ImmutableRegisterTableRequest.builder().name(name).metadataLocation(metadataLocation); + + Boolean overwrite = JsonUtil.getBoolOrNull(OVERWRITE, json); + if (overwrite != null) { + builder.overwrite(overwrite); + } + + return builder.build(); } } diff --git a/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequest.java b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequest.java new file mode 100644 index 000000000000..4b382003d7d6 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequest.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.requests; + +import org.apache.iceberg.rest.RESTRequest; +import org.immutables.value.Value; + +@Value.Immutable +public interface RegisterViewRequest extends RESTRequest { + + String name(); + + String metadataLocation(); + + @Override + default void validate() { + // nothing to validate as it's not possible to create an invalid instance + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequestParser.java b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequestParser.java new file mode 100644 index 000000000000..7061245bf949 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RegisterViewRequestParser.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.requests; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.JsonUtil; + +public class RegisterViewRequestParser { + + private static final String NAME = "name"; + private static final String METADATA_LOCATION = "metadata-location"; + + private RegisterViewRequestParser() {} + + public static String toJson(RegisterViewRequest request) { + return toJson(request, false); + } + + public static String toJson(RegisterViewRequest request, boolean pretty) { + return JsonUtil.generate(gen -> toJson(request, gen), pretty); + } + + public static void toJson(RegisterViewRequest request, JsonGenerator gen) throws IOException { + Preconditions.checkArgument(null != request, "Invalid register view request: null"); + + gen.writeStartObject(); + + gen.writeStringField(NAME, request.name()); + gen.writeStringField(METADATA_LOCATION, request.metadataLocation()); + + gen.writeEndObject(); + } + + public static RegisterViewRequest fromJson(String json) { + return JsonUtil.parse(json, RegisterViewRequestParser::fromJson); + } + + public static RegisterViewRequest fromJson(JsonNode json) { + Preconditions.checkArgument( + null != json, "Cannot parse register view request from null object"); + + String name = JsonUtil.getString(NAME, json); + String metadataLocation = JsonUtil.getString(METADATA_LOCATION, json); + + return ImmutableRegisterViewRequest.builder() + .name(name) + .metadataLocation(metadataLocation) + .build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequest.java b/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequest.java new file mode 100644 index 000000000000..561007c480eb --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequest.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.requests; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import org.apache.iceberg.rest.RESTRequest; +import org.immutables.value.Value; + +@Value.Immutable +public interface RemoteSignRequest extends RESTRequest { + String region(); + + String method(); + + URI uri(); + + Map> headers(); + + Map properties(); + + @Value.Default + @Nullable + default String body() { + return null; + } + + @Nullable + String provider(); + + @Override + default void validate() {} +} diff --git a/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequestParser.java b/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequestParser.java new file mode 100644 index 000000000000..61b44cc177d1 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/requests/RemoteSignRequestParser.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.requests; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.JsonUtil; + +public class RemoteSignRequestParser { + + private static final String REGION = "region"; + private static final String METHOD = "method"; + private static final String URI = "uri"; + private static final String HEADERS = "headers"; + private static final String PROPERTIES = "properties"; + private static final String BODY = "body"; + private static final String PROVIDER = "provider"; + + private RemoteSignRequestParser() {} + + public static String toJson(RemoteSignRequest request) { + return toJson(request, false); + } + + public static String toJson(RemoteSignRequest request, boolean pretty) { + return JsonUtil.generate(gen -> toJson(request, gen), pretty); + } + + public static void toJson(RemoteSignRequest request, JsonGenerator gen) throws IOException { + Preconditions.checkArgument(null != request, "Invalid remote sign request: null"); + + gen.writeStartObject(); + + gen.writeStringField(REGION, request.region()); + gen.writeStringField(METHOD, request.method()); + gen.writeStringField(URI, request.uri().toString()); + headersToJson(HEADERS, request.headers(), gen); + + if (!request.properties().isEmpty()) { + JsonUtil.writeStringMap(PROPERTIES, request.properties(), gen); + } + + if (request.body() != null && !request.body().isEmpty()) { + gen.writeStringField(BODY, request.body()); + } + + if (request.provider() != null) { + gen.writeStringField(PROVIDER, request.provider()); + } + + gen.writeEndObject(); + } + + public static RemoteSignRequest fromJson(String json) { + return JsonUtil.parse(json, RemoteSignRequestParser::fromJson); + } + + public static RemoteSignRequest fromJson(JsonNode json) { + Preconditions.checkArgument(null != json, "Cannot parse remote sign request from null object"); + Preconditions.checkArgument( + json.isObject(), "Cannot parse remote sign request from non-object: %s", json); + + String region = JsonUtil.getString(REGION, json); + String method = JsonUtil.getString(METHOD, json); + java.net.URI uri = java.net.URI.create(JsonUtil.getString(URI, json)); + Map> headers = headersFromJson(HEADERS, json); + + ImmutableRemoteSignRequest.Builder builder = + ImmutableRemoteSignRequest.builder() + .region(region) + .method(method) + .uri(uri) + .headers(headers); + + if (json.has(PROPERTIES)) { + builder.properties(JsonUtil.getStringMap(PROPERTIES, json)); + } + + if (json.has(BODY)) { + builder.body(JsonUtil.getString(BODY, json)); + } + + if (json.has(PROVIDER)) { + builder.provider(JsonUtil.getString(PROVIDER, json)); + } + + return builder.build(); + } + + public static void headersToJson( + String property, Map> headers, JsonGenerator gen) throws IOException { + gen.writeObjectFieldStart(property); + for (Entry> entry : headers.entrySet()) { + gen.writeFieldName(entry.getKey()); + + gen.writeStartArray(); + for (String val : entry.getValue()) { + gen.writeString(val); + } + gen.writeEndArray(); + } + gen.writeEndObject(); + } + + public static Map> headersFromJson(String property, JsonNode json) { + Map> headers = Maps.newHashMap(); + JsonNode headersNode = JsonUtil.get(property, json); + headersNode + .properties() + .forEach( + entry -> { + String key = entry.getKey(); + List values = Arrays.asList(JsonUtil.getStringArray(entry.getValue())); + headers.put(key, values); + }); + return headers; + } +} diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/BaseScanTaskResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/BaseScanTaskResponse.java index f98a24cf7970..e83cacc48459 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/BaseScanTaskResponse.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/BaseScanTaskResponse.java @@ -23,13 +23,15 @@ import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.rest.RESTResponse; +import org.apache.iceberg.util.DeleteFileSet; public abstract class BaseScanTaskResponse implements RESTResponse { private final List planTasks; private final List fileScanTasks; - private final List deleteFiles; + private final DeleteFileSet deleteFiles; private final Map specsById; protected BaseScanTaskResponse( @@ -39,7 +41,7 @@ protected BaseScanTaskResponse( Map specsById) { this.planTasks = planTasks; this.fileScanTasks = fileScanTasks; - this.deleteFiles = deleteFiles; + this.deleteFiles = deleteFiles == null ? null : DeleteFileSet.of(deleteFiles); this.specsById = specsById; } @@ -52,7 +54,7 @@ public List fileScanTasks() { } public List deleteFiles() { - return deleteFiles; + return deleteFiles == null ? null : Lists.newArrayList(deleteFiles.iterator()); } /** @@ -66,7 +68,7 @@ public Map specsById() { public abstract static class Builder, R extends BaseScanTaskResponse> { private List planTasks; private List fileScanTasks; - private List deleteFiles; + private DeleteFileSet deleteFiles; private Map specsById; protected Builder() {} @@ -83,11 +85,20 @@ public B withPlanTasks(List tasks) { public B withFileScanTasks(List tasks) { this.fileScanTasks = tasks; + if (fileScanTasks != null) { + this.deleteFiles = + DeleteFileSet.of( + () -> tasks.stream().flatMap(task -> task.deletes().stream()).iterator()); + } return self(); } + /** + * @deprecated since 1.11.0, will be removed in 1.12.0. + */ + @Deprecated public B withDeleteFiles(List deleteFilesList) { - this.deleteFiles = deleteFilesList; + this.deleteFiles = DeleteFileSet.of(deleteFilesList); return self(); } @@ -108,8 +119,12 @@ public List fileScanTasks() { return fileScanTasks; } + /** + * @deprecated since 1.11.0, visibility will be reduced in 1.12.0. + */ + @Deprecated public List deleteFiles() { - return deleteFiles; + return deleteFiles == null ? null : Lists.newArrayList(deleteFiles.iterator()); } /** diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/ErrorResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/ErrorResponseParser.java index 31ad0573b107..1329c074ab29 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/ErrorResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/ErrorResponseParser.java @@ -46,9 +46,12 @@ public static String toJson(ErrorResponse errorResponse, boolean pretty) { public static void toJson(ErrorResponse errorResponse, JsonGenerator generator) throws IOException { generator.writeStartObject(); + writeError(errorResponse, generator); + generator.writeEndObject(); + } + static void writeError(ErrorResponse errorResponse, JsonGenerator generator) throws IOException { generator.writeObjectFieldStart(ERROR); - generator.writeStringField(MESSAGE, errorResponse.message()); generator.writeStringField(TYPE, errorResponse.type()); generator.writeNumberField(CODE, errorResponse.code()); @@ -57,8 +60,6 @@ public static void toJson(ErrorResponse errorResponse, JsonGenerator generator) } generator.writeEndObject(); - - generator.writeEndObject(); } /** diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponse.java index 05d64a235891..2e176aac653f 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponse.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponse.java @@ -24,19 +24,28 @@ import org.apache.iceberg.FileScanTask; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.rest.PlanStatus; +import org.apache.iceberg.rest.credentials.Credential; public class FetchPlanningResultResponse extends BaseScanTaskResponse { private final PlanStatus planStatus; + private final ErrorResponse errorResponse; + private final List credentials; private FetchPlanningResultResponse( PlanStatus planStatus, + ErrorResponse errorResponse, List planTasks, List fileScanTasks, List deleteFiles, - Map specsById) { + Map specsById, + List credentials) { super(planTasks, fileScanTasks, deleteFiles, specsById); this.planStatus = planStatus; + this.errorResponse = errorResponse; + this.credentials = credentials; validate(); } @@ -44,6 +53,14 @@ public PlanStatus planStatus() { return planStatus; } + public ErrorResponse errorResponse() { + return errorResponse; + } + + public List credentials() { + return credentials != null ? credentials : ImmutableList.of(); + } + public static Builder builder() { return new Builder(); } @@ -54,6 +71,9 @@ public void validate() { Preconditions.checkArgument( planStatus() == PlanStatus.COMPLETED || (planTasks() == null && fileScanTasks() == null), "Invalid response: tasks can only be returned in a 'completed' status"); + Preconditions.checkArgument( + planStatus() == PlanStatus.FAILED || errorResponse() == null, + "Invalid response: error can only be returned in a 'failed' status"); if (fileScanTasks() == null || fileScanTasks().isEmpty()) { Preconditions.checkArgument( (deleteFiles() == null || deleteFiles().isEmpty()), @@ -66,16 +86,34 @@ public static class Builder private Builder() {} private PlanStatus planStatus; + private ErrorResponse errorResponse; + private final List credentials = Lists.newArrayList(); public Builder withPlanStatus(PlanStatus status) { this.planStatus = status; return this; } + public Builder withErrorResponse(ErrorResponse response) { + this.errorResponse = response; + return this; + } + + public Builder withCredentials(List credentialsToAdd) { + credentials.addAll(credentialsToAdd); + return this; + } + @Override public FetchPlanningResultResponse build() { return new FetchPlanningResultResponse( - planStatus, planTasks(), fileScanTasks(), deleteFiles(), specsById()); + planStatus, + errorResponse, + planTasks(), + fileScanTasks(), + deleteFiles(), + specsById(), + credentials); } } } diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponseParser.java index 2ce416a0c39c..aa74049ab9f0 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/FetchPlanningResultResponseParser.java @@ -30,11 +30,15 @@ import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.rest.PlanStatus; import org.apache.iceberg.rest.TableScanResponseParser; +import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.credentials.CredentialParser; import org.apache.iceberg.util.JsonUtil; public class FetchPlanningResultResponseParser { private static final String STATUS = "status"; private static final String PLAN_TASKS = "plan-tasks"; + private static final String STORAGE_CREDENTIALS = "storage-credentials"; + private static final String ERROR = "error"; private FetchPlanningResultResponseParser() {} @@ -55,10 +59,24 @@ public static void toJson(FetchPlanningResultResponse response, JsonGenerator ge "Cannot serialize fileScanTasks in fetchingPlanningResultResponse without specsById"); gen.writeStartObject(); gen.writeStringField(STATUS, response.planStatus().status()); + + if (response.errorResponse() != null) { + ErrorResponseParser.writeError(response.errorResponse(), gen); + } + if (response.planTasks() != null) { JsonUtil.writeStringArray(PLAN_TASKS, response.planTasks(), gen); } + if (!response.credentials().isEmpty()) { + gen.writeArrayFieldStart(STORAGE_CREDENTIALS); + for (Credential credential : response.credentials()) { + CredentialParser.toJson(credential, gen); + } + + gen.writeEndArray(); + } + TableScanResponseParser.serializeScanTasks( response.fileScanTasks(), response.deleteFiles(), response.specsById(), gen); gen.writeEndObject(); @@ -78,16 +96,28 @@ public static FetchPlanningResultResponse fromJson( json != null && !json.isEmpty(), "Invalid fetchPlanningResult response: null or empty"); PlanStatus planStatus = PlanStatus.fromName(JsonUtil.getString(STATUS, json)); + ErrorResponse errorResponse = null; + if (json.has(ERROR) && json.get(ERROR).isObject()) { + errorResponse = ErrorResponseParser.fromJson(json); + } + List planTasks = JsonUtil.getStringListOrNull(PLAN_TASKS, json); List deleteFiles = TableScanResponseParser.parseDeleteFiles(json, specsById); List fileScanTasks = TableScanResponseParser.parseFileScanTasks(json, deleteFiles, specsById, caseSensitive); - return FetchPlanningResultResponse.builder() - .withPlanStatus(planStatus) - .withPlanTasks(planTasks) - .withFileScanTasks(fileScanTasks) - .withDeleteFiles(deleteFiles) - .withSpecsById(specsById) - .build(); + + FetchPlanningResultResponse.Builder builder = + FetchPlanningResultResponse.builder() + .withPlanStatus(planStatus) + .withErrorResponse(errorResponse) + .withPlanTasks(planTasks) + .withFileScanTasks(fileScanTasks) + .withSpecsById(specsById); + + if (json.hasNonNull(STORAGE_CREDENTIALS)) { + builder.withCredentials(LoadCredentialsResponseParser.fromJson(json).credentials()); + } + + return builder.build(); } } diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/FetchScanTasksResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/FetchScanTasksResponseParser.java index 14ef77b39f68..e1a4d8117caf 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/FetchScanTasksResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/FetchScanTasksResponseParser.java @@ -79,7 +79,6 @@ public static FetchScanTasksResponse fromJson( .withPlanTasks(planTasks) .withSpecsById(specsById) .withFileScanTasks(fileScanTasks) - .withDeleteFiles(deleteFiles) .build(); } } diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java index 1b4bb86e65eb..d0ac222c3052 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponse.java @@ -33,11 +33,13 @@ public class PlanTableScanResponse extends BaseScanTaskResponse { private final PlanStatus planStatus; private final String planId; + private final ErrorResponse errorResponse; private final List credentials; private PlanTableScanResponse( PlanStatus planStatus, String planId, + ErrorResponse errorResponse, List planTasks, List fileScanTasks, List deleteFiles, @@ -46,6 +48,7 @@ private PlanTableScanResponse( super(planTasks, fileScanTasks, deleteFiles, specsById); this.planStatus = planStatus; this.planId = planId; + this.errorResponse = errorResponse; this.credentials = credentials; validate(); } @@ -58,6 +61,10 @@ public String planId() { return planId; } + public ErrorResponse errorResponse() { + return errorResponse; + } + public List credentials() { return credentials != null ? credentials : ImmutableList.of(); } @@ -86,6 +93,10 @@ public void validate() { planStatus() == PlanStatus.COMPLETED || (planTasks() == null && fileScanTasks() == null), "Invalid response: tasks can only be defined when status is '%s'", PlanStatus.COMPLETED.status()); + Preconditions.checkArgument( + planStatus() == PlanStatus.FAILED || errorResponse() == null, + "Invalid response: error can only be defined when status is '%s'", + PlanStatus.FAILED.status()); if (null != planId()) { Preconditions.checkArgument( planStatus() == PlanStatus.SUBMITTED || planStatus() == PlanStatus.COMPLETED, @@ -108,6 +119,7 @@ public static Builder builder() { public static class Builder extends BaseScanTaskResponse.Builder { private PlanStatus planStatus; private String planId; + private ErrorResponse errorResponse; private final List credentials = Lists.newArrayList(); /** @@ -127,6 +139,11 @@ public Builder withPlanId(String id) { return this; } + public Builder withErrorResponse(ErrorResponse response) { + this.errorResponse = response; + return this; + } + public Builder withCredentials(List credentialsToAdd) { credentials.addAll(credentialsToAdd); return this; @@ -137,6 +154,7 @@ public PlanTableScanResponse build() { return new PlanTableScanResponse( planStatus, planId, + errorResponse, planTasks(), fileScanTasks(), deleteFiles(), diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponseParser.java index df2ad94a3238..8ca199397ea6 100644 --- a/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponseParser.java +++ b/core/src/main/java/org/apache/iceberg/rest/responses/PlanTableScanResponseParser.java @@ -39,6 +39,7 @@ public class PlanTableScanResponseParser { private static final String PLAN_ID = "plan-id"; private static final String PLAN_TASKS = "plan-tasks"; private static final String STORAGE_CREDENTIALS = "storage-credentials"; + private static final String ERROR = "error"; private PlanTableScanResponseParser() {} @@ -60,6 +61,10 @@ public static void toJson(PlanTableScanResponse response, JsonGenerator gen) thr gen.writeStartObject(); gen.writeStringField(STATUS, response.planStatus().status()); + if (response.errorResponse() != null) { + ErrorResponseParser.writeError(response.errorResponse(), gen); + } + if (response.planId() != null) { gen.writeStringField(PLAN_ID, response.planId()); } @@ -98,6 +103,11 @@ public static PlanTableScanResponse fromJson( "Cannot parse planTableScan response from empty or null object"); PlanStatus planStatus = PlanStatus.fromName(JsonUtil.getString(STATUS, json)); + ErrorResponse errorResponse = null; + if (json.has(ERROR) && json.get(ERROR).isObject()) { + errorResponse = ErrorResponseParser.fromJson(json); + } + String planId = JsonUtil.getStringOrNull(PLAN_ID, json); List planTasks = JsonUtil.getStringListOrNull(PLAN_TASKS, json); List deleteFiles = TableScanResponseParser.parseDeleteFiles(json, specsById); @@ -108,9 +118,9 @@ public static PlanTableScanResponse fromJson( PlanTableScanResponse.builder() .withPlanId(planId) .withPlanStatus(planStatus) + .withErrorResponse(errorResponse) .withPlanTasks(planTasks) .withFileScanTasks(fileScanTasks) - .withDeleteFiles(deleteFiles) .withSpecsById(specsById); if (json.hasNonNull(STORAGE_CREDENTIALS)) { diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/RemoteSignResponse.java b/core/src/main/java/org/apache/iceberg/rest/responses/RemoteSignResponse.java new file mode 100644 index 000000000000..c5009505bf4f --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/responses/RemoteSignResponse.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.responses; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.rest.RESTResponse; +import org.immutables.value.Value; + +@Value.Immutable +public interface RemoteSignResponse extends RESTResponse { + URI uri(); + + Map> headers(); + + @Override + default void validate() {} +} diff --git a/core/src/main/java/org/apache/iceberg/rest/responses/RemoteSignResponseParser.java b/core/src/main/java/org/apache/iceberg/rest/responses/RemoteSignResponseParser.java new file mode 100644 index 000000000000..f53e844c6162 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/rest/responses/RemoteSignResponseParser.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.responses; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.rest.requests.RemoteSignRequestParser; +import org.apache.iceberg.util.JsonUtil; + +public class RemoteSignResponseParser { + + private static final String URI_FIELD = "uri"; + private static final String HEADERS = "headers"; + + private RemoteSignResponseParser() {} + + public static String toJson(RemoteSignResponse response) { + return toJson(response, false); + } + + public static String toJson(RemoteSignResponse response, boolean pretty) { + return JsonUtil.generate(gen -> toJson(response, gen), pretty); + } + + public static void toJson(RemoteSignResponse response, JsonGenerator gen) throws IOException { + Preconditions.checkArgument(null != response, "Invalid remote sign response: null"); + + gen.writeStartObject(); + + gen.writeStringField(URI_FIELD, response.uri().toString()); + RemoteSignRequestParser.headersToJson(HEADERS, response.headers(), gen); + + gen.writeEndObject(); + } + + public static RemoteSignResponse fromJson(String json) { + return JsonUtil.parse(json, RemoteSignResponseParser::fromJson); + } + + public static RemoteSignResponse fromJson(JsonNode json) { + Preconditions.checkArgument(null != json, "Cannot parse remote sign response from null object"); + Preconditions.checkArgument( + json.isObject(), "Cannot parse remote sign response from non-object: %s", json); + + URI uri = URI.create(JsonUtil.getString(URI_FIELD, json)); + Map> headers = RemoteSignRequestParser.headersFromJson(HEADERS, json); + + return ImmutableRemoteSignResponse.builder().uri(uri).headers(headers).build(); + } +} diff --git a/core/src/main/java/org/apache/iceberg/util/BinPacking.java b/core/src/main/java/org/apache/iceberg/util/BinPacking.java index f3160389ca6c..db31b63974fe 100644 --- a/core/src/main/java/org/apache/iceberg/util/BinPacking.java +++ b/core/src/main/java/org/apache/iceberg/util/BinPacking.java @@ -36,11 +36,18 @@ public static class ListPacker { private final long targetWeight; private final int lookback; private final boolean largestBinFirst; + private final long maxItemsPerBin; public ListPacker(long targetWeight, int lookback, boolean largestBinFirst) { + this(targetWeight, lookback, largestBinFirst, Long.MAX_VALUE); + } + + public ListPacker( + long targetWeight, int lookback, boolean largestBinFirst, long maxItemsPerBin) { this.targetWeight = targetWeight; this.lookback = lookback; this.largestBinFirst = largestBinFirst; + this.maxItemsPerBin = maxItemsPerBin; } public List> packEnd(List items, Function weightFunc) { @@ -48,13 +55,19 @@ public List> packEnd(List items, Function weightFunc) { ImmutableList.copyOf( Iterables.transform( new PackingIterable<>( - Lists.reverse(items), targetWeight, lookback, weightFunc, largestBinFirst), + Lists.reverse(items), + targetWeight, + lookback, + weightFunc, + largestBinFirst, + maxItemsPerBin), Lists::reverse))); } public List> pack(Iterable items, Function weightFunc) { return ImmutableList.copyOf( - new PackingIterable<>(items, targetWeight, lookback, weightFunc, largestBinFirst)); + new PackingIterable<>( + items, targetWeight, lookback, weightFunc, largestBinFirst, maxItemsPerBin)); } } @@ -62,12 +75,13 @@ public static class PackingIterable implements Iterable> { private final Iterable iterable; private final long targetWeight; private final int lookback; + private final long maxSize; private final Function weightFunc; private final boolean largestBinFirst; public PackingIterable( Iterable iterable, long targetWeight, int lookback, Function weightFunc) { - this(iterable, targetWeight, lookback, weightFunc, false); + this(iterable, targetWeight, lookback, weightFunc, false, Long.MAX_VALUE); } public PackingIterable( @@ -76,11 +90,22 @@ public PackingIterable( int lookback, Function weightFunc, boolean largestBinFirst) { + this(iterable, targetWeight, lookback, weightFunc, largestBinFirst, Long.MAX_VALUE); + } + + public PackingIterable( + Iterable iterable, + long targetWeight, + int lookback, + Function weightFunc, + boolean largestBinFirst, + long maxSize) { Preconditions.checkArgument( lookback > 0, "Bin look-back size must be greater than 0: %s", lookback); this.iterable = iterable; this.targetWeight = targetWeight; this.lookback = lookback; + this.maxSize = maxSize; this.weightFunc = weightFunc; this.largestBinFirst = largestBinFirst; } @@ -88,7 +113,7 @@ public PackingIterable( @Override public Iterator> iterator() { return new PackingIterator<>( - iterable.iterator(), targetWeight, lookback, weightFunc, largestBinFirst); + iterable.iterator(), targetWeight, lookback, maxSize, weightFunc, largestBinFirst); } } @@ -97,6 +122,7 @@ private static class PackingIterator implements Iterator> { private final Iterator items; private final long targetWeight; private final int lookback; + private final long maxSize; private final Function weightFunc; private final boolean largestBinFirst; @@ -104,11 +130,13 @@ private PackingIterator( Iterator items, long targetWeight, int lookback, + long maxSize, Function weightFunc, boolean largestBinFirst) { this.items = items; this.targetWeight = targetWeight; this.lookback = lookback; + this.maxSize = maxSize; this.weightFunc = weightFunc; this.largestBinFirst = largestBinFirst; } @@ -163,7 +191,7 @@ private Bin findBin(long weight) { } private Bin newBin() { - return new Bin<>(targetWeight); + return new Bin<>(targetWeight, maxSize); } private static Bin removeLargestBin(Collection> bins) { @@ -181,11 +209,14 @@ private static Bin removeLargestBin(Collection> bins) { private static class Bin { private final long targetWeight; + private final long maxSize; private final List items = Lists.newArrayList(); private long binWeight = 0L; + private int binSize = 0; - Bin(long targetWeight) { + Bin(long targetWeight, long maxSize) { this.targetWeight = targetWeight; + this.maxSize = maxSize; } List items() { @@ -193,11 +224,12 @@ List items() { } boolean canAdd(long weight) { - return binWeight + weight <= targetWeight; + return binWeight + weight <= targetWeight && binSize < maxSize; } void add(T item, long weight) { this.binWeight += weight; + this.binSize++; items.add(item); } diff --git a/core/src/main/java/org/apache/iceberg/util/HashWriter.java b/core/src/main/java/org/apache/iceberg/util/HashWriter.java new file mode 100644 index 000000000000..2d0668fe8283 --- /dev/null +++ b/core/src/main/java/org/apache/iceberg/util/HashWriter.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.util; + +import java.io.IOException; +import java.io.Writer; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * {@link java.io.Writer} implementation that uses a hashing function to produce a hash value based + * on the streamed bytes. The output of the writer is not preserved. + */ +public class HashWriter extends Writer { + + private final MessageDigest digest; + private final CharsetEncoder encoder; + private boolean isClosed = false; + + public HashWriter(String hashAlgorithm, Charset charset) throws NoSuchAlgorithmException { + this.digest = MessageDigest.getInstance(hashAlgorithm); + this.encoder = charset.newEncoder(); + } + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + ensureNotClosed(); + CharBuffer chars = CharBuffer.wrap(cbuf, off, len); + ByteBuffer byteBuffer = encoder.encode(chars); + digest.update(byteBuffer); + } + + @Override + public void flush() throws IOException {} + + @Override + public void close() throws IOException { + isClosed = true; + } + + /** + * Calculates the final hash value. The underlying digest will be reset thus subsequent getHash() + * calls are not permitted. + * + * @return bytes of final hash value + */ + public byte[] getHash() { + ensureNotClosed(); + isClosed = true; + return digest.digest(); + } + + private void ensureNotClosed() { + if (isClosed) { + throw new IllegalStateException("HashWriter is closed."); + } + } +} diff --git a/core/src/main/java/org/apache/iceberg/util/LocationUtil.java b/core/src/main/java/org/apache/iceberg/util/LocationUtil.java index 400307149238..4c0d401c74b9 100644 --- a/core/src/main/java/org/apache/iceberg/util/LocationUtil.java +++ b/core/src/main/java/org/apache/iceberg/util/LocationUtil.java @@ -18,6 +18,8 @@ */ package org.apache.iceberg.util; +import java.util.UUID; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.base.Strings; @@ -33,4 +35,26 @@ public static String stripTrailingSlash(String path) { } return result; } + + /** + * Returns a path component derived from the {@code tableIdentifier}, used as part of the table + * location URI. + * + *

If {@code useUniqueLocation} is {@code true}, the returned component will include a random + * UUID suffix. Otherwise, the plain table name is returned. + * + * @param tableIdentifier Iceberg table identifier + * @param useUniqueLocation whether to ensure uniqueness + * @return a string representing the table name component for a location URI + */ + public static String tableLocation(TableIdentifier tableIdentifier, boolean useUniqueLocation) { + Preconditions.checkArgument(null != tableIdentifier, "Invalid identifier: null"); + + if (useUniqueLocation) { + String uniqueSuffix = UUID.randomUUID().toString().replace("-", ""); + return String.format("%s-%s", tableIdentifier.name(), uniqueSuffix); + } else { + return tableIdentifier.name(); + } + } } diff --git a/core/src/main/java/org/apache/iceberg/util/LockManagers.java b/core/src/main/java/org/apache/iceberg/util/LockManagers.java index 96622cb57f83..561d0a8975dd 100644 --- a/core/src/main/java/org/apache/iceberg/util/LockManagers.java +++ b/core/src/main/java/org/apache/iceberg/util/LockManagers.java @@ -18,11 +18,9 @@ */ package org.apache.iceberg.util; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -108,6 +106,11 @@ public int heartbeatThreads() { return heartbeatThreads; } + /** + * Returns the shared scheduler for lock heartbeats. + * + *

Callers must not shut down this scheduler. It is shared across lock manager instances. + */ public ScheduledExecutorService scheduler() { if (scheduler == null) { synchronized (BaseLockManager.class) { @@ -159,16 +162,10 @@ public void initialize(Map properties) { @Override public void close() throws Exception { - if (scheduler != null) { - List tasks = scheduler.shutdownNow(); - tasks.forEach( - task -> { - if (task instanceof Future) { - ((Future) task).cancel(true); - } - }); - scheduler = null; - } + // The scheduler is a shared static resource used across all BaseLockManager instances. + // Individual instances must not shut it down, as other instances may still be using it. + // The scheduler uses daemon threads and will be terminated at JVM exit by the shutdown + // hook registered via MoreExecutors.getExitingScheduledExecutorService. } } diff --git a/core/src/main/java/org/apache/iceberg/util/PropertyUtil.java b/core/src/main/java/org/apache/iceberg/util/PropertyUtil.java index 633b0a6ae739..b82e5ed2751a 100644 --- a/core/src/main/java/org/apache/iceberg/util/PropertyUtil.java +++ b/core/src/main/java/org/apache/iceberg/util/PropertyUtil.java @@ -168,7 +168,7 @@ public static Map propertiesWithPrefix( return properties.entrySet().stream() .filter(e -> e.getKey().startsWith(prefix)) - .collect(Collectors.toMap(e -> e.getKey().replaceFirst(prefix, ""), Map.Entry::getValue)); + .collect(Collectors.toMap(e -> e.getKey().substring(prefix.length()), Map.Entry::getValue)); } /** @@ -191,6 +191,21 @@ public static Map filterProperties( .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } + public static Map mergeProperties( + Map properties, Map overrides) { + if (overrides == null || overrides.isEmpty()) { + return properties; + } + + if (properties == null || properties.isEmpty()) { + return overrides; + } + + Map merged = Maps.newHashMap(properties); + merged.putAll(overrides); + return merged; + } + public static Map applySchemaChanges( Map properties, List deletedColumns, diff --git a/core/src/main/java/org/apache/iceberg/util/SerializationUtil.java b/core/src/main/java/org/apache/iceberg/util/SerializationUtil.java index 216f55eae30c..c65251abcfc4 100644 --- a/core/src/main/java/org/apache/iceberg/util/SerializationUtil.java +++ b/core/src/main/java/org/apache/iceberg/util/SerializationUtil.java @@ -43,7 +43,7 @@ private SerializationUtil() {} * @return serialized bytes */ public static byte[] serializeToBytes(Object obj) { - return serializeToBytes(obj, conf -> new SerializableConfiguration(conf)::get); + return serializeToBytes(obj, SerializableConfiguration::new); } /** diff --git a/core/src/main/java/org/apache/iceberg/util/SnapshotUtil.java b/core/src/main/java/org/apache/iceberg/util/SnapshotUtil.java index a040bd26786d..370bbfed336e 100644 --- a/core/src/main/java/org/apache/iceberg/util/SnapshotUtil.java +++ b/core/src/main/java/org/apache/iceberg/util/SnapshotUtil.java @@ -23,10 +23,12 @@ import java.util.NoSuchElementException; import java.util.Objects; import java.util.function.Function; +import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.DataFile; import org.apache.iceberg.HistoryEntry; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; @@ -283,8 +285,8 @@ private static Iterable toIds(Iterable snapshots) { } /** - * @deprecated will be removed in 2.0.0, use {@link #newFilesBetween(Long, long, Function, - * FileIO)} instead. + * @deprecated will be removed in 1.12.0, use {@link SnapshotChanges} with {@link + * #ancestorsBetween(long, Long, Function)} instead. */ @Deprecated public static List newFiles( @@ -309,6 +311,11 @@ public static List newFiles( return newFiles; } + /** + * @deprecated will be removed in 1.12.0, use {@link SnapshotChanges} with {@link + * #ancestorsBetween(long, Long, Function)} instead. + */ + @Deprecated public static CloseableIterable newFilesBetween( Long startSnapshotId, long endSnapshotId, Function lookup, FileIO io) { @@ -395,11 +402,18 @@ public static Long nullableSnapshotIdAsOfTime(Table table, long timestampMillis) /** * Returns the schema of the table for the specified snapshot. * + *

Note that metadata tables may support time travel but don't inherit the snapshot schema, + * unlike normal data scans. + * * @param table a {@link Table} * @param snapshotId the ID of the snapshot * @return the schema */ public static Schema schemaFor(Table table, long snapshotId) { + if (table instanceof BaseMetadataTable) { + return table.schema(); + } + Snapshot snapshot = table.snapshot(snapshotId); Preconditions.checkArgument(snapshot != null, "Cannot find snapshot with ID %s", snapshotId); Integer schemaId = snapshot.schemaId(); diff --git a/core/src/main/java/org/apache/iceberg/util/SortOrderUtil.java b/core/src/main/java/org/apache/iceberg/util/SortOrderUtil.java index 37e0c1fffab0..654823993776 100644 --- a/core/src/main/java/org/apache/iceberg/util/SortOrderUtil.java +++ b/core/src/main/java/org/apache/iceberg/util/SortOrderUtil.java @@ -46,6 +46,23 @@ public static SortOrder buildSortOrder(Table table, SortOrder sortOrder) { return buildSortOrder(table.schema(), table.spec(), sortOrder); } + /** + * Attempts to match a user-supplied {@link SortOrder} with an equivalent sort order from a {@link + * Table}. + * + * @param table the table to try and match the sort order against + * @param userSuppliedSortOrder the user supplied sort order to try and match with a table sort + * order + * @return the matching {@link SortOrder} from the table (with the orderId set) or {@link + * SortOrder#unsorted()} if no match is found. + */ + public static SortOrder findTableSortOrder(Table table, SortOrder userSuppliedSortOrder) { + return table.sortOrders().values().stream() + .filter(sortOrder -> sortOrder.sameOrder(userSuppliedSortOrder)) + .findFirst() + .orElseGet(SortOrder::unsorted); + } + /** * Build a final sort order that satisfies the clustering required by the partition spec. * diff --git a/core/src/main/java/org/apache/iceberg/util/StructLikeWrapper.java b/core/src/main/java/org/apache/iceberg/util/StructLikeWrapper.java index 28629706bf5e..2e71d2419185 100644 --- a/core/src/main/java/org/apache/iceberg/util/StructLikeWrapper.java +++ b/core/src/main/java/org/apache/iceberg/util/StructLikeWrapper.java @@ -88,7 +88,13 @@ public boolean equals(Object other) { return false; } - return comparator.compare(this.struct, that.struct) == 0; + try { + return comparator.compare(this.struct, that.struct) == 0; + } catch (RuntimeException e) { + // An exception may occur, for example, when struct is PartitionData and its type does not + // match its data. + return false; + } } @Override diff --git a/core/src/main/java/org/apache/iceberg/util/TableScanUtil.java b/core/src/main/java/org/apache/iceberg/util/TableScanUtil.java index 4d9dc0ec69af..6291acbf9c73 100644 --- a/core/src/main/java/org/apache/iceberg/util/TableScanUtil.java +++ b/core/src/main/java/org/apache/iceberg/util/TableScanUtil.java @@ -51,6 +51,10 @@ public class TableScanUtil { private TableScanUtil() {} + /** + * @deprecated since 1.11.0 and will be removed in 1.12.0 + */ + @Deprecated public static boolean hasDeletes(CombinedScanTask task) { return task.files().stream().anyMatch(TableScanUtil::hasDeletes); } @@ -58,7 +62,10 @@ public static boolean hasDeletes(CombinedScanTask task) { /** * This is temporarily introduced since we plan to support pos-delete vectorized read first, then * get to the equality-delete support. We will remove this method once both are supported. + * + * @deprecated since 1.11.0 and will be removed in 1.12.0 */ + @Deprecated public static boolean hasEqDeletes(CombinedScanTask task) { return task.files().stream() .anyMatch( diff --git a/core/src/main/java/org/apache/iceberg/util/ThreadPools.java b/core/src/main/java/org/apache/iceberg/util/ThreadPools.java index a96f020fe8aa..bb508295ecfa 100644 --- a/core/src/main/java/org/apache/iceberg/util/ThreadPools.java +++ b/core/src/main/java/org/apache/iceberg/util/ThreadPools.java @@ -35,8 +35,8 @@ public class ThreadPools { private ThreadPools() {} /** - * @deprecated Use {@link SystemConfigs#WORKER_THREAD_POOL_SIZE WORKER_THREAD_POOL_SIZE} instead; - * will be removed in 2.0.0 + * @deprecated Use {@link SystemConfigs#WORKER_THREAD_POOL_SIZE} instead. will be removed in + * 1.12.0 */ @Deprecated public static final String WORKER_THREAD_POOL_SIZE_PROP = @@ -113,7 +113,7 @@ private static class AuthRefreshPoolHolder { * either {@link #newExitingWorkerPool(String, int)} or {@link #newFixedThreadPool(String, int)}, * depending on the intended lifecycle of the thread pool. * - * @deprecated will be removed in 2.0.0. Use {@link #newExitingWorkerPool(String, int)} for + * @deprecated will be removed in 1.12.0. Use {@link #newExitingWorkerPool(String, int)} for * long-lived thread pools that require a shutdown hook, or {@link #newFixedThreadPool(String, * int)} for short-lived thread pools where you manage the lifecycle. */ @@ -134,7 +134,7 @@ public static ExecutorService newWorkerPool(String namePrefix) { * either {@link #newExitingWorkerPool(String, int)} or {@link #newFixedThreadPool(String, int)}, * depending on the intended lifecycle of the thread pool. * - * @deprecated will be removed in 2.0.0. Use {@link #newExitingWorkerPool(String, int)} for + * @deprecated will be removed in 1.12.0. Use {@link #newExitingWorkerPool(String, int)} for * long-lived thread pools that require a shutdown hook, or {@link #newFixedThreadPool(String, * int)} for short-lived thread pools where you manage the lifecycle. */ diff --git a/core/src/main/java/org/apache/iceberg/variants/ShreddedObject.java b/core/src/main/java/org/apache/iceberg/variants/ShreddedObject.java index c1fba073cfdf..471097e855c6 100644 --- a/core/src/main/java/org/apache/iceberg/variants/ShreddedObject.java +++ b/core/src/main/java/org/apache/iceberg/variants/ShreddedObject.java @@ -85,6 +85,7 @@ public int numFields() { public void remove(String field) { shreddedFields.remove(field); removedFields.add(field); + this.serializationState = null; } public void put(String field, VariantValue value) { @@ -153,12 +154,12 @@ private static class SerializationState { private SerializationState( VariantMetadata metadata, VariantObject unshredded, - Map shreddedFields, + Map shredded, Set removedFields) { this.metadata = metadata; // field ID size is the size needed to store the largest field ID in the data this.fieldIdSize = VariantUtil.sizeOf(metadata.dictionarySize()); - this.shreddedFields = Maps.newHashMap(shreddedFields); + this.shreddedFields = Maps.newHashMap(shredded); int totalDataSize = 0; // get the unshredded field names and values as byte buffers diff --git a/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java b/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java index 76e87711a9e9..1d8195ccd49f 100644 --- a/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java +++ b/core/src/main/java/org/apache/iceberg/view/BaseMetastoreViewCatalog.java @@ -298,4 +298,28 @@ public Transaction replaceTransaction() { return super.replaceTransaction(); } } + + @Override + public View registerView(TableIdentifier identifier, String metadataFileLocation) { + Preconditions.checkArgument( + identifier != null && isValidIdentifier(identifier), "Invalid identifier: %s", identifier); + Preconditions.checkArgument( + metadataFileLocation != null && !metadataFileLocation.isEmpty(), + "Cannot register an empty metadata file location as a view"); + + if (viewExists(identifier)) { + throw new AlreadyExistsException("View already exists: %s", identifier); + } + + if (tableExists(identifier)) { + throw new AlreadyExistsException("Table with same name already exists: %s", identifier); + } + + ViewOperations ops = newViewOps(identifier); + ViewMetadata metadata = + ViewMetadataParser.read(((BaseViewOperations) ops).io(), metadataFileLocation); + ops.commit(null, metadata); + + return new BaseView(ops, ViewUtil.fullViewName(name(), identifier)); + } } diff --git a/core/src/main/java/org/apache/iceberg/view/BaseViewOperations.java b/core/src/main/java/org/apache/iceberg/view/BaseViewOperations.java index e3f4f9c6acfd..0bab053bcb99 100644 --- a/core/src/main/java/org/apache/iceberg/view/BaseViewOperations.java +++ b/core/src/main/java/org/apache/iceberg/view/BaseViewOperations.java @@ -184,7 +184,7 @@ protected void refreshFromMetadataLocation( newLocation, shouldRetry, numRetries, - metadataLocation -> ViewMetadataParser.read(io().newInputFile(metadataLocation))); + metadataLocation -> ViewMetadataParser.read(io(), metadataLocation)); } protected void refreshFromMetadataLocation( diff --git a/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java b/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java index d91349beaccb..09384e1f18d1 100644 --- a/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java +++ b/core/src/main/java/org/apache/iceberg/view/ViewMetadataParser.java @@ -33,6 +33,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.TableMetadataParser.Codec; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -169,6 +170,10 @@ public static void write(ViewMetadata metadata, OutputFile outputFile) { internalWrite(metadata, outputFile, false); } + public static ViewMetadata read(FileIO io, String path) { + return read(io.newInputFile(path)); + } + public static ViewMetadata read(InputFile file) { Codec codec = Codec.fromFileName(file.location()); try (InputStream is = diff --git a/core/src/main/java/org/apache/iceberg/view/ViewProperties.java b/core/src/main/java/org/apache/iceberg/view/ViewProperties.java index e396beece0a6..bf276cf24f02 100644 --- a/core/src/main/java/org/apache/iceberg/view/ViewProperties.java +++ b/core/src/main/java/org/apache/iceberg/view/ViewProperties.java @@ -26,10 +26,7 @@ public class ViewProperties { public static final String METADATA_COMPRESSION = "write.metadata.compression-codec"; public static final String METADATA_COMPRESSION_DEFAULT = "gzip"; - /** - * @deprecated will be removed in 2.0.0, use {@link ViewBuilder#withLocation} instead. - */ - @Deprecated public static final String WRITE_METADATA_LOCATION = "write.metadata.path"; + public static final String WRITE_METADATA_LOCATION = "write.metadata.path"; public static final String COMMENT = "comment"; public static final String REPLACE_DROP_DIALECT_ALLOWED = "replace.drop-dialect.allowed"; diff --git a/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java b/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java index 298b657fbcbb..08849fe35124 100644 --- a/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java +++ b/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Function; import org.apache.iceberg.DeleteFileIndex.EqualityDeletes; import org.apache.iceberg.DeleteFileIndex.PositionDeletes; import org.apache.iceberg.exceptions.ValidationException; @@ -39,6 +40,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types; import org.apache.iceberg.util.CharSequenceSet; import org.apache.iceberg.util.ContentFileUtil; import org.junit.jupiter.api.TestTemplate; @@ -717,11 +719,12 @@ public void testEqualityDeletesGroup() { DeleteFile file3 = withDataSequenceNumber(3, partitionedEqDeletes(SPEC, FILE_A.partition())); DeleteFile file4 = withDataSequenceNumber(4, partitionedEqDeletes(SPEC, FILE_A.partition())); - EqualityDeletes group = new EqualityDeletes(); - group.add(SPEC, file4); - group.add(SPEC, file2); - group.add(SPEC, file1); - group.add(SPEC, file3); + Function fieldLookup = SCHEMA::findField; + EqualityDeletes group = new EqualityDeletes(fieldLookup); + group.add(file4); + group.add(file2); + group.add(file1); + group.add(file3); // the group must not be empty assertThat(group.isEmpty()).isFalse(); @@ -741,7 +744,7 @@ public void testEqualityDeletesGroup() { assertThat(group.filter(4, FILE_A)).isEqualTo(new DeleteFile[] {}); // it should not be possible to add more elements upon indexing - assertThatThrownBy(() -> group.add(SPEC, file1)) + assertThatThrownBy(() -> group.add(file1)) .isInstanceOf(IllegalStateException.class) .hasMessage("Can't add files upon indexing"); } diff --git a/core/src/test/java/org/apache/iceberg/FileGenerationUtil.java b/core/src/test/java/org/apache/iceberg/FileGenerationUtil.java index b08d43e572a2..1d57d96203b9 100644 --- a/core/src/test/java/org/apache/iceberg/FileGenerationUtil.java +++ b/core/src/test/java/org/apache/iceberg/FileGenerationUtil.java @@ -280,9 +280,13 @@ private static long generateContentLength() { } private static Pair generateBounds(PrimitiveType type, MetricsMode mode) { - Comparator cmp = Comparators.forType(type); Object value1 = generateBound(type, mode); Object value2 = generateBound(type, mode); + + if (value1 == null || value2 == null) { + return Pair.of(null, null); + } + Comparator cmp = Comparators.forType(type); if (cmp.compare(value1, value2) > 0) { ByteBuffer lowerBuffer = Conversions.toByteBuffer(type, value2); ByteBuffer upperBuffer = Conversions.toByteBuffer(type, value1); diff --git a/core/src/test/java/org/apache/iceberg/PartitionStatisticsScanTestBase.java b/core/src/test/java/org/apache/iceberg/PartitionStatisticsScanTestBase.java new file mode 100644 index 000000000000..321d6fedad5a --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/PartitionStatisticsScanTestBase.java @@ -0,0 +1,454 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.PartitionStatistics.EMPTY_PARTITION_FIELD; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Comparators; +import org.apache.iceberg.types.Types; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.Test; + +public abstract class PartitionStatisticsScanTestBase extends PartitionStatisticsTestBase { + + public abstract FileFormat format(); + + private final Map fileFormatProperty = + ImmutableMap.of(TableProperties.DEFAULT_FILE_FORMAT, format().name()); + + @Test + public void testEmptyTable() throws Exception { + Table testTable = + TestTables.create( + tempDir("scan_empty_table"), "scan_empty_table", SCHEMA, SPEC, 2, fileFormatProperty); + + assertThat(Lists.newArrayList(testTable.newPartitionStatisticsScan().scan())).isEmpty(); + } + + @Test + public void testInvalidSnapshotId() throws Exception { + Table testTable = + TestTables.create( + tempDir("scan_invalid_snapshot"), + "scan_invalid_snapshot", + SCHEMA, + SPEC, + 2, + fileFormatProperty); + + assertThatThrownBy(() -> testTable.newPartitionStatisticsScan().useSnapshot(1234L).scan()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot find snapshot with ID 1234"); + } + + @Test + public void testNoStatsForSnapshot() throws Exception { + Table testTable = + TestTables.create( + tempDir("scan_no_stats"), "scan_no_stats", SCHEMA, SPEC, 2, fileFormatProperty); + + DataFile dataFile = + DataFiles.builder(SPEC) + .withPath("some_path") + .withFileSizeInBytes(15) + .withFormat(format()) + .withRecordCount(1) + .build(); + testTable.newAppend().appendFile(dataFile).commit(); + long snapshotId = testTable.currentSnapshot().snapshotId(); + + assertThat(testTable.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()).isEmpty(); + } + + @Test + public void testReadingStatsWithInvalidSchema() throws Exception { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Table testTable = + TestTables.create( + tempDir("scan_with_old_schema"), + "scan_with_old_schema", + SCHEMA, + spec, + 2, + fileFormatProperty); + Types.StructType partitionType = Partitioning.partitionType(testTable); + Schema oldSchema = invalidOldSchema(partitionType); + + // Add a dummy file to the table to have a snapshot + DataFile dataFile = + DataFiles.builder(spec) + .withPath("some_path") + .withFileSizeInBytes(15) + .withFormat(FileFormat.PARQUET) + .withRecordCount(1) + .build(); + testTable.newAppend().appendFile(dataFile).commit(); + long snapshotId = testTable.currentSnapshot().snapshotId(); + + testTable + .updatePartitionStatistics() + .setPartitionStatistics( + PartitionStatsHandler.writePartitionStatsFile( + testTable, + snapshotId, + oldSchema, + Collections.singletonList(randomStats(partitionType)))) + .commit(); + + try (CloseableIterable recordIterator = + testTable.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()) { + + if (format() == FileFormat.PARQUET) { + assertThatThrownBy(() -> Lists.newArrayList(recordIterator)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Not a primitive type: struct"); + } else if (format() == FileFormat.AVRO) { + assertThatThrownBy(() -> Lists.newArrayList(recordIterator)) + .isInstanceOf(ClassCastException.class) + .hasMessageContaining("Integer cannot be cast to class org.apache.iceberg.StructLike"); + } + } + } + + @Test + public void testV2toV3SchemaEvolution() throws Exception { + Table testTable = + TestTables.create( + tempDir("scan_with_schema_evolution"), + "scan_with_schema_evolution", + SCHEMA, + SPEC, + 2, + fileFormatProperty); + + // write stats file using v2 schema + DataFile dataFile = + FileGenerationUtil.generateDataFile(testTable, TestHelpers.Row.of("foo", "A")); + testTable.newAppend().appendFile(dataFile).commit(); + + testTable + .updatePartitionStatistics() + .setPartitionStatistics( + PartitionStatsHandler.computeAndWriteStatsFile( + testTable, testTable.currentSnapshot().snapshotId())) + .commit(); + + Types.StructType partitionSchema = Partitioning.partitionType(testTable); + + // read with v2 schema + List partitionStatsV2; + try (CloseableIterable recordIterator = + testTable.newPartitionStatisticsScan().scan()) { + partitionStatsV2 = Lists.newArrayList(recordIterator); + } + + testTable.updateProperties().set(TableProperties.FORMAT_VERSION, "3").commit(); + + // read with v3 schema + List partitionStatsV3; + try (CloseableIterable recordIterator = + testTable.newPartitionStatisticsScan().scan()) { + partitionStatsV3 = Lists.newArrayList(recordIterator); + } + + assertThat(partitionStatsV2).hasSameSizeAs(partitionStatsV3); + Comparator comparator = Comparators.forType(partitionSchema); + for (int i = 0; i < partitionStatsV2.size(); i++) { + assertThat(isEqual(comparator, partitionStatsV2.get(i), partitionStatsV3.get(i))).isTrue(); + } + } + + @SuppressWarnings("checkstyle:MethodLength") + @Test + public void testScanPartitionStatsForCurrentSnapshot() throws Exception { + Table testTable = + TestTables.create( + tempDir("scan_partition_stats"), + "scan_partition_stats", + SCHEMA, + SPEC, + 2, + fileFormatProperty); + + DataFile dataFile1 = + FileGenerationUtil.generateDataFile(testTable, TestHelpers.Row.of("foo", "A")); + DataFile dataFile2 = + FileGenerationUtil.generateDataFile(testTable, TestHelpers.Row.of("foo", "B")); + DataFile dataFile3 = + FileGenerationUtil.generateDataFile(testTable, TestHelpers.Row.of("bar", "A")); + DataFile dataFile4 = + FileGenerationUtil.generateDataFile(testTable, TestHelpers.Row.of("bar", "B")); + + for (int i = 0; i < 3; i++) { + // insert same set of seven records thrice to have a new manifest files + testTable + .newAppend() + .appendFile(dataFile1) + .appendFile(dataFile2) + .appendFile(dataFile3) + .appendFile(dataFile4) + .commit(); + } + + Snapshot snapshot1 = testTable.currentSnapshot(); + Schema recordSchema = PartitionStatistics.schema(Partitioning.partitionType(testTable), 2); + + Types.StructType partitionType = + recordSchema.findField(EMPTY_PARTITION_FIELD.fieldId()).type().asStructType(); + computeAndValidatePartitionStats( + testTable, + testTable.currentSnapshot().snapshotId(), + Tuple.tuple( + partitionRecord(partitionType, "foo", "A"), + 0, + 3 * dataFile1.recordCount(), + 3, + 3 * dataFile1.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + snapshot1.timestampMillis(), + snapshot1.snapshotId(), + null), + Tuple.tuple( + partitionRecord(partitionType, "foo", "B"), + 0, + 3 * dataFile2.recordCount(), + 3, + 3 * dataFile2.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + snapshot1.timestampMillis(), + snapshot1.snapshotId(), + null), + Tuple.tuple( + partitionRecord(partitionType, "bar", "A"), + 0, + 3 * dataFile3.recordCount(), + 3, + 3 * dataFile3.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + snapshot1.timestampMillis(), + snapshot1.snapshotId(), + null), + Tuple.tuple( + partitionRecord(partitionType, "bar", "B"), + 0, + 3 * dataFile4.recordCount(), + 3, + 3 * dataFile4.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + snapshot1.timestampMillis(), + snapshot1.snapshotId(), + null)); + + DeleteFile posDelete = + FileGenerationUtil.generatePositionDeleteFile(testTable, TestHelpers.Row.of("bar", "A")); + testTable.newRowDelta().addDeletes(posDelete).commit(); + // snapshot2 is unused in the result as same partition was updated by snapshot4 + + DeleteFile eqDelete = + FileGenerationUtil.generateEqualityDeleteFile(testTable, TestHelpers.Row.of("foo", "A")); + testTable.newRowDelta().addDeletes(eqDelete).commit(); + Snapshot snapshot3 = testTable.currentSnapshot(); + + testTable.updateProperties().set(TableProperties.FORMAT_VERSION, "3").commit(); + DeleteFile dv = FileGenerationUtil.generateDV(testTable, dataFile3); + testTable.newRowDelta().addDeletes(dv).commit(); + Snapshot snapshot4 = testTable.currentSnapshot(); + + computeAndValidatePartitionStats( + testTable, + testTable.currentSnapshot().snapshotId(), + Tuple.tuple( + partitionRecord(partitionType, "foo", "A"), + 0, + 3 * dataFile1.recordCount(), + 3, + 3 * dataFile1.fileSizeInBytes(), + 0L, + 0, + eqDelete.recordCount(), + 1, + null, + snapshot3.timestampMillis(), + snapshot3.snapshotId(), + 0), + Tuple.tuple( + partitionRecord(partitionType, "foo", "B"), + 0, + 3 * dataFile2.recordCount(), + 3, + 3 * dataFile2.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + snapshot1.timestampMillis(), + snapshot1.snapshotId(), + 0), + Tuple.tuple( + partitionRecord(partitionType, "bar", "A"), + 0, + 3 * dataFile3.recordCount(), + 3, + 3 * dataFile3.fileSizeInBytes(), + posDelete.recordCount() + dv.recordCount(), + 1, + 0L, + 0, + null, + snapshot4.timestampMillis(), + snapshot4.snapshotId(), + 1), // dv count + Tuple.tuple( + partitionRecord(partitionType, "bar", "B"), + 0, + 3 * dataFile4.recordCount(), + 3, + 3 * dataFile4.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + snapshot1.timestampMillis(), + snapshot1.snapshotId(), + 0)); + } + + @Test + public void testScanPartitionStatsForOlderSnapshot() throws Exception { + Table testTable = + TestTables.create( + tempDir("scan_older_snapshot"), + "scan_older_snapshot", + SCHEMA, + SPEC, + 2, + fileFormatProperty); + + DataFile dataFile1 = + FileGenerationUtil.generateDataFile(testTable, TestHelpers.Row.of("foo", "A")); + DataFile dataFile2 = + FileGenerationUtil.generateDataFile(testTable, TestHelpers.Row.of("foo", "B")); + + testTable.newAppend().appendFile(dataFile1).appendFile(dataFile2).commit(); + + Snapshot firstSnapshot = testTable.currentSnapshot(); + + testTable.newAppend().appendFile(dataFile1).appendFile(dataFile2).commit(); + + Schema recordSchema = PartitionStatistics.schema(Partitioning.partitionType(testTable), 2); + + Types.StructType partitionType = + recordSchema.findField(EMPTY_PARTITION_FIELD.fieldId()).type().asStructType(); + + computeAndValidatePartitionStats( + testTable, + firstSnapshot.snapshotId(), + Tuple.tuple( + partitionRecord(partitionType, "foo", "A"), + 0, + dataFile1.recordCount(), + 1, + dataFile1.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + firstSnapshot.timestampMillis(), + firstSnapshot.snapshotId(), + null), + Tuple.tuple( + partitionRecord(partitionType, "foo", "B"), + 0, + dataFile2.recordCount(), + 1, + dataFile2.fileSizeInBytes(), + 0L, + 0, + 0L, + 0, + null, + firstSnapshot.timestampMillis(), + firstSnapshot.snapshotId(), + null)); + } + + private static void computeAndValidatePartitionStats( + Table testTable, long snapshotId, Tuple... expectedValues) throws IOException { + PartitionStatisticsFile result = + PartitionStatsHandler.computeAndWriteStatsFile(testTable, snapshotId); + testTable.updatePartitionStatistics().setPartitionStatistics(result).commit(); + assertThat(result.snapshotId()).isEqualTo(snapshotId); + + PartitionStatisticsScan statScan = testTable.newPartitionStatisticsScan(); + if (testTable.currentSnapshot().snapshotId() != snapshotId) { + statScan.useSnapshot(snapshotId); + } + + List partitionStats; + try (CloseableIterable recordIterator = statScan.scan()) { + partitionStats = Lists.newArrayList(recordIterator); + } + + assertThat(partitionStats) + .extracting( + PartitionStatistics::partition, + PartitionStatistics::specId, + PartitionStatistics::dataRecordCount, + PartitionStatistics::dataFileCount, + PartitionStatistics::totalDataFileSizeInBytes, + PartitionStatistics::positionDeleteRecordCount, + PartitionStatistics::positionDeleteFileCount, + PartitionStatistics::equalityDeleteRecordCount, + PartitionStatistics::equalityDeleteFileCount, + PartitionStatistics::totalRecords, + PartitionStatistics::lastUpdatedAt, + PartitionStatistics::lastUpdatedSnapshotId, + PartitionStatistics::dvCount) + .containsExactlyInAnyOrder(expectedValues); + } +} diff --git a/core/src/test/java/org/apache/iceberg/PartitionStatisticsTestBase.java b/core/src/test/java/org/apache/iceberg/PartitionStatisticsTestBase.java new file mode 100644 index 000000000000..518c1b3cde81 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/PartitionStatisticsTestBase.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.PartitionStatistics.DATA_FILE_COUNT; +import static org.apache.iceberg.PartitionStatistics.DATA_RECORD_COUNT; +import static org.apache.iceberg.PartitionStatistics.EMPTY_PARTITION_FIELD; +import static org.apache.iceberg.PartitionStatistics.EQUALITY_DELETE_FILE_COUNT; +import static org.apache.iceberg.PartitionStatistics.EQUALITY_DELETE_RECORD_COUNT; +import static org.apache.iceberg.PartitionStatistics.LAST_UPDATED_AT; +import static org.apache.iceberg.PartitionStatistics.LAST_UPDATED_SNAPSHOT_ID; +import static org.apache.iceberg.PartitionStatistics.POSITION_DELETE_FILE_COUNT; +import static org.apache.iceberg.PartitionStatistics.POSITION_DELETE_RECORD_COUNT; +import static org.apache.iceberg.PartitionStatistics.SPEC_ID; +import static org.apache.iceberg.PartitionStatistics.TOTAL_DATA_FILE_SIZE_IN_BYTES; +import static org.apache.iceberg.PartitionStatistics.TOTAL_RECORD_COUNT; +import static org.apache.iceberg.types.Types.NestedField.optional; + +import java.io.File; +import java.io.IOException; +import java.util.Comparator; +import java.util.Objects; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.io.TempDir; + +public abstract class PartitionStatisticsTestBase { + + @TempDir private File temp; + + protected static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + + protected static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).identity("c2").identity("c3").build(); + + private static final Random RANDOM = ThreadLocalRandom.current(); + + protected Schema invalidOldSchema(Types.StructType unifiedPartitionType) { + // field ids starts from 0 instead of 1 + return new Schema( + Types.NestedField.required(0, EMPTY_PARTITION_FIELD.name(), unifiedPartitionType), + Types.NestedField.required(1, SPEC_ID.name(), Types.IntegerType.get()), + Types.NestedField.required(2, DATA_RECORD_COUNT.name(), Types.LongType.get()), + Types.NestedField.required(3, DATA_FILE_COUNT.name(), Types.IntegerType.get()), + Types.NestedField.required(4, TOTAL_DATA_FILE_SIZE_IN_BYTES.name(), Types.LongType.get()), + Types.NestedField.optional(5, POSITION_DELETE_RECORD_COUNT.name(), Types.LongType.get()), + Types.NestedField.optional(6, POSITION_DELETE_FILE_COUNT.name(), Types.IntegerType.get()), + Types.NestedField.optional(7, EQUALITY_DELETE_RECORD_COUNT.name(), Types.LongType.get()), + Types.NestedField.optional(8, EQUALITY_DELETE_FILE_COUNT.name(), Types.IntegerType.get()), + Types.NestedField.optional(9, TOTAL_RECORD_COUNT.name(), Types.LongType.get()), + Types.NestedField.optional(10, LAST_UPDATED_AT.name(), Types.LongType.get()), + Types.NestedField.optional(11, LAST_UPDATED_SNAPSHOT_ID.name(), Types.LongType.get())); + } + + protected PartitionStatistics randomStats(Types.StructType partitionType) { + PartitionData partitionData = new PartitionData(partitionType); + partitionData.set(0, RANDOM.nextInt()); + + return randomStats(partitionData); + } + + protected PartitionStatistics randomStats(PartitionData partitionData) { + PartitionStatistics stats = new BasePartitionStatistics(partitionData, RANDOM.nextInt(10)); + stats.set(PartitionStatistics.DATA_RECORD_COUNT_POSITION, RANDOM.nextLong()); + stats.set(PartitionStatistics.DATA_FILE_COUNT_POSITION, RANDOM.nextInt()); + stats.set( + PartitionStatistics.TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION, 1024L * RANDOM.nextInt(20)); + return stats; + } + + protected File tempDir(String folderName) throws IOException { + return java.nio.file.Files.createTempDirectory(temp.toPath(), folderName).toFile(); + } + + protected static StructLike partitionRecord( + Types.StructType partitionType, String val1, String val2) { + GenericRecord record = GenericRecord.create(partitionType); + record.set(0, val1); + record.set(1, val2); + return record; + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + protected static boolean isEqual( + Comparator partitionComparator, + PartitionStatistics stats1, + PartitionStatistics stats2) { + if (stats1 == stats2) { + return true; + } else if (stats1 == null || stats2 == null) { + return false; + } + + return partitionComparator.compare(stats1.partition(), stats2.partition()) == 0 + && Objects.equals(stats1.specId(), stats2.specId()) + && Objects.equals(stats1.dataRecordCount(), stats2.dataRecordCount()) + && Objects.equals(stats1.dataFileCount(), stats2.dataFileCount()) + && Objects.equals(stats1.totalDataFileSizeInBytes(), stats2.totalDataFileSizeInBytes()) + && Objects.equals(stats1.positionDeleteRecordCount(), stats2.positionDeleteRecordCount()) + && Objects.equals(stats1.positionDeleteFileCount(), stats2.positionDeleteFileCount()) + && Objects.equals(stats1.equalityDeleteRecordCount(), stats2.equalityDeleteRecordCount()) + && Objects.equals(stats1.equalityDeleteFileCount(), stats2.equalityDeleteFileCount()) + && Objects.equals(stats1.totalRecords(), stats2.totalRecords()) + && Objects.equals(stats1.lastUpdatedAt(), stats2.lastUpdatedAt()) + && Objects.equals(stats1.lastUpdatedSnapshotId(), stats2.lastUpdatedSnapshotId()); + } +} diff --git a/core/src/test/java/org/apache/iceberg/PartitionStatsHandlerTestBase.java b/core/src/test/java/org/apache/iceberg/PartitionStatsHandlerTestBase.java index 71fdc9507d58..7b7e85bfffce 100644 --- a/core/src/test/java/org/apache/iceberg/PartitionStatsHandlerTestBase.java +++ b/core/src/test/java/org/apache/iceberg/PartitionStatsHandlerTestBase.java @@ -18,25 +18,12 @@ */ package org.apache.iceberg; -import static org.apache.iceberg.PartitionStatsHandler.DATA_FILE_COUNT; -import static org.apache.iceberg.PartitionStatsHandler.DATA_RECORD_COUNT; -import static org.apache.iceberg.PartitionStatsHandler.EQUALITY_DELETE_FILE_COUNT; -import static org.apache.iceberg.PartitionStatsHandler.EQUALITY_DELETE_RECORD_COUNT; -import static org.apache.iceberg.PartitionStatsHandler.LAST_UPDATED_AT; -import static org.apache.iceberg.PartitionStatsHandler.LAST_UPDATED_SNAPSHOT_ID; -import static org.apache.iceberg.PartitionStatsHandler.PARTITION_FIELD_ID; -import static org.apache.iceberg.PartitionStatsHandler.PARTITION_FIELD_NAME; -import static org.apache.iceberg.PartitionStatsHandler.POSITION_DELETE_FILE_COUNT; -import static org.apache.iceberg.PartitionStatsHandler.POSITION_DELETE_RECORD_COUNT; -import static org.apache.iceberg.PartitionStatsHandler.SPEC_ID; -import static org.apache.iceberg.PartitionStatsHandler.TOTAL_DATA_FILE_SIZE_IN_BYTES; -import static org.apache.iceberg.PartitionStatsHandler.TOTAL_RECORD_COUNT; +import static org.apache.iceberg.PartitionStatistics.EMPTY_PARTITION_FIELD; import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.io.File; import java.io.IOException; import java.math.BigDecimal; import java.nio.ByteBuffer; @@ -46,10 +33,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Random; import java.util.UUID; -import java.util.concurrent.ThreadLocalRandom; -import org.apache.iceberg.data.GenericRecord; import org.apache.iceberg.expressions.Literal; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -61,10 +45,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; @ExtendWith(ParameterizedTestExtension.class) -public abstract class PartitionStatsHandlerTestBase { +public abstract class PartitionStatsHandlerTestBase extends PartitionStatisticsTestBase { public abstract FileFormat format(); @@ -75,35 +58,9 @@ protected static List formatVersions() { @Parameter protected int formatVersion; - private static final Schema SCHEMA = - new Schema( - optional(1, "c1", Types.IntegerType.get()), - optional(2, "c2", Types.StringType.get()), - optional(3, "c3", Types.StringType.get())); - - protected static final PartitionSpec SPEC = - PartitionSpec.builderFor(SCHEMA).identity("c2").identity("c3").build(); - - @TempDir public File temp; - - private static final Random RANDOM = ThreadLocalRandom.current(); - private final Map fileFormatProperty = ImmutableMap.of(TableProperties.DEFAULT_FILE_FORMAT, format().name()); - // position in StructLike - private static final int DATA_RECORD_COUNT_POSITION = 2; - private static final int DATA_FILE_COUNT_POSITION = 3; - private static final int TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION = 4; - private static final int POSITION_DELETE_RECORD_COUNT_POSITION = 5; - private static final int POSITION_DELETE_FILE_COUNT_POSITION = 6; - private static final int EQUALITY_DELETE_RECORD_COUNT_POSITION = 7; - private static final int EQUALITY_DELETE_FILE_COUNT_POSITION = 8; - private static final int TOTAL_RECORD_COUNT_POSITION = 9; - private static final int LAST_UPDATED_AT_POSITION = 10; - private static final int LAST_UPDATED_SNAPSHOT_ID_POSITION = 11; - private static final int DV_COUNT_POSITION = 12; - @Test public void testPartitionStatsOnEmptyTable() throws Exception { Table testTable = @@ -202,10 +159,11 @@ public void testAllDatatypePartitionWriting() throws Exception { fileFormatProperty); Types.StructType partitionSchema = Partitioning.partitionType(testTable); - Schema dataSchema = PartitionStatsHandler.schema(partitionSchema, formatVersion); + Schema dataSchema = PartitionStatistics.schema(partitionSchema, formatVersion); PartitionData partitionData = - new PartitionData(dataSchema.findField(PARTITION_FIELD_ID).type().asStructType()); + new PartitionData( + dataSchema.findField(EMPTY_PARTITION_FIELD.fieldId()).type().asStructType()); partitionData.set(0, true); partitionData.set(1, 42); partitionData.set(2, 42L); @@ -223,18 +181,31 @@ public void testAllDatatypePartitionWriting() throws Exception { partitionData.set(13, new BigDecimal("12345678901234567890.1234567890")); partitionData.set(14, Literal.of("10:10:10").to(Types.TimeType.get()).value()); - PartitionStats partitionStats = new PartitionStats(partitionData, RANDOM.nextInt(10)); - partitionStats.set(DATA_RECORD_COUNT_POSITION, RANDOM.nextLong()); - partitionStats.set(DATA_FILE_COUNT_POSITION, RANDOM.nextInt()); - partitionStats.set(TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION, 1024L * RANDOM.nextInt(20)); - List expected = Collections.singletonList(partitionStats); - PartitionStatisticsFile statisticsFile = - PartitionStatsHandler.writePartitionStatsFile(testTable, 42L, dataSchema, expected); + PartitionStatistics partitionStats = randomStats(partitionData); + List expected = Collections.singletonList(partitionStats); - List written; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, testTable.io().newInputFile(statisticsFile.path()))) { + // Add a dummy file to the table to have a snapshot + DataFile dataFile = + DataFiles.builder(spec) + .withPath("some_path") + .withPartition(partitionData) + .withFileSizeInBytes(15) + .withFormat(FileFormat.PARQUET) + .withRecordCount(1) + .build(); + testTable.newAppend().appendFile(dataFile).commit(); + long snapshotId = testTable.currentSnapshot().snapshotId(); + + testTable + .updatePartitionStatistics() + .setPartitionStatistics( + PartitionStatsHandler.writePartitionStatsFile( + testTable, snapshotId, dataSchema, expected)) + .commit(); + + List written; + try (CloseableIterable recordIterator = + testTable.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()) { written = Lists.newArrayList(recordIterator); } @@ -258,53 +229,61 @@ public void testOptionalFieldsWriting() throws Exception { fileFormatProperty); Types.StructType partitionSchema = Partitioning.partitionType(testTable); - Schema dataSchema = PartitionStatsHandler.schema(partitionSchema, formatVersion); + Schema dataSchema = PartitionStatistics.schema(partitionSchema, formatVersion); - ImmutableList.Builder partitionListBuilder = ImmutableList.builder(); + ImmutableList.Builder partitionListBuilder = ImmutableList.builder(); for (int i = 0; i < 5; i++) { - PartitionData partitionData = - new PartitionData(dataSchema.findField(PARTITION_FIELD_ID).type().asStructType()); - partitionData.set(0, RANDOM.nextInt()); - - PartitionStats stats = new PartitionStats(partitionData, RANDOM.nextInt(10)); - stats.set(DATA_RECORD_COUNT_POSITION, RANDOM.nextLong()); - stats.set(DATA_FILE_COUNT_POSITION, RANDOM.nextInt()); - stats.set(TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION, 1024L * RANDOM.nextInt(20)); - stats.set(POSITION_DELETE_RECORD_COUNT_POSITION, null); - stats.set(POSITION_DELETE_FILE_COUNT_POSITION, null); - stats.set(EQUALITY_DELETE_RECORD_COUNT_POSITION, null); - stats.set(EQUALITY_DELETE_FILE_COUNT_POSITION, null); - stats.set(TOTAL_RECORD_COUNT_POSITION, null); - stats.set(LAST_UPDATED_AT_POSITION, null); - stats.set(LAST_UPDATED_SNAPSHOT_ID_POSITION, null); - stats.set(DV_COUNT_POSITION, null); + PartitionStatistics stats = + randomStats(dataSchema.findField(EMPTY_PARTITION_FIELD.fieldId()).type().asStructType()); + stats.set(PartitionStatistics.POSITION_DELETE_RECORD_COUNT_POSITION, null); + stats.set(PartitionStatistics.POSITION_DELETE_FILE_COUNT_POSITION, null); + stats.set(PartitionStatistics.EQUALITY_DELETE_RECORD_COUNT_POSITION, null); + stats.set(PartitionStatistics.EQUALITY_DELETE_FILE_COUNT_POSITION, null); + stats.set(PartitionStatistics.TOTAL_RECORD_COUNT_POSITION, null); + stats.set(PartitionStatistics.LAST_UPDATED_AT_POSITION, null); + stats.set(PartitionStatistics.LAST_UPDATED_SNAPSHOT_ID_POSITION, null); + stats.set(PartitionStatistics.DV_COUNT_POSITION, null); partitionListBuilder.add(stats); } - List expected = partitionListBuilder.build(); + List expected = partitionListBuilder.build(); assertThat(expected.get(0)) .extracting( - PartitionStats::positionDeleteRecordCount, - PartitionStats::positionDeleteFileCount, - PartitionStats::equalityDeleteRecordCount, - PartitionStats::equalityDeleteFileCount, - PartitionStats::totalRecords, - PartitionStats::lastUpdatedAt, - PartitionStats::lastUpdatedSnapshotId, - PartitionStats::dvCount) + PartitionStatistics::positionDeleteRecordCount, + PartitionStatistics::positionDeleteFileCount, + PartitionStatistics::equalityDeleteRecordCount, + PartitionStatistics::equalityDeleteFileCount, + PartitionStatistics::totalRecords, + PartitionStatistics::lastUpdatedAt, + PartitionStatistics::lastUpdatedSnapshotId, + PartitionStatistics::dvCount) .isEqualTo( Arrays.asList( 0L, 0, 0L, 0, null, null, null, 0)); // null counters must be initialized to zero. - PartitionStatisticsFile statisticsFile = - PartitionStatsHandler.writePartitionStatsFile(testTable, 42L, dataSchema, expected); - - List written; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, testTable.io().newInputFile(statisticsFile.path()))) { + // Add a dummy file to the table to have a snapshot + DataFile dataFile = + DataFiles.builder(spec) + .withPath("some_path") + .withFileSizeInBytes(15) + .withFormat(FileFormat.PARQUET) + .withRecordCount(1) + .build(); + testTable.newAppend().appendFile(dataFile).commit(); + long snapshotId = testTable.currentSnapshot().snapshotId(); + + testTable + .updatePartitionStatistics() + .setPartitionStatistics( + PartitionStatsHandler.writePartitionStatsFile( + testTable, snapshotId, dataSchema, expected)) + .commit(); + + List written; + try (CloseableIterable recordIterator = + testTable.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()) { written = Lists.newArrayList(recordIterator); } @@ -315,8 +294,12 @@ public void testOptionalFieldsWriting() throws Exception { } } + /** + * @deprecated will be removed in 1.12.0 + */ @SuppressWarnings("checkstyle:MethodLength") @Test + @Deprecated public void testPartitionStats() throws Exception { Table testTable = TestTables.create( @@ -348,10 +331,10 @@ public void testPartitionStats() throws Exception { } Snapshot snapshot1 = testTable.currentSnapshot(); - Schema recordSchema = PartitionStatsHandler.schema(Partitioning.partitionType(testTable), 2); + Schema recordSchema = PartitionStatistics.schema(Partitioning.partitionType(testTable), 2); Types.StructType partitionType = - recordSchema.findField(PARTITION_FIELD_ID).type().asStructType(); + recordSchema.findField(EMPTY_PARTITION_FIELD.fieldId()).type().asStructType(); computeAndValidatePartitionStats( testTable, recordSchema, @@ -427,7 +410,7 @@ public void testPartitionStats() throws Exception { testTable.newRowDelta().addDeletes(dv).commit(); Snapshot snapshot4 = testTable.currentSnapshot(); - recordSchema = PartitionStatsHandler.schema(Partitioning.partitionType(testTable), 3); + recordSchema = PartitionStatistics.schema(Partitioning.partitionType(testTable), 3); computeAndValidatePartitionStats( testTable, @@ -512,27 +495,24 @@ public void testCopyOnWriteDelete() throws Exception { testTable.newAppend().appendFile(dataFile1).appendFile(dataFile2).commit(); - PartitionStatisticsFile statisticsFile = - PartitionStatsHandler.computeAndWriteStatsFile(testTable); - testTable.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); + testTable + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(testTable)) + .commit(); - assertThat( - PartitionStatsHandler.readPartitionStatsFile( - PartitionStatsHandler.schema(Partitioning.partitionType(testTable), 2), - testTable.io().newInputFile(statisticsFile.path()))) + assertThat(testTable.newPartitionStatisticsScan().scan()) .allMatch(s -> (s.dataRecordCount() != 0 && s.dataFileCount() != 0)); testTable.newDelete().deleteFile(dataFile1).commit(); testTable.newDelete().deleteFile(dataFile2).commit(); - PartitionStatisticsFile statisticsFileNew = - PartitionStatsHandler.computeAndWriteStatsFile(testTable); + testTable + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(testTable)) + .commit(); // stats must be decremented to zero as all the files removed from table. - assertThat( - PartitionStatsHandler.readPartitionStatsFile( - PartitionStatsHandler.schema(Partitioning.partitionType(testTable), 2), - testTable.io().newInputFile(statisticsFileNew.path()))) + assertThat(testTable.newPartitionStatisticsScan().scan()) .allMatch(s -> (s.dataRecordCount() == 0 && s.dataFileCount() == 0)); } @@ -611,13 +591,17 @@ public void testLatestStatsFileWithBranch() throws Exception { assertThat(PartitionStatsHandler.latestStatsFile(testTable, snapshotBranchBId)).isNull(); } + /** + * @deprecated will be removed in 1.12.0 + */ @Test + @Deprecated public void testReadingStatsWithInvalidSchema() throws Exception { PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); Table testTable = TestTables.create(tempDir("old_schema"), "old_schema", SCHEMA, spec, 2, fileFormatProperty); Types.StructType partitionType = Partitioning.partitionType(testTable); - Schema newSchema = PartitionStatsHandler.schema(partitionType, 2); + Schema newSchema = PartitionStatistics.schema(partitionType, 2); Schema oldSchema = invalidOldSchema(partitionType); PartitionStatisticsFile invalidStatisticsFile = @@ -660,15 +644,15 @@ public void testFullComputeFallbackWithInvalidStats() throws Exception { testTable.updatePartitionStatistics().setPartitionStatistics(invalidStatisticsFile).commit(); testTable.newAppend().appendFile(dataFile).commit(); - PartitionStatisticsFile statisticsFile = - PartitionStatsHandler.computeAndWriteStatsFile(testTable); + testTable + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(testTable)) + .commit(); // read the partition entries from the stats file - List partitionStats; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - PartitionStatsHandler.schema(partitionType, 2), - testTable.io().newInputFile(statisticsFile.path()))) { + List partitionStats; + try (CloseableIterable recordIterator = + testTable.newPartitionStatisticsScan().scan()) { partitionStats = Lists.newArrayList(recordIterator); } @@ -677,7 +661,11 @@ public void testFullComputeFallbackWithInvalidStats() throws Exception { assertThat(partitionStats.get(0).dataFileCount()).isEqualTo(2); } + /** + * @deprecated will be removed in 1.12.0 + */ @Test + @Deprecated public void testV2toV3SchemaEvolution() throws Exception { Table testTable = TestTables.create( @@ -694,7 +682,7 @@ public void testV2toV3SchemaEvolution() throws Exception { Types.StructType partitionSchema = Partitioning.partitionType(testTable); // read with v2 schema - Schema v2Schema = PartitionStatsHandler.schema(partitionSchema, 2); + Schema v2Schema = PartitionStatistics.schema(partitionSchema, 2); List partitionStatsV2; try (CloseableIterable recordIterator = PartitionStatsHandler.readPartitionStatsFile( @@ -703,7 +691,7 @@ public void testV2toV3SchemaEvolution() throws Exception { } // read with v3 schema - Schema v3Schema = PartitionStatsHandler.schema(partitionSchema, 3); + Schema v3Schema = PartitionStatistics.schema(partitionSchema, 3); List partitionStatsV3; try (CloseableIterable recordIterator = PartitionStatsHandler.readPartitionStatsFile( @@ -718,14 +706,6 @@ public void testV2toV3SchemaEvolution() throws Exception { } } - private static StructLike partitionRecord( - Types.StructType partitionType, String val1, String val2) { - GenericRecord record = GenericRecord.create(partitionType); - record.set(0, val1); - record.set(1, val2); - return record; - } - private static void computeAndValidatePartitionStats( Table testTable, Schema recordSchema, Tuple... expectedValues) throws IOException { // compute and commit partition stats file @@ -760,38 +740,6 @@ private static void computeAndValidatePartitionStats( .containsExactlyInAnyOrder(expectedValues); } - private File tempDir(String folderName) throws IOException { - return java.nio.file.Files.createTempDirectory(temp.toPath(), folderName).toFile(); - } - - private Schema invalidOldSchema(Types.StructType unifiedPartitionType) { - // field ids starts from 0 instead of 1 - return new Schema( - Types.NestedField.required(0, PARTITION_FIELD_NAME, unifiedPartitionType), - Types.NestedField.required(1, SPEC_ID.name(), Types.IntegerType.get()), - Types.NestedField.required(2, DATA_RECORD_COUNT.name(), Types.LongType.get()), - Types.NestedField.required(3, DATA_FILE_COUNT.name(), Types.IntegerType.get()), - Types.NestedField.required(4, TOTAL_DATA_FILE_SIZE_IN_BYTES.name(), Types.LongType.get()), - Types.NestedField.optional(5, POSITION_DELETE_RECORD_COUNT.name(), Types.LongType.get()), - Types.NestedField.optional(6, POSITION_DELETE_FILE_COUNT.name(), Types.IntegerType.get()), - Types.NestedField.optional(7, EQUALITY_DELETE_RECORD_COUNT.name(), Types.LongType.get()), - Types.NestedField.optional(8, EQUALITY_DELETE_FILE_COUNT.name(), Types.IntegerType.get()), - Types.NestedField.optional(9, TOTAL_RECORD_COUNT.name(), Types.LongType.get()), - Types.NestedField.optional(10, LAST_UPDATED_AT.name(), Types.LongType.get()), - Types.NestedField.optional(11, LAST_UPDATED_SNAPSHOT_ID.name(), Types.LongType.get())); - } - - private PartitionStats randomStats(Types.StructType partitionType) { - PartitionData partitionData = new PartitionData(partitionType); - partitionData.set(0, RANDOM.nextInt()); - - PartitionStats stats = new PartitionStats(partitionData, RANDOM.nextInt(10)); - stats.set(DATA_RECORD_COUNT_POSITION, RANDOM.nextLong()); - stats.set(DATA_FILE_COUNT_POSITION, RANDOM.nextInt()); - stats.set(TOTAL_DATA_FILE_SIZE_IN_BYTES_POSITION, 1024L * RANDOM.nextInt(20)); - return stats; - } - @SuppressWarnings("checkstyle:CyclomaticComplexity") private static boolean isEqual( Comparator partitionComparator, PartitionStats stats1, PartitionStats stats2) { diff --git a/core/src/test/java/org/apache/iceberg/ScanTestBase.java b/core/src/test/java/org/apache/iceberg/ScanTestBase.java index de1173033742..d5cdec579e93 100644 --- a/core/src/test/java/org/apache/iceberg/ScanTestBase.java +++ b/core/src/test/java/org/apache/iceberg/ScanTestBase.java @@ -238,6 +238,17 @@ public void testDataFileSorted() throws Exception { .build()) .commit(); + table + .newFastAppend() + .appendFile( + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/path/to/data/b.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .withSortOrderId(1) + .build()) + .commit(); + TableScan scan = table.newScan(); try (CloseableIterable tasks = scan.planFiles()) { for (FileScanTask fileScanTask : tasks) { diff --git a/core/src/test/java/org/apache/iceberg/TestAllManifestsTableTaskParser.java b/core/src/test/java/org/apache/iceberg/TestAllManifestsTableTaskParser.java index 2f057d7bd5a8..dde6bab6aa9f 100644 --- a/core/src/test/java/org/apache/iceberg/TestAllManifestsTableTaskParser.java +++ b/core/src/test/java/org/apache/iceberg/TestAllManifestsTableTaskParser.java @@ -91,7 +91,7 @@ private AllManifestsTable.ManifestListReadTask createTask() { fileIO, AllManifestsTable.MANIFEST_FILE_SCHEMA, specsById, - "/path/manifest-list-file.avro", + new BaseManifestListFile("/path/manifest-list-file.avro", "a"), Expressions.equal("id", 1), 1L); } @@ -121,10 +121,12 @@ private String taskJson() { + "{\"id\":11,\"name\":\"contains_nan\",\"required\":true,\"type\":\"boolean\"}," + "{\"id\":12,\"name\":\"lower_bound\",\"required\":false,\"type\":\"string\"}," + "{\"id\":13,\"name\":\"upper_bound\",\"required\":false,\"type\":\"string\"}]},\"element-required\":true}}," - + "{\"id\":18,\"name\":\"reference_snapshot_id\",\"required\":true,\"type\":\"long\"}]}," + + "{\"id\":18,\"name\":\"reference_snapshot_id\",\"required\":true,\"type\":\"long\"}," + + "{\"id\":19,\"name\":\"key_metadata\",\"required\":false,\"type\":\"binary\"}]}," + "\"partition-specs\":[{\"spec-id\":0,\"fields\":[{\"name\":\"data_bucket\"," + "\"transform\":\"bucket[16]\",\"source-id\":4,\"field-id\":1000}]}]," + "\"manifest-list-Location\":\"/path/manifest-list-file.avro\"," + + "\"manifest-list-key-id\":\"a\"," + "\"residual-filter\":{\"type\":\"eq\",\"term\":\"id\",\"value\":1}," + "\"reference-snapshot-id\":1}"; } @@ -145,7 +147,9 @@ private void assertTaskEquals( .isEqualTo(expected.schema().asStruct()); assertThat(actual.specsById()).isEqualTo(expected.specsById()); - assertThat(actual.manifestListLocation()).isEqualTo(expected.manifestListLocation()); + assertThat(actual.manifestList().location()).isEqualTo(expected.manifestList().location()); + assertThat(actual.manifestList().encryptionKeyID()) + .isEqualTo(expected.manifestList().encryptionKeyID()); assertThat(actual.residual().toString()).isEqualTo(expected.residual().toString()); assertThat(actual.referenceSnapshotId()).isEqualTo(expected.referenceSnapshotId()); } diff --git a/core/src/test/java/org/apache/iceberg/TestBase.java b/core/src/test/java/org/apache/iceberg/TestBase.java index 0929c1bd37e6..27b8a49d0497 100644 --- a/core/src/test/java/org/apache/iceberg/TestBase.java +++ b/core/src/test/java/org/apache/iceberg/TestBase.java @@ -91,6 +91,15 @@ public class TestBase { .withPartitionPath("data_bucket=0") // easy way to set partition data for now .withRecordCount(1) .build(); + public static final DeleteFile FILE_A_EQUALITY_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes(1) // delete on column 1 (id column) + .withPath("/path/to/data-a-equality-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("data_bucket=0") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A_DV = FileMetadata.deleteFileBuilder(SPEC) .ofPositionDeletes() @@ -127,6 +136,14 @@ public class TestBase { .withPartitionPath("data_bucket=1") // easy way to set partition data for now .withRecordCount(1) .build(); + public static final DeleteFile FILE_B_EQUALITY_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes(1) // delete on column 1 (id column) + .withPath("/path/to/data-b-equality-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("data_bucket=1") // same partition as FILE_B + .withRecordCount(1) + .build(); static final DeleteFile FILE_B_DV = FileMetadata.deleteFileBuilder(SPEC) .ofPositionDeletes() @@ -154,6 +171,14 @@ public class TestBase { .withPartitionPath("data_bucket=2") // easy way to set partition data for now .withRecordCount(1) .build(); + public static final DeleteFile FILE_C_EQUALITY_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes(1) // delete on column 1 (id column) + .withPath("/path/to/data-c-equality-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("data_bucket=2") // same partition as FILE_C + .withRecordCount(1) + .build(); static final DataFile FILE_D = DataFiles.builder(SPEC) .withPath("/path/to/data-d.parquet") @@ -170,7 +195,7 @@ public class TestBase { .withPartitionPath("data_bucket=3") // easy way to set partition data for now .withRecordCount(1) .build(); - static final DataFile FILE_WITH_STATS = + public static final DataFile FILE_WITH_STATS = DataFiles.builder(SPEC) .withPath("/path/to/data-with-stats.parquet") .withMetrics( @@ -420,6 +445,10 @@ void validateSnapshot(Snapshot old, Snapshot snap, DataFile... newFiles) { validateSnapshot(old, snap, null, newFiles); } + void validateSnapshot(Table validationTable, Snapshot old, Snapshot snap, DataFile... newFiles) { + validateSnapshot(validationTable, old, snap, null, newFiles); + } + void validateSnapshot(Snapshot old, Snapshot snap, long sequenceNumber, DataFile... newFiles) { validateSnapshot(old, snap, (Long) sequenceNumber, newFiles); } @@ -447,6 +476,16 @@ Snapshot apply(SnapshotUpdate snapshotUpdate, String branch) { } void validateSnapshot(Snapshot old, Snapshot snap, Long sequenceNumber, DataFile... newFiles) { + validateSnapshot(table, old, snap, sequenceNumber, newFiles); + } + + @SuppressWarnings("checkstyle:HiddenField") + void validateSnapshot( + Table validationTable, + Snapshot old, + Snapshot snap, + Long sequenceNumber, + DataFile... newFiles) { assertThat(old != null ? Sets.newHashSet(old.deleteManifests(FILE_IO)) : ImmutableSet.of()) .as("Should not change delete manifests") .isEqualTo(Sets.newHashSet(snap.deleteManifests(FILE_IO))); @@ -466,7 +505,8 @@ void validateSnapshot(Snapshot old, Snapshot snap, Long sequenceNumber, DataFile long id = snap.snapshotId(); Iterator newPaths = paths(newFiles).iterator(); - for (ManifestEntry entry : ManifestFiles.read(manifest, FILE_IO).entries()) { + for (ManifestEntry entry : + ManifestFiles.read(manifest, FILE_IO, validationTable.specs()).entries()) { DataFile file = entry.file(); if (sequenceNumber != null) { V1Assert.assertEquals( @@ -504,7 +544,9 @@ void validateSnapshot(Snapshot old, Snapshot snap, Long sequenceNumber, DataFile assertThat(newPaths.hasNext()).as("Should find all files in the manifest").isFalse(); - assertThat(snap.schemaId()).as("Schema ID should match").isEqualTo(table.schema().schemaId()); + assertThat(snap.schemaId()) + .as("Schema ID should match") + .isEqualTo(validationTable.schema().schemaId()); } void validateTableFiles(Table tbl, DataFile... expectedFiles) { @@ -578,7 +620,8 @@ void validateManifest( Iterator ids, Iterator expectedFiles, Iterator statuses) { - for (ManifestEntry entry : ManifestFiles.read(manifest, FILE_IO).entries()) { + for (ManifestEntry entry : + ManifestFiles.read(manifest, FILE_IO, table.specs()).entries()) { DataFile file = entry.file(); DataFile expected = expectedFiles.next(); @@ -604,7 +647,7 @@ void validateDeleteManifest( Iterator expectedFiles, Iterator statuses) { for (ManifestEntry entry : - ManifestFiles.readDeleteManifest(manifest, FILE_IO, null).entries()) { + ManifestFiles.readDeleteManifest(manifest, FILE_IO, table.specs()).entries()) { DeleteFile file = entry.file(); DeleteFile expected = expectedFiles.next(); @@ -762,12 +805,23 @@ private void move(String location, String newLocation) { } } - static void validateManifestEntries( + void validateManifestEntries( + ManifestFile manifest, + Iterator ids, + Iterator expectedFiles, + Iterator expectedStatuses) { + validateManifestEntries(table, manifest, ids, expectedFiles, expectedStatuses); + } + + @SuppressWarnings("checkstyle:HiddenField") + void validateManifestEntries( + Table validationTable, ManifestFile manifest, Iterator ids, Iterator expectedFiles, Iterator expectedStatuses) { - for (ManifestEntry entry : ManifestFiles.read(manifest, FILE_IO).entries()) { + for (ManifestEntry entry : + ManifestFiles.read(manifest, FILE_IO, validationTable.specs()).entries()) { DataFile file = entry.file(); DataFile expected = expectedFiles.next(); final ManifestEntry.Status expectedStatus = expectedStatuses.next(); @@ -821,8 +875,8 @@ static Iterator files(DeleteFile... files) { return Iterators.forArray(files); } - static Iterator files(ManifestFile manifest) { - return ManifestFiles.read(manifest, FILE_IO).iterator(); + Iterator files(ManifestFile manifest) { + return ManifestFiles.read(manifest, FILE_IO, table.specs()).iterator(); } static long recordCount(ContentFile... files) { diff --git a/core/src/test/java/org/apache/iceberg/TestCatalogUtil.java b/core/src/test/java/org/apache/iceberg/TestCatalogUtil.java index 4b755079ec88..84e79e35c9b5 100644 --- a/core/src/test/java/org/apache/iceberg/TestCatalogUtil.java +++ b/core/src/test/java/org/apache/iceberg/TestCatalogUtil.java @@ -19,7 +19,15 @@ package org.apache.iceberg; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; import java.util.List; import java.util.Map; @@ -33,9 +41,11 @@ import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.io.SupportsStorageCredentials; import org.apache.iceberg.metrics.MetricsReport; import org.apache.iceberg.metrics.MetricsReporter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.junit.jupiter.api.Test; @@ -282,6 +292,34 @@ public void fullTableNameWithDifferentValues() { .isEqualTo(pathStyleCatalogName + "/" + nameSpaceWithTwoLevels + "." + tableName); } + @Test + public void noFailureWhenBulkDeletingMetadataFiles() { + FileIO io = mock(FileIO.class, withSettings().extraInterfaces(SupportsBulkOperations.class)); + + doThrow(new RuntimeException("Simulated bulk delete failure")) + .when((SupportsBulkOperations) io) + .deleteFiles(any()); + + TableMetadata.MetadataLogEntry entry1 = + new TableMetadata.MetadataLogEntry( + System.currentTimeMillis(), "s3://bucket/metadata/v1.json"); + TableMetadata.MetadataLogEntry entry2 = + new TableMetadata.MetadataLogEntry( + System.currentTimeMillis(), "s3://bucket/metadata/v2.json"); + + TableMetadata base = mock(TableMetadata.class); + TableMetadata metadata = mock(TableMetadata.class); + + when(metadata.propertyAsBoolean( + eq(TableProperties.METADATA_DELETE_AFTER_COMMIT_ENABLED), anyBoolean())) + .thenReturn(true); + when(base.previousFiles()).thenReturn(ImmutableList.of(entry1, entry2)); + when(metadata.previousFiles()).thenReturn(ImmutableList.of()); + + assertThatCode(() -> CatalogUtil.deleteRemovedMetadataFiles(io, base, metadata)) + .doesNotThrowAnyException(); + } + public static class TestCatalog extends BaseMetastoreCatalog { private String catalogName; diff --git a/core/src/test/java/org/apache/iceberg/TestCommitReporting.java b/core/src/test/java/org/apache/iceberg/TestCommitReporting.java index d17348a99cb8..3d39dc6d3896 100644 --- a/core/src/test/java/org/apache/iceberg/TestCommitReporting.java +++ b/core/src/test/java/org/apache/iceberg/TestCommitReporting.java @@ -63,6 +63,10 @@ public void addAndDeleteDataFiles() { assertThat(metrics.addedFilesSizeInBytes().value()).isEqualTo(20L); assertThat(metrics.totalFilesSizeInBytes().value()).isEqualTo(20L); + assertThat(metrics.manifestsCreated().value()).isEqualTo(1L); + assertThat(metrics.manifestsKept().value()).isEqualTo(0L); + assertThat(metrics.manifestsReplaced().value()).isEqualTo(0L); + // now remove those 2 data files table.newDelete().deleteFile(FILE_A).deleteFile(FILE_D).commit(); report = reporter.lastCommitReport(); @@ -81,6 +85,11 @@ public void addAndDeleteDataFiles() { assertThat(metrics.removedFilesSizeInBytes().value()).isEqualTo(20L); assertThat(metrics.totalFilesSizeInBytes().value()).isEqualTo(0L); + + // delete rewrites the manifest to mark files as deleted: 1 created, 0 kept, 1 replaced + assertThat(metrics.manifestsCreated().value()).isEqualTo(1L); + assertThat(metrics.manifestsKept().value()).isEqualTo(0L); + assertThat(metrics.manifestsReplaced().value()).isEqualTo(1L); } @TestTemplate @@ -128,6 +137,10 @@ public void addAndDeleteDeleteFiles() { assertThat(metrics.addedFilesSizeInBytes().value()).isEqualTo(totalDeleteContentSize); assertThat(metrics.totalFilesSizeInBytes().value()).isEqualTo(totalDeleteContentSize); + assertThat(metrics.manifestsCreated().value()).isEqualTo(1L); + assertThat(metrics.manifestsKept().value()).isEqualTo(0L); + assertThat(metrics.manifestsReplaced().value()).isEqualTo(0L); + // now remove those 2 positional + 1 equality delete files table .newRewrite() @@ -165,6 +178,11 @@ public void addAndDeleteDeleteFiles() { assertThat(metrics.removedFilesSizeInBytes().value()).isEqualTo(totalDeleteContentSize); assertThat(metrics.totalFilesSizeInBytes().value()).isEqualTo(0L); + + // rewrite creates 1 manifest (delete manifest rewritten), keeps 0, replaces 1 + assertThat(metrics.manifestsCreated().value()).isEqualTo(1L); + assertThat(metrics.manifestsKept().value()).isEqualTo(0L); + assertThat(metrics.manifestsReplaced().value()).isEqualTo(1L); } @TestTemplate diff --git a/core/src/test/java/org/apache/iceberg/stats/TestContentStats.java b/core/src/test/java/org/apache/iceberg/TestContentStats.java similarity index 72% rename from core/src/test/java/org/apache/iceberg/stats/TestContentStats.java rename to core/src/test/java/org/apache/iceberg/TestContentStats.java index 7e64b9f11ed9..0f06276d454b 100644 --- a/core/src/test/java/org/apache/iceberg/stats/TestContentStats.java +++ b/core/src/test/java/org/apache/iceberg/TestContentStats.java @@ -16,22 +16,21 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; - -import static org.apache.iceberg.stats.FieldStatistic.AVG_VALUE_SIZE; -import static org.apache.iceberg.stats.FieldStatistic.EXACT_BOUNDS; -import static org.apache.iceberg.stats.FieldStatistic.LOWER_BOUND; -import static org.apache.iceberg.stats.FieldStatistic.MAX_VALUE_SIZE; -import static org.apache.iceberg.stats.FieldStatistic.NAN_VALUE_COUNT; -import static org.apache.iceberg.stats.FieldStatistic.NULL_VALUE_COUNT; -import static org.apache.iceberg.stats.FieldStatistic.UPPER_BOUND; -import static org.apache.iceberg.stats.FieldStatistic.VALUE_COUNT; +package org.apache.iceberg; + +import static org.apache.iceberg.FieldStatistic.AVG_VALUE_SIZE; +import static org.apache.iceberg.FieldStatistic.EXACT_BOUNDS; +import static org.apache.iceberg.FieldStatistic.LOWER_BOUND; +import static org.apache.iceberg.FieldStatistic.MAX_VALUE_SIZE; +import static org.apache.iceberg.FieldStatistic.NAN_VALUE_COUNT; +import static org.apache.iceberg.FieldStatistic.NULL_VALUE_COUNT; +import static org.apache.iceberg.FieldStatistic.UPPER_BOUND; +import static org.apache.iceberg.FieldStatistic.VALUE_COUNT; import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import org.apache.iceberg.Schema; import org.apache.iceberg.data.GenericRecord; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.types.Types; @@ -154,7 +153,7 @@ public void retrievalByPosition() { assertThatThrownBy(() -> stats.get(0, Long.class)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining( - "Wrong class, expected java.lang.Long but was org.apache.iceberg.stats.BaseFieldStats for object:"); + "Wrong class, expected java.lang.Long but was org.apache.iceberg.BaseFieldStats for object:"); } @Test @@ -241,34 +240,93 @@ public void retrievalByPositionWithPartialStats() { } @Test - public void setByPosition() { + public void setByPositionOptionalString() { + Schema tableSchema = new Schema(optional(1, "s", Types.StringType.get())); + Types.StructType rootStatsStruct = StatsUtil.contentStatsFor(tableSchema).type().asStructType(); + Types.StructType statsStructForFieldId = rootStatsStruct.fields().get(0).type().asStructType(); + assertThat(statsStructForFieldId.fields()).hasSize(7); + + GenericRecord record = GenericRecord.create(statsStructForFieldId); + BaseFieldStats fieldStats = + BaseFieldStats.builder() + .type(Types.StringType.get()) + .fieldId(1) + .valueCount(10L) + .nullValueCount(2L) + .avgValueSize(3) + .maxValueSize(10) + .lowerBound("aa") + .upperBound("zzz") + .hasExactBounds() + .build(); + + record.setField(VALUE_COUNT.fieldName(), fieldStats.valueCount()); + record.setField(NULL_VALUE_COUNT.fieldName(), fieldStats.nullValueCount()); + record.setField(AVG_VALUE_SIZE.fieldName(), fieldStats.avgValueSize()); + record.setField(MAX_VALUE_SIZE.fieldName(), fieldStats.maxValueSize()); + record.setField(LOWER_BOUND.fieldName(), fieldStats.lowerBound()); + record.setField(UPPER_BOUND.fieldName(), fieldStats.upperBound()); + record.setField(EXACT_BOUNDS.fieldName(), fieldStats.hasExactBounds()); + + BaseContentStats stats = new BaseContentStats(rootStatsStruct); + stats.set(0, record); + assertThat(stats.fieldStats()).containsExactly(fieldStats); + } + + @Test + public void setByPositionOptionalDouble() { + Schema tableSchema = new Schema(optional(1, "d", Types.DoubleType.get())); + Types.StructType rootStatsStruct = StatsUtil.contentStatsFor(tableSchema).type().asStructType(); + Types.StructType statsStructForFieldId = rootStatsStruct.fields().get(0).type().asStructType(); + assertThat(statsStructForFieldId.fields()).hasSize(6); + + GenericRecord record = GenericRecord.create(statsStructForFieldId); + BaseFieldStats fieldStats = + BaseFieldStats.builder() + .type(Types.DoubleType.get()) + .fieldId(1) + .valueCount(10L) + .nullValueCount(2L) + .nanValueCount(3L) + .lowerBound(5.0) + .upperBound(20.0) + .hasExactBounds() + .build(); + + record.setField(VALUE_COUNT.fieldName(), fieldStats.valueCount()); + record.setField(NULL_VALUE_COUNT.fieldName(), fieldStats.nullValueCount()); + record.setField(NAN_VALUE_COUNT.fieldName(), fieldStats.nanValueCount()); + record.setField(LOWER_BOUND.fieldName(), fieldStats.lowerBound()); + record.setField(UPPER_BOUND.fieldName(), fieldStats.upperBound()); + record.setField(EXACT_BOUNDS.fieldName(), fieldStats.hasExactBounds()); + + BaseContentStats stats = new BaseContentStats(rootStatsStruct); + stats.set(0, record); + assertThat(stats.fieldStats()).containsExactly(fieldStats); + } + + @Test + public void setByPositionRequiredInteger() { Schema tableSchema = new Schema(required(1, "id", Types.IntegerType.get())); Types.StructType rootStatsStruct = StatsUtil.contentStatsFor(tableSchema).type().asStructType(); - Types.StructType statsStructForIdField = rootStatsStruct.fields().get(0).type().asStructType(); + Types.StructType statsStructForFieldId = rootStatsStruct.fields().get(0).type().asStructType(); + assertThat(statsStructForFieldId.fields()).hasSize(4); - GenericRecord record = GenericRecord.create(statsStructForIdField); + GenericRecord record = GenericRecord.create(statsStructForFieldId); BaseFieldStats fieldStats = BaseFieldStats.builder() .type(Types.IntegerType.get()) .fieldId(1) .valueCount(10L) - .nullValueCount(2L) - .nanValueCount(3L) - .avgValueSize(30) - .maxValueSize(70) .lowerBound(5) .upperBound(20) .hasExactBounds() .build(); - record.set(VALUE_COUNT.offset(), fieldStats.valueCount()); - record.set(NULL_VALUE_COUNT.offset(), fieldStats.nullValueCount()); - record.set(NAN_VALUE_COUNT.offset(), fieldStats.nanValueCount()); - record.set(AVG_VALUE_SIZE.offset(), fieldStats.avgValueSize()); - record.set(MAX_VALUE_SIZE.offset(), fieldStats.maxValueSize()); - record.set(LOWER_BOUND.offset(), fieldStats.lowerBound()); - record.set(UPPER_BOUND.offset(), fieldStats.upperBound()); - record.set(EXACT_BOUNDS.offset(), fieldStats.hasExactBounds()); + record.setField(VALUE_COUNT.fieldName(), fieldStats.valueCount()); + record.setField(LOWER_BOUND.fieldName(), fieldStats.lowerBound()); + record.setField(UPPER_BOUND.fieldName(), fieldStats.upperBound()); + record.setField(EXACT_BOUNDS.fieldName(), fieldStats.hasExactBounds()); // this is typically called by Avro reflection code BaseContentStats stats = new BaseContentStats(rootStatsStruct); @@ -287,17 +345,17 @@ public void setByPositionWithInvalidLowerAndUpperBound() { BaseContentStats stats = new BaseContentStats(rootStatsStruct); // invalid lower bound - record.set(LOWER_BOUND.offset(), 5.0); + record.setField(LOWER_BOUND.fieldName(), 5.0); assertThatThrownBy(() -> stats.set(0, record)) .isInstanceOf(IllegalArgumentException.class) .hasMessage( "Invalid lower bound type, expected a subtype of class java.lang.Integer: java.lang.Double"); // set valid lower bound so that upper bound is evaluated - record.set(LOWER_BOUND.offset(), 5); + record.setField(LOWER_BOUND.fieldName(), 5); // invalid upper bound - record.set(UPPER_BOUND.offset(), "20"); + record.setField(UPPER_BOUND.fieldName(), "20"); assertThatThrownBy(() -> stats.set(0, record)) .isInstanceOf(IllegalArgumentException.class) .hasMessage( diff --git a/core/src/test/java/org/apache/iceberg/TestDeleteFiles.java b/core/src/test/java/org/apache/iceberg/TestDeleteFiles.java index cad294f97a28..d7cdd5c5d884 100644 --- a/core/src/test/java/org/apache/iceberg/TestDeleteFiles.java +++ b/core/src/test/java/org/apache/iceberg/TestDeleteFiles.java @@ -84,7 +84,10 @@ public class TestDeleteFiles extends TestBase { @Parameters(name = "formatVersion = {0}, branch = {1}") protected static List parameters() { return TestHelpers.ALL_VERSIONS.stream() - .flatMap(v -> Stream.of(new Object[] {v, "main"}, new Object[] {v, "testBranch"})) + .flatMap( + v -> + Stream.of( + new Object[] {v, SnapshotRef.MAIN_BRANCH}, new Object[] {v, "testBranch"})) .collect(Collectors.toList()); } @@ -94,6 +97,10 @@ public void testMultipleDeletes() { table, table.newAppend().appendFile(FILE_A).appendFile(FILE_B).appendFile(FILE_C), branch); Snapshot append = latestSnapshot(readMetadata(), branch); assertThat(version()).isEqualTo(1); + assertThat(append.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); validateSnapshot(null, append, FILE_A, FILE_B, FILE_C); commit(table, table.newDelete().deleteFile(FILE_A), branch); @@ -101,6 +108,11 @@ public void testMultipleDeletes() { assertThat(version()).isEqualTo(2); assertThat(delete1.allManifests(FILE_IO)).hasSize(1); + // delete rewrites manifest: 1 created, 0 kept, 1 replaced + assertThat(delete1.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( delete1.allManifests(table.io()).get(0), ids(delete1.snapshotId(), append.snapshotId(), append.snapshotId()), @@ -110,6 +122,11 @@ public void testMultipleDeletes() { Snapshot delete2 = commit(table, table.newDelete().deleteFile(FILE_B), branch); assertThat(version()).isEqualTo(3); assertThat(delete2.allManifests(FILE_IO)).hasSize(1); + // second delete rewrites manifest: 1 created, 0 kept, 1 replaced + assertThat(delete2.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( delete2.allManifests(FILE_IO).get(0), ids(delete2.snapshotId(), append.snapshotId()), @@ -163,6 +180,10 @@ public void testAlreadyDeletedFilesAreIgnoredDuringDeletesByRowFilter() { branch); assertThat(initialSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(initialSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); validateManifestEntries( initialSnapshot.allManifests(FILE_IO).get(0), ids(initialSnapshot.snapshotId(), initialSnapshot.snapshotId()), @@ -172,6 +193,10 @@ public void testAlreadyDeletedFilesAreIgnoredDuringDeletesByRowFilter() { // delete the first data file Snapshot deleteSnapshot = commit(table, table.newDelete().deleteFile(firstDataFile), branch); assertThat(deleteSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(deleteSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( deleteSnapshot.allManifests(FILE_IO).get(0), ids(deleteSnapshot.snapshotId(), initialSnapshot.snapshotId()), @@ -184,6 +209,10 @@ public void testAlreadyDeletedFilesAreIgnoredDuringDeletesByRowFilter() { commit(table, table.newDelete().deleteFromRowFilter(Expressions.lessThan("id", 7)), branch); assertThat(finalSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(finalSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( finalSnapshot.allManifests(FILE_IO).get(0), ids(finalSnapshot.snapshotId()), @@ -204,6 +233,10 @@ public void testDeleteSomeFilesByRowFilterWithoutPartitionPredicates() { branch); assertThat(initialSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(initialSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); validateManifestEntries( initialSnapshot.allManifests(FILE_IO).get(0), ids(initialSnapshot.snapshotId(), initialSnapshot.snapshotId()), @@ -216,6 +249,10 @@ public void testDeleteSomeFilesByRowFilterWithoutPartitionPredicates() { table, table.newDelete().deleteFromRowFilter(Expressions.greaterThan("id", 5)), branch); assertThat(deleteSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(deleteSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( deleteSnapshot.allManifests(FILE_IO).get(0), ids(initialSnapshot.snapshotId(), deleteSnapshot.snapshotId()), @@ -236,6 +273,10 @@ public void testDeleteSomeFilesByRowFilterWithCombinedPredicates() { branch); assertThat(initialSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(initialSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); validateManifestEntries( initialSnapshot.allManifests(FILE_IO).get(0), ids(initialSnapshot.snapshotId(), initialSnapshot.snapshotId()), @@ -249,6 +290,10 @@ public void testDeleteSomeFilesByRowFilterWithCombinedPredicates() { Snapshot deleteSnapshot = commit(table, table.newDelete().deleteFromRowFilter(predicate), branch); assertThat(deleteSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(deleteSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( deleteSnapshot.allManifests(FILE_IO).get(0), ids(initialSnapshot.snapshotId(), deleteSnapshot.snapshotId()), @@ -290,7 +335,12 @@ public void testCannotDeleteFileWhereNotAllRowsMatchPartitionFilter() { @TestTemplate public void testDeleteCaseSensitivity() { - commit(table, table.newFastAppend().appendFile(DATA_FILE_BUCKET_0_IDS_0_2), branch); + Snapshot append = + commit(table, table.newFastAppend().appendFile(DATA_FILE_BUCKET_0_IDS_0_2), branch); + assertThat(append.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); Expression rowFilter = Expressions.lessThan("iD", 5); @@ -313,6 +363,10 @@ public void testDeleteCaseSensitivity() { table, table.newDelete().deleteFromRowFilter(rowFilter).caseSensitive(false), branch); assertThat(deleteSnapshot.allManifests(FILE_IO)).hasSize(1); + assertThat(deleteSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( deleteSnapshot.allManifests(FILE_IO).get(0), ids(deleteSnapshot.snapshotId()), @@ -325,13 +379,26 @@ public void testDeleteFilesOnIndependentBranches() { String testBranch = "testBranch"; table.newAppend().appendFile(FILE_A).appendFile(FILE_B).appendFile(FILE_C).commit(); Snapshot initialSnapshot = table.currentSnapshot(); + assertThat(initialSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); + // Delete A on test branch table.newDelete().deleteFile(FILE_A).toBranch(testBranch).commit(); Snapshot testBranchTip = table.snapshot(testBranch); + assertThat(testBranchTip.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); // Delete B and C on main table.newDelete().deleteFile(FILE_B).deleteFile(FILE_C).commit(); Snapshot delete2 = table.currentSnapshot(); + assertThat(delete2.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); // Verify B and C on testBranch validateManifestEntries( @@ -400,9 +467,18 @@ public void testDeleteWithCollision() { @TestTemplate public void testDeleteValidateFileExistence() { - commit(table, table.newFastAppend().appendFile(FILE_B), branch); + Snapshot append = commit(table, table.newFastAppend().appendFile(FILE_B), branch); + assertThat(append.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); + Snapshot delete = commit(table, table.newDelete().deleteFile(FILE_B).validateFilesExist(), branch); + assertThat(delete.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( Iterables.getOnlyElement(delete.allManifests(FILE_IO)), ids(delete.snapshotId()), @@ -429,17 +505,32 @@ public void testDeleteValidateFileExistence() { @TestTemplate public void testDeleteFilesNoValidation() { - commit(table, table.newFastAppend().appendFile(FILE_B), branch); + Snapshot append = commit(table, table.newFastAppend().appendFile(FILE_B), branch); + assertThat(append.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); + Snapshot delete1 = commit(table, table.newDelete().deleteFile(FILE_B), branch); + assertThat(delete1.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifestEntries( Iterables.getOnlyElement(delete1.allManifests(FILE_IO)), ids(delete1.snapshotId()), files(FILE_B), statuses(Status.DELETED)); + // deleting already deleted file results in no manifest changes Snapshot delete2 = commit(table, table.newDelete().deleteFile(FILE_B), branch); + assertThat(delete2.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); assertThat(delete2.allManifests(FILE_IO)).isEmpty(); - assertThat(delete2.removedDataFiles(FILE_IO)).isEmpty(); + assertThat(SnapshotChanges.builderFor(table).snapshot(delete2).build().removedDataFiles()) + .isEmpty(); } @Test @@ -507,6 +598,10 @@ public void removingDataFileByExpressionAlsoRemovesDV() { Snapshot snapshot = latestSnapshot(table, branch); assertThat(snapshot.sequenceNumber()).isEqualTo(1); assertThat(table.ops().current().lastSequenceNumber()).isEqualTo(1); + assertThat(snapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); // deleting by row filter should also remove the orphaned dv1 from delete manifests commit(table, table.newDelete().deleteFromRowFilter(Expressions.lessThan("id", 5)), branch); @@ -514,8 +609,16 @@ public void removingDataFileByExpressionAlsoRemovesDV() { Snapshot deleteSnap = latestSnapshot(table, branch); assertThat(deleteSnap.sequenceNumber()).isEqualTo(2); assertThat(table.ops().current().lastSequenceNumber()).isEqualTo(2); + assertThat(deleteSnap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "2"); assertThat(deleteSnap.deleteManifests(table.io())).hasSize(1); + assertThat(deleteSnap.summary()) + .containsEntry(SnapshotSummary.REMOVED_DVS_PROP, "1") + .containsEntry(SnapshotSummary.REMOVED_DELETE_FILES_PROP, "1"); + validateDeleteManifest( deleteSnap.deleteManifests(table.io()).get(0), dataSeqs(1L, 1L), @@ -541,6 +644,10 @@ public void removingDataFileByPathAlsoRemovesDV() { Snapshot snapshot = latestSnapshot(table, branch); assertThat(snapshot.sequenceNumber()).isEqualTo(1); assertThat(table.ops().current().lastSequenceNumber()).isEqualTo(1); + assertThat(snapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); // deleting by path should also remove the orphaned DV for fileA from delete manifests commit(table, table.newDelete().deleteFile(FILE_A.location()), branch); @@ -548,8 +655,17 @@ public void removingDataFileByPathAlsoRemovesDV() { Snapshot deleteSnap = latestSnapshot(table, branch); assertThat(deleteSnap.sequenceNumber()).isEqualTo(2); assertThat(table.ops().current().lastSequenceNumber()).isEqualTo(2); + // delete rewrites both data and delete manifests + assertThat(deleteSnap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "2"); assertThat(deleteSnap.deleteManifests(table.io())).hasSize(1); + assertThat(deleteSnap.summary()) + .containsEntry(SnapshotSummary.REMOVED_DVS_PROP, "1") + .containsEntry(SnapshotSummary.REMOVED_DELETE_FILES_PROP, "1"); + validateDeleteManifest( deleteSnap.deleteManifests(table.io()).get(0), dataSeqs(1L, 1L), @@ -559,6 +675,69 @@ public void removingDataFileByPathAlsoRemovesDV() { statuses(ManifestEntry.Status.DELETED, ManifestEntry.Status.EXISTING)); } + @TestTemplate + public void removingDataFilesWhenTruncatingAlsoRemovesDVs() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + DeleteFile dv1 = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-1-deletes.puffin") + .withFileSizeInBytes(10) + .withPartitionPath("data_bucket=0") + .withRecordCount(5) + .withReferencedDataFile(DATA_FILE_BUCKET_0_IDS_0_2.location()) + .withContentOffset(4) + .withContentSizeInBytes(6) + .build(); + + DeleteFile dv2 = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-2-deletes.puffin") + .withFileSizeInBytes(10) + .withPartitionPath("data_bucket=0") + .withRecordCount(5) + .withReferencedDataFile(DATA_FILE_BUCKET_0_IDS_8_10.location()) + .withContentOffset(4) + .withContentSizeInBytes(6) + .build(); + + commit( + table, + table + .newRowDelta() + .addRows(DATA_FILE_BUCKET_0_IDS_0_2) + .addRows(DATA_FILE_BUCKET_0_IDS_8_10) + .addDeletes(dv1) + .addDeletes(dv2), + branch); + + Snapshot snapshot = latestSnapshot(table, branch); + assertThat(snapshot.sequenceNumber()).isEqualTo(1); + assertThat(table.ops().current().lastSequenceNumber()).isEqualTo(1); + + // deleting by row filter should also remove the orphaned dv1 from delete manifests. When a + // table is truncated via TRUNCATE, the row filter is sent as Expressions.alwaysTrue() + commit(table, table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()), branch); + + Snapshot deleteSnap = latestSnapshot(table, branch); + assertThat(deleteSnap.sequenceNumber()).isEqualTo(2); + assertThat(table.ops().current().lastSequenceNumber()).isEqualTo(2); + + assertThat(deleteSnap.deleteManifests(table.io())).hasSize(1); + assertThat(deleteSnap.summary()) + .containsEntry(SnapshotSummary.REMOVED_DVS_PROP, "2") + .containsEntry(SnapshotSummary.REMOVED_DELETE_FILES_PROP, "2"); + + validateDeleteManifest( + deleteSnap.deleteManifests(table.io()).get(0), + dataSeqs(1L, 1L), + fileSeqs(1L, 1L), + ids(deleteSnap.snapshotId(), deleteSnap.snapshotId()), + files(dv1, dv2), + statuses(Status.DELETED, Status.DELETED)); + } + private static ByteBuffer longToBuffer(long value) { return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0, value); } diff --git a/core/src/test/java/org/apache/iceberg/TestDeletionVectorStruct.java b/core/src/test/java/org/apache/iceberg/TestDeletionVectorStruct.java new file mode 100644 index 000000000000..325f9afd9ca9 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestDeletionVectorStruct.java @@ -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. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +class TestDeletionVectorStruct { + + @Test + void testFieldAccess() { + DeletionVectorStruct dv = + DeletionVectorStruct.builder() + .location("s3://bucket/data/dv.puffin") + .offset(256L) + .sizeInBytes(128L) + .cardinality(42L) + .build(); + + assertThat(dv.location()).isEqualTo("s3://bucket/data/dv.puffin"); + assertThat(dv.offset()).isEqualTo(256L); + assertThat(dv.sizeInBytes()).isEqualTo(128L); + assertThat(dv.cardinality()).isEqualTo(42L); + } + + @Test + void testCopy() { + DeletionVectorStruct dv = + DeletionVectorStruct.builder() + .location("s3://bucket/data/dv.puffin") + .offset(256L) + .sizeInBytes(128L) + .cardinality(42L) + .build(); + + DeletionVectorStruct copy = dv.copy(); + + assertThat(copy.location()).isEqualTo("s3://bucket/data/dv.puffin"); + assertThat(copy.offset()).isEqualTo(256L); + assertThat(copy.sizeInBytes()).isEqualTo(128L); + assertThat(copy.cardinality()).isEqualTo(42L); + } + + @Test + void testSize() { + DeletionVectorStruct dv = new DeletionVectorStruct(DeletionVector.schema()); + assertThat(dv.size()).isEqualTo(4); + } + + @Test + void testProjectedStructLike() { + // project only location (field ID 155) and cardinality (field ID 156) + Types.StructType projection = + Types.StructType.of(DeletionVector.LOCATION, DeletionVector.CARDINALITY); + + DeletionVectorStruct dv = new DeletionVectorStruct(projection); + assertThat(dv.size()).isEqualTo(2); + + // projected position 0 maps to internal position 0 (location) + // projected position 1 maps to internal position 3 (cardinality) + dv.set(0, "s3://bucket/data/dv.puffin"); + dv.set(1, 42L); + + assertThat(dv.location()).isEqualTo("s3://bucket/data/dv.puffin"); + assertThat(dv.cardinality()).isEqualTo(42L); + assertThat(dv.get(0, String.class)).isEqualTo("s3://bucket/data/dv.puffin"); + assertThat(dv.get(1, Long.class)).isEqualTo(42L); + } + + @Test + void testJavaSerializationRoundTrip() throws IOException, ClassNotFoundException { + DeletionVectorStruct dv = + DeletionVectorStruct.builder() + .location("s3://bucket/data/dv.puffin") + .offset(256L) + .sizeInBytes(128L) + .cardinality(42L) + .build(); + + DeletionVectorStruct deserialized = TestHelpers.roundTripSerialize(dv); + + assertThat(deserialized.location()).isEqualTo("s3://bucket/data/dv.puffin"); + assertThat(deserialized.offset()).isEqualTo(256L); + assertThat(deserialized.sizeInBytes()).isEqualTo(128L); + assertThat(deserialized.cardinality()).isEqualTo(42L); + } + + @Test + void testBuilderValidation() { + assertThatThrownBy( + () -> DeletionVectorStruct.builder().offset(0).sizeInBytes(1).cardinality(1).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid location: null"); + + assertThatThrownBy( + () -> + DeletionVectorStruct.builder() + .location("s3://bucket/dv.puffin") + .sizeInBytes(1) + .cardinality(1) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid offset: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + DeletionVectorStruct.builder() + .location("s3://bucket/dv.puffin") + .offset(0) + .cardinality(1) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid size in bytes: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + DeletionVectorStruct.builder() + .location("s3://bucket/dv.puffin") + .offset(0) + .sizeInBytes(1) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid cardinality: -1 (must be >= 0)"); + } + + @Test + void testKryoSerializationRoundTrip() throws IOException { + DeletionVectorStruct dv = + DeletionVectorStruct.builder() + .location("s3://bucket/data/dv.puffin") + .offset(256L) + .sizeInBytes(128L) + .cardinality(42L) + .build(); + + DeletionVectorStruct deserialized = TestHelpers.KryoHelpers.roundTripSerialize(dv); + + assertThat(deserialized.location()).isEqualTo("s3://bucket/data/dv.puffin"); + assertThat(deserialized.offset()).isEqualTo(256L); + assertThat(deserialized.sizeInBytes()).isEqualTo(128L); + assertThat(deserialized.cardinality()).isEqualTo(42L); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestEntryStatus.java b/core/src/test/java/org/apache/iceberg/TestEntryStatus.java new file mode 100644 index 000000000000..c395cdcece7c --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestEntryStatus.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.stream.IntStream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +class TestEntryStatus { + + @ParameterizedTest + @EnumSource(EntryStatus.class) + void fromId(EntryStatus status) { + assertThat(EntryStatus.fromId(status.id())).isEqualTo(status); + } + + static IntStream invalidIds() { + return IntStream.of(-1, EntryStatus.values().length); + } + + @ParameterizedTest + @MethodSource("invalidIds") + void fromIdInvalid(int id) { + assertThatThrownBy(() -> EntryStatus.fromId(id)) + .isInstanceOf(ArrayIndexOutOfBoundsException.class) + .hasMessageContaining(String.valueOf(id)); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestFastAppend.java b/core/src/test/java/org/apache/iceberg/TestFastAppend.java index 33153d8454c3..8f427525e214 100644 --- a/core/src/test/java/org/apache/iceberg/TestFastAppend.java +++ b/core/src/test/java/org/apache/iceberg/TestFastAppend.java @@ -170,7 +170,11 @@ public void testEmptyTableAppendManifest() throws IOException { } // validate that the metadata summary is correct when using appendManifest - assertThat(snap.summary()).containsEntry("added-data-files", "2"); + assertThat(snap.summary()) + .containsEntry("added-data-files", "2") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); V2Assert.assertEquals("Snapshot sequence number should be 1", 1, snap.sequenceNumber()); V2Assert.assertEquals( @@ -214,6 +218,13 @@ public void testEmptyTableAppendFilesAndManifest() throws IOException { assertThat(snap.allManifests(FILE_IO).get(1).path()).isEqualTo(manifest.path()); } + // validate manifest metrics in the snapshot summary + // 2 manifests created: 1 for appendFile (FILE_C, FILE_D) + 1 for appendManifest + assertThat(snap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); + V2Assert.assertEquals("Snapshot sequence number should be 1", 1, snap.sequenceNumber()); V2Assert.assertEquals( "Last sequence number should be 1", 1, readMetadata().lastSequenceNumber()); @@ -459,7 +470,10 @@ public void testAppendManifestWithSnapshotIdInheritance() throws IOException { .containsEntry("added-data-files", "2") .containsEntry("added-records", "2") .containsEntry("total-data-files", "2") - .containsEntry("total-records", "2"); + .containsEntry("total-records", "2") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -551,7 +565,10 @@ public void testDefaultPartitionSummaries() { assertThat(table.currentSnapshot().summary()) .doesNotContainKey(SnapshotSummary.PARTITION_SUMMARY_PROP) - .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1"); + .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -571,7 +588,10 @@ public void testIncludedPartitionSummaries() { .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1") .containsEntry( SnapshotSummary.CHANGED_PARTITION_PREFIX + "data_bucket=0", - "added-data-files=1,added-records=1,added-files-size=10"); + "added-data-files=1,added-records=1,added-files-size=10") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -588,7 +608,10 @@ public void testIncludedPartitionSummaryLimit() { assertThat(table.currentSnapshot().summary()) .doesNotContainKey(SnapshotSummary.PARTITION_SUMMARY_PROP) - .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "2"); + .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "2") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate diff --git a/core/src/test/java/org/apache/iceberg/stats/TestFieldStats.java b/core/src/test/java/org/apache/iceberg/TestFieldStats.java similarity index 72% rename from core/src/test/java/org/apache/iceberg/stats/TestFieldStats.java rename to core/src/test/java/org/apache/iceberg/TestFieldStats.java index d5d0ae23094b..c703a3044fc0 100644 --- a/core/src/test/java/org/apache/iceberg/stats/TestFieldStats.java +++ b/core/src/test/java/org/apache/iceberg/TestFieldStats.java @@ -16,21 +16,29 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; - -import static org.apache.iceberg.stats.FieldStatistic.AVG_VALUE_SIZE; -import static org.apache.iceberg.stats.FieldStatistic.EXACT_BOUNDS; -import static org.apache.iceberg.stats.FieldStatistic.LOWER_BOUND; -import static org.apache.iceberg.stats.FieldStatistic.MAX_VALUE_SIZE; -import static org.apache.iceberg.stats.FieldStatistic.NAN_VALUE_COUNT; -import static org.apache.iceberg.stats.FieldStatistic.NULL_VALUE_COUNT; -import static org.apache.iceberg.stats.FieldStatistic.UPPER_BOUND; -import static org.apache.iceberg.stats.FieldStatistic.VALUE_COUNT; +package org.apache.iceberg; + +import static org.apache.iceberg.FieldStatistic.AVG_VALUE_SIZE; +import static org.apache.iceberg.FieldStatistic.EXACT_BOUNDS; +import static org.apache.iceberg.FieldStatistic.LOWER_BOUND; +import static org.apache.iceberg.FieldStatistic.MAX_VALUE_SIZE; +import static org.apache.iceberg.FieldStatistic.NAN_VALUE_COUNT; +import static org.apache.iceberg.FieldStatistic.NULL_VALUE_COUNT; +import static org.apache.iceberg.FieldStatistic.UPPER_BOUND; +import static org.apache.iceberg.FieldStatistic.VALUE_COUNT; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; +import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; public class TestFieldStats { @@ -192,23 +200,52 @@ public void retrievalByPosition() { .hasExactBounds() .build(); - assertThat(fieldStats.get(VALUE_COUNT.offset(), Long.class)).isEqualTo(10L); - assertThat(fieldStats.get(NULL_VALUE_COUNT.offset(), Long.class)).isEqualTo(2L); - assertThat(fieldStats.get(NAN_VALUE_COUNT.offset(), Long.class)).isEqualTo(3L); - assertThat(fieldStats.get(AVG_VALUE_SIZE.offset(), Integer.class)).isEqualTo(30); - assertThat(fieldStats.get(MAX_VALUE_SIZE.offset(), Integer.class)).isEqualTo(70); - assertThat(fieldStats.get(LOWER_BOUND.offset(), Integer.class)).isEqualTo(5); - assertThat(fieldStats.get(UPPER_BOUND.offset(), Integer.class)).isEqualTo(20); - assertThat(fieldStats.get(EXACT_BOUNDS.offset(), Boolean.class)).isEqualTo(true); + assertThat(fieldStats.get(VALUE_COUNT.position(), Long.class)).isEqualTo(10L); + assertThat(fieldStats.get(NULL_VALUE_COUNT.position(), Long.class)).isEqualTo(2L); + assertThat(fieldStats.get(NAN_VALUE_COUNT.position(), Long.class)).isEqualTo(3L); + assertThat(fieldStats.get(AVG_VALUE_SIZE.position(), Integer.class)).isEqualTo(30); + assertThat(fieldStats.get(MAX_VALUE_SIZE.position(), Integer.class)).isEqualTo(70); + assertThat(fieldStats.get(LOWER_BOUND.position(), Integer.class)).isEqualTo(5); + assertThat(fieldStats.get(UPPER_BOUND.position(), Integer.class)).isEqualTo(20); + assertThat(fieldStats.get(EXACT_BOUNDS.position(), Boolean.class)).isEqualTo(true); assertThatThrownBy(() -> assertThat(fieldStats.get(10, Long.class))) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Invalid statistic offset: 10"); - assertThatThrownBy(() -> assertThat(fieldStats.get(VALUE_COUNT.offset(), Double.class))) + .isInstanceOf(ArrayIndexOutOfBoundsException.class) + .hasMessage("Index 10 out of bounds for length 8"); + assertThatThrownBy(() -> assertThat(fieldStats.get(VALUE_COUNT.position(), Double.class))) .isInstanceOf(ClassCastException.class) .hasMessage("Cannot cast java.lang.Long to java.lang.Double"); - assertThatThrownBy(() -> assertThat(fieldStats.get(AVG_VALUE_SIZE.offset(), Long.class))) + assertThatThrownBy(() -> assertThat(fieldStats.get(AVG_VALUE_SIZE.position(), Long.class))) .isInstanceOf(ClassCastException.class) .hasMessage("Cannot cast java.lang.Integer to java.lang.Long"); } + + private static Stream binaryTypes() { + return Stream.of( + Arguments.of(Types.BinaryType.get()), Arguments.of(Types.FixedType.ofLength(3))); + } + + @ParameterizedTest + @MethodSource("binaryTypes") + public void statsForBinaryTypes(Type binaryType) throws IOException, ClassNotFoundException { + BaseFieldStats statsWithByteBuffer = + BaseFieldStats.builder() + .type(binaryType) + .lowerBound(ByteBuffer.wrap("AAA".getBytes(StandardCharsets.UTF_8))) + .upperBound(ByteBuffer.wrap("ZZZ".getBytes(StandardCharsets.UTF_8))) + .build(); + assertThat(TestHelpers.roundTripSerialize(statsWithByteBuffer)).isEqualTo(statsWithByteBuffer); + + BaseFieldStats statsWithByteArray = + BaseFieldStats.builder() + .type(binaryType) + .lowerBound("AAA".getBytes(StandardCharsets.UTF_8)) + .upperBound("ZZZ".getBytes(StandardCharsets.UTF_8)) + .build(); + assertThat(TestHelpers.roundTripSerialize(statsWithByteArray)).isEqualTo(statsWithByteArray); + + assertThat(statsWithByteArray) + .as("both field stats should produce the same upper/lower bound values") + .isEqualTo(statsWithByteBuffer); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestFileGenerationUtil.java b/core/src/test/java/org/apache/iceberg/TestFileGenerationUtil.java index ea44aa73c6d6..f533df159893 100644 --- a/core/src/test/java/org/apache/iceberg/TestFileGenerationUtil.java +++ b/core/src/test/java/org/apache/iceberg/TestFileGenerationUtil.java @@ -31,6 +31,8 @@ import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.NestedField; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class TestFileGenerationUtil { @@ -85,6 +87,23 @@ public void testBoundsWithSpecificValues() { assertThat(actualIntUpper).isEqualTo(intUpper); } + @ParameterizedTest + @ValueSource(strings = {"none", "counts", "truncate(16)", "full"}) + @SuppressWarnings("deprecation") + void testBoundsForAllMetricsModes(String metricsMode) { + MetricsConfig metricsConfig = + MetricsConfig.fromProperties( + ImmutableMap.of(TableProperties.DEFAULT_WRITE_METRICS_MODE, metricsMode)); + Metrics metrics = + FileGenerationUtil.generateRandomMetrics( + SCHEMA, + metricsConfig, + ImmutableMap.of() /* no lower bounds */, + ImmutableMap.of() /* no upper bounds */); + + checkBounds(metrics, metricsConfig); + } + private void checkBounds(Metrics metrics, MetricsConfig metricsConfig) { for (NestedField field : SCHEMA.columns()) { MetricsMode mode = metricsConfig.columnMode(field.name()); diff --git a/core/src/test/java/org/apache/iceberg/TestFindFiles.java b/core/src/test/java/org/apache/iceberg/TestFindFiles.java index 14746601e74a..9a473dd41b3a 100644 --- a/core/src/test/java/org/apache/iceberg/TestFindFiles.java +++ b/core/src/test/java/org/apache/iceberg/TestFindFiles.java @@ -24,12 +24,14 @@ import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.Conversions; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ParallelIterable; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; @@ -209,15 +211,20 @@ public void testNoSnapshot() { @TestTemplate public void testPlanWith() { - table - .newAppend() - .appendFile(FILE_A) - .appendFile(FILE_B) - .appendFile(FILE_C) - .appendFile(FILE_D) - .commit(); - - ExecutorService executorService = Executors.newFixedThreadPool(2); + // use separate commits to create multiple manifests for parallel scanning + table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + table.newAppend().appendFile(FILE_C).appendFile(FILE_D).commit(); + + AtomicInteger planThreadsIndex = new AtomicInteger(0); + ExecutorService executorService = + Executors.newFixedThreadPool( + 2, + runnable -> { + Thread thread = new Thread(runnable); + thread.setName("plan-" + planThreadsIndex.getAndIncrement()); + thread.setDaemon(true); + return thread; + }); try { Iterable files = FindFiles.in(table) @@ -225,7 +232,11 @@ public void testPlanWith() { .withMetadataMatching(Expressions.startsWith("file_path", "/path/to/data")) .collect(); + assertThat(files).isInstanceOf(ParallelIterable.class); assertThat(pathSet(files)).isEqualTo(pathSet(FILE_A, FILE_B, FILE_C, FILE_D)); + assertThat(planThreadsIndex.get()) + .as("Thread should be created in provided pool") + .isGreaterThan(0); } finally { executorService.shutdown(); } diff --git a/core/src/test/java/org/apache/iceberg/TestLocationProvider.java b/core/src/test/java/org/apache/iceberg/TestLocationProvider.java index 93b4d582942e..d665d84cad82 100644 --- a/core/src/test/java/org/apache/iceberg/TestLocationProvider.java +++ b/core/src/test/java/org/apache/iceberg/TestLocationProvider.java @@ -219,7 +219,7 @@ public void testObjectStorageLocationProviderThrowOnDeprecatedProperties() { assertThatThrownBy(() -> table.locationProvider().newDataLocation("file")) .isInstanceOf(IllegalArgumentException.class) .hasMessage( - "Property 'write.folder-storage.path' has been deprecated and will be removed in 2.0, use 'write.data.path' instead."); + "Property 'write.folder-storage.path' has been deprecated and will be removed in 2.0.0, use 'write.data.path' instead."); table .updateProperties() @@ -230,7 +230,7 @@ public void testObjectStorageLocationProviderThrowOnDeprecatedProperties() { assertThatThrownBy(() -> table.locationProvider().newDataLocation("file")) .isInstanceOf(IllegalArgumentException.class) .hasMessage( - "Property 'write.object-storage.path' has been deprecated and will be removed in 2.0, use 'write.data.path' instead."); + "Property 'write.object-storage.path' has been deprecated and will be removed in 2.0.0, use 'write.data.path' instead."); } @TestTemplate @@ -245,7 +245,7 @@ public void testDefaultStorageLocationProviderThrowOnDeprecatedProperties() { assertThatThrownBy(() -> table.locationProvider().newDataLocation("file")) .isInstanceOf(IllegalArgumentException.class) .hasMessage( - "Property 'write.folder-storage.path' has been deprecated and will be removed in 2.0, use 'write.data.path' instead."); + "Property 'write.folder-storage.path' has been deprecated and will be removed in 2.0.0, use 'write.data.path' instead."); } @TestTemplate @@ -295,7 +295,7 @@ public void testExcludePartitionInPath() { String fileLocation = table.locationProvider().newDataLocation(table.spec(), partitionData, "test.parquet"); - // no partition values included in the path and last part of entropy is seperated with "-" + // no partition values included in the path and last part of entropy is separated with "-" assertThat(fileLocation).endsWith("/data/0110/1010/0011/11101000-test.parquet"); } diff --git a/core/src/test/java/org/apache/iceberg/TestManifestCaching.java b/core/src/test/java/org/apache/iceberg/TestManifestCaching.java index 9a944c1583d0..57acb7d92d05 100644 --- a/core/src/test/java/org/apache/iceberg/TestManifestCaching.java +++ b/core/src/test/java/org/apache/iceberg/TestManifestCaching.java @@ -78,16 +78,16 @@ public void testPlanWithCache() throws Exception { assertThat(cache.estimatedCacheSize()) .as("All manifest files should be cached") .isEqualTo(numFiles); - assertThat(cache.stats().loadCount()) + assertThat(cache.stats().loadSuccessCount()) .as("All manifest files should be recently loaded") .isEqualTo(numFiles); - long missCount = cache.stats().missCount(); + long missCount = ManifestFiles.contentCacheStats(table.io()).missCount(); // planFiles and verify that cache size still the same TableScan scan2 = table.newScan(); assertThat(scan2.planFiles()).hasSize(numFiles); assertThat(cache.estimatedCacheSize()).isEqualTo(numFiles); - assertThat(cache.stats().missCount()) + assertThat(ManifestFiles.contentCacheStats(table.io()).missCount()) .as("All manifest file reads should hit cache") .isEqualTo(missCount); @@ -115,10 +115,14 @@ public void testPlanWithSmallCache() throws Exception { assertThat(cache.maxTotalBytes()).isEqualTo(1); assertThat(scan.planFiles()).hasSize(numFiles); assertThat(cache.estimatedCacheSize()).isEqualTo(0); - assertThat(cache.stats().loadCount()) + assertThat(cache.stats().loadSuccessCount()) .as("File should not be loaded through cache") .isEqualTo(0); - assertThat(cache.stats().requestCount()).as("Cache should not serve file").isEqualTo(0); + assertThat( + ManifestFiles.contentCacheStats(table.io()).hitCount() + + ManifestFiles.contentCacheStats(table.io()).missCount()) + .as("Cache should not serve file") + .isEqualTo(0); ManifestFiles.dropCache(scan.table().io()); } diff --git a/core/src/test/java/org/apache/iceberg/TestManifestEntryStatus.java b/core/src/test/java/org/apache/iceberg/TestManifestEntryStatus.java new file mode 100644 index 000000000000..39867bbf7c02 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestManifestEntryStatus.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.stream.IntStream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +class TestManifestEntryStatus { + + @ParameterizedTest + @EnumSource(ManifestEntry.Status.class) + void fromId(ManifestEntry.Status status) { + assertThat(ManifestEntry.Status.fromId(status.id())).isEqualTo(status); + } + + static IntStream invalidIds() { + return IntStream.of(-1, ManifestEntry.Status.values().length); + } + + @ParameterizedTest + @MethodSource("invalidIds") + void fromIdInvalid(int id) { + assertThatThrownBy(() -> ManifestEntry.Status.fromId(id)) + .isInstanceOf(ArrayIndexOutOfBoundsException.class) + .hasMessageContaining(String.valueOf(id)); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestManifestInfoStruct.java b/core/src/test/java/org/apache/iceberg/TestManifestInfoStruct.java new file mode 100644 index 000000000000..3a694f1a38f2 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestManifestInfoStruct.java @@ -0,0 +1,389 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.ByteBuffer; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +class TestManifestInfoStruct { + + @Test + void testFieldAccess() { + ManifestInfoStruct info = new ManifestInfoStruct(ManifestInfo.schema()); + + info.set(0, 10); + info.set(1, 20); + info.set(2, 3); + info.set(3, 2); + info.set(4, 1000L); + info.set(5, 2000L); + info.set(6, 300L); + info.set(7, 200L); + info.set(8, 5L); + info.set(9, ByteBuffer.wrap(new byte[] {0xF})); + info.set(10, 1L); + + assertThat(info.addedFilesCount()).isEqualTo(10); + assertThat(info.existingFilesCount()).isEqualTo(20); + assertThat(info.deletedFilesCount()).isEqualTo(3); + assertThat(info.replacedFilesCount()).isEqualTo(2); + assertThat(info.addedRowsCount()).isEqualTo(1000L); + assertThat(info.existingRowsCount()).isEqualTo(2000L); + assertThat(info.deletedRowsCount()).isEqualTo(300L); + assertThat(info.replacedRowsCount()).isEqualTo(200L); + assertThat(info.minSequenceNumber()).isEqualTo(5L); + assertThat(info.dv()).isNotNull(); + assertThat(info.dvCardinality()).isEqualTo(1L); + } + + @Test + void testCopy() { + ManifestInfoStruct info = + ManifestInfoStruct.builder() + .addedFilesCount(10) + .existingFilesCount(20) + .deletedFilesCount(3) + .replacedFilesCount(2) + .addedRowsCount(1000L) + .existingRowsCount(2000L) + .deletedRowsCount(300L) + .replacedRowsCount(200L) + .minSequenceNumber(5L) + .dv(new byte[] {0xF}) + .dvCardinality(1L) + .build(); + + ManifestInfoStruct copy = info.copy(); + + assertThat(copy.addedFilesCount()).isEqualTo(10); + assertThat(copy.existingFilesCount()).isEqualTo(20); + assertThat(copy.deletedFilesCount()).isEqualTo(3); + assertThat(copy.replacedFilesCount()).isEqualTo(2); + assertThat(copy.addedRowsCount()).isEqualTo(1000L); + assertThat(copy.existingRowsCount()).isEqualTo(2000L); + assertThat(copy.deletedRowsCount()).isEqualTo(300L); + assertThat(copy.replacedRowsCount()).isEqualTo(200L); + assertThat(copy.minSequenceNumber()).isEqualTo(5L); + assertThat(copy.dvCardinality()).isEqualTo(1L); + + // verify deep copy of dv byte array + assertThat(copy.dv().array()).isNotSameAs(info.dv().array()); + } + + @Test + void testNullableFields() { + ManifestInfoStruct info = + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build(); + + assertThat(info.dv()).isNull(); + assertThat(info.dvCardinality()).isNull(); + } + + @Test + void testProjectedStructLike() { + // project only added_files_count (field ID 504) and min_sequence_number (field ID 516) + Types.StructType projection = + Types.StructType.of(ManifestInfo.ADDED_FILES_COUNT, ManifestInfo.MIN_SEQUENCE_NUMBER); + + ManifestInfoStruct info = new ManifestInfoStruct(projection); + assertThat(info.size()).isEqualTo(2); + + // projected position 0 maps to internal position 0 (added_files_count) + // projected position 1 maps to internal position 8 (min_sequence_number) + info.set(0, 10); + info.set(1, 5L); + + assertThat(info.addedFilesCount()).isEqualTo(10); + assertThat(info.minSequenceNumber()).isEqualTo(5L); + assertThat(info.get(0, Integer.class)).isEqualTo(10); + assertThat(info.get(1, Long.class)).isEqualTo(5L); + } + + @Test + void testJavaSerializationRoundTrip() throws IOException, ClassNotFoundException { + ManifestInfoStruct info = + ManifestInfoStruct.builder() + .addedFilesCount(10) + .existingFilesCount(20) + .deletedFilesCount(3) + .replacedFilesCount(2) + .addedRowsCount(1000L) + .existingRowsCount(2000L) + .deletedRowsCount(300L) + .replacedRowsCount(200L) + .minSequenceNumber(5L) + .dv(new byte[] {0xF}) + .dvCardinality(1L) + .build(); + + ManifestInfoStruct deserialized = TestHelpers.roundTripSerialize(info); + + assertThat(deserialized.addedFilesCount()).isEqualTo(10); + assertThat(deserialized.existingFilesCount()).isEqualTo(20); + assertThat(deserialized.deletedFilesCount()).isEqualTo(3); + assertThat(deserialized.replacedFilesCount()).isEqualTo(2); + assertThat(deserialized.addedRowsCount()).isEqualTo(1000L); + assertThat(deserialized.existingRowsCount()).isEqualTo(2000L); + assertThat(deserialized.deletedRowsCount()).isEqualTo(300L); + assertThat(deserialized.replacedRowsCount()).isEqualTo(200L); + assertThat(deserialized.minSequenceNumber()).isEqualTo(5L); + assertThat(deserialized.dv()).isEqualTo(ByteBuffer.wrap(new byte[] {0xF})); + assertThat(deserialized.dvCardinality()).isEqualTo(1L); + } + + @Test + void testBuilderValidation() { + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid added files count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid existing files count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid deleted files count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid replaced files count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid added rows count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid existing rows count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid deleted rows count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .minSequenceNumber(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid replaced rows count: -1 (must be >= 0)"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid min sequence number: -1 (must be >= 0)"); + } + + @Test + void testBuilderDvPairingValidation() { + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .dv(new byte[] {0xF}) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid DV and cardinality: must both be null or non-null"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .dvCardinality(1L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid DV and cardinality: must both be null or non-null"); + + assertThatThrownBy( + () -> + ManifestInfoStruct.builder() + .addedFilesCount(0) + .existingFilesCount(0) + .deletedFilesCount(0) + .replacedFilesCount(0) + .addedRowsCount(0L) + .existingRowsCount(0L) + .deletedRowsCount(0L) + .replacedRowsCount(0L) + .minSequenceNumber(0L) + .dv(new byte[] {0xF}) + .dvCardinality(0L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid DV cardinality: 0 (must be positive)"); + } + + @Test + void testKryoSerializationRoundTrip() throws IOException { + ManifestInfoStruct info = + ManifestInfoStruct.builder() + .addedFilesCount(10) + .existingFilesCount(20) + .deletedFilesCount(3) + .replacedFilesCount(2) + .addedRowsCount(1000L) + .existingRowsCount(2000L) + .deletedRowsCount(300L) + .replacedRowsCount(200L) + .minSequenceNumber(5L) + .dv(new byte[] {0xF}) + .dvCardinality(1L) + .build(); + + ManifestInfoStruct deserialized = TestHelpers.KryoHelpers.roundTripSerialize(info); + + assertThat(deserialized.addedFilesCount()).isEqualTo(10); + assertThat(deserialized.existingFilesCount()).isEqualTo(20); + assertThat(deserialized.deletedFilesCount()).isEqualTo(3); + assertThat(deserialized.replacedFilesCount()).isEqualTo(2); + assertThat(deserialized.addedRowsCount()).isEqualTo(1000L); + assertThat(deserialized.existingRowsCount()).isEqualTo(2000L); + assertThat(deserialized.deletedRowsCount()).isEqualTo(300L); + assertThat(deserialized.replacedRowsCount()).isEqualTo(200L); + assertThat(deserialized.minSequenceNumber()).isEqualTo(5L); + assertThat(deserialized.dv()).isEqualTo(ByteBuffer.wrap(new byte[] {0xF})); + assertThat(deserialized.dvCardinality()).isEqualTo(1L); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestManifestReader.java b/core/src/test/java/org/apache/iceberg/TestManifestReader.java index fe4e4a74d1c4..6690a1483e53 100644 --- a/core/src/test/java/org/apache/iceberg/TestManifestReader.java +++ b/core/src/test/java/org/apache/iceberg/TestManifestReader.java @@ -51,7 +51,7 @@ public class TestManifestReader extends TestBase { @TestTemplate public void testManifestReaderWithEmptyInheritableMetadata() throws IOException { ManifestFile manifest = writeManifest(1000L, manifestEntry(Status.EXISTING, 1000L, FILE_A)); - try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { ManifestEntry entry = Iterables.getOnlyElement(reader.entries()); assertThat(entry.status()).isEqualTo(Status.EXISTING); assertThat(entry.file().location()).isEqualTo(FILE_A.location()); @@ -63,7 +63,8 @@ public void testManifestReaderWithEmptyInheritableMetadata() throws IOException public void testReaderWithFilterWithoutSelect() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE_A, FILE_B, FILE_C); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO).filterRows(Expressions.equal("id", 0))) { + ManifestFiles.read(manifest, FILE_IO, table.specs()) + .filterRows(Expressions.equal("id", 0))) { List files = Streams.stream(reader).collect(Collectors.toList()); // note that all files are returned because the reader returns data files that may match, and @@ -78,7 +79,7 @@ public void testReaderWithFilterWithoutSelect() throws IOException { @TestTemplate public void testInvalidUsage() throws IOException { ManifestFile manifest = writeManifest(FILE_A, FILE_B); - assertThatThrownBy(() -> ManifestFiles.read(manifest, FILE_IO)) + assertThatThrownBy(() -> ManifestFiles.read(manifest, FILE_IO, table.specs())) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot read from ManifestFile with null (unassigned) snapshot ID"); } @@ -86,7 +87,7 @@ public void testInvalidUsage() throws IOException { @TestTemplate public void testManifestReaderWithPartitionMetadata() throws IOException { ManifestFile manifest = writeManifest(1000L, manifestEntry(Status.EXISTING, 123L, FILE_A)); - try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { ManifestEntry entry = Iterables.getOnlyElement(reader.entries()); assertThat(entry.snapshotId()).isEqualTo(123L); @@ -106,7 +107,7 @@ public void testManifestReaderWithUpdatedPartitionMetadataForV1Table() throws IO table.ops().commit(table.ops().current(), table.ops().current().updatePartitionSpec(spec)); ManifestFile manifest = writeManifest(1000L, manifestEntry(Status.EXISTING, 123L, FILE_A)); - try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { ManifestEntry entry = Iterables.getOnlyElement(reader.entries()); assertThat(entry.snapshotId()).isEqualTo(123L); @@ -126,7 +127,7 @@ public void testManifestReaderWithUpdatedPartitionMetadataForV1Table() throws IO @TestTemplate public void testDataFilePositions() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE_A, FILE_B, FILE_C); - try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { long expectedPos = 0L; for (DataFile file : reader) { assertThat(file.pos()).as("Position should match").isEqualTo(expectedPos); @@ -138,7 +139,7 @@ public void testDataFilePositions() throws IOException { @TestTemplate public void testDataFileManifestPaths() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE_A, FILE_B, FILE_C); - try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { for (DataFile file : reader) { assertThat(file.manifestLocation()).isEqualTo(manifest.path()); } @@ -151,7 +152,7 @@ public void testDeleteFilePositions() throws IOException { ManifestFile manifest = writeDeleteManifest(formatVersion, 1000L, FILE_A_DELETES, FILE_B_DELETES); try (ManifestReader reader = - ManifestFiles.readDeleteManifest(manifest, FILE_IO, null)) { + ManifestFiles.readDeleteManifest(manifest, FILE_IO, table.specs())) { long expectedPos = 0L; for (DeleteFile file : reader) { assertThat(file.pos()).as("Position should match").isEqualTo(expectedPos); @@ -168,7 +169,7 @@ public void testDeleteFileManifestPaths() throws IOException { ManifestFile manifest = writeDeleteManifest(formatVersion, 1000L, FILE_A_DELETES, FILE_B_DELETES); try (ManifestReader reader = - ManifestFiles.readDeleteManifest(manifest, FILE_IO, null)) { + ManifestFiles.readDeleteManifest(manifest, FILE_IO, table.specs())) { for (DeleteFile file : reader) { assertThat(file.manifestLocation()).isEqualTo(manifest.path()); } @@ -217,6 +218,42 @@ public void testDVs() throws IOException { } } + @TestTemplate + public void testReadCommitedManifestNullifiesEntryRowId() throws IOException { + long firstRowId = 42L; + DataFile fileWithRowId = + DataFiles.builder(SPEC).copy(FILE_A).withFirstRowId(firstRowId).build(); + // the manifest has no manifest-level first_row_id (the v3+ entry still carries one) + ManifestFile manifest = + writeManifest(1000L, manifestEntry(Status.EXISTING, 123L, fileWithRowId)); + assertThat(manifest.firstRowId()).isNull(); + + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { + assertThat(Iterables.getOnlyElement(reader).firstRowId()).isNull(); + } + } + + @TestTemplate + public void testReadUncommittedManifestPreservesEntryRowId() throws IOException { + assumeThat(formatVersion) + .as("first_row_id is only written in v3+ manifests") + .isGreaterThanOrEqualTo(3); + + long firstRowId = 42L; + DataFile fileWithRowId = + DataFiles.builder(SPEC).copy(FILE_A).withFirstRowId(firstRowId).build(); + // the manifest has no manifest-level first_row_id, but the entry has one + ManifestFile manifest = + writeManifest(1000L, manifestEntry(Status.EXISTING, 123L, fileWithRowId)); + assertThat(manifest.firstRowId()).isNull(); + assertThat(fileWithRowId.firstRowId()).isEqualTo(firstRowId); + + try (ManifestReader reader = + ManifestFiles.read(manifest, FILE_IO, table.specs(), false /* isCommitted */)) { + assertThat(Iterables.getOnlyElement(reader).firstRowId()).isEqualTo(firstRowId); + } + } + @TestTemplate public void testDataFileSplitOffsetsNullWhenInvalid() throws IOException { DataFile invalidOffset = @@ -227,9 +264,20 @@ public void testDataFileSplitOffsetsNullWhenInvalid() throws IOException { .withSplitOffsets(ImmutableList.of(2L, 1000L)) // Offset 1000 is out of bounds .build(); ManifestFile manifest = writeManifest(1000L, invalidOffset); - try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { DataFile file = Iterables.getOnlyElement(reader); assertThat(file.splitOffsets()).isNull(); } } + + @SuppressWarnings("deprecation") + @TestTemplate + public void testDeprecatedReadWithoutSpecsById() throws IOException { + ManifestFile manifest = writeManifest(1000L, manifestEntry(Status.EXISTING, 1000L, FILE_A)); + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + ManifestEntry entry = Iterables.getOnlyElement(reader.entries()); + assertThat(entry.status()).isEqualTo(Status.EXISTING); + assertThat(entry.file().location()).isEqualTo(FILE_A.location()); + } + } } diff --git a/core/src/test/java/org/apache/iceberg/TestManifestReaderStats.java b/core/src/test/java/org/apache/iceberg/TestManifestReaderStats.java index 0e2f4c0ebec3..483910d3000b 100644 --- a/core/src/test/java/org/apache/iceberg/TestManifestReaderStats.java +++ b/core/src/test/java/org/apache/iceberg/TestManifestReaderStats.java @@ -59,7 +59,7 @@ public class TestManifestReaderStats extends TestBase { @TestTemplate public void testReadIncludesFullStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); - try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO)) { + try (ManifestReader reader = ManifestFiles.read(manifest, FILE_IO, table.specs())) { CloseableIterable> entries = reader.entries(); ManifestEntry entry = entries.iterator().next(); assertFullStats(entry.file()); @@ -70,7 +70,8 @@ public void testReadIncludesFullStats() throws IOException { public void testReadEntriesWithFilterIncludesFullStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO).filterRows(Expressions.equal("id", 3))) { + ManifestFiles.read(manifest, FILE_IO, table.specs()) + .filterRows(Expressions.equal("id", 3))) { CloseableIterable> entries = reader.entries(); ManifestEntry entry = entries.iterator().next(); assertFullStats(entry.file()); @@ -81,7 +82,8 @@ public void testReadEntriesWithFilterIncludesFullStats() throws IOException { public void testReadIteratorWithFilterIncludesFullStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO).filterRows(Expressions.equal("id", 3))) { + ManifestFiles.read(manifest, FILE_IO, table.specs()) + .filterRows(Expressions.equal("id", 3))) { DataFile entry = reader.iterator().next(); assertFullStats(entry); } @@ -91,7 +93,7 @@ public void testReadIteratorWithFilterIncludesFullStats() throws IOException { public void testReadEntriesWithFilterAndSelectIncludesFullStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO) + ManifestFiles.read(manifest, FILE_IO, table.specs()) .select(ImmutableList.of("file_path")) .filterRows(Expressions.equal("id", 3))) { CloseableIterable> entries = reader.entries(); @@ -104,7 +106,7 @@ public void testReadEntriesWithFilterAndSelectIncludesFullStats() throws IOExcep public void testReadIteratorWithFilterAndSelectDropsStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO) + ManifestFiles.read(manifest, FILE_IO, table.specs()) .select(ImmutableList.of("file_path")) .filterRows(Expressions.equal("id", 3))) { DataFile entry = reader.iterator().next(); @@ -116,7 +118,7 @@ public void testReadIteratorWithFilterAndSelectDropsStats() throws IOException { public void testReadIteratorWithFilterAndSelectRecordCountDropsStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO) + ManifestFiles.read(manifest, FILE_IO, table.specs()) .select(ImmutableList.of("file_path", "record_count")) .filterRows(Expressions.equal("id", 3))) { DataFile entry = reader.iterator().next(); @@ -128,7 +130,7 @@ public void testReadIteratorWithFilterAndSelectRecordCountDropsStats() throws IO public void testReadIteratorWithFilterAndSelectStatsIncludesFullStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO) + ManifestFiles.read(manifest, FILE_IO, table.specs()) .select(ImmutableList.of("file_path", "value_counts")) .filterRows(Expressions.equal("id", 3))) { DataFile entry = reader.iterator().next(); @@ -143,7 +145,7 @@ public void testReadIteratorWithFilterAndSelectStatsIncludesFullStats() throws I public void testReadIteratorWithProjectStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO) + ManifestFiles.read(manifest, FILE_IO, table.specs()) .project(new Schema(ImmutableList.of(DataFile.FILE_PATH, DataFile.VALUE_COUNTS)))) { DataFile entry = reader.iterator().next(); @@ -162,7 +164,8 @@ public void testReadIteratorWithProjectStats() throws IOException { public void testReadEntriesWithSelectNotProjectStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO).select(ImmutableList.of("file_path"))) { + ManifestFiles.read(manifest, FILE_IO, table.specs()) + .select(ImmutableList.of("file_path"))) { CloseableIterable> entries = reader.entries(); ManifestEntry entry = entries.iterator().next(); DataFile dataFile = entry.file(); @@ -185,7 +188,7 @@ public void testReadEntriesWithSelectNotProjectStats() throws IOException { public void testReadEntriesWithSelectCertainStatNotProjectStats() throws IOException { ManifestFile manifest = writeManifest(1000L, FILE); try (ManifestReader reader = - ManifestFiles.read(manifest, FILE_IO) + ManifestFiles.read(manifest, FILE_IO, table.specs()) .select(ImmutableList.of("file_path", "value_counts"))) { DataFile dataFile = reader.iterator().next(); diff --git a/core/src/test/java/org/apache/iceberg/TestManifestWriter.java b/core/src/test/java/org/apache/iceberg/TestManifestWriter.java index 5d682421d541..00e66bdd7d0d 100644 --- a/core/src/test/java/org/apache/iceberg/TestManifestWriter.java +++ b/core/src/test/java/org/apache/iceberg/TestManifestWriter.java @@ -100,7 +100,8 @@ public void testWriteManifestWithSequenceNumber() throws IOException { writer.close(); ManifestFile manifest = writer.toManifestFile(); assertThat(manifest.sequenceNumber()).isEqualTo(-1); - ManifestReader manifestReader = ManifestFiles.read(manifest, table.io()); + ManifestReader manifestReader = + ManifestFiles.read(manifest, table.io(), table.specs()); for (ManifestEntry entry : manifestReader.entries()) { assertThat(entry.dataSequenceNumber()).isEqualTo(1000); assertThat(entry.fileSequenceNumber()).isEqualTo(ManifestWriter.UNASSIGNED_SEQ); diff --git a/core/src/test/java/org/apache/iceberg/TestManifestWriterVersions.java b/core/src/test/java/org/apache/iceberg/TestManifestWriterVersions.java index b0e1c6939ba0..5e83827f0c45 100644 --- a/core/src/test/java/org/apache/iceberg/TestManifestWriterVersions.java +++ b/core/src/test/java/org/apache/iceberg/TestManifestWriterVersions.java @@ -18,14 +18,17 @@ */ package org.apache.iceberg; +import static org.apache.iceberg.avro.AvroTestHelpers.readAvroCodec; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assumptions.assumeThat; +import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Map; import org.apache.iceberg.encryption.EncryptedOutputFile; import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.encryption.EncryptionManager; @@ -65,6 +68,9 @@ public class TestManifestWriterVersions { .bucket("id", 16) .build(); + private static final Map SPECS_BY_ID = + ImmutableMap.of(SPEC.specId(), SPEC); + private static final long SEQUENCE_NUMBER = 34L; private static final long SNAPSHOT_ID = 987134631982734L; private static final String PATH = @@ -309,6 +315,35 @@ public void testV3ManifestRewriteWithInheritance() throws IOException { checkRewrittenEntry(readManifest(manifest3), 0L, FileContent.DATA, FIRST_ROW_ID); } + @ParameterizedTest + @FieldSource("org.apache.iceberg.TestHelpers#ALL_VERSIONS") + public void testDefaultManifestCompression(int formatVersion) throws IOException { + File manifestFile = temp.resolve("default-v" + formatVersion + ".avro").toFile(); + OutputFile outputFile = Files.localOutput(manifestFile); + + try (ManifestWriter writer = + ManifestFiles.write(formatVersion, SPEC, outputFile, SNAPSHOT_ID)) { + writer.add(DATA_FILE); + } + + assertThat(readAvroCodec(manifestFile)).isEqualTo("deflate"); + } + + @ParameterizedTest + @FieldSource("org.apache.iceberg.TestHelpers#ALL_VERSIONS") + public void testCustomManifestCompression(int formatVersion) throws IOException { + Map props = ImmutableMap.of(TableProperties.AVRO_COMPRESSION, "snappy"); + File manifestFile = temp.resolve("snappy-v" + formatVersion + ".avro").toFile(); + OutputFile outputFile = Files.localOutput(manifestFile); + + try (ManifestWriter writer = + ManifestFiles.write(formatVersion, SPEC, outputFile, SNAPSHOT_ID, props)) { + writer.add(DATA_FILE); + } + + assertThat(readAvroCodec(manifestFile)).isEqualTo("snappy"); + } + void checkEntry( ManifestEntry entry, Long expectedDataSequenceNumber, @@ -465,7 +500,7 @@ private ManifestFile writeManifest(int formatVersion, DataFile... files) throws private List> readManifestAsList(ManifestFile manifest) throws IOException { try (CloseableIterable> reader = - ManifestFiles.read(manifest, io).entries()) { + ManifestFiles.read(manifest, io, SPECS_BY_ID).entries()) { return Lists.newArrayList(Iterables.transform(reader, ManifestEntry::copy)); } } @@ -491,7 +526,7 @@ private ManifestFile writeDeleteManifest(int formatVersion) throws IOException { private ManifestEntry readDeleteManifest(ManifestFile manifest) throws IOException { try (CloseableIterable> reader = - ManifestFiles.readDeleteManifest(manifest, io, null).entries()) { + ManifestFiles.readDeleteManifest(manifest, io, SPECS_BY_ID).entries()) { List> entries = Lists.newArrayList(reader); assertThat(entries).hasSize(1); return entries.get(0); diff --git a/core/src/test/java/org/apache/iceberg/TestMergeAppend.java b/core/src/test/java/org/apache/iceberg/TestMergeAppend.java index a2c5ba0adbf2..3947f16fe159 100644 --- a/core/src/test/java/org/apache/iceberg/TestMergeAppend.java +++ b/core/src/test/java/org/apache/iceberg/TestMergeAppend.java @@ -52,7 +52,10 @@ public class TestMergeAppend extends TestBase { @Parameters(name = "formatVersion = {0}, branch = {1}") protected static List parameters() { return TestHelpers.ALL_VERSIONS.stream() - .flatMap(v -> Stream.of(new Object[] {v, "main"}, new Object[] {v, "testBranch"})) + .flatMap( + v -> + Stream.of( + new Object[] {v, SnapshotRef.MAIN_BRANCH}, new Object[] {v, "testBranch"})) .collect(Collectors.toList()); } @@ -282,7 +285,11 @@ public void testEmptyTableAppendManifest() throws IOException { statuses(Status.ADDED, Status.ADDED)); // validate that the metadata summary is correct when using appendManifest - assertThat(committedSnapshot.summary()).containsEntry("added-data-files", "2"); + assertThat(committedSnapshot.summary()) + .containsEntry("added-data-files", "2") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -511,6 +518,12 @@ public void testManifestMergeMinCount() throws IOException { ids(commitId1, commitId1), files(FILE_C, FILE_D), statuses(Status.ADDED, Status.ADDED)); + // 3 manifests appended, bin-packing creates 2 bins: [m1] and [m2, m3] + // bin 1 kept as-is, bin 2 merged; first snapshot has no prior manifests + assertThat(snap1.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); // produce new manifests as the old ones could have been compacted manifest = writeManifestWithName("FILE_A_S2", FILE_A); @@ -556,8 +569,13 @@ public void testManifestMergeMinCount() throws IOException { files(FILE_A, FILE_C, FILE_D), statuses(Status.EXISTING, Status.EXISTING, Status.EXISTING)); - // validate that the metadata summary is correct when using appendManifest - assertThat(snap2.summary()).containsEntry("added-data-files", "3"); + // 3 new manifests + 2 existing from snap1; bin-packing merges both groups + // 2 replaced = existing manifests from snap1 that were merged + assertThat(snap2.summary()) + .containsEntry("added-data-files", "3") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "3") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -778,6 +796,13 @@ public void testMergeWithExistingManifestAfterDelete() { V1Assert.assertEquals( "Table should end with last-sequence-number 0", 0, readMetadata().lastSequenceNumber()); + // The delete operation rewrites the original manifest to mark FILE_A as deleted + // This should result in 1 replaced manifest, 1 created manifest, and 0 kept manifests + assertThat(deleteSnapshot.summary()) + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); + long deleteId = latestSnapshot(table, branch).snapshotId(); assertThat(latestSnapshot(table, branch).allManifests(table.io())).hasSize(1); ManifestFile deleteManifest = deleteSnapshot.allManifests(table.io()).get(0); @@ -1448,7 +1473,8 @@ public void testManifestEntryFieldIdsForChangedPartitionSpecForV1Table() { // field ids of manifest entries in two manifests with different specs of the same source field // should be different ManifestEntry entry = - ManifestFiles.read(committedSnapshot.allManifests(table.io()).get(0), FILE_IO) + ManifestFiles.read( + committedSnapshot.allManifests(table.io()).get(0), FILE_IO, table.specs()) .entries() .iterator() .next(); @@ -1461,7 +1487,8 @@ public void testManifestEntryFieldIdsForChangedPartitionSpecForV1Table() { assertThat(field.name()).isEqualTo("data_bucket"); entry = - ManifestFiles.read(committedSnapshot.allManifests(table.io()).get(1), FILE_IO) + ManifestFiles.read( + committedSnapshot.allManifests(table.io()).get(1), FILE_IO, table.specs()) .entries() .iterator() .next(); @@ -1482,7 +1509,10 @@ public void testDefaultPartitionSummaries() { assertThat(table.currentSnapshot().summary()) .doesNotContainKey(SnapshotSummary.PARTITION_SUMMARY_PROP) - .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1"); + .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -1502,7 +1532,10 @@ public void testIncludedPartitionSummaries() { .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1") .containsEntry( SnapshotSummary.CHANGED_PARTITION_PREFIX + "data_bucket=0", - "added-data-files=1,added-records=1,added-files-size=10"); + "added-data-files=1,added-records=1,added-files-size=10") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -1519,6 +1552,9 @@ public void testIncludedPartitionSummaryLimit() { assertThat(table.currentSnapshot().summary()) .doesNotContainKey(SnapshotSummary.PARTITION_SUMMARY_PROP) - .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "2"); + .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "2") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } } diff --git a/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java b/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java index 4d2971c7f12a..210dfb3683ab 100644 --- a/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java +++ b/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java @@ -1752,6 +1752,82 @@ public void testEntriesTableEstimateSize() throws Exception { assertEstimatedRowCount(new AllEntriesTable(table), 4); } + @TestTemplate + public void testMetadataTableScansOnBranch() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + table.manageSnapshots().createBranch("testBranch").commit(); + table.newFastAppend().appendFile(FILE_B).commit(); + + Table entriesTable = new ManifestEntriesTable(table); + assertThat( + rowCount( + entriesTable + .newScan() + .useRef("testBranch") + .select("status") + .filter(Expressions.lessThan("data_file.partition.data_bucket", 0)))) + .as("ManifestEntriesTable on branch should have no entry satisfying the filter") + .isEqualTo(0); + assertThat(rowCount(entriesTable.newScan())) + .as("ManifestEntriesTable on main should have 2 entries") + .isEqualTo(2); + + Table dataFilesTable = new DataFilesTable(table); + assertThat(rowCount(dataFilesTable.newScan().useRef("testBranch"))) + .as("DataFilesTable on branch should have 1 file") + .isEqualTo(1); + assertThat(rowCount(dataFilesTable.newScan())) + .as("DataFilesTable on main should have 2 files") + .isEqualTo(2); + + Table manifestsTable = new ManifestsTable(table); + assertThat(rowCount(manifestsTable.newScan().useRef("testBranch"))) + .as("ManifestsTable on branch should have 1 manifest") + .isEqualTo(1); + assertThat(rowCount(manifestsTable.newScan())) + .as("ManifestsTable on main should have 2 manifests") + .isEqualTo(2); + + Table filesTable = new FilesTable(table); + assertThat(rowCount(filesTable.newScan().useRef("testBranch"))) + .as("FilesTable on branch should have 1 file") + .isEqualTo(1); + assertThat(rowCount(filesTable.newScan())) + .as("FilesTable on main should have 2 files") + .isEqualTo(2); + } + + @TestTemplate + public void testDeleteFilesTableScanOnBranch() throws IOException { + assumeThat(formatVersion).as("Delete files are not supported by V1 Tables").isGreaterThan(1); + + table.newFastAppend().appendFile(FILE_A).commit(); + table.newRowDelta().addDeletes(fileADeletes()).commit(); + table.manageSnapshots().createBranch("testBranch").commit(); + table.newFastAppend().appendFile(FILE_B).commit(); + table.newRowDelta().addDeletes(fileBDeletes()).commit(); + + Table deleteFilesTable = new DeleteFilesTable(table); + assertThat(rowCount(deleteFilesTable.newScan().useRef("testBranch"))) + .as("DeleteFilesTable on branch should have 1 delete file") + .isEqualTo(1); + assertThat(rowCount(deleteFilesTable.newScan())) + .as("DeleteFilesTable on main should have 2 delete files") + .isEqualTo(2); + } + + private int rowCount(TableScan scan) throws IOException { + int count = 0; + try (CloseableIterable tasks = scan.planFiles()) { + for (FileScanTask task : tasks) { + try (CloseableIterable rows = task.asDataTask().rows()) { + count += Iterators.size(rows.iterator()); + } + } + } + return count; + } + private void assertEstimatedRowCount(Table metadataTable, int size) throws Exception { TableScan scan = metadataTable.newScan(); diff --git a/core/src/test/java/org/apache/iceberg/TestMetrics.java b/core/src/test/java/org/apache/iceberg/TestMetrics.java index 835a09210f70..874bb6d74d44 100644 --- a/core/src/test/java/org/apache/iceberg/TestMetrics.java +++ b/core/src/test/java/org/apache/iceberg/TestMetrics.java @@ -166,20 +166,22 @@ public void testMetricsForRepeatedValues() throws IOException { record.setField("timestampColBelowEpoch", DateTimeUtil.timestampFromMicros(0L)); Metrics metrics = getMetrics(SIMPLE_SCHEMA, record, record); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(SIMPLE_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(2L); - assertCounts(1, 2L, 0L, metrics); - assertCounts(2, 2L, 0L, metrics); - assertCounts(3, 2L, 2L, metrics); - assertCounts(4, 2L, 0L, 2L, metrics); - assertCounts(5, 2L, 0L, 0L, metrics); - assertCounts(6, 2L, 0L, metrics); - assertCounts(7, 2L, 0L, metrics); - assertCounts(8, 2L, 0L, metrics); - assertCounts(9, 2L, 0L, metrics); - assertCounts(10, 2L, 0L, metrics); - assertCounts(11, 2L, 0L, metrics); - assertCounts(12, 2L, 0L, metrics); - assertCounts(13, 2L, 0L, metrics); + assertCounts(1, 2L, 0L, metricsWithStats); + assertCounts(2, 2L, 0L, metricsWithStats); + assertCounts(3, 2L, 2L, metricsWithStats); + assertCounts(4, 2L, 0L, 2L, metricsWithStats); + assertCounts(5, 2L, 0L, 0L, metricsWithStats); + assertCounts(6, 2L, 0L, metricsWithStats); + assertCounts(7, 2L, 0L, metricsWithStats); + assertCounts(8, 2L, 0L, metricsWithStats); + assertCounts(9, 2L, 0L, metricsWithStats); + assertCounts(10, 2L, 0L, metricsWithStats); + assertCounts(11, 2L, 0L, metricsWithStats); + assertCounts(12, 2L, 0L, metricsWithStats); + assertCounts(13, 2L, 0L, metricsWithStats); } @TestTemplate @@ -214,46 +216,54 @@ public void testMetricsForTopLevelFields() throws IOException { secondRecord.setField("timestampColBelowEpoch", DateTimeUtil.timestampFromMicros(-7_000L)); Metrics metrics = getMetrics(SIMPLE_SCHEMA, firstRecord, secondRecord); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(SIMPLE_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(2L); - assertCounts(1, 2L, 0L, metrics); - assertBounds(1, BooleanType.get(), false, true, metrics); - assertCounts(2, 2L, 0L, metrics); - assertBounds(2, IntegerType.get(), Integer.MIN_VALUE, 3, metrics); - assertCounts(3, 2L, 1L, metrics); - assertBounds(3, LongType.get(), 5L, 5L, metrics); - assertCounts(4, 2L, 0L, 0L, metrics); - assertBounds(4, FloatType.get(), 1.0F, 2.0F, metrics); - assertCounts(5, 2L, 1L, 0L, metrics); - assertBounds(5, DoubleType.get(), 2.0D, 2.0D, metrics); - assertCounts(6, 2L, 1L, metrics); - assertBounds(6, DecimalType.of(10, 2), new BigDecimal("3.50"), new BigDecimal("3.50"), metrics); - assertCounts(7, 2L, 0L, metrics); - assertBounds(7, StringType.get(), CharBuffer.wrap("AAA"), CharBuffer.wrap("ZZZ"), metrics); - assertCounts(8, 2L, 1L, metrics); - assertBounds(8, DateType.get(), 1500, 1500, metrics); - assertCounts(9, 2L, 0L, metrics); - assertBounds(9, TimeType.get(), 2000L, 3000L, metrics); - assertCounts(10, 2L, 0L, metrics); - assertBounds(10, TimestampType.withoutZone(), 0L, 900L, metrics); - assertCounts(11, 2L, 0L, metrics); + assertCounts(1, 2L, 0L, metricsWithStats); + assertBounds(1, BooleanType.get(), false, true, metricsWithStats); + assertCounts(2, 2L, 0L, metricsWithStats); + assertBounds(2, IntegerType.get(), Integer.MIN_VALUE, 3, metricsWithStats); + assertCounts(3, 2L, 1L, metricsWithStats); + assertBounds(3, LongType.get(), 5L, 5L, metricsWithStats); + assertCounts(4, 2L, 0L, 0L, metricsWithStats); + assertBounds(4, FloatType.get(), 1.0F, 2.0F, metricsWithStats); + assertCounts(5, 2L, 1L, 0L, metricsWithStats); + assertBounds(5, DoubleType.get(), 2.0D, 2.0D, metricsWithStats); + assertCounts(6, 2L, 1L, metricsWithStats); assertBounds( - 11, FixedType.ofLength(4), ByteBuffer.wrap(fixed), ByteBuffer.wrap(fixed), metrics); - assertCounts(12, 2L, 0L, metrics); + 6, DecimalType.of(10, 2), new BigDecimal("3.50"), new BigDecimal("3.50"), metricsWithStats); + assertCounts(7, 2L, 0L, metricsWithStats); + assertBounds( + 7, StringType.get(), CharBuffer.wrap("AAA"), CharBuffer.wrap("ZZZ"), metricsWithStats); + assertCounts(8, 2L, 1L, metricsWithStats); + assertBounds(8, DateType.get(), 1500, 1500, metricsWithStats); + assertCounts(9, 2L, 0L, metricsWithStats); + assertBounds(9, TimeType.get(), 2000L, 3000L, metricsWithStats); + assertCounts(10, 2L, 0L, metricsWithStats); + assertBounds(10, TimestampType.withoutZone(), 0L, 900L, metricsWithStats); + assertCounts(11, 2L, 0L, metricsWithStats); + assertBounds( + 11, + FixedType.ofLength(4), + ByteBuffer.wrap(fixed), + ByteBuffer.wrap(fixed), + metricsWithStats); + assertCounts(12, 2L, 0L, metricsWithStats); assertBounds( 12, BinaryType.get(), ByteBuffer.wrap("S".getBytes()), ByteBuffer.wrap("W".getBytes()), - metrics); + metricsWithStats); if (fileFormat() == FileFormat.ORC) { // TODO: The special condition for ORC can be removed when ORC-342 is fixed // ORC-342: ORC writer creates inaccurate timestamp data and stats 1 sec below epoch // Values in the range `[1969-12-31 23:59:59.000,1969-12-31 23:59:59.999]` will have 1 sec // added to them // So the upper bound value of -7_000 micros becomes 993_000 micros - assertBounds(13, TimestampType.withoutZone(), -1_900_300L, 993_000L, metrics); + assertBounds(13, TimestampType.withoutZone(), -1_900_300L, 993_000L, metricsWithStats); } else { - assertBounds(13, TimestampType.withoutZone(), -1_900_300L, -7_000L, metrics); + assertBounds(13, TimestampType.withoutZone(), -1_900_300L, -7_000L, metricsWithStats); } } @@ -271,34 +281,41 @@ public void testMetricsForDecimals() throws IOException { record.setField("decimalAsFixed", new BigDecimal("5.80")); Metrics metrics = getMetrics(schema, record); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(schema, metrics)); assertThat(metrics.recordCount()).isEqualTo(1); - assertCounts(1, 1L, 0L, metrics); - assertBounds(1, DecimalType.of(4, 2), new BigDecimal("2.55"), new BigDecimal("2.55"), metrics); - assertCounts(2, 1L, 0L, metrics); - assertBounds(2, DecimalType.of(14, 2), new BigDecimal("4.75"), new BigDecimal("4.75"), metrics); - assertCounts(3, 1L, 0L, metrics); - assertBounds(3, DecimalType.of(22, 2), new BigDecimal("5.80"), new BigDecimal("5.80"), metrics); + assertCounts(1, 1L, 0L, metricsWithStats); + assertBounds( + 1, DecimalType.of(4, 2), new BigDecimal("2.55"), new BigDecimal("2.55"), metricsWithStats); + assertCounts(2, 1L, 0L, metricsWithStats); + assertBounds( + 2, DecimalType.of(14, 2), new BigDecimal("4.75"), new BigDecimal("4.75"), metricsWithStats); + assertCounts(3, 1L, 0L, metricsWithStats); + assertBounds( + 3, DecimalType.of(22, 2), new BigDecimal("5.80"), new BigDecimal("5.80"), metricsWithStats); } @TestTemplate public void testMetricsForNestedStructFields() throws IOException { Metrics metrics = getMetrics(NESTED_SCHEMA, buildNestedTestRecord()); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(NESTED_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(1L); - assertCounts(1, 1L, 0L, metrics); - assertBounds(1, IntegerType.get(), Integer.MAX_VALUE, Integer.MAX_VALUE, metrics); - assertCounts(3, 1L, 0L, metrics); - assertBounds(3, LongType.get(), 100L, 100L, metrics); - assertCounts(5, 1L, 0L, metrics); - assertBounds(5, LongType.get(), 20L, 20L, metrics); - assertCounts(6, 1L, 0L, metrics); + assertCounts(1, 1L, 0L, metricsWithStats); + assertBounds(1, IntegerType.get(), Integer.MAX_VALUE, Integer.MAX_VALUE, metricsWithStats); + assertCounts(3, 1L, 0L, metricsWithStats); + assertBounds(3, LongType.get(), 100L, 100L, metricsWithStats); + assertCounts(5, 1L, 0L, metricsWithStats); + assertBounds(5, LongType.get(), 20L, 20L, metricsWithStats); + assertCounts(6, 1L, 0L, metricsWithStats); assertBounds( 6, BinaryType.get(), ByteBuffer.wrap("A".getBytes()), ByteBuffer.wrap("A".getBytes()), - metrics); - assertCounts(7, 1L, 0L, 1L, metrics); - assertBounds(7, DoubleType.get(), null, null, metrics); + metricsWithStats); + assertCounts(7, 1L, 0L, 1L, metricsWithStats); + assertBounds(7, DoubleType.get(), null, null, metricsWithStats); } @TestTemplate @@ -312,10 +329,12 @@ public void testMetricsModeForNestedStructFields() throws IOException { MetricsConfig config = MetricsConfig.fromProperties(properties); Metrics metrics = getMetrics(NESTED_SCHEMA, config, buildNestedTestRecord()); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(NESTED_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(1L); assertThat(metrics.lowerBounds()).hasSize(1); assertThat(metrics.upperBounds()).hasSize(1); - assertBounds(3, LongType.get(), 100L, 100L, metrics); + assertBounds(3, LongType.get(), 100L, 100L, metricsWithStats); } private Record buildNestedTestRecord() { @@ -354,16 +373,18 @@ public void testMetricsForListAndMapElements() throws IOException { record.set(1, map); Metrics metrics = getMetrics(schema, record); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(schema, metrics)); assertThat(metrics.recordCount()).isEqualTo(1L); - assertCounts(1, null, null, metrics); - assertCounts(2, null, null, metrics); - assertCounts(4, null, null, metrics); - assertCounts(6, null, null, metrics); - assertBounds(1, IntegerType.get(), null, null, metrics); - assertBounds(2, StringType.get(), null, null, metrics); - assertBounds(4, IntegerType.get(), null, null, metrics); - assertBounds(6, StringType.get(), null, null, metrics); - assertBounds(7, structType, null, null, metrics); + assertCounts(1, null, null, metricsWithStats); + assertCounts(2, null, null, metricsWithStats); + assertCounts(4, null, null, metricsWithStats); + assertCounts(6, null, null, metricsWithStats); + assertBounds(1, IntegerType.get(), null, null, metricsWithStats); + assertBounds(2, StringType.get(), null, null, metricsWithStats); + assertBounds(4, IntegerType.get(), null, null, metricsWithStats); + assertBounds(6, StringType.get(), null, null, metricsWithStats); + assertBounds(7, structType, null, null, metricsWithStats); } @TestTemplate @@ -375,20 +396,24 @@ public void testMetricsForNullColumns() throws IOException { secondRecord.setField("intCol", null); Metrics metrics = getMetrics(schema, firstRecord, secondRecord); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(schema, metrics)); assertThat(metrics.recordCount()).isEqualTo(2L); - assertCounts(1, 2L, 2L, metrics); - assertBounds(1, IntegerType.get(), null, null, metrics); + assertCounts(1, 2L, 2L, metricsWithStats); + assertBounds(1, IntegerType.get(), null, null, metricsWithStats); } @TestTemplate public void testMetricsForNaNColumns() throws IOException { Metrics metrics = getMetrics(FLOAT_DOUBLE_ONLY_SCHEMA, NAN_ONLY_RECORD, NAN_ONLY_RECORD); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(FLOAT_DOUBLE_ONLY_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(2L); - assertCounts(1, 2L, 0L, 2L, metrics); - assertCounts(2, 2L, 0L, 2L, metrics); + assertCounts(1, 2L, 0L, 2L, metricsWithStats); + assertCounts(2, 2L, 0L, 2L, metricsWithStats); - assertBounds(1, FloatType.get(), null, null, metrics); - assertBounds(2, DoubleType.get(), null, null, metrics); + assertBounds(1, FloatType.get(), null, null, metricsWithStats); + assertBounds(2, DoubleType.get(), null, null, metricsWithStats); } @TestTemplate @@ -399,12 +424,14 @@ public void testColumnBoundsWithNaNValueAtFront() throws IOException { NAN_ONLY_RECORD, FLOAT_DOUBLE_RECORD_1, FLOAT_DOUBLE_RECORD_2); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(FLOAT_DOUBLE_ONLY_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(3L); - assertCounts(1, 3L, 0L, 1L, metrics); - assertCounts(2, 3L, 0L, 1L, metrics); + assertCounts(1, 3L, 0L, 1L, metricsWithStats); + assertCounts(2, 3L, 0L, 1L, metricsWithStats); - assertBounds(1, FloatType.get(), 1.2F, 5.6F, metrics); - assertBounds(2, DoubleType.get(), 3.4D, 7.8D, metrics); + assertBounds(1, FloatType.get(), 1.2F, 5.6F, metricsWithStats); + assertBounds(2, DoubleType.get(), 3.4D, 7.8D, metricsWithStats); } @TestTemplate @@ -415,12 +442,14 @@ public void testColumnBoundsWithNaNValueInMiddle() throws IOException { FLOAT_DOUBLE_RECORD_1, NAN_ONLY_RECORD, FLOAT_DOUBLE_RECORD_2); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(FLOAT_DOUBLE_ONLY_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(3L); - assertCounts(1, 3L, 0L, 1L, metrics); - assertCounts(2, 3L, 0L, 1L, metrics); + assertCounts(1, 3L, 0L, 1L, metricsWithStats); + assertCounts(2, 3L, 0L, 1L, metricsWithStats); - assertBounds(1, FloatType.get(), 1.2F, 5.6F, metrics); - assertBounds(2, DoubleType.get(), 3.4D, 7.8D, metrics); + assertBounds(1, FloatType.get(), 1.2F, 5.6F, metricsWithStats); + assertBounds(2, DoubleType.get(), 3.4D, 7.8D, metricsWithStats); } @TestTemplate @@ -431,12 +460,14 @@ public void testColumnBoundsWithNaNValueAtEnd() throws IOException { FLOAT_DOUBLE_RECORD_1, FLOAT_DOUBLE_RECORD_2, NAN_ONLY_RECORD); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(FLOAT_DOUBLE_ONLY_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(3L); - assertCounts(1, 3L, 0L, 1L, metrics); - assertCounts(2, 3L, 0L, 1L, metrics); + assertCounts(1, 3L, 0L, 1L, metricsWithStats); + assertCounts(2, 3L, 0L, 1L, metricsWithStats); - assertBounds(1, FloatType.get(), 1.2F, 5.6F, metrics); - assertBounds(2, DoubleType.get(), 3.4D, 7.8D, metrics); + assertBounds(1, FloatType.get(), 1.2F, 5.6F, metricsWithStats); + assertBounds(2, DoubleType.get(), 3.4D, 7.8D, metricsWithStats); } @TestTemplate @@ -473,6 +504,8 @@ public void testMetricsForTopLevelWithMultipleRowGroup() throws Exception { Metrics metrics = getMetricsForRecordsWithSmallRowGroups( SIMPLE_SCHEMA, outputFile, records.toArray(new Record[0])); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(SIMPLE_SCHEMA, metrics)); InputFile recordsFile = outputFile.toInputFile(); assertThat(recordsFile).isNotNull(); @@ -480,18 +513,22 @@ public void testMetricsForTopLevelWithMultipleRowGroup() throws Exception { assertThat(splitCount(recordsFile)).isEqualTo(3); assertThat(metrics.recordCount()).isEqualTo(201L); - assertCounts(1, 201L, 0L, metrics); - assertBounds(1, Types.BooleanType.get(), false, true, metrics); - assertBounds(2, Types.IntegerType.get(), 1, 201, metrics); - assertCounts(3, 201L, 1L, metrics); - assertBounds(3, Types.LongType.get(), 2L, 201L, metrics); - assertCounts(4, 201L, 0L, 0L, metrics); - assertBounds(4, Types.FloatType.get(), 1.0F, 201.0F, metrics); - assertCounts(5, 201L, 1L, 0L, metrics); - assertBounds(5, Types.DoubleType.get(), 2.0D, 201.0D, metrics); - assertCounts(6, 201L, 1L, metrics); + assertCounts(1, 201L, 0L, metricsWithStats); + assertBounds(1, Types.BooleanType.get(), false, true, metricsWithStats); + assertBounds(2, Types.IntegerType.get(), 1, 201, metricsWithStats); + assertCounts(3, 201L, 1L, metricsWithStats); + assertBounds(3, Types.LongType.get(), 2L, 201L, metricsWithStats); + assertCounts(4, 201L, 0L, 0L, metricsWithStats); + assertBounds(4, Types.FloatType.get(), 1.0F, 201.0F, metricsWithStats); + assertCounts(5, 201L, 1L, 0L, metricsWithStats); + assertBounds(5, Types.DoubleType.get(), 2.0D, 201.0D, metricsWithStats); + assertCounts(6, 201L, 1L, metricsWithStats); assertBounds( - 6, Types.DecimalType.of(10, 2), new BigDecimal("2.00"), new BigDecimal("201.00"), metrics); + 6, + Types.DecimalType.of(10, 2), + new BigDecimal("2.00"), + new BigDecimal("201.00"), + metricsWithStats); } @TestTemplate @@ -522,6 +559,8 @@ public void testMetricsForNestedStructFieldsWithMultipleRowGroup() throws IOExce Metrics metrics = getMetricsForRecordsWithSmallRowGroups( NESTED_SCHEMA, outputFile, records.toArray(new Record[0])); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(NESTED_SCHEMA, metrics)); InputFile recordsFile = outputFile.toInputFile(); assertThat(recordsFile).isNotNull(); @@ -529,21 +568,21 @@ public void testMetricsForNestedStructFieldsWithMultipleRowGroup() throws IOExce assertThat(splitCount(recordsFile)).isEqualTo(3); assertThat(metrics.recordCount()).isEqualTo(201L); - assertCounts(1, 201L, 0L, metrics); - assertBounds(1, IntegerType.get(), 1, 201, metrics); - assertCounts(3, 201L, 0L, metrics); - assertBounds(3, LongType.get(), 1L, 201L, metrics); - assertCounts(5, 201L, 0L, metrics); - assertBounds(5, LongType.get(), 1L, 201L, metrics); - assertCounts(6, 201L, 0L, metrics); + assertCounts(1, 201L, 0L, metricsWithStats); + assertBounds(1, IntegerType.get(), 1, 201, metricsWithStats); + assertCounts(3, 201L, 0L, metricsWithStats); + assertBounds(3, LongType.get(), 1L, 201L, metricsWithStats); + assertCounts(5, 201L, 0L, metricsWithStats); + assertBounds(5, LongType.get(), 1L, 201L, metricsWithStats); + assertCounts(6, 201L, 0L, metricsWithStats); assertBounds( 6, BinaryType.get(), ByteBuffer.wrap("A".getBytes()), ByteBuffer.wrap("A".getBytes()), - metrics); - assertCounts(7, 201L, 0L, 201L, metrics); - assertBounds(7, DoubleType.get(), null, null, metrics); + metricsWithStats); + assertCounts(7, 201L, 0L, 201L, metricsWithStats); + assertBounds(7, DoubleType.get(), null, null, metricsWithStats); } @TestTemplate @@ -553,18 +592,20 @@ public void testNoneMetricsMode() throws IOException { NESTED_SCHEMA, MetricsConfig.fromProperties(ImmutableMap.of("write.metadata.metrics.default", "none")), buildNestedTestRecord()); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(NESTED_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(1L); assertThat(metrics.columnSizes()).isEmpty(); - assertCounts(1, null, null, metrics); - assertBounds(1, Types.IntegerType.get(), null, null, metrics); - assertCounts(3, null, null, metrics); - assertBounds(3, Types.LongType.get(), null, null, metrics); - assertCounts(5, null, null, metrics); - assertBounds(5, Types.LongType.get(), null, null, metrics); - assertCounts(6, null, null, metrics); - assertBounds(6, Types.BinaryType.get(), null, null, metrics); - assertCounts(7, null, null, metrics); - assertBounds(7, Types.DoubleType.get(), null, null, metrics); + assertCounts(1, null, null, metricsWithStats); + assertBounds(1, Types.IntegerType.get(), null, null, metricsWithStats); + assertCounts(3, null, null, metricsWithStats); + assertBounds(3, Types.LongType.get(), null, null, metricsWithStats); + assertCounts(5, null, null, metricsWithStats); + assertBounds(5, Types.LongType.get(), null, null, metricsWithStats); + assertCounts(6, null, null, metricsWithStats); + assertBounds(6, Types.BinaryType.get(), null, null, metricsWithStats); + assertCounts(7, null, null, metricsWithStats); + assertBounds(7, Types.DoubleType.get(), null, null, metricsWithStats); } @TestTemplate @@ -575,19 +616,21 @@ public void testCountsMetricsMode() throws IOException { MetricsConfig.fromProperties( ImmutableMap.of("write.metadata.metrics.default", "counts")), buildNestedTestRecord()); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(NESTED_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(1L); assertThat(metrics.columnSizes()).doesNotContainValue(null); assertThat(metrics.columnSizes()).isNotEmpty(); - assertCounts(1, 1L, 0L, metrics); - assertBounds(1, Types.IntegerType.get(), null, null, metrics); - assertCounts(3, 1L, 0L, metrics); - assertBounds(3, Types.LongType.get(), null, null, metrics); - assertCounts(5, 1L, 0L, metrics); - assertBounds(5, Types.LongType.get(), null, null, metrics); - assertCounts(6, 1L, 0L, metrics); - assertBounds(6, Types.BinaryType.get(), null, null, metrics); - assertCounts(7, 1L, 0L, 1L, metrics); - assertBounds(7, Types.DoubleType.get(), null, null, metrics); + assertCounts(1, 1L, 0L, metricsWithStats); + assertBounds(1, Types.IntegerType.get(), null, null, metricsWithStats); + assertCounts(3, 1L, 0L, metricsWithStats); + assertBounds(3, Types.LongType.get(), null, null, metricsWithStats); + assertCounts(5, 1L, 0L, metricsWithStats); + assertBounds(5, Types.LongType.get(), null, null, metricsWithStats); + assertCounts(6, 1L, 0L, metricsWithStats); + assertBounds(6, Types.BinaryType.get(), null, null, metricsWithStats); + assertCounts(7, 1L, 0L, 1L, metricsWithStats); + assertBounds(7, Types.DoubleType.get(), null, null, metricsWithStats); } @TestTemplate @@ -597,24 +640,27 @@ public void testFullMetricsMode() throws IOException { NESTED_SCHEMA, MetricsConfig.fromProperties(ImmutableMap.of("write.metadata.metrics.default", "full")), buildNestedTestRecord()); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(NESTED_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(1L); assertThat(metrics.columnSizes()).doesNotContainValue(null); assertThat(metrics.columnSizes()).isNotEmpty(); - assertCounts(1, 1L, 0L, metrics); - assertBounds(1, Types.IntegerType.get(), Integer.MAX_VALUE, Integer.MAX_VALUE, metrics); - assertCounts(3, 1L, 0L, metrics); - assertBounds(3, Types.LongType.get(), 100L, 100L, metrics); - assertCounts(5, 1L, 0L, metrics); - assertBounds(5, Types.LongType.get(), 20L, 20L, metrics); - assertCounts(6, 1L, 0L, metrics); + assertCounts(1, 1L, 0L, metricsWithStats); + assertBounds( + 1, Types.IntegerType.get(), Integer.MAX_VALUE, Integer.MAX_VALUE, metricsWithStats); + assertCounts(3, 1L, 0L, metricsWithStats); + assertBounds(3, Types.LongType.get(), 100L, 100L, metricsWithStats); + assertCounts(5, 1L, 0L, metricsWithStats); + assertBounds(5, Types.LongType.get(), 20L, 20L, metricsWithStats); + assertCounts(6, 1L, 0L, metricsWithStats); assertBounds( 6, Types.BinaryType.get(), ByteBuffer.wrap("A".getBytes()), ByteBuffer.wrap("A".getBytes()), - metrics); - assertCounts(7, 1L, 0L, 1L, metrics); - assertBounds(7, Types.DoubleType.get(), null, null, metrics); + metricsWithStats); + assertCounts(7, 1L, 0L, 1L, metricsWithStats); + assertBounds(7, Types.DoubleType.get(), null, null, metricsWithStats); } @TestTemplate @@ -632,14 +678,16 @@ public void testTruncateStringMetricsMode() throws IOException { MetricsConfig.fromProperties( ImmutableMap.of("write.metadata.metrics.default", "truncate(10)")), record); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(singleStringColSchema, metrics)); CharBuffer expectedMinBound = CharBuffer.wrap("Lorem ipsu"); CharBuffer expectedMaxBound = CharBuffer.wrap("Lorem ipsv"); assertThat(metrics.recordCount()).isEqualTo(1L); assertThat(metrics.columnSizes()).doesNotContainValue(null); assertThat(metrics.columnSizes()).isNotEmpty(); - assertCounts(1, 1L, 0L, metrics); - assertBounds(1, Types.StringType.get(), expectedMinBound, expectedMaxBound, metrics); + assertCounts(1, 1L, 0L, metricsWithStats); + assertBounds(1, Types.StringType.get(), expectedMinBound, expectedMaxBound, metricsWithStats); } @TestTemplate @@ -657,14 +705,16 @@ public void testTruncateBinaryMetricsMode() throws IOException { MetricsConfig.fromProperties( ImmutableMap.of("write.metadata.metrics.default", "truncate(5)")), record); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(singleBinaryColSchema, metrics)); ByteBuffer expectedMinBounds = ByteBuffer.wrap(new byte[] {0x1, 0x2, 0x3, 0x4, 0x5}); ByteBuffer expectedMaxBounds = ByteBuffer.wrap(new byte[] {0x1, 0x2, 0x3, 0x4, 0x6}); assertThat(metrics.recordCount()).isEqualTo(1L); assertThat(metrics.columnSizes()).doesNotContainValue(null); assertThat(metrics.columnSizes()).isNotEmpty(); - assertCounts(1, 1L, 0L, metrics); - assertBounds(1, Types.BinaryType.get(), expectedMinBounds, expectedMaxBounds, metrics); + assertCounts(1, 1L, 0L, metricsWithStats); + assertBounds(1, Types.BinaryType.get(), expectedMinBounds, expectedMaxBounds, metricsWithStats); } @TestTemplate @@ -716,15 +766,22 @@ public void testSortedColumnMetrics() throws IOException { Metrics metrics = getMetrics(SIMPLE_SCHEMA, MetricsConfig.forTable(table), firstRecord, secondRecord); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(SIMPLE_SCHEMA, metrics)); assertThat(metrics.recordCount()).isEqualTo(2L); - assertBounds(1, BooleanType.get(), false, true, metrics); - assertBounds(2, IntegerType.get(), Integer.MIN_VALUE, Integer.MAX_VALUE, metrics); - assertBounds(3, LongType.get(), Long.MIN_VALUE, Long.MAX_VALUE, metrics); + assertBounds(1, BooleanType.get(), false, true, metricsWithStats); + assertBounds(2, IntegerType.get(), Integer.MIN_VALUE, Integer.MAX_VALUE, metricsWithStats); + assertBounds(3, LongType.get(), Long.MIN_VALUE, Long.MAX_VALUE, metricsWithStats); + assertBounds( + 6, + DecimalType.of(10, 2), + new BigDecimal("0.00"), + new BigDecimal("10.00"), + metricsWithStats); assertBounds( - 6, DecimalType.of(10, 2), new BigDecimal("0.00"), new BigDecimal("10.00"), metrics); - assertBounds(7, StringType.get(), CharBuffer.wrap("AAA"), CharBuffer.wrap("ZZZ"), metrics); - assertBounds(8, DateType.get(), 1500, 3000, metrics); + 7, StringType.get(), CharBuffer.wrap("AAA"), CharBuffer.wrap("ZZZ"), metricsWithStats); + assertBounds(8, DateType.get(), 1500, 3000, metricsWithStats); } @TestTemplate @@ -750,29 +807,51 @@ public void testMetricsForSortedNestedStructFields() throws IOException { record.setField("nestedStructCol", nestedStruct); Metrics metrics = getMetrics(NESTED_SCHEMA, MetricsConfig.forTable(table), record); + MetricsWithStats metricsWithStats = + new MetricsWithStats(metrics, MetricsUtil.fromMetrics(NESTED_SCHEMA, metrics)); - assertBounds(3, LongType.get(), Long.MAX_VALUE, Long.MAX_VALUE, metrics); - assertBounds(5, LongType.get(), Long.MAX_VALUE, Long.MAX_VALUE, metrics); + assertBounds(3, LongType.get(), Long.MAX_VALUE, Long.MAX_VALUE, metricsWithStats); + assertBounds(5, LongType.get(), Long.MAX_VALUE, Long.MAX_VALUE, metricsWithStats); } - protected void assertCounts(int fieldId, Long valueCount, Long nullValueCount, Metrics metrics) { - assertCounts(fieldId, valueCount, nullValueCount, null, metrics); + protected void assertCounts( + int fieldId, Long valueCount, Long nullValueCount, MetricsWithStats metricsWithStats) { + assertCounts(fieldId, valueCount, nullValueCount, null, metricsWithStats); } protected void assertCounts( - int fieldId, Long valueCount, Long nullValueCount, Long nanValueCount, Metrics metrics) { - assertThat(metrics.valueCounts().get(fieldId)).isEqualTo(valueCount); - assertThat(metrics.nullValueCounts().get(fieldId)).isEqualTo(nullValueCount); - assertThat(metrics.nanValueCounts().get(fieldId)).isEqualTo(nanValueCount); + int fieldId, + Long valueCount, + Long nullValueCount, + Long nanValueCount, + MetricsWithStats metricsWithStats) { + assertThat(metricsWithStats.metrics().valueCounts().get(fieldId)).isEqualTo(valueCount); + assertThat(metricsWithStats.metrics().nullValueCounts().get(fieldId)).isEqualTo(nullValueCount); + assertThat(metricsWithStats.metrics().nanValueCounts().get(fieldId)).isEqualTo(nanValueCount); + if (null != metricsWithStats.stats()) { + assertSerializable(metricsWithStats.stats()); + FieldStats stat = metricsWithStats.stats().statsFor(fieldId); + if (null == stat) { + // stat is only null when metrics mode is set to none + assertThat(valueCount).isNull(); + assertThat(nullValueCount).isNull(); + assertThat(nanValueCount).isNull(); + } else { + assertThat(stat.valueCount()).isEqualTo(valueCount); + assertThat(stat.nullValueCount()).isEqualTo(nullValueCount); + assertThat(stat.nanValueCount()).isEqualTo(nanValueCount); + } + } } + @SuppressWarnings("DataFlowIssue") protected void assertBounds( - int fieldId, Type type, T lowerBound, T upperBound, Metrics metrics) { - Map lowerBounds = metrics.lowerBounds(); - Map upperBounds = metrics.upperBounds(); + int fieldId, Type type, T lowerBound, T upperBound, MetricsWithStats metricsWithStats) { + Map lowerBounds = metricsWithStats.metrics().lowerBounds(); + Map upperBounds = metricsWithStats.metrics().upperBounds(); if (null != lowerBound || null != upperBound) { // if there's an expected lower/upper bound, then the original type should be available - assertThat(metrics.originalTypes().get(fieldId)).isEqualTo(type); + assertThat(metricsWithStats.metrics().originalTypes().get(fieldId)).isEqualTo(type); } if (lowerBounds.containsKey(fieldId)) { @@ -786,5 +865,36 @@ protected void assertBounds( } else { assertThat(upperBound).isNull(); } + + if (null != metricsWithStats.stats()) { + assertSerializable(metricsWithStats.stats()); + FieldStats stat = metricsWithStats.stats().statsFor(fieldId); + if (null == stat) { + // stat is only null when metrics mode is set to none + assertThat(lowerBound).isNull(); + assertThat(upperBound).isNull(); + } else { + assertThat(stat).isNotNull(); + if (lowerBound instanceof CharBuffer || upperBound instanceof CharBuffer) { + // CharBuffer is stored as String in content stats + assertThat(stat.lowerBound()).isEqualTo(lowerBound.toString()); + assertThat(stat.upperBound()).isEqualTo(upperBound.toString()); + } else { + assertThat(stat.lowerBound()).isEqualTo(lowerBound); + assertThat(stat.upperBound()).isEqualTo(upperBound); + } + } + } + } + + protected record MetricsWithStats(Metrics metrics, ContentStats stats) {} + + private void assertSerializable(ContentStats stats) { + try { + ContentStats serialized = TestHelpers.roundTripSerialize(stats); + assertThat(serialized).isEqualTo(stats); + } catch (IOException | ClassNotFoundException e) { + throw new RuntimeException(e); + } } } diff --git a/core/src/test/java/org/apache/iceberg/TestOverwrite.java b/core/src/test/java/org/apache/iceberg/TestOverwrite.java index c853cf69ea08..efd4935a4b9f 100644 --- a/core/src/test/java/org/apache/iceberg/TestOverwrite.java +++ b/core/src/test/java/org/apache/iceberg/TestOverwrite.java @@ -111,7 +111,10 @@ public class TestOverwrite extends TestBase { @Parameters(name = "formatVersion = {0}, branch = {1}") protected static List parameters() { return TestHelpers.ALL_VERSIONS.stream() - .flatMap(v -> Stream.of(new Object[] {v, "main"}, new Object[] {v, "testBranch"})) + .flatMap( + v -> + Stream.of( + new Object[] {v, SnapshotRef.MAIN_BRANCH}, new Object[] {v, "testBranch"})) .collect(Collectors.toList()); } diff --git a/core/src/test/java/org/apache/iceberg/TestOverwriteWithValidation.java b/core/src/test/java/org/apache/iceberg/TestOverwriteWithValidation.java index f984002f4601..f3070c0e2ccc 100644 --- a/core/src/test/java/org/apache/iceberg/TestOverwriteWithValidation.java +++ b/core/src/test/java/org/apache/iceberg/TestOverwriteWithValidation.java @@ -186,7 +186,10 @@ public class TestOverwriteWithValidation extends TestBase { @Parameters(name = "formatVersion = {0}, branch = {1}") protected static List parameters() { return TestHelpers.ALL_VERSIONS.stream() - .flatMap(v -> Stream.of(new Object[] {v, "main"}, new Object[] {v, "testBranch"})) + .flatMap( + v -> + Stream.of( + new Object[] {v, SnapshotRef.MAIN_BRANCH}, new Object[] {v, "testBranch"})) .collect(Collectors.toList()); } diff --git a/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java b/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java index c250ff82e4f7..09e9fdd1f722 100644 --- a/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java +++ b/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java @@ -793,8 +793,10 @@ public void testWithExpiringDanglingStageCommitWithReachableCleanup() { expectedDeletes.add(snapshotA.manifestListLocation()); // Files should be deleted of dangling staged snapshot - snapshotB - .addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(snapshotB) + .build() + .addedDataFiles() .forEach( i -> { expectedDeletes.add(i.location()); @@ -883,7 +885,10 @@ public void testWithCherryPickTableSnapshot() { Lists.newArrayList(snapshotB, snapshotC, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -929,7 +934,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -946,7 +954,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -1388,7 +1399,7 @@ public void testUnreferencedSnapshotParentOfTag() { table .manageSnapshots() .createTag("tag", table.currentSnapshot().snapshotId()) - .replaceBranch("main", initialSnapshotId) + .replaceBranch(SnapshotRef.MAIN_BRANCH, initialSnapshotId) .commit(); removeSnapshots(table) @@ -1426,7 +1437,7 @@ public void testSnapshotParentOfBranchNotUnreferenced() { .manageSnapshots() .createBranch("branch", table.currentSnapshot().snapshotId()) .setMaxSnapshotAgeMs("branch", Long.MAX_VALUE) - .replaceBranch("main", initialSnapshotId) + .replaceBranch(SnapshotRef.MAIN_BRANCH, initialSnapshotId) .commit(); removeSnapshots(table) @@ -2196,4 +2207,47 @@ private static PartitionStatisticsFile reusePartitionStatsFile( private static void commitPartitionStats(Table table, PartitionStatisticsFile statisticsFile) { table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); } + + @TestTemplate + public void testAppendOnlyManifestsNotScannedDuringCleanup() { + assumeThat(incrementalCleanup).isTrue(); + + TestTables.LocalFileIO spyFileIO = Mockito.spy(new TestTables.LocalFileIO()); + String tableName = "testAppendOnlyManifests"; + Table testTable = + TestTables.create( + tableDir, + tableName, + SCHEMA, + SPEC, + SortOrder.unsorted(), + formatVersion, + new TestTables.TestTableOperations(tableName, tableDir, spyFileIO)); + + testTable.newAppend().appendFile(FILE_A).commit(); + Snapshot firstSnapshot = testTable.currentSnapshot(); + + Set appendOnlyManifestPaths = + firstSnapshot.allManifests(testTable.io()).stream() + .map(ManifestFile::path) + .collect(Collectors.toSet()); + + waitUntilAfter(firstSnapshot.timestampMillis()); + + testTable.newAppend().appendFile(FILE_B).commit(); + long tAfterCommits = waitUntilAfter(testTable.currentSnapshot().timestampMillis()); + + Mockito.clearInvocations(spyFileIO); + + Set deletedFiles = Sets.newHashSet(); + removeSnapshots(testTable) + .expireOlderThan(tAfterCommits) + .deleteWith(deletedFiles::add) + .commit(); + + assertThat(deletedFiles).containsExactly(firstSnapshot.manifestListLocation()); + + appendOnlyManifestPaths.forEach( + path -> Mockito.verify(spyFileIO, Mockito.never()).newInputFile(path)); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestReplacePartitions.java b/core/src/test/java/org/apache/iceberg/TestReplacePartitions.java index abc27cddd610..23567dd99746 100644 --- a/core/src/test/java/org/apache/iceberg/TestReplacePartitions.java +++ b/core/src/test/java/org/apache/iceberg/TestReplacePartitions.java @@ -125,7 +125,8 @@ public class TestReplacePartitions extends TestBase { @Parameters(name = "formatVersion = {0}, branch = {1}") protected static List parameters() { return TestHelpers.ALL_VERSIONS.stream() - .flatMap(v -> Stream.of(new Object[] {v, "main"}, new Object[] {v, "branch"})) + .flatMap( + v -> Stream.of(new Object[] {v, SnapshotRef.MAIN_BRANCH}, new Object[] {v, "branch"})) .collect(Collectors.toList()); } @@ -187,14 +188,17 @@ public void testReplaceWithUnpartitionedTable() throws IOException { assertThat(TestTables.metadataVersion("unpartitioned")).isEqualTo(0); - commit(table, unpartitioned.newAppend().appendFile(FILE_A), branch); + commit(unpartitioned, unpartitioned.newAppend().appendFile(FILE_A), branch); // make sure the data was successfully added assertThat(TestTables.metadataVersion("unpartitioned")).isEqualTo(1); validateSnapshot( - null, latestSnapshot(TestTables.readMetadata("unpartitioned"), branch), FILE_A); + unpartitioned, + null, + latestSnapshot(TestTables.readMetadata("unpartitioned"), branch), + FILE_A); ReplacePartitions replacePartitions = unpartitioned.newReplacePartitions().addFile(FILE_B); - commit(table, replacePartitions, branch); + commit(unpartitioned, replacePartitions, branch); assertThat(TestTables.metadataVersion("unpartitioned")).isEqualTo(2); TableMetadata replaceMetadata = TestTables.readMetadata("unpartitioned"); @@ -203,12 +207,14 @@ public void testReplaceWithUnpartitionedTable() throws IOException { assertThat(latestSnapshot(replaceMetadata, branch).allManifests(unpartitioned.io())).hasSize(2); validateManifestEntries( + unpartitioned, latestSnapshot(replaceMetadata, branch).allManifests(unpartitioned.io()).get(0), ids(replaceId), files(FILE_B), statuses(Status.ADDED)); validateManifestEntries( + unpartitioned, latestSnapshot(replaceMetadata, branch).allManifests(unpartitioned.io()).get(1), ids(replaceId), files(FILE_A), @@ -222,6 +228,7 @@ public void testReplaceAllVoidUnpartitionedTable() { commit(tableVoid, tableVoid.newAppend().appendFile(FILE_ALL_VOID_UNPARTITIONED_A), branch); validateSnapshot( + tableVoid, null, latestSnapshot(TestTables.readMetadata("allvoidUnpartitioned"), branch), FILE_ALL_VOID_UNPARTITIONED_A); @@ -239,12 +246,14 @@ public void testReplaceAllVoidUnpartitionedTable() { assertThat(manifestFiles).hasSize(2); validateManifestEntries( + tableVoid, manifestFiles.get(0), ids(replaceId), files(FILE_ALL_VOID_UNPARTITIONED_B), statuses(Status.ADDED)); validateManifestEntries( + tableVoid, manifestFiles.get(1), ids(replaceId), files(FILE_ALL_VOID_UNPARTITIONED_A), @@ -263,15 +272,18 @@ public void testReplaceAndMergeWithUnpartitionedTable() throws IOException { assertThat(TestTables.metadataVersion("unpartitioned")).isEqualTo(1); AppendFiles appendFiles = unpartitioned.newAppend().appendFile(FILE_A); - commit(table, appendFiles, branch); + commit(unpartitioned, appendFiles, branch); // make sure the data was successfully added assertThat(TestTables.metadataVersion("unpartitioned")).isEqualTo(2); validateSnapshot( - null, latestSnapshot(TestTables.readMetadata("unpartitioned"), branch), FILE_A); + unpartitioned, + null, + latestSnapshot(TestTables.readMetadata("unpartitioned"), branch), + FILE_A); ReplacePartitions replacePartitions = unpartitioned.newReplacePartitions().addFile(FILE_B); - commit(table, replacePartitions, branch); + commit(unpartitioned, replacePartitions, branch); assertThat(TestTables.metadataVersion("unpartitioned")).isEqualTo(3); TableMetadata replaceMetadata = TestTables.readMetadata("unpartitioned"); @@ -280,6 +292,7 @@ public void testReplaceAndMergeWithUnpartitionedTable() throws IOException { assertThat(latestSnapshot(replaceMetadata, branch).allManifests(unpartitioned.io())).hasSize(1); validateManifestEntries( + unpartitioned, latestSnapshot(replaceMetadata, branch).allManifests(unpartitioned.io()).get(0), ids(replaceId, replaceId), files(FILE_B, FILE_A), diff --git a/core/src/test/java/org/apache/iceberg/TestRewriteFiles.java b/core/src/test/java/org/apache/iceberg/TestRewriteFiles.java index 64b2b4d52c46..72a3c89b74d5 100644 --- a/core/src/test/java/org/apache/iceberg/TestRewriteFiles.java +++ b/core/src/test/java/org/apache/iceberg/TestRewriteFiles.java @@ -48,7 +48,8 @@ public class TestRewriteFiles extends TestBase { @Parameters(name = "formatVersion = {0}, branch = {1}") protected static List parameters() { return TestHelpers.ALL_VERSIONS.stream() - .flatMap(v -> Stream.of(new Object[] {v, "main"}, new Object[] {v, "branch"})) + .flatMap( + v -> Stream.of(new Object[] {v, SnapshotRef.MAIN_BRANCH}, new Object[] {v, "branch"})) .collect(Collectors.toList()); } @@ -364,7 +365,7 @@ public void testRewriteDataAndAssignOldSequenceNumber() { long pendingId = pending.snapshotId(); ManifestFile newManifest = pending.allManifests(table.io()).get(0); validateManifestEntries(newManifest, ids(pendingId), files(FILE_D), statuses(ADDED)); - assertThat(ManifestFiles.read(newManifest, FILE_IO).entries()) + assertThat(ManifestFiles.read(newManifest, FILE_IO, table.specs()).entries()) .allSatisfy(entry -> assertThat(entry.dataSequenceNumber()).isEqualTo(oldSequenceNumber)); assertThat(newManifest.sequenceNumber()).isEqualTo(oldSequenceNumber + 1); diff --git a/core/src/test/java/org/apache/iceberg/TestRewriteManifests.java b/core/src/test/java/org/apache/iceberg/TestRewriteManifests.java index 0d5414eaf0d6..19ee156c9eec 100644 --- a/core/src/test/java/org/apache/iceberg/TestRewriteManifests.java +++ b/core/src/test/java/org/apache/iceberg/TestRewriteManifests.java @@ -133,7 +133,8 @@ public void testRewriteManifestsGeneratedAndAppendedDirectly() throws IOExceptio // get the correct file order List files; List ids; - try (ManifestReader reader = ManifestFiles.read(manifests.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { if (reader.iterator().next().location().equals(FILE_A.location())) { files = Arrays.asList(FILE_A, FILE_B); ids = Arrays.asList(manifestAppendId, fileAppendId); @@ -193,7 +194,8 @@ public void testReplaceManifestsConsolidate() throws IOException { // get the file order correct List files; List ids; - try (ManifestReader reader = ManifestFiles.read(manifests.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { if (reader.iterator().next().location().equals(FILE_A.location())) { files = Arrays.asList(FILE_A, FILE_B); ids = Arrays.asList(appendIdA, appendIdB); @@ -232,7 +234,8 @@ public void testReplaceManifestsWithFilter() throws IOException { .clusterBy(file -> "file") .rewriteIf( manifest -> { - try (ManifestReader reader = ManifestFiles.read(manifest, table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifest, table.io(), table.specs())) { return !reader.iterator().next().location().equals(FILE_A.location()); } catch (IOException x) { throw new RuntimeIOException(x); @@ -246,7 +249,8 @@ public void testReplaceManifestsWithFilter() throws IOException { // get the file order correct List files; List ids; - try (ManifestReader reader = ManifestFiles.read(manifests.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { if (reader.iterator().next().location().equals(FILE_B.location())) { files = Arrays.asList(FILE_B, FILE_C); ids = Arrays.asList(appendIdB, appendIdC); @@ -307,7 +311,8 @@ public void testConcurrentRewriteManifest() throws IOException { .clusterBy(file -> "file") .rewriteIf( manifest -> { - try (ManifestReader reader = ManifestFiles.read(manifest, table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifest, table.io(), table.specs())) { return !reader.iterator().next().location().equals(FILE_A.location()); } catch (IOException x) { throw new RuntimeIOException(x); @@ -327,7 +332,8 @@ public void testConcurrentRewriteManifest() throws IOException { // get the file order correct List files; List ids; - try (ManifestReader reader = ManifestFiles.read(manifests.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { if (reader.iterator().next().location().equals(FILE_A.location())) { files = Arrays.asList(FILE_A, FILE_B); ids = Arrays.asList(appendIdA, appendIdB); @@ -845,7 +851,8 @@ public void testManifestReplacementCombinedWithRewrite() throws IOException { .clusterBy(dataFile -> "const-value") .rewriteIf( manifest -> { - try (ManifestReader reader = ManifestFiles.read(manifest, table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifest, table.io(), table.specs())) { return !reader.iterator().next().location().equals(FILE_B.location()); } catch (IOException x) { throw new RuntimeIOException(x); diff --git a/core/src/test/java/org/apache/iceberg/TestRewriteTablePathUtil.java b/core/src/test/java/org/apache/iceberg/TestRewriteTablePathUtil.java index 851e423fb660..bedd8dd66d71 100644 --- a/core/src/test/java/org/apache/iceberg/TestRewriteTablePathUtil.java +++ b/core/src/test/java/org/apache/iceberg/TestRewriteTablePathUtil.java @@ -19,10 +19,17 @@ package org.apache.iceberg; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import java.io.IOException; +import java.util.Set; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; -public class TestRewriteTablePathUtil { +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewriteTablePathUtil extends TestBase { @Test public void testStagingPathPreservesDirectoryStructure() { @@ -80,4 +87,198 @@ public void testStagingPathWithNoMiddlePart() { RewriteTablePathUtil.stagingPath(fileDirectlyUnderPrefix, sourcePrefix, stagingDir); assertThat(newMethodResult).isEqualTo("/staging/file.parquet"); } + + @Test + public void testRelativize() { + // Normal case: path is under prefix + assertThat(RewriteTablePathUtil.relativize("/a/b/c", "/a")).isEqualTo("b/c"); + assertThat(RewriteTablePathUtil.relativize("/a/b", "/a")).isEqualTo("b"); + + // Edge case: path equals prefix exactly (issue #15172) + assertThat(RewriteTablePathUtil.relativize("/a", "/a")).isEqualTo(""); + assertThat(RewriteTablePathUtil.relativize("s3://bucket/warehouse", "s3://bucket/warehouse")) + .isEqualTo(""); + + // Trailing separator variations - all combinations should work + assertThat(RewriteTablePathUtil.relativize("/a/", "/a")).isEqualTo(""); + assertThat(RewriteTablePathUtil.relativize("/a/", "/a/")).isEqualTo(""); + assertThat(RewriteTablePathUtil.relativize("/a", "/a/")).isEqualTo(""); + } + + @Test + public void testRelativizeInvalid() { + // Path does not start with prefix + assertThatThrownBy(() -> RewriteTablePathUtil.relativize("/other/path", "/source/table")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not start with"); + + // Overlapping names: /table-old should NOT match prefix /table + assertThatThrownBy(() -> RewriteTablePathUtil.relativize("/table-old/data", "/table")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not start with"); + } + + @Test + public void testNewPath() { + // Normal case: path is under prefix + assertThat(RewriteTablePathUtil.newPath("/src/data/file.parquet", "/src", "/tgt")) + .isEqualTo("/tgt/data/file.parquet"); + + // Trailing separator on path + assertThat(RewriteTablePathUtil.newPath("/src/data/", "/src", "/tgt")).isEqualTo("/tgt/data/"); + + // Both path and prefix with trailing separator - result preserves target format + assertThat(RewriteTablePathUtil.newPath("/src/", "/src/", "/tgt")).isEqualTo("/tgt"); + } + + @Test + public void testNewPathEqualsPrefix() { + // Issue #15172: path equals prefix (e.g., write.data.path = table location) + // Result preserves the target prefix format (no trailing separator added) + assertThat(RewriteTablePathUtil.newPath("/src", "/src", "/tgt")).isEqualTo("/tgt"); + + // S3 paths - storage migration scenario + assertThat( + RewriteTablePathUtil.newPath( + "s3://bucket/warehouse/db/table", + "s3://bucket/warehouse/db/table", + "s3://bucket-dr/warehouse/db/table")) + .isEqualTo("s3://bucket-dr/warehouse/db/table"); + } + + @Test + public void testNewPathTrailingSeparatorCombinations() { + // All combinations of trailing separators should work consistently + // Path equals prefix - result preserves target format + assertThat(RewriteTablePathUtil.newPath("/src", "/src", "/tgt")).isEqualTo("/tgt"); + assertThat(RewriteTablePathUtil.newPath("/src/", "/src", "/tgt")).isEqualTo("/tgt"); + assertThat(RewriteTablePathUtil.newPath("/src", "/src/", "/tgt")).isEqualTo("/tgt"); + assertThat(RewriteTablePathUtil.newPath("/src/", "/src/", "/tgt")).isEqualTo("/tgt"); + + // Path under prefix - all should preserve relative structure + assertThat(RewriteTablePathUtil.newPath("/src/data", "/src", "/tgt")).isEqualTo("/tgt/data"); + assertThat(RewriteTablePathUtil.newPath("/src/data", "/src/", "/tgt")).isEqualTo("/tgt/data"); + + // Target with trailing separator - preserved when path equals prefix + assertThat(RewriteTablePathUtil.newPath("/src", "/src", "/tgt/")).isEqualTo("/tgt/"); + assertThat(RewriteTablePathUtil.newPath("/src/data", "/src", "/tgt/")).isEqualTo("/tgt/data"); + } + + @Test + public void testNewPathInvalid() { + // Path does not start with source prefix + assertThatThrownBy(() -> RewriteTablePathUtil.newPath("/other/path", "/src", "/tgt")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not start with"); + + // Overlapping names: /table-old should NOT match prefix /table + assertThatThrownBy(() -> RewriteTablePathUtil.newPath("/table-old/data", "/table", "/tgt")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not start with"); + } + + @Test + public void testNewPathBackupRestore() { + // Backup: rewriting to a subdirectory of the original location + assertThat(RewriteTablePathUtil.newPath("/table/data/file.parquet", "/table", "/table/backup")) + .isEqualTo("/table/backup/data/file.parquet"); + assertThat(RewriteTablePathUtil.newPath("/table", "/table", "/table/backup")) + .isEqualTo("/table/backup"); + + // Restore: rewriting from subdirectory to parent + assertThat( + RewriteTablePathUtil.newPath( + "/table/backup/data/file.parquet", "/table/backup", "/table")) + .isEqualTo("/table/data/file.parquet"); + assertThat(RewriteTablePathUtil.newPath("/table/backup", "/table/backup", "/table")) + .isEqualTo("/table"); + } + + @Test + public void testNewPathTableRename() { + // Rename /tableX to /table (target is substring of source name) + assertThat(RewriteTablePathUtil.newPath("/tableX/data/file.parquet", "/tableX", "/table")) + .isEqualTo("/table/data/file.parquet"); + assertThat(RewriteTablePathUtil.newPath("/tableX", "/tableX", "/table")).isEqualTo("/table"); + assertThat(RewriteTablePathUtil.newPath("/tableX/metadata/v1.json", "/tableX", "/table")) + .isEqualTo("/table/metadata/v1.json"); + + // Rename /table to /tableX (source is substring of target name) + assertThat(RewriteTablePathUtil.newPath("/table/data/file.parquet", "/table", "/tableX")) + .isEqualTo("/tableX/data/file.parquet"); + assertThat(RewriteTablePathUtil.newPath("/table", "/table", "/tableX")).isEqualTo("/tableX"); + } + + @Test + public void testCombinePaths() { + // Normal case: adds separator between base and relative path + assertThat(RewriteTablePathUtil.combinePaths("/base", "relative/path")) + .isEqualTo("/base/relative/path"); + + // Base already has trailing separator - no double separator + assertThat(RewriteTablePathUtil.combinePaths("/base/", "relative/path")) + .isEqualTo("/base/relative/path"); + + // Empty relative path - returns absolutePath unchanged (no trailing separator added) + // This preserves the original path format when combining with empty relative + assertThat(RewriteTablePathUtil.combinePaths("/base", "")).isEqualTo("/base"); + assertThat(RewriteTablePathUtil.combinePaths("/base/", "")).isEqualTo("/base/"); + + // S3 paths + assertThat(RewriteTablePathUtil.combinePaths("s3://bucket/prefix", "data/file.parquet")) + .isEqualTo("s3://bucket/prefix/data/file.parquet"); + assertThat(RewriteTablePathUtil.combinePaths("s3://bucket/prefix", "")) + .isEqualTo("s3://bucket/prefix"); + + // Single-level relative path + assertThat(RewriteTablePathUtil.combinePaths("/base", "file.parquet")) + .isEqualTo("/base/file.parquet"); + } + + @Test + public void testFileName() { + // Normal file paths + assertThat(RewriteTablePathUtil.fileName("/path/to/file.parquet")).isEqualTo("file.parquet"); + assertThat(RewriteTablePathUtil.fileName("/a/b/c/data.json")).isEqualTo("data.json"); + + // S3 paths + assertThat(RewriteTablePathUtil.fileName("s3://bucket/warehouse/file.avro")) + .isEqualTo("file.avro"); + + // File directly at root + assertThat(RewriteTablePathUtil.fileName("/file.txt")).isEqualTo("file.txt"); + + // No separator (just filename) + assertThat(RewriteTablePathUtil.fileName("file.parquet")).isEqualTo("file.parquet"); + } + + @TestTemplate + public void testRewritingMultiplePositionDeleteEntriesWithinManifestFile() throws IOException { + assumeThat(formatVersion) + .as("Delete files only work for format version 2") + .isGreaterThanOrEqualTo(2); + + String sourcePrefix = "/path/to/"; + String stagingDir = "/staging/"; + String targetPrefix = "/path/new/"; + + ManifestFile manifest = + writeDeleteManifest(formatVersion, 1000L, FILE_A_DELETES, FILE_B_DELETES); + + RewriteTablePathUtil.RewriteResult deleteFileRewriteResult = + RewriteTablePathUtil.rewriteDeleteManifest( + manifest, + Set.of(1000L), + Files.localOutput( + FileFormat.AVRO.addExtension( + temp.resolve("junit" + System.nanoTime()).toFile().toString())), + table.io(), + formatVersion, + table.specs(), + sourcePrefix, + targetPrefix, + stagingDir); + + assertThat(deleteFileRewriteResult.toRewrite()).hasSize(2); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestRowDelta.java b/core/src/test/java/org/apache/iceberg/TestRowDelta.java index 397e1ac56dc4..05c35dd3447e 100644 --- a/core/src/test/java/org/apache/iceberg/TestRowDelta.java +++ b/core/src/test/java/org/apache/iceberg/TestRowDelta.java @@ -35,19 +35,32 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.LongStream; import java.util.stream.Stream; import org.apache.iceberg.ManifestEntry.Status; +import org.apache.iceberg.deletes.BaseDVFileWriter; +import org.apache.iceberg.deletes.DVFileWriter; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.encryption.EncryptingFileIO; +import org.apache.iceberg.encryption.EncryptionManager; +import org.apache.iceberg.encryption.EncryptionTestHelpers; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.ManifestEvaluator; +import org.apache.iceberg.expressions.Projections; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.OutputFileFactory; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.util.ContentFileUtil; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; @@ -60,7 +73,10 @@ public class TestRowDelta extends TestBase { @Parameters(name = "formatVersion = {0}, branch = {1}") protected static List parameters() { return TestHelpers.V2_AND_ABOVE.stream() - .flatMap(v -> Stream.of(new Object[] {v, "main"}, new Object[] {v, "testBranch"})) + .flatMap( + v -> + Stream.of( + new Object[] {v, SnapshotRef.MAIN_BRANCH}, new Object[] {v, "testBranch"})) .collect(Collectors.toList()); } @@ -74,6 +90,10 @@ public void addOnlyDeleteFilesProducesDeleteOperation() { assertThat(snap.sequenceNumber()).isEqualTo(1); assertThat(snap.operation()).isEqualTo(DataOperations.DELETE); assertThat(snap.deleteManifests(table.io())).hasSize(1); + assertThat(snap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -85,6 +105,10 @@ public void addOnlyDataFilesProducesAppendOperation() { assertThat(snap.sequenceNumber()).isEqualTo(1); assertThat(snap.operation()).isEqualTo(DataOperations.APPEND); assertThat(snap.dataManifests(table.io())).hasSize(1); + assertThat(snap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); validateManifest( snap.dataManifests(table.io()).get(0), @@ -108,6 +132,10 @@ public void testAddRemoveRows() { .as("Delta commit should use operation 'overwrite'") .isEqualTo(DataOperations.OVERWRITE); assertThat(snap.dataManifests(table.io())).hasSize(1); + assertThat(snap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); validateManifest( snap.dataManifests(table.io()).get(0), @@ -129,7 +157,12 @@ public void testAddRemoveRows() { @TestTemplate public void testAddRowsRemoveDataFile() { - table.newRowDelta().addRows(FILE_A).commit(); + Snapshot firstSnap = commit(table, table.newRowDelta().addRows(FILE_A), branch); + assertThat(firstSnap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); + SnapshotUpdate rowDelta = table.newRowDelta().addRows(FILE_B).removeRows(FILE_A); commit(table, rowDelta, branch); @@ -140,6 +173,10 @@ public void testAddRowsRemoveDataFile() { .as("Delta commit should use operation 'overwrite'") .isEqualTo(DataOperations.OVERWRITE); assertThat(snap.dataManifests(table.io())).hasSize(2); + assertThat(snap.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateManifest( snap.dataManifests(table.io()).get(0), @@ -1067,6 +1104,10 @@ public void testAddDeleteFilesMultipleSpecs() { .containsEntry(TOTAL_DELETE_FILES_PROP, "3") .containsEntry(ADDED_POS_DELETES_PROP, String.valueOf(posDeletesCount)) .containsEntry(TOTAL_POS_DELETES_PROP, String.valueOf(posDeletesCount)) + // 4 created (1 data + 3 delete), 3 kept (prior data manifests) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "4") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "3") .hasEntrySatisfying( CHANGED_PARTITION_PREFIX + "data_bucket=0", v -> assertThat(v).contains(ADDED_DELETE_FILES_PROP + "=1")) @@ -1157,6 +1198,10 @@ public void testManifestMergingMultipleSpecs() { // 2 appends and 1 row delta where delete files belong to different specs assertThat(thirdSnapshot.dataManifests(table.io())).hasSize(2); assertThat(thirdSnapshot.deleteManifests(table.io())).hasSize(2); + assertThat(thirdSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); // commit two more delete files to the same specs to trigger merging DeleteFile thirdDeleteFile = newDeletes(firstSnapshotDataFile); @@ -1600,6 +1645,10 @@ public void testRewrittenDeleteFiles() { RowDelta baseRowDelta = table.newRowDelta().addRows(dataFile).addDeletes(deleteFile); Snapshot baseSnapshot = commit(table, baseRowDelta, branch); assertThat(baseSnapshot.operation()).isEqualTo(DataOperations.OVERWRITE); + assertThat(baseSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); DeleteFile newDeleteFile = newDeletes(dataFile); RowDelta rowDelta = @@ -1610,6 +1659,10 @@ public void testRewrittenDeleteFiles() { .validateFromSnapshot(baseSnapshot.snapshotId()); Snapshot snapshot = commit(table, rowDelta, branch); assertThat(snapshot.operation()).isEqualTo(DataOperations.DELETE); + assertThat(snapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); List dataManifests = snapshot.dataManifests(table.io()); assertThat(dataManifests).hasSize(1); @@ -1761,7 +1814,7 @@ public void testConcurrentDeletesRewriteSameRemoveRows() { public void testConcurrentManifestRewriteWithRemoveRowsRemoval() throws IOException { assumeThat(formatVersion).isEqualTo(2); // Manifest rewrite isn't supported on branches currently - assumeThat(branch).isEqualTo("main"); + assumeThat(branch).isEqualTo(SnapshotRef.MAIN_BRANCH); DataFile dataFile = newDataFile("data_bucket=0"); DeleteFile deleteFile = newDeleteFile(dataFile.specId(), "data_bucket=0"); @@ -1879,6 +1932,469 @@ public void testConcurrentDVsForSameDataFile() { .hasMessageContaining("Found concurrently added DV for %s", dataFile.location()); } + @TestTemplate + public void testDuplicateDVsAreMerged() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + DataFile dataFile = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile), branch); + + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PUFFIN).build(); + + DeleteFile deleteFile1 = dvWithPositions(dataFile, fileFactory, 0, 2); + DeleteFile deleteFile2 = dvWithPositions(dataFile, fileFactory, 2, 4); + DeleteFile deleteFile3 = dvWithPositions(dataFile, fileFactory, 4, 8); + RowDelta rowDelta1 = + table.newRowDelta().addDeletes(deleteFile1).addDeletes(deleteFile2).addDeletes(deleteFile3); + + commit(table, rowDelta1, branch); + + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(table) + .snapshot(latestSnapshot(table, branch)) + .build() + .addedDeleteFiles(); + assertThat(Iterables.size(addedDeleteFiles)).isEqualTo(1); + DeleteFile mergedDV = Iterables.getOnlyElement(addedDeleteFiles); + + assertDVHasDeletedPositions(mergedDV, LongStream.range(0, 8).boxed()::iterator); + } + + @TestTemplate + public void testDuplicateDVsAreMergedWithEncryption() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + TestTables.TestTable encryptedTable = createEncryptedTable(); + DataFile dataFile = newDataFile("data_bucket=0"); + commit(encryptedTable, encryptedTable.newRowDelta().addRows(dataFile), branch); + + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(encryptedTable, 1, 1).format(FileFormat.PUFFIN).build(); + + DeleteFile deleteFile1 = dvWithPositions(encryptedTable, dataFile, fileFactory, 0, 2); + DeleteFile deleteFile2 = dvWithPositions(encryptedTable, dataFile, fileFactory, 2, 4); + commit( + encryptedTable, + encryptedTable.newRowDelta().addDeletes(deleteFile1).addDeletes(deleteFile2), + branch); + + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(encryptedTable) + .snapshot(latestSnapshot(encryptedTable, branch)) + .build() + .addedDeleteFiles(); + DeleteFile mergedDV = Iterables.getOnlyElement(addedDeleteFiles); + + assertThat(mergedDV.keyMetadata()).isNotNull(); + assertDVHasDeletedPositions(encryptedTable, mergedDV, LongStream.range(0, 4).boxed()::iterator); + } + + @TestTemplate + public void testDuplicateDVsMergedMultipleSpecs() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + // append a partitioned data file + DataFile firstSnapshotDataFile = newDataFile("data_bucket=0"); + commit(table, table.newAppend().appendFile(firstSnapshotDataFile), branch); + + // remove the only partition field to make the spec unpartitioned + table.updateSpec().removeField(Expressions.bucket("data", 16)).commit(); + + // append an unpartitioned data file + DataFile secondSnapshotDataFile = newDataFile(""); + commit(table, table.newAppend().appendFile(secondSnapshotDataFile), branch); + + // evolve the spec and add a new partition field + table.updateSpec().addField("data").commit(); + + // append a data file with the new spec + DataFile thirdSnapshotDataFile = newDataFile("data=abc"); + commit(table, table.newAppend().appendFile(thirdSnapshotDataFile), branch); + + assertThat(table.specs()).hasSize(3); + + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PUFFIN).build(); + + DataFile dataFile = newDataFile("data=xyz"); + // For each data file, create two DVs covering positions [0,2) and [2,4) + DeleteFile deleteFile1a = dvWithPositions(firstSnapshotDataFile, fileFactory, 0, 2); + DeleteFile deleteFile1b = dvWithPositions(firstSnapshotDataFile, fileFactory, 2, 4); + DeleteFile deleteFile2a = dvWithPositions(secondSnapshotDataFile, fileFactory, 0, 2); + DeleteFile deleteFile2b = dvWithPositions(secondSnapshotDataFile, fileFactory, 2, 4); + DeleteFile deleteFile3a = dvWithPositions(thirdSnapshotDataFile, fileFactory, 0, 2); + DeleteFile deleteFile3b = dvWithPositions(thirdSnapshotDataFile, fileFactory, 2, 4); + + commit( + table, + table + .newRowDelta() + .addRows(dataFile) + .addDeletes(deleteFile1a) + .addDeletes(deleteFile1b) + .addDeletes(deleteFile2a) + .addDeletes(deleteFile2b) + .addDeletes(deleteFile3a) + .addDeletes(deleteFile3b), + branch); + + Snapshot snapshot = latestSnapshot(table, branch); + // Expect 3 merged DVs, one per data file + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDeleteFiles(); + List mergedDVs = Lists.newArrayList(addedDeleteFiles); + assertThat(mergedDVs).hasSize(3); + // Should be a Puffin produced per merged DV spec + assertThat(mergedDVs.stream().map(ContentFile::location).collect(Collectors.toSet())) + .hasSize(1); + + DeleteFile committedDVForDataFile1 = + Iterables.getOnlyElement( + mergedDVs.stream() + .filter( + dv -> Objects.equals(dv.referencedDataFile(), firstSnapshotDataFile.location())) + .collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDVForDataFile1, LongStream.range(0, 4).boxed()::iterator); + + DeleteFile committedDVForDataFile2 = + Iterables.getOnlyElement( + mergedDVs.stream() + .filter( + dv -> + Objects.equals(dv.referencedDataFile(), secondSnapshotDataFile.location())) + .collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDVForDataFile2, LongStream.range(0, 4).boxed()::iterator); + + DeleteFile committedDVForDataFile3 = + Iterables.getOnlyElement( + mergedDVs.stream() + .filter( + dv -> Objects.equals(dv.referencedDataFile(), thirdSnapshotDataFile.location())) + .collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDVForDataFile3, LongStream.range(0, 4).boxed()::iterator); + } + + @TestTemplate + public void testDuplicateDVsAreMergedForMultipleReferenceFiles() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + DataFile dataFile1 = newDataFile("data_bucket=0"); + DataFile dataFile2 = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile1).addRows(dataFile2), branch); + + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PUFFIN).build(); + + // For each data file, create two DVs covering positions [0,2) and [2,4) + DeleteFile deleteFile1a = dvWithPositions(dataFile1, fileFactory, 0, 2); + DeleteFile deleteFile1b = dvWithPositions(dataFile1, fileFactory, 2, 4); + DeleteFile deleteFile2a = dvWithPositions(dataFile2, fileFactory, 0, 2); + DeleteFile deleteFile2b = dvWithPositions(dataFile2, fileFactory, 2, 4); + + // Commit all four duplicate DVs + RowDelta rowDelta = + table + .newRowDelta() + .addDeletes(deleteFile1a) + .addDeletes(deleteFile1b) + .addDeletes(deleteFile2a) + .addDeletes(deleteFile2b); + + commit(table, rowDelta, branch); + + // Expect two merged DVs, one per data file + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(table) + .snapshot(latestSnapshot(table, branch)) + .build() + .addedDeleteFiles(); + List mergedDVs = Lists.newArrayList(addedDeleteFiles); + + assertThat(mergedDVs).hasSize(2); + // Should be a single Puffin produced + assertThat(mergedDVs.stream().map(ContentFile::location).collect(Collectors.toSet())) + .hasSize(1); + + DeleteFile committedDVForDataFile1 = + Iterables.getOnlyElement( + mergedDVs.stream() + .filter(dv -> Objects.equals(dv.referencedDataFile(), dataFile1.location())) + .collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDVForDataFile1, LongStream.range(0, 4).boxed()::iterator); + + DeleteFile committedDVForDataFile2 = + Iterables.getOnlyElement( + mergedDVs.stream() + .filter(dv -> Objects.equals(dv.referencedDataFile(), dataFile2.location())) + .collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDVForDataFile2, LongStream.range(0, 4).boxed()::iterator); + } + + @TestTemplate + public void testDuplicateDVsAndValidDV() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + DataFile dataFile1 = newDataFile("data_bucket=0"); + DataFile dataFile2 = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile1).addRows(dataFile2), branch); + + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PUFFIN).build(); + + // dataFile1 has duplicate DVs that need merging + DeleteFile deleteFile1a = dvWithPositions(dataFile1, fileFactory, 0, 2); + DeleteFile deleteFile1b = dvWithPositions(dataFile1, fileFactory, 2, 4); + + // dataFile2 has a valid DV + DeleteFile deleteFile2 = dvWithPositions(dataFile2, fileFactory, 0, 3); + + RowDelta rowDelta = + table + .newRowDelta() + .addDeletes(deleteFile1a) + .addDeletes(deleteFile1b) + .addDeletes(deleteFile2); + + commit(table, rowDelta, branch); + + // Expect two DVs: one merged for dataFile1 and deleteFile2 + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(table) + .snapshot(latestSnapshot(table, branch)) + .build() + .addedDeleteFiles(); + List committedDVs = Lists.newArrayList(addedDeleteFiles); + + assertThat(committedDVs).hasSize(2); + + // Verify merged DV for dataFile1 has positions [0,4) + DeleteFile committedDVForDataFile1 = + Iterables.getOnlyElement( + committedDVs.stream() + .filter(dv -> Objects.equals(dv.referencedDataFile(), dataFile1.location())) + .collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDVForDataFile1, LongStream.range(0, 4).boxed()::iterator); + + // Verify deleteFile2 state + DeleteFile committedDVForDataFile2 = + Iterables.getOnlyElement( + committedDVs.stream() + .filter(dv -> Objects.equals(dv.referencedDataFile(), dataFile2.location())) + .collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDVForDataFile2, LongStream.range(0, 3).boxed()::iterator); + } + + @TestTemplate + public void testDuplicateDVsAreMergedAndEqDelete() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + DataFile dataFile = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile), branch); + + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PUFFIN).build(); + + // Two DVs for the same data file: [0,2) and [2,4) => 4 deleted positions total + DeleteFile dv1 = dvWithPositions(dataFile, fileFactory, 0, 2); + DeleteFile dv2 = dvWithPositions(dataFile, fileFactory, 2, 4); + + // One equality delete file for the same partition + DeleteFile eqDelete = + newEqualityDeleteFile( + table.spec().specId(), + "data_bucket=0", + table.schema().asStruct().fields().get(0).fieldId()); + + RowDelta rowDelta = table.newRowDelta().addDeletes(eqDelete).addDeletes(dv1).addDeletes(dv2); + + commit(table, rowDelta, branch); + + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(table) + .snapshot(latestSnapshot(table, branch)) + .build() + .addedDeleteFiles(); + List committedDeletes = Lists.newArrayList(addedDeleteFiles); + + // 1 DV + 1 equality delete + assertThat(committedDeletes).hasSize(2); + + DeleteFile committedDV = + Iterables.getOnlyElement( + committedDeletes.stream().filter(ContentFileUtil::isDV).collect(Collectors.toList())); + assertDVHasDeletedPositions(committedDV, LongStream.range(0, 4).boxed()::iterator); + + DeleteFile committedEqDelete = + Iterables.getOnlyElement( + committedDeletes.stream() + .filter(df -> df.content() == FileContent.EQUALITY_DELETES) + .collect(Collectors.toList())); + assertThat(committedEqDelete).isNotNull(); + assertThat(committedEqDelete.content()).isEqualTo(FileContent.EQUALITY_DELETES); + } + + @TestTemplate + public void testConcurrentDVsInDifferentPartitionsWithFilter() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + // bucket16("u") -> 0, bucket16("a") -> 2 + DataFile dataFileInBucket0 = newDataFile("data_bucket=0"); + DataFile dataFileInBucket2 = newDataFile("data_bucket=2"); + commit( + table, table.newRowDelta().addRows(dataFileInBucket0).addRows(dataFileInBucket2), branch); + + Snapshot base = latestSnapshot(table, branch); + + // prepare a DV for bucket 0 with a conflict detection filter scoped to bucket 0 + DeleteFile dvBucket0 = newDeletes(dataFileInBucket0); + RowDelta rowDelta = + table + .newRowDelta() + .addDeletes(dvBucket0) + .validateFromSnapshot(base.snapshotId()) + .conflictDetectionFilter(Expressions.equal("data", "u")); // bucket16("u") -> 0 + + // concurrently commit a DV in bucket 2 + DeleteFile dvBucket2 = newDeletes(dataFileInBucket2); + Snapshot concurrentSnapshot = commit(table, table.newRowDelta().addDeletes(dvBucket2), branch); + + // commit should succeed because the concurrent DV is in bucket 2 + // which does not overlap the conflict detection filter + Snapshot finalSnapshot = commit(table, rowDelta, branch); + + assertThat(finalSnapshot.deleteManifests(table.io())).hasSize(2); + validateDeleteManifest( + finalSnapshot.deleteManifests(table.io()).get(0), + dataSeqs(finalSnapshot.sequenceNumber()), + fileSeqs(finalSnapshot.sequenceNumber()), + ids(finalSnapshot.snapshotId()), + files(dvBucket0), + statuses(Status.ADDED)); + validateDeleteManifest( + finalSnapshot.deleteManifests(table.io()).get(1), + dataSeqs(concurrentSnapshot.sequenceNumber()), + fileSeqs(concurrentSnapshot.sequenceNumber()), + ids(concurrentSnapshot.snapshotId()), + files(dvBucket2), + statuses(Status.ADDED)); + } + + @TestTemplate + public void testConcurrentDVsInSamePartitionWithFilter() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + // bucket16("u") -> 0 + DataFile dataFile = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile), branch); + + Snapshot base = latestSnapshot(table, branch); + + // prepare a DV for dataFile with a conflict detection filter scoped to bucket 0 + DeleteFile dv1 = newDeletes(dataFile); + RowDelta rowDelta = + table + .newRowDelta() + .addDeletes(dv1) + .validateFromSnapshot(base.snapshotId()) + .conflictDetectionFilter(Expressions.equal("data", "u")); // bucket16("u") -> 0 + + // concurrently commit another DV for the same data file in bucket 0 + DeleteFile dv2 = newDeletes(dataFile); + commit(table, table.newRowDelta().addDeletes(dv2), branch); + + // must be conflict because the concurrent DV is in the same partition + assertThatThrownBy(() -> commit(table, rowDelta, branch)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Found concurrently added DV for %s", dataFile.location()); + } + + @TestTemplate + public void testDVValidationPartitionPruningManifestCount() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + // disable manifest merging so each commit produces a separate delete manifest + table.updateProperties().set(TableProperties.MANIFEST_MERGE_ENABLED, "false").commit(); + + // create data files and DVs across 10 different partitions (buckets 0-9) + int numPartitions = 10; + DataFile[] dataFiles = new DataFile[numPartitions]; + for (int bucket = 0; bucket < numPartitions; bucket++) { + dataFiles[bucket] = newDataFile("data_bucket=" + bucket); + commit(table, table.newRowDelta().addRows(dataFiles[bucket]), branch); + DeleteFile dv = newDeletes(dataFiles[bucket]); + commit(table, table.newRowDelta().addDeletes(dv), branch); + } + + Snapshot base = latestSnapshot(table, branch); + List deleteManifests = base.deleteManifests(table.io()); + + // there should be one delete manifest per partition since we disabled merging + assertThat(deleteManifests).hasSizeGreaterThanOrEqualTo(numPartitions); + + // count how many manifests match a filter scoped to bucket 0 + // bucket16("u") -> 0 + Expression filter = Expressions.equal("data", "u"); + int matching = 0; + for (ManifestFile manifest : deleteManifests) { + PartitionSpec spec = table.specs().get(manifest.partitionSpecId()); + Expression partitionFilter = Projections.inclusive(spec, true).project(filter); + ManifestEvaluator evaluator = + ManifestEvaluator.forPartitionFilter(partitionFilter, spec, true); + if (evaluator.eval(manifest)) { + matching = matching + 1; + } + } + + // only 1 out of N manifests should match the filter for bucket 0 + assertThat(matching).isEqualTo(1); + assertThat(deleteManifests.size() - matching) + .as("pruned manifests") + .isGreaterThanOrEqualTo(numPartitions - 1); + + // verify the DV manifest pruning works: commit a new DV in bucket 0 + // while concurrent DVs exist in all other partitions + DataFile newDataFileInBucket0 = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(newDataFileInBucket0), branch); + + Snapshot preCommit = latestSnapshot(table, branch); + DeleteFile newDV = newDeletes(newDataFileInBucket0); + RowDelta rowDelta = + table + .newRowDelta() + .addDeletes(newDV) + .validateFromSnapshot(preCommit.snapshotId()) + .conflictDetectionFilter(Expressions.equal("data", "u")); // bucket16("u") -> 0 + + // concurrently add a DV in a different partition (bucket 5) + // bucket16("v") -> 5 + DataFile newDataFileInBucket5 = newDataFile("data_bucket=5"); + commit(table, table.newRowDelta().addRows(newDataFileInBucket5), branch); + DeleteFile concurrentDV = newDeletes(newDataFileInBucket5); + Snapshot concurrentSnapshot = + commit(table, table.newRowDelta().addDeletes(concurrentDV), branch); + + // commit should succeed: the concurrent DV is in bucket 5, pruned by the filter + Snapshot finalSnapshot = commit(table, rowDelta, branch); + + List finalDeleteManifests = finalSnapshot.deleteManifests(table.io()); + // the new DV manifest and the concurrent DV manifest should be present + validateDeleteManifest( + finalDeleteManifests.get(0), + dataSeqs(finalSnapshot.sequenceNumber()), + fileSeqs(finalSnapshot.sequenceNumber()), + ids(finalSnapshot.snapshotId()), + files(newDV), + statuses(Status.ADDED)); + validateDeleteManifest( + finalDeleteManifests.get(1), + dataSeqs(concurrentSnapshot.sequenceNumber()), + fileSeqs(concurrentSnapshot.sequenceNumber()), + ids(concurrentSnapshot.snapshotId()), + files(concurrentDV), + statuses(Status.ADDED)); + } + @TestTemplate public void testManifestMergingAfterUpgradeToV3() { assumeThat(formatVersion).isEqualTo(2); @@ -1909,8 +2425,13 @@ public void testManifestMergingAfterUpgradeToV3() { RowDelta rowDelta2 = table.newRowDelta().addDeletes(dv); Snapshot dvSnapshot = commit(table, rowDelta2, branch); - // both must be part of the table and merged into one manifest + // both delete files merged into one manifest ManifestFile deleteManifest = Iterables.getOnlyElement(dvSnapshot.deleteManifests(table.io())); + // 1 created (merged), 1 kept (data), 1 replaced (existing delete manifest) + assertThat(dvSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); validateDeleteManifest( deleteManifest, dataSeqs(3L, 2L), @@ -1930,6 +2451,27 @@ public void testManifestMergingAfterUpgradeToV3() { assertThat(taskDV.contentSizeInBytes()).isEqualTo(dv.contentSizeInBytes()); } + @TestTemplate + public void testV2StagedPositionDeleteCannotCommitToV3() { + assumeThat(formatVersion).isEqualTo(2); + + Snapshot initial = commit(table, table.newAppend().appendFile(FILE_A), branch); + + // Stage RowDelta at v2: position delete for FILE_A + add new data FILE_B. + RowDelta rowDelta = table.newRowDelta().addDeletes(FILE_A_DELETES).addRows(FILE_B); + + // upgrade the table + table.updateProperties().set(TableProperties.FORMAT_VERSION, "3").commit(); + + assertThatThrownBy(rowDelta::commit) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Must use DVs for position deletes in V3"); + + table.refresh(); + assertThat(table.operations().current().formatVersion()).isEqualTo(3); + assertThat(table.snapshot(branch)).isEqualTo(initial); + } + @TestTemplate public void testInabilityToAddPositionDeleteFilesInTablesWithDVs() { assumeThat(formatVersion).isGreaterThanOrEqualTo(3); @@ -1956,4 +2498,177 @@ private List planFiles() { throw new RuntimeException(e); } } + + @TestTemplate + public void testCannotMergeDVsMismatchedSequenceNumbers() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + DataFile dataFile = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile), branch); + + DeleteFile dv1 = + FileMetadata.deleteFileBuilder(table.spec()) + .ofPositionDeletes() + .withFormat(FileFormat.PUFFIN) + .withPath("/tmp/dv-1.puffin") + .withFileSizeInBytes(10) + .withRecordCount(1) + .withPartition(dataFile.partition()) + .withReferencedDataFile(dataFile.location()) + .withContentOffset(0) + .withContentSizeInBytes(10) + .build(); + DeleteFile dv2 = + FileMetadata.deleteFileBuilder(table.spec()).copy(dv1).withPath("/tmp/dv-2.puffin").build(); + + // Use protected add(DeleteFile, long) to assign different data sequence numbers + MergingSnapshotProducer rowDelta = (MergingSnapshotProducer) table.newRowDelta(); + rowDelta.add(dv1, 1L); + rowDelta.add(dv2, 2L); + + assertThatThrownBy(() -> commit(table, rowDelta, branch)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot merge DVs, mismatched sequence numbers"); + } + + @TestTemplate + public void testCannotMergeDVsMismatchedPartitionSpecs() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + DataFile dataFile = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile), branch); + + // Evolve the spec so we have two distinct spec IDs + table.updateSpec().removeField(Expressions.bucket("data", 16)).commit(); + PartitionSpec originalSpec = table.specs().get(0); + PartitionSpec evolvedSpec = table.specs().get(1); + + DeleteFile dv1 = + FileMetadata.deleteFileBuilder(originalSpec) + .ofPositionDeletes() + .withFormat(FileFormat.PUFFIN) + .withPath("/tmp/dv-1.puffin") + .withFileSizeInBytes(10) + .withRecordCount(1) + .withPartition(dataFile.partition()) + .withReferencedDataFile(dataFile.location()) + .withContentOffset(0) + .withContentSizeInBytes(10) + .build(); + DeleteFile dv2 = + FileMetadata.deleteFileBuilder(evolvedSpec).copy(dv1).withPath("/tmp/dv-2.puffin").build(); + + assertThatThrownBy( + () -> commit(table, table.newRowDelta().addDeletes(dv1).addDeletes(dv2), branch)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot merge DVs, mismatched partition specs"); + } + + @TestTemplate + public void testCannotMergeDVsMismatchedPartitionTuples() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + DataFile dataFile = newDataFile("data_bucket=0"); + commit(table, table.newRowDelta().addRows(dataFile), branch); + + DeleteFile dv1 = + FileMetadata.deleteFileBuilder(table.spec()) + .ofPositionDeletes() + .withFormat(FileFormat.PUFFIN) + .withPath("/tmp/dv-1.puffin") + .withFileSizeInBytes(10) + .withRecordCount(1) + .withPartitionPath("data_bucket=0") + .withReferencedDataFile(dataFile.location()) + .withContentOffset(0) + .withContentSizeInBytes(10) + .build(); + DeleteFile dv2 = + FileMetadata.deleteFileBuilder(table.spec()) + .copy(dv1) + .withPath("/tmp/dv-2.puffin") + .withPartitionPath("data_bucket=1") + .build(); + + assertThatThrownBy( + () -> commit(table, table.newRowDelta().addDeletes(dv1).addDeletes(dv2), branch)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot merge DVs, mismatched partition tuples"); + } + + private DeleteFile dvWithPositions( + DataFile dataFile, OutputFileFactory fileFactory, int fromInclusive, int toExclusive) + throws IOException { + return dvWithPositions(table, dataFile, fileFactory, fromInclusive, toExclusive); + } + + private DeleteFile dvWithPositions( + Table targetTable, + DataFile dataFile, + OutputFileFactory fileFactory, + int fromInclusive, + int toExclusive) + throws IOException { + + List> deletes = Lists.newArrayList(); + for (int i = fromInclusive; i < toExclusive; i++) { + deletes.add(PositionDelete.create().set(dataFile.location(), i)); + } + + return writeDV(targetTable, deletes, dataFile.specId(), dataFile.partition(), fileFactory); + } + + private void assertDVHasDeletedPositions(DeleteFile dv, Iterable positions) { + assertDVHasDeletedPositions(table, dv, positions); + } + + private void assertDVHasDeletedPositions( + Table targetTable, DeleteFile dv, Iterable positions) { + assertThat(dv).isNotNull(); + PositionDeleteIndex index = DVUtil.readDV(dv, targetTable.io()); + assertThat(positions) + .allSatisfy( + pos -> + assertThat(index.isDeleted(pos)) + .as("Expected position %s to be deleted", pos) + .isTrue()); + } + + private DeleteFile writeDV( + Table targetTable, + List> deletes, + int specId, + StructLike partition, + OutputFileFactory fileFactory) + throws IOException { + + DVFileWriter writer = new BaseDVFileWriter(fileFactory, p -> null); + try (DVFileWriter closeableWriter = writer) { + for (PositionDelete delete : deletes) { + closeableWriter.delete( + delete.path().toString(), delete.pos(), targetTable.specs().get(specId), partition); + } + } + + return Iterables.getOnlyElement(writer.result().deleteFiles()); + } + + private TestTables.TestTable createEncryptedTable() { + EncryptionManager encryptionManager = EncryptionTestHelpers.createEncryptionManager(); + String tableName = "encrypted-" + branch; + java.io.File encryptedTableDir = temp.resolve(tableName).toFile(); + TestTables.TestTableOperations ops = + new TestTables.TestTableOperations( + tableName, + encryptedTableDir, + EncryptingFileIO.combine(new TestTables.LocalFileIO(), encryptionManager)) { + @Override + public EncryptionManager encryption() { + return encryptionManager; + } + }; + + return TestTables.create( + encryptedTableDir, tableName, SCHEMA, SPEC, SortOrder.unsorted(), formatVersion, ops); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestRowLineageAssignment.java b/core/src/test/java/org/apache/iceberg/TestRowLineageAssignment.java index 5f027ccc88ca..42ee8d015779 100644 --- a/core/src/test/java/org/apache/iceberg/TestRowLineageAssignment.java +++ b/core/src/test/java/org/apache/iceberg/TestRowLineageAssignment.java @@ -244,7 +244,7 @@ public void testFastForwardPreservesRowIds() { long branchSnapshot = table.snapshot("branch").snapshotId(); // fast-forward main to the branch - table.manageSnapshots().fastForwardBranch("main", "branch").commit(); + table.manageSnapshots().fastForwardBranch(SnapshotRef.MAIN_BRANCH, "branch").commit(); // branch data manifests: [added(FILE_C)], [added(FILE_B)], [added(FILE_A)] assertThat(table.operations().current().nextRowId()) @@ -252,7 +252,7 @@ public void testFastForwardPreservesRowIds() { assertThat(table.currentSnapshot().snapshotId()).isEqualTo(branchSnapshot); // validate that the branches have the same first_row_id assignments - for (String branch : List.of("main", "branch")) { + for (String branch : List.of(SnapshotRef.MAIN_BRANCH, "branch")) { checkManifestListAssignment( table.io().newInputFile(table.snapshot(branch).manifestListLocation()), startingNextRowId + FILE_B.recordCount(), @@ -781,6 +781,33 @@ public void testUpgradeAssignmentWithManifestCompaction(@TempDir File altLocatio FILE_C.recordCount() + FILE_B.recordCount()); } + @Test + public void testRewritePreservesExistingFileFirstRowIds() { + table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + // FILE_A gets firstRowId=0, FILE_B gets firstRowId=FILE_A.recordCount()=125; assert before + // rewrite + ManifestFile preRewriteManifest = + Iterables.getOnlyElement(table.currentSnapshot().dataManifests(table.io())); + checkDataFileAssignment(table, preRewriteManifest, 0L, FILE_A.recordCount()); + + // set low to trigger an internal manifest merge during the rewrite + table.updateProperties().set(TableProperties.MANIFEST_MIN_MERGE_COUNT, "1").commit(); + // FILE_A and FILE_C must be removed and added in the same operation. Removing FILE_A creates + // an uncommitted manifest containing FILE_B as an existing entry. Adding FILE_C triggers the + // internal manifest merge to read that uncommitted manifest before its firstRowId is assigned. + table.newRewrite().deleteFile(FILE_A).addFile(FILE_C).commit(); + + // merged manifest live files: [FILE_C (added), FILE_B (existing)] + ManifestFile manifest = + Iterables.getOnlyElement(table.currentSnapshot().dataManifests(table.io())); + checkDataFileAssignment( + table, + manifest, + FILE_A.recordCount() + FILE_B.recordCount(), // FILE_C gets 225 + FILE_A.recordCount()); // FILE_B must retain its original firstRowId (125) + } + private static ManifestContent content(int ordinal) { return ManifestContent.values()[ordinal]; } diff --git a/core/src/test/java/org/apache/iceberg/TestSchemaUpdate.java b/core/src/test/java/org/apache/iceberg/TestSchemaUpdate.java index 3b9ef1871da7..5325e4013c68 100644 --- a/core/src/test/java/org/apache/iceberg/TestSchemaUpdate.java +++ b/core/src/test/java/org/apache/iceberg/TestSchemaUpdate.java @@ -1181,6 +1181,17 @@ public void testDeleteMapKey() { .hasMessageStartingWith("Cannot delete map keys"); } + @Test + public void testDeleteMapValue() { + assertThatThrownBy( + () -> + new SchemaUpdate(SCHEMA, SCHEMA_LAST_COLUMN_ID) + .deleteColumn("locations.value") + .apply()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot delete value type from map"); + } + @Test public void testAddFieldToMapKey() { assertThatThrownBy( @@ -2503,4 +2514,80 @@ public void testAddRequiredUnknown() { .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot create required field with unknown type: unk"); } + + @Test + public void testCaseInsensitiveAddTopLevelAndMove() { + Schema schema = new Schema(required(1, "id", Types.LongType.get())); + + Schema expected = + new Schema( + optional(2, "data", Types.StringType.get()), required(1, "id", Types.LongType.get())); + + Schema actual = + new SchemaUpdate(schema, schema.highestFieldId()) + .caseSensitive(false) + .addColumn("data", Types.StringType.get()) + .moveFirst("dAtA") + .apply(); + + assertThat(actual.asStruct()).isEqualTo(expected.asStruct()); + } + + @Test + public void testCaseInsensitiveAddNestedAndMove() { + Schema schema = + new Schema( + required(1, "id", Types.LongType.get()), + optional( + 2, "struct", Types.StructType.of(required(3, "field1", Types.StringType.get())))); + + Schema expected = + new Schema( + required(1, "id", Types.LongType.get()), + optional( + 2, + "struct", + Types.StructType.of( + optional(4, "field2", Types.IntegerType.get()), + required(3, "field1", Types.StringType.get())))); + + Schema actual = + new SchemaUpdate(schema, schema.highestFieldId()) + .caseSensitive(false) + .addColumn("STRUCT", "field2", Types.IntegerType.get()) + .moveFirst("STRUCT.FIELD2") + .apply(); + + assertThat(actual.asStruct()).isEqualTo(expected.asStruct()); + } + + @Test + public void testCaseInsensitiveMoveAfterNewlyAddedField() { + Schema schema = + new Schema( + required(1, "id", Types.LongType.get()), + optional( + 2, "struct", Types.StructType.of(required(3, "field1", Types.StringType.get())))); + + Schema expected = + new Schema( + required(1, "id", Types.LongType.get()), + optional( + 2, + "struct", + Types.StructType.of( + required(3, "field1", Types.StringType.get()), + optional(4, "field2", Types.IntegerType.get()), + optional(5, "field3", Types.DoubleType.get())))); + + Schema actual = + new SchemaUpdate(schema, schema.highestFieldId()) + .caseSensitive(false) + .addColumn("struct", "field2", Types.IntegerType.get()) + .addColumn("STRUCT", "field3", Types.DoubleType.get()) + .moveAfter("STRUCT.FIELD3", "struct.FIELD2") + .apply(); + + assertThat(actual.asStruct()).isEqualTo(expected.asStruct()); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestSequenceNumberForV2Table.java b/core/src/test/java/org/apache/iceberg/TestSequenceNumberForV2Table.java index d416a25d1c72..76178d4ea318 100644 --- a/core/src/test/java/org/apache/iceberg/TestSequenceNumberForV2Table.java +++ b/core/src/test/java/org/apache/iceberg/TestSequenceNumberForV2Table.java @@ -72,7 +72,8 @@ public void testRewrite() { "Last sequence number should be 3", 3, readMetadata().lastSequenceNumber()); // FILE_A and FILE_B in manifest may reorder - for (ManifestEntry entry : ManifestFiles.read(newManifest, FILE_IO).entries()) { + for (ManifestEntry entry : + ManifestFiles.read(newManifest, FILE_IO, table.specs()).entries()) { if (entry.file().location().equals(FILE_A.location())) { V2Assert.assertEquals( "FILE_A sequence number should be 1", 1, entry.dataSequenceNumber().longValue()); diff --git a/core/src/test/java/org/apache/iceberg/TestSetStatistics.java b/core/src/test/java/org/apache/iceberg/TestSetStatistics.java index e51614f45b2a..68336e44dc4c 100644 --- a/core/src/test/java/org/apache/iceberg/TestSetStatistics.java +++ b/core/src/test/java/org/apache/iceberg/TestSetStatistics.java @@ -106,4 +106,54 @@ public void testRemoveStatistics() { assertThat(version()).isEqualTo(3); assertThat(metadata.statisticsFiles()).isEmpty(); } + + @TestTemplate + public void setStatisticsRetryWithConcurrentModification() { + table.newFastAppend().appendFile(FILE_A).commit(); + long snapshotId = readMetadata().currentSnapshot().snapshotId(); + + GenericStatisticsFile statisticsFile = + new GenericStatisticsFile( + snapshotId, "/some/statistics/file.puffin", 100, 42, ImmutableList.of()); + + // Create a TableOperations that simulates concurrent modification + // On the first commit attempt, another writer modifies the table + TableOperations concurrentOps = + new TestTables.TestTableOperations("test", tableDir, table.ops().io()) { + private boolean firstAttempt = true; + + @Override + public void commit(TableMetadata base, TableMetadata metadata) { + if (firstAttempt) { + firstAttempt = false; + table.newFastAppend().appendFile(FILE_B).commit(); + } + + super.commit(base, metadata); + } + }; + + SetStatistics setStats = new SetStatistics(concurrentOps); + setStats.setStatistics(statisticsFile); + setStats.commit(); + + assertThat(readMetadata().statisticsFiles()).containsExactly(statisticsFile); + } + + @TestTemplate + public void setStatisticsRetrySuccess() { + table.newFastAppend().appendFile(FILE_A).commit(); + long snapshotId = readMetadata().currentSnapshot().snapshotId(); + + GenericStatisticsFile statisticsFile = + new GenericStatisticsFile( + snapshotId, "/some/statistics/file.puffin", 100, 42, ImmutableList.of()); + + TestTables.TestTableOperations ops = table.ops(); + ops.failCommits(2); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + assertThat(readMetadata().statisticsFiles()).containsExactly(statisticsFile); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestSnapshot.java b/core/src/test/java/org/apache/iceberg/TestSnapshot.java index 166db7e3e6a1..ff9aebffc3a6 100644 --- a/core/src/test/java/org/apache/iceberg/TestSnapshot.java +++ b/core/src/test/java/org/apache/iceberg/TestSnapshot.java @@ -35,7 +35,7 @@ public void testAppendFilesFromTable() { table.newFastAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); // collect data files from deserialization - Iterable filesToAdd = table.currentSnapshot().addedDataFiles(table.io()); + Iterable filesToAdd = SnapshotChanges.builderFor(table).build().addedDataFiles(); table.newDelete().deleteFile(FILE_A).deleteFile(FILE_B).commit(); @@ -89,7 +89,8 @@ public void testCachedDataFiles() { Snapshot thirdSnapshot = table.currentSnapshot(); - Iterable removedDataFiles = thirdSnapshot.removedDataFiles(FILE_IO); + SnapshotChanges changes = SnapshotChanges.builderFor(table).snapshot(thirdSnapshot).build(); + Iterable removedDataFiles = changes.removedDataFiles(); assertThat(removedDataFiles).as("Must have 1 removed data file").hasSize(1); DataFile removedDataFile = Iterables.getOnlyElement(removedDataFiles); @@ -97,7 +98,7 @@ public void testCachedDataFiles() { assertThat(removedDataFile.specId()).isEqualTo(FILE_A.specId()); assertThat(removedDataFile.partition()).isEqualTo(FILE_A.partition()); - Iterable addedDataFiles = thirdSnapshot.addedDataFiles(FILE_IO); + Iterable addedDataFiles = changes.addedDataFiles(); assertThat(addedDataFiles).as("Must have 1 added data file").hasSize(1); DataFile addedDataFile = Iterables.getOnlyElement(addedDataFiles); @@ -135,7 +136,8 @@ public void testCachedDeleteFiles() { Snapshot thirdSnapshot = table.currentSnapshot(); - Iterable removedDeleteFiles = thirdSnapshot.removedDeleteFiles(FILE_IO); + SnapshotChanges changes = SnapshotChanges.builderFor(table).snapshot(thirdSnapshot).build(); + Iterable removedDeleteFiles = changes.removedDeleteFiles(); assertThat(removedDeleteFiles).as("Must have 1 removed delete file").hasSize(1); DeleteFile removedDeleteFile = Iterables.getOnlyElement(removedDeleteFiles); @@ -143,7 +145,7 @@ public void testCachedDeleteFiles() { assertThat(removedDeleteFile.specId()).isEqualTo(secondSnapshotDeleteFile.specId()); assertThat(removedDeleteFile.partition()).isEqualTo(secondSnapshotDeleteFile.partition()); - Iterable addedDeleteFiles = thirdSnapshot.addedDeleteFiles(FILE_IO); + Iterable addedDeleteFiles = changes.addedDeleteFiles(); assertThat(addedDeleteFiles).as("Must have 1 added delete file").hasSize(1); DeleteFile addedDeleteFile = Iterables.getOnlyElement(addedDeleteFiles); @@ -172,7 +174,8 @@ private void runAddedDataFileSequenceNumberTest(long expectedSequenceNumber) { table.newFastAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); Snapshot snapshot = table.currentSnapshot(); - Iterable addedDataFiles = snapshot.addedDataFiles(table.io()); + Iterable addedDataFiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(); assertThat(snapshot.sequenceNumber()) .as("Sequence number mismatch in Snapshot") @@ -218,7 +221,8 @@ private void runRemovedDataFileSequenceNumberTest( table.newDelete().deleteFile(fileToRemove).commit(); Snapshot snapshot = table.currentSnapshot(); - Iterable removedDataFiles = snapshot.removedDataFiles(table.io()); + Iterable removedDataFiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().removedDataFiles(); assertThat(removedDataFiles).as("Must have 1 removed data file").hasSize(1); DataFile removedDataFile = Iterables.getOnlyElement(removedDataFiles); @@ -250,7 +254,8 @@ private void runAddedDeleteFileSequenceNumberTest( table.newRowDelta().addDeletes(deleteFileToAdd).commit(); Snapshot snapshot = table.currentSnapshot(); - Iterable addedDeleteFiles = snapshot.addedDeleteFiles(table.io()); + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDeleteFiles(); assertThat(addedDeleteFiles).as("Must have 1 added delete file").hasSize(1); DeleteFile addedDeleteFile = Iterables.getOnlyElement(addedDeleteFiles); diff --git a/core/src/test/java/org/apache/iceberg/TestSnapshotChanges.java b/core/src/test/java/org/apache/iceberg/TestSnapshotChanges.java new file mode 100644 index 000000000000..337fcedfda39 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestSnapshotChanges.java @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestSnapshotChanges { + @TempDir private File tableDir; + + // Schema passed to create tables + public static final Schema SCHEMA = + new Schema( + required(3, "id", Types.IntegerType.get()), required(4, "data", Types.StringType.get())); + + // Partition spec used to create tables + protected static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).build(); + + public TestTables.TestTable table = null; + + @BeforeEach + public void before() throws Exception { + new File(tableDir, "metadata"); + this.table = TestTables.create(tableDir, "test", SCHEMA, SPEC, 2); + } + + @AfterEach + public void cleanupTables() { + TestTables.clearTables(); + } + + @Test + public void testAddedDataFiles() { + DataFile addedFile = + DataFiles.builder(SPEC) + .withPath("/path/to/test-data.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + + table.newFastAppend().appendFile(addedFile).commit(); + Snapshot snapshotWithAddedFile = table.currentSnapshot(); + + // Test using SnapshotChanges object directly + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshotWithAddedFile).build(); + Iterable filesFromChanges = changes.addedDataFiles(); + assertThat(filesFromChanges).hasSize(1); + + // Verify the file path matches + DataFile resultFile = filesFromChanges.iterator().next(); + assertThat(resultFile.path().toString()).isEqualTo(addedFile.path().toString()); + } + + @Test + public void testRemovedDataFiles() { + DataFile fileToRemove = + DataFiles.builder(SPEC) + .withPath("/path/to/file-to-remove.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + + DataFile fileToKeep = + DataFiles.builder(SPEC) + .withPath("/path/to/file-to-keep.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + + // Add both files + table.newAppend().appendFile(fileToRemove).appendFile(fileToKeep).commit(); + + // Remove one file + table.newDelete().deleteFile(fileToRemove).commit(); + + Snapshot snapshotAfterDelete = table.currentSnapshot(); + + // Test using SnapshotChanges object directly (for caching multiple calls) + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshotAfterDelete).build(); + Iterable filesFromChangesFirstCall = changes.removedDataFiles(); + Iterable filesFromChangesSecondCall = changes.removedDataFiles(); + assertThat(filesFromChangesFirstCall).isSameAs(filesFromChangesSecondCall); + + // Verify the file path matches + DataFile resultFile = filesFromChangesFirstCall.iterator().next(); + assertThat(resultFile.path().toString()).isEqualTo(fileToRemove.path().toString()); + } + + @Test + public void testSnapshotChangesCaching() { + DataFile firstFile = + DataFiles.builder(SPEC) + .withPath("/path/to/file1.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + + DataFile secondFile = + DataFiles.builder(SPEC) + .withPath("/path/to/file2.parquet") + .withFileSizeInBytes(20) + .withRecordCount(2) + .build(); + + table.newAppend().appendFile(firstFile).appendFile(secondFile).commit(); + table.newDelete().deleteFile(firstFile).commit(); + + Snapshot snapshotAfterDelete = table.currentSnapshot(); + + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshotAfterDelete).build(); + + // First call should cache the data file changes + Iterable firstCallResult = changes.removedDataFiles(); + assertThat(firstCallResult).hasSize(1); + + // Second call should return the cached results + Iterable secondCallResult = changes.removedDataFiles(); + assertThat(secondCallResult).hasSize(1); + + // Both calls should return the same reference (cached) + assertThat(firstCallResult).isSameAs(secondCallResult); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestSnapshotManager.java b/core/src/test/java/org/apache/iceberg/TestSnapshotManager.java index 72a1e623a45a..9f612383d5ba 100644 --- a/core/src/test/java/org/apache/iceberg/TestSnapshotManager.java +++ b/core/src/test/java/org/apache/iceberg/TestSnapshotManager.java @@ -244,10 +244,11 @@ public void testCreateBranchOnEmptyTable() { Snapshot snapshot = table.snapshot(branch1SnapshotRef.snapshotId()); assertThat(snapshot.parentId()).isNull(); - assertThat(snapshot.addedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); } @TestTemplate diff --git a/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java b/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java index ad25a30b56e7..dd97738759f4 100644 --- a/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java +++ b/core/src/test/java/org/apache/iceberg/TestSnapshotProducer.java @@ -19,10 +19,13 @@ package org.apache.iceberg; import static org.apache.iceberg.SnapshotSummary.PUBLISHED_WAP_ID_PROP; +import static org.apache.iceberg.avro.AvroTestHelpers.readAvroCodec; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.io.File; import java.io.IOException; +import java.nio.file.Paths; import java.util.List; import java.util.stream.Collectors; import javax.annotation.Nonnull; @@ -158,4 +161,86 @@ public void testCommitValidationWithCustomSummaryProperties() throws IOException // Verify the table wasn't updated assertThat(table.snapshots()).hasSize(1); } + + @TestTemplate + public void manifestNotCleanedUpWhenSnapshotNotLoadableAfterCommit() { + // Uses a custom TableOps that returns stale metadata (without the new snapshot) on the + // first refresh() after commit, simulating eventual consistency. Verifies that commit succeeds + // and that the committed data is visible once the table is refreshed again + String tableName = "stale-table-on-first-refresh"; + TestTables.TestTableOperations ops = opsWithStaleRefreshAfterCommit(tableName, tableDir); + TestTables.TestTable tableWithStaleRefresh = + TestTables.create( + tableDir, tableName, SCHEMA, SPEC, SortOrder.unsorted(), formatVersion, ops); + + // the first refresh() after the commit will return stale metadata (without this snapshot), so + // SnapshotProducer will skip cleanup to avoid accidentally deleting files that are part of the + // committed snapshot but commit still succeeds + tableWithStaleRefresh.newAppend().appendFile(FILE_A).commit(); + + // Refresh again to get the real metadata; the snapshot must be visible now + tableWithStaleRefresh.ops().refresh(); + Snapshot snapshot = tableWithStaleRefresh.currentSnapshot(); + assertThat(snapshot) + .as("Committed snapshot must be visible after refresh (eventual consistency resolved)") + .isNotNull(); + + File metadata = Paths.get(tableDir.getPath(), "metadata").toFile(); + assertThat(snapshot.allManifests(tableWithStaleRefresh.io())) + .isNotEmpty() + .allSatisfy( + manifest -> assertThat(metadata.listFiles()).contains(new File(manifest.path()))); + } + + /** + * Creates a TableOperations that returns stale metadata (without the newly committed snapshot) on + * the first refresh() after a commit. This simulates eventual consistency where the committed + * snapshot is not yet visible. Used to verify that when the snapshot cannot be loaded after + * commit, cleanup is skipped to avoid accidentally deleting files that are part of the committed + * snapshot. + */ + private static TestTables.TestTableOperations opsWithStaleRefreshAfterCommit( + String name, File location) { + return new TestTables.TestTableOperations(name, location) { + private TableMetadata metadataToReturnOnNextRefresh; + + @Override + public void commit(TableMetadata base, TableMetadata updatedMetadata) { + super.commit(base, updatedMetadata); + if (base != null) { + // return stale metadata on the first refresh() call + this.metadataToReturnOnNextRefresh = base; + } + } + + @Override + public TableMetadata refresh() { + if (metadataToReturnOnNextRefresh != null) { + this.current = metadataToReturnOnNextRefresh; + this.metadataToReturnOnNextRefresh = null; + return current; + } + + return super.refresh(); + } + }; + } + + @TestTemplate + public void testDefaultManifestCompression() throws IOException { + table.newFastAppend().appendFile(FILE_A).commit(); + + ManifestFile manifest = table.currentSnapshot().dataManifests(table.io()).get(0); + assertThat(readAvroCodec(new File(manifest.path()))).isEqualTo("deflate"); + } + + @TestTemplate + public void testManifestCompressionFromTableProperty() throws IOException { + table.updateProperties().set(TableProperties.MANIFEST_COMPRESSION, "snappy").commit(); + + table.newFastAppend().appendFile(FILE_A).commit(); + + ManifestFile manifest = table.currentSnapshot().dataManifests(table.io()).get(0); + assertThat(readAvroCodec(new File(manifest.path()))).isEqualTo("snappy"); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestSnapshotSelection.java b/core/src/test/java/org/apache/iceberg/TestSnapshotSelection.java index 765b93574210..7e5daea0207c 100644 --- a/core/src/test/java/org/apache/iceberg/TestSnapshotSelection.java +++ b/core/src/test/java/org/apache/iceberg/TestSnapshotSelection.java @@ -67,7 +67,8 @@ public void testSnapshotStatsForAddedFiles() { table.newFastAppend().appendFile(fileWithStats).commit(); Snapshot snapshot = table.currentSnapshot(); - Iterable addedFiles = snapshot.addedDataFiles(table.io()); + Iterable addedFiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(); assertThat(addedFiles).hasSize(1); DataFile dataFile = Iterables.getOnlyElement(addedFiles); assertThat(dataFile.valueCounts()).isNotNull(); diff --git a/core/src/test/java/org/apache/iceberg/TestSnapshotSummary.java b/core/src/test/java/org/apache/iceberg/TestSnapshotSummary.java index 1eee2d293ec0..61084b4e6749 100644 --- a/core/src/test/java/org/apache/iceberg/TestSnapshotSummary.java +++ b/core/src/test/java/org/apache/iceberg/TestSnapshotSummary.java @@ -101,7 +101,7 @@ public void fastAppendWithDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(11) + .hasSize(14) .containsEntry(SnapshotSummary.ADDED_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "10") .containsEntry(SnapshotSummary.ADDED_RECORDS_PROP, "1") @@ -111,7 +111,10 @@ public void fastAppendWithDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "10") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -126,7 +129,7 @@ public void mergeAppendWithDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(11) + .hasSize(14) .containsEntry(SnapshotSummary.ADDED_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "10") .containsEntry(SnapshotSummary.ADDED_RECORDS_PROP, "1") @@ -136,7 +139,10 @@ public void mergeAppendWithDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "10") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -155,7 +161,7 @@ public void overwriteWithDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(14) + .hasSize(17) .containsEntry(SnapshotSummary.ADDED_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "10") .containsEntry(SnapshotSummary.ADDED_RECORDS_PROP, "1") @@ -168,7 +174,10 @@ public void overwriteWithDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "10") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); } @TestTemplate @@ -187,7 +196,7 @@ public void deleteWithDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(11) + .hasSize(14) .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "2") .containsEntry(SnapshotSummary.DELETED_FILES_PROP, "2") .containsEntry(SnapshotSummary.DELETED_RECORDS_PROP, "2") @@ -197,7 +206,10 @@ public void deleteWithDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "0") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "0"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "0") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); } @TestTemplate @@ -212,7 +224,7 @@ public void replacePartitionsWithDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(12) + .hasSize(15) .containsEntry(SnapshotSummary.ADDED_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "10") .containsEntry(SnapshotSummary.ADDED_RECORDS_PROP, "1") @@ -223,7 +235,10 @@ public void replacePartitionsWithDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "10") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -238,7 +253,7 @@ public void rowDeltaWithDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(11) + .hasSize(14) .containsEntry(SnapshotSummary.ADDED_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "10") .containsEntry(SnapshotSummary.ADDED_RECORDS_PROP, "1") @@ -248,7 +263,10 @@ public void rowDeltaWithDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "10") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -267,7 +285,7 @@ public void rowDeltaWithDeletesAndDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(14) + .hasSize(17) .containsEntry(SnapshotSummary.ADDED_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_DELETE_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "20") // size of data + delete file @@ -280,7 +298,10 @@ public void rowDeltaWithDeletesAndDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "1") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "20") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -300,7 +321,7 @@ public void rewriteWithDuplicateFiles() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(14) + .hasSize(17) .containsEntry(SnapshotSummary.ADDED_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "10") .containsEntry(SnapshotSummary.ADDED_RECORDS_PROP, "1") @@ -313,7 +334,10 @@ public void rewriteWithDuplicateFiles() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "10") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0"); } @TestTemplate @@ -334,7 +358,7 @@ public void rewriteWithDeletesAndDuplicates() { .commit(); assertThat(table.currentSnapshot().summary()) - .hasSize(16) + .hasSize(19) .containsEntry(SnapshotSummary.ADDED_DELETE_FILES_PROP, "1") .containsEntry(SnapshotSummary.ADDED_FILE_SIZE_PROP, "10") .containsEntry(SnapshotSummary.ADD_POS_DELETE_FILES_PROP, "1") @@ -349,7 +373,10 @@ public void rewriteWithDeletesAndDuplicates() { .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_POS_DELETES_PROP, "1") .containsEntry(SnapshotSummary.TOTAL_FILE_SIZE_PROP, "20") - .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1"); + .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "2") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); } @TestTemplate @@ -368,7 +395,7 @@ public void testFileSizeSummaryWithDVs() { long totalPosDeletes1 = dv1.recordCount() + dv2.recordCount(); long totalFileSize1 = dv1.contentSizeInBytes() + dv2.contentSizeInBytes(); assertThat(summary1) - .hasSize(12) + .hasSize(15) .doesNotContainKey(SnapshotSummary.ADD_POS_DELETE_FILES_PROP) .doesNotContainKey(SnapshotSummary.REMOVED_POS_DELETE_FILES_PROP) .containsEntry(SnapshotSummary.ADDED_DELETE_FILES_PROP, "1") @@ -385,7 +412,10 @@ public void testFileSizeSummaryWithDVs() { .containsEntry(SnapshotSummary.TOTAL_DATA_FILES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "0") - .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1"); + .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "1") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); DeleteFile dv3 = newDV(FILE_A); table @@ -404,7 +434,7 @@ public void testFileSizeSummaryWithDVs() { long totalPosDeletes2 = dv3.recordCount(); long totalFileSize2 = dv3.contentSizeInBytes(); assertThat(summary2) - .hasSize(16) + .hasSize(19) .doesNotContainKey(SnapshotSummary.ADD_POS_DELETE_FILES_PROP) .doesNotContainKey(SnapshotSummary.REMOVED_POS_DELETE_FILES_PROP) .containsEntry(SnapshotSummary.ADDED_DELETE_FILES_PROP, "1") @@ -421,7 +451,10 @@ public void testFileSizeSummaryWithDVs() { .containsEntry(SnapshotSummary.TOTAL_DATA_FILES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_EQ_DELETES_PROP, "0") .containsEntry(SnapshotSummary.TOTAL_RECORDS_PROP, "0") - .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "2"); + .containsEntry(SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, "2") + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "3") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "2"); } @TestTemplate diff --git a/api/src/test/java/org/apache/iceberg/stats/TestStatsUtil.java b/core/src/test/java/org/apache/iceberg/TestStatsUtil.java similarity index 52% rename from api/src/test/java/org/apache/iceberg/stats/TestStatsUtil.java rename to core/src/test/java/org/apache/iceberg/TestStatsUtil.java index cf7f2fc3f918..54db9e5d2095 100644 --- a/api/src/test/java/org/apache/iceberg/stats/TestStatsUtil.java +++ b/core/src/test/java/org/apache/iceberg/TestStatsUtil.java @@ -16,14 +16,22 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.stats; +package org.apache.iceberg; +import static org.apache.iceberg.FieldStatistic.AVG_VALUE_SIZE; +import static org.apache.iceberg.FieldStatistic.EXACT_BOUNDS; +import static org.apache.iceberg.FieldStatistic.LOWER_BOUND; +import static org.apache.iceberg.FieldStatistic.MAX_VALUE_SIZE; +import static org.apache.iceberg.FieldStatistic.NAN_VALUE_COUNT; +import static org.apache.iceberg.FieldStatistic.NULL_VALUE_COUNT; +import static org.apache.iceberg.FieldStatistic.UPPER_BOUND; +import static org.apache.iceberg.FieldStatistic.VALUE_COUNT; import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import java.util.List; import java.util.concurrent.ThreadLocalRandom; -import org.apache.iceberg.Schema; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; @@ -139,37 +147,38 @@ public void statsIdsForReservedColumns() { @Test public void contentStatsForSimpleSchema() { - Schema schema = - new Schema( - required(0, "i", Types.IntegerType.get()), - required(2, "f", Types.FloatType.get()), - required(4, "s", Types.StringType.get()), - required(6, "b", Types.BooleanType.get()), - required(1_000_000, "u", Types.UUIDType.get())); + Types.NestedField intField = required(0, "i", Types.IntegerType.get()); + Types.NestedField floatField = required(2, "f", Types.FloatType.get()); + Types.NestedField stringField = required(4, "s", Types.StringType.get()); + Types.NestedField booleanField = required(6, "b", Types.BooleanType.get()); + Types.NestedField uuidField = required(1_000_000, "u", Types.UUIDType.get()); + Schema schema = new Schema(intField, floatField, stringField, booleanField, uuidField); Schema expectedStatsSchema = new Schema( optional( 146, "content_stats", Types.StructType.of( - optional( - 10000, "0", FieldStatistic.fieldStatsFor(Types.IntegerType.get(), 10001)), - optional( - 10400, "2", FieldStatistic.fieldStatsFor(Types.FloatType.get(), 10401)), - optional( - 10800, "4", FieldStatistic.fieldStatsFor(Types.StringType.get(), 10801)), - optional( - 11200, "6", FieldStatistic.fieldStatsFor(Types.BooleanType.get(), 11201)), + optional(10000, "0", FieldStatistic.fieldStatsFor(intField, 10000)), + optional(10400, "2", FieldStatistic.fieldStatsFor(floatField, 10400)), + optional(10800, "4", FieldStatistic.fieldStatsFor(stringField, 10800)), + optional(11200, "6", FieldStatistic.fieldStatsFor(booleanField, 11200)), optional( 200010000, "1000000", - FieldStatistic.fieldStatsFor(Types.UUIDType.get(), 200010001))))); + FieldStatistic.fieldStatsFor(uuidField, 200010000))))); Schema statsSchema = new Schema(StatsUtil.contentStatsFor(schema)); assertThat(statsSchema.asStruct()).isEqualTo(expectedStatsSchema.asStruct()); } @Test public void contentStatsForComplexSchema() { + Types.NestedField listElement = optional(3, "element", Types.IntegerType.get()); + Types.NestedField structInt = optional(7, "int", Types.IntegerType.get()); + Types.NestedField structString = optional(8, "string", Types.StringType.get()); + Types.NestedField mapKey = required(22, "key", Types.IntegerType.get()); + Types.NestedField mapValue = optional(24, "value", Types.StringType.get()); + Types.NestedField uuidField = required(100_000, "u", Types.UUIDType.get()); Schema schema = new Schema( required(0, "i", Types.IntegerType.get()), @@ -185,7 +194,7 @@ public void contentStatsForComplexSchema() { "b", Types.MapType.ofOptional(22, 24, Types.IntegerType.get(), Types.StringType.get())), required(30, "variant", Types.VariantType.get()), - required(100_000, "u", Types.UUIDType.get())); + uuidField); Schema expectedStatsSchema = new Schema( optional( @@ -193,22 +202,132 @@ public void contentStatsForComplexSchema() { "content_stats", Types.StructType.of( optional( - 10000, "0", FieldStatistic.fieldStatsFor(Types.IntegerType.get(), 10001)), - optional( - 10600, "3", FieldStatistic.fieldStatsFor(Types.IntegerType.get(), 10601)), + 10000, + "0", + FieldStatistic.fieldStatsFor( + required(0, "i", Types.IntegerType.get()), 10000)), + optional(10600, "3", FieldStatistic.fieldStatsFor(listElement, 10600)), + optional(11400, "7", FieldStatistic.fieldStatsFor(structInt, 11400)), + optional(11600, "8", FieldStatistic.fieldStatsFor(structString, 11600)), + optional(14400, "22", FieldStatistic.fieldStatsFor(mapKey, 14400)), + optional(14800, "24", FieldStatistic.fieldStatsFor(mapValue, 14800)), optional( - 11400, "7", FieldStatistic.fieldStatsFor(Types.IntegerType.get(), 11401)), - optional( - 11600, "8", FieldStatistic.fieldStatsFor(Types.StringType.get(), 11601)), - optional( - 14400, "22", FieldStatistic.fieldStatsFor(Types.IntegerType.get(), 14401)), - optional( - 14800, "24", FieldStatistic.fieldStatsFor(Types.StringType.get(), 14801)), - optional( - 20010000, - "100000", - FieldStatistic.fieldStatsFor(Types.UUIDType.get(), 20010001))))); + 20010000, "100000", FieldStatistic.fieldStatsFor(uuidField, 20010000))))); Schema statsSchema = new Schema(StatsUtil.contentStatsFor(schema)); assertThat(statsSchema.asStruct()).isEqualTo(expectedStatsSchema.asStruct()); } + + @Test + public void conditionalFieldInclusionForInteger() { + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(required(1, "x", Types.IntegerType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()) + .doesNotContain( + NULL_VALUE_COUNT.fieldName(), + NAN_VALUE_COUNT.fieldName(), + AVG_VALUE_SIZE.fieldName(), + MAX_VALUE_SIZE.fieldName()); + + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(optional(1, "x", Types.IntegerType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + NULL_VALUE_COUNT.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()) + .doesNotContain( + NAN_VALUE_COUNT.fieldName(), AVG_VALUE_SIZE.fieldName(), MAX_VALUE_SIZE.fieldName()); + } + + @Test + public void conditionalFieldInclusionForFloatAndDouble() { + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(required(1, "x", Types.FloatType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + NAN_VALUE_COUNT.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()) + .doesNotContain( + NULL_VALUE_COUNT.fieldName(), AVG_VALUE_SIZE.fieldName(), MAX_VALUE_SIZE.fieldName()); + + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(optional(1, "x", Types.DoubleType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + NULL_VALUE_COUNT.fieldName(), + NAN_VALUE_COUNT.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()); + } + + @Test + public void conditionalFieldInclusionForString() { + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(required(1, "x", Types.StringType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + AVG_VALUE_SIZE.fieldName(), + MAX_VALUE_SIZE.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()) + .doesNotContain(NULL_VALUE_COUNT.fieldName(), NAN_VALUE_COUNT.fieldName()); + + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(optional(1, "x", Types.StringType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + NULL_VALUE_COUNT.fieldName(), + AVG_VALUE_SIZE.fieldName(), + MAX_VALUE_SIZE.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()); + } + + @Test + public void conditionalFieldInclusionForBinary() { + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(optional(1, "x", Types.BinaryType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + NULL_VALUE_COUNT.fieldName(), + AVG_VALUE_SIZE.fieldName(), + MAX_VALUE_SIZE.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()) + .doesNotContain(NAN_VALUE_COUNT.fieldName()); + + assertThat( + fieldStatsNames( + FieldStatistic.fieldStatsFor(required(1, "x", Types.BinaryType.get()), 10000))) + .containsExactly( + VALUE_COUNT.fieldName(), + AVG_VALUE_SIZE.fieldName(), + MAX_VALUE_SIZE.fieldName(), + LOWER_BOUND.fieldName(), + UPPER_BOUND.fieldName(), + EXACT_BOUNDS.fieldName()) + .doesNotContain(NULL_VALUE_COUNT.fieldName(), NAN_VALUE_COUNT.fieldName()); + } + + private List fieldStatsNames(Types.StructType structType) { + return structType.fields().stream().map(Types.NestedField::name).toList(); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestTableMetadata.java b/core/src/test/java/org/apache/iceberg/TestTableMetadata.java index ed7b630f6043..cb1decd2d8dc 100644 --- a/core/src/test/java/org/apache/iceberg/TestTableMetadata.java +++ b/core/src/test/java/org/apache/iceberg/TestTableMetadata.java @@ -153,9 +153,12 @@ public void testJsonConversion() throws Exception { Map refs = ImmutableMap.of( - "main", SnapshotRef.branchBuilder(currentSnapshotId).build(), - "previous", SnapshotRef.tagBuilder(previousSnapshotId).build(), - "test", SnapshotRef.branchBuilder(previousSnapshotId).build()); + SnapshotRef.MAIN_BRANCH, + SnapshotRef.branchBuilder(currentSnapshotId).build(), + "previous", + SnapshotRef.tagBuilder(previousSnapshotId).build(), + "test", + SnapshotRef.branchBuilder(previousSnapshotId).build()); List statisticsFiles = ImmutableList.of( @@ -402,7 +405,8 @@ public void testInvalidMainBranch() throws IOException { Schema schema = new Schema(6, Types.NestedField.required(10, "x", Types.StringType.get())); Map refs = - ImmutableMap.of("main", SnapshotRef.branchBuilder(previousSnapshotId).build()); + ImmutableMap.of( + SnapshotRef.MAIN_BRANCH, SnapshotRef.branchBuilder(previousSnapshotId).build()); assertThatThrownBy( () -> @@ -450,7 +454,7 @@ public void testMainWithoutCurrent() throws IOException { Schema schema = new Schema(6, Types.NestedField.required(10, "x", Types.StringType.get())); Map refs = - ImmutableMap.of("main", SnapshotRef.branchBuilder(snapshotId).build()); + ImmutableMap.of(SnapshotRef.MAIN_BRANCH, SnapshotRef.branchBuilder(snapshotId).build()); assertThatThrownBy( () -> @@ -492,7 +496,7 @@ public void testBranchSnapshotMissing() { Schema schema = new Schema(6, Types.NestedField.required(10, "x", Types.StringType.get())); Map refs = - ImmutableMap.of("main", SnapshotRef.branchBuilder(snapshotId).build()); + ImmutableMap.of(SnapshotRef.MAIN_BRANCH, SnapshotRef.branchBuilder(snapshotId).build()); assertThatThrownBy( () -> @@ -1986,4 +1990,82 @@ public void testMetadataWithRemoveSchemas() { assertThat(meta.changes()).anyMatch(u -> u instanceof MetadataUpdate.RemoveSchemas); } + + @Test + public void testAddSnapshotWithStaleSequenceNumberIsRetryable() { + TableMetadata base = + TableMetadata.newTableMetadata( + TEST_SCHEMA, PartitionSpec.unpartitioned(), "location", ImmutableMap.of()); + + // Advance lastSequenceNumber to 1 by adding a root snapshot + Snapshot s1 = + new BaseSnapshot( + 1, + 1L, + null, + System.currentTimeMillis(), + null, + null, + null, + "file:/s1.avro", + null, + null, + null); + TableMetadata withS1 = TableMetadata.buildFrom(base).addSnapshot(s1).build(); + + // A snapshot with seqNum=1 and non-null parentId is stale (1 is not > lastSequenceNumber=1) + Snapshot staleSnapshot = + new BaseSnapshot( + 1, + 2L, + 1L, + System.currentTimeMillis(), + null, + null, + null, + "file:/s2.avro", + null, + null, + null); + + assertThatThrownBy(() -> TableMetadata.buildFrom(withS1).addSnapshot(staleSnapshot)) + .isInstanceOf(RetryableValidationException.class) + .hasMessageContaining("Cannot add snapshot with sequence number"); + } + + @Test + public void testAddSnapshotWithStaleFirstRowIdIsRetryable() { + TableMetadata base = + TableMetadata.newTableMetadata( + TEST_SCHEMA, + PartitionSpec.unpartitioned(), + "location", + ImmutableMap.of(TableProperties.FORMAT_VERSION, "3")); + + // Advance nextRowId to 5 by adding a snapshot that allocates 5 rows + Snapshot s1 = + new BaseSnapshot( + 1, + 1L, + null, + System.currentTimeMillis(), + null, + null, + null, + "file:/s1.avro", + 0L, + 5L, + null); + TableMetadata withS1 = TableMetadata.buildFrom(base).addSnapshot(s1).build(); + // nextRowId is now 5 + + // A snapshot with firstRowId=0 is stale (0 < nextRowId=5) + Snapshot staleSnapshot = + new BaseSnapshot( + 2, 2L, 1L, System.currentTimeMillis(), null, null, null, "file:/s2.avro", 0L, 1L, null); + + assertThatThrownBy(() -> TableMetadata.buildFrom(withS1).addSnapshot(staleSnapshot)) + .isInstanceOf(RetryableValidationException.class) + .hasMessageContaining("Cannot add a snapshot, first-row-id is behind table next-row-id"); + } } diff --git a/core/src/test/java/org/apache/iceberg/TestTables.java b/core/src/test/java/org/apache/iceberg/TestTables.java index 55232689ad01..13c859f86065 100644 --- a/core/src/test/java/org/apache/iceberg/TestTables.java +++ b/core/src/test/java/org/apache/iceberg/TestTables.java @@ -276,7 +276,7 @@ public static class TestTableOperations implements TableOperations { private final String tableName; private final File metadata; private final FileIO fileIO; - private TableMetadata current = null; + protected TableMetadata current = null; private long lastSnapshotId = 0; private int failCommits = 0; diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFile.java b/core/src/test/java/org/apache/iceberg/TestTrackedFile.java new file mode 100644 index 000000000000..0d850ee4c886 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestTrackedFile.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +public class TestTrackedFile { + + private static final Schema TABLE_SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + + private static final Types.StructType CONTENT_STATS_TYPE = + StatsUtil.contentStatsFor(TABLE_SCHEMA).type().asStructType(); + + private static final Types.StructType PARTITION_TYPE = + PartitionSpec.builderFor(TABLE_SCHEMA).identity("id").build().partitionType(); + + @Test + public void schemaWithContentStatsFieldOrder() { + Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, CONTENT_STATS_TYPE); + List fields = type.fields(); + + assertThat(fields) + .extracting(Types.NestedField::name) + .containsExactly( + "tracking", + "content_type", + "location", + "file_format", + "record_count", + "file_size_in_bytes", + "spec_id", + "partition", + "content_stats", + "sort_order_id", + "deletion_vector", + "manifest_info", + "key_metadata", + "split_offsets", + "equality_ids"); + } + + @Test + public void schemaWithContentStatsFieldIds() { + Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, CONTENT_STATS_TYPE); + List fields = type.fields(); + + assertThat(fields) + .extracting(Types.NestedField::fieldId) + .containsExactly(147, 134, 100, 101, 103, 104, 141, 102, 146, 140, 148, 150, 131, 132, 135); + } + + @Test + public void schemaWithContentStatsUsesProvidedType() { + Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, CONTENT_STATS_TYPE); + Types.NestedField contentStatsField = type.field(TrackedFile.CONTENT_STATS_ID); + Types.NestedField partitionField = type.field(TrackedFile.PARTITION_ID); + + assertThat(contentStatsField.type().asStructType()).isEqualTo(CONTENT_STATS_TYPE); + assertThat(partitionField.type().asStructType()).isEqualTo(PARTITION_TYPE); + } + + @Test + public void schemaWithContentStatsReflectsInput() { + Schema smallSchema = new Schema(optional(1, "id", Types.IntegerType.get())); + Schema largeSchema = + new Schema( + optional(1, "id", Types.IntegerType.get()), + optional(2, "data", Types.StringType.get()), + optional(3, "ts", Types.TimestampType.withoutZone())); + + Types.StructType smallStats = StatsUtil.contentStatsFor(smallSchema).type().asStructType(); + Types.StructType largeStats = StatsUtil.contentStatsFor(largeSchema).type().asStructType(); + + Types.StructType smallType = TrackedFile.schemaWithContentStats(PARTITION_TYPE, smallStats); + Types.StructType largeType = TrackedFile.schemaWithContentStats(PARTITION_TYPE, largeStats); + + Types.StructType smallResult = + smallType.field(TrackedFile.CONTENT_STATS_ID).type().asStructType(); + Types.StructType largeResult = + largeType.field(TrackedFile.CONTENT_STATS_ID).type().asStructType(); + + assertThat(smallResult.fields()).hasSize(1); + assertThat(largeResult.fields()).hasSize(3); + } + + @Test + public void schemaWithContentStatsPartitionIsRequired() { + Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, CONTENT_STATS_TYPE); + Types.NestedField partitionField = type.field(TrackedFile.PARTITION_ID); + + assertThat(partitionField.isRequired()).isTrue(); + assertThat(partitionField.name()).isEqualTo(TrackedFile.PARTITION_NAME); + assertThat(partitionField.doc()).isEqualTo(TrackedFile.PARTITION_DOC); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java b/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java new file mode 100644 index 000000000000..3abb36aa51ff --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java @@ -0,0 +1,435 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Set; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +class TestTrackedFileStruct { + private static final Types.StructType PARTITION_TYPE = + Types.StructType.of( + Types.NestedField.optional(1000, "id_bucket", Types.IntegerType.get()), + Types.NestedField.optional(1001, "category", Types.StringType.get())); + + @Test + void testFieldAccess() { + TrackedFileStruct file = new TrackedFileStruct(); + TrackingStruct tracking = + TrackingStruct.builder().status(EntryStatus.ADDED).snapshotId(42L).build(); + DeletionVectorStruct dv = + DeletionVectorStruct.builder() + .location("s3://bucket/dv.puffin") + .offset(100L) + .sizeInBytes(50L) + .cardinality(5L) + .build(); + ManifestInfoStruct info = + ManifestInfoStruct.builder() + .addedFilesCount(10) + .existingFilesCount(20) + .deletedFilesCount(3) + .replacedFilesCount(2) + .addedRowsCount(1000L) + .existingRowsCount(2000L) + .deletedRowsCount(300L) + .replacedRowsCount(200L) + .minSequenceNumber(5L) + .build(); + + file.set(0, tracking); + file.set(1, FileContent.EQUALITY_DELETES.id()); + file.set(2, "s3://bucket/data/eq-delete.avro"); + file.set(3, "avro"); + file.set(4, 50L); + file.set(5, 512L); + file.set(6, 1); + file.set(9, 5); + file.set(10, dv); + file.set(11, info); + file.set(12, ByteBuffer.wrap(new byte[] {1, 2, 3})); + file.set(13, ImmutableList.of(100L, 200L)); + file.set(14, ImmutableList.of(1, 2, 3)); + + assertThat(file.tracking()).isNotNull(); + assertThat(file.tracking().status()).isEqualTo(EntryStatus.ADDED); + assertThat(file.tracking().snapshotId()).isEqualTo(42L); + assertThat(file.contentType()).isEqualTo(FileContent.EQUALITY_DELETES); + assertThat(file.location()).isEqualTo("s3://bucket/data/eq-delete.avro"); + assertThat(file.fileFormat()).isEqualTo(FileFormat.AVRO); + assertThat(file.recordCount()).isEqualTo(50L); + assertThat(file.fileSizeInBytes()).isEqualTo(512L); + assertThat(file.specId()).isEqualTo(1); + assertThat(file.sortOrderId()).isEqualTo(5); + assertThat(file.deletionVector()).isSameAs(dv); + assertThat(file.manifestInfo()).isSameAs(info); + assertThat(file.keyMetadata()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2, 3})); + assertThat(file.splitOffsets()).containsExactly(100L, 200L); + assertThat(file.equalityIds()).containsExactly(1, 2, 3); + // should return EMPTY_PARTITION_DATA + assertThat(file.partition()).isNotNull(); + assertThat(file.partition().size()).isEqualTo(0); + } + + @Test + void testReaderSideFields() { + TrackedFileStruct file = new TrackedFileStruct(); + + TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build(); + tracking.setManifestLocation("s3://bucket/metadata/manifest.avro"); + tracking.set(8, 7L); + + file.set(0, tracking); + file.set(1, FileContent.DATA.id()); + file.set(2, "test"); + file.set(3, "parquet"); + file.set(4, 0L); + file.set(5, 0L); + + assertThat(file.tracking().manifestLocation()).isEqualTo("s3://bucket/metadata/manifest.avro"); + assertThat(file.tracking().manifestPos()).isEqualTo(7L); + } + + @Test + void projectionWithoutPartition() { + // project only location (field ID 100) and file_size_in_bytes (field ID 104) + Types.StructType projection = + Types.StructType.of(TrackedFile.LOCATION, TrackedFile.FILE_SIZE_IN_BYTES); + + TrackedFileStruct file = new TrackedFileStruct(projection); + assertThat(file.size()).isEqualTo(2); + // should return EMPTY_PARTITION_DATA + assertThat(file.partition()).isNotNull(); + assertThat(file.partition().size()).isEqualTo(0); + } + + @Test + void partitionAccess() { + PartitionData partition = newPartition(5, "books"); + + TrackedFileStruct file = new TrackedFileStruct(); + file.set(7, partition); + + assertThat(file.partition()).isSameAs(partition); + assertThat(file.partition().get(0, Integer.class)).isEqualTo(5); + assertThat(file.partition().get(1, String.class)).isEqualTo("books"); + } + + @Test + void partitionIsCopied() { + PartitionData partition = newPartition(5, "books"); + TrackedFileStruct file = createFullTrackedFile(); + file.set(7, partition); + + TrackedFile copy = file.copy(); + + assertThat(copy.partition()).isNotNull().isNotSameAs(partition); + assertThat(copy.partition()).isEqualTo(partition); + assertThat(copy.partition().get(0, Integer.class)).isEqualTo(5); + assertThat(copy.partition().get(1, String.class)).isEqualTo("books"); + } + + @Test + void testCopy() { + TrackedFileStruct file = createFullTrackedFile(); + + TrackedFile copy = file.copy(); + assertThat(copy).isInstanceOf(TrackedFileStruct.class); + + assertThat(copy.contentType()).isEqualTo(FileContent.DATA); + assertThat(copy.location()).isEqualTo("s3://bucket/data/file.parquet"); + assertThat(copy.fileFormat()).isEqualTo(FileFormat.PARQUET); + assertThat(copy.tracking().status()).isEqualTo(EntryStatus.ADDED); + assertThat(copy.tracking().snapshotId()).isEqualTo(42L); + assertThat(copy.deletionVector().location()).isEqualTo("s3://bucket/dv.puffin"); + assertThat(copy.specId()).isEqualTo(0); + assertThat(copy.sortOrderId()).isEqualTo(1); + assertThat(copy.recordCount()).isEqualTo(100L); + assertThat(copy.fileSizeInBytes()).isEqualTo(1024L); + assertThat(copy.keyMetadata()).isNotNull(); + assertThat(copy.splitOffsets()).containsExactly(50L); + assertThat(copy.equalityIds()).isNull(); + assertThat(copy.tracking().manifestLocation()).isEqualTo("s3://bucket/manifest.avro"); + assertThat(copy.tracking().manifestPos()).isEqualTo(3L); + assertThat(copy.partition()).isEqualTo(newPartition(7, "music")); + } + + @Test + void testCopyWithoutStats() { + TrackedFileStruct file = createTrackedFileWithStats(); + assertThat(file.contentStats()).isNotNull(); + + TrackedFile copy = file.copyWithoutStats(); + + assertThat(copy.contentType()).isEqualTo(FileContent.DATA); + assertThat(copy.location()).isEqualTo("s3://bucket/data/file.parquet"); + assertThat(copy.contentStats()).isNull(); + } + + @Test + void testCopyWithStatsFilters() { + TrackedFileStruct file = createTrackedFileWithStats(); + Set keepFieldIds = ImmutableSet.of(1); + + TrackedFile copy = file.copyWithStats(keepFieldIds); + + assertThat(copy.contentStats()).isNotNull(); + ContentStats stats = copy.contentStats(); + assertThat(stats.fieldStats()).hasSize(1); + assertThat(stats.fieldStats().get(0).fieldId()).isEqualTo(1); + } + + @Test + void testCopyIsDeep() { + TrackedFileStruct file = createFullTrackedFile(); + + TrackedFile copy = file.copy(); + + // keyMetadata should be a deep copy + assertThat(copy.keyMetadata()).isNotSameAs(file.keyMetadata()); + } + + @Test + void testStructLikeSize() { + TrackedFileStruct file = new TrackedFileStruct(); + assertThat(file.size()).isEqualTo(15); + } + + @Test + void testStructLikeGetSet() { + TrackedFileStruct file = new TrackedFileStruct(); + + file.set(1, FileContent.DATA.id()); + assertThat(file.get(1, Integer.class)).isEqualTo(FileContent.DATA.id()); + + file.set(2, "test-location"); + assertThat(file.get(2, String.class)).isEqualTo("test-location"); + + file.set(4, 999L); + assertThat(file.get(4, Long.class)).isEqualTo(999L); + } + + @Test + void testProjectedStructLike() { + // project only location (field ID 100) and file_size_in_bytes (field ID 104) + Types.StructType projection = + Types.StructType.of(TrackedFile.LOCATION, TrackedFile.FILE_SIZE_IN_BYTES); + + TrackedFileStruct file = new TrackedFileStruct(projection); + assertThat(file.size()).isEqualTo(2); + + // projected position 0 maps to internal position 2 (location) + // projected position 1 maps to internal position 5 (file_size_in_bytes) + file.set(0, "s3://bucket/file.parquet"); + file.set(1, 1024L); + + assertThat(file.location()).isEqualTo("s3://bucket/file.parquet"); + assertThat(file.fileSizeInBytes()).isEqualTo(1024L); + assertThat(file.get(0, String.class)).isEqualTo("s3://bucket/file.parquet"); + assertThat(file.get(1, Long.class)).isEqualTo(1024L); + } + + @Test + void testContentStatsReturnedWhenPresent() { + TrackedFileStruct file = createTrackedFileWithStats(); + assertThat(file.contentStats()).isNotNull(); + assertThat(file.contentStats().fieldStats()).hasSize(2); + } + + @Test + void testContentStatsNullWhenNotSet() { + TrackedFileStruct file = new TrackedFileStruct(); + file.set(1, FileContent.DATA.id()); + file.set(2, "test"); + file.set(3, "parquet"); + file.set(4, 0L); + file.set(5, 0L); + file.set(6, 0); + + assertThat(file.contentStats()).isNull(); + } + + @Test + void testAllFileContentTypesSupported() { + for (FileContent content : FileContent.values()) { + TrackedFileStruct file = new TrackedFileStruct(); + file.set(1, content.id()); + assertThat(file.contentType()).isEqualTo(content); + } + } + + @Test + void testJavaSerializationRoundTrip() throws IOException, ClassNotFoundException { + TrackedFileStruct file = createFullTrackedFile(); + + TrackedFileStruct deserialized = TestHelpers.roundTripSerialize(file); + + assertThat(deserialized.contentType()).isEqualTo(FileContent.DATA); + assertThat(deserialized.location()).isEqualTo("s3://bucket/data/file.parquet"); + assertThat(deserialized.fileFormat()).isEqualTo(FileFormat.PARQUET); + assertThat(deserialized.recordCount()).isEqualTo(100L); + assertThat(deserialized.fileSizeInBytes()).isEqualTo(1024L); + assertThat(deserialized.specId()).isEqualTo(0); + assertThat(deserialized.sortOrderId()).isEqualTo(1); + assertThat(deserialized.tracking().status()).isEqualTo(EntryStatus.ADDED); + assertThat(deserialized.tracking().snapshotId()).isEqualTo(42L); + assertThat(deserialized.deletionVector().location()).isEqualTo("s3://bucket/dv.puffin"); + assertThat(deserialized.keyMetadata()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2, 3})); + assertThat(deserialized.splitOffsets()).containsExactly(50L); + assertThat(deserialized.tracking().manifestPos()).isEqualTo(3L); + assertThat(deserialized.tracking().manifestLocation()).isEqualTo("s3://bucket/manifest.avro"); + assertThat(deserialized.partition()).isEqualTo(newPartition(7, "music")); + } + + @Test + void testKryoSerializationRoundTrip() throws IOException { + TrackedFileStruct file = createFullTrackedFile(); + + TrackedFileStruct deserialized = TestHelpers.KryoHelpers.roundTripSerialize(file); + + assertThat(deserialized.contentType()).isEqualTo(FileContent.DATA); + assertThat(deserialized.location()).isEqualTo("s3://bucket/data/file.parquet"); + assertThat(deserialized.fileFormat()).isEqualTo(FileFormat.PARQUET); + assertThat(deserialized.recordCount()).isEqualTo(100L); + assertThat(deserialized.fileSizeInBytes()).isEqualTo(1024L); + assertThat(deserialized.specId()).isEqualTo(0); + assertThat(deserialized.sortOrderId()).isEqualTo(1); + assertThat(deserialized.tracking().status()).isEqualTo(EntryStatus.ADDED); + assertThat(deserialized.tracking().snapshotId()).isEqualTo(42L); + assertThat(deserialized.deletionVector().location()).isEqualTo("s3://bucket/dv.puffin"); + assertThat(deserialized.keyMetadata()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2, 3})); + assertThat(deserialized.splitOffsets()).containsExactly(50L); + assertThat(deserialized.tracking().manifestPos()).isEqualTo(3L); + assertThat(deserialized.tracking().manifestLocation()).isEqualTo("s3://bucket/manifest.avro"); + assertThat(deserialized.partition()).isEqualTo(newPartition(7, "music")); + } + + static TrackedFileStruct createFullTrackedFile() { + TrackingStruct tracking = + TrackingStruct.builder() + .status(EntryStatus.ADDED) + .snapshotId(42L) + .dataSequenceNumber(10L) + .build(); + tracking.setManifestLocation("s3://bucket/manifest.avro"); + tracking.set(8, 3L); + + DeletionVectorStruct dv = + DeletionVectorStruct.builder() + .location("s3://bucket/dv.puffin") + .offset(100L) + .sizeInBytes(50L) + .cardinality(5L) + .build(); + + TrackedFileStruct file = + new TrackedFileStruct( + tracking, + FileContent.DATA, + "s3://bucket/data/file.parquet", + FileFormat.PARQUET, + newPartition(7, "music"), + 100L, + 1024L); + file.set(6, 0); + file.set(9, 1); + file.set(10, dv); + file.set(12, ByteBuffer.wrap(new byte[] {1, 2, 3})); + file.set(13, ImmutableList.of(50L)); + + return file; + } + + private static PartitionData newPartition(int idBucket, String category) { + PartitionData partition = new PartitionData(PARTITION_TYPE); + partition.set(0, idBucket); + partition.set(1, category); + return partition; + } + + static TrackedFileStruct createTrackedFileWithStats() { + Types.StructType statsStruct = + Types.StructType.of( + Types.NestedField.optional( + 10000, + "1", + Types.StructType.of( + Types.NestedField.optional(10001, "value_count", Types.LongType.get()), + Types.NestedField.optional(10002, "null_value_count", Types.LongType.get()), + Types.NestedField.optional(10003, "nan_value_count", Types.LongType.get()), + Types.NestedField.optional(10006, "lower_bound", Types.IntegerType.get()), + Types.NestedField.optional(10007, "upper_bound", Types.IntegerType.get()))), + Types.NestedField.optional( + 20000, + "2", + Types.StructType.of( + Types.NestedField.optional(20001, "value_count", Types.LongType.get()), + Types.NestedField.optional(20002, "null_value_count", Types.LongType.get()), + Types.NestedField.optional(20003, "nan_value_count", Types.LongType.get()), + Types.NestedField.optional(20006, "lower_bound", Types.FloatType.get()), + Types.NestedField.optional(20007, "upper_bound", Types.FloatType.get())))); + + List> fieldStatsList = + ImmutableList.of( + BaseFieldStats.builder() + .fieldId(1) + .type(Types.IntegerType.get()) + .valueCount(100L) + .nullValueCount(5L) + .lowerBound(1) + .upperBound(1000) + .build(), + BaseFieldStats.builder() + .fieldId(2) + .type(Types.FloatType.get()) + .valueCount(200L) + .nullValueCount(10L) + .nanValueCount(3L) + .lowerBound(1.0f) + .upperBound(100.0f) + .build()); + + BaseContentStats stats = + BaseContentStats.builder() + .withStatsStruct(statsStruct) + .withFieldStats(fieldStatsList) + .build(); + + TrackedFileStruct file = + new TrackedFileStruct( + null, + FileContent.DATA, + "s3://bucket/data/file.parquet", + FileFormat.PARQUET, + new PartitionData(Types.StructType.of()), + 100L, + 1024L); + file.set(6, 0); + file.set(8, stats); + + return file; + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java new file mode 100644 index 000000000000..98a7eff2af45 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java @@ -0,0 +1,247 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.ByteBuffer; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class TestTrackingStruct { + + @Test + void testFieldAccess() { + TrackingStruct tracking = new TrackingStruct(Tracking.schema()); + + tracking.set(0, EntryStatus.ADDED.id()); + tracking.set(1, 42L); + tracking.set(2, 10L); + tracking.set(3, 11L); + tracking.set(4, 43L); + tracking.set(5, 1000L); + + assertThat(tracking.status()).isEqualTo(EntryStatus.ADDED); + assertThat(tracking.snapshotId()).isEqualTo(42L); + assertThat(tracking.dataSequenceNumber()).isEqualTo(10L); + assertThat(tracking.fileSequenceNumber()).isEqualTo(11L); + assertThat(tracking.dvSnapshotId()).isEqualTo(43L); + assertThat(tracking.firstRowId()).isEqualTo(1000L); + assertThat(tracking.deletedPositions()).isNull(); + assertThat(tracking.replacedPositions()).isNull(); + } + + @Test + void testCopy() { + TrackingStruct tracking = + TrackingStruct.builder() + .status(EntryStatus.ADDED) + .snapshotId(42L) + .dataSequenceNumber(10L) + .deletedPositions(new byte[] {1, 2}) + .build(); + + TrackingStruct copy = tracking.copy(); + + assertThat(copy.status()).isEqualTo(EntryStatus.ADDED); + assertThat(copy.snapshotId()).isEqualTo(42L); + assertThat(copy.dataSequenceNumber()).isEqualTo(10L); + assertThat(copy.deletedPositions()).isNotNull(); + + // verify deep copy of ByteBuffer + assertThat(copy.deletedPositions()).isNotSameAs(tracking.deletedPositions()); + } + + @ParameterizedTest + @EnumSource(EntryStatus.class) + void testAllStatuses(EntryStatus status) { + TrackingStruct tracking = new TrackingStruct(Tracking.schema()); + tracking.set(0, status.id()); + assertThat(tracking.status()).isEqualTo(status); + } + + @Test + void testIsLive() { + TrackingStruct tracking = new TrackingStruct(Tracking.schema()); + + tracking.set(0, EntryStatus.ADDED.id()); + assertThat(tracking.isLive()).isTrue(); + + tracking.set(0, EntryStatus.EXISTING.id()); + assertThat(tracking.isLive()).isTrue(); + + tracking.set(0, EntryStatus.DELETED.id()); + assertThat(tracking.isLive()).isFalse(); + + tracking.set(0, EntryStatus.REPLACED.id()); + assertThat(tracking.isLive()).isFalse(); + } + + @Test + void testInheritSnapshotId() { + TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build(); + tracking.inheritFrom(createManifestTracking(100L, 60L)); + + // snapshotId is null, should inherit from manifest + assertThat(tracking.snapshotId()).isEqualTo(100L); + } + + @Test + void testInheritSequenceNumberForAddedEntries() { + TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build(); + tracking.inheritFrom(createManifestTracking(100L, 60L)); + + // sequence numbers are null and status is ADDED, should inherit + assertThat(tracking.dataSequenceNumber()).isEqualTo(60L); + assertThat(tracking.fileSequenceNumber()).isEqualTo(60L); + } + + @Test + void testDoNotInheritSequenceNumberForExistingEntries() { + TrackingStruct tracking = + TrackingStruct.builder() + .status(EntryStatus.EXISTING) + .dataSequenceNumber(5L) + .fileSequenceNumber(6L) + .build(); + tracking.inheritFrom(createManifestTracking(100L, 60L)); + + // sequence numbers are not inherited for EXISTING entries + assertThat(tracking.dataSequenceNumber()).isEqualTo(5L); + assertThat(tracking.fileSequenceNumber()).isEqualTo(6L); + } + + @Test + void testExplicitValuesOverrideInheritance() { + TrackingStruct tracking = + TrackingStruct.builder() + .status(EntryStatus.ADDED) + .snapshotId(200L) + .dataSequenceNumber(75L) + .fileSequenceNumber(76L) + .build(); + tracking.inheritFrom(createManifestTracking(100L, 60L)); + + // explicit values should take precedence + assertThat(tracking.snapshotId()).isEqualTo(200L); + assertThat(tracking.dataSequenceNumber()).isEqualTo(75L); + assertThat(tracking.fileSequenceNumber()).isEqualTo(76L); + } + + @Test + void testInheritFromRejectsUnequalSequenceNumbers() { + TrackingStruct tracking = new TrackingStruct(Tracking.schema()); + tracking.set(0, EntryStatus.ADDED.id()); + + TrackingStruct manifestTracking = new TrackingStruct(Tracking.schema()); + manifestTracking.set(0, EntryStatus.ADDED.id()); + manifestTracking.set(1, 100L); + manifestTracking.set(2, 50L); + manifestTracking.set(3, 60L); + + assertThatThrownBy(() -> tracking.inheritFrom(manifestTracking)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Manifest data and file sequence numbers must be equal, got 50 and 60"); + } + + @Test + void testNoDefaultingWithoutInheritance() { + TrackingStruct tracking = TrackingStruct.builder().status(EntryStatus.ADDED).build(); + + // no inheritance, nulls stay null + assertThat(tracking.snapshotId()).isNull(); + assertThat(tracking.dataSequenceNumber()).isNull(); + assertThat(tracking.fileSequenceNumber()).isNull(); + } + + private static Tracking createManifestTracking(long snapshotId, long sequenceNumber) { + return TrackingStruct.builder() + .status(EntryStatus.ADDED) + .snapshotId(snapshotId) + .dataSequenceNumber(sequenceNumber) + .fileSequenceNumber(sequenceNumber) + .build(); + } + + @Test + void testBuilderValidation() { + assertThatThrownBy(() -> TrackingStruct.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid status: null"); + } + + @Test + void testProjectedStructLike() { + // project only snapshot_id (field ID 1) and first_row_id (field ID 142) + Types.StructType projection = Types.StructType.of(Tracking.SNAPSHOT_ID, Tracking.FIRST_ROW_ID); + + TrackingStruct tracking = new TrackingStruct(projection); + assertThat(tracking.size()).isEqualTo(2); + + // projected position 0 maps to internal position 1 (snapshot_id) + // projected position 1 maps to internal position 5 (first_row_id) + tracking.set(0, 42L); + tracking.set(1, 1000L); + + assertThat(tracking.snapshotId()).isEqualTo(42L); + assertThat(tracking.firstRowId()).isEqualTo(1000L); + assertThat(tracking.get(0, Long.class)).isEqualTo(42L); + assertThat(tracking.get(1, Long.class)).isEqualTo(1000L); + } + + @Test + void testJavaSerializationRoundTrip() throws IOException, ClassNotFoundException { + TrackingStruct tracking = + TrackingStruct.builder() + .status(EntryStatus.ADDED) + .snapshotId(42L) + .dataSequenceNumber(10L) + .deletedPositions(new byte[] {1, 2}) + .build(); + + TrackingStruct deserialized = TestHelpers.roundTripSerialize(tracking); + + assertThat(deserialized.status()).isEqualTo(EntryStatus.ADDED); + assertThat(deserialized.snapshotId()).isEqualTo(42L); + assertThat(deserialized.dataSequenceNumber()).isEqualTo(10L); + assertThat(deserialized.deletedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2})); + } + + @Test + void testKryoSerializationRoundTrip() throws IOException { + TrackingStruct tracking = + TrackingStruct.builder() + .status(EntryStatus.ADDED) + .snapshotId(42L) + .dataSequenceNumber(10L) + .deletedPositions(new byte[] {1, 2}) + .build(); + + TrackingStruct deserialized = TestHelpers.KryoHelpers.roundTripSerialize(tracking); + + assertThat(deserialized.status()).isEqualTo(EntryStatus.ADDED); + assertThat(deserialized.snapshotId()).isEqualTo(42L); + assertThat(deserialized.dataSequenceNumber()).isEqualTo(10L); + assertThat(deserialized.deletedPositions()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2})); + } +} diff --git a/core/src/test/java/org/apache/iceberg/TestTransaction.java b/core/src/test/java/org/apache/iceberg/TestTransaction.java index 7715c045bd9e..9ec8c47840d9 100644 --- a/core/src/test/java/org/apache/iceberg/TestTransaction.java +++ b/core/src/test/java/org/apache/iceberg/TestTransaction.java @@ -170,6 +170,18 @@ public void testMultipleOperationTransactionFromTable() { ids(appendSnapshot.snapshotId(), appendSnapshot.snapshotId()), files(FILE_A, FILE_B), statuses(Status.ADDED, Status.ADDED)); + + // validate snapshot summaries for manifest metrics + assertThat(appendSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "0"); + + // delete rewrites the append manifest + assertThat(deleteSnapshot.summary()) + .containsEntry(SnapshotSummary.CREATED_MANIFESTS_COUNT, "1") + .containsEntry(SnapshotSummary.KEPT_MANIFESTS_COUNT, "0") + .containsEntry(SnapshotSummary.REPLACED_MANIFESTS_COUNT, "1"); } @TestTemplate @@ -765,7 +777,7 @@ public void testCommitProperties() { @TestTemplate public void testRowDeltaWithConcurrentManifestRewrite() throws IOException { assumeThat(formatVersion).isEqualTo(2); - String branch = "main"; + String branch = SnapshotRef.MAIN_BRANCH; RowDelta rowDelta = table.newRowDelta().addRows(FILE_A).addDeletes(FILE_A_DELETES); Snapshot first = commit(table, rowDelta, branch); @@ -829,7 +841,7 @@ public void testRowDeltaWithConcurrentManifestRewrite() throws IOException { @TestTemplate public void testOverwriteWithConcurrentManifestRewrite() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(2); - String branch = "main"; + String branch = SnapshotRef.MAIN_BRANCH; OverwriteFiles overwrite = table.newOverwrite().addFile(FILE_A).addFile(FILE_A2); Snapshot first = commit(table, overwrite, branch); @@ -917,8 +929,12 @@ public void testExtendBaseTransaction() { txn.commitTransaction(); assertThat(version()).isEqualTo(2); - assertThat(readMetadata().refs()).hasSize(2).containsKey("main").containsKey("branch"); - assertThat(readMetadata().ref("main").snapshotId()).isEqualTo(mainSnapshot.snapshotId()); + assertThat(readMetadata().refs()) + .hasSize(2) + .containsKey(SnapshotRef.MAIN_BRANCH) + .containsKey("branch"); + assertThat(readMetadata().ref(SnapshotRef.MAIN_BRANCH).snapshotId()) + .isEqualTo(mainSnapshot.snapshotId()); assertThat(readMetadata().snapshot(mainSnapshot.snapshotId()).allManifests(table.io())) .hasSize(1); assertThat(readMetadata().ref("branch").snapshotId()).isEqualTo(branchSnapshot.snapshotId()); diff --git a/core/src/test/java/org/apache/iceberg/TestWapWorkflow.java b/core/src/test/java/org/apache/iceberg/TestWapWorkflow.java index dbb856fb2f8a..d46a687fd92d 100644 --- a/core/src/test/java/org/apache/iceberg/TestWapWorkflow.java +++ b/core/src/test/java/org/apache/iceberg/TestWapWorkflow.java @@ -113,7 +113,12 @@ public void testCurrentSnapshotOperation() { assertThat(base.currentSnapshot().snapshotId()).isEqualTo(wapSnapshot.snapshotId()); assertThat(base.snapshots()).hasSize(2); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(2); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") .hasSize(2); @@ -136,7 +141,12 @@ public void testSetCurrentSnapshotNoWAP() { assertThat(base.currentSnapshot().snapshotId()).isEqualTo(firstSnapshotId); assertThat(base.snapshots()).hasSize(2); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(1); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") .hasSize(3); @@ -172,7 +182,12 @@ public void testRollbackOnInvalidNonAncestor() { assertThat(base.currentSnapshot().snapshotId()).isEqualTo(firstSnapshotId); assertThat(base.snapshots()).hasSize(2); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(1); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") .hasSize(1); @@ -269,7 +284,12 @@ public void testWithCherryPicking() { assertThat(base.currentSnapshot().snapshotId()).isEqualTo(wapSnapshot.snapshotId()); assertThat(base.snapshots()).hasSize(2); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(2); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") .hasSize(2); @@ -315,7 +335,12 @@ public void testWithTwoPhaseCherryPicking() { // as a result of the cherry-pick operation assertThat(base.currentSnapshot().snapshotId()).isEqualTo(parentSnapshot.snapshotId() + 1); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(2); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.currentSnapshot().parentId()) .as("Parent snapshot id should change to latest snapshot before commit") .isEqualTo(parentSnapshot.snapshotId()); @@ -334,7 +359,12 @@ public void testWithTwoPhaseCherryPicking() { assertThat(base.currentSnapshot().snapshotId()) .isEqualTo(parentSnapshot.snapshotId() + 1 /* one fast-forwarded snapshot */ + 1); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(3); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.currentSnapshot().parentId()) .as("Parent snapshot id should change to latest snapshot before commit") .isEqualTo(parentSnapshot.snapshotId()); @@ -397,7 +427,12 @@ public void testWithCommitsBetweenCherryPicking() { assertThat(base.snapshots()).hasSize(5); assertThat(base.currentSnapshot().snapshotId()).isEqualTo(parentSnapshot.snapshotId() + 1); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(3); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.currentSnapshot().parentId()).isEqualTo(parentSnapshot.snapshotId()); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") @@ -414,7 +449,12 @@ public void testWithCommitsBetweenCherryPicking() { assertThat(base.snapshots()).hasSize(6); assertThat(base.currentSnapshot().snapshotId()).isEqualTo(parentSnapshot.snapshotId() + 1); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(4); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.currentSnapshot().parentId()).isEqualTo(parentSnapshot.snapshotId()); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") @@ -458,7 +498,12 @@ public void testWithCherryPickingWithCommitRetry() { // as a result of the cherry-pick operation assertThat(base.currentSnapshot().snapshotId()).isEqualTo(parentSnapshot.snapshotId() + 1); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(2); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.currentSnapshot().parentId()).isEqualTo(parentSnapshot.snapshotId()); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") @@ -496,7 +541,12 @@ public void testCherrypickingAncestor() { assertThat(base.currentSnapshot().snapshotId()).isEqualTo(wapPublishedId); assertThat(base.snapshots()).hasSize(2); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(2); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") .hasSize(2); @@ -538,7 +588,12 @@ public void testDuplicateCherrypick() { assertThat(base.snapshots()).hasSize(3); assertThat(base.currentSnapshot().allManifests(table.io())).hasSize(2); - assertThat(base.currentSnapshot().addedDataFiles(table.io())).hasSize(1); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(base.currentSnapshot()) + .build() + .addedDataFiles()) + .hasSize(1); assertThat(base.snapshotLog()) .as("Snapshot log should indicate number of snapshots committed") .hasSize(2); diff --git a/core/src/test/java/org/apache/iceberg/actions/TestBinPackRewriteFilePlanner.java b/core/src/test/java/org/apache/iceberg/actions/TestBinPackRewriteFilePlanner.java index 64209d778117..aa65140c0b89 100644 --- a/core/src/test/java/org/apache/iceberg/actions/TestBinPackRewriteFilePlanner.java +++ b/core/src/test/java/org/apache/iceberg/actions/TestBinPackRewriteFilePlanner.java @@ -522,6 +522,41 @@ public void testRewriteMaxFilesOption() { assertThat(fileScanTasks).isEqualTo(5); } + @Test + public void testMaxFilesToRewriteUsesCorrectInputSizeForExpectedOutputFiles() { + // Create files with known sizes where truncation matters for expectedOutputFiles + DataFile largeFile1 = newDataFile("data_bucket=0", 200); + DataFile largeFile2 = newDataFile("data_bucket=0", 200); + DataFile largeFile3 = newDataFile("data_bucket=0", 200); + table.newAppend().appendFile(largeFile1).appendFile(largeFile2).appendFile(largeFile3).commit(); + + BinPackRewriteFilePlanner planner = new BinPackRewriteFilePlanner(table); + // target=250 means: + // Full group (600 bytes): expectedOutputFiles = ceil(600/250) = 3 + // Truncated to 1 file (200 bytes): expectedOutputFiles = ceil(200/250) = 1 + Map options = + ImmutableMap.of( + BinPackRewriteFilePlanner.MAX_FILES_TO_REWRITE, "1", + BinPackRewriteFilePlanner.REWRITE_ALL, "true", + BinPackRewriteFilePlanner.TARGET_FILE_SIZE_BYTES, "250"); + planner.init(options); + + FileRewritePlan plan = planner.plan(); + List groups = Lists.newArrayList(plan.groups().iterator()); + + assertThat(groups).hasSize(1); + RewriteFileGroup group = groups.get(0); + // Only 1 file should be in the group due to max-files-to-rewrite=1 + assertThat(group.inputFileNum()).isEqualTo(1); + // expectedOutputFiles should be based on the truncated input (200 bytes), not full group (600) + // ceil(200/250) = 1, NOT ceil(600/250) = 3 + assertThat(group.expectedOutputFiles()) + .as( + "expectedOutputFiles should be computed from actual files being rewritten (200 bytes)," + + " not the full group (600 bytes)") + .isEqualTo(1); + } + @Test public void testRewriteMaxFilesRewriteGreaterThanTotalFiles() { addFiles(); diff --git a/core/src/test/java/org/apache/iceberg/avro/AvroTestHelpers.java b/core/src/test/java/org/apache/iceberg/avro/AvroTestHelpers.java index 56efc4cd3e72..fd73706ce082 100644 --- a/core/src/test/java/org/apache/iceberg/avro/AvroTestHelpers.java +++ b/core/src/test/java/org/apache/iceberg/avro/AvroTestHelpers.java @@ -21,18 +21,23 @@ import static org.apache.iceberg.avro.AvroSchemaUtil.toOption; import static org.assertj.core.api.Assertions.assertThat; +import java.io.File; +import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.Map; import org.apache.avro.JsonProperties; import org.apache.avro.Schema; +import org.apache.avro.file.DataFileReader; +import org.apache.avro.file.SeekableFileInput; import org.apache.avro.generic.GenericData.Record; +import org.apache.avro.generic.GenericDatumReader; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.iceberg.variants.Variant; import org.apache.iceberg.variants.VariantTestUtil; -class AvroTestHelpers { +public class AvroTestHelpers { private AvroTestHelpers() {} @@ -164,4 +169,20 @@ private static void assertEquals(Type type, Object expected, Object actual) { throw new IllegalArgumentException("Not a supported type: " + type); } } + + /** Reads the {@code avro.codec} metadata value from an Avro data file. */ + public static String readAvroCodec(File file) throws IOException { + try (DataFileReader reader = + new DataFileReader<>(new SeekableFileInput(file), new GenericDatumReader<>())) { + return reader.getMetaString("avro.codec"); + } + } + + public static boolean hasIds(Schema schema) { + return AvroSchemaUtil.hasIds(schema); + } + + public static Schema removeIds(org.apache.iceberg.Schema schema) { + return RemoveIds.removeIds(schema); + } } diff --git a/core/src/test/java/org/apache/iceberg/avro/TestAvroPartitionStatisticsScan.java b/core/src/test/java/org/apache/iceberg/avro/TestAvroPartitionStatisticsScan.java new file mode 100644 index 000000000000..54e03180cedb --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/avro/TestAvroPartitionStatisticsScan.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.avro; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionStatisticsScanTestBase; + +public class TestAvroPartitionStatisticsScan extends PartitionStatisticsScanTestBase { + + public FileFormat format() { + return FileFormat.AVRO; + } +} diff --git a/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java b/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java index c2fd24856fb2..6c6949ce42ae 100644 --- a/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java +++ b/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java @@ -24,8 +24,14 @@ import static org.assertj.core.api.Assertions.setMaxStackTraceElementsDisplayed; import static org.assertj.core.api.Assumptions.assumeThat; +import java.io.Closeable; import java.io.IOException; import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -38,6 +44,7 @@ import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.DataFile; import org.apache.iceberg.DataFiles; +import org.apache.iceberg.ExpireSnapshots; import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.FilesTable; @@ -62,9 +69,12 @@ import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.exceptions.NotFoundException; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.expressions.Literal; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.metrics.CommitReport; import org.apache.iceberg.metrics.MetricsReport; import org.apache.iceberg.metrics.MetricsReporter; @@ -80,6 +90,7 @@ import org.apache.iceberg.types.Types; import org.apache.iceberg.util.CharSequenceSet; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -421,6 +432,44 @@ public void testDropNonEmptyNamespace() { assertThat(catalog.namespaceExists(NS)).as("Namespace should not exist").isFalse(); } + @Test + public void testDropNamespaceWithNestedNamespace() { + assumeThat(supportsNestedNamespaces()) + .as("Only valid when the catalog supports nested namespaces") + .isTrue(); + + C catalog = catalog(); + + Namespace parent = Namespace.of("parent"); + Namespace nested = Namespace.of("parent", "child"); + + assertThat(catalog.namespaceExists(parent)).as("Parent namespace should not exist").isFalse(); + assertThat(catalog.namespaceExists(nested)).as("Nested namespace should not exist").isFalse(); + + catalog.createNamespace(parent); + catalog.createNamespace(nested); + + assertThat(catalog.namespaceExists(parent)).as("Parent namespace should exist").isTrue(); + assertThat(catalog.namespaceExists(nested)).as("Nested namespace should exist").isTrue(); + + assertThatThrownBy(() -> catalog.dropNamespace(parent)) + .isInstanceOf(NamespaceNotEmptyException.class) + .hasMessageContaining("is not empty"); + + assertThat(catalog.namespaceExists(parent)).as("Parent namespace should still exist").isTrue(); + assertThat(catalog.namespaceExists(nested)).as("Nested namespace should still exist").isTrue(); + + assertThat(catalog.dropNamespace(nested)) + .as("Dropping an existing nested namespace should return true") + .isTrue(); + assertThat(catalog.namespaceExists(nested)).as("Nested namespace should not exist").isFalse(); + + assertThat(catalog.dropNamespace(parent)) + .as("Dropping an existing namespace should return true") + .isTrue(); + assertThat(catalog.namespaceExists(parent)).as("Parent namespace should not exist").isFalse(); + } + @Test public void testListNamespaces() { C catalog = catalog(); @@ -959,6 +1008,37 @@ public void testLoadMissingTable() { .hasMessageStartingWith("Table does not exist: ns.tbl"); } + @Test + public void testLoadTableWithMissingMetadataFile(@TempDir Path tempDir) throws IOException { + C catalog = catalog(); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(TBL.namespace()); + } + + catalog.buildTable(TBL, SCHEMA).create(); + assertThat(catalog.tableExists(TBL)).as("Table should exist").isTrue(); + + Table table = catalog.loadTable(TBL); + String metadataFileLocation = + ((HasTableOperations) table).operations().current().metadataFileLocation(); + Path renamedMetadataFile = tempDir.resolve("tmp.json"); + renamedMetadataFile.toFile().deleteOnExit(); + Files.writeString(renamedMetadataFile, "metadata"); + Path metadataFilePath = + metadataFileLocation.startsWith("file:") + ? Paths.get(URI.create(metadataFileLocation)) + : Paths.get(metadataFileLocation); + try { + Files.move(metadataFilePath, renamedMetadataFile, StandardCopyOption.REPLACE_EXISTING); + assertThatThrownBy(() -> catalog.loadTable(TBL)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("Failed to open input stream for file: " + metadataFileLocation); + } finally { + Files.move(renamedMetadataFile, metadataFilePath, StandardCopyOption.REPLACE_EXISTING); + } + } + @Test public void testRenameTable() { C catalog = catalog(); @@ -986,6 +1066,86 @@ public void testRenameTable() { assertEmpty("Should not contain table after drop", catalog, NS); } + @Test + public void createTableInUniqueLocation() { + Map additionalProperties = + ImmutableMap.of(CatalogProperties.UNIQUE_TABLE_LOCATION, "true"); + C catalog = initCatalog("uniq_path_catalog", additionalProperties); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(NS); + } + + catalog.createTable(TABLE, SCHEMA, PartitionSpec.unpartitioned()); + catalog.renameTable(TABLE, RENAMED_TABLE); + catalog.createTable(TABLE, SCHEMA, PartitionSpec.unpartitioned()); + + Table table = catalog.loadTable(TABLE); + Table renamedTable = catalog.loadTable(RENAMED_TABLE); + + assertThat(table.location()) + .as("Tables %s and %s have different location", TABLE, RENAMED_TABLE) + .isNotEqualTo(renamedTable.location()); + } + + @Test + public void dropAfterRenameDoesntCorruptTable() throws IOException { + C catalog = catalog(); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(TABLE.namespace()); + } + + PartitionSpec spec = PartitionSpec.unpartitioned(); + + Table initialTable = catalog.createTable(TABLE, SCHEMA, spec); + String initialFilePath = initialTable.locationProvider().newDataLocation("data-a.parquet"); + DataFile dataFile = + DataFiles.builder(spec) + .withPath(initialFilePath) + .withFileSizeInBytes(10) + .withRecordCount(2) + .build(); + initialTable.io().newOutputFile(initialFilePath).create().close(); + initialTable.newAppend().appendFile(dataFile).commit(); + + catalog.renameTable(TABLE, RENAMED_TABLE); + + Table newTable = catalog.createTable(TABLE, SCHEMA, spec); + String newFilePath = newTable.locationProvider().newDataLocation("data-b.parquet"); + DataFile anotherFile = + DataFiles.builder(spec) + .withPath(newFilePath) + .withFileSizeInBytes(10) + .withRecordCount(2) + .build(); + newTable.io().newOutputFile(newFilePath).create().close(); + newTable.newAppend().appendFile(anotherFile).commit(); + + catalog.dropTable(RENAMED_TABLE, true); + + assertThat(catalog.tableExists(RENAMED_TABLE)) + .as("After PURGE, %s must not exist", RENAMED_TABLE) + .isFalse(); + assertThat(catalog.tableExists(TABLE)) + .as( + "After dropping the renamed table with PURGE, the recreated table with the original name (%s) must exist", + TABLE) + .isTrue(); + + Table table = catalog.loadTable(TABLE); + FileIO io = table.io(); + try (CloseableIterable tasks = table.newScan().planFiles()) { + tasks.forEach( + task -> { + InputFile file = io.newInputFile(task.file().location()); + assertThat(file.exists()) + .as("Table %s should remain unaffected by dropping %s", TABLE, RENAMED_TABLE) + .isTrue(); + }); + } + } + @Test public void testRenameTableMissingSourceTable() { C catalog = catalog(); @@ -1650,7 +1810,7 @@ public void testRemoveUnusedSpec(boolean withBranch) { table.newAppend().appendFile(anotherFile).commit(); table .expireSnapshots() - .cleanExpiredFiles(false) + .cleanupLevel(ExpireSnapshots.CleanupLevel.NONE) .expireOlderThan(table.currentSnapshot().timestampMillis()) .cleanExpiredMetadata(true) .commit(); @@ -3232,11 +3392,18 @@ public void testCatalogWithCustomMetricsReporter() throws IOException { assertThat(CustomMetricsReporter.SCAN_COUNTER.get()).isEqualTo(1); // reset counter in case subclasses run this test multiple times CustomMetricsReporter.SCAN_COUNTER.set(0); + + CustomMetricsReporter.CLOSE_COUNTER.set(0); + ((Closeable) catalogWithCustomReporter).close(); + assertThat(CustomMetricsReporter.CLOSE_COUNTER.get()) + .as("Catalog.close() must propagate to the configured MetricsReporter") + .isEqualTo(1); } public static class CustomMetricsReporter implements MetricsReporter { static final AtomicInteger SCAN_COUNTER = new AtomicInteger(0); static final AtomicInteger COMMIT_COUNTER = new AtomicInteger(0); + static final AtomicInteger CLOSE_COUNTER = new AtomicInteger(0); @Override public void report(MetricsReport report) { @@ -3246,6 +3413,11 @@ public void report(MetricsReport report) { COMMIT_COUNTER.incrementAndGet(); } } + + @Override + public void close() { + CLOSE_COUNTER.incrementAndGet(); + } } private static void assertEmpty(String context, Catalog catalog, Namespace ns) { diff --git a/data/src/test/java/org/apache/iceberg/data/DataTestHelpers.java b/core/src/test/java/org/apache/iceberg/data/DataTestHelpers.java similarity index 95% rename from data/src/test/java/org/apache/iceberg/data/DataTestHelpers.java rename to core/src/test/java/org/apache/iceberg/data/DataTestHelpers.java index fc8d47680b0f..f2e2b4e7fa34 100644 --- a/data/src/test/java/org/apache/iceberg/data/DataTestHelpers.java +++ b/core/src/test/java/org/apache/iceberg/data/DataTestHelpers.java @@ -31,6 +31,14 @@ public class DataTestHelpers { private DataTestHelpers() {} + public static void assertEquals( + Types.StructType struct, List expected, List actual) { + assertThat(actual).hasSize(expected.size()); + for (int i = 0; i < expected.size(); i += 1) { + assertEquals(struct, expected.get(i), actual.get(i)); + } + } + public static void assertEquals(Types.StructType struct, Record expected, Record actual) { assertEquals(struct, expected, actual, null, -1); } diff --git a/core/src/test/java/org/apache/iceberg/data/TestGenericDataUtil.java b/core/src/test/java/org/apache/iceberg/data/TestGenericDataUtil.java new file mode 100644 index 000000000000..2b0da119701e --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/data/TestGenericDataUtil.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DateTimeUtil; +import org.junit.jupiter.api.Test; + +public class TestGenericDataUtil { + + @Test + public void testDateConversion() { + int days = DateTimeUtil.daysFromDate(LocalDate.of(2025, 1, 15)); + + Object result = GenericDataUtil.internalToGeneric(Types.DateType.get(), days); + + assertThat(result).isInstanceOf(LocalDate.class).isEqualTo(LocalDate.of(2025, 1, 15)); + } + + @Test + public void testTimeConversion() { + long micros = DateTimeUtil.microsFromTime(LocalTime.of(11, 25, 20, 111_456_000)); + + Object result = GenericDataUtil.internalToGeneric(Types.TimeType.get(), micros); + + assertThat(result) + .isInstanceOf(LocalTime.class) + .isEqualTo(LocalTime.of(11, 25, 20, 111_456_000)); + } + + @Test + public void testTimestampConversion() { + long micros = DateTimeUtil.isoTimestampToMicros("2025-01-15T11:25:20.111456"); + + Object withoutZone = + GenericDataUtil.internalToGeneric(Types.TimestampType.withoutZone(), micros); + + Object withZone = GenericDataUtil.internalToGeneric(Types.TimestampType.withZone(), micros); + + assertThat(withoutZone) + .as("TIMESTAMP without zone should materialize as LocalDateTime") + .isInstanceOf(LocalDateTime.class) + .isEqualTo(LocalDateTime.of(2025, 1, 15, 11, 25, 20, 111_456_000)); + + assertThat(withZone) + .as("TIMESTAMP with zone should materialize as OffsetDateTime") + .isInstanceOf(OffsetDateTime.class) + .isEqualTo(OffsetDateTime.parse("2025-01-15T11:25:20.111456Z")); + } + + @Test + public void testTimestampNanoConversion() { + long nanos = DateTimeUtil.isoTimestampToNanos("2025-01-15T11:25:20.111456789"); + + Object withoutZone = + GenericDataUtil.internalToGeneric(Types.TimestampNanoType.withoutZone(), nanos); + + Object withZone = GenericDataUtil.internalToGeneric(Types.TimestampNanoType.withZone(), nanos); + + assertThat(withoutZone) + .as("TIMESTAMP_NANO without zone should materialize as LocalDateTime") + .isInstanceOf(LocalDateTime.class) + .isEqualTo(LocalDateTime.of(2025, 1, 15, 11, 25, 20, 111_456_789)); + + assertThat(withZone) + .as("TIMESTAMP_NANO with zone should materialize as OffsetDateTime") + .isInstanceOf(OffsetDateTime.class) + .isEqualTo(OffsetDateTime.parse("2025-01-15T11:25:20.111456789Z")); + } + + @Test + public void testFixedConversion() { + byte[] bytes = new byte[] {1, 2, 3, 4}; + ByteBuffer buffer = ByteBuffer.wrap(bytes); + + Object result = GenericDataUtil.internalToGeneric(Types.FixedType.ofLength(4), buffer); + + assertThat(result).isInstanceOf(byte[].class).isEqualTo(bytes); + } + + @Test + public void testNullValueConversion() { + Object result = GenericDataUtil.internalToGeneric(Types.IntegerType.get(), null); + assertThat(result).isNull(); + } +} diff --git a/core/src/test/java/org/apache/iceberg/data/avro/TestPlannedDataReader.java b/core/src/test/java/org/apache/iceberg/data/avro/TestPlannedDataReader.java index 438a9202cc6d..a32a7548ae44 100644 --- a/core/src/test/java/org/apache/iceberg/data/avro/TestPlannedDataReader.java +++ b/core/src/test/java/org/apache/iceberg/data/avro/TestPlannedDataReader.java @@ -23,20 +23,33 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Collections; +import java.util.List; +import java.util.Map; import org.apache.avro.LogicalTypes; import org.apache.avro.Schema; import org.apache.avro.SchemaBuilder; +import org.apache.avro.file.DataFileWriter; import org.apache.avro.generic.GenericData; import org.apache.avro.generic.GenericDatumWriter; import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.BinaryDecoder; import org.apache.avro.io.BinaryEncoder; import org.apache.avro.io.DecoderFactory; import org.apache.avro.io.EncoderFactory; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.avro.AvroIterable; import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.DateTimeUtil; import org.junit.jupiter.api.Test; @@ -186,19 +199,205 @@ public void timestampTzDataReader() throws IOException { .isEqualTo(preEpochTimestampMillis.withOffsetSameInstant(ZoneOffset.UTC)); } + @Test + public void testRowLineageInjectedWithPlannedReader() throws IOException { + org.apache.iceberg.Schema icebergSchema = + new org.apache.iceberg.Schema( + Types.NestedField.required(1, "data", Types.StringType.get()), + MetadataColumns.ROW_ID, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER); + + Schema avroFileSchema = + SchemaBuilder.record("test") + .fields() + .name("data") + .type() + .stringType() + .noDefault() + .endRecord(); + avroFileSchema.getField("data").addProp("field-id", 1); + + Map idToConstant = + ImmutableMap.of( + MetadataColumns.ROW_ID.fieldId(), 1000L, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(), 5L); + + PlannedDataReader reader = PlannedDataReader.create(icebergSchema, idToConstant); + reader.setSchema(avroFileSchema); + reader.setRowPositionSupplier(() -> 0L); + + GenericRecord rec1 = new GenericData.Record(avroFileSchema); + rec1.put("data", "a"); + GenericRecord rec2 = new GenericData.Record(avroFileSchema); + rec2.put("data", "b"); + + List results = readRecords(reader, avroFileSchema, Lists.newArrayList(rec1, rec2)); + + assertThat(results).hasSize(2); + + assertThat(results.get(0).getField(MetadataColumns.ROW_ID.name())).isEqualTo(1000L); + assertThat(results.get(0).getField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name())) + .isEqualTo(5L); + assertThat(results.get(1).getField(MetadataColumns.ROW_ID.name())).isEqualTo(1001L); + assertThat(results.get(1).getField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name())) + .isEqualTo(5L); + } + + @Test + public void testMixedRowLineageValues() throws IOException { + org.apache.iceberg.Schema icebergSchema = + new org.apache.iceberg.Schema( + Types.NestedField.required(1, "data", Types.StringType.get()), + MetadataColumns.ROW_ID, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER); + + Schema avroFileSchema = + SchemaBuilder.record("test") + .fields() + .name("data") + .type() + .stringType() + .noDefault() + .name(MetadataColumns.ROW_ID.name()) + .type() + .optional() + .longType() + .name(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()) + .type() + .optional() + .longType() + .endRecord(); + + avroFileSchema.getField("data").addProp("field-id", 1); + avroFileSchema + .getField(MetadataColumns.ROW_ID.name()) + .addProp("field-id", MetadataColumns.ROW_ID.fieldId()); + avroFileSchema + .getField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()) + .addProp("field-id", MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId()); + + Map idToConstant = + ImmutableMap.of( + MetadataColumns.ROW_ID.fieldId(), 1000L, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(), 10L); + + PlannedDataReader reader = PlannedDataReader.create(icebergSchema, idToConstant); + reader.setSchema(avroFileSchema); + reader.setRowPositionSupplier(() -> 0L); + + GenericRecord recWithRowLineageValues = new GenericData.Record(avroFileSchema); + recWithRowLineageValues.put("data", "has_values"); + recWithRowLineageValues.put(MetadataColumns.ROW_ID.name(), 555L); + recWithRowLineageValues.put(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), 7L); + + GenericRecord recWithoutRowLineageValues = new GenericData.Record(avroFileSchema); + recWithoutRowLineageValues.put("data", "has_nulls"); + recWithoutRowLineageValues.put(MetadataColumns.ROW_ID.name(), null); + recWithoutRowLineageValues.put(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), null); + + List results = + readRecords( + reader, + avroFileSchema, + Lists.newArrayList(recWithRowLineageValues, recWithoutRowLineageValues)); + + assertThat(results.get(0).getField(MetadataColumns.ROW_ID.name())).isEqualTo(555L); + assertThat(results.get(0).getField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name())) + .isEqualTo(7L); + + assertThat(results.get(1).getField(MetadataColumns.ROW_ID.name())).isEqualTo(1001L); + assertThat(results.get(1).getField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name())) + .isEqualTo(10L); + } + + @Test + public void testLineageColumnsNotProjected() throws Exception { + + org.apache.iceberg.Schema icebergSchema = + new org.apache.iceberg.Schema( + Types.NestedField.required(1, "data", Types.StringType.get())); + + Schema fileSchema = + SchemaBuilder.record("test") + .fields() + .name("data") + .type() + .stringType() + .noDefault() + .name(MetadataColumns.ROW_ID.name()) + .type() + .optional() + .longType() + .name(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()) + .type() + .optional() + .longType() + .endRecord(); + + fileSchema.getField("data").addProp("field-id", 1); + fileSchema + .getField(MetadataColumns.ROW_ID.name()) + .addProp("field-id", MetadataColumns.ROW_ID.fieldId()); + fileSchema + .getField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()) + .addProp("field-id", MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId()); + + File file = File.createTempFile("test", ".avro"); + + try (DataFileWriter writer = + new DataFileWriter<>(new GenericDatumWriter<>(fileSchema))) { + + writer.create(fileSchema, file); + + GenericRecord rec = new GenericData.Record(fileSchema); + rec.put("data", "a"); + rec.put(MetadataColumns.ROW_ID.name(), 10L); + rec.put(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), 5L); + + writer.append(rec); + } + + try (AvroIterable reader = + Avro.read(Files.localInput(file)) + .createResolvingReader(schema -> PlannedDataReader.create(icebergSchema)) + .project(icebergSchema) + .build()) { + + List rows = Lists.newArrayList(reader); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getField("data")).isEqualTo("a"); + } + } + private Record readRecord( PlannedDataReader reader, Schema avroSchema, GenericRecord avroRecord) throws IOException { + return Iterables.getOnlyElement( + readRecords(reader, avroSchema, Collections.singletonList(avroRecord))); + } + + private List readRecords( + PlannedDataReader reader, Schema avroSchema, List avroRecords) + throws IOException { + List results = Lists.newArrayList(); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(out, null); GenericDatumWriter writer = new GenericDatumWriter<>(avroSchema); - writer.write(avroRecord, encoder); + + for (GenericRecord rec : avroRecords) { + writer.write(rec, encoder); + } encoder.flush(); try (ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray())) { - return reader.read(null, DecoderFactory.get().binaryDecoder(in, null)); + BinaryDecoder decoder = DecoderFactory.get().binaryDecoder(in, null); + for (int i = 0; i < avroRecords.size(); i++) { + results.add(reader.read(null, decoder)); + } } } + return results; } private Schema utcAdjustedLongSchema() { diff --git a/core/src/test/java/org/apache/iceberg/deletes/TestRoaringPositionBitmap.java b/core/src/test/java/org/apache/iceberg/deletes/TestRoaringPositionBitmap.java index 2daf0382973b..68b73ed0a218 100644 --- a/core/src/test/java/org/apache/iceberg/deletes/TestRoaringPositionBitmap.java +++ b/core/src/test/java/org/apache/iceberg/deletes/TestRoaringPositionBitmap.java @@ -148,9 +148,116 @@ public void testAddRangeAcrossKeys() { @TestTemplate public void testAddEmptyRange() { + RoaringPositionBitmap equalRange = new RoaringPositionBitmap(); + equalRange.setRange(10, 10); + assertThat(equalRange.isEmpty()).isTrue(); + assertThat(equalRange.cardinality()).isEqualTo(0); + assertThat(equalRange.contains(10)).isFalse(); + } + + @TestTemplate + public void testSetRangeReversedThrows() { + RoaringPositionBitmap bitmap = new RoaringPositionBitmap(); + assertThatThrownBy(() -> bitmap.setRange(100, 50)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Start position must not exceed end position"); + } + + @TestTemplate + public void testAddRangeLargeContiguous() { + RoaringPositionBitmap bitmap = new RoaringPositionBitmap(); + + long start = 500L; + long end = 200_500L; + bitmap.setRange(start, end); + + assertThat(bitmap.cardinality()).isEqualTo(200_000L); + assertThat(bitmap.contains(start)).isTrue(); + assertThat(bitmap.contains(end - 1)).isTrue(); + assertThat(bitmap.contains(start - 1)).isFalse(); + assertThat(bitmap.contains(end)).isFalse(); + } + + @TestTemplate + public void testAddRangeSpanningThreeKeys() { + RoaringPositionBitmap bitmap = new RoaringPositionBitmap(); + + long start = ((long) 0 << 32) | 0xFFFFFFF0L; + long end = ((long) 2 << 32) | 0x10L; + bitmap.setRange(start, end); + + assertThat(bitmap.contains(start)).isTrue(); + assertThat(bitmap.contains(end - 1)).isTrue(); + assertThat(bitmap.contains(start - 1)).isFalse(); + assertThat(bitmap.contains(end)).isFalse(); + + // key 1 should be fully covered + assertThat(bitmap.contains((long) 1 << 32)).isTrue(); + assertThat(bitmap.contains(((long) 1 << 32) | 0xFFFFFFFFL)).isTrue(); + + long expectedCardinality = end - start; + assertThat(bitmap.cardinality()).isEqualTo(expectedCardinality); + } + + @TestTemplate + public void testAddRangeSinglePosition() { + RoaringPositionBitmap rangeBitmap = new RoaringPositionBitmap(); + rangeBitmap.setRange(42, 43); + + RoaringPositionBitmap setBitmap = new RoaringPositionBitmap(); + setBitmap.set(42); + + assertThat(rangeBitmap.cardinality()).isEqualTo(setBitmap.cardinality()); + assertThat(rangeBitmap.contains(42)).isTrue(); + assertThat(rangeBitmap.contains(41)).isFalse(); + assertThat(rangeBitmap.contains(43)).isFalse(); + } + + @TestTemplate + public void testAddRangeAtKeyBoundary() { + RoaringPositionBitmap bitmap = new RoaringPositionBitmap(); + + bitmap.setRange(0L, 1L << 32); + + assertThat(bitmap.cardinality()).isEqualTo(1L << 32); + assertThat(bitmap.contains(0L)).isTrue(); + assertThat(bitmap.contains((1L << 32) - 1)).isTrue(); + assertThat(bitmap.contains(1L << 32)).isFalse(); + assertThat(bitmap.allocatedBitmapCount()).isEqualTo(1); + } + + @TestTemplate + public void testAddRangeSameKeyForEachExact() { RoaringPositionBitmap bitmap = new RoaringPositionBitmap(); - bitmap.setRange(10, 10); - assertThat(bitmap.isEmpty()).isTrue(); + + long start = 1000L; + long end = 1200L; + bitmap.setRange(start, end); + + assertThat(bitmap.cardinality()).isEqualTo(end - start); + assertThat(bitmap.contains(start - 1)).isFalse(); + assertThat(bitmap.contains(end)).isFalse(); + + for (long pos = start; pos < end; pos++) { + assertThat(bitmap.contains(pos)).isTrue(); + } + } + + @TestTemplate + public void testAddRangeCrossKeyForEachExact() { + RoaringPositionBitmap bitmap = new RoaringPositionBitmap(); + + long start = ((long) 1 << 32) - 100L; + long end = ((long) 1 << 32) + 100L; + bitmap.setRange(start, end); + + assertThat(bitmap.cardinality()).isEqualTo(end - start); + assertThat(bitmap.contains(start - 1)).isFalse(); + assertThat(bitmap.contains(end)).isFalse(); + + for (long pos = start; pos < end; pos++) { + assertThat(bitmap.contains(pos)).isTrue(); + } } @TestTemplate @@ -357,6 +464,18 @@ public void testUnsupportedPositions() { .hasMessageContaining( "Bitmap supports positions that are >= 0 and <= %s", RoaringPositionBitmap.MAX_POSITION); + + assertThatThrownBy(() -> bitmap.setRange(-1L, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Bitmap supports positions that are >= 0 and <= %s", + RoaringPositionBitmap.MAX_POSITION); + + assertThatThrownBy(() -> bitmap.setRange(0L, RoaringPositionBitmap.MAX_POSITION + 2L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Bitmap supports positions that are >= 0 and <= %s", + RoaringPositionBitmap.MAX_POSITION); } @TestTemplate diff --git a/core/src/test/java/org/apache/iceberg/encryption/TestBaseEncryptedKeySerialization.java b/core/src/test/java/org/apache/iceberg/encryption/TestBaseEncryptedKeySerialization.java new file mode 100644 index 000000000000..f6219183ff5f --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/encryption/TestBaseEncryptedKeySerialization.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.encryption; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.apache.iceberg.TestHelpers; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class TestBaseEncryptedKeySerialization { + + @ParameterizedTest + @MethodSource("org.apache.iceberg.TestHelpers#serializers") + public void testSerialization(TestHelpers.RoundTripSerializer roundTripSerializer) + throws Exception { + byte[] keyBytes = "key".getBytes(StandardCharsets.UTF_8); + EncryptedKey key = + new BaseEncryptedKey("a", ByteBuffer.wrap(keyBytes), "b", Map.of("test", "value")); + + EncryptedKey result = roundTripSerializer.apply(key); + + assertThat(result.keyId()).isEqualTo(key.keyId()); + assertThat(result.encryptedById()).isEqualTo(key.encryptedById()); + assertThat(result.encryptedKeyMetadata()).isEqualTo(key.encryptedKeyMetadata()); + assertThat(result.properties()).isEqualTo(key.properties()); + } +} diff --git a/core/src/test/java/org/apache/iceberg/encryption/TestEncryptionUtil.java b/core/src/test/java/org/apache/iceberg/encryption/TestEncryptionUtil.java index 39f0e7a38153..edb55d321842 100644 --- a/core/src/test/java/org/apache/iceberg/encryption/TestEncryptionUtil.java +++ b/core/src/test/java/org/apache/iceberg/encryption/TestEncryptionUtil.java @@ -19,6 +19,7 @@ package org.apache.iceberg.encryption; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.ByteArrayOutputStream; import java.io.File; @@ -27,6 +28,7 @@ import java.lang.reflect.InvocationTargetException; import java.util.Map; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; public class TestEncryptionUtil { @@ -52,6 +54,21 @@ public void testClassLoader() assertThat(kmsClientObj.getClass().getClassLoader()).isSameAs(customClassLoader); } + @Test + public void testInvalidTypeAndImpl() { + assertThatThrownBy( + () -> + EncryptionUtil.createKmsClient( + ImmutableMap.of( + CatalogProperties.ENCRYPTION_KMS_TYPE, + CatalogProperties.ENCRYPTION_KMS_TYPE_AWS, + CatalogProperties.ENCRYPTION_KMS_IMPL, + CatalogProperties.ENCRYPTION_KMS_IMPL_AWS))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot set both KMS type (aws) and KMS impl (org.apache.iceberg.aws.AwsKeyManagementClient)"); + } + static class UnitTestCustomClassLoader extends ClassLoader { @Override diff --git a/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithExtract.java b/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithExtract.java index c629270bbb0f..03c1c12f4fba 100644 --- a/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithExtract.java +++ b/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithExtract.java @@ -41,8 +41,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.nio.ByteBuffer; import java.util.List; import java.util.Map; +import java.util.UUID; import org.apache.iceberg.DataFile; import org.apache.iceberg.Schema; import org.apache.iceberg.TestHelpers.Row; @@ -52,10 +54,13 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.IntegerType; +import org.apache.iceberg.variants.PhysicalType; import org.apache.iceberg.variants.VariantTestUtil; +import org.apache.iceberg.variants.VariantValue; import org.apache.iceberg.variants.Variants; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.FieldSource; public class TestInclusiveMetricsEvaluatorWithExtract { @@ -304,7 +309,7 @@ public void testIntegerLt() { .isTrue(); assertThat(shouldRead(lessThan(extract("variant", "$.event_id", "long"), INT_MAX_VALUE))) - .as("Should read: may possible ids") + .as("Should read: many possible ids") .isTrue(); } @@ -346,7 +351,7 @@ public void testIntegerGt() { .isTrue(); assertThat(shouldRead(greaterThan(extract("variant", "$.event_id", "long"), INT_MAX_VALUE - 4))) - .as("Should read: may possible ids") + .as("Should read: many possible ids") .isTrue(); } @@ -372,7 +377,7 @@ public void testIntegerGtEq() { assertThat( shouldRead( greaterThanOrEqual(extract("variant", "$.event_id", "long"), INT_MAX_VALUE - 4))) - .as("Should read: may possible ids") + .as("Should read: many possible ids") .isTrue(); } @@ -683,4 +688,349 @@ public void testIntegerNotIn() { .as("Should read: id above upper bound (85 > 79, 86 > 79)") .isTrue(); } + + private static final List DATEANDTIMESTAMPTYPESEQPARAMETERS = + List.of( + Arguments.of( + Types.TimestampNanoType.withoutZone().toString(), + Arguments.of( + "1970-01-11T00:00:01.123456789", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + false), + Arguments.of( + "1970-01-31T00:00:01.123456", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-21T00:00:01.123456789", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-31T00:00:01.123456", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-04-01T00:00:01.123456789", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + false), + Arguments.of( + "1970-01-11T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + false), + Arguments.of( + "1970-01-31T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + true), + Arguments.of( + "1970-03-21T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + true), + Arguments.of( + "1970-03-31T00:00:00.000000000", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + true), + Arguments.of( + "1970-04-01T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + false)), + Arguments.of( + Types.DateType.get().toString(), + Arguments.of( + "1970-01-11", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + false), + Arguments.of( + "1970-01-31", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-21", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-31", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-04-01", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456"), + false), + Arguments.of( + "1970-01-11", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + false), + Arguments.of( + "1970-01-31", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-21", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-31", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-04-01", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + false)), + Arguments.of( + Types.TimestampType.withoutZone().toString(), + Arguments.of( + "1970-01-11T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + false), + Arguments.of( + "1970-01-31T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-21T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-03-31T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + true), + Arguments.of( + "1970-04-01T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"), + false), + Arguments.of( + "1970-01-11T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + false), + Arguments.of( + "1970-01-31T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + true), + Arguments.of( + "1970-03-21T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + true), + Arguments.of( + "1970-03-31T00:00:00.000000", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + true), + Arguments.of( + "1970-04-01T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"), + false))); + + @ParameterizedTest + @FieldSource("DATEANDTIMESTAMPTYPESEQPARAMETERS") + public void testDateAndTimestampTypesEq(String variantType, Arguments args) { + // lower bounds + Map lowerBounds = + ImmutableMap.of( + 2, + VariantTestUtil.variantBuffer( + Map.of("$['event_timestamp']", (VariantValue) args.get()[1]))); + // upper bounds + Map upperBounds = + ImmutableMap.of( + 2, + VariantTestUtil.variantBuffer( + Map.of("$['event_timestamp']", (VariantValue) args.get()[2]))); + + DataFile file = + new TestDataFile("file.parquet", Row.of(), 50, null, null, null, lowerBounds, upperBounds); + Expression expr = equal(extract("variant", "$.event_timestamp", variantType), args.get()[0]); + assertThat(shouldRead(expr, file)).isEqualTo(args.get()[3]); + } + + private static final List DATEANDTIMESTAMPTYPESNOTEQPARAMETERS = + List.of( + Arguments.of( + Types.TimestampNanoType.withoutZone().toString(), + Arguments.of( + "1970-01-11T00:00:01.123456", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-01-31T00:00:01.123456", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-01T00:00:01.123456", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-31T00:00:01.123456", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-04-01T00:00:01.123456", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-01-11T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-01-31T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-03-01T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-03-31T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-04-01T00:00:01.123456789", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31"))), + Arguments.of( + Types.DateType.get().toString(), + Arguments.of( + "1970-01-31", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-01", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-31", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-04-01", + Variants.ofIsoTimestampntz("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntz("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-01-11", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-01", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-01-31", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-31", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-04-01", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456"))), + Arguments.of( + Types.TimestampType.withoutZone().toString(), + Arguments.of( + "1970-01-11T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-03-01T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-01-31T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-03-31T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-04-01T00:00:01.123456", + Variants.ofIsoDate("1970-01-31"), + Variants.ofIsoDate("1970-03-31")), + Arguments.of( + "1970-01-11T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-01-31T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-01T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-03-31T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")), + Arguments.of( + "1970-04-01T00:00:01.123456", + Variants.ofIsoTimestampntzNanos("1970-01-31T00:00:01.123456"), + Variants.ofIsoTimestampntzNanos("1970-03-31T00:00:01.123456")))); + + @ParameterizedTest + @FieldSource("DATEANDTIMESTAMPTYPESNOTEQPARAMETERS") + public void testDateAndTimestampTypesNotEq(String variantType, Arguments args) { + // lower bounds + Map lowerBounds = + ImmutableMap.of( + 2, + VariantTestUtil.variantBuffer( + Map.of("$['event_timestamp']", (VariantValue) args.get()[1]))); + // upper bounds + Map upperBounds = + ImmutableMap.of( + 2, + VariantTestUtil.variantBuffer( + Map.of("$['event_timestamp']", (VariantValue) args.get()[2]))); + + DataFile file = + new TestDataFile("file.parquet", Row.of(), 50, null, null, null, lowerBounds, upperBounds); + Expression expr = notEqual(extract("variant", "$.event_timestamp", variantType), args.get()[0]); + assertThat(shouldRead(expr, file)).as("Should read: many possible timestamps" + expr).isTrue(); + } + + @Test + public void testUUIDEq() { + UUID uuid = UUID.randomUUID(); + // lower bounds + Map lowerBounds = + ImmutableMap.of( + 2, VariantTestUtil.variantBuffer(Map.of("$['event_uuid']", Variants.ofUUID(uuid)))); + // upper bounds + Map upperBounds = + ImmutableMap.of( + 2, VariantTestUtil.variantBuffer(Map.of("$['event_uuid']", Variants.ofUUID(uuid)))); + DataFile file = + new TestDataFile("file.parquet", Row.of(), 50, null, null, null, lowerBounds, upperBounds); + Expression expr = equal(extract("variant", "$.event_uuid", PhysicalType.UUID.name()), uuid); + assertThat(shouldRead(expr, file)).as("Should read: many possible UUIDs" + expr).isTrue(); + } } diff --git a/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithTransforms.java b/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithTransforms.java index 5632830064bc..41a14667eae1 100644 --- a/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithTransforms.java +++ b/core/src/test/java/org/apache/iceberg/expressions/TestInclusiveMetricsEvaluatorWithTransforms.java @@ -368,7 +368,7 @@ public void testIntegerLt() { .isTrue(); assertThat(shouldRead(lessThan(day("ts"), INT_MAX_VALUE))) - .as("Should read: may possible ids") + .as("Should read: many possible ids") .isTrue(); } @@ -406,7 +406,7 @@ public void testIntegerGt() { .isTrue(); assertThat(shouldRead(greaterThan(day("ts"), INT_MAX_VALUE - 4))) - .as("Should read: may possible ids") + .as("Should read: many possible ids") .isTrue(); } @@ -425,7 +425,7 @@ public void testIntegerGtEq() { .isTrue(); assertThat(shouldRead(greaterThanOrEqual(day("ts"), INT_MAX_VALUE - 4))) - .as("Should read: may possible ids") + .as("Should read: many possible ids") .isTrue(); } diff --git a/core/src/test/java/org/apache/iceberg/formats/TestFormatModelRegistry.java b/core/src/test/java/org/apache/iceberg/formats/TestFormatModelRegistry.java new file mode 100644 index 000000000000..fe7a4d96f612 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/formats/TestFormatModelRegistry.java @@ -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. + */ +package org.apache.iceberg.formats; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.util.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TestFormatModelRegistry { + + @BeforeEach + void clearRegistry() { + FormatModelRegistry.models().clear(); + } + + @Test + void testSuccessfulRegister() { + FormatModel model = new DummyParquetFormatModel(Object.class, Object.class); + FormatModelRegistry.register(model); + assertThat(FormatModelRegistry.models()) + .containsEntry(Pair.of(FileFormat.PARQUET, Object.class), model); + } + + /** Tests that registering the same class with the same configuration updates the registration. */ + @Test + void testRegistrationForDifferentType() { + FormatModel model1 = new DummyParquetFormatModel(Object.class, Object.class); + FormatModel model2 = new DummyParquetFormatModel(Long.class, Object.class); + FormatModelRegistry.register(model1); + assertThat(FormatModelRegistry.models().get(Pair.of(FileFormat.PARQUET, model1.type()))) + .isSameAs(model1); + + // Registering a new model with the different format will succeed + FormatModelRegistry.register(model2); + assertThat(FormatModelRegistry.models().get(Pair.of(FileFormat.PARQUET, model1.type()))) + .isSameAs(model1); + assertThat(FormatModelRegistry.models().get(Pair.of(FileFormat.PARQUET, model2.type()))) + .isSameAs(model2); + } + + /** + * Tests that registering different classes, or different schema type for the same file format and + * type is failing. + */ + @Test + void testFailingReRegistrations() { + FormatModel model = new DummyParquetFormatModel(Object.class, Object.class); + FormatModelRegistry.register(model); + assertThat(FormatModelRegistry.models()) + .containsEntry(Pair.of(FileFormat.PARQUET, Object.class), model); + + // Registering a new model with different schema type should fail + assertThatThrownBy( + () -> + FormatModelRegistry.register( + new DummyParquetFormatModel(Object.class, String.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot register class"); + + // Registering a new model with null schema type should fail + assertThatThrownBy( + () -> FormatModelRegistry.register(new DummyParquetFormatModel(Object.class, null))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot register class"); + } + + private static class DummyParquetFormatModel implements FormatModel { + private final Class type; + private final Class schemaType; + + private DummyParquetFormatModel(Class type, Class schemaType) { + this.type = type; + this.schemaType = schemaType; + } + + @Override + public FileFormat format() { + return FileFormat.PARQUET; + } + + @Override + @SuppressWarnings("unchecked") + public Class type() { + return (Class) type; + } + + @Override + @SuppressWarnings("unchecked") + public Class schemaType() { + return (Class) schemaType; + } + + @Override + public ModelWriteBuilder writeBuilder(EncryptedOutputFile outputFile) { + return null; + } + + @Override + public ReadBuilder readBuilder(InputFile inputFile) { + return null; + } + } +} diff --git a/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java b/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java index 4c711c118772..1c36c1acea2e 100644 --- a/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java +++ b/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java @@ -34,6 +34,7 @@ import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.CachingCatalog; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.MetadataTableType; import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; @@ -407,6 +408,34 @@ public void testInvalidateTableForChainedCachingCatalogs() throws Exception { assertThat(wrappedCatalog.cache().asMap()).doesNotContainKey(tableIdent); } + @Test + public void testRegisterTableWithOverwriteInvalidatesCache() throws Exception { + TestableCachingCatalog catalog = + TestableCachingCatalog.wrap(hadoopCatalog(), EXPIRATION_TTL, ticker); + Namespace namespace = Namespace.of("db", "ns1", "ns2"); + TableIdentifier sourceIdent = TableIdentifier.of(namespace, "src"); + TableIdentifier targetIdent = TableIdentifier.of(namespace, "tgt"); + + Table sourceTable = + catalog.createTable(sourceIdent, SCHEMA, SPEC, ImmutableMap.of("key", "value")); + String metadataLocation = + ((HasTableOperations) sourceTable).operations().current().metadataFileLocation(); + + Table registered = catalog.registerTable(targetIdent, metadataLocation, false); + assertThat(registered).isNotNull(); + + // load so the target table is cached + catalog.loadTable(targetIdent); + assertThat(catalog.cache().asMap()).containsKey(targetIdent); + + // drop and re-register with overwrite=false to verify cache invalidation + catalog.dropTable(targetIdent); + catalog.registerTable(targetIdent, metadataLocation, false); + + // cache should have been invalidated by registerTable + assertThat(catalog.cache().asMap()).doesNotContainKey(targetIdent); + } + public static TableIdentifier[] metadataTables(TableIdentifier tableIdent) { return Arrays.stream(MetadataTableType.values()) .map(type -> TableIdentifier.parse(tableIdent + "." + type.name().toLowerCase(Locale.ROOT))) diff --git a/core/src/test/java/org/apache/iceberg/hadoop/TestCatalogUtilDropTable.java b/core/src/test/java/org/apache/iceberg/hadoop/TestCatalogUtilDropTable.java index bd10f476f9d8..7c1e284b27e9 100644 --- a/core/src/test/java/org/apache/iceberg/hadoop/TestCatalogUtilDropTable.java +++ b/core/src/test/java/org/apache/iceberg/hadoop/TestCatalogUtilDropTable.java @@ -28,9 +28,7 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -import java.util.stream.StreamSupport; import org.apache.iceberg.CatalogUtil; -import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.GenericBlobMetadata; @@ -40,7 +38,9 @@ import org.apache.iceberg.ManifestListFile; import org.apache.iceberg.PartitionStatisticsFile; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableProperties; import org.apache.iceberg.io.FileIO; @@ -50,6 +50,7 @@ import org.apache.iceberg.puffin.PuffinWriter; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.collect.Streams; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; @@ -81,7 +82,7 @@ public void dropTableDataDeletesExpectedFiles() throws IOException { Set manifestListLocations = manifestListLocations(snapshotSet); Set manifestLocations = manifestLocations(snapshotSet, table.io()); - Set dataLocations = dataLocations(snapshotSet, table.io()); + Set dataLocations = dataLocations(snapshotSet, table); Set metadataLocations = metadataLocations(tableMetadata); Set statsLocations = statsLocations(tableMetadata); Set partitionStatsLocations = partitionStatsLocations(tableMetadata); @@ -146,7 +147,7 @@ public void dropTableDataDoNotThrowWhenDeletesFail() { Mockito.times( manifestListLocations(snapshotSet).size() + manifestLocations(snapshotSet, fileIO).size() - + dataLocations(snapshotSet, table.io()).size() + + dataLocations(snapshotSet, table).size() + metadataLocations(tableMetadata).size())) .deleteFile(ArgumentMatchers.anyString()); } @@ -222,10 +223,13 @@ private static Set manifestLocations(Set snapshotSet, FileIO i .collect(Collectors.toSet()); } - private static Set dataLocations(Set snapshotSet, FileIO io) { + private static Set dataLocations(Set snapshotSet, Table table) { return snapshotSet.stream() - .flatMap(snapshot -> StreamSupport.stream(snapshot.addedDataFiles(io).spliterator(), false)) - .map(ContentFile::location) + .flatMap( + snapshot -> + Streams.stream( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles())) + .map(DataFile::location) .collect(Collectors.toSet()); } diff --git a/core/src/test/java/org/apache/iceberg/hadoop/TestHadoopFileIO.java b/core/src/test/java/org/apache/iceberg/hadoop/TestHadoopFileIO.java index b459c7e062a0..554ed625b9e3 100644 --- a/core/src/test/java/org/apache/iceberg/hadoop/TestHadoopFileIO.java +++ b/core/src/test/java/org/apache/iceberg/hadoop/TestHadoopFileIO.java @@ -18,7 +18,6 @@ */ package org.apache.iceberg.hadoop; -import static org.apache.hadoop.fs.CommonConfigurationKeysPublic.FS_TRASH_INTERVAL_KEY; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -127,46 +126,6 @@ public void testDeletePrefix() { .hasMessageContaining("java.io.FileNotFoundException"); } - @Test - public void testDeletePrefixWithTrashEnabled() throws IOException { - Configuration conf = new Configuration(); - conf.set(FS_TRASH_INTERVAL_KEY, "60"); - fs = FileSystem.getLocal(conf); - - hadoopFileIO = new HadoopFileIO(conf); - Path parent = new Path(tempDir.toURI()); - - List scaleSizes = Lists.newArrayList(1, 1000, 2500); - - scaleSizes.parallelStream() - .forEach( - scale -> { - Path scalePath = new Path(parent, Integer.toString(scale)); - - List filesCreated = createRandomFiles(scalePath, scale); - hadoopFileIO.deletePrefix(scalePath.toUri().toString()); - - // Hadoop filesystem will throw if the path does not exist - assertThatThrownBy( - () -> hadoopFileIO.listPrefix(scalePath.toUri().toString()).iterator()) - .isInstanceOf(UncheckedIOException.class) - .hasMessageContaining("java.io.FileNotFoundException"); - filesCreated.forEach( - file -> { - String fileSuffix = Path.getPathWithoutSchemeAndAuthority(file).toString(); - String trashPath = - fs.getTrashRoot(scalePath).toString() + "/Current" + fileSuffix; - assertThat(hadoopFileIO.newInputFile(trashPath).exists()).isTrue(); - }); - }); - - hadoopFileIO.deletePrefix(parent.toUri().toString()); - // Hadoop filesystem will throw if the path does not exist - assertThatThrownBy(() -> hadoopFileIO.listPrefix(parent.toUri().toString()).iterator()) - .isInstanceOf(UncheckedIOException.class) - .hasMessageContaining("java.io.FileNotFoundException"); - } - @Test public void testDeleteFiles() { Path parent = new Path(tempDir.toURI()); @@ -177,27 +136,6 @@ public void testDeleteFiles() { file -> assertThat(hadoopFileIO.newInputFile(file.toString()).exists()).isFalse()); } - @Test - public void testDeleteFilesWithTrashEnabled() throws IOException { - Configuration conf = new Configuration(); - conf.set(FS_TRASH_INTERVAL_KEY, "60"); - fs = FileSystem.getLocal(conf); - - hadoopFileIO = new HadoopFileIO(conf); - Path parent = new Path(tempDir.toURI()); - List filesCreated = createRandomFiles(parent, 10); - hadoopFileIO.deleteFiles( - filesCreated.stream().map(Path::toString).collect(Collectors.toList())); - filesCreated.forEach( - file -> assertThat(hadoopFileIO.newInputFile(file.toString()).exists()).isFalse()); - filesCreated.forEach( - file -> { - String fileSuffix = Path.getPathWithoutSchemeAndAuthority(file).toString(); - String trashPath = fs.getTrashRoot(parent).toString() + "/Current" + fileSuffix; - assertThat(hadoopFileIO.newInputFile(trashPath).exists()).isTrue(); - }); - } - @Test public void testDeleteFilesErrorHandling() { List filesCreated = diff --git a/core/src/test/java/org/apache/iceberg/hadoop/TestSerializableConfiguration.java b/core/src/test/java/org/apache/iceberg/hadoop/TestSerializableConfiguration.java new file mode 100644 index 000000000000..8912b0135f25 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/hadoop/TestSerializableConfiguration.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.hadoop; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.TestHelpers; +import org.junit.jupiter.api.Test; + +public class TestSerializableConfiguration { + + @Test + public void kryoSerialization() throws IOException { + Configuration configuration = new Configuration(); + configuration.set("prefix.key1", "value1"); + configuration.set("prefix.key2", "value2"); + SerializableConfiguration conf = new SerializableConfiguration(configuration); + SerializableConfiguration serialized = TestHelpers.KryoHelpers.roundTripSerialize(conf); + + assertThat(serialized.get().getPropsWithPrefix("prefix")) + .isEqualTo(conf.get().getPropsWithPrefix("prefix")) + .isEqualTo(configuration.getPropsWithPrefix("prefix")); + } + + @Test + public void javaSerialization() throws IOException, ClassNotFoundException { + Configuration configuration = new Configuration(); + configuration.set("prefix.key1", "value1"); + configuration.set("prefix.key2", "value2"); + SerializableConfiguration conf = new SerializableConfiguration(configuration); + SerializableConfiguration serialized = TestHelpers.roundTripSerialize(conf); + + assertThat(serialized.get().getPropsWithPrefix("prefix")) + .isEqualTo(conf.get().getPropsWithPrefix("prefix")) + .isEqualTo(configuration.getPropsWithPrefix("prefix")); + } +} diff --git a/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java b/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java index 5103e2e9be92..ece9b24af3d1 100644 --- a/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java +++ b/core/src/test/java/org/apache/iceberg/hadoop/TestTableSerialization.java @@ -79,6 +79,22 @@ public void testSerializableTable() throws IOException, ClassNotFoundException { .isEqualTo(TableUtil.metadataFileLocation(table)); } + @Test + public void testSerializableTableSortOrdersWithDroppedColumn() + throws IOException, ClassNotFoundException { + table.updateSchema().addColumn("ts", Types.LongType.get()).commit(); + table.replaceSortOrder().asc("id").asc("ts").commit(); + + // historical sort order 1 still references "ts" after the column is dropped + table.replaceSortOrder().asc("id").commit(); + table.updateSchema().deleteColumn("ts").commit(); + + TestHelpers.assertSerializedAndLoadedMetadata(table, TestHelpers.roundTripSerialize(table)); + Table serializableTable = SerializableTable.copyOf(table); + TestHelpers.assertSerializedAndLoadedMetadata( + serializableTable, TestHelpers.KryoHelpers.roundTripSerialize(serializableTable)); + } + @Test public void testSerializableTableWithSnapshot() throws IOException, ClassNotFoundException { table.newAppend().appendFile(FILE_A).commit(); diff --git a/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java b/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java index 705ff3dc8699..827450d4a398 100644 --- a/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java +++ b/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java @@ -18,11 +18,21 @@ */ package org.apache.iceberg.inmemory; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.file.Path; import java.util.Map; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.Table; import org.apache.iceberg.catalog.CatalogTests; +import org.apache.iceberg.exceptions.NotFoundException; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; public class TestInMemoryCatalog extends CatalogTests { private InMemoryCatalog catalog; @@ -71,4 +81,29 @@ protected boolean requiresNamespaceCreate() { protected boolean supportsEmptyNamespace() { return true; } + + @Override + protected boolean supportsNestedNamespaces() { + return true; + } + + @Test + @Override + public void testLoadTableWithMissingMetadataFile(@TempDir Path tempDir) throws IOException { + + if (requiresNamespaceCreate()) { + catalog.createNamespace(TBL.namespace()); + } + + catalog.buildTable(TBL, SCHEMA).create(); + assertThat(catalog.tableExists(TBL)).as("Table should exist").isTrue(); + Table table = catalog.loadTable(TBL); + String metadataFileLocation = + ((HasTableOperations) table).operations().current().metadataFileLocation(); + table.io().deleteFile(metadataFileLocation); + assertThatThrownBy(() -> catalog.loadTable(TBL)) + .isInstanceOf(NotFoundException.class) + .hasMessage("No in-memory file found for location: " + metadataFileLocation); + table.io().newOutputFile(metadataFileLocation).create(); + } } diff --git a/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryFileIO.java b/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryFileIO.java index 262d34536e6b..97e5d29f4e8a 100644 --- a/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryFileIO.java +++ b/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryFileIO.java @@ -24,9 +24,11 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.Map; import java.util.UUID; import org.apache.iceberg.exceptions.AlreadyExistsException; import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; public class TestInMemoryFileIO { @@ -129,6 +131,14 @@ public void testFilesAreSharedAcrossMultipleInstances() { .as("Files should be shared across all InMemoryFileIO instances"); } + @Test + public void properties() { + InMemoryFileIO io = new InMemoryFileIO(); + Map properties = ImmutableMap.of("key1", "value1", "key2", "value2"); + io.initialize(properties); + assertThat(io.properties()).isEqualTo(properties); + } + private String randomLocation() { return "s3://foo/" + UUID.randomUUID(); } diff --git a/core/src/test/java/org/apache/iceberg/io/TestBufferedFileAppender.java b/core/src/test/java/org/apache/iceberg/io/TestBufferedFileAppender.java new file mode 100644 index 000000000000..9bbc0f9f8c71 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/io/TestBufferedFileAppender.java @@ -0,0 +1,227 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.io; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.List; +import java.util.function.Function; +import org.apache.iceberg.Schema; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.avro.AvroIterable; +import org.apache.iceberg.data.DataTestHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.avro.DataWriter; +import org.apache.iceberg.data.avro.PlannedDataReader; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.inmemory.InMemoryOutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestBufferedFileAppender { + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + private InMemoryOutputFile outputFile; + private GenericRecord record; + + @BeforeEach + public void before() { + this.outputFile = new InMemoryOutputFile(); + this.record = GenericRecord.create(SCHEMA); + } + + private Function, FileAppender> avroFactory(OutputFile out) { + return bufferedRows -> { + try { + return Avro.write(out) + .createWriterFunc(DataWriter::create) + .schema(SCHEMA) + .overwrite() + .build(); + } catch (IOException e) { + throw new RuntimeIOException(e); + } + }; + } + + private BufferedFileAppender createAppender(int bufferSize) { + return new BufferedFileAppender<>(bufferSize, avroFactory(outputFile), Record::copy); + } + + private Record createRecord(long id, String data) { + return record.copy(ImmutableMap.of("id", id, "data", data)); + } + + private List readBack() throws IOException { + try (AvroIterable reader = + Avro.read(outputFile.toInputFile()) + .project(SCHEMA) + .createResolvingReader(PlannedDataReader::create) + .build()) { + return Lists.newArrayList(reader); + } + } + + @Test + public void testBufferFlushesOnThreshold() throws IOException { + BufferedFileAppender appender = createAppender(3); + + appender.add(createRecord(1L, "a")); + appender.add(createRecord(2L, "b")); + + // delegate not yet created, length should be 0 + assertThat(appender.length()).isEqualTo(0L); + + appender.add(createRecord(3L, "c")); + + // delegate created after 3rd row, length should be > 0 + assertThat(appender.length()).isGreaterThan(0L); + + appender.add(createRecord(4L, "d")); + appender.add(createRecord(5L, "e")); + appender.close(); + + List expected = + Lists.newArrayList( + createRecord(1L, "a"), + createRecord(2L, "b"), + createRecord(3L, "c"), + createRecord(4L, "d"), + createRecord(5L, "e")); + DataTestHelpers.assertEquals(SCHEMA.asStruct(), expected, readBack()); + } + + @Test + public void testCloseWithPartialBuffer() throws IOException { + BufferedFileAppender appender = createAppender(10); + + appender.add(createRecord(1L, "a")); + appender.add(createRecord(2L, "b")); + appender.add(createRecord(3L, "c")); + + // buffer not full yet + assertThat(appender.length()).isEqualTo(0L); + + // close flushes partial buffer through factory + appender.close(); + + List expected = + Lists.newArrayList(createRecord(1L, "a"), createRecord(2L, "b"), createRecord(3L, "c")); + DataTestHelpers.assertEquals(SCHEMA.asStruct(), expected, readBack()); + } + + @Test + public void testCopyFuncIsApplied() throws IOException { + BufferedFileAppender appender = createAppender(3); + + // use a single mutable record, relying on copyFunc to snapshot it + record.set(0, 1L); + record.set(1, "first"); + appender.add(record); + + record.set(0, 2L); + record.set(1, "second"); + appender.add(record); + + record.set(0, 3L); + record.set(1, "third"); + appender.add(record); + + appender.close(); + + List expected = + Lists.newArrayList( + createRecord(1L, "first"), createRecord(2L, "second"), createRecord(3L, "third")); + DataTestHelpers.assertEquals(SCHEMA.asStruct(), expected, readBack()); + } + + @Test + public void testMetricsAfterClose() throws IOException { + BufferedFileAppender appender = createAppender(2); + + appender.add(createRecord(1L, "a")); + appender.add(createRecord(2L, "b")); + appender.add(createRecord(3L, "c")); + appender.close(); + + assertThat(appender.metrics()).isNotNull(); + assertThat(appender.metrics().recordCount()).isEqualTo(3L); + assertThat(appender.length()).isGreaterThan(0L); + } + + @Test + public void testMetricsBeforeCloseThrows() throws IOException { + try (BufferedFileAppender appender = createAppender(10)) { + assertThatThrownBy(appender::metrics) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Cannot return metrics for unclosed appender"); + } + } + + @Test + public void testAddAfterCloseThrows() throws IOException { + try (BufferedFileAppender appender = createAppender(10)) { + appender.add(createRecord(1L, "a")); + appender.close(); + + assertThatThrownBy(() -> appender.add(createRecord(2L, "b"))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Cannot add to a closed appender"); + } + } + + @Test + public void testAddAllSpanningBuffer() throws IOException { + BufferedFileAppender appender = createAppender(2); + + List records = + Lists.newArrayList( + createRecord(1L, "a"), + createRecord(2L, "b"), + createRecord(3L, "c"), + createRecord(4L, "d")); + + appender.addAll(records); + appender.close(); + + DataTestHelpers.assertEquals(SCHEMA.asStruct(), records, readBack()); + } + + @Test + public void testCloseWithNoData() throws IOException { + BufferedFileAppender appender = createAppender(10); + // close immediately with no data written + appender.close(); + // delegate was never created + assertThat(appender.length()).isEqualTo(0L); + assertThat(appender.metrics()).isNotNull(); + assertThat(appender.metrics().recordCount()).isEqualTo(0L); + assertThat(appender.splitOffsets()).isNull(); + } +} diff --git a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java index 978c1d9698b8..ff0af5c56306 100644 --- a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java +++ b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java @@ -852,11 +852,11 @@ public void testDropNamespace() { assertThatThrownBy(() -> catalog.dropNamespace(tbl2.namespace())) .isInstanceOf(NamespaceNotEmptyException.class) - .hasMessage("Namespace db.ns1 is not empty. Contains 1 table(s)."); + .hasMessage("Namespace db.ns1 is not empty. Contains 1 child namespace(s)."); assertThatThrownBy(() -> catalog.dropNamespace(tbl4.namespace())) .isInstanceOf(NamespaceNotEmptyException.class) - .hasMessage("Namespace db is not empty. Contains 1 table(s)."); + .hasMessage("Namespace db is not empty. Contains 2 child namespace(s)."); } @Test @@ -1083,6 +1083,7 @@ public void testCommitExceptionWithoutMessage() { mockedStatic .when(() -> JdbcUtil.loadTable(any(), any(), any(), any())) .thenThrow(new SQLException()); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); assertThatThrownBy(() -> ops.commit(ops.current(), metadataV1)) .isInstanceOf(UncheckedSQLException.class) .hasMessageStartingWith("Unknown failure"); @@ -1103,12 +1104,76 @@ public void testCommitExceptionWithMessage() { mockedStatic .when(() -> JdbcUtil.loadTable(any(), any(), any(), any())) .thenThrow(new SQLException("constraint failed")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); assertThatThrownBy(() -> ops.commit(ops.current(), metadataV1)) - .isInstanceOf(AlreadyExistsException.class) + .isInstanceOf(UncheckedSQLException.class) .hasMessageStartingWith("Table already exists: " + tableIdent); } } + @Test + public void testCommitExceptionWithPostgresUniqueViolation() { + TableIdentifier tableIdent = TableIdentifier.of("db", "tbl"); + BaseTable table = (BaseTable) catalog.buildTable(tableIdent, SCHEMA).create(); + TableOperations ops = table.operations(); + TableMetadata metadataV1 = ops.current(); + + table.updateSchema().addColumn("n", Types.IntegerType.get()).commit(); + ops.refresh(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(JdbcUtil.class)) { + mockedStatic + .when(() -> JdbcUtil.loadTable(any(), any(), any(), any())) + .thenThrow(new SQLException("unique violation", "23505")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); + assertThatThrownBy(() -> ops.commit(ops.current(), metadataV1)) + .isInstanceOf(UncheckedSQLException.class) + .hasMessageStartingWith("Table already exists: " + tableIdent); + } + } + + @Test + public void testCreateTableConstraintExceptionWithMessage() { + TableIdentifier existingIdent = TableIdentifier.of("db", "existing_tbl"); + BaseTable existing = (BaseTable) catalog.buildTable(existingIdent, SCHEMA).create(); + TableMetadata metadata = existing.operations().current(); + + TableIdentifier newIdent = TableIdentifier.of("db", "new_tbl"); + TableOperations ops = catalog.newTableOps(newIdent); + + try (MockedStatic mockedStatic = Mockito.mockStatic(JdbcUtil.class)) { + mockedStatic + .when(() -> JdbcUtil.loadTable(any(), any(), any(), any())) + .thenReturn(Maps.newHashMap()) + .thenThrow(new SQLException("constraint failed")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); + assertThatThrownBy(() -> ops.commit(null, metadata)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("Table already exists: " + newIdent); + } + } + + @Test + public void testCreateTableConstraintExceptionWithPostgresUniqueViolation() { + TableIdentifier existingIdent = TableIdentifier.of("db", "existing_tbl2"); + BaseTable existing = (BaseTable) catalog.buildTable(existingIdent, SCHEMA).create(); + TableMetadata metadata = existing.operations().current(); + + TableIdentifier newIdent = TableIdentifier.of("db", "new_tbl2"); + TableOperations ops = catalog.newTableOps(newIdent); + + try (MockedStatic mockedStatic = Mockito.mockStatic(JdbcUtil.class)) { + mockedStatic + .when(() -> JdbcUtil.loadTable(any(), any(), any(), any())) + .thenReturn(Maps.newHashMap()) + .thenThrow(new SQLException("unique violation", "23505")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); + assertThatThrownBy(() -> ops.commit(null, metadata)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("Table already exists: " + newIdent); + } + } + private String createMetadataLocationViaJdbcCatalog(TableIdentifier identifier) throws SQLException { // temporary connection just to actually create a concrete metadata location diff --git a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcTableConcurrency.java b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcTableConcurrency.java index db264e4ffc18..06fdc562a8ca 100644 --- a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcTableConcurrency.java +++ b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcTableConcurrency.java @@ -646,7 +646,8 @@ public void setAsciiStream(int parameterIndex, InputStream x, int length) throws delegate.setAsciiStream(parameterIndex, x, length); } - @Deprecated(since = "1.2") + // This is deprecated in JDK, we have to remove it once removed there. + @SuppressWarnings("deprecation") @Override public void setUnicodeStream(int parameterIndex, InputStream inputStream, int length) throws SQLException { diff --git a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcUtil.java b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcUtil.java index 4ac3a9301b4a..8128139da0b2 100644 --- a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcUtil.java +++ b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcUtil.java @@ -23,6 +23,8 @@ import java.nio.file.Files; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; import java.util.Map; import java.util.Properties; import org.apache.iceberg.catalog.Namespace; @@ -144,6 +146,28 @@ public void testV0toV1SqlStatements() throws Exception { } } + @Test + public void testIsConstraintViolationWithSQLIntegrityConstraintViolationException() { + assertThat(JdbcUtil.isConstraintViolation(new SQLIntegrityConstraintViolationException())) + .isTrue(); + } + + @Test + public void testIsConstraintViolationWithPostgresSQLState() { + assertThat(JdbcUtil.isConstraintViolation(new SQLException("unique violation", "23505"))) + .isTrue(); + } + + @Test + public void testIsConstraintViolationWithConstraintFailedMessage() { + assertThat(JdbcUtil.isConstraintViolation(new SQLException("constraint failed"))).isTrue(); + } + + @Test + public void testIsConstraintViolationWithPlainSQLException() { + assertThat(JdbcUtil.isConstraintViolation(new SQLException("some other error"))).isFalse(); + } + @Test public void emptyNamespaceInIdentifier() { assertThat(JdbcUtil.stringToTableIdentifier("", "tblName")) diff --git a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcViewCatalog.java b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcViewCatalog.java index 38c908a0c03c..b9f51dc5d93b 100644 --- a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcViewCatalog.java +++ b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcViewCatalog.java @@ -112,6 +112,7 @@ public void testCommitExceptionWithoutMessage() { mockedStatic .when(() -> JdbcUtil.loadView(any(), any(), any(), any())) .thenThrow(new SQLException()); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); assertThatThrownBy(() -> ops.commit(ops.current(), metadataV1)) .isInstanceOf(UncheckedSQLException.class) .hasMessageStartingWith("Unknown failure"); @@ -139,12 +140,97 @@ public void testCommitExceptionWithMessage() { mockedStatic .when(() -> JdbcUtil.loadView(any(), any(), any(), any())) .thenThrow(new SQLException("constraint failed")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); assertThatThrownBy(() -> ops.commit(ops.current(), metadataV1)) - .isInstanceOf(AlreadyExistsException.class) + .isInstanceOf(UncheckedSQLException.class) + .hasMessageStartingWith("View already exists: " + identifier); + } + } + + @Test + public void testCommitExceptionWithPostgresUniqueViolation() { + TableIdentifier identifier = TableIdentifier.of("namespace1", "view"); + BaseView view = + (BaseView) + catalog + .buildView(identifier) + .withQuery("spark", "select * from tbl") + .withSchema(SCHEMA) + .withDefaultNamespace(Namespace.of("namespace1")) + .create(); + ViewOperations ops = view.operations(); + ViewMetadata metadataV1 = ops.current(); + + view.updateProperties().set("k1", "v1").commit(); + ops.refresh(); + + try (MockedStatic mockedStatic = Mockito.mockStatic(JdbcUtil.class)) { + mockedStatic + .when(() -> JdbcUtil.loadView(any(), any(), any(), any())) + .thenThrow(new SQLException("unique violation", "23505")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); + assertThatThrownBy(() -> ops.commit(ops.current(), metadataV1)) + .isInstanceOf(UncheckedSQLException.class) .hasMessageStartingWith("View already exists: " + identifier); } } + @Test + public void testCreateViewConstraintExceptionWithMessage() { + TableIdentifier existingIdent = TableIdentifier.of("namespace1", "existing_view"); + BaseView existing = + (BaseView) + catalog + .buildView(existingIdent) + .withQuery("spark", "select * from tbl") + .withSchema(SCHEMA) + .withDefaultNamespace(Namespace.of("namespace1")) + .create(); + ViewMetadata metadata = existing.operations().current(); + + TableIdentifier newIdent = TableIdentifier.of("namespace1", "new_view"); + ViewOperations ops = catalog.newViewOps(newIdent); + + try (MockedStatic mockedStatic = Mockito.mockStatic(JdbcUtil.class)) { + mockedStatic + .when(() -> JdbcUtil.loadView(any(), any(), any(), any())) + .thenReturn(Maps.newHashMap()) + .thenThrow(new SQLException("constraint failed")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); + assertThatThrownBy(() -> ops.commit(null, metadata)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("View already exists: " + newIdent); + } + } + + @Test + public void testCreateViewConstraintExceptionWithPostgresUniqueViolation() { + TableIdentifier existingIdent = TableIdentifier.of("namespace1", "existing_view2"); + BaseView existing = + (BaseView) + catalog + .buildView(existingIdent) + .withQuery("spark", "select * from tbl") + .withSchema(SCHEMA) + .withDefaultNamespace(Namespace.of("namespace1")) + .create(); + ViewMetadata metadata = existing.operations().current(); + + TableIdentifier newIdent = TableIdentifier.of("namespace1", "new_view2"); + ViewOperations ops = catalog.newViewOps(newIdent); + + try (MockedStatic mockedStatic = Mockito.mockStatic(JdbcUtil.class)) { + mockedStatic + .when(() -> JdbcUtil.loadView(any(), any(), any(), any())) + .thenReturn(Maps.newHashMap()) + .thenThrow(new SQLException("unique violation", "23505")); + mockedStatic.when(() -> JdbcUtil.isConstraintViolation(any())).thenCallRealMethod(); + assertThatThrownBy(() -> ops.commit(null, metadata)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("View already exists: " + newIdent); + } + } + @Test public void dropViewShouldNotDropMetadataFileIfGcNotEnabled() { TableIdentifier identifier = TableIdentifier.of("namespace1", "view"); diff --git a/core/src/test/java/org/apache/iceberg/metrics/TestCacheMetricsReport.java b/core/src/test/java/org/apache/iceberg/metrics/TestCacheMetricsReport.java new file mode 100644 index 000000000000..bbaca531d68a --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/metrics/TestCacheMetricsReport.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Weigher; +import org.junit.jupiter.api.Test; + +public class TestCacheMetricsReport { + @Test + public void testNoInputStats() { + CacheMetricsReport cacheMetrics = CacheMetricsReport.of(Caffeine.newBuilder().build().stats()); + + assertThat(cacheMetrics.hitCount()).isZero(); + assertThat(cacheMetrics.missCount()).isZero(); + assertThat(cacheMetrics.evictionCount()).isZero(); + } + + @Test + public void testCacheMetricsFromCaffeineCache() { + int maxTotalWeight = 300; + + Cache inputCache = + Caffeine.newBuilder() + .maximumWeight(maxTotalWeight) + .weigher((Weigher) (key, value) -> value * 100) + .recordStats() + .build(); + + inputCache.get(1, key -> key); + inputCache.get(1, key -> key); + inputCache.get(2, key -> key); + inputCache.get(3, key -> key); // This evicts the other entries due to max weight + + inputCache.cleanUp(); + + CacheMetricsReport cacheMetrics = CacheMetricsReport.of(inputCache.stats()); + + assertThat(cacheMetrics.hitCount()).isOne(); + assertThat(cacheMetrics.missCount()).isEqualTo(3); + assertThat(cacheMetrics.evictionCount()).isEqualTo(2); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java index ff6daa61e35c..8c6dc52b1575 100644 --- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java +++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java @@ -53,9 +53,11 @@ import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.exceptions.NoSuchViewException; import org.apache.iceberg.exceptions.NotAuthorizedException; +import org.apache.iceberg.exceptions.NotFoundException; import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.exceptions.UnprocessableEntityException; import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.rest.HTTPRequest.HTTPMethod; @@ -68,6 +70,7 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; @@ -81,6 +84,12 @@ /** Adaptor class to translate REST requests into {@link Catalog} API calls. */ public class RESTCatalogAdapter extends BaseHTTPClient { + + @SuppressWarnings("AvoidEscapedUnicodeCharacters") + private static final String NAMESPACE_SEPARATOR_UNICODE = "\u002e"; + + @VisibleForTesting static final String NAMESPACE_SEPARATOR_URLENCODED_UTF_8 = "%2E"; + private static final Map, Integer> EXCEPTION_ERROR_CODES = ImmutableMap., Integer>builder() .put(IllegalArgumentException.class, 400) @@ -90,6 +99,7 @@ public class RESTCatalogAdapter extends BaseHTTPClient { .put(ForbiddenException.class, 403) .put(NoSuchNamespaceException.class, 404) .put(NoSuchTableException.class, 404) + .put(NotFoundException.class, 404) .put(NoSuchViewException.class, 404) .put(NoSuchIcebergTableException.class, 404) .put(UnsupportedOperationException.class, 406) @@ -106,7 +116,7 @@ public class RESTCatalogAdapter extends BaseHTTPClient { private final ViewCatalog asViewCatalog; private AuthSession authSession = AuthSession.EMPTY; - private final PlanningBehavior planningBehavior = planningBehavior(); + private PlanningBehavior planningBehavior; public RESTCatalogAdapter(Catalog catalog) { this.catalog = catalog; @@ -168,13 +178,15 @@ public T handleRequest( Arrays.stream(Route.values()) .map(r -> Endpoint.create(r.method().name(), r.resourcePath())) .collect(Collectors.toList())) + .withOverride( + RESTCatalogProperties.NAMESPACE_SEPARATOR, NAMESPACE_SEPARATOR_URLENCODED_UTF_8) .build()); case LIST_NAMESPACES: if (asNamespaceCatalog != null) { Namespace ns; if (vars.containsKey("parent")) { - ns = RESTUtil.namespaceFromQueryParam(vars.get("parent")); + ns = RESTUtil.namespaceFromQueryParam(vars.get("parent"), NAMESPACE_SEPARATOR_UNICODE); } else { ns = Namespace.empty(); } @@ -196,8 +208,11 @@ public T handleRequest( case CREATE_NAMESPACE: if (asNamespaceCatalog != null) { CreateNamespaceRequest request = castRequest(CreateNamespaceRequest.class, body); - return castResponse( - responseType, CatalogHandlers.createNamespace(asNamespaceCatalog, request)); + return CatalogHandlers.withIdempotency( + httpRequest, + () -> + castResponse( + responseType, CatalogHandlers.createNamespace(asNamespaceCatalog, request))); } break; @@ -218,7 +233,9 @@ public T handleRequest( case DROP_NAMESPACE: if (asNamespaceCatalog != null) { - CatalogHandlers.dropNamespace(asNamespaceCatalog, namespaceFromPathVars(vars)); + CatalogHandlers.withIdempotency( + httpRequest, + () -> CatalogHandlers.dropNamespace(asNamespaceCatalog, namespaceFromPathVars(vars))); return null; } break; @@ -228,9 +245,13 @@ public T handleRequest( Namespace namespace = namespaceFromPathVars(vars); UpdateNamespacePropertiesRequest request = castRequest(UpdateNamespacePropertiesRequest.class, body); - return castResponse( - responseType, - CatalogHandlers.updateNamespaceProperties(asNamespaceCatalog, namespace, request)); + return CatalogHandlers.withIdempotency( + httpRequest, + () -> + castResponse( + responseType, + CatalogHandlers.updateNamespaceProperties( + asNamespaceCatalog, namespace, request))); } break; @@ -257,19 +278,30 @@ public T handleRequest( return castResponse( responseType, CatalogHandlers.stageTableCreate(catalog, namespace, request)); } else { - LoadTableResponse response = CatalogHandlers.createTable(catalog, namespace, request); - responseHeaders.accept( - ImmutableMap.of(HttpHeaders.ETAG, ETagProvider.of(response.metadataLocation()))); - return castResponse(responseType, response); + return CatalogHandlers.withIdempotency( + httpRequest, + () -> { + LoadTableResponse response = + CatalogHandlers.createTable(catalog, namespace, request); + responseHeaders.accept( + ImmutableMap.of( + HttpHeaders.ETAG, + ETagProvider.of(response.metadataLocation(), defaultQueryParams()))); + return castResponse(responseType, response); + }); } } case DROP_TABLE: { if (PropertyUtil.propertyAsBoolean(vars, "purgeRequested", false)) { - CatalogHandlers.purgeTable(catalog, tableIdentFromPathVars(vars)); + CatalogHandlers.withIdempotency( + httpRequest, + () -> CatalogHandlers.purgeTable(catalog, tableIdentFromPathVars(vars))); } else { - CatalogHandlers.dropTable(catalog, tableIdentFromPathVars(vars)); + CatalogHandlers.withIdempotency( + httpRequest, + () -> CatalogHandlers.dropTable(catalog, tableIdentFromPathVars(vars))); } return null; } @@ -292,7 +324,7 @@ public T handleRequest( Optional ifNoneMatchHeader = httpRequest.headers().firstEntry(HttpHeaders.IF_NONE_MATCH); - String eTag = ETagProvider.of(response.metadataLocation()); + String eTag = ETagProvider.of(response.metadataLocation(), httpRequest.queryParameters()); if (ifNoneMatchHeader.isPresent() && eTag.equals(ifNoneMatchHeader.get().value())) { return null; @@ -313,8 +345,8 @@ public T handleRequest( catalog, ident, request, - planningBehavior::shouldPlanTableScanAsync, - scan -> planningBehavior.numberFileScanTasksPerPlanTask())); + planningBehavior()::shouldPlanTableScanAsync, + scan -> planningBehavior().numberFileScanTasksPerPlanTask())); } case FETCH_PLANNING_RESULT: @@ -341,36 +373,49 @@ public T handleRequest( case REGISTER_TABLE: { - LoadTableResponse response = - CatalogHandlers.registerTable( - catalog, - namespaceFromPathVars(vars), - castRequest(RegisterTableRequest.class, body)); - - responseHeaders.accept( - ImmutableMap.of(HttpHeaders.ETAG, ETagProvider.of(response.metadataLocation()))); - - return castResponse(responseType, response); + return CatalogHandlers.withIdempotency( + httpRequest, + () -> { + LoadTableResponse response = + CatalogHandlers.registerTable( + catalog, + namespaceFromPathVars(vars), + castRequest(RegisterTableRequest.class, body)); + + responseHeaders.accept( + ImmutableMap.of( + HttpHeaders.ETAG, + ETagProvider.of(response.metadataLocation(), defaultQueryParams()))); + + return castResponse(responseType, response); + }); } case UPDATE_TABLE: { - LoadTableResponse response = - CatalogHandlers.updateTable( - catalog, - tableIdentFromPathVars(vars), - castRequest(UpdateTableRequest.class, body)); - - responseHeaders.accept( - ImmutableMap.of(HttpHeaders.ETAG, ETagProvider.of(response.metadataLocation()))); - - return castResponse(responseType, response); + return CatalogHandlers.withIdempotency( + httpRequest, + () -> { + LoadTableResponse response = + CatalogHandlers.updateTable( + catalog, + tableIdentFromPathVars(vars), + castRequest(UpdateTableRequest.class, body)); + + responseHeaders.accept( + ImmutableMap.of( + HttpHeaders.ETAG, + ETagProvider.of(response.metadataLocation(), defaultQueryParams()))); + + return castResponse(responseType, response); + }); } case RENAME_TABLE: { RenameTableRequest request = castRequest(RenameTableRequest.class, body); - CatalogHandlers.renameTable(catalog, request); + CatalogHandlers.withIdempotency( + httpRequest, () -> CatalogHandlers.renameTable(catalog, request)); return null; } @@ -384,7 +429,7 @@ public T handleRequest( case COMMIT_TRANSACTION: { CommitTransactionRequest request = castRequest(CommitTransactionRequest.class, body); - commitTransaction(catalog, request); + CatalogHandlers.withIdempotency(httpRequest, () -> commitTransaction(catalog, request)); return null; } @@ -411,8 +456,12 @@ public T handleRequest( if (null != asViewCatalog) { Namespace namespace = namespaceFromPathVars(vars); CreateViewRequest request = castRequest(CreateViewRequest.class, body); - return castResponse( - responseType, CatalogHandlers.createView(asViewCatalog, namespace, request)); + return CatalogHandlers.withIdempotency( + httpRequest, + () -> + castResponse( + responseType, + CatalogHandlers.createView(asViewCatalog, namespace, request))); } break; } @@ -440,8 +489,11 @@ public T handleRequest( if (null != asViewCatalog) { TableIdentifier ident = viewIdentFromPathVars(vars); UpdateTableRequest request = castRequest(UpdateTableRequest.class, body); - return castResponse( - responseType, CatalogHandlers.updateView(asViewCatalog, ident, request)); + return CatalogHandlers.withIdempotency( + httpRequest, + () -> + castResponse( + responseType, CatalogHandlers.updateView(asViewCatalog, ident, request))); } break; } @@ -450,7 +502,8 @@ public T handleRequest( { if (null != asViewCatalog) { RenameTableRequest request = castRequest(RenameTableRequest.class, body); - CatalogHandlers.renameView(asViewCatalog, request); + CatalogHandlers.withIdempotency( + httpRequest, () -> CatalogHandlers.renameView(asViewCatalog, request)); return null; } break; @@ -459,12 +512,25 @@ public T handleRequest( case DROP_VIEW: { if (null != asViewCatalog) { - CatalogHandlers.dropView(asViewCatalog, viewIdentFromPathVars(vars)); + CatalogHandlers.withIdempotency( + httpRequest, + () -> CatalogHandlers.dropView(asViewCatalog, viewIdentFromPathVars(vars))); return null; } break; } + case REGISTER_VIEW: + { + if (null != asViewCatalog) { + Namespace namespace = namespaceFromPathVars(vars); + RegisterViewRequest request = castRequest(RegisterViewRequest.class, body); + return castResponse( + responseType, CatalogHandlers.registerView(asViewCatalog, namespace, request)); + } + break; + } + default: if (responseType == OAuthTokenResponse.class) { return castResponse(responseType, handleOAuthRequest(body)); @@ -474,6 +540,12 @@ public T handleRequest( return null; } + private static Map defaultQueryParams() { + return Map.of( + RESTCatalogProperties.SNAPSHOTS_QUERY_PARAMETER, + SnapshotMode.ALL.toString().toLowerCase(Locale.US)); + } + /** * This is a very simplistic approach that only validates the requirements for each table and does * not do any other conflict detection. Therefore, it does not guarantee true transactional @@ -557,8 +629,10 @@ protected T execute( vars.putAll(request.queryParameters()); vars.putAll(routeAndVars.second()); - return handleRequest( - routeAndVars.first(), vars.build(), request, responseType, responseHeaders); + T resp = + handleRequest( + routeAndVars.first(), vars.build(), request, responseType, responseHeaders); + return resp; } catch (RuntimeException e) { configureResponseFromException(e, errorBuilder); } @@ -595,7 +669,11 @@ default boolean shouldPlanTableScanAsync(Scan scan) { } protected PlanningBehavior planningBehavior() { - return new PlanningBehavior() {}; + return this.planningBehavior == null ? new PlanningBehavior() {} : planningBehavior; + } + + protected void setPlanningBehavior(PlanningBehavior behavior) { + this.planningBehavior = behavior; } @Override @@ -645,7 +723,8 @@ public static void configureResponseFromException( } private static Namespace namespaceFromPathVars(Map pathVars) { - return RESTUtil.decodeNamespace(pathVars.get("namespace")); + return RESTUtil.decodeNamespace( + pathVars.get("namespace"), NAMESPACE_SEPARATOR_URLENCODED_UTF_8); } private static TableIdentifier tableIdentFromPathVars(Map pathVars) { @@ -665,7 +744,9 @@ private static String planIDFromPathVars(Map pathVars) { private static SnapshotMode snapshotModeFromQueryParams(Map queryParams) { return SnapshotMode.valueOf( queryParams - .getOrDefault("snapshots", RESTCatalogProperties.SNAPSHOT_LOADING_MODE_DEFAULT) + .getOrDefault( + RESTCatalogProperties.SNAPSHOTS_QUERY_PARAMETER, + RESTCatalogProperties.SNAPSHOT_LOADING_MODE_DEFAULT.name()) .toUpperCase(Locale.US)); } } diff --git a/core/src/test/java/org/apache/iceberg/rest/RemoteSignerServlet.java b/core/src/test/java/org/apache/iceberg/rest/RemoteSignerServlet.java new file mode 100644 index 000000000000..c55224c00b2f --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/RemoteSignerServlet.java @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import static java.lang.String.format; +import static org.apache.iceberg.rest.RESTCatalogAdapter.castRequest; +import static org.apache.iceberg.rest.RESTCatalogAdapter.castResponse; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHeaders; +import org.apache.iceberg.exceptions.RESTException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.io.CharStreams; +import org.apache.iceberg.rest.requests.RemoteSignRequest; +import org.apache.iceberg.rest.responses.OAuthTokenResponse; +import org.apache.iceberg.rest.responses.RemoteSignResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base servlet for remote signing tests. This servlet handles OAuth token requests and delegates + * signing to subclasses. It does not handle any other requests. + * + *

Subclasses must implement {@link #signRequest(RemoteSignRequest)} to provide the actual + * signing logic. + */ +public abstract class RemoteSignerServlet extends HttpServlet { + + private static final Logger LOG = LoggerFactory.getLogger(RemoteSignerServlet.class); + private static final String POST = "POST"; + + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String CACHE_CONTROL_PRIVATE = "private"; + private static final String CACHE_CONTROL_NO_CACHE = "no-cache"; + + private static final Set CACHEABLE_METHODS = Set.of("GET", "HEAD"); + + private static final Map RESPONSE_HEADERS = + ImmutableMap.of(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); + + private final String signEndpoint; + + protected RemoteSignerServlet(String signEndpoint) { + this.signEndpoint = signEndpoint; + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) { + execute(request, response); + } + + @Override + protected void doHead(HttpServletRequest request, HttpServletResponse response) { + execute(request, response); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) { + execute(request, response); + } + + @Override + protected void doDelete(HttpServletRequest request, HttpServletResponse response) { + execute(request, response); + } + + /** + * Sign the given request and return the signed response. + * + * @param request the remote sign request + * @return the signed response + */ + protected abstract RemoteSignResponse signRequest(RemoteSignRequest request); + + /** + * Called after a sign request is parsed but before signing. Subclasses can override to add + * additional validation. + * + * @param request the remote sign request + */ + protected void validateSignRequest(RemoteSignRequest request) { + // no-op by default + } + + /** + * Called after signing to allow subclasses to add response headers (e.g., cache control). By + * default, this method adds cache control headers based on the request method. + * + * @param request the original sign request + * @param response the HTTP response to add headers to + */ + protected void addSignResponseHeaders(RemoteSignRequest request, HttpServletResponse response) { + if (CACHEABLE_METHODS.contains(request.method().toUpperCase(Locale.ROOT))) { + // tell the client this can be cached + response.setHeader(CACHE_CONTROL, CACHE_CONTROL_PRIVATE); + } else { + response.setHeader(CACHE_CONTROL, CACHE_CONTROL_NO_CACHE); + } + } + + private OAuthTokenResponse handleOAuth(Map requestMap) { + String grantType = requestMap.get("grant_type"); + switch (grantType) { + case "client_credentials": + return castResponse( + OAuthTokenResponse.class, + OAuthTokenResponse.builder() + .withToken("client-credentials-token:sub=" + requestMap.get("client_id")) + .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token") + .withTokenType("Bearer") + .setExpirationInSeconds(10000) + .build()); + + case "urn:ietf:params:oauth:grant-type:token-exchange": + String actor = requestMap.get("actor_token"); + String token = + String.format( + "token-exchange-token:sub=%s%s", + requestMap.get("subject_token"), actor != null ? ",act=" + actor : ""); + return castResponse( + OAuthTokenResponse.class, + OAuthTokenResponse.builder() + .withToken(token) + .withIssuedTokenType("urn:ietf:params:oauth:token-type:access_token") + .withTokenType("Bearer") + .setExpirationInSeconds(10000) + .build()); + + default: + throw new UnsupportedOperationException("Unsupported grant_type: " + grantType); + } + } + + protected void execute(HttpServletRequest request, HttpServletResponse response) { + response.setStatus(HttpServletResponse.SC_OK); + RESPONSE_HEADERS.forEach(response::setHeader); + + String path = request.getRequestURI().substring(1); + Object requestBody; + try { + if (POST.equals(request.getMethod()) && signEndpoint.equals(path)) { + RemoteSignRequest signRequest = + castRequest( + RemoteSignRequest.class, + RESTObjectMapper.mapper().readValue(request.getReader(), RemoteSignRequest.class)); + validateSignRequest(signRequest); + RemoteSignResponse signResponse = signRequest(signRequest); + addSignResponseHeaders(signRequest, response); + RESTObjectMapper.mapper().writeValue(response.getWriter(), signResponse); + } else if (POST.equals(request.getMethod()) && ResourcePaths.tokens().equals(path)) { + try (Reader reader = new InputStreamReader(request.getInputStream())) { + requestBody = RESTUtil.decodeFormData(CharStreams.toString(reader)); + } + + @SuppressWarnings("unchecked") + OAuthTokenResponse oAuthTokenResponse = + handleOAuth((Map) castRequest(Map.class, requestBody)); + RESTObjectMapper.mapper().writeValue(response.getWriter(), oAuthTokenResponse); + } else { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + RESTObjectMapper.mapper() + .writeValue( + response.getWriter(), + org.apache.iceberg.rest.responses.ErrorResponse.builder() + .responseCode(400) + .withType("BadRequestException") + .withMessage(format("No route for request: %s %s", request.getMethod(), path)) + .build()); + } + } catch (RESTException e) { + LOG.error("Error processing REST request", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } catch (Exception e) { + LOG.error("Unexpected exception when processing REST request", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/RequestMatcher.java b/core/src/test/java/org/apache/iceberg/rest/RequestMatcher.java new file mode 100644 index 000000000000..fb93b7c9da5a --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/RequestMatcher.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import static org.mockito.ArgumentMatchers.argThat; + +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +class RequestMatcher { + private RequestMatcher() {} + + public static HTTPRequest matches(HTTPRequest.HTTPMethod method) { + return argThat(req -> req.method() == method); + } + + static HTTPRequest matches(HTTPRequest.HTTPMethod method, String path) { + return argThat(req -> req.method() == method && req.path().equals(path)); + } + + public static HTTPRequest matches( + HTTPRequest.HTTPMethod method, String path, Map headers) { + return argThat( + req -> + req.method() == method + && req.path().equals(path) + && req.headers().equals(HTTPHeaders.of(headers))); + } + + public static HTTPRequest matches( + HTTPRequest.HTTPMethod method, + String path, + Map headers, + Map parameters) { + return argThat( + req -> + req.method() == method + && req.path().equals(path) + && req.headers().equals(HTTPHeaders.of(headers)) + && req.queryParameters().equals(parameters)); + } + + public static HTTPRequest matches( + HTTPRequest.HTTPMethod method, + String path, + Map headers, + Map parameters, + Object body) { + return argThat( + req -> + req.method() == method + && req.path().equals(path) + && req.headers().equals(HTTPHeaders.of(headers)) + && req.queryParameters().equals(parameters) + && Objects.equals(req.body(), body)); + } + + static HTTPRequest matches( + HTTPRequest.HTTPMethod method, + String path, + Map headers, + Map parameters, + Predicate pred) { + return argThat( + req -> + req.method() == method + && req.path().equals(path) + && req.headers().equals(HTTPHeaders.of(headers)) + && req.queryParameters().equals(parameters) + && pred.test(req.body())); + } + + public static HTTPRequest containsHeaders( + HTTPRequest.HTTPMethod method, String path, Map headers) { + return argThat( + req -> + req.method() == method + && req.path().equals(path) + && req.headers().entries().containsAll(HTTPHeaders.of(headers).entries())); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/Route.java b/core/src/test/java/org/apache/iceberg/rest/Route.java index eedb2615ad64..8680915bff64 100644 --- a/core/src/test/java/org/apache/iceberg/rest/Route.java +++ b/core/src/test/java/org/apache/iceberg/rest/Route.java @@ -29,6 +29,7 @@ import org.apache.iceberg.rest.requests.FetchScanTasksRequest; import org.apache.iceberg.rest.requests.PlanTableScanRequest; import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.RegisterViewRequest; import org.apache.iceberg.rest.requests.RenameTableRequest; import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest; @@ -115,6 +116,11 @@ enum Route { RENAME_VIEW( HTTPRequest.HTTPMethod.POST, ResourcePaths.V1_VIEW_RENAME, RenameTableRequest.class, null), DROP_VIEW(HTTPRequest.HTTPMethod.DELETE, ResourcePaths.V1_VIEW), + REGISTER_VIEW( + HTTPRequest.HTTPMethod.POST, + ResourcePaths.V1_VIEW_REGISTER, + RegisterViewRequest.class, + LoadViewResponse.class), PLAN_TABLE_SCAN( HTTPRequest.HTTPMethod.POST, ResourcePaths.V1_TABLE_SCAN_PLAN_SUBMIT, diff --git a/core/src/test/java/org/apache/iceberg/rest/TestBaseWithRESTServer.java b/core/src/test/java/org/apache/iceberg/rest/TestBaseWithRESTServer.java new file mode 100644 index 000000000000..9cab8b1f240e --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestBaseWithRESTServer.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SessionCatalog; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.responses.ErrorResponse; +import org.eclipse.jetty.compression.gzip.GzipCompression; +import org.eclipse.jetty.compression.server.CompressionHandler; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +public abstract class TestBaseWithRESTServer { + protected static final ObjectMapper MAPPER = RESTObjectMapper.mapper(); + protected static final Namespace NS = Namespace.of("ns"); + protected static final SessionCatalog.SessionContext DEFAULT_SESSION_CONTEXT = + new SessionCatalog.SessionContext( + UUID.randomUUID().toString(), + "user", + ImmutableMap.of("credential", "user:12345"), + ImmutableMap.of()); + + protected InMemoryCatalog backendCatalog; + protected RESTCatalogAdapter adapterForRESTServer; + protected Server httpServer; + protected RESTCatalog restCatalog; + protected ParserContext parserContext; + + @TempDir private Path temp; + + /** + * GZIP responses interfere with freshness-aware loading tests that assert on {@code ETag} and + * conditional requests. Subclasses may disable HTTP compression while keeping the default for + * other REST catalog tests. + */ + protected boolean useHttpCompression() { + return true; + } + + @BeforeEach + public void before() throws Exception { + File warehouse = temp.toFile(); + this.backendCatalog = new InMemoryCatalog(); + this.backendCatalog.initialize( + "in-memory", + ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, warehouse.getAbsolutePath())); + + adapterForRESTServer = createAdapterForServer(); + + ServletContextHandler servletContext = + new ServletContextHandler(ServletContextHandler.NO_SESSIONS); + servletContext.addServlet( + new ServletHolder(new RESTCatalogServlet(adapterForRESTServer)), "/*"); + if (useHttpCompression()) { + CompressionHandler compressionHandler = new CompressionHandler(); + compressionHandler.putCompression(new GzipCompression()); + servletContext.insertHandler(compressionHandler); + } + + this.httpServer = new Server(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); + httpServer.setHandler(servletContext); + httpServer.start(); + + restCatalog = initCatalog(catalogName(), additionalCatalogProperties()); + } + + @AfterEach + public void after() throws Exception { + if (restCatalog != null) { + restCatalog.close(); + } + + if (backendCatalog != null) { + backendCatalog.close(); + } + + if (httpServer != null) { + httpServer.stop(); + httpServer.join(); + } + } + + protected RESTCatalogAdapter createAdapterForServer() { + return Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + Object body = roundTripSerialize(request.body(), "request"); + HTTPRequest req = ImmutableHTTPRequest.builder().from(request).body(body).build(); + T response = super.execute(req, responseType, errorHandler, responseHeaders); + return roundTripSerialize(response, "response"); + } + }); + } + + protected abstract String catalogName(); + + protected Map additionalCatalogProperties() { + return ImmutableMap.of(); + } + + @SuppressWarnings("unchecked") + protected T roundTripSerialize(T payload, String description) { + if (payload != null) { + try { + if (payload instanceof RESTMessage) { + return (T) MAPPER.readValue(MAPPER.writeValueAsString(payload), payload.getClass()); + } else { + // use Map so that Jackson doesn't try to instantiate ImmutableMap from payload.getClass() + return (T) MAPPER.readValue(MAPPER.writeValueAsString(payload), Map.class); + } + } catch (JsonProcessingException e) { + throw new RuntimeException( + String.format("Failed to serialize and deserialize %s: %s", description, payload), e); + } + } + return null; + } + + private RESTCatalog initCatalog(String catalogName, Map additionalProperties) { + RESTCatalog catalog = + new RESTCatalog( + DEFAULT_SESSION_CONTEXT, + (config) -> + HTTPClient.builder(config) + .uri(config.get(CatalogProperties.URI)) + .withHeaders(RESTUtil.configHeaders(config)) + .build()); + catalog.setConf(new Configuration()); + Map properties = + ImmutableMap.of( + CatalogProperties.URI, + httpServer.getURI().toString(), + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO"); + catalog.initialize( + catalogName, + ImmutableMap.builder() + .putAll(properties) + .putAll(additionalProperties) + .build()); + + return catalog; + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestETagProvider.java b/core/src/test/java/org/apache/iceberg/rest/TestETagProvider.java index 1a3971492d84..aaf5c3442e1c 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestETagProvider.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestETagProvider.java @@ -21,30 +21,49 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.Map; import org.junit.jupiter.api.Test; public class TestETagProvider { + static final String METADATA_LOCATION = + "/var/folders/20/290st0_52y5fyjcj2mlg49500000gn/T/junit-3064022805908958416/db_name/tbl_name/metadata/00000-f7a7956e-61d0-499b-be60-b141283f8229.metadata.json"; + @Test public void testNullInput() { - assertThatThrownBy(() -> ETagProvider.of(null)) + assertThatThrownBy(() -> ETagProvider.of(null, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Invalid metadata location: null"); } @Test public void testEmptyInput() { - assertThatThrownBy(() -> ETagProvider.of("")) + assertThatThrownBy(() -> ETagProvider.of("", null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Invalid metadata location: empty"); } @Test public void testETagContent() { - assertThat("1f865717") + assertThat("90b8ad4e") + .isEqualTo( + ETagProvider.of(METADATA_LOCATION, Map.of("param1", "value1", "param2", "value2"))); + + assertThat("cb787e6a") .isEqualTo( ETagProvider.of( - "/var/folders/20/290st0_52y5fyjcj2mlg49500000gn/T/junit-3064022805908958416/db_name/tbl_name/metadata/00000-f7a7956e-61d0-499b-be60-b141283f8229.metadata.json")); + METADATA_LOCATION, Map.of("param1", "other_value1", "param2", "other_value2"))); + + assertThat("55faa5d9").isEqualTo(ETagProvider.of("/short/path", null)); + + assertThat("55faa5d9").isEqualTo(ETagProvider.of("/short/path", Map.of())); - assertThat("55faa5d9").isEqualTo(ETagProvider.of("/short/path")); + assertThat("8adf3766").isEqualTo(ETagProvider.of("/short/path", Map.of("param", "some_value"))); + } + + @Test + public void testDifferentParameterOrderGiveSameETag() { + assertThat(ETagProvider.of(METADATA_LOCATION, Map.of("param1", "value1", "param2", "value2"))) + .isEqualTo( + ETagProvider.of(METADATA_LOCATION, Map.of("param2", "value2", "param1", "value1"))); } } diff --git a/core/src/test/java/org/apache/iceberg/rest/TestErrorHandlers.java b/core/src/test/java/org/apache/iceberg/rest/TestErrorHandlers.java new file mode 100644 index 000000000000..b7bbe337cd27 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestErrorHandlers.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.exceptions.NoSuchWarehouseException; +import org.apache.iceberg.exceptions.RESTException; +import org.apache.iceberg.exceptions.ServiceFailureException; +import org.apache.iceberg.rest.responses.ErrorResponse; +import org.junit.jupiter.api.Test; + +public class TestErrorHandlers { + + @Test + public void errorHandlerIncludesCodeAndType() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(422) + .withType("ValidationException") + .withMessage("Invalid input") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.defaultErrorHandler().accept(error)) + .isInstanceOf(RESTException.class) + .hasMessage("Unable to process (code: 422, type: ValidationException): Invalid input"); + } + + @Test + public void errorHandlerWithCodeOnly() { + ErrorResponse error = ErrorResponse.builder().responseCode(422).build(); + + assertThatThrownBy(() -> ErrorHandlers.defaultErrorHandler().accept(error)) + .isInstanceOf(RESTException.class) + .hasMessage("Unable to process (code: 422, type: null): null"); + } + + @Test + public void errorHandlerWithCodeAndMessageOnly() { + ErrorResponse error = + ErrorResponse.builder().responseCode(422).withMessage("Invalid input").build(); + + assertThatThrownBy(() -> ErrorHandlers.defaultErrorHandler().accept(error)) + .isInstanceOf(RESTException.class) + .hasMessage("Unable to process (code: 422, type: null): Invalid input"); + } + + @Test + public void errorHandlerWithCodeAndTypeOnly() { + ErrorResponse error = + ErrorResponse.builder().responseCode(422).withType("ValidationException").build(); + + assertThatThrownBy(() -> ErrorHandlers.defaultErrorHandler().accept(error)) + .isInstanceOf(RESTException.class) + .hasMessage("Unable to process (code: 422, type: ValidationException): null"); + } + + @Test + public void testConfigErrorHandler404ThrowsNoSuchWarehouseException() { + ErrorResponse error = + ErrorResponse.builder() + .responseCode(404) + .withType("NotFoundException") + .withMessage("Warehouse not found") + .build(); + + assertThatThrownBy(() -> ErrorHandlers.configErrorHandler().accept(error)) + .isInstanceOf(NoSuchWarehouseException.class) + .hasMessage("Warehouse not found"); + } + + @Test + public void testConfigErrorHandler404ForMisconfiguredUri() { + ErrorResponse error = + ErrorResponse.builder().responseCode(404).withMessage("Not Found").build(); + + assertThatThrownBy(() -> ErrorHandlers.configErrorHandler().accept(error)) + .isInstanceOf(RESTException.class) + .hasMessageContaining("Not Found"); + } + + @Test + public void testConfigErrorHandlerDelegatesToDefaultForNon404() { + ErrorResponse error = + ErrorResponse.builder().responseCode(500).withMessage("Internal server error").build(); + + assertThatThrownBy(() -> ErrorHandlers.configErrorHandler().accept(error)) + .isInstanceOf(ServiceFailureException.class) + .hasMessageContaining("Internal server error"); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestFreshnessAwareLoading.java b/core/src/test/java/org/apache/iceberg/rest/TestFreshnessAwareLoading.java new file mode 100644 index 000000000000..a4bb170d1411 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestFreshnessAwareLoading.java @@ -0,0 +1,970 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import static org.apache.iceberg.TestBase.FILE_A; +import static org.apache.iceberg.TestBase.FILE_B; +import static org.apache.iceberg.TestBase.SCHEMA; +import static org.apache.iceberg.rest.RESTTableCache.SessionIdTableId; +import static org.apache.iceberg.rest.RESTTableCache.TableWithETag; +import static org.apache.iceberg.rest.RequestMatcher.matches; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.github.benmanes.caffeine.cache.Cache; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import org.apache.http.HttpHeaders; +import org.apache.iceberg.BaseMetadataTable; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.catalog.SessionCatalog; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.exceptions.RESTException; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.rest.responses.ConfigResponse; +import org.apache.iceberg.rest.responses.ErrorResponse; +import org.apache.iceberg.rest.responses.LoadTableResponse; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.FakeTicker; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +public class TestFreshnessAwareLoading extends TestBaseWithRESTServer { + @Override + protected boolean useHttpCompression() { + return false; + } + + private static final ResourcePaths RESOURCE_PATHS = + ResourcePaths.forCatalogProperties( + ImmutableMap.of( + RESTCatalogProperties.NAMESPACE_SEPARATOR, + RESTCatalogAdapter.NAMESPACE_SEPARATOR_URLENCODED_UTF_8)); + private static final TableIdentifier TABLE = TableIdentifier.of(NS, "newtable"); + private static final Duration TABLE_EXPIRATION = + Duration.ofMillis(RESTCatalogProperties.TABLE_CACHE_EXPIRE_AFTER_WRITE_MS_DEFAULT); + private static final Duration HALF_OF_TABLE_EXPIRATION = TABLE_EXPIRATION.dividedBy(2); + + @Override + protected String catalogName() { + return "catalog-freshness-aware-loading"; + } + + @Test + public void eTagWithCreateAndLoadTable() { + Map respHeaders = Maps.newHashMap(); + RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); + + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + String eTag = respHeaders.get(HttpHeaders.ETAG); + respHeaders.clear(); + + catalog.loadTable(TABLE); + + assertThat(respHeaders).containsEntry(HttpHeaders.ETAG, eTag); + } + + @Test + public void eTagWithDifferentTables() { + Map respHeaders = Maps.newHashMap(); + RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); + + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + String eTagTbl1 = respHeaders.get(HttpHeaders.ETAG); + respHeaders.clear(); + + catalog.createTable(TableIdentifier.of(TABLE.namespace(), "table2"), SCHEMA); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + assertThat(eTagTbl1).isNotEqualTo(respHeaders.get(HttpHeaders.ETAG)); + } + + @Test + public void eTagAfterDataUpdate() { + Map respHeaders = Maps.newHashMap(); + RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); + + catalog.createNamespace(TABLE.namespace()); + Table tbl = catalog.createTable(TABLE, SCHEMA); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + String eTag = respHeaders.get(HttpHeaders.ETAG); + + respHeaders.clear(); + tbl.newAppend().appendFile(FILE_A).commit(); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + assertThat(eTag).isNotEqualTo(respHeaders.get(HttpHeaders.ETAG)); + } + + @Test + public void eTagAfterMetadataOnlyUpdate() { + Map respHeaders = Maps.newHashMap(); + RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); + + catalog.createNamespace(TABLE.namespace()); + Table tbl = catalog.createTable(TABLE, SCHEMA); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + String eTag = respHeaders.get(HttpHeaders.ETAG); + + respHeaders.clear(); + tbl.updateSchema().addColumn("extra", Types.IntegerType.get()).commit(); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + assertThat(eTag).isNotEqualTo(respHeaders.get(HttpHeaders.ETAG)); + } + + @Test + public void eTagWithRegisterTable() { + Map respHeaders = Maps.newHashMap(); + RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); + + catalog.createNamespace(TABLE.namespace()); + Table tbl = catalog.createTable(TABLE, SCHEMA); + + assertThat(respHeaders).containsKey(HttpHeaders.ETAG); + String eTag = respHeaders.get(HttpHeaders.ETAG); + + respHeaders.clear(); + catalog.registerTable( + TableIdentifier.of(TABLE.namespace(), "other_table"), + ((BaseTable) tbl).operations().current().metadataFileLocation()); + + assertThat(respHeaders).containsEntry(HttpHeaders.ETAG, eTag); + } + + @Test + public void differentETagForDifferentSnapshotMode() { + Map responseHeaders = Maps.newHashMap(); + RESTCatalogAdapter adapter = adapterCapturingResponseHeaders(responseHeaders); + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), config -> adapter); + catalog.initialize( + "test", + ImmutableMap.of( + RESTCatalogProperties.SNAPSHOT_LOADING_MODE, + RESTCatalogProperties.SnapshotMode.REFS.name())); + + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + + assertThat(responseHeaders).containsKey(HttpHeaders.ETAG); + String eTagForCreateTable = responseHeaders.get(HttpHeaders.ETAG); + responseHeaders.clear(); + + catalog.loadTable(TABLE); + + assertThat(responseHeaders).containsKey(HttpHeaders.ETAG); + assertThat(eTagForCreateTable).isNotEqualTo(responseHeaders.get(HttpHeaders.ETAG)); + + // Verify that table load used the refs query parameter + verify(adapter, times(1)) + .execute( + matches( + HTTPRequest.HTTPMethod.GET, + RESOURCE_PATHS.table(TABLE), + Map.of(), + Map.of("snapshots", "refs")), + eq(LoadTableResponse.class), + any(), + any()); + } + + @Test + public void notModifiedResponse() { + // Capture the response headers from createTable to get an ETag. + Map responseHeaders = Maps.newHashMap(); + Mockito.doAnswer( + invocation -> + adapterForRESTServer.execute( + invocation.getArgument(0), + invocation.getArgument(1), + invocation.getArgument(2), + responseHeaders::putAll, + ParserContext.builder().build())) + .when(adapterForRESTServer) + .execute( + matches(HTTPRequest.HTTPMethod.POST, RESOURCE_PATHS.tables(TABLE.namespace())), + eq(LoadTableResponse.class), + any(), + any()); + + restCatalog.createNamespace(TABLE.namespace()); + restCatalog.createTable(TABLE, SCHEMA); + restCatalog.loadTable(TABLE); + + assertThat(responseHeaders).containsKeys(HttpHeaders.ETAG); + String eTag = responseHeaders.get(HttpHeaders.ETAG); + + Mockito.doAnswer( + invocation -> { + HTTPRequest originalRequest = invocation.getArgument(0); + + assertThat(originalRequest.headers().contains(HttpHeaders.IF_NONE_MATCH)); + assertThat( + originalRequest.headers().firstEntry(HttpHeaders.IF_NONE_MATCH).get().value()) + .isEqualTo(eTag); + + assertThat( + adapterForRESTServer.execute( + originalRequest, + LoadTableResponse.class, + invocation.getArgument(2), + invocation.getArgument(3), + ParserContext.builder().build())) + .isNull(); + + return null; + }) + .when(adapterForRESTServer) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), + eq(LoadTableResponse.class), + any(), + any()); + + assertThat(restCatalog.loadTable(TABLE)).isNotNull(); + + TableIdentifier metadataTableIdentifier = + TableIdentifier.of(NS.toString(), TABLE.name(), "partitions"); + + assertThat(restCatalog.loadTable(metadataTableIdentifier)).isNotNull(); + + Mockito.verify(adapterForRESTServer, times(3)) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), + eq(LoadTableResponse.class), + any(), + any()); + + verify(adapterForRESTServer) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(metadataTableIdentifier)), + any(), + any(), + any()); + } + + @Test + public void freshnessAwareLoading() { + restCatalog.createNamespace(TABLE.namespace()); + restCatalog.createTable(TABLE, SCHEMA); + + Cache tableCache = + restCatalog.sessionCatalog().tableCache().cache(); + assertThat(tableCache.estimatedSize()).isZero(); + + expectFullTableLoadForLoadTable(TABLE, adapterForRESTServer); + BaseTable tableAfterFirstLoad = (BaseTable) restCatalog.loadTable(TABLE); + + assertThat(tableCache.stats().hitCount()).isZero(); + assertThat(tableCache.asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + expectNotModifiedResponseForLoadTable(TABLE, adapterForRESTServer); + BaseTable tableAfterSecondLoad = (BaseTable) restCatalog.loadTable(TABLE); + + assertThat(tableAfterFirstLoad).isNotSameAs(tableAfterSecondLoad); + assertThat(tableAfterFirstLoad.operations().current().location()) + .isEqualTo(tableAfterSecondLoad.operations().current().location()); + assertThat( + tableCache + .asMap() + .get(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)) + .supplier() + .get() + .operations() + .current() + .metadataFileLocation()) + .isEqualTo(tableAfterFirstLoad.operations().current().metadataFileLocation()); + + Mockito.verify(adapterForRESTServer, times(2)) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); + } + + @Test + public void freshnessAwareLoadingMetadataTables() { + restCatalog.createNamespace(TABLE.namespace()); + restCatalog.createTable(TABLE, SCHEMA); + + Cache tableCache = + restCatalog.sessionCatalog().tableCache().cache(); + assertThat(tableCache.estimatedSize()).isZero(); + + BaseTable table = (BaseTable) restCatalog.loadTable(TABLE); + + assertThat(tableCache.stats().hitCount()).isZero(); + assertThat(tableCache.asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + TableIdentifier metadataTableIdentifier = + TableIdentifier.of(TABLE.namespace().toString(), TABLE.name(), "partitions"); + + BaseMetadataTable metadataTable = + (BaseMetadataTable) restCatalog.loadTable(metadataTableIdentifier); + + assertThat(tableCache.stats().hitCount()).isEqualTo(1); + assertThat(tableCache.asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + assertThat(table).isNotSameAs(metadataTable.table()); + assertThat(table.operations().current().metadataFileLocation()) + .isEqualTo(metadataTable.table().operations().current().metadataFileLocation()); + + ResourcePaths paths = + ResourcePaths.forCatalogProperties( + ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, "%2E")); + + Mockito.verify(adapterForRESTServer, times(2)) + .execute(matches(HTTPRequest.HTTPMethod.GET, paths.table(TABLE)), any(), any(), any()); + + Mockito.verify(adapterForRESTServer) + .execute( + matches(HTTPRequest.HTTPMethod.GET, paths.table(metadataTableIdentifier)), + any(), + any(), + any()); + } + + @Test + public void renameTableInvalidatesTable() { + runTableInvalidationTest( + restCatalog, + adapterForRESTServer, + catalog -> catalog.renameTable(TABLE, TableIdentifier.of(TABLE.namespace(), "other_table")), + 0); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void dropTableInvalidatesTable(boolean purge) { + runTableInvalidationTest( + restCatalog, adapterForRESTServer, catalog -> catalog.dropTable(TABLE, purge), 0); + } + + @Test + public void tableExistViaHeadRequestInvalidatesTable() { + runTableInvalidationTest( + restCatalog, + adapterForRESTServer, + (catalog -> { + // Use a different catalog to drop the table + catalog(new RESTCatalogAdapter(backendCatalog)).dropTable(TABLE, true); + + // The main catalog still has the table in cache + assertThat(catalog.sessionCatalog().tableCache().cache().asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + catalog.tableExists(TABLE); + }), + 0); + } + + @Test + public void tableExistViaGetRequestInvalidatesTable() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + + // Configure REST server to answer tableExists query via GET + Mockito.doAnswer( + invocation -> + ConfigResponse.builder() + .withEndpoints( + ImmutableList.of( + Endpoint.V1_LOAD_TABLE, + Endpoint.V1_CREATE_NAMESPACE, + Endpoint.V1_CREATE_TABLE)) + .build()) + .when(adapter) + .execute( + matches(HTTPRequest.HTTPMethod.GET, ResourcePaths.config()), + eq(ConfigResponse.class), + any(), + any()); + + RESTCatalog catalog = new RESTCatalog(DEFAULT_SESSION_CONTEXT, config -> adapter); + catalog.initialize( + "catalog", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.inmemory.InMemoryFileIO")); + + runTableInvalidationTest( + catalog, + adapter, + cat -> { + // Use a different catalog to drop the table + catalog(new RESTCatalogAdapter(backendCatalog)).dropTable(TABLE, true); + + // The main catalog still has the table in cache + assertThat(cat.sessionCatalog().tableCache().cache().asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + cat.tableExists(TABLE); + }, + 1); + } + + @Test + public void loadTableInvalidatesCache() { + runTableInvalidationTest( + restCatalog, + adapterForRESTServer, + catalog -> { + // Use a different catalog to drop the table + catalog(new RESTCatalogAdapter(backendCatalog)).dropTable(TABLE, true); + + // The main catalog still has the table in cache + assertThat(catalog.sessionCatalog().tableCache().cache().asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + assertThatThrownBy(() -> catalog.loadTable(TABLE)) + .isInstanceOf(NoSuchTableException.class) + .hasMessage("Table does not exist: %s", TABLE); + }, + 1); + } + + @Test + public void loadTableWithMetadataTableNameInvalidatesCache() { + TableIdentifier metadataTableIdentifier = + TableIdentifier.of(TABLE.namespace().toString(), TABLE.name(), "partitions"); + + runTableInvalidationTest( + restCatalog, + adapterForRESTServer, + catalog -> { + // Use a different catalog to drop the table + catalog(new RESTCatalogAdapter(backendCatalog)).dropTable(TABLE, true); + + // The main catalog still has the table in cache + assertThat(catalog.sessionCatalog().tableCache().cache().asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + assertThatThrownBy(() -> catalog.loadTable(metadataTableIdentifier)) + .isInstanceOf(NoSuchTableException.class) + .hasMessage("Table does not exist: %s", TABLE); + }, + 1); + + ResourcePaths paths = + ResourcePaths.forCatalogProperties( + ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, "%2E")); + + Mockito.verify(adapterForRESTServer) + .execute( + matches(HTTPRequest.HTTPMethod.GET, paths.table(metadataTableIdentifier)), + any(), + any(), + any()); + } + + private void runTableInvalidationTest( + RESTCatalog catalog, + RESTCatalogAdapter adapterToVerify, + Consumer action, + int loadTableCountFromAction) { + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + BaseTable originalTable = (BaseTable) catalog.loadTable(TABLE); + + Cache tableCache = + catalog.sessionCatalog().tableCache().cache(); + assertThat(tableCache.stats().hitCount()).isZero(); + assertThat(tableCache.asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + action.accept(catalog); + + // Check that 'action' invalidates cache + assertThat(tableCache.estimatedSize()).isZero(); + + assertThatThrownBy(() -> catalog.loadTable(TABLE)) + .isInstanceOf(NoSuchTableException.class) + .hasMessageContaining("Table does not exist: %s", TABLE); + + catalog.createTable(TABLE, SCHEMA); + expectFullTableLoadForLoadTable(TABLE, adapterToVerify); + BaseTable newTableWithSameName = (BaseTable) catalog.loadTable(TABLE); + + assertThat(tableCache.stats().hitCount()).isEqualTo(loadTableCountFromAction); + assertThat(tableCache.asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + assertThat(newTableWithSameName).isNotEqualTo(originalTable); + assertThat(newTableWithSameName.operations().current().metadataFileLocation()) + .isNotEqualTo(originalTable.operations().current().metadataFileLocation()); + + Mockito.verify(adapterToVerify, times(3 + loadTableCountFromAction)) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); + } + + @Test + public void tableCacheWithMultiSessions() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + + RESTSessionCatalog sessionCatalog = new RESTSessionCatalog(config -> adapter, null); + sessionCatalog.initialize("test_session_catalog", Map.of()); + + SessionCatalog.SessionContext otherSessionContext = + new SessionCatalog.SessionContext( + "session_id_2", "user", ImmutableMap.of("credential", "user:12345"), ImmutableMap.of()); + + sessionCatalog.createNamespace(DEFAULT_SESSION_CONTEXT, TABLE.namespace()); + sessionCatalog.buildTable(DEFAULT_SESSION_CONTEXT, TABLE, SCHEMA).create(); + expectFullTableLoadForLoadTable(TABLE, adapter); + BaseTable tableSession1 = (BaseTable) sessionCatalog.loadTable(DEFAULT_SESSION_CONTEXT, TABLE); + + Cache tableCache = sessionCatalog.tableCache().cache(); + assertThat(tableCache.stats().hitCount()).isZero(); + assertThat(tableCache.asMap()) + .containsOnlyKeys(SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE)); + + expectFullTableLoadForLoadTable(TABLE, adapter); + BaseTable tableSession2 = (BaseTable) sessionCatalog.loadTable(otherSessionContext, TABLE); + + assertThat(tableSession1).isNotEqualTo(tableSession2); + assertThat(tableSession1.operations().current().metadataFileLocation()) + .isEqualTo(tableSession2.operations().current().metadataFileLocation()); + assertThat(tableCache.asMap()) + .containsOnlyKeys( + SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE), + SessionIdTableId.of(otherSessionContext.sessionId(), TABLE)); + } + + @Test + public void notModified304ResponseWithEmptyTableCache() { + Mockito.doAnswer(invocation -> null) + .when(adapterForRESTServer) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), + eq(LoadTableResponse.class), + any(), + any()); + + restCatalog.createNamespace(TABLE.namespace()); + restCatalog.createTable(TABLE, SCHEMA); + restCatalog.invalidateTable(TABLE); + + // Table is not in the cache and null LoadTableResponse is received + assertThatThrownBy(() -> restCatalog.loadTable(TABLE)) + .isInstanceOf(RESTException.class) + .hasMessage( + "Invalid (NOT_MODIFIED) response for request: method=%s, path=%s", + HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)); + } + + @Test + public void tableCacheNotUpdatedWithoutETag() { + RESTCatalogAdapter adapter = + Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + // Wrap the original responseHeaders to not accept ETag. + Consumer> noETagConsumer = + headers -> { + if (!headers.containsKey(HttpHeaders.ETAG)) { + responseHeaders.accept(headers); + } + }; + return super.execute(request, responseType, errorHandler, noETagConsumer); + } + }); + + RESTCatalog catalog = new RESTCatalog(DEFAULT_SESSION_CONTEXT, config -> adapter); + catalog.initialize( + "catalog", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.inmemory.InMemoryFileIO")); + + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + assertThat(catalog.loadTable(TABLE)).isNotNull(); + + assertThat(catalog.sessionCatalog().tableCache().cache().estimatedSize()).isZero(); + } + + @Test + public void tableCacheDisabled() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + + RESTCatalog catalog = new RESTCatalog(DEFAULT_SESSION_CONTEXT, config -> adapter); + catalog.initialize( + "catalog", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO", + RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES, + "0")); + + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + + assertThat(catalog.sessionCatalog().tableCache().cache().estimatedSize()).isZero(); + + expectFullTableLoadForLoadTable(TABLE, adapter); + assertThat(catalog.loadTable(TABLE)).isNotNull(); + catalog.sessionCatalog().tableCache().cache().cleanUp(); + + assertThat(catalog.sessionCatalog().tableCache().cache().estimatedSize()).isZero(); + } + + @Test + public void fullTableLoadAfterExpiryFromCache() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + + FakeTicker ticker = new FakeTicker(); + + TestableRESTCatalog catalog = + new TestableRESTCatalog(DEFAULT_SESSION_CONTEXT, config -> adapter, ticker); + catalog.initialize("catalog", Map.of()); + + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + catalog.loadTable(TABLE); + + Cache tableCache = + catalog.sessionCatalog().tableCache().cache(); + SessionIdTableId tableCacheKey = + SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE); + + assertThat(tableCache.asMap()).containsOnlyKeys(tableCacheKey); + assertThat(tableCache.policy().expireAfterWrite().get().ageOf(tableCacheKey)) + .isPresent() + .get() + .isEqualTo(Duration.ZERO); + + ticker.advance(HALF_OF_TABLE_EXPIRATION); + + assertThat(tableCache.asMap()).containsOnlyKeys(tableCacheKey); + assertThat(tableCache.policy().expireAfterWrite().get().ageOf(tableCacheKey)) + .isPresent() + .get() + .isEqualTo(HALF_OF_TABLE_EXPIRATION); + + ticker.advance(HALF_OF_TABLE_EXPIRATION.plus(Duration.ofSeconds(10))); + + assertThat(tableCache.asMap()).doesNotContainKey(tableCacheKey); + + expectFullTableLoadForLoadTable(TABLE, adapter); + assertThat(catalog.loadTable(TABLE)).isNotNull(); + + assertThat(tableCache.stats().hitCount()).isEqualTo(0); + assertThat(tableCache.asMap()).containsOnlyKeys(tableCacheKey); + assertThat(tableCache.policy().expireAfterWrite().get().ageOf(tableCacheKey)) + .isPresent() + .get() + .isEqualTo(Duration.ZERO); + } + + @Test + public void tableCacheAgeNotRefreshedAfterAccess() { + FakeTicker ticker = new FakeTicker(); + + TestableRESTCatalog catalog = + new TestableRESTCatalog( + DEFAULT_SESSION_CONTEXT, config -> new RESTCatalogAdapter(backendCatalog), ticker); + catalog.initialize("catalog", Map.of()); + + catalog.createNamespace(TABLE.namespace()); + catalog.createTable(TABLE, SCHEMA); + catalog.loadTable(TABLE); + + ticker.advance(HALF_OF_TABLE_EXPIRATION); + + Cache tableCache = + catalog.sessionCatalog().tableCache().cache(); + SessionIdTableId tableCacheKey = + SessionIdTableId.of(DEFAULT_SESSION_CONTEXT.sessionId(), TABLE); + + assertThat(tableCache.policy().expireAfterWrite().get().ageOf(tableCacheKey)) + .isPresent() + .get() + .isEqualTo(HALF_OF_TABLE_EXPIRATION); + + assertThat(catalog.loadTable(TABLE)).isNotNull(); + + assertThat(tableCache.policy().expireAfterWrite().get().ageOf(tableCacheKey)) + .isPresent() + .get() + .isEqualTo(HALF_OF_TABLE_EXPIRATION); + } + + @Test + public void customTableOperationsWithFreshnessAwareLoading() { + class CustomTableOps extends RESTTableOperations { + CustomTableOps( + RESTClient client, + String path, + Supplier> readHeaders, + Supplier> mutationHeaders, + FileIO io, + TableMetadata current, + Set endpoints) { + super(client, path, readHeaders, mutationHeaders, io, current, endpoints); + } + } + + class CustomRESTSessionCatalog extends RESTSessionCatalog { + CustomRESTSessionCatalog( + Function, RESTClient> clientBuilder, + BiFunction, FileIO> ioBuilder) { + super(clientBuilder, ioBuilder); + } + + @Override + protected RESTTableOperations newTableOps( + RESTClient restClient, + String path, + Supplier> readHeaders, + Supplier> mutationHeaders, + FileIO fileIO, + TableMetadata current, + Set supportedEndpoints) { + return new CustomTableOps( + restClient, path, readHeaders, mutationHeaders, fileIO, current, supportedEndpoints); + } + } + + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + RESTCatalog catalog = + catalog(adapter, clientBuilder -> new CustomRESTSessionCatalog(clientBuilder, null)); + + catalog.createNamespace(NS); + catalog.createTable(TABLE, SCHEMA); + + expectFullTableLoadForLoadTable(TABLE, adapter); + BaseTable table = (BaseTable) catalog.loadTable(TABLE); + assertThat(table.operations()).isInstanceOf(CustomTableOps.class); + + // When answering loadTable from table cache we still get the injected ops. + expectNotModifiedResponseForLoadTable(TABLE, adapter); + table = (BaseTable) catalog.loadTable(TABLE); + assertThat(table.operations()).isInstanceOf(CustomTableOps.class); + } + + @Test + public void tableCacheWithLazySnapshotLoading() { + Map responseHeaders = Maps.newHashMap(); + RESTCatalogAdapter adapter = adapterCapturingResponseHeaders(responseHeaders); + SessionCatalog.SessionContext sessionContext = SessionCatalog.SessionContext.createEmpty(); + RESTCatalog catalog = new RESTCatalog(sessionContext, config -> adapter); + catalog.initialize( + "test", + ImmutableMap.of( + RESTCatalogProperties.SNAPSHOT_LOADING_MODE, + RESTCatalogProperties.SnapshotMode.REFS.name())); + + catalog.createNamespace(TABLE.namespace()); + Table table = catalog.createTable(TABLE, SCHEMA); + table.newAppend().appendFile(FILE_A).commit(); + table.newAppend().appendFile(FILE_B).commit(); + + Table refsTable = catalog.loadTable(TABLE); + String eTag = responseHeaders.get(HttpHeaders.ETAG); + assertThat(eTag).isNotNull(); + + // Verify that only the current snapshot was loaded (refs mode). Access the snapshots field + // directly to avoid triggering lazy loading of all snapshots. + assertThat(((BaseTable) refsTable).operations().current()) + .extracting("snapshots") + .asInstanceOf(InstanceOfAssertFactories.list(Snapshot.class)) + .hasSize(1); + + Cache tableCache = + catalog.sessionCatalog().tableCache().cache(); + assertThat(tableCache.estimatedSize()).isEqualTo(1); + SessionIdTableId tableCacheKey = SessionIdTableId.of(sessionContext.sessionId(), TABLE); + TableWithETag tableWithEtag = tableCache.asMap().get(tableCacheKey); + assertThat(tableWithEtag).isNotNull(); + + // Trigger loading all snapshots via the lazy loading mechanism + assertThat(refsTable.snapshots()).hasSize(2); + + // After lazy snapshot loading, the cache entry remains the same object. However, the + // underlying TableMetadata was refreshed with the full list of snapshots. + assertThat(tableCache.estimatedSize()).isEqualTo(1); + assertThat(tableWithEtag).isSameAs(tableCache.asMap().get(tableCacheKey)); + // Lazy snapshot loading doesn't go through the cache + assertThat(tableCache.stats().hitCount()).isZero(); + + // The next loadTable should hit the cache and return a table with the full snapshot list + Table reloadedTable = catalog.loadTable(TABLE); + assertThat(tableCache.stats().hitCount()).isOne(); + assertThat(((BaseTable) reloadedTable).operations().current()) + .extracting("snapshots") + .asInstanceOf(InstanceOfAssertFactories.list(Snapshot.class)) + .hasSize(2); + + // Accessing snapshots again doesn't trigger another load + assertThat(reloadedTable.snapshots()).hasSize(2); + // Verify the loaded snapshots match the original table's snapshots + assertThat(refsTable.snapshots()).containsExactlyInAnyOrderElementsOf(table.snapshots()); + + // Verify that the initial table load used the refs query parameter + verify(adapter, times(1)) + .execute( + matches( + HTTPRequest.HTTPMethod.GET, + RESOURCE_PATHS.table(TABLE), + Map.of(), + Map.of("snapshots", "refs")), + eq(LoadTableResponse.class), + any(), + any()); + + // Verify the second load table (cache hit) included the IF_NONE_MATCH header + verify(adapter, times(1)) + .execute( + matches( + HTTPRequest.HTTPMethod.GET, + RESOURCE_PATHS.table(TABLE), + Map.of(HttpHeaders.IF_NONE_MATCH, eTag), + Map.of("snapshots", "refs")), + eq(LoadTableResponse.class), + any(), + any()); + + // Verify that lazy snapshot loading triggered exactly one request with snapshots=all + verify(adapter, times(1)) + .execute( + matches( + HTTPRequest.HTTPMethod.GET, + RESOURCE_PATHS.table(TABLE), + Map.of(), + Map.of("snapshots", "all")), + eq(LoadTableResponse.class), + any(), + any()); + } + + private RESTCatalogAdapter adapterCapturingResponseHeaders(Map respHeaders) { + return Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + Consumer> compositeConsumer = + headers -> { + responseHeaders.accept(headers); + respHeaders.putAll(headers); + }; + return super.execute(request, responseType, errorHandler, compositeConsumer); + } + }); + } + + private RESTCatalog catalogWithResponseHeaders(Map respHeaders) { + return catalog(adapterCapturingResponseHeaders(respHeaders)); + } + + private RESTCatalog catalog(RESTCatalogAdapter adapter) { + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); + catalog.initialize( + "test", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.inmemory.InMemoryFileIO")); + return catalog; + } + + private RESTCatalog catalog( + RESTCatalogAdapter adapter, + Function, RESTClient>, RESTSessionCatalog> + sessionCatalogFactory) { + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter) { + @Override + protected RESTSessionCatalog newSessionCatalog( + Function, RESTClient> clientBuilder) { + return sessionCatalogFactory.apply(clientBuilder); + } + }; + catalog.initialize( + "test", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.inmemory.InMemoryFileIO")); + return catalog; + } + + private void expectFullTableLoadForLoadTable(TableIdentifier ident, RESTCatalogAdapter adapter) { + Answer invocationAssertsFullLoad = + invocation -> + assertThat((LoadTableResponse) invocation.callRealMethod()).isNotEqualTo(null).actual(); + + Mockito.doAnswer(invocationAssertsFullLoad) + .when(adapter) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(ident)), + eq(LoadTableResponse.class), + any(), + any()); + } + + private void expectNotModifiedResponseForLoadTable( + TableIdentifier ident, RESTCatalogAdapter adapter) { + Answer invocationAssertsFullLoad = + invocation -> + assertThat((LoadTableResponse) invocation.callRealMethod()).isEqualTo(null).actual(); + + Mockito.doAnswer(invocationAssertsFullLoad) + .when(adapter) + .execute( + matches(HTTPRequest.HTTPMethod.GET, RESOURCE_PATHS.table(ident)), + eq(LoadTableResponse.class), + any(), + any()); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java index 8cf97bca32ef..701ae699f136 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java @@ -35,22 +35,30 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.net.SocketTimeoutException; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateException; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.io.HttpClientConnectionManager; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.HttpStatus; import org.apache.iceberg.IcebergBuild; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.rest.auth.AuthSession; import org.apache.iceberg.rest.auth.TLSConfigurer; import org.apache.iceberg.rest.responses.ErrorResponse; @@ -58,14 +66,17 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.mockserver.configuration.Configuration; import org.mockserver.integration.ClientAndServer; +import org.mockserver.logging.MockServerLogger; import org.mockserver.matchers.Times; import org.mockserver.model.HttpRequest; import org.mockserver.model.HttpResponse; +import org.mockserver.socket.tls.KeyStoreFactory; import org.mockserver.verify.VerificationTimes; /** @@ -87,6 +98,7 @@ public class TestHTTPClient { private static RESTClient restClient; public static class DefaultTLSConfigurer implements TLSConfigurer { + public static int count = 0; public DefaultTLSConfigurer() { @@ -95,6 +107,7 @@ public DefaultTLSConfigurer() { } public static class TLSConfigurerMissingNoArgCtor implements TLSConfigurer { + TLSConfigurerMissingNoArgCtor(String str) {} } @@ -395,6 +408,101 @@ public void testLoadTLSConfigurerNotImplementTLSConfigurer() { .hasMessageContaining("does not implement TLSConfigurer"); } + /** A TLSConfigurer that relies on the default (built-in) JSSE verifier. */ + public static class BuiltInHostnameVerifierTLSConfigurer implements TLSConfigurer { + + @Override + public SSLContext sslContext() { + return mockServerSSLContext(); + } + } + + /** A TLSConfigurer that overrides hostnameVerifier() to return a custom verifier. */ + public static class CustomHostnameVerifierTLSConfigurer implements TLSConfigurer { + + @Override + public SSLContext sslContext() { + return mockServerSSLContext(); + } + + @Override + public HostnameVerifier hostnameVerifier() { + return NoopHostnameVerifier.INSTANCE; + } + } + + private static SSLContext mockServerSSLContext() { + try { + KeyStore keyStore = + new KeyStoreFactory(Configuration.configuration(), new MockServerLogger()) + .loadOrCreateKeyStore(); + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); + SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); + sslContext.init(null, tmf.getTrustManagers(), null); + return sslContext; + } catch (Exception e) { + throw new RuntimeException("Failed to create SSLContext", e); + } + } + + @Test + public void testTLSConfigurerHostnameVerifier(@TempDir Path temp) throws IOException { + + // Start a dedicated MockServer with a certificate that does NOT include + // 127.0.0.1 or localhost in its SANs. + Configuration tlsConfig = Configuration.configuration(); + tlsConfig.proactivelyInitialiseTLS(true); + tlsConfig.preventCertificateDynamicUpdate(true); + tlsConfig.sslCertificateDomainName("example.com"); + tlsConfig.sslSubjectAlternativeNameIps(Sets.newHashSet("1.2.3.4")); + tlsConfig.sslSubjectAlternativeNameDomains(Sets.newHashSet("example.com")); + tlsConfig.directoryToSaveDynamicSSLCertificate(temp.toFile().getAbsolutePath()); + + int tlsPort = PORT + 1; + try (ClientAndServer server = startClientAndServer(tlsConfig, tlsPort)) { + + String path = "tls/hostname-verifier/path"; + HttpRequest mockRequest = + request() + .withPath("/" + path) + .withMethod(HttpMethod.HEAD.name().toUpperCase(Locale.ROOT)); + HttpResponse mockResponse = response().withStatusCode(200).withBody("TLS response"); + server.when(mockRequest).respond(mockResponse); + + // With no custom hostnameVerifier (null), the BUILTIN policy is used automatically, + // so the JSSE built-in verifier rejects the connection because the SANs don't match + try (HTTPClient builtInVerifierClient = + HTTPClient.builder( + Map.of( + HTTPClient.REST_TLS_CONFIGURER, + BuiltInHostnameVerifierTLSConfigurer.class.getName())) + .uri(String.format("https://127.0.0.1:%d", tlsPort)) + .withAuthSession(AuthSession.EMPTY) + .build()) { + assertThatThrownBy(() -> builtInVerifierClient.head(path, Map.of(), (unused) -> {})) + .rootCause() + .isInstanceOf(CertificateException.class) + .hasMessage("No subject alternative names matching IP address 127.0.0.1 found"); + } + + // With a custom hostnameVerifier (NoopHostnameVerifier), the CLIENT policy is used + // automatically, so hostname verification is bypassed and the request succeeds + try (HTTPClient customVerifierClient = + HTTPClient.builder( + Map.of( + HTTPClient.REST_TLS_CONFIGURER, + CustomHostnameVerifierTLSConfigurer.class.getName())) + .uri(String.format("https://127.0.0.1:%d", tlsPort)) + .withAuthSession(AuthSession.EMPTY) + .build()) { + assertThatCode(() -> customVerifierClient.head(path, Map.of(), (unused) -> {})) + .doesNotThrowAnyException(); + } + } + } + @Test public void testSocketTimeout() throws IOException { long socketTimeoutMs = 2000L; @@ -613,6 +721,7 @@ private static Item doExecuteRequest( } public static class Item implements RESTRequest, RESTResponse { + private Long id; private String data; diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java index 385893ea7130..017f400f860f 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java @@ -18,9 +18,12 @@ */ package org.apache.iceberg.rest; +import static org.apache.iceberg.rest.RequestMatcher.containsHeaders; +import static org.apache.iceberg.rest.RequestMatcher.matches; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.assertj.core.api.InstanceOfAssertFactories.map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; @@ -34,13 +37,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; import java.io.IOException; +import java.io.UncheckedIOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.nio.file.Path; import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -53,43 +56,59 @@ import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.hadoop.conf.Configuration; -import org.apache.http.HttpHeaders; import org.apache.iceberg.BaseTable; import org.apache.iceberg.BaseTransaction; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.DataFile; import org.apache.iceberg.DataFiles; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.HistoryEntry; import org.apache.iceberg.MetadataUpdate; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TestCatalogUtil; import org.apache.iceberg.Transaction; import org.apache.iceberg.UpdatePartitionSpec; import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.CatalogTests; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.SessionCatalog; import org.apache.iceberg.catalog.TableCommit; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.AlreadyExistsException; import org.apache.iceberg.exceptions.CommitFailedException; import org.apache.iceberg.exceptions.CommitStateUnknownException; import org.apache.iceberg.exceptions.NotAuthorizedException; import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.exceptions.ServiceFailureException; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.inmemory.InMemoryCatalog; import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.io.SupportsStorageCredentials; +import org.apache.iceberg.metrics.CommitReport; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.rest.HTTPRequest.HTTPMethod; import org.apache.iceberg.rest.RESTCatalogProperties.SnapshotMode; +import org.apache.iceberg.rest.auth.AuthManager; +import org.apache.iceberg.rest.auth.AuthManagers; +import org.apache.iceberg.rest.auth.AuthSession; import org.apache.iceberg.rest.auth.AuthSessionUtil; import org.apache.iceberg.rest.auth.OAuth2Properties; import org.apache.iceberg.rest.auth.OAuth2Util; +import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.credentials.ImmutableCredential; +import org.apache.iceberg.rest.requests.CreateTableRequest; +import org.apache.iceberg.rest.requests.RegisterTableRequest; +import org.apache.iceberg.rest.requests.ReportMetricsRequest; import org.apache.iceberg.rest.requests.UpdateTableRequest; import org.apache.iceberg.rest.responses.ConfigResponse; import org.apache.iceberg.rest.responses.CreateNamespaceResponse; @@ -99,12 +118,14 @@ import org.apache.iceberg.rest.responses.LoadTableResponse; import org.apache.iceberg.rest.responses.OAuthTokenResponse; import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; import org.assertj.core.api.InstanceOfAssertFactories; import org.awaitility.Awaitility; +import org.eclipse.jetty.compression.gzip.GzipCompression; +import org.eclipse.jetty.compression.server.CompressionHandler; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.handler.gzip.GzipHandler; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -118,23 +139,143 @@ public class TestRESTCatalog extends CatalogTests { private static final ObjectMapper MAPPER = RESTObjectMapper.mapper(); private static final ResourcePaths RESOURCE_PATHS = - ResourcePaths.forCatalogProperties(Maps.newHashMap()); + ResourcePaths.forCatalogProperties( + ImmutableMap.of( + RESTCatalogProperties.NAMESPACE_SEPARATOR, + RESTCatalogAdapter.NAMESPACE_SEPARATOR_URLENCODED_UTF_8)); + + private static final class IdempotentEnv { + private final TableIdentifier ident; + private final RESTClient http; + private final Map headers; + + private IdempotentEnv(TableIdentifier ident, RESTClient http, Map headers) { + this.ident = ident; + this.http = http; + this.headers = headers; + } + } + + /** + * Test-only adapter that keeps request/response round-trip serialization and header validation + * from the base test setup, while also allowing specific tests to inject transient failures. + */ + private static class HeaderValidatingAdapter extends RESTCatalogAdapter { + private final HTTPHeaders catalogHeaders; + private final HTTPHeaders contextHeaders; + private final java.util.concurrent.ConcurrentMap + simulateFailureOnFirstSuccessByKey = new java.util.concurrent.ConcurrentHashMap<>(); + + HeaderValidatingAdapter( + Catalog catalog, HTTPHeaders catalogHeaders, HTTPHeaders contextHeaders) { + super(catalog); + this.catalogHeaders = catalogHeaders; + this.contextHeaders = contextHeaders; + } + + /** + * Test helper to simulate a transient failure after the first successful mutation for a key. + * + *

Useful to validate that idempotency correctly replays a finalized result when the client + * retries after a post-success transient failure. + */ + public void simulateFailureOnFirstSuccessForKey(String key, RuntimeException failure) { + Preconditions.checkArgument(key != null, "Invalid idempotency key: null"); + Preconditions.checkArgument(failure != null, "Invalid failure: null"); + simulateFailureOnFirstSuccessByKey.put(key, failure); + } + + /** Test helper to simulate a transient 503 after the first successful mutation for a key. */ + public void simulate503OnFirstSuccessForKey(String key) { + simulateFailureOnFirstSuccessForKey( + key, + new CommitStateUnknownException( + new RuntimeException("simulated transient 503 after success"))); + } + + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + if (!ResourcePaths.tokens().equals(request.path())) { + if (ResourcePaths.config().equals(request.path())) { + assertThat(request.headers().entries()).containsAll(catalogHeaders.entries()); + } else { + assertThat(request.headers().entries()).containsAll(contextHeaders.entries()); + } + } + + Object body = roundTripSerialize(request.body(), "request"); + HTTPRequest req = ImmutableHTTPRequest.builder().from(request).body(body).build(); + T response = super.execute(req, responseType, errorHandler, responseHeaders); + return roundTripSerialize(response, "response"); + } + + @Override + protected T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders, + ParserContext parserContext) { + ErrorResponse.Builder errorBuilder = ErrorResponse.builder(); + Pair> routeAndVars = Route.from(request.method(), request.path()); + if (routeAndVars != null) { + try { + ImmutableMap.Builder vars = ImmutableMap.builder(); + vars.putAll(request.queryParameters()); + vars.putAll(routeAndVars.second()); + + T resp = + handleRequest( + routeAndVars.first(), vars.build(), request, responseType, responseHeaders); + + // For tests: simulate a transient 503 after the first successful mutation for a key. + Optional keyHeader = + request.headers().firstEntry(RESTUtil.IDEMPOTENCY_KEY_HEADER); + boolean isMutation = + request.method() == HTTPMethod.POST || request.method() == HTTPMethod.DELETE; + if (isMutation && keyHeader.isPresent()) { + String key = keyHeader.get().value(); + RuntimeException failure = simulateFailureOnFirstSuccessByKey.remove(key); + if (failure != null) { + throw failure; + } + } + + return resp; + } catch (RuntimeException e) { + configureResponseFromException(e, errorBuilder); + } + + } else { + errorBuilder + .responseCode(400) + .withType("BadRequestException") + .withMessage( + String.format("No route for request: %s %s", request.method(), request.path())); + } + + ErrorResponse error = errorBuilder.build(); + errorHandler.accept(error); + + // if the error handler doesn't throw an exception, throw a generic one + throw new RESTException("Unhandled error: %s", error); + } + } @TempDir public Path temp; private RESTCatalog restCatalog; private InMemoryCatalog backendCatalog; private Server httpServer; - private RESTCatalogAdapter adapterForRESTServer; + private HeaderValidatingAdapter adapterForRESTServer; @BeforeEach public void createCatalog() throws Exception { - File warehouse = temp.toFile(); - this.backendCatalog = new InMemoryCatalog(); - this.backendCatalog.initialize( - "in-memory", - ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, warehouse.getAbsolutePath())); HTTPHeaders catalogHeaders = HTTPHeaders.of( @@ -152,37 +293,15 @@ public void createCatalog() throws Exception { "test-value")); adapterForRESTServer = - Mockito.spy( - new RESTCatalogAdapter(backendCatalog) { - @Override - public T execute( - HTTPRequest request, - Class responseType, - Consumer errorHandler, - Consumer> responseHeaders) { - // this doesn't use a Mockito spy because this is used for catalog tests, which have - // different method calls - if (!ResourcePaths.tokens().equals(request.path())) { - if (ResourcePaths.config().equals(request.path())) { - assertThat(request.headers().entries()).containsAll(catalogHeaders.entries()); - } else { - assertThat(request.headers().entries()).containsAll(contextHeaders.entries()); - } - } - - Object body = roundTripSerialize(request.body(), "request"); - HTTPRequest req = ImmutableHTTPRequest.builder().from(request).body(body).build(); - T response = super.execute(req, responseType, errorHandler, responseHeaders); - T responseAfterSerialization = roundTripSerialize(response, "response"); - return responseAfterSerialization; - } - }); + Mockito.spy(new HeaderValidatingAdapter(backendCatalog, catalogHeaders, contextHeaders)); ServletContextHandler servletContext = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); servletContext.addServlet( new ServletHolder(new RESTCatalogServlet(adapterForRESTServer)), "/*"); - servletContext.setHandler(new GzipHandler()); + CompressionHandler compressionHandler = new CompressionHandler(); + compressionHandler.putCompression(new GzipCompression()); + servletContext.insertHandler(compressionHandler); this.httpServer = new Server(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); httpServer.setHandler(servletContext); @@ -194,16 +313,22 @@ public T execute( @Override protected RESTCatalog initCatalog(String catalogName, Map additionalProperties) { Configuration conf = new Configuration(); - SessionCatalog.SessionContext context = - new SessionCatalog.SessionContext( - UUID.randomUUID().toString(), - "user", - ImmutableMap.of("credential", "user:12345"), - ImmutableMap.of()); + File warehouse = temp.toFile(); + + backendCatalog.initialize( + "in-memory", + ImmutableMap.builder() + .put(CatalogProperties.WAREHOUSE_LOCATION, warehouse.getAbsolutePath()) + .putAll(additionalProperties) + .build()); RESTCatalog catalog = new RESTCatalog( - context, + new SessionCatalog.SessionContext( + UUID.randomUUID().toString(), + "user", + ImmutableMap.of("credential", "user:12345"), + ImmutableMap.of()), (config) -> HTTPClient.builder(config) .uri(config.get(CatalogProperties.URI)) @@ -298,6 +423,15 @@ protected boolean requiresNamespaceCreate() { return true; } + @Override + protected boolean supportsNamesWithSlashes() { + // names with slashes are rejected and considered as suspicious characters after upgrading Jetty + // and the Servlet API. See also + // https://jakarta.ee/specifications/servlet/6.0/jakarta-servlet-spec-6.0.html#uri-path-canonicalization + // for additional details + return false; + } + /* RESTCatalog specific tests */ @Test @@ -403,13 +537,13 @@ public void testCatalogBasicBearerToken() { // the bearer token should be used for all interactions Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), catalogHeaders), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), catalogHeaders), any(), any(), any()); @@ -433,21 +567,21 @@ public void testCatalogCredentialNoOauth2ServerUri() { // no token or credential for catalog token exchange Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, ResourcePaths.tokens(), emptyHeaders), + matches(HTTPMethod.POST, ResourcePaths.tokens(), emptyHeaders), eq(OAuthTokenResponse.class), any(), any()); // no token or credential for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); // use the catalog token for all interactions Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), catalogHeaders), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), catalogHeaders), any(), any(), any()); @@ -479,21 +613,21 @@ public void testCatalogCredential(String oauth2ServerUri) { // no token or credential for catalog token exchange Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), eq(OAuthTokenResponse.class), any(), any()); // no token or credential for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); // use the catalog token for all interactions Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), catalogHeaders), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), catalogHeaders), any(), any(), any()); @@ -531,21 +665,21 @@ public void testCatalogBearerTokenWithClientCredential(String oauth2ServerUri) { // use the bearer token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); // use the bearer token to fetch the context token Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), eq(OAuthTokenResponse.class), any(), any()); // use the context token for table existence check Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), contextHeaders), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), contextHeaders), any(), any(), any()); @@ -585,28 +719,28 @@ public void testCatalogCredentialWithClientCredential(String oauth2ServerUri) { // call client credentials with no initial auth Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), eq(OAuthTokenResponse.class), any(), any()); // use the client credential token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); // use the client credential to fetch the context token Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), eq(OAuthTokenResponse.class), any(), any()); // use the context token for table existence check Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), contextHeaders), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), contextHeaders), any(), any(), any()); @@ -648,28 +782,28 @@ public void testCatalogBearerTokenAndCredentialWithClientCredential(String oauth // use the bearer token for client credentials Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, initHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, initHeaders), eq(OAuthTokenResponse.class), any(), any()); // use the client credential token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); // use the client credential to fetch the context token Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), eq(OAuthTokenResponse.class), any(), any()); // use the context token for table existence check Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), contextHeaders), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), contextHeaders), any(), any(), any()); @@ -831,7 +965,7 @@ private void testClientAuth( Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -841,14 +975,14 @@ private void testClientAuth( if (!credentials.containsKey("token")) { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), eq(OAuthTokenResponse.class), any(), any()); } Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), expectedHeaders), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), expectedHeaders), any(), any(), any()); @@ -944,8 +1078,6 @@ public void testTableSnapshotLoading() { .build()) .commit(); - ResourcePaths paths = ResourcePaths.forCatalogProperties(Maps.newHashMap()); - Table refsTable = catalog.loadTable(TABLE); // don't call snapshots() directly as that would cause to load all snapshots. Instead, @@ -955,12 +1087,21 @@ public void testTableSnapshotLoading() { .asInstanceOf(InstanceOfAssertFactories.list(Snapshot.class)) .hasSize(1); + // snapshot log is complete regardless REFS mode + assertThat(((BaseTable) refsTable).operations().current()) + .extracting("snapshotLog") + .asInstanceOf(InstanceOfAssertFactories.list(HistoryEntry.class)) + .hasSize(2) + .containsExactlyInAnyOrderElementsOf( + ((BaseTable) table).operations().current().snapshotLog()); + assertThat(refsTable.currentSnapshot()).isEqualTo(table.currentSnapshot()); // verify that the table was loaded with the refs argument verify(adapter, times(1)) .execute( - reqMatcher(HTTPMethod.GET, paths.table(TABLE), Map.of(), Map.of("snapshots", "refs")), + matches( + HTTPMethod.GET, RESOURCE_PATHS.table(TABLE), Map.of(), Map.of("snapshots", "refs")), eq(LoadTableResponse.class), any(), any()); @@ -969,7 +1110,8 @@ public void testTableSnapshotLoading() { assertThat(refsTable.snapshots()).containsExactlyInAnyOrderElementsOf(table.snapshots()); verify(adapter, times(1)) .execute( - reqMatcher(HTTPMethod.GET, paths.table(TABLE), Map.of(), Map.of("snapshots", "all")), + matches( + HTTPMethod.GET, RESOURCE_PATHS.table(TABLE), Map.of(), Map.of("snapshots", "all")), eq(LoadTableResponse.class), any(), any()); @@ -1038,8 +1180,6 @@ public void testTableSnapshotLoadingWithDivergedBranches(String formatVersion) { .toBranch(branch) .commit(); - ResourcePaths paths = ResourcePaths.forCatalogProperties(Maps.newHashMap()); - Table refsTable = catalog.loadTable(TABLE); // don't call snapshots() directly as that would cause to load all snapshots. Instead, @@ -1049,12 +1189,21 @@ public void testTableSnapshotLoadingWithDivergedBranches(String formatVersion) { .asInstanceOf(InstanceOfAssertFactories.list(Snapshot.class)) .hasSize(2); + // snapshot log is complete regardless REFS mode + assertThat(((BaseTable) refsTable).operations().current()) + .extracting("snapshotLog") + .asInstanceOf(InstanceOfAssertFactories.list(HistoryEntry.class)) + .hasSize(1) // main branch has a single snapshot + .containsExactlyInAnyOrderElementsOf( + ((BaseTable) table).operations().current().snapshotLog()); + assertThat(refsTable.currentSnapshot()).isEqualTo(table.currentSnapshot()); // verify that the table was loaded with the refs argument verify(adapter, times(1)) .execute( - reqMatcher(HTTPMethod.GET, paths.table(TABLE), Map.of(), Map.of("snapshots", "refs")), + matches( + HTTPMethod.GET, RESOURCE_PATHS.table(TABLE), Map.of(), Map.of("snapshots", "refs")), eq(LoadTableResponse.class), any(), any()); @@ -1064,7 +1213,8 @@ public void testTableSnapshotLoadingWithDivergedBranches(String formatVersion) { .containsExactlyInAnyOrderElementsOf(table.snapshots()); verify(adapter, times(1)) .execute( - reqMatcher(HTTPMethod.GET, paths.table(TABLE), Map.of(), Map.of("snapshots", "all")), + matches( + HTTPMethod.GET, RESOURCE_PATHS.table(TABLE), Map.of(), Map.of("snapshots", "all")), eq(LoadTableResponse.class), any(), any()); @@ -1132,6 +1282,14 @@ public void lazySnapshotLoadingWithDivergedHistory() { .asInstanceOf(InstanceOfAssertFactories.list(Snapshot.class)) .hasSize(1); + // snapshot log is complete regardless REFS mode + assertThat(((BaseTable) refsTable).operations().current()) + .extracting("snapshotLog") + .asInstanceOf(InstanceOfAssertFactories.list(HistoryEntry.class)) + .hasSize(numSnapshots) + .containsExactlyInAnyOrderElementsOf( + ((BaseTable) table).operations().current().snapshotLog()); + assertThat(refsTable.currentSnapshot()).isEqualTo(table.currentSnapshot()); assertThat(refsTable.snapshots()).hasSize(numSnapshots); assertThat(refsTable.history()).hasSize(numSnapshots); @@ -1164,7 +1322,7 @@ public void testTableAuth( Mockito.doAnswer(addTableConfig) .when(adapter) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.tables(namespace), expectedContextHeaders), + matches(HTTPMethod.POST, RESOURCE_PATHS.tables(namespace), expectedContextHeaders), eq(LoadTableResponse.class), any(), any()); @@ -1172,7 +1330,7 @@ public void testTableAuth( Mockito.doAnswer(addTableConfig) .when(adapter) .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedContextHeaders), + matches(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedContextHeaders), eq(LoadTableResponse.class), any(), any()); @@ -1215,14 +1373,14 @@ public void testTableAuth( Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); // session client credentials flow Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, catalogHeaders), eq(OAuthTokenResponse.class), any(), any()); @@ -1230,7 +1388,7 @@ public void testTableAuth( // create table request Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.tables(namespace), expectedContextHeaders), + matches(HTTPMethod.POST, RESOURCE_PATHS.tables(namespace), expectedContextHeaders), eq(LoadTableResponse.class), any(), any()); @@ -1240,7 +1398,7 @@ public void testTableAuth( // token exchange to get a table token Mockito.verify(adapter, times(1)) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, expectedContextHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, expectedContextHeaders), eq(OAuthTokenResponse.class), any(), any()); @@ -1250,7 +1408,7 @@ public void testTableAuth( // load table from catalog + refresh loaded table Mockito.verify(adapter, times(2)) .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedTableHeaders), + matches(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedTableHeaders), eq(LoadTableResponse.class), any(), any()); @@ -1258,7 +1416,7 @@ public void testTableAuth( // load table from catalog Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedContextHeaders), + matches(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedContextHeaders), eq(LoadTableResponse.class), any(), any()); @@ -1266,7 +1424,7 @@ public void testTableAuth( // refresh loaded table Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedTableHeaders), + matches(HTTPMethod.GET, RESOURCE_PATHS.table(TBL), expectedTableHeaders), eq(LoadTableResponse.class), any(), any()); @@ -1321,7 +1479,7 @@ public void testCatalogTokenRefresh(String oauth2ServerUri) { // call client credentials with no initial auth Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), eq(OAuthTokenResponse.class), any(), any()); @@ -1329,7 +1487,7 @@ public void testCatalogTokenRefresh(String oauth2ServerUri) { // use the client credential token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -1343,7 +1501,7 @@ public void testCatalogTokenRefresh(String oauth2ServerUri) { "scope", "catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, catalogHeaders, @@ -1367,7 +1525,7 @@ public void testCatalogTokenRefresh(String oauth2ServerUri) { "Bearer token-exchange-token:sub=client-credentials-token:sub=catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, secondRefreshHeaders, @@ -1436,7 +1594,7 @@ public void testCatalogRefreshedTokenIsUsed(String oauth2ServerUri) { "scope", "catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, emptyHeaders, @@ -1449,7 +1607,7 @@ public void testCatalogRefreshedTokenIsUsed(String oauth2ServerUri) { // use the client credential token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -1463,7 +1621,7 @@ public void testCatalogRefreshedTokenIsUsed(String oauth2ServerUri) { "scope", "catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, catalogHeaders, @@ -1480,8 +1638,7 @@ public void testCatalogRefreshedTokenIsUsed(String oauth2ServerUri) { "Bearer token-exchange-token:sub=client-credentials-token:sub=catalog"); Mockito.verify(adapter) .execute( - reqMatcher( - HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), refreshedCatalogHeader), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), refreshedCatalogHeader), any(), any(), any()); @@ -1545,7 +1702,7 @@ public void testCatalogTokenRefreshExchangeDisabled(String oauth2ServerUri) { // call client credentials with no initial auth Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), + matches(HTTPMethod.POST, oauth2ServerUri, emptyHeaders), eq(OAuthTokenResponse.class), any(), any()); @@ -1553,7 +1710,7 @@ public void testCatalogTokenRefreshExchangeDisabled(String oauth2ServerUri) { // use the client credential token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, "v1/config", catalogHeaders), + matches(HTTPMethod.GET, "v1/config", catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -1561,7 +1718,7 @@ public void testCatalogTokenRefreshExchangeDisabled(String oauth2ServerUri) { // verify the new token request is issued Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, emptyHeaders, Map.of(), refreshRequest), eq(OAuthTokenResponse.class), any(), @@ -1628,7 +1785,7 @@ public void testCatalogExpiredBearerTokenIsRefreshedWithCredential(String oauth2 "scope", "catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, emptyHeaders, Map.of(), clientCredentialsRequest), eq(OAuthTokenResponse.class), any(), @@ -1636,7 +1793,7 @@ public void testCatalogExpiredBearerTokenIsRefreshedWithCredential(String oauth2 Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -1649,7 +1806,7 @@ public void testCatalogExpiredBearerTokenIsRefreshedWithCredential(String oauth2 "scope", "catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, OAuth2Util.basicAuthHeaders(credential), @@ -1668,7 +1825,7 @@ public void testCatalogExpiredBearerTokenIsRefreshedWithCredential(String oauth2 "scope", "catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, OAuth2Util.basicAuthHeaders(credential), @@ -1680,7 +1837,7 @@ public void testCatalogExpiredBearerTokenIsRefreshedWithCredential(String oauth2 Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), Map.of("Authorization", "Bearer token-exchange-token:sub=" + token)), @@ -1729,7 +1886,7 @@ public void testCatalogExpiredTokenCredentialRefreshWithExchangeDisabled(String Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, emptyHeaders, Map.of(), clientCredentialsRequest), eq(OAuthTokenResponse.class), any(), @@ -1737,14 +1894,14 @@ public void testCatalogExpiredTokenCredentialRefreshWithExchangeDisabled(String Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, "v1/config", catalogHeaders), + matches(HTTPMethod.GET, "v1/config", catalogHeaders), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, emptyHeaders, Map.of(), clientCredentialsRequest), eq(OAuthTokenResponse.class), any(), @@ -1774,14 +1931,14 @@ public void testCatalogValidBearerTokenIsNotRefreshed() { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), OAuth2Util.authHeaders(token)), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), OAuth2Util.authHeaders(token)), any(), any(), any()); @@ -1867,7 +2024,7 @@ public void testCatalogTokenRefreshFailsAndUsesCredentialForRefresh(String oauth "scope", "catalog"); Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.POST, oauth2ServerUri, emptyHeaders, @@ -1880,7 +2037,7 @@ public void testCatalogTokenRefreshFailsAndUsesCredentialForRefresh(String oauth // use the client credential token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -1912,8 +2069,7 @@ public void testCatalogTokenRefreshFailsAndUsesCredentialForRefresh(String oauth "Bearer token-exchange-token:sub=client-credentials-token:sub=catalog"); Mockito.verify(adapter) .execute( - reqMatcher( - HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), refreshedCatalogHeader), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TBL), refreshedCatalogHeader), any(), any(), any()); @@ -1980,7 +2136,7 @@ public void testCatalogWithCustomTokenScope(String oauth2ServerUri) { // use the client credential token for config Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -2046,7 +2202,7 @@ public void testCatalogTokenRefreshDisabledWithToken(String oauth2ServerUri) { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -2098,7 +2254,7 @@ public void testCatalogTokenRefreshDisabledWithCredential(String oauth2ServerUri Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), + matches(HTTPMethod.GET, ResourcePaths.config(), catalogHeaders), eq(ConfigResponse.class), any(), any()); @@ -2270,14 +2426,14 @@ public void testPaginationForListNamespaces(int numberOfItems) { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), + matches(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter, times(numberOfItems)) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.namespaces(), Map.of(), Map.of()), + matches(HTTPMethod.POST, RESOURCE_PATHS.namespaces(), Map.of(), Map.of()), eq(CreateNamespaceResponse.class), any(), any()); @@ -2332,14 +2488,14 @@ public void testPaginationForListTables(int numberOfItems) { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), + matches(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter, times(numberOfItems)) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.tables(namespace), Map.of(), Map.of()), + matches(HTTPMethod.POST, RESOURCE_PATHS.tables(namespace), Map.of(), Map.of()), eq(LoadTableResponse.class), any(), any()); @@ -2392,7 +2548,7 @@ public void testCleanupUncommitedFilesForCleanableFailures() { Table table = catalog.loadTable(TABLE); Mockito.doThrow(new NotAuthorizedException("not authorized")) .when(adapter) - .execute(reqMatcher(HTTPMethod.POST), any(), any(), any()); + .execute(matches(HTTPMethod.POST), any(), any(), any()); assertThatThrownBy(() -> catalog.loadTable(TABLE).newFastAppend().appendFile(file).commit()) .isInstanceOf(NotAuthorizedException.class) .hasMessage("not authorized"); @@ -2429,7 +2585,7 @@ public void testNoCleanupForNonCleanableExceptions() { Mockito.doThrow(new ServiceFailureException("some service failure")) .when(adapter) - .execute(reqMatcher(HTTPMethod.POST), any(), any(), any()); + .execute(matches(HTTPMethod.POST), any(), any(), any()); assertThatThrownBy(() -> catalog.loadTable(TABLE).newFastAppend().appendFile(FILE_A).commit()) .isInstanceOf(ServiceFailureException.class) .hasMessage("some service failure"); @@ -2463,7 +2619,7 @@ public void testCleanupCleanableExceptionsCreate() { TableIdentifier newTable = TableIdentifier.of(TABLE.namespace(), "some_table"); Mockito.doThrow(new NotAuthorizedException("not authorized")) .when(adapter) - .execute(reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(newTable)), any(), any(), any()); + .execute(matches(HTTPMethod.POST, RESOURCE_PATHS.table(newTable)), any(), any(), any()); Transaction createTableTransaction = catalog.newCreateTableTransaction(newTable, SCHEMA); createTableTransaction.newAppend().appendFile(FILE_A).commit(); @@ -2510,7 +2666,7 @@ public void testNoCleanupForNonCleanableCreateTransaction() { TableIdentifier newTable = TableIdentifier.of(TABLE.namespace(), "some_table"); Mockito.doThrow(new ServiceFailureException("some service failure")) .when(adapter) - .execute(reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(newTable)), any(), any(), any()); + .execute(matches(HTTPMethod.POST, RESOURCE_PATHS.table(newTable)), any(), any(), any()); Transaction createTableTransaction = catalog.newCreateTableTransaction(newTable, SCHEMA); createTableTransaction.newAppend().appendFile(FILE_A).commit(); @@ -2539,6 +2695,77 @@ public void testNoCleanupForNonCleanableCreateTransaction() { }); } + @Test + public void testNoCleanupOnCreate503() { + RESTCatalogAdapter adapter = + Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + protected T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + var response = super.execute(request, responseType, errorHandler, responseHeaders); + if (request.method() == HTTPMethod.POST && request.path().contains(TABLE.name())) { + // Simulate a 503 Service Unavailable error + ErrorResponse error = + ErrorResponse.builder() + .responseCode(503) + .withMessage("Service unavailable") + .build(); + + errorHandler.accept(error); + throw new IllegalStateException("Error handler should have thrown"); + } + return response; + } + }); + + RESTCatalog catalog = catalog(adapter); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(TABLE.namespace()); + } + + Transaction createTableTransaction = catalog.newCreateTableTransaction(TABLE, SCHEMA); + createTableTransaction.newAppend().appendFile(FILE_A).commit(); + + // Verify that 503 is mapped to CommitStateUnknownException (not just ServiceFailureException) + assertThatThrownBy(createTableTransaction::commitTransaction) + .isInstanceOf(CommitStateUnknownException.class) + .cause() + .isInstanceOf(ServiceFailureException.class) + .hasMessageContaining("Service failed: 503"); + + // Verify files are NOT cleaned up (because commit state is unknown) + assertThat(allRequests(adapter)) + .anySatisfy( + req -> { + assertThat(req.method()).isEqualTo(HTTPMethod.POST); + assertThat(req.path()).isEqualTo(RESOURCE_PATHS.table(TABLE)); + assertThat(req.body()).isInstanceOf(UpdateTableRequest.class); + UpdateTableRequest body = (UpdateTableRequest) req.body(); + assertThat( + body.updates().stream() + .filter(MetadataUpdate.AddSnapshot.class::isInstance) + .map(MetadataUpdate.AddSnapshot.class::cast) + .findFirst()) + .hasValueSatisfying( + addSnapshot -> { + String manifestListLocation = addSnapshot.snapshot().manifestListLocation(); + // Files should still exist because we don't know if commit succeeded + assertThat( + catalog + .loadTable(TABLE) + .io() + .newInputFile(manifestListLocation) + .exists()) + .isTrue(); + }); + }); + } + @Test public void testCleanupCleanableExceptionsReplace() { RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); @@ -2551,7 +2778,7 @@ public void testCleanupCleanableExceptionsReplace() { catalog.createTable(TABLE, SCHEMA); Mockito.doThrow(new NotAuthorizedException("not authorized")) .when(adapter) - .execute(reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); + .execute(matches(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); Transaction replaceTableTransaction = catalog.newReplaceTableTransaction(TABLE, SCHEMA, false); replaceTableTransaction.newAppend().appendFile(FILE_A).commit(); @@ -2594,7 +2821,7 @@ public void testNoCleanupForNonCleanableReplaceTransaction() { catalog.createTable(TABLE, SCHEMA); Mockito.doThrow(new ServiceFailureException("some service failure")) .when(adapter) - .execute(reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); + .execute(matches(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); Transaction replaceTableTransaction = catalog.newReplaceTableTransaction(TABLE, SCHEMA, false); replaceTableTransaction.newAppend().appendFile(FILE_A).commit(); @@ -2635,13 +2862,13 @@ public void testNamespaceExistsViaHEADRequest() { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), + matches(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.namespace(namespace), Map.of(), Map.of()), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.namespace(namespace), Map.of(), Map.of()), any(), any(), any()); @@ -2684,7 +2911,7 @@ public T execute( Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), + matches(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); @@ -2692,7 +2919,7 @@ public T execute( // verifies that the namespace is loaded via a GET instead of HEAD (V1_NAMESPACE_EXISTS) Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.namespace(namespace), Map.of(), Map.of()), + matches(HTTPMethod.GET, RESOURCE_PATHS.namespace(namespace), Map.of(), Map.of()), any(), any(), any()); @@ -2716,13 +2943,13 @@ public void testTableExistsViaHEADRequest() { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), + matches(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, RESOURCE_PATHS.table(TABLE), Map.of(), Map.of()), + matches(HTTPMethod.HEAD, RESOURCE_PATHS.table(TABLE), Map.of(), Map.of()), any(), any(), any()); @@ -2762,7 +2989,7 @@ public T execute( Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), + matches(HTTPMethod.GET, ResourcePaths.config(), Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); @@ -2770,7 +2997,7 @@ public T execute( // verifies that the table is loaded via a GET instead of HEAD (V1_LOAD_TABLE) Mockito.verify(adapter) .execute( - reqMatcher( + matches( HTTPMethod.GET, RESOURCE_PATHS.table(TABLE), Map.of(), Map.of("snapshots", "all")), any(), any(), @@ -2784,186 +3011,6 @@ public void testTableExistsFallbackToGETRequestWithLegacyServer() { verifyTableExistsFallbackToGETRequest(ConfigResponse.builder().build()); } - @Test - public void testETagWithCreateAndLoadTable() { - Map respHeaders = Maps.newHashMap(); - - RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); - - if (requiresNamespaceCreate()) { - catalog.createNamespace(TABLE.namespace()); - } - - catalog.createTable(TABLE, SCHEMA); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - String eTag = respHeaders.get(HttpHeaders.ETAG); - respHeaders.clear(); - - catalog.loadTable(TABLE); - - assertThat(respHeaders).containsEntry(HttpHeaders.ETAG, eTag); - } - - @Test - public void testETagWithDifferentTables() { - Map respHeaders = Maps.newHashMap(); - - RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); - - if (requiresNamespaceCreate()) { - catalog.createNamespace(TABLE.namespace()); - } - - catalog.createTable(TABLE, SCHEMA); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - String eTagTbl1 = respHeaders.get(HttpHeaders.ETAG); - respHeaders.clear(); - - catalog.createTable(TableIdentifier.of(TABLE.namespace(), "table2"), SCHEMA); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - assertThat(eTagTbl1).isNotEqualTo(respHeaders.get(HttpHeaders.ETAG)); - } - - @Test - public void testETagAfterDataUpdate() { - Map respHeaders = Maps.newHashMap(); - - RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); - - if (requiresNamespaceCreate()) { - catalog.createNamespace(TABLE.namespace()); - } - - Table tbl = catalog.createTable(TABLE, SCHEMA); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - String eTag = respHeaders.get(HttpHeaders.ETAG); - - respHeaders.clear(); - - tbl.newAppend().appendFile(FILE_A).commit(); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - assertThat(eTag).isNotEqualTo(respHeaders.get(HttpHeaders.ETAG)); - } - - @Test - public void testETagAfterMetadataOnlyUpdate() { - Map respHeaders = Maps.newHashMap(); - - RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); - - if (requiresNamespaceCreate()) { - catalog.createNamespace(TABLE.namespace()); - } - - Table tbl = catalog.createTable(TABLE, SCHEMA); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - String eTag = respHeaders.get(HttpHeaders.ETAG); - - respHeaders.clear(); - - tbl.updateSchema().addColumn("extra", Types.IntegerType.get()).commit(); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - assertThat(eTag).isNotEqualTo(respHeaders.get(HttpHeaders.ETAG)); - } - - @Test - public void testETagWithRegisterTable() { - Map respHeaders = Maps.newHashMap(); - - RESTCatalog catalog = catalogWithResponseHeaders(respHeaders); - - if (requiresNamespaceCreate()) { - catalog.createNamespace(TABLE.namespace()); - } - - Table tbl = catalog.createTable(TABLE, SCHEMA); - - assertThat(respHeaders).containsKey(HttpHeaders.ETAG); - String eTag = respHeaders.get(HttpHeaders.ETAG); - - respHeaders.clear(); - - catalog.registerTable( - TableIdentifier.of(TABLE.namespace(), "other_table"), - ((BaseTable) tbl).operations().current().metadataFileLocation()); - - assertThat(respHeaders).containsEntry(HttpHeaders.ETAG, eTag); - } - - @SuppressWarnings("checkstyle:AssertThatThrownByWithMessageCheck") - @Test - public void testNotModified() { - catalog().createNamespace(TABLE.namespace()); - - Table table = catalog().createTable(TABLE, SCHEMA); - - String eTag = - ETagProvider.of(((BaseTable) table).operations().current().metadataFileLocation()); - - Mockito.doAnswer( - invocation -> { - HTTPRequest originalRequest = invocation.getArgument(0); - - HTTPHeaders extendedHeaders = - ImmutableHTTPHeaders.copyOf(originalRequest.headers()) - .putIfAbsent( - ImmutableHTTPHeader.builder() - .name(HttpHeaders.IF_NONE_MATCH) - .value(eTag) - .build()); - - ImmutableHTTPRequest extendedRequest = - ImmutableHTTPRequest.builder() - .from(originalRequest) - .headers(extendedHeaders) - .build(); - - return adapterForRESTServer.execute( - extendedRequest, - LoadTableResponse.class, - invocation.getArgument(2), - invocation.getArgument(3), - ParserContext.builder().build()); - }) - .when(adapterForRESTServer) - .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), - eq(LoadTableResponse.class), - any(), - any()); - - // TODO: This won't throw when client side of freshness-aware loading is implemented - assertThatThrownBy(() -> catalog().loadTable(TABLE)).isInstanceOf(NullPointerException.class); - - TableIdentifier metadataTableIdentifier = - TableIdentifier.of(TABLE.namespace().toString(), TABLE.name(), "partitions"); - - // TODO: This won't throw when client side of freshness-aware loading is implemented - assertThatThrownBy(() -> catalog().loadTable(metadataTableIdentifier)) - .isInstanceOf(NullPointerException.class); - - Mockito.verify(adapterForRESTServer, times(2)) - .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), - eq(LoadTableResponse.class), - any(), - any()); - - verify(adapterForRESTServer) - .execute( - reqMatcher(HTTPMethod.GET, RESOURCE_PATHS.table(metadataTableIdentifier)), - any(), - any(), - any()); - } - @Test void testDifferentTableUUID() { RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); @@ -2989,7 +3036,7 @@ void testDifferentTableUUID() { Mockito.doAnswer(updateTable) .when(adapter) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), + matches(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), eq(LoadTableResponse.class), any(), any()); @@ -3006,22 +3053,6 @@ void testDifferentTableUUID() { .hasMessageMatching("Table UUID does not match: current=.* != refreshed=" + newUUID); } - private RESTCatalog catalogWithResponseHeaders(Map respHeaders) { - RESTCatalogAdapter adapter = - new RESTCatalogAdapter(backendCatalog) { - @Override - public T execute( - HTTPRequest request, - Class responseType, - Consumer errorHandler, - Consumer> responseHeaders) { - return super.execute(request, responseType, errorHandler, respHeaders::putAll); - } - }; - - return catalog(adapter); - } - @Test public void testReconcileOnUnknownSnapshotAddMatchesSnapshotId() { RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); @@ -3048,7 +3079,7 @@ public void testReconcileOnUnknownSnapshotAddMatchesSnapshotId() { }) .when(adapter) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), + matches(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), eq(LoadTableResponse.class), any(), any()); @@ -3104,7 +3135,7 @@ public void testCommitStateUnknownNotReconciled() { new CommitStateUnknownException(new ServiceFailureException("Service failed: 503"))) .when(adapter) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), + matches(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), eq(LoadTableResponse.class), any(), any()); @@ -3235,7 +3266,7 @@ protected RESTTableOperations newTableOps( // Verify the custom operations were used with custom headers Mockito.verify(adapter, Mockito.atLeastOnce()) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE), customHeaders), + matches(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE), customHeaders), eq(LoadTableResponse.class), any(), any()); @@ -3259,7 +3290,7 @@ protected RESTTableOperations newTableOps( // Verify the custom operations were used with custom headers Mockito.verify(adapter, Mockito.atLeastOnce()) .execute( - reqMatcher(HTTPMethod.POST, RESOURCE_PATHS.table(table2), customHeaders), + matches(HTTPMethod.POST, RESOURCE_PATHS.table(table2), customHeaders), eq(LoadTableResponse.class), any(), any()); @@ -3309,6 +3340,240 @@ public void testClientDoesNotSendIdempotencyWhenServerNotAdvertising() { local.dropTable(ident); } + @Test + public void testIdempotentDuplicateCreateReturnsCached() { + String key = "dup-create-key"; + Namespace ns = Namespace.of("ns_dup"); + IdempotentEnv env = idempotentEnv(key, ns, "t_dup"); + CreateTableRequest req = createReq(env.ident); + + // First create succeeds + LoadTableResponse first = + env.http.post( + ResourcePaths.forCatalogProperties(ImmutableMap.of()).tables(ns), + req, + LoadTableResponse.class, + env.headers, + ErrorHandlers.tableErrorHandler()); + assertThat(first).isNotNull(); + + // Verify request shape (method, path, headers including Idempotency-Key) + verifyCreatePost(ns, env.headers); + + // Duplicate with same key returns cached 200 OK + LoadTableResponse second = + env.http.post( + ResourcePaths.forCatalogProperties(ImmutableMap.of()).tables(ns), + req, + LoadTableResponse.class, + env.headers, + ErrorHandlers.tableErrorHandler()); + assertThat(second).isNotNull(); + } + + @Test + public void testIdempotencyKeyLifetimeExpiredTreatsAsNew() { + // Set TTL to 0 so cached success expires immediately + CatalogHandlers.setIdempotencyLifetimeFromIso("PT0S"); + try { + String key = "expired-create-key"; + Namespace ns = Namespace.of("ns_exp"); + IdempotentEnv env = idempotentEnv(key, ns, "t_exp"); + CreateTableRequest req = createReq(env.ident); + + // First create succeeds + LoadTableResponse created = + env.http.post( + ResourcePaths.forCatalogProperties(ImmutableMap.of()).tables(ns), + req, + LoadTableResponse.class, + env.headers, + ErrorHandlers.tableErrorHandler()); + assertThat(created).isNotNull(); + + // Verify request shape (method, path, headers including Idempotency-Key) + verifyCreatePost(ns, env.headers); + + // TTL expired -> duplicate with same key should be treated as new and fail with AlreadyExists + assertThatThrownBy( + () -> + env.http.post( + ResourcePaths.forCatalogProperties(ImmutableMap.of()).tables(ns), + req, + LoadTableResponse.class, + env.headers, + ErrorHandlers.tableErrorHandler())) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageContaining(env.ident.toString()); + } finally { + // Restore default TTL for other tests + CatalogHandlers.setIdempotencyLifetimeFromIso("PT30M"); + } + } + + @Test + public void testIdempotentCreateReplayAfterSimulated503() { + // Use a fixed key and simulate 503 after first success for that key + String key = "idemp-create-503"; + adapterForRESTServer.simulate503OnFirstSuccessForKey(key); + Namespace ns = Namespace.of("ns_idemp"); + IdempotentEnv env = idempotentEnv(key, ns, "t_idemp"); + CreateTableRequest req = createReq(env.ident); + + // First attempt: server finalizes success but responds 503 + assertThatThrownBy( + () -> + env.http.post( + ResourcePaths.forCatalogProperties(ImmutableMap.of()).tables(ns), + req, + LoadTableResponse.class, + env.headers, + ErrorHandlers.tableErrorHandler())) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("simulated transient 503"); + + // Verify request shape (method, path, headers including Idempotency-Key) + verifyCreatePost(ns, env.headers); + + // Retry with same key: server should replay 200 OK + LoadTableResponse replay = + env.http.post( + ResourcePaths.forCatalogProperties(ImmutableMap.of()).tables(ns), + req, + LoadTableResponse.class, + env.headers, + ErrorHandlers.tableErrorHandler()); + assertThat(replay).isNotNull(); + } + + @Test + public void testIdempotentDropDuplicateNoop() { + String key = "idemp-drop-void"; + Namespace ns = Namespace.of("ns_void"); + IdempotentEnv env = idempotentEnv(key, ns, "t_void"); + + // Create a table to drop + restCatalog.createTable( + env.ident, + new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())), + PartitionSpec.unpartitioned()); + + String path = ResourcePaths.forCatalogProperties(ImmutableMap.of()).table(env.ident); + + // First drop: table exists -> drop succeeds + env.http.delete(path, null, env.headers, ErrorHandlers.tableErrorHandler()); + assertThat(restCatalog.tableExists(env.ident)).isFalse(); + + // Second drop with the same key: should be a no-op (no exception) + assertThatCode( + () -> env.http.delete(path, null, env.headers, ErrorHandlers.tableErrorHandler())) + .doesNotThrowAnyException(); + } + + @Test + public void nestedNamespaceWithLegacySeparator() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + + // Simulate that the server doesn't send the namespace separator in the overrides + Mockito.doAnswer( + invocation -> { + ConfigResponse configResponse = (ConfigResponse) invocation.callRealMethod(); + + Map overridesWithoutNamespaceSeparator = configResponse.overrides(); + overridesWithoutNamespaceSeparator.remove(RESTCatalogProperties.NAMESPACE_SEPARATOR); + + return ConfigResponse.builder() + .withDefaults(configResponse.defaults()) + .withOverrides(overridesWithoutNamespaceSeparator) + .withEndpoints(configResponse.endpoints()) + .withIdempotencyKeyLifetime(configResponse.idempotencyKeyLifetime()) + .build(); + }) + .when(adapter) + .execute( + matches(HTTPMethod.GET, ResourcePaths.config()), + eq(ConfigResponse.class), + any(), + any()); + + RESTCatalog catalog = catalog(adapter); + + ResourcePaths pathsWithLegacySeparator = ResourcePaths.forCatalogProperties(ImmutableMap.of()); + + runConfigurableNamespaceSeparatorTest( + catalog, adapter, pathsWithLegacySeparator, RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8); + } + + @Test + public void nestedNamespaceWithOverriddenSeparator() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + + // When initializing the catalog, the adapter always sends an override for the namespace + // separator. + Mockito.doAnswer( + invocation -> { + ConfigResponse configResponse = (ConfigResponse) invocation.callRealMethod(); + + assertThat(configResponse.overrides()) + .containsEntry( + RESTCatalogProperties.NAMESPACE_SEPARATOR, + RESTCatalogAdapter.NAMESPACE_SEPARATOR_URLENCODED_UTF_8); + + return configResponse; + }) + .when(adapter) + .execute( + matches(HTTPMethod.GET, ResourcePaths.config()), + eq(ConfigResponse.class), + any(), + any()); + + RESTCatalog catalog = catalog(adapter); + + runConfigurableNamespaceSeparatorTest( + catalog, adapter, RESOURCE_PATHS, RESTCatalogAdapter.NAMESPACE_SEPARATOR_URLENCODED_UTF_8); + } + + private void runConfigurableNamespaceSeparatorTest( + RESTCatalog catalog, + RESTCatalogAdapter adapter, + ResourcePaths expectedPaths, + String expectedSeparator) { + Namespace nestedNamespace = Namespace.of("ns1", "ns2", "ns3"); + Namespace parentNamespace = Namespace.of("ns1", "ns2"); + TableIdentifier table = TableIdentifier.of(nestedNamespace, "tbl"); + + catalog.createNamespace(nestedNamespace); + + catalog.createTable(table, SCHEMA); + + assertThat(catalog.listNamespaces(parentNamespace)).containsExactly(nestedNamespace); + + // Verify the namespace separator in the path + Mockito.verify(adapter) + .execute( + matches(HTTPMethod.POST, expectedPaths.tables(nestedNamespace)), + eq(LoadTableResponse.class), + any(), + any()); + + // Verify the namespace separator in query parameters + Mockito.verify(adapter) + .execute( + matches( + HTTPMethod.GET, + expectedPaths.namespaces(), + Map.of(), + Map.of( + "parent", + RESTUtil.namespaceToQueryParam(parentNamespace, expectedSeparator), + "pageToken", + "")), + eq(ListNamespacesResponse.class), + any(), + any()); + } + private RESTCatalog createCatalogWithIdempAdapter(ConfigResponse cfg, boolean expectOnMutations) { RESTCatalogAdapter adapter = Mockito.spy( @@ -3345,6 +3610,378 @@ public T execute( return local; } + private Pair> httpAndHeaders(String idempotencyKey) { + Map headers = + ImmutableMap.of( + RESTUtil.IDEMPOTENCY_KEY_HEADER, + idempotencyKey, + "Authorization", + "Bearer client-credentials-token:sub=user", + "test-header", + "test-value"); + + Map conf = + ImmutableMap.of( + CatalogProperties.URI, + httpServer.getURI().toString(), + HTTPClient.REST_SOCKET_TIMEOUT_MS, + "600000", + HTTPClient.REST_CONNECTION_TIMEOUT_MS, + "600000", + "header.test-header", + "test-value"); + RESTClient httpBase = + HTTPClient.builder(conf) + .uri(conf.get(CatalogProperties.URI)) + .withHeaders(RESTUtil.configHeaders(conf)) + .build(); + AuthManager am = AuthManagers.loadAuthManager("test", conf); + AuthSession httpSession = am.initSession(httpBase, conf); + RESTClient http = httpBase.withAuthSession(httpSession); + return Pair.of(http, headers); + } + + private Pair>> prepareIdempotentEnv( + String key, Namespace ns, String tableName) { + TableIdentifier ident = TableIdentifier.of(ns, tableName); + restCatalog.createNamespace(ns, ImmutableMap.of()); + return Pair.of(ident, httpAndHeaders(key)); + } + + private IdempotentEnv idempotentEnv(String key, Namespace ns, String tableName) { + Pair>> env = + prepareIdempotentEnv(key, ns, tableName); + Pair> httpAndHeaders = env.second(); + return new IdempotentEnv(env.first(), httpAndHeaders.first(), httpAndHeaders.second()); + } + + private static CreateTableRequest createReq(TableIdentifier ident) { + return CreateTableRequest.builder() + .withName(ident.name()) + .withSchema(new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get()))) + .withPartitionSpec(PartitionSpec.unpartitioned()) + .build(); + } + + private void verifyCreatePost(Namespace ns, Map headers) { + verify(adapterForRESTServer, atLeastOnce()) + .execute( + containsHeaders( + HTTPMethod.POST, + ResourcePaths.forCatalogProperties(ImmutableMap.of()).tables(ns), + headers), + eq(LoadTableResponse.class), + any(), + any()); + } + + @Test + @Override + public void testLoadTableWithMissingMetadataFile(@TempDir Path tempDir) { + + if (requiresNamespaceCreate()) { + restCatalog.createNamespace(TBL.namespace()); + } + + restCatalog.buildTable(TBL, SCHEMA).create(); + assertThat(restCatalog.tableExists(TBL)).as("Table should exist").isTrue(); + + Table table = restCatalog.loadTable(TBL); + String metadataFileLocation = + ((HasTableOperations) table).operations().current().metadataFileLocation(); + table.io().deleteFile(metadataFileLocation); + + assertThatThrownBy(() -> restCatalog.loadTable(TBL)) + .isInstanceOf(NotFoundException.class) + .hasMessageContaining("No in-memory file found for location: " + metadataFileLocation); + } + + @Test + public void testNumLoadTableCallsForMergeAppend() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + RESTCatalog catalog = catalog(adapter); + + catalog.createNamespace(TABLE.namespace()); + BaseTable table = (BaseTable) catalog.createTable(TABLE, SCHEMA); + table.newAppend().appendFile(FILE_A).commit(); + + // loadTable is executed once + Mockito.verify(adapter, times(2)) + .execute(matches(HTTPMethod.GET, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); + + // CommitReport reflects the table state after the commit + Mockito.verify(adapter) + .execute( + matches( + HTTPMethod.POST, + RESOURCE_PATHS.metrics(TABLE), + Map.of(), + Map.of(), + requestObj -> + requestObj instanceof ReportMetricsRequest reportRequest + && reportRequest.report() instanceof CommitReport commitReport + && commitReport.tableName().equals(table.name()) + && commitReport.snapshotId() == table.currentSnapshot().snapshotId() + && commitReport.sequenceNumber() == table.currentSnapshot().sequenceNumber() + && commitReport.operation().equals("append") + && commitReport.commitMetrics().addedDataFiles().value() == 1), + any(), + any(), + any()); + } + + @Test + public void testSequenceNumberConflictThrowsCommitFailed() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + RESTCatalog catalog = catalog(adapter); + + catalog.createNamespace(TABLE.namespace()); + catalog.buildTable(TABLE, SCHEMA).withPartitionSpec(SPEC).create(); + + DataFile fileOnMain = + DataFiles.builder(SPEC) + .withPath("/path/commit-test-file1.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("id_bucket=0") + .withRecordCount(1) + .build(); + + catalog.loadTable(TABLE).newFastAppend().appendFile(fileOnMain).commit(); + + DataFile fileOnAnotherBranch = + DataFiles.builder(SPEC) + .withPath("/path/commit-test-conflicting.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("id_bucket=0") + .withRecordCount(1) + .build(); + + // Before the next commit is processed by the server, advance the server's lastSequenceNumber + // by committing to a different branch. This simulates a concurrent request to a different + // branch + // that "beats" the commit to main. + Mockito.doAnswer( + invocation -> { + backendCatalog + .loadTable(TABLE) + .newFastAppend() + .appendFile(fileOnAnotherBranch) + .toBranch("other") + .commit(); + return invocation.callRealMethod(); + }) + .when(adapter) + .execute(matches(HTTPMethod.POST, RESOURCE_PATHS.table(TABLE)), any(), any(), any()); + + DataFile anotherFileOnMain = + DataFiles.builder(SPEC) + .withPath("/path/commit-test-file2.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("id_bucket=0") + .withRecordCount(1) + .build(); + + assertThatThrownBy( + () -> catalog.loadTable(TABLE).newFastAppend().appendFile(anotherFileOnMain).commit()) + .isInstanceOf(CommitFailedException.class) + .hasMessageContaining("Validation failed, please retry"); + } + + @Test + public void testIoBuilderReceivesStorageCredentials() { + Credential credential = + ImmutableCredential.builder() + .prefix("s3://test-bucket/") + .putConfig("s3.access-key-id", "test-access-key") + .putConfig("s3.secret-access-key", "test-secret-key") + .build(); + + // Adapter that injects storage credentials into LoadTableResponse + RESTCatalogAdapter adapter = + new RESTCatalogAdapter(backendCatalog) { + @SuppressWarnings("unchecked") + @Override + public T handleRequest( + Route route, + Map vars, + HTTPRequest httpRequest, + Class responseType, + Consumer> responseHeaders) { + T response = + super.handleRequest(route, vars, httpRequest, responseType, responseHeaders); + if (route == Route.LOAD_TABLE && response instanceof LoadTableResponse loadResponse) { + return (T) + LoadTableResponse.builder() + .withTableMetadata(loadResponse.tableMetadata()) + .addAllConfig(loadResponse.config()) + .addCredential(credential) + .build(); + } + return response; + } + }; + + AtomicReference createdFileIO = new AtomicReference<>(); + + try (RESTCatalog catalog = + catalog( + adapter, + clientBuilder -> + new RESTSessionCatalog( + clientBuilder, + (context, config) -> { + TestCatalogUtil.TestFileIOWithStorageCredentials fileIO = + new TestCatalogUtil.TestFileIOWithStorageCredentials(); + createdFileIO.set(fileIO); + return fileIO; + }))) { + catalog.createNamespace(NS); + catalog.createTable(TABLE, SCHEMA); + catalog.loadTable(TABLE); + + assertThat(createdFileIO.get()).isInstanceOf(SupportsStorageCredentials.class); + List creds = + ((SupportsStorageCredentials) createdFileIO.get()).credentials(); + assertThat(creds).hasSize(1); + assertThat(creds.get(0).prefix()).isEqualTo("s3://test-bucket/"); + assertThat(creds.get(0).config()) + .containsEntry("s3.access-key-id", "test-access-key") + .containsEntry("s3.secret-access-key", "test-secret-key"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Test + public void testRegisterTableOverwriteFalse() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + RESTCatalog catalog = catalog(adapter); + + catalog.createNamespace(TABLE.namespace()); + Table sourceTable = catalog.createTable(TABLE, SCHEMA); + String metadataLocation = + ((HasTableOperations) sourceTable).operations().current().metadataFileLocation(); + TableIdentifier target = TableIdentifier.of(TABLE.namespace(), "table_register_false"); + + catalog.registerTable(target, metadataLocation, false); + + verify(adapter) + .execute( + matches( + HTTPMethod.POST, + RESOURCE_PATHS.register(target.namespace()), + Map.of(), + Map.of(), + requestObj -> + requestObj instanceof RegisterTableRequest request + && request.name().equals(target.name()) + && request.metadataLocation().equals(metadataLocation) + && !request.overwrite()), + eq(LoadTableResponse.class), + any(), + any()); + } + + @Test + public void testRegisterTableOverwriteTrue() { + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(backendCatalog)); + RESTCatalog catalog = catalog(adapter); + + catalog.createNamespace(TABLE.namespace()); + Table sourceTable = catalog.createTable(TABLE, SCHEMA); + String metadataLocation = + ((HasTableOperations) sourceTable).operations().current().metadataFileLocation(); + TableIdentifier target = TableIdentifier.of(TABLE.namespace(), "table_register_true"); + + assertThatThrownBy(() -> catalog.registerTable(target, metadataLocation, true)) + .isInstanceOf(RESTException.class) + .hasMessageContaining("Registering tables with overwrite is not supported"); + + verify(adapter) + .execute( + matches( + HTTPMethod.POST, + RESOURCE_PATHS.register(target.namespace()), + Map.of(), + Map.of(), + requestObj -> + requestObj instanceof RegisterTableRequest request + && request.name().equals(target.name()) + && request.metadataLocation().equals(metadataLocation) + && request.overwrite()), + eq(LoadTableResponse.class), + any(), + any()); + } + + @Test + public void testRegisterTableOverwriteTrueSupported() throws Exception { + File warehouse = new File(temp.toFile(), "overwrite-supported-warehouse"); + + InMemoryCatalog overwriteCatalog = + new InMemoryCatalog() { + @Override + public Table registerTable( + TableIdentifier identifier, String metadataFileLocation, boolean overwrite) { + if (overwrite && tableExists(identifier)) { + dropTable(identifier, false); + } + + return registerTable(identifier, metadataFileLocation); + } + }; + + overwriteCatalog.initialize( + "overwrite-catalog", + ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, warehouse.getAbsolutePath())); + + RESTCatalogAdapter adapter = Mockito.spy(new RESTCatalogAdapter(overwriteCatalog)); + RESTCatalog catalog = catalog(adapter); + + try { + catalog.createNamespace(TABLE.namespace()); + + Table sourceTable = catalog.createTable(TABLE, SCHEMA); + String metadataLocation = + ((HasTableOperations) sourceTable).operations().current().metadataFileLocation(); + + TableIdentifier target = TableIdentifier.of(TABLE.namespace(), "table_register_true_success"); + Table initialTargetTable = + catalog.createTable( + target, + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of("format-version", "2")); + String initialMetadataLocation = + ((HasTableOperations) initialTargetTable).operations().current().metadataFileLocation(); + + Table overwritten = catalog.registerTable(target, metadataLocation, true); + + assertThat(((HasTableOperations) overwritten).operations().current().metadataFileLocation()) + .isEqualTo(metadataLocation) + .isNotEqualTo(initialMetadataLocation); + + verify(adapter) + .execute( + matches( + HTTPMethod.POST, + RESOURCE_PATHS.register(target.namespace()), + Map.of(), + Map.of(), + requestObj -> + requestObj instanceof RegisterTableRequest request + && request.name().equals(target.name()) + && request.metadataLocation().equals(metadataLocation) + && request.overwrite()), + eq(LoadTableResponse.class), + any(), + any()); + } finally { + catalog.close(); + overwriteCatalog.close(); + } + } + private RESTCatalog catalog(RESTCatalogAdapter adapter) { RESTCatalog catalog = new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); @@ -3374,47 +4011,6 @@ protected RESTSessionCatalog newSessionCatalog( return catalog; } - static HTTPRequest reqMatcher(HTTPMethod method) { - return argThat(req -> req.method() == method); - } - - static HTTPRequest reqMatcher(HTTPMethod method, String path) { - return argThat(req -> req.method() == method && req.path().equals(path)); - } - - static HTTPRequest reqMatcher(HTTPMethod method, String path, Map headers) { - return argThat( - req -> - req.method() == method - && req.path().equals(path) - && req.headers().equals(HTTPHeaders.of(headers))); - } - - static HTTPRequest reqMatcher( - HTTPMethod method, String path, Map headers, Map parameters) { - return argThat( - req -> - req.method() == method - && req.path().equals(path) - && req.headers().equals(HTTPHeaders.of(headers)) - && req.queryParameters().equals(parameters)); - } - - static HTTPRequest reqMatcher( - HTTPMethod method, - String path, - Map headers, - Map parameters, - Object body) { - return argThat( - req -> - req.method() == method - && req.path().equals(path) - && req.headers().equals(HTTPHeaders.of(headers)) - && req.queryParameters().equals(parameters) - && Objects.equals(req.body(), body)); - } - private static List allRequests(RESTCatalogAdapter adapter) { ArgumentCaptor captor = ArgumentCaptor.forClass(HTTPRequest.class); verify(adapter, atLeastOnce()).execute(captor.capture(), any(), any(), any()); diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java new file mode 100644 index 000000000000..9b42d445f585 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java @@ -0,0 +1,1579 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import static org.apache.iceberg.TestBase.FILE_A; +import static org.apache.iceberg.TestBase.FILE_A_DELETES; +import static org.apache.iceberg.TestBase.FILE_A_EQUALITY_DELETES; +import static org.apache.iceberg.TestBase.FILE_B; +import static org.apache.iceberg.TestBase.FILE_B_DELETES; +import static org.apache.iceberg.TestBase.FILE_B_EQUALITY_DELETES; +import static org.apache.iceberg.TestBase.FILE_C; +import static org.apache.iceberg.TestBase.FILE_C_EQUALITY_DELETES; +import static org.apache.iceberg.TestBase.SCHEMA; +import static org.apache.iceberg.TestBase.SPEC; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectReader; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Scan; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.catalog.SessionCatalog; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.credentials.ImmutableCredential; +import org.apache.iceberg.rest.requests.PlanTableScanRequest; +import org.apache.iceberg.rest.responses.ConfigResponse; +import org.apache.iceberg.rest.responses.ErrorResponse; +import org.apache.iceberg.rest.responses.FetchPlanningResultResponse; +import org.apache.iceberg.rest.responses.LoadTableResponse; +import org.apache.iceberg.rest.responses.PlanTableScanResponse; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +public class TestRESTScanPlanning extends TestBaseWithRESTServer { + @Override + protected RESTCatalogAdapter createAdapterForServer() { + return Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + // roundTripSerialize before intercepting so we modify the deserialized response + Object body = roundTripSerialize(request.body(), "request"); + HTTPRequest req = ImmutableHTTPRequest.builder().from(request).body(body).build(); + T response = super.execute(req, responseType, errorHandler, responseHeaders); + response = roundTripSerialize(response, "response"); + + // Add scan planning mode to table config for LoadTableResponse + if (response instanceof LoadTableResponse) { + return castResponse( + responseType, + withPlanningMode( + (LoadTableResponse) response, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName())); + } + + return response; + } + }); + } + + @Override + protected String catalogName() { + return "prod-with-scan-planning"; + } + + @Override + protected Map additionalCatalogProperties() { + return ImmutableMap.of( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()); + } + + // ==================== Helper Methods ==================== + + private static LoadTableResponse withPlanningMode(LoadTableResponse response, String mode) { + return LoadTableResponse.builder() + .withTableMetadata(response.tableMetadata()) + .addAllConfig(response.config()) + .addConfig(RESTCatalogProperties.SCAN_PLANNING_MODE, mode) + .addAllCredentials(response.credentials()) + .build(); + } + + @Override + @SuppressWarnings("unchecked") + protected T roundTripSerialize(T payload, String description) { + if (payload == null) { + return null; + } + + try { + if (payload instanceof RESTMessage) { + RESTMessage message = (RESTMessage) payload; + ObjectReader reader = MAPPER.readerFor(message.getClass()); + if (parserContext != null && !parserContext.isEmpty()) { + reader = reader.with(parserContext.toInjectableValues()); + } + return reader.readValue(MAPPER.writeValueAsString(message)); + } else { + // use Map so that Jackson doesn't try to instantiate ImmutableMap from payload.getClass() + return (T) MAPPER.readValue(MAPPER.writeValueAsString(payload), Map.class); + } + } catch (JsonProcessingException e) { + throw new RuntimeException( + String.format("Failed to serialize and deserialize %s: %s", description, payload), e); + } + } + + private void setParserContext(Table table) { + parserContext = + ParserContext.builder().add("specsById", table.specs()).add("caseSensitive", false).build(); + } + + private void configurePlanningBehavior( + Function configurator) { + TestPlanningBehavior.Builder builder = TestPlanningBehavior.builder(); + adapterForRESTServer.setPlanningBehavior(configurator.apply(builder).build()); + } + + private Table createTableWithScanPlanning(RESTCatalog catalog, String tableName) { + return createTableWithScanPlanning(catalog, TableIdentifier.of(NS, tableName)); + } + + private Table createTableWithScanPlanning(RESTCatalog catalog, TableIdentifier identifier) { + catalog.createNamespace(identifier.namespace()); + return catalog.buildTable(identifier, SCHEMA).withPartitionSpec(SPEC).create(); + } + + private RESTTable restTableFor(RESTCatalog catalog, String tableName) { + Table table = createTableWithScanPlanning(catalog, tableName); + table.newAppend().appendFile(FILE_A).commit(); + assertThat(table).isInstanceOf(RESTTable.class); + return (RESTTable) table; + } + + private RESTTableScan restTableScanFor(Table table) { + assertThat(table).isInstanceOf(RESTTable.class); + RESTTable restTable = (RESTTable) table; + TableScan scan = restTable.newScan(); + assertThat(scan).isInstanceOf(RESTTableScan.class); + return (RESTTableScan) scan; + } + + // ==================== Test Planning Behavior ==================== + + /** Enum for parameterized tests to test both synchronous and asynchronous planning modes. */ + private enum PlanningMode + implements Function { + SYNCHRONOUS(TestPlanningBehavior.Builder::synchronous), + ASYNCHRONOUS(TestPlanningBehavior.Builder::asynchronous); + + private final Function configurer; + + PlanningMode(Function configurer) { + this.configurer = configurer; + } + + @Override + public TestPlanningBehavior.Builder apply(TestPlanningBehavior.Builder builder) { + return this.configurer.apply(builder); + } + } + + private static class TestPlanningBehavior implements RESTCatalogAdapter.PlanningBehavior { + private final boolean asyncPlanning; + private final int tasksPerPage; + + private TestPlanningBehavior(boolean asyncPlanning, int tasksPerPage) { + this.asyncPlanning = asyncPlanning; + this.tasksPerPage = tasksPerPage; + } + + private static Builder builder() { + return new Builder(); + } + + @Override + public boolean shouldPlanTableScanAsync(Scan scan) { + return asyncPlanning; + } + + @Override + public int numberFileScanTasksPerPlanTask() { + return tasksPerPage; + } + + protected static class Builder { + private boolean asyncPlanning; + private int tasksPerPage; + + Builder asyncPlanning(boolean async) { + asyncPlanning = async; + return this; + } + + Builder tasksPerPage(int tasks) { + tasksPerPage = tasks; + return this; + } + + // Convenience methods for common test scenarios + Builder synchronous() { + return asyncPlanning(false).tasksPerPage(100); + } + + Builder synchronousWithPagination() { + return asyncPlanning(false).tasksPerPage(1); + } + + Builder asynchronous() { + return asyncPlanning(true).tasksPerPage(100); + } + + TestPlanningBehavior build() { + return new TestPlanningBehavior(asyncPlanning, tasksPerPage); + } + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void scanPlanningWithAllTasksInSingleResponse( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "all_tasks_table"); + setParserContext(table); + + // Verify actual data file is returned with correct count + try (CloseableIterable iterable = table.newScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + + assertThat(tasks).hasSize(1); + assertThat(tasks.get(0).file().location()).isEqualTo(FILE_A.location()); + assertThat(tasks.get(0).deletes()).isEmpty(); + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void scanPlanningWithBatchScan( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "batch_scan_table"); + setParserContext(table); + + // Verify actual data file is returned with correct count + try (CloseableIterable iterable = table.newBatchScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + + assertThat(tasks).hasSize(1); + assertThat(tasks.get(0).asFileScanTask().file().location()).isEqualTo(FILE_A.location()); + assertThat(tasks.get(0).asFileScanTask().deletes()).isEmpty(); + } + } + + @Test + public void nestedPlanTaskPagination() throws IOException { + // Configure: synchronous planning with very small pages (creates nested plan task structure) + configurePlanningBehavior(TestPlanningBehavior.Builder::synchronousWithPagination); + + Table table = restTableFor(restCatalog, "nested_plan_task_table"); + // add one more files for proper pagination + table.newFastAppend().appendFile(FILE_B).commit(); + setParserContext(table); + + // Verify actual data file is returned via nested plan task fetching with correct count + try (CloseableIterable iterable = table.newScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + assertThat(tasks).hasSize(2); + assertThat(tasks) + .anySatisfy(task -> assertThat(task.file().location()).isEqualTo(FILE_A.location())); + assertThat(tasks) + .anySatisfy(task -> assertThat(task.file().location()).isEqualTo(FILE_B.location())); + assertThat(tasks.get(0).deletes()).isEmpty(); + assertThat(tasks.get(1).deletes()).isEmpty(); + } + } + + @Test + public void cancelPlanMethodAvailability() { + configurePlanningBehavior(TestPlanningBehavior.Builder::synchronousWithPagination); + RESTTable table = restTableFor(restCatalog, "cancel_method_table"); + RESTTableScan restTableScan = restTableScanFor(table); + + // Test that cancelPlan method is available and callable + // When no plan is active, it should return false + assertThat(restTableScan.cancelPlan()).isFalse(); + + // Verify the method doesn't throw exceptions when called multiple times + assertThat(restTableScan.cancelPlan()).isFalse(); + } + + @Test + public void iterableCloseTriggersCancel() throws IOException { + configurePlanningBehavior(TestPlanningBehavior.Builder::asynchronous); + RESTTable restTable = restTableFor(restCatalog, "iterable_close_test"); + setParserContext(restTable); + + TableScan scan = restTable.newScan(); + assertThat(scan).isInstanceOf(RESTTableScan.class); + RESTTableScan restTableScan = (RESTTableScan) scan; + + // Get the iterable + CloseableIterable iterable = restTableScan.planFiles(); + + // call cancelPlan before closing the iterable + boolean cancelled = restTableScan.cancelPlan(); + assertThat(cancelled).isTrue(); + + // Verify we can close the iterable without exceptions + // This tests that cancellation callbacks are properly wired through + iterable.close(); + } + + @ParameterizedTest + @EnumSource(MetadataTableType.class) + public void metadataTablesWithRemotePlanning(MetadataTableType type) { + configurePlanningBehavior(TestPlanningBehavior.Builder::synchronous); + RESTTable table = restTableFor(restCatalog, "metadata_tables_test"); + table.newAppend().appendFile(FILE_B).commit(); + table.newRowDelta().addDeletes(FILE_A_DELETES).addDeletes(FILE_B_EQUALITY_DELETES).commit(); + setParserContext(table); + // RESTTable should be only be returned for non-metadata tables, because client would + // not have access to metadata files for example manifests, since all it needs is file scan + // tasks, this test just verifies that metadata tables can be scanned with RESTTable. + Table metadataTableInstance = MetadataTableUtils.createMetadataTableInstance(table, type); + assertThat(metadataTableInstance).isNotNull(); + if (type.equals(MetadataTableType.POSITION_DELETES)) { + // Position deletes table only uses batch scan + assertThat(metadataTableInstance.newBatchScan().planFiles()).isNotEmpty(); + } else { + assertThat(metadataTableInstance.newScan().planFiles()).isNotEmpty(); + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningWithEmptyTable( + Function planMode) { + configurePlanningBehavior(planMode); + Table table = createTableWithScanPlanning(restCatalog, "empty_table_test"); + setParserContext(table); + assertThat(table.newScan().planFiles()).isEmpty(); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + @Disabled("Pruning files based on columns is not yet supported in REST scan planning") + void remoteScanPlanningWithNonExistentColumn( + Function planMode) { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "non-existent_column"); + setParserContext(table); + assertThat(table.newScan().select("non-existent-column").planFiles()).isEmpty(); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void incrementalScan( + Function planMode) { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "incremental_scan"); + setParserContext(table); + + // Add second file to the table + table.newAppend().appendFile(FILE_B).commit(); + long startSnapshotId = table.currentSnapshot().snapshotId(); + // Add third file to the table + table.newAppend().appendFile(FILE_C).commit(); + long endSnapshotId = table.currentSnapshot().snapshotId(); + assertThat( + table + .newIncrementalAppendScan() + .fromSnapshotInclusive(startSnapshotId) + .toSnapshot(endSnapshotId) + .planFiles()) + .hasSize(2) + .extracting(task -> task.file().location()) + .contains(FILE_C.location(), FILE_B.location()); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningWithPositionDeletes( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "position_deletes_test"); + setParserContext(table); + + // Add position deletes that correspond to FILE_A (which was added in table creation) + table.newRowDelta().addDeletes(FILE_A_DELETES).commit(); + + // Ensure we have a RESTTable with server-side planning enabled + assertThat(table).isInstanceOf(RESTTable.class); + + // Execute scan planning - should handle position deletes correctly + try (CloseableIterable iterable = table.newScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + + // Verify we get tasks back (specific count depends on implementation) + assertThat(tasks).hasSize(1); // 1 data file: FILE_A + + // Verify specific task content and delete file associations + FileScanTask taskWithDeletes = + assertThat(tasks) + .filteredOn(task -> !task.deletes().isEmpty()) + .first() + .as("Expected at least one task with delete files") + .actual(); + + assertThat(taskWithDeletes.file().location()).isEqualTo(FILE_A.location()); + assertThat(taskWithDeletes.deletes()).hasSize(1); // 1 delete file: FILE_A_DELETES + assertThat(taskWithDeletes.deletes().get(0).location()).isEqualTo(FILE_A_DELETES.location()); + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningWithEqualityDeletes( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "equality_deletes_test"); + setParserContext(table); + + // Add equality deletes that correspond to FILE_A + table.newRowDelta().addDeletes(FILE_A_EQUALITY_DELETES).commit(); + + // Execute scan planning - should handle equality deletes correctly + try (CloseableIterable iterable = table.newScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + + // Verify the task count and file paths + assertThat(tasks).hasSize(1); // 1 data file: FILE_A + + // Verify specific task content and equality delete file associations + FileScanTask taskWithDeletes = + assertThat(tasks) + .filteredOn(task -> !task.deletes().isEmpty()) + .first() + .as("Expected at least one task with delete files") + .actual(); + + assertThat(taskWithDeletes.file().location()).isEqualTo(FILE_A.location()); + assertThat(taskWithDeletes.deletes()).hasSize(1); + assertThat(taskWithDeletes.deletes().get(0).location()) + .isEqualTo(FILE_A_EQUALITY_DELETES.location()); + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningWithMixedDeletes( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "mixed_deletes_test"); + setParserContext(table); + + // Add both position and equality deletes in separate commits + table.newRowDelta().addDeletes(FILE_A_DELETES).commit(); + table + .newRowDelta() + .addDeletes(FILE_B_EQUALITY_DELETES) + .commit(); // Equality deletes for different partition + + // Execute scan planning - should handle mixed delete types correctly + try (CloseableIterable iterable = table.newScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + + // Verify task count: FILE_A only (FILE_B_EQUALITY_DELETES is in different partition) + assertThat(tasks).hasSize(1); // 1 data file: FILE_A + + // Verify FILE_A with position deletes (FILE_B_EQUALITY_DELETES not associated since no + // FILE_B) + FileScanTask fileATask = + assertThat(tasks) + .filteredOn(task -> task.file().location().equals(FILE_A.location())) + .first() + .as("Expected FILE_A in scan tasks") + .actual(); + + assertThat(fileATask.deletes()).hasSize(1); + assertThat(fileATask.deletes().get(0).location()).isEqualTo(FILE_A_DELETES.location()); + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningWithMultipleDeleteFiles( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "multiple_deletes_test"); + setParserContext(table); + + // Add FILE_B and FILE_C to the table (FILE_A is already added during table creation) + table.newAppend().appendFile(FILE_B).appendFile(FILE_C).commit(); + + // Add multiple delete files corresponding to FILE_A, FILE_B, FILE_C + table + .newRowDelta() + .addDeletes(FILE_A_DELETES) + .addDeletes(FILE_B_DELETES) + .addDeletes(FILE_C_EQUALITY_DELETES) + .commit(); + + // Execute scan planning with multiple delete files + try (CloseableIterable iterable = table.newScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + + // Verify we get tasks back (should have 3 data files: FILE_A, FILE_B, FILE_C) + assertThat(tasks).hasSize(3); // 3 data files + + // Verify FILE_A with position deletes + FileScanTask fileATask = + assertThat(tasks) + .filteredOn(task -> task.file().location().equals(FILE_A.location())) + .first() + .as("Expected FILE_A in scan tasks") + .actual(); + assertThat(fileATask.deletes()).isNotEmpty(); // Has delete files + assertThat(fileATask.deletes().stream().map(DeleteFile::location)) + .contains(FILE_A_DELETES.location()); // FILE_A_DELETES is present + + // Verify FILE_B with position deletes + FileScanTask fileBTask = + assertThat(tasks) + .filteredOn(task -> task.file().location().equals(FILE_B.location())) + .first() + .as("Expected FILE_B in scan tasks") + .actual(); + assertThat(fileBTask.deletes()).isNotEmpty(); // Has delete files + assertThat(fileBTask.deletes().stream().map(DeleteFile::location)) + .contains(FILE_B_DELETES.location()); // FILE_B_DELETES is present + + // Verify FILE_C with equality deletes + FileScanTask fileCTask = + assertThat(tasks) + .filteredOn(task -> task.file().location().equals(FILE_C.location())) + .first() + .as("Expected FILE_C in scan tasks") + .actual(); + assertThat(fileCTask.deletes()).isNotEmpty(); // Has delete files + assertThat(fileCTask.deletes().stream().map(DeleteFile::location)) + .contains(FILE_C_EQUALITY_DELETES.location()); // FILE_C_EQUALITY_DELETES is present + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningWithDeletesAndFiltering( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "deletes_filtering_test"); + setParserContext(table); + + // Add FILE_B to have more data for filtering + table.newAppend().appendFile(FILE_B).commit(); + + // Add equality delete for FILE_B + table.newRowDelta().addDeletes(FILE_B_EQUALITY_DELETES).commit(); + + // Create a filtered scan and execute scan planning with filtering and deletes + try (CloseableIterable iterable = + table.newScan().filter(Expressions.lessThan("id", 4)).planFiles()) { + List tasks = Lists.newArrayList(iterable); + + // Verify scan planning works with both filtering and deletes + assertThat(tasks).hasSize(2); // 2 data files: FILE_A, FILE_B + + // FILE_A should have no delete files + FileScanTask fileATask = + assertThat(tasks) + .filteredOn(task -> task.file().location().equals(FILE_A.location())) + .first() + .as("Expected FILE_A in scan tasks") + .actual(); + assertThat(fileATask.deletes()).isEmpty(); // 0 delete files for FILE_A + + // FILE_B should have FILE_B_EQUALITY_DELETES + FileScanTask fileBTask = + assertThat(tasks) + .filteredOn(task -> task.file().location().equals(FILE_B.location())) + .first() + .as("Expected FILE_B in scan tasks") + .actual(); + assertThat(fileBTask.deletes()).hasSize(1); // 1 delete file: FILE_B_EQUALITY_DELETES + assertThat(fileBTask.deletes().get(0).location()) + .isEqualTo(FILE_B_EQUALITY_DELETES.location()); + } + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void scanPlanningWithMinRowsRequested( + Function planMode) { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "min_rows_requested_table"); + table.newAppend().appendFile(FILE_B).appendFile(FILE_C).commit(); + setParserContext(table); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HTTPRequest.class); + + assertThat(table.newScan().minRowsRequested(1L).planFiles()).hasSize(1); + Mockito.verify(adapterForRESTServer, Mockito.atLeastOnce()) + .execute(requestCaptor.capture(), any(), any(), any(), any()); + assertThat( + requestCaptor.getAllValues().stream() + .filter(req -> req.body() instanceof PlanTableScanRequest) + .map(req -> (PlanTableScanRequest) req.body()) + .reduce((first, second) -> second) + .get() + .minRowsRequested()) + .isEqualTo(1L); + + assertThat(table.newScan().minRowsRequested(100L).planFiles()).hasSize(3); + Mockito.verify(adapterForRESTServer, Mockito.atLeastOnce()) + .execute(requestCaptor.capture(), any(), any(), any(), any()); + assertThat( + requestCaptor.getAllValues().stream() + .filter(req -> req.body() instanceof PlanTableScanRequest) + .map(req -> (PlanTableScanRequest) req.body()) + .reduce((first, second) -> second) + .get() + .minRowsRequested()) + .isEqualTo(100L); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningDeletesCancellation( + Function planMode) + throws IOException { + configurePlanningBehavior(planMode); + Table table = restTableFor(restCatalog, "deletes_cancellation_test"); + setParserContext(table); + + // Add deletes to make the scenario more complex + table.newRowDelta().addDeletes(FILE_A_DELETES).addDeletes(FILE_A_EQUALITY_DELETES).commit(); + + RESTTableScan restTableScan = restTableScanFor(table); + + // Get the iterable (which may involve async planning with deletes) + try (CloseableIterable iterable = restTableScan.planFiles(); + CloseableIterator iterator = iterable.iterator()) { + // Test cancellation works with delete files present + // Resources will be closed automatically + } + + // Verify cancellation method is still accessible + assertThat(restTableScan.cancelPlan()).isFalse(); // No active plan at this point + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void remoteScanPlanningWithTimeTravel( + Function planMode) { + // Test server-side scan planning with time travel (snapshot-based queries) + // Verify that snapshot IDs are correctly passed through the REST API + // and that historical scans return the correct files and deletes + configurePlanningBehavior(planMode); + + // Create table and add FILE_A (snapshot 1) + Table table = restTableFor(restCatalog, "snapshot_scan_test"); + setParserContext(table); + table.refresh(); + long snapshot1Id = table.currentSnapshot().snapshotId(); + + // Add FILE_B (snapshot 2) + table.newAppend().appendFile(FILE_B).commit(); + table.refresh(); + long snapshot2Id = table.currentSnapshot().snapshotId(); + assertThat(snapshot2Id).isNotEqualTo(snapshot1Id); + + // Add FILE_C and deletes (snapshots 3 and 4) + table.newAppend().appendFile(FILE_C).commit(); + table.newRowDelta().addDeletes(FILE_A_DELETES).commit(); + table.refresh(); + long snapshot4Id = table.currentSnapshot().snapshotId(); + assertThat(snapshot4Id).isNotEqualTo(snapshot2Id); + + // Test 1: Scan at snapshot 1 (should only see FILE_A, no deletes) + TableScan scan1 = table.newScan().useSnapshot(snapshot1Id); + CloseableIterable iterable1 = scan1.planFiles(); + List tasks1 = Lists.newArrayList(iterable1); + + assertThat(tasks1).hasSize(1); // Only FILE_A exists at snapshot 1 + assertThat(tasks1.get(0).file().location()).isEqualTo(FILE_A.location()); + assertThat(tasks1.get(0).deletes()).isEmpty(); // No deletes at snapshot 1 + + // Test 2: Scan at snapshot 2 (should see FILE_A and FILE_B, no deletes) + TableScan scan2 = table.newScan().useSnapshot(snapshot2Id); + CloseableIterable iterable2 = scan2.planFiles(); + List tasks2 = Lists.newArrayList(iterable2); + + assertThat(tasks2).hasSize(2); // FILE_A and FILE_B exist at snapshot 2 + assertThat(tasks2) + .map(task -> task.file().location()) + .containsExactlyInAnyOrder(FILE_A.location(), FILE_B.location()); + assertThat(tasks2).allMatch(task -> task.deletes().isEmpty()); // No deletes at snapshot 2 + + // Test 3: Scan at current snapshot (should see FILE_A, FILE_B, FILE_C, and FILE_A has deletes) + TableScan scan3 = table.newScan().useSnapshot(snapshot4Id); + CloseableIterable iterable3 = scan3.planFiles(); + List tasks3 = Lists.newArrayList(iterable3); + + assertThat(tasks3).hasSize(3); // All 3 data files exist at snapshot 4 + assertThat(tasks3) + .map(task -> task.file().location()) + .containsExactlyInAnyOrder(FILE_A.location(), FILE_B.location(), FILE_C.location()); + + // Verify FILE_A has deletes at snapshot 4 + FileScanTask fileATask = + assertThat(tasks3) + .filteredOn(task -> task.file().location().equals(FILE_A.location())) + .first() + .as("Expected FILE_A in scan tasks") + .actual(); + assertThat(fileATask.deletes()).hasSize(1); // FILE_A_DELETES present at snapshot 4 + assertThat(fileATask.deletes().get(0).location()).isEqualTo(FILE_A_DELETES.location()); + + // Verify FILE_B and FILE_C have no deletes at snapshot 4 + assertThat(tasks3) + .filteredOn( + task -> + task.file().location().equals(FILE_B.location()) + || task.file().location().equals(FILE_C.location())) + .allMatch(task -> task.deletes().isEmpty()); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + public void scanPlanningWithMultiplePartitionSpecs() throws IOException { + configurePlanningBehavior(TestPlanningBehavior.Builder::synchronous); + + RESTTable table = restTableFor(restCatalog, "multiple_partition_specs"); + table.newFastAppend().appendFile(FILE_B).commit(); + + // Evolve partition spec to bucket by id with 8 buckets instead of 16 + table.updateSpec().removeField("data_bucket").addField(Expressions.bucket("data", 8)).commit(); + + // Create data file with new partition spec (spec-id=1) + PartitionSpec newSpec = table.spec(); + assertThat(newSpec.specId()).isEqualTo(1); + + DataFile fileWithNewSpec = + DataFiles.builder(newSpec) + .withPath("/path/to/data-new-spec.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("data_bucket_8=3") // 8-bucket partition + .withRecordCount(2) + .build(); + + table.newFastAppend().appendFile(fileWithNewSpec).commit(); + setParserContext(table); + + // Scan table - should return all 3 files despite different partition specs + try (CloseableIterable iterable = table.newScan().planFiles()) { + List tasks = Lists.newArrayList(iterable); + + // Verify all 3 files are present + assertThat(tasks).hasSize(3); + assertThat(tasks) + .map(task -> task.file().location()) + .containsExactlyInAnyOrder( + FILE_A.location(), FILE_B.location(), fileWithNewSpec.location()); + + // Verify files have correct partition spec IDs + assertThat(tasks) + .filteredOn( + task -> + task.file().location().equals(FILE_A.location()) + || task.file().location().equals(FILE_B.location())) + .allMatch(task -> task.spec().specId() == 0); + assertThat(tasks) + .filteredOn(task -> task.file().location().equals(fileWithNewSpec.location())) + .allMatch(task -> task.spec().specId() == 1); + } + } + + @Test + void remoteScanPlanningWithFreshnessAwareLoading() throws IOException { + TableIdentifier tableIdentifier = TableIdentifier.of(NS, "freshness_aware_loading_test"); + restTableFor(restCatalog, tableIdentifier.name()); + + assertThat(restCatalog.sessionCatalog().tableCache().cache().estimatedSize()).isZero(); + + // Table is cached with the first loadTable + restCatalog.loadTable(tableIdentifier); + assertThat(restCatalog.sessionCatalog().tableCache().cache().estimatedSize()).isOne(); + + // Second loadTable is answered from cache + Table table = restCatalog.loadTable(tableIdentifier); + + // Verify table is RESTTable and newScan() returns RESTTableScan + restTableScanFor(table); + } + + // ==================== useSnapshotSchema and Projection Tests ==================== + + private PlanTableScanRequest captureLastPlanRequest() { + ArgumentCaptor captor = ArgumentCaptor.forClass(HTTPRequest.class); + Mockito.verify(adapterForRESTServer, atLeastOnce()) + .execute(captor.capture(), any(), any(), any()); + return captor.getAllValues().stream() + .filter(req -> req.body() instanceof PlanTableScanRequest) + .map(req -> (PlanTableScanRequest) req.body()) + .reduce((first, second) -> second) + .orElseThrow(() -> new AssertionError("No PlanTableScanRequest captured")); + } + + @Test + void useSnapshotSchemaSetCorrectlyForSnapshotAndBranchAndTag() throws IOException { + configurePlanningBehavior(TestPlanningBehavior.Builder::synchronous); + Table table = restTableFor(restCatalog, "use_snapshot_schema_test"); + setParserContext(table); + + table.newAppend().appendFile(FILE_B).commit(); + table.refresh(); + long snapshotId = table.currentSnapshot().snapshotId(); + + // Create a tag and a branch pointing to the current snapshot + table.manageSnapshots().createTag("test-tag", snapshotId).commit(); + table.manageSnapshots().createBranch("test-branch", snapshotId).commit(); + + // Test 1: Scanning current snapshot without time travel should NOT set useSnapshotSchema + try (CloseableIterable ignored = table.newScan().planFiles()) { + PlanTableScanRequest request = captureLastPlanRequest(); + assertThat(request.useSnapshotSchema()) + .as("Default scan should not use snapshot schema") + .isFalse(); + } + + // Test 2: useSnapshot() should set useSnapshotSchema=true + try (CloseableIterable ignored = + table.newScan().useSnapshot(snapshotId).planFiles()) { + PlanTableScanRequest request = captureLastPlanRequest(); + assertThat(request.useSnapshotSchema()) + .as("useSnapshot() should set useSnapshotSchema=true") + .isTrue(); + } + + // Test 3: useRef() with a tag should set useSnapshotSchema=true + try (CloseableIterable ignored = table.newScan().useRef("test-tag").planFiles()) { + PlanTableScanRequest request = captureLastPlanRequest(); + assertThat(request.useSnapshotSchema()) + .as("useRef() with a tag should set useSnapshotSchema=true") + .isTrue(); + } + + // Test 4: useRef() with a branch should NOT set useSnapshotSchema + try (CloseableIterable ignored = + table.newScan().useRef("test-branch").planFiles()) { + PlanTableScanRequest request = captureLastPlanRequest(); + assertThat(request.useSnapshotSchema()) + .as("useRef() with a branch should not use snapshot schema") + .isFalse(); + } + } + + @Test + void selectWithNestedFieldsSendsFullyQualifiedNames() throws IOException { + configurePlanningBehavior(TestPlanningBehavior.Builder::synchronous); + + Schema nestedSchema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required( + 2, + "address", + Types.StructType.of( + Types.NestedField.required(3, "city", Types.StringType.get()), + Types.NestedField.required(4, "zip", Types.StringType.get())))); + + restCatalog.createNamespace(NS); + Table table = + restCatalog + .buildTable(TableIdentifier.of(NS, "nested_projection_test"), nestedSchema) + .create(); + + setParserContext(table); + + // Select a nested field — the server needs the fully qualified name "address.city" + try (CloseableIterable ignored = + table.newScan().select("address.city").planFiles()) { + PlanTableScanRequest request = captureLastPlanRequest(); + assertThat(request.select()) + .as("Nested field projection should send fully qualified column names") + .contains("address.city") + .doesNotContain("address.zip"); + } + } + + // ==================== Endpoint Support Tests ==================== + + /** Helper class to hold catalog and adapter for endpoint support tests. */ + private static class CatalogWithAdapter { + final RESTCatalog catalog; + final RESTCatalogAdapter adapter; + + CatalogWithAdapter(RESTCatalog catalog, RESTCatalogAdapter adapter) { + this.catalog = catalog; + this.adapter = adapter; + } + } + + private CatalogWithAdapter catalogWithModes(String clientMode, String serverMode) { + RESTCatalogAdapter adapter = + Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + T response = super.execute(request, responseType, errorHandler, responseHeaders); + if (response instanceof LoadTableResponse && serverMode != null) { + return castResponse( + responseType, withPlanningMode((LoadTableResponse) response, serverMode)); + } + + return response; + } + }); + + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); + + ImmutableMap.Builder clientConfigBuilder = + ImmutableMap.builder() + .put(CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.inmemory.InMemoryFileIO"); + + if (clientMode != null) { + clientConfigBuilder.put(RESTCatalogProperties.SCAN_PLANNING_MODE, clientMode); + } + + catalog.initialize("test-scan-planning-modes", clientConfigBuilder.build()); + return new CatalogWithAdapter(catalog, adapter); + } + + // Helper: Create base catalog endpoints (namespace and table operations) + private List baseCatalogEndpoints() { + return ImmutableList.of( + Endpoint.V1_CREATE_NAMESPACE, + Endpoint.V1_LOAD_NAMESPACE, + Endpoint.V1_LIST_TABLES, + Endpoint.V1_CREATE_TABLE, + Endpoint.V1_LOAD_TABLE, + Endpoint.V1_UPDATE_TABLE); + } + + // Helper: Create endpoint list with base + specified planning endpoints + private List endpointsWithPlanning(Endpoint... planningEndpoints) { + return ImmutableList.builder() + .addAll(baseCatalogEndpoints()) + .add(planningEndpoints) + .build(); + } + + // Helper: Create catalog with custom endpoint support and optional planning behavior + private CatalogWithAdapter catalogWithEndpoints( + List endpoints, TestPlanningBehavior planningBehavior) { + RESTCatalogAdapter adapter = + Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders) { + if (ResourcePaths.config().equals(request.path())) { + return castResponse( + responseType, ConfigResponse.builder().withEndpoints(endpoints).build()); + } + T response = super.execute(request, responseType, errorHandler, responseHeaders); + + // Add scan planning mode to table config for LoadTableResponse + if (response instanceof LoadTableResponse) { + return castResponse( + responseType, + withPlanningMode( + (LoadTableResponse) response, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName())); + } + + return response; + } + }); + + if (planningBehavior != null) { + adapter.setPlanningBehavior(planningBehavior); + } + + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); + catalog.initialize( + "test", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO", + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName())); + return new CatalogWithAdapter(catalog, adapter); + } + + @Test + public void serverDoesNotSupportPlanningEndpoint() throws IOException { + // Server requires server-side planning but doesn't support the endpoint - should fail + CatalogWithAdapter catalogWithAdapter = catalogWithEndpoints(baseCatalogEndpoints(), null); + RESTCatalog catalog = catalogWithAdapter.catalog; + + catalog.createNamespace(NS); + assertThatThrownBy( + () -> + catalog.buildTable(TableIdentifier.of(NS, "no_planning_support"), SCHEMA).create()) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Server requires server-side scan planning for table %s but does not support endpoint %s", + TableIdentifier.of(NS, "no_planning_support"), Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN); + } + + @Test + public void serverSupportsPlanningSyncOnlyNotAsync() { + // Server supports submit (sync) but not fetch (async polling) + // Use ASYNC planning to trigger SUBMITTED status, which will hit the Endpoint.check() + CatalogWithAdapter catalogWithAdapter = + catalogWithEndpoints( + endpointsWithPlanning( + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN, Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS), + TestPlanningBehavior.builder().asynchronous().build()); + + RESTCatalog catalog = catalogWithAdapter.catalog; + RESTTable table = restTableFor(catalog, "async_not_supported"); + setParserContext(table); + + // Should fail with UnsupportedOperationException when trying to fetch async plan result + // because V1_FETCH_TABLE_SCAN_PLAN endpoint is not supported + assertThatThrownBy(restTableScanFor(table)::planFiles) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Server does not support endpoint: %s", Endpoint.V1_FETCH_TABLE_SCAN_PLAN); + } + + @Test + public void serverSupportsPlanningButNotPagination() { + // Server supports planning but not task pagination endpoint + // Use synchronousWithPagination (tasksPerPage=1) to trigger pagination, which will hit + // Endpoint.check() + CatalogWithAdapter catalogWithAdapter = + catalogWithEndpoints( + endpointsWithPlanning( + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN, + Endpoint.V1_CANCEL_TABLE_SCAN_PLAN), + TestPlanningBehavior.builder().synchronousWithPagination().build()); + + RESTCatalog catalog = catalogWithAdapter.catalog; + RESTTable table = restTableFor(catalog, "pagination_not_supported"); + table.newAppend().appendFile(FILE_B).commit(); + setParserContext(table); + RESTTableScan scan = restTableScanFor(table); + + // Should fail with UnsupportedOperationException when trying to fetch paginated tasks + // because V1_FETCH_TABLE_SCAN_PLAN_TASKS endpoint is not supported + assertThatThrownBy(scan::planFiles) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage( + "Server does not support endpoint: %s", Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS); + } + + @Test + public void serverSupportsPlanningButNotCancellation() throws IOException { + // Server supports planning but not the cancel endpoint + CatalogWithAdapter catalogWithAdapter = + catalogWithEndpoints( + endpointsWithPlanning( + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS), + TestPlanningBehavior.builder().asynchronous().build()); + + RESTCatalog catalog = catalogWithAdapter.catalog; + RESTTable table = restTableFor(catalog, "cancellation_not_supported"); + setParserContext(table); + RESTTableScan scan = restTableScanFor(table); + + // Get the iterable - this starts async planning + CloseableIterable iterable = scan.planFiles(); + + // Cancellation should not fail even though server doesn't support it + // The client should handle this gracefully by returning false + boolean cancelled = scan.cancelPlan(); + iterable.close(); + + // Verify no exception was thrown - cancelPlan returns false when endpoint not supported + assertThat(cancelled).isFalse(); + } + + @Test + public void asyncPlanningRespectsConfigurablePollTimeout() { + // Create an adapter that always returns SUBMITTED (never completes) + List endpoints = + endpointsWithPlanning( + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN, + Endpoint.V1_CANCEL_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS); + + RESTCatalogAdapter adapter = + Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders, + ParserContext parserContext) { + if (ResourcePaths.config().equals(request.path())) { + return castResponse( + responseType, ConfigResponse.builder().withEndpoints(endpoints).build()); + } + T response = + super.execute( + request, responseType, errorHandler, responseHeaders, parserContext); + if (response instanceof LoadTableResponse) { + return castResponse( + responseType, + withPlanningMode( + (LoadTableResponse) response, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName())); + } + + // Override fetch responses to always return SUBMITTED so the poll never completes + if (response instanceof FetchPlanningResultResponse) { + return castResponse( + responseType, + FetchPlanningResultResponse.builder() + .withPlanStatus(PlanStatus.SUBMITTED) + .build()); + } + + return response; + } + }); + + adapter.setPlanningBehavior(TestPlanningBehavior.builder().asynchronous().build()); + + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); + catalog.initialize( + "test-poll-timeout", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO", + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName(), + RESTCatalogProperties.REST_SCAN_PLANNING_POLL_TIMEOUT_MS, + "1")); + + RESTTable table = restTableFor(catalog, "poll_timeout_test"); + setParserContext(table); + RESTTableScan scan = restTableScanFor(table); + + // With a 1ms timeout and a server that never completes, planFiles should fail + assertThatThrownBy(scan::planFiles) + .isInstanceOf(RemotePlanTimeoutException.class) + .hasMessageContaining("did not complete within configured limits"); + } + + @Test + public void asyncPlanningSucceedsWithCustomTimeout() { + List endpoints = + endpointsWithPlanning( + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN, + Endpoint.V1_CANCEL_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS); + + CatalogWithAdapter catalogWithAdapter = + catalogWithEndpoints(endpoints, TestPlanningBehavior.builder().asynchronous().build()); + + catalogWithAdapter.catalog.initialize( + "test-custom-timeout", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO", + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName(), + RESTCatalogProperties.REST_SCAN_PLANNING_POLL_TIMEOUT_MS, + "30000")); + + RESTTable table = restTableFor(catalogWithAdapter.catalog, "custom_timeout_success"); + setParserContext(table); + assertThat(table.newScan().planFiles()).hasSize(1); + } + + @Test + public void asyncPlanningRejectsInvalidTimeout() { + List endpoints = + endpointsWithPlanning( + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN, + Endpoint.V1_CANCEL_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS); + + CatalogWithAdapter catalogWithAdapter = + catalogWithEndpoints(endpoints, TestPlanningBehavior.builder().asynchronous().build()); + + // re-initialize with an invalid timeout + catalogWithAdapter.catalog.initialize( + "test-invalid-timeout", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO", + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName(), + RESTCatalogProperties.REST_SCAN_PLANNING_POLL_TIMEOUT_MS, + "-1")); + + RESTTable table = restTableFor(catalogWithAdapter.catalog, "invalid_timeout_test"); + setParserContext(table); + RESTTableScan scan = restTableScanFor(table); + + assertThatThrownBy(scan::planFiles) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be positive"); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + public void planningFailsWithServerError( + Function planMode) { + ErrorResponse serverError = + ErrorResponse.builder() + .withMessage("table too large to plan") + .withType("IllegalStateException") + .responseCode(500) + .build(); + + TestPlanningBehavior behavior = planMode.apply(TestPlanningBehavior.builder()).build(); + CatalogWithAdapter catalogWithAdapter = + catalogThatFailsPlanning(serverError, behavior, "test-planning-failed"); + + RESTTable table = restTableFor(catalogWithAdapter.catalog, "planning_failed_test"); + setParserContext(table); + RESTTableScan scan = restTableScanFor(table); + + assertThatThrownBy(scan::planFiles) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Remote scan planning failed") + .hasMessageContaining(serverError.type()) + .hasMessageContaining("code=" + serverError.code()) + .hasMessageContaining(serverError.message()); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + public void planningFailsWithoutServerErrorIsStillSurfaced( + Function planMode) { + // Spec requires an error payload with a FAILED status; if a server violates that, + // the client must still surface a meaningful failure rather than throw on top of it. + TestPlanningBehavior behavior = planMode.apply(TestPlanningBehavior.builder()).build(); + CatalogWithAdapter catalogWithAdapter = + catalogThatFailsPlanning(null, behavior, "test-planning-failed-no-error"); + + RESTTable table = restTableFor(catalogWithAdapter.catalog, "planning_failed_no_error_test"); + setParserContext(table); + RESTTableScan scan = restTableScanFor(table); + + assertThatThrownBy(scan::planFiles) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Remote scan planning failed") + .hasMessageContaining("unknown") + .hasMessageContaining("code=0"); + } + + private CatalogWithAdapter catalogThatFailsPlanning( + ErrorResponse serverError, TestPlanningBehavior behavior, String catalogName) { + List endpoints = + endpointsWithPlanning( + Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN, + Endpoint.V1_CANCEL_TABLE_SCAN_PLAN, + Endpoint.V1_FETCH_TABLE_SCAN_PLAN_TASKS); + + RESTCatalogAdapter adapter = + Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders, + ParserContext parserContext) { + if (ResourcePaths.config().equals(request.path())) { + return castResponse( + responseType, ConfigResponse.builder().withEndpoints(endpoints).build()); + } + T response = + super.execute( + request, responseType, errorHandler, responseHeaders, parserContext); + if (response instanceof LoadTableResponse) { + return castResponse( + responseType, + withPlanningMode( + (LoadTableResponse) response, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName())); + } + // Leave SUBMITTED untouched so async mode polls and hits the fetch below. + if (response instanceof PlanTableScanResponse planResp + && planResp.planStatus() == PlanStatus.COMPLETED) { + return castResponse( + responseType, + PlanTableScanResponse.builder() + .withPlanStatus(PlanStatus.FAILED) + .withErrorResponse(serverError) + .withSpecsById(planResp.specsById()) + .build()); + } + if (response instanceof FetchPlanningResultResponse) { + return castResponse( + responseType, + FetchPlanningResultResponse.builder() + .withPlanStatus(PlanStatus.FAILED) + .withErrorResponse(serverError) + .build()); + } + return response; + } + }); + + adapter.setPlanningBehavior(behavior); + + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); + catalog.initialize( + catalogName, + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO", + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName())); + return new CatalogWithAdapter(catalog, adapter); + } + + @ParameterizedTest + @EnumSource(PlanningMode.class) + void fileIOForRemotePlanningIsPropagated( + Function planMode) { + RESTCatalogAdapter adapter = + Mockito.spy( + new RESTCatalogAdapter(backendCatalog) { + @Override + public T execute( + HTTPRequest request, + Class responseType, + Consumer errorHandler, + Consumer> responseHeaders, + ParserContext parserContext) { + T response = + super.execute( + request, responseType, errorHandler, responseHeaders, parserContext); + return maybeAddStorageCredential(response); + } + }); + + adapter.setPlanningBehavior(planMode.apply(TestPlanningBehavior.builder()).build()); + + RESTCatalog catalog = + new RESTCatalog(SessionCatalog.SessionContext.createEmpty(), (config) -> adapter); + catalog.initialize( + "test", + ImmutableMap.of( + CatalogProperties.FILE_IO_IMPL, + "org.apache.iceberg.inmemory.InMemoryFileIO", + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName())); + + Table table = restTableFor(catalog, "file_io_propagation"); + + assertThat(table.io().properties()).doesNotContainKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + + TableScan tableScan = table.newScan(); + assertThatThrownBy(() -> tableScan.fileIO().get()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("FileIO is not available: planFiles() must be called first"); + + // make sure remote scan planning is called and FileIO gets the planId + assertThat(tableScan.planFiles()).hasSize(1); + assertThat(table.io().properties()).doesNotContainKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + assertThat(tableScan.fileIO().get().properties()) + .containsKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + String planId = + tableScan.fileIO().get().properties().get(RESTCatalogProperties.REST_SCAN_PLAN_ID); + + TableScan newScan = table.newScan(); + assertThatThrownBy(() -> newScan.fileIO().get()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("FileIO is not available: planFiles() must be called first"); + + // make sure remote scan planning is called and FileIO gets the planId + assertThat(newScan.planFiles()).hasSize(1); + assertThat(table.io().properties()).doesNotContainKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + + // make sure planIds are different for each scan + assertThat(newScan.fileIO().get().properties()) + .containsKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + assertThat(newScan.fileIO().get().properties().get(RESTCatalogProperties.REST_SCAN_PLAN_ID)) + .isNotEqualTo(planId); + } + + @SuppressWarnings("unchecked") + private T maybeAddStorageCredential(T response) { + if (response instanceof PlanTableScanResponse resp + && PlanStatus.COMPLETED == resp.planStatus()) { + return (T) + PlanTableScanResponse.builder() + .withPlanStatus(resp.planStatus()) + .withPlanId(resp.planId()) + .withPlanTasks(resp.planTasks()) + .withFileScanTasks(resp.fileScanTasks()) + .withCredentials( + ImmutableList.builder() + .addAll(resp.credentials()) + .add( + ImmutableCredential.builder() + .prefix("dummy") + .putConfig("dummyKey", "dummyVal") + .build()) + .build()) + .withSpecsById(resp.specsById()) + .build(); + } else if (response instanceof FetchPlanningResultResponse resp + && PlanStatus.COMPLETED == resp.planStatus()) { + return (T) + FetchPlanningResultResponse.builder() + .withPlanStatus(resp.planStatus()) + .withFileScanTasks(resp.fileScanTasks()) + .withPlanTasks(resp.planTasks()) + .withSpecsById(resp.specsById()) + .withCredentials( + ImmutableList.builder() + .addAll(resp.credentials()) + .add( + ImmutableCredential.builder() + .prefix("dummy") + .putConfig("dummyKey", "dummyVal") + .build()) + .build()) + .build(); + } + + return response; + } + + @Test + public void serverConfigTakesPrecedenceOnMismatch() { + // Client=SERVER, Server=CLIENT + CatalogWithAdapter catalogWithAdapter1 = + catalogWithModes( + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName(), + RESTCatalogProperties.ScanPlanningMode.CLIENT.modeName()); + catalogWithAdapter1.catalog.createNamespace(NS); + + Table table1 = + catalogWithAdapter1 + .catalog + .buildTable(TableIdentifier.of(NS, "mismatch_test"), SCHEMA) + .create(); + + assertThat(table1).isNotInstanceOf(RESTTable.class).isInstanceOf(BaseTable.class); + + // Client=CLIENT, Server=SERVER + CatalogWithAdapter catalogWithAdapter2 = + catalogWithModes( + RESTCatalogProperties.ScanPlanningMode.CLIENT.modeName(), + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()); + + Table table2 = + catalogWithAdapter2 + .catalog + .buildTable(TableIdentifier.of(NS, "client_override_rejected_test"), SCHEMA) + .create(); + + assertThat(table2).isInstanceOf(RESTTable.class); + } + + @Test + public void clientExplicitlyRequestsClientSidePlanning() { + CatalogWithAdapter catalogWithAdapter = + catalogWithModes( + RESTCatalogProperties.ScanPlanningMode.CLIENT.modeName(), + RESTCatalogProperties.ScanPlanningMode.CLIENT.modeName()); + catalogWithAdapter.catalog.createNamespace(NS); + + Table table = + catalogWithAdapter + .catalog + .buildTable(TableIdentifier.of(NS, "client_explicit_test"), SCHEMA) + .create(); + + assertThat(table).isNotInstanceOf(RESTTable.class).isInstanceOf(BaseTable.class); + } + + @Test + public void clientRequestsClientAndServerReturnsNothing() { + CatalogWithAdapter catalogWithAdapter = + catalogWithModes(RESTCatalogProperties.ScanPlanningMode.CLIENT.modeName(), null); + catalogWithAdapter.catalog.createNamespace(NS); + + Table table = + catalogWithAdapter + .catalog + .buildTable(TableIdentifier.of(NS, "client_server_null_test"), SCHEMA) + .create(); + + assertThat(table).isNotInstanceOf(RESTTable.class); + assertThat(table).isInstanceOf(BaseTable.class); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTTableCache.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTTableCache.java new file mode 100644 index 000000000000..165b508e5272 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTTableCache.java @@ -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. + */ +package org.apache.iceberg.rest; + +import static org.apache.iceberg.rest.RESTTableCache.SessionIdTableId; +import static org.apache.iceberg.rest.RESTTableCache.TableWithETag; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.function.Supplier; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.TestTables; +import org.apache.iceberg.catalog.SessionCatalog; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.util.FakeTicker; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestRESTTableCache { + @TempDir private static Path temp; + + private static final String SESSION_ID = SessionCatalog.SessionContext.createEmpty().sessionId(); + private static final String TABLE_NAME = "tbl"; + private static final TableIdentifier TABLE_IDENTIFIER = TableIdentifier.of("ns", TABLE_NAME); + private static final Supplier TABLE_SUPPLIER = + () -> + new BaseTable(new TestTables.TestTableOperations(TABLE_NAME, temp.toFile()), TABLE_NAME); + private static final String ETAG = "d7sa6das"; + private static final Duration HALF_OF_TABLE_EXPIRATION = + Duration.ofMillis(RESTCatalogProperties.TABLE_CACHE_EXPIRE_AFTER_WRITE_MS_DEFAULT) + .dividedBy(2); + + @Test + public void invalidProperties() { + assertThatThrownBy( + () -> + new RESTTableCache( + Map.of(RESTCatalogProperties.TABLE_CACHE_EXPIRE_AFTER_WRITE_MS, "0"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid expire after write: zero or negative"); + + assertThatThrownBy( + () -> + new RESTTableCache( + Map.of(RESTCatalogProperties.TABLE_CACHE_EXPIRE_AFTER_WRITE_MS, "-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid expire after write: zero or negative"); + + assertThatThrownBy( + () -> new RESTTableCache(Map.of(RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES, "-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid max entries: negative"); + } + + @Test + public void basicPutAndGet() { + RESTTableCache cache = new RESTTableCache(Map.of()); + cache.put(SESSION_ID, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + + assertThat(cache.cache().asMap()).hasSize(1); + assertThat(cache.cache().asMap()) + .containsKeys(SessionIdTableId.of(SESSION_ID, TABLE_IDENTIFIER)); + + TableWithETag tableWithETag = cache.getIfPresent(SESSION_ID, TABLE_IDENTIFIER); + + assertThat(tableWithETag.supplier()).isSameAs(TABLE_SUPPLIER); + assertThat(tableWithETag.eTag()).isSameAs(ETAG); + } + + @Test + public void notFoundInCache() { + RESTTableCache cache = new RESTTableCache(Map.of()); + cache.put(SESSION_ID, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + + assertThat(cache.getIfPresent("some_id", TABLE_IDENTIFIER)).isNull(); + assertThat(cache.getIfPresent(SESSION_ID, TableIdentifier.of("ns", "other_table"))).isNull(); + } + + @Test + public void tableInMultipleSessions() { + RESTTableCache cache = new RESTTableCache(Map.of()); + String otherSessionId = "sessionID2"; + cache.put(SESSION_ID, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + cache.put(otherSessionId, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + + assertThat(cache.cache().asMap()).hasSize(2); + + cache.invalidate(SESSION_ID, TABLE_IDENTIFIER); + TableWithETag tableWithETag = cache.getIfPresent(otherSessionId, TABLE_IDENTIFIER); + cache.cache().cleanUp(); + + assertThat(cache.cache().asMap()).hasSize(1); + assertThat(cache.getIfPresent(SESSION_ID, TABLE_IDENTIFIER)).isNull(); + assertThat(tableWithETag.supplier()).isSameAs(TABLE_SUPPLIER); + assertThat(tableWithETag.eTag()).isSameAs(ETAG); + } + + @Test + public void maxEntriesReached() { + RESTTableCache cache = new RESTTableCache(Map.of()); + // Add more items than the max limit + for (int i = 0; i < RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES_DEFAULT + 10; ++i) { + cache.put(SESSION_ID, TableIdentifier.of("ns", "tbl" + i), TABLE_SUPPLIER, ETAG); + } + cache.cache().cleanUp(); + + assertThat(cache.cache().asMap()) + .hasSize(RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES_DEFAULT); + } + + @Test + public void configureMaxEntriesReached() { + RESTTableCache cache = + new RESTTableCache(Map.of(RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES, "1")); + TableIdentifier otherTableIdentifier = TableIdentifier.of("ns", "other_table"); + cache.put(SESSION_ID, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + cache.put(SESSION_ID, otherTableIdentifier, TABLE_SUPPLIER, ETAG); + cache.cache().cleanUp(); + + assertThat(cache.cache().asMap()).hasSize(1); + assertThat(cache.getIfPresent(SESSION_ID, otherTableIdentifier)).isNotNull(); + assertThat(cache.getIfPresent(SESSION_ID, TABLE_IDENTIFIER)).isNull(); + } + + @Test + public void cacheTurnedOff() { + RESTTableCache cache = + new RESTTableCache(Map.of(RESTCatalogProperties.TABLE_CACHE_MAX_ENTRIES, "0")); + cache.put(SESSION_ID, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + cache.cache().cleanUp(); + + assertThat(cache.cache().asMap()).isEmpty(); + } + + @Test + public void entryExpires() { + FakeTicker ticker = new FakeTicker(); + RESTTableCache cache = new RESTTableCache(Map.of(), ticker); + cache.put(SESSION_ID, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + + SessionIdTableId cacheKey = SessionIdTableId.of(SESSION_ID, TABLE_IDENTIFIER); + assertThat(cache.cache().policy().expireAfterAccess()).isNotPresent(); + assertThat(cache.cache().policy().expireAfterWrite().get().ageOf(cacheKey)) + .isPresent() + .get() + .isEqualTo(Duration.ZERO); + + ticker.advance(HALF_OF_TABLE_EXPIRATION); + + assertThat(cache.cache().asMap()).containsOnlyKeys(cacheKey); + assertThat(cache.cache().policy().expireAfterWrite().get().ageOf(cacheKey)) + .isPresent() + .get() + .isEqualTo(HALF_OF_TABLE_EXPIRATION); + + ticker.advance(HALF_OF_TABLE_EXPIRATION.plus(Duration.ofSeconds(10))); + cache.cache().cleanUp(); + + assertThat(cache.cache().asMap()).doesNotContainKey(cacheKey); + } + + @Test + public void configureExpiration() { + FakeTicker ticker = new FakeTicker(); + Duration expirationInterval = Duration.ofSeconds(30); + RESTTableCache cache = + new RESTTableCache( + Map.of( + RESTCatalogProperties.TABLE_CACHE_EXPIRE_AFTER_WRITE_MS, + String.valueOf(expirationInterval.toMillis())), + ticker); + cache.put(SESSION_ID, TABLE_IDENTIFIER, TABLE_SUPPLIER, ETAG); + + assertThat(cache.getIfPresent(SESSION_ID, TABLE_IDENTIFIER)).isNotNull(); + + ticker.advance(expirationInterval.plus(Duration.ofSeconds(10))); + cache.cache().cleanUp(); + + assertThat(cache.getIfPresent(SESSION_ID, TABLE_IDENTIFIER)).isNull(); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java index fe022b351822..1ed732ebc91a 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTUtil.java @@ -26,6 +26,8 @@ import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class TestRESTUtil { @@ -68,18 +70,24 @@ public void testStripTrailingSlash() { } } - @Test - public void testRoundTripUrlEncodeDecodeNamespace() { + @ParameterizedTest + @ValueSource(strings = {"%1F", "%2D", "%2E", "#", "_"}) + public void testRoundTripUrlEncodeDecodeNamespace(String namespaceSeparator) { // Namespace levels and their expected url encoded form Object[][] testCases = new Object[][] { new Object[] {new String[] {"dogs"}, "dogs"}, new Object[] {new String[] {"dogs.named.hank"}, "dogs.named.hank"}, new Object[] {new String[] {"dogs/named/hank"}, "dogs%2Fnamed%2Fhank"}, - new Object[] {new String[] {"dogs", "named", "hank"}, "dogs%1Fnamed%1Fhank"}, + new Object[] { + new String[] {"dogs", "named", "hank"}, + String.format("dogs%snamed%shank", namespaceSeparator, namespaceSeparator) + }, new Object[] { new String[] {"dogs.and.cats", "named", "hank.or.james-westfall"}, - "dogs.and.cats%1Fnamed%1Fhank.or.james-westfall" + String.format( + "dogs.and.cats%snamed%shank.or.james-westfall", + namespaceSeparator, namespaceSeparator), } }; @@ -90,14 +98,35 @@ public void testRoundTripUrlEncodeDecodeNamespace() { Namespace namespace = Namespace.of(levels); // To be placed into a URL path as query parameter or path parameter - assertThat(RESTUtil.encodeNamespace(namespace)).isEqualTo(encodedNs); + assertThat(RESTUtil.encodeNamespace(namespace, namespaceSeparator)).isEqualTo(encodedNs); // Decoded (after pulling as String) from URL - Namespace asNamespace = RESTUtil.decodeNamespace(encodedNs); - assertThat(asNamespace).isEqualTo(namespace); + assertThat(RESTUtil.decodeNamespace(encodedNs, namespaceSeparator)).isEqualTo(namespace); } } + @Test + public void encodeAsOldClientAndDecodeAsNewServer() { + Namespace namespace = Namespace.of("first", "second", "third"); + // old client would call encodeNamespace without specifying a separator + String encodedNamespace = RESTUtil.encodeNamespace(namespace); + assertThat(encodedNamespace).contains(RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8); + + // old client would also call namespaceToQueryParam without specifying a separator + String namespaceAsUnicode = RESTUtil.namespaceToQueryParam(namespace); + assertThat(namespaceAsUnicode).contains("\u001f"); + + // newer server would try and decode the namespace with the separator it communicates to clients + String separator = "%2E"; + Namespace decodedNamespace = RESTUtil.decodeNamespace(encodedNamespace, separator); + assertThat(decodedNamespace).isEqualTo(namespace); + + // newer server would try and split the namespace with the separator it communicates to clients + // but should detect whether the namespace contains the legacy separator + assertThat(RESTUtil.namespaceFromQueryParam(namespaceAsUnicode, separator)) + .isEqualTo(namespace); + } + @Test public void testNamespaceUrlEncodeDecodeDoesNotAllowNull() { assertThatExceptionOfType(IllegalArgumentException.class) @@ -212,4 +241,40 @@ public void namespaceFromQueryParam() { assertThat(RESTUtil.namespaceFromQueryParam("one%1Ftwo\u001fns")) .isEqualTo(Namespace.of("one%1Ftwo", "ns")); } + + @Test + public void nullOrEmptyNamespaceSeparator() { + String errorMsg = "Invalid separator: null or empty"; + assertThatThrownBy(() -> RESTUtil.encodeNamespace(Namespace.empty(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.encodeNamespace(Namespace.empty(), "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.decodeNamespace("namespace", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.decodeNamespace("namespace", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.namespaceToQueryParam(Namespace.empty(), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.namespaceToQueryParam(Namespace.empty(), "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.namespaceFromQueryParam("namespace", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + + assertThatThrownBy(() -> RESTUtil.namespaceFromQueryParam("namespace", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(errorMsg); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java index 8bfe26b18cda..24450949df5f 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalog.java @@ -18,7 +18,7 @@ */ package org.apache.iceberg.rest; -import static org.apache.iceberg.rest.TestRESTCatalog.reqMatcher; +import static org.apache.iceberg.rest.RequestMatcher.matches; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -57,10 +57,11 @@ import org.apache.iceberg.rest.responses.LoadViewResponse; import org.apache.iceberg.view.ViewCatalogTests; import org.apache.iceberg.view.ViewMetadata; +import org.eclipse.jetty.compression.gzip.GzipCompression; +import org.eclipse.jetty.compression.server.CompressionHandler; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.handler.gzip.GzipHandler; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -114,7 +115,9 @@ public T execute( new ServletContextHandler(ServletContextHandler.NO_SESSIONS); servletContext.setContextPath("/"); servletContext.addServlet(new ServletHolder(new RESTCatalogServlet(adaptor)), "/*"); - servletContext.setHandler(new GzipHandler()); + CompressionHandler compressionHandler = new CompressionHandler(); + compressionHandler.putCompression(new GzipCompression()); + servletContext.insertHandler(compressionHandler); this.httpServer = new Server(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); httpServer.setHandler(servletContext); @@ -199,11 +202,11 @@ public void testPaginationForListViews(int numberOfItems) { assertThat(views).hasSize(numberOfItems); Mockito.verify(adapter) - .execute(reqMatcher(HTTPMethod.GET, "v1/config"), eq(ConfigResponse.class), any(), any()); + .execute(matches(HTTPMethod.GET, "v1/config"), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter, times(numberOfItems)) .execute( - reqMatcher(HTTPMethod.POST, String.format("v1/namespaces/%s/views", namespaceName)), + matches(HTTPMethod.POST, String.format("v1/namespaces/%s/views", namespaceName)), eq(LoadViewResponse.class), any(), any()); @@ -249,13 +252,13 @@ public void viewExistsViaHEADRequest() { Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, "v1/config", Map.of(), Map.of()), + matches(HTTPMethod.GET, "v1/config", Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.HEAD, "v1/namespaces/ns/views/view", Map.of(), Map.of()), + matches(HTTPMethod.HEAD, "v1/namespaces/ns/views/view", Map.of(), Map.of()), any(), any(), any()); @@ -296,13 +299,13 @@ public T execute( Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, "v1/config", Map.of(), Map.of()), + matches(HTTPMethod.GET, "v1/config", Map.of(), Map.of()), eq(ConfigResponse.class), any(), any()); Mockito.verify(adapter) .execute( - reqMatcher(HTTPMethod.GET, "v1/namespaces/ns/views/view", Map.of(), Map.of()), + matches(HTTPMethod.GET, "v1/namespaces/ns/views/view", Map.of(), Map.of()), any(), any(), any()); @@ -394,7 +397,7 @@ protected RESTViewOperations newViewOps( ResourcePaths resourcePaths = ResourcePaths.forCatalogProperties(Maps.newHashMap()); Mockito.verify(adapter, Mockito.atLeastOnce()) .execute( - reqMatcher(HTTPMethod.POST, resourcePaths.view(viewIdentifier), customHeaders), + matches(HTTPMethod.POST, resourcePaths.view(viewIdentifier), customHeaders), eq(LoadViewResponse.class), any(), any()); diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java index 2ac08284433d..3f3d7ba77493 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTViewCatalogWithAssumedViewSupport.java @@ -18,6 +18,8 @@ */ package org.apache.iceberg.rest; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import java.io.File; import java.net.InetAddress; import java.net.InetSocketAddress; @@ -29,10 +31,11 @@ import org.apache.iceberg.inmemory.InMemoryCatalog; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.responses.ConfigResponse; +import org.eclipse.jetty.compression.gzip.GzipCompression; +import org.eclipse.jetty.compression.server.CompressionHandler; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.handler.gzip.GzipHandler; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; import org.junit.jupiter.api.BeforeEach; public class TestRESTViewCatalogWithAssumedViewSupport extends TestRESTViewCatalog { @@ -69,7 +72,9 @@ public T handleRequest( new ServletContextHandler(ServletContextHandler.NO_SESSIONS); servletContext.setContextPath("/"); servletContext.addServlet(new ServletHolder(new RESTCatalogServlet(adaptor)), "/*"); - servletContext.setHandler(new GzipHandler()); + CompressionHandler compressionHandler = new CompressionHandler(); + compressionHandler.putCompression(new GzipCompression()); + servletContext.insertHandler(compressionHandler); this.httpServer = new Server(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0)); httpServer.setHandler(servletContext); @@ -107,4 +112,31 @@ public T handleRequest( CatalogProperties.VIEW_OVERRIDE_PREFIX + "key4", "catalog-override-key4")); } + + @Override + public void registerView() { + // Older client doesn't support the newer endpoint. + assertThatThrownBy(super::registerView) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Server does not support endpoint: POST /v1/{prefix}/namespaces/{namespace}/register-view"); + } + + @Override + public void registerExistingView() { + // Older client doesn't support the newer endpoint. + assertThatThrownBy(super::registerExistingView) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Server does not support endpoint: POST /v1/{prefix}/namespaces/{namespace}/register-view"); + } + + @Override + public void registerViewThatAlreadyExistsAsTable() { + // Older client doesn't support the newer endpoint. + assertThatThrownBy(super::registerViewThatAlreadyExistsAsTable) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Server does not support endpoint: POST /v1/{prefix}/namespaces/{namespace}/register-view"); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java index c2d3fe560345..a742b89a7627 100644 --- a/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java +++ b/core/src/test/java/org/apache/iceberg/rest/TestResourcePaths.java @@ -24,6 +24,8 @@ import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class TestResourcePaths { private final String prefix = "ws/catalog"; @@ -64,6 +66,91 @@ public void testNamespaceWithMultipartNamespace() { assertThat(withoutPrefix.namespace(ns)).isEqualTo("v1/namespaces/n%1Fs"); } + @ParameterizedTest + @ValueSource(strings = {"%1F", "%2D", "%2E"}) + public void testNamespaceWithMultipartNamespace(String namespaceSeparator) { + Namespace ns = Namespace.of("n", "s"); + String namespace = String.format("n%ss", namespaceSeparator); + assertThat( + ResourcePaths.forCatalogProperties( + ImmutableMap.of( + "prefix", + prefix, + RESTCatalogProperties.NAMESPACE_SEPARATOR, + namespaceSeparator)) + .namespace(ns)) + .isEqualTo("v1/ws/catalog/namespaces/" + namespace); + + assertThat( + ResourcePaths.forCatalogProperties( + ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, namespaceSeparator)) + .namespace(ns)) + .isEqualTo("v1/namespaces/" + namespace); + } + + @ParameterizedTest + @ValueSource(strings = {"%1F", "%2D", "%2E"}) + public void testNamespaceWithDot(String namespaceSeparator) { + Namespace ns = Namespace.of("n.s", "a.b"); + String namespace = String.format("n.s%sa.b", namespaceSeparator); + assertThat( + ResourcePaths.forCatalogProperties( + ImmutableMap.of( + "prefix", + prefix, + RESTCatalogProperties.NAMESPACE_SEPARATOR, + namespaceSeparator)) + .namespace(ns)) + .isEqualTo("v1/ws/catalog/namespaces/" + namespace); + + assertThat( + ResourcePaths.forCatalogProperties( + ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, namespaceSeparator)) + .namespace(ns)) + .isEqualTo("v1/namespaces/" + namespace); + } + + @Test + public void nestedNamespaceWithLegacySeparator() { + Namespace namespace = Namespace.of("first", "second", "third"); + String legacySeparator = RESTUtil.NAMESPACE_SEPARATOR_URLENCODED_UTF_8; + String newSeparator = RESTCatalogAdapter.NAMESPACE_SEPARATOR_URLENCODED_UTF_8; + + // legacy separator is always used by default, so no need to configure it + ResourcePaths pathsWithLegacySeparator = ResourcePaths.forCatalogProperties(ImmutableMap.of()); + + // Encode namespace using legacy separator. No need to provide the separator to encodeNamespace + String legacyEncodedNamespace = RESTUtil.encodeNamespace(namespace); + assertThat(pathsWithLegacySeparator.namespace(namespace)) + .contains(legacyEncodedNamespace) + .contains(legacySeparator); + + // Decode the namespace containing legacy separator without providing the separator + assertThat(RESTUtil.decodeNamespace(legacyEncodedNamespace)).isEqualTo(namespace); + + // Decode the namespace containing legacy separator with providing the new separator + assertThat(RESTUtil.decodeNamespace(legacyEncodedNamespace, newSeparator)).isEqualTo(namespace); + } + + @Test + public void nestedNamespaceWithNewSeparator() { + Namespace namespace = Namespace.of("first", "second", "third"); + String newSeparator = RESTCatalogAdapter.NAMESPACE_SEPARATOR_URLENCODED_UTF_8; + + ResourcePaths pathsWithNewSeparator = + ResourcePaths.forCatalogProperties( + ImmutableMap.of(RESTCatalogProperties.NAMESPACE_SEPARATOR, newSeparator)); + + // Encode namespace using new separator + String newEncodedSeparator = RESTUtil.encodeNamespace(namespace, newSeparator); + assertThat(pathsWithNewSeparator.namespace(namespace)) + .contains(newEncodedSeparator) + .contains(newSeparator); + + // Decode the namespace containing new separator with explicitly providing the separator + assertThat(RESTUtil.decodeNamespace(newEncodedSeparator, newSeparator)).isEqualTo(namespace); + } + @Test public void testNamespaceProperties() { Namespace ns = Namespace.of("ns"); @@ -179,4 +266,90 @@ public void viewWithMultipartNamespace() { assertThat(withPrefix.view(ident)).isEqualTo("v1/ws/catalog/namespaces/n%1Fs/views/view-name"); assertThat(withoutPrefix.view(ident)).isEqualTo("v1/namespaces/n%1Fs/views/view-name"); } + + @Test + public void testRegisterView() { + Namespace ns = Namespace.of("ns"); + assertThat(withPrefix.registerView(ns)).isEqualTo("v1/ws/catalog/namespaces/ns/register-view"); + assertThat(withoutPrefix.registerView(ns)).isEqualTo("v1/namespaces/ns/register-view"); + } + + @Test + public void planEndpointPath() { + TableIdentifier tableId = TableIdentifier.of("test_namespace", "test_table"); + + assertThat(withPrefix.planTableScan(tableId)) + .isEqualTo("v1/ws/catalog/namespaces/test_namespace/tables/test_table/plan"); + assertThat(withoutPrefix.planTableScan(tableId)) + .isEqualTo("v1/namespaces/test_namespace/tables/test_table/plan"); + + // Test with different identifiers + TableIdentifier complexId = TableIdentifier.of(Namespace.of("db", "schema"), "my_table"); + assertThat(withPrefix.planTableScan(complexId)) + .isEqualTo("v1/ws/catalog/namespaces/db%1Fschema/tables/my_table/plan"); + assertThat(withoutPrefix.planTableScan(complexId)) + .isEqualTo("v1/namespaces/db%1Fschema/tables/my_table/plan"); + } + + @Test + public void fetchScanTasksPath() { + TableIdentifier tableId = TableIdentifier.of("test_namespace", "test_table"); + + assertThat(withPrefix.fetchScanTasks(tableId)) + .isEqualTo("v1/ws/catalog/namespaces/test_namespace/tables/test_table/tasks"); + assertThat(withoutPrefix.fetchScanTasks(tableId)) + .isEqualTo("v1/namespaces/test_namespace/tables/test_table/tasks"); + + // Test with different identifiers + TableIdentifier complexId = TableIdentifier.of(Namespace.of("db", "schema"), "my_table"); + assertThat(withPrefix.fetchScanTasks(complexId)) + .isEqualTo("v1/ws/catalog/namespaces/db%1Fschema/tables/my_table/tasks"); + assertThat(withoutPrefix.fetchScanTasks(complexId)) + .isEqualTo("v1/namespaces/db%1Fschema/tables/my_table/tasks"); + } + + @Test + public void cancelPlanEndpointPath() { + TableIdentifier tableId = TableIdentifier.of("test_namespace", "test_table"); + String planId = "plan-abc-123"; + + assertThat(withPrefix.plan(tableId, planId)) + .isEqualTo("v1/ws/catalog/namespaces/test_namespace/tables/test_table/plan/plan-abc-123"); + assertThat(withoutPrefix.plan(tableId, planId)) + .isEqualTo("v1/namespaces/test_namespace/tables/test_table/plan/plan-abc-123"); + + // The planId contains a space which needs to be encoded + String spaceSeparatedPlanId = "plan with spaces"; + // The expected encoded version of the planId + String encodedPlanId = "plan+with+spaces"; + + assertThat(withPrefix.plan(tableId, spaceSeparatedPlanId)) + .isEqualTo( + "v1/ws/catalog/namespaces/test_namespace/tables/test_table/plan/" + encodedPlanId); + assertThat(withoutPrefix.plan(tableId, spaceSeparatedPlanId)) + .isEqualTo("v1/namespaces/test_namespace/tables/test_table/plan/" + encodedPlanId); + + // Test with different identifiers + TableIdentifier complexId = TableIdentifier.of(Namespace.of("db", "schema"), "my_table"); + assertThat(withPrefix.plan(complexId, "plan-xyz-789")) + .isEqualTo("v1/ws/catalog/namespaces/db%1Fschema/tables/my_table/plan/plan-xyz-789"); + assertThat(withoutPrefix.plan(complexId, "plan-xyz-789")) + .isEqualTo("v1/namespaces/db%1Fschema/tables/my_table/plan/plan-xyz-789"); + } + + @Test + public void testRemoteSign() { + TableIdentifier tableId = TableIdentifier.of("test_namespace", "test_table"); + assertThat(withPrefix.remoteSign(tableId)) + .isEqualTo("v1/ws/catalog/namespaces/test_namespace/tables/test_table/sign"); + assertThat(withoutPrefix.remoteSign(tableId)) + .isEqualTo("v1/namespaces/test_namespace/tables/test_table/sign"); + + // Test with different identifiers + TableIdentifier complexId = TableIdentifier.of(Namespace.of("db", "schema"), "my_table"); + assertThat(withPrefix.remoteSign(complexId)) + .isEqualTo("v1/ws/catalog/namespaces/db%1Fschema/tables/my_table/sign"); + assertThat(withoutPrefix.remoteSign(complexId)) + .isEqualTo("v1/namespaces/db%1Fschema/tables/my_table/sign"); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/TestScanTaskIterable.java b/core/src/test/java/org/apache/iceberg/rest/TestScanTaskIterable.java new file mode 100644 index 000000000000..cf4d8030b35f --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestScanTaskIterable.java @@ -0,0 +1,605 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MockFileScanTask; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.rest.requests.FetchScanTasksRequest; +import org.apache.iceberg.rest.responses.FetchScanTasksResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestScanTaskIterable { + + private static final TableIdentifier TABLE_IDENTIFIER = + TableIdentifier.of(Namespace.of("ns"), "table"); + private static final String FETCH_TASKS_PATH = "v1/namespaces/ns/tables/table/tasks"; + private static final Map HEADERS = + ImmutableMap.of("Authorization", "Bearer token"); + + private RESTClient mockClient; + private ResourcePaths resourcePaths; + private ExecutorService executorService; + private ParserContext parserContext; + + @BeforeEach + public void before() { + mockClient = mock(RESTClient.class); + resourcePaths = ResourcePaths.forCatalogProperties(ImmutableMap.of()); + executorService = Executors.newFixedThreadPool(4); + parserContext = ParserContext.builder().build(); + } + + @AfterEach + public void after() { + if (executorService != null) { + executorService.shutdownNow(); + } + } + + private void assertIteratorThrows(CloseableIterator iterator, String errorPattern) { + assertThatThrownBy(iterator::hasNext) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining(errorPattern); + } + + private List planTasks(int count) { + return IntStream.range(0, count).mapToObj(i -> "planTask" + i).collect(toList()); + } + + private List fileTasks(int count) { + return IntStream.range(1, count + 1).mapToObj(i -> new MockFileScanTask(i)).collect(toList()); + } + + private List collectAll(CloseableIterator iterator) + throws IOException { + try (iterator) { + return Lists.newArrayList(iterator); + } + } + + private ScanTaskIterable createIterable(List planTasks, List initialTasks) { + return new ScanTaskIterable( + planTasks, + initialTasks, + mockClient, + resourcePaths, + TABLE_IDENTIFIER, + HEADERS, + executorService, + parserContext); + } + + private void mockClientPost(FetchScanTasksResponse... responses) { + if (responses.length == 1) { + when(mockClient.post( + eq(FETCH_TASKS_PATH), + any(FetchScanTasksRequest.class), + eq(FetchScanTasksResponse.class), + eq(HEADERS), + any(), + any(), + eq(parserContext))) + .thenReturn(responses[0]); + } else { + when(mockClient.post( + eq(FETCH_TASKS_PATH), + any(FetchScanTasksRequest.class), + eq(FetchScanTasksResponse.class), + eq(HEADERS), + any(), + any(), + eq(parserContext))) + .thenReturn(responses[0], java.util.Arrays.copyOfRange(responses, 1, responses.length)); + } + } + + private void mockClientPostAnswer(org.mockito.stubbing.Answer answer) { + when(mockClient.post( + eq(FETCH_TASKS_PATH), + any(FetchScanTasksRequest.class), + eq(FetchScanTasksResponse.class), + eq(HEADERS), + any(), + any(), + eq(parserContext))) + .thenAnswer(answer); + } + + // ==================== Nested/Paginated Plan Tasks Tests ==================== + + @Test + public void iterableWithNestedPlanTasks() throws IOException { + // First plan task returns more plan tasks + FetchScanTasksResponse response1 = + FetchScanTasksResponse.builder() + .withPlanTasks(ImmutableList.of("nestedPlanTask1", "nestedPlanTask2")) + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(100))) + .build(); + + FetchScanTasksResponse response2 = + FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(200))) + .build(); + FetchScanTasksResponse response3 = + FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(300))) + .build(); + + mockClientPost(response1, response2, response3); + + ScanTaskIterable iterable = + createIterable(ImmutableList.of("planTask1"), Collections.emptyList()); + + List result = collectAll(iterable.iterator()); + assertThat(result).hasSize(3); + assertThat(result).extracting(FileScanTask::length).containsExactlyInAnyOrder(100L, 200L, 300L); + + verify(mockClient, times(3)) + .post( + eq(FETCH_TASKS_PATH), + any(FetchScanTasksRequest.class), + eq(FetchScanTasksResponse.class), + eq(HEADERS), + any(), + any(), + eq(parserContext)); + } + + @Test + public void iterableWithDeeplyNestedPlanTasks() throws IOException { + FetchScanTasksResponse response1 = + FetchScanTasksResponse.builder().withPlanTasks(ImmutableList.of("level2")).build(); + FetchScanTasksResponse response2 = + FetchScanTasksResponse.builder().withPlanTasks(ImmutableList.of("level3")).build(); + FetchScanTasksResponse response3 = + FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(100))) + .build(); + + mockClientPost(response1, response2, response3); + + ScanTaskIterable iterable = createIterable(ImmutableList.of("level1"), Collections.emptyList()); + + List result = collectAll(iterable.iterator()); + assertThat(result).hasSize(1); + assertThat(result.get(0).length()).isEqualTo(100L); + } + + // ==================== Iterator Behavior Tests ==================== + + @Test + public void iteratorNextWithoutHasNext() throws IOException { + ScanTaskIterable iterable = createIterable(null, ImmutableList.of(new MockFileScanTask(100))); + + try (CloseableIterator iterator = iterable.iterator()) { + FileScanTask task = iterator.next(); + assertThat(task.length()).isEqualTo(100L); + assertThatThrownBy(iterator::next) + .isInstanceOf(NoSuchElementException.class) + .hasMessage("No more scan tasks available"); + } + } + + @Test + public void iteratorMultipleHasNextCallsIdempotent() throws IOException { + ScanTaskIterable iterable = createIterable(null, ImmutableList.of(new MockFileScanTask(100))); + + try (CloseableIterator iterator = iterable.iterator()) { + // Multiple hasNext() calls should be idempotent + assertThat(iterator.hasNext()).isTrue(); + assertThat(iterator.hasNext()).isTrue(); + assertThat(iterator.hasNext()).isTrue(); + + FileScanTask task = iterator.next(); + assertThat(task.length()).isEqualTo(100L); + + assertThat(iterator.hasNext()).isFalse(); + assertThat(iterator.hasNext()).isFalse(); + } + } + + // ==================== Error Handling Tests ==================== + + @Test + public void workerFailurePropagatesException() throws IOException { + when(mockClient.post( + eq(FETCH_TASKS_PATH), + any(FetchScanTasksRequest.class), + eq(FetchScanTasksResponse.class), + eq(HEADERS), + any(), + any(), + eq(parserContext))) + .thenThrow(new RuntimeException("Network error")); + + ScanTaskIterable iterable = + createIterable(ImmutableList.of("planTask1"), Collections.emptyList()); + + try (CloseableIterator iterator = iterable.iterator()) { + assertIteratorThrows(iterator, "Worker failed"); + } + } + + // ==================== Chained Plan Tasks Test ==================== + + @Test + public void chainedPlanTasks() throws IOException { + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + if (count <= 3) { + return FetchScanTasksResponse.builder() + .withPlanTasks(ImmutableList.of("chainedTask" + count)) + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 100L))) + .build(); + } else { + return FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 100L))) + .build(); + } + }); + + ScanTaskIterable iterable = + createIterable(ImmutableList.of("initialTask"), Collections.emptyList()); + + List result = collectAll(iterable.iterator()); + assertThat(result).hasSize(4); + } + + // ==================== Concurrency Tests ==================== + + @Test + public void concurrentWorkersProcessingTasks() throws IOException { + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + // Simulate some network latency + Thread.sleep(10); + return FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 100))) + .build(); + }); + + // Create many plan tasks to trigger multiple workers + ScanTaskIterable iterable = createIterable(planTasks(50), Collections.emptyList()); + + List result = collectAll(iterable.iterator()); + assertThat(result).hasSize(50); + + // All plan tasks should have been processed exactly once + assertThat(callCount.get()).isEqualTo(50); + } + + @Test + public void slowProducerFastConsumer() throws IOException { + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + // Slow producer - simulate network delay + Thread.sleep(50); + int count = callCount.incrementAndGet(); + return FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 100))) + .build(); + }); + + ScanTaskIterable iterable = createIterable(planTasks(3), Collections.emptyList()); + + List result = collectAll(iterable.iterator()); + assertThat(result).hasSize(3); + } + + @Test + public void closeWhileWorkersAreRunning() throws IOException, InterruptedException { + CountDownLatch workerStarted = new CountDownLatch(1); + + mockClientPostAnswer( + invocation -> { + workerStarted.countDown(); + // Simulate a very slow network call + Thread.sleep(5000); + return FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(100))) + .build(); + }); + + ScanTaskIterable iterable = + createIterable(ImmutableList.of("planTask1"), Collections.emptyList()); + + try (CloseableIterator iterator = iterable.iterator()) { + // Wait for worker to start + workerStarted.await(1, TimeUnit.SECONDS); + // Close immediately - should not block + iterator.close(); + // hasNext should return false after close + assertThat(iterator.hasNext()).isFalse(); + } + } + + @Test + public void multipleWorkersWithMixedNestedPlanTasks() throws IOException { + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + // First few calls return nested plan tasks to generate more work + if (count <= 3) { + return FetchScanTasksResponse.builder() + .withPlanTasks( + ImmutableList.of("nestedTask" + count + "a", "nestedTask" + count + "b")) + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 100))) + .build(); + } else { + return FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 100))) + .build(); + } + }); + + ScanTaskIterable iterable = createIterable(planTasks(3), Collections.emptyList()); + + List result = collectAll(iterable.iterator()); + // 3 initial tasks + 6 nested tasks = 9 total + assertThat(result).hasSize(9); + } + + @Test + public void initialFileScanTasksWithConcurrentPlanTasks() throws IOException { + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + // Simulate network delay + Thread.sleep(20); + return FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 1000))) + .build(); + }); + + // Initial tasks should be available immediately while plan tasks are being fetched + ScanTaskIterable iterable = createIterable(planTasks(10), fileTasks(10)); + + List result = collectAll(iterable.iterator()); + // 10 initial + 10 from plan tasks = 20 total + assertThat(result).hasSize(20); + } + + @Test + public void workerExceptionDoesNotBlockOtherTasks() throws IOException { + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + // First call fails + throw new RuntimeException("First call failed"); + } + return FetchScanTasksResponse.builder() + .withFileScanTasks(ImmutableList.of(new MockFileScanTask(count * 100))) + .build(); + }); + + ScanTaskIterable iterable = + createIterable(ImmutableList.of("planTask1"), Collections.emptyList()); + + try (CloseableIterator iterator = iterable.iterator()) { + assertIteratorThrows(iterator, "Worker failed"); + } + } + + @Test + public void multipleWorkerFailuresOnlySignalOnce() throws IOException { + // This test verifies that when multiple workers fail, only one DUMMY_TASK is added + // and the iterator correctly propagates the first failure without hanging + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + // Add small delay to allow multiple workers to start + Thread.sleep(10); + throw new RuntimeException("Worker " + count + " failed"); + }); + + // Create multiple plan tasks so multiple workers can pick them up and fail + ScanTaskIterable iterable = createIterable(planTasks(10), Collections.emptyList()); + + try (CloseableIterator iterator = iterable.iterator()) { + // Should propagate the first failure without hanging + assertIteratorThrows(iterator, "Worker failed"); + + // Subsequent calls should also throw (not hang waiting for more DUMMY_TASKs) + assertIteratorThrows(iterator, "Worker failed"); + } + } + + @Test + public void workerExceptionWithFullQueueDoesNotHangOtherWorkers() throws Exception { + // This test verifies that when one worker fails and the consumer throws (stops draining), + // other workers don't hang indefinitely trying to put tasks into the full queue. + // Key: consumer does NOT call close() - it just blows up on the exception. + CountDownLatch firstWorkerStarted = new CountDownLatch(1); + CountDownLatch failureTriggered = new CountDownLatch(1); + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + firstWorkerStarted.countDown(); + // First worker returns MORE tasks than queue capacity (1000) to ensure it blocks on + // offer().Also adds more plan tasks so other workers have work. + return FetchScanTasksResponse.builder() + .withPlanTasks(planTasks(5)) + .withFileScanTasks(fileTasks(1500)) + .build(); + } else if (count == 2) { + // Second worker waits for first worker to start blocking on full queue, then fails. + // This sets shutdown=true, which should unblock worker 1. + Thread.sleep(200); + failureTriggered.countDown(); + throw new RuntimeException("Worker failed"); + } else { + // Other workers also return many tasks - they may also block on full queue + return FetchScanTasksResponse.builder().withFileScanTasks(fileTasks(1500)).build(); + } + }); + + ScanTaskIterable iterable = createIterable(planTasks(3), Collections.emptyList()); + + // Intentionally NOT using try-with-resources - we don't want close() called + CloseableIterator iterator = iterable.iterator(); + try { + // Wait for first worker to start filling the queue + firstWorkerStarted.await(5, TimeUnit.SECONDS); + + // Consume just a few tasks + int consumed = 0; + while (consumed < 5) { + if (iterator.hasNext()) { + iterator.next(); + consumed++; + } + } + + // Wait for failure to be triggered + failureTriggered.await(5, TimeUnit.SECONDS); + + // Verify at least 2 workers ran. Worker 1 produced 1500 tasks (queue capacity is 1000), + // so worker 1 should be blocked on offer() when worker 2 fails and sets shutdown=true. + assertThat(callCount.get()).isGreaterThanOrEqualTo(2); + + // Give time for failure to propagate + Thread.sleep(200); + + // This hasNext() should throw due to worker failure + iterator.hasNext(); + + // Should not reach here + assertThat(false).as("Expected RuntimeException from hasNext()").isTrue(); + } catch (RuntimeException e) { + // Expected - consumer blows up, does NOT call close() + assertThat(e.getMessage()).contains("Worker failed"); + } + // Note: iterator.close() is intentionally NOT called + // Give workers time to see the shutdown flag (set by failing worker) and exit. + Thread.sleep(500); + + // Verify executor can shut down cleanly (workers aren't stuck on offer()) + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(2, TimeUnit.SECONDS); + assertThat(terminated) + .as("Executor should terminate - workers should have exited via shutdown flag") + .isTrue(); + } + + @Test + public void closeWithFullQueueDoesNotHangWorkers() throws Exception { + // This test verifies that when the queue is full and the consumer closes the iterator, + // workers don't hang indefinitely trying to put tasks into the full queue. + // The queue capacity is 1000, so we need to generate more tasks than that. + CountDownLatch workerStarted = new CountDownLatch(1); + AtomicInteger callCount = new AtomicInteger(0); + + mockClientPostAnswer( + invocation -> { + workerStarted.countDown(); + int count = callCount.incrementAndGet(); + // Each response returns many file scan tasks to fill the queue quickly, + // plus more plan tasks to keep workers busy + if (count <= 10) { + return FetchScanTasksResponse.builder() + .withPlanTasks(planTasks(2)) + .withFileScanTasks(fileTasks(500)) + .build(); + } + return FetchScanTasksResponse.builder().withFileScanTasks(fileTasks(500)).build(); + }); + + ScanTaskIterable iterable = createIterable(planTasks(2), Collections.emptyList()); + + try (CloseableIterator iterator = iterable.iterator()) { + // Wait for workers to start producing + workerStarted.await(5, TimeUnit.SECONDS); + + // Consume a few tasks to let the queue fill up + int consumed = 0; + while (iterator.hasNext() && consumed < 10) { + iterator.next(); + consumed++; + } + + // Give workers time to fill the queue (each worker produces 500 tasks, queue capacity is + // 1000) + Thread.sleep(200); + + // Verify enough workers ran to fill the queue beyond capacity. + // With 500 tasks per call and queue capacity 1000, we need 3+ calls to overflow. + assertThat(callCount.get()).isGreaterThanOrEqualTo(3); + } + // iterator.close() called here by try-with-resources + + // Give workers a bit more time to exit after consumer closed + Thread.sleep(500); + + // Verify executor can shut down cleanly (workers aren't stuck on offer()) + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(2, TimeUnit.SECONDS); + assertThat(terminated) + .as("Executor should terminate - workers should have exited gracefully") + .isTrue(); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestableRESTCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestableRESTCatalog.java new file mode 100644 index 000000000000..cf66c04ade33 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestableRESTCatalog.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import com.github.benmanes.caffeine.cache.Ticker; +import java.util.Map; +import java.util.function.Function; +import org.apache.iceberg.catalog.SessionCatalog; + +class TestableRESTCatalog extends RESTCatalog { + private final Ticker ticker; + + TestableRESTCatalog( + SessionCatalog.SessionContext context, + Function, RESTClient> clientBuilder, + Ticker ticker) { + super(context, clientBuilder); + + this.ticker = ticker; + } + + @Override + protected RESTSessionCatalog newSessionCatalog( + Function, RESTClient> clientBuilder) { + // This is called from RESTCatalog's constructor, 'ticker' member is not yet set, we have to + // defer passing it to the session catalog. + return new TestableRESTSessionCatalog(clientBuilder, null); + } + + @Override + public void initialize(String name, Map props) { + ((TestableRESTSessionCatalog) sessionCatalog()).setTicker(ticker); + + super.initialize(name, props); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/TestableRESTSessionCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestableRESTSessionCatalog.java new file mode 100644 index 000000000000..492fd998e862 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/TestableRESTSessionCatalog.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest; + +import com.github.benmanes.caffeine.cache.Ticker; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; +import org.apache.iceberg.io.FileIO; + +class TestableRESTSessionCatalog extends RESTSessionCatalog { + private Ticker ticker; + + TestableRESTSessionCatalog( + Function, RESTClient> clientBuilder, + BiFunction, FileIO> ioBuilder) { + super(clientBuilder, ioBuilder); + } + + public void setTicker(Ticker newTicker) { + this.ticker = newTicker; + } + + @Override + protected RESTTableCache createTableCache(Map props) { + return new RESTTableCache(props, ticker); + } +} diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java b/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java index fbcb87fb06e2..4a6fbf7a1cdc 100644 --- a/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java +++ b/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java @@ -26,9 +26,13 @@ import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.argThat; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.PlainJWT; import java.io.IOException; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.apache.iceberg.rest.RESTClient; +import org.apache.iceberg.rest.auth.OAuth2Util.AuthSession; import org.apache.iceberg.rest.responses.OAuthTokenResponse; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -135,4 +139,97 @@ public void testCredentialFlowForSessionRefresh() throws IOException { any()); } } + + @Test + void fromTokenResponseUsesChildTokenExpiry() { + AuthSession parent = parentSession(7200); + OAuthTokenResponse response = childTokenResponse(tokenWithExp(300), 300); + + AuthSession child = + AuthSession.fromTokenResponse(null, null, response, System.currentTimeMillis(), parent); + assertThat(child.expiresAtMillis()) + .as("Child session should use the child token's exp, not the parent's") + .isEqualTo(TimeUnit.SECONDS.toMillis(300)); + } + + @Test + void fromTokenResponseOpaqueTokenDoesNotInheritParentExpiry() { + AuthSession parent = parentSession(7200); + OAuthTokenResponse response = childTokenResponse("opaque-access-token", 600); + + AuthSession child = + AuthSession.fromTokenResponse(null, null, response, System.currentTimeMillis(), parent); + + assertThat(child.expiresAtMillis()) + .as("Child session with opaque token should not inherit parent's expiresAtMillis") + .isNull(); + } + + @Test + void fromAccessTokenUsesChildTokenExpiry() { + AuthSession parent = parentSession(7200); + String childToken = tokenWithExp(300); + + AuthSession child = AuthSession.fromAccessToken(null, null, childToken, null, parent); + assertThat(child.expiresAtMillis()) + .as("Child session should use the child token's exp, not the parent's") + .isEqualTo(TimeUnit.SECONDS.toMillis(300)); + } + + @Test + void refreshUsesRefreshedTokenExpiry() throws IOException { + String parentToken = tokenWithExp(7200); + String refreshedToken = tokenWithExp(500); + + AuthConfig authConfig = + AuthConfig.builder() + .token(parentToken) + .tokenType(OAuth2Properties.ACCESS_TOKEN_TYPE) + .keepRefreshed(true) + .credential("testClientId:testClientSecret") + .oauth2ServerUri("/v1/token") + .expiresAtMillis(OAuth2Util.expiresAtMillis(parentToken)) + .build(); + + OAuthTokenResponse response = childTokenResponse(refreshedToken, 500); + + try (RESTClient client = Mockito.mock(RESTClient.class); + AuthSession session = new AuthSession(Map.of(), authConfig)) { + Mockito.when(client.postForm(any(), anyMap(), any(), anyMap(), any())).thenReturn(response); + + session.refresh(client); + + assertThat(session.expiresAtMillis()) + .as("After refresh, session should use the refreshed token's exp") + .isEqualTo(TimeUnit.SECONDS.toMillis(500)); + } + } + + private static AuthSession parentSession(long expSeconds) { + String parentToken = tokenWithExp(expSeconds); + AuthConfig parentConfig = + AuthConfig.builder() + .token(parentToken) + .tokenType(OAuth2Properties.ACCESS_TOKEN_TYPE) + .keepRefreshed(false) + .build(); + AuthSession parent = new AuthSession(Map.of(), parentConfig); + assertThat(parent.expiresAtMillis()).isEqualTo(TimeUnit.SECONDS.toMillis(expSeconds)); + return parent; + } + + private static OAuthTokenResponse childTokenResponse(String token, int expiresInSeconds) { + return OAuthTokenResponse.builder() + .withToken(token) + .withTokenType(BEARER) + .withIssuedTokenType(OAuth2Properties.ACCESS_TOKEN_TYPE) + .setExpirationInSeconds(expiresInSeconds) + .build(); + } + + private static String tokenWithExp(long expSeconds) { + JWTClaimsSet claimsSet = + new JWTClaimsSet.Builder().subject("test").claim("exp", expSeconds).build(); + return new PlainJWT(claimsSet).serialize(); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterTableRequestParser.java b/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterTableRequestParser.java index 50a47df974a3..d7bbb3db40e9 100644 --- a/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterTableRequestParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterTableRequestParser.java @@ -76,4 +76,28 @@ public void roundTripSerde() { assertThat(RegisterTableRequestParser.toJson(RegisterTableRequestParser.fromJson(json), true)) .isEqualTo(expectedJson); } + + @Test + public void roundTripSerdeWithOverwrite() { + RegisterTableRequest request = + ImmutableRegisterTableRequest.builder() + .name("table_1") + .metadataLocation( + "file://tmp/NS/test_tbl/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json") + .overwrite(true) + .build(); + + String expectedJson = + "{\n" + + " \"name\" : \"table_1\",\n" + + " \"metadata-location\" : \"file://tmp/NS/test_tbl/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json\",\n" + + " \"overwrite\" : true\n" + + "}"; + + String json = RegisterTableRequestParser.toJson(request, true); + assertThat(json).isEqualTo(expectedJson); + + assertThat(RegisterTableRequestParser.toJson(RegisterTableRequestParser.fromJson(json), true)) + .isEqualTo(expectedJson); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterViewRequestParser.java b/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterViewRequestParser.java new file mode 100644 index 000000000000..5f55dff1120e --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/rest/requests/TestRegisterViewRequestParser.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.rest.requests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +public class TestRegisterViewRequestParser { + + @Test + public void nullCheck() { + assertThatThrownBy(() -> RegisterViewRequestParser.toJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid register view request: null"); + + assertThatThrownBy(() -> RegisterViewRequestParser.fromJson((JsonNode) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse register view request from null object"); + } + + @Test + public void missingFields() { + assertThatThrownBy(() -> RegisterViewRequestParser.fromJson("{}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: name"); + + assertThatThrownBy(() -> RegisterViewRequestParser.fromJson("{\"name\" : \"test_vw\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: metadata-location"); + + assertThatThrownBy( + () -> + RegisterViewRequestParser.fromJson( + "{\"metadata-location\" : \"file://tmp/NS/test_vw/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json\"}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse missing string: name"); + } + + @Test + public void roundTripSerde() { + RegisterViewRequest request = + ImmutableRegisterViewRequest.builder() + .name("view_1") + .metadataLocation( + "file://tmp/NS/view_1/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json") + .build(); + + String expectedJson = + "{\n" + + " \"name\" : \"view_1\",\n" + + " \"metadata-location\" : \"file://tmp/NS/view_1/metadata/00000-d4f60d2f-2ad2-408b-8832-0ed7fbd851ee.metadata.json\"\n" + + "}"; + + String json = RegisterViewRequestParser.toJson(request, true); + assertThat(json).isEqualTo(expectedJson); + + assertThat(RegisterViewRequestParser.toJson(RegisterViewRequestParser.fromJson(json), true)) + .isEqualTo(expectedJson); + } +} diff --git a/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3SignRequestParser.java b/core/src/test/java/org/apache/iceberg/rest/requests/TestRemoteSignRequestParser.java similarity index 70% rename from aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3SignRequestParser.java rename to core/src/test/java/org/apache/iceberg/rest/requests/TestRemoteSignRequestParser.java index 75ae2d88cccf..3515588e444d 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3SignRequestParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/requests/TestRemoteSignRequestParser.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.aws.s3.signer; +package org.apache.iceberg.rest.requests; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -28,37 +28,39 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; -public class TestS3SignRequestParser { +public class TestRemoteSignRequestParser { @Test public void nullRequest() { - assertThatThrownBy(() -> S3SignRequestParser.fromJson((JsonNode) null)) + assertThatThrownBy(() -> RemoteSignRequestParser.fromJson((JsonNode) null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Cannot parse s3 sign request from null object"); + .hasMessage("Cannot parse remote sign request from null object"); - assertThatThrownBy(() -> S3SignRequestParser.toJson(null)) + assertThatThrownBy(() -> RemoteSignRequestParser.toJson(null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Invalid s3 sign request: null"); + .hasMessage("Invalid remote sign request: null"); } @Test public void missingFields() { - assertThatThrownBy(() -> S3SignRequestParser.fromJson("{}")) + assertThatThrownBy(() -> RemoteSignRequestParser.fromJson("{}")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot parse missing string: region"); - assertThatThrownBy(() -> S3SignRequestParser.fromJson("{\"region\":\"us-west-2\"}")) + assertThatThrownBy(() -> RemoteSignRequestParser.fromJson("{\"region\":\"us-west-2\"}")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot parse missing string: method"); assertThatThrownBy( - () -> S3SignRequestParser.fromJson("{\"region\":\"us-west-2\", \"method\" : \"PUT\"}")) + () -> + RemoteSignRequestParser.fromJson( + "{\"region\":\"us-west-2\", \"method\" : \"PUT\"}")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot parse missing string: uri"); assertThatThrownBy( () -> - S3SignRequestParser.fromJson( + RemoteSignRequestParser.fromJson( "{\n" + " \"region\" : \"us-west-2\",\n" + " \"method\" : \"PUT\",\n" @@ -72,7 +74,7 @@ public void missingFields() { public void invalidMethod() { assertThatThrownBy( () -> - S3SignRequestParser.fromJson( + RemoteSignRequestParser.fromJson( "{\n" + " \"region\" : \"us-west-2\",\n" + " \"method\" : 23,\n" @@ -87,7 +89,7 @@ public void invalidMethod() { public void invalidUri() { assertThatThrownBy( () -> - S3SignRequestParser.fromJson( + RemoteSignRequestParser.fromJson( "{\n" + " \"region\" : \"us-west-2\",\n" + " \"method\" : \"PUT\",\n" @@ -102,7 +104,7 @@ public void invalidUri() { public void invalidRegion() { assertThatThrownBy( () -> - S3SignRequestParser.fromJson( + RemoteSignRequestParser.fromJson( "{\n" + " \"region\" : 23,\n" + " \"method\" : \"PUT\",\n" @@ -115,8 +117,8 @@ public void invalidRegion() { @Test public void roundTripSerde() { - ImmutableS3SignRequest s3SignRequest = - ImmutableS3SignRequest.builder() + RemoteSignRequest request = + ImmutableRemoteSignRequest.builder() .uri(URI.create("http://localhost:49208/iceberg-signer-test")) .method("PUT") .region("us-west-2") @@ -132,8 +134,8 @@ public void roundTripSerde() { Arrays.asList("aws-sdk-java/2.20.18", "Linux/5.4.0-126"))) .build(); - String json = S3SignRequestParser.toJson(s3SignRequest, true); - assertThat(S3SignRequestParser.fromJson(json)).isEqualTo(s3SignRequest); + String json = RemoteSignRequestParser.toJson(request, true); + assertThat(RemoteSignRequestParser.fromJson(json)).isEqualTo(request); assertThat(json) .isEqualTo( "{\n" @@ -151,8 +153,8 @@ public void roundTripSerde() { @Test public void roundTripSerdeWithProperties() { - ImmutableS3SignRequest s3SignRequest = - ImmutableS3SignRequest.builder() + RemoteSignRequest request = + ImmutableRemoteSignRequest.builder() .uri(URI.create("http://localhost:49208/iceberg-signer-test")) .method("PUT") .region("us-west-2") @@ -169,8 +171,8 @@ public void roundTripSerdeWithProperties() { .properties(ImmutableMap.of("k1", "v1")) .build(); - String json = S3SignRequestParser.toJson(s3SignRequest, true); - assertThat(S3SignRequestParser.fromJson(json)).isEqualTo(s3SignRequest); + String json = RemoteSignRequestParser.toJson(request, true); + assertThat(RemoteSignRequestParser.fromJson(json)).isEqualTo(request); assertThat(json) .isEqualTo( "{\n" @@ -191,8 +193,8 @@ public void roundTripSerdeWithProperties() { @Test public void roundTripWithBody() { - ImmutableS3SignRequest s3SignRequest = - ImmutableS3SignRequest.builder() + RemoteSignRequest request = + ImmutableRemoteSignRequest.builder() .uri(URI.create("http://localhost:49208/iceberg-signer-test")) .method("PUT") .region("us-west-2") @@ -210,8 +212,8 @@ public void roundTripWithBody() { .body("some-body") .build(); - String json = S3SignRequestParser.toJson(s3SignRequest, true); - assertThat(S3SignRequestParser.fromJson(json)).isEqualTo(s3SignRequest); + String json = RemoteSignRequestParser.toJson(request, true); + assertThat(RemoteSignRequestParser.fromJson(json)).isEqualTo(request); assertThat(json) .isEqualTo( "{\n" @@ -230,4 +232,46 @@ public void roundTripWithBody() { + " \"body\" : \"some-body\"\n" + "}"); } + + @Test + public void roundTripWithProvider() { + RemoteSignRequest request = + ImmutableRemoteSignRequest.builder() + .uri(URI.create("http://localhost:49208/iceberg-signer-test")) + .method("PUT") + .region("us-west-2") + .headers( + ImmutableMap.of( + "amz-sdk-request", + Arrays.asList("attempt=1", "max=4"), + "Content-Length", + Collections.singletonList("191"), + "Content-Type", + Collections.singletonList("application/json"), + "User-Agent", + Arrays.asList("aws-sdk-java/2.20.18", "Linux/5.4.0-126"))) + .properties(ImmutableMap.of("k1", "v1")) + .provider("s3") + .build(); + + String json = RemoteSignRequestParser.toJson(request, true); + assertThat(RemoteSignRequestParser.fromJson(json)).isEqualTo(request); + assertThat(json) + .isEqualTo( + "{\n" + + " \"region\" : \"us-west-2\",\n" + + " \"method\" : \"PUT\",\n" + + " \"uri\" : \"http://localhost:49208/iceberg-signer-test\",\n" + + " \"headers\" : {\n" + + " \"amz-sdk-request\" : [ \"attempt=1\", \"max=4\" ],\n" + + " \"Content-Length\" : [ \"191\" ],\n" + + " \"Content-Type\" : [ \"application/json\" ],\n" + + " \"User-Agent\" : [ \"aws-sdk-java/2.20.18\", \"Linux/5.4.0-126\" ]\n" + + " },\n" + + " \"properties\" : {\n" + + " \"k1\" : \"v1\"\n" + + " },\n" + + " \"provider\" : \"s3\"\n" + + "}"); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchPlanningResultResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchPlanningResultResponseParser.java index 7c1021d4afab..841083f88baf 100644 --- a/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchPlanningResultResponseParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchPlanningResultResponseParser.java @@ -40,8 +40,12 @@ import org.apache.iceberg.SchemaParser; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.expressions.ResidualEvaluator; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.PlanStatus; import org.apache.iceberg.rest.RESTSerializers; +import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.credentials.ImmutableCredential; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -140,7 +144,6 @@ public void roundTripSerdeWithInvalidPlanStatusSubmittedWithTasksPresent() { @Test public void roundTripSerdeWithInvalidPlanStatusSubmittedWithDeleteFilesNoFileScanTasksPresent() { - PlanStatus planStatus = PlanStatus.fromName("submitted"); assertThatThrownBy( () -> { @@ -186,7 +189,6 @@ public void roundTripSerdeWithValidStatusAndFileScanTasks() throws JsonProcessin FetchPlanningResultResponse.builder() .withPlanStatus(planStatus) .withFileScanTasks(List.of(fileScanTask)) - .withDeleteFiles(List.of(FILE_A_DELETES)) // assume this has been set .withSpecsById(PARTITION_SPECS_BY_ID) .build(); @@ -219,7 +221,6 @@ public void roundTripSerdeWithValidStatusAndFileScanTasks() throws JsonProcessin FetchPlanningResultResponse.builder() .withPlanStatus(fromResponse.planStatus()) .withPlanTasks(fromResponse.planTasks()) - .withDeleteFiles(fromResponse.deleteFiles()) .withFileScanTasks(fromResponse.fileScanTasks()) .withSpecsById(PARTITION_SPECS_BY_ID) .build(); @@ -227,4 +228,172 @@ public void roundTripSerdeWithValidStatusAndFileScanTasks() throws JsonProcessin assertThat(FetchPlanningResultResponseParser.toJson(copyResponse, false)) .isEqualTo(expectedToJson); } + + @Test + public void emptyOrInvalidCredentials() { + assertThat( + FetchPlanningResultResponseParser.fromJson( + "{\"status\": \"completed\",\"storage-credentials\": null}", + PARTITION_SPECS_BY_ID, + false) + .credentials()) + .isEmpty(); + + assertThat( + FetchPlanningResultResponseParser.fromJson( + "{\"status\": \"completed\",\"storage-credentials\": []}", + PARTITION_SPECS_BY_ID, + false) + .credentials()) + .isEmpty(); + + assertThatThrownBy( + () -> + FetchPlanningResultResponseParser.fromJson( + "{\"status\": \"completed\",\"storage-credentials\": \"invalid\"}", + PARTITION_SPECS_BY_ID, + false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse credentials from non-array: \"invalid\""); + } + + @Test + public void roundTripSerdeWithCredentials() { + List credentials = + ImmutableList.of( + ImmutableCredential.builder() + .prefix("s3://custom-uri") + .config( + ImmutableMap.of( + "s3.access-key-id", + "keyId", + "s3.secret-access-key", + "accessKey", + "s3.session-token", + "sessionToken")) + .build(), + ImmutableCredential.builder() + .prefix("gs://custom-uri") + .config( + ImmutableMap.of( + "gcs.oauth2.token", "gcsToken1", "gcs.oauth2.token-expires-at", "1000")) + .build(), + ImmutableCredential.builder() + .prefix("gs") + .config( + ImmutableMap.of( + "gcs.oauth2.token", "gcsToken2", "gcs.oauth2.token-expires-at", "2000")) + .build()); + + FetchPlanningResultResponse response = + FetchPlanningResultResponse.builder() + .withPlanStatus(PlanStatus.COMPLETED) + .withCredentials(credentials) + .build(); + + String expectedJson = + "{\n" + + " \"status\" : \"completed\",\n" + + " \"storage-credentials\" : [ {\n" + + " \"prefix\" : \"s3://custom-uri\",\n" + + " \"config\" : {\n" + + " \"s3.access-key-id\" : \"keyId\",\n" + + " \"s3.secret-access-key\" : \"accessKey\",\n" + + " \"s3.session-token\" : \"sessionToken\"\n" + + " }\n" + + " }, {\n" + + " \"prefix\" : \"gs://custom-uri\",\n" + + " \"config\" : {\n" + + " \"gcs.oauth2.token\" : \"gcsToken1\",\n" + + " \"gcs.oauth2.token-expires-at\" : \"1000\"\n" + + " }\n" + + " }, {\n" + + " \"prefix\" : \"gs\",\n" + + " \"config\" : {\n" + + " \"gcs.oauth2.token\" : \"gcsToken2\",\n" + + " \"gcs.oauth2.token-expires-at\" : \"2000\"\n" + + " }\n" + + " } ]\n" + + "}"; + + String json = FetchPlanningResultResponseParser.toJson(response, true); + assertThat(json).isEqualTo(expectedJson); + + FetchPlanningResultResponse fromResponse = + FetchPlanningResultResponseParser.fromJson(json, PARTITION_SPECS_BY_ID, false); + FetchPlanningResultResponse copyResponse = + FetchPlanningResultResponse.builder() + .withPlanStatus(fromResponse.planStatus()) + .withCredentials(credentials) + .build(); + + assertThat(FetchPlanningResultResponseParser.toJson(copyResponse, true)) + .isEqualTo(expectedJson); + } + + @Test + public void roundTripSerdeWithFailedStatusAndErrorResponse() { + ErrorResponse errorResponse = + ErrorResponse.builder() + .withMessage("Scan planning failed: table too large to plan") + .withType("IllegalStateException") + .responseCode(500) + .build(); + + FetchPlanningResultResponse response = + FetchPlanningResultResponse.builder() + .withPlanStatus(PlanStatus.FAILED) + .withErrorResponse(errorResponse) + .build(); + + String expectedJson = + "{\"status\":\"failed\"," + + "\"error\":{\"message\":\"Scan planning failed: table too large to plan\"," + + "\"type\":\"IllegalStateException\",\"code\":500}}"; + String json = FetchPlanningResultResponseParser.toJson(response); + assertThat(json).isEqualTo(expectedJson); + + FetchPlanningResultResponse fromResponse = + FetchPlanningResultResponseParser.fromJson(json, PARTITION_SPECS_BY_ID, false); + assertThat(fromResponse.planStatus()).isEqualTo(PlanStatus.FAILED); + assertThat(fromResponse.errorResponse()).isNotNull(); + assertThat(fromResponse.errorResponse().message()) + .isEqualTo("Scan planning failed: table too large to plan"); + assertThat(fromResponse.errorResponse().type()).isEqualTo("IllegalStateException"); + assertThat(fromResponse.errorResponse().code()).isEqualTo(500); + } + + @Test + public void parseFailedStatusWithoutErrorObject() { + // Spec requires an `error` object on failed responses, but parse leniently so + // a non-compliant server still surfaces the failure to the client. + String json = "{\"status\":\"failed\"}"; + FetchPlanningResultResponse response = + FetchPlanningResultResponseParser.fromJson(json, PARTITION_SPECS_BY_ID, false); + assertThat(response.planStatus()).isEqualTo(PlanStatus.FAILED); + assertThat(response.errorResponse()).isNull(); + } + + @Test + public void parseFailedStatusWithPrimitiveErrorField() { + String json = "{\"status\":\"failed\",\"error\":\"oops\"}"; + FetchPlanningResultResponse response = + FetchPlanningResultResponseParser.fromJson(json, PARTITION_SPECS_BY_ID, false); + assertThat(response.planStatus()).isEqualTo(PlanStatus.FAILED); + assertThat(response.errorResponse()).isNull(); + } + + @Test + public void cannotBuildWithErrorResponseWhenStatusIsNotFailed() { + ErrorResponse errorResponse = + ErrorResponse.builder().withMessage("boom").withType("X").responseCode(500).build(); + assertThatThrownBy( + () -> + FetchPlanningResultResponse.builder() + .withPlanStatus(PlanStatus.COMPLETED) + .withErrorResponse(errorResponse) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid response: error can only be returned in a 'failed' status"); + } } diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchScanTasksResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchScanTasksResponseParser.java index d7824bc6a673..7b44b3533a5b 100644 --- a/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchScanTasksResponseParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestFetchScanTasksResponseParser.java @@ -122,7 +122,6 @@ public void roundTripSerdeWithFileScanTasks() { FetchScanTasksResponse response = FetchScanTasksResponse.builder() .withFileScanTasks(List.of(fileScanTask)) - .withDeleteFiles(List.of(FILE_A_DELETES)) // assume you have set this already .withSpecsById(PARTITION_SPECS_BY_ID) .build(); @@ -149,7 +148,6 @@ public void roundTripSerdeWithFileScanTasks() { FetchScanTasksResponse copyResponse = FetchScanTasksResponse.builder() .withPlanTasks(fromResponse.planTasks()) - .withDeleteFiles(fromResponse.deleteFiles()) .withFileScanTasks(fromResponse.fileScanTasks()) .withSpecsById(PARTITION_SPECS_BY_ID) .build(); diff --git a/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java index e2c9f21dabba..6354e7bf246f 100644 --- a/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestPlanTableScanResponseParser.java @@ -238,7 +238,6 @@ public void roundTripSerdeWithValidStatusAndFileScanTasks() { PlanTableScanResponse.builder() .withPlanStatus(PlanStatus.COMPLETED) .withFileScanTasks(List.of(fileScanTask)) - .withDeleteFiles(List.of(FILE_A_DELETES)) .withSpecsById(PARTITION_SPECS_BY_ID) .build(); @@ -307,7 +306,6 @@ public void multipleTasksWithDifferentDeleteFilesDontAccumulateReferences() { PlanTableScanResponse.builder() .withPlanStatus(PlanStatus.COMPLETED) .withFileScanTasks(List.of(taskA, taskB, taskC)) - .withDeleteFiles(List.of(FILE_A_DELETES, FILE_B_DELETES, FILE_C2_DELETES)) .withSpecsById(PARTITION_SPECS_BY_ID) .build(); @@ -425,7 +423,6 @@ public void roundTripSerdeWithoutDeleteFiles() { .withPlanStatus(fromResponse.planStatus()) .withPlanId(fromResponse.planId()) .withPlanTasks(fromResponse.planTasks()) - .withDeleteFiles(fromResponse.deleteFiles()) .withFileScanTasks(fromResponse.fileScanTasks()) .withSpecsById(PARTITION_SPECS_BY_ID) .build(); @@ -578,7 +575,6 @@ public void roundTripSerdeWithValidStatusAndFileScanTasksAndCredentials() { PlanTableScanResponse.builder() .withPlanStatus(PlanStatus.COMPLETED) .withFileScanTasks(List.of(fileScanTask)) - .withDeleteFiles(List.of(FILE_A_DELETES)) .withSpecsById(PARTITION_SPECS_BY_ID) .withCredentials(credentials) .build(); @@ -645,7 +641,6 @@ public void roundTripSerdeWithValidStatusAndFileScanTasksAndCredentials() { .withPlanStatus(fromResponse.planStatus()) .withPlanId(fromResponse.planId()) .withPlanTasks(fromResponse.planTasks()) - .withDeleteFiles(fromResponse.deleteFiles()) .withFileScanTasks(fromResponse.fileScanTasks()) .withSpecsById(PARTITION_SPECS_BY_ID) .withCredentials(credentials) @@ -653,4 +648,72 @@ public void roundTripSerdeWithValidStatusAndFileScanTasksAndCredentials() { assertThat(PlanTableScanResponseParser.toJson(copyResponse, true)).isEqualTo(expectedJson); } + + @Test + public void roundTripSerdeWithFailedStatusAndErrorResponse() { + ErrorResponse errorResponse = + ErrorResponse.builder() + .withMessage("Scan planning failed: table too large to plan") + .withType("IllegalStateException") + .responseCode(500) + .build(); + + PlanTableScanResponse response = + PlanTableScanResponse.builder() + .withPlanStatus(PlanStatus.FAILED) + .withErrorResponse(errorResponse) + .withSpecsById(PARTITION_SPECS_BY_ID) + .build(); + + String expectedJson = + "{\"status\":\"failed\"," + + "\"error\":{\"message\":\"Scan planning failed: table too large to plan\"," + + "\"type\":\"IllegalStateException\",\"code\":500}}"; + String json = PlanTableScanResponseParser.toJson(response); + assertThat(json).isEqualTo(expectedJson); + + PlanTableScanResponse fromResponse = + PlanTableScanResponseParser.fromJson(json, PARTITION_SPECS_BY_ID, false); + assertThat(fromResponse.planStatus()).isEqualTo(PlanStatus.FAILED); + assertThat(fromResponse.errorResponse()).isNotNull(); + assertThat(fromResponse.errorResponse().message()) + .isEqualTo("Scan planning failed: table too large to plan"); + assertThat(fromResponse.errorResponse().type()).isEqualTo("IllegalStateException"); + assertThat(fromResponse.errorResponse().code()).isEqualTo(500); + } + + @Test + public void parseFailedStatusWithoutErrorObject() { + // Spec requires an `error` object on failed responses, but parse leniently so + // a non-compliant server still surfaces the failure to the client. + String json = "{\"status\":\"failed\"}"; + PlanTableScanResponse response = + PlanTableScanResponseParser.fromJson(json, PARTITION_SPECS_BY_ID, false); + assertThat(response.planStatus()).isEqualTo(PlanStatus.FAILED); + assertThat(response.errorResponse()).isNull(); + } + + @Test + public void parseFailedStatusWithPrimitiveErrorField() { + String json = "{\"status\":\"failed\",\"error\":\"oops\"}"; + PlanTableScanResponse response = + PlanTableScanResponseParser.fromJson(json, PARTITION_SPECS_BY_ID, false); + assertThat(response.planStatus()).isEqualTo(PlanStatus.FAILED); + assertThat(response.errorResponse()).isNull(); + } + + @Test + public void cannotBuildWithErrorResponseWhenStatusIsNotFailed() { + ErrorResponse errorResponse = + ErrorResponse.builder().withMessage("boom").withType("X").responseCode(500).build(); + assertThatThrownBy( + () -> + PlanTableScanResponse.builder() + .withPlanStatus(PlanStatus.COMPLETED) + .withErrorResponse(errorResponse) + .withSpecsById(PARTITION_SPECS_BY_ID) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid response: error can only be defined when status is 'failed'"); + } } diff --git a/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3SignResponseParser.java b/core/src/test/java/org/apache/iceberg/rest/responses/TestRemoteSignResponseParser.java similarity index 78% rename from aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3SignResponseParser.java rename to core/src/test/java/org/apache/iceberg/rest/responses/TestRemoteSignResponseParser.java index 19f2f540d765..b6d1178c3fa1 100644 --- a/aws/src/test/java/org/apache/iceberg/aws/s3/signer/TestS3SignResponseParser.java +++ b/core/src/test/java/org/apache/iceberg/rest/responses/TestRemoteSignResponseParser.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.aws.s3.signer; +package org.apache.iceberg.rest.responses; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -28,28 +28,28 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; -public class TestS3SignResponseParser { +public class TestRemoteSignResponseParser { @Test public void nullResponse() { - assertThatThrownBy(() -> S3SignResponseParser.fromJson((JsonNode) null)) + assertThatThrownBy(() -> RemoteSignResponseParser.fromJson((JsonNode) null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Cannot parse s3 sign response from null object"); + .hasMessage("Cannot parse remote sign response from null object"); - assertThatThrownBy(() -> S3SignResponseParser.toJson(null)) + assertThatThrownBy(() -> RemoteSignResponseParser.toJson(null)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Invalid s3 sign response: null"); + .hasMessage("Invalid remote sign response: null"); } @Test public void missingFields() { - assertThatThrownBy(() -> S3SignResponseParser.fromJson("{}")) + assertThatThrownBy(() -> RemoteSignResponseParser.fromJson("{}")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot parse missing string: uri"); assertThatThrownBy( () -> - S3SignResponseParser.fromJson( + RemoteSignResponseParser.fromJson( "{\"uri\" : \"http://localhost:49208/iceberg-signer-test\"}")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot parse missing field: headers"); @@ -57,15 +57,15 @@ public void missingFields() { @Test public void invalidUri() { - assertThatThrownBy(() -> S3SignResponseParser.fromJson("{\"uri\" : 45, \"headers\" : {}}}")) + assertThatThrownBy(() -> RemoteSignResponseParser.fromJson("{\"uri\" : 45, \"headers\" : {}}}")) .isInstanceOf(IllegalArgumentException.class) .hasMessage("Cannot parse to a string value: uri: 45"); } @Test public void roundTripSerde() { - S3SignResponse s3SignResponse = - ImmutableS3SignResponse.builder() + RemoteSignResponse response = + ImmutableRemoteSignResponse.builder() .uri(URI.create("http://localhost:49208/iceberg-signer-test")) .headers( ImmutableMap.of( @@ -79,8 +79,8 @@ public void roundTripSerde() { Arrays.asList("aws-sdk-java/2.20.18", "Linux/5.4.0-126"))) .build(); - String json = S3SignResponseParser.toJson(s3SignResponse, true); - assertThat(S3SignResponseParser.fromJson(json)).isEqualTo(s3SignResponse); + String json = RemoteSignResponseParser.toJson(response, true); + assertThat(RemoteSignResponseParser.fromJson(json)).isEqualTo(response); assertThat(json) .isEqualTo( "{\n" diff --git a/core/src/test/java/org/apache/iceberg/util/TestBinPacking.java b/core/src/test/java/org/apache/iceberg/util/TestBinPacking.java index f0cc6db1ad6e..213f099c8ad2 100644 --- a/core/src/test/java/org/apache/iceberg/util/TestBinPacking.java +++ b/core/src/test/java/org/apache/iceberg/util/TestBinPacking.java @@ -61,6 +61,37 @@ public void testBasicBinPacking() { .isEqualTo(list(list(1, 2, 3, 4, 5))); } + @Test + public void testBasicBinPackingTargetSize() { + assertThat(pack(list(1, 2, 3, 4, 5), 3, Integer.MAX_VALUE, 2)) + .as("Should pack the first 2 values") + .isEqualTo(list(list(1, 2), list(3), list(4), list(5))); + + assertThat(pack(list(1, 2, 3, 4, 5), 5, Integer.MAX_VALUE, 2)) + .as("Should pack the first 2 values") + .isEqualTo(list(list(1, 2), list(3), list(4), list(5))); + + assertThat(pack(list(1, 2, 3, 4, 5), 6, Integer.MAX_VALUE, 2)) + .as("Should pack the first 3 values") + .isEqualTo(list(list(1, 2), list(3), list(4), list(5))); + + assertThat(pack(list(1, 2, 3, 4, 5), 8, Integer.MAX_VALUE, 3)) + .as("Should pack the first 3 values") + .isEqualTo(list(list(1, 2, 3), list(4), list(5))); + + assertThat(pack(list(1, 2, 3, 4, 5), 9, Integer.MAX_VALUE, 3)) + .as("Should pack the first 3 values, last 2 values") + .isEqualTo(list(list(1, 2, 3), list(4, 5))); + + assertThat(pack(list(1, 2, 3, 4, 5), 10, Integer.MAX_VALUE, 3)) + .as("Should pack the first 3 values, last 2 values") + .isEqualTo(list(list(1, 2, 3), list(4, 5))); + + assertThat(pack(list(1, 2, 3, 4, 5), 14, Integer.MAX_VALUE, 3)) + .as("Should pack the first 3 values, last 2 values") + .isEqualTo(list(list(1, 2, 3), list(4, 5))); + } + @Test public void testReverseBinPackingSingleLookback() { assertThat(packEnd(list(1, 2, 3, 4, 5), 3, 1)) @@ -212,12 +243,27 @@ private List> pack(List items, long targetWeight) { } private List> pack(List items, long targetWeight, int lookback) { - return pack(items, targetWeight, lookback, false); + return pack(items, targetWeight, lookback, Long.MAX_VALUE); } private List> pack( List items, long targetWeight, int lookback, boolean largestBinFirst) { - ListPacker packer = new ListPacker<>(targetWeight, lookback, largestBinFirst); + return pack(items, targetWeight, lookback, largestBinFirst, Long.MAX_VALUE); + } + + private List> pack( + List items, long targetWeight, int lookback, long targetSize) { + return pack(items, targetWeight, lookback, false, targetSize); + } + + private List> pack( + List items, + long targetWeight, + int lookback, + boolean largestBinFirst, + long targetSize) { + ListPacker packer = + new ListPacker<>(targetWeight, lookback, largestBinFirst, targetSize); return packer.pack(items, Integer::longValue); } diff --git a/core/src/test/java/org/apache/iceberg/util/TestHashWriter.java b/core/src/test/java/org/apache/iceberg/util/TestHashWriter.java new file mode 100644 index 000000000000..a1c32e996054 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/util/TestHashWriter.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.fasterxml.jackson.core.JsonGenerator; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableMetadataParser; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +public class TestHashWriter { + + @Test + public void testIncrementalHashCalculation() throws Exception { + HashWriter hashWriter = spy(new HashWriter("SHA-256", StandardCharsets.UTF_8)); + + // Create large enough TableMetadata which will be serialized into JSON in multiple chunks by + // the JSON generator + Map icebergTblProperties = Maps.newHashMap(); + for (int i = 0; i < 300; ++i) { + icebergTblProperties.put("Property Key " + i, "Property Value " + i); + } + Schema schema = new Schema(Types.NestedField.required(1, "col1", Types.StringType.get())); + TableMetadata tableMetadata = + TableMetadata.newTableMetadata( + schema, PartitionSpec.unpartitioned(), null, icebergTblProperties); + + JsonGenerator generator = JsonUtil.factory().createGenerator(hashWriter); + TableMetadataParser.toJson(tableMetadata, generator); + + // Expecting to see 3 write() invocations (and therefore incremental hash calculations) + verify(hashWriter, times(3)).write(any(char[].class), anyInt(), anyInt()); + + // +1 after flushing + generator.flush(); + verify(hashWriter, times(4)).write(any(char[].class), anyInt(), anyInt()); + + // Expected hash is calculated on the whole object i.e. without streaming + byte[] expectedHash = + MessageDigest.getInstance("SHA-256") + .digest(TableMetadataParser.toJson(tableMetadata).getBytes(StandardCharsets.UTF_8)); + assertThat(hashWriter.getHash()).isEqualTo(expectedHash); + assertThatThrownBy(() -> hashWriter.getHash()).hasMessageContaining("HashWriter is closed."); + } +} diff --git a/core/src/test/java/org/apache/iceberg/util/TestPropertyUtil.java b/core/src/test/java/org/apache/iceberg/util/TestPropertyUtil.java new file mode 100644 index 000000000000..3925b38a5970 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/util/TestPropertyUtil.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +public class TestPropertyUtil { + + @Test + void propertiesWithPrefixHandlesRegexSpecialChars() { + // prefix[0]. is a valid regex where [0] matches char '0' and . matches any char, + // so replaceFirst (used by the older propertiesWithPrefix) would silently fail to strip the + // literal prefix "prefix[0]." + Map properties = Map.of("prefix[0].key", "value"); + + Map result = PropertyUtil.propertiesWithPrefix(properties, "prefix[0]."); + + assertThat(result).containsExactly(Map.entry("key", "value")); + } + + @Test + void propertiesWithPrefixHandlesUnclosedRegexChars() { + // prefix[0. contains an unclosed character class which would cause + // PatternSyntaxException with replaceFirst (used by the older propertiesWithPrefix) but works + // correctly with substring + Map properties = Map.of("prefix[0.key", "value"); + + Map result = PropertyUtil.propertiesWithPrefix(properties, "prefix[0."); + + assertThat(result).containsExactly(Map.entry("key", "value")); + } + + @Test + void mergeProperties() { + Map properties = Map.of("k1", "v1", "k2", "v2"); + Map overrides = Map.of("k1", "v11", "k3", "v3"); + + assertThat(PropertyUtil.mergeProperties(null, null)).isNull(); + assertThat(PropertyUtil.mergeProperties(properties, null)).isEqualTo(properties); + assertThat(PropertyUtil.mergeProperties(properties, Map.of())).isEqualTo(properties); + assertThat(PropertyUtil.mergeProperties(null, overrides)).isEqualTo(overrides); + assertThat(PropertyUtil.mergeProperties(Map.of(), overrides)).isEqualTo(overrides); + assertThat(PropertyUtil.mergeProperties(properties, overrides)) + .containsExactlyInAnyOrderEntriesOf(Map.of("k1", "v11", "k2", "v2", "k3", "v3")); + } +} diff --git a/core/src/test/java/org/apache/iceberg/util/TestSnapshotUtil.java b/core/src/test/java/org/apache/iceberg/util/TestSnapshotUtil.java index 37d0953ab85d..96b56e5ffb67 100644 --- a/core/src/test/java/org/apache/iceberg/util/TestSnapshotUtil.java +++ b/core/src/test/java/org/apache/iceberg/util/TestSnapshotUtil.java @@ -21,6 +21,7 @@ import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.util.Iterator; @@ -28,10 +29,13 @@ import java.util.stream.StreamSupport; import org.apache.iceberg.DataFile; import org.apache.iceberg.DataFiles; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; import org.apache.iceberg.TestHelpers; import org.apache.iceberg.TestTables; import org.apache.iceberg.types.Types; @@ -248,4 +252,69 @@ public void schemaForTag() { assertThat(table.schema().asStruct()).isEqualTo(expected.asStruct()); assertThat(SnapshotUtil.schemaFor(table, tag).asStruct()).isEqualTo(initialSchema.asStruct()); } + + @Test + public void schemaForSnapshotId() { + Schema initialSchema = + new Schema( + required(1, "id", Types.IntegerType.get()), + required(2, "data", Types.StringType.get())); + assertThat(table.schema().asStruct()).isEqualTo(initialSchema.asStruct()); + + long firstSnapshotId = table.currentSnapshot().snapshotId(); + assertThat(SnapshotUtil.schemaFor(table, firstSnapshotId).asStruct()) + .isEqualTo(initialSchema.asStruct()); + + table.updateSchema().addColumn("zip", Types.IntegerType.get()).commit(); + appendFileToMain(); + + Schema updatedSchema = + new Schema( + required(1, "id", Types.IntegerType.get()), + required(2, "data", Types.StringType.get()), + optional(3, "zip", Types.IntegerType.get())); + + long secondSnapshotId = table.currentSnapshot().snapshotId(); + + assertThat(SnapshotUtil.schemaFor(table, firstSnapshotId).asStruct()) + .isEqualTo(initialSchema.asStruct()); + assertThat(SnapshotUtil.schemaFor(table, secondSnapshotId).asStruct()) + .isEqualTo(updatedSchema.asStruct()); + } + + @Test + public void schemaForSnapshotIdInvalidSnapshot() { + long invalidSnapshotId = 999999L; + assertThat(table.snapshot(invalidSnapshotId)).isNull(); + + assertThatThrownBy(() -> SnapshotUtil.schemaFor(table, invalidSnapshotId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot find snapshot with ID " + invalidSnapshotId); + } + + @Test + public void schemaForSnapshotIdMetadataTable() { + long firstSnapshotId = table.currentSnapshot().snapshotId(); + + table.updateSchema().addColumn("zip", Types.IntegerType.get()).commit(); + appendFileToMain(); + + long secondSnapshotId = table.currentSnapshot().snapshotId(); + + Schema updatedSchema = + new Schema( + required(1, "id", Types.IntegerType.get()), + required(2, "data", Types.StringType.get()), + optional(3, "zip", Types.IntegerType.get())); + + assertThat(table.schema().asStruct()).isEqualTo(updatedSchema.asStruct()); + + Table snapshotsTable = + MetadataTableUtils.createMetadataTableInstance(table, MetadataTableType.SNAPSHOTS); + + assertThat(SnapshotUtil.schemaFor(snapshotsTable, secondSnapshotId).asStruct()) + .isEqualTo(snapshotsTable.schema().asStruct()); + assertThat(SnapshotUtil.schemaFor(snapshotsTable, firstSnapshotId).asStruct()) + .isEqualTo(snapshotsTable.schema().asStruct()); + } } diff --git a/core/src/test/java/org/apache/iceberg/util/TestSortOrderUtil.java b/core/src/test/java/org/apache/iceberg/util/TestSortOrderUtil.java index 02c81de93222..7ac7d72a2f64 100644 --- a/core/src/test/java/org/apache/iceberg/util/TestSortOrderUtil.java +++ b/core/src/test/java/org/apache/iceberg/util/TestSortOrderUtil.java @@ -287,4 +287,68 @@ public void testSortOrderClusteringWithRedundantPartitionFieldsMissing() { .as("Should add spec fields as prefix") .isEqualTo(expected); } + + @Test + public void testFindSortOrderForTable() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder order = SortOrder.builderFor(SCHEMA).withOrderId(1).asc("id", NULLS_LAST).build(); + TestTables.TestTable table = TestTables.create(tableDir, "test", SCHEMA, spec, order, 2); + + SortOrder tableSortOrder = table.sortOrder(); + + SortOrder actualOrder = SortOrderUtil.findTableSortOrder(table, tableSortOrder); + + assertThat(actualOrder).as("Should find current table sort order").isEqualTo(table.sortOrder()); + } + + @Test + public void testFindSortOrderForTableWithoutFieldId() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder order = SortOrder.builderFor(SCHEMA).withOrderId(1).asc("id", NULLS_LAST).build(); + TestTables.TestTable table = TestTables.create(tableDir, "test", SCHEMA, spec, order, 2); + + SortOrder userSuppliedOrder = + SortOrder.builderFor(table.schema()).asc("id", NULLS_LAST).build(); + + SortOrder actualOrder = SortOrderUtil.findTableSortOrder(table, userSuppliedOrder); + + assertThat(actualOrder).as("Should find current table sort order").isEqualTo(table.sortOrder()); + } + + @Test + public void testFindSortOrderForTableThatIsNotCurrentOrder() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder order = SortOrder.builderFor(SCHEMA).withOrderId(1).asc("id", NULLS_LAST).build(); + TestTables.TestTable table = TestTables.create(tableDir, "test", SCHEMA, spec, order, 2); + + table.replaceSortOrder().asc("data").desc("ts").commit(); + + SortOrder userSuppliedOrder = + SortOrder.builderFor(table.schema()).asc("id", NULLS_LAST).build(); + + SortOrder actualOrder = SortOrderUtil.findTableSortOrder(table, userSuppliedOrder); + + assertThat(actualOrder) + .as("Should find first sorted table sort order") + .isEqualTo(table.sortOrders().get(1)); + } + + @Test + public void testReturnsUnsortedForMissingSortOrder() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder order = SortOrder.builderFor(SCHEMA).withOrderId(1).asc("id", NULLS_LAST).build(); + TestTables.TestTable table = TestTables.create(tableDir, "test", SCHEMA, spec, order, 2); + + table.replaceSortOrder().asc("data").desc("ts").commit(); + + SortOrder userSuppliedOrder = + SortOrder.builderFor(table.schema()).desc("id", NULLS_LAST).build(); + + SortOrder actualOrder = SortOrderUtil.findTableSortOrder(table, userSuppliedOrder); + + assertThat(actualOrder) + .as( + "Should return unsorted order if user supplied order does not match any table sort order") + .isEqualTo(SortOrder.unsorted()); + } } diff --git a/core/src/test/java/org/apache/iceberg/util/TestStructLikeWrapper.java b/core/src/test/java/org/apache/iceberg/util/TestStructLikeWrapper.java new file mode 100644 index 000000000000..9eaa45c85a48 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/util/TestStructLikeWrapper.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +public class TestStructLikeWrapper { + @Test + public void equalsTypeAndDataMismatch() { + Types.StructType intType = + Types.StructType.of(Types.NestedField.required(1, "a", Types.IntegerType.get())); + Types.StructType stringType = + Types.StructType.of(Types.NestedField.required(1, "a", Types.StringType.get())); + + PartitionData intData = new PartitionData(intType); + intData.set(0, 1); + + PartitionData stringData = new PartitionData(stringType); + stringData.set(0, "test"); + + StructLikeWrapper integerStruct = StructLikeWrapper.forType(intType).set(intData); + StructLikeWrapper stringStruct = StructLikeWrapper.forType(stringType).set(stringData); + + // StructLikeWrapper.equals previously threw an exception when the type and data mismatch + assertThat(integerStruct).isNotEqualTo(stringStruct); + } +} diff --git a/core/src/test/java/org/apache/iceberg/variants/TestShreddedObject.java b/core/src/test/java/org/apache/iceberg/variants/TestShreddedObject.java index 1ebb433a5423..cea1c6922c09 100644 --- a/core/src/test/java/org/apache/iceberg/variants/TestShreddedObject.java +++ b/core/src/test/java/org/apache/iceberg/variants/TestShreddedObject.java @@ -213,6 +213,28 @@ public void testPartiallyShreddedObjectSerializationMinimalBuffer() { .isEqualTo(DateTimeUtil.isoDateToDays("2024-10-12")); } + @Test + public void testPartiallyShreddedUnserializedObjectSerializationMinimalBuffer() { + ShreddedObject partial = createUnserializedObject(FIELDS); + VariantMetadata metadata = partial.metadata(); + + // replace field c with a new value + partial.put("c", Variants.ofIsoDate("2024-10-12")); + partial.remove("b"); + + VariantValue value = roundTripMinimalBuffer(partial, metadata); + + assertThat(value).isInstanceOf(SerializedObject.class); + SerializedObject actual = (SerializedObject) value; + + assertThat(actual.get("a")).isInstanceOf(VariantPrimitive.class); + assertThat(actual.get("a").asPrimitive().get()).isEqualTo(34); + assertThat(actual.get("c")).isInstanceOf(VariantPrimitive.class); + assertThat(actual.get("c").type()).isEqualTo(PhysicalType.DATE); + assertThat(actual.get("c").asPrimitive().get()) + .isEqualTo(DateTimeUtil.isoDateToDays("2024-10-12")); + } + @Test public void testPartiallyShreddedObjectSerializationLargeBuffer() { ShreddedObject partial = createUnshreddedObject(FIELDS); @@ -235,6 +257,29 @@ public void testPartiallyShreddedObjectSerializationLargeBuffer() { .isEqualTo(DateTimeUtil.isoDateToDays("2024-10-12")); } + @Test + public void testRemoveInvalidatesSerializationState() { + ShreddedObject object = createShreddedObject(FIELDS); + VariantMetadata metadata = object.metadata(); + + // warm the serialization cache + VariantValue first = roundTripMinimalBuffer(object, metadata); + assertThat(first).isInstanceOf(SerializedObject.class); + assertThat(((SerializedObject) first).numFields()).isEqualTo(3); + + // remove a field after caching + object.remove("b"); + assertThat(object.get("b")).as("removed field should be hidden from reads").isNull(); + assertThat(object.numFields()).as("numFields should reflect removal").isEqualTo(2); + + // re-serialize: should not include the removed field + VariantValue second = roundTripMinimalBuffer(object, metadata); + assertThat(second).isInstanceOf(SerializedObject.class); + SerializedObject actual = (SerializedObject) second; + assertThat(actual.numFields()).isEqualTo(2); + assertThat(actual.get("b")).as("removed field must not be serialized").isNull(); + } + @ParameterizedTest @ValueSource(ints = {300, 70_000, 16_777_300}) public void testMultiByteOffsets(int len) { @@ -381,6 +426,12 @@ private static ShreddedObject createShreddedObject( return object; } + private static ShreddedObject createUnserializedObject(Map fields) { + ByteBuffer metadataBuffer = VariantTestUtil.createMetadata(fields.keySet(), false); + VariantMetadata metadata = SerializedMetadata.from(metadataBuffer); + return new ShreddedObject(metadata, createShreddedObject(metadata, fields)); + } + /** Creates a ShreddedObject with fields in its shredded map */ private static ShreddedObject createShreddedObject(Map fields) { ByteBuffer metadataBuffer = VariantTestUtil.createMetadata(fields.keySet(), false); diff --git a/core/src/test/java/org/apache/iceberg/variants/VariantTestHelper.java b/core/src/test/java/org/apache/iceberg/variants/VariantTestHelper.java new file mode 100644 index 000000000000..090cbe555174 --- /dev/null +++ b/core/src/test/java/org/apache/iceberg/variants/VariantTestHelper.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.variants; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; + +public final class VariantTestHelper { + + private VariantTestHelper() {} + + public static final VariantPrimitive[] PRIMITIVES = + new VariantPrimitive[] { + Variants.ofNull(), + Variants.of(true), + Variants.of(false), + Variants.of((byte) 34), + Variants.of((byte) -34), + Variants.of((short) 1234), + Variants.of((short) -1234), + Variants.of(12345), + Variants.of(-12345), + Variants.of(9876543210L), + Variants.of(-9876543210L), + Variants.of(10.11F), + Variants.of(-10.11F), + Variants.of(14.3D), + Variants.of(-14.3D), + Variants.ofIsoDate("2024-11-07"), + Variants.ofIsoDate("1957-11-07"), + Variants.ofIsoTimestamptz("2024-11-07T12:33:54.123456+00:00"), + Variants.ofIsoTimestamptz("1957-11-07T12:33:54.123456+00:00"), + Variants.ofIsoTimestampntz("2024-11-07T12:33:54.123456"), + Variants.ofIsoTimestampntz("1957-11-07T12:33:54.123456"), + Variants.of(new BigDecimal("12345.6789")), // decimal4 + Variants.of(new BigDecimal("-12345.6789")), // decimal4 + Variants.of(new BigDecimal("123456789.987654321")), // decimal8 + Variants.of(new BigDecimal("-123456789.987654321")), // decimal8 + Variants.of(new BigDecimal("9876543210.123456789")), // decimal16 + Variants.of(new BigDecimal("-9876543210.123456789")), // decimal16 + Variants.of(ByteBuffer.wrap(new byte[] {0x0a, 0x0b, 0x0c, 0x0d})), + Variants.of("iceberg"), + Variants.ofUUID("f24f9b64-81fa-49d1-b74e-8c09a6e31c56"), + }; + + public static final VariantPrimitive[] UNSUPPORTED_PRIMITIVES = + new VariantPrimitive[] { + Variants.ofIsoTime("12:33:54.123456"), + Variants.ofIsoTimestamptzNanos("2024-11-07T12:33:54.123456789+00:00"), + Variants.ofIsoTimestampntzNanos("2024-11-07T12:33:54.123456789"), + }; + + /** + * Tests round-trip conversion of all primitive variant types. + * + * @param testFunction the engine-specific test function + * @param primitive the primitive variant value to test + */ + public static void testVariantPrimitiveRoundTrip( + VariantTestFunction testFunction, VariantPrimitive primitive) { + testFunction.test(Variants.emptyMetadata(), primitive); + } + + /** + * Tests round-trip conversion of array variants. + * + * @param testFunction the engine-specific test function + */ + public static void testVariantArrayRoundTrip(VariantTestFunction testFunction) { + VariantMetadata metadata = Variants.emptyMetadata(); + ValueArray array = Variants.array(); + array.add(Variants.of("hello")); + array.add(Variants.of((byte) 42)); + array.add(Variants.ofNull()); + + testFunction.test(metadata, array); + } + + /** + * Tests round-trip conversion of object variants. + * + * @param testFunction the engine-specific test function + */ + public static void testVariantObjectRoundTrip(VariantTestFunction testFunction) { + VariantMetadata metadata = Variants.metadata("name", "age", "active"); + ShreddedObject object = Variants.object(metadata); + object.put("name", Variants.of("John Doe")); + object.put("age", Variants.of((byte) 30)); + object.put("active", Variants.of(true)); + + testFunction.test(metadata, object); + } + + /** + * Tests round-trip conversion of nested variant structures. + * + * @param testFunction the engine-specific test function + */ + public static void testVariantNestedStructures(VariantTestFunction testFunction) { + VariantMetadata metadata = Variants.metadata("user", "scores", "address", "city", "state"); + + // Create nested object: address + ShreddedObject address = Variants.object(metadata); + address.put("city", Variants.of("Anytown")); + address.put("state", Variants.of("CA")); + + // Create array of scores + ValueArray scores = Variants.array(); + scores.add(Variants.of((byte) 95)); + scores.add(Variants.of((byte) 87)); + scores.add(Variants.of((byte) 92)); + + // Create main object + ShreddedObject mainObject = Variants.object(metadata); + mainObject.put("user", Variants.of("Jane")); + mainObject.put("scores", scores); + mainObject.put("address", address); + + testFunction.test(metadata, mainObject); + } + + @FunctionalInterface + public interface VariantTestFunction { + void test(VariantMetadata metadata, VariantValue value); + } +} diff --git a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java index fdf4138f3ce5..00926ca73cc7 100644 --- a/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java +++ b/core/src/test/java/org/apache/iceberg/view/ViewCatalogTests.java @@ -18,6 +18,7 @@ */ package org.apache.iceberg.view; +import static org.apache.iceberg.TableProperties.GC_ENABLED; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -25,7 +26,10 @@ import java.nio.file.Path; import java.util.UUID; +import org.apache.iceberg.BaseTable; import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; import org.apache.iceberg.Transaction; import org.apache.iceberg.UpdateLocation; import org.apache.iceberg.catalog.Catalog; @@ -1968,4 +1972,171 @@ public void dropNonEmptyNamespace() { .as("Namespace should not exist") .isFalse(); } + + @Test + public void registerTableThatAlreadyExistsAsView() { + TableIdentifier identifier = TableIdentifier.of("ns", "tbl"); + + if (requiresNamespaceCreate()) { + catalog().createNamespace(identifier.namespace()); + } + + tableCatalog().createTable(identifier, SCHEMA); + assertThat(tableCatalog().tableExists(identifier)).as("Table should exist").isTrue(); + Table table = tableCatalog().loadTable(identifier); + TableOperations ops = ((BaseTable) table).operations(); + String metadataLocation = ops.current().metadataFileLocation(); + + // don't purge the metadata + tableCatalog().dropTable(identifier, false); + assertThat(tableCatalog().tableExists(identifier)).as("Table should not exist").isFalse(); + + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withQuery("spark", "select * from ns.tbl") + .create(); + + assertThat(catalog().viewExists(identifier)).as("View should exist").isTrue(); + + assertThatThrownBy(() -> tableCatalog().registerTable(identifier, metadataLocation)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("View with same name already exists: %s", identifier); + + assertThat(tableCatalog().tableExists(identifier)).as("Table should not exist").isFalse(); + assertThat(catalog().dropView(identifier)).isTrue(); + } + + @Test + public void registerView() { + C catalog = catalog(); + + TableIdentifier identifier = TableIdentifier.of("ns", "view"); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(identifier.namespace()); + } + + View originalView = + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withDefaultCatalog(catalog().name()) + .withQuery("spark", "select * from ns.tbl") + .withProperty(GC_ENABLED, "false") // don't delete metadata when dropping the view + .create(); + + ViewOperations ops = ((BaseView) originalView).operations(); + String metadataLocation = ops.current().metadataFileLocation(); + + assertThat(catalog.dropView(identifier)).isTrue(); + assertThat(catalog.viewExists(identifier)).as("View must not exist").isFalse(); + + // view metadata should still exist after dropping the view as gc is disabled + if (ops instanceof BaseViewOperations) { + assertThat(((BaseViewOperations) ops).io().newInputFile(metadataLocation).exists()).isTrue(); + } + + View registeredView = catalog.registerView(identifier, metadataLocation); + + assertThat(registeredView).isNotNull(); + assertThat(catalog.viewExists(identifier)).as("View must exist").isTrue(); + assertThat(registeredView.name()) + .isEqualTo(ViewUtil.fullViewName(catalog().name(), identifier)); + assertThat(registeredView.history()) + .hasSize(1) + .first() + .extracting(ViewHistoryEntry::versionId) + .isEqualTo(1); + assertThat(registeredView.schemas()).hasSize(1).containsKey(0); + assertThat(registeredView.schema().asStruct()).isEqualTo(SCHEMA.asStruct()); + assertThat(registeredView.currentVersion().operation()).isEqualTo("create"); + assertThat(registeredView.versions()) + .hasSize(1) + .containsExactly(registeredView.currentVersion()); + assertThat(registeredView.versions()).isEqualTo(originalView.versions()); + assertThat(registeredView.currentVersion()) + .isEqualTo( + ImmutableViewVersion.builder() + .timestampMillis(registeredView.currentVersion().timestampMillis()) + .versionId(1) + .schemaId(0) + .summary(registeredView.currentVersion().summary()) + .defaultNamespace(identifier.namespace()) + .defaultCatalog(catalog().name()) + .addRepresentations( + ImmutableSQLViewRepresentation.builder() + .sql("select * from ns.tbl") + .dialect("spark") + .build()) + .build()); + + assertThat(catalog.loadView(identifier)).isNotNull(); + assertThat(catalog.dropView(identifier)).isTrue(); + assertThat(catalog.viewExists(identifier)).isFalse(); + } + + @Test + public void registerExistingView() { + C catalog = catalog(); + + TableIdentifier identifier = TableIdentifier.of("ns", "view"); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(identifier.namespace()); + } + + View view = + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withDefaultCatalog(catalog().name()) + .withQuery("spark", "select * from ns.tbl") + .create(); + + ViewOperations ops = ((BaseView) view).operations(); + String metadataLocation = ops.current().metadataFileLocation(); + + assertThatThrownBy(() -> catalog.registerView(identifier, metadataLocation)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("View already exists: ns.view"); + assertThat(catalog.dropView(identifier)).isTrue(); + } + + @Test + public void registerViewThatAlreadyExistsAsTable() { + C catalog = catalog(); + + TableIdentifier identifier = TableIdentifier.of("ns", "view"); + + if (requiresNamespaceCreate()) { + catalog.createNamespace(identifier.namespace()); + } + + View view = + catalog() + .buildView(identifier) + .withSchema(SCHEMA) + .withDefaultNamespace(identifier.namespace()) + .withDefaultCatalog(catalog().name()) + .withQuery("spark", "select * from ns.tbl") + .withProperty(GC_ENABLED, "false") // don't delete metadata when dropping the view + .create(); + + ViewOperations ops = ((BaseView) view).operations(); + String metadataLocation = ops.current().metadataFileLocation(); + assertThat(catalog.dropView(identifier)).isTrue(); + + // create a table with the same name as the view + tableCatalog().createTable(identifier, SCHEMA); + + assertThatThrownBy(() -> catalog.registerView(identifier, metadataLocation)) + .isInstanceOf(AlreadyExistsException.class) + .hasMessageStartingWith("Table with same name already exists: ns.view"); + + assertThat(tableCatalog().dropTable(identifier)).isTrue(); + } } diff --git a/data/src/jmh/java/org/apache/iceberg/PartitionStatsHandlerBenchmark.java b/data/src/jmh/java/org/apache/iceberg/PartitionStatsHandlerBenchmark.java index 938dc2863784..1995e8d02d28 100644 --- a/data/src/jmh/java/org/apache/iceberg/PartitionStatsHandlerBenchmark.java +++ b/data/src/jmh/java/org/apache/iceberg/PartitionStatsHandlerBenchmark.java @@ -99,13 +99,14 @@ public void tearDownBenchmark() { @Benchmark @Threads(1) public void benchmarkPartitionStats() throws IOException { - PartitionStatisticsFile statisticsFile = PartitionStatsHandler.computeAndWriteStatsFile(table); - - List stats; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - PartitionStatsHandler.schema(Partitioning.partitionType(table), 2), - Files.localInput(statisticsFile.path()))) { + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List stats; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { stats = Lists.newArrayList(recordIterator); } diff --git a/data/src/main/java/org/apache/iceberg/data/BaseDeleteLoader.java b/data/src/main/java/org/apache/iceberg/data/BaseDeleteLoader.java index 99f5c742d37c..02b06b70e483 100644 --- a/data/src/main/java/org/apache/iceberg/data/BaseDeleteLoader.java +++ b/data/src/main/java/org/apache/iceberg/data/BaseDeleteLoader.java @@ -30,36 +30,26 @@ import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; import org.apache.iceberg.StructLike; -import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.avro.PlannedDataReader; -import org.apache.iceberg.data.orc.GenericOrcReader; -import org.apache.iceberg.data.parquet.GenericParquetReaders; import org.apache.iceberg.deletes.Deletes; import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.deletes.PositionDeleteIndexUtil; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.DeleteSchemaUtil; +import org.apache.iceberg.io.IOUtil; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.io.RangeReadable; -import org.apache.iceberg.io.SeekableInputStream; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.orc.OrcRowReader; -import org.apache.iceberg.parquet.Parquet; -import org.apache.iceberg.parquet.ParquetValueReader; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; -import org.apache.iceberg.relocated.com.google.common.io.ByteStreams; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.CharSequenceMap; import org.apache.iceberg.util.ContentFileUtil; import org.apache.iceberg.util.StructLikeSet; import org.apache.iceberg.util.Tasks; import org.apache.iceberg.util.ThreadPools; -import org.apache.orc.TypeDescription; -import org.apache.parquet.schema.MessageType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -183,8 +173,13 @@ private PositionDeleteIndex readDV(DeleteFile dv) { InputFile inputFile = loadInputFile.apply(dv); long offset = dv.contentOffset(); int length = dv.contentSizeInBytes().intValue(); - byte[] bytes = readBytes(inputFile, offset, length); - return PositionDeleteIndex.deserialize(bytes, dv); + byte[] bytes = new byte[length]; + try { + IOUtil.readFully(inputFile, offset, bytes, 0, length); + return PositionDeleteIndex.deserialize(bytes, dv); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } private PositionDeleteIndex getOrReadPosDeletes( @@ -229,44 +224,9 @@ private CloseableIterable openDeletes( LOG.trace("Opening delete file {}", deleteFile.location()); InputFile inputFile = loadInputFile.apply(deleteFile); - switch (format) { - case AVRO: - return Avro.read(inputFile) - .project(projection) - .reuseContainers() - .createResolvingReader(PlannedDataReader::create) - .build(); - - case PARQUET: - return Parquet.read(inputFile) - .project(projection) - .filter(filter) - .reuseContainers() - .createReaderFunc(newParquetReaderFunc(projection)) - .build(); - - case ORC: - // reusing containers is automatic for ORC, no need to call 'reuseContainers' - return ORC.read(inputFile) - .project(projection) - .filter(filter) - .createReaderFunc(newOrcReaderFunc(projection)) - .build(); - - default: - throw new UnsupportedOperationException( - String.format( - "Cannot read deletes, %s is not a supported file format: %s", - format.name(), inputFile.location())); - } - } - - private Function> newParquetReaderFunc(Schema projection) { - return fileSchema -> GenericParquetReaders.buildReader(projection, fileSchema); - } - - private Function> newOrcReaderFunc(Schema projection) { - return fileSchema -> GenericOrcReader.buildReader(projection, fileSchema); + ReadBuilder builder = + FormatModelRegistry.readBuilder(format, Record.class, inputFile); + return builder.project(projection).reuseContainers().filter(filter).build(); } private Iterable execute(Iterable objects, Function func) { @@ -322,22 +282,4 @@ private void validateDV(DeleteFile dv, CharSequence filePath) { filePath, dv.referencedDataFile()); } - - private static byte[] readBytes(InputFile inputFile, long offset, int length) { - try (SeekableInputStream stream = inputFile.newStream()) { - byte[] bytes = new byte[length]; - - if (stream instanceof RangeReadable) { - RangeReadable rangeReadable = (RangeReadable) stream; - rangeReadable.readFully(offset, bytes); - } else { - stream.seek(offset); - ByteStreams.readFully(stream, bytes); - } - - return bytes; - } catch (IOException e) { - throw new UncheckedIOException(e); - } - } } diff --git a/data/src/main/java/org/apache/iceberg/data/BaseFileWriterFactory.java b/data/src/main/java/org/apache/iceberg/data/BaseFileWriterFactory.java index 55f3b5701e0b..444c0d0226bd 100644 --- a/data/src/main/java/org/apache/iceberg/data/BaseFileWriterFactory.java +++ b/data/src/main/java/org/apache/iceberg/data/BaseFileWriterFactory.java @@ -40,7 +40,13 @@ import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -/** A base writer factory to be extended by query engine integrations. */ +/** + * A base writer factory to be extended by query engine integrations. + * + * @deprecated since version 1.11.0 and will be removed in 1.12.0. Use {@link + * RegistryBasedFileWriterFactory} + */ +@Deprecated public abstract class BaseFileWriterFactory implements FileWriterFactory, Serializable { private final Table table; private final FileFormat dataFileFormat; @@ -75,13 +81,6 @@ protected BaseFileWriterFactory( this.positionDeleteRowSchema = null; } - /** - * @deprecated This constructor is deprecated as of version 1.11.0 and will be removed in 1.12.0. - * Position deletes that include row data are no longer supported. Use {@link - * #BaseFileWriterFactory(Table, FileFormat, Schema, SortOrder, FileFormat, int[], Schema, - * SortOrder, Map)} instead. - */ - @Deprecated protected BaseFileWriterFactory( Table table, FileFormat dataFileFormat, diff --git a/data/src/main/java/org/apache/iceberg/data/DeleteFilter.java b/data/src/main/java/org/apache/iceberg/data/DeleteFilter.java index 797e6d6408cc..69236cee8f9b 100644 --- a/data/src/main/java/org/apache/iceberg/data/DeleteFilter.java +++ b/data/src/main/java/org/apache/iceberg/data/DeleteFilter.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.function.Predicate; import org.apache.iceberg.Accessor; import org.apache.iceberg.DeleteFile; @@ -72,6 +73,16 @@ protected DeleteFilter( Schema expectedSchema, DeleteCounter counter, boolean needRowPosCol) { + this(filePath, deletes, tableSchema::findField, expectedSchema, counter, needRowPosCol); + } + + protected DeleteFilter( + String filePath, + List deletes, + Function fieldLookup, + Schema expectedSchema, + DeleteCounter counter, + boolean needRowPosCol) { this.filePath = filePath; this.counter = counter; this.expectedSchema = expectedSchema; @@ -97,7 +108,7 @@ protected DeleteFilter( this.posDeletes = posDeleteBuilder.build(); this.eqDeletes = eqDeleteBuilder.build(); this.requiredSchema = - fileProjection(tableSchema, expectedSchema, posDeletes, eqDeletes, needRowPosCol); + fileProjection(fieldLookup, expectedSchema, posDeletes, eqDeletes, needRowPosCol); this.posAccessor = requiredSchema.accessorForField(MetadataColumns.ROW_POSITION.fieldId()); this.hasIsDeletedColumn = requiredSchema.findField(MetadataColumns.IS_DELETED.fieldId()) != null; @@ -110,7 +121,7 @@ protected DeleteFilter( Schema tableSchema, Schema requestedSchema, DeleteCounter counter) { - this(filePath, deletes, tableSchema, requestedSchema, counter, true); + this(filePath, deletes, tableSchema::findField, requestedSchema, counter, true); } protected DeleteFilter( @@ -199,7 +210,7 @@ private List> applyEqDeletes() { Set ids = entry.getKey(); Iterable deletes = entry.getValue(); - Schema deleteSchema = TypeUtil.select(requiredSchema, ids); + Schema deleteSchema = TypeUtil.selectInIdOrder(requiredSchema, ids); // a projection to select and reorder fields of the file schema to match the delete rows StructProjection projectRow = StructProjection.create(requiredSchema, deleteSchema); @@ -265,7 +276,7 @@ private CloseableIterable createDeleteIterable( } private static Schema fileProjection( - Schema tableSchema, + Function fieldLookup, Schema requestedSchema, List posDeletes, List eqDeletes, @@ -300,7 +311,7 @@ private static Schema fileProjection( continue; // add _pos and _deleted at the end } - Types.NestedField field = tableSchema.asStruct().field(fieldId); + Types.NestedField field = fieldLookup.apply(fieldId); Preconditions.checkArgument(field != null, "Cannot find required field for ID %s", fieldId); columns.add(field); diff --git a/data/src/main/java/org/apache/iceberg/data/GenericFileWriterFactory.java b/data/src/main/java/org/apache/iceberg/data/GenericFileWriterFactory.java index e6872cc6e136..1e75b9eda961 100644 --- a/data/src/main/java/org/apache/iceberg/data/GenericFileWriterFactory.java +++ b/data/src/main/java/org/apache/iceberg/data/GenericFileWriterFactory.java @@ -22,21 +22,37 @@ import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; import static org.apache.iceberg.TableProperties.DELETE_DEFAULT_FILE_FORMAT; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.Map; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.avro.DataWriter; import org.apache.iceberg.data.orc.GenericOrcWriter; import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.orc.ORC; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -public class GenericFileWriterFactory extends BaseFileWriterFactory { +public class GenericFileWriterFactory extends RegistryBasedFileWriterFactory { + private static final Logger LOG = LoggerFactory.getLogger(GenericFileWriterFactory.class); + + private Table table; + private FileFormat format; + private Schema positionDeleteRowSchema; + private Map writerProperties; GenericFileWriterFactory( Table table, @@ -50,13 +66,16 @@ public class GenericFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + Record.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - ImmutableMap.of()); + ImmutableMap.of(), + null, + null); } /** @@ -80,14 +99,20 @@ public class GenericFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + Record.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - positionDeleteRowSchema, - writerProperties); + writerProperties, + null, + null); + this.table = table; + this.format = dataFileFormat; + this.positionDeleteRowSchema = positionDeleteRowSchema; + this.writerProperties = writerProperties != null ? writerProperties : ImmutableMap.of(); } /** @@ -107,64 +132,168 @@ public class GenericFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + Record.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - positionDeleteRowSchema); + ImmutableMap.of(), + dataSchema, + equalityDeleteRowSchema); + this.table = table; + this.format = dataFileFormat; + this.positionDeleteRowSchema = positionDeleteRowSchema; } static Builder builderFor(Table table) { return new Builder(table); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configureDataWrite(Avro.DataWriteBuilder builder) { builder.createWriterFunc(DataWriter::create); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configureEqualityDelete(Avro.DeleteWriteBuilder builder) { builder.createWriterFunc(DataWriter::create); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configurePositionDelete(Avro.DeleteWriteBuilder builder) { builder.createWriterFunc(DataWriter::create); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configureDataWrite(Parquet.DataWriteBuilder builder) { builder.createWriterFunc(GenericParquetWriter::create); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configureEqualityDelete(Parquet.DeleteWriteBuilder builder) { builder.createWriterFunc(GenericParquetWriter::create); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configurePositionDelete(Parquet.DeleteWriteBuilder builder) { builder.createWriterFunc(GenericParquetWriter::create); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configureDataWrite(ORC.DataWriteBuilder builder) { builder.createWriterFunc(GenericOrcWriter::buildWriter); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configureEqualityDelete(ORC.DeleteWriteBuilder builder) { builder.createWriterFunc(GenericOrcWriter::buildWriter); } - @Override + /** + * @deprecated Since 1.11.0, will be removed in 1.12.0. It won't be called starting in 1.11.0 as + * the configuration is done by the {@link FormatModelRegistry}. + */ + @Deprecated protected void configurePositionDelete(ORC.DeleteWriteBuilder builder) { builder.createWriterFunc(GenericOrcWriter::buildWriter); } + @Override + public PositionDeleteWriter newPositionDeleteWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + if (positionDeleteRowSchema == null) { + return super.newPositionDeleteWriter(file, spec, partition); + } else { + LOG.warn( + "Deprecated feature used. Position delete row schema is used to create the position delete writer."); + Map properties = table == null ? ImmutableMap.of() : table.properties(); + MetricsConfig metricsConfig = + table == null + ? MetricsConfig.forPositionDelete() + : MetricsConfig.forPositionDelete(table); + + try { + return switch (format) { + case AVRO -> + Avro.writeDeletes(file) + .setAll(properties) + .setAll(writerProperties) + .metricsConfig(metricsConfig) + .createWriterFunc(DataWriter::create) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(file) + .setAll(properties) + .setAll(writerProperties) + .metricsConfig(metricsConfig) + .createWriterFunc(GenericOrcWriter::buildWriter) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(file) + .setAll(properties) + .setAll(writerProperties) + .metricsConfig(metricsConfig) + .createWriterFunc(GenericParquetWriter::create) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + default -> + throw new UnsupportedOperationException( + "Cannot write pos-deletes for unsupported file format: " + format); + }; + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new position delete writer", e); + } + } + } + public static class Builder { private final Table table; private FileFormat dataFileFormat; diff --git a/data/src/main/java/org/apache/iceberg/data/GenericFormatModels.java b/data/src/main/java/org/apache/iceberg/data/GenericFormatModels.java new file mode 100644 index 000000000000..af7176f7a1f6 --- /dev/null +++ b/data/src/main/java/org/apache/iceberg/data/GenericFormatModels.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.data; + +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.data.avro.DataWriter; +import org.apache.iceberg.data.avro.PlannedDataReader; +import org.apache.iceberg.data.orc.GenericOrcReader; +import org.apache.iceberg.data.orc.GenericOrcWriter; +import org.apache.iceberg.data.parquet.GenericParquetReaders; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; + +public class GenericFormatModels { + public static void register() { + FormatModelRegistry.register( + AvroFormatModel.create( + Record.class, + Void.class, + (icebergSchema, fileSchema, engineSchema) -> DataWriter.create(fileSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + PlannedDataReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register(AvroFormatModel.forPositionDeletes()); + + FormatModelRegistry.register( + ParquetFormatModel.create( + Record.class, + Void.class, + (icebergSchema, fileSchema, engineSchema) -> + GenericParquetWriter.create(icebergSchema, fileSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + GenericParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register(ParquetFormatModel.forPositionDeletes()); + + FormatModelRegistry.register( + ORCFormatModel.create( + Record.class, + Void.class, + (icebergSchema, fileSchema, engineSchema) -> + GenericOrcWriter.buildWriter(icebergSchema, fileSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + GenericOrcReader.buildReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register(ORCFormatModel.forPositionDeletes()); + } + + private GenericFormatModels() {} +} diff --git a/data/src/main/java/org/apache/iceberg/data/GenericReader.java b/data/src/main/java/org/apache/iceberg/data/GenericReader.java index 9a1455f80fb0..f18f5785105f 100644 --- a/data/src/main/java/org/apache/iceberg/data/GenericReader.java +++ b/data/src/main/java/org/apache/iceberg/data/GenericReader.java @@ -22,26 +22,19 @@ import java.util.Map; import org.apache.iceberg.CombinedScanTask; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; import org.apache.iceberg.TableScan; -import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.avro.PlannedDataReader; -import org.apache.iceberg.data.orc.GenericOrcReader; -import org.apache.iceberg.data.parquet.GenericParquetReaders; import org.apache.iceberg.expressions.Evaluator; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableGroup; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.PartitionUtil; class GenericReader implements Serializable { @@ -96,58 +89,19 @@ private CloseableIterable openFile(FileScanTask task, Schema fileProject Map partition = PartitionUtil.constantsMap(task, IdentityPartitionConverters::convertConstant); - switch (task.file().format()) { - case AVRO: - Avro.ReadBuilder avro = - Avro.read(input) - .project(fileProjection) - .createResolvingReader(schema -> PlannedDataReader.create(schema, partition)) - .split(task.start(), task.length()); - - if (reuseContainers) { - avro.reuseContainers(); - } - - return avro.build(); - - case PARQUET: - Parquet.ReadBuilder parquet = - Parquet.read(input) - .project(fileProjection) - .createReaderFunc( - fileSchema -> - GenericParquetReaders.buildReader(fileProjection, fileSchema, partition)) - .split(task.start(), task.length()) - .caseSensitive(caseSensitive) - .filter(task.residual()); - - if (reuseContainers) { - parquet.reuseContainers(); - } - - return parquet.build(); - - case ORC: - Schema projectionWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - fileProjection, Sets.union(partition.keySet(), MetadataColumns.metadataFieldIds())); - ORC.ReadBuilder orc = - ORC.read(input) - .project(projectionWithoutConstantAndMetadataFields) - .createReaderFunc( - fileSchema -> - GenericOrcReader.buildReader(fileProjection, fileSchema, partition)) - .split(task.start(), task.length()) - .caseSensitive(caseSensitive) - .filter(task.residual()); - - return orc.build(); - - default: - throw new UnsupportedOperationException( - String.format( - "Cannot read %s file: %s", task.file().format().name(), task.file().location())); + ReadBuilder builder = + FormatModelRegistry.readBuilder(task.file().format(), Record.class, input); + if (reuseContainers) { + builder = builder.reuseContainers(); } + + return builder + .project(fileProjection) + .idToConstant(partition) + .split(task.start(), task.length()) + .caseSensitive(caseSensitive) + .filter(task.residual()) + .build(); } private class CombinedTaskIterable extends CloseableGroup implements CloseableIterable { diff --git a/data/src/main/java/org/apache/iceberg/data/InternalRecordWrapper.java b/data/src/main/java/org/apache/iceberg/data/InternalRecordWrapper.java index 871000279848..828bd58ec9c6 100644 --- a/data/src/main/java/org/apache/iceberg/data/InternalRecordWrapper.java +++ b/data/src/main/java/org/apache/iceberg/data/InternalRecordWrapper.java @@ -59,6 +59,12 @@ private static Function converter(Type type) { } else { return timestamp -> DateTimeUtil.microsFromTimestamp((LocalDateTime) timestamp); } + case TIMESTAMP_NANO: + if (((Types.TimestampNanoType) type).shouldAdjustToUTC()) { + return timestamp -> DateTimeUtil.nanosFromTimestamptz((OffsetDateTime) timestamp); + } else { + return timestamp -> DateTimeUtil.nanosFromTimestamp((LocalDateTime) timestamp); + } case FIXED: return bytes -> ByteBuffer.wrap((byte[]) bytes); case STRUCT: diff --git a/data/src/main/java/org/apache/iceberg/data/RegistryBasedFileWriterFactory.java b/data/src/main/java/org/apache/iceberg/data/RegistryBasedFileWriterFactory.java new file mode 100644 index 000000000000..868b41f5840b --- /dev/null +++ b/data/src/main/java/org/apache/iceberg/data/RegistryBasedFileWriterFactory.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.data; + +import java.io.IOException; +import java.io.Serializable; +import java.io.UncheckedIOException; +import java.util.Map; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.deletes.EqualityDeleteWriter; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.encryption.EncryptionKeyMetadata; +import org.apache.iceberg.formats.FileWriterBuilder; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.FileWriterFactory; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; + +/** + * A base writer factory to be extended by query engine integrations. + * + * @param row type + */ +public abstract class RegistryBasedFileWriterFactory + implements FileWriterFactory, Serializable { + private final Table table; + private final FileFormat dataFileFormat; + private final Class inputType; + private final Schema dataSchema; + private final SortOrder dataSortOrder; + private final FileFormat deleteFileFormat; + private final int[] equalityFieldIds; + private final Schema equalityDeleteRowSchema; + private final SortOrder equalityDeleteSortOrder; + private final Map writerProperties; + private final S inputSchema; + private final S equalityDeleteInputSchema; + + protected RegistryBasedFileWriterFactory( + Table table, + FileFormat dataFileFormat, + Class inputType, + Schema dataSchema, + SortOrder dataSortOrder, + FileFormat deleteFileFormat, + int[] equalityFieldIds, + Schema equalityDeleteRowSchema, + SortOrder equalityDeleteSortOrder, + Map writerProperties, + S inputSchema, + S equalityDeleteInputSchema) { + this.table = table; + this.dataFileFormat = dataFileFormat; + this.inputType = inputType; + this.dataSchema = dataSchema; + this.dataSortOrder = dataSortOrder; + this.deleteFileFormat = deleteFileFormat; + this.equalityFieldIds = equalityFieldIds; + this.equalityDeleteRowSchema = equalityDeleteRowSchema; + this.equalityDeleteSortOrder = equalityDeleteSortOrder; + this.writerProperties = writerProperties != null ? writerProperties : ImmutableMap.of(); + this.inputSchema = inputSchema; + this.equalityDeleteInputSchema = equalityDeleteInputSchema; + } + + protected S inputSchema() { + return inputSchema; + } + + protected S equalityDeleteInputSchema() { + return equalityDeleteInputSchema; + } + + @Override + public DataWriter newDataWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + Preconditions.checkArgument(dataSchema != null, "Invalid data schema: null"); + EncryptionKeyMetadata keyMetadata = file.keyMetadata(); + Map properties = table != null ? table.properties() : ImmutableMap.of(); + MetricsConfig metricsConfig = + table != null ? MetricsConfig.forTable(table) : MetricsConfig.getDefault(); + + try { + FileWriterBuilder, S> builder = + FormatModelRegistry.dataWriteBuilder(dataFileFormat, inputType, file); + return builder + .schema(dataSchema) + .engineSchema(inputSchema()) + .setAll(properties) + .setAll(writerProperties) + .metricsConfig(metricsConfig) + .spec(spec) + .partition(partition) + .keyMetadata(keyMetadata) + .sortOrder(dataSortOrder) + .overwrite() + .build(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new data writer", e); + } + } + + @Override + public EqualityDeleteWriter newEqualityDeleteWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + Preconditions.checkArgument(equalityDeleteRowSchema != null, "Invalid delete schema: null"); + + EncryptionKeyMetadata keyMetadata = file.keyMetadata(); + Map properties = table != null ? table.properties() : ImmutableMap.of(); + MetricsConfig metricsConfig = + table != null ? MetricsConfig.forTable(table) : MetricsConfig.getDefault(); + + try { + FileWriterBuilder, S> builder = + FormatModelRegistry.equalityDeleteWriteBuilder(deleteFileFormat, inputType, file); + return builder + .setAll(properties) + .setAll(writerProperties) + .metricsConfig(metricsConfig) + .schema(equalityDeleteRowSchema) + .engineSchema(equalityDeleteInputSchema()) + .equalityFieldIds(equalityFieldIds) + .spec(spec) + .partition(partition) + .keyMetadata(keyMetadata) + .sortOrder(equalityDeleteSortOrder) + .overwrite() + .build(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new equality delete writer", e); + } + } + + @Override + public PositionDeleteWriter newPositionDeleteWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + EncryptionKeyMetadata keyMetadata = file.keyMetadata(); + Map properties = table != null ? table.properties() : ImmutableMap.of(); + MetricsConfig metricsConfig = + table != null ? MetricsConfig.forPositionDelete(table) : MetricsConfig.forPositionDelete(); + + try { + FileWriterBuilder, ?> builder = + FormatModelRegistry.positionDeleteWriteBuilder(deleteFileFormat, file); + return builder + .setAll(properties) + .setAll(writerProperties) + .metricsConfig(metricsConfig) + .spec(spec) + .partition(partition) + .keyMetadata(keyMetadata) + .overwrite() + .build(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new position delete writer", e); + } + } +} diff --git a/data/src/main/java/org/apache/iceberg/data/TableMigrationUtil.java b/data/src/main/java/org/apache/iceberg/data/TableMigrationUtil.java index eb1c1a341e2b..3fbcb5a0cca5 100644 --- a/data/src/main/java/org/apache/iceberg/data/TableMigrationUtil.java +++ b/data/src/main/java/org/apache/iceberg/data/TableMigrationUtil.java @@ -203,7 +203,7 @@ public static List listPartition( } return Arrays.asList(datafiles); } catch (IOException e) { - throw new RuntimeException("Unable to list files in partition: " + partitionUri, e); + throw new UncheckedIOException("Unable to list files in partition: " + partitionUri, e); } finally { if (service != null) { service.shutdown(); diff --git a/data/src/test/java/org/apache/iceberg/RecordWrapperTestBase.java b/data/src/test/java/org/apache/iceberg/RecordWrapperTestBase.java index f51d1ebed4e4..df0fe6f1879d 100644 --- a/data/src/test/java/org/apache/iceberg/RecordWrapperTestBase.java +++ b/data/src/test/java/org/apache/iceberg/RecordWrapperTestBase.java @@ -52,6 +52,16 @@ public abstract class RecordWrapperTestBase { required(101, "ts0", Types.TimestampType.withoutZone()), required(102, "ts1", Types.TimestampType.withoutZone())); + private static final Types.StructType TIMESTAMP_NS_WITHOUT_ZONE = + Types.StructType.of( + required(101, "ts0", Types.TimestampNanoType.withoutZone()), + required(102, "ts1", Types.TimestampNanoType.withoutZone())); + + private static final Types.StructType TIMESTAMP_NS_WITH_ZONE = + Types.StructType.of( + required(101, "ts0", Types.TimestampNanoType.withZone()), + required(102, "ts1", Types.TimestampNanoType.withZone())); + protected static final Types.StructType TIME = Types.StructType.of( required(100, "time0", Types.TimeType.get()), @@ -67,6 +77,16 @@ public void testTimestampWithoutZone() { generateAndValidate(new Schema(TIMESTAMP_WITHOUT_ZONE.fields())); } + @Test + public void testTimestampNanoWithoutZone() { + generateAndValidate(new Schema(TIMESTAMP_NS_WITHOUT_ZONE.fields())); + } + + @Test + public void testTimestampNanoWithZone() { + generateAndValidate(new Schema(TIMESTAMP_NS_WITH_ZONE.fields())); + } + @Test public void testTime() { generateAndValidate(new Schema(TIME.fields())); diff --git a/data/src/test/java/org/apache/iceberg/TestInternalRecordWrapper.java b/data/src/test/java/org/apache/iceberg/TestInternalRecordWrapper.java new file mode 100644 index 000000000000..14047da7c4ae --- /dev/null +++ b/data/src/test/java/org/apache/iceberg/TestInternalRecordWrapper.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.data.InternalRecordWrapper; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; + +public class TestInternalRecordWrapper extends RecordWrapperTestBase { + @Override + protected void generateAndValidate(Schema schema, AssertMethod assertMethod) { + Record record = RandomGenericData.generate(schema, 1, 101L).iterator().next(); + StructLike wrapped = new InternalRecordWrapper(schema.asStruct()).wrap(record); + + for (int i = 0; i < schema.columns().size(); i++) { + Object val = wrapped.get(i, Object.class); + if (val != null) { + assertThat(val).isInstanceOf(schema.columns().get(i).type().typeId().javaClass()); + } + } + } +} diff --git a/data/src/test/java/org/apache/iceberg/data/BaseFormatModelTests.java b/data/src/test/java/org/apache/iceberg/data/BaseFormatModelTests.java new file mode 100644 index 000000000000..a38b025e0f05 --- /dev/null +++ b/data/src/test/java/org/apache/iceberg/data/BaseFormatModelTests.java @@ -0,0 +1,2230 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.data; + +import static org.apache.iceberg.MetadataColumns.DELETE_FILE_PATH; +import static org.apache.iceberg.MetadataColumns.DELETE_FILE_POS; +import static org.apache.iceberg.TestBase.SCHEMA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.IntStream; +import org.apache.avro.file.DataFileStream; +import org.apache.avro.file.DataFileWriter; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.avro.generic.GenericDatumWriter; +import org.apache.avro.io.DatumWriter; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.MetricsModes; +import org.apache.iceberg.MetricsModes.MetricsMode; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestTables; +import org.apache.iceberg.avro.AvroTestHelpers; +import org.apache.iceberg.data.orc.GenericOrcWriter; +import org.apache.iceberg.deletes.EqualityDeleteWriter; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.encryption.EncryptionKeyMetadata; +import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.formats.FileWriterBuilder; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.inmemory.InMemoryFileIO; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.DeleteSchemaUtil; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.mapping.MappingUtil; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.orc.ORCSchemaUtil; +import org.apache.iceberg.orc.OrcRowWriter; +import org.apache.iceberg.orc.OrcWritingTestUtils; +import org.apache.iceberg.orc.TestORCSchemaUtil; +import org.apache.iceberg.parquet.ParquetFileTestUtils; +import org.apache.iceberg.parquet.ParquetSchemaUtil; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Comparators; +import org.apache.iceberg.types.Conversions; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.orc.OrcFile; +import org.apache.orc.Reader; +import org.apache.orc.TypeDescription; +import org.apache.orc.Writer; +import org.apache.orc.storage.ql.exec.vector.VectorizedRowBatch; +import org.apache.parquet.avro.AvroParquetWriter; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.ParquetWriter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.FieldSource; + +public abstract class BaseFormatModelTests { + + protected abstract Class engineType(); + + protected abstract Object engineSchema(Schema schema); + + protected abstract T convertToEngine(Record record, Schema schema); + + protected abstract void assertEquals(Schema schema, List expected, List actual); + + protected abstract Object convertConstantToEngine(Type type, Object value); + + protected boolean supportsBatchReads() { + return false; + } + + @TempDir private File tableDir; + + private static final FileFormat[] FILE_FORMATS = + new FileFormat[] {FileFormat.AVRO, FileFormat.PARQUET, FileFormat.ORC}; + + private static final List FORMAT_AND_GENERATOR = + Arrays.stream(FILE_FORMATS) + .flatMap( + format -> + Arrays.stream(DataGenerators.ALL) + .map(generator -> Arguments.of(format, generator))) + .toList(); + + static final String FEATURE_FILTER = "filter"; + static final String FEATURE_CASE_SENSITIVE = "caseSensitive"; + static final String FEATURE_SPLIT = "split"; + static final String FEATURE_READER_DEFAULT = "readerDefault"; + static final String FEATURE_REUSE_CONTAINERS = "reuseContainers"; + static final String FEATURE_META_ROW_LINEAGE = "metaRowLineage"; + static final String FEATURE_COLUMN_LEVEL_METRICS = "columnLevelMetrics"; + static final String FEATURE_COLUMN_METRICS_TRUNCATE_BINARY = "columnMetricsTruncateBinary"; + + private static final Map MISSING_FEATURES = + Map.of( + FileFormat.AVRO, + new String[] { + FEATURE_FILTER, + FEATURE_CASE_SENSITIVE, + FEATURE_SPLIT, + FEATURE_COLUMN_LEVEL_METRICS, + FEATURE_COLUMN_METRICS_TRUNCATE_BINARY + }, + FileFormat.ORC, + new String[] { + FEATURE_REUSE_CONTAINERS, + FEATURE_COLUMN_METRICS_TRUNCATE_BINARY, + FEATURE_META_ROW_LINEAGE, + FEATURE_READER_DEFAULT + }); + + private InMemoryFileIO fileIO; + private EncryptedOutputFile encryptedFile; + + @BeforeEach + void before() { + this.fileIO = new InMemoryFileIO(); + this.encryptedFile = + EncryptedFiles.encryptedOutput( + fileIO.newOutputFile("test-file-" + UUID.randomUUID()), EncryptionKeyMetadata.EMPTY); + } + + @AfterEach + void after() { + try { + fileIO.deleteFile(encryptedFile.encryptingOutputFile()); + } catch (NotFoundException ignored) { + // ignore if the file is not created + } + + this.encryptedFile = null; + if (fileIO != null) { + fileIO.close(); + } + + TestTables.clearTables(); + } + + /** Write with engine type T, read with Generic Record */ + @ParameterizedTest + @FieldSource("FORMAT_AND_GENERATOR") + void testDataWriterEngineWriteGenericRead(FileFormat fileFormat, DataGenerator dataGenerator) + throws IOException { + Schema schema = dataGenerator.schema(); + FileWriterBuilder, Object> writerBuilder = + FormatModelRegistry.dataWriteBuilder(fileFormat, engineType(), encryptedFile); + + DataWriter writer = writerBuilder.schema(schema).spec(PartitionSpec.unpartitioned()).build(); + + List genericRecords = dataGenerator.generateRecords(); + List engineRecords = convertToEngineRecords(genericRecords, schema); + + try (writer) { + engineRecords.forEach(writer::write); + } + + DataFile dataFile = writer.toDataFile(); + + assertThat(dataFile).isNotNull(); + assertThat(dataFile.recordCount()).isEqualTo(engineRecords.size()); + assertThat(dataFile.format()).isEqualTo(fileFormat); + + readAndAssertGenericRecords(fileFormat, schema, genericRecords); + } + + /** Write with engine type T without explicit engineSchema, read with Generic Record */ + @ParameterizedTest + @FieldSource("FORMAT_AND_GENERATOR") + void testDataWriterEngineWriteWithoutEngineSchema( + FileFormat fileFormat, DataGenerator dataGenerator) throws IOException { + Schema schema = dataGenerator.schema(); + FileWriterBuilder, Object> writerBuilder = + FormatModelRegistry.dataWriteBuilder(fileFormat, engineType(), encryptedFile); + + DataWriter writer = writerBuilder.schema(schema).spec(PartitionSpec.unpartitioned()).build(); + + List genericRecords = dataGenerator.generateRecords(); + List engineRecords = convertToEngineRecords(genericRecords, schema); + + try (writer) { + engineRecords.forEach(writer::write); + } + + DataFile dataFile = writer.toDataFile(); + + assertThat(dataFile).isNotNull(); + assertThat(dataFile.recordCount()).isEqualTo(engineRecords.size()); + assertThat(dataFile.format()).isEqualTo(fileFormat); + + readAndAssertGenericRecords(fileFormat, schema, genericRecords); + } + + /** Write with Generic Record, read with engine type T */ + @ParameterizedTest + @FieldSource("FORMAT_AND_GENERATOR") + void testDataWriterGenericWriteEngineRead(FileFormat fileFormat, DataGenerator dataGenerator) + throws IOException { + Schema schema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + // Read back and verify + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertEquals(schema, convertToEngineRecords(genericRecords, schema), readRecords); + } + + /** Write with engine type T, read with Generic Record */ + @ParameterizedTest + @FieldSource("FORMAT_AND_GENERATOR") + void testEqualityDeleteWriterEngineWriteGenericRead( + FileFormat fileFormat, DataGenerator dataGenerator) throws IOException { + Schema schema = dataGenerator.schema(); + FileWriterBuilder, Object> writerBuilder = + FormatModelRegistry.equalityDeleteWriteBuilder(fileFormat, engineType(), encryptedFile); + + EqualityDeleteWriter writer = + writerBuilder + .schema(schema) + .spec(PartitionSpec.unpartitioned()) + .equalityFieldIds(1) + .build(); + + List genericRecords = dataGenerator.generateRecords(); + List engineRecords = convertToEngineRecords(genericRecords, schema); + + try (writer) { + engineRecords.forEach(writer::write); + } + + DeleteFile deleteFile = writer.toDeleteFile(); + + assertThat(deleteFile).isNotNull(); + assertThat(deleteFile.recordCount()).isEqualTo(engineRecords.size()); + assertThat(deleteFile.format()).isEqualTo(fileFormat); + assertThat(deleteFile.equalityFieldIds()).containsExactly(1); + + readAndAssertGenericRecords(fileFormat, schema, genericRecords); + } + + /** + * Write equality deletes with engine type T without explicit engineSchema, read with Generic + * Record + */ + @ParameterizedTest + @FieldSource("FORMAT_AND_GENERATOR") + void testEqualityDeleteWriterEngineWriteWithoutEngineSchema( + FileFormat fileFormat, DataGenerator dataGenerator) throws IOException { + Schema schema = dataGenerator.schema(); + FileWriterBuilder, Object> writerBuilder = + FormatModelRegistry.equalityDeleteWriteBuilder(fileFormat, engineType(), encryptedFile); + + EqualityDeleteWriter writer = + writerBuilder + .schema(schema) + .spec(PartitionSpec.unpartitioned()) + .equalityFieldIds(1) + .build(); + + List genericRecords = dataGenerator.generateRecords(); + List engineRecords = convertToEngineRecords(genericRecords, schema); + + try (writer) { + engineRecords.forEach(writer::write); + } + + DeleteFile deleteFile = writer.toDeleteFile(); + + assertThat(deleteFile).isNotNull(); + assertThat(deleteFile.recordCount()).isEqualTo(engineRecords.size()); + assertThat(deleteFile.format()).isEqualTo(fileFormat); + assertThat(deleteFile.equalityFieldIds()).containsExactly(1); + + readAndAssertGenericRecords(fileFormat, schema, genericRecords); + } + + /** Write with Generic Record, read with engine type T */ + @ParameterizedTest + @FieldSource("FORMAT_AND_GENERATOR") + void testEqualityDeleteWriterGenericWriteEngineRead( + FileFormat fileFormat, DataGenerator dataGenerator) throws IOException { + Schema schema = dataGenerator.schema(); + FileWriterBuilder, Object> writerBuilder = + FormatModelRegistry.equalityDeleteWriteBuilder(fileFormat, Record.class, encryptedFile); + + EqualityDeleteWriter writer = + writerBuilder + .schema(schema) + .spec(PartitionSpec.unpartitioned()) + .equalityFieldIds(1) + .build(); + + List genericRecords = dataGenerator.generateRecords(); + + try (writer) { + genericRecords.forEach(writer::write); + } + + DeleteFile deleteFile = writer.toDeleteFile(); + + assertThat(deleteFile).isNotNull(); + assertThat(deleteFile.recordCount()).isEqualTo(genericRecords.size()); + assertThat(deleteFile.format()).isEqualTo(fileFormat); + assertThat(deleteFile.equalityFieldIds()).containsExactly(1); + + // Read back and verify + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertEquals(schema, convertToEngineRecords(genericRecords, schema), readRecords); + } + + /** Write position deletes, read with Generic Record */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testPositionDeleteWriterEngineWriteGenericRead(FileFormat fileFormat) throws IOException { + Schema positionDeleteSchema = DeleteSchemaUtil.pathPosSchema(); + + FileWriterBuilder, ?> writerBuilder = + FormatModelRegistry.positionDeleteWriteBuilder(fileFormat, encryptedFile); + + List> deletes = + ImmutableList.of( + PositionDelete.create().set("data-file-1.parquet", 0L), + PositionDelete.create().set("data-file-1.parquet", 1L)); + + List records = + deletes.stream() + .map( + d -> + GenericRecord.create(positionDeleteSchema) + .copy(DELETE_FILE_PATH.name(), d.path(), DELETE_FILE_POS.name(), d.pos())) + .toList(); + + PositionDeleteWriter writer = writerBuilder.spec(PartitionSpec.unpartitioned()).build(); + try (writer) { + deletes.forEach(writer::write); + } + + DeleteFile deleteFile = writer.toDeleteFile(); + + assertThat(deleteFile).isNotNull(); + assertThat(deleteFile.recordCount()).isEqualTo(2); + assertThat(deleteFile.format()).isEqualTo(fileFormat); + + readAndAssertGenericRecords(fileFormat, positionDeleteSchema, records); + } + + /** Write with Generic Record, read with projected engine type T (narrow schema) */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReaderBuilderProjection(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema fullSchema = dataGenerator.schema(); + + List columns = fullSchema.columns(); + List projectedColumns = + IntStream.range(0, columns.size()).filter(i -> i % 2 == 1).mapToObj(columns::get).toList(); + if (projectedColumns.isEmpty()) { + projectedColumns = ImmutableList.of(columns.get(columns.size() - 1)); + } + + Schema projectedSchema = new Schema(projectedColumns); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, fullSchema, genericRecords); + + List projectedGenericRecords = + genericRecords.stream() + .map(record -> copy(record, projectedSchema, projectedSchema)) + .toList(); + List expectedEngineRecords = + convertToEngineRecords(projectedGenericRecords, projectedSchema); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(projectedSchema) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertEquals(projectedSchema, expectedEngineRecords, readRecords); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReaderBuilderFilter(FileFormat fileFormat) throws IOException { + + assumeSupports(fileFormat, FEATURE_FILTER); + + Schema schema = SCHEMA; + + // Generate records with known id values [0, count) + int count = 10000; + List genericRecords = + IntStream.range(0, count) + .mapToObj(i -> GenericRecord.create(SCHEMA).copy("id", i, "data", "row-" + i)) + .toList(); + + writeRecordsForSplit(fileFormat, schema, genericRecords); + + // Filter: id < 0, so no record matches, file-level filtering should eliminate all rows + Expression lessThanFilter = Expressions.lessThan("id", 0); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .filter(lessThanFilter) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).isEmpty(); + + // Filter: id >= count - 1, so only the last record matches across all row groups + Expression greaterThanFilter = Expressions.greaterThanOrEqual("id", count - 1); + + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .filter(greaterThanFilter) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).hasSizeGreaterThan(0).hasSizeLessThan(genericRecords.size()); + } + + /** + * Write with Generic Record, then read using an upper-cased column name in the filter to verify + * caseSensitive behavior. + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReaderBuilderCaseSensitive(FileFormat fileFormat) throws IOException { + + assumeSupports(fileFormat, FEATURE_CASE_SENSITIVE); + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + // Build a filter using the upper-cased name of the first column. + Types.NestedField firstField = schema.columns().get(0); + Object filterValue = genericRecords.get(0).getField(firstField.name()); + Expression upperCaseFilter = Expressions.equal(firstField.name().toUpperCase(), filterValue); + assertThat(firstField.name()).isNotEqualTo(firstField.name().toUpperCase()); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + + // caseSensitive=false: upper-cased column name must be resolved correctly. + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .filter(upperCaseFilter) + .caseSensitive(false) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).isNotEmpty(); + + // caseSensitive=true: upper-cased column name cannot be resolved, so must throw. + assertThatThrownBy( + () -> { + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .filter(upperCaseFilter) + .caseSensitive(true) + .build()) { + ImmutableList.copyOf(reader); + } + }) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Cannot find field '%s'", firstField.name().toUpperCase()); + } + + /** + * Write with Generic Record, then read using split to verify that the split range is respected. + * The test writes enough records to produce multiple split so that {@link + * DataFile#splitOffsets()} contains at least two offsets. It then reads only the first row-group + * split {@code [splitOffsets[0], splitOffsets[1])} and asserts that the returned record count is + * greater than zero but less than the total, confirming that the split boundary is honoured. A + * second read over the full file range {@code [0, fileLength)} must return all records. + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReaderBuilderSplit(FileFormat fileFormat) throws IOException { + + assumeSupports(fileFormat, FEATURE_SPLIT); + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List records = dataGenerator.generateRecords(10000); + + DataFile dataFile = writeRecordsForSplit(fileFormat, schema, records); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + long fileLength = inputFile.getLength(); + + List splitOffsets = dataFile.splitOffsets(); + + long firstSplitStart = splitOffsets.get(0); + long firstSplitLength = splitOffsets.get(1) - splitOffsets.get(0); + + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .split(firstSplitStart, firstSplitLength) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).hasSizeGreaterThan(0).hasSizeLessThan(records.size()); + + // split(fileLength, 0): empty range at the end of the file, so no records should be returned + List emptyReadRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .split(fileLength, 0) + .build()) { + emptyReadRecords = ImmutableList.copyOf(reader); + } + + assertThat(emptyReadRecords).isEmpty(); + + // split(0, fileLength): full file range, so all records should be returned + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .split(0, fileLength) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).hasSize(records.size()); + } + + /** Verifies the contract of reuseContainers */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReaderBuilderReuseContainers(FileFormat fileFormat) throws IOException { + + assumeSupports(fileFormat, FEATURE_REUSE_CONTAINERS); + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List genericRecords = dataGenerator.generateRecords(); + // Need at least 2 records to verify container reuse + assumeThat(genericRecords).hasSizeGreaterThanOrEqualTo(2); + writeGenericRecords(fileFormat, schema, genericRecords); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + + // Without reuseContainers: every record must be a distinct object instance + List noReuseRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .build()) { + noReuseRecords = ImmutableList.copyOf(reader); + } + + for (int i = 0; i < noReuseRecords.size(); i++) { + for (int j = i + 1; j < noReuseRecords.size(); j++) { + assertThat(noReuseRecords.get(i)).isNotSameAs(noReuseRecords.get(j)); + } + } + + // With reuseContainers: all collected elements must be the same object instance + List reuseRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(schema) + .reuseContainers() + .build()) { + reuseRecords = ImmutableList.copyOf(reader); + } + + reuseRecords.forEach(r -> assertThat(r).isSameAs(reuseRecords.get(0))); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReaderSchemaEvolutionNewColumnWithDefault(FileFormat fileFormat) throws IOException { + + assumeSupports(fileFormat, FEATURE_READER_DEFAULT); + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + String defaultStringValue = "default_value"; + int defaultIntValue = 42; + + int maxFieldId = + writeSchema.columns().stream().mapToInt(Types.NestedField::fieldId).max().orElse(0); + + List evolvedColumns = Lists.newArrayList(writeSchema.columns()); + evolvedColumns.add( + Types.NestedField.required("col_f") + .withId(maxFieldId + 1) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of(defaultStringValue)) + .build()); + evolvedColumns.add( + Types.NestedField.optional("col_g") + .withId(maxFieldId + 2) + .ofType(Types.IntegerType.get()) + .withInitialDefault(Literal.of(defaultIntValue)) + .build()); + + Schema evolvedSchema = new Schema(evolvedColumns); + readAndAssertGenericRecords( + fileFormat, + evolvedSchema, + genericRecords, + record -> { + Record expected = copy(record, writeSchema, evolvedSchema); + expected.setField("col_f", defaultStringValue); + expected.setField("col_g", defaultIntValue); + return expected; + }); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReaderBuilderRecordsPerBatchNotSupported(FileFormat fileFormat) throws IOException { + assumeFalse(supportsBatchReads(), engineType().getSimpleName() + " supports batch reads"); + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + assertThatThrownBy( + () -> + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .recordsPerBatch(100)) + .hasMessageContaining("Batch reading is not supported") + .isInstanceOf(UnsupportedOperationException.class); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterMetricsCollection(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + DataFile dataFile = writeGenericRecords(fileFormat, schema, genericRecords); + + assertCounts(fileFormat, schema, genericRecords, dataFile); + assertBounds(fileFormat, schema, genericRecords, dataFile); + assertColumnSize(fileFormat, dataFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterMetricsWithNoneMode(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + + MetricsConfig noneConfig = config(schema, MetricsModes.None.get()); + List genericRecords = dataGenerator.generateRecords(); + DataFile dataFile = writeGenericRecords(fileFormat, schema, genericRecords, noneConfig); + + assertCountsNull(schema, dataFile); + assertBoundsNull(schema, dataFile); + assertColumnSizeEmpty(fileFormat, dataFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterMetricsWithCountsMode(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + MetricsConfig countsConfig = config(schema, MetricsModes.Counts.get()); + List genericRecords = dataGenerator.generateRecords(); + DataFile dataFile = writeGenericRecords(fileFormat, schema, genericRecords, countsConfig); + + // In the counts mode, valueCounts and nullValueCounts should be present, while lowerBounds and + // upperBounds should be null. + assertCounts(fileFormat, schema, genericRecords, dataFile); + assertBoundsNull(schema, dataFile); + assertColumnSize(fileFormat, dataFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterMetricsWithTruncateMode(FileFormat fileFormat) throws IOException { + int truncateLength = 5; + Schema schema = + new Schema( + Types.NestedField.required(1, "col_str", Types.StringType.get()), + Types.NestedField.required(2, "col_int", Types.IntegerType.get())); + + List records = Lists.newArrayList(); + records.add(GenericRecord.create(schema).copy("col_str", "abcdefghij", "col_int", 10)); + records.add(GenericRecord.create(schema).copy("col_str", "abcdezyxwv", "col_int", 20)); + records.add(GenericRecord.create(schema).copy("col_str", "abcdeAAAAA", "col_int", 5)); + + assertTruncateBoundsForFirstColumn( + fileFormat, + schema, + records, + truncateLength, + FEATURE_COLUMN_LEVEL_METRICS, + (lower, upper) -> { + // Lower bound: "abcdeAAAAA" truncated to "abcde" + CharSequence actualLower = Conversions.fromByteBuffer(Types.StringType.get(), lower); + assertThat(actualLower.toString()).hasSize(truncateLength); + assertThat(actualLower.toString()).isEqualTo("abcde"); + + // Upper bound: "abcdezyxwv" truncated and incremented to "abcdf" + CharSequence actualUpper = Conversions.fromByteBuffer(Types.StringType.get(), upper); + assertThat(actualUpper.toString()).hasSize(truncateLength); + assertThat(actualUpper.toString()).isEqualTo("abcdf"); + }); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterMetricsWithTruncateModeForBinary(FileFormat fileFormat) throws IOException { + int truncateLength = 5; + Schema schema = + new Schema( + Types.NestedField.required(1, "col_bin", Types.BinaryType.get()), + Types.NestedField.required(2, "col_int", Types.IntegerType.get())); + + List records = Lists.newArrayList(); + records.add( + GenericRecord.create(schema) + .copy( + "col_bin", + ByteBuffer.wrap( + new byte[] {0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0x10, 0xA, 0xB}), + "col_int", + 10)); + + assertTruncateBoundsForFirstColumn( + fileFormat, + schema, + records, + truncateLength, + FEATURE_COLUMN_METRICS_TRUNCATE_BINARY, + (lower, upper) -> { + ByteBuffer actualLower = Conversions.fromByteBuffer(Types.BinaryType.get(), lower); + ByteBuffer actualUpper = Conversions.fromByteBuffer(Types.BinaryType.get(), upper); + + ByteBuffer expectedLower = ByteBuffer.wrap(new byte[] {0x1, 0x2, 0x3, 0x4, 0x5}); + ByteBuffer expectedUpper = ByteBuffer.wrap(new byte[] {0x1, 0x2, 0x3, 0x4, 0x6}); + + assertThat(actualLower).isEqualTo(expectedLower); + assertThat(actualUpper).isEqualTo(expectedUpper); + }); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testEqualityDeleteWriterMetricsCollection(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + FileWriterBuilder, Object> writerBuilder = + FormatModelRegistry.equalityDeleteWriteBuilder(fileFormat, Record.class, encryptedFile); + + EqualityDeleteWriter writer = + writerBuilder + .schema(schema) + .spec(PartitionSpec.unpartitioned()) + .equalityFieldIds(1) + .build(); + + List genericRecords = dataGenerator.generateRecords(); + + try (writer) { + genericRecords.forEach(writer::write); + } + + DeleteFile deleteFile = writer.toDeleteFile(); + + assertCounts(fileFormat, schema, genericRecords, deleteFile); + assertBounds(fileFormat, schema, genericRecords, deleteFile); + assertColumnSize(fileFormat, deleteFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testPositionDeleteWriterMetricsSingleFile(FileFormat fileFormat) throws IOException { + // Single file reference: counts are removed but bounds are preserved. + List> deletes = + ImmutableList.of( + PositionDelete.create().set("d-file-1.file", 0L), + PositionDelete.create().set("d-file-1.file", 5L), + PositionDelete.create().set("d-file-1.file", 3L)); + + DeleteFile deleteFile = writePositionDeletes(fileFormat, deletes); + assertPositionDeleteMetrics(fileFormat, deletes, deleteFile, true /* checkBounds */); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testPositionDeleteWriterMetricsMultipleFiles(FileFormat fileFormat) throws IOException { + // Multiple file references: both counts and bounds are removed. + List> deletes = + ImmutableList.of( + PositionDelete.create().set("d-file-1.file", 0L), + PositionDelete.create().set("d-file-1.file", 5L), + PositionDelete.create().set("d-file-2.file", 3L)); + + DeleteFile deleteFile = writePositionDeletes(fileFormat, deletes); + assertPositionDeleteMetrics(fileFormat, deletes, deleteFile, false /* checkBounds */); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterMetricsWithPerColumnMode(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + + // Default mode is "counts", col_b is overridden to "full", col_a is overridden to "none" + MetricsConfig perColumnConfig = + config( + schema, + MetricsModes.Counts.get(), + ImmutableMap.of("col_b", MetricsModes.Full.get(), "col_a", MetricsModes.None.get())); + + List genericRecords = dataGenerator.generateRecords(); + DataFile dataFile = writeGenericRecords(fileFormat, schema, genericRecords, perColumnConfig); + + // col_a: mode=none -> no valueCounts, nullValueCounts, bounds + Schema noneSchema = new Schema(schema.findField("col_a")); + assertCountsNull(noneSchema, dataFile); + assertBoundsNull(noneSchema, dataFile); + + // col_b: mode=full -> valueCounts, nullValueCounts, and bounds all present + Schema fullSchema = new Schema(schema.findField("col_b")); + assertCounts(fileFormat, fullSchema, genericRecords, dataFile); + assertBounds(fileFormat, fullSchema, genericRecords, dataFile); + + // col_c, col_d, col_e: mode=counts (default) -> valueCounts and nullValueCounts present, + // but no bounds + Schema countsSchema = + new Schema(schema.findField("col_c"), schema.findField("col_d"), schema.findField("col_e")); + assertCounts(fileFormat, countsSchema, genericRecords, dataFile); + assertBoundsNull(countsSchema, dataFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterNanMetrics(FileFormat fileFormat) throws IOException { + Schema schema = new DataGenerators.FloatDoubleSchema().schema(); + + List records = Lists.newArrayList(); + records.add( + GenericRecord.create(schema).copy("col_float", Float.NaN, "col_double", Double.NaN)); + records.add( + GenericRecord.create(schema).copy("col_float", Float.NaN, "col_double", Double.NaN)); + records.add(GenericRecord.create(schema).copy("col_float", 1.0F, "col_double", 10.0D)); + records.add(GenericRecord.create(schema).copy("col_float", 5.0F, "col_double", 50.0D)); + records.add(GenericRecord.create(schema).copy("col_float", 3.0F, "col_double", 30.0D)); + + DataFile dataFile = writeGenericRecords(fileFormat, schema, records); + + assertCounts(fileFormat, schema, records, dataFile); + assertBounds(fileFormat, schema, records, dataFile); + assertNanCounts(fileFormat, schema, records, dataFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterNanSortingOrder(FileFormat fileFormat) throws IOException { + Schema schema = new DataGenerators.FloatDoubleSchema().schema(); + + List records = Lists.newArrayList(); + records.add( + GenericRecord.create(schema).copy("col_float", Float.NaN, "col_double", Double.NaN)); + records.add( + GenericRecord.create(schema) + .copy("col_float", Float.NEGATIVE_INFINITY, "col_double", Double.NEGATIVE_INFINITY)); + records.add(GenericRecord.create(schema).copy("col_float", -1.0F, "col_double", -1.0D)); + records.add(GenericRecord.create(schema).copy("col_float", -0.0F, "col_double", -0.0D)); + records.add(GenericRecord.create(schema).copy("col_float", 0.0F, "col_double", 0.0D)); + records.add(GenericRecord.create(schema).copy("col_float", 1.0F, "col_double", 1.0D)); + records.add( + GenericRecord.create(schema) + .copy("col_float", Float.POSITIVE_INFINITY, "col_double", Double.POSITIVE_INFINITY)); + records.add( + GenericRecord.create(schema).copy("col_float", Float.NaN, "col_double", Double.NaN)); + + DataFile dataFile = writeGenericRecords(fileFormat, schema, records); + + // Bounds should exclude NaN: float/double lower = -Infinity, upper = +Infinity + assertBounds(fileFormat, schema, records, dataFile); + assertNanCounts(fileFormat, schema, records, dataFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testDataWriterNegativeZeroBounds(FileFormat fileFormat) throws IOException { + Schema schema = new DataGenerators.FloatDoubleSchema().schema(); + + List records = Lists.newArrayList(); + records.add(GenericRecord.create(schema).copy("col_float", -0.0F, "col_double", -0.0D)); + records.add(GenericRecord.create(schema).copy("col_float", 0.0F, "col_double", 0.0D)); + + DataFile dataFile = writeGenericRecords(fileFormat, schema, records); + assertBounds(fileFormat, schema, records, dataFile); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnFilePath(FileFormat fileFormat) throws IOException { + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + String filePath = "test-data-file.parquet"; + Schema projectionSchema = new Schema(MetadataColumns.FILE_PATH); + + Map idToConstant = + ImmutableMap.of(MetadataColumns.FILE_PATH.fieldId(), filePath); + + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + genericRecords, + ignored -> + GenericRecord.create(projectionSchema) + .copy(MetadataColumns.FILE_PATH.name(), filePath)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnSpecId(FileFormat fileFormat) throws IOException { + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + int specId = 0; + Schema projectionSchema = new Schema(MetadataColumns.SPEC_ID); + + Map idToConstant = ImmutableMap.of(MetadataColumns.SPEC_ID.fieldId(), specId); + + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + genericRecords, + ignored -> + GenericRecord.create(projectionSchema).copy(MetadataColumns.SPEC_ID.name(), specId)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnRowPosition(FileFormat fileFormat) throws IOException { + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + Schema projectionSchema = new Schema(MetadataColumns.ROW_POSITION); + + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + null, + genericRecords, + (position, ignored) -> + GenericRecord.create(projectionSchema) + .copy(MetadataColumns.ROW_POSITION.name(), (long) position)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnIsDeleted(FileFormat fileFormat) throws IOException { + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + Schema projectionSchema = new Schema(MetadataColumns.IS_DELETED); + + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + null, + genericRecords, + ignored -> + GenericRecord.create(projectionSchema).copy(MetadataColumns.IS_DELETED.name(), false)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnRowLinage(FileFormat fileFormat) throws IOException { + assumeSupports(fileFormat, FEATURE_META_ROW_LINEAGE); + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema schema = dataGenerator.schema(); + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, schema, genericRecords); + + long baseRowId = 100L; + long fileSeqNumber = 5L; + Schema projectionSchema = + new Schema(MetadataColumns.ROW_ID, MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER); + + Map idToConstant = + ImmutableMap.of( + MetadataColumns.ROW_ID.fieldId(), baseRowId, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(), fileSeqNumber); + + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + genericRecords, + (position, ignored) -> + GenericRecord.create(projectionSchema) + .copy( + MetadataColumns.ROW_ID.name(), + baseRowId + position, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), + fileSeqNumber)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnRowLinageExistValue(FileFormat fileFormat) throws IOException { + assumeSupports(fileFormat, FEATURE_META_ROW_LINEAGE); + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema dataSchema = dataGenerator.schema(); + + Schema writeSchema = MetadataColumns.schemaWithRowLineage(dataSchema); + + List baseRecords = dataGenerator.generateRecords(); + List writeRecords = Lists.newArrayListWithExpectedSize(baseRecords.size()); + for (int i = 0; i < baseRecords.size(); i++) { + Record base = baseRecords.get(i); + Record rec = copy(base, dataSchema, writeSchema); + + if (i % 2 == 0) { + rec.setField(MetadataColumns.ROW_ID.name(), 555L + i); + rec.setField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), 7L); + } else { + rec.setField(MetadataColumns.ROW_ID.name(), null); + rec.setField(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), null); + } + + writeRecords.add(rec); + } + + DataWriter writer = + FormatModelRegistry.dataWriteBuilder(fileFormat, Record.class, encryptedFile) + .schema(writeSchema) + .spec(PartitionSpec.unpartitioned()) + .build(); + + try (writer) { + writeRecords.forEach(writer::write); + } + + long baseRowId = 100L; + long fileSeqNumber = 5L; + Schema projectionSchema = + new Schema(MetadataColumns.ROW_ID, MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER); + + Map idToConstant = + ImmutableMap.of( + MetadataColumns.ROW_ID.fieldId(), baseRowId, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(), fileSeqNumber); + + // Expected results: + // - Even rows (explicit values): _row_id = 555+i, _last_updated_sequence_number = 7 + // - Odd rows (null values): _row_id = baseRowId+pos, _last_updated_sequence_number = + // fileSeqNumber + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + baseRecords, + (position, ignored) -> { + if (position % 2 == 0) { + return GenericRecord.create(projectionSchema) + .copy( + MetadataColumns.ROW_ID.name(), + 555L + position, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), + 7L); + } else { + return GenericRecord.create(projectionSchema) + .copy( + MetadataColumns.ROW_ID.name(), + baseRowId + position, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), + fileSeqNumber); + } + }); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnPartitionIdentity(FileFormat fileFormat) throws IOException { + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + PartitionSpec spec = PartitionSpec.builderFor(dataGenerator.schema()).identity("col_a").build(); + + Types.StructType partitionType = spec.partitionType(); + PartitionData partitionData = new PartitionData(partitionType); + partitionData.set(0, "test_col_a"); + + DataWriter writer = + FormatModelRegistry.dataWriteBuilder(fileFormat, Record.class, encryptedFile) + .schema(dataGenerator.schema()) + .spec(PartitionSpec.unpartitioned()) + .build(); + + List records = dataGenerator.generateRecords(); + try (writer) { + records.forEach(writer::write); + } + + Types.NestedField partitionField = + Types.NestedField.optional( + MetadataColumns.PARTITION_COLUMN_ID, + MetadataColumns.PARTITION_COLUMN_NAME, + partitionType, + MetadataColumns.PARTITION_COLUMN_DOC); + Schema projectionSchema = new Schema(partitionField); + + Map idToConstant = + ImmutableMap.of(MetadataColumns.PARTITION_COLUMN_ID, partitionData); + + Record partitionRecord = structLikeToRecord(partitionData, partitionType); + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + records, + ignored -> + GenericRecord.create(projectionSchema) + .copy(MetadataColumns.PARTITION_COLUMN_NAME, partitionRecord)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnPartitionEvolutionAddColumn(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema dataSchema = dataGenerator.schema(); + + // Old spec: partition by col_a only (spec id = 0) + PartitionSpec oldSpec = PartitionSpec.builderFor(dataSchema).identity("col_a").build(); + + // New spec: partition by col_a + col_b (spec id = 1, simulates partition evolution) + PartitionSpec newSpec = + PartitionSpec.builderFor(dataSchema) + .withSpecId(1) + .identity("col_a") + .identity("col_b") + .build(); + + // Partition data for the old file (only col_a is set, col_b is absent) + PartitionData oldPartitionData = new PartitionData(oldSpec.partitionType()); + oldPartitionData.set(0, "test_data"); + + // Write data using the old spec + DataWriter writer = + FormatModelRegistry.dataWriteBuilder(fileFormat, Record.class, encryptedFile) + .schema(dataSchema) + .spec(PartitionSpec.unpartitioned()) + .build(); + + List records = dataGenerator.generateRecords(); + + try (writer) { + records.forEach(writer::write); + } + + Types.StructType unifiedPartitionType = newSpec.partitionType(); + + // Build projection schema with PARTITION_COLUMN using the unified partition type + Types.NestedField partitionField = + Types.NestedField.optional( + MetadataColumns.PARTITION_COLUMN_ID, + MetadataColumns.PARTITION_COLUMN_NAME, + unifiedPartitionType, + MetadataColumns.PARTITION_COLUMN_DOC); + Schema projectionSchema = new Schema(partitionField); + + Map idToConstant = + ImmutableMap.of(MetadataColumns.PARTITION_COLUMN_ID, oldPartitionData); + + Record partitionRecord = structLikeToRecord(oldPartitionData, unifiedPartitionType); + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + records, + ignored -> + GenericRecord.create(projectionSchema) + .copy(MetadataColumns.PARTITION_COLUMN_NAME, partitionRecord)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadMetadataColumnPartitionEvolutionRemoveColumn(FileFormat fileFormat) + throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema dataSchema = dataGenerator.schema(); + + PartitionSpec oldSpec = + PartitionSpec.builderFor(dataSchema).identity("col_a").identity("col_b").build(); + + PartitionSpec newSpec = + PartitionSpec.builderFor(dataSchema).withSpecId(1).identity("col_a").build(); + + // Partition data for the old file (both col_a and col_b are set) + PartitionData oldPartitionData = new PartitionData(oldSpec.partitionType()); + oldPartitionData.set(0, "test_col_a"); + oldPartitionData.set(1, 1); + + DataWriter writer = + FormatModelRegistry.dataWriteBuilder(fileFormat, Record.class, encryptedFile) + .schema(dataSchema) + .spec(PartitionSpec.unpartitioned()) + .build(); + + List records = dataGenerator.generateRecords(); + + try (writer) { + records.forEach(writer::write); + } + + // Use the new spec's partition type for projection (only col_a remains after evolution) + // This simulates reading an old file from the perspective of the new spec + Types.StructType newPartitionType = newSpec.partitionType(); + Types.NestedField partitionField = + Types.NestedField.optional( + MetadataColumns.PARTITION_COLUMN_ID, + MetadataColumns.PARTITION_COLUMN_NAME, + newPartitionType, + MetadataColumns.PARTITION_COLUMN_DOC); + Schema projectionSchema = new Schema(partitionField); + + Map idToConstant = + ImmutableMap.of(MetadataColumns.PARTITION_COLUMN_ID, oldPartitionData); + + Record partitionRecord = structLikeToRecord(oldPartitionData, newPartitionType); + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + records, + ignored -> + GenericRecord.create(projectionSchema) + .copy(MetadataColumns.PARTITION_COLUMN_NAME, partitionRecord)); + } + + private void readAndAssertGenericRecords( + FileFormat fileFormat, + Schema schema, + List sourceRecords, + Function transform) + throws IOException { + readAndAssertGenericRecords(fileFormat, schema, sourceRecords.stream().map(transform).toList()); + } + + /** + * Schema evolution: Adding column (reading with wider schema). Write with DefaultSchema, read + * with additional optional columns. The new columns should be filled with null values. + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionAddColumn(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + List evolvedColumns = Lists.newArrayList(writeSchema.columns()); + + int maxFieldId = + writeSchema.columns().stream().mapToInt(Types.NestedField::fieldId).max().orElse(0); + evolvedColumns.add( + Types.NestedField.optional("new_string_col") + .withId(maxFieldId + 1) + .ofType(Types.StringType.get()) + .build()); + evolvedColumns.add( + Types.NestedField.optional("new_int_col") + .withId(maxFieldId + 2) + .ofType(Types.IntegerType.get()) + .build()); + Schema readSchema = new Schema(evolvedColumns); + readAndAssertEngineRecords( + fileFormat, + readSchema, + genericRecords, + record -> { + Record expected = copy(record, writeSchema, readSchema); + + expected.setField("new_string_col", null); + expected.setField("new_int_col", null); + return expected; + }); + } + + /** + * Schema evolution: Projection / Removing column (reading with narrower schema). Write with + * DefaultSchema, read with only a subset of columns (skipping middle columns). + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionProjection(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + List writeColumns = writeSchema.columns(); + assumeThat(writeColumns).hasSizeGreaterThanOrEqualTo(2); + Schema projectedSchema = + new Schema(writeColumns.get(0), writeColumns.get(writeColumns.size() - 1)); + + readAndAssertEngineRecords( + fileFormat, + projectedSchema, + genericRecords, + record -> copy(record, projectedSchema, projectedSchema)); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionDropAndReAddSameNameColumn(FileFormat fileFormat) throws IOException { + + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + // Remove col_b and add a new col_b with a different field ID + Schema readSchema = + new Schema( + Types.NestedField.required(1, "col_a", Types.StringType.get()), + Types.NestedField.optional(6, "col_b", Types.IntegerType.get()), + Types.NestedField.required(3, "col_c", Types.LongType.get()), + Types.NestedField.required(4, "col_d", Types.FloatType.get()), + Types.NestedField.required(5, "col_e", Types.DoubleType.get())); + + readAndAssertEngineRecords( + fileFormat, + readSchema, + genericRecords, + record -> { + Record expected = GenericRecord.create(readSchema); + expected.setField("col_a", record.getField("col_a")); + expected.setField("col_b", null); + expected.setField("col_c", record.getField("col_c")); + expected.setField("col_d", record.getField("col_d")); + expected.setField("col_e", record.getField("col_e")); + return expected; + }); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionTypePromotionIntToLong(FileFormat fileFormat) throws IOException { + runTypePromotionCheck( + fileFormat, + Types.IntegerType.get(), + Types.LongType.get(), + value -> value == null ? null : ((Integer) value).longValue()); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionTypePromotionFloatToDouble(FileFormat fileFormat) throws IOException { + runTypePromotionCheck( + fileFormat, + Types.FloatType.get(), + Types.DoubleType.get(), + value -> value == null ? null : ((Float) value).doubleValue()); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionTypePromotionDecimalPrecision(FileFormat fileFormat) throws IOException { + runTypePromotionCheck( + fileFormat, Types.DecimalType.of(9, 2), Types.DecimalType.of(18, 2), Function.identity()); + } + + /** + * Schema evolution: Reorder columns. Write with DefaultSchema {col_a, col_b, col_c, col_d, + * col_e}, read with reordered schema {col_e, col_c, col_a, col_d, col_b}. + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionReorderColumns(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + Schema reorderedSchema = + new Schema( + Types.NestedField.required(5, "col_e", Types.DoubleType.get()), + Types.NestedField.required(3, "col_c", Types.LongType.get()), + Types.NestedField.required(1, "col_a", Types.StringType.get()), + Types.NestedField.required(4, "col_d", Types.FloatType.get()), + Types.NestedField.required(2, "col_b", Types.IntegerType.get())); + + readAndAssertEngineRecords( + fileFormat, + reorderedSchema, + genericRecords, + record -> copy(record, reorderedSchema, reorderedSchema)); + } + + /** + * Schema evolution: Rename column. Write with DefaultSchema where col_b has field ID 2. Read with + * a schema where the same field ID 2 is renamed to "column_b". Since Iceberg binds by field ID, + * the renamed column should still read the original data correctly. + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionRenameColumn(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + // rename col_b(id=2) -> column_b, col_d(id=4) -> column_d + Schema renamedSchema = + new Schema( + Types.NestedField.required(1, "col_a", Types.StringType.get()), + Types.NestedField.required(2, "column_b", Types.IntegerType.get()), + Types.NestedField.required(3, "col_c", Types.LongType.get()), + Types.NestedField.required(4, "column_d", Types.FloatType.get()), + Types.NestedField.required(5, "col_e", Types.DoubleType.get())); + + readAndAssertEngineRecords( + fileFormat, + renamedSchema, + genericRecords, + record -> { + Record expected = GenericRecord.create(renamedSchema); + expected.setField("col_a", record.getField("col_a")); + expected.setField("column_b", record.getField("col_b")); + expected.setField("col_c", record.getField("col_c")); + expected.setField("column_d", record.getField("col_d")); + expected.setField("col_e", record.getField("col_e")); + return expected; + }); + } + + /** + * Schema evolution: Required → Optional. Write with DefaultSchema where all columns are required. + * Read with a schema where some columns are changed to optional. Iceberg allows widening required + * to optional. The data should still be read correctly. + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionRequiredToOptional(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + // change col_b and col_d to optional + Schema readSchema = + new Schema( + Types.NestedField.required(1, "col_a", Types.StringType.get()), + Types.NestedField.optional(2, "col_b", Types.IntegerType.get()), + Types.NestedField.required(3, "col_c", Types.LongType.get()), + Types.NestedField.optional(4, "col_d", Types.FloatType.get()), + Types.NestedField.required(5, "col_e", Types.DoubleType.get())); + + readAndAssertEngineRecords( + fileFormat, readSchema, genericRecords, record -> copy(record, readSchema, readSchema)); + } + + /** + * Schema evolution: Read with empty projection. Write with DefaultSchema, read with an empty + * schema (no columns). The reader should return the correct number of rows but with no data + * columns. + */ + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testSchemaEvolutionEmptyProjection(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema writeSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + Schema emptySchema = new Schema(); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(emptySchema) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).hasSameSizeAs(genericRecords); + } + + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testReadFileWithoutFieldIdsUsingNameMapping(FileFormat fileFormat) throws IOException { + DataGenerator dataGenerator = new DataGenerators.DefaultSchema(); + Schema icebergSchema = dataGenerator.schema(); + + List genericRecords = dataGenerator.generateRecords(); + + // Write the file WITHOUT Iceberg field IDs (as an external writer would). + writeRecordsWithoutFieldIds(fileFormat, icebergSchema, genericRecords); + + NameMapping nameMapping = MappingUtil.create(icebergSchema); + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(icebergSchema) + .withNameMapping(nameMapping) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertEquals(icebergSchema, convertToEngineRecords(genericRecords, icebergSchema), readRecords); + } + + private void readAndAssertGenericRecords( + FileFormat fileFormat, Schema schema, List expected) throws IOException { + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, Record.class, inputFile) + .project(schema) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + DataTestHelpers.assertEquals(schema.asStruct(), expected, readRecords); + } + + private DataFile writeGenericRecords(FileFormat fileFormat, Schema schema, List records) + throws IOException { + return writeGenericRecords(fileFormat, schema, records, null); + } + + private DataFile writeGenericRecords( + FileFormat fileFormat, Schema schema, List records, MetricsConfig metricsConfig) + throws IOException { + FileWriterBuilder, Object> writerBuilder = + FormatModelRegistry.dataWriteBuilder(fileFormat, Record.class, encryptedFile); + + if (metricsConfig != null) { + writerBuilder.metricsConfig(metricsConfig); + } + + DataWriter writer = + writerBuilder.schema(schema).spec(PartitionSpec.unpartitioned()).build(); + + try (writer) { + records.forEach(writer::write); + } + + DataFile dataFile = writer.toDataFile(); + assertThat(dataFile).isNotNull(); + assertThat(dataFile.recordCount()).isEqualTo(records.size()); + assertThat(dataFile.format()).isEqualTo(fileFormat); + + return dataFile; + } + + private List convertToEngineRecords(List records, Schema schema) { + return records.stream().map(r -> convertToEngine(r, schema)).toList(); + } + + private static void assumeSupports(FileFormat fileFormat, String feature) { + assumeThat(MISSING_FEATURES.getOrDefault(fileFormat, new String[] {})).doesNotContain(feature); + } + + /** + * Returns whether the given file format supports the specified feature. + * + *

The check is based on {@link #MISSING_FEATURES}. Features not listed as missing for a format + * are treated as supported. + * + *

Prefer this method over {@link #assumeSupports(FileFormat, String)} when only part of a test + * should be skipped conditionally. Unlike {@code assumeSupports}, this method does not abort the + * entire test via an assumption failure; it returns {@code false} so callers can skip only + * feature-specific assertions while still validating shared behavior. + * + * @param fileFormat the file format under test + * @param feature the feature name + * @return {@code true} if the feature is supported by the format; {@code false} otherwise + */ + private static boolean supportsFeature(FileFormat fileFormat, String feature) { + String[] missing = MISSING_FEATURES.getOrDefault(fileFormat, new String[] {}); + return !Arrays.asList(missing).contains(feature); + } + + private DataFile writeRecordsForSplit(FileFormat fileFormat, Schema schema, List records) + throws IOException { + + String splitSizeProperty = splitSizeProperty(fileFormat); + DataWriter writer = + FormatModelRegistry.dataWriteBuilder(fileFormat, Record.class, encryptedFile) + .schema(schema) + .spec(PartitionSpec.unpartitioned()) + .set(splitSizeProperty, "1") + .build(); + + try (writer) { + records.forEach(writer::write); + } + + DataFile dataFile = writer.toDataFile(); + List splitOffsets = dataFile.splitOffsets(); + assertThat(splitOffsets) + .as( + "Expected multiple split offsets. " + + "If this fails, the file did not produce multiple splits. " + + "Try reducing the split size property (see writeRecordsForSplit) " + + "or increasing the number of records written.") + .hasSizeGreaterThan(1); + + assertThat(dataFile.format()).isEqualTo(fileFormat); + return dataFile; + } + + private static String splitSizeProperty(FileFormat fileFormat) { + return switch (fileFormat) { + case PARQUET -> TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; + case ORC -> TableProperties.ORC_STRIPE_SIZE_BYTES; + default -> + throw new UnsupportedOperationException( + "No split size property defined for format: " + fileFormat); + }; + } + + private static void assertCounts( + FileFormat fileFormat, Schema schema, List genericRecords, ContentFile file) { + if (!supportsFeature(fileFormat, FEATURE_COLUMN_LEVEL_METRICS)) { + return; + } + + Map valueCounts = file.valueCounts(); + Map nullValueCounts = file.nullValueCounts(); + for (Types.NestedField field : schema.columns()) { + if (field.type().isPrimitiveType()) { + assertThat(valueCounts).containsKey(field.fieldId()); + assertThat(nullValueCounts).containsKey(field.fieldId()); + + long nullCount = + genericRecords.stream().filter(r -> r.getField(field.name()) == null).count(); + + assertThat(valueCounts.get(field.fieldId())).isEqualTo(genericRecords.size()); + assertThat(nullValueCounts.get(field.fieldId())).isEqualTo(nullCount); + } + } + } + + private static void assertBounds( + FileFormat fileFormat, Schema schema, List genericRecords, ContentFile file) { + if (!supportsFeature(fileFormat, FEATURE_COLUMN_LEVEL_METRICS)) { + return; + } + + Map lowerBounds = file.lowerBounds(); + Map upperBounds = file.upperBounds(); + for (Types.NestedField field : schema.columns()) { + if (field.type().isPrimitiveType()) { + assertThat(lowerBounds).containsKey(field.fieldId()); + assertThat(upperBounds).containsKey(field.fieldId()); + + ByteBuffer lowerBuffer = lowerBounds.get(field.fieldId()); + ByteBuffer upperBuffer = upperBounds.get(field.fieldId()); + + Comparator cmp = Comparators.forType(field.type().asPrimitiveType()); + + Object[] minMax = computeMinMax(genericRecords, field, cmp); + Object expectedMin = minMax[0]; + Object expectedMax = minMax[1]; + + if (expectedMin != null) { + assertThat(lowerBuffer).isNotNull(); + Object actualLower = Conversions.fromByteBuffer(field.type(), lowerBuffer); + assertThat(cmp.compare(actualLower, expectedMin)).isEqualTo(0); + } + + if (expectedMax != null) { + assertThat(upperBuffer).isNotNull(); + Object actualUpper = Conversions.fromByteBuffer(field.type(), upperBuffer); + assertThat(cmp.compare(actualUpper, expectedMax)).isEqualTo(0); + } + } + } + } + + private static Object[] computeMinMax( + List records, Types.NestedField field, Comparator cmp) { + Object min = null; + Object max = null; + for (Record record : records) { + Object value = record.getField(field.name()); + if (value == null) { + continue; + } + + if (value instanceof Float && ((Float) value).isNaN()) { + continue; + } + + if (value instanceof Double && ((Double) value).isNaN()) { + continue; + } + + if (min == null || cmp.compare(value, min) < 0) { + min = value; + } + + if (max == null || cmp.compare(value, max) > 0) { + max = value; + } + } + + return new Object[] {min, max}; + } + + private static void assertBoundsNull(Schema schema, ContentFile file) { + Map lowerBounds = file.lowerBounds(); + Map upperBounds = file.upperBounds(); + for (Types.NestedField field : schema.columns()) { + if (field.type().isPrimitiveType()) { + assertThat(lowerBounds == null || lowerBounds.get(field.fieldId()) == null).isTrue(); + assertThat(upperBounds == null || upperBounds.get(field.fieldId()) == null).isTrue(); + } + } + } + + private static void assertColumnSize(FileFormat fileFormat, ContentFile file) { + if (!supportsFeature(fileFormat, FEATURE_COLUMN_LEVEL_METRICS)) { + return; + } + + assertThat(file.columnSizes()).isNotNull().isNotEmpty(); + } + + private static void assertColumnSizeEmpty(FileFormat fileFormat, ContentFile file) { + if (!supportsFeature(fileFormat, FEATURE_COLUMN_LEVEL_METRICS)) { + return; + } + + assertThat(file.columnSizes()).isEmpty(); + } + + private static void assertCountsNull(Schema schema, ContentFile file) { + Map valueCounts = file.valueCounts(); + Map nullValueCounts = file.nullValueCounts(); + for (Types.NestedField field : schema.columns()) { + if (field.type().isPrimitiveType()) { + assertThat(valueCounts == null || valueCounts.get(field.fieldId()) == null).isTrue(); + assertThat(nullValueCounts == null || nullValueCounts.get(field.fieldId()) == null) + .isTrue(); + } + } + } + + private static void assertNanCounts( + FileFormat fileFormat, Schema schema, List records, ContentFile file) { + if (!supportsFeature(fileFormat, FEATURE_COLUMN_LEVEL_METRICS)) { + return; + } + + Map nanValueCounts = file.nanValueCounts(); + assertThat(nanValueCounts).isNotNull(); + + for (Types.NestedField field : schema.columns()) { + if (field.type().typeId() == Type.TypeID.FLOAT + || field.type().typeId() == Type.TypeID.DOUBLE) { + long expectedNanCount = + records.stream() + .map(r -> r.getField(field.name())) + .filter( + v -> + (v instanceof Float && ((Float) v).isNaN()) + || (v instanceof Double && ((Double) v).isNaN())) + .count(); + assertThat(nanValueCounts.get(field.fieldId())).isEqualTo(expectedNanCount); + } + } + } + + private DeleteFile writePositionDeletes(FileFormat fileFormat, List> deletes) + throws IOException { + FileWriterBuilder, ?> writerBuilder = + FormatModelRegistry.positionDeleteWriteBuilder(fileFormat, encryptedFile); + + PositionDeleteWriter writer = writerBuilder.spec(PartitionSpec.unpartitioned()).build(); + try (writer) { + deletes.forEach(writer::write); + } + + return writer.toDeleteFile(); + } + + private void assertPositionDeleteMetrics( + FileFormat fileFormat, + List> deletes, + DeleteFile deleteFile, + boolean checkBounds) { + Schema positionDeleteSchema = DeleteSchemaUtil.pathPosSchema(); + + assertThat(deleteFile).isNotNull(); + assertThat(deleteFile.recordCount()).isEqualTo(deletes.size()); + assertCountsNull(positionDeleteSchema, deleteFile); + + assumeSupports(fileFormat, FEATURE_COLUMN_LEVEL_METRICS); + + if (checkBounds) { + // Single file reference: bounds are preserved + List genericRecords = + deletes.stream() + .map( + d -> + GenericRecord.create(positionDeleteSchema) + .copy( + DELETE_FILE_PATH.name(), d.path(), + DELETE_FILE_POS.name(), d.pos())) + .toList(); + assertBounds(fileFormat, positionDeleteSchema, genericRecords, deleteFile); + } else { + // Multiple file references: bounds are also removed + assertBoundsNull(positionDeleteSchema, deleteFile); + } + } + + private MetricsConfig config(Schema schema, MetricsMode defaultMode) { + return config(schema, defaultMode, ImmutableMap.of()); + } + + private MetricsConfig config( + Schema schema, MetricsMode defaultMode, Map columnModes) { + ImmutableMap.Builder properties = ImmutableMap.builder(); + properties.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, defaultMode.toString()); + columnModes.forEach( + (column, mode) -> + properties.put( + TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + column, mode.toString())); + + TestTables.TestTable table = + TestTables.create( + tableDir, "test", schema, PartitionSpec.unpartitioned(), 3, properties.build()); + + return MetricsConfig.forTable(table); + } + + private void assertTruncateBoundsForFirstColumn( + FileFormat fileFormat, + Schema schema, + List records, + int truncateLength, + String requiredFeature, + BiConsumer boundsAssertion) + throws IOException { + MetricsConfig truncateConfig = config(schema, MetricsModes.Truncate.withLength(truncateLength)); + + DataFile dataFile = writeGenericRecords(fileFormat, schema, records, truncateConfig); + assertCounts(fileFormat, schema, records, dataFile); + + if (!supportsFeature(fileFormat, requiredFeature)) { + return; + } + + Map lowerBounds = dataFile.lowerBounds(); + Map upperBounds = dataFile.upperBounds(); + + assertThat(lowerBounds).containsKey(1); + assertThat(upperBounds).containsKey(1); + + boundsAssertion.accept(lowerBounds.get(1), upperBounds.get(1)); + + Schema intSchema = new Schema(schema.findField("col_int")); + assertBounds(fileFormat, intSchema, records, dataFile); + + assertThat(dataFile.columnSizes()).isNotNull().isNotEmpty(); + } + + private Map convertConstantsToEngine( + Schema projectionSchema, Map idToConstant) { + return idToConstant.entrySet().stream() + .collect( + ImmutableMap.toImmutableMap( + Map.Entry::getKey, + entry -> + convertConstantToEngine( + projectionSchema.findType(entry.getKey()), entry.getValue()))); + } + + private static Record structLikeToRecord(StructLike structLike, Types.StructType structType) { + Record record = GenericRecord.create(structType); + int sourceSize = structLike.size(); + for (int i = 0; i < structType.fields().size(); i++) { + if (i < sourceSize) { + record.set(i, structLike.get(i, Object.class)); + } else { + Types.NestedField field = structType.fields().get(i); + record.set(i, field.initialDefault()); + } + } + + return record; + } + + private void readAndAssertMetadataColumn( + FileFormat fileFormat, + Schema projectionSchema, + Map idToConstant, + List sourceRecords, + Function transform) + throws IOException { + readAndAssertMetadataColumn( + fileFormat, projectionSchema, idToConstant, sourceRecords.stream().map(transform).toList()); + } + + private void readAndAssertMetadataColumn( + FileFormat fileFormat, + Schema projectionSchema, + Map idToConstant, + List sourceRecords, + BiFunction transform) + throws IOException { + readAndAssertMetadataColumn( + fileFormat, + projectionSchema, + idToConstant, + IntStream.range(0, sourceRecords.size()) + .mapToObj(index -> transform.apply(index, sourceRecords.get(index))) + .toList()); + } + + private void readAndAssertMetadataColumn( + FileFormat fileFormat, + Schema projectionSchema, + Map idToConstant, + List expectedRecords) + throws IOException { + + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + + var readerBuilder = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(projectionSchema); + + if (idToConstant != null) { + readerBuilder.idToConstant(convertConstantsToEngine(projectionSchema, idToConstant)); + } + + try (CloseableIterable reader = readerBuilder.build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).hasSize(expectedRecords.size()); + assertEquals( + projectionSchema, convertToEngineRecords(expectedRecords, projectionSchema), readRecords); + } + + private static Record copy(Record source, Schema sourceSchema, Schema targetSchema) { + Record result = GenericRecord.create(targetSchema); + for (Types.NestedField col : sourceSchema.columns()) { + result.setField(col.name(), source.getField(col.name())); + } + + return result; + } + + private void writeRecordsWithoutFieldIds( + FileFormat fileFormat, Schema schema, List records) throws IOException { + switch (fileFormat) { + case PARQUET -> writeParquetWithoutFieldIds(schema, records); + case AVRO -> writeAvroWithoutFieldIds(schema, records); + case ORC -> writeOrcWithoutFieldIds(schema, records); + default -> throw new UnsupportedOperationException("Unsupported file format: " + fileFormat); + } + } + + private void writeAvroWithoutFieldIds(Schema schema, List records) throws IOException { + org.apache.avro.Schema avroSchemaWithoutIds = AvroTestHelpers.removeIds(schema); + + OutputFile outputFile = encryptedFile.encryptingOutputFile(); + DatumWriter datumWriter = new GenericDatumWriter<>(avroSchemaWithoutIds); + try (OutputStream out = outputFile.create(); + DataFileWriter writer = new DataFileWriter<>(datumWriter)) { + writer.create(avroSchemaWithoutIds, out); + for (Record record : records) { + GenericData.Record avroRecord = new GenericData.Record(avroSchemaWithoutIds); + for (Types.NestedField field : schema.columns()) { + avroRecord.put(field.name(), record.getField(field.name())); + } + + writer.append(avroRecord); + } + } + + try (DataFileStream reader = + new DataFileStream<>(outputFile.toInputFile().newStream(), new GenericDatumReader<>())) { + assertThat(AvroTestHelpers.hasIds(reader.getSchema())).isFalse(); + } + } + + private void writeParquetWithoutFieldIds(Schema schema, List records) throws IOException { + org.apache.avro.Schema avroSchemaWithoutIds = AvroTestHelpers.removeIds(schema); + + OutputFile outputFile = encryptedFile.encryptingOutputFile(); + + try (ParquetWriter writer = + AvroParquetWriter.builder(ParquetFileTestUtils.file(outputFile)) + .withDataModel(GenericData.get()) + .withSchema(avroSchemaWithoutIds) + .withConf(new Configuration()) + .build()) { + for (Record record : records) { + GenericData.Record avroRecord = new GenericData.Record(avroSchemaWithoutIds); + for (Types.NestedField field : schema.columns()) { + avroRecord.put(field.name(), record.getField(field.name())); + } + + writer.write(avroRecord); + } + } + + try (ParquetFileReader reader = + ParquetFileReader.open(ParquetFileTestUtils.file(outputFile.toInputFile()))) { + assertThat(ParquetSchemaUtil.hasIds(reader.getFooter().getFileMetaData().getSchema())) + .isFalse(); + } + } + + private void writeOrcWithoutFieldIds(Schema schema, List records) throws IOException { + TypeDescription typeWithIds = ORCSchemaUtil.convert(schema); + TypeDescription typeWithoutIds = TestORCSchemaUtil.removeIds(typeWithIds); + + OutputFile outputFile = encryptedFile.encryptingOutputFile(); + Path hadoopPath = new Path(outputFile.location()); + + Configuration conf = new Configuration(); + OrcFile.WriterOptions options = + OrcFile.writerOptions(conf) + .useUTCTimestamp(true) + .setSchema(typeWithoutIds) + .fileSystem(OrcWritingTestUtils.outputFileSystem(outputFile)); + + OrcRowWriter rowWriter = GenericOrcWriter.buildWriter(schema, typeWithIds); + + try (Writer orcWriter = OrcFile.createWriter(hadoopPath, options)) { + VectorizedRowBatch batch = typeWithoutIds.createRowBatch(); + for (Record record : records) { + rowWriter.write(record, batch); + if (batch.size == batch.getMaxSize()) { + orcWriter.addRowBatch(batch); + batch.reset(); + } + } + + if (batch.size > 0) { + orcWriter.addRowBatch(batch); + batch.reset(); + } + } + + InputFile inputFile = outputFile.toInputFile(); + OrcFile.ReaderOptions readerOptions = + OrcFile.readerOptions(conf) + .useUTCTimestamp(true) + .filesystem(OrcWritingTestUtils.inputFileSystem(inputFile)) + .maxLength(inputFile.getLength()); + + try (Reader reader = OrcFile.createReader(hadoopPath, readerOptions)) { + assertThat(TestORCSchemaUtil.hasIds(reader.getSchema())).isFalse(); + } + } + + private void runTypePromotionCheck( + FileFormat fileFormat, Type fromType, Type toType, Function promoteValue) + throws IOException { + String columnName = "col"; + Schema writeSchema = new Schema(Types.NestedField.required(1, columnName, fromType)); + Schema readSchema = new Schema(Types.NestedField.required(1, columnName, toType)); + + List genericRecords = RandomGenericData.generate(writeSchema, 10, 1L); + writeGenericRecords(fileFormat, writeSchema, genericRecords); + + readAndAssertEngineRecords( + fileFormat, + readSchema, + genericRecords, + record -> { + Record expected = GenericRecord.create(readSchema); + expected.setField(columnName, promoteValue.apply(record.getField(columnName))); + return expected; + }); + } + + private void readAndAssertEngineRecords( + FileFormat fileFormat, + Schema readSchema, + List sourceRecords, + Function converter) + throws IOException { + List expectedGenericRecords = sourceRecords.stream().map(converter).toList(); + InputFile inputFile = encryptedFile.encryptingOutputFile().toInputFile(); + List readRecords; + try (CloseableIterable reader = + FormatModelRegistry.readBuilder(fileFormat, engineType(), inputFile) + .project(readSchema) + .build()) { + readRecords = ImmutableList.copyOf(reader); + } + + assertThat(readRecords).hasSize(expectedGenericRecords.size()); + assertEquals( + readSchema, convertToEngineRecords(expectedGenericRecords, readSchema), readRecords); + } +} diff --git a/data/src/test/java/org/apache/iceberg/data/DataGenerator.java b/data/src/test/java/org/apache/iceberg/data/DataGenerator.java new file mode 100644 index 000000000000..ad068fbc686c --- /dev/null +++ b/data/src/test/java/org/apache/iceberg/data/DataGenerator.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.data; + +import java.util.List; +import org.apache.iceberg.Schema; + +/** Interface for generating test data with different schema types. */ +interface DataGenerator { + + /** Returns the Iceberg schema for this test data. */ + Schema schema(); + + /** Generates test records using RandomGenericData. */ + default List generateRecords() { + return generateRecords(10); + } + + /** Generates test records using RandomGenericData. */ + default List generateRecords(int count) { + return RandomGenericData.generate(schema(), count, 1L); + } +} diff --git a/data/src/test/java/org/apache/iceberg/data/DataGenerators.java b/data/src/test/java/org/apache/iceberg/data/DataGenerators.java new file mode 100644 index 000000000000..390c0949cb72 --- /dev/null +++ b/data/src/test/java/org/apache/iceberg/data/DataGenerators.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.data; + +import static org.apache.iceberg.types.Types.NestedField.required; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.types.Types; + +/** + * Test data generators for different schema types. Add new generators to ALL array to include them + * in format model tests. + */ +class DataGenerators { + + static final DataGenerator[] ALL = new DataGenerator[] {new StructOfPrimitive()}; + + private DataGenerators() {} + + static class StructOfPrimitive implements DataGenerator { + private final Schema schema = + new Schema( + Types.NestedField.required(1, "row_id", Types.StringType.get()), + Types.NestedField.required( + 2, + "struct_of_primitive", + Types.StructType.of( + required(101, "id", Types.IntegerType.get()), + required(102, "name", Types.StringType.get())))); + + @Override + public Schema schema() { + return schema; + } + } + + static class DefaultSchema implements DataGenerator { + private final Schema schema = + new Schema( + Types.NestedField.required(1, "col_a", Types.StringType.get()), + Types.NestedField.required(2, "col_b", Types.IntegerType.get()), + Types.NestedField.required(3, "col_c", Types.LongType.get()), + Types.NestedField.required(4, "col_d", Types.FloatType.get()), + Types.NestedField.required(5, "col_e", Types.DoubleType.get())); + + @Override + public Schema schema() { + return schema; + } + } + + static class FloatDoubleSchema implements DataGenerator { + private final Schema schema = + new Schema( + Types.NestedField.required(1, "col_float", Types.FloatType.get()), + Types.NestedField.required(2, "col_double", Types.DoubleType.get())); + + @Override + public Schema schema() { + return schema; + } + } +} diff --git a/data/src/test/java/org/apache/iceberg/data/TestMetricsRowGroupFilter.java b/data/src/test/java/org/apache/iceberg/data/TestMetricsRowGroupFilter.java index e1f9f07e7a37..3cb46b309d82 100644 --- a/data/src/test/java/org/apache/iceberg/data/TestMetricsRowGroupFilter.java +++ b/data/src/test/java/org/apache/iceberg/data/TestMetricsRowGroupFilter.java @@ -577,7 +577,7 @@ public void testIntegerLt() { assertThat(shouldRead).as("Should read: one possible id").isTrue(); shouldRead = shouldRead(lessThan("id", INT_MAX_VALUE)); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -609,7 +609,7 @@ public void testIntegerGt() { assertThat(shouldRead).as("Should read: one possible id").isTrue(); shouldRead = shouldRead(greaterThan("id", INT_MAX_VALUE - 4)); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -624,7 +624,7 @@ public void testIntegerGtEq() { assertThat(shouldRead).as("Should read: one possible id").isTrue(); shouldRead = shouldRead(greaterThanOrEqual("id", INT_MAX_VALUE - 4)); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -713,7 +713,7 @@ public void testStructFieldLt() { assertThat(shouldRead).as("Should read: one possible id").isTrue(); shouldRead = shouldRead(lessThan("struct_not_null.int_field", INT_MAX_VALUE)); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -746,7 +746,7 @@ public void testStructFieldGt() { assertThat(shouldRead).as("Should read: one possible id").isTrue(); shouldRead = shouldRead(greaterThan("struct_not_null.int_field", INT_MAX_VALUE - 4)); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -762,7 +762,7 @@ public void testStructFieldGtEq() { assertThat(shouldRead).as("Should read: one possible id").isTrue(); shouldRead = shouldRead(greaterThanOrEqual("struct_not_null.int_field", INT_MAX_VALUE - 4)); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate diff --git a/data/src/test/java/org/apache/iceberg/data/orc/TestGenericData.java b/data/src/test/java/org/apache/iceberg/data/orc/TestGenericData.java index 3a7fec6962bb..cc318b2f53b1 100644 --- a/data/src/test/java/org/apache/iceberg/data/orc/TestGenericData.java +++ b/data/src/test/java/org/apache/iceberg/data/orc/TestGenericData.java @@ -68,6 +68,11 @@ protected boolean supportsUnknown() { return true; } + @Override + protected boolean supportsRowLineage() { + return true; + } + /** Orc writers don't have notion of non-null / required fields. */ @Override protected boolean allowsWritingNullValuesForRequiredFields() { @@ -250,13 +255,15 @@ private void writeAndValidateRecords(Schema schema, List expected) throw try (CloseableIterable reader = ORC.read(Files.localInput(testFile)) .project(schema) - .createReaderFunc(fileSchema -> GenericOrcReader.buildReader(schema, fileSchema)) + .createReaderFunc( + fileSchema -> GenericOrcReader.buildReader(schema, fileSchema, ID_TO_CONSTANT)) .build()) { rows = Lists.newArrayList(reader); } for (int i = 0; i < expected.size(); i += 1) { - DataTestHelpers.assertEquals(schema.asStruct(), expected.get(i), rows.get(i)); + DataTestHelpers.assertEquals( + schema.asStruct(), expected.get(i), rows.get(i), ID_TO_CONSTANT, i); } } } diff --git a/data/src/test/java/org/apache/iceberg/io/TestDVWriters.java b/data/src/test/java/org/apache/iceberg/io/TestDVWriters.java index 70330901f4bb..b89f04a3c0e8 100644 --- a/data/src/test/java/org/apache/iceberg/io/TestDVWriters.java +++ b/data/src/test/java/org/apache/iceberg/io/TestDVWriters.java @@ -23,6 +23,8 @@ import java.io.File; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -30,11 +32,13 @@ import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Files; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.RowDelta; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.TestHelpers; @@ -42,10 +46,14 @@ import org.apache.iceberg.data.DeleteLoader; import org.apache.iceberg.deletes.BaseDVFileWriter; import org.apache.iceberg.deletes.DVFileWriter; +import org.apache.iceberg.deletes.Deletes; import org.apache.iceberg.deletes.PositionDelete; import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedFiles; import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.encryption.EncryptionKeyMetadata; +import org.apache.iceberg.encryption.NativeEncryptionKeyMetadata; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; @@ -81,6 +89,44 @@ public void setupTable() throws Exception { OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PARQUET).build(); } + @TestTemplate + public void testDVWriterUsesNativeEncryptionKeyMetadataWithFileSize() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + OutputFile outputFile = Files.localOutput(temp.resolve("native-dv.puffin").toString()); + TestNativeEncryptionKeyMetadata keyMetadata = new TestNativeEncryptionKeyMetadata(123L); + EncryptedOutputFile encryptedOutputFile = + EncryptedFiles.encryptedOutput(outputFile, keyMetadata); + + DVFileWriter writer = Deletes.writeDVs(encryptedOutputFile, path -> null); + writer.delete("/path/to/data.parquet", 1L, PartitionSpec.unpartitioned(), null); + writer.close(); + + DeleteFile deleteFile = Iterables.getOnlyElement(writer.result().deleteFiles()); + long fileSize = deleteFile.fileSizeInBytes(); + + assertThat(fileSize).isPositive(); + assertThat(decode(deleteFile.keyMetadata())).isEqualTo(fileSize); + } + + @TestTemplate + public void testDVWriterPreservesNonNativeEncryptionKeyMetadata() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + OutputFile outputFile = Files.localOutput(temp.resolve("non-native-dv.puffin").toString()); + ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); + buffer.putLong(456L); + buffer.flip(); + EncryptedOutputFile encryptedOutputFile = EncryptedFiles.encryptedOutput(outputFile, buffer); + + DVFileWriter writer = Deletes.writeDVs(encryptedOutputFile, path -> null); + writer.delete("/path/to/data.parquet", 1L, PartitionSpec.unpartitioned(), null); + writer.close(); + + DeleteFile deleteFile = Iterables.getOnlyElement(writer.result().deleteFiles()); + assertThat(decode(deleteFile.keyMetadata())).isEqualTo(456L); + } + @TestTemplate public void testBasicDVs() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(3); @@ -150,8 +196,9 @@ public void testRewriteDVs() throws IOException { // commit the first DV commit(result1); - assertThat(table.currentSnapshot().addedDeleteFiles(table.io())).hasSize(1); - assertThat(table.currentSnapshot().removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes1 = SnapshotChanges.builderFor(table).build(); + assertThat(changes1.addedDeleteFiles()).hasSize(1); + assertThat(changes1.removedDeleteFiles()).isEmpty(); // verify correctness after committing the first DV assertRows(ImmutableList.of(toRow(1, "aaa"), toRow(3, "aaa"))); @@ -174,8 +221,9 @@ public void testRewriteDVs() throws IOException { // replace DVs commit(result2); - assertThat(table.currentSnapshot().addedDeleteFiles(table.io())).hasSize(1); - assertThat(table.currentSnapshot().removedDeleteFiles(table.io())).hasSize(1); + SnapshotChanges changes2 = SnapshotChanges.builderFor(table).build(); + assertThat(changes2.addedDeleteFiles()).hasSize(1); + assertThat(changes2.removedDeleteFiles()).hasSize(1); // verify correctness after replacing DVs assertRows(ImmutableList.of(toRow(1, "aaa"))); @@ -220,8 +268,9 @@ public void testRewriteFileScopedPositionDeletes() throws IOException { // replace the position delete file with the DV commit(result); - assertThat(table.currentSnapshot().addedDeleteFiles(table.io())).hasSize(1); - assertThat(table.currentSnapshot().removedDeleteFiles(table.io())).hasSize(1); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + assertThat(changes.addedDeleteFiles()).hasSize(1); + assertThat(changes.removedDeleteFiles()).hasSize(1); // verify correctness assertRows(ImmutableList.of(toRow(3, "aaa"))); @@ -299,8 +348,9 @@ public void testApplyPartitionScopedPositionDeletes() throws IOException { // commit the DV, ensuring the position delete file remains commit(result); - assertThat(table.currentSnapshot().addedDeleteFiles(table.io())).hasSize(1); - assertThat(table.currentSnapshot().removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + assertThat(changes.addedDeleteFiles()).hasSize(1); + assertThat(changes.removedDeleteFiles()).isEmpty(); // verify correctness with DVs and position delete files assertRows(ImmutableList.of(toRow(3, "aaa"), toRow(6, "aaa"))); @@ -349,6 +399,47 @@ private DeleteFile writePositionDeletes( return writer.toDeleteFile(); } + private static long decode(ByteBuffer buffer) { + return buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN).getLong(); + } + + private record TestNativeEncryptionKeyMetadata(long encodedValue) + implements NativeEncryptionKeyMetadata { + + @Override + public ByteBuffer encryptionKey() { + return ByteBuffer.allocate(16); + } + + @Override + public ByteBuffer aadPrefix() { + return ByteBuffer.allocate(4); + } + + @Override + public Long fileLength() { + return encodedValue; + } + + @Override + public ByteBuffer buffer() { + ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); + buffer.putLong(encodedValue); + buffer.flip(); + return buffer; + } + + @Override + public EncryptionKeyMetadata copy() { + return new TestNativeEncryptionKeyMetadata(encodedValue); + } + + @Override + public NativeEncryptionKeyMetadata copyWithLength(long length) { + return new TestNativeEncryptionKeyMetadata(length); + } + } + private static class PreviousDeleteLoader implements Function { private final Map deleteFiles; private final DeleteLoader deleteLoader; diff --git a/data/src/test/java/org/apache/iceberg/orc/TestOrcMetrics.java b/data/src/test/java/org/apache/iceberg/orc/TestOrcMetrics.java index d85a73dbd233..940cf4522291 100644 --- a/data/src/test/java/org/apache/iceberg/orc/TestOrcMetrics.java +++ b/data/src/test/java/org/apache/iceberg/orc/TestOrcMetrics.java @@ -109,12 +109,12 @@ private boolean isBinaryType(Type type) { @Override protected void assertBounds( - int fieldId, Type type, T lowerBound, T upperBound, Metrics metrics) { + int fieldId, Type type, T lowerBound, T upperBound, MetricsWithStats metricsWithStats) { if (isBinaryType(type)) { - assertThat(metrics.lowerBounds()).doesNotContainKey(fieldId); - assertThat(metrics.upperBounds()).doesNotContainKey(fieldId); + assertThat(metricsWithStats.metrics().lowerBounds()).doesNotContainKey(fieldId); + assertThat(metricsWithStats.metrics().upperBounds()).doesNotContainKey(fieldId); return; } - super.assertBounds(fieldId, type, lowerBound, upperBound, metrics); + super.assertBounds(fieldId, type, lowerBound, upperBound, metricsWithStats); } } diff --git a/dell/src/main/java/org/apache/iceberg/dell/ecs/EcsCatalog.java b/dell/src/main/java/org/apache/iceberg/dell/ecs/EcsCatalog.java index 07ad68365837..bb8150d16dca 100644 --- a/dell/src/main/java/org/apache/iceberg/dell/ecs/EcsCatalog.java +++ b/dell/src/main/java/org/apache/iceberg/dell/ecs/EcsCatalog.java @@ -59,6 +59,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.io.ByteStreams; import org.apache.iceberg.util.LocationUtil; +import org.apache.iceberg.util.PropertyUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,6 +87,7 @@ public class EcsCatalog extends BaseMetastoreCatalog private FileIO fileIO; private CloseableGroup closeableGroup; private Map catalogProperties; + private boolean uniqueTableLocation; /** * No-arg constructor to load the catalog dynamically. @@ -102,6 +104,12 @@ public void initialize(String name, Map properties) { !Strings.isNullOrEmpty(inputWarehouseLocation), "Cannot initialize EcsCatalog because warehousePath must not be null or empty"); + this.uniqueTableLocation = + PropertyUtil.propertyAsBoolean( + properties, + CatalogProperties.UNIQUE_TABLE_LOCATION, + CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT); + this.catalogName = name; this.warehouseLocation = new EcsURI(LocationUtil.stripTrailingSlash(inputWarehouseLocation)); this.client = DellClientFactories.from(properties).ecsS3(); @@ -136,8 +144,8 @@ protected TableOperations newTableOps(TableIdentifier tableIdentifier) { @Override protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { - return String.format( - "%s/%s", namespacePrefix(tableIdentifier.namespace()), tableIdentifier.name()); + String tableLocation = LocationUtil.tableLocation(tableIdentifier, uniqueTableLocation); + return String.format("%s/%s", namespacePrefix(tableIdentifier.namespace()), tableLocation); } /** Iterate all table objects with the namespace prefix. */ diff --git a/dell/src/test/java/org/apache/iceberg/dell/ecs/TestEcsCatalog.java b/dell/src/test/java/org/apache/iceberg/dell/ecs/TestEcsCatalog.java index 4714d37d72b9..82549f1eccd9 100644 --- a/dell/src/test/java/org/apache/iceberg/dell/ecs/TestEcsCatalog.java +++ b/dell/src/test/java/org/apache/iceberg/dell/ecs/TestEcsCatalog.java @@ -55,11 +55,17 @@ public class TestEcsCatalog { @BeforeEach public void before() { - ecsCatalog = new EcsCatalog(); + ecsCatalog = createCatalog("test", ImmutableMap.of()); + } + + private EcsCatalog createCatalog(String name, Map additionalProperties) { + EcsCatalog catalog = new EcsCatalog(); Map properties = Maps.newHashMap(); properties.put(CatalogProperties.WAREHOUSE_LOCATION, new EcsURI(rule.bucket(), "").location()); properties.putAll(rule.clientProperties()); - ecsCatalog.initialize("test", properties); + properties.putAll(additionalProperties); + catalog.initialize(name, properties); + return catalog; } @AfterEach @@ -172,6 +178,30 @@ public void testRenameTable() { .isTrue(); } + @Test + public void testCreateTableInUniqueLocation() throws Exception { + try (EcsCatalog catalog = + createCatalog( + "unique_location_catalog", + ImmutableMap.of(CatalogProperties.UNIQUE_TABLE_LOCATION, "true"))) { + + Namespace ns = Namespace.of("a"); + TableIdentifier tableIdent = TableIdentifier.of(ns, "t1"); + TableIdentifier renamedIdent = TableIdentifier.of(ns, "t2"); + + catalog.createNamespace(ns); + catalog.createTable(tableIdent, SCHEMA); + catalog.renameTable(tableIdent, renamedIdent); + + Table table = catalog.createTable(tableIdent, SCHEMA); + Table renamedTable = catalog.loadTable(renamedIdent); + + assertThat(table.location()) + .as("Should have a different table location") + .isNotEqualTo(renamedTable.location()); + } + } + @Test public void testRegisterTable() { TableIdentifier identifier = TableIdentifier.of("a", "t1"); diff --git a/delta-lake/src/integration/java/org/apache/iceberg/delta/TestSnapshotDeltaLakeTable.java b/delta-lake/src/integration/java/org/apache/iceberg/delta/TestSnapshotDeltaLakeTable.java index b2dae682d296..c71d435f3228 100644 --- a/delta-lake/src/integration/java/org/apache/iceberg/delta/TestSnapshotDeltaLakeTable.java +++ b/delta-lake/src/integration/java/org/apache/iceberg/delta/TestSnapshotDeltaLakeTable.java @@ -47,6 +47,7 @@ import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.net.URLCodec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.TableIdentifier; @@ -459,9 +460,10 @@ private void checkDataFilePathsIntegrity( deltaLog.update().getAllFiles().stream() .map(f -> getFullFilePath(f.getPath(), deltaLog.getPath().toString())) .collect(Collectors.toList()); - icebergTable - .currentSnapshot() - .addedDataFiles(icebergTable.io()) + SnapshotChanges.builderFor(icebergTable) + .snapshot(icebergTable.currentSnapshot()) + .build() + .addedDataFiles() .forEach( dataFile -> { assertThat(URI.create(dataFile.location()).isAbsolute()).isTrue(); diff --git a/deploy.gradle b/deploy.gradle index 740d0056273b..65836bf1b3f1 100644 --- a/deploy.gradle +++ b/deploy.gradle @@ -75,7 +75,6 @@ subprojects { } else if (isOpenApi) { artifact testJar artifact testFixturesJar - artifact shadowJar } else { if (tasks.matching({task -> task.name == 'shadowJar'}).isEmpty()) { from components.java diff --git a/dev/.rat-excludes b/dev/.rat-excludes index 6dbaa6fccdc5..f94ef5bf8988 100644 --- a/dev/.rat-excludes +++ b/dev/.rat-excludes @@ -5,31 +5,28 @@ iceberg-build.properties .java-version books.json new-books.json -build -.gitignore -.git -.gradle -.idea -.rat-excludes -LICENSE -NOTICE -revapi.yml +**/build/** +.gradle/** +dev/.rat-excludes +**/LICENSE +**/NOTICE +**/revapi.yml gradlew -examples/* -gradle/* -.*\.sql -.*\.iml -.*\.iws -.*\.html -.*\.css -.*\.js -.*\.svg -.*\.lock -.*\.json -.*\.bin -.*\.prefs -package-list +examples/** +gradle/** +**/*.sql +**/*.html +**/*.css +**/*.js +**/*.svg +**/*.lock +**/*.json +**/*.bin +**/*.prefs +**/package-list sitemap.xml -derby.log +**/derby.log .python-version -.*_index.md +**/*_index.md +**/.venv/** +**/runtime-deps.txt diff --git a/dev/check-license b/dev/check-license index e929e68bf93c..afbe5e9898e3 100755 --- a/dev/check-license +++ b/dev/check-license @@ -58,7 +58,7 @@ else declare java_cmd=java fi -export RAT_VERSION=0.16.1 +export RAT_VERSION=0.17 export rat_jar="$FWDIR"/lib/apache-rat-${RAT_VERSION}.jar mkdir -p "$FWDIR"/lib @@ -67,20 +67,12 @@ mkdir -p "$FWDIR"/lib exit 1 } -mkdir -p build -$java_cmd -jar "$rat_jar" --scan-hidden-directories -E "$FWDIR"/dev/.rat-excludes -d "$FWDIR" > build/rat-results.txt +$java_cmd -jar "$rat_jar" \ + --input-exclude-file "$FWDIR"/dev/.rat-excludes \ + --input-exclude-std GIT IDEA MAC \ + --input-include-std HIDDEN_DIR \ + --output-style missing-headers \ + --log-level ERROR \ + -- "$FWDIR" || exit 1 -if [ $? -ne 0 ]; then - echo "RAT exited abnormally" - exit 1 -fi - -ERRORS="$(cat build/rat-results.txt | grep -e "??")" - -if test ! -z "$ERRORS"; then - echo "Could not find Apache license headers in the following files:" - echo "$ERRORS" - exit 1 -else - echo -e "RAT checks passed." -fi +echo "RAT checks passed." diff --git a/dev/stage-binaries.sh b/dev/stage-binaries.sh index ec3080575b86..50f984eb404e 100755 --- a/dev/stage-binaries.sh +++ b/dev/stage-binaries.sh @@ -20,7 +20,7 @@ SCALA_VERSION=2.12 FLINK_VERSIONS=1.20,2.0,2.1 -SPARK_VERSIONS=3.4,3.5,4.0 +SPARK_VERSIONS=3.4,3.5,4.0,4.1 KAFKA_VERSIONS=3 ./gradlew -Prelease -DscalaVersion=$SCALA_VERSION -DflinkVersions=$FLINK_VERSIONS -DsparkVersions=$SPARK_VERSIONS -DkafkaVersions=$KAFKA_VERSIONS publishApachePublicationToMavenRepository --no-parallel --no-configuration-cache diff --git a/doap.rdf b/doap.rdf index 0ed7548a89b1..38ea90fe9ed5 100644 --- a/doap.rdf +++ b/doap.rdf @@ -42,9 +42,9 @@ - 1.10.0 - 2025-09-11 - 1.10.0 + 1.10.1 + 2025-12-22 + 1.10.1 diff --git a/docker/iceberg-flink-quickstart/Dockerfile b/docker/iceberg-flink-quickstart/Dockerfile new file mode 100644 index 000000000000..cdfa156d530c --- /dev/null +++ b/docker/iceberg-flink-quickstart/Dockerfile @@ -0,0 +1,49 @@ +# +# 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. +# + +# Version arguments - can be overridden at build time +ARG FLINK_VERSION=2.0 + +FROM apache/flink:${FLINK_VERSION}-java21 + +SHELL ["/bin/bash", "-c"] + +# Redeclare ARG variables after FROM to make them available in subsequent layers +ARG ICEBERG_FLINK_RUNTIME_VERSION=2.0 +ARG ICEBERG_VERSION=1.10.1 +ARG HADOOP_VERSION=3.4.2 + +# Switch to flink user for installation +USER flink + +WORKDIR /opt/flink + +# Install Iceberg Flink runtime and AWS bundle JARs +RUN echo "-> Install JARs: Dependencies for Iceberg" && \ + mkdir -p ./lib/iceberg && pushd $_ && \ + curl -fO https://repo.maven.apache.org/maven2/org/apache/iceberg/iceberg-flink-runtime-${ICEBERG_FLINK_RUNTIME_VERSION}/${ICEBERG_VERSION}/iceberg-flink-runtime-${ICEBERG_FLINK_RUNTIME_VERSION}-${ICEBERG_VERSION}.jar && \ + curl -fO https://repo.maven.apache.org/maven2/org/apache/iceberg/iceberg-aws-bundle/${ICEBERG_VERSION}/iceberg-aws-bundle-${ICEBERG_VERSION}.jar && \ + popd + +# Install Hadoop client JARs (API + shaded runtime bundle) +RUN echo "-> Install JARs: Hadoop" && \ + mkdir -p ./lib/hadoop && pushd $_ && \ + curl -fO https://repo.maven.apache.org/maven2/org/apache/hadoop/hadoop-client-api/${HADOOP_VERSION}/hadoop-client-api-${HADOOP_VERSION}.jar && \ + curl -fO https://repo.maven.apache.org/maven2/org/apache/hadoop/hadoop-client-runtime/${HADOOP_VERSION}/hadoop-client-runtime-${HADOOP_VERSION}.jar && \ + popd diff --git a/docker/iceberg-flink-quickstart/README.md b/docker/iceberg-flink-quickstart/README.md new file mode 100644 index 000000000000..b844f2891d83 --- /dev/null +++ b/docker/iceberg-flink-quickstart/README.md @@ -0,0 +1,94 @@ + + +# Iceberg Flink Quickstart Docker Image + +A pre-configured Apache Flink image with Apache Iceberg dependencies for quickly getting started with Iceberg on Flink. + +See the [Flink quickstart documentation](https://iceberg.apache.org/flink-quickstart/) for details. + +## Overview + +This Docker image extends the official Apache Flink image to include: + +- Iceberg Flink runtime +- Iceberg AWS bundle for S3/Glue support +- Minimal Hadoop dependencies necessary for Flink + +## Build Arguments + +The following build arguments can be customized when building the image: + +| Argument | Default | Description | +|----------|---------|-------------| +| `FLINK_VERSION` | `2.0` | Apache Flink version | +| `ICEBERG_FLINK_RUNTIME_VERSION` | `2.0` | Iceberg Flink runtime version | +| `ICEBERG_VERSION` | `1.10.1` | Apache Iceberg version | +| `HADOOP_VERSION` | `3.4.2` | Apache Hadoop version | + +## Building Locally + +To build the image locally with default versions: + +```bash +docker build -t apache/iceberg-flink-quickstart docker/iceberg-flink-quickstart/ +``` + +To build with custom versions: + +```bash +docker build \ + --build-arg FLINK_VERSION=2.0 \ + --build-arg ICEBERG_VERSION=1.10.1 \ + -t apache/iceberg-flink-quickstart \ + docker/iceberg-flink-quickstart/ +``` + +## Usage + +See the [Flink quickstart documentation](https://iceberg.apache.org/flink-quickstart/) for details. + + +## Test Script + +A test script (`test.sql`) is provided to validate the Iceberg-Flink integration and future changes to the Docker image. + +Start up the Docker containers: + +```sh +docker compose -f docker/iceberg-flink-quickstart/docker-compose.yml up -d --build +``` + +Execute the test script directly from the host: + +```bash +docker exec -i jobmanager ./bin/sql-client.sh < docker/iceberg-flink-quickstart/test.sql +``` + +**Expected behavior:** +- Exit code: 0 (success) +- Creates: 1 catalog (`iceberg_catalog`), 1 database (`nyc`), 1 table (`taxis`) +- Inserts: 4 records +- Final state: Table `iceberg_catalog.nyc.taxis` contains 4 rows + +To stop the stack: + +```bash +docker compose -f docker/iceberg-flink-quickstart/docker-compose.yml down +``` diff --git a/docker/iceberg-flink-quickstart/docker-compose.yml b/docker/iceberg-flink-quickstart/docker-compose.yml new file mode 100644 index 000000000000..dd5d711f67c4 --- /dev/null +++ b/docker/iceberg-flink-quickstart/docker-compose.yml @@ -0,0 +1,135 @@ +# +# 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. +# + +# Flink Quickstart with Apache Iceberg +# +# Usage: +# docker compose -f docker/iceberg-flink-quickstart/docker-compose.yml up -d --build +# +# Connect to SQL client: +# docker exec -it jobmanager ./bin/sql-client.sh +services: + # Flink JobManager + jobmanager: + build: . + hostname: jobmanager + container_name: jobmanager + depends_on: + iceberg-rest: + condition: service_healthy + networks: + iceberg_net: + command: jobmanager + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081/overview"] + start_period: 30s + interval: 5s + timeout: 3s + retries: 5 + environment: + FLINK_PROPERTIES: | + jobmanager.rpc.address: jobmanager + taskmanager.numberOfTaskSlots: 2 + parallelism.default: 2 + AWS_REGION: us-east-1 + AWS_ACCESS_KEY_ID: admin + AWS_SECRET_ACCESS_KEY: password + S3_ENDPOINT: http://minio:9000 + + # Flink TaskManager + taskmanager: + build: . + hostname: taskmanager + depends_on: + jobmanager: + condition: service_healthy + networks: + iceberg_net: + command: taskmanager + deploy: + replicas: 1 + environment: + FLINK_PROPERTIES: | + jobmanager.rpc.address: jobmanager + taskmanager.numberOfTaskSlots: 2 + parallelism.default: 2 + AWS_REGION: us-east-1 + AWS_ACCESS_KEY_ID: admin + AWS_SECRET_ACCESS_KEY: password + S3_ENDPOINT: http://minio:9000 + + # Iceberg REST Catalog + iceberg-rest: + image: apache/iceberg-rest-fixture + hostname: iceberg-rest + depends_on: + create-bucket: + condition: service_completed_successfully + networks: + iceberg_net: + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8181/v1/config"] + interval: 5s + timeout: 3s + retries: 5 + environment: + AWS_REGION: us-east-1 + CATALOG_WAREHOUSE: s3://warehouse/ + CATALOG_IO__IMPL: org.apache.iceberg.aws.s3.S3FileIO + CATALOG_S3_ENDPOINT: http://minio:9000 + CATALOG_S3_ACCESS__KEY__ID: admin + CATALOG_S3_SECRET__ACCESS__KEY: password + + # MinIO for S3-compatible object storage + minio: + image: minio/minio + hostname: minio + environment: + MINIO_ROOT_USER: admin + MINIO_ROOT_PASSWORD: password + MINIO_DOMAIN: minio + networks: + iceberg_net: + aliases: + - warehouse.minio + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + # Create the warehouse bucket + create-bucket: + image: minio/mc + depends_on: + minio: + condition: service_healthy + networks: + iceberg_net: + entrypoint: | + /bin/sh -c " + until (/usr/bin/mc alias set minio http://minio:9000 admin password) do echo '...waiting...' && sleep 1; done; + /usr/bin/mc rm -r --force minio/warehouse; + /usr/bin/mc mb minio/warehouse; + /usr/bin/mc policy set public minio/warehouse; + " + +networks: + iceberg_net: diff --git a/docker/iceberg-flink-quickstart/test.sql b/docker/iceberg-flink-quickstart/test.sql new file mode 100644 index 000000000000..a7de5a89581f --- /dev/null +++ b/docker/iceberg-flink-quickstart/test.sql @@ -0,0 +1,78 @@ +-- ============================================================================= +-- Iceberg Flink Quickstart Test Script +-- ============================================================================= +-- +-- Prerequisites: +-- docker compose -f docker/iceberg-flink-quickstart/docker-compose.yml up -d --build +-- docker exec -it jobmanager ./bin/sql-client.sh +-- +-- Then paste this script or run line by line +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- 1. Create the Iceberg REST catalog +-- ----------------------------------------------------------------------------- +CREATE CATALOG iceberg_catalog WITH ( + 'type' = 'iceberg', + 'catalog-impl' = 'org.apache.iceberg.rest.RESTCatalog', + 'uri' = 'http://iceberg-rest:8181', + 'warehouse' = 's3://warehouse/', + 'io-impl' = 'org.apache.iceberg.aws.s3.S3FileIO', + 's3.endpoint' = 'http://minio:9000', + 's3.access-key-id' = 'admin', + 's3.secret-access-key' = 'password', + 's3.path-style-access' = 'true' +); + +-- ----------------------------------------------------------------------------- +-- 2. Create a database and table +-- ----------------------------------------------------------------------------- +CREATE DATABASE IF NOT EXISTS iceberg_catalog.nyc; + +CREATE TABLE iceberg_catalog.nyc.taxis ( + vendor_id BIGINT, + trip_id BIGINT, + trip_distance FLOAT, + fare_amount DOUBLE, + store_and_fwd_flag STRING +); + +-- ----------------------------------------------------------------------------- +-- 3. Enable checkpointing (required for Iceberg commits) +-- ----------------------------------------------------------------------------- +SET 'execution.checkpointing.interval' = '10s'; + +-- ----------------------------------------------------------------------------- +-- 4. Insert data +-- ----------------------------------------------------------------------------- +INSERT INTO iceberg_catalog.nyc.taxis +VALUES + (1, 1000371, 1.8, 15.32, 'N'), + (2, 1000372, 2.5, 22.15, 'N'), + (2, 1000373, 0.9, 9.01, 'N'), + (1, 1000374, 8.4, 42.13, 'Y'); + +-- ----------------------------------------------------------------------------- +-- 5. Query the data +-- ----------------------------------------------------------------------------- +SET 'sql-client.execution.result-mode' = 'tableau'; +SELECT * FROM iceberg_catalog.nyc.taxis; + +-- ----------------------------------------------------------------------------- +-- 6. Inspect Iceberg metadata +-- ----------------------------------------------------------------------------- +-- Snapshots +SELECT * FROM iceberg_catalog.nyc.`taxis$snapshots`; + +-- Data files +SELECT content, file_path, file_format, record_count +FROM iceberg_catalog.nyc.`taxis$files`; + +-- History +SELECT * FROM iceberg_catalog.nyc.`taxis$history`; + +-- ----------------------------------------------------------------------------- +-- 7. Cleanup (optional) +-- ----------------------------------------------------------------------------- +DROP TABLE iceberg_catalog.nyc.taxis; +DROP DATABASE iceberg_catalog.nyc; diff --git a/docker/iceberg-rest-fixture/README.md b/docker/iceberg-rest-fixture/README.md index 3805cc2468cb..5e02a2b4712a 100644 --- a/docker/iceberg-rest-fixture/README.md +++ b/docker/iceberg-rest-fixture/README.md @@ -23,6 +23,39 @@ For converting different catalog implementations into a rest one. Adapter for wrapping the existing catalog backends over REST. +## Configuration + +All configuration is provided via environment variables. + +### Backend catalog properties + +Catalog properties can be set via `CATALOG_*` environment variables. The +`CATALOG_` prefix is stripped; single underscores become dots (`.`); double +underscores become dashes (`-`). Names are lowercased. + +| Env var | Catalog property | +|---|---| +| `CATALOG_CATALOG_NAME` | `catalog.name` | +| `CATALOG_WAREHOUSE` | `warehouse` | +| `CATALOG_URI` | `uri` | +| `CATALOG_CATALOG__IMPL` | `catalog-impl` | +| `CATALOG_IO__IMPL` | `io-impl` | +| `CATALOG_JDBC_USER` | `jdbc.user` | + +If `catalog-impl` and `uri` are unset, the fixture defaults to an in-memory +SQLite `JdbcCatalog`. + +### Catalog name + +By default, the fixture serves a catalog named `rest_backend`. To match a +name expected by a specific engine (for example, a catalog created via Trino +or PyIceberg), override the `catalog.name` property: + +```bash +docker run -e CATALOG_CATALOG_NAME=mycatalog -p 8181:8181 apache/iceberg-rest-fixture +``` + + ## Build the Docker Image When making changes to the local files and test them out, you can build the image locally: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000000..3b7c46ade50a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ + + +For information about the documentation, please see [this README.md](../site/README.md) diff --git a/docs/docs/aws.md b/docs/docs/aws.md index c306d5493a0a..fba4921f73a5 100644 --- a/docs/docs/aws.md +++ b/docs/docs/aws.md @@ -270,9 +270,9 @@ Read [this AWS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserG With all the available options, we offer the following guidelines when choosing the right catalog to use for your application: 1. if your organization has an existing Glue metastore or plans to use the AWS analytics ecosystem including Glue, [Athena](https://aws.amazon.com/athena), [EMR](https://aws.amazon.com/emr), [Redshift](https://aws.amazon.com/redshift) and [LakeFormation](https://aws.amazon.com/lake-formation), Glue catalog provides the easiest integration. -2. if your application requires frequent updates to table or high read and write throughput (e.g. streaming write), Glue and DynamoDB catalog provides the best performance through optimistic locking. +2. if your application requires frequent updates to table or high read and write throughput (e.g. streaming write), Glue and DynamoDB catalogs provide the best performance through optimistic locking. 3. if you would like to enforce access control for tables in a catalog, Glue tables can be managed as an [IAM resource](https://docs.aws.amazon.com/service-authorization/latest/reference/list_awsglue.html), whereas DynamoDB catalog tables can only be managed through [item-level permission](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/specifying-conditions.html) which is much more complicated. -4. if you would like to query tables based on table property information without the need to scan the entire catalog, DynamoDB catalog allows you to build secondary indexes for any arbitrary property field and provide efficient query performance. +4. if you would like to query tables based on table property information without the need to scan the entire catalog, DynamoDB catalog allows you to build secondary indexes for any arbitrary property field and provides efficient query performance. 5. if you would like to have the benefit of DynamoDB catalog while also connect to Glue, you can enable [DynamoDB stream with Lambda trigger](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.Lambda.Tutorial.html) to asynchronously update your Glue metastore with table information in the DynamoDB catalog. 6. if your organization already maintains an existing relational database in RDS or uses [serverless Aurora](https://aws.amazon.com/rds/aurora/serverless/) to manage tables, the JDBC catalog provides the easiest integration. @@ -288,7 +288,7 @@ This feature requires the following lock related catalog properties: 2. Set `lock.table` as the DynamoDB table name you would like to use. If the lock table with the given name does not exist in DynamoDB, a new table is created with billing mode set as [pay-per-request](https://aws.amazon.com/blogs/aws/amazon-dynamodb-on-demand-no-capacity-planning-and-pay-per-request-pricing). Other lock related catalog properties can also be used to adjust locking behaviors such as heartbeat interval. -For more details, please refer to [Lock catalog properties](configuration.md#lock-catalog-properties). +For more details, please refer to [Lock catalog properties](catalog-properties.md#lock-catalog-properties). ## S3 FileIO @@ -416,9 +416,11 @@ There is no redundant consistency wait and check which might negatively impact p ### Hadoop S3A FileSystem +!!! important + **S3FileIO is recommended** for S3 use cases rather than the `S3A FileSystem` (`HadoopFileIO`). + Before `S3FileIO` was introduced, many Iceberg users choose to use `HadoopFileIO` to write data to S3 through the [S3A FileSystem](https://github.com/apache/hadoop/blob/trunk/hadoop-tools/hadoop-aws/src/main/java/org/apache/hadoop/fs/s3a/S3AFileSystem.java). -As introduced in the previous sections, `S3FileIO` adopts the latest AWS clients and S3 features for optimized security and performance - and is thus recommended for S3 use cases rather than the S3A FileSystem. +As introduced in the previous sections, `S3FileIO` adopts the latest AWS clients and S3 features for optimized security and performance. `S3FileIO` writes data with `s3://` URI scheme, but it is also compatible with schemes written by the S3A FileSystem. This means for any table manifests containing `s3a://` or `s3n://` file paths, `S3FileIO` is still able to read them. @@ -703,10 +705,12 @@ For more details of configuration, see sections [URL Connection HTTP Client Conf Configurations for the HTTP client can be set via catalog properties. Below is an overview of available configurations: -| Property | Default | Description | -|----------------------------|---------|------------------------------------------------------------------------------------------------------------| -| http-client.type | apache | Types of HTTP Client.
`urlconnection`: URL Connection HTTP Client
`apache`: Apache HTTP Client | -| http-client.proxy-endpoint | null | An optional proxy endpoint to use for the HTTP client. | +| Property | Default | Description | +|---------------------------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| http-client.type | apache | Types of HTTP Client.
`urlconnection`: URL Connection HTTP Client
`apache`: Apache HTTP Client | +| http-client.proxy-endpoint | null | An optional proxy endpoint to use for the HTTP client. | +| http-client.proxy-use-system-property-values | null, enabled by default | An optional `true/false` setting that controls whether proxy configuration is read from Java system properties (`http.proxyHost`, `http.proxyPort`, `http.nonProxyHosts`, etc.). | +| http-client.proxy-use-environment-variable-values | null, enabled by default | An optional `true/false` setting that controls whether proxy configuration is read from environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, etc.). | #### URL Connection HTTP Client Configurations diff --git a/docs/docs/catalog-properties.md b/docs/docs/catalog-properties.md new file mode 100644 index 000000000000..5afae0b98ae2 --- /dev/null +++ b/docs/docs/catalog-properties.md @@ -0,0 +1,167 @@ +--- +title: "Catalog properties" +--- + + +# Catalog properties + +## Common properties + +Iceberg catalogs support using catalog properties to configure catalog behaviors. Here is a list of commonly used catalog properties: + +| Property | Default | Description | +| --------------------------------- | ------------------ | ------------------------------------------------------ | +| catalog-impl | null | a custom `Catalog` implementation to use by an engine | +| io-impl | null | a custom `FileIO` implementation to use in a catalog | +| warehouse | null | the root path of the data warehouse | +| uri | null | a URI string, such as Hive metastore URI | +| clients | 2 | client pool size | +| cache-enabled | true | Whether to cache catalog entries | +| cache.expiration-interval-ms | 30000 | How long catalog entries are locally cached, in milliseconds; 0 disables caching, negative values disable expiration | +| metrics-reporter-impl | org.apache.iceberg.metrics.LoggingMetricsReporter | Custom `MetricsReporter` implementation to use in a catalog. See the [Metrics reporting](metrics-reporting.md) section for additional details | +| unique-table-location | false | Whether to use a unique location for new tables | +| encryption.kms-impl | null | a custom `KeyManagementClient` implementation to use in a catalog for interactions with KMS (key management service). See the [Encryption](encryption.md) document for additional details | + +`HadoopCatalog` and `HiveCatalog` can access the properties in their constructors. +Any other custom catalog can access the properties by implementing `Catalog.initialize(catalogName, catalogProperties)`. +The properties can be manually constructed or passed in from a compute engine like Spark or Flink. +Spark uses its session properties as catalog properties, see more details in the [Spark configuration](spark-configuration.md#catalog-configuration) section. +Flink passes in catalog properties through `CREATE CATALOG` statement, see more details in the [Flink](flink.md#adding-catalogs) section. + +## REST catalog properties + +The following properties configure the behavior of the REST catalog client. + +| Property | Default | Description | +|---------------------------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `snapshot-loading-mode` | `ALL` | Controls how snapshots are loaded from the REST server. Supported values: `ALL` (load all snapshots), `REFS` (load only referenced snapshots). | +| `rest-metrics-reporting-enabled` | `true` | Whether to enable metrics reporting to the REST server. | +| `view-endpoints-supported` | `false` | For backwards compatibility with older REST servers. Set to `true` if the server supports view endpoints but doesn't send the `endpoints` field in the ConfigResponse. | +| `rest-page-size` | null | The page size to use when listing namespaces, tables, or other paginated resources. | +| `namespace-separator` | `%1F` | The separator character used for namespace levels when communicating with the REST server. | +| `scan-planning-mode` | `CLIENT` | Controls where scan planning is performed. Supported values: `CLIENT` (client-side planning), `SERVER` (server-side planning). Can be overridden per-table by the server in LoadTableResponse. | + +### Table cache properties + +The following properties configure the table cache used for freshness-aware table loading. Note, this cache is different from the one that can be configured at catalog level in general. + +| Property | Default | Description | +|------------------------------------------|-------------------|----------------------------------------------------------------------------------------| +| `rest-table-cache.expire-after-write-ms` | `300000` (5 min) | Time in milliseconds after which cached table entries expire. | +| `rest-table-cache.max-entries` | `100` | Maximum number of table entries to cache. | + +### Auth properties + +The following catalog properties configure authentication for the REST catalog. +They support Basic, OAuth2, SigV4, and Google authentication. + +#### REST auth properties + +| Property | Default | Description | +|--------------------------------------|------------------|-------------------------------------------------------------------------------------------------------------------| +| `rest.auth.type` | `none` | Authentication mechanism for REST catalog access. Supported values: `none`, `basic`, `oauth2`, `sigv4`, `google`. | +| `rest.auth.basic.username` | null | Username for Basic authentication. Required if `rest.auth.type` = `basic`. | +| `rest.auth.basic.password` | null | Password for Basic authentication. Required if `rest.auth.type` = `basic`. | +| `rest.auth.sigv4.delegate-auth-type` | `oauth2` | Auth type to delegate to after `sigv4` signing. | + +#### OAuth2 auth properties +Required and optional properties to include while using `oauth2` authentication + +| Property | Default | Description | +|-------------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `token` | null | A Bearer token to interact with the server. Either `token` or `credential` is required. | +| `credential` | null | Credential string in the form of `client_id:client_secret` to exchange for a token in the OAuth2 client credentials flow. Either `token` or `credential` is required. | +| `oauth2-server-uri` | `v1/oauth/tokens` | OAuth2 token endpoint URI. Required if the REST catalog is not the OAuth2 authentication server. | +| `token-expires-in-ms` | 3600000 (1 hour) | Time in milliseconds after which a bearer token is considered expired. Used to decide when to refresh or re-exchange a token. | +| `token-refresh-enabled` | true | Determines whether tokens are automatically refreshed when expiration details are available. | +| `token-exchange-enabled`| true | Determines whether to use the token exchange flow to acquire new tokens. Disabling this will allow fallback to the client credential flow. | +| `scope` | `catalog` | Additional scope for `oauth2`. | +| `audience` | null | Optional param to specify token `audience` | +| `resource` | null | Optional param to specify `resource` | + +#### Google auth properties +Required and optional properties to include while using `google` authentication + +| Property | Default | Description | +|----------------------------|--------------------------------------------------|--------------------------------------------------| +| `gcp.auth.credentials-path`| Application Default Credentials (ADC) | Path to a service account JSON key file. | +| `gcp.auth.credentials-json` | Application Default Credentials (ADC) | JSON string of a service account credential. | +| `gcp.auth.scopes` | `https://www.googleapis.com/auth/cloud-platform` | Comma-separated list of OAuth scopes to request. | + +## Lock catalog properties + +Here are the catalog properties related to locking. They are used by some catalog implementations to control the locking behavior during commits. + +| Property | Default | Description | +| --------------------------------- | ------------------ | ------------------------------------------------------ | +| lock-impl | null | a custom implementation of the lock manager, the actual interface depends on the catalog used | +| lock.table | null | an auxiliary table for locking, such as in [AWS DynamoDB lock manager](aws.md#dynamodb-lock-manager) | +| lock.acquire-interval-ms | 5000 (5 s) | the interval to wait between each attempt to acquire a lock | +| lock.acquire-timeout-ms | 180000 (3 min) | the maximum time to try acquiring a lock | +| lock.heartbeat-interval-ms | 3000 (3 s) | the interval to wait between each heartbeat after acquiring a lock | +| lock.heartbeat-timeout-ms | 15000 (15 s) | the maximum time without a heartbeat to consider a lock expired | + +## Hadoop configuration + +### HadoopTables Lock Configuration + +When using `HadoopTables` (tables without a catalog), lock properties from the [Lock catalog properties](#lock-catalog-properties) section can be configured by prefixing them with `iceberg.tables.hadoop.`. This ensures atomic commits on file systems like S3 that lack native write mutual exclusion. + +!!! info + To use DynamoDB as a lock manager with `HadoopTables`, set `iceberg.tables.hadoop.lock-impl` to `org.apache.iceberg.aws.dynamodb.DynamoDbLockManager` and `iceberg.tables.hadoop.lock.table` to your DynamoDB table name. See [DynamoDB Lock Manager](aws.md#dynamodb-lock-manager) for more details. + +### Hive Metastore Configuration + +The following properties from the Hadoop configuration are used by the Hive Metastore connector. +The HMS table locking is a 2-step process: + +1. Lock Creation: Create lock in HMS and queue for acquisition +2. Lock Check: Check if lock successfully acquired + +| Property | Default | Description | +|-------------------------------------------|-----------------|------------------------------------------------------------------------------| +| iceberg.hive.client-pool-size | 5 | The size of the Hive client pool when tracking tables in HMS | +| iceberg.hive.lock-creation-timeout-ms | 180000 (3 min) | Maximum time in milliseconds to create a lock in the HMS | +| iceberg.hive.lock-creation-min-wait-ms | 50 | Minimum time in milliseconds between retries of creating the lock in the HMS | +| iceberg.hive.lock-creation-max-wait-ms | 5000 | Maximum time in milliseconds between retries of creating the lock in the HMS | +| iceberg.hive.lock-timeout-ms | 180000 (3 min) | Maximum time in milliseconds to acquire a lock | +| iceberg.hive.lock-check-min-wait-ms | 50 | Minimum time in milliseconds between checking the acquisition of the lock | +| iceberg.hive.lock-check-max-wait-ms | 5000 | Maximum time in milliseconds between checking the acquisition of the lock | +| iceberg.hive.lock-heartbeat-interval-ms | 240000 (4 min) | The heartbeat interval for the HMS locks. | +| iceberg.hive.metadata-refresh-max-retries | 2 | Maximum number of retries when the metadata file is missing | +| iceberg.hive.table-level-lock-evict-ms | 600000 (10 min) | The timeout for the JVM table lock is | +| iceberg.engine.hive.lock-enabled | true | Use HMS locks to ensure atomicity of commits | + +Note: `iceberg.hive.lock-check-max-wait-ms` and `iceberg.hive.lock-heartbeat-interval-ms` should be less than the [transaction timeout](https://cwiki.apache.org/confluence/display/Hive/Configuration+Properties#ConfigurationProperties-hive.txn.timeout) +of the Hive Metastore (`hive.txn.timeout` or `metastore.txn.timeout` in the newer versions). Otherwise, the heartbeats on the lock (which happens during the lock checks) would end up expiring in the +Hive Metastore before the lock is retried from Iceberg. + +Warn: Setting `iceberg.engine.hive.lock-enabled`=`false` will cause HiveCatalog to commit to tables without using Hive locks. +This should only be set to `false` if all following conditions are met: + +- [HIVE-26882](https://issues.apache.org/jira/browse/HIVE-26882) +is available on the Hive Metastore server +- [HIVE-28121](https://issues.apache.org/jira/browse/HIVE-28121) +is available on the Hive Metastore server, if it is backed by MySQL or MariaDB +- All other HiveCatalogs committing to tables that this HiveCatalog commits to are also on Iceberg 1.3 or later +- All other HiveCatalogs committing to tables that this HiveCatalog commits to have also disabled Hive locks on commit. + +**Failing to ensure these conditions risks corrupting the table.** + +Even with `iceberg.engine.hive.lock-enabled` set to `false`, a HiveCatalog can still use locks for individual tables by setting the table property `engine.hive.lock-enabled`=`true`. +This is useful in the case where other HiveCatalogs cannot be upgraded and set to commit without using Hive locks. diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 3f730a4f4c13..17bf1f8ac0a1 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -45,10 +45,13 @@ Iceberg tables support table properties to configure table behavior, like the de | write.delete.format.default | data file format | Default delete file format for the table; parquet, avro, or orc | | write.parquet.row-group-size-bytes | 134217728 (128 MB) | Parquet row group size | | write.parquet.page-size-bytes | 1048576 (1 MB) | Parquet page size | +| write.parquet.page-version | v1 | Parquet data page version: v1 (DataPage V1) or v2 (DataPage V2) | | write.parquet.page-row-limit | 20000 | Parquet page row limit | | write.parquet.dict-size-bytes | 2097152 (2 MB) | Parquet dictionary page size | | write.parquet.compression-codec | zstd | Parquet compression codec: zstd, brotli, lz4, gzip, snappy, uncompressed | | write.parquet.compression-level | null | Parquet compression level | +| write.parquet.shred-variants | false | When true, variant columns are written with shredded Parquet encoding for improved query performance | +| write.parquet.variant-inference-buffer-size | 100 | Number of rows to buffer for schema inference when variant shredding is enabled | | write.parquet.bloom-filter-enabled.column.col1 | (not set) | Hint to parquet to write a bloom filter for the column: 'col1' | | write.parquet.bloom-filter-max-bytes | 1048576 (1 MB) | The maximum number of bytes for a bloom filter bitset | | write.parquet.bloom-filter-fpp.column.col1 | 0.01 | The false positive probability for a bloom filter applied to 'col1' (must > 0.0 and < 1.0) | @@ -127,121 +130,16 @@ The value of these properties are not persisted as a part of the table metadata. | -------------- | -------- |--------------------------------------------------------------------------------------------------------------------------------------| | format-version | 2 | Table's format version as defined in the [Spec](../../spec.md#format-versioning). Defaults to 2 since version 1.4.0. | +### Informational properties + +Informational properties can be set to provide additional context about a table. They can be useful for documentation, discovery, and integration with external tools. They do not affect read/write behavior or query semantics. + +| Property | Default | Description | +| -------- | ---------- | ------------------------------------------------------------------------------------------------------------------- | +| comment | (not set) | A table-level description that documents the business meaning and usage context. | + ### Compatibility flags | Property | Default | Description | | --------------------------------------------- | -------- | ------------------------------------------------------------- | | compatibility.snapshot-id-inheritance.enabled | false | Enables committing snapshots without explicit snapshot IDs (always true if the format version is > 1) | - -## Catalog properties - -Iceberg catalogs support using catalog properties to configure catalog behaviors. Here is a list of commonly used catalog properties: - -| Property | Default | Description | -| --------------------------------- | ------------------ | ------------------------------------------------------ | -| catalog-impl | null | a custom `Catalog` implementation to use by an engine | -| io-impl | null | a custom `FileIO` implementation to use in a catalog | -| warehouse | null | the root path of the data warehouse | -| uri | null | a URI string, such as Hive metastore URI | -| clients | 2 | client pool size | -| cache-enabled | true | Whether to cache catalog entries | -| cache.expiration-interval-ms | 30000 | How long catalog entries are locally cached, in milliseconds; 0 disables caching, negative values disable expiration | -| metrics-reporter-impl | org.apache.iceberg.metrics.LoggingMetricsReporter | Custom `MetricsReporter` implementation to use in a catalog. See the [Metrics reporting](metrics-reporting.md) section for additional details | -| encryption.kms-impl | null | a custom `KeyManagementClient` implementation to use in a catalog for interactions with KMS (key management service). See the [Encryption](encryption.md) document for additional details | - -`HadoopCatalog` and `HiveCatalog` can access the properties in their constructors. -Any other custom catalog can access the properties by implementing `Catalog.initialize(catalogName, catalogProperties)`. -The properties can be manually constructed or passed in from a compute engine like Spark or Flink. -Spark uses its session properties as catalog properties, see more details in the [Spark configuration](spark-configuration.md#catalog-configuration) section. -Flink passes in catalog properties through `CREATE CATALOG` statement, see more details in the [Flink](flink.md#adding-catalogs) section. - -### REST Catalog auth properties - -The following catalog properties configure authentication for the REST catalog. -They support Basic, OAuth2, SigV4, and Google authentication. - -#### REST auth properties - -| Property | Default | Description | -|--------------------------------------|------------------|-------------------------------------------------------------------------------------------------------------------| -| `rest.auth.type` | `none` | Authentication mechanism for REST catalog access. Supported values: `none`, `basic`, `oauth2`, `sigv4`, `google`. | -| `rest.auth.basic.username` | null | Username for Basic authentication. Required if `rest.auth.type` = `basic`. | -| `rest.auth.basic.password` | null | Password for Basic authentication. Required if `rest.auth.type` = `basic`. | -| `rest.auth.sigv4.delegate-auth-type` | `oauth2` | Auth type to delegate to after `sigv4` signing. | - -#### OAuth2 auth properties -Required and optional properties to include while using `oauth2` authentication - -| Property | Default | Description | -|-------------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `token` | null | A Bearer token to interact with the server. Either `token` or `credential` is required. | -| `credential` | null | Credential string in the form of `client_id:client_secret` to exchange for a token in the OAuth2 client credentials flow. Either `token` or `credential` is required. | -| `oauth2-server-uri` | `v1/oauth/tokens` | OAuth2 token endpoint URI. Required if the REST catalog is not the OAuth2 authentication server. | -| `token-expires-in-ms` | 3600000 (1 hour) | Time in milliseconds after which a bearer token is considered expired. Used to decide when to refresh or re-exchange a token. | -| `token-refresh-enabled` | true | Determines whether tokens are automatically refreshed when expiration details are available. | -| `token-exchange-enabled`| true | Determines whether to use the token exchange flow to acquire new tokens. Disabling this will allow fallback to the client credential flow. | -| `scope` | `catalog` | Additional scope for `oauth2`. | -| `audience` | null | Optional param to specify token `audience` | -| `resource` | null | Optional param to specify `resource` | - -#### Google auth properties -Required and optional properties to include while using `google` authentication - -| Property | Default | Description | -|----------------------------|--------------------------------------------------|--------------------------------------------------| -| `gcp.auth.credentials-path`| Application Default Credentials (ADC) | Path to a service account JSON key file. | -| `gcp.auth.scopes` | `https://www.googleapis.com/auth/cloud-platform` | Comma-separated list of OAuth scopes to request. | - -### Lock catalog properties - -Here are the catalog properties related to locking. They are used by some catalog implementations to control the locking behavior during commits. - -| Property | Default | Description | -| --------------------------------- | ------------------ | ------------------------------------------------------ | -| lock-impl | null | a custom implementation of the lock manager, the actual interface depends on the catalog used | -| lock.table | null | an auxiliary table for locking, such as in [AWS DynamoDB lock manager](aws.md#dynamodb-lock-manager) | -| lock.acquire-interval-ms | 5000 (5 s) | the interval to wait between each attempt to acquire a lock | -| lock.acquire-timeout-ms | 180000 (3 min) | the maximum time to try acquiring a lock | -| lock.heartbeat-interval-ms | 3000 (3 s) | the interval to wait between each heartbeat after acquiring a lock | -| lock.heartbeat-timeout-ms | 15000 (15 s) | the maximum time without a heartbeat to consider a lock expired | - -## Hadoop configuration - -The following properties from the Hadoop configuration are used by the Hive Metastore connector. -The HMS table locking is a 2-step process: - -1. Lock Creation: Create lock in HMS and queue for acquisition -2. Lock Check: Check if lock successfully acquired - -| Property | Default | Description | -|-------------------------------------------|-----------------|------------------------------------------------------------------------------| -| iceberg.hive.client-pool-size | 5 | The size of the Hive client pool when tracking tables in HMS | -| iceberg.hive.lock-creation-timeout-ms | 180000 (3 min) | Maximum time in milliseconds to create a lock in the HMS | -| iceberg.hive.lock-creation-min-wait-ms | 50 | Minimum time in milliseconds between retries of creating the lock in the HMS | -| iceberg.hive.lock-creation-max-wait-ms | 5000 | Maximum time in milliseconds between retries of creating the lock in the HMS | -| iceberg.hive.lock-timeout-ms | 180000 (3 min) | Maximum time in milliseconds to acquire a lock | -| iceberg.hive.lock-check-min-wait-ms | 50 | Minimum time in milliseconds between checking the acquisition of the lock | -| iceberg.hive.lock-check-max-wait-ms | 5000 | Maximum time in milliseconds between checking the acquisition of the lock | -| iceberg.hive.lock-heartbeat-interval-ms | 240000 (4 min) | The heartbeat interval for the HMS locks. | -| iceberg.hive.metadata-refresh-max-retries | 2 | Maximum number of retries when the metadata file is missing | -| iceberg.hive.table-level-lock-evict-ms | 600000 (10 min) | The timeout for the JVM table lock is | -| iceberg.engine.hive.lock-enabled | true | Use HMS locks to ensure atomicity of commits | - -Note: `iceberg.hive.lock-check-max-wait-ms` and `iceberg.hive.lock-heartbeat-interval-ms` should be less than the [transaction timeout](https://cwiki.apache.org/confluence/display/Hive/Configuration+Properties#ConfigurationProperties-hive.txn.timeout) -of the Hive Metastore (`hive.txn.timeout` or `metastore.txn.timeout` in the newer versions). Otherwise, the heartbeats on the lock (which happens during the lock checks) would end up expiring in the -Hive Metastore before the lock is retried from Iceberg. - -Warn: Setting `iceberg.engine.hive.lock-enabled`=`false` will cause HiveCatalog to commit to tables without using Hive locks. -This should only be set to `false` if all following conditions are met: - -- [HIVE-26882](https://issues.apache.org/jira/browse/HIVE-26882) -is available on the Hive Metastore server -- [HIVE-28121](https://issues.apache.org/jira/browse/HIVE-28121) -is available on the Hive Metastore server, if it is backed by MySQL or MariaDB -- All other HiveCatalogs committing to tables that this HiveCatalog commits to are also on Iceberg 1.3 or later -- All other HiveCatalogs committing to tables that this HiveCatalog commits to have also disabled Hive locks on commit. - -**Failing to ensure these conditions risks corrupting the table.** - -Even with `iceberg.engine.hive.lock-enabled` set to `false`, a HiveCatalog can still use locks for individual tables by setting the table property `engine.hive.lock-enabled`=`true`. -This is useful in the case where other HiveCatalogs cannot be upgraded and set to commit without using Hive locks. diff --git a/docs/docs/custom-catalog.md b/docs/docs/custom-catalog.md index f0a6b5718a6c..d30a629401aa 100644 --- a/docs/docs/custom-catalog.md +++ b/docs/docs/custom-catalog.md @@ -151,7 +151,7 @@ public class CustomCatalog extends BaseMetastoreCatalog { Catalog implementations can be dynamically loaded in most compute engines. For Spark and Flink, you can specify the `catalog-impl` catalog property to load it. -Read the [Configuration](configuration.md#catalog-properties) section for more details. +Read the [Configuration](catalog-properties.md) section for more details. For MapReduce, implement `org.apache.iceberg.mr.CatalogLoader` and set Hadoop property `iceberg.mr.catalog.loader.class` to load it. If your catalog must read Hadoop configuration to access certain environment properties, make your catalog implement `org.apache.hadoop.conf.Configurable`. @@ -199,7 +199,7 @@ public class CustomFileIO implements FileIO { If you are already implementing your own catalog, you can implement `TableOperations.io()` to use your custom `FileIO`. In addition, custom `FileIO` implementations can also be dynamically loaded in `HadoopCatalog` and `HiveCatalog` by specifying the `io-impl` catalog property. -Read the [Configuration](configuration.md#catalog-properties) section for more details. +Read the [Configuration](catalog-properties.md) section for more details. If your `FileIO` must read Hadoop configuration to access certain environment properties, make your `FileIO` implement `org.apache.hadoop.conf.Configurable`. ### Custom location provider implementation diff --git a/docs/docs/encryption.md b/docs/docs/encryption.md index 5da51486f76b..cbdce85e760e 100644 --- a/docs/docs/encryption.md +++ b/docs/docs/encryption.md @@ -28,7 +28,7 @@ Currently, encryption is supported in the Hive and REST catalogs for tables with Two parameters are required to activate encryption of a table: -1. Catalog property `encryption.kms-impl`, that specifies the class path for a client of a KMS ("key management service"). +1. Catalog property that specifies the KMS ("key management service"). It can be either `encryption.kms-type` for pre-defined KMS clients (`aws`, `azure` or `gcp`) or `encryption.kms-impl` with the client class path for custom KMS clients. 2. Table property `encryption.key-id`, that specifies the ID of a master key used to encrypt and decrypt the table. Master keys are stored and managed in the KMS. For more details on table encryption, see the "Appendix: Internals Overview" [subsection](#appendix-internals-overview). @@ -42,7 +42,7 @@ spark-sql --packages org.apache.iceberg:iceberg-spark-runtime-{{ sparkVersionMaj --conf spark.sql.catalog.spark_catalog.type=hive \ --conf spark.sql.catalog.local=org.apache.iceberg.spark.SparkCatalog \ --conf spark.sql.catalog.local.type=hive \ - --conf spark.sql.catalog.local.encryption.kms-impl=org.apache.iceberg.aws.AwsKeyManagementClient + --conf spark.sql.catalog.local.encryption.kms-type=aws ``` ```sql diff --git a/docs/docs/fileio.md b/docs/docs/fileio.md index 0062219a49bd..6c7a779193c2 100644 --- a/docs/docs/fileio.md +++ b/docs/docs/fileio.md @@ -33,6 +33,7 @@ The metadata for an Iceberg table tracks the absolute path for data files which The responsibility of reading and writing data files lies with the processing engines and happens during task execution. However, after data files are written, processing engines use FileIO to write new Iceberg metadata files that capture the new state of the table. Different FileIO implementations are used depending on the type of storage. Iceberg comes with a set of FileIO implementations for popular storage providers. + - Amazon S3 - Google Cloud Storage - Object Service Storage (including https) diff --git a/docs/docs/flink-configuration.md b/docs/docs/flink-configuration.md index 41d3df0745c9..f30b42288896 100644 --- a/docs/docs/flink-configuration.md +++ b/docs/docs/flink-configuration.md @@ -123,7 +123,7 @@ env.getConfig() | include-column-stats | connector.iceberg.include-column-stats | N/A | false | Create a new scan from this that loads the column stats with each data file. Column stats include: value count, null value count, lower bounds, and upper bounds. | | max-planning-snapshot-count | connector.iceberg.max-planning-snapshot-count | N/A | Integer.MAX_VALUE | Max number of snapshots limited per split enumeration. Applicable only to streaming read. | | limit | connector.iceberg.limit | N/A | -1 | Limited output number of rows. | -| max-allowed-planning-failures | connector.iceberg.max-allowed-planning-failures | N/A | 3 | Max allowed consecutive failures for scan planning before failing the job. Set to -1 for never failing the job for scan planing failure. | +| max-allowed-planning-failures | connector.iceberg.max-allowed-planning-failures | N/A | 3 | Max allowed consecutive failures for scan planning before failing the job. Set to -1 for never failing the job for scan planning failure. | | watermark-column | connector.iceberg.watermark-column | N/A | null | Specifies the watermark column to use for watermark generation. If this option is present, the `splitAssignerFactory` will be overridden with `OrderedSplitAssignerFactory`. | | watermark-column-time-unit | connector.iceberg.watermark-column-time-unit | N/A | TimeUnit.MICROSECONDS | Specifies the watermark time unit to use for watermark generation. The possible values are DAYS, HOURS, MINUTES, SECONDS, MILLISECONDS, MICROSECONDS, NANOSECONDS. | diff --git a/docs/docs/flink-ddl.md b/docs/docs/flink-ddl.md index 756256f0df4f..0a9b26712235 100644 --- a/docs/docs/flink-ddl.md +++ b/docs/docs/flink-ddl.md @@ -45,6 +45,23 @@ The following properties can be set if using the Hive catalog: * `hive-conf-dir`: Path to a directory containing a `hive-site.xml` configuration file which will be used to provide custom Hive configuration values. The value of `hive.metastore.warehouse.dir` from `/hive-site.xml` (or hive configure file from classpath) will be overwritten with the `warehouse` value if setting both `hive-conf-dir` and `warehouse` when creating iceberg catalog. * `hadoop-conf-dir`: Path to a directory containing `core-site.xml` and `hdfs-site.xml` configuration files which will be used to provide custom Hadoop configuration values. +!!! warning "Hive Catalog Limitation" + The Hive Metastore (HMS) validates schema changes by comparing column types **positionally** + (`hive.metastore.disallow.incompatible.col.type.changes`, default `true`). When using a Hive catalog, + schema evolution operations that change column positions — such as dropping a non-last column or + reordering columns — may fail regardless of which engine performs the change (Spark, Flink Java API, etc.). + + To work around this, disable the HMS schema compatibility check by setting + `hive.metastore.disallow.incompatible.col.type.changes=false`: + + - **Remote HMS:** Set this property in the HMS server's `hive-site.xml`. + - **Embedded HMS:** Add the equivalent property to the Hive catalog configuration. + + **Trade-off:** After disabling this check, the Hive engine may no longer be able to read the table + correctly due to the schema mismatch in the Hive Metastore. Iceberg-aware engines (Spark, Flink, + Trino, etc.) will continue to work correctly, as they read schema from Iceberg metadata rather + than the Hive Metastore. + #### Hadoop catalog Iceberg also supports a directory-based catalog in HDFS that can be configured using `'catalog-type'='hadoop'`: diff --git a/docs/docs/flink-maintenance.md b/docs/docs/flink-maintenance.md index a3d3ff1e4b43..bdf26adbd1c9 100644 --- a/docs/docs/flink-maintenance.md +++ b/docs/docs/flink-maintenance.md @@ -124,6 +124,10 @@ TriggerLockFactory lockFactory = new ZkLockFactory( ); ``` +#### Flink-maintained lock + +Maintain the lock within Flink itself. This does not require configuring external systems. The only prerequisite is that there are no parallel table maintenance jobs for a given table. + ### Quick Start The following example demonstrates the implementation of automated maintenance for an Iceberg table within a Flink environment. @@ -147,7 +151,10 @@ TriggerLockFactory lockFactory = new JdbcLockFactory( jdbcProps // JDBC connection properties ); +// Option 1: With external lock factory (plan to deprecate this Option since 1.12) TableMaintenance.forTable(env, tableLoader, lockFactory) +// Option 2: With Flink-managed lock (no external lock required) +TableMaintenance.forTable(env, tableLoader) .uidSuffix("my-maintenance-job") .rateLimit(Duration.ofMinutes(10)) .lockCheckDelay(Duration.ofSeconds(10)) @@ -200,7 +207,7 @@ env.execute("Table Maintenance Job"); | `retainLast(int)` | Minimum number of snapshots to retain | 1 | int | | `deleteBatchSize(int)` | Number of files to delete in each batch | 1000 | int | | `planningWorkerPoolSize(int)` | Number of worker threads for planning snapshot expiration | Shared worker pool | int | -| `cleanExpiredMetadata(boolean)` | Remove expired metadata files when expiring snapshots | false | boolean | +| `cleanExpiredMetadata(boolean)` | Remove expired metadata files when expiring snapshots | true | boolean | #### RewriteDataFiles Configuration @@ -218,13 +225,14 @@ env.execute("Table Maintenance Job"); | `partialProgressMaxCommits(int)` | Maximum commits allowed for partial progress when partialProgressEnabled is true | 10 | int | | `maxRewriteBytes(long)` | Maximum bytes to rewrite per execution | Long.MAX_VALUE | long | | `filter(Expression)` | Filter expression for selecting files to rewrite | Expressions.alwaysTrue() | Expression | +| `maxFileGroupInputFiles(long)` | Maximum allowed number of input files within a file group | Long.MAX_VALUE | long | #### DeleteOrphanFiles Configuration | Method | Description | Default Value | Type | |------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------|--------------------| -| `location(string)` | The location to start the recursive listing the candidate files for removal. | Table's location | String | -| `usePrefixListing(boolean)` | When true, use prefix-based file listing via the SupportsPrefixOperations interface. The Table FileIO implementation must support SupportsPrefixOperations when this flag is enabled.(Note: Setting it to False will use a recursive method to obtain file information. If the underlying storage is object storage, it will repeatedly call the API to get the path.) | False | boolean | +| `location(string)` | The location to start the recursive listing of the candidate files for removal. | Table's location | String | +| `usePrefixListing(boolean)` | When true, use prefix-based file listing via the SupportsPrefixOperations interface. The Table FileIO implementation must support SupportsPrefixOperations when this flag is enabled.(Note: Setting it to False will use a recursive method to obtain file information. If the underlying storage is object storage, it will repeatedly call the API to get the path.) | True | boolean | | `prefixMismatchMode(PrefixMismatchMode)` | Action behavior when location prefixes (schemes/authorities) mismatch:
  • ERROR - throw an exception.
  • IGNORE - no action.
  • DELETE - delete files.
| ERROR | PrefixMismatchMode | | `equalSchemes(Map)` | Mapping of file system schemes to be considered equal. Key is a comma-separated list of schemes and value is a scheme | "s3n"=>"s3","s3a"=>"s3" | Map | | `equalAuthorities(Map)` | Mapping of file system authorities to be considered equal. Key is a comma-separated list of authorities and value is an authority. | Empty map | Map | @@ -294,19 +302,50 @@ public class TableMaintenanceJob { Apache Iceberg Sink V2 for Flink allows automatic execution of maintenance tasks after data is committed to the table, using the addPostCommitTopology(...) method. +#### DataStream API + +##### Builder + +```java +IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .rewriteDataFiles(Map.of( + RewriteDataFilesConfig.MAX_BYTES, "1073741824")) + .expireSnapshots(Map.of( + ExpireSnapshotsConfig.RETAIN_LAST, "5", + ExpireSnapshotsConfig.MAX_SNAPSHOT_AGE_SECONDS, "604800")) + .deleteOrphanFiles(Map.of( + DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "259200")) + .append(); +``` + +##### Config + +All maintenance tasks are configured through string properties: + ```java Map flinkConf = new HashMap<>(); -// Enable compaction and maintenance features -flinkConf.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); +// Enable maintenance tasks +flinkConf.put("flink-maintenance.rewrite.enabled", "true"); +flinkConf.put("flink-maintenance.expire-snapshots.enabled", "true"); +flinkConf.put("flink-maintenance.delete-orphan-files.enabled", "true"); + +// Configure rewrite data files +flinkConf.put("flink-maintenance.rewrite.max-bytes", "1073741824"); + +// Configure expire snapshots +flinkConf.put("flink-maintenance.expire-snapshots.retain-last", "5"); +flinkConf.put("flink-maintenance.expire-snapshots.max-snapshot-age-seconds", "604800"); -// Configure JDBC lock settings -flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), LockConfig.JdbcLockConfig.JDBC); -flinkConf.put(LockConfig.JdbcLockConfig.JDBC_URI_OPTION.key(), "jdbc:postgresql://localhost:5432/iceberg"); -flinkConf.put(LockConfig.LOCK_ID_OPTION.key(), "catalog.db.table"); +// Configure delete orphan files +flinkConf.put("flink-maintenance.delete-orphan-files.min-age-seconds", "259200"); -// Add any other maintenance-related options here as needed -// ... +// Configure JDBC lock settings (deprecated, lock configuration is no longer required for a single Flink job) +flinkConf.put("flink-maintenance.lock.type", "jdbc"); +flinkConf.put("flink-maintenance.lock.jdbc.uri", "jdbc:postgresql://localhost:5432/iceberg"); +flinkConf.put("flink-maintenance.lock.lock-id", "catalog.db.table"); IcebergSink.forRowData(dataStream) .table(table) @@ -320,9 +359,20 @@ IcebergSink.forRowData(dataStream) You can enable maintenance and configure locks using SQL before executing writes: ```sql --- Enable Iceberg V2 Sink and compaction (maintenance) +-- Enable Iceberg V2 Sink and maintenance tasks SET 'table.exec.iceberg.use.v2.sink' = 'true'; -SET 'compaction-enabled' = 'true'; +SET 'flink-maintenance.rewrite.enabled' = 'true'; +SET 'flink-maintenance.expire-snapshots.enabled' = 'true'; +SET 'flink-maintenance.delete-orphan-files.enabled' = 'true'; + +-- Configure rewrite data files +SET 'flink-maintenance.rewrite.max-bytes' = '1073741824'; + +-- Configure expire snapshots +SET 'flink-maintenance.expire-snapshots.retain-last' = '5'; + +-- Configure delete orphan files +SET 'flink-maintenance.delete-orphan-files.min-age-seconds' = '259200'; -- Configure maintenance lock (JDBC) SET 'flink-maintenance.lock.type' = 'jdbc'; @@ -344,7 +394,13 @@ CREATE TABLE db.tbl ( 'catalog-name' = 'my_catalog', 'catalog-database' = 'db', 'catalog-table' = 'tbl', - 'compaction-enabled' = 'true', + 'flink-maintenance.rewrite.enabled' = 'true', + 'flink-maintenance.expire-snapshots.enabled' = 'true', + 'flink-maintenance.delete-orphan-files.enabled' = 'true', + + 'flink-maintenance.rewrite.max-bytes' = '1073741824', + 'flink-maintenance.expire-snapshots.retain-last' = '5', + 'flink-maintenance.delete-orphan-files.min-age-seconds' = '259200', 'flink-maintenance.lock.type' = 'jdbc', 'flink-maintenance.lock.lock-id' = 'catalog.db.table', @@ -353,9 +409,59 @@ CREATE TABLE db.tbl ( ); ``` +### IcebergSink Maintenance Configuration (SQL) + +These keys are used in SQL (SET or table WITH options) or via `IcebergSink.Builder.set()` / `setAll()`. + +#### Enable Flags + +| Key | Description | Default | +|-----|-------------|---------| +| `flink-maintenance.rewrite.enabled` | Enable compaction (rewrite data files) | `false` | +| `flink-maintenance.expire-snapshots.enabled` | Enable expire snapshots | `false` | +| `flink-maintenance.delete-orphan-files.enabled` | Enable delete orphan files | `false` | + +#### Rewrite Data Files Configuration + +| Key | Description | Default | +|-----|-------------|---------| +| `flink-maintenance.rewrite.schedule.commit-count` | Trigger after N commits | `10` | +| `flink-maintenance.rewrite.schedule.data-file-count` | Trigger after N data files | `1000` | +| `flink-maintenance.rewrite.schedule.data-file-size` | Trigger after total data file size (bytes) | `107374182400` (100GB) | +| `flink-maintenance.rewrite.schedule.interval-second` | Trigger after time interval (seconds) | `600` | +| `flink-maintenance.rewrite.max-bytes` | Maximum bytes to rewrite per execution | `Long.MAX_VALUE` | +| `flink-maintenance.rewrite.partial-progress.enabled` | Enable partial progress commits | `false` | +| `flink-maintenance.rewrite.partial-progress.max-commits` | Maximum commits for partial progress | `10` | + +#### Expire Snapshots Configuration + +| Key | Description | Default | +|-----|-------------|---------| +| `flink-maintenance.expire-snapshots.schedule.commit-count` | Trigger after N commits | `10` | +| `flink-maintenance.expire-snapshots.schedule.interval-second` | Trigger after time interval (seconds) | `3600` (1 hour) | +| `flink-maintenance.expire-snapshots.max-snapshot-age-seconds` | Maximum age of snapshots to retain (seconds) | Not set | +| `flink-maintenance.expire-snapshots.retain-last` | Minimum number of snapshots to retain | Not set | +| `flink-maintenance.expire-snapshots.delete-batch-size` | Batch size for deleting expired files | `1000` | +| `flink-maintenance.expire-snapshots.clean-expired-metadata` | Remove expired metadata (partition specs, schemas) | `true` | +| `flink-maintenance.expire-snapshots.planning-worker-pool-size` | Worker pool size for planning | Shared pool | + +#### Delete Orphan Files Configuration + +| Key | Description | Default | +|-----|-------------|---------| +| `flink-maintenance.delete-orphan-files.schedule.interval-second` | Trigger after time interval (seconds) | `3600` (1 hour) | +| `flink-maintenance.delete-orphan-files.min-age-seconds` | Minimum age of files to consider for deletion (seconds) | `259200` (3 days) | +| `flink-maintenance.delete-orphan-files.delete-batch-size` | Batch size for deleting orphan files | `1000` | +| `flink-maintenance.delete-orphan-files.location` | Location to start recursive listing | Table location | +| `flink-maintenance.delete-orphan-files.use-prefix-listing` | Use prefix listing for file discovery | `true` | +| `flink-maintenance.delete-orphan-files.planning-worker-pool-size` | Worker pool size for planning | Shared pool | +| `flink-maintenance.delete-orphan-files.equal-schemes` | Equivalent schemes (format: `s3n=s3,s3a=s3`) | `s3n=s3,s3a=s3` | +| `flink-maintenance.delete-orphan-files.equal-authorities` | Equivalent authorities (format: `auth1=auth2`) | Not set | +| `flink-maintenance.delete-orphan-files.prefix-mismatch-mode` | Behavior on prefix mismatch: `ERROR`, `IGNORE`, `DELETE` | `ERROR` | + ### Lock Configuration (SQL) -These keys are used in SQL (SET or table WITH options) and are applicable when writing with compaction enabled. +These keys are used in SQL (SET or table WITH options) and are applicable when writing with maintenance enabled. - JDBC @@ -380,6 +486,12 @@ These keys are used in SQL (SET or table WITH options) and are applicable when w | `flink-maintenance.lock.zookeeper.max-sleep-ms` | Maximum sleep time (ms) between retries. Caps the exponential backoff delay. | `10000` | | `flink-maintenance.lock.zookeeper.retry-policy` | Retry policy name for ZooKeeper client. Supported values include: ONE_TIME, N_TIME, BOUNDED_EXPONENTIAL_BACKOFF, UNTIL_ELAPSED, EXPONENTIAL_BACKOFF. | `EXPONENTIAL_BACKOFF` | +- COORDINATOR LOCK + +| Key | Description | Default | +|-----|----------------------|---------| +| `flink-maintenance.lock.type` | Set to `` or not set | | + ### Best Practices #### Resource Management diff --git a/docs/docs/flink-writes.md b/docs/docs/flink-writes.md index 5d3fde5e5456..03795b5beed0 100644 --- a/docs/docs/flink-writes.md +++ b/docs/docs/flink-writes.md @@ -207,6 +207,10 @@ They should have the following key-value tags. | dataFilesSizeHistogram | Histogram | Histogram distribution of data file sizes (in bytes). | | deleteFilesSizeHistogram | Histogram | Histogram distribution of delete file sizes (in bytes). | +The `Histogram` metrics above require `org.apache.flink:flink-metrics-dropwizard` on the classpath, +which is not shipped by Flink by default. Please add this artifact to your classpath to see histogram metrics. +If not present, histogram metrics will be missing. All other metric types will continue to get published. + Committer metrics are added under the sub group of `IcebergFilesCommitter`. They should have the following key-value tags. @@ -483,7 +487,7 @@ We need the following information (DynamicRecord) for every record: | `Schema` | The schema of the record. | | `Spec` | The expected partitioning specification for the record. | | `RowData` | The actual row data to be written. | -| `DistributionMode` | The distribution mode for writing the record (currently supports NONE or HASH). | +| `DistributionMode` | The distribution mode for writing the record (NONE, HASH or `null`). When `null`, the record won't be shuffled at all. | | `Parallelism` | The maximum number of parallel writers for a given table/branch/schema/spec (WriteTarget). | | `UpsertMode` | Overrides this table's write.upsert.enabled (optional). | | `EqualityFields` | The equality fields for the table(optional). | @@ -497,18 +501,26 @@ The dynamic sink tries to match the schema provided in `DynamicRecord` with the The dynamic sink maintains an LRU cache for both table metadata and incoming schemas, with eviction based on size and time constraints. When a DynamicRecord contains a schema that is incompatible with the current table schema, a schema update is triggered. This update can occur either immediately or via a centralized executor, depending on the `immediateTableUpdate` configuration. While centralized updates reduce load on the Catalog, they may introduce backpressure on the sink. -Supported schema updates: +#### Supported schema updates - Adding new columns - Widening existing column types (e.g., Integer → Long, Float → Double) - Making required columns optional +- Dropping columns (disabled by default) + +Dropping columns is disabled by default to prevent issues with late or out-of-order data, as removed fields cannot be easily restored without data loss. -Unsupported schema updates: +You can opt-in to allow dropping columns (see the configuration options below). Once a column has been dropped, it is +technically still possible to write data to that column because Iceberg maintains all past table schemas. However, +regular queries won't be able to reference the column. If the field was to re-appear as part of a new schema, an +entirely new column would be added, which apart from the name, has nothing in common with the old column, i.e. queries +for the new column will never return data of the old column. + +##### Unsupported schema updates -- Dropping columns - Renaming columns -Dropping columns is avoided to prevent issues with late or out-of-order data, as removed fields cannot be easily restored without data loss. Renaming is unsupported because schema comparison is name-based, and renames would require additional metadata or hints to resolve. +Renaming is unsupported because schema comparison is name-based, and renames would require additional metadata or hints to resolve. ### Caching @@ -537,6 +549,31 @@ The Dynamic Iceberg Flink Sink is configured using the Builder pattern. Here are | `set(String property, String value)` | Set any Iceberg write property (e.g., `"write.format"`, `"write.upsert.enabled"`).Check out all the options here: [write-options](flink-configuration.md#write-options) | | `setAll(Map properties)` | Set multiple properties at once | | `tableCreator(TableCreator creator)` | When DynamicIcebergSink creates new Iceberg tables, allows overriding how tables are created - setting custom table properties and location based on the table name. | +| `dropUnusedColumns(boolean enabled)` | When enabled, drops all columns from the current table schema which are not contained in the input schema (see the caveats above on dropping columns). | + +### Distribution Modes + +The `DistributionMode` set on each `DynamicRecord` controls how that record is routed from the processor to the writer: + +| Mode | Behavior | +|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `NONE` | Records are distributed across writer subtasks in a round-robin fashion (or by equality fields if set). | +| `HASH` | Records are distributed by partition key (partitioned tables) or equality fields (unpartitioned tables). Ensures that records for the same partition are handled by the same writer subtask. | +| `null` | Forward mode: bypasses distribution entirely and sends records directly via a forward edge (see below). | + +#### Forward Mode + +Using the `DynamicRecord` constructor overload without `distributionMode` parameter bypasses distribution entirely. This is designed for high-throughput pipelines where every partition already has a large volume of data and the serialization and network shuffle cost is prohibitive. Records are sent directly from the processor to the writer using a forward edge, enabling Flink operator chaining. Table metadata updates are always performed immediately inside the processor (regardless of `immediateTableUpdate` setting), because a dedicated table-update operator was deliberately omitted to avoid introducing extra data shuffles. + +Forward and regular records can be mixed in the same pipeline. The processor routes records to two separate sink outputs: + +- **Shuffle sink**: receives shuffling records. These go through the normal distribution topology (hash/round-robin) before reaching the writer. +- **Forward sink**: receives records without a `distributionMode`. These skip distribution entirely and flow via a forward edge from the processor, allowing Flink operator chaining. Suited for high-throughput tables where avoiding shuffle overhead is critical. The sink's `writeParallelism` config does not apply to this path. + +!!! warning + +1. In the forward path, schema changes are always applied immediately because records must pass straight through via the forward edge. For the intended high-volume use case, this can cause many conflicting commits to the Iceberg catalog and temporarily delay data processing. Consider either updating the schema externally before publishing records with the new schema, or planning for a temporary disruption in throughput when a new schema is introduced from upstream. +2. Because the forward path skips distribution entirely, users are responsible for distributing the data correctly in the upstream before the records reach the dynamic Iceberg sink. Otherwise, writes could be unbalanced. ### Notes diff --git a/docs/docs/flink.md b/docs/docs/flink.md index fd0a1077ed02..5a6788621140 100644 --- a/docs/docs/flink.md +++ b/docs/docs/flink.md @@ -1,5 +1,5 @@ --- -title: "Flink Getting Started" +title: "Getting Started" --- -# Flink +!!!tip + For an overview of using Iceberg with Flink, see the [Flink Quickstart](/flink-quickstart) Apache Iceberg supports both [Apache Flink](https://flink.apache.org/)'s DataStream API and Table API. See the [Multi-Engine Support](../../multi-engine-support.md#apache-flink) page for the integration of Apache Flink. @@ -76,11 +77,11 @@ Start the Flink SQL client. There is a separate `flink-runtime` module in the Ic # HADOOP_HOME is your hadoop root directory after unpack the binary package. export HADOOP_CLASSPATH=`$HADOOP_HOME/bin/hadoop classpath` -# Below works for 1.15 or less -./bin/sql-client.sh embedded -j /iceberg-flink-runtime-1.15-{{ icebergVersion }}.jar shell +# Below works for Flink 1.15 or earlier +./bin/sql-client.sh embedded -j /iceberg-flink-runtime-{{ flinkVersionMajor }}-{{ icebergVersion }}.jar shell -# 1.16 or above has a regression in loading external jar via -j option. See FLINK-30035 for details. -put iceberg-flink-runtime-1.16-{{ icebergVersion }}.jar in flink/lib dir +# Flink 1.16+ has a regression in loading external jars via -j. See FLINK-30035 for details. +# put iceberg-flink-runtime-{{ flinkVersionMajor }}-{{ icebergVersion }}.jar in flink/lib dir ./bin/sql-client.sh embedded shell ``` @@ -110,11 +111,11 @@ wget ${FLINK_CONNECTOR_URL}/${FLINK_CONNECTOR_PACKAGE}-${HIVE_VERSION}_${SCALA_V ## Flink's Python API !!! info - PyFlink 1.6.1 [does not work on OSX with a M1 cpu](https://issues.apache.org/jira/browse/FLINK-28786) + PyFlink 1.6.1 has a known issue on macOS with Apple Silicon. See [FLINK-28786](https://issues.apache.org/jira/browse/FLINK-28786). Install the Apache Flink dependency using `pip`: -```python +```bash pip install apache-flink=={{ flinkVersion }} ``` @@ -174,20 +175,20 @@ Run a query: For more details, please refer to the [Python Table API](https://ci.apache.org/projects/flink/flink-docs-release-{{ flinkVersionMajor }}/docs/dev/python/table/intro_to_table_api/). -## Adding catalogs. +## Adding catalogs -Flink support to create catalogs by using Flink SQL. +Flink supports creating catalogs using Flink SQL. ### Catalog Configuration A catalog is created and named by executing the following query (replace `` with your catalog name and -``=`` with catalog implementation config): +`'' = ''` with catalog implementation config): ```sql CREATE CATALOG WITH ( 'type'='iceberg', - ``=`` -); + '' = '' +); ``` The following properties can be set globally and are not limited to a specific catalog implementation: @@ -244,7 +245,7 @@ INSERT INTO `hive_catalog`.`default`.`sample` VALUES (1, 'a'); INSERT INTO `hive_catalog`.`default`.`sample` SELECT id, data from other_kafka_table; ``` -To replace data in the table with the result of a query, use `INSERT OVERWRITE` in batch job (flink streaming job does not support `INSERT OVERWRITE`). Overwrites are atomic operations for Iceberg tables. +To replace data in the table with the result of a query, use `INSERT OVERWRITE` in a batch job (Flink streaming jobs do not support `INSERT OVERWRITE`). Overwrites are atomic operations for Iceberg tables. Partitions that have rows produced by the SELECT query will be replaced, for example: @@ -252,7 +253,7 @@ Partitions that have rows produced by the SELECT query will be replaced, for exa INSERT OVERWRITE `hive_catalog`.`default`.`sample` VALUES (1, 'a'); ``` -Iceberg also support overwriting given partitions by the `select` values: +Iceberg also supports overwriting given partitions by the `SELECT` values: ```sql INSERT OVERWRITE `hive_catalog`.`default`.`sample` PARTITION(data='a') SELECT 6; @@ -275,8 +276,10 @@ env.execute("Test Iceberg DataStream"); ``` ### Branch Writes -Writing to branches in Iceberg tables is also supported via the `toBranch` API in `FlinkSink` +Writing to branches in Iceberg tables is also supported via the `toBranch` API in `FlinkSink`. + For more information on branches please refer to [branches](branching.md). + ```java FlinkSink.forRowData(input) .tableLoader(tableLoader) @@ -286,7 +289,7 @@ FlinkSink.forRowData(input) ## Reading -Submit a Flink __batch__ job using the following sentences: +Submit a Flink __batch__ job using the following statements: ```sql -- Execute the flink job in batch mode for current session context @@ -294,7 +297,7 @@ SET execution.runtime-mode = batch; SELECT * FROM `hive_catalog`.`default`.`sample`; ``` -Iceberg supports processing incremental data in flink __streaming__ jobs which starts from a historical snapshot-id: +Iceberg supports processing incremental data in Flink __streaming__ jobs that start from a historical snapshot ID: ```sql -- Submit the flink job in streaming mode for current session. @@ -313,10 +316,10 @@ SELECT * FROM `hive_catalog`.`default`.`sample` /*+ OPTIONS('streaming'='true', SQL is also the recommended way to inspect tables. To view all of the snapshots in a table, use the snapshots metadata table: ```sql -SELECT * FROM `hive_catalog`.`default`.`sample`.`snapshots` +SELECT * FROM `hive_catalog`.`default`.`sample$snapshots`; ``` -Iceberg support streaming or batch read in Java API: +Iceberg supports streaming or batch reads in the Java API: ``` DataStream batch = FlinkSource.forRowData() diff --git a/docs/docs/hive.md b/docs/docs/hive.md index 0531e9b04a15..4829acfe208b 100644 --- a/docs/docs/hive.md +++ b/docs/docs/hive.md @@ -71,9 +71,9 @@ Starting from 1.8.0 Iceberg doesn't release Hive runtime connector. For Hive que with Hive 2.x and 3.x) use Hive runtime connector coming with Iceberg 1.6.1, or use Hive 4.0.0 or later which is released with embedded Iceberg integration. -### Hive 4.1.x +### Hive 4.1.x, 4.2.x -Hive 4.1.x comes with Iceberg 1.9.1 included. +Hive 4.1.x and 4.2.x come with Iceberg 1.9.1 included. ### Hive 4.0.x diff --git a/docs/docs/java-api-quickstart.md b/docs/docs/java-api-quickstart.md index 430450fc87c2..74b40feed86d 100644 --- a/docs/docs/java-api-quickstart.md +++ b/docs/docs/java-api-quickstart.md @@ -28,11 +28,11 @@ Tables are created using either a [`Catalog`](../../javadoc/{{ icebergVersion }} The Hive catalog connects to a Hive metastore to keep track of Iceberg tables. You can initialize a Hive catalog with a name and some properties. -(see: [Catalog properties](configuration.md#catalog-properties)) +(see: [Catalog properties](catalog-properties.md)) ```java -import java.util.HashMap -import java.util.Map +import java.util.HashMap; +import java.util.Map; import org.apache.iceberg.hive.HiveCatalog; diff --git a/docs/docs/metrics-reporting.md b/docs/docs/metrics-reporting.md index e019e2761fe6..4ca452b0d503 100644 --- a/docs/docs/metrics-reporting.md +++ b/docs/docs/metrics-reporting.md @@ -145,7 +145,7 @@ public class InMemoryMetricsReporter implements MetricsReporter { ### Via Catalog Configuration -The [catalog property](configuration.md#catalog-properties) `metrics-reporter-impl` allows registering a given [`MetricsReporter`](https://github.com/apache/iceberg/blob/main/api/src/main/java/org/apache/iceberg/metrics/MetricsReporter.java) by specifying its fully-qualified class name, e.g. `metrics-reporter-impl=org.apache.iceberg.metrics.InMemoryMetricsReporter`. +The [catalog property](catalog-properties.md) `metrics-reporter-impl` allows registering a given [`MetricsReporter`](https://github.com/apache/iceberg/blob/main/api/src/main/java/org/apache/iceberg/metrics/MetricsReporter.java) by specifying its fully-qualified class name, e.g. `metrics-reporter-impl=org.apache.iceberg.metrics.InMemoryMetricsReporter`. ### Via the Java API during Scan planning diff --git a/docs/docs/schemas.md b/docs/docs/schemas.md index a22c9ba7a331..7448e858da67 100644 --- a/docs/docs/schemas.md +++ b/docs/docs/schemas.md @@ -22,23 +22,30 @@ title: Schemas Iceberg tables support the following types: -| Type | Description | Notes | -|--------------------|--------------------------------------------------------------------------|--------------------------------------------------| -| **`boolean`** | True or false | | -| **`int`** | 32-bit signed integers | Can promote to `long` | -| **`long`** | 64-bit signed integers | | -| **`float`** | [32-bit IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) floating point | Can promote to `double` | -| **`double`** | [64-bit IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) floating point | | -| **`decimal(P,S)`** | Fixed-point decimal; precision P, scale S | Scale is fixed and precision must be 38 or less | -| **`date`** | Calendar date without timezone or time | | -| **`time`** | Time of day without date, timezone | Stored as microseconds | -| **`timestamp`** | Timestamp without timezone | Stored as microseconds | -| **`timestamptz`** | Timestamp with timezone | Stored as microseconds | -| **`string`** | Arbitrary-length character sequences | Encoded with UTF-8 | -| **`fixed(L)`** | Fixed-length byte array of length L | | -| **`binary`** | Arbitrary-length byte array | | -| **`struct<...>`** | A record with named fields of any data type | | -| **`list`** | A list with elements of any data type | | -| **`map`** | A map with keys and values of any data type | | +| Type | Description | Notes | +| --------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | +| **`boolean`** | True or false | | +| **`int`** | 32-bit signed integers | Can promote to `long` | +| **`long`** | 64-bit signed integers | | +| **`float`** | [32-bit IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) floating point | Can promote to `double` | +| **`double`** | [64-bit IEEE 754](https://en.wikipedia.org/wiki/IEEE_754) floating point | | +| **`decimal(P,S)`** | Fixed-point decimal; precision P, scale S | Scale is fixed and precision must be 38 or less | +| **`date`** | Calendar date without timezone or time | | +| **`time`** | Time of day without date, timezone | Stored as microseconds | +| **`timestamp`** | Timestamp without timezone | Stored as microseconds | +| **`timestamptz`** | Timestamp with timezone | Stored as microseconds | +| **`timestamp_ns`** | Timestamp without timezone, nanosecond precision | Stored as nanoseconds; added in v3 | +| **`timestamptz_ns`** | Timestamp with timezone, nanosecond precision | Stored as nanoseconds; added in v3 | +| **`string`** | Arbitrary-length character sequences | Encoded with UTF-8 | +| **`uuid`** | Universally unique identifiers | | +| **`fixed(L)`** | Fixed-length byte array of length L | | +| **`binary`** | Arbitrary-length byte array | | +| **`variant`** | Semi-structured data (JSON-like) | Added in v3 | +| **`geometry(C)`** | Geospatial features with CRS parameter | Linear edge-interpolation; added in v3 | +| **`geography(C, A)`** | Geospatial features with CRS and edge algorithm | Non-linear edge-interpolation; added in v3 | +| **`unknown`** | Placeholder type for undetermined columns | Must be optional; added in v3 | +| **`struct<...>`** | A record with named fields of any data type | | +| **`list`** | A list with elements of any data type | | +| **`map`** | A map with keys and values of any data type | | Iceberg tracks each field in a table schema using an ID that is never reused in a table. See [correctness guarantees](evolution.md#correctness) for more information. diff --git a/docs/docs/spark-configuration.md b/docs/docs/spark-configuration.md index 6dee8244854e..5972aafc3d39 100644 --- a/docs/docs/spark-configuration.md +++ b/docs/docs/spark-configuration.md @@ -80,7 +80,7 @@ Both catalogs are configured using properties nested under the catalog name. Com | spark.sql.catalog._catalog-name_.view-override._propertyKey_ | | Enforced Iceberg view property value for property key _propertyKey_, which cannot be overridden on view creation by user | | spark.sql.catalog._catalog-name_.use-nullable-query-schema | `true` or `false` | Whether to preserve fields' nullability when creating the table using CTAS and RTAS. If set to `true`, all fields will be marked as nullable. If set to `false`, fields' nullability will be preserved. The default value is `true`. Available in Spark 3.5 and above. | -Additional properties can be found in common [catalog configuration](configuration.md#catalog-properties). +Additional properties can be found in common [catalog configuration](catalog-properties.md). ### Using catalogs @@ -112,6 +112,16 @@ Spark's built-in catalog supports existing v1 and v2 tables tracked in a Hive Me This configuration can use same Hive Metastore for both Iceberg and non-Iceberg tables. +`SparkSessionCatalog` is useful when you want `spark_catalog` to work with both Iceberg and non-Iceberg +tables in the same metastore. + +!!! note + Spark before 4.2.0 does not support `V2Function` in the session catalog. See + [SPARK-54760](https://issues.apache.org/jira/browse/SPARK-54760) ([apache/spark#53531](https://github.com/apache/spark/pull/53531)) for details. As a result, + catalog-scoped SQL functions such as `system.bucket`, `system.days`, and `system.iceberg_version` + are not available through `spark_catalog`. To work around this limitation, configure a separate + Iceberg catalog with `org.apache.iceberg.spark.SparkCatalog` and call them through that catalog. + ### Using catalog specific Hadoop configuration values Similar to configuring Hadoop properties by using `spark.hadoop.*`, it's possible to set per-catalog Hadoop configuration values when using Spark by adding the property for the catalog with the prefix `spark.sql.catalog.(catalog-name).hadoop.*`. These properties will take precedence over values configured globally using `spark.hadoop.*` and will only affect Iceberg tables. @@ -174,7 +184,6 @@ val spark = SparkSession.builder() | Spark option | Default | Description | |--------------------------------------------------------|----------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------| | spark.sql.iceberg.vectorization.enabled | Table default | Enables vectorized reads of data files | -| spark.sql.iceberg.parquet.reader-type | ICEBERG | Sets Parquet reader implementation (`ICEBERG`,`COMET`) | | spark.sql.iceberg.check-nullability | true | Validate that the write schema's nullability matches the table's nullability | | spark.sql.iceberg.check-ordering | true | Validates the write schema column order matches the table schema order | | spark.sql.iceberg.planning.preserve-data-grouping | false | When true, co-locate scan tasks for the same partition in the same read split, used in Storage Partitioned Joins | @@ -182,6 +191,8 @@ val spark = SparkSession.builder() | spark.sql.iceberg.distribution-mode | See [Spark Writes](spark-writes.md#writing-distribution-modes) | Controls distribution strategy during writes | | spark.wap.id | null | [Write-Audit-Publish](branching.md#audit-branch) snapshot staging ID | | spark.wap.branch | null | WAP branch name for snapshot commit | +| spark.sql.iceberg.shred-variants | Table default | When true, variant columns are written with shredded Parquet encoding for improved query performance | +| spark.sql.iceberg.variant-inference-buffer-size | Table default | Number of rows to buffer for schema inference when variant shredding is enabled | | spark.sql.iceberg.compression-codec | Table default | Write compression codec (e.g., `zstd`, `snappy`) | | spark.sql.iceberg.compression-level | Table default | Compression level for Parquet/Avro | | spark.sql.iceberg.compression-strategy | Table default | Compression strategy for ORC | @@ -196,6 +207,7 @@ val spark = SparkSession.builder() | spark.sql.iceberg.executor-cache.locality.enabled | false | Enables locality-aware executor cache usage | | spark.sql.iceberg.merge-schema | false | Enables modifying the table schema to match the write schema. Only adds columns missing columns | | spark.sql.iceberg.report-column-stats | true | Report Puffin Table Statistics if available to Spark's Cost Based Optimizer. CBO must be enabled for this to be effective | +| spark.sql.iceberg.async-micro-batch-planning-enabled | false | Enables asynchronous microbatch planning to reduce planning latency by pre-fetching file scan tasks | ### Read options @@ -220,6 +232,10 @@ spark.read | stream-from-timestamp | (none) | A timestamp in milliseconds to stream from; if before the oldest known ancestor snapshot, the oldest will be used | | streaming-max-files-per-micro-batch | INT_MAX | Maximum number of files per microbatch | | streaming-max-rows-per-micro-batch | INT_MAX | "Soft maximum" number of rows per microbatch; always includes all rows in next unprocessed file, excludes additional files if their inclusion would exceed the soft max limit | +| async-micro-batch-planning-enabled | false | Enables asynchronous microbatch planning to reduce planning latency by pre-fetching file scan tasks | +| streaming-snapshot-polling-interval-ms | 30000 | Overrides the polling time for async planner to refresh and detect new snapshots. Only affects when async-micro-batch-planning-enabled is set | +| async-queue-preload-file-limit | 100 | Overrides the number of files loaded to background queue initially. Tune to prevent queue starvation. Only affects when async-micro-batch-planning-enabled is set | +| async-queue-preload-row-limit | 100000 | Overrides the number of rows loaded to background queue initially. Tune to prevent queue starvation. Only affects when async-micro-batch-planning-enabled is set | ### Write options @@ -248,6 +264,8 @@ df.writeTo("catalog.db.table") | compression-strategy | Table write.orc.compression-strategy | Overrides this table's compression strategy for ORC tables for this write | | distribution-mode | See [Spark Writes](spark-writes.md#writing-distribution-modes) for defaults | Override this table's distribution mode for this write | | delete-granularity | file | Override this table's delete granularity for this write | +| shred-variants | false | Overrides this table's write.parquet.shred-variants for this write | +| variant-inference-buffer-size | 100 | Overrides this table's write.parquet.variant-inference-buffer-size for this write | CommitMetadata provides an interface to add custom metadata to a snapshot summary during a SQL execution, which can be beneficial for purposes such as auditing or change tracking. If properties start with `snapshot-property.`, then that prefix will be removed from each property. Here is an example: diff --git a/docs/docs/spark-ddl.md b/docs/docs/spark-ddl.md index 0b3f7389a3fa..9fa6c0e7d3c7 100644 --- a/docs/docs/spark-ddl.md +++ b/docs/docs/spark-ddl.md @@ -84,6 +84,9 @@ Supported transformations are: Note: Old syntax of `years(ts)`, `months(ts)`, `days(ts)` and `hours(ts)` are also supported for compatibility. +The same transforms are also available as Spark SQL functions under the `system` namespace. See +[Spark SQL functions](spark-queries.md#spark-sql-functions). + ## `CREATE TABLE ... AS SELECT` Iceberg supports CTAS as an atomic operation when using a [`SparkCatalog`](spark-configuration.md#catalog-configuration). CTAS is supported, but is not atomic when using [`SparkSessionCatalog`](spark-configuration.md#replacing-the-session-catalog). @@ -170,6 +173,27 @@ Iceberg has full `ALTER TABLE` support in Spark 3, including: In addition, [SQL extensions](spark-configuration.md#sql-extensions) can be used to add support for partition evolution and setting a table's write order +!!! warning "Hive Catalog Limitation" + The Hive Metastore (HMS) validates schema changes by comparing column types **positionally** + (`hive.metastore.disallow.incompatible.col.type.changes`, default `true`). Any schema evolution + operation that shifts column positions will fail when using a Hive catalog. Affected operations + include: + + - `ADD COLUMN` with `FIRST` or `AFTER` clauses + - `ALTER COLUMN` with `FIRST` or `AFTER` clauses (reordering) + - `DROP COLUMN` on a non-last column + + To work around this, disable the HMS schema compatibility check by setting + `hive.metastore.disallow.incompatible.col.type.changes=false`: + + - **Remote HMS:** Set this property in the HMS server's `hive-site.xml`. + - **Embedded HMS:** Pass `--conf spark.hadoop.hive.metastore.disallow.incompatible.col.type.changes=false` when starting Spark. + + **Trade-off:** After disabling this check, the Hive engine may no longer be able to read the table + correctly due to the schema mismatch in the Hive Metastore. Iceberg-aware engines (Spark, Flink, + Trino, etc.) will continue to work correctly, as they read schema from Iceberg metadata rather + than HMS. + ### `ALTER TABLE ... RENAME TO` ```sql @@ -259,6 +283,11 @@ ALTER TABLE prod.db.sample ADD COLUMN nested.new_column bigint FIRST; ``` +!!! warning "Hive Catalog Limitation" + When using a Hive catalog, adding a column with `FIRST` or `AFTER` may fail due to HMS positional + schema validation. See the warning above for details + and workaround. + ### `ALTER TABLE ... RENAME COLUMN` Iceberg allows any field to be renamed. To rename a field, use `RENAME COLUMN`: @@ -302,6 +331,10 @@ ALTER TABLE prod.db.sample ALTER COLUMN col FIRST; ALTER TABLE prod.db.sample ALTER COLUMN nested.col AFTER other_col; ``` +!!! warning "Hive Catalog Limitation" + When using a Hive catalog, reordering columns may fail due to HMS positional schema validation. + See the Hive Catalog Limitation note above for details and workaround. + Nullability for a non-nullable column can be changed using `DROP NOT NULL`: ```sql @@ -323,6 +356,11 @@ ALTER TABLE prod.db.sample DROP COLUMN id; ALTER TABLE prod.db.sample DROP COLUMN point.z; ``` +!!! warning "Hive Catalog Limitation" + When using a Hive catalog, dropping a non-last column may fail due to HMS positional schema + validation. See the earlier Hive Catalog Limitation warning above for details and + workaround. + ## `ALTER TABLE` SQL extensions These commands are available in Spark 3 when using Iceberg [SQL extensions](spark-configuration.md#sql-extensions). diff --git a/docs/docs/spark-getting-started.md b/docs/docs/spark-getting-started.md index 6813c76937a1..821bfd022c20 100644 --- a/docs/docs/spark-getting-started.md +++ b/docs/docs/spark-getting-started.md @@ -61,6 +61,8 @@ To create your first Iceberg table in Spark, use the `spark-sql` shell or `spark ```sql -- local is the path-based catalog defined above CREATE TABLE local.db.table (id bigint, data string) USING iceberg; +CREATE TABLE source (id bigint, data string) USING parquet; +CREATE TABLE updates (id bigint, data string) USING parquet; ``` Iceberg catalogs support the full range of SQL DDL commands, including: @@ -76,14 +78,16 @@ Once your table is created, insert data using [`INSERT INTO`](spark-writes.md#in ```sql INSERT INTO local.db.table VALUES (1, 'a'), (2, 'b'), (3, 'c'); +INSERT INTO source VALUES (10, 'd'), (11, 'ee'); +INSERT INTO updates VALUES (1, 'x'), (2, 'x'), (4, 'z'); INSERT INTO local.db.table SELECT id, data FROM source WHERE length(data) = 1; ``` Iceberg also adds row-level SQL updates to Spark, [`MERGE INTO`](spark-writes.md#merge-into) and [`DELETE FROM`](spark-writes.md#delete-from): ```sql -MERGE INTO local.db.target t USING (SELECT * FROM updates) u ON t.id = u.id -WHEN MATCHED THEN UPDATE SET t.count = t.count + u.count +MERGE INTO local.db.table t USING (SELECT * FROM updates) u ON t.id = u.id +WHEN MATCHED THEN UPDATE SET t.data = u.data WHEN NOT MATCHED THEN INSERT *; ``` diff --git a/docs/docs/spark-procedures.md b/docs/docs/spark-procedures.md index 7f211d9f260b..8e594caa12d4 100644 --- a/docs/docs/spark-procedures.md +++ b/docs/docs/spark-procedures.md @@ -189,15 +189,17 @@ publish_changes creates a new snapshot from an existing snapshot without alterin Only append and dynamic overwrite snapshots can be successfully published. +The `publish_changes` procedure will fail if there are multiple snapshots in the table with the provided `wap_id`. + !!! info This procedure invalidates all cached Spark plans that reference the affected table. #### Usage -| Argument Name | Required? | Type | Description | -|---------------|-----------|------|-------------| +| Argument Name | Required? | Type | Description | +|---------------|-----------|--------|-------------| | `table` | ✔️ | string | Name of the table to update | -| `wap_id` | ✔️ | long | The wap_id to be published from stage to prod | +| `wap_id` | ✔️ | string | The wap_id to be published from stage to prod | #### Output @@ -399,7 +401,7 @@ Iceberg can compact data files in parallel using Spark with the `rewriteDataFile | `max-concurrent-file-group-rewrites` | 5 | Maximum number of file groups to be simultaneously rewritten | | `partial-progress.enabled` | false | Enable committing groups of files prior to the entire rewrite completing | | `partial-progress.max-commits` | 10 | Maximum amount of commits that this rewrite is allowed to produce if partial progress is enabled | -| `partial-progress.max-failed-commits` | value of `partital-progress.max-commits` | Maximum amount of failed commits allowed before job failure, if partial progress is enabled | +| `partial-progress.max-failed-commits` | value of `partial-progress.max-commits` | Maximum amount of failed commits allowed before job failure, if partial progress is enabled | | `use-starting-sequence-number` | true | Use the sequence number of the snapshot at compaction start time instead of that of the newly produced snapshot | | `rewrite-job-order` | none | Force the rewrite job order based on the value.
  • If rewrite-job-order=bytes-asc, then rewrite the smallest job groups first.
  • If rewrite-job-order=bytes-desc, then rewrite the largest job groups first.
  • If rewrite-job-order=files-asc, then rewrite the job groups with the least files first.
  • If rewrite-job-order=files-desc, then rewrite the job groups with the most files first.
  • If rewrite-job-order=none, then rewrite job groups in the order they were planned (no specific ordering).
| | `target-file-size-bytes` | 536870912 (512 MB, default value of `write.target-file-size-bytes` from [table properties](configuration.md#write-properties)) | Target output file size | @@ -489,6 +491,7 @@ Data files in manifests are sorted by fields in the partition spec. This procedu | `table` | ✔️ | string | Name of the table to update | | `use_caching` | ️ | boolean | Use Spark caching during operation (defaults to false). Enabling caching can increase memory footprint on executors. | | `spec_id` | ️ | int | Spec id of the manifests to rewrite (defaults to current spec id) | +| `sort_by` | ️ | array | List of partition transform names to cluster manifests by. Choosing frequently queried partition transforms can reduce planning time by skipping unnecessary manifests. If not set, manifests will be sorted by all partition transforms in spec order. | #### Output @@ -509,6 +512,12 @@ Rewrite the manifests on the partition spec `1` in table `db.sample`. CALL catalog_name.system.rewrite_manifests(table => 'db.sample', spec_id => 1); ``` +Rewrite the manifests in table `db.sample` and cluster manifest entries by partition field `category`. +This can improve scan planning performance when queries frequently filter on `category`. +```sql +CALL catalog_name.system.rewrite_manifests(table => 'db.sample', sort_by => array('category')); +``` + ### `rewrite_position_delete_files` Iceberg can rewrite position delete files, which serves two purposes: @@ -628,6 +637,7 @@ Replace a table with an Iceberg table, loaded with the source's data files. Table schema, partitioning, properties, and location will be copied from the source table. Migrate will fail if any table partition uses an unsupported format. Supported formats are Avro, Parquet, and ORC. +Migrate will also fail if the table is bucketed, as the bucketing will not be preserved. Existing data files are added to the Iceberg table's metadata and can be read using a name-to-id mapping created from the original table schema. To leave the original table intact while testing, use [`snapshot`](#snapshot) to create new temporary table that shares source data files and schema. diff --git a/docs/docs/spark-queries.md b/docs/docs/spark-queries.md index 41189d05ffb4..91f1759f568c 100644 --- a/docs/docs/spark-queries.md +++ b/docs/docs/spark-queries.md @@ -44,6 +44,65 @@ SELECT * FROM prod.db.table.files; | 0 | s3:/.../table/data/00001-4-8d6d60e8-d427-4809-bcf0-f5d45a4aad96.parquet | PARQUET | 0 | {1999-01-01, 02} | 1 | 597 | [1 -> 90, 2 -> 62] | [1 -> 1, 2 -> 1] | [1 -> 0, 2 -> 0] | [] | [1 -> , 2 -> b] | [1 -> , 2 -> b] | null | [4] | null | null | | 0 | s3:/.../table/data/00002-5-8d6d60e8-d427-4809-bcf0-f5d45a4aad96.parquet | PARQUET | 0 | {1999-01-01, 03} | 1 | 597 | [1 -> 90, 2 -> 62] | [1 -> 1, 2 -> 1] | [1 -> 0, 2 -> 0] | [] | [1 -> , 2 -> a] | [1 -> , 2 -> a] | null | [4] | null | null | +### Spark SQL functions + +Iceberg adds SQL functions to each Iceberg catalog for inspecting transform results in queries and for +writing filters that match Iceberg partition transforms. These functions are available only through an +[Iceberg catalog](spark-configuration.md#catalog-configuration); they are not registered in Spark's +built-in catalog. + +!!! note + Spark before 4.2.0 does not support `V2Function` in the session catalog. + Queries such as `SELECT spark_catalog.system.bucket(16, id)` fail even when + `spark_catalog` is configured with `org.apache.iceberg.spark.SparkSessionCatalog`. + See [SPARK-54760](https://issues.apache.org/jira/browse/SPARK-54760) ([apache/spark#53531](https://github.com/apache/spark/pull/53531)) for details. + To use Iceberg SQL functions, call them through a catalog configured with + `org.apache.iceberg.spark.SparkCatalog`. + +Use the `system` namespace when calling these functions: + +```sql +SELECT system.iceberg_version(); + +SELECT system.bucket(16, id), system.days(ts) +FROM prod.db.table; +``` + +When you want to be explicit about the catalog, qualify the function with the catalog name: + +```sql +SELECT prod.system.bucket(16, id) +FROM prod.db.table; +``` + +!!! info + `PARTITIONED BY` clauses use singular transform expressions such as `year(ts)` and `month(ts)`. + The SQL functions use `system.years(ts)` and `system.months(ts)`. + +| Function | Supported input types | Return type | Example | +| --- | --- | --- | --- | +| `system.iceberg_version()` | none | `string` | `SELECT system.iceberg_version();` | +| `system.bucket(numBuckets, col)` | `date`, `tinyint`, `smallint`, `int`, `bigint`, `timestamp`, `timestamp_ntz`, `decimal`, `string`, `binary` | `int` | `SELECT system.bucket(16, id) FROM prod.db.table;` | +| `system.years(col)` | `date`, `timestamp`, `timestamp_ntz` | `int` | `SELECT system.years(ts) FROM prod.db.table;` | +| `system.months(col)` | `date`, `timestamp`, `timestamp_ntz` | `int` | `SELECT system.months(ts) FROM prod.db.table;` | +| `system.days(col)` | `date`, `timestamp`, `timestamp_ntz` | `date` | `SELECT * FROM prod.db.table WHERE system.days(ts) = date('2025-03-01');` | +| `system.hours(col)` | `timestamp`, `timestamp_ntz` | `int` | `SELECT system.hours(ts) FROM prod.db.table;` | +| `system.truncate(width, col)` | `tinyint`, `smallint`, `int`, `bigint`, `decimal`, `string`, `binary` | same type as `col` | `SELECT system.truncate(4, data) FROM prod.db.table;` | + +All transform functions return `NULL` for `NULL` inputs. + +`system.years`, `system.months`, `system.days`, and `system.hours` return Iceberg transform values +rather than extracted calendar fields. For example, `system.years` returns years since 1970-01-01, +`system.months` returns months since 1970-01, and `system.hours` returns hours since +1970-01-01T00:00. `system.days` returns a `date` value representing the date part of the input +(for `date` inputs it returns the same value unchanged; for timestamps it discards the time component). + +For numeric inputs, `system.truncate(width, col)` rounds down to the nearest multiple of `width`. +For `string` and `binary` inputs, it keeps the first `width` characters or bytes. + +These functions are especially useful when you want to inspect how Iceberg transforms values or +when writing filters for queries and row-level operations that align with partition transforms. + ### Time travel Queries with SQL Spark supports time travel in SQL queries using `TIMESTAMP AS OF` or `VERSION AS OF` clauses. The `VERSION AS OF` clause can contain a long snapshot ID or a string branch or tag name. @@ -116,6 +175,80 @@ SELECT * FROM prod.db.table VERSION AS OF 'historical-snapshot'; SELECT * FROM prod.db.table.`tag_historical-snapshot`; ``` +For example, consider a table that evolves its schema over time, and see how each type of time travel query selects its schema: + +```sql +-- snapshot S1: initial schema (id, status) +CREATE TABLE prod.db.orders ( + id BIGINT, + status STRING +) USING iceberg; + +INSERT INTO prod.db.orders VALUES (1, 'NEW'), (2, 'PAID'); + +-- record snapshot S1's snapshot_id and committed_at timestamp +-- e.g. snapshot_id = 101, committed_at = '2025-01-01 10:00:00' + +-- snapshot S2: add a new column "total" and write new data +ALTER TABLE prod.db.orders ADD COLUMN total DOUBLE; + +INSERT INTO prod.db.orders VALUES (3, 'PAID', 100.0); + +-- now S2 is the current snapshot with schema (id, status, total) +``` + +Time travel queries that select a specific snapshot or timestamp use the +snapshot's schema: + +```sql +-- uses the snapshot schema of S1: columns (id, status) +SELECT * FROM prod.db.orders VERSION AS OF 101; + +SELECT * FROM prod.db.orders TIMESTAMP AS OF '2025-01-01 10:00:00'; +``` + +In both queries above, the result only has `id` and `status`. The `total` +column does not exist in the S1 schema and is not visible, even though the +current table schema includes `total`. + +Now create a branch and a tag that both reference S1: + +```sql +-- branch "audit_branch" points to snapshot S1 +ALTER TABLE prod.db.orders CREATE BRANCH audit_branch AS OF VERSION 101; + +-- tag "first_load" also points to snapshot S1 +ALTER TABLE prod.db.orders CREATE TAG first_load AS OF VERSION 101; +``` + +When you query a branch, Spark uses the table's current schema: + +```sql +-- uses the table schema: columns (id, status, total) +SELECT * FROM prod.db.orders VERSION AS OF 'audit_branch'; + +-- equivalent identifier form +SELECT * FROM prod.db.orders.`branch_audit_branch`; +``` + +In these queries, the result has columns `(id, status, total)`. For the rows +from S1, `total` is returned as `NULL` because that column did not exist when +those rows were written. + +When you query a tag, Spark uses the snapshot's schema referenced by the tag: + +```sql +-- uses the snapshot schema of S1: columns (id, status) +SELECT * FROM prod.db.orders VERSION AS OF 'first_load'; + +-- equivalent identifier form +SELECT * FROM prod.db.orders.`tag_first_load`; +``` + +These queries only return `id` and `status`, because tags are bound to a +specific snapshot and use that snapshot's schema, even if the table's current +schema has evolved. + ## Querying with DataFrames To load a table as a DataFrame, use `table`: diff --git a/docs/docs/spark-structured-streaming.md b/docs/docs/spark-structured-streaming.md index e722df1ea4e7..3313f8150b73 100644 --- a/docs/docs/spark-structured-streaming.md +++ b/docs/docs/spark-structured-streaming.md @@ -63,6 +63,13 @@ val df = spark.readStream !!! info Note: In addition to limiting micro-batch sizes on queries that use the default trigger (i.e. `Trigger.ProcessingTime`), rate limiting options can be applied to queries that use `Trigger.AvailableNow` to split one-time processing of all available source data into multiple micro-batches for better query scalability. Rate limiting options will be ignored when using the deprecated `Trigger.Once` trigger. +### Asynchronous Micro-Batch Planning + +Users can enable asynchronous micro-batch planning by setting `async-micro-batch-planning-enabled` to true. With this option enabled, Iceberg will start processing the current micro-batch while planning the next micro-batches in parallel. +This can help improve query throughput by reducing idle time between micro-batches. Users should weigh the tradeoffs, which include higher memory usage and increased snapshot detection latency. + +Users can also set additional options to control the behavior of asynchronous micro-batch planning, found in the [spark configuration](spark-configuration.md#read-options). + ## Streaming Writes To write values from streaming query to Iceberg table, use `DataStreamWriter`: diff --git a/docs/docs/spark-writes.md b/docs/docs/spark-writes.md index f224894a454b..31a0bbc21155 100644 --- a/docs/docs/spark-writes.md +++ b/docs/docs/spark-writes.md @@ -100,6 +100,24 @@ Spark 3.5 added support for `WHEN NOT MATCHED BY SOURCE ... THEN ...` to update WHEN NOT MATCHED BY SOURCE THEN UPDATE SET status = 'invalid' ``` +#### Snapshot summary + +After a `MERGE INTO` commit, the [snapshot summary](../../spec.md#optional-snapshot-summary-fields) may include the following fields. Each value is the string form of a non-negative count. A field is omitted when the value is unknown (e.g., not reported by Spark). + +!!! info + Only available in Spark 4.1 and higher. + +| Field | Description | +|-----------------------------------------------------------------------|-----------------------------------------------------------------------------| +| **`spark.merge-into.num-target-rows-copied`** | Number of target rows copied unmodified because they did not match any action | +| **`spark.merge-into.num-target-rows-deleted`** | Number of target rows deleted | +| **`spark.merge-into.num-target-rows-updated`** | Number of target rows updated | +| **`spark.merge-into.num-target-rows-inserted`** | Number of target rows inserted | +| **`spark.merge-into.num-target-rows-matched-updated`** | Number of target rows updated by a MATCHED clause | +| **`spark.merge-into.num-target-rows-matched-deleted`** | Number of target rows deleted by a MATCHED clause | +| **`spark.merge-into.num-target-rows-not-matched-by-source-updated`** | Number of target rows updated by a NOT MATCHED BY SOURCE clause | +| **`spark.merge-into.num-target-rows-not-matched-by-source-deleted`** | Number of target rows deleted by a NOT MATCHED BY SOURCE clause | + ### `INSERT OVERWRITE` `INSERT OVERWRITE` can replace data in the table with the result of a query. Overwrites are atomic operations for Iceberg tables. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c1807a6b8542..8e31aba5c98b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -65,6 +65,7 @@ nav: - Hive Migration: hive-migration.md - Delta Lake Migration: delta-lake-migration.md - Catalogs: + - Catalog properties: catalog-properties.md - AWS Glue: aws/#glue-catalog - AWS DynamoDB: aws/#dynamodb-catalog - HadoopCatalog: https://iceberg.apache.org/javadoc/nightly/org/apache/iceberg/hadoop/HadoopCatalog.html diff --git a/flink/v1.20/build.gradle b/flink/v1.20/build.gradle index 3591bf37b1a7..41f2489c8038 100644 --- a/flink/v1.20/build.gradle +++ b/flink/v1.20/build.gradle @@ -33,7 +33,8 @@ project(":iceberg-flink:iceberg-flink-${flinkMajorVersion}") { implementation project(':iceberg-hive-metastore') compileOnly libs.flink120.avro - // for dropwizard histogram metrics implementation + compileOnly libs.joda.time + // dropwizard histogram metrics (optional in Flink) compileOnly libs.flink120.metrics.dropwizard compileOnly libs.flink120.streaming.java compileOnly "${libs.flink120.streaming.java.get().module}:${libs.flink120.streaming.java.get().getVersion()}:tests" @@ -83,6 +84,8 @@ project(":iceberg-flink:iceberg-flink-${flinkMajorVersion}") { testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation(testFixtures(project(':iceberg-parquet'))) // By default, hive-exec is a fat/uber jar and it exports a guava library // that's really old. We use the core classifier to be able to override our guava @@ -169,9 +172,6 @@ project(":iceberg-flink:iceberg-flink-runtime-${flinkMajorVersion}") { exclude group: 'com.google.code.findbugs', module: 'jsr305' } - // for dropwizard histogram metrics implementation - implementation libs.flink120.metrics.dropwizard - // for integration testing with the flink-runtime-jar // all of those dependencies are required because the integration test extends FlinkTestBase integrationCompileOnly project(':iceberg-api') @@ -266,4 +266,6 @@ project(":iceberg-flink:iceberg-flink-runtime-${flinkMajorVersion}") { jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/flink/v1.20/flink-runtime/LICENSE b/flink/v1.20/flink-runtime/LICENSE index 5f8bfb4edbf4..d73eda0104b9 100644 --- a/flink/v1.20/flink-runtime/LICENSE +++ b/flink/v1.20/flink-runtime/LICENSE @@ -203,138 +203,170 @@ -------------------------------------------------------------------------------- -This binary artifact contains Apache Avro. +This product bundles Apache Avro. Copyright: 2014-2020 The Apache Software Foundation. -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://avro.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains the Jackson JSON processor. +This product bundles Jackson JSON Processor. Copyright: 2007-2020 Tatu Saloranta and other contributors -Home page: http://jackson.codehaus.org/ -License: http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Parquet. +This product bundles FastDoubleParser (via Jackson JSON Processor). -Copyright: 2014-2020 The Apache Software Foundation. -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -This binary artifact contains Apache Thrift. +This product bundles fast_float (bundled by FastDoubleParser). -Copyright: 2006-2010 The Apache Software Foundation. -Home page: https://thrift.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -This binary artifact contains fastutil. +This product bundles bigint (bundled by FastDoubleParser). -Copyright: 2002-2014 Sebastiano Vigna -Home page: http://fastutil.di.unimi.it/ -License: http://www.apache.org/licenses/LICENSE-2.0.html +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -This binary artifact contains Apache ORC. +This product bundles Apache Parquet. -Copyright: 2013-2020 The Apache Software Foundation. -Home page: https://orc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2014-2020 The Apache Software Foundation. +Project URL: https://parquet.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Hive's storage API via ORC. +This product bundles Apache Thrift (bundled by Parquet). -Copyright: 2013-2020 The Apache Software Foundation. -Home page: https://hive.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2006-2010 The Apache Software Foundation. +Project URL: https://thrift.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Airlift Aircompressor. +This product includes code from Daniel Lemire's JavaFastPFOR project (bundled by Parquet). -Copyright: 2011-2020 Aircompressor authors. -Home page: https://github.com/airlift/aircompressor -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2013 Daniel Lemire +Project URL: https://github.com/lemire/JavaFastPFOR +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Guava. +This product bundles fastutil (bundled by Parquet). -Copyright: 2006-2020 The Guava Authors -Home page: https://github.com/google/guava -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2002-2014 Sebastiano Vigna +Project URL: http://fastutil.di.unimi.it/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Error Prone Annotations. +This product bundles Zero-Allocation Hashing (bundled by Parquet). -Copyright: Copyright 2011-2019 The Error Prone Authors -Home page: https://github.com/google/error-prone -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/OpenHFT/Zero-Allocation-Hashing +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains checkerframework checker-qual Annotations. - -Copyright: 2004-2020 the Checker Framework developers -Home page: https://github.com/typetools/checker-framework -License: https://github.com/typetools/checker-framework/blob/master/LICENSE.txt (MIT license) +This product bundles Apache ORC. -License text: -| The annotations are licensed under the MIT License. (The text of this -| license appears below.) More specifically, all the parts of the Checker -| Framework that you might want to include with your own program use the -| MIT License. This is the checker-qual.jar file and all the files that -| appear in it: every file in a qual/ directory, plus utility files such -| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. -| In addition, the cleanroom implementations of third-party annotations, -| which the Checker Framework recognizes as aliases for its own -| annotations, are licensed under the MIT License. -| -| Permission is hereby granted, free of charge, to any person obtaining a copy -| of this software and associated documentation files (the "Software"), to deal -| in the Software without restriction, including without limitation the rights -| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -| copies of the Software, and to permit persons to whom the Software is -| furnished to do so, subject to the following conditions: -| -| The above copyright notice and this permission notice shall be included in -| all copies or substantial portions of the Software. -| -| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -| THE SOFTWARE. +Copyright: 2013-2020 The Apache Software Foundation. +Project URL: https://orc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Caffeine by Ben Manes. +This product bundles Apache Hive's Storage API (bundled by ORC). -Copyright: 2014-2020 Ben Manes and contributors -Home page: https://github.com/ben-manes/caffeine -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2008-2020 The Apache Software Foundation. +Project URL: https://hive.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google protobuf. +This product bundles Google protobuf (bundled by ORC). Copyright: 2008 Google Inc. -Home page: https://developers.google.com/protocol-buffers -License: https://github.com/protocolbuffers/protobuf/blob/master/LICENSE (BSD) - -License text: +Project URL: https://developers.google.com/protocol-buffers +License: BSD 3-Clause | Copyright 2008 Google Inc. All rights reserved. | @@ -371,14 +403,88 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains ThreeTen. +This product bundles Apache Datasketches. -Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. -Home page: https://www.threeten.org/threeten-extra/ -License: https://github.com/ThreeTen/threeten-extra/blob/master/LICENSE.txt (BSD 3-clause) +Project URL: https://datasketches.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Airlift Aircompressor. + +Copyright: 2011-2020 Aircompressor authors. +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Google Guava. + +Copyright: 2006-2020 The Guava Authors +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- -License text: +This product bundles Google Error Prone Annotations. +Copyright: Copyright 2011-2019 The Error Prone Authors +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles checkerframework checker-qual. + +Copyright: 2004-2020 the Checker Framework developers +Project URL: https://github.com/typetools/checker-framework +License: MIT + +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Caffeine by Ben Manes. + +Copyright: 2014-2020 Ben Manes and contributors +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles ThreeTen Extra. + +Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. +Project URL: https://www.threeten.org/threeten-extra/ +License: BSD 3-Clause + +| Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. | All rights reserved. | | * Redistribution and use in source and binary forms, with or without @@ -409,80 +515,63 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Project Nessie. +This product bundles Project Nessie. Copyright: Copyright 2015-2025 Dremio Corporation -Home page: https://projectnessie.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://projectnessie.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary includes code from Apache Commons. +This product includes code from Apache Commons. * Core ArrayUtil. Copyright: 2020 The Apache Software Foundation -Home page: https://commons.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- - -This binary artifact contains Apache HttpComponents Client. - -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://commons.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache HttpComponents Client. +This product bundles and includes code from Apache HttpComponents (core/client). * retry and error handling logic in ExponentialHttpRequestRetryStrategy.java Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains failsafe. +This product bundles failsafe. Copyright: Jonathan Halterman and friends -Home page: https://failsafe.dev/ -License: https://www.apache.org/licenses/LICENSE-2.0.html - --------------------------------------------------------------------------------- - -This binary artifact contains Codehale Metrics. - -Copyright: (c) 2010-2013 Coda Hale, Yammer.com, 2014-2021 Dropwizard Team -Home page: https://github.com/dropwizard/metrics -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains RoaringBitmap. +This product bundles RoaringBitmap. Copyright: (c) 2013-... the RoaringBitmap authors -Home page: https://github.com/RoaringBitmap/RoaringBitmap -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Eclipse Microprofile OpenAPI. +This product bundles Eclipse MicroProfile OpenAPI. Copyright: Copyright (c) 2017 Contributors to the Eclipse Foundation -Home page: https://github.com/microprofile/microprofile-open-api -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/microprofile/microprofile-open-api +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Luben Zstd. +This product bundles Luben Zstd. Copyright: Copyright (c) 2015-present, Luben Karavelov/ All rights reserved. -Home page: https://github.com/luben/zstd-jni/ -License: BSD-2 License -License text: +Project URL: https://github.com/luben/zstd-jni/ +License: BSD 2-Clause | Zstd-jni: JNI bindings to Zstd Library | @@ -510,3 +599,426 @@ License text: | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles JTS Topology Suite. + +Project URL: https://github.com/locationtech/jts +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| - Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| - Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| - Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles the Mozilla Public Suffix List, distributed by Apache HttpComponents. + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + diff --git a/flink/v1.20/flink-runtime/NOTICE b/flink/v1.20/flink-runtime/NOTICE index 0838a76b3473..61b02129d0e1 100644 --- a/flink/v1.20/flink-runtime/NOTICE +++ b/flink/v1.20/flink-runtime/NOTICE @@ -1,15 +1,13 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -This binary artifact includes Airlift Aircompressor with the following in its -NOTICE file: - +This product bundles Airlift Aircompressor with the following in its NOTICE file: | Snappy Copyright Notices | ========================= | @@ -50,9 +48,7 @@ NOTICE file: -------------------------------------------------------------------------------- -This binary artifact includes Project Nessie with the following in its NOTICE -file: - +This product bundles Project Nessie with the following in its NOTICE file: | Nessie | Copyright 2015-2025 Dremio Corporation | @@ -337,8 +333,7 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Eclipse Microprofile OpenAPI with the following in its NOTICE file: - +This product bundles Eclipse Microprofile OpenAPI with the following in its NOTICE file: | ========================================================================= | == NOTICE file corresponding to section 4(d) of the Apache License, == | == Version 2.0, in this case for MicroProfile OpenAPI == @@ -358,3 +353,39 @@ This binary artifact includes Eclipse Microprofile OpenAPI with the following in | PackageCopyrightText: | Arthur De Magalhaes arthurdm@ca.ibm.com | + +-------------------------------------------------------------------------------- + +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. diff --git a/flink/v1.20/flink-runtime/runtime-deps.txt b/flink/v1.20/flink-runtime/runtime-deps.txt new file mode 100644 index 000000000000..431cbe0d114e --- /dev/null +++ b/flink/v1.20/flink-runtime/runtime-deps.txt @@ -0,0 +1,31 @@ +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.21.3 +com.fasterxml.jackson.core:jackson-databind:2.21.3 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.github.luben:zstd-jni:1.5.7-3 +com.google.errorprone:error_prone_annotations:2.10.0 +dev.failsafe:failsafe:3.3.2 +io.airlift:aircompressor:2.0.3 +org.apache.avro:avro:1.12.1 +org.apache.datasketches:datasketches-java:6.2.0 +org.apache.datasketches:datasketches-memory:3.0.2 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.checkerframework:checker-qual:3.19.0 +org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1 +org.locationtech.jts:jts-core:1.20.0 +org.projectnessie.nessie:nessie-client:0.107.5 +org.projectnessie.nessie:nessie-model:0.107.5 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.threeten:threeten-extra:1.7.1 diff --git a/flink/v1.20/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java b/flink/v1.20/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java index d7c3a7b32bc8..6e5d04499e75 100644 --- a/flink/v1.20/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java +++ b/flink/v1.20/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java @@ -32,6 +32,7 @@ import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.data.RandomGenericData; import org.apache.iceberg.data.Record; import org.apache.iceberg.flink.CatalogLoader; @@ -94,7 +95,7 @@ public void setupBenchmark() throws IOException { r -> new DynamicRecordInternal( "t", - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA, RowDataConverter.convert(SCHEMA, r), PartitionSpec.unpartitioned(), diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java index e0672811cf5f..7661372c88e8 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java @@ -44,7 +44,7 @@ public FlinkConfParser(Table table, Map options, ReadableConfig this.readableConfig = readableConfig; } - FlinkConfParser(Map options, ReadableConfig readableConfig) { + public FlinkConfParser(Map options, ReadableConfig readableConfig) { this.tableProperties = ImmutableMap.of(); this.options = options; this.readableConfig = readableConfig; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java index ab69ec5adc7f..0612260bfe7d 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java @@ -67,6 +67,24 @@ private FlinkCreateTableOptions( .noDefaultValue() .withDescription("Properties for the underlying catalog for iceberg table."); + public static final ConfigOption USE_DYNAMIC_ICEBERG_SINK = + ConfigOptions.key("use-dynamic-iceberg-sink") + .booleanType() + .defaultValue(false) + .withDescription( + "Whether to use dynamic iceberg sink for routing data to multiple tables. " + + "When enabled, a single sink instance can dynamically route records to different " + + "Iceberg tables based on the logic defined in the DynamicRecordGenerator implementation. " + + "Requires 'dynamic-record-generator-impl' to be specified. " + + "Default is false (uses standard static sink behavior)."); + + public static final ConfigOption DYNAMIC_RECORD_GENERATOR_IMPL = + ConfigOptions.key("dynamic-record-generator-impl") + .stringType() + .noDefaultValue() + .withDescription( + "Implementation of DynamicTableRecordGenerator class when use-dynamic-iceberg-sink is enabled."); + public static final String SRC_CATALOG_PROPS_KEY = "src-catalog"; public static final String CONNECTOR_PROPS_KEY = "connector"; public static final String LOCATION_KEY = "location"; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java index 3b9cc0baff11..b2e2e33b9291 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java @@ -88,26 +88,33 @@ public DynamicTableSink createDynamicTableSink(Context context) { ObjectIdentifier objectIdentifier = context.getObjectIdentifier(); ResolvedCatalogTable resolvedCatalogTable = context.getCatalogTable(); Map writeProps = resolvedCatalogTable.getOptions(); - ResolvedSchema resolvedSchema = - ResolvedSchema.of( - resolvedCatalogTable.getResolvedSchema().getColumns().stream() - .filter(Column::isPhysical) - .collect(Collectors.toList())); - TableLoader tableLoader; - if (catalog != null) { - tableLoader = createTableLoader(catalog, objectIdentifier.toObjectPath()); + Configuration flinkConf = new Configuration(); + writeProps.forEach(flinkConf::setString); + + boolean useDynamicSink = flinkConf.get(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK); + + if (useDynamicSink) { + return getIcebergTableSinkWithDynamicSinkProps(context, flinkConf, writeProps); } else { - tableLoader = - createTableLoader( - resolvedCatalogTable, - writeProps, - objectIdentifier.getDatabaseName(), - objectIdentifier.getObjectName()); - } + TableLoader tableLoader; + if (catalog != null) { + tableLoader = createTableLoader(catalog, objectIdentifier.toObjectPath()); + } else { + tableLoader = + createTableLoader( + resolvedCatalogTable, + writeProps, + objectIdentifier.getDatabaseName(), + objectIdentifier.getObjectName()); + } - return new IcebergTableSink( - tableLoader, resolvedSchema, context.getConfiguration(), writeProps); + return new IcebergTableSink( + tableLoader, + resolvedCatalogTable.getResolvedSchema(), + context.getConfiguration(), + writeProps); + } } @Override @@ -123,6 +130,8 @@ public Set> optionalOptions() { Set> options = Sets.newHashSet(); options.add(FlinkCreateTableOptions.CATALOG_DATABASE); options.add(FlinkCreateTableOptions.CATALOG_TABLE); + options.add(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK); + options.add(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL); return options; } @@ -131,6 +140,46 @@ public String factoryIdentifier() { return FACTORY_IDENTIFIER; } + private IcebergTableSink getIcebergTableSinkWithDynamicSinkProps( + Context context, Configuration flinkConf, Map writeProps) { + String dynamicRecordGeneratorImpl = + flinkConf.get(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL); + Preconditions.checkArgument( + dynamicRecordGeneratorImpl != null, + "Invalid dynamic record generator value: null. %s must be specified when use-dynamic-iceberg-sink is true.", + FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL.key()); + + CatalogLoader catalogLoader; + if (catalog != null) { + catalogLoader = catalog.getCatalogLoader(); + } else { + FlinkCatalog flinkCatalog = + createCatalogLoader(writeProps, flinkConf.get(FlinkCreateTableOptions.CATALOG_NAME)); + catalogLoader = flinkCatalog.getCatalogLoader(); + } + + ResolvedCatalogTable resolvedCatalogTable = context.getCatalogTable(); + + return new IcebergTableSink( + catalogLoader, + dynamicRecordGeneratorImpl, + resolvedCatalogTable.getResolvedSchema(), + context.getConfiguration(), + writeProps); + } + + private static FlinkCatalog createCatalogLoader( + Map tableProps, String catalogName) { + Preconditions.checkArgument( + catalogName != null, + "Invalid catalog name: null. Set %s create table option.", + FlinkCreateTableOptions.CATALOG_NAME); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + return (FlinkCatalog) + factory.createCatalog(catalogName, tableProps, FlinkCatalogFactory.clusterHadoopConf()); + } + private static TableLoader createTableLoader( ResolvedCatalogTable resolvedCatalogTable, Map tableProps, @@ -142,23 +191,17 @@ private static TableLoader createTableLoader( mergedProps.forEach(flinkConf::setString); - String catalogName = flinkConf.getString(FlinkCreateTableOptions.CATALOG_NAME); - Preconditions.checkNotNull( - catalogName, - "Table property '%s' cannot be null", - FlinkCreateTableOptions.CATALOG_NAME.key()); + String catalogName = flinkConf.get(FlinkCreateTableOptions.CATALOG_NAME); - String catalogDatabase = - flinkConf.getString(FlinkCreateTableOptions.CATALOG_DATABASE, databaseName); - Preconditions.checkNotNull(catalogDatabase, "The iceberg database name cannot be null"); + String catalogDatabase = flinkConf.get(FlinkCreateTableOptions.CATALOG_DATABASE, databaseName); + Preconditions.checkArgument( + catalogDatabase != null, + "Invalid database name: null. Set %s create table option or specify fully qualified table name.", + FlinkCreateTableOptions.CATALOG_DATABASE); - String catalogTable = flinkConf.getString(FlinkCreateTableOptions.CATALOG_TABLE, tableName); - Preconditions.checkNotNull(catalogTable, "The iceberg table name cannot be null"); + String catalogTable = flinkConf.get(FlinkCreateTableOptions.CATALOG_TABLE, tableName); - org.apache.hadoop.conf.Configuration hadoopConf = FlinkCatalogFactory.clusterHadoopConf(); - FlinkCatalogFactory factory = new FlinkCatalogFactory(); - FlinkCatalog flinkCatalog = - (FlinkCatalog) factory.createCatalog(catalogName, mergedProps, hadoopConf); + FlinkCatalog flinkCatalog = createCatalogLoader(mergedProps, catalogName); ObjectPath objectPath = new ObjectPath(catalogDatabase, catalogTable); // Create database if not exists in the external catalog. @@ -230,7 +273,6 @@ private static Map mergeSrcCatalogProps(Map tabl } private static TableLoader createTableLoader(FlinkCatalog catalog, ObjectPath objectPath) { - Preconditions.checkNotNull(catalog, "Flink catalog cannot be null"); return TableLoader.fromCatalog(catalog.getCatalogLoader(), catalog.toIdentifier(objectPath)); } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java index 408065f06057..8f106da8d56b 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java @@ -137,11 +137,17 @@ public Type visit(TimeType timeType) { @Override public Type visit(TimestampType timestampType) { + if (timestampType.getPrecision() > 6) { + return Types.TimestampNanoType.withoutZone(); + } return Types.TimestampType.withoutZone(); } @Override public Type visit(LocalZonedTimestampType localZonedTimestampType) { + if (localZonedTimestampType.getPrecision() > 6) { + return Types.TimestampNanoType.withZone(); + } return Types.TimestampType.withZone(); } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java index 66fd09807794..990d23f2aaff 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java @@ -220,6 +220,24 @@ public Integer writeParallelism() { return confParser.intConf().option(FlinkWriteOptions.WRITE_PARALLELISM.key()).parseOptional(); } + public boolean expireSnapshotsMode() { + return confParser + .booleanConf() + .option(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key()) + .flinkConfig(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE) + .defaultValue(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.defaultValue()) + .parse(); + } + + public boolean deleteOrphanFilesMode() { + return confParser + .booleanConf() + .option(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key()) + .flinkConfig(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE) + .defaultValue(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.defaultValue()) + .parse(); + } + public boolean compactMode() { return confParser .booleanConf() diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java index e68e64ac573d..ee2aeaa45007 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java @@ -23,6 +23,9 @@ import org.apache.flink.configuration.ConfigOption; import org.apache.flink.configuration.ConfigOptions; import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; +import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; import org.apache.iceberg.flink.sink.shuffle.StatisticsType; /** Flink sink write options */ @@ -82,7 +85,18 @@ private FlinkWriteOptions() {} ConfigOptions.key("write-parallelism").intType().noDefaultValue(); public static final ConfigOption COMPACTION_ENABLE = - ConfigOptions.key("compaction-enabled").booleanType().defaultValue(false); + ConfigOptions.key(RewriteDataFilesConfig.PREFIX + "enabled") + .booleanType() + .defaultValue(false) + .withDeprecatedKeys("compaction-enabled"); + + public static final ConfigOption EXPIRE_SNAPSHOTS_ENABLE = + ConfigOptions.key(ExpireSnapshotsConfig.PREFIX + "enabled").booleanType().defaultValue(false); + + public static final ConfigOption DELETE_ORPHAN_FILES_ENABLE = + ConfigOptions.key(DeleteOrphanFilesConfig.PREFIX + "enabled") + .booleanType() + .defaultValue(false); @Experimental public static final ConfigOption TABLE_REFRESH_INTERVAL = diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java index 218e298c9583..84d41778dbeb 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java @@ -20,10 +20,12 @@ import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.flink.configuration.ReadableConfig; import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.DataStreamSink; import org.apache.flink.table.api.TableSchema; +import org.apache.flink.table.catalog.Column; import org.apache.flink.table.catalog.ResolvedSchema; import org.apache.flink.table.catalog.UniqueConstraint; import org.apache.flink.table.connector.ChangelogMode; @@ -33,28 +35,44 @@ import org.apache.flink.table.connector.sink.abilities.SupportsOverwrite; import org.apache.flink.table.connector.sink.abilities.SupportsPartitioning; import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.types.RowKind; +import org.apache.iceberg.common.DynConstructors; import org.apache.iceberg.flink.sink.FlinkSink; import org.apache.iceberg.flink.sink.IcebergSink; +import org.apache.iceberg.flink.sink.dynamic.DynamicIcebergSink; +import org.apache.iceberg.flink.sink.dynamic.DynamicRecordGenerator; +import org.apache.iceberg.flink.sink.dynamic.DynamicTableRecordGenerator; +import org.apache.iceberg.flink.sink.dynamic.TableCreator; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.util.PropertyUtil; public class IcebergTableSink implements DynamicTableSink, SupportsPartitioning, SupportsOverwrite { private final TableLoader tableLoader; - @Deprecated private final TableSchema tableSchema; + private final CatalogLoader catalogLoader; + + @SuppressWarnings("deprecation") + @Deprecated + private final TableSchema tableSchema; + private final ResolvedSchema resolvedSchema; private final ReadableConfig readableConfig; private final Map writeProps; - + private final String dynamicRecordGeneratorImpl; private boolean overwrite = false; + private boolean useDynamicSink = false; private IcebergTableSink(IcebergTableSink toCopy) { this.tableLoader = toCopy.tableLoader; + this.catalogLoader = toCopy.catalogLoader; this.tableSchema = toCopy.tableSchema; this.resolvedSchema = toCopy.resolvedSchema; this.overwrite = toCopy.overwrite; this.readableConfig = toCopy.readableConfig; this.writeProps = toCopy.writeProps; + this.dynamicRecordGeneratorImpl = toCopy.dynamicRecordGeneratorImpl; + this.useDynamicSink = toCopy.useDynamicSink; } /** @@ -68,10 +86,12 @@ public IcebergTableSink( ReadableConfig readableConfig, Map writeProps) { this.tableLoader = tableLoader; + this.catalogLoader = null; this.tableSchema = tableSchema; this.resolvedSchema = null; this.readableConfig = readableConfig; this.writeProps = writeProps; + this.dynamicRecordGeneratorImpl = null; } public IcebergTableSink( @@ -80,84 +100,74 @@ public IcebergTableSink( ReadableConfig readableConfig, Map writeProps) { this.tableLoader = tableLoader; + this.catalogLoader = null; this.tableSchema = null; this.resolvedSchema = resolvedSchema; this.readableConfig = readableConfig; this.writeProps = writeProps; + this.dynamicRecordGeneratorImpl = null; } + public IcebergTableSink( + CatalogLoader catalogLoader, + String dynamicRecordGeneratorImpl, + ResolvedSchema resolvedSchema, + ReadableConfig readableConfig, + Map writeProps) { + this.tableLoader = null; + this.catalogLoader = catalogLoader; + this.dynamicRecordGeneratorImpl = dynamicRecordGeneratorImpl; + this.readableConfig = readableConfig; + this.writeProps = writeProps; + this.tableSchema = null; + this.resolvedSchema = resolvedSchema; + this.useDynamicSink = true; + } + + @SuppressWarnings("deprecation") @Override public SinkRuntimeProvider getSinkRuntimeProvider(Context context) { Preconditions.checkState( !overwrite || context.isBounded(), "Unbounded data stream doesn't support overwrite operation."); - if (resolvedSchema != null) { - List equalityColumns = - resolvedSchema - .getPrimaryKey() - .map(UniqueConstraint::getColumns) - .orElseGet(ImmutableList::of); - - return new DataStreamSinkProvider() { - @Override - public DataStreamSink consumeDataStream( - ProviderContext providerContext, DataStream dataStream) { - if (Boolean.TRUE.equals( - readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK))) { - return IcebergSink.forRowData(dataStream) - .tableLoader(tableLoader) - .resolvedSchema(resolvedSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } else { - return FlinkSink.forRowData(dataStream) - .tableLoader(tableLoader) - .resolvedSchema(resolvedSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } + return new DataStreamSinkProvider() { + @Override + public DataStreamSink consumeDataStream( + ProviderContext providerContext, DataStream dataStream) { + if (useDynamicSink) { + return createDynamicIcebergSink(dataStream); } - }; - } else { - List equalityColumns = - tableSchema - .getPrimaryKey() - .map(org.apache.flink.table.api.constraints.UniqueConstraint::getColumns) - .orElseGet(ImmutableList::of); - - return new DataStreamSinkProvider() { - @Override - public DataStreamSink consumeDataStream( - ProviderContext providerContext, DataStream dataStream) { - if (readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK)) { - return IcebergSink.forRowData(dataStream) - .tableLoader(tableLoader) - .tableSchema(tableSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } else { - return FlinkSink.forRowData(dataStream) - .tableLoader(tableLoader) - .tableSchema(tableSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } + + ResolvedSchema physicalColumnsOnlySchema = null; + List equalityColumns; + if (resolvedSchema != null) { + physicalColumnsOnlySchema = + ResolvedSchema.of( + resolvedSchema.getColumns().stream() + .filter(Column::isPhysical) + .collect(Collectors.toList())); + + equalityColumns = + physicalColumnsOnlySchema + .getPrimaryKey() + .map(UniqueConstraint::getColumns) + .orElseGet(ImmutableList::of); + } else { + equalityColumns = + tableSchema + .getPrimaryKey() + .map(org.apache.flink.table.api.constraints.UniqueConstraint::getColumns) + .orElseGet(ImmutableList::of); } - }; - } + + if (readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK)) { + return createIcebergSink(dataStream, equalityColumns, physicalColumnsOnlySchema); + } else { + return createLegacySink(dataStream, equalityColumns, physicalColumnsOnlySchema); + } + } + }; } @Override @@ -189,4 +199,104 @@ public String asSummaryString() { public void applyOverwrite(boolean newOverwrite) { this.overwrite = newOverwrite; } + + private DataStreamSink createLegacySink( + DataStream dataStream, + List equalityColumns, + ResolvedSchema physicalColumnsOnlySchema) { + FlinkSink.Builder builder = + FlinkSink.forRowData(dataStream) + .tableLoader(tableLoader) + .equalityFieldColumns(equalityColumns) + .overwrite(overwrite) + .setAll(writeProps) + .flinkConf(readableConfig); + + if (physicalColumnsOnlySchema != null) { + builder = builder.resolvedSchema(physicalColumnsOnlySchema); + } else { + builder = builder.tableSchema(tableSchema); + } + + return builder.append(); + } + + private DataStreamSink createIcebergSink( + DataStream dataStream, + List equalityColumns, + ResolvedSchema physicalColumnsOnlySchema) { + IcebergSink.Builder builder = + IcebergSink.forRowData(dataStream) + .tableLoader(tableLoader) + .equalityFieldColumns(equalityColumns) + .overwrite(overwrite) + .setAll(writeProps) + .flinkConf(readableConfig); + + if (physicalColumnsOnlySchema != null) { + builder = builder.resolvedSchema(physicalColumnsOnlySchema); + } else { + builder = builder.tableSchema(tableSchema); + } + + return builder.append(); + } + + private DataStreamSink createDynamicIcebergSink(DataStream dataStream) { + Preconditions.checkArgument( + catalogLoader != null && dynamicRecordGeneratorImpl != null, + "Invalid value catalogLoader: %s, DynamicRecordGenerator Implementation class: %s. " + + "Both should be not null to use dynamic iceberg sink.", + catalogLoader, + dynamicRecordGeneratorImpl); + + TableCreator tableCreator = createTableCreator(); + DynamicRecordGenerator generator = + createDynamicRecordGenerator(dynamicRecordGeneratorImpl); + + DynamicIcebergSink.Builder builder = + DynamicIcebergSink.forInput(dataStream) + .generator(generator) + .catalogLoader(catalogLoader) + .setAll(writeProps) + .tableCreator(tableCreator) + .flinkConf(readableConfig); + + return builder.append(); + } + + private TableCreator createTableCreator() { + final Map tableProperties = + PropertyUtil.propertiesWithPrefix(writeProps, "table.props."); + final String location = writeProps.get("location"); + + return (catalog, identifier, schema, spec) -> + catalog + .buildTable(identifier, schema) + .withPartitionSpec(spec) + .withLocation(location) + .withProperties(tableProperties) + .create(); + } + + private DynamicTableRecordGenerator createDynamicRecordGenerator(String generatorImpl) { + RowType rowType = (RowType) resolvedSchema.toSourceRowDataType().getLogicalType(); + + DynConstructors.Ctor ctor; + + try { + ctor = + DynConstructors.builder(DynamicTableRecordGenerator.class) + .loader(IcebergTableSink.class.getClassLoader()) + .impl(generatorImpl, RowType.class) + .buildChecked(); + return ctor.newInstance(rowType); + } catch (ClassCastException e) { + throw new IllegalArgumentException( + String.format("Class %s does not implement DynamicRecordGeneratorSQL", generatorImpl), e); + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to instantiate DynamicRecordGeneratorSQL %s", generatorImpl), e); + } + } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java index 3ef611f2ded5..920e44b24b31 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java @@ -114,19 +114,35 @@ private static PositionalGetter buildGetter(LogicalType logicalType, Type typ case TIMESTAMP_WITHOUT_TIME_ZONE: TimestampType timestampType = (TimestampType) logicalType; - return (row, pos) -> { - LocalDateTime localDateTime = - row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); - return DateTimeUtil.microsFromTimestamp(localDateTime); - }; + if (type.typeId() == Type.TypeID.TIMESTAMP_NANO) { + return (row, pos) -> { + LocalDateTime localDateTime = + row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); + return DateTimeUtil.nanosFromTimestamp(localDateTime); + }; + } else { + return (row, pos) -> { + LocalDateTime localDateTime = + row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); + return DateTimeUtil.microsFromTimestamp(localDateTime); + }; + } case TIMESTAMP_WITH_LOCAL_TIME_ZONE: LocalZonedTimestampType lzTs = (LocalZonedTimestampType) logicalType; - return (row, pos) -> { - TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); - return timestampData.getMillisecond() * 1000 - + timestampData.getNanoOfMillisecond() / 1000; - }; + if (type.typeId() == Type.TypeID.TIMESTAMP_NANO) { + return (row, pos) -> { + TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + }; + } else { + return (row, pos) -> { + TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + }; + } case ROW: RowType rowType = (RowType) logicalType; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java index 4cf30ed90418..dcd4ac4e58a5 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java @@ -53,7 +53,7 @@ protected FileIO fileIO() { protected List rewriteDataForTasks(List combinedScanTasks) { int size = combinedScanTasks.size(); int parallelism = Math.min(size, maxParallelism); - DataStream dataStream = env.fromCollection(combinedScanTasks); + DataStream dataStream = env.fromData(combinedScanTasks); RowDataRewriter rowDataRewriter = new RowDataRewriter(table(), caseSensitive(), fileIO(), encryptionManager()); try { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java index 66ed95792e62..b0f8a4217115 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java @@ -33,6 +33,7 @@ import org.apache.iceberg.avro.MetricsAwareDatumWriter; import org.apache.iceberg.avro.ValueWriter; import org.apache.iceberg.avro.ValueWriters; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; public class FlinkAvroWriter implements MetricsAwareDatumWriter { @@ -43,6 +44,10 @@ public FlinkAvroWriter(RowType rowType) { this.rowType = rowType; } + public FlinkAvroWriter(org.apache.iceberg.Schema icebergSchema, RowType engineSchema) { + this(engineSchema != null ? engineSchema : FlinkSchemaUtil.convert(icebergSchema)); + } + @Override @SuppressWarnings("unchecked") public void setSchema(Schema schema) { @@ -127,7 +132,7 @@ public ValueWriter primitive(LogicalType type, Schema primitive) { return FlinkValueWriters.decimal(decimal.getPrecision(), decimal.getScale()); case "uuid": - return ValueWriters.uuids(); + return FlinkValueWriters.uuids(); default: throw new IllegalArgumentException("Unsupported logical type: " + logicalType); diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java new file mode 100644 index 000000000000..dd713b0dce2a --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.data; + +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; + +public class FlinkFormatModels { + public static void register() { + FormatModelRegistry.register( + ParquetFormatModel.create( + RowData.class, + RowType.class, + FlinkParquetWriters::buildWriter, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + FlinkParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + AvroFormatModel.create( + RowData.class, + RowType.class, + (icebergSchema, fileSchema, engineSchema) -> + new FlinkAvroWriter(icebergSchema, engineSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + FlinkPlannedAvroReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + RowData.class, + RowType.class, + (icebergSchema, fileSchema, engineSchema) -> + FlinkOrcWriter.buildWriter(engineSchema, icebergSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + new FlinkOrcReader(icebergSchema, fileSchema, idToConstant))); + } + + private FlinkFormatModels() {} +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java index 65b9d44ad4b8..77f16bfdb2ab 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java @@ -70,7 +70,7 @@ public OrcValueReader record( TypeDescription record, List names, List> fields) { - return FlinkOrcReaders.struct(fields, iStruct, idToConstant); + return FlinkOrcReaders.struct(record, fields, iStruct, idToConstant); } @Override @@ -112,6 +112,13 @@ public OrcValueReader primitive(Type.PrimitiveType iPrimitive, TypeDescriptio } else { return FlinkOrcReaders.timestamps(); } + case TIMESTAMP_NANO: + Types.TimestampNanoType timestampNanoType = (Types.TimestampNanoType) iPrimitive; + if (timestampNanoType.shouldAdjustToUTC()) { + return FlinkOrcReaders.timestampTzs(); + } else { + return FlinkOrcReaders.timestamps(); + } case STRING: return FlinkOrcReaders.strings(); case UUID: diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java index 7a4a15c7e600..c5c958fbdb04 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java @@ -39,6 +39,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.types.Types; +import org.apache.orc.TypeDescription; import org.apache.orc.storage.ql.exec.vector.BytesColumnVector; import org.apache.orc.storage.ql.exec.vector.ColumnVector; import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector; @@ -91,8 +92,11 @@ public static OrcValueReader map( } public static OrcValueReader struct( - List> readers, Types.StructType struct, Map idToConstant) { - return new StructReader(readers, struct, idToConstant); + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + return new StructReader(record, readers, struct, idToConstant); } private static class StringReader implements OrcValueReader { @@ -265,8 +269,11 @@ private static class StructReader extends OrcValueReaders.StructReader private final int numFields; StructReader( - List> readers, Types.StructType struct, Map idToConstant) { - super(readers, struct, idToConstant); + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + super(record, readers, struct, idToConstant); this.numFields = struct.fields().size(); } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java index 6a31accffd22..c1b46252e18a 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java @@ -27,6 +27,7 @@ import org.apache.iceberg.FieldMetrics; import org.apache.iceberg.Schema; import org.apache.iceberg.data.orc.GenericOrcWriters; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.orc.OrcRowWriter; import org.apache.iceberg.orc.OrcValueWriter; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -45,7 +46,8 @@ private FlinkOrcWriter(RowType rowType, Schema iSchema) { } public static OrcRowWriter buildWriter(RowType rowType, Schema iSchema) { - return new FlinkOrcWriter(rowType, iSchema); + return new FlinkOrcWriter( + rowType != null ? rowType : FlinkSchemaUtil.convert(iSchema), iSchema); } @Override @@ -143,6 +145,13 @@ public OrcValueWriter primitive(Type.PrimitiveType iPrimitive, LogicalType fl } else { return FlinkOrcWriters.timestamps(); } + case TIMESTAMP_NANO: + Types.TimestampNanoType timestampNanoType = (Types.TimestampNanoType) iPrimitive; + if (timestampNanoType.shouldAdjustToUTC()) { + return FlinkOrcWriters.timestampNanoTzs(); + } else { + return FlinkOrcWriters.timestampNanos(); + } case STRING: return FlinkOrcWriters.strings(); case UUID: diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java index afce2cda1db1..bf19a46c05fb 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java @@ -30,6 +30,7 @@ import org.apache.flink.table.data.StringData; import org.apache.flink.table.data.TimestampData; import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeRoot; import org.apache.iceberg.FieldMetrics; import org.apache.iceberg.data.orc.GenericOrcWriters; import org.apache.iceberg.flink.FlinkRowData; @@ -69,6 +70,14 @@ static OrcValueWriter timestampTzs() { return TimestampTzWriter.INSTANCE; } + static OrcValueWriter timestampNanos() { + return TimestampNanoWriter.INSTANCE; + } + + static OrcValueWriter timestampNanoTzs() { + return TimestampNanoTzWriter.INSTANCE; + } + static OrcValueWriter decimals(int precision, int scale) { if (precision <= 18) { return new Decimal18Writer(precision, scale); @@ -93,7 +102,19 @@ static OrcValueWriter map( } static OrcValueWriter struct(List> writers, List types) { - return new RowDataWriter(writers, types); + int[] fieldIndexes = new int[writers.size()]; + int fieldIndex = 0; + List logicalTypes = Lists.newArrayList(); + for (int i = 0; i < types.size(); i += 1) { + LogicalType logicalType = types.get(i); + if (!logicalType.is(LogicalTypeRoot.NULL)) { + fieldIndexes[fieldIndex] = i; + fieldIndex += 1; + logicalTypes.add(logicalType); + } + } + + return new RowDataWriter(fieldIndexes, writers, logicalTypes); } private static class StringWriter implements OrcValueWriter { @@ -157,6 +178,35 @@ public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { } } + private static class TimestampNanoWriter implements OrcValueWriter { + private static final TimestampNanoWriter INSTANCE = new TimestampNanoWriter(); + + @Override + public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { + TimestampColumnVector cv = (TimestampColumnVector) output; + cv.setIsUTC(true); + // millis + OffsetDateTime offsetDateTime = data.toInstant().atOffset(ZoneOffset.UTC); + cv.time[rowId] = + offsetDateTime.toEpochSecond() * 1_000 + offsetDateTime.getNano() / 1_000_000; + cv.nanos[rowId] = offsetDateTime.getNano(); + } + } + + private static class TimestampNanoTzWriter implements OrcValueWriter { + private static final TimestampNanoTzWriter INSTANCE = new TimestampNanoTzWriter(); + + @SuppressWarnings("JavaInstantGetSecondsGetNano") + @Override + public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { + TimestampColumnVector cv = (TimestampColumnVector) output; + // millis + Instant instant = data.toInstant(); + cv.time[rowId] = instant.toEpochMilli(); + cv.nanos[rowId] = instant.getNano(); + } + } + private static class Decimal18Writer implements OrcValueWriter { private final int precision; private final int scale; @@ -294,12 +344,12 @@ public Stream> metrics() { static class RowDataWriter extends GenericOrcWriters.StructWriter { private final List fieldGetters; - RowDataWriter(List> writers, List types) { + RowDataWriter(int[] fieldIndexes, List> writers, List types) { super(writers); this.fieldGetters = Lists.newArrayListWithExpectedSize(types.size()); for (int i = 0; i < types.size(); i++) { - fieldGetters.add(FlinkRowData.createFieldGetter(types.get(i), i)); + fieldGetters.add(FlinkRowData.createFieldGetter(types.get(i), fieldIndexes[i])); } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java index 4e650e9574e3..3de64aa99865 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java @@ -294,6 +294,12 @@ public Optional> visit( LogicalTypeAnnotation.BsonLogicalTypeAnnotation bsonLogicalType) { return Optional.of(new ParquetValueReaders.ByteArrayReader(desc)); } + + @Override + public Optional> visit( + LogicalTypeAnnotation.UUIDLogicalTypeAnnotation uuidLogicalType) { + return Optional.of(new ParquetValueReaders.ByteArrayReader(desc)); + } } @Override diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java index 5c90252723bd..2957a4837893 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java @@ -37,7 +37,9 @@ import org.apache.flink.table.types.logical.RowType.RowField; import org.apache.flink.table.types.logical.SmallIntType; import org.apache.flink.table.types.logical.TinyIntType; +import org.apache.iceberg.Schema; import org.apache.iceberg.flink.FlinkRowData; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.parquet.ParquetValueReaders; import org.apache.iceberg.parquet.ParquetValueWriter; import org.apache.iceberg.parquet.ParquetValueWriters; @@ -59,6 +61,7 @@ import org.apache.parquet.schema.LogicalTypeAnnotation.StringLogicalTypeAnnotation; import org.apache.parquet.schema.LogicalTypeAnnotation.TimeLogicalTypeAnnotation; import org.apache.parquet.schema.LogicalTypeAnnotation.TimestampLogicalTypeAnnotation; +import org.apache.parquet.schema.LogicalTypeAnnotation.UUIDLogicalTypeAnnotation; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; @@ -66,6 +69,12 @@ public class FlinkParquetWriters { private FlinkParquetWriters() {} + public static ParquetValueWriter buildWriter( + Schema icebergSchema, MessageType type, RowType engineSchema) { + return buildWriter( + engineSchema != null ? engineSchema : FlinkSchemaUtil.convert(icebergSchema), type); + } + @SuppressWarnings("unchecked") public static ParquetValueWriter buildWriter(LogicalType schema, MessageType type) { return (ParquetValueWriter) @@ -279,6 +288,11 @@ public Optional> visit(JsonLogicalTypeAnnotation ignored) public Optional> visit(BsonLogicalTypeAnnotation ignored) { return Optional.of(byteArrays(desc)); } + + @Override + public Optional> visit(UUIDLogicalTypeAnnotation uuid) { + return Optional.of(byteArrays(desc)); + } } private static ParquetValueWriter ints(LogicalType type, ColumnDescriptor desc) { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java index ba4e1a7a7aec..1440fde3248c 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java @@ -106,7 +106,9 @@ private static T visitRecord( visitor.beforeField(iField); try { - results.add(visit(fieldFlinkType, iField.type(), visitor)); + if (iField.type() != Types.UnknownType.get()) { + results.add(visit(fieldFlinkType, iField.type(), visitor)); + } } finally { visitor.afterField(iField); } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java index f87e63704965..66e944eb52e3 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java @@ -55,6 +55,10 @@ static ValueWriter timestampNanos() { return TimestampNanosWriter.INSTANCE; } + static ValueWriter uuids() { + return UUIDWriter.INSTANCE; + } + static ValueWriter decimal(int precision, int scale) { return new DecimalWriter(precision, scale); } @@ -145,6 +149,15 @@ public void write(TimestampData timestampData, Encoder encoder) throws IOExcepti } } + private static class UUIDWriter implements ValueWriter { + private static final UUIDWriter INSTANCE = new UUIDWriter(); + + @Override + public void write(byte[] bytes, Encoder encoder) throws IOException { + encoder.writeFixed(bytes); + } + } + private static class ArrayWriter implements ValueWriter { private final ValueWriter elementWriter; private final ArrayData.ElementGetter elementGetter; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java index f23a7ee3d0d3..81bb55967992 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java @@ -69,6 +69,8 @@ public static Object convertConstant(Type type, Object value) { return (int) ((Long) value / 1000); case TIMESTAMP: // TimestampData return TimestampData.fromLocalDateTime(DateTimeUtil.timestampFromMicros((Long) value)); + case TIMESTAMP_NANO: + return TimestampData.fromLocalDateTime(DateTimeUtil.timestampFromNanos((Long) value)); case UUID: return UUIDUtil.convert((UUID) value); default: diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java index 34576a1e5c0b..b469f2310f42 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java @@ -48,6 +48,7 @@ import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.DateTimeUtil; @Internal public class StructRowData implements RowData { @@ -120,8 +121,8 @@ public int getInt(int pos) { if (integer instanceof Integer) { return (int) integer; - } else if (integer instanceof LocalDate) { - return (int) ((LocalDate) integer).toEpochDay(); + } else if (integer instanceof LocalDate localDate) { + return (int) localDate.toEpochDay(); } else if (integer instanceof LocalTime) { return (int) (((LocalTime) integer).toNanoOfDay() / 1000_000); } else { @@ -185,8 +186,27 @@ private BigDecimal getDecimalInternal(int pos) { @Override public TimestampData getTimestamp(int pos, int precision) { + if (precision > 6) { + Object timeVal = struct.get(pos, Object.class); + if (timeVal instanceof OffsetDateTime) { + OffsetDateTime odt = (OffsetDateTime) timeVal; + return TimestampData.fromEpochMillis( + odt.toInstant().toEpochMilli(), odt.getNano() % 1_000_000); + } else if (timeVal instanceof LocalDateTime) { + LocalDateTime ldt = (LocalDateTime) timeVal; + return TimestampData.fromEpochMillis( + ldt.toInstant(ZoneOffset.UTC).toEpochMilli(), ldt.getNano() % 1_000_000); + } else if (timeVal instanceof Long) { + long timeLong = (Long) timeVal; + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1_000_000L), (int) Math.floorMod(timeLong, 1_000_000L)); + } else { + throw new IllegalStateException("Unknown type for timestamp_ns: " + timeVal.getClass()); + } + } long timeLong = getLong(pos); - return TimestampData.fromEpochMillis(timeLong / 1000, (int) (timeLong % 1000) * 1000); + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1000L), (int) Math.floorMod(timeLong, 1000L) * 1000); } @Override @@ -257,9 +277,29 @@ private Object convertValue(Type elementType, Object value) { case DECIMAL: return value; case TIMESTAMP: - long millisecond = (long) value / 1000; - int nanoOfMillisecond = (int) ((Long) value % 1000) * 1000; - return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); + long timeMillis; + if (value instanceof LocalDateTime localDateTime) { + timeMillis = DateTimeUtil.microsFromTimestamp(localDateTime) / 1000L; + } else if (value instanceof OffsetDateTime offsetDateTime) { + timeMillis = DateTimeUtil.microsFromTimestamptz(offsetDateTime) / 1000L; + } else { + timeMillis = Math.floorDiv((Long) value, 1000L); + } + return TimestampData.fromEpochMillis( + timeMillis, + (int) Math.floorMod(value instanceof Long ? (Long) value : timeMillis * 1000L, 1000L) + * 1000); + case TIMESTAMP_NANO: + long nanoLong; + if (value instanceof LocalDateTime localDateTime) { + nanoLong = DateTimeUtil.nanosFromTimestamp(localDateTime); + } else if (value instanceof OffsetDateTime offsetDateTime) { + nanoLong = DateTimeUtil.nanosFromTimestamptz(offsetDateTime); + } else { + nanoLong = (Long) value; + } + return TimestampData.fromEpochMillis( + Math.floorDiv(nanoLong, 1_000_000L), (int) Math.floorMod(nanoLong, 1_000_000L)); case STRING: return StringData.fromString(value.toString()); case FIXED: diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java new file mode 100644 index 000000000000..0f70e60a1b9f --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java @@ -0,0 +1,303 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import java.io.Serializable; +import java.lang.reflect.Array; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoField; +import java.util.List; +import java.util.Map; +import org.apache.avro.generic.GenericFixed; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.generic.IndexedRecord; +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.GenericArrayData; +import org.apache.flink.table.data.GenericMapData; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.utils.LogicalTypeUtils; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; + +/** + * Tool class used to convert from Avro {@link GenericRecord} to {@link RowData}. + * + *

This class is adapted in Iceberg to add support for nanosecond precision timestamps + * (FLINK-39251). Once that ticket is resolved in Flink, this custom converter may be removed. + */ +@Internal +public class AvroToRowDataConverters { + + private AvroToRowDataConverters() {} + + /** + * Runtime converter that converts Avro data structures into objects of Flink Table & SQL + * internal data structures. + */ + @FunctionalInterface + public interface AvroToRowDataConverter extends Serializable { + Object convert(Object object); + } + + // ------------------------------------------------------------------------------------- + // Runtime Converters + // ------------------------------------------------------------------------------------- + + public static AvroToRowDataConverter createRowConverter(RowType rowType) { + return createRowConverter(rowType, true); + } + + public static AvroToRowDataConverter createRowConverter( + RowType rowType, boolean legacyTimestampMapping) { + final AvroToRowDataConverter[] fieldConverters = + rowType.getFields().stream() + .map(RowType.RowField::getType) + .map(type -> createNullableConverter(type, legacyTimestampMapping)) + .toArray(AvroToRowDataConverter[]::new); + final int arity = rowType.getFieldCount(); + + return avroObject -> { + IndexedRecord record = (IndexedRecord) avroObject; + GenericRowData row = new GenericRowData(arity); + for (int i = 0; i < arity; ++i) { + // avro always deserialize successfully even though the type isn't matched + // so no need to throw exception about which field can't be deserialized + row.setField(i, fieldConverters[i].convert(record.get(i))); + } + return row; + }; + } + + /** Creates a runtime converter which is null safe. */ + private static AvroToRowDataConverter createNullableConverter( + LogicalType type, boolean legacyTimestampMapping) { + final AvroToRowDataConverter converter = createConverter(type, legacyTimestampMapping); + return avroObject -> { + if (avroObject == null) { + return null; + } + return converter.convert(avroObject); + }; + } + + /** Creates a runtime converter which assuming input object is not null. */ + private static AvroToRowDataConverter createConverter( + LogicalType type, boolean legacyTimestampMapping) { + switch (type.getTypeRoot()) { + case NULL: + return avroObject -> null; + case TINYINT: + return avroObject -> ((Integer) avroObject).byteValue(); + case SMALLINT: + return avroObject -> ((Integer) avroObject).shortValue(); + case BOOLEAN: // boolean + case INTEGER: // int + case INTERVAL_YEAR_MONTH: // long + case BIGINT: // long + case INTERVAL_DAY_TIME: // long + case FLOAT: // float + case DOUBLE: // double + return avroObject -> avroObject; + case DATE: + return AvroToRowDataConverters::convertToDate; + case TIME_WITHOUT_TIME_ZONE: + return AvroToRowDataConverters::convertToTime; + case TIMESTAMP_WITHOUT_TIME_ZONE: + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return avroObject -> convertToTimestamp(avroObject, type); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + if (legacyTimestampMapping) { + throw new UnsupportedOperationException("Unsupported type: " + type); + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return avroObject -> convertToTimestamp(avroObject, type); + } + case CHAR: + case VARCHAR: + return avroObject -> StringData.fromString(avroObject.toString()); + case BINARY: + case VARBINARY: + return AvroToRowDataConverters::convertToBytes; + case DECIMAL: + return createDecimalConverter((DecimalType) type); + case ARRAY: + return createArrayConverter((ArrayType) type, legacyTimestampMapping); + case ROW: + return createRowConverter((RowType) type); + case MAP: + case MULTISET: + return createMapConverter(type, legacyTimestampMapping); + case RAW: + default: + throw new UnsupportedOperationException("Unsupported type: " + type); + } + } + + private static AvroToRowDataConverter createDecimalConverter(DecimalType decimalType) { + final int precision = decimalType.getPrecision(); + final int scale = decimalType.getScale(); + return avroObject -> { + final byte[] bytes; + if (avroObject instanceof GenericFixed) { + bytes = ((GenericFixed) avroObject).bytes(); + } else if (avroObject instanceof ByteBuffer) { + ByteBuffer byteBuffer = (ByteBuffer) avroObject; + bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + } else { + bytes = (byte[]) avroObject; + } + return DecimalData.fromUnscaledBytes(bytes, precision, scale); + }; + } + + private static AvroToRowDataConverter createArrayConverter( + ArrayType arrayType, boolean legacyTimestampMapping) { + final AvroToRowDataConverter elementConverter = + createNullableConverter(arrayType.getElementType(), legacyTimestampMapping); + final Class elementClass = + LogicalTypeUtils.toInternalConversionClass(arrayType.getElementType()); + + return avroObject -> { + final List list = (List) avroObject; + final int length = list.size(); + final Object[] array = (Object[]) Array.newInstance(elementClass, length); + for (int i = 0; i < length; ++i) { + array[i] = elementConverter.convert(list.get(i)); + } + return new GenericArrayData(array); + }; + } + + private static AvroToRowDataConverter createMapConverter( + LogicalType type, boolean legacyTimestampMapping) { + final AvroToRowDataConverter keyConverter = + createConverter(DataTypes.STRING().getLogicalType(), legacyTimestampMapping); + final AvroToRowDataConverter valueConverter = + createNullableConverter( + AvroSchemaConverter.extractValueTypeToAvroMap(type), legacyTimestampMapping); + + return avroObject -> { + final Map map = (Map) avroObject; + Map result = Maps.newHashMap(); + for (Map.Entry entry : map.entrySet()) { + Object key = keyConverter.convert(entry.getKey()); + Object value = valueConverter.convert(entry.getValue()); + result.put(key, value); + } + return new GenericMapData(result); + }; + } + + private static TimestampData convertToTimestamp(Object object, LogicalType type) { + int precision = 3; + if (type instanceof org.apache.flink.table.types.logical.TimestampType) { + precision = ((org.apache.flink.table.types.logical.TimestampType) type).getPrecision(); + } else if (type instanceof org.apache.flink.table.types.logical.LocalZonedTimestampType) { + precision = + ((org.apache.flink.table.types.logical.LocalZonedTimestampType) type).getPrecision(); + } + + if (object instanceof Long) { + long timeLong = (Long) object; + if (precision <= 3) { + return TimestampData.fromEpochMillis(timeLong); + } else if (precision <= 6) { + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1000L), (int) Math.floorMod(timeLong, 1000L) * 1_000_000); + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1_000_000L), (int) Math.floorMod(timeLong, 1_000_000L)); + } + } else if (object instanceof Instant) { + return TimestampData.fromInstant((Instant) object); + } else if (object instanceof LocalDateTime) { + return TimestampData.fromLocalDateTime((LocalDateTime) object); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + return TimestampData.fromEpochMillis(jodaConverter.convertTimestamp(object)); + } else { + throw new IllegalArgumentException( + "Unexpected object type for TIMESTAMP logical type. Received: " + object); + } + } + } + + private static int convertToDate(Object object) { + if (object instanceof Integer) { + return (Integer) object; + } else if (object instanceof LocalDate) { + return (int) ((LocalDate) object).toEpochDay(); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + return (int) jodaConverter.convertDate(object); + } else { + throw new IllegalArgumentException( + "Unexpected object type for DATE logical type. Received: " + object); + } + } + } + + private static int convertToTime(Object object) { + final int millis; + if (object instanceof Integer) { + millis = (Integer) object; + } else if (object instanceof LocalTime) { + millis = ((LocalTime) object).get(ChronoField.MILLI_OF_DAY); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + millis = jodaConverter.convertTime(object); + } else { + throw new IllegalArgumentException( + "Unexpected object type for TIME logical type. Received: " + object); + } + } + return millis; + } + + private static byte[] convertToBytes(Object object) { + if (object instanceof GenericFixed) { + return ((GenericFixed) object).bytes(); + } else if (object instanceof ByteBuffer) { + ByteBuffer byteBuffer = (ByteBuffer) object; + byte[] bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + return bytes; + } else { + return (byte[]) object; + } + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java new file mode 100644 index 000000000000..c30b78023345 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeFieldType; +import org.joda.time.LocalDate; +import org.joda.time.LocalTime; + +/** + * Encapsulates joda optional dependency. Instantiates this class only if joda is available on the + * classpath. + */ +@SuppressWarnings("JavaUtilDate") +class JodaConverter { + + private static JodaConverter instance; + private static boolean instantiated = false; + + public static JodaConverter getConverter() { + if (instantiated) { + return instance; + } + + try { + Class.forName( + "org.joda.time.DateTime", false, Thread.currentThread().getContextClassLoader()); + instance = new JodaConverter(); + } catch (ClassNotFoundException e) { + instance = null; + } finally { + instantiated = true; + } + return instance; + } + + public long convertDate(Object object) { + final LocalDate value = (LocalDate) object; + return value.toDate().getTime(); + } + + public int convertTime(Object object) { + final LocalTime value = (LocalTime) object; + return value.get(DateTimeFieldType.millisOfDay()); + } + + public long convertTimestamp(Object object) { + final DateTime value = (DateTime) object; + return value.toDate().getTime(); + } + + private JodaConverter() {} +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java new file mode 100644 index 000000000000..d4c7e4282d6e --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java @@ -0,0 +1,394 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.util.Utf8; +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.data.ArrayData; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.util.CollectionUtil; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; + +/** + * Tool class used to convert from {@link RowData} to Avro {@link GenericRecord}. + * + *

This class is adapted in Iceberg to add support for nanosecond precision timestamps + * (FLINK-39251). Once that ticket is resolved in Flink, this custom converter may be removed. + */ +@Internal +public class RowDataToAvroConverters { + + private RowDataToAvroConverters() {} + + // -------------------------------------------------------------------------------- + // Runtime Converters + // -------------------------------------------------------------------------------- + + /** + * Runtime converter that converts objects of Flink Table & SQL internal data structures to + * corresponding Avro data structures. + */ + @FunctionalInterface + public interface RowDataToAvroConverter extends Serializable { + Object convert(Schema schema, Object object); + } + + // -------------------------------------------------------------------------------- + // IMPORTANT! We use anonymous classes instead of lambdas for a reason here. It is + // necessary because the maven shade plugin cannot relocate classes in + // SerializedLambdas (MSHADE-260). On the other hand we want to relocate Avro for + // sql-client uber jars. + // -------------------------------------------------------------------------------- + + /** + * Creates a runtime converter according to the given logical type that converts objects of Flink + * Table & SQL internal data structures to corresponding Avro data structures. + */ + public static RowDataToAvroConverter createConverter(LogicalType type) { + return createConverter(type, true); + } + + @SuppressWarnings("checkstyle:MethodLength") + public static RowDataToAvroConverter createConverter( + LogicalType type, boolean legacyTimestampMapping) { + final RowDataToAvroConverter converter; + switch (type.getTypeRoot()) { + case NULL: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return null; + } + }; + break; + case TINYINT: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ((Byte) object).intValue(); + } + }; + break; + case SMALLINT: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ((Short) object).intValue(); + } + }; + break; + case BOOLEAN: // boolean + case INTEGER: // int + case INTERVAL_YEAR_MONTH: // long + case BIGINT: // long + case INTERVAL_DAY_TIME: // long + case FLOAT: // float + case DOUBLE: // double + case TIME_WITHOUT_TIME_ZONE: // int + case DATE: // int + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return object; + } + }; + break; + case CHAR: + case VARCHAR: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return new Utf8(object.toString()); + } + }; + break; + case BINARY: + case VARBINARY: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ByteBuffer.wrap((byte[]) object); + } + }; + break; + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + case TIMESTAMP_WITHOUT_TIME_ZONE: + final int tzPrecision; + if (type instanceof org.apache.flink.table.types.logical.TimestampType) { + tzPrecision = ((org.apache.flink.table.types.logical.TimestampType) type).getPrecision(); + } else { + tzPrecision = 3; + } + if (legacyTimestampMapping) { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + if (tzPrecision <= 3) { + return timestampData.getMillisecond(); + } else if (tzPrecision <= 6) { + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + } + } + }; + } else { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + java.time.Instant instant = + timestampData.toLocalDateTime().toInstant(ZoneOffset.UTC); + if (tzPrecision <= 3) { + return instant.toEpochMilli(); + } else if (tzPrecision <= 6) { + return instant.getEpochSecond() * 1_000_000L + instant.getNano() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return instant.getEpochSecond() * 1_000_000_000L + instant.getNano(); + } + } + }; + } + break; + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + final int ltzPrecision; + if (type instanceof org.apache.flink.table.types.logical.LocalZonedTimestampType) { + ltzPrecision = + ((org.apache.flink.table.types.logical.LocalZonedTimestampType) type).getPrecision(); + } else { + ltzPrecision = 3; + } + if (legacyTimestampMapping) { + throw new UnsupportedOperationException("Unsupported type: " + type); + } else { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + if (ltzPrecision <= 3) { + return timestampData.getMillisecond(); + } else if (ltzPrecision <= 6) { + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + } + } + }; + } + break; + case DECIMAL: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ByteBuffer.wrap(((DecimalData) object).toUnscaledBytes()); + } + }; + break; + case ARRAY: + converter = createArrayConverter((ArrayType) type, legacyTimestampMapping); + break; + case ROW: + converter = createRowConverter((RowType) type, legacyTimestampMapping); + break; + case MAP: + case MULTISET: + converter = createMapConverter(type, legacyTimestampMapping); + break; + case RAW: + default: + throw new UnsupportedOperationException("Unsupported type: " + type); + } + + // wrap into nullable converter + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + if (object == null) { + return null; + } + + // get actual schema if it is a nullable schema + Schema actualSchema; + if (schema.getType() == Schema.Type.UNION) { + List types = schema.getTypes(); + int size = types.size(); + if (size == 2 && types.get(1).getType() == Schema.Type.NULL) { + actualSchema = types.get(0); + } else if (size == 2 && types.get(0).getType() == Schema.Type.NULL) { + actualSchema = types.get(1); + } else { + throw new IllegalArgumentException( + "The Avro schema is not a nullable type: " + schema.toString()); + } + } else { + actualSchema = schema; + } + return converter.convert(actualSchema, object); + } + }; + } + + private static RowDataToAvroConverter createRowConverter( + RowType rowType, boolean legacyTimestampMapping) { + final RowDataToAvroConverter[] fieldConverters = + rowType.getChildren().stream() + .map(legacyType -> createConverter(legacyType, legacyTimestampMapping)) + .toArray(RowDataToAvroConverter[]::new); + final LogicalType[] fieldTypes = + rowType.getFields().stream().map(RowType.RowField::getType).toArray(LogicalType[]::new); + final RowData.FieldGetter[] fieldGetters = new RowData.FieldGetter[fieldTypes.length]; + for (int i = 0; i < fieldTypes.length; i++) { + fieldGetters[i] = RowData.createFieldGetter(fieldTypes[i], i); + } + final int length = rowType.getFieldCount(); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final RowData row = (RowData) object; + final List fields = schema.getFields(); + final GenericRecord record = new GenericData.Record(schema); + for (int i = 0; i < length; ++i) { + final Schema.Field schemaField = fields.get(i); + try { + Object avroObject = + fieldConverters[i].convert( + schemaField.schema(), fieldGetters[i].getFieldOrNull(row)); + record.put(i, avroObject); + } catch (Throwable t) { + throw new RuntimeException( + String.format("Fail to serialize at field: %s.", schemaField.name()), t); + } + } + return record; + } + }; + } + + private static RowDataToAvroConverter createArrayConverter( + ArrayType arrayType, boolean legacyTimestampMapping) { + LogicalType elementType = arrayType.getElementType(); + final ArrayData.ElementGetter elementGetter = ArrayData.createElementGetter(elementType); + final RowDataToAvroConverter elementConverter = + createConverter(arrayType.getElementType(), legacyTimestampMapping); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final Schema elementSchema = schema.getElementType(); + ArrayData arrayData = (ArrayData) object; + List list = Lists.newArrayList(); + for (int i = 0; i < arrayData.size(); ++i) { + list.add( + elementConverter.convert( + elementSchema, elementGetter.getElementOrNull(arrayData, i))); + } + return list; + } + }; + } + + private static RowDataToAvroConverter createMapConverter( + LogicalType type, boolean legacyTimestampMapping) { + LogicalType valueType = AvroSchemaConverter.extractValueTypeToAvroMap(type); + final ArrayData.ElementGetter valueGetter = ArrayData.createElementGetter(valueType); + final RowDataToAvroConverter valueConverter = + createConverter(valueType, legacyTimestampMapping); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final Schema valueSchema = schema.getValueType(); + final MapData mapData = (MapData) object; + final ArrayData keyArray = mapData.keyArray(); + final ArrayData valueArray = mapData.valueArray(); + final Map map = CollectionUtil.newHashMapWithExpectedSize(mapData.size()); + for (int i = 0; i < mapData.size(); ++i) { + final String key = keyArray.getString(i).toString(); + final Object value = + valueConverter.convert(valueSchema, valueGetter.getElementOrNull(valueArray, i)); + map.put(key, value); + } + return map; + } + }; + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java new file mode 100644 index 000000000000..347631c7f451 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java @@ -0,0 +1,625 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro.typeutils; + +import java.util.List; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.avro.SchemaParseException; +import org.apache.avro.specific.SpecificData; +import org.apache.avro.specific.SpecificRecord; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.api.java.typeutils.RowTypeInfo; +import org.apache.flink.formats.avro.AvroRowDataDeserializationSchema; +import org.apache.flink.formats.avro.AvroRowDataSerializationSchema; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.types.AtomicDataType; +import org.apache.flink.table.types.DataType; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LocalZonedTimestampType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeFamily; +import org.apache.flink.table.types.logical.MapType; +import org.apache.flink.table.types.logical.MultisetType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.TimeType; +import org.apache.flink.table.types.logical.TimestampType; +import org.apache.flink.table.types.logical.TypeInformationRawType; +import org.apache.flink.types.Row; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +/** + * Converts an Avro schema into Flink's type information. It uses {@link RowTypeInfo} for + * representing objects and converts Avro types into types that are compatible with Flink's Table + * & SQL API. + * + *

Note: Changes in this class need to be kept in sync with the corresponding runtime classes + * {@link AvroRowDataDeserializationSchema} and {@link AvroRowDataSerializationSchema}. + * + *

This class is adapted in Iceberg to support custom 'timestamp-nanos' and + * 'local-timestamp-nanos' logical types (FLINK-39251). Once that ticket is resolved in Flink, these + * custom types may be removed. + */ +public class AvroSchemaConverter { + + private AvroSchemaConverter() { + // private + } + + /** + * Converts an Avro class into a nested row structure with deterministic field order and data + * types that are compatible with Flink's Table & SQL API. + * + * @param avroClass Avro specific record that contains schema information + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + Class avroClass) { + return convertToTypeInfo(avroClass, true); + } + + /** + * Converts an Avro class into a nested row structure with deterministic field order and data + * types that are compatible with Flink's Table & SQL API. + * + * @param avroClass Avro specific record that contains schema information + * @param legacyTimestampMapping legacy mapping of timestamp types + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + Class avroClass, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroClass, "Avro specific record class must not be null."); + // determine schema to retrieve deterministic field order + final Schema schema = SpecificData.get().getSchema(avroClass); + return (TypeInformation) convertToTypeInfo(schema, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo(String avroSchemaString) { + return convertToTypeInfo(avroSchemaString, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @param legacyTimestampMapping legacy mapping of timestamp types + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + String avroSchemaString, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroSchemaString, "Avro schema must not be null."); + final Schema schema; + try { + schema = new Schema.Parser().parse(avroSchemaString); + } catch (SchemaParseException e) { + throw new IllegalArgumentException("Could not parse Avro schema string.", e); + } + return (TypeInformation) convertToTypeInfo(schema, legacyTimestampMapping); + } + + private static TypeInformation convertToTypeInfo( + Schema schema, boolean legacyTimestampMapping) { + switch (schema.getType()) { + case RECORD: + final List fields = schema.getFields(); + + final TypeInformation[] types = new TypeInformation[fields.size()]; + final String[] names = new String[fields.size()]; + for (int i = 0; i < fields.size(); i++) { + final Schema.Field field = fields.get(i); + types[i] = convertToTypeInfo(field.schema(), legacyTimestampMapping); + names[i] = field.name(); + } + return Types.ROW_NAMED(names, types); + case ENUM: + return Types.STRING; + case ARRAY: + // result type might either be ObjectArrayTypeInfo or BasicArrayTypeInfo for Strings + return Types.OBJECT_ARRAY( + convertToTypeInfo(schema.getElementType(), legacyTimestampMapping)); + case MAP: + return Types.MAP( + Types.STRING, convertToTypeInfo(schema.getValueType(), legacyTimestampMapping)); + case UNION: + final Schema actualSchema; + if (schema.getTypes().size() == 2 + && schema.getTypes().get(0).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(1); + } else if (schema.getTypes().size() == 2 + && schema.getTypes().get(1).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(0); + } else if (schema.getTypes().size() == 1) { + actualSchema = schema.getTypes().get(0); + } else { + // use Kryo for serialization + return Types.GENERIC(Object.class); + } + return convertToTypeInfo(actualSchema, legacyTimestampMapping); + case FIXED: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + return Types.BIG_DEC; + } + // convert fixed size binary data to primitive byte arrays + return Types.PRIMITIVE_ARRAY(Types.BYTE); + case STRING: + // convert Avro's Utf8/CharSequence to String + return Types.STRING; + case BYTES: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + return Types.BIG_DEC; + } + return Types.PRIMITIVE_ARRAY(Types.BYTE); + case INT: + // logical date and time type + final org.apache.avro.LogicalType logicalType = schema.getLogicalType(); + if (logicalType == LogicalTypes.date()) { + return Types.SQL_DATE; + } else if (logicalType == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + return Types.INT; + case LONG: + if (legacyTimestampMapping) { + if (schema.getLogicalType() == LogicalTypes.timestampMillis() + || schema.getLogicalType() == LogicalTypes.timestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos"))) { + return Types.SQL_TIMESTAMP; + } else if (schema.getLogicalType() == LogicalTypes.timeMicros() + || schema.getLogicalType() == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + } else { + // Avro logical timestamp types to Flink DataStream timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis() + || schema.getLogicalType() == LogicalTypes.timestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos"))) { + return Types.INSTANT; + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMillis() + || schema.getLogicalType() == LogicalTypes.localTimestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("local-timestamp-nanos"))) { + return Types.LOCAL_DATE_TIME; + } else if (schema.getLogicalType() == LogicalTypes.timeMicros() + || schema.getLogicalType() == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + } + return Types.LONG; + case FLOAT: + return Types.FLOAT; + case DOUBLE: + return Types.DOUBLE; + case BOOLEAN: + return Types.BOOLEAN; + case NULL: + return Types.VOID; + } + throw new IllegalArgumentException("Unsupported Avro type '" + schema.getType() + "'."); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @return data type matching the schema + */ + public static DataType convertToDataType(String avroSchemaString) { + return convertToDataType(avroSchemaString, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @param legacyTimestampMapping legacy mapping of local timestamps + * @return data type matching the schema + */ + public static DataType convertToDataType( + String avroSchemaString, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroSchemaString, "Avro schema must not be null."); + final Schema schema; + try { + schema = new Schema.Parser().parse(avroSchemaString); + } catch (SchemaParseException e) { + throw new IllegalArgumentException("Could not parse Avro schema string.", e); + } + return convertToDataType(schema, legacyTimestampMapping); + } + + @SuppressWarnings("deprecation") + private static DataType convertToDataType(Schema schema, boolean legacyMapping) { + switch (schema.getType()) { + case RECORD: + final List schemaFields = schema.getFields(); + + final DataTypes.Field[] fields = new DataTypes.Field[schemaFields.size()]; + for (int i = 0; i < schemaFields.size(); i++) { + final Schema.Field field = schemaFields.get(i); + fields[i] = + DataTypes.FIELD(field.name(), convertToDataType(field.schema(), legacyMapping)); + } + return DataTypes.ROW(fields).notNull(); + case ENUM: + return DataTypes.STRING().notNull(); + case ARRAY: + return DataTypes.ARRAY(convertToDataType(schema.getElementType(), legacyMapping)).notNull(); + case MAP: + return DataTypes.MAP( + DataTypes.STRING().notNull(), + convertToDataType(schema.getValueType(), legacyMapping)) + .notNull(); + case UNION: + final Schema actualSchema; + final boolean nullable; + if (schema.getTypes().size() == 2 + && schema.getTypes().get(0).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(1); + nullable = true; + } else if (schema.getTypes().size() == 2 + && schema.getTypes().get(1).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(0); + nullable = true; + } else if (schema.getTypes().size() == 1) { + actualSchema = schema.getTypes().get(0); + nullable = false; + } else { + // use Kryo for serialization + return new AtomicDataType( + new TypeInformationRawType<>(false, Types.GENERIC(Object.class))); + } + DataType converted = convertToDataType(actualSchema, legacyMapping); + return nullable ? converted.nullable() : converted; + case FIXED: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + final LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) schema.getLogicalType(); + return DataTypes.DECIMAL(decimalType.getPrecision(), decimalType.getScale()).notNull(); + } + // convert fixed size binary data to primitive byte arrays + return DataTypes.VARBINARY(schema.getFixedSize()).notNull(); + case STRING: + // convert Avro's Utf8/CharSequence to String + return DataTypes.STRING().notNull(); + case BYTES: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + final LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) schema.getLogicalType(); + return DataTypes.DECIMAL(decimalType.getPrecision(), decimalType.getScale()).notNull(); + } + return DataTypes.BYTES().notNull(); + case INT: + // logical date and time type + final org.apache.avro.LogicalType logicalType = schema.getLogicalType(); + if (logicalType == LogicalTypes.date()) { + return DataTypes.DATE().notNull(); + } else if (logicalType == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } + return DataTypes.INT().notNull(); + case LONG: + if (legacyMapping) { + // Avro logical timestamp types to Flink SQL timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis()) { + return DataTypes.TIMESTAMP(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timestampMicros()) { + return DataTypes.TIMESTAMP(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP(9).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMicros()) { + return DataTypes.TIME(6).notNull(); + } + } else { + // Avro logical timestamp types to Flink SQL timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis()) { + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timestampMicros()) { + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(9).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMicros()) { + return DataTypes.TIME(6).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMillis()) { + return DataTypes.TIMESTAMP(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMicros()) { + return DataTypes.TIMESTAMP(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("local-timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP(9).notNull(); + } + } + + return DataTypes.BIGINT().notNull(); + case FLOAT: + return DataTypes.FLOAT().notNull(); + case DOUBLE: + return DataTypes.DOUBLE().notNull(); + case BOOLEAN: + return DataTypes.BOOLEAN().notNull(); + case NULL: + return DataTypes.NULL(); + } + throw new IllegalArgumentException("Unsupported Avro type '" + schema.getType() + "'."); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

Use "org.apache.flink.avro.generated.record" as the type name. + * + * @param schema the schema type, usually it should be the top level record type, e.g. not a + * nested type + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType schema) { + return convertToSchema(schema, true); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

Use "org.apache.flink.avro.generated.record" as the type name. + * + * @param schema the schema type, usually it should be the top level record type, e.g. not a + * nested type + * @param legacyTimestampMapping whether to use the legacy timestamp mapping + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType schema, boolean legacyTimestampMapping) { + return convertToSchema( + schema, "org.apache.flink.avro.generated.record", legacyTimestampMapping); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

The "{rowName}_" is used as the nested row type name prefix in order to generate the right + * schema. Nested record type that only differs with type name is still compatible. + * + * @param logicalType logical type + * @param rowName the record name + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType logicalType, String rowName) { + return convertToSchema(logicalType, rowName, true); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

The "{rowName}_" is used as the nested row type name prefix in order to generate the right + * schema. Nested record type that only differs with type name is still compatible. + * + * @param logicalType logical type + * @param rowName the record name + * @param legacyTimestampMapping whether to use legal timestamp mapping + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema( + LogicalType logicalType, String rowName, boolean legacyTimestampMapping) { + int precision; + boolean nullable = logicalType.isNullable(); + switch (logicalType.getTypeRoot()) { + case NULL: + return SchemaBuilder.builder().nullType(); + case BOOLEAN: + Schema bool = SchemaBuilder.builder().booleanType(); + return nullable ? nullableSchema(bool) : bool; + case TINYINT: + case SMALLINT: + case INTEGER: + Schema integer = SchemaBuilder.builder().intType(); + return nullable ? nullableSchema(integer) : integer; + case BIGINT: + Schema bigint = SchemaBuilder.builder().longType(); + return nullable ? nullableSchema(bigint) : bigint; + case FLOAT: + Schema floatSchema = SchemaBuilder.builder().floatType(); + return nullable ? nullableSchema(floatSchema) : floatSchema; + case DOUBLE: + Schema doubleSchema = SchemaBuilder.builder().doubleType(); + return nullable ? nullableSchema(doubleSchema) : doubleSchema; + case CHAR: + case VARCHAR: + Schema str = SchemaBuilder.builder().stringType(); + return nullable ? nullableSchema(str) : str; + case BINARY: + case VARBINARY: + Schema binary = SchemaBuilder.builder().bytesType(); + return nullable ? nullableSchema(binary) : binary; + case TIMESTAMP_WITHOUT_TIME_ZONE: + // use long to represents Timestamp + final TimestampType timestampType = (TimestampType) logicalType; + precision = timestampType.getPrecision(); + org.apache.avro.LogicalType avroLogicalType; + if (legacyTimestampMapping) { + if (precision <= 3) { + avroLogicalType = LogicalTypes.timestampMillis(); + } else { + throw new IllegalArgumentException( + "Avro does not support TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 3."); + } + } else { + if (precision <= 3) { + avroLogicalType = LogicalTypes.localTimestampMillis(); + } else if (precision <= 6) { + avroLogicalType = LogicalTypes.localTimestampMicros(); + } else { + throw new IllegalArgumentException( + "Avro does not support LOCAL TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 6."); + } + } + Schema timestamp = avroLogicalType.addToSchema(SchemaBuilder.builder().longType()); + return nullable ? nullableSchema(timestamp) : timestamp; + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + if (legacyTimestampMapping) { + throw new UnsupportedOperationException( + "Unsupported to derive Schema for type: " + logicalType); + } else { + final LocalZonedTimestampType localZonedTimestampType = + (LocalZonedTimestampType) logicalType; + precision = localZonedTimestampType.getPrecision(); + if (precision <= 3) { + avroLogicalType = LogicalTypes.timestampMillis(); + } else if (precision <= 6) { + avroLogicalType = LogicalTypes.timestampMicros(); + } else { + throw new IllegalArgumentException( + "Avro does not support TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 6."); + } + timestamp = avroLogicalType.addToSchema(SchemaBuilder.builder().longType()); + return nullable ? nullableSchema(timestamp) : timestamp; + } + case DATE: + // use int to represents Date + Schema date = LogicalTypes.date().addToSchema(SchemaBuilder.builder().intType()); + return nullable ? nullableSchema(date) : date; + case TIME_WITHOUT_TIME_ZONE: + precision = ((TimeType) logicalType).getPrecision(); + if (precision > 3) { + throw new IllegalArgumentException( + "Avro does not support TIME type with precision: " + + precision + + ", it only supports precision less than 3."); + } + // use int to represents Time, we only support millisecond when deserialization + Schema time = LogicalTypes.timeMillis().addToSchema(SchemaBuilder.builder().intType()); + return nullable ? nullableSchema(time) : time; + case DECIMAL: + DecimalType decimalType = (DecimalType) logicalType; + // store BigDecimal as byte[] + Schema decimal = + LogicalTypes.decimal(decimalType.getPrecision(), decimalType.getScale()) + .addToSchema(SchemaBuilder.builder().bytesType()); + return nullable ? nullableSchema(decimal) : decimal; + case ROW: + RowType rowType = (RowType) logicalType; + List fieldNames = rowType.getFieldNames(); + // we have to make sure the record name is different in a Schema + SchemaBuilder.FieldAssembler builder = + SchemaBuilder.builder().record(rowName).fields(); + for (int i = 0; i < rowType.getFieldCount(); i++) { + String fieldName = fieldNames.get(i); + LogicalType fieldType = rowType.getTypeAt(i); + SchemaBuilder.GenericDefault fieldBuilder = + builder + .name(fieldName) + .type( + convertToSchema( + fieldType, rowName + "_" + fieldName, legacyTimestampMapping)); + + if (fieldType.isNullable()) { + builder = fieldBuilder.withDefault(null); + } else { + builder = fieldBuilder.noDefault(); + } + } + Schema record = builder.endRecord(); + return nullable ? nullableSchema(record) : record; + case MULTISET: + case MAP: + Schema map = + SchemaBuilder.builder() + .map() + .values(convertToSchema(extractValueTypeToAvroMap(logicalType), rowName)); + return nullable ? nullableSchema(map) : map; + case ARRAY: + ArrayType arrayType = (ArrayType) logicalType; + Schema array = + SchemaBuilder.builder() + .array() + .items(convertToSchema(arrayType.getElementType(), rowName)); + return nullable ? nullableSchema(array) : array; + case RAW: + default: + throw new UnsupportedOperationException( + "Unsupported to derive Schema for type: " + logicalType); + } + } + + public static LogicalType extractValueTypeToAvroMap(LogicalType type) { + LogicalType keyType; + LogicalType valueType; + if (type instanceof MapType) { + MapType mapType = (MapType) type; + keyType = mapType.getKeyType(); + valueType = mapType.getValueType(); + } else { + MultisetType multisetType = (MultisetType) type; + keyType = multisetType.getElementType(); + valueType = new IntType(); + } + if (!keyType.is(LogicalTypeFamily.CHARACTER_STRING)) { + throw new UnsupportedOperationException( + "Avro format doesn't support non-string as key type of map. " + + "The key type is: " + + keyType.asSummaryString()); + } + return valueType; + } + + /** Returns schema with nullable true. */ + private static Schema nullableSchema(Schema schema) { + return schema.isNullable() + ? schema + : Schema.createUnion(SchemaBuilder.builder().nullType(), schema); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java index 2fce5e0b3e80..377413c3c3ab 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java @@ -53,6 +53,11 @@ public class DeleteOrphanFiles { ScanContext.builder().streaming(true).project(FILE_PATH_SCHEMA).build(); private static final Splitter COMMA_SPLITTER = Splitter.on(","); + static final Map DEFAULT_EQUAL_SCHEMES = + ImmutableMap.of( + "s3n", "s3", + "s3a", "s3"); + @Internal public static final OutputTag ERROR_STREAM = new OutputTag<>("error-stream", TypeInformation.of(Exception.class)); @@ -79,12 +84,8 @@ public static class Builder extends MaintenanceTaskBuilder equalSchemes = - Maps.newHashMap( - ImmutableMap.of( - "s3n", "s3", - "s3a", "s3")); + private boolean usePrefixListing = true; + private Map equalSchemes = Maps.newHashMap(DEFAULT_EQUAL_SCHEMES); private final Map equalAuthorities = Maps.newHashMap(); private PrefixMismatchMode prefixMismatchMode = PrefixMismatchMode.ERROR; @@ -189,78 +190,92 @@ public Builder deleteBatchSize(int newDeleteBatchSize) { return this; } + public Builder config(DeleteOrphanFilesConfig deleteOrphanFilesConfig) { + return this.scheduleOnInterval( + Duration.ofSeconds(deleteOrphanFilesConfig.scheduleOnIntervalSecond())) + .minAge(Duration.ofSeconds(deleteOrphanFilesConfig.minAgeSeconds())) + .deleteBatchSize(deleteOrphanFilesConfig.deleteBatchSize()) + .usePrefixListing(deleteOrphanFilesConfig.usePrefixListing()) + .prefixMismatchMode(deleteOrphanFilesConfig.prefixMismatchMode()) + .location(deleteOrphanFilesConfig.location()) + .planningWorkerPoolSize(deleteOrphanFilesConfig.planningWorkerPoolSize()) + .equalSchemes(deleteOrphanFilesConfig.equalSchemes()) + .equalAuthorities(deleteOrphanFilesConfig.equalAuthorities()); + } + @Override DataStream append(DataStream trigger) { tableLoader().open(); // Collect all data files SingleOutputStreamOperator splits = - trigger - .process( - new MetadataTablePlanner( - taskName(), - index(), - tableLoader(), - FILE_PATH_SCAN_CONTEXT, - MetadataTableType.ALL_FILES, - planningWorkerPoolSize)) - .name(operatorName(PLANNER_TASK_NAME)) - .uid(PLANNER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new MetadataTablePlanner( + taskName(), + index(), + tableLoader(), + FILE_PATH_SCAN_CONTEXT, + MetadataTableType.ALL_FILES, + planningWorkerPoolSize)) + .name(operatorName(PLANNER_TASK_NAME)) + .uid(PLANNER_TASK_NAME + uidSuffix()) + .forceNonParallel()); // Read the records and get all data files SingleOutputStreamOperator tableDataFiles = - splits - .rebalance() - .process( - new FileNameReader( - taskName(), - index(), - tableLoader(), - FILE_PATH_SCHEMA, - FILE_PATH_SCAN_CONTEXT, - MetadataTableType.ALL_FILES)) - .name(operatorName(READER_TASK_NAME)) - .uid(READER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + splits + .rebalance() + .process( + new FileNameReader( + taskName(), + index(), + tableLoader(), + FILE_PATH_SCHEMA, + FILE_PATH_SCAN_CONTEXT, + MetadataTableType.ALL_FILES)) + .name(operatorName(READER_TASK_NAME)) + .uid(READER_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); // Collect all meta data files SingleOutputStreamOperator tableMetadataFiles = - trigger - .process(new ListMetadataFiles(taskName(), index(), tableLoader())) - .name(operatorName(METADATA_FILES_TASK_NAME)) - .uid(METADATA_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process(new ListMetadataFiles(taskName(), index(), tableLoader())) + .name(operatorName(METADATA_FILES_TASK_NAME)) + .uid(METADATA_FILES_TASK_NAME + uidSuffix()) + .forceNonParallel()); // List the all file system files SingleOutputStreamOperator allFsFiles = - trigger - .process( - new ListFileSystemFiles( - taskName(), - index(), - tableLoader(), - location, - minAge.toMillis(), - usePrefixListing)) - .name(operatorName(FILESYSTEM_FILES_TASK_NAME)) - .uid(FILESYSTEM_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new ListFileSystemFiles( + taskName(), + index(), + tableLoader(), + location, + minAge.toMillis(), + usePrefixListing)) + .name(operatorName(FILESYSTEM_FILES_TASK_NAME)) + .uid(FILESYSTEM_FILES_TASK_NAME + uidSuffix()) + .forceNonParallel()); SingleOutputStreamOperator filesToDelete = - tableMetadataFiles - .union(tableDataFiles) - .keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities)) - .connect(allFsFiles.keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities))) - .process(new OrphanFilesDetector(prefixMismatchMode, equalSchemes, equalAuthorities)) - .slotSharingGroup(slotSharingGroup()) - .name(operatorName(FILTER_FILES_TASK_NAME)) - .uid(FILTER_FILES_TASK_NAME + uidSuffix()) - .setParallelism(parallelism()); + setSlotSharingGroup( + tableMetadataFiles + .union(tableDataFiles) + .keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities)) + .connect(allFsFiles.keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities))) + .process( + new OrphanFilesDetector(prefixMismatchMode, equalSchemes, equalAuthorities)) + .name(operatorName(FILTER_FILES_TASK_NAME)) + .uid(FILTER_FILES_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); DataStream errorStream = tableMetadataFiles @@ -273,38 +288,38 @@ DataStream append(DataStream trigger) { // Stop deleting the files if there is an error SingleOutputStreamOperator filesOrSkip = - filesToDelete - .connect(errorStream) - .transform( - operatorName(SKIP_ON_ERROR_TASK_NAME), - TypeInformation.of(String.class), - new SkipOnError()) - .uid(SKIP_ON_ERROR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + filesToDelete + .connect(errorStream) + .transform( + operatorName(SKIP_ON_ERROR_TASK_NAME), + TypeInformation.of(String.class), + new SkipOnError()) + .uid(SKIP_ON_ERROR_TASK_NAME + uidSuffix()) + .forceNonParallel()); // delete the files - filesOrSkip - .rebalance() - .transform( - operatorName(DELETE_FILES_TASK_NAME), - TypeInformation.of(Void.class), - new DeleteFilesProcessor( - tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) - .uid(DELETE_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + filesOrSkip + .rebalance() + .transform( + operatorName(DELETE_FILES_TASK_NAME), + TypeInformation.of(Void.class), + new DeleteFilesProcessor( + tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) + .uid(DELETE_FILES_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); // Ignore the file deletion result and return the DataStream directly - return trigger - .connect(errorStream) - .transform( - operatorName(AGGREGATOR_TASK_NAME), - TypeInformation.of(TaskResult.class), - new TaskResultAggregator(tableName(), taskName(), index())) - .uid(AGGREGATOR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .connect(errorStream) + .transform( + operatorName(AGGREGATOR_TASK_NAME), + TypeInformation.of(TaskResult.class), + new TaskResultAggregator(tableName(), taskName(), index())) + .uid(AGGREGATOR_TASK_NAME + uidSuffix()) + .forceNonParallel()); } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java new file mode 100644 index 000000000000..af347357815c --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import java.util.List; +import java.util.Map; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles.PrefixMismatchMode; +import org.apache.iceberg.flink.FlinkConfParser; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; + +public class DeleteOrphanFilesConfig { + public static final String PREFIX = FlinkMaintenanceConfig.PREFIX + "delete-orphan-files."; + + private static final Splitter COMMA_SPLITTER = Splitter.on(","); + private static final Splitter EQUALS_SPLITTER = Splitter.on("=").limit(2); + + public static final String SCHEDULE_ON_INTERVAL_SECOND = PREFIX + "schedule.interval-second"; + public static final ConfigOption SCHEDULE_ON_INTERVAL_SECOND_OPTION = + ConfigOptions.key(SCHEDULE_ON_INTERVAL_SECOND) + .longType() + .defaultValue(60 * 60L) // Default is 1 hour + .withDescription( + "The time interval (in seconds) between two consecutive delete orphan files operations."); + + public static final String MIN_AGE_SECONDS = PREFIX + "min-age-seconds"; + public static final ConfigOption MIN_AGE_SECONDS_OPTION = + ConfigOptions.key(MIN_AGE_SECONDS) + .longType() + .defaultValue(3L * 24 * 60 * 60) // Default is 3 days + .withDescription( + "The minimum age (in seconds) of files to be considered for deletion. " + + "Files newer than this will not be removed."); + + public static final String DELETE_BATCH_SIZE = PREFIX + "delete-batch-size"; + public static final ConfigOption DELETE_BATCH_SIZE_OPTION = + ConfigOptions.key(DELETE_BATCH_SIZE) + .intType() + .defaultValue(1000) + .withDescription("The batch size used for deleting orphan files."); + + public static final String LOCATION = PREFIX + "location"; + public static final ConfigOption LOCATION_OPTION = + ConfigOptions.key(LOCATION) + .stringType() + .noDefaultValue() + .withDescription( + "The location to start recursive listing of candidate files for removal. " + + "By default, the table location is used."); + + public static final String USE_PREFIX_LISTING = PREFIX + "use-prefix-listing"; + public static final ConfigOption USE_PREFIX_LISTING_OPTION = + ConfigOptions.key(USE_PREFIX_LISTING) + .booleanType() + .defaultValue(true) + .withDescription( + "Whether to use prefix listing when listing files from the file system."); + + public static final String PLANNING_WORKER_POOL_SIZE = PREFIX + "planning-worker-pool-size"; + public static final ConfigOption PLANNING_WORKER_POOL_SIZE_OPTION = + ConfigOptions.key(PLANNING_WORKER_POOL_SIZE) + .intType() + .noDefaultValue() + .withDescription( + "The worker pool size used for planning the scan of the ALL_FILES table. " + + "If not set, the shared worker pool is used."); + + public static final String EQUAL_SCHEMES = PREFIX + "equal-schemes"; + public static final ConfigOption EQUAL_SCHEMES_OPTION = + ConfigOptions.key(EQUAL_SCHEMES) + .stringType() + .noDefaultValue() + .withDescription( + "Schemes that should be considered equal, in the format 'scheme1=scheme2,scheme3=scheme4'."); + + public static final String EQUAL_AUTHORITIES = PREFIX + "equal-authorities"; + public static final ConfigOption EQUAL_AUTHORITIES_OPTION = + ConfigOptions.key(EQUAL_AUTHORITIES) + .stringType() + .noDefaultValue() + .withDescription( + "Authorities that should be considered equal, in the format 'auth1=auth2,auth3=auth4'."); + + public static final String PREFIX_MISMATCH_MODE = PREFIX + "prefix-mismatch-mode"; + public static final ConfigOption PREFIX_MISMATCH_MODE_OPTION = + ConfigOptions.key(PREFIX_MISMATCH_MODE) + .stringType() + .defaultValue(PrefixMismatchMode.ERROR.name()) + .withDescription( + "Action behavior when location prefixes (schemes/authorities) mismatch. " + + "Valid values: ERROR, IGNORE, DELETE."); + + private final FlinkConfParser confParser; + + public DeleteOrphanFilesConfig( + Table table, Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(table, writeOptions, readableConfig); + } + + public long scheduleOnIntervalSecond() { + return confParser + .longConf() + .option(SCHEDULE_ON_INTERVAL_SECOND) + .flinkConfig(SCHEDULE_ON_INTERVAL_SECOND_OPTION) + .defaultValue(SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()) + .parse(); + } + + public long minAgeSeconds() { + return confParser + .longConf() + .option(MIN_AGE_SECONDS) + .flinkConfig(MIN_AGE_SECONDS_OPTION) + .defaultValue(MIN_AGE_SECONDS_OPTION.defaultValue()) + .parse(); + } + + public int deleteBatchSize() { + return confParser + .intConf() + .option(DELETE_BATCH_SIZE) + .flinkConfig(DELETE_BATCH_SIZE_OPTION) + .defaultValue(DELETE_BATCH_SIZE_OPTION.defaultValue()) + .parse(); + } + + public String location() { + return confParser.stringConf().option(LOCATION).flinkConfig(LOCATION_OPTION).parseOptional(); + } + + public boolean usePrefixListing() { + return confParser + .booleanConf() + .option(USE_PREFIX_LISTING) + .flinkConfig(USE_PREFIX_LISTING_OPTION) + .defaultValue(USE_PREFIX_LISTING_OPTION.defaultValue()) + .parse(); + } + + public Integer planningWorkerPoolSize() { + return confParser + .intConf() + .option(PLANNING_WORKER_POOL_SIZE) + .flinkConfig(PLANNING_WORKER_POOL_SIZE_OPTION) + .defaultValue(ThreadPools.WORKER_THREAD_POOL_SIZE) + .parse(); + } + + public Map equalSchemes() { + String equalSchemes = + confParser + .stringConf() + .option(EQUAL_SCHEMES) + .flinkConfig(EQUAL_SCHEMES_OPTION) + .parseOptional(); + + return equalSchemes != null + ? parseKeyValuePairs(equalSchemes) + : Maps.newHashMap(DeleteOrphanFiles.DEFAULT_EQUAL_SCHEMES); + } + + public Map equalAuthorities() { + String equalAuthorities = + confParser + .stringConf() + .option(EQUAL_AUTHORITIES) + .flinkConfig(EQUAL_AUTHORITIES_OPTION) + .parseOptional(); + + return equalAuthorities != null ? parseKeyValuePairs(equalAuthorities) : Map.of(); + } + + public PrefixMismatchMode prefixMismatchMode() { + String value = + confParser + .stringConf() + .option(PREFIX_MISMATCH_MODE) + .flinkConfig(PREFIX_MISMATCH_MODE_OPTION) + .defaultValue(PREFIX_MISMATCH_MODE_OPTION.defaultValue()) + .parse(); + return PrefixMismatchMode.valueOf(value); + } + + private static Map parseKeyValuePairs(String value) { + Map result = Maps.newHashMap(); + for (String pair : COMMA_SPLITTER.split(value)) { + List parts = EQUALS_SPLITTER.splitToList(pair); + Preconditions.checkArgument(parts.size() == 2, "Invalid key-value pair: %s", pair); + result.put(parts.get(0).trim(), parts.get(1).trim()); + } + + return result; + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java index 628a91141474..7c524175c42b 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java @@ -19,6 +19,7 @@ package org.apache.iceberg.flink.maintenance.api; import java.time.Duration; +import java.util.Optional; import org.apache.flink.api.common.typeinfo.TypeInformation; import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; @@ -107,35 +108,48 @@ public Builder cleanExpiredMetadata(boolean newCleanExpiredMetadata) { return this; } + public Builder config(ExpireSnapshotsConfig expireSnapshotsConfig) { + return this.scheduleOnCommitCount(expireSnapshotsConfig.scheduleOnCommitCount()) + .scheduleOnInterval(Duration.ofSeconds(expireSnapshotsConfig.scheduleOnIntervalSecond())) + .deleteBatchSize(expireSnapshotsConfig.deleteBatchSize()) + .maxSnapshotAge( + Optional.ofNullable(expireSnapshotsConfig.maxSnapshotAgeSeconds()) + .map(Duration::ofSeconds) + .orElse(null)) + .retainLast(expireSnapshotsConfig.retainLast()) + .cleanExpiredMetadata(expireSnapshotsConfig.cleanExpiredMetadata()) + .planningWorkerPoolSize(expireSnapshotsConfig.planningWorkerPoolSize()); + } + @Override DataStream append(DataStream trigger) { Preconditions.checkNotNull(tableLoader(), "TableLoader should not be null"); SingleOutputStreamOperator result = - trigger - .process( - new ExpireSnapshotsProcessor( - tableLoader(), - maxSnapshotAge == null ? null : maxSnapshotAge.toMillis(), - numSnapshots, - planningWorkerPoolSize, - cleanExpiredMetadata)) - .name(operatorName(EXECUTOR_OPERATOR_NAME)) - .uid(EXECUTOR_OPERATOR_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); - - result - .getSideOutput(ExpireSnapshotsProcessor.DELETE_STREAM) - .rebalance() - .transform( - operatorName(DELETE_FILES_OPERATOR_NAME), - TypeInformation.of(Void.class), - new DeleteFilesProcessor( - tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) - .uid(DELETE_FILES_OPERATOR_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + trigger + .process( + new ExpireSnapshotsProcessor( + tableLoader(), + maxSnapshotAge == null ? null : maxSnapshotAge.toMillis(), + numSnapshots, + planningWorkerPoolSize, + cleanExpiredMetadata)) + .name(operatorName(EXECUTOR_OPERATOR_NAME)) + .uid(EXECUTOR_OPERATOR_NAME + uidSuffix()) + .forceNonParallel()); + + setSlotSharingGroup( + result + .getSideOutput(ExpireSnapshotsProcessor.DELETE_STREAM) + .rebalance() + .transform( + operatorName(DELETE_FILES_OPERATOR_NAME), + TypeInformation.of(Void.class), + new DeleteFilesProcessor( + tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) + .uid(DELETE_FILES_OPERATOR_NAME + uidSuffix()) + .setParallelism(parallelism())); // Ignore the file deletion result and return the DataStream directly return result; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java new file mode 100644 index 000000000000..13436975e182 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import java.util.Map; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.FlinkConfParser; +import org.apache.iceberg.util.ThreadPools; + +public class ExpireSnapshotsConfig { + public static final String PREFIX = FlinkMaintenanceConfig.PREFIX + "expire-snapshots."; + + public static final String SCHEDULE_ON_COMMIT_COUNT = PREFIX + "schedule.commit-count"; + public static final ConfigOption SCHEDULE_ON_COMMIT_COUNT_OPTION = + ConfigOptions.key(SCHEDULE_ON_COMMIT_COUNT) + .intType() + .defaultValue(10) + .withDescription( + "The number of commits after which to trigger a new expire snapshots operation."); + + public static final String SCHEDULE_ON_INTERVAL_SECOND = PREFIX + "schedule.interval-second"; + public static final ConfigOption SCHEDULE_ON_INTERVAL_SECOND_OPTION = + ConfigOptions.key(SCHEDULE_ON_INTERVAL_SECOND) + .longType() + .defaultValue(60 * 60L) // Default is 1 hour + .withDescription( + "The time interval (in seconds) between two consecutive expire snapshots operations."); + + public static final String MAX_SNAPSHOT_AGE_SECONDS = PREFIX + "max-snapshot-age-seconds"; + public static final ConfigOption MAX_SNAPSHOT_AGE_SECONDS_OPTION = + ConfigOptions.key(MAX_SNAPSHOT_AGE_SECONDS) + .longType() + .noDefaultValue() + .withDescription( + "The maximum age (in seconds) of snapshots to retain. " + + "Snapshots older than this will be expired."); + + public static final String RETAIN_LAST = PREFIX + "retain-last"; + public static final ConfigOption RETAIN_LAST_OPTION = + ConfigOptions.key(RETAIN_LAST) + .intType() + .noDefaultValue() + .withDescription("The minimum number of snapshots to retain."); + + public static final String DELETE_BATCH_SIZE = PREFIX + "delete-batch-size"; + public static final ConfigOption DELETE_BATCH_SIZE_OPTION = + ConfigOptions.key(DELETE_BATCH_SIZE) + .intType() + .defaultValue(1000) + .withDescription("The batch size used for deleting expired files."); + + public static final String CLEAN_EXPIRED_METADATA = PREFIX + "clean-expired-metadata"; + public static final ConfigOption CLEAN_EXPIRED_METADATA_OPTION = + ConfigOptions.key(CLEAN_EXPIRED_METADATA) + .booleanType() + .defaultValue(true) + .withDescription( + "Whether to clean expired metadata such as partition specs and schemas."); + + public static final String PLANNING_WORKER_POOL_SIZE = PREFIX + "planning-worker-pool-size"; + public static final ConfigOption PLANNING_WORKER_POOL_SIZE_OPTION = + ConfigOptions.key(PLANNING_WORKER_POOL_SIZE) + .intType() + .noDefaultValue() + .withDescription( + "The worker pool size used to calculate the files to delete. " + + "If not set, the shared worker pool is used."); + + private final FlinkConfParser confParser; + + public ExpireSnapshotsConfig( + Table table, Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(table, writeOptions, readableConfig); + } + + public int scheduleOnCommitCount() { + return confParser + .intConf() + .option(SCHEDULE_ON_COMMIT_COUNT) + .flinkConfig(SCHEDULE_ON_COMMIT_COUNT_OPTION) + .defaultValue(SCHEDULE_ON_COMMIT_COUNT_OPTION.defaultValue()) + .parse(); + } + + public long scheduleOnIntervalSecond() { + return confParser + .longConf() + .option(SCHEDULE_ON_INTERVAL_SECOND) + .flinkConfig(SCHEDULE_ON_INTERVAL_SECOND_OPTION) + .defaultValue(SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()) + .parse(); + } + + public Long maxSnapshotAgeSeconds() { + return confParser + .longConf() + .option(MAX_SNAPSHOT_AGE_SECONDS) + .flinkConfig(MAX_SNAPSHOT_AGE_SECONDS_OPTION) + .parseOptional(); + } + + public Integer retainLast() { + return confParser.intConf().option(RETAIN_LAST).flinkConfig(RETAIN_LAST_OPTION).parseOptional(); + } + + public int deleteBatchSize() { + return confParser + .intConf() + .option(DELETE_BATCH_SIZE) + .flinkConfig(DELETE_BATCH_SIZE_OPTION) + .defaultValue(DELETE_BATCH_SIZE_OPTION.defaultValue()) + .parse(); + } + + public Boolean cleanExpiredMetadata() { + return confParser + .booleanConf() + .option(CLEAN_EXPIRED_METADATA) + .flinkConfig(CLEAN_EXPIRED_METADATA_OPTION) + .defaultValue(CLEAN_EXPIRED_METADATA_OPTION.defaultValue()) + .parse(); + } + + public Integer planningWorkerPoolSize() { + return confParser + .intConf() + .option(PLANNING_WORKER_POOL_SIZE) + .flinkConfig(PLANNING_WORKER_POOL_SIZE_OPTION) + .defaultValue(ThreadPools.WORKER_THREAD_POOL_SIZE) + .parse(); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java index 0c88abf82099..34d7330c5913 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java @@ -23,7 +23,6 @@ import org.apache.flink.configuration.ConfigOption; import org.apache.flink.configuration.ConfigOptions; import org.apache.flink.configuration.ReadableConfig; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.iceberg.Table; import org.apache.iceberg.flink.FlinkConfParser; @@ -60,7 +59,7 @@ public class FlinkMaintenanceConfig { public static final ConfigOption SLOT_SHARING_GROUP_OPTION = ConfigOptions.key(SLOT_SHARING_GROUP) .stringType() - .defaultValue(StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP) + .noDefaultValue() .withDescription( "The slot sharing group for maintenance tasks. " + "Determines which operators can share slots in the Flink execution environment."); @@ -114,14 +113,21 @@ public String slotSharingGroup() { .stringConf() .option(SLOT_SHARING_GROUP) .flinkConfig(SLOT_SHARING_GROUP_OPTION) - .defaultValue(SLOT_SHARING_GROUP_OPTION.defaultValue()) - .parse(); + .parseOptional(); } public RewriteDataFilesConfig createRewriteDataFilesConfig() { return new RewriteDataFilesConfig(table, writeProperties, readableConfig); } + public ExpireSnapshotsConfig createExpireSnapshotsConfig() { + return new ExpireSnapshotsConfig(table, writeProperties, readableConfig); + } + + public DeleteOrphanFilesConfig createDeleteOrphanFilesConfig() { + return new DeleteOrphanFilesConfig(table, writeProperties, readableConfig); + } + public LockConfig createLockConfig() { return new LockConfig(table, writeProperties, readableConfig); } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java index f68605accc57..30e95b1edba0 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java @@ -260,10 +260,6 @@ public void unlock() { this, instanceId, count); - } catch (SQLException e) { - // SQL exception happened when deleting lock information - throw new UncheckedSQLException( - e, "Failed to delete %s lock with instanceId %s", this, instanceId); } return null; @@ -298,9 +294,6 @@ private String instanceId() { return null; } } - } catch (SQLException e) { - // SQL exception happened when getting lock information - throw new UncheckedSQLException(e, "Failed to get lock information for %s", type); } }); } catch (InterruptedException e) { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java index 5d5f17b0a80e..04df6106bd4c 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java @@ -226,4 +226,8 @@ DataStream append( return append(sourceStream); } + + SingleOutputStreamOperator setSlotSharingGroup(SingleOutputStreamOperator operator) { + return slotSharingGroup == null ? operator : operator.slotSharingGroup(slotSharingGroup); + } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java index bedf70725a63..be77fda23d29 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java @@ -24,6 +24,8 @@ import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.util.function.SerializableSupplier; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.actions.BinPackRewriteFilePlanner; import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; import org.apache.iceberg.expressions.Expression; @@ -58,7 +60,8 @@ public static class Builder extends MaintenanceTaskBuilder rewriteOptions = Maps.newHashMapWithExpectedSize(6); private long maxRewriteBytes = Long.MAX_VALUE; - private Expression filter = Expressions.alwaysTrue(); + private SerializableSupplier filterSupplier = Expressions::alwaysTrue; + private String branch = SnapshotRef.MAIN_BRANCH; @Override String maintenanceTaskName() { @@ -181,6 +184,19 @@ public Builder maxFileGroupSizeBytes(long maxFileGroupSizeBytes) { return this; } + /** + * Configures the max file count for rewriting. See {@link + * SizeBasedFileRewritePlanner#MAX_FILE_GROUP_INPUT_FILES} for more details. + * + * @param maxFileGroupInputFiles file count for rewrite + */ + public Builder maxFileGroupInputFiles(long maxFileGroupInputFiles) { + this.rewriteOptions.put( + SizeBasedFileRewritePlanner.MAX_FILE_GROUP_INPUT_FILES, + String.valueOf(maxFileGroupInputFiles)); + return this; + } + /** * Configures max files to rewrite. See {@link BinPackRewriteFilePlanner#MAX_FILES_TO_REWRITE} * for more details. @@ -199,9 +215,44 @@ public Builder maxFilesToRewrite(int maxFilesToRewrite) { * * @param newFilter the filter expression to apply * @return this for method chaining + * @deprecated will be removed in 1.12.0. Use {@link #filter(SerializableSupplier)} instead */ + @Deprecated public Builder filter(Expression newFilter) { - this.filter = newFilter; + this.filterSupplier = () -> newFilter; + return this; + } + + /** + * A user-provided supplier of a filter expression that determines which files are considered by + * the rewrite strategy. + * + *

The supplier is evaluated by the planner on every compaction trigger, allowing a fresh + * filter to be produced for each compaction run. + * + *

This is particularly useful for time-relative filters. For example, a supplier such as + * {@code () -> Expressions.greaterThanOrEqual("ts", + * LocalDateTime.now(ZoneOffset.UTC).minus(Duration.ofDays(3)).toString())} ensures that each + * compaction rewrites files from the last 3 days relative to the time the compaction is + * planned, rather than relative to when the job was started. + * + * @param newFilterSupplier the supplier providing the filter expression to apply + * @return this for method chaining + */ + public Builder filter(SerializableSupplier newFilterSupplier) { + this.filterSupplier = newFilterSupplier; + return this; + } + + /** + * Sets the branch to compact. When set, the planner reads from the branch's snapshot and + * commits are made to this branch. + * + * @param newBranch the branch name + * @return this for method chaining + */ + public Builder branch(String newBranch) { + this.branch = newBranch; return this; } @@ -239,56 +290,58 @@ public Builder config(RewriteDataFilesConfig rewriteDataFilesConfig) { @Override DataStream append(DataStream trigger) { SingleOutputStreamOperator planned = - trigger - .process( - new DataFileRewritePlanner( - tableName(), - taskName(), - index(), - tableLoader(), - partialProgressEnabled ? partialProgressMaxCommits : 1, - maxRewriteBytes, - rewriteOptions, - filter)) - .name(operatorName(PLANNER_TASK_NAME)) - .uid(PLANNER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new DataFileRewritePlanner( + tableName(), + taskName(), + index(), + tableLoader(), + partialProgressEnabled ? partialProgressMaxCommits : 1, + maxRewriteBytes, + rewriteOptions, + filterSupplier, + branch)) + .name(operatorName(PLANNER_TASK_NAME)) + .uid(PLANNER_TASK_NAME + uidSuffix()) + .forceNonParallel()); SingleOutputStreamOperator rewritten = - planned - .rebalance() - .process(new DataFileRewriteRunner(tableName(), taskName(), index())) - .name(operatorName(REWRITE_TASK_NAME)) - .uid(REWRITE_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + planned + .rebalance() + .process(new DataFileRewriteRunner(tableName(), taskName(), index())) + .name(operatorName(REWRITE_TASK_NAME)) + .uid(REWRITE_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); SingleOutputStreamOperator updated = - rewritten - .transform( - operatorName(COMMIT_TASK_NAME), - TypeInformation.of(Trigger.class), - new DataFileRewriteCommitter(tableName(), taskName(), index(), tableLoader())) - .uid(COMMIT_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + rewritten + .transform( + operatorName(COMMIT_TASK_NAME), + TypeInformation.of(Trigger.class), + new DataFileRewriteCommitter( + tableName(), taskName(), index(), tableLoader(), branch)) + .uid(COMMIT_TASK_NAME + uidSuffix()) + .forceNonParallel()); - return trigger - .union(updated) - .connect( - planned - .getSideOutput(TaskResultAggregator.ERROR_STREAM) - .union( - rewritten.getSideOutput(TaskResultAggregator.ERROR_STREAM), - updated.getSideOutput(TaskResultAggregator.ERROR_STREAM))) - .transform( - operatorName(AGGREGATOR_TASK_NAME), - TypeInformation.of(TaskResult.class), - new TaskResultAggregator(tableName(), taskName(), index())) - .uid(AGGREGATOR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .union(updated) + .connect( + planned + .getSideOutput(TaskResultAggregator.ERROR_STREAM) + .union( + rewritten.getSideOutput(TaskResultAggregator.ERROR_STREAM), + updated.getSideOutput(TaskResultAggregator.ERROR_STREAM))) + .transform( + operatorName(AGGREGATOR_TASK_NAME), + TypeInformation.of(TaskResult.class), + new TaskResultAggregator(tableName(), taskName(), index())) + .uid(AGGREGATOR_TASK_NAME + uidSuffix()) + .forceNonParallel()); } } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java index 1a2b0607dd1e..025a6d17c023 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java @@ -20,9 +20,11 @@ import java.io.IOException; import java.time.Duration; +import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.UUID; +import javax.annotation.Nullable; import org.apache.flink.annotation.Internal; import org.apache.flink.api.common.ExecutionConfig; import org.apache.flink.api.common.eventtime.TimestampAssigner; @@ -40,13 +42,14 @@ import org.apache.flink.streaming.api.datastream.DataStreamUtils; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.iceberg.flink.TableLoader; import org.apache.iceberg.flink.maintenance.operator.LockRemover; +import org.apache.iceberg.flink.maintenance.operator.LockRemoverOperatorFactory; import org.apache.iceberg.flink.maintenance.operator.MonitorSource; import org.apache.iceberg.flink.maintenance.operator.TableChange; import org.apache.iceberg.flink.maintenance.operator.TriggerEvaluator; import org.apache.iceberg.flink.maintenance.operator.TriggerManager; +import org.apache.iceberg.flink.maintenance.operator.TriggerManagerOperatorFactory; import org.apache.iceberg.flink.sink.IcebergSink; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -71,39 +74,77 @@ private TableMaintenance() {} * * @param changeStream the table changes * @param tableLoader used for accessing the table - * @param lockFactory used for preventing concurrent task runs + * @param lockFactory used for preventing concurrent task runs, if null, use coordination lock. * @return builder for the maintenance stream + * @deprecated since 1.12.0, will be removed in 2.0.0. Use {@link #forChangeStream(DataStream, + * TableLoader)} instead. */ + @Deprecated @Internal public static Builder forChangeStream( DataStream changeStream, TableLoader tableLoader, - TriggerLockFactory lockFactory) { + @Nullable TriggerLockFactory lockFactory) { Preconditions.checkNotNull(changeStream, "The change stream should not be null"); Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); - Preconditions.checkNotNull(lockFactory, "LockFactory should not be null"); return new Builder(null, changeStream, tableLoader, lockFactory); } + /** + * Use when the change stream is already provided, like in the {@link + * IcebergSink#addPostCommitTopology(DataStream)}. + * + * @param changeStream the table changes + * @param tableLoader used for accessing the table + * @return builder for the maintenance stream + */ + @Internal + public static Builder forChangeStream( + DataStream changeStream, TableLoader tableLoader) { + Preconditions.checkNotNull(changeStream, "The change stream should not be null"); + Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); + + return new Builder(null, changeStream, tableLoader, null); + } + /** * Use this for standalone maintenance job. It creates a monitor source that detect table changes * and build the maintenance pipelines afterwards. * * @param env used to register the monitor source * @param tableLoader used for accessing the table - * @param lockFactory used for preventing concurrent task runs + * @param lockFactory used for preventing concurrent task runs. If null, use coordination lock. * @return builder for the maintenance stream + * @deprecated since 1.12.0, will be removed in 2.0.0. Use {@link + * #forTable(StreamExecutionEnvironment, TableLoader)} instead. */ + @Deprecated public static Builder forTable( - StreamExecutionEnvironment env, TableLoader tableLoader, TriggerLockFactory lockFactory) { + StreamExecutionEnvironment env, + TableLoader tableLoader, + @Nullable TriggerLockFactory lockFactory) { Preconditions.checkNotNull(env, "StreamExecutionEnvironment should not be null"); Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); - Preconditions.checkNotNull(lockFactory, "LockFactory should not be null"); return new Builder(env, null, tableLoader, lockFactory); } + /** + * Use this for standalone maintenance job. It creates a monitor source that detect table changes + * and build the maintenance pipelines afterwards. But use coordination lock default. + * + * @param env used to register the monitor source + * @param tableLoader used for accessing the table + * @return builder for the maintenance stream + */ + public static Builder forTable(StreamExecutionEnvironment env, TableLoader tableLoader) { + Preconditions.checkNotNull(env, "StreamExecutionEnvironment should not be null"); + Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); + + return new Builder(env, null, tableLoader, null); + } + public static class Builder { private final StreamExecutionEnvironment env; private final DataStream inputStream; @@ -112,7 +153,7 @@ public static class Builder { private final TriggerLockFactory lockFactory; private String uidSuffix = "TableMaintenance-" + UUID.randomUUID(); - private String slotSharingGroup = StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP; + private String slotSharingGroup = null; private Duration rateLimit = Duration.ofSeconds(RATE_LIMIT_SECOND_DEFAULT); private Duration lockCheckDelay = Duration.ofSeconds(LOCK_CHECK_DELAY_SECOND_DEFAULT); private int parallelism = ExecutionConfig.PARALLELISM_DEFAULT; @@ -211,6 +252,17 @@ public Builder add(MaintenanceTaskBuilder task) { return this; } + /** + * Adds multiple tasks with the given schedules. + * + * @param tasks to add + */ + public Builder add(Collection> tasks) { + Preconditions.checkNotNull(tasks, "Tasks collection should not be null"); + taskBuilders.addAll(tasks); + return this; + } + /** Builds the task graph for the maintenance tasks. */ public void append() throws IOException { Preconditions.checkArgument(!taskBuilders.isEmpty(), "Provide at least one task"); @@ -226,38 +278,60 @@ public void append() throws IOException { try (TableLoader loader = tableLoader.clone()) { loader.open(); String tableName = loader.loadTable().name(); - DataStream triggers = - DataStreamUtils.reinterpretAsKeyedStream( - changeStream(tableName, loader), unused -> true) - .process( - new TriggerManager( - loader, - lockFactory, - taskNames, - evaluators, - rateLimit.toMillis(), - lockCheckDelay.toMillis())) - .name(TRIGGER_MANAGER_OPERATOR_NAME) - .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel() - .assignTimestampsAndWatermarks(new PunctuatedWatermarkStrategy()) - .name(WATERMARK_ASSIGNER_OPERATOR_NAME) - .uid(WATERMARK_ASSIGNER_OPERATOR_NAME + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel(); + DataStream triggers; + if (lockFactory == null) { + triggers = + setSlotSharingGroup( + DataStreamUtils.reinterpretAsKeyedStream( + changeStream(tableName, loader), unused -> true) + .transform( + TRIGGER_MANAGER_OPERATOR_NAME, + TypeInformation.of(Trigger.class), + new TriggerManagerOperatorFactory( + tableName, + taskNames, + evaluators, + rateLimit.toMillis(), + lockCheckDelay.toMillis())) + .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); + } else { + triggers = + setSlotSharingGroup( + DataStreamUtils.reinterpretAsKeyedStream( + changeStream(tableName, loader), unused -> true) + .process( + new TriggerManager( + loader, + lockFactory, + taskNames, + evaluators, + rateLimit.toMillis(), + lockCheckDelay.toMillis())) + .name(TRIGGER_MANAGER_OPERATOR_NAME) + .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); + } + + triggers = + setSlotSharingGroup( + triggers + .assignTimestampsAndWatermarks(new PunctuatedWatermarkStrategy()) + .name(WATERMARK_ASSIGNER_OPERATOR_NAME) + .uid(WATERMARK_ASSIGNER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); // Add the specific tasks DataStream unioned = null; for (int i = 0; i < taskBuilders.size(); ++i) { int taskIndex = i; DataStream filtered = - triggers - .filter(t -> t.taskId() != null && t.taskId() == taskIndex) - .name(FILTER_OPERATOR_NAME_PREFIX + taskIndex) - .forceNonParallel() - .uid(FILTER_OPERATOR_NAME_PREFIX + taskIndex + "-" + uidSuffix) - .slotSharingGroup(slotSharingGroup); + setSlotSharingGroup( + triggers + .filter(t -> t.taskId() != null && t.taskId() == taskIndex) + .name(FILTER_OPERATOR_NAME_PREFIX + taskIndex) + .forceNonParallel() + .uid(FILTER_OPERATOR_NAME_PREFIX + taskIndex + "-" + uidSuffix)); MaintenanceTaskBuilder builder = taskBuilders.get(taskIndex); DataStream result = builder.append( @@ -277,14 +351,25 @@ public void append() throws IOException { } // Add the LockRemover to the end - unioned - .transform( - LOCK_REMOVER_OPERATOR_NAME, - TypeInformation.of(Void.class), - new LockRemover(tableName, lockFactory, taskNames)) - .forceNonParallel() - .uid("lock-remover-" + uidSuffix) - .slotSharingGroup(slotSharingGroup); + if (lockFactory == null) { + setSlotSharingGroup( + unioned + .transform( + LOCK_REMOVER_OPERATOR_NAME, + TypeInformation.of(Void.class), + new LockRemoverOperatorFactory(tableName, taskNames)) + .uid("lock-remover-" + uidSuffix) + .forceNonParallel()); + } else { + setSlotSharingGroup( + unioned + .transform( + LOCK_REMOVER_OPERATOR_NAME, + TypeInformation.of(Void.class), + new LockRemover(tableName, lockFactory, taskNames)) + .forceNonParallel() + .uid("lock-remover-" + uidSuffix)); + } } } @@ -294,11 +379,13 @@ private DataStream changeStream(String tableName, TableLoader loade MonitorSource source = new MonitorSource( loader, RateLimiterStrategy.perSecond(1.0 / rateLimit.getSeconds()), maxReadBack); - return env.fromSource( - source, WatermarkStrategy.noWatermarks(), SOURCE_OPERATOR_NAME_PREFIX + tableName) - .uid(SOURCE_OPERATOR_NAME_PREFIX + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel(); + return setSlotSharingGroup( + env.fromSource( + source, + WatermarkStrategy.noWatermarks(), + SOURCE_OPERATOR_NAME_PREFIX + tableName) + .uid(SOURCE_OPERATOR_NAME_PREFIX + uidSuffix) + .forceNonParallel()); } else { return inputStream.global(); } @@ -307,6 +394,11 @@ private DataStream changeStream(String tableName, TableLoader loade private static String nameFor(MaintenanceTaskBuilder streamBuilder, int taskIndex) { return String.format(Locale.ROOT, "%s [%d]", streamBuilder.maintenanceTaskName(), taskIndex); } + + private SingleOutputStreamOperator setSlotSharingGroup( + SingleOutputStreamOperator operator) { + return slotSharingGroup == null ? operator : operator.slotSharingGroup(slotSharingGroup); + } } @Internal diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java index 27c0a43a858a..7797da0caac3 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java @@ -20,7 +20,6 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; -import org.apache.curator.shaded.com.google.common.annotations.VisibleForTesting; import org.apache.flink.shaded.curator5.org.apache.curator.RetryPolicy; import org.apache.flink.shaded.curator5.org.apache.curator.framework.CuratorFramework; import org.apache.flink.shaded.curator5.org.apache.curator.framework.CuratorFrameworkFactory; @@ -31,6 +30,7 @@ import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryNTimes; import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryOneTime; import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryUntilElapsed; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java new file mode 100644 index 000000000000..21e9ca43f304 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import org.apache.flink.annotation.VisibleForTesting; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.util.FatalExitExceptionHandler; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base coordinator for table maintenance operators. Provides common functionality for thread + * management, subtask gateway management, and checkpoint handling. + */ +abstract class BaseCoordinator implements OperatorCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(BaseCoordinator.class); + private static final Map> LOCK_RELEASE_CONSUMERS = + Maps.newConcurrentMap(); + private static final List PENDING_RELEASE_EVENTS = Lists.newArrayList(); + + private final String operatorName; + private final Context context; + private final ExecutorService coordinatorExecutor; + private final CoordinatorExecutorThreadFactory coordinatorThreadFactory; + private final SubtaskGateways subtaskGateways; + + private boolean started; + + BaseCoordinator(String operatorName, Context context) { + this.operatorName = operatorName; + this.context = context; + + this.coordinatorThreadFactory = + new CoordinatorExecutorThreadFactory( + "Coordinator-" + operatorName, context.getUserCodeClassloader()); + this.coordinatorExecutor = Executors.newSingleThreadExecutor(coordinatorThreadFactory); + Preconditions.checkState( + context.currentParallelism() == 1, "Coordinator must run with parallelism 1"); + this.subtaskGateways = SubtaskGateways.create(operatorName); + LOG.info("Created coordinator: {}", operatorName); + } + + @SuppressWarnings("FutureReturnValueIgnored") + void registerLock(LockRegisterEvent lockRegisterEvent) { + LOCK_RELEASE_CONSUMERS.put( + lockRegisterEvent.lockId(), + lock -> { + LOG.info( + "Send release event for lock id {}, timestamp: {} to Operator {}", + lock.lockId(), + lock.timestamp(), + operatorName()); + subtaskGateways.subtaskGateway().sendEvent(lock); + }); + + synchronized (PENDING_RELEASE_EVENTS) { + if (!PENDING_RELEASE_EVENTS.isEmpty()) { + PENDING_RELEASE_EVENTS.forEach(this::handleReleaseLock); + PENDING_RELEASE_EVENTS.clear(); + } + } + } + + void handleReleaseLock(LockReleaseEvent lockReleaseEvent) { + synchronized (PENDING_RELEASE_EVENTS) { + if (LOCK_RELEASE_CONSUMERS.containsKey(lockReleaseEvent.lockId())) { + LOCK_RELEASE_CONSUMERS.get(lockReleaseEvent.lockId()).accept(lockReleaseEvent); + LOG.info( + "Send release event for lock id {}, timestamp: {}", + lockReleaseEvent.lockId(), + lockReleaseEvent.timestamp()); + } else { + PENDING_RELEASE_EVENTS.add(lockReleaseEvent); + LOG.info( + "No consumer for lock id {}, timestamp: {}", + lockReleaseEvent.lockId(), + lockReleaseEvent.timestamp()); + } + } + } + + @Override + public void start() throws Exception { + LOG.info("Starting coordinator: {}", operatorName); + this.started = true; + } + + @Override + public void close() throws Exception { + coordinatorExecutor.shutdown(); + this.started = false; + synchronized (PENDING_RELEASE_EVENTS) { + LOCK_RELEASE_CONSUMERS.clear(); + PENDING_RELEASE_EVENTS.clear(); + } + + LOG.info("Closed coordinator: {}", operatorName); + } + + @Override + public void checkpointCoordinator(long checkpointId, CompletableFuture resultFuture) { + runInCoordinatorThread( + () -> resultFuture.complete(new byte[0]), + String.format(Locale.ROOT, "taking checkpoint %d", checkpointId)); + } + + @Override + public void resetToCheckpoint(long checkpointId, byte[] checkpointData) { + Preconditions.checkState( + !started, "The coordinator %s can only be reset if it was not yet started", operatorName); + LOG.info("Reset to checkpoint {}", checkpointId); + synchronized (PENDING_RELEASE_EVENTS) { + LOCK_RELEASE_CONSUMERS.clear(); + PENDING_RELEASE_EVENTS.clear(); + } + } + + @Override + public void notifyCheckpointComplete(long checkpointId) {} + + @Override + public void subtaskReset(int subtask, long checkpointId) { + runInCoordinatorThread( + () -> { + LOG.info("Subtask {} is reset to checkpoint {}", subtask, checkpointId); + Preconditions.checkState(coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.reset(); + }, + String.format( + Locale.ROOT, "handling subtask %d recovery to checkpoint %d", subtask, checkpointId)); + } + + @Override + public void executionAttemptFailed(int subtask, int attemptNumber, Throwable reason) { + runInCoordinatorThread( + () -> { + LOG.info( + "Unregistering gateway after failure for subtask {} (#{}) of data statistics {}", + subtask, + attemptNumber, + operatorName); + Preconditions.checkState( + this.coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.unregisterSubtaskGateway(subtask, attemptNumber); + }, + String.format(Locale.ROOT, "handling subtask %d (#%d) failure", subtask, attemptNumber)); + } + + @Override + public void executionAttemptReady(int subtask, int attemptNumber, SubtaskGateway gateway) { + Preconditions.checkArgument(subtask == gateway.getSubtask()); + Preconditions.checkArgument(attemptNumber == gateway.getExecution().getAttemptNumber()); + runInCoordinatorThread( + () -> { + Preconditions.checkState( + this.coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.registerSubtaskGateway(gateway); + }, + String.format( + Locale.ROOT, + "making event gateway to subtask %d (#%d) available", + subtask, + attemptNumber)); + } + + String operatorName() { + return operatorName; + } + + void runInCoordinatorThread(Runnable runnable, String actionString) { + ensureStarted(); + coordinatorExecutor.execute( + () -> { + try { + runnable.run(); + } catch (Throwable t) { + LOG.error( + "Uncaught exception in coordinator while {}: {}", actionString, t.getMessage(), t); + context.failJob(t); + } + }); + } + + private void ensureStarted() { + Preconditions.checkState(started, "The coordinator has not started yet."); + } + + /** Inner class to manage subtask gateways. */ + private record SubtaskGateways(String operatorName, Map gateways) { + + private static SubtaskGateways create(String operatorName) { + return new SubtaskGateways(operatorName, Maps.newHashMap()); + } + + private void registerSubtaskGateway(SubtaskGateway gateway) { + int attemptNumber = gateway.getExecution().getAttemptNumber(); + Preconditions.checkState( + !gateways.containsKey(attemptNumber), + "Coordinator of %s already has a subtask gateway for (#%d)", + operatorName, + attemptNumber); + LOG.debug("Coordinator of {} registers gateway for attempt {}", operatorName, attemptNumber); + gateways.put(attemptNumber, gateway); + LOG.debug("Registered gateway for attempt {}", attemptNumber); + } + + private void unregisterSubtaskGateway(int subtaskIndex, int attemptNumber) { + gateways.remove(attemptNumber); + LOG.debug("Unregistered gateway for subtask {} attempt {}", subtaskIndex, attemptNumber); + } + + private SubtaskGateway subtaskGateway() { + Preconditions.checkState( + !gateways.isEmpty(), + "Coordinator of %s is not ready yet to receive events", + operatorName); + return Iterables.getOnlyElement(gateways.values()); + } + + private void reset() { + gateways.clear(); + } + } + + /** Custom thread factory for the coordinator executor. */ + private static class CoordinatorExecutorThreadFactory + implements ThreadFactory, Thread.UncaughtExceptionHandler { + + private final String coordinatorThreadName; + private final ClassLoader classLoader; + private final Thread.UncaughtExceptionHandler errorHandler; + + private Thread thread; + + private CoordinatorExecutorThreadFactory( + String coordinatorThreadName, ClassLoader contextClassLoader) { + this(coordinatorThreadName, contextClassLoader, FatalExitExceptionHandler.INSTANCE); + } + + private CoordinatorExecutorThreadFactory( + String coordinatorThreadName, + ClassLoader contextClassLoader, + Thread.UncaughtExceptionHandler errorHandler) { + this.coordinatorThreadName = coordinatorThreadName; + this.classLoader = contextClassLoader; + this.errorHandler = errorHandler; + } + + @Override + public synchronized Thread newThread(@Nonnull Runnable runnable) { + thread = new Thread(runnable, coordinatorThreadName); + thread.setContextClassLoader(classLoader); + thread.setUncaughtExceptionHandler(this); + return thread; + } + + @Override + public synchronized void uncaughtException(Thread t, Throwable e) { + errorHandler.uncaughtException(t, e); + } + + private boolean isCurrentThreadCoordinatorThread() { + return Thread.currentThread() == thread; + } + } + + @VisibleForTesting + List pendingReleaseEvents() { + return PENDING_RELEASE_EVENTS; + } + + @VisibleForTesting + ExecutorService coordinatorExecutor() { + return coordinatorExecutor; + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java index 135d3d9b42db..1125e5d9b6a5 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java @@ -29,12 +29,14 @@ import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; import org.apache.iceberg.DataFile; import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteDataFiles; import org.apache.iceberg.actions.RewriteDataFilesCommitManager; import org.apache.iceberg.actions.RewriteDataFilesCommitManager.CommitService; import org.apache.iceberg.actions.RewriteFileGroup; import org.apache.iceberg.flink.TableLoader; import org.apache.iceberg.flink.maintenance.api.Trigger; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,6 +54,7 @@ public class DataFileRewriteCommitter extends AbstractStreamOperator private final String taskName; private final int taskIndex; private final TableLoader tableLoader; + private final String branch; private transient Table table; private transient CommitService commitService; @@ -62,15 +65,17 @@ public class DataFileRewriteCommitter extends AbstractStreamOperator private transient Counter removedDataFileSizeCounter; public DataFileRewriteCommitter( - String tableName, String taskName, int taskIndex, TableLoader tableLoader) { + String tableName, String taskName, int taskIndex, TableLoader tableLoader, String branch) { Preconditions.checkNotNull(tableName, "Table name should no be null"); Preconditions.checkNotNull(taskName, "Task name should no be null"); Preconditions.checkNotNull(tableLoader, "Table loader should no be null"); + Preconditions.checkNotNull(branch, "Branch should not be null"); this.tableName = tableName; this.taskName = taskName; this.taskIndex = taskIndex; this.tableLoader = tableLoader; + this.branch = branch; } @Override @@ -103,7 +108,7 @@ public void processElement(StreamRecord str FlinkRewriteDataFilesCommitManager commitManager = new FlinkRewriteDataFilesCommitManager( - table, executedGroup.snapshotId(), streamRecord.getTimestamp()); + table, executedGroup.snapshotId(), streamRecord.getTimestamp(), branch); this.commitService = commitManager.service(executedGroup.groupsPerCommit()); commitService.start(); } @@ -164,8 +169,14 @@ public void close() throws IOException { private class FlinkRewriteDataFilesCommitManager extends RewriteDataFilesCommitManager { private final long timestamp; - FlinkRewriteDataFilesCommitManager(Table table, long startingSnapshotId, long timestamp) { - super(table, startingSnapshotId); + FlinkRewriteDataFilesCommitManager( + Table table, long startingSnapshotId, long timestamp, String branch) { + super( + table, + startingSnapshotId, + RewriteDataFiles.USE_STARTING_SEQUENCE_NUMBER_DEFAULT, + ImmutableMap.of(), + branch); this.timestamp = timestamp; } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java index 6751caeb2892..9c3b44b9d544 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java @@ -26,9 +26,11 @@ import org.apache.flink.metrics.Counter; import org.apache.flink.streaming.api.functions.ProcessFunction; import org.apache.flink.util.Collector; +import org.apache.flink.util.function.SerializableSupplier; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.SerializableTable; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.actions.BinPackRewriteFilePlanner; import org.apache.iceberg.actions.FileRewritePlan; import org.apache.iceberg.actions.RewriteDataFiles; @@ -61,7 +63,8 @@ public class DataFileRewritePlanner private final long maxRewriteBytes; private final Map rewriterOptions; private transient Counter errorCounter; - private final Expression filter; + private final String branch; + private final SerializableSupplier filterSupplier; public DataFileRewritePlanner( String tableName, @@ -71,12 +74,14 @@ public DataFileRewritePlanner( int newPartialProgressMaxCommits, long maxRewriteBytes, Map rewriterOptions, - Expression filter) { + SerializableSupplier filterSupplier, + String branch) { Preconditions.checkNotNull(tableName, "Table name should no be null"); Preconditions.checkNotNull(taskName, "Task name should no be null"); Preconditions.checkNotNull(tableLoader, "Table loader should no be null"); Preconditions.checkNotNull(rewriterOptions, "Options map should no be null"); + Preconditions.checkNotNull(branch, "Branch should no be null"); this.tableName = tableName; this.taskName = taskName; @@ -85,7 +90,8 @@ public DataFileRewritePlanner( this.partialProgressMaxCommits = newPartialProgressMaxCommits; this.maxRewriteBytes = maxRewriteBytes; this.rewriterOptions = rewriterOptions; - this.filter = filter; + this.branch = branch; + this.filterSupplier = filterSupplier; } @Override @@ -108,7 +114,8 @@ public void processElement(Trigger value, Context ctx, Collector o try { SerializableTable table = (SerializableTable) SerializableTable.copyOf(tableLoader.loadTable()); - if (table.currentSnapshot() == null) { + Snapshot snapshot = table.snapshot(branch); + if (snapshot == null) { LOG.info( DataFileRewritePlanner.MESSAGE_PREFIX + "Nothing to plan for in an empty table", tableName, @@ -118,7 +125,8 @@ public void processElement(Trigger value, Context ctx, Collector o return; } - BinPackRewriteFilePlanner planner = new BinPackRewriteFilePlanner(table, filter); + BinPackRewriteFilePlanner planner = + new BinPackRewriteFilePlanner(table, filterSupplier.get(), snapshot.snapshotId(), false); planner.init(rewriterOptions); FileRewritePlan @@ -164,7 +172,7 @@ public void processElement(Trigger value, Context ctx, Collector o taskIndex, ctx.timestamp(), group); - out.collect(new PlannedGroup(table, groupsPerCommit, group)); + out.collect(new PlannedGroup(table, groupsPerCommit, group, branch)); } } catch (Exception e) { LOG.warn( @@ -189,11 +197,14 @@ public static class PlannedGroup { private final SerializableTable table; private final int groupsPerCommit; private final RewriteFileGroup group; + private final String branch; - private PlannedGroup(SerializableTable table, int groupsPerCommit, RewriteFileGroup group) { + private PlannedGroup( + SerializableTable table, int groupsPerCommit, RewriteFileGroup group, String branch) { this.table = table; this.groupsPerCommit = groupsPerCommit; this.group = group; + this.branch = branch; } SerializableTable table() { @@ -207,5 +218,9 @@ int groupsPerCommit() { RewriteFileGroup group() { return group; } + + String branch() { + return branch; + } } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java index 1e8db128e9e1..f7a53c7fca3d 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java @@ -124,7 +124,7 @@ public void processElement(PlannedGroup value, Context ctx, Collector o ctx.output(DELETE_STREAM, file); deleteFileCounter.incrementAndGet(); }) - .cleanExpiredFiles(true) + .cleanupLevel(ExpireSnapshots.CleanupLevel.ALL) .commit(); LOG.info( diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java index 3ae42c60831c..f9000511c11f 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java @@ -66,6 +66,7 @@ public void open(OpenContext openContext) throws Exception { public void processElement(Trigger trigger, Context ctx, Collector collector) throws Exception { try { + table.refresh(); table .snapshots() .forEach( diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java new file mode 100644 index 000000000000..0dcd15a66559 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +/** + * Event sent from TriggerManagerOperator to TriggerManagerCoordinator to register a lock release + * handler. This handler will be used to forward lock release events back to the operator when + * triggered by downstream operators. + */ +@Internal +public class LockRegisterEvent implements OperatorEvent { + + private final String lockId; + + public LockRegisterEvent(String lockId) { + this.lockId = lockId; + } + + public String lockId() { + return lockId; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("lockId", lockId).toString(); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java new file mode 100644 index 000000000000..8c6e71ca9761 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +/** + * Event sent from LockRemoverOperator to LockRemoverCoordinator to notify that a lock has been + * released. The LockRemoverCoordinator then forwards this event to the TriggerManagerOperator via + * the registered lock release handler. + */ +@Internal +public class LockReleaseEvent implements OperatorEvent { + + private final String lockId; + private final long timestamp; + + public LockReleaseEvent(String lockId, long timestamp) { + this.lockId = lockId; + this.timestamp = timestamp; + } + + public long timestamp() { + return timestamp; + } + + public String lockId() { + return lockId; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("lockId", lockId) + .add("timestamp", timestamp) + .toString(); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java new file mode 100644 index 000000000000..39020cd5d30a --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.Locale; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Coordinator for LockRemoverOperator. Handles lock release events from downstream operators. */ +class LockRemoverCoordinator extends BaseCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(LockRemoverCoordinator.class); + + LockRemoverCoordinator(String operatorName, Context context) { + super(operatorName, context); + LOG.info("Created LockRemoverCoordinator: {}", operatorName); + } + + @Override + public void handleEventFromOperator(int subtask, int attemptNumber, OperatorEvent event) { + runInCoordinatorThread( + () -> { + LOG.debug( + "Handling event from subtask {} (#{}) of {}: {}", + subtask, + attemptNumber, + operatorName(), + event); + if (event instanceof LockReleaseEvent) { + handleReleaseLock((LockReleaseEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + }, + String.format( + Locale.ROOT, + "handling operator event %s from subtask %d (#%d)", + event.getClass(), + subtask, + attemptNumber)); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java new file mode 100644 index 000000000000..bee95f5003f0 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.io.Serial; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.flink.metrics.Counter; +import org.apache.flink.metrics.MetricGroup; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventHandler; +import org.apache.flink.streaming.api.operators.AbstractStreamOperator; +import org.apache.flink.streaming.api.operators.OneInputStreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.iceberg.flink.maintenance.api.TaskResult; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class LockRemoverOperator extends AbstractStreamOperator + implements OneInputStreamOperator, OperatorEventHandler { + + @Serial private static final long serialVersionUID = 1L; + private static final Logger LOG = LoggerFactory.getLogger(LockRemoverOperator.class); + + private final String tableName; + private final OperatorEventGateway operatorEventGateway; + private final List maintenanceTaskNames; + private transient List succeededTaskResultCounters; + private transient List failedTaskResultCounters; + private transient List taskLastRunDurationMs; + + LockRemoverOperator( + StreamOperatorParameters parameters, + OperatorEventGateway operatorEventGateway, + String tableName, + List maintenanceTaskNames) { + this.tableName = tableName; + this.operatorEventGateway = operatorEventGateway; + this.maintenanceTaskNames = maintenanceTaskNames; + } + + @Override + public void open() throws Exception { + this.succeededTaskResultCounters = + Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + this.failedTaskResultCounters = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + this.taskLastRunDurationMs = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + for (int taskIndex = 0; taskIndex < maintenanceTaskNames.size(); ++taskIndex) { + MetricGroup taskMetricGroup = + TableMaintenanceMetrics.groupFor( + getRuntimeContext(), tableName, maintenanceTaskNames.get(taskIndex), taskIndex); + succeededTaskResultCounters.add( + taskMetricGroup.counter(TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER)); + failedTaskResultCounters.add( + taskMetricGroup.counter(TableMaintenanceMetrics.FAILED_TASK_COUNTER)); + AtomicLong duration = new AtomicLong(0); + taskLastRunDurationMs.add(duration); + taskMetricGroup.gauge(TableMaintenanceMetrics.LAST_RUN_DURATION_MS, duration::get); + } + } + + @Override + public void handleOperatorEvent(OperatorEvent event) { + // no incoming events + } + + @SuppressWarnings("FutureReturnValueIgnored") + @Override + public void processElement(StreamRecord streamRecord) { + TaskResult taskResult = streamRecord.getValue(); + LOG.info( + "Processing result {} for task {}", + taskResult, + maintenanceTaskNames.get(taskResult.taskIndex())); + long duration = System.currentTimeMillis() - taskResult.startEpoch(); + // Update the metrics + taskLastRunDurationMs.get(taskResult.taskIndex()).set(duration); + if (taskResult.success()) { + succeededTaskResultCounters.get(taskResult.taskIndex()).inc(); + } else { + failedTaskResultCounters.get(taskResult.taskIndex()).inc(); + } + } + + @Override + public void processWatermark(Watermark mark) throws Exception { + if (Watermark.MAX_WATERMARK.getTimestamp() != mark.getTimestamp()) { + operatorEventGateway.sendEventToCoordinator( + new LockReleaseEvent(tableName, mark.getTimestamp())); + } + + super.processWatermark(mark); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java new file mode 100644 index 000000000000..212df184bb13 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.RecreateOnResetOperatorCoordinator; +import org.apache.flink.streaming.api.operators.AbstractStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.CoordinatedOperatorFactory; +import org.apache.flink.streaming.api.operators.OneInputStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.StreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.iceberg.flink.maintenance.api.TaskResult; + +@Internal +public class LockRemoverOperatorFactory extends AbstractStreamOperatorFactory + implements CoordinatedOperatorFactory, OneInputStreamOperatorFactory { + private final String tableName; + private final List maintenanceTaskNames; + + public LockRemoverOperatorFactory(String tableName, List maintenanceTaskNames) { + this.tableName = tableName; + this.maintenanceTaskNames = maintenanceTaskNames; + } + + @Override + public OperatorCoordinator.Provider getCoordinatorProvider( + String operatorName, OperatorID operatorID) { + return new LockRemoverCoordinatorProvider(operatorName, operatorID); + } + + @SuppressWarnings("unchecked") + @Override + public > T createStreamOperator( + StreamOperatorParameters parameters) { + OperatorID operatorId = parameters.getStreamConfig().getOperatorID(); + OperatorEventGateway gateway = + parameters.getOperatorEventDispatcher().getOperatorEventGateway(operatorId); + LockRemoverOperator lockRemoverOperator = + new LockRemoverOperator(parameters, gateway, tableName, maintenanceTaskNames); + lockRemoverOperator.setup( + parameters.getContainingTask(), parameters.getStreamConfig(), parameters.getOutput()); + parameters.getOperatorEventDispatcher().registerEventHandler(operatorId, lockRemoverOperator); + + return (T) lockRemoverOperator; + } + + @SuppressWarnings("rawtypes") + @Override + public Class getStreamOperatorClass(ClassLoader classLoader) { + return LockRemoverOperator.class; + } + + private static class LockRemoverCoordinatorProvider + extends RecreateOnResetOperatorCoordinator.Provider { + + private final String operatorName; + + private LockRemoverCoordinatorProvider(String operatorName, OperatorID operatorID) { + super(operatorID); + this.operatorName = operatorName; + } + + @Override + public OperatorCoordinator getCoordinator(OperatorCoordinator.Context context) { + return new LockRemoverCoordinator(operatorName, context); + } + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java index d74b2349b1de..f468c2f6eb74 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java @@ -129,7 +129,7 @@ public TableChange next() { if (snapshot != null) { if (!DataOperations.REPLACE.equals(snapshot.operation())) { LOG.debug("Reading snapshot {}", snapshot.snapshotId()); - event.merge(new TableChange(snapshot, table.io())); + event.merge(new TableChange(snapshot, table)); } else { LOG.debug("Skipping replace snapshot {}", snapshot.snapshotId()); } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java index 87600c52304a..b363e3443d47 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java @@ -23,7 +23,8 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.Snapshot; -import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; /** Event describing changes in an Iceberg table */ @@ -54,8 +55,12 @@ private TableChange( this.commitCount = commitCount; } - TableChange(Snapshot snapshot, FileIO io) { - this(snapshot.addedDataFiles(io), snapshot.addedDeleteFiles(io)); + TableChange(Snapshot snapshot, Table table) { + this(SnapshotChanges.builderFor(table).snapshot(snapshot).build()); + } + + private TableChange(SnapshotChanges changes) { + this(changes.addedDataFiles(), changes.addedDeleteFiles()); } public TableChange(Iterable dataFiles, Iterable deleteFiles) { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java index bd8424d726ec..2f5b3fd49c21 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java @@ -226,7 +226,8 @@ private void checkAndFire(long current, TimerService timerService, Collector evaluators, - List changes, - List lastTriggerTimes, - long currentTime, - int startPos) { - int current = startPos; - do { - if (evaluators - .get(current) - .check(changes.get(current), lastTriggerTimes.get(current), currentTime)) { - return current; - } - - current = (current + 1) % evaluators.size(); - } while (current != startPos); - - return null; - } - private void init(Collector out, TimerService timerService) throws Exception { if (!inited) { long current = timerService.currentProcessingTime(); diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java new file mode 100644 index 000000000000..b5af41da279a --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.Locale; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class TriggerManagerCoordinator extends BaseCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(TriggerManagerCoordinator.class); + + TriggerManagerCoordinator(String operatorName, Context context) { + super(operatorName, context); + LOG.info("Created TriggerManagerCoordinator: {}", operatorName); + } + + @Override + public void handleEventFromOperator(int subtask, int attemptNumber, OperatorEvent event) { + runInCoordinatorThread( + () -> { + LOG.debug( + "Handling event from subtask {} (#{}) of {}: {}", + subtask, + attemptNumber, + operatorName(), + event); + if (event instanceof LockRegisterEvent) { + registerLock((LockRegisterEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + }, + String.format( + Locale.ROOT, + "handling operator event %s from subtask %d (#%d)", + event.getClass(), + subtask, + attemptNumber)); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java new file mode 100644 index 000000000000..95c0db81a439 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java @@ -0,0 +1,322 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.io.Serial; +import java.util.List; +import org.apache.flink.annotation.VisibleForTesting; +import org.apache.flink.api.common.operators.ProcessingTimeService.ProcessingTimeCallback; +import org.apache.flink.api.common.state.ListState; +import org.apache.flink.api.common.state.ListStateDescriptor; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.metrics.Counter; +import org.apache.flink.metrics.MetricGroup; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventHandler; +import org.apache.flink.runtime.state.StateInitializationContext; +import org.apache.flink.runtime.state.StateSnapshotContext; +import org.apache.flink.streaming.api.operators.AbstractStreamOperator; +import org.apache.flink.streaming.api.operators.OneInputStreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.flink.streaming.runtime.tasks.ProcessingTimeService; +import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The TriggerManagerOperator itself holds the lock and registers a callback method with the + * coordinator. When a task finishes, it sends a signal from downstream to the coordinator to + * trigger this callback, allowing the TriggerManagerOperator to release the lock. + */ +class TriggerManagerOperator extends AbstractStreamOperator + implements OneInputStreamOperator, + OperatorEventHandler, + ProcessingTimeCallback { + + @Serial private static final long serialVersionUID = 1L; + private static final Logger LOG = LoggerFactory.getLogger(TriggerManagerOperator.class); + + private final OperatorEventGateway operatorEventGateway; + private final List maintenanceTaskNames; + private final List evaluators; + private final long minFireDelayMs; + private final long lockCheckDelayMs; + private final String tableName; + + private transient Counter rateLimiterTriggeredCounter; + private transient Counter concurrentRunThrottledCounter; + private transient Counter nothingToTriggerCounter; + private transient List triggerCounters; + private transient ListState nextEvaluationTimeState; + private transient ListState accumulatedChangesState; + private transient ListState lastTriggerTimesState; + private transient Long nextEvaluationTime; + private transient List accumulatedChanges; + private transient List lastTriggerTimes; + // To keep the task scheduling fair we keep the last triggered task position in memory. + // If we find a task to trigger, then we run it, but after it is finished, we start from the given + // position to prevent "starvation" of the tasks. + // When there is nothing to trigger, we start from the beginning, as the order of the tasks might + // be important (RewriteDataFiles first, and then RewriteManifestFiles later) + private transient int startsFrom = 0; + private transient boolean triggered = false; + private transient Long lockTime; + private transient boolean shouldRestoreTasks = false; + + TriggerManagerOperator( + StreamOperatorParameters parameters, + OperatorEventGateway operatorEventGateway, + List maintenanceTaskNames, + List evaluators, + long minFireDelayMs, + long lockCheckDelayMs, + String tableName) { + Preconditions.checkArgument( + maintenanceTaskNames != null && !maintenanceTaskNames.isEmpty(), + "Invalid maintenance task names: null or empty"); + Preconditions.checkArgument( + evaluators != null && !evaluators.isEmpty(), "Invalid evaluators: null or empty"); + Preconditions.checkArgument( + maintenanceTaskNames.size() == evaluators.size(), + "Provide a name and evaluator for all of the maintenance tasks"); + Preconditions.checkArgument(minFireDelayMs > 0, "Minimum fire delay should be at least 1."); + Preconditions.checkArgument( + lockCheckDelayMs > 0, "Minimum lock delay rate should be at least 1 ms."); + + this.processingTimeService = parameters.getProcessingTimeService(); + this.maintenanceTaskNames = maintenanceTaskNames; + this.evaluators = evaluators; + this.minFireDelayMs = minFireDelayMs; + this.lockCheckDelayMs = lockCheckDelayMs; + this.tableName = tableName; + this.operatorEventGateway = operatorEventGateway; + } + + @Override + public void open() throws Exception { + super.open(); + MetricGroup mainGroup = TableMaintenanceMetrics.groupFor(getRuntimeContext(), tableName); + this.rateLimiterTriggeredCounter = + mainGroup.counter(TableMaintenanceMetrics.RATE_LIMITER_TRIGGERED); + this.concurrentRunThrottledCounter = + mainGroup.counter(TableMaintenanceMetrics.CONCURRENT_RUN_THROTTLED); + this.nothingToTriggerCounter = mainGroup.counter(TableMaintenanceMetrics.NOTHING_TO_TRIGGER); + this.triggerCounters = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + for (int taskIndex = 0; taskIndex < maintenanceTaskNames.size(); ++taskIndex) { + triggerCounters.add( + TableMaintenanceMetrics.groupFor( + mainGroup, maintenanceTaskNames.get(taskIndex), taskIndex) + .counter(TableMaintenanceMetrics.TRIGGERED)); + } + } + + @Override + public void initializeState(StateInitializationContext context) throws Exception { + super.initializeState(context); + this.nextEvaluationTimeState = + context + .getOperatorStateStore() + .getListState(new ListStateDescriptor<>("triggerManagerNextTriggerTime", Types.LONG)); + + this.accumulatedChangesState = + context + .getOperatorStateStore() + .getListState( + new ListStateDescriptor<>( + "triggerManagerAccumulatedChange", TypeInformation.of(TableChange.class))); + + this.lastTriggerTimesState = + context + .getOperatorStateStore() + .getListState(new ListStateDescriptor<>("triggerManagerLastTriggerTime", Types.LONG)); + + long current = getProcessingTimeService().getCurrentProcessingTime(); + + // Initialize from state + if (!Iterables.isEmpty(nextEvaluationTimeState.get())) { + nextEvaluationTime = Iterables.getOnlyElement(nextEvaluationTimeState.get()); + } + + this.accumulatedChanges = Lists.newArrayList(accumulatedChangesState.get()); + this.lastTriggerTimes = Lists.newArrayList(lastTriggerTimesState.get()); + + // Initialize if the state was empty + if (accumulatedChanges.isEmpty()) { + for (int i = 0; i < evaluators.size(); ++i) { + accumulatedChanges.add(TableChange.empty()); + lastTriggerTimes.add(current); + } + } + + // register the lock register event + operatorEventGateway.sendEventToCoordinator(new LockRegisterEvent(tableName)); + + if (context.isRestored()) { + // When the job state is restored, there could be ongoing tasks. + // To prevent collision with the new triggers the following is done: + // - add a recovery lock + // This ensures that the tasks of the previous trigger are executed, and the lock is removed + // in the end. The result of the 'tryLock' is ignored as an already existing lock prevents + // collisions as well. + // register the recover lock + this.lockTime = current; + this.shouldRestoreTasks = true; + output.collect(new StreamRecord<>(Trigger.recovery(current), current)); + if (nextEvaluationTime == null) { + schedule(getProcessingTimeService(), current + minFireDelayMs); + } else { + schedule(getProcessingTimeService(), nextEvaluationTime); + } + } else { + this.lockTime = null; + } + } + + @Override + public void snapshotState(StateSnapshotContext context) throws Exception { + nextEvaluationTimeState.clear(); + if (nextEvaluationTime != null) { + nextEvaluationTimeState.add(nextEvaluationTime); + } + + accumulatedChangesState.update(accumulatedChanges); + lastTriggerTimesState.update(lastTriggerTimes); + LOG.info( + "Storing state: nextEvaluationTime {}, accumulatedChanges {}, lastTriggerTimes {}", + nextEvaluationTime, + accumulatedChanges, + lastTriggerTimes); + } + + @Override + public void handleOperatorEvent(OperatorEvent event) { + if (event instanceof LockReleaseEvent) { + LOG.info("Received lock released event: {}", event); + handleLockRelease((LockReleaseEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + } + + @Override + public void processElement(StreamRecord streamRecord) throws Exception { + TableChange change = streamRecord.getValue(); + accumulatedChanges.forEach(tableChange -> tableChange.merge(change)); + if (nextEvaluationTime == null) { + checkAndFire(getProcessingTimeService()); + } else { + LOG.info( + "Trigger manager rate limiter triggered current: {}, next: {}, accumulated changes: {},{}", + getProcessingTimeService().getCurrentProcessingTime(), + nextEvaluationTime, + accumulatedChanges, + maintenanceTaskNames); + rateLimiterTriggeredCounter.inc(); + } + } + + @Override + public void onProcessingTime(long l) { + this.nextEvaluationTime = null; + checkAndFire(getProcessingTimeService()); + } + + @Override + public void close() throws Exception { + super.close(); + this.lockTime = null; + } + + @VisibleForTesting + void handleLockRelease(LockReleaseEvent event) { + Preconditions.checkArgument(lockTime != null, "Lock time is null, Can't release lock"); + + if (event.timestamp() >= lockTime) { + this.lockTime = null; + this.shouldRestoreTasks = false; + } + } + + private void checkAndFire(ProcessingTimeService timerService) { + long current = timerService.getCurrentProcessingTime(); + if (shouldRestoreTasks) { + if (lockTime != null) { + // Recovered tasks in progress. Skip trigger check + LOG.info("The recovery lock is still held at {}", current); + schedule(timerService, current + lockCheckDelayMs); + return; + } + } + + Integer taskToStart = + TriggerUtil.nextTrigger( + evaluators, accumulatedChanges, lastTriggerTimes, current, startsFrom); + if (taskToStart == null) { + // Nothing to execute + if (!triggered) { + nothingToTriggerCounter.inc(); + LOG.debug("Nothing to execute at {} for collected: {}", current, accumulatedChanges); + } else { + LOG.debug("Execution check finished"); + } + + // Next time start from the beginning + startsFrom = 0; + triggered = false; + return; + } + + if (lockTime == null) { + this.lockTime = current; + TableChange change = accumulatedChanges.get(taskToStart); + output.collect(new StreamRecord<>(Trigger.create(current, taskToStart), current)); + LOG.debug("Fired event with time: {}, collected: {} for {}", current, change, tableName); + triggerCounters.get(taskToStart).inc(); + accumulatedChanges.set(taskToStart, TableChange.empty()); + lastTriggerTimes.set(taskToStart, current); + schedule(timerService, current + minFireDelayMs); + startsFrom = (taskToStart + 1) % evaluators.size(); + triggered = true; + } else { + // A task is already running, waiting for it to finish + LOG.info("Failed to acquire lock. Delaying task to {}", current + lockCheckDelayMs); + + startsFrom = taskToStart; + concurrentRunThrottledCounter.inc(); + schedule(timerService, current + lockCheckDelayMs); + } + } + + private void schedule(ProcessingTimeService timerService, long time) { + this.nextEvaluationTime = time; + timerService.registerTimer(time, this); + } + + @VisibleForTesting + Long lockTime() { + return lockTime; + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java new file mode 100644 index 000000000000..b4da6e3def6c --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.RecreateOnResetOperatorCoordinator; +import org.apache.flink.streaming.api.operators.AbstractStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.CoordinatedOperatorFactory; +import org.apache.flink.streaming.api.operators.OneInputStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.StreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.iceberg.flink.maintenance.api.Trigger; + +@Internal +public class TriggerManagerOperatorFactory extends AbstractStreamOperatorFactory + implements CoordinatedOperatorFactory, + OneInputStreamOperatorFactory { + + private final String lockId; + private final List maintenanceTaskNames; + private final List evaluators; + private final long minFireDelayMs; + private final long lockCheckDelayMs; + + public TriggerManagerOperatorFactory( + String lockId, + List maintenanceTaskNames, + List evaluators, + long minFireDelayMs, + long lockCheckDelayMs) { + this.lockId = lockId; + this.maintenanceTaskNames = maintenanceTaskNames; + this.evaluators = evaluators; + this.minFireDelayMs = minFireDelayMs; + this.lockCheckDelayMs = lockCheckDelayMs; + } + + @Override + public OperatorCoordinator.Provider getCoordinatorProvider( + String operatorName, OperatorID operatorID) { + return new TriggerManagerCoordinatorProvider(operatorName, operatorID); + } + + @SuppressWarnings("unchecked") + @Override + public > T createStreamOperator( + StreamOperatorParameters parameters) { + OperatorID operatorId = parameters.getStreamConfig().getOperatorID(); + OperatorEventGateway gateway = + parameters.getOperatorEventDispatcher().getOperatorEventGateway(operatorId); + + TriggerManagerOperator triggerManagerOperator = + new TriggerManagerOperator( + parameters, + gateway, + maintenanceTaskNames, + evaluators, + minFireDelayMs, + lockCheckDelayMs, + lockId); + + triggerManagerOperator.setup( + parameters.getContainingTask(), parameters.getStreamConfig(), parameters.getOutput()); + + parameters + .getOperatorEventDispatcher() + .registerEventHandler(operatorId, triggerManagerOperator); + + return (T) triggerManagerOperator; + } + + @SuppressWarnings("rawtypes") + @Override + public Class getStreamOperatorClass(ClassLoader classLoader) { + return TriggerManagerOperator.class; + } + + private static class TriggerManagerCoordinatorProvider + extends RecreateOnResetOperatorCoordinator.Provider { + + private final String operatorName; + + private TriggerManagerCoordinatorProvider(String operatorName, OperatorID operatorID) { + super(operatorID); + this.operatorName = operatorName; + } + + @Override + public OperatorCoordinator getCoordinator(OperatorCoordinator.Context context) { + return new TriggerManagerCoordinator(operatorName, context); + } + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java new file mode 100644 index 000000000000..634e9a0d03c6 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; + +class TriggerUtil { + + private TriggerUtil() {} + + static Integer nextTrigger( + List evaluators, + List changes, + List lastTriggerTimes, + long currentTime, + int startPos) { + int current = startPos; + do { + if (evaluators + .get(current) + .check(changes.get(current), lastTriggerTimes.get(current), currentTime)) { + return current; + } + + current = (current + 1) % evaluators.size(); + } while (current != startPos); + + return null; + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java index f7e8e0c884cf..5f3494330cfc 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java @@ -21,14 +21,14 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.api.common.functions.MapFunction; -import org.apache.flink.formats.avro.AvroToRowDataConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.RowType; import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; +import org.apache.iceberg.flink.formats.avro.AvroToRowDataConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; /** * This util class converts Avro GenericRecord to Flink RowData.
diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java index 1b786e46452f..1da0f8564938 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java @@ -19,6 +19,7 @@ package org.apache.iceberg.flink.sink; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.NavigableMap; import java.util.concurrent.atomic.AtomicLong; @@ -44,7 +45,11 @@ public CommitSummary(NavigableMap pendingResults) { } public void addAll(NavigableMap> pendingResults) { - pendingResults.values().forEach(writeResults -> writeResults.forEach(this::addWriteResult)); + pendingResults.values().forEach(this::addAll); + } + + public void addAll(Collection pendingResults) { + pendingResults.forEach(this::addWriteResult); } private void addWriteResult(WriteResult writeResult) { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java index b3ada41737bc..d5247941d863 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java @@ -25,28 +25,19 @@ import java.io.Serializable; import java.util.Map; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.data.StringData; import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.FileFormat; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; -import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; import org.apache.iceberg.flink.FlinkSchemaUtil; -import org.apache.iceberg.flink.data.FlinkAvroWriter; -import org.apache.iceberg.flink.data.FlinkOrcWriter; -import org.apache.iceberg.flink.data.FlinkParquetWriters; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -public class FlinkFileWriterFactory extends BaseFileWriterFactory implements Serializable { - private RowType dataFlinkType; - private RowType equalityDeleteFlinkType; - - private FlinkFileWriterFactory( +public class FlinkFileWriterFactory extends RegistryBasedFileWriterFactory + implements Serializable { + FlinkFileWriterFactory( Table table, FileFormat dataFileFormat, Schema dataSchema, @@ -62,85 +53,30 @@ private FlinkFileWriterFactory( super( table, dataFileFormat, + RowData.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - writeProperties); - - this.dataFlinkType = dataFlinkType; - this.equalityDeleteFlinkType = equalityDeleteFlinkType; - } - - static Builder builderFor(Table table) { - return new Builder(table); - } - - @Override - protected void configureDataWrite(Avro.DataWriteBuilder builder) { - builder.createWriterFunc(ignore -> new FlinkAvroWriter(dataFlinkType())); - } - - @Override - protected void configureEqualityDelete(Avro.DeleteWriteBuilder builder) { - builder.createWriterFunc(ignored -> new FlinkAvroWriter(equalityDeleteFlinkType())); - } - - @Override - protected void configurePositionDelete(Avro.DeleteWriteBuilder builder) {} - - @Override - protected void configureDataWrite(Parquet.DataWriteBuilder builder) { - builder.createWriterFunc(msgType -> FlinkParquetWriters.buildWriter(dataFlinkType(), msgType)); - } - - @Override - protected void configureEqualityDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> FlinkParquetWriters.buildWriter(equalityDeleteFlinkType(), msgType)); - } - - @Override - protected void configurePositionDelete(Parquet.DeleteWriteBuilder builder) { - builder.transformPaths(path -> StringData.fromString(path.toString())); - } - - @Override - protected void configureDataWrite(ORC.DataWriteBuilder builder) { - builder.createWriterFunc( - (iSchema, typDesc) -> FlinkOrcWriter.buildWriter(dataFlinkType(), iSchema)); - } - - @Override - protected void configureEqualityDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc( - (iSchema, typDesc) -> FlinkOrcWriter.buildWriter(equalityDeleteFlinkType(), iSchema)); + writeProperties, + dataFlinkType == null ? FlinkSchemaUtil.convert(dataSchema) : dataFlinkType, + equalityDeleteInputSchema(equalityDeleteFlinkType, equalityDeleteRowSchema)); } - @Override - protected void configurePositionDelete(ORC.DeleteWriteBuilder builder) { - builder.transformPaths(path -> StringData.fromString(path.toString())); - } - - private RowType dataFlinkType() { - if (dataFlinkType == null) { - Preconditions.checkNotNull(dataSchema(), "Data schema must not be null"); - this.dataFlinkType = FlinkSchemaUtil.convert(dataSchema()); + private static RowType equalityDeleteInputSchema(RowType rowType, Schema rowSchema) { + if (rowType != null) { + return rowType; + } else if (rowSchema != null) { + return FlinkSchemaUtil.convert(rowSchema); + } else { + return null; } - - return dataFlinkType; } - private RowType equalityDeleteFlinkType() { - if (equalityDeleteFlinkType == null) { - Preconditions.checkNotNull( - equalityDeleteRowSchema(), "Equality delete schema must not be null"); - this.equalityDeleteFlinkType = FlinkSchemaUtil.convert(equalityDeleteRowSchema()); - } - - return equalityDeleteFlinkType; + static Builder builderFor(Table table) { + return new Builder(table); } public static class Builder { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java index 0e3790aec806..90fbdc5c2389 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java @@ -158,7 +158,11 @@ public static class Builder implements IcebergSinkBuilder { private Function> inputCreator = null; private TableLoader tableLoader; private Table table; - @Deprecated private TableSchema tableSchema; + + @SuppressWarnings("deprecation") + @Deprecated + private TableSchema tableSchema; + private ResolvedSchema resolvedSchema; private List equalityFieldColumns = null; private String uidPrefix = null; @@ -239,6 +243,7 @@ public Builder setAll(Map properties) { return this; } + @SuppressWarnings("deprecation") @Override public Builder tableSchema(TableSchema newTableSchema) { this.tableSchema = newTableSchema; @@ -725,9 +730,8 @@ private DataStream distributeDataStream( @Deprecated static RowType toFlinkRowType(Schema schema, TableSchema requestedSchema) { if (requestedSchema != null) { - // Convert the flink schema to iceberg schema firstly, then reassign ids to match the existing - // iceberg schema. - Schema writeSchema = TypeUtil.reassignIds(FlinkSchemaUtil.convert(requestedSchema), schema); + // Convert the flink schema to iceberg schema using the table schema as the reference. + Schema writeSchema = FlinkSchemaUtil.convert(schema, requestedSchema); TypeUtil.validateWriteSchema(schema, writeSchema, true, true); // We use this flink schema to read values from RowData. The flink's TINYINT and SMALLINT will @@ -742,9 +746,8 @@ static RowType toFlinkRowType(Schema schema, TableSchema requestedSchema) { static RowType toFlinkRowType(Schema schema, ResolvedSchema requestedSchema) { if (requestedSchema != null) { - // Convert the flink schema to iceberg schema firstly, then reassign ids to match the existing - // iceberg schema. - Schema writeSchema = TypeUtil.reassignIds(FlinkSchemaUtil.convert(requestedSchema), schema); + // Convert the flink schema to iceberg schema using the table schema as the reference. + Schema writeSchema = FlinkSchemaUtil.convert(schema, requestedSchema); TypeUtil.validateWriteSchema(schema, writeSchema, true, true); // We use this flink schema to read values from RowData. The flink's TINYINT and SMALLINT will diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java index c05e7d918093..8e45a2db30b2 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java @@ -78,7 +78,7 @@ class IcebergCommitter implements Committer { private int maxContinuousEmptyCommits; private ExecutorService workerPool; private int continuousEmptyCheckpoints = 0; - private boolean compactMode = false; + private final boolean tableMaintenanceEnabled; IcebergCommitter( TableLoader tableLoader, @@ -88,7 +88,7 @@ class IcebergCommitter implements Committer { int workerPoolSize, String sinkId, IcebergFilesCommitterMetrics committerMetrics, - boolean compactMode) { + boolean tableMaintenanceEnabled) { this.branch = branch; this.snapshotProperties = snapshotProperties; this.replacePartitions = replacePartitions; @@ -107,7 +107,7 @@ class IcebergCommitter implements Committer { ThreadPools.newFixedThreadPool( "iceberg-committer-pool-" + table.name() + "-" + sinkId, workerPoolSize); this.continuousEmptyCheckpoints = 0; - this.compactMode = compactMode; + this.tableMaintenanceEnabled = tableMaintenanceEnabled; } @Override @@ -177,7 +177,7 @@ private void commitPendingRequests( committerMetrics.updateCommitSummary(summary); } - if (!compactMode) { + if (!tableMaintenanceEnabled) { FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergFilesCommitter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergFilesCommitter.java index f78a705e6632..510dcaee3d90 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergFilesCommitter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergFilesCommitter.java @@ -155,8 +155,8 @@ public void initializeState(StateInitializationContext context) throws Exception Preconditions.checkArgument( maxContinuousEmptyCommits > 0, MAX_CONTINUOUS_EMPTY_COMMITS + " must be positive"); - int subTaskId = getRuntimeContext().getIndexOfThisSubtask(); - int attemptId = getRuntimeContext().getAttemptNumber(); + int subTaskId = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask(); + int attemptId = getRuntimeContext().getTaskInfo().getAttemptNumber(); this.manifestOutputFileFactory = FlinkManifestUtil.createOutputFileFactory( () -> table, table.properties(), flinkJobId, operatorUniqueId, subTaskId, attemptId); diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java index f27db4f2b4cf..d5f3114d0b96 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java @@ -29,6 +29,7 @@ import java.util.Set; import java.util.UUID; import java.util.function.Function; +import org.apache.commons.lang3.StringUtils; import org.apache.flink.annotation.Experimental; import org.apache.flink.api.common.SupportsConcurrentExecutionAttempts; import org.apache.flink.api.common.functions.FlatMapFunction; @@ -71,12 +72,16 @@ import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.TableLoader; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFiles; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshots; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; import org.apache.iceberg.flink.maintenance.api.FlinkMaintenanceConfig; import org.apache.iceberg.flink.maintenance.api.LockConfig; +import org.apache.iceberg.flink.maintenance.api.MaintenanceTaskBuilder; import org.apache.iceberg.flink.maintenance.api.RewriteDataFiles; import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; import org.apache.iceberg.flink.maintenance.api.TableMaintenance; -import org.apache.iceberg.flink.maintenance.api.TriggerLockFactory; import org.apache.iceberg.flink.maintenance.operator.LockFactoryBuilder; import org.apache.iceberg.flink.maintenance.operator.TableChange; import org.apache.iceberg.flink.sink.shuffle.DataStatisticsOperatorFactory; @@ -87,7 +92,9 @@ import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.SerializableSupplier; import org.slf4j.Logger; @@ -157,11 +164,14 @@ public class IcebergSink private final String branch; private final boolean overwriteMode; private final int workerPoolSize; - private final boolean compactMode; - private final transient FlinkMaintenanceConfig flinkMaintenanceConfig; - + private final boolean maintenanceEnabled; private final Table table; - private final Set equalityFieldColumns = null; + // This should only be used for logging/error messages. For any actual logic always use + // equalityFieldIds instead. + private final Set equalityFieldColumns; + + private final transient List> maintenanceTasks; + private final transient FlinkMaintenanceConfig flinkMaintenanceConfig; private IcebergSink( TableLoader tableLoader, @@ -175,7 +185,9 @@ private IcebergSink( Set equalityFieldIds, String branch, boolean overwriteMode, - FlinkMaintenanceConfig flinkMaintenanceConfig) { + List> maintenanceTasks, + FlinkMaintenanceConfig flinkMaintenanceConfig, + Set equalityFieldColumns) { this.tableLoader = tableLoader; this.snapshotProperties = snapshotProperties; this.uidSuffix = uidSuffix; @@ -195,10 +207,13 @@ private IcebergSink( // This is used to separate files generated by different sinks writing the same table. // Also used to generate the aggregator operator name this.sinkId = UUID.randomUUID().toString(); - this.compactMode = flinkWriteConf.compactMode(); + this.maintenanceEnabled = !maintenanceTasks.isEmpty(); + this.maintenanceTasks = maintenanceTasks; this.flinkMaintenanceConfig = flinkMaintenanceConfig; + this.equalityFieldColumns = equalityFieldColumns; } + @SuppressWarnings("deprecation") @Override public SinkWriter createWriter(InitContext context) { RowDataTaskWriterFactory taskWriterFactory = @@ -216,8 +231,8 @@ public SinkWriter createWriter(InitContext context) { tableSupplier.get().name(), taskWriterFactory, metrics, - context.getSubtaskId(), - context.getAttemptNumber()); + context.getTaskInfo().getIndexOfThisSubtask(), + context.getTaskInfo().getAttemptNumber()); } @Override @@ -232,7 +247,7 @@ public Committer createCommitter(CommitterInitContext contex workerPoolSize, sinkId, metrics, - compactMode); + maintenanceEnabled); } @Override @@ -244,7 +259,7 @@ public SimpleVersionedSerializer getCommittableSerializer() public void addPostCommitTopology( DataStream> committables) { - if (!compactMode) { + if (maintenanceTasks.isEmpty()) { return; } @@ -258,25 +273,31 @@ public void addPostCommitTopology( .uid(postCommitUid) .forceNonParallel(); try { - RewriteDataFilesConfig rewriteDataFilesConfig = - flinkMaintenanceConfig.createRewriteDataFilesConfig(); - RewriteDataFiles.Builder rewriteBuilder = - RewriteDataFiles.builder().config(rewriteDataFilesConfig); - LockConfig lockConfig = flinkMaintenanceConfig.createLockConfig(); - TriggerLockFactory triggerLockFactory = LockFactoryBuilder.build(lockConfig, table.name()); String tableMaintenanceUid = String.format("TableMaintenance : %s", suffix); - TableMaintenance.Builder builder = - TableMaintenance.forChangeStream(tableChangeStream, tableLoader, triggerLockFactory) - .uidSuffix(tableMaintenanceUid) - .add(rewriteBuilder); + + TableMaintenance.Builder builder; + if (StringUtils.isNotEmpty(lockConfig.lockType())) { + builder = + TableMaintenance.forChangeStream( + tableChangeStream, tableLoader, LockFactoryBuilder.build(lockConfig, table.name())); + } else { + builder = TableMaintenance.forChangeStream(tableChangeStream, tableLoader); + } builder + .uidSuffix(tableMaintenanceUid) + .add(maintenanceTasks) .rateLimit(Duration.ofSeconds(flinkMaintenanceConfig.rateLimit())) .lockCheckDelay(Duration.ofSeconds(flinkMaintenanceConfig.lockCheckDelay())) - .slotSharingGroup(flinkMaintenanceConfig.slotSharingGroup()) - .parallelism(flinkMaintenanceConfig.parallelism()) - .append(); + .parallelism(flinkMaintenanceConfig.parallelism()); + + String slotSharingGroup = flinkMaintenanceConfig.slotSharingGroup(); + if (slotSharingGroup != null) { + builder.slotSharingGroup(slotSharingGroup); + } + + builder.append(); } catch (IOException e) { throw new UncheckedIOException("Failed to create tableMaintenance ", e); } @@ -321,13 +342,18 @@ public SimpleVersionedSerializer getWriteResultSerializer() { public static class Builder implements IcebergSinkBuilder { private TableLoader tableLoader; private Function> inputCreator = null; - @Deprecated private TableSchema tableSchema; + + @SuppressWarnings("deprecation") + @Deprecated + private TableSchema tableSchema; + private ResolvedSchema resolvedSchema; private SerializableTable table; private final Map writeOptions = Maps.newHashMap(); private final Map snapshotSummary = Maps.newHashMap(); private ReadableConfig readableConfig = new Configuration(); private List equalityFieldColumns = null; + private final List> maintenanceTasks = Lists.newArrayList(); private Builder() {} @@ -436,6 +462,7 @@ public Builder setAll(Map properties) { return this; } + @SuppressWarnings("deprecation") @Override public Builder tableSchema(TableSchema newTableSchema) { this.tableSchema = newTableSchema; @@ -609,6 +636,85 @@ public Builder setSnapshotProperty(String property, String value) { return this; } + /** + * Enables or disables compaction (rewriting data files) as a post-commit maintenance task. + * + * @param enabled whether to enable compaction + * @see RewriteDataFilesConfig for the default config. + * @deprecated See {@code rewriteDatafiles(..)} + */ + @Deprecated + public Builder compaction(boolean enabled) { + writeOptions.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), Boolean.toString(enabled)); + return this; + } + + /** + * Enables rewriting data files (compaction) as a post-commit maintenance task. + * + * @see RewriteDataFilesConfig for the default config. + */ + public Builder rewriteDataFiles() { + writeOptions.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); + return this; + } + + /** + * Enables rewriting data files (compaction) as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link RewriteDataFilesConfig} for available + * keys + */ + public Builder rewriteDataFiles(Map config) { + rewriteDataFiles(); + writeOptions.putAll(config); + return this; + } + + /** + * Enables expire snapshots as a post-commit maintenance task. + * + * @see ExpireSnapshotsConfig for the default config. + */ + public Builder expireSnapshots() { + writeOptions.put(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key(), "true"); + return this; + } + + /** + * Enables or disables expire snapshots as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link ExpireSnapshotsConfig} for available + * keys + */ + public Builder expireSnapshots(Map config) { + expireSnapshots(); + writeOptions.putAll(config); + return this; + } + + /** + * Enables delete orphan files as a post-commit maintenance task. + * + * @see DeleteOrphanFilesConfig for the default config. + */ + public Builder deleteOrphanFiles() { + writeOptions.put(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key(), "true"); + return this; + } + + /** + * Enables delete orphan files as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link DeleteOrphanFilesConfig} for available + * keys. + */ + public Builder deleteOrphanFiles(Map config) { + deleteOrphanFiles(); + writeOptions.putAll(config); + return this; + } + @Override public Builder toBranch(String branch) { writeOptions.put(FlinkWriteOptions.BRANCH.key(), branch); @@ -665,6 +771,30 @@ IcebergSink build() { FlinkMaintenanceConfig flinkMaintenanceConfig = new FlinkMaintenanceConfig(table, writeOptions, readableConfig); + if (flinkWriteConf.compactMode()) { + RewriteDataFilesConfig rewriteDataFilesConfig = + flinkMaintenanceConfig.createRewriteDataFilesConfig(); + maintenanceTasks.add( + RewriteDataFiles.builder() + .branch(flinkWriteConf.branch()) + .config(rewriteDataFilesConfig)); + } + + if (flinkWriteConf.expireSnapshotsMode()) { + ExpireSnapshotsConfig expireSnapshotsConfig = + flinkMaintenanceConfig.createExpireSnapshotsConfig(); + maintenanceTasks.add(ExpireSnapshots.builder().config(expireSnapshotsConfig)); + } + + if (flinkWriteConf.deleteOrphanFilesMode()) { + DeleteOrphanFilesConfig deleteOrphanFilesConfig = + flinkMaintenanceConfig.createDeleteOrphanFilesConfig(); + maintenanceTasks.add(DeleteOrphanFiles.builder().config(deleteOrphanFilesConfig)); + } + + Set equalityFieldColumnsSet = + equalityFieldColumns != null ? Sets.newHashSet(equalityFieldColumns) : null; + return new IcebergSink( tableLoader, table, @@ -679,7 +809,9 @@ IcebergSink build() { equalityFieldIds, flinkWriteConf.branch(), overwriteMode, - flinkMaintenanceConfig); + maintenanceTasks, + flinkMaintenanceConfig, + equalityFieldColumnsSet); } /** diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriter.java index 412d6c7081bf..c1725702d98f 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriter.java @@ -51,8 +51,8 @@ class IcebergStreamWriter extends AbstractStreamOperator @Override public void open() { - this.subTaskId = getRuntimeContext().getIndexOfThisSubtask(); - this.attemptId = getRuntimeContext().getAttemptNumber(); + this.subTaskId = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask(); + this.attemptId = getRuntimeContext().getTaskInfo().getAttemptNumber(); this.writerMetrics = new IcebergStreamWriterMetrics(super.metrics, fullTableName); // Initialize the task writer factory. diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java index 434f3969577f..6cf15ff713fb 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java @@ -18,23 +18,33 @@ */ package org.apache.iceberg.flink.sink; -import com.codahale.metrics.SlidingWindowReservoir; import java.util.Arrays; import java.util.concurrent.atomic.AtomicLong; import org.apache.flink.annotation.Internal; -import org.apache.flink.dropwizard.metrics.DropwizardHistogramWrapper; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.metrics.Counter; import org.apache.flink.metrics.Histogram; import org.apache.flink.metrics.MetricGroup; +import org.apache.iceberg.common.DynClasses; +import org.apache.iceberg.common.DynConstructors; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.util.ScanTaskUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Internal public class IcebergStreamWriterMetrics { + + private static final Logger LOG = LoggerFactory.getLogger(IcebergStreamWriterMetrics.class); + // 1,024 reservoir size should cost about 8KB, which is quite small. // It should also produce good accuracy for histogram distribution (like percentiles). private static final int HISTOGRAM_RESERVOIR_SIZE = 1024; + // Histogram metrics loaded through Flink's optional flink-metrics-dropwizard dependency. + // Will be null if not available. + private static final DropwizardCtors DROPWIZARD = loadDropwizardCtors(); + private final Counter flushedDataFiles; private final Counter flushedDeleteFiles; private final Counter flushedReferencedDataFiles; @@ -51,18 +61,8 @@ public IcebergStreamWriterMetrics(MetricGroup metrics, String fullTableName) { this.lastFlushDurationMs = new AtomicLong(); writerMetrics.gauge("lastFlushDurationMs", lastFlushDurationMs::get); - com.codahale.metrics.Histogram dropwizardDataFilesSizeHistogram = - new com.codahale.metrics.Histogram(new SlidingWindowReservoir(HISTOGRAM_RESERVOIR_SIZE)); - this.dataFilesSizeHistogram = - writerMetrics.histogram( - "dataFilesSizeHistogram", - new DropwizardHistogramWrapper(dropwizardDataFilesSizeHistogram)); - com.codahale.metrics.Histogram dropwizardDeleteFilesSizeHistogram = - new com.codahale.metrics.Histogram(new SlidingWindowReservoir(HISTOGRAM_RESERVOIR_SIZE)); - this.deleteFilesSizeHistogram = - writerMetrics.histogram( - "deleteFilesSizeHistogram", - new DropwizardHistogramWrapper(dropwizardDeleteFilesSizeHistogram)); + this.dataFilesSizeHistogram = registerHistogram(writerMetrics, "dataFilesSizeHistogram"); + this.deleteFilesSizeHistogram = registerHistogram(writerMetrics, "deleteFilesSizeHistogram"); } public void updateFlushResult(WriteResult result) { @@ -74,16 +74,21 @@ public void updateFlushResult(WriteResult result) { // This should works equally well and we avoided the overhead of tracking the list of file sizes // in the {@link CommitSummary}, which currently stores simple stats for counters and gauges // metrics. - Arrays.stream(result.dataFiles()) - .forEach( - dataFile -> { - dataFilesSizeHistogram.update(dataFile.fileSizeInBytes()); - }); - Arrays.stream(result.deleteFiles()) - .forEach( - deleteFile -> { - deleteFilesSizeHistogram.update(ScanTaskUtil.contentSizeInBytes(deleteFile)); - }); + if (dataFilesSizeHistogram != null) { + Arrays.stream(result.dataFiles()) + .forEach( + dataFile -> { + dataFilesSizeHistogram.update(dataFile.fileSizeInBytes()); + }); + } + + if (deleteFilesSizeHistogram != null) { + Arrays.stream(result.deleteFiles()) + .forEach( + deleteFile -> { + deleteFilesSizeHistogram.update(ScanTaskUtil.contentSizeInBytes(deleteFile)); + }); + } } public void flushDuration(long flushDurationMs) { @@ -97,4 +102,60 @@ public Counter getFlushedDataFiles() { public Counter getFlushedDeleteFiles() { return flushedDeleteFiles; } + + @VisibleForTesting + Histogram dataFilesSizeHistogram() { + return dataFilesSizeHistogram; + } + + @VisibleForTesting + Histogram deleteFilesSizeHistogram() { + return deleteFilesSizeHistogram; + } + + private static Histogram registerHistogram(MetricGroup group, String name) { + Histogram histogram = newDropwizardHistogram(); + return histogram != null ? group.histogram(name, histogram) : null; + } + + private static Histogram newDropwizardHistogram() { + if (DROPWIZARD == null) { + return null; + } + + Object reservoir = DROPWIZARD.reservoirCtor.newInstance(HISTOGRAM_RESERVOIR_SIZE); + Object codahaleHistogram = DROPWIZARD.histogramCtor.newInstance(reservoir); + return DROPWIZARD.wrapperCtor.newInstance(codahaleHistogram); + } + + private static DropwizardCtors loadDropwizardCtors() { + try { + Class reservoirInterface = + DynClasses.builder().impl("com.codahale.metrics.Reservoir").buildChecked(); + Class codahaleHistogramClass = + DynClasses.builder().impl("com.codahale.metrics.Histogram").buildChecked(); + return new DropwizardCtors( + DynConstructors.builder() + .impl("com.codahale.metrics.SlidingWindowReservoir", int.class) + .buildChecked(), + DynConstructors.builder() + .impl("com.codahale.metrics.Histogram", reservoirInterface) + .buildChecked(), + DynConstructors.builder(Histogram.class) + .impl( + "org.apache.flink.dropwizard.metrics.DropwizardHistogramWrapper", + codahaleHistogramClass) + .buildChecked()); + } catch (ClassNotFoundException | NoSuchMethodException e) { + LOG.warn( + "Cannot load Dropwizard metrics; is org.apache.flink:flink-metrics-dropwizard on the classpath?", + e); + return null; + } + } + + private record DropwizardCtors( + DynConstructors.Ctor reservoirCtor, + DynConstructors.Ctor histogramCtor, + DynConstructors.Ctor wrapperCtor) {} } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java index 41ffa609540b..cb4fc62c01b8 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.Map; +import javax.annotation.Nullable; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.iceberg.Schema; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.schema.SchemaWithPartnerVisitor; @@ -43,23 +45,31 @@ public class CompareSchemasVisitor extends SchemaWithPartnerVisitor { private final Schema tableSchema; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; - private CompareSchemasVisitor(Schema tableSchema) { + private CompareSchemasVisitor( + Schema tableSchema, boolean caseSensitive, boolean dropUnusedColumns) { this.tableSchema = tableSchema; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } - public static Result visit(Schema dataSchema, Schema tableSchema) { - return visit(dataSchema, tableSchema, true); - } - - public static Result visit(Schema dataSchema, Schema tableSchema, boolean caseSensitive) { + public static Result visit( + Schema dataSchema, Schema tableSchema, boolean caseSensitive, boolean dropUnusedColumns) { return visit( dataSchema, -1, - new CompareSchemasVisitor(tableSchema), + new CompareSchemasVisitor(tableSchema, caseSensitive, dropUnusedColumns), new PartnerIdByNameAccessors(tableSchema, caseSensitive)); } + @VisibleForTesting + @Deprecated + public static Result visit(Schema dataSchema, Schema tableSchema) { + return visit(dataSchema, tableSchema, true, false); + } + @Override public Result schema(Schema dataSchema, Integer tableSchemaId, Result downstream) { if (tableSchemaId == null) { @@ -70,6 +80,7 @@ public Result schema(Schema dataSchema, Integer tableSchemaId, Result downstream } @Override + @SuppressWarnings("CyclomaticComplexity") public Result struct(Types.StructType struct, Integer tableSchemaId, List fields) { if (tableSchemaId == null) { return Result.SCHEMA_UPDATE_NEEDED; @@ -88,10 +99,10 @@ public Result struct(Types.StructType struct, Integer tableSchemaId, List { private final Schema tableSchema; - private boolean caseSensitive = true; + private boolean caseSensitive; - PartnerIdByNameAccessors(Schema tableSchema) { + PartnerIdByNameAccessors(Schema tableSchema, boolean caseSensitive) { this.tableSchema = tableSchema; - } - - private PartnerIdByNameAccessors(Schema tableSchema, boolean caseSensitive) { - this(tableSchema); this.caseSensitive = caseSensitive; } @@ -207,8 +219,7 @@ public Integer fieldPartner(Integer tableSchemaFieldId, int fieldId, String name struct = tableSchema.findField(tableSchemaFieldId).type().asStructType(); } - Types.NestedField field = - caseSensitive ? struct.field(name) : struct.caseInsensitiveField(name); + Types.NestedField field = getFieldFromStruct(name, struct, caseSensitive); if (field != null) { return field.fieldId(); } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java index 33edefe71eb0..4f0b68573ff5 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java @@ -26,7 +26,7 @@ /** * The aggregated results of a single checkpoint which should be committed. Containing the - * serialized {@link DeltaManifests} file - which contains the commit data, and the jobId, + * serialized {@link DeltaManifests} files - which contains the commit data, and the jobId, * operatorId, checkpointId triplet to identify the specific commit. * *

{@link DynamicCommittableSerializer} is used to serialize {@link DynamicCommittable} between @@ -34,27 +34,27 @@ */ class DynamicCommittable implements Serializable { - private final WriteTarget key; - private final byte[] manifest; + private final TableKey key; + private final byte[][] manifests; private final String jobId; private final String operatorId; private final long checkpointId; DynamicCommittable( - WriteTarget key, byte[] manifest, String jobId, String operatorId, long checkpointId) { + TableKey key, byte[][] manifests, String jobId, String operatorId, long checkpointId) { this.key = key; - this.manifest = manifest; + this.manifests = manifests; this.jobId = jobId; this.operatorId = operatorId; this.checkpointId = checkpointId; } - WriteTarget key() { + TableKey key() { return key; } - byte[] manifest() { - return manifest; + byte[][] manifests() { + return manifests; } String jobId() { @@ -78,14 +78,14 @@ public boolean equals(Object o) { DynamicCommittable that = (DynamicCommittable) o; return checkpointId == that.checkpointId && Objects.equals(key, that.key) - && Objects.deepEquals(manifest, that.manifest) + && Arrays.deepEquals(manifests, that.manifests) && Objects.equals(jobId, that.jobId) && Objects.equals(operatorId, that.operatorId); } @Override public int hashCode() { - return Objects.hash(key, Arrays.hashCode(manifest), jobId, operatorId, checkpointId); + return Objects.hash(key, Arrays.deepHashCode(manifests), jobId, operatorId, checkpointId); } @Override @@ -97,8 +97,4 @@ public String toString() { .add("operatorId", operatorId) .toString(); } - - public WriteTarget writeTarget() { - return key; - } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java index 4aadcf1f3620..d599d29dba01 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java @@ -31,11 +31,12 @@ */ class DynamicCommittableSerializer implements SimpleVersionedSerializer { - private static final int VERSION = 1; + private static final int VERSION_1 = 1; + private static final int VERSION_2 = 2; @Override public int getVersion() { - return VERSION; + return VERSION_2; } @Override @@ -46,26 +47,60 @@ public byte[] serialize(DynamicCommittable committable) throws IOException { view.writeUTF(committable.jobId()); view.writeUTF(committable.operatorId()); view.writeLong(committable.checkpointId()); - view.writeInt(committable.manifest().length); - view.write(committable.manifest()); + + int numManifests = committable.manifests().length; + view.writeInt(numManifests); + for (int i = 0; i < numManifests; i++) { + byte[] manifest = committable.manifests()[i]; + view.writeInt(manifest.length); + view.write(manifest); + } + return out.toByteArray(); } @Override public DynamicCommittable deserialize(int version, byte[] serialized) throws IOException { - if (version == 1) { - DataInputDeserializer view = new DataInputDeserializer(serialized); - WriteTarget key = WriteTarget.deserializeFrom(view); - String jobId = view.readUTF(); - String operatorId = view.readUTF(); - long checkpointId = view.readLong(); - int manifestLen = view.readInt(); - byte[] manifestBuf; - manifestBuf = new byte[manifestLen]; - view.read(manifestBuf); - return new DynamicCommittable(key, manifestBuf, jobId, operatorId, checkpointId); + if (version == VERSION_1) { + return deserializeV1(serialized); + } else if (version == VERSION_2) { + return deserializeV2(serialized); } throw new IOException("Unrecognized version or corrupt state: " + version); } + + private DynamicCommittable deserializeV1(byte[] serialized) throws IOException { + DataInputDeserializer view = new DataInputDeserializer(serialized); + WriteTarget key = WriteTarget.deserializeFrom(view); + String jobId = view.readUTF(); + String operatorId = view.readUTF(); + long checkpointId = view.readLong(); + int manifestLen = view.readInt(); + byte[] manifestBuf = new byte[manifestLen]; + view.read(manifestBuf); + return new DynamicCommittable( + new TableKey(key.tableName(), key.branch()), + new byte[][] {manifestBuf}, + jobId, + operatorId, + checkpointId); + } + + private DynamicCommittable deserializeV2(byte[] serialized) throws IOException { + DataInputDeserializer view = new DataInputDeserializer(serialized); + TableKey key = TableKey.deserializeFrom(view); + String jobId = view.readUTF(); + String operatorId = view.readUTF(); + long checkpointId = view.readLong(); + + byte[][] manifestsBuf = new byte[view.readInt()][]; + for (int i = 0; i < manifestsBuf.length; i++) { + byte[] manifest = new byte[view.readInt()]; + view.read(manifest); + manifestsBuf[i] = manifest; + } + + return new DynamicCommittable(key, manifestsBuf, jobId, operatorId, checkpointId); + } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java index 1cddc64d6016..5e824773f4bf 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java @@ -19,15 +19,15 @@ package org.apache.iceberg.flink.sink.dynamic; import java.io.IOException; -import java.io.Serializable; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.NavigableMap; -import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import org.apache.flink.annotation.Internal; import org.apache.flink.api.connector.sink2.Committer; import org.apache.flink.core.io.SimpleVersionedSerialization; @@ -51,12 +51,10 @@ import org.apache.iceberg.flink.sink.FlinkManifestUtil; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; -import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.ContentFileUtil; -import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.util.SnapshotUtil; import org.apache.iceberg.util.ThreadPools; import org.slf4j.Logger; @@ -79,26 +77,15 @@ class DynamicCommitter implements Committer { private static final String MAX_COMMITTED_CHECKPOINT_ID = "flink.max-committed-checkpoint-id"; private static final Logger LOG = LoggerFactory.getLogger(DynamicCommitter.class); - private static final byte[] EMPTY_MANIFEST_DATA = new byte[0]; - private static final WriteResult EMPTY_WRITE_RESULT = - WriteResult.builder() - .addDataFiles(Lists.newArrayList()) - .addDeleteFiles(Lists.newArrayList()) - .build(); private static final long INITIAL_CHECKPOINT_ID = -1L; - @VisibleForTesting - static final String MAX_CONTINUOUS_EMPTY_COMMITS = "flink.max-continuous-empty-commits"; - private static final String FLINK_JOB_ID = "flink.job-id"; private static final String OPERATOR_ID = "flink.operator-id"; private final Map snapshotProperties; private final boolean replacePartitions; private final DynamicCommitterMetrics committerMetrics; private final Catalog catalog; - private final Map maxContinuousEmptyCommitsMap; - private final Map continuousEmptyCheckpointsMap; private final ExecutorService workerPool; DynamicCommitter( @@ -112,9 +99,6 @@ class DynamicCommitter implements Committer { this.replacePartitions = replacePartitions; this.committerMetrics = committerMetrics; this.catalog = catalog; - this.maxContinuousEmptyCommitsMap = Maps.newHashMap(); - this.continuousEmptyCheckpointsMap = Maps.newHashMap(); - this.workerPool = ThreadPools.newFixedThreadPool("iceberg-committer-pool-" + sinkId, workerPoolSize); } @@ -126,9 +110,15 @@ public void commit(Collection> commitRequests) return; } - // For every table and every checkpoint, we store the list of to-be-committed - // DynamicCommittable. - // There may be DynamicCommittable from previous checkpoints which have not been committed yet. + /* + Each (table, branch, checkpoint) triplet must have only one commit request. + There may be commit requests from previous checkpoints which have not been committed yet. + + We currently keep a List of commit requests per checkpoint instead of a single CommitRequest + to process the Flink state from previous releases, which had multiple commit requests created by the upstream + DynamicWriteResultAggregator. Iceberg 1.12 will remove this, and users should upgrade to the 1.11 release first + to migrate their state to a single commit request per checkpoint. + */ Map>>> commitRequestMap = Maps.newHashMap(); for (CommitRequest request : commitRequests) { @@ -151,12 +141,16 @@ public void commit(Collection> commitRequests) : List.of(); long maxCommittedCheckpointId = getMaxCommittedCheckpointId(ancestors, last.jobId(), last.operatorId()); + + NavigableMap>> skippedCommitRequests = + entry.getValue().headMap(maxCommittedCheckpointId, true); + LOG.debug( + "Skipping {} commit requests: {}", skippedCommitRequests.size(), skippedCommitRequests); // Mark the already committed FilesCommittable(s) as finished - entry - .getValue() - .headMap(maxCommittedCheckpointId, true) + skippedCommitRequests .values() .forEach(list -> list.forEach(CommitRequest::signalAlreadyCommitted)); + NavigableMap>> uncommitted = entry.getValue().tailMap(maxCommittedCheckpointId, false); if (!uncommitted.isEmpty()) { @@ -210,89 +204,45 @@ private void commitPendingRequests( NavigableMap> pendingResults = Maps.newTreeMap(); for (Map.Entry>> e : commitRequestMap.entrySet()) { for (CommitRequest committable : e.getValue()) { - if (Arrays.equals(EMPTY_MANIFEST_DATA, committable.getCommittable().manifest())) { - pendingResults - .computeIfAbsent(e.getKey(), unused -> Lists.newArrayList()) - .add(EMPTY_WRITE_RESULT); - } else { + for (byte[] manifest : committable.getCommittable().manifests()) { DeltaManifests deltaManifests = SimpleVersionedSerialization.readVersionAndDeSerialize( - DeltaManifestsSerializer.INSTANCE, committable.getCommittable().manifest()); - - WriteResult writeResult = - FlinkManifestUtil.readCompletedFiles(deltaManifests, table.io(), table.specs()); - if (TableUtil.formatVersion(table) > 2) { - for (DeleteFile deleteFile : writeResult.deleteFiles()) { - if (deleteFile.content() == FileContent.POSITION_DELETES) { - Preconditions.checkArgument( - ContentFileUtil.isDV(deleteFile), - "Can't add position delete file to the %s table. Concurrent table upgrade to V3 is not supported.", - table.name()); - } - } - } - + DeltaManifestsSerializer.INSTANCE, manifest); pendingResults .computeIfAbsent(e.getKey(), unused -> Lists.newArrayList()) - .add(writeResult); + .add(FlinkManifestUtil.readCompletedFiles(deltaManifests, table.io(), table.specs())); manifests.addAll(deltaManifests.manifests()); } } } - CommitSummary summary = new CommitSummary(); - summary.addAll(pendingResults); - commitPendingResult(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - if (committerMetrics != null) { - committerMetrics.updateCommitSummary(table.name(), summary); + if (TableUtil.formatVersion(table) > 2) { + Optional positionalDelete = + pendingResults.values().stream() + .flatMap(List::stream) + .flatMap(writeResult -> Arrays.stream(writeResult.deleteFiles())) + .filter(deleteFile -> deleteFile.content() == FileContent.POSITION_DELETES) + .filter(Predicate.not(ContentFileUtil::isDV)) + .findAny(); + Preconditions.checkArgument( + positionalDelete.isEmpty(), + "Can't add position delete file to the %s table. Concurrent table upgrade to V3 is not supported.", + table.name()); } - FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); - } - - private void commitPendingResult( - Table table, - String branch, - NavigableMap> pendingResults, - CommitSummary summary, - String newFlinkJobId, - String operatorId) { - long totalFiles = summary.dataFilesCount() + summary.deleteFilesCount(); - TableKey key = new TableKey(table.name(), branch); - int continuousEmptyCheckpoints = - continuousEmptyCheckpointsMap.computeIfAbsent(key, unused -> 0); - int maxContinuousEmptyCommits = - maxContinuousEmptyCommitsMap.computeIfAbsent( - key, - unused -> { - int result = - PropertyUtil.propertyAsInt(table.properties(), MAX_CONTINUOUS_EMPTY_COMMITS, 10); - Preconditions.checkArgument( - result > 0, MAX_CONTINUOUS_EMPTY_COMMITS + " must be positive"); - return result; - }); - continuousEmptyCheckpoints = totalFiles == 0 ? continuousEmptyCheckpoints + 1 : 0; - if (totalFiles != 0 || continuousEmptyCheckpoints % maxContinuousEmptyCommits == 0) { - if (replacePartitions) { - replacePartitions(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - } else { - commitDeltaTxn(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - } - - continuousEmptyCheckpoints = 0; + if (replacePartitions) { + replacePartitions(table, branch, pendingResults, newFlinkJobId, operatorId); } else { - long checkpointId = pendingResults.lastKey(); - LOG.info("Skip commit for checkpoint {} due to no data files or delete files.", checkpointId); + commitDeltaTxn(table, branch, pendingResults, newFlinkJobId, operatorId); } - continuousEmptyCheckpointsMap.put(key, continuousEmptyCheckpoints); + FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); } private void replacePartitions( Table table, String branch, NavigableMap> pendingResults, - CommitSummary summary, String newFlinkJobId, String operatorId) { // Iceberg tables are unsorted. So the order of the append data does not matter. @@ -305,6 +255,9 @@ private void replacePartitions( } } + CommitSummary summary = new CommitSummary(); + summary.addAll(pendingResults); + commitOperation( table, branch, @@ -320,7 +273,6 @@ private void commitDeltaTxn( Table table, String branch, NavigableMap> pendingResults, - CommitSummary summary, String newFlinkJobId, String operatorId) { for (Map.Entry> e : pendingResults.entrySet()) { @@ -340,6 +292,9 @@ private void commitDeltaTxn( Arrays.stream(result.deleteFiles()).forEach(rowDelta::addDeletes); } + CommitSummary summary = new CommitSummary(); + summary.addAll(writeResults); + // Every Flink checkpoint contains a set of independent changes which can be committed // together. While it is technically feasible to combine append-only data across checkpoints, // for the sake of simplicity, we do not implement this (premature) optimization. Multiple @@ -434,6 +389,7 @@ void commitOperation( durationMs); if (committerMetrics != null) { committerMetrics.commitDuration(table.name(), durationMs); + committerMetrics.updateCommitSummary(table.name(), summary); } } @@ -441,54 +397,4 @@ void commitOperation( public void close() throws IOException { workerPool.shutdown(); } - - private static class TableKey implements Serializable { - private String tableName; - private String branch; - - TableKey(String tableName, String branch) { - this.tableName = tableName; - this.branch = branch; - } - - TableKey(DynamicCommittable committable) { - this.tableName = committable.key().tableName(); - this.branch = committable.key().branch(); - } - - String tableName() { - return tableName; - } - - String branch() { - return branch; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - - if (other == null || getClass() != other.getClass()) { - return false; - } - - TableKey that = (TableKey) other; - return tableName.equals(that.tableName) && branch.equals(that.branch); - } - - @Override - public int hashCode() { - return Objects.hash(tableName, branch); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("tableName", tableName) - .add("branch", branch) - .toString(); - } - } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java index 73122101c214..e7cd2c16459f 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java @@ -44,15 +44,14 @@ import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.DataStreamSink; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; +import org.apache.flink.streaming.runtime.operators.sink.SinkWriterOperatorFactory; import org.apache.flink.table.data.RowData; import org.apache.flink.util.OutputTag; -import org.apache.iceberg.FileFormat; import org.apache.iceberg.Table; import org.apache.iceberg.flink.CatalogLoader; import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.sink.IcebergSink; -import org.apache.iceberg.flink.sink.SinkUtil; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -78,43 +77,40 @@ public class DynamicIcebergSink private final String uidPrefix; private final String sinkId; private final Map writeProperties; - private final transient FlinkWriteConf flinkWriteConf; - private final FileFormat dataFileFormat; - private final long targetDataFileSize; - private final boolean overwriteMode; - private final int workerPoolSize; + private final Configuration flinkConfig; private final int cacheMaximumSize; + // Set by the builder before sinkTo() — forward writer results to union into pre-commit topology + private final transient DataStream> forwardWriteResults; + DynamicIcebergSink( CatalogLoader catalogLoader, Map snapshotProperties, String uidPrefix, Map writeProperties, - FlinkWriteConf flinkWriteConf, - int cacheMaximumSize) { + Configuration flinkConfig, + int cacheMaximumSize, + DataStream> forwardWriteResults) { this.catalogLoader = catalogLoader; this.snapshotProperties = snapshotProperties; this.uidPrefix = uidPrefix; this.writeProperties = writeProperties; - this.flinkWriteConf = flinkWriteConf; - this.dataFileFormat = flinkWriteConf.dataFileFormat(); - this.targetDataFileSize = flinkWriteConf.targetDataFileSize(); - this.overwriteMode = flinkWriteConf.overwriteMode(); - this.workerPoolSize = flinkWriteConf.workerPoolSize(); + this.flinkConfig = flinkConfig; this.cacheMaximumSize = cacheMaximumSize; // We generate a random UUID every time when a sink is created. // This is used to separate files generated by different sinks writing the same table. // Also used to generate the aggregator operator name this.sinkId = UUID.randomUUID().toString(); + this.forwardWriteResults = forwardWriteResults; } + @SuppressWarnings("deprecation") @Override public SinkWriter createWriter(InitContext context) throws IOException { return new DynamicWriter( catalogLoader.loadCatalog(), - dataFileFormat, - targetDataFileSize, writeProperties, + flinkConfig, cacheMaximumSize, new DynamicWriterMetrics(context.metricGroup()), context.getTaskInfo().getIndexOfThisSubtask(), @@ -123,12 +119,13 @@ public SinkWriter createWriter(InitContext context) throw @Override public Committer createCommitter(CommitterInitContext context) { + FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeProperties, flinkConfig); DynamicCommitterMetrics metrics = new DynamicCommitterMetrics(context.metricGroup()); return new DynamicCommitter( catalogLoader.loadCatalog(), snapshotProperties, - overwriteMode, - workerPoolSize, + flinkWriteConf.overwriteMode(), + flinkWriteConf.workerPoolSize(), sinkId, metrics); } @@ -154,7 +151,11 @@ public DataStream> addPreCommitTopology( TypeInformation> typeInformation = CommittableMessageTypeInfo.of(this::getCommittableSerializer); - return writeResults + // Union forward writer results with the shuffle writer results + DataStream> allResults = + writeResults.union(forwardWriteResults); + + return allResults .keyBy( committable -> { if (committable instanceof CommittableSummary) { @@ -169,7 +170,7 @@ public DataStream> addPreCommitTopology( prefixIfNotNull(uidPrefix, sinkId + " Pre Commit"), typeInformation, new DynamicWriteResultAggregator(catalogLoader, cacheMaximumSize)) - .uid(prefixIfNotNull(uidPrefix, sinkId + "-pre-commit-topology")); + .uid(prefixIfNotNull(uidPrefix, "-pre-commit-topology")); } @Override @@ -177,6 +178,56 @@ public SimpleVersionedSerializer getWriteResultSerializer() return new DynamicWriteResultSerializer(); } + /** + * A lightweight Sink used with {@link SinkWriterOperatorFactory} for the forward write path. + * Implements {@link SupportsCommitter} so that {@code SinkWriterOperator} emits committables + * downstream. The committer is never called — committing is handled by the main sink. + */ + @VisibleForTesting + static class ForwardWriterSink + implements Sink, SupportsCommitter { + + private final CatalogLoader catalogLoader; + private final Map writeProperties; + private final Configuration flinkConfig; + private final int cacheMaximumSize; + + ForwardWriterSink( + CatalogLoader catalogLoader, + Map writeProperties, + Configuration flinkConfig, + int cacheMaximumSize) { + this.catalogLoader = catalogLoader; + this.writeProperties = writeProperties; + this.flinkConfig = flinkConfig; + this.cacheMaximumSize = cacheMaximumSize; + } + + @SuppressWarnings("deprecation") + @Override + public SinkWriter createWriter(InitContext context) throws IOException { + return new DynamicWriter( + catalogLoader.loadCatalog(), + writeProperties, + flinkConfig, + cacheMaximumSize, + new DynamicWriterMetrics(context.metricGroup()), + context.getSubtaskId(), + context.getAttemptNumber()); + } + + @Override + public Committer createCommitter(CommitterInitContext context) { + throw new UnsupportedOperationException( + "WriterSink is used only for writing; committing is handled by the main sink"); + } + + @Override + public SimpleVersionedSerializer getCommittableSerializer() { + return new DynamicWriteResultSerializer(); + } + } + public static class Builder { private DataStream input; private DynamicRecordGenerator generator; @@ -186,10 +237,6 @@ public static class Builder { private final Map snapshotSummary = Maps.newHashMap(); private ReadableConfig readableConfig = new Configuration(); private TableCreator tableCreator = TableCreator.DEFAULT; - private boolean immediateUpdate = false; - private int cacheMaximumSize = 100; - private long cacheRefreshMs = 1_000; - private int inputSchemasPerTableCacheMaximumSize = 10; Builder() {} @@ -310,19 +357,39 @@ public Builder toBranch(String branch) { } public Builder immediateTableUpdate(boolean newImmediateUpdate) { - this.immediateUpdate = newImmediateUpdate; + writeOptions.put( + FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.key(), + Boolean.toString(newImmediateUpdate)); + return this; + } + + /** + * Dropping columns is disabled by default to prevent issues with late or out-of-order data, as + * removed fields cannot be easily restored without data loss. + * + *

You can opt-in to allow dropping columns. Once a column has been dropped, it is + * technically still possible to write data to that column because Iceberg maintains all past + * table schemas. However, regular queries won't be able to reference the column. If the field + * was to re-appear as part of a new schema, an entirely new column would be added, which apart + * from the name, has nothing in common with the old column, i.e. queries for the new column + * will never return data of the old column. + */ + public Builder dropUnusedColumns(boolean newDropUnusedColumns) { + writeOptions.put( + FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key(), + Boolean.toString(newDropUnusedColumns)); return this; } /** Maximum size of the caches used in Dynamic Sink for table data and serializers. */ public Builder cacheMaxSize(int maxSize) { - this.cacheMaximumSize = maxSize; + writeOptions.put(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.key(), Integer.toString(maxSize)); return this; } /** Maximum interval for cache items renewals. */ public Builder cacheRefreshMs(long refreshMs) { - this.cacheRefreshMs = refreshMs; + writeOptions.put(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.key(), Long.toString(refreshMs)); return this; } @@ -332,7 +399,19 @@ public Builder cacheRefreshMs(long refreshMs) { * comparison results. */ public Builder inputSchemasPerTableCacheMaxSize(int inputSchemasPerTableCacheMaxSize) { - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaxSize; + writeOptions.put( + FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.key(), + Integer.toString(inputSchemasPerTableCacheMaxSize)); + return this; + } + + /** + * Set whether schema field name matching should be case-sensitive. The default is to match the + * field names case-sensitive. + */ + public Builder caseSensitive(boolean newCaseSensitive) { + writeOptions.put( + FlinkDynamicSinkOptions.CASE_SENSITIVE.key(), Boolean.toString(newCaseSensitive)); return this; } @@ -340,81 +419,134 @@ private String operatorName(String suffix) { return uidPrefix != null ? uidPrefix + "-" + suffix : suffix; } - private DynamicIcebergSink build() { + private DynamicIcebergSink build( + SingleOutputStreamOperator converted, + DynamicRecordInternalType sideOutputType) { Preconditions.checkArgument( generator != null, "Please use withGenerator() to convert the input DataStream."); Preconditions.checkNotNull(catalogLoader, "Catalog loader shouldn't be null"); - FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeOptions, readableConfig); - Map writeProperties = - SinkUtil.writeProperties(flinkWriteConf.dataFileFormat(), flinkWriteConf, null); - uidPrefix = Optional.ofNullable(uidPrefix).orElse(""); + Configuration flinkConfig = fromReadableConfig(); + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeOptions, flinkConfig); + + // Forward writer: chained with generator via forward edge, no data shuffle + ForwardWriterSink forwardWriterSink = + new ForwardWriterSink( + catalogLoader, writeOptions, flinkConfig, flinkDynamicSinkConf.cacheMaxSize()); + TypeInformation> writeResultTypeInfo = + CommittableMessageTypeInfo.of(DynamicWriteResultSerializer::new); - return instantiateSink(writeProperties, flinkWriteConf); + DataStream> forwardWriteResults = + converted + .getSideOutput( + new OutputTag<>(DynamicRecordProcessor.DYNAMIC_FORWARD_STREAM, sideOutputType)) + .transform( + operatorName("Forward-Writer"), + writeResultTypeInfo, + new SinkWriterOperatorFactory<>(forwardWriterSink)) + .setParallelism(converted.getParallelism()) + .uid(prefixIfNotNull(uidPrefix, "-forward-writer")); + + // Inject forward write results into sink — they'll be unioned in addPreCommitTopology + return instantiateSink(writeOptions, flinkConfig, forwardWriteResults); } @VisibleForTesting DynamicIcebergSink instantiateSink( - Map writeProperties, FlinkWriteConf flinkWriteConf) { + Map writeProperties, + Configuration flinkWriteConf, + DataStream> forwardWriteResults) { + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeProperties, flinkWriteConf); return new DynamicIcebergSink( catalogLoader, snapshotSummary, uidPrefix, writeProperties, flinkWriteConf, - cacheMaximumSize); + flinkDynamicSinkConf.cacheMaxSize(), + forwardWriteResults); } /** * Append the iceberg sink operators to write records to iceberg table. * + *

The topology splits records by distribution mode: + * + *

    + *
  • Forward records ({@code null} distributionMode) go through a forward edge to a chained + * writer, avoiding any data shuffle. + *
  • Shuffle records (non-null distributionMode) go through the standard Sink2 pipeline with + * hash/round-robin distribution. + *
+ * + * Both writers feed into a single shared pre-commit aggregator and committer, ensuring atomic + * commits across both paths. + * * @return {@link DataStreamSink} for sink. */ public DataStreamSink append() { + uidPrefix = Optional.ofNullable(uidPrefix).orElse(""); + + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeOptions, readableConfig); + Configuration flinkConfig = fromReadableConfig(); + DynamicRecordInternalType type = - new DynamicRecordInternalType(catalogLoader, false, cacheMaximumSize); - DynamicIcebergSink sink = build(); + new DynamicRecordInternalType(catalogLoader, false, flinkDynamicSinkConf.cacheMaxSize()); + DynamicRecordInternalType sideOutputType = + new DynamicRecordInternalType(catalogLoader, true, flinkDynamicSinkConf.cacheMaxSize()); + SingleOutputStreamOperator converted = input .process( new DynamicRecordProcessor<>( generator, catalogLoader, - immediateUpdate, - cacheMaximumSize, - cacheRefreshMs, - inputSchemasPerTableCacheMaximumSize, - tableCreator)) + tableCreator, + flinkDynamicSinkConf, + writeOptions, + flinkConfig)) + .setParallelism(input.getParallelism()) .uid(prefixIfNotNull(uidPrefix, "-generator")) .name(operatorName("generator")) .returns(type); - DataStreamSink rowDataDataStreamSink = + DynamicIcebergSink sink = build(converted, sideOutputType); + + // Shuffle path: table update side output + main output → sinkTo() + DataStream shuffleInput = converted .getSideOutput( new OutputTag<>( - DynamicRecordProcessor.DYNAMIC_TABLE_UPDATE_STREAM, - new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize))) + DynamicRecordProcessor.DYNAMIC_TABLE_UPDATE_STREAM, sideOutputType)) .keyBy((KeySelector) DynamicRecordInternal::tableName) .map( - new DynamicTableUpdateOperator( - catalogLoader, - cacheMaximumSize, - cacheRefreshMs, - inputSchemasPerTableCacheMaximumSize, - tableCreator)) + new DynamicTableUpdateOperator(catalogLoader, tableCreator, flinkDynamicSinkConf)) .uid(prefixIfNotNull(uidPrefix, "-updater")) .name(operatorName("Updater")) .returns(type) - .union(converted) - .sinkTo(sink) + .union(converted); + + DataStreamSink result = + shuffleInput + .sinkTo(sink) // Forward write results are implicitly injected here .uid(prefixIfNotNull(uidPrefix, "-sink")); - if (sink.flinkWriteConf.writeParallelism() != null) { - rowDataDataStreamSink.setParallelism(sink.flinkWriteConf.writeParallelism()); + + FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeOptions, readableConfig); + if (flinkWriteConf.writeParallelism() != null) { + result.setParallelism(flinkWriteConf.writeParallelism()); } - return rowDataDataStreamSink; + return result; + } + + private Configuration fromReadableConfig() { + return readableConfig instanceof Configuration + ? (Configuration) readableConfig + : Configuration.fromMap(readableConfig.toMap()); } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java index 9f445766083e..6507a575c2af 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java @@ -20,6 +20,7 @@ import java.util.Set; import javax.annotation.Nullable; +import org.apache.flink.annotation.Internal; import org.apache.flink.table.data.RowData; import org.apache.iceberg.DistributionMode; import org.apache.iceberg.PartitionSpec; @@ -34,20 +35,43 @@ public class DynamicRecord { private Schema schema; private RowData rowData; private PartitionSpec partitionSpec; - private DistributionMode distributionMode; + @Nullable private DistributionMode distributionMode; private int writeParallelism; private boolean upsertMode; @Nullable private Set equalityFields; + @Internal + DynamicRecord() {} + + /** + * Constructs a new DynamicRecord with forward (no shuffle) writes. + * + * @param tableIdentifier The target table identifier. + * @param branch The target table branch. + * @param schema The target table schema. + * @param rowData The data matching the provided schema. + * @param partitionSpec The target table {@link PartitionSpec}. + */ + public DynamicRecord( + TableIdentifier tableIdentifier, + String branch, + Schema schema, + RowData rowData, + PartitionSpec partitionSpec) { + this(tableIdentifier, branch, schema, rowData, partitionSpec, null, -1); + } + /** - * Constructs a new DynamicRecord. + * Constructs a new DynamicRecord. This record will be shuffled as specified by {@code + * distributionMode}. * * @param tableIdentifier The target table identifier. * @param branch The target table branch. * @param schema The target table schema. * @param rowData The data matching the provided schema. * @param partitionSpec The target table {@link PartitionSpec}. - * @param distributionMode The {@link DistributionMode}. + * @param distributionMode The {@link DistributionMode}. {@code null} indicates forward (no + * shuffle) writes. * @param writeParallelism The number of parallel writers. Can be set to any value {@literal > 0}, * but will always be automatically capped by the maximum write parallelism, which is the * parallelism of the sink. Set to Integer.MAX_VALUE for always using the maximum available diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java index 22b7742913e7..0d758ace1b19 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.Set; import org.apache.flink.annotation.Internal; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.common.typeutils.TypeSerializer; import org.apache.flink.api.common.typeutils.TypeSerializerSchemaCompatibility; import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot; @@ -34,6 +35,9 @@ import org.apache.iceberg.PartitionSpecParser; import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.flink.util.SerializerHelper; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @Internal @@ -43,18 +47,27 @@ class DynamicRecordInternalSerializer extends TypeSerializer duplicate() { return new DynamicRecordInternalSerializer( new TableSerializerCache(serializerCache.catalogLoader(), serializerCache.maximumSize()), - writeSchemaAndSpec); + writeSchemaAndSpec, + writeLongUTF); } @Override @@ -68,7 +81,12 @@ public void serialize(DynamicRecordInternal toSerialize, DataOutputView dataOutp dataOutputView.writeUTF(toSerialize.tableName()); dataOutputView.writeUTF(toSerialize.branch()); if (writeSchemaAndSpec) { - dataOutputView.writeUTF(SchemaParser.toJson(toSerialize.schema())); + if (writeLongUTF) { + SerializerHelper.writeLongUTF(dataOutputView, SchemaParser.toJson(toSerialize.schema())); + } else { + dataOutputView.writeUTF(SchemaParser.toJson(toSerialize.schema())); + } + dataOutputView.writeUTF(PartitionSpecParser.toJson(toSerialize.spec())); } else { dataOutputView.writeInt(toSerialize.schema().schemaId()); @@ -108,7 +126,12 @@ public DynamicRecordInternal deserialize(DataInputView dataInputView) throws IOE final PartitionSpec spec; final RowDataSerializer rowDataSerializer; if (writeSchemaAndSpec) { - schema = SchemaParser.fromJson(dataInputView.readUTF()); + if (writeLongUTF) { + schema = SchemaParser.fromJson(SerializerHelper.readLongUTF(dataInputView)); + } else { + schema = SchemaParser.fromJson(dataInputView.readUTF()); + } + spec = PartitionSpecParser.fromJson(schema, dataInputView.readUTF()); rowDataSerializer = serializerCache.serializer(tableName, schema, spec); } else { @@ -152,7 +175,12 @@ public DynamicRecordInternal deserialize(DynamicRecordInternal reuse, DataInputV final PartitionSpec spec; final RowDataSerializer rowDataSerializer; if (writeSchemaAndSpec) { - schema = SchemaParser.fromJson(dataInputView.readUTF()); + if (writeLongUTF) { + schema = SchemaParser.fromJson(SerializerHelper.readLongUTF(dataInputView)); + } else { + schema = SchemaParser.fromJson(dataInputView.readUTF()); + } + spec = PartitionSpecParser.fromJson(schema, dataInputView.readUTF()); reuse.setSchema(schema); reuse.setSpec(spec); @@ -245,25 +273,32 @@ public int getLength() { @Override public TypeSerializerSnapshot snapshotConfiguration() { - return new DynamicRecordInternalTypeSerializerSnapshot(writeSchemaAndSpec); + return new DynamicRecordInternalTypeSerializerSnapshot(writeSchemaAndSpec, serializerCache); } public static class DynamicRecordInternalTypeSerializerSnapshot implements TypeSerializerSnapshot { + private static final int MOST_RECENT_VERSION = 1; + private boolean writeSchemaAndSpec; + private int version; + private TableSerializerCache serializerCache; - // Zero args constructor is required to instantiate this class on restore + // Zero args constructor is required to instantiate this class on restore via readSnapshot(..) @SuppressWarnings({"unused", "checkstyle:RedundantModifier"}) public DynamicRecordInternalTypeSerializerSnapshot() {} - DynamicRecordInternalTypeSerializerSnapshot(boolean writeSchemaAndSpec) { + DynamicRecordInternalTypeSerializerSnapshot( + boolean writeSchemaAndSpec, TableSerializerCache serializerCache) { this.writeSchemaAndSpec = writeSchemaAndSpec; + this.serializerCache = serializerCache; + this.version = MOST_RECENT_VERSION; } @Override public int getCurrentVersion() { - return 0; + return version; } @Override @@ -274,22 +309,62 @@ public void writeSnapshot(DataOutputView out) throws IOException { @Override public void readSnapshot(int readVersion, DataInputView in, ClassLoader userCodeClassLoader) throws IOException { + this.version = readVersion; this.writeSchemaAndSpec = in.readBoolean(); } @Override public TypeSerializerSchemaCompatibility resolveSchemaCompatibility( TypeSerializerSnapshot oldSerializerSnapshot) { - return TypeSerializerSchemaCompatibility.compatibleAsIs(); + if (oldSerializerSnapshot.getCurrentVersion() == getCurrentVersion()) { + return TypeSerializerSchemaCompatibility.compatibleAsIs(); + } + + // Old TypeSerializerSnapshots do not contain the serializer cache, but the newest one does. + // This will also ensure that we always use the up-to-date cache alongside with its catalog + // configuration. + Preconditions.checkNotNull(serializerCache, "serializerCache should not be null"); + try { + DynMethods.builder("initializeSerializerCache") + .hiddenImpl( + DynamicRecordInternalTypeSerializerSnapshot.class, TableSerializerCache.class) + .build() + .invoke(oldSerializerSnapshot, serializerCache); + } catch (Exception e) { + throw new RuntimeException( + "Failed to initialize serializerCache for reading data with old serializer", e); + } + + // This will first read data with the old serializer, then switch to the most recent one. + return TypeSerializerSchemaCompatibility.compatibleAfterMigration(); } @Override public TypeSerializer restoreSerializer() { - // Note: We pass in a null serializer cache which would create issues if we tried to use this - // restored serializer, but since we are using {@code - // TypeSerializerSchemaCompatibility.compatibleAsIs()} above, this serializer will never be - // used. A new one will be created via {@code DynamicRecordInternalType}. - return new DynamicRecordInternalSerializer(null, writeSchemaAndSpec); + if (getCurrentVersion() < MOST_RECENT_VERSION) { + // If this serializer is not the most recent one, we need to read old data with the correct + // parameters. + return new DynamicRecordInternalSerializer(serializerCache, writeSchemaAndSpec, false); + } + + // In all other cases, we just use the newest serializer. + return new DynamicRecordInternalSerializer(serializerCache, writeSchemaAndSpec, true); + } + + /** + * We need to lazily initialize the cache from the up-to-date serializer which has the current + * CatalogLoader available. + * + *

This method must not be removed! + */ + @SuppressWarnings("unused") + private void initializeSerializerCache(TableSerializerCache cache) { + this.serializerCache = cache; } } + + @VisibleForTesting + TableSerializerCache getSerializerCache() { + return serializerCache; + } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java index bc3a25468d84..c752b8e9b8d9 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java @@ -18,10 +18,12 @@ */ package org.apache.iceberg.flink.sink.dynamic; +import java.util.Map; import org.apache.flink.annotation.Internal; import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.common.functions.OpenContext; import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.configuration.Configuration; import org.apache.flink.streaming.api.functions.ProcessFunction; import org.apache.flink.table.data.RowData; import org.apache.flink.util.Collector; @@ -30,6 +32,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.flink.CatalogLoader; +import org.apache.iceberg.flink.FlinkWriteConf; @Internal class DynamicRecordProcessor extends ProcessFunction @@ -37,36 +40,47 @@ class DynamicRecordProcessor extends ProcessFunction generator; private final CatalogLoader catalogLoader; + private final Map writeProperties; + private final Configuration flinkConfig; private final boolean immediateUpdate; + private final boolean dropUnusedColumns; private final int cacheMaximumSize; private final long cacheRefreshMs; private final int inputSchemasPerTableCacheMaximumSize; private final TableCreator tableCreator; + private final boolean caseSensitive; private transient TableMetadataCache tableCache; private transient HashKeyGenerator hashKeyGenerator; private transient TableUpdater updater; private transient OutputTag updateStream; + private transient OutputTag forwardStream; private transient Collector collector; + private transient DynamicRecordWithConfig dynamicRecordWithConfig; private transient Context context; DynamicRecordProcessor( DynamicRecordGenerator generator, CatalogLoader catalogLoader, - boolean immediateUpdate, - int cacheMaximumSize, - long cacheRefreshMs, - int inputSchemasPerTableCacheMaximumSize, - TableCreator tableCreator) { + TableCreator tableCreator, + FlinkDynamicSinkConf sinkConfig, + Map writeProperties, + Configuration flinkConfig) { this.generator = generator; this.catalogLoader = catalogLoader; - this.immediateUpdate = immediateUpdate; - this.cacheMaximumSize = cacheMaximumSize; - this.cacheRefreshMs = cacheRefreshMs; - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; + this.flinkConfig = flinkConfig; + this.writeProperties = writeProperties; + this.immediateUpdate = sinkConfig.immediateTableUpdate(); + this.cacheMaximumSize = sinkConfig.cacheMaxSize(); + this.cacheRefreshMs = sinkConfig.cacheRefreshMs(); + this.inputSchemasPerTableCacheMaximumSize = sinkConfig.inputSchemasPerTableCacheMaxSize(); this.tableCreator = tableCreator; + this.caseSensitive = sinkConfig.caseSensitive(); + this.dropUnusedColumns = sinkConfig.dropUnusedColumns(); } @Override @@ -75,19 +89,31 @@ public void open(OpenContext openContext) throws Exception { Catalog catalog = catalogLoader.loadCatalog(); this.tableCache = new TableMetadataCache( - catalog, cacheMaximumSize, cacheRefreshMs, inputSchemasPerTableCacheMaximumSize); + catalog, + cacheMaximumSize, + cacheRefreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns); this.hashKeyGenerator = new HashKeyGenerator( cacheMaximumSize, getRuntimeContext().getTaskInfo().getMaxNumberOfParallelSubtasks()); - if (immediateUpdate) { - updater = new TableUpdater(tableCache, catalog); - } else { + // Always create updater — needed for forced immediate updates on forward records + this.updater = new TableUpdater(tableCache, catalog, caseSensitive, dropUnusedColumns); + // Always create forward stream tag for forward (distributionMode == null) records + this.forwardStream = + new OutputTag<>( + DYNAMIC_FORWARD_STREAM, + new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize)) {}; + if (!immediateUpdate) { updateStream = new OutputTag<>( DYNAMIC_TABLE_UPDATE_STREAM, new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize)) {}; } + this.dynamicRecordWithConfig = + new DynamicRecordWithConfig(new FlinkWriteConf(writeProperties, flinkConfig)); generator.open(openContext); } @@ -100,7 +126,10 @@ public void processElement(T element, Context ctx, Collector newData = updater.update( data.tableIdentifier(), data.branch(), data.schema(), data.spec(), tableCreator); emit( - collector, data, newData.f0.resolvedTableSchema(), newData.f0.recordConverter(), - newData.f1); + newData.f1, + isForward); } else { + // Shuffled records with immediateUpdate=false go to the update side output int writerKey = hashKeyGenerator.generateKey( data, @@ -148,33 +182,38 @@ public void collect(DynamicRecord data) { } } else { emit( - collector, data, foundSchema.resolvedTableSchema(), foundSchema.recordConverter(), - foundSpec); + foundSpec, + isForward); } } private void emit( - Collector out, DynamicRecord data, Schema schema, DataConverter recordConverter, - PartitionSpec spec) { + PartitionSpec spec, + boolean forward) { RowData rowData = (RowData) recordConverter.convert(data.rowData()); - int writerKey = hashKeyGenerator.generateKey(data, schema, spec, rowData); - String tableName = data.tableIdentifier().toString(); - out.collect( + // writerKey is unused in the forward path. + int writerKey = forward ? -1 : hashKeyGenerator.generateKey(data, schema, spec, rowData); + DynamicRecordInternal record = new DynamicRecordInternal( - tableName, + data.tableIdentifier().toString(), data.branch(), schema, rowData, spec, writerKey, data.upsertMode(), - DynamicSinkUtil.getEqualityFieldIds(data.equalityFields(), schema))); + DynamicSinkUtil.getEqualityFieldIds(data.equalityFields(), schema)); + if (forward) { + context.output(forwardStream, record); + } else { + collector.collect(record); + } } @Override diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java new file mode 100644 index 000000000000..32716c3e4ac7 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.util.Set; +import org.apache.flink.table.data.RowData; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; + +class DynamicRecordWithConfig extends DynamicRecord { + private final String defaultBranch; + private final Integer defaultWriteParallelism; + + private DynamicRecord wrapped; + + DynamicRecordWithConfig(FlinkWriteConf flinkWriteConf) { + this.defaultBranch = flinkWriteConf.branch(); + this.defaultWriteParallelism = flinkWriteConf.writeParallelism(); + } + + DynamicRecordWithConfig wrap(DynamicRecord newWrapped) { + this.wrapped = newWrapped; + return this; + } + + @Override + public String branch() { + return wrapped.branch() != null ? wrapped.branch() : defaultBranch; + } + + @Override + public DistributionMode distributionMode() { + return wrapped.distributionMode(); + } + + @Override + public int writeParallelism() { + int originalParallelism = wrapped.writeParallelism(); + if (originalParallelism > 0 || defaultWriteParallelism == null) { + return originalParallelism; + } + + return defaultWriteParallelism; + } + + @Override + public TableIdentifier tableIdentifier() { + return wrapped.tableIdentifier(); + } + + @Override + public Schema schema() { + return wrapped.schema(); + } + + @Override + public PartitionSpec spec() { + return wrapped.spec(); + } + + @Override + public RowData rowData() { + return wrapped.rowData(); + } + + @Override + public boolean upsertMode() { + return wrapped.upsertMode(); + } + + @Override + public Set equalityFields() { + return wrapped.equalityFields(); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java new file mode 100644 index 000000000000..684da9bb54c7 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; + +/** + * Abstract base class for SQL-based dynamic record generators. Users will extend this class to + * create a DynamicRecord from RowData. + */ +public abstract class DynamicTableRecordGenerator implements DynamicRecordGenerator { + + private final RowType rowType; + + public DynamicTableRecordGenerator(RowType rowType) { + this.rowType = rowType; + } + + protected RowType rowType() { + return rowType; + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java index 586239b54bca..93c268ff86ad 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java @@ -38,24 +38,25 @@ class DynamicTableUpdateOperator extends RichMapFunction { private final CatalogLoader catalogLoader; + private final boolean dropUnusedColumns; private final int cacheMaximumSize; private final long cacheRefreshMs; private final int inputSchemasPerTableCacheMaximumSize; private final TableCreator tableCreator; + private final boolean caseSensitive; private transient TableUpdater updater; DynamicTableUpdateOperator( - CatalogLoader catalogLoader, - int cacheMaximumSize, - long cacheRefreshMs, - int inputSchemasPerTableCacheMaximumSize, - TableCreator tableCreator) { + CatalogLoader catalogLoader, TableCreator tableCreator, FlinkDynamicSinkConf configuration) { this.catalogLoader = catalogLoader; - this.cacheMaximumSize = cacheMaximumSize; - this.cacheRefreshMs = cacheRefreshMs; - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; this.tableCreator = tableCreator; + + this.cacheMaximumSize = configuration.cacheMaxSize(); + this.cacheRefreshMs = configuration.cacheRefreshMs(); + this.inputSchemasPerTableCacheMaximumSize = configuration.inputSchemasPerTableCacheMaxSize(); + this.caseSensitive = configuration.caseSensitive(); + this.dropUnusedColumns = configuration.dropUnusedColumns(); } @Override @@ -65,8 +66,15 @@ public void open(OpenContext openContext) throws Exception { this.updater = new TableUpdater( new TableMetadataCache( - catalog, cacheMaximumSize, cacheRefreshMs, inputSchemasPerTableCacheMaximumSize), - catalog); + catalog, + cacheMaximumSize, + cacheRefreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns), + catalog, + caseSensitive, + dropUnusedColumns); } @Override diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java index 85806f932ad5..d8d0ed6b573e 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java @@ -19,22 +19,37 @@ package org.apache.iceberg.flink.sink.dynamic; import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; class DynamicWriteResult { - - private final WriteTarget key; + private final TableKey key; + private final int specId; private final WriteResult writeResult; - DynamicWriteResult(WriteTarget key, WriteResult writeResult) { + DynamicWriteResult(TableKey key, int specId, WriteResult writeResult) { this.key = key; + this.specId = specId; this.writeResult = writeResult; } - WriteTarget key() { + TableKey key() { return key; } + public int specId() { + return specId; + } + WriteResult writeResult() { return writeResult; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("key", key) + .add("specId", specId) + .add("writeResult", writeResult) + .toString(); + } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java index 927491fa89ea..47d239200b06 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java @@ -42,6 +42,7 @@ import org.apache.iceberg.flink.sink.FlinkManifestUtil; import org.apache.iceberg.flink.sink.ManifestOutputFileFactory; import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.slf4j.Logger; @@ -57,11 +58,10 @@ class DynamicWriteResultAggregator implements OneInputStreamOperator< CommittableMessage, CommittableMessage> { private static final Logger LOG = LoggerFactory.getLogger(DynamicWriteResultAggregator.class); - private static final byte[] EMPTY_MANIFEST_DATA = new byte[0]; private final CatalogLoader catalogLoader; private final int cacheMaximumSize; - private transient Map> results; + private transient Map>> resultsByTableKeyAndSpec; private transient Map> specs; private transient Map> outputFileFactoriesAndFormatVersions; @@ -82,7 +82,7 @@ public void open() throws Exception { this.operatorId = getOperatorID().toString(); this.subTaskId = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask(); this.attemptId = getRuntimeContext().getTaskInfo().getAttemptNumber(); - this.results = Maps.newHashMap(); + this.resultsByTableKeyAndSpec = Maps.newHashMap(); this.specs = new LRUCache<>(cacheMaximumSize); this.outputFileFactoriesAndFormatVersions = new LRUCache<>(cacheMaximumSize); this.catalog = catalogLoader.loadCatalog(); @@ -96,14 +96,15 @@ public void finish() throws IOException { @Override public void prepareSnapshotPreBarrier(long checkpointId) throws IOException { Collection> committables = - Sets.newHashSetWithExpectedSize(results.size()); + Sets.newHashSetWithExpectedSize(resultsByTableKeyAndSpec.size()); int count = 0; - for (Map.Entry> entries : results.entrySet()) { + for (Map.Entry>> entries : + resultsByTableKeyAndSpec.entrySet()) { committables.add( new CommittableWithLineage<>( new DynamicCommittable( entries.getKey(), - writeToManifest(entries.getKey(), entries.getValue(), checkpointId), + writeToManifests(entries.getKey().tableName(), entries.getValue(), checkpointId), getContainingTask().getEnvironment().getJobID().toString(), getRuntimeContext().getOperatorUniqueID(), checkpointId), @@ -121,32 +122,42 @@ public void prepareSnapshotPreBarrier(long checkpointId) throws IOException { new StreamRecord<>( new CommittableWithLineage<>(c.getCommittable(), checkpointId, subTaskId)))); LOG.info("Emitted {} commit message to downstream committer operator", count); - results.clear(); + resultsByTableKeyAndSpec.clear(); } /** - * Write all the completed data files to a newly created manifest file and return the manifest's + * Write all the completed data files to a newly created manifest files and return the manifests' * avro serialized bytes. */ @VisibleForTesting - byte[] writeToManifest( - WriteTarget key, Collection writeResults, long checkpointId) + byte[][] writeToManifests( + String tableName, Map> writeResultsBySpec, long checkpointId) throws IOException { - if (writeResults.isEmpty()) { - return EMPTY_MANIFEST_DATA; + byte[][] deltaManifestsBySpec = new byte[writeResultsBySpec.size()][]; + int idx = 0; + for (Map.Entry> entry : writeResultsBySpec.entrySet()) { + deltaManifestsBySpec[idx] = + writeToManifest(tableName, entry.getKey(), entry.getValue(), checkpointId); + idx++; } + return deltaManifestsBySpec; + } + + private byte[] writeToManifest( + String tableName, int specId, Collection writeResults, long checkpointId) + throws IOException { WriteResult.Builder builder = WriteResult.builder(); - writeResults.forEach(w -> builder.add(w.writeResult())); + writeResults.forEach(builder::add); WriteResult result = builder.build(); Tuple2 outputFileFactoryAndVersion = - outputFileFactoryAndFormatVersion(key.tableName()); + outputFileFactoryAndFormatVersion(tableName); DeltaManifests deltaManifests = FlinkManifestUtil.writeCompletedFiles( result, () -> outputFileFactoryAndVersion.f0.create(checkpointId), - spec(key.tableName(), key.specId()), + spec(tableName, specId), outputFileFactoryAndVersion.f1); return SimpleVersionedSerialization.writeVersionAndSerialize( @@ -160,8 +171,16 @@ public void processElement(StreamRecord> if (element.isRecord() && element.getValue() instanceof CommittableWithLineage) { DynamicWriteResult result = ((CommittableWithLineage) element.getValue()).getCommittable(); - WriteTarget key = result.key(); - results.computeIfAbsent(key, unused -> Sets.newHashSet()).add(result); + Collection resultsPerTableKeyAndSpec = + resultsByTableKeyAndSpec + .computeIfAbsent(result.key(), unused -> Maps.newHashMap()) + .computeIfAbsent(result.specId(), unused -> Lists.newArrayList()); + resultsPerTableKeyAndSpec.add(result.writeResult()); + LOG.debug( + "Added {}, specId={}, totalResults={}", + result, + result.specId(), + resultsPerTableKeyAndSpec.size()); } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java index cf5f423fd7ff..5153ec6a49ee 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java @@ -41,6 +41,7 @@ public byte[] serialize(DynamicWriteResult writeResult) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); writeResult.key().serializeTo(view); + view.writeInt(writeResult.specId()); byte[] result = WRITE_RESULT_SERIALIZER.serialize(writeResult.writeResult()); view.write(result); return out.toByteArray(); @@ -50,11 +51,12 @@ public byte[] serialize(DynamicWriteResult writeResult) throws IOException { public DynamicWriteResult deserialize(int version, byte[] serialized) throws IOException { if (version == 1) { DataInputDeserializer view = new DataInputDeserializer(serialized); - WriteTarget key = WriteTarget.deserializeFrom(view); + TableKey key = TableKey.deserializeFrom(view); + int specId = view.readInt(); byte[] resultBuf = new byte[view.available()]; view.read(resultBuf); WriteResult writeResult = WRITE_RESULT_SERIALIZER.deserialize(version, resultBuf); - return new DynamicWriteResult(key, writeResult); + return new DynamicWriteResult(key, specId, writeResult); } throw new IOException("Unrecognized version or corrupt state: " + version); diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java index c2a303285801..fcd0d082704a 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java @@ -28,14 +28,16 @@ import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.connector.sink2.CommittingSinkWriter; import org.apache.flink.api.connector.sink2.SinkWriter; +import org.apache.flink.configuration.Configuration; import org.apache.flink.table.data.RowData; -import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionField; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.sink.SinkUtil; import org.apache.iceberg.io.TaskWriter; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; @@ -56,27 +58,24 @@ class DynamicWriter implements CommittingSinkWriter taskWriterFactories; private final Map> writers; + private final Configuration flinkConfig; + private final Map commonWriteProperties; private final DynamicWriterMetrics metrics; private final int subTaskId; private final int attemptId; private final Catalog catalog; - private final FileFormat dataFileFormat; - private final long targetDataFileSize; - private final Map commonWriteProperties; DynamicWriter( Catalog catalog, - FileFormat dataFileFormat, - long targetDataFileSize, Map commonWriteProperties, + Configuration flinkConfig, int cacheMaximumSize, DynamicWriterMetrics metrics, int subTaskId, int attemptId) { this.catalog = catalog; - this.dataFileFormat = dataFileFormat; - this.targetDataFileSize = targetDataFileSize; this.commonWriteProperties = commonWriteProperties; + this.flinkConfig = flinkConfig; this.metrics = metrics; this.subTaskId = subTaskId; this.attemptId = attemptId; @@ -106,10 +105,6 @@ public void write(DynamicRecordInternal element, Context context) Table table = catalog.loadTable(TableIdentifier.parse(factoryKey.tableName())); - Map tableWriteProperties = - Maps.newHashMap(table.properties()); - tableWriteProperties.putAll(commonWriteProperties); - Set equalityFieldIds = getEqualityFields(table, element.equalityFields()); if (element.upsertMode()) { @@ -128,12 +123,18 @@ public void write(DynamicRecordInternal element, Context context) } } + FlinkWriteConf flinkWriteConf = + new FlinkWriteConf(table, commonWriteProperties, flinkConfig); + Map tableWriteProperties = + SinkUtil.writeProperties( + flinkWriteConf.dataFileFormat(), flinkWriteConf, table); + LOG.debug("Creating new writer factory for table '{}'", table.name()); return new RowDataTaskWriterFactory( () -> table, FlinkSchemaUtil.convert(element.schema()), - targetDataFileSize, - dataFileFormat, + flinkWriteConf.targetDataFileSize(), + flinkWriteConf.dataFileFormat(), tableWriteProperties, Lists.newArrayList(equalityFieldIds), element.upsertMode(), @@ -145,6 +146,7 @@ public void write(DynamicRecordInternal element, Context context) return taskWriterFactory.create(); }) .write(element.rowData()); + metrics.mainMetricsGroup().getNumRecordsSendCounter().inc(); } @Override @@ -164,8 +166,6 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("subtaskId", subTaskId) .add("attemptId", attemptId) - .add("dataFileFormat", dataFileFormat) - .add("targetDataFileSize", targetDataFileSize) .add("writeProperties", commonWriteProperties) .toString(); } @@ -188,7 +188,11 @@ public Collection prepareCommit() throws IOException { writeResult.dataFiles().length, writeResult.deleteFiles().length); - result.add(new DynamicWriteResult(writeTarget, writeResult)); + result.add( + new DynamicWriteResult( + new TableKey(writeTarget.tableName(), writeTarget.branch()), + writeTarget.specId(), + writeResult)); } writers.clear(); diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java index 2e1f82df9d2d..d50a41512a15 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java @@ -18,24 +18,37 @@ */ package org.apache.iceberg.flink.sink.dynamic; +import java.util.Arrays; import java.util.Map; -import org.apache.flink.metrics.MetricGroup; +import java.util.function.ToLongFunction; +import org.apache.flink.metrics.groups.SinkWriterMetricGroup; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; import org.apache.iceberg.flink.sink.IcebergStreamWriterMetrics; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ScanTaskUtil; class DynamicWriterMetrics { private final Map metrics; - private final MetricGroup mainMetricsGroup; + private final SinkWriterMetricGroup mainMetricsGroup; - DynamicWriterMetrics(MetricGroup mainMetricsGroup) { + DynamicWriterMetrics(SinkWriterMetricGroup mainMetricsGroup) { this.mainMetricsGroup = mainMetricsGroup; this.metrics = Maps.newHashMap(); } + SinkWriterMetricGroup mainMetricsGroup() { + return this.mainMetricsGroup; + } + public void updateFlushResult(String fullTableName, WriteResult result) { writerMetrics(fullTableName).updateFlushResult(result); + + long bytesOutTotal = sum(result.dataFiles()) + sum(result.deleteFiles()); + this.mainMetricsGroup.getNumBytesSendCounter().inc(bytesOutTotal); } public void flushDuration(String fullTableName, long flushDurationMs) { @@ -46,4 +59,16 @@ IcebergStreamWriterMetrics writerMetrics(String fullTableName) { return metrics.computeIfAbsent( fullTableName, tableName -> new IcebergStreamWriterMetrics(mainMetricsGroup, tableName)); } + + private static long sum(DataFile[] files) { + return sum(files, DataFile::fileSizeInBytes); + } + + private static long sum(DeleteFile[] files) { + return sum(files, ScanTaskUtil::contentSizeInBytes); + } + + private static > long sum(T[] files, ToLongFunction sizeExtractor) { + return Arrays.stream(files).mapToLong(sizeExtractor).sum(); + } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java index ee0549997178..d9747d201e3d 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java @@ -21,10 +21,13 @@ import java.util.List; import org.apache.iceberg.Schema; import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.schema.SchemaWithPartnerVisitor; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Visitor class that accumulates the set of changes needed to evolve an existing schema into the @@ -36,30 +39,42 @@ *

  • Adding new columns *
  • Widening the type of existing columsn *
  • Reordering columns + *
  • Dropping columns (when dropUnusedColumns is enabled) * * * We don't support: * *
      - *
    • Dropping columns *
    • Renaming columns *
    * - * The reason is that dropping columns would create issues with late / out of order data. Once we - * drop fields, we wouldn't be able to easily add them back later without losing the associated - * data. Renaming columns is not supported because we compare schemas by name, which doesn't allow - * for renaming without additional hints. + * By default, any columns present in the table but absent from the input schema are marked as + * optional to prevent issues caused by late or out-of-order data. If dropUnusedColumns is enabled, + * these columns are removed instead to ensure a strict one-to-one schema alignment. */ public class EvolveSchemaVisitor extends SchemaWithPartnerVisitor { + private static final Logger LOG = LoggerFactory.getLogger(EvolveSchemaVisitor.class); + private final TableIdentifier identifier; private final UpdateSchema api; private final Schema existingSchema; private final Schema targetSchema; - - private EvolveSchemaVisitor(UpdateSchema api, Schema existingSchema, Schema targetSchema) { - this.api = api; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; + + private EvolveSchemaVisitor( + TableIdentifier identifier, + UpdateSchema api, + Schema existingSchema, + Schema targetSchema, + boolean caseSensitive, + boolean dropUnusedColumns) { + this.identifier = identifier; + this.api = api.caseSensitive(caseSensitive); this.existingSchema = existingSchema; this.targetSchema = targetSchema; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } /** @@ -70,13 +85,22 @@ private EvolveSchemaVisitor(UpdateSchema api, Schema existingSchema, Schema targ * @param api an UpdateSchema for adding changes * @param existingSchema an existing schema * @param targetSchema a new schema to compare with the existing + * @param caseSensitive whether field name matching should be case-sensitive + * @param dropUnusedColumns whether to drop columns not present in target schema */ - public static void visit(UpdateSchema api, Schema existingSchema, Schema targetSchema) { + public static void visit( + TableIdentifier identifier, + UpdateSchema api, + Schema existingSchema, + Schema targetSchema, + boolean caseSensitive, + boolean dropUnusedColumns) { visit( targetSchema, -1, - new EvolveSchemaVisitor(api, existingSchema, targetSchema), - new CompareSchemasVisitor.PartnerIdByNameAccessors(existingSchema)); + new EvolveSchemaVisitor( + identifier, api, existingSchema, targetSchema, caseSensitive, dropUnusedColumns), + new CompareSchemasVisitor.PartnerIdByNameAccessors(existingSchema, caseSensitive)); } @Override @@ -89,25 +113,36 @@ public Boolean struct(Types.StructType struct, Integer partnerId, List Types.StructType partnerStruct = findFieldType(partnerId).asStructType(); String after = null; for (Types.NestedField targetField : struct.fields()) { - Types.NestedField nestedField = partnerStruct.field(targetField.name()); + Types.NestedField nestedField = + CompareSchemasVisitor.getFieldFromStruct( + targetField.name(), partnerStruct, caseSensitive); final String columnName; if (nestedField != null) { updateColumn(nestedField, targetField); columnName = this.existingSchema.findColumnName(nestedField.fieldId()); } else { addColumn(partnerId, targetField); - columnName = this.targetSchema.findColumnName(targetField.fieldId()); + columnName = targetSchema.findColumnName(targetField.fieldId()); } setPosition(columnName, after); after = columnName; } - // Ensure that unused fields are made optional for (Types.NestedField existingField : partnerStruct.fields()) { - if (struct.field(existingField.name()) == null) { - if (existingField.isRequired()) { - this.api.makeColumnOptional(this.existingSchema.findColumnName(existingField.fieldId())); + Types.NestedField targetField = + caseSensitive + ? struct.field(existingField.name()) + : struct.caseInsensitiveField(existingField.name()); + if (targetField == null) { + String columnName = this.existingSchema.findColumnName(existingField.fieldId()); + if (dropUnusedColumns) { + LOG.debug("{}: Dropping column: {}", identifier.name(), columnName); + this.api.deleteColumn(columnName); + } else { + if (existingField.isRequired()) { + this.api.makeColumnOptional(columnName); + } } } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java new file mode 100644 index 000000000000..75b169c4b533 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.util.Map; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.flink.FlinkConfParser; + +/** + * A class for common Dynamic Iceberg sink configs for Flink writes. + * + *

    If a config is set at multiple levels, the following order of precedence is used (top to + * bottom): + * + *

      + *
    1. Write options + *
    2. Flink ReadableConfig + *
    3. Default values + *
    + * + * The most specific value is set in write options and takes precedence over all other configs. If + * no write option is provided, this class checks the flink configuration for any overrides. If no + * applicable value is found in the write options, this class uses the default values. + */ +class FlinkDynamicSinkConf { + + private final FlinkConfParser confParser; + + FlinkDynamicSinkConf(Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(writeOptions, readableConfig); + } + + int cacheMaxSize() { + return confParser + .intConf() + .option(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.key()) + .flinkConfig(FlinkDynamicSinkOptions.CACHE_MAX_SIZE) + .defaultValue(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.defaultValue()) + .parse(); + } + + boolean immediateTableUpdate() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.key()) + .flinkConfig(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE) + .defaultValue(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.defaultValue()) + .parse(); + } + + boolean dropUnusedColumns() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key()) + .flinkConfig(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS) + .defaultValue(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.defaultValue()) + .parse(); + } + + long cacheRefreshMs() { + return confParser + .longConf() + .option(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.key()) + .flinkConfig(FlinkDynamicSinkOptions.CACHE_REFRESH_MS) + .defaultValue(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.defaultValue()) + .parse(); + } + + int inputSchemasPerTableCacheMaxSize() { + return confParser + .intConf() + .option(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.key()) + .flinkConfig(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE) + .defaultValue(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.defaultValue()) + .parse(); + } + + boolean caseSensitive() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.CASE_SENSITIVE.key()) + .flinkConfig(FlinkDynamicSinkOptions.CASE_SENSITIVE) + .defaultValue(FlinkDynamicSinkOptions.CASE_SENSITIVE.defaultValue()) + .parse(); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java new file mode 100644 index 000000000000..7a4f038219d9 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; + +@Experimental +public class FlinkDynamicSinkOptions { + + private FlinkDynamicSinkOptions() {} + + public static final ConfigOption CACHE_MAX_SIZE = + ConfigOptions.key("dynamic-sink.cache-max-size") + .intType() + .defaultValue(100) + .withDescription( + "Maximum size of the caches used in Dynamic Sink for table data and serializers."); + + public static final ConfigOption IMMEDIATE_TABLE_UPDATE = + ConfigOptions.key("dynamic-sink.immediate-table-update") + .booleanType() + .defaultValue(false) + .withDescription( + "Controls whether table schema and partition updates should be applied immediately in Dynamic Sink."); + + public static final ConfigOption DROP_UNUSED_COLUMNS = + ConfigOptions.key("dynamic-sink.drop-unused-columns") + .booleanType() + .defaultValue(false) + .withDescription( + "Allows dropping unused columns during schema evolution in Dynamic Sink."); + + public static final ConfigOption CACHE_REFRESH_MS = + ConfigOptions.key("dynamic-sink.cache-refresh-ms") + .longType() + .defaultValue(1_000L) + .withDescription( + "Cache refresh interval for dynamic table metadata in Dynamic Sink in milliseconds."); + + public static final ConfigOption INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE = + ConfigOptions.key("dynamic-sink.input-schemas-per-table-cache-max-size") + .intType() + .defaultValue(10) + .withDescription( + "Maximum input schema objects to cache per each table in Dynamic Sink for performance."); + + public static final ConfigOption CASE_SENSITIVE = + ConfigOptions.key("dynamic-sink.case-sensitive") + .booleanType() + .defaultValue(true) + .withDescription( + "Controls whether schema field name matching should be case-sensitive in Dynamic Sink."); +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java index 1c611c46b90c..61a850212bf4 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java @@ -40,6 +40,7 @@ import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,7 +87,9 @@ int generateKey( tableSpec != null ? tableSpec.specId() : null, dynamicRecord.schema(), dynamicRecord.spec(), - dynamicRecord.equalityFields()); + dynamicRecord.equalityFields(), + dynamicRecord.distributionMode(), + Math.min(dynamicRecord.writeParallelism(), maxWriteParallelism)); KeySelector keySelector = keySelectorCache.computeIfAbsent( cacheKey, @@ -95,8 +98,7 @@ int generateKey( tableIdent, MoreObjects.firstNonNull(tableSchema, dynamicRecord.schema()), MoreObjects.firstNonNull(tableSpec, dynamicRecord.spec()), - MoreObjects.firstNonNull( - dynamicRecord.distributionMode(), DistributionMode.NONE), + dynamicRecord.distributionMode(), MoreObjects.firstNonNull( dynamicRecord.equalityFields(), Collections.emptySet()), Math.min(dynamicRecord.writeParallelism(), maxWriteParallelism))); @@ -151,8 +153,9 @@ private KeySelector getKeySelector( tableName, schema, equalityFields, writeParallelism, maxWriteParallelism); } else { for (PartitionField partitionField : spec.fields()) { + Types.NestedField sourceField = schema.findField(partitionField.sourceId()); Preconditions.checkState( - equalityFields.contains(partitionField.name()), + sourceField != null && equalityFields.contains(sourceField.name()), "%s: In 'hash' distribution mode with equality fields set, partition field '%s' " + "should be included in equality fields: '%s'", tableName, @@ -320,6 +323,8 @@ static class SelectorKey { private final Schema schema; private final PartitionSpec spec; private final Set equalityFields; + private final DistributionMode distributionMode; + private final int writeParallelism; SelectorKey( String tableName, @@ -328,7 +333,9 @@ static class SelectorKey { @Nullable Integer tableSpecId, Schema schema, PartitionSpec spec, - Set equalityFields) { + Set equalityFields, + DistributionMode distributionMode, + int writeParallelism) { this.tableName = tableName; this.branch = branch; this.schemaId = tableSchemaId; @@ -336,6 +343,8 @@ static class SelectorKey { this.schema = tableSchemaId == null ? schema : null; this.spec = tableSpecId == null ? spec : null; this.equalityFields = equalityFields; + this.distributionMode = distributionMode; + this.writeParallelism = writeParallelism; } @Override @@ -355,12 +364,23 @@ public boolean equals(Object other) { && Objects.equals(specId, that.specId) && Objects.equals(schema, that.schema) && Objects.equals(spec, that.spec) - && Objects.equals(equalityFields, that.equalityFields); + && Objects.equals(equalityFields, that.equalityFields) + && distributionMode == that.distributionMode + && writeParallelism == that.writeParallelism; } @Override public int hashCode() { - return Objects.hash(tableName, branch, schemaId, specId, schema, spec, equalityFields); + return Objects.hash( + tableName, + branch, + schemaId, + specId, + schema, + spec, + equalityFields, + distributionMode, + writeParallelism); } @Override @@ -373,6 +393,8 @@ public String toString() { .add("schema", schema) .add("spec", spec) .add("equalityFields", equalityFields) + .add("distributionMode", distributionMode) + .add("writeParallelism", writeParallelism) .toString(); } } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java new file mode 100644 index 000000000000..08b755fe14a8 --- /dev/null +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.io.IOException; +import java.util.Objects; +import org.apache.flink.core.memory.DataInputView; +import org.apache.flink.core.memory.DataOutputView; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +class TableKey { + private final String tableName; + private final String branch; + + TableKey(String tableName, String branch) { + this.tableName = tableName; + this.branch = branch; + } + + TableKey(DynamicCommittable committable) { + this.tableName = committable.key().tableName(); + this.branch = committable.key().branch(); + } + + String tableName() { + return tableName; + } + + String branch() { + return branch; + } + + void serializeTo(DataOutputView view) throws IOException { + view.writeUTF(tableName); + view.writeUTF(branch); + } + + static TableKey deserializeFrom(DataInputView view) throws IOException { + return new TableKey(view.readUTF(), view.readUTF()); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other == null || getClass() != other.getClass()) { + return false; + } + + TableKey that = (TableKey) other; + return tableName.equals(that.tableName) && branch.equals(that.branch); + } + + @Override + public int hashCode() { + return Objects.hash(tableName, branch); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("tableName", tableName) + .add("branch", branch) + .toString(); + } +} diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java index 2c08a3486e7c..fdefc01402ac 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java @@ -29,6 +29,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.flink.FlinkSchemaUtil; import org.slf4j.Logger; @@ -54,10 +55,24 @@ class TableMetadataCache { private final Clock cacheRefreshClock; private final int inputSchemasPerTableCacheMaximumSize; private final Map tableCache; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; TableMetadataCache( - Catalog catalog, int maximumSize, long refreshMs, int inputSchemasPerTableCacheMaximumSize) { - this(catalog, maximumSize, refreshMs, inputSchemasPerTableCacheMaximumSize, Clock.systemUTC()); + Catalog catalog, + int maximumSize, + long refreshMs, + int inputSchemasPerTableCacheMaximumSize, + boolean caseSensitive, + boolean dropUnusedColumns) { + this( + catalog, + maximumSize, + refreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns, + Clock.systemUTC()); } @VisibleForTesting @@ -66,19 +81,23 @@ class TableMetadataCache { int maximumSize, long refreshMs, int inputSchemasPerTableCacheMaximumSize, + boolean caseSensitive, + boolean dropUnusedColumns, Clock cacheRefreshClock) { this.catalog = catalog; this.refreshMs = refreshMs; - this.cacheRefreshClock = cacheRefreshClock; this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; this.tableCache = new LRUCache<>(maximumSize); + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; + this.cacheRefreshClock = cacheRefreshClock; } Tuple2 exists(TableIdentifier identifier) { CacheItem cached = tableCache.get(identifier); if (cached != null && Boolean.TRUE.equals(cached.tableExists)) { return EXISTS; - } else if (needsRefresh(cached, true)) { + } else if (needsRefresh(identifier, cached, true)) { return refreshTable(identifier); } else { return NOT_EXISTS; @@ -115,7 +134,7 @@ private String branch(TableIdentifier identifier, String branch, boolean allowRe return branch; } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return branch(identifier, branch, false); } else { @@ -139,7 +158,8 @@ private ResolvedSchemaInfo schema( for (Map.Entry tableSchema : cached.tableSchemas.entrySet()) { CompareSchemasVisitor.Result result = - CompareSchemasVisitor.visit(input, tableSchema.getValue(), true); + CompareSchemasVisitor.visit( + input, tableSchema.getValue(), caseSensitive, dropUnusedColumns); if (result == CompareSchemasVisitor.Result.SAME) { ResolvedSchemaInfo newResult = new ResolvedSchemaInfo( @@ -155,7 +175,7 @@ private ResolvedSchemaInfo schema( } } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return schema(identifier, input, false); } else if (compatible != null) { @@ -185,7 +205,7 @@ private PartitionSpec spec(TableIdentifier identifier, PartitionSpec spec, boole } } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return spec(identifier, spec, false); } else { @@ -198,18 +218,32 @@ private Tuple2 refreshTable(TableIdentifier identifier) { Table table = catalog.loadTable(identifier); update(identifier, table); return EXISTS; - } catch (NoSuchTableException e) { - LOG.debug("Table doesn't exist {}", identifier, e); + } catch (NoSuchTableException | NoSuchNamespaceException e) { + LOG.debug("Table or namespace doesn't exist {}", identifier, e); tableCache.put( identifier, new CacheItem(cacheRefreshClock.millis(), false, null, null, null, 1)); return Tuple2.of(false, e); } } - private boolean needsRefresh(CacheItem cacheItem, boolean allowRefresh) { - return allowRefresh - && (cacheItem == null - || cacheRefreshClock.millis() - cacheItem.createdTimestampMillis > refreshMs); + private boolean needsRefresh( + TableIdentifier identifier, CacheItem cacheItem, boolean allowRefresh) { + if (!allowRefresh) { + return false; + } + + if (cacheItem == null) { + return true; + } + + long nowMillis = cacheRefreshClock.millis(); + long timeElapsedMillis = nowMillis - cacheItem.createdTimestampMillis; + if (timeElapsedMillis > refreshMs) { + LOG.info("Refreshing table metadata for {} after {} millis", identifier, timeElapsedMillis); + return true; + } + + return false; } public void invalidate(TableIdentifier identifier) { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java index cadfe345980c..b0bdad8ed1e1 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java @@ -43,10 +43,15 @@ class TableUpdater { private static final Logger LOG = LoggerFactory.getLogger(TableUpdater.class); private final TableMetadataCache cache; private final Catalog catalog; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; - TableUpdater(TableMetadataCache cache, Catalog catalog) { + TableUpdater( + TableMetadataCache cache, Catalog catalog, boolean caseSensitive, boolean dropUnusedColumns) { this.cache = cache; this.catalog = catalog; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } /** @@ -124,7 +129,8 @@ private TableMetadataCache.ResolvedSchemaInfo findOrCreateSchema( } else { Table table = catalog.loadTable(identifier); Schema tableSchema = table.schema(); - CompareSchemasVisitor.Result result = CompareSchemasVisitor.visit(schema, tableSchema, true); + CompareSchemasVisitor.Result result = + CompareSchemasVisitor.visit(schema, tableSchema, caseSensitive, dropUnusedColumns); switch (result) { case SAME: cache.update(identifier, table); @@ -141,7 +147,8 @@ private TableMetadataCache.ResolvedSchemaInfo findOrCreateSchema( LOG.info( "Triggering schema update for table {} {} to {}", identifier, tableSchema, schema); UpdateSchema updateApi = table.updateSchema(); - EvolveSchemaVisitor.visit(updateApi, tableSchema, schema); + EvolveSchemaVisitor.visit( + identifier, updateApi, tableSchema, schema, caseSensitive, dropUnusedColumns); try { updateApi.commit(); diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java index a01b82539c5a..211f28c6518a 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java @@ -23,7 +23,7 @@ import java.util.Objects; import java.util.Set; import org.apache.flink.core.memory.DataInputView; -import org.apache.flink.core.memory.DataOutputView; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -44,7 +44,7 @@ class WriteTarget implements Serializable { boolean upsertMode, Set equalityFields) { this.tableName = tableName; - this.branch = branch != null ? branch : "main"; + this.branch = branch != null ? branch : SnapshotRef.MAIN_BRANCH; this.schemaId = schemaId; this.specId = specId; this.upsertMode = upsertMode; @@ -75,18 +75,6 @@ Set equalityFields() { return equalityFields; } - void serializeTo(DataOutputView view) throws IOException { - view.writeUTF(tableName); - view.writeUTF(branch); - view.writeInt(schemaId); - view.writeInt(specId); - view.writeBoolean(upsertMode); - view.writeInt(equalityFields.size()); - for (Integer equalityField : equalityFields) { - view.writeInt(equalityField); - } - } - static WriteTarget deserializeFrom(DataInputView view) throws IOException { return new WriteTarget( view.readUTF(), diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java index 6f5bb6722771..ec099fbfe80a 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java @@ -159,6 +159,7 @@ public void serialize(SortKey record, DataOutputView target) throws IOException case LONG: case TIME: case TIMESTAMP: + case TIMESTAMP_NANO: target.writeLong(record.get(i, Long.class)); break; case FLOAT: @@ -237,6 +238,7 @@ public SortKey deserialize(SortKey reuse, DataInputView source) throws IOExcepti case LONG: case TIME: case TIMESTAMP: + case TIMESTAMP_NANO: reuse.set(i, source.readLong()); break; case FLOAT: diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java index 035682be8296..27d34e1e56e4 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java @@ -292,7 +292,10 @@ public static class Builder { private RowDataConverter converter; private ReadableConfig flinkConfig = new Configuration(); private final ScanContext.Builder contextBuilder = ScanContext.builder(); + + @SuppressWarnings("deprecation") private TableSchema projectedTableSchema; + private ResolvedSchema projectedFlinkSchema; private Boolean exposeLocality; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java index e2d131dc3b3d..c65a42348985 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java @@ -120,6 +120,7 @@ public void applyProjection(int[][] projectFields) { } } + @SuppressWarnings("deprecation") private DataStream createDataStream(StreamExecutionEnvironment execEnv) { return FlinkSource.forRowData() .env(execEnv) diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java index b8fb1ba32edf..ee4aaf4a3da1 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java @@ -24,10 +24,8 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; import org.apache.iceberg.StructLike; -import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.encryption.InputFilesDecryptor; import org.apache.iceberg.expressions.Expression; @@ -35,19 +33,14 @@ import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.flink.FlinkSourceFilter; import org.apache.iceberg.flink.RowDataWrapper; -import org.apache.iceberg.flink.data.FlinkOrcReader; -import org.apache.iceberg.flink.data.FlinkParquetReaders; -import org.apache.iceberg.flink.data.FlinkPlannedAvroReader; import org.apache.iceberg.flink.data.RowDataProjection; import org.apache.iceberg.flink.data.RowDataUtil; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.mapping.NameMappingParser; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.PartitionUtil; @Internal @@ -73,8 +66,7 @@ public RowDataFileScanTaskReader( if (filters != null && !filters.isEmpty()) { Expression combinedExpression = filters.stream().reduce(Expressions.alwaysTrue(), Expressions::and); - this.rowFilter = - new FlinkSourceFilter(this.projectedSchema, combinedExpression, this.caseSensitive); + this.rowFilter = new FlinkSourceFilter(projectedSchema, combinedExpression, caseSensitive); } else { this.rowFilter = null; } @@ -112,23 +104,23 @@ private CloseableIterable newIterable( if (task.isDataTask()) { throw new UnsupportedOperationException("Cannot read data task."); } else { - switch (task.file().format()) { - case PARQUET: - iter = newParquetIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - case AVRO: - iter = newAvroIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - case ORC: - iter = newOrcIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - default: - throw new UnsupportedOperationException( - "Cannot read unknown format: " + task.file().format()); + ReadBuilder builder = + FormatModelRegistry.readBuilder( + task.file().format(), RowData.class, inputFilesDecryptor.getInputFile(task)); + + if (nameMapping != null) { + builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); } + + iter = + builder + .project(schema) + .idToConstant(idToConstant) + .split(task.start(), task.length()) + .caseSensitive(caseSensitive) + .filter(task.residual()) + .reuseContainers() + .build(); } if (rowFilter != null) { @@ -137,72 +129,6 @@ private CloseableIterable newIterable( return iter; } - private CloseableIterable newAvroIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Avro.ReadBuilder builder = - Avro.read(inputFilesDecryptor.getInputFile(task)) - .reuseContainers() - .project(schema) - .split(task.start(), task.length()) - .createReaderFunc(readSchema -> FlinkPlannedAvroReader.create(schema, idToConstant)); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - - private CloseableIterable newParquetIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Parquet.ReadBuilder builder = - Parquet.read(inputFilesDecryptor.getInputFile(task)) - .split(task.start(), task.length()) - .project(schema) - .createReaderFunc( - fileSchema -> FlinkParquetReaders.buildReader(schema, fileSchema, idToConstant)) - .filter(task.residual()) - .caseSensitive(caseSensitive) - .reuseContainers(); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - - private CloseableIterable newOrcIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Schema readSchemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - schema, Sets.union(idToConstant.keySet(), MetadataColumns.metadataFieldIds())); - - ORC.ReadBuilder builder = - ORC.read(inputFilesDecryptor.getInputFile(task)) - .project(readSchemaWithoutConstantAndMetadataFields) - .split(task.start(), task.length()) - .createReaderFunc( - readOrcSchema -> new FlinkOrcReader(schema, readOrcSchema, idToConstant)) - .filter(task.residual()) - .caseSensitive(caseSensitive); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - private static class FlinkDeleteFilter extends DeleteFilter { private final RowType requiredRowType; private final RowDataWrapper asStructLike; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataRewriter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataRewriter.java index 391633924264..518f59390ab6 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataRewriter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataRewriter.java @@ -126,8 +126,8 @@ public RewriteMap( @Override public void open(Configuration parameters) { - this.subTaskId = getRuntimeContext().getIndexOfThisSubtask(); - this.attemptId = getRuntimeContext().getAttemptNumber(); + this.subTaskId = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask(); + this.attemptId = getRuntimeContext().getTaskInfo().getAttemptNumber(); // Initialize the task writer factory. this.taskWriterFactory.initialize(subTaskId, attemptId); } diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java index 8ef1f1fbb833..d74b8b9d620f 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java @@ -23,8 +23,6 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.annotation.Internal; -import org.apache.flink.formats.avro.RowDataToAvroConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.LogicalType; @@ -32,6 +30,8 @@ import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.formats.avro.RowDataToAvroConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; /** * This is not serializable because Avro {@link Schema} is not actually serializable, even though it diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java index bac7c05bdfef..1c6644238c3d 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java @@ -136,23 +136,26 @@ void validate() { if (startingStrategy == StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_ID) { Preconditions.checkArgument( startSnapshotId != null, - "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: null"); + "Invalid starting snapshot id for %s strategy: null", + startingStrategy); Preconditions.checkArgument( startSnapshotTimestamp == null, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot timestamp for %s strategy: not null", + startingStrategy); } if (startingStrategy == StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP) { Preconditions.checkArgument( startSnapshotTimestamp != null, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_TIMESTAMP strategy: null"); + "Invalid starting snapshot timestamp for %s strategy: null", + startingStrategy); Preconditions.checkArgument( startSnapshotId == null, - "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot id for %s strategy: not null", + startingStrategy); } Preconditions.checkArgument( - tag == null, - String.format("Cannot scan table using ref %s configured for streaming reader", tag)); + tag == null, "Cannot scan table using ref %s configured for streaming reader", tag); Preconditions.checkArgument( snapshotId == null, "Cannot set snapshot-id option for streaming reader"); Preconditions.checkArgument( diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java index 39f615aeacc5..1090d92ee016 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java @@ -56,6 +56,7 @@ *

    The splits to be read are forwarded to the downstream {@link StreamingReaderOperator} which * can have parallelism greater than one. */ +@SuppressWarnings("deprecation") public class StreamingMonitorFunction extends RichSourceFunction implements CheckpointedFunction { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java index ee6f7b63988d..2e02b334f074 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java @@ -67,6 +67,7 @@ public class StreamingReaderOperator extends AbstractStreamOperator private final MailboxExecutor executor; private FlinkInputFormat format; + @SuppressWarnings("deprecation") private transient SourceFunction.SourceContext sourceContext; private transient ListState inputSplitsState; @@ -89,6 +90,7 @@ private StreamingReaderOperator( Preconditions.checkNotNull(mailboxExecutor, "The mailboxExecutor should not be null."); } + @SuppressWarnings("deprecation") @Override public void initializeState(StateInitializationContext context) throws Exception { super.initializeState(context); @@ -106,7 +108,7 @@ public void initializeState(StateInitializationContext context) throws Exception // Recover splits state from flink state backend if possible. splits = Lists.newLinkedList(); if (context.isRestored()) { - int subtaskIdx = getRuntimeContext().getIndexOfThisSubtask(); + int subtaskIdx = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask(); LOG.info("Restoring state for the {} (taskIdx: {}).", getClass().getSimpleName(), subtaskIdx); for (FlinkInputSplit split : inputSplitsState.get()) { @@ -210,6 +212,7 @@ private enum SplitState { RUNNING } + @SuppressWarnings("deprecation") private static class OperatorFactory extends AbstractStreamOperatorFactory implements YieldingOperatorFactory, OneInputStreamOperatorFactory { diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java index b158b0871a53..cfef780a4daa 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java @@ -21,8 +21,6 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.api.common.typeinfo.TypeInformation; -import org.apache.flink.formats.avro.RowDataToAvroConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.formats.avro.typeutils.GenericRecordAvroTypeInfo; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; @@ -31,6 +29,8 @@ import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.formats.avro.RowDataToAvroConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; public class AvroGenericRecordConverter implements RowDataConverter { private final Schema avroSchema; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java index 4bb6f0a98c4c..34de689c6c5d 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java @@ -53,13 +53,24 @@ public ColumnStatsWatermarkExtractor( Types.NestedField field = schema.findField(eventTimeFieldName); TypeID typeID = field.type().typeId(); Preconditions.checkArgument( - typeID.equals(TypeID.LONG) || typeID.equals(TypeID.TIMESTAMP), - "Found %s, expected a LONG or TIMESTAMP column for watermark generation.", + typeID.equals(TypeID.LONG) + || typeID.equals(TypeID.TIMESTAMP) + || typeID.equals(TypeID.TIMESTAMP_NANO), + "Found %s, expected a LONG, TIMESTAMP, or TIMESTAMP_NANO column for watermark generation.", typeID); this.eventTimeFieldId = field.fieldId(); this.eventTimeFieldName = eventTimeFieldName; - // Use the timeUnit only for Long columns. - this.timeUnit = typeID.equals(TypeID.LONG) ? timeUnit : TimeUnit.MICROSECONDS; + // Use the timeUnit only for Long columns; timestamp columns store fixed-precision longs. + switch (typeID) { + case LONG: + this.timeUnit = timeUnit; + break; + case TIMESTAMP_NANO: + this.timeUnit = TimeUnit.NANOSECONDS; + break; + default: + this.timeUnit = TimeUnit.MICROSECONDS; + } } @VisibleForTesting diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java index 02ef57d344b1..3af9957875e8 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java @@ -34,7 +34,7 @@ class WatermarkExtractorRecordEmitter implements SerializableRecordEmitter private static final Logger LOG = LoggerFactory.getLogger(WatermarkExtractorRecordEmitter.class); private final SplitWatermarkExtractor timeExtractor; private String lastSplitId = null; - private long watermark; + private long watermark = Long.MIN_VALUE; WatermarkExtractorRecordEmitter(SplitWatermarkExtractor timeExtractor) { this.timeExtractor = timeExtractor; @@ -44,7 +44,10 @@ class WatermarkExtractorRecordEmitter implements SerializableRecordEmitter public void emitRecord( RecordAndPosition element, SourceOutput output, IcebergSourceSplit split) { if (!split.splitId().equals(lastSplitId)) { - long newWatermark = timeExtractor.extractWatermark(split); + long extracted = timeExtractor.extractWatermark(split); + // Subtract 1 because watermark W means all records with eventTime <= W have arrived; + // records in this split have eventTime == extracted, so watermark must be extracted - 1. + long newWatermark = extracted > Long.MIN_VALUE ? extracted - 1 : Long.MIN_VALUE; if (newWatermark < watermark) { LOG.info( "Received a new split with lower watermark. Previous watermark = {}, current watermark = {}, previous split = {}, current split = {}", diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java index b6d6f60ef673..0cc1c633f906 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java @@ -33,6 +33,7 @@ import org.apache.iceberg.CombinedScanTask; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.ScanTaskParser; +import org.apache.iceberg.flink.util.SerializerHelper; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java similarity index 92% rename from flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java rename to flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java index 841969666ee5..3a161ea2631f 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java +++ b/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java @@ -16,20 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.flink.source.split; +package org.apache.iceberg.flink.util; import java.io.IOException; import java.io.Serializable; import java.io.UTFDataFormatException; +import org.apache.flink.annotation.Internal; import org.apache.flink.core.memory.DataInputDeserializer; +import org.apache.flink.core.memory.DataInputView; import org.apache.flink.core.memory.DataOutputSerializer; +import org.apache.flink.core.memory.DataOutputView; /** * Helper class to serialize and deserialize strings longer than 65K. The inspiration is mostly * taken from the class org.apache.flink.core.memory.DataInputSerializer.readUTF and * org.apache.flink.core.memory.DataOutputSerializer.writeUTF. */ -class SerializerHelper implements Serializable { +@Internal +public class SerializerHelper implements Serializable { private SerializerHelper() {} @@ -47,7 +51,7 @@ private SerializerHelper() {} * @param out the output stream to write the string to. * @param str the string value to be written. */ - public static void writeLongUTF(DataOutputSerializer out, String str) throws IOException { + public static void writeLongUTF(DataOutputView out, String str) throws IOException { int strlen = str.length(); long utflen = 0; int ch; @@ -85,7 +89,7 @@ public static void writeLongUTF(DataOutputSerializer out, String str) throws IOE * @return the string value read from the input stream. * @throws IOException if an I/O error occurs when reading from the input stream. */ - public static String readLongUTF(DataInputDeserializer in) throws IOException { + public static String readLongUTF(DataInputView in) throws IOException { int utflen = in.readInt(); byte[] bytearr = new byte[utflen]; char[] chararr = new char[utflen]; @@ -168,8 +172,7 @@ private static int getUTFBytesSize(int ch) { } } - private static void writeUTFBytes(DataOutputSerializer out, String str, int utflen) - throws IOException { + private static void writeUTFBytes(DataOutputView out, String str, int utflen) throws IOException { int strlen = str.length(); int ch; diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java index e2cd411d7069..795c4fa5a766 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java @@ -75,6 +75,11 @@ public static class Primitives implements DataGenerator { OffsetDateTime.of(2022, 1, 10, 0, 0, 0, 0, ZoneOffset.UTC); private static final LocalDateTime JAVA_LOCAL_DATE_TIME_20220110 = LocalDateTime.of(2022, 1, 10, 0, 0, 0); + private static final OffsetDateTime JAVA_OFFSET_DATE_TIME_MAX_NANO = + OffsetDateTime.of(2262, 4, 11, 23, 47, 16, 854_775_807, ZoneOffset.UTC); + private static final LocalDateTime JAVA_LOCAL_DATE_TIME_MAX_NANO = + LocalDateTime.of(2262, 4, 11, 23, 47, 16, 854_775_807); + private static final long ICEBERG_MAX_NANOS_EPOCH = 9223372036854775807L; private static final BigDecimal BIG_DECIMAL_NEGATIVE = new BigDecimal("-1.50"); private static final byte[] FIXED_BYTES = "012345689012345".getBytes(StandardCharsets.UTF_8); @@ -96,7 +101,11 @@ public static class Primitives implements DataGenerator { Types.NestedField.required(12, "uuid_field", Types.UUIDType.get()), Types.NestedField.required(13, "binary_field", Types.BinaryType.get()), Types.NestedField.required(14, "decimal_field", Types.DecimalType.of(9, 2)), - Types.NestedField.required(15, "fixed_field", Types.FixedType.ofLength(16))); + Types.NestedField.required(15, "fixed_field", Types.FixedType.ofLength(16)), + Types.NestedField.required( + 16, "ts_ns_with_zone_field", Types.TimestampNanoType.withZone()), + Types.NestedField.required( + 17, "ts_ns_without_zone_field", Types.TimestampNanoType.withoutZone())); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -171,6 +180,8 @@ public GenericRecord generateIcebergGenericRecord() { genericRecord.setField("time_field", JAVA_LOCAL_TIME_HOUR8); genericRecord.setField("ts_with_zone_field", JAVA_OFFSET_DATE_TIME_20220110); genericRecord.setField("ts_without_zone_field", JAVA_LOCAL_DATE_TIME_20220110); + genericRecord.setField("ts_ns_with_zone_field", JAVA_OFFSET_DATE_TIME_MAX_NANO); + genericRecord.setField("ts_ns_without_zone_field", JAVA_LOCAL_DATE_TIME_MAX_NANO); byte[] uuidBytes = new byte[16]; for (int i = 0; i < 16; ++i) { @@ -220,7 +231,11 @@ public GenericRowData generateFlinkRowData() { uuidBytes, binaryBytes, DecimalData.fromBigDecimal(BIG_DECIMAL_NEGATIVE, 9, 2), - FIXED_BYTES); + FIXED_BYTES, + TimestampData.fromEpochMillis( + ICEBERG_MAX_NANOS_EPOCH / 1_000_000, (int) (ICEBERG_MAX_NANOS_EPOCH % 1_000_000)), + TimestampData.fromEpochMillis( + ICEBERG_MAX_NANOS_EPOCH / 1_000_000, (int) (ICEBERG_MAX_NANOS_EPOCH % 1_000_000))); } @Override @@ -236,10 +251,12 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { genericRecord.put("date_field", DAYS_BTW_EPOC_AND_20220110); genericRecord.put("time_field", HOUR_8_IN_MILLI); - // Although Avro logical type for timestamp fields are in micro seconds, - // AvroToRowDataConverters only looks for long value in milliseconds. - genericRecord.put("ts_with_zone_field", JODA_DATETIME_20220110.getMillis()); - genericRecord.put("ts_without_zone_field", JODA_DATETIME_20220110.getMillis()); + // Now that AvroToRowDataConverters correctly supports microseconds, + // we must inject correct microsecond scale values into the Avro data. + genericRecord.put("ts_with_zone_field", JODA_DATETIME_20220110.getMillis() * 1000L); + genericRecord.put("ts_without_zone_field", JODA_DATETIME_20220110.getMillis() * 1000L); + genericRecord.put("ts_ns_with_zone_field", ICEBERG_MAX_NANOS_EPOCH); + genericRecord.put("ts_ns_without_zone_field", ICEBERG_MAX_NANOS_EPOCH); byte[] uuidBytes = new byte[16]; for (int i = 0; i < 16; ++i) { @@ -554,7 +571,11 @@ public static class ArrayOfPrimitive implements DataGenerator { new Schema( Types.NestedField.required(1, "row_id", Types.StringType.get()), Types.NestedField.required( - 2, "array_of_int", Types.ListType.ofOptional(101, Types.IntegerType.get()))); + 2, "array_of_int", Types.ListType.ofOptional(101, Types.IntegerType.get())), + Types.NestedField.optional( + 3, + "array_of_ts_ns", + Types.ListType.ofRequired(102, Types.TimestampNanoType.withoutZone()))); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -581,13 +602,33 @@ public GenericRecord generateIcebergGenericRecord() { GenericRecord genericRecord = GenericRecord.create(icebergSchema); genericRecord.setField("row_id", "row_id_value"); genericRecord.setField("array_of_int", Arrays.asList(1, 2, 3)); + + LocalDateTime posNanos = LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789); + LocalDateTime negNanos = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321); + genericRecord.setField("array_of_ts_ns", Arrays.asList(posNanos, negNanos)); return genericRecord; } @Override public GenericRowData generateFlinkRowData() { Integer[] arr = {1, 2, 3}; - return GenericRowData.of(StringData.fromString("row_id_value"), new GenericArrayData(arr)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + TimestampData[] tsArr = { + TimestampData.fromEpochMillis( + Math.floorDiv(posNanos, 1_000_000L), (int) Math.floorMod(posNanos, 1_000_000L)), + TimestampData.fromEpochMillis( + Math.floorDiv(negNanos, 1_000_000L), (int) Math.floorMod(negNanos, 1_000_000L)) + }; + return GenericRowData.of( + StringData.fromString("row_id_value"), + new GenericArrayData(arr), + new GenericArrayData(tsArr)); } @Override @@ -595,6 +636,14 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { org.apache.avro.generic.GenericRecord genericRecord = new GenericData.Record(avroSchema); genericRecord.put("row_id", "row_id_value"); genericRecord.put("array_of_int", Arrays.asList(1, 2, 3)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + genericRecord.put("array_of_ts_ns", Arrays.asList(posNanos, negNanos)); return genericRecord; } } @@ -808,7 +857,12 @@ public static class MapOfPrimitives implements DataGenerator { 2, "map_of_primitives", Types.MapType.ofRequired( - 101, 102, Types.StringType.get(), Types.IntegerType.get()))); + 101, 102, Types.StringType.get(), Types.IntegerType.get())), + Types.NestedField.optional( + 3, + "map_of_ts_ns", + Types.MapType.ofRequired( + 103, 104, Types.StringType.get(), Types.TimestampNanoType.withoutZone()))); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -835,15 +889,37 @@ public GenericRecord generateIcebergGenericRecord() { GenericRecord genericRecord = GenericRecord.create(icebergSchema); genericRecord.setField("row_id", "row_id_value"); genericRecord.setField("map_of_primitives", ImmutableMap.of("Jane", 1, "Joe", 2)); + + LocalDateTime posNanos = LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789); + LocalDateTime negNanos = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321); + genericRecord.setField( + "map_of_ts_ns", ImmutableMap.of("positive", posNanos, "negative", negNanos)); return genericRecord; } @Override public GenericRowData generateFlinkRowData() { + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + return GenericRowData.of( StringData.fromString("row_id_value"), new GenericMapData( - ImmutableMap.of(StringData.fromString("Jane"), 1, StringData.fromString("Joe"), 2))); + ImmutableMap.of(StringData.fromString("Jane"), 1, StringData.fromString("Joe"), 2)), + new GenericMapData( + ImmutableMap.of( + StringData.fromString("positive"), + TimestampData.fromEpochMillis( + Math.floorDiv(posNanos, 1_000_000L), + (int) Math.floorMod(posNanos, 1_000_000L)), + StringData.fromString("negative"), + TimestampData.fromEpochMillis( + Math.floorDiv(negNanos, 1_000_000L), + (int) Math.floorMod(negNanos, 1_000_000L))))); } @Override @@ -851,6 +927,15 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { org.apache.avro.generic.GenericRecord genericRecord = new GenericData.Record(avroSchema); genericRecord.put("row_id", "row_id_value"); genericRecord.put("map_of_primitives", ImmutableMap.of("Jane", 1, "Joe", 2)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + genericRecord.put( + "map_of_ts_ns", ImmutableMap.of("positive", posNanos, "negative", negNanos)); return genericRecord; } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java index 03d96ac2c573..e5e478fe4ad1 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java @@ -39,11 +39,13 @@ import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.ManifestReader; import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.maintenance.api.LockConfig; import org.apache.iceberg.flink.source.BoundedTableFactory; import org.apache.iceberg.flink.source.BoundedTestSource; import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; @@ -51,7 +53,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +@ExtendWith(ParameterizedTestExtension.class) public class TestFlinkTableSinkCompaction extends CatalogTestBase { private static final TypeInformation ROW_TYPE_INFO = @@ -75,15 +79,31 @@ public class TestFlinkTableSinkCompaction extends CatalogTestBase { + "'flink-maintenance.rewrite.rewrite-all'='true'," + "'flink-maintenance.rewrite.schedule.data-file-size'='1'," + "'flink-maintenance.lock-check-delay-seconds'='60'"; + private static final String TABLE_PROPERTIES_COORDINATOR = + "'flink-maintenance.rewrite.rewrite-all'='true'," + + "'flink-maintenance.rewrite.schedule.data-file-size'='1'," + + "'flink-maintenance.lock-check-delay-seconds'='60'"; @Parameter(index = 2) private boolean userSqlHint; - @Parameters(name = "catalogName={0}, baseNamespace={1}, userSqlHint={2}") + @Parameter(index = 3) + private String lockType; + + @Parameters(name = "catalogName={0}, baseNamespace={1}, userSqlHint={2}, lockType={3}") public static List parameters() { return Arrays.asList( - new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), true}, - new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), false}); + new Object[] { + "testhadoop_basenamespace", Namespace.of("l0", "l1"), true, LockConfig.JdbcLockConfig.JDBC + }, + new Object[] { + "testhadoop_basenamespace", + Namespace.of("l0", "l1"), + false, + LockConfig.JdbcLockConfig.JDBC + }, + new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), true, ""}, + new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), false, ""}); } @Override @@ -118,7 +138,13 @@ public void before() { if (userSqlHint) { sql("CREATE TABLE %s (id int, data varchar)", TABLE_NAME); } else { - sql("CREATE TABLE %s (id int, data varchar) with (%s)", TABLE_NAME, TABLE_PROPERTIES); + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + sql("CREATE TABLE %s (id int, data varchar) with (%s)", TABLE_NAME, TABLE_PROPERTIES); + } else { + sql( + "CREATE TABLE %s (id int, data varchar) with (%s)", + TABLE_NAME, TABLE_PROPERTIES_COORDINATOR); + } } icebergTable = validationCatalog.loadTable(TableIdentifier.of(icebergNamespace, TABLE_NAME)); @@ -144,9 +170,15 @@ public void testSQLCompactionE2e() throws Exception { // Redirect the records from source table to destination table. if (userSqlHint) { - sql( - "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", - TABLE_NAME, TABLE_PROPERTIES); + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + sql( + "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", + TABLE_NAME, TABLE_PROPERTIES); + } else { + sql( + "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", + TABLE_NAME, TABLE_PROPERTIES_COORDINATOR); + } } else { sql("INSERT INTO %s SELECT id,data from sourceTable", TABLE_NAME); } @@ -174,7 +206,8 @@ public void testSQLCompactionE2e() throws Exception { private List getDataFiles(Snapshot snapshot, Table table) throws IOException { List dataFiles = Lists.newArrayList(); for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { - try (ManifestReader reader = ManifestFiles.read(dataManifest, table.io())) { + try (ManifestReader reader = + ManifestFiles.read(dataManifest, table.io(), table.specs())) { reader.iterator().forEachRemaining(dataFiles::add); } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java index 3afabf6e0795..2cc94bfe4977 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java @@ -50,6 +50,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.Namespace; @@ -356,7 +357,14 @@ public void testRangeDistributionPartitionColumn() { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -371,7 +379,12 @@ public void testRangeDistributionPartitionColumn() { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // range partition results in each partition only assigned to one writer task // maybe less than 26 partitions as BoundedSource doesn't always precisely // control the checkpoint boundary. diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java new file mode 100644 index 000000000000..aaf0ebda0e2b --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink; + +import static org.apache.iceberg.flink.TestFixtures.DATABASE; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.util.List; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericAppenderHelper; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.sink.TaskWriterFactory; +import org.apache.iceberg.flink.source.DataIterator; +import org.apache.iceberg.flink.source.reader.ReaderUtil; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.TaskWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +class TestFlinkUnknownType { + private static final long TARGET_FILE_SIZE = 128 * 1024 * 1024; + private static final Schema SCHEMA_WITH_UNKNOWN_COL = + new Schema( + Lists.newArrayList( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "unknown_col", Types.UnknownType.get()), + Types.NestedField.optional(4, "data1", Types.StringType.get()))); + private static final List EXCEPTED_ROW_DATA = + Lists.newArrayList( + GenericRowData.of(1, StringData.fromString("data"), null, StringData.fromString("data1")), + GenericRowData.of( + 2, StringData.fromString("data"), null, StringData.fromString("data1"))); + private static final List EXPECTED_RECORDS = exceptedRecords(); + + @RegisterExtension + private static final HadoopCatalogExtension CATALOG_EXTENSION = + new HadoopCatalogExtension(DATABASE, TestFixtures.TABLE); + + @TempDir private Path warehouseDir; + + @Parameter private FileFormat fileFormat; + + private Table table; + + @Parameters(name = "fileFormat={0}") + public static Iterable parameters() { + return ImmutableList.of( + new Object[] {FileFormat.PARQUET}, + new Object[] {FileFormat.AVRO}, + new Object[] {FileFormat.ORC}); + } + + @BeforeEach + public void before() { + table = + CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SCHEMA_WITH_UNKNOWN_COL, + PartitionSpec.unpartitioned(), + null, + ImmutableMap.of("format-version", "3", "write.format.default", fileFormat.name())); + } + + @TestTemplate + void testV3TableUnknownTypeRead() throws Exception { + new GenericAppenderHelper(table, fileFormat, warehouseDir).appendToTable(EXPECTED_RECORDS); + table.refresh(); + + List genericRowData = Lists.newArrayList(); + CloseableIterable combinedScanTasks = table.newScan().planTasks(); + for (CombinedScanTask combinedScanTask : combinedScanTasks) { + DataIterator dataIterator = + ReaderUtil.createDataIterator(combinedScanTask, table.schema(), table.schema()); + while (dataIterator.hasNext()) { + GenericRowData rowData = (GenericRowData) dataIterator.next(); + genericRowData.add( + GenericRowData.of( + rowData.getInt(0), + rowData.getString(1), + rowData.getField(2), + rowData.getString(3))); + } + } + + assertThat(genericRowData).containsExactlyInAnyOrderElementsOf(EXCEPTED_ROW_DATA); + } + + @TestTemplate + void testV3TableUnknownTypeWrite() throws Exception { + try (TaskWriter taskWriter = createTaskWriter()) { + for (GenericRowData rowData : EXCEPTED_ROW_DATA) { + taskWriter.write(rowData); + } + + taskWriter.close(); + AppendFiles appendFiles = table.newAppend(); + for (DataFile dataFile : taskWriter.dataFiles()) { + appendFiles.appendFile(dataFile); + } + + appendFiles.commit(); + List records = SimpleDataUtil.tableRecords(table); + assertThat(records).containsExactlyInAnyOrderElementsOf(exceptedRecords()); + } + } + + private TaskWriter createTaskWriter() { + RowType flinkWriteType = FlinkSchemaUtil.convert(table.schema()); + TaskWriterFactory taskWriterFactory = + new RowDataTaskWriterFactory( + table, flinkWriteType, TARGET_FILE_SIZE, fileFormat, table.properties(), null, false); + taskWriterFactory.initialize(1, 1); + return taskWriterFactory.create(); + } + + private static List exceptedRecords() { + GenericRecord record = GenericRecord.create(SCHEMA_WITH_UNKNOWN_COL); + ImmutableList.Builder builder = ImmutableList.builder(); + EXCEPTED_ROW_DATA.forEach( + recordData -> { + GenericRecord copy = record.copy(); + for (int i = 0; i < recordData.getArity(); i++) { + Object field = recordData.getField(i); + if (field instanceof StringData) { + copy.set(i, recordData.getField(i).toString()); + } else { + copy.set(i, recordData.getField(i)); + } + } + + builder.add(copy); + }); + + return builder.build(); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java new file mode 100644 index 000000000000..cac258e7adcc --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.GenericAppenderHelper; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.source.DataIterator; +import org.apache.iceberg.flink.source.reader.ReaderUtil; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.TaskWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.UUIDUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.io.TempDir; + +class TestFlinkUuidType extends CatalogTestBase { + private static final String TABLE_NAME = "test_table"; + private static final long TARGET_FILE_SIZE = 128 * 1024 * 1024; + private static final UUID EXPECTED_UUID = UUID.fromString("0f8fad5b-d9cb-469f-a165-70867728950e"); + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "uuid", Types.UUIDType.get())); + + private Table icebergTable; + @TempDir private Path warehouseDir; + + @Parameter(index = 2) + private FileFormat fileFormat; + + @Parameters(name = "catalogName={0}, baseNamespace={1}, fileFormat={2}") + protected static List parameters() { + return Arrays.asList( + new Object[] {"testhadoop", Namespace.empty(), FileFormat.PARQUET}, + new Object[] {"testhadoop", Namespace.empty(), FileFormat.AVRO}, + new Object[] {"testhadoop", Namespace.empty(), FileFormat.ORC}); + } + + @Override + @BeforeEach + public void before() { + super.before(); + sql("CREATE DATABASE %s", flinkDatabase); + sql("USE CATALOG %s", catalogName); + sql("USE %s", DATABASE); + } + + /** Writes UUID via Generic writer, reads via Flink. */ + @TestTemplate + void uuidWrittenByGenericWriter() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + Record record = + GenericRecord.create(icebergTable.schema()).copy("id", 1, "uuid", EXPECTED_UUID); + new GenericAppenderHelper(icebergTable, fileFormat, warehouseDir) + .appendToTable(ImmutableList.of(record)); + icebergTable.refresh(); + + List genericRowData = Lists.newArrayList(); + try (CloseableIterable combinedScanTasks = + icebergTable.newScan().planTasks()) { + for (CombinedScanTask combinedScanTask : combinedScanTasks) { + try (DataIterator dataIterator = + ReaderUtil.createDataIterator( + combinedScanTask, icebergTable.schema(), icebergTable.schema())) { + while (dataIterator.hasNext()) { + GenericRowData rowData = (GenericRowData) dataIterator.next(); + genericRowData.add(rowData); + } + } + } + } + + assertThat(genericRowData).hasSize(1); + assertThat(genericRowData.get(0).getField(1)).isInstanceOf(byte[].class); + byte[] bytes = (byte[]) genericRowData.get(0).getField(1); + assertThat(bytes).hasSize(16); + + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + UUID actualUuid = new UUID(byteBuffer.getLong(), byteBuffer.getLong()); + assertThat(actualUuid).isEqualTo(EXPECTED_UUID); + } + + /** Writes UUID via Flink TaskWriter, reads via Generic reader. */ + @TestTemplate + void writeUuidViaFlinkWriter() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + RowType rowType = FlinkSchemaUtil.convert(SCHEMA); + RowDataTaskWriterFactory rowDataTaskWriterFactory = + new RowDataTaskWriterFactory( + icebergTable, + rowType, + TARGET_FILE_SIZE, + fileFormat, + icebergTable.properties(), + null, + false); + rowDataTaskWriterFactory.initialize(1, 1); + + byte[] uuidBytes = UUIDUtil.convert(EXPECTED_UUID); + GenericRowData genericRowData = GenericRowData.of(1, uuidBytes); + + try (TaskWriter writer = rowDataTaskWriterFactory.create()) { + writer.write(genericRowData); + writer.close(); + + AppendFiles append = icebergTable.newAppend(); + for (DataFile dataFile : writer.dataFiles()) { + append.appendFile(dataFile); + } + + append.commit(); + } + + List records = SimpleDataUtil.tableRecords(icebergTable); + assertThat(records).hasSize(1); + assertThat(records.get(0).getField("uuid")).isEqualTo(EXPECTED_UUID); + } + + /** Writes UUID via SQL INSERT, reads via Generic reader. */ + @TestTemplate + void sqlInsertUuid() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + String uuidHex = EXPECTED_UUID.toString().replace("-", ""); + sql("INSERT INTO %s VALUES (1, CAST(X'%s' AS BINARY(16)))", TABLE_NAME, uuidHex); + + List records = SimpleDataUtil.tableRecords(icebergTable); + assertThat(records).hasSize(1); + assertThat(records.get(0).getField("uuid")).isEqualTo(EXPECTED_UUID); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java index 47f5485df879..ff654449e9cd 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java @@ -25,23 +25,37 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.util.Map; +import org.apache.flink.api.common.functions.OpenContext; import org.apache.flink.configuration.CoreOptions; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.table.api.EnvironmentSettings; import org.apache.flink.table.api.TableEnvironment; import org.apache.flink.table.api.bridge.java.StreamTableEnvironment; import org.apache.flink.table.catalog.Catalog; +import org.apache.flink.table.catalog.CatalogDatabaseImpl; import org.apache.flink.table.catalog.ObjectPath; +import org.apache.flink.table.catalog.exceptions.DatabaseAlreadyExistException; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.types.Row; +import org.apache.flink.util.Collector; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.DistributionMode; import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.sink.dynamic.DynamicRecord; +import org.apache.iceberg.flink.sink.dynamic.DynamicTableRecordGenerator; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; import org.apache.thrift.TException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.TestTemplate; @@ -70,16 +84,25 @@ public static Iterable parameters() { new Object[] { "testhadoop", ImmutableMap.of( - "connector", "iceberg", - "catalog-type", "hadoop"), + "connector", + "iceberg", + "catalog-type", + "hadoop", + "catalog-database", + "test_database"), true }, new Object[] { "testhadoop", ImmutableMap.of( - "connector", "iceberg", - "catalog-type", "hadoop", - "catalog-table", "not_existing_table"), + "connector", + "iceberg", + "catalog-type", + "hadoop", + "catalog-database", + "test_database", + "catalog-table", + "not_existing_table"), true }, new Object[] { @@ -328,4 +351,126 @@ private String createWarehouse() { throw new UncheckedIOException(e); } } + + @TestTemplate + public void testCreateDynamicIcebergSink() throws DatabaseAlreadyExistException { + Map tableProps = createTableProps(); + Map dynamicTableProps = Maps.newHashMap(tableProps); + dynamicTableProps.put("use-dynamic-iceberg-sink", "true"); + dynamicTableProps.put( + "dynamic-record-generator-impl", SimpleRowDataTableRecordGenerator.class.getName()); + dynamicTableProps.put("table.props.key1", "val1"); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + FlinkCatalog flinkCatalog = + (FlinkCatalog) factory.createCatalog(catalogName, tableProps, new Configuration()); + flinkCatalog.createDatabase( + databaseName(), new CatalogDatabaseImpl(Maps.newHashMap(), null), true); + + // Create table with dynamic sink enabled + sql( + "CREATE TABLE %s (id BIGINT, data STRING, database_name STRING, table_name STRING) WITH %s", + TABLE_NAME + "_dynamic", toWithClause(dynamicTableProps)); + + // Insert data with database and table information + sql( + "INSERT INTO %s VALUES (1, 'AAA', '%s', '%s'), (2, 'BBB', '%s', '%s'), (3, 'CCC', '%s', '%s')", + TABLE_NAME + "_dynamic", + databaseName(), + tableName(), + databaseName(), + tableName(), + databaseName(), + tableName()); + + // Verify the table and data exists + ObjectPath objectPath = new ObjectPath(databaseName(), tableName()); + assertThat(flinkCatalog.tableExists(objectPath)).isTrue(); + Table table = + flinkCatalog + .getCatalogLoader() + .loadCatalog() + .loadTable(TableIdentifier.of(databaseName(), tableName())); + assertThat(table.properties()).containsEntry("key1", "val1"); + + tableProps.put("catalog-database", databaseName()); + sql("CREATE TABLE %s (id BIGINT, data STRING) WITH %s", tableName(), toWithClause(tableProps)); + assertThat(sql("SELECT * FROM %s", tableName())) + .containsExactlyInAnyOrder(Row.of(1L, "AAA"), Row.of(2L, "BBB"), Row.of(3L, "CCC")); + } + + @TestTemplate + public void testMissingDynamicRecordGeneratorImpl() throws DatabaseAlreadyExistException { + Map tableProps = createTableProps(); + tableProps.put("use-dynamic-iceberg-sink", "true"); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + FlinkCatalog flinkCatalog = + (FlinkCatalog) factory.createCatalog(catalogName, tableProps, new Configuration()); + flinkCatalog.createDatabase( + databaseName(), new CatalogDatabaseImpl(Maps.newHashMap(), null), true); + + sql( + "CREATE TABLE %s (id BIGINT, data STRING, database_name STRING, table_name STRING) WITH %s", + TABLE_NAME + "_dynamic", toWithClause(tableProps)); + + assertThatThrownBy( + () -> + sql( + "INSERT INTO %s VALUES (1, 'AAA', '%s', '%s')", + TABLE_NAME + "_dynamic", databaseName(), tableName())) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Invalid dynamic record generator value: null. dynamic-record-generator-impl must be specified when use-dynamic-iceberg-sink is true."); + } + + public static class SimpleRowDataTableRecordGenerator extends DynamicTableRecordGenerator { + + private int databaseFieldIndex = -1; + private int tableFieldIndex = -1; + + public SimpleRowDataTableRecordGenerator(RowType rowType) { + super(rowType); + } + + @Override + public void open(OpenContext openContext) throws Exception { + String[] fieldNames = rowType().getFieldNames().toArray(new String[0]); + + for (int i = 0; i < fieldNames.length; i++) { + if ("database_name".equals(fieldNames[i])) { + databaseFieldIndex = i; + } else if ("table_name".equals(fieldNames[i])) { + tableFieldIndex = i; + } + } + } + + @Override + public void generate(RowData inputRecord, Collector out) throws Exception { + // Extract database and table names using the discovered field indexes + String databaseName = inputRecord.getString(databaseFieldIndex).toString(); + String tableName = inputRecord.getString(tableFieldIndex).toString(); + + // Create schema for the actual data fields (excluding metadata fields) + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.required(1, "data", Types.StringType.get())); + + TableIdentifier tableIdentifier = TableIdentifier.of(databaseName, tableName); + + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + "main", + schema, + inputRecord, + PartitionSpec.unpartitioned(), + DistributionMode.NONE, + 1); + out.collect(dynamicRecord); + } + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java new file mode 100644 index 000000000000..1f0fe70ac53b --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.data; + +import java.util.List; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseFormatModelTests; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.RowDataConverter; +import org.apache.iceberg.flink.TestHelpers; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +public class TestFlinkFormatModel extends BaseFormatModelTests { + + @Override + protected Class engineType() { + return RowData.class; + } + + @Override + protected Object engineSchema(Schema schema) { + return FlinkSchemaUtil.convert(schema); + } + + @Override + protected RowData convertToEngine(Record record, Schema schema) { + return RowDataConverter.convert(schema, record); + } + + @Override + protected void assertEquals(Schema schema, List expected, List actual) { + TestHelpers.assertRows(actual, expected, FlinkSchemaUtil.convert(schema)); + } + + @Override + protected Object convertConstantToEngine(Type type, Object value) { + if (value instanceof PartitionData partitionData) { + Types.StructType structType = type.asStructType(); + List fields = structType.fields(); + GenericRowData rowData = new GenericRowData(fields.size()); + int sourceSize = partitionData.size(); + for (int i = 0; i < fields.size(); i++) { + if (i < sourceSize) { + Object fieldValue = partitionData.get(i, Object.class); + rowData.setField(i, convertConstantToEngine(fields.get(i).type(), fieldValue)); + } else { + rowData.setField(i, null); + } + } + + return rowData; + } + + return RowDataUtil.convertConstant(type, value); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java index 4a70802f2a2e..b7b0a54156cc 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java @@ -49,6 +49,11 @@ protected boolean allowsWritingNullValuesForRequiredFields() { return true; } + @Override + protected boolean supportsTimestampNanos() { + return true; + } + @Override protected void writeAndValidate(Schema schema) throws IOException { List expectedRecords = RandomGenericData.generate(schema, NUM_RECORDS, 1990L); diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java index d181d3351410..1eaf539df4a0 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java @@ -25,9 +25,8 @@ import java.util.Iterator; import java.util.List; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.data.binary.BinaryRowData; -import org.apache.flink.table.runtime.typeutils.RowDataSerializer; import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.Schema; import org.apache.iceberg.data.DataTestBase; import org.apache.iceberg.data.RandomGenericData; @@ -59,63 +58,66 @@ protected boolean supportsTimestampNanos() { return true; } - private void writeAndValidate(Iterable iterable, Schema schema) throws IOException { - OutputFile outputFile = new InMemoryOutputFile(); + @Override + protected boolean supportsDefaultValues() { + return true; + } - LogicalType logicalType = FlinkSchemaUtil.convert(schema); + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema, List data) + throws IOException { + OutputFile outputFile = new InMemoryOutputFile(); + LogicalType logicalType = FlinkSchemaUtil.convert(writeSchema); try (FileAppender writer = Parquet.write(outputFile) - .schema(schema) + .schema(writeSchema) .createWriterFunc(msgType -> FlinkParquetWriters.buildWriter(logicalType, msgType)) .build()) { - writer.addAll(iterable); + writer.addAll(RandomRowData.convert(writeSchema, data)); } try (CloseableIterable reader = Parquet.read(outputFile.toInputFile()) - .project(schema) - .createReaderFunc(msgType -> GenericParquetReaders.buildReader(schema, msgType)) + .project(expectedSchema) + .createReaderFunc( + fileSchema -> GenericParquetReaders.buildReader(expectedSchema, fileSchema)) .build()) { - Iterator expected = iterable.iterator(); Iterator actual = reader.iterator(); - LogicalType rowType = FlinkSchemaUtil.convert(schema); - for (int i = 0; i < NUM_RECORDS; i += 1) { + RowType rowType = FlinkSchemaUtil.convert(expectedSchema); + for (Record expected : data) { assertThat(actual).hasNext(); - TestHelpers.assertRowData(schema.asStruct(), rowType, actual.next(), expected.next()); + RowData actualRowData = RowDataConverter.convert(expectedSchema, actual.next()); + TestHelpers.assertRowData(expectedSchema.asStruct(), rowType, expected, actualRowData); } + assertThat(actual).isExhausted(); } } @Override protected void writeAndValidate(Schema schema) throws IOException { - writeAndValidate(RandomRowData.generate(schema, NUM_RECORDS, 19981), schema); + writeAndValidate(schema, RandomGenericData.generate(schema, NUM_RECORDS, 19981)); writeAndValidate( - RandomRowData.convert( - schema, - RandomGenericData.generateDictionaryEncodableRecords(schema, NUM_RECORDS, 21124)), - schema); + schema, + Lists.newArrayList( + RandomGenericData.generateDictionaryEncodableRecords(schema, NUM_RECORDS, 21124))); writeAndValidate( - RandomRowData.convert( - schema, + schema, + Lists.newArrayList( RandomGenericData.generateFallbackRecords( - schema, NUM_RECORDS, 21124, NUM_RECORDS / 20)), - schema); + schema, NUM_RECORDS, 21124, NUM_RECORDS / 20))); } @Override - protected void writeAndValidate(Schema schema, List expectedData) throws IOException { - RowDataSerializer rowDataSerializer = new RowDataSerializer(FlinkSchemaUtil.convert(schema)); - List binaryRowList = Lists.newArrayList(); - for (Record record : expectedData) { - RowData rowData = RowDataConverter.convert(schema, record); - BinaryRowData binaryRow = rowDataSerializer.toBinaryRow(rowData); - binaryRowList.add(binaryRow); - } + protected void writeAndValidate(Schema schema, List data) throws IOException { + writeAndValidate(schema, schema, data); + } - writeAndValidate(binaryRowList, schema); + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + List data = RandomGenericData.generate(writeSchema, NUM_RECORDS, 1991L); + writeAndValidate(writeSchema, expectedSchema, data); } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java index 4e5b38ffb026..a2411da1e344 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java @@ -271,18 +271,19 @@ public void testMapOfPrimitivesProjection() { GenericRowData.of( StringData.fromString("row_id_value"), new GenericMapData( - ImmutableMap.of(StringData.fromString("foo"), 1, StringData.fromString("bar"), 2))); + ImmutableMap.of(StringData.fromString("foo"), 1, StringData.fromString("bar"), 2)), + null); testEqualsAndHashCode(schema, idOnly, rowData, copyRowData, otherRowData, true); testEqualsAndHashCode(schema, mapOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, schema, rowData, copyRowData, otherRowData); GenericRowData rowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("row_id_value"), null); + GenericRowData.of(StringData.fromString("row_id_value"), null, null); GenericRowData copyRowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("row_id_value"), null); + GenericRowData.of(StringData.fromString("row_id_value"), null, null); // modify the map field value GenericRowData otherRowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("other_row_id_value"), null); + GenericRowData.of(StringData.fromString("other_row_id_value"), null, null); testEqualsAndHashCode( schema, idOnly, @@ -432,7 +433,8 @@ public void testArrayOfPrimitiveProjection() { GenericRowData otherRowData = GenericRowData.of( StringData.fromString("other_row_id_value"), - new GenericArrayData(new Integer[] {4, 5, 6})); + new GenericArrayData(new Integer[] {4, 5, 6}), + null); testEqualsAndHashCode(schema, idOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, arrayOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, schema, rowData, copyRowData, otherRowData); @@ -440,16 +442,19 @@ public void testArrayOfPrimitiveProjection() { GenericRowData rowDataNullOptionalFields = GenericRowData.of( StringData.fromString("row_id_value"), - new GenericArrayData(new Integer[] {1, null, 3})); + new GenericArrayData(new Integer[] {1, null, 3}), + null); GenericRowData copyRowDataNullOptionalFields = GenericRowData.of( StringData.fromString("row_id_value"), - new GenericArrayData(new Integer[] {1, null, 3})); + new GenericArrayData(new Integer[] {1, null, 3}), + null); // modify the map field value GenericRowData otherRowDataNullOptionalFields = GenericRowData.of( StringData.fromString("other_row_id_value"), - new GenericArrayData(new Integer[] {4, null, 6})); + new GenericArrayData(new Integer[] {4, null, 6}), + null); testEqualsAndHashCode( schema, idOnly, diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java new file mode 100644 index 000000000000..f26f608ffa61 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.flink.configuration.Configuration; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles.PrefixMismatchMode; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestDeleteOrphanFilesConfig extends OperatorTestBase { + private Table table; + private Map input = Maps.newHashMap(); + + @BeforeEach + public void before() { + this.table = createTable(); + input.put(DeleteOrphanFilesConfig.SCHEDULE_ON_INTERVAL_SECOND, "60"); + input.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + input.put(DeleteOrphanFilesConfig.DELETE_BATCH_SIZE, "500"); + input.put(DeleteOrphanFilesConfig.LOCATION, "/tmp/test-location"); + input.put(DeleteOrphanFilesConfig.USE_PREFIX_LISTING, "true"); + input.put(DeleteOrphanFilesConfig.PLANNING_WORKER_POOL_SIZE, "4"); + input.put(DeleteOrphanFilesConfig.EQUAL_SCHEMES, "s3n=s3,s3a=s3"); + input.put(DeleteOrphanFilesConfig.EQUAL_AUTHORITIES, "auth1=auth2"); + input.put(DeleteOrphanFilesConfig.PREFIX_MISMATCH_MODE, "IGNORE"); + input.put("other.config", "should-be-ignored"); + } + + @AfterEach + public void after() { + input.clear(); + } + + @Test + void testConfigParsing() { + DeleteOrphanFilesConfig config = new DeleteOrphanFilesConfig(table, input, new Configuration()); + + assertThat(config.scheduleOnIntervalSecond()).isEqualTo(60); + assertThat(config.minAgeSeconds()).isEqualTo(86400L); + assertThat(config.deleteBatchSize()).isEqualTo(500); + assertThat(config.location()).isEqualTo("/tmp/test-location"); + assertThat(config.usePrefixListing()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(4); + assertThat(config.equalSchemes()).containsEntry("s3n", "s3").containsEntry("s3a", "s3"); + assertThat(config.equalAuthorities()).containsEntry("auth1", "auth2"); + assertThat(config.prefixMismatchMode()).isEqualTo(PrefixMismatchMode.IGNORE); + } + + @Test + void testConfigDefaults() { + DeleteOrphanFilesConfig config = + new DeleteOrphanFilesConfig(table, Maps.newHashMap(), new Configuration()); + + assertThat(config.scheduleOnIntervalSecond()) + .isEqualTo(DeleteOrphanFilesConfig.SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()); + assertThat(config.minAgeSeconds()) + .isEqualTo(DeleteOrphanFilesConfig.MIN_AGE_SECONDS_OPTION.defaultValue()); + assertThat(config.deleteBatchSize()) + .isEqualTo(DeleteOrphanFilesConfig.DELETE_BATCH_SIZE_OPTION.defaultValue()); + assertThat(config.location()).isNull(); + assertThat(config.usePrefixListing()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(ThreadPools.WORKER_THREAD_POOL_SIZE); + assertThat(config.equalSchemes()).containsEntry("s3n", "s3").containsEntry("s3a", "s3"); + assertThat(config.equalAuthorities()).isEqualTo(Map.of()); + assertThat(config.prefixMismatchMode()).isEqualTo(PrefixMismatchMode.ERROR); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java index b8aa259e2f17..a23850d82341 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java @@ -164,7 +164,7 @@ void testUidAndSlotSharingGroupUnset() { 0, tableLoader(), UID_SUFFIX, - StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP, + null, 1) .sinkTo(infra.sink()); diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java new file mode 100644 index 000000000000..3bcec8114b0c --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.flink.configuration.Configuration; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestExpireSnapshotsConfig extends OperatorTestBase { + private Table table; + private Map input = Maps.newHashMap(); + + @BeforeEach + public void before() { + this.table = createTable(); + input.put(ExpireSnapshotsConfig.SCHEDULE_ON_COMMIT_COUNT, "10"); + input.put(ExpireSnapshotsConfig.SCHEDULE_ON_INTERVAL_SECOND, "60"); + input.put(ExpireSnapshotsConfig.MAX_SNAPSHOT_AGE_SECONDS, "7200"); + input.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + input.put(ExpireSnapshotsConfig.DELETE_BATCH_SIZE, "500"); + input.put(ExpireSnapshotsConfig.CLEAN_EXPIRED_METADATA, "true"); + input.put(ExpireSnapshotsConfig.PLANNING_WORKER_POOL_SIZE, "4"); + input.put("other.config", "should-be-ignored"); + } + + @AfterEach + public void after() { + input.clear(); + } + + @Test + void testConfigParsing() { + ExpireSnapshotsConfig config = new ExpireSnapshotsConfig(table, input, new Configuration()); + + assertThat(config.scheduleOnCommitCount()).isEqualTo(10); + assertThat(config.scheduleOnIntervalSecond()).isEqualTo(60); + assertThat(config.maxSnapshotAgeSeconds()).isEqualTo(7200L); + assertThat(config.retainLast()).isEqualTo(5); + assertThat(config.deleteBatchSize()).isEqualTo(500); + assertThat(config.cleanExpiredMetadata()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(4); + } + + @Test + void testConfigDefaults() { + ExpireSnapshotsConfig config = + new ExpireSnapshotsConfig(table, Maps.newHashMap(), new Configuration()); + + assertThat(config.scheduleOnCommitCount()) + .isEqualTo(ExpireSnapshotsConfig.SCHEDULE_ON_COMMIT_COUNT_OPTION.defaultValue()); + assertThat(config.scheduleOnIntervalSecond()) + .isEqualTo(ExpireSnapshotsConfig.SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()); + assertThat(config.maxSnapshotAgeSeconds()).isNull(); + assertThat(config.retainLast()).isNull(); + assertThat(config.deleteBatchSize()) + .isEqualTo(ExpireSnapshotsConfig.DELETE_BATCH_SIZE_OPTION.defaultValue()); + assertThat(config.cleanExpiredMetadata()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(ThreadPools.WORKER_THREAD_POOL_SIZE); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java index 3cb18ffbb77e..4d35792e440e 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java @@ -19,11 +19,18 @@ package org.apache.iceberg.flink.maintenance.api; import static org.apache.iceberg.flink.maintenance.api.JdbcLockFactory.INIT_LOCK_TABLES_PROPERTY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.sql.SQLTransientConnectionException; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.iceberg.jdbc.JdbcCatalog; +import org.apache.iceberg.jdbc.JdbcClientPool; +import org.apache.iceberg.jdbc.UncheckedSQLException; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.Test; class TestJdbcLockFactory extends TestLockFactoryBase { @Override @@ -38,4 +45,62 @@ TriggerLockFactory lockFactory(String tableName) { tableName, properties); } + + @Test + void testSQLExceptionEnablesRetryInClientPool() throws Exception { + // Regression test for #15759: verify that removing the inner try-catch allows + // ClientPoolImpl to retry on transient connection failures. + // + // Before the fix: inner catch converted SQLException -> UncheckedSQLException + // (RuntimeException) inside the lambda. ClientPoolImpl only catches the declared + // exception type (SQLException), so RuntimeException bypasses retry entirely. + // After the fix: SQLException propagates naturally, ClientPoolImpl catches it, + // and retries on transient connection exceptions. + Map props = Maps.newHashMap(); + props.put("username", "user"); + props.put("password", "password"); + String uri = "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", ""); + + try (JdbcClientPool pool = new JdbcClientPool(1, uri, props)) { + AtomicInteger attempts = new AtomicInteger(0); + + String result = + pool.run( + conn -> { + if (attempts.incrementAndGet() == 1) { + throw new SQLTransientConnectionException("transient failure"); + } + + return "success"; + }); + + assertThat(result).isEqualTo("success"); + assertThat(attempts.get()).isGreaterThan(1); + } + } + + @Test + void testUncheckedSQLExceptionBypassesRetry() throws Exception { + // Companion test: demonstrates that wrapping SQLException as UncheckedSQLException + // (the OLD behavior before the fix) prevents ClientPoolImpl from retrying. + Map props = Maps.newHashMap(); + props.put("username", "user"); + props.put("password", "password"); + String uri = "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", ""); + + try (JdbcClientPool pool = new JdbcClientPool(1, uri, props)) { + assertThatThrownBy( + () -> + pool.run( + conn -> { + try { + throw new SQLTransientConnectionException("transient failure"); + } catch (java.sql.SQLException e) { + throw new UncheckedSQLException(e, "wrapped"); + } + })) + .isInstanceOf(UncheckedSQLException.class) + .hasMessageContaining("wrapped"); + } + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java index 0a860fec4799..fe8457167a1f 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java @@ -77,4 +77,43 @@ void testE2e() throws Exception { closeJobClient(jobClient); } } + + @Test + void testE2eUseCoordinator() throws Exception { + TableMaintenance.forTable(env, tableLoader()) + .uidSuffix("E2eTestUID") + .rateLimit(Duration.ofMinutes(10)) + .lockCheckDelay(Duration.ofSeconds(10)) + .add( + ExpireSnapshots.builder() + .scheduleOnCommitCount(10) + .maxSnapshotAge(Duration.ofMinutes(10)) + .retainLast(5) + .deleteBatchSize(5) + .parallelism(8)) + .add( + RewriteDataFiles.builder() + .scheduleOnDataFileCount(10) + .partialProgressEnabled(true) + .partialProgressMaxCommits(10) + .maxRewriteBytes(1000L) + .targetFileSizeBytes(1000L) + .minFileSizeBytes(1000L) + .maxFileSizeBytes(1000L) + .minInputFiles(10) + .deleteFileThreshold(10) + .rewriteAll(false) + .maxFileGroupSizeBytes(1000L)) + .append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Just make sure that we are able to instantiate the flow + assertThat(jobClient).isNotNull(); + } finally { + closeJobClient(jobClient); + } + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java index 707038c925d5..c27f6081af5a 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java @@ -29,9 +29,11 @@ import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.REMOVED_DATA_FILE_SIZE_METRIC; import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; import java.util.List; import java.util.stream.StreamSupport; import org.apache.flink.streaming.api.graph.StreamGraphGenerator; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; @@ -43,8 +45,14 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; class TestRewriteDataFiles extends MaintenanceTaskTestBase { + + private static final FileFormat[] FILE_FORMATS = + new FileFormat[] {FileFormat.AVRO, FileFormat.PARQUET, FileFormat.ORC}; + @Test void testRewriteUnpartitioned() throws Exception { Table table = createTable(); @@ -82,13 +90,14 @@ void testRewriteUnpartitioned() throws Exception { createRecord(4, "d"))); } - @Test - void testRewriteUnpartitionedPreserveLineage() throws Exception { - Table table = createTable(3); - insert(table, 1, "a"); - insert(table, 2, "b"); - insert(table, 3, "c"); - insert(table, 4, "d"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewriteUnpartitionedPreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createTable(3, fileFormat); + insert(table, 1, "a", fileFormat); + insert(table, 2, "b", fileFormat); + insert(table, 3, "c", fileFormat); + insert(table, 4, "d", fileFormat); assertFileNum(table, 4, 0); @@ -122,15 +131,17 @@ void testRewriteUnpartitionedPreserveLineage() throws Exception { schema); } - @Test - void testRewriteTheSameFilePreserveLineage() throws Exception { - Table table = createTable(3); - insert(table, 1, "a"); - insert(table, 2, "b"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewriteTheSameFilePreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createTable(3, fileFormat); + insert(table, 1, "a", fileFormat); + insert(table, 2, "b", fileFormat); // Create a file with two lines of data to verify that the rowid is read correctly. insert( table, - ImmutableList.of(SimpleDataUtil.createRecord(3, "c"), SimpleDataUtil.createRecord(4, "d"))); + ImmutableList.of(SimpleDataUtil.createRecord(3, "c"), SimpleDataUtil.createRecord(4, "d")), + fileFormat); assertFileNum(table, 3, 0); @@ -166,13 +177,14 @@ void testRewriteTheSameFilePreserveLineage() throws Exception { schema); } - @Test - void testRewritePartitionedPreserveLineage() throws Exception { - Table table = createPartitionedTable(3); - insertPartitioned(table, 1, "p1"); - insertPartitioned(table, 2, "p1"); - insertPartitioned(table, 3, "p2"); - insertPartitioned(table, 4, "p2"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewritePartitionedPreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createPartitionedTable(3, fileFormat); + insertPartitioned(table, 1, "p1", fileFormat); + insertPartitioned(table, 2, "p1", fileFormat); + insertPartitioned(table, 3, "p2", fileFormat); + insertPartitioned(table, 4, "p2", fileFormat); assertFileNum(table, 4, 0); @@ -328,7 +340,7 @@ void testUidAndSlotSharingGroupUnset() { 0, tableLoader(), UID_SUFFIX, - StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP, + null, 1) .sinkTo(infra.sink()); @@ -529,6 +541,92 @@ void testRewriteWithFilter() throws Exception { createRecord(4, "d"))); } + /** + * By verifying that the creation time of the data content in the builder is later than the + * creation time of the filter condition — if the filter condition is actually created in the + * planner, then all files can be compacted; otherwise, not all files can be compacted — we can + * confirm whether the filter condition is actually created in the planner. + */ + @Test + void testRewriteWithFilterSupplier() throws Exception { + Table table = createTable(); + + appendRewriteDataFiles( + RewriteDataFiles.builder() + .parallelism(2) + .deleteFileThreshold(10) + .targetFileSizeBytes(1_000_000L) + .maxFileGroupSizeBytes(10_000_000L) + .maxFileSizeBytes(2_000_000L) + .minFileSizeBytes(500_000L) + .minInputFiles(2) + // Rewrite data files where id is less than current timestamp in planner + .filter(() -> Expressions.lessThan("id", (int) Instant.now().getEpochSecond())) + .partialProgressEnabled(true) + .partialProgressMaxCommits(1) + .maxRewriteBytes(100_000L) + .rewriteAll(false)); + + insert(table, 1, "a"); + insert(table, 2, "b"); + insert(table, 3, "c"); + + int epochSecond = (int) Instant.now().getEpochSecond(); + insert(table, epochSecond, "d"); + + assertFileNum(table, 4, 0); + + Thread.sleep(1_000L); + runAndWaitForSuccess(infra.env(), infra.source(), infra.sink()); + + // There is four files, only id is less than current timestamp will be rewritten. so expect 2 + // files. + assertFileNum(table, 1, 0); + + SimpleDataUtil.assertTableRecords( + table, + ImmutableList.of( + createRecord(1, "a"), + createRecord(2, "b"), + createRecord(3, "c"), + createRecord(epochSecond, "d"))); + } + + @Test + void testBranch() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + insert(table, 2, "b"); + + // Create branch based on above inserts + String branchName = "test-branch"; + table.manageSnapshots().createBranch(branchName).commit(); + + // Insert another file on main only (main has 3 files, branch stays at 2) + insert(table, 3, "c"); + + appendRewriteDataFiles(RewriteDataFiles.builder().rewriteAll(true).branch(branchName)); + + runAndWaitForSuccess(infra.env(), infra.source(), infra.sink()); + + table.refresh(); + + // Branch should be compacted from 2 files to 1 + assertThat( + table.snapshot(branchName).dataManifests(table.io()).stream() + .flatMap( + m -> + StreamSupport.stream( + ManifestFiles.read(m, table.io(), table.specs()).spliterator(), false)) + .count()) + .isEqualTo(1); + SimpleDataUtil.assertTableRecords( + table, ImmutableList.of(createRecord(1, "a"), createRecord(2, "b")), branchName); + + // Main should be untouched with 3 files + assertFileNum(table, 3, 0); + } + private void appendRewriteDataFiles() { appendRewriteDataFiles(RewriteDataFiles.builder().rewriteAll(true)); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java index eaa5b5e1b5b1..49219d5b4698 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java @@ -424,12 +424,12 @@ String maintenanceTaskName() { @Override DataStream append(DataStream trigger) { String name = TASKS[id]; - return trigger - .map(new DummyMaintenanceTask(success)) - .name(name) - .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .map(new DummyMaintenanceTask(success)) + .name(name) + .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) + .forceNonParallel()); } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java new file mode 100644 index 000000000000..9b7941c2d306 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java @@ -0,0 +1,344 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.apache.iceberg.flink.SimpleDataUtil.createRowData; +import static org.apache.iceberg.flink.maintenance.api.TableMaintenance.SOURCE_OPERATOR_NAME_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import org.apache.flink.api.common.functions.MapFunction; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.dag.Transformation; +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.api.java.typeutils.ResultTypeQueryable; +import org.apache.flink.configuration.CheckpointingOptions; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.transformations.SourceTransformation; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.runtime.typeutils.InternalTypeInfo; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.TableLoader; +import org.apache.iceberg.flink.maintenance.operator.ManualSource; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.flink.maintenance.operator.TableChange; +import org.apache.iceberg.flink.sink.FlinkSink; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TestTableMaintenanceCoordinationLock extends OperatorTestBase { + private static final String MAINTENANCE_TASK_NAME = "TestTableMaintenance"; + private static final String[] TASKS = + new String[] {MAINTENANCE_TASK_NAME + " [0]", MAINTENANCE_TASK_NAME + " [1]"}; + private static final TableChange DUMMY_CHANGE = TableChange.builder().commitCount(1).build(); + private static final List PROCESSED = + Collections.synchronizedList(Lists.newArrayListWithCapacity(1)); + + private StreamExecutionEnvironment env; + private Table table; + + @TempDir private File checkpointDir; + + @BeforeEach + void beforeEach() throws IOException { + Configuration config = new Configuration(); + config.set(CheckpointingOptions.CHECKPOINT_STORAGE, "filesystem"); + config.set(CheckpointingOptions.CHECKPOINTS_DIRECTORY, "file://" + checkpointDir.getPath()); + this.env = StreamExecutionEnvironment.getExecutionEnvironment(config); + this.table = createTable(); + insert(table, 1, "a"); + + PROCESSED.clear(); + MaintenanceTaskBuilderForTest.counter = 0; + } + + @Test + void testForChangeStream() throws Exception { + ManualSource schedulerSource = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + + TableMaintenance.Builder streamBuilder = + TableMaintenance.forChangeStream(schedulerSource.dataStream(), tableLoader()) + .rateLimit(Duration.ofMillis(2)) + .lockCheckDelay(Duration.ofSeconds(3)) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .scheduleOnDataFileCount(2) + .scheduleOnDataFileSize(3L) + .scheduleOnEqDeleteFileCount(4) + .scheduleOnEqDeleteRecordCount(5L) + .scheduleOnPosDeleteFileCount(6) + .scheduleOnPosDeleteRecordCount(7L) + .scheduleOnInterval(Duration.ofHours(1))); + + sendEvents(schedulerSource, streamBuilder, ImmutableList.of(Tuple2.of(DUMMY_CHANGE, 1))); + } + + @Test + void testForTable() throws Exception { + TableLoader tableLoader = tableLoader(); + + env.enableCheckpointing(10); + + TableMaintenance.forTable(env, tableLoader) + .rateLimit(Duration.ofMillis(2)) + .maxReadBack(2) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(2)) + .append(); + + // Creating a stream for inserting data into the table concurrently + ManualSource insertSource = + new ManualSource<>(env, InternalTypeInfo.of(FlinkSchemaUtil.convert(table.schema()))); + FlinkSink.forRowData(insertSource.dataStream()) + .tableLoader(tableLoader) + .uidPrefix(UID_SUFFIX + "-iceberg-sink") + .append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + insertSource.sendRecord(createRowData(2, "b")); + + Awaitility.await().until(() -> PROCESSED.size() == 1); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testUidAndSlotSharingGroup() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP)) + .append(); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + @Test + void testUidAndSlotSharingGroupUnset() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(1)) + .append(); + + checkUidsAreSet(env, null); + checkSlotSharingGroupsAreSet(env, null); + } + + @Test + void testUidAndSlotSharingGroupInherit() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(1)) + .append(); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + @Test + void testUidAndSlotSharingGroupOverWrite() throws IOException { + String anotherUid = "Another-UID"; + String anotherSlotSharingGroup = "Another-SlotSharingGroup"; + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(anotherUid) + .slotSharingGroup(anotherSlotSharingGroup)) + .append(); + + // Choose an operator from the scheduler part of the graph + Transformation schedulerTransformation = + env.getTransformations().stream() + .filter(t -> t.getName().equals("Trigger manager")) + .findFirst() + .orElseThrow(); + assertThat(schedulerTransformation.getUid()).contains(UID_SUFFIX); + assertThat(schedulerTransformation.getSlotSharingGroup()).isPresent(); + assertThat(schedulerTransformation.getSlotSharingGroup().get().getName()) + .isEqualTo(SLOT_SHARING_GROUP); + + // Choose an operator from the maintenance task part of the graph + Transformation scheduledTransformation = + env.getTransformations().stream() + .filter(t -> t.getName().startsWith(MAINTENANCE_TASK_NAME)) + .findFirst() + .orElseThrow(); + assertThat(scheduledTransformation.getUid()).contains(anotherUid); + assertThat(scheduledTransformation.getSlotSharingGroup()).isPresent(); + assertThat(scheduledTransformation.getSlotSharingGroup().get().getName()) + .isEqualTo(anotherSlotSharingGroup); + } + + @Test + void testUidAndSlotSharingGroupForMonitorSource() throws IOException { + TableMaintenance.forTable(env, tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP)) + .append(); + + Transformation source = monitorSource(); + assertThat(source).isNotNull(); + assertThat(source.getUid()).contains(UID_SUFFIX); + assertThat(source.getSlotSharingGroup()).isPresent(); + assertThat(source.getSlotSharingGroup().get().getName()).isEqualTo(SLOT_SHARING_GROUP); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + /** + * Sends the events though the {@link ManualSource} provided, and waits until the given number of + * records are processed. + * + * @param schedulerSource used for sending the events + * @param streamBuilder used for generating the job + * @param eventsAndResultNumbers the pair of the event and the expected processed records + * @throws Exception if any + */ + private void sendEvents( + ManualSource schedulerSource, + TableMaintenance.Builder streamBuilder, + List> eventsAndResultNumbers) + throws Exception { + streamBuilder.append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + eventsAndResultNumbers.forEach( + eventsAndResultNumber -> { + int expectedSize = PROCESSED.size() + eventsAndResultNumber.f1; + schedulerSource.sendRecord(eventsAndResultNumber.f0); + Awaitility.await().until(() -> PROCESSED.size() == expectedSize); + }); + } finally { + closeJobClient(jobClient); + } + } + + /** + * Finds the {@link org.apache.iceberg.flink.maintenance.operator.MonitorSource} for testing + * purposes by parsing the transformation tree. + * + * @return The monitor source if we found it + */ + private Transformation monitorSource() { + assertThat(env.getTransformations()).isNotEmpty(); + assertThat(env.getTransformations().get(0).getInputs()).isNotEmpty(); + assertThat(env.getTransformations().get(0).getInputs().get(0).getInputs()).isNotEmpty(); + + Transformation result = + env.getTransformations().get(0).getInputs().get(0).getInputs().get(0); + + // Some checks to make sure this is the transformation we are looking for + assertThat(result).isInstanceOf(SourceTransformation.class); + assertThat(result.getName()).startsWith(SOURCE_OPERATOR_NAME_PREFIX); + + return result; + } + + private static class MaintenanceTaskBuilderForTest + extends MaintenanceTaskBuilder { + private final boolean success; + private final int id; + private static int counter = 0; + + MaintenanceTaskBuilderForTest(boolean success) { + this.success = success; + this.id = counter; + ++counter; + } + + @Override + String maintenanceTaskName() { + return MAINTENANCE_TASK_NAME; + } + + @Override + DataStream append(DataStream trigger) { + String name = TASKS[id]; + return setSlotSharingGroup( + trigger + .map(new DummyMaintenanceTask(success)) + .name(name) + .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) + .forceNonParallel()); + } + } + + private record DummyMaintenanceTask(boolean success) + implements MapFunction, ResultTypeQueryable, Serializable { + + @Override + public TaskResult map(Trigger trigger) { + PROCESSED.add(trigger); + + return new TaskResult( + trigger.taskId(), + trigger.timestamp(), + success, + success ? Collections.emptyList() : Lists.newArrayList(new Exception("Testing error"))); + } + + @Override + public TypeInformation getProducedType() { + return TypeInformation.of(TaskResult.class); + } + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java new file mode 100644 index 000000000000..5bfc889f2720 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 10) +class CoordinatorTestBase extends OperatorTestBase { + protected static final String OPERATOR_NAME = "TestCoordinator"; + protected static final String OPERATOR_NAME_1 = "TestCoordinator_1"; + protected static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + protected static final OperatorID TEST_OPERATOR_ID_1 = new OperatorID(1235L, 5679L); + protected static final int NUM_SUBTASKS = 1; + protected static final LockRegisterEvent LOCK_REGISTER_EVENT = + new LockRegisterEvent(DUMMY_TABLE_NAME); + protected static final LockReleaseEvent LOCK_RELEASE_EVENT = + new LockReleaseEvent(DUMMY_TABLE_NAME, 1L); + + protected static void setAllTasksReady( + BaseCoordinator baseCoordinator, EventReceivingTasks receivingTasks) { + for (int i = 0; i < NUM_SUBTASKS; i++) { + baseCoordinator.executionAttemptReady(i, 0, receivingTasks.createGatewayForSubtask(i, 0)); + } + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java index 5eecc5a803d3..638da61949c9 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java @@ -24,7 +24,10 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; +import java.util.concurrent.TimeUnit; import org.apache.flink.configuration.Configuration; import org.apache.flink.configuration.MetricOptions; import org.apache.flink.core.execution.JobClient; @@ -32,7 +35,6 @@ import org.apache.flink.runtime.jobgraph.SavepointConfigOptions; import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.flink.streaming.api.transformations.SinkTransformation; import org.apache.flink.streaming.api.watermark.Watermark; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; @@ -79,6 +81,12 @@ public class OperatorTestBase { ImmutableMap.of(), ImmutableSet.of(SimpleDataUtil.SCHEMA.columns().get(0).fieldId())); + private static final Schema SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "ts", Types.TimestampType.withoutZone())); + protected static final String UID_SUFFIX = "UID-Dummy"; protected static final String SLOT_SHARING_GROUP = "SlotSharingGroup"; protected static final TriggerLockFactory LOCK_FACTORY = new MemoryLockFactory(); @@ -124,10 +132,14 @@ void after() throws IOException { } protected static Table createTable() { - return createTable(2); + return createTable(2, FileFormat.PARQUET); } protected static Table createTable(int formatVersion) { + return createPartitionedTable(formatVersion, FileFormat.PARQUET); + } + + protected static Table createTable(int formatVersion, FileFormat fileFormat) { return CATALOG_EXTENSION .catalog() .createTable( @@ -136,12 +148,29 @@ protected static Table createTable(int formatVersion) { PartitionSpec.unpartitioned(), null, ImmutableMap.of( + "write.format.default", + fileFormat.name(), TableProperties.FORMAT_VERSION, String.valueOf(formatVersion), "flink.max-continuous-empty-commits", "100000")); } + protected static Table createTableWithTimestampWithoutZone() { + return CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE, + PartitionSpec.builderFor(SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE).identity("ts").build(), + null, + ImmutableMap.of( + TableProperties.FORMAT_VERSION, + "2", + "flink.max-continuous-empty-commits", + "100000")); + } + protected static Table createTableWithDelete() { return createTableWithDelete(2); } @@ -158,7 +187,7 @@ protected static Table createTableWithDelete(int formatVersion) { "format-version", String.valueOf(formatVersion), "write.upsert.enabled", "true")); } - protected static Table createPartitionedTable(int formatVersion) { + protected static Table createPartitionedTable(int formatVersion, FileFormat fileFormat) { return CATALOG_EXTENSION .catalog() .createTable( @@ -167,6 +196,8 @@ protected static Table createPartitionedTable(int formatVersion) { PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).identity("data").build(), null, ImmutableMap.of( + "write.format.default", + fileFormat.name(), "format-version", String.valueOf(formatVersion), "flink.max-continuous-empty-commits", @@ -174,17 +205,27 @@ protected static Table createPartitionedTable(int formatVersion) { } protected static Table createPartitionedTable() { - return createPartitionedTable(2); + return createPartitionedTable(2, FileFormat.PARQUET); } protected void insert(Table table, Integer id, String data) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + insert(table, id, data, FileFormat.PARQUET); + } + + protected void insert(Table table, Integer id, String data, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir) .appendToTable(Lists.newArrayList(SimpleDataUtil.createRecord(id, data))); table.refresh(); } protected void insert(Table table, List records) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir).appendToTable(records); + insert(table, records, FileFormat.PARQUET); + } + + protected void insert(Table table, List records, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir).appendToTable(records); table.refresh(); } @@ -194,6 +235,20 @@ protected void insert(Table table, Integer id, String data, String extra) throws table.refresh(); } + protected void insertWithTimestampWithoutZone( + Table table, Integer id, String data, LocalDateTime ts) throws IOException { + GenericRecord record = GenericRecord.create(SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE); + record.setField("id", id); + record.setField("data", data); + record.setField("ts", ts); + long tsMicros = + TimeUnit.SECONDS.toMicros(ts.toEpochSecond(ZoneOffset.UTC)) + + TimeUnit.NANOSECONDS.toMicros(ts.getNano()); + new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + .appendToTable(TestHelpers.Row.of(tsMicros), Lists.newArrayList(record)); + table.refresh(); + } + /** * For the same identifier column id this methods simulate the following row operations: *

  • add an equality delete on oldData @@ -271,7 +326,12 @@ protected void update(Table table, Integer id, String oldData, String tempData, } protected void insertPartitioned(Table table, Integer id, String data) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + insertPartitioned(table, id, data, FileFormat.PARQUET); + } + + protected void insertPartitioned(Table table, Integer id, String data, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir) .appendToTable( TestHelpers.Row.of(data), Lists.newArrayList(SimpleDataUtil.createRecord(id, data))); table.refresh(); @@ -355,15 +415,17 @@ protected static void checkUidsAreSet(StreamExecutionEnvironment env, String uid } protected static void checkSlotSharingGroupsAreSet(StreamExecutionEnvironment env, String name) { - String nameToCheck = name != null ? name : StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP; - env.getTransformations().stream() .filter( t -> !(t instanceof SinkTransformation) && !(t.getName().equals(IGNORED_OPERATOR_NAME))) .forEach( t -> { - assertThat(t.getSlotSharingGroup()).isPresent(); - assertThat(t.getSlotSharingGroup().get().getName()).isEqualTo(nameToCheck); + if (name == null) { + assertThat(t.getSlotSharingGroup()).isNotPresent(); + } else { + assertThat(t.getSlotSharingGroup()).isPresent(); + assertThat(t.getSlotSharingGroup().get().getName()).isEqualTo(name); + } }); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java index 68aaf29ac0d1..7b8f638b7e2f 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java @@ -22,10 +22,13 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Map; import java.util.Set; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses; import org.apache.iceberg.DataFile; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.flink.TableLoader; @@ -38,6 +41,11 @@ private RewriteUtil() {} static List planDataFileRewrite(TableLoader tableLoader) throws Exception { + return planDataFileRewrite(tableLoader, ImmutableMap.of(MIN_INPUT_FILES, "2")); + } + + static List planDataFileRewrite( + TableLoader tableLoader, Map rewriterOptions) throws Exception { try (OneInputStreamOperatorTestHarness testHarness = ProcessFunctionTestHarnesses.forProcessFunction( @@ -48,8 +56,9 @@ static List planDataFileRewrite(TableLoader tableLoader, 11, 10_000_000L, - ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + rewriterOptions, + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); @@ -80,6 +89,6 @@ static List executeRewrite( static Set newDataFiles(Table table) { table.refresh(); - return Sets.newHashSet(table.currentSnapshot().addedDataFiles(table.io())); + return Sets.newHashSet(SnapshotChanges.builderFor(table).build().addedDataFiles()); } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java index 9e8f2ec92162..ded958538ff0 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java @@ -31,6 +31,8 @@ import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.flink.maintenance.api.Trigger; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -225,7 +227,8 @@ private OneInputStreamOperatorTestHarness actualAdded = Sets.newHashSet(table.currentSnapshot().addedDataFiles(table.io())); - Set actualRemoved = - Sets.newHashSet(table.currentSnapshot().removedDataFiles(table.io())); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + Set actualAdded = Sets.newHashSet(changes.addedDataFiles()); + Set actualRemoved = Sets.newHashSet(changes.removedDataFiles()); assertThat(actualAdded.stream().map(DataFile::location).collect(Collectors.toSet())) .isEqualTo(expectedAdded.stream().map(DataFile::location).collect(Collectors.toSet())); assertThat(actualRemoved.stream().map(DataFile::location).collect(Collectors.toSet())) diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java index 9f4f96e1065b..8300df8c94eb 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java @@ -18,11 +18,15 @@ */ package org.apache.iceberg.flink.maintenance.operator; +import static org.apache.iceberg.actions.SizeBasedFileRewritePlanner.MAX_FILE_GROUP_INPUT_FILES; import static org.apache.iceberg.actions.SizeBasedFileRewritePlanner.MIN_INPUT_FILES; import static org.apache.iceberg.flink.maintenance.operator.RewriteUtil.newDataFiles; import static org.apache.iceberg.flink.maintenance.operator.RewriteUtil.planDataFileRewrite; import static org.assertj.core.api.Assertions.assertThat; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -32,6 +36,7 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.FileContent; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.flink.maintenance.api.Trigger; @@ -105,7 +110,8 @@ void testError() throws Exception { 11, 1L, ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); // Cause an exception @@ -171,7 +177,8 @@ void testMaxRewriteBytes() throws Exception { 11, maxRewriteBytes, ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); @@ -182,6 +189,103 @@ void testMaxRewriteBytes() throws Exception { } } + @Test + void testMaxFileGroupCount() throws Exception { + Table table = createPartitionedTable(); + insertPartitioned(table, 1, "p1"); + insertPartitioned(table, 2, "p1"); + insertPartitioned(table, 3, "p2"); + insertPartitioned(table, 4, "p2"); + insertPartitioned(table, 5, "p2"); + insertPartitioned(table, 6, "p2"); + + List planWithNoLimit = planDataFileRewrite(tableLoader()); + assertThat(planWithNoLimit).hasSize(2); + + List planWithMaxFileGroupCount = + planDataFileRewrite( + tableLoader(), ImmutableMap.of(MIN_INPUT_FILES, "2", MAX_FILE_GROUP_INPUT_FILES, "2")); + assertThat(planWithMaxFileGroupCount).hasSize(3); + } + + @Test + void testBranch() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + insert(table, 2, "b"); + + String branchName = "test-branch"; + table.manageSnapshots().createBranch(branchName).commit(); + + // Insert more data on main only + insert(table, 3, "c"); + + try (OneInputStreamOperatorTestHarness + testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new DataFileRewritePlanner( + OperatorTestBase.DUMMY_TABLE_NAME, + OperatorTestBase.DUMMY_TABLE_NAME, + 0, + tableLoader(), + 11, + 10_000_000L, + ImmutableMap.of(MIN_INPUT_FILES, "2"), + Expressions::alwaysTrue, + branchName))) { + testHarness.open(); + + trigger(testHarness); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + List planned = testHarness.extractOutputValues(); + assertThat(planned).hasSize(1); + // Branch has 2 files, main has 3 + assertThat(planned.get(0).group().fileScanTasks()).hasSize(2); + assertThat(planned.get(0).branch()).isEqualTo(branchName); + } + } + + @Test + void testFilterSupplierWithTimestamp() throws Exception { + Table table = createTableWithTimestampWithoutZone(); + + LocalDateTime oldTs = LocalDateTime.now().minusDays(10); + insertWithTimestampWithoutZone(table, 1, "old_a", oldTs); + insertWithTimestampWithoutZone(table, 2, "old_b", oldTs); + + LocalDateTime recentTs = LocalDateTime.now().minusHours(1); + insertWithTimestampWithoutZone(table, 3, "new_a", recentTs); + insertWithTimestampWithoutZone(table, 4, "new_b", recentTs); + + try (OneInputStreamOperatorTestHarness + testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new DataFileRewritePlanner( + OperatorTestBase.DUMMY_TABLE_NAME, + OperatorTestBase.DUMMY_TABLE_NAME, + 0, + tableLoader(), + 11, + 10_000_000L, + ImmutableMap.of(MIN_INPUT_FILES, "2"), + () -> + Expressions.greaterThanOrEqual( + "ts", + LocalDateTime.now(ZoneOffset.UTC).minus(Duration.ofDays(3)).toString()), + SnapshotRef.MAIN_BRANCH))) { + testHarness.open(); + + trigger(testHarness); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + List planned = testHarness.extractOutputValues(); + + assertThat(planned).hasSize(1); + assertThat(planned.get(0).group().fileScanTasks()).hasSize(2); + } + } + void assertRewriteFileGroup( DataFileRewritePlanner.PlannedGroup plannedGroup, Table table, Set files) { assertThat(plannedGroup.table().currentSnapshot().snapshotId()) diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java index 4e21c7a956e4..62b29e7c017a 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java @@ -38,6 +38,7 @@ import org.apache.iceberg.PartitionData; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.data.GenericAppenderHelper; @@ -308,7 +309,8 @@ void testSplitSize() throws Exception { "2", TARGET_FILE_SIZE_BYTES, String.valueOf(targetFileSize)), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java index bb8c74f3d5e9..8614c634f125 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java @@ -23,8 +23,10 @@ import java.util.List; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.junit.jupiter.api.Test; class TestListMetadataFiles extends OperatorTestBase { @@ -87,4 +89,37 @@ void testMetadataFilesWithEmptyTable() throws Exception { assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); } } + + @Test + void testMetadataFilesIncludesSnapshotsAddedAfterOpen() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + + try (OneInputStreamOperatorTestHarness testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new ListMetadataFiles(OperatorTestBase.DUMMY_TABLE_NAME, 0, tableLoader()))) { + testHarness.open(); + + // Add more snapshots AFTER the operator has been opened + insert(table, 2, "b"); + insert(table, 3, "c"); + + OperatorTestBase.trigger(testHarness); + + List tableMetadataFiles = testHarness.extractOutputValues(); + + // Verify that manifest lists from ALL 3 snapshots are present, not just the first one. + // Without table.refresh() in processElement, only snapshot 1's files would be emitted. + table.refresh(); + List snapshots = Lists.newArrayList(table.snapshots()); + assertThat(snapshots).hasSize(3); + for (Snapshot snapshot : snapshots) { + assertThat(tableMetadataFiles).contains(snapshot.manifestListLocation()); + } + // Verify total count matches what 3 snapshots should produce + assertThat(tableMetadataFiles).hasSize(24); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + } + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java new file mode 100644 index 000000000000..3427d2abe0ad --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.ExecutionException; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Timeout(value = 10) +@Execution(ExecutionMode.SAME_THREAD) +class TestLockRemoveCoordinator extends CoordinatorTestBase { + + private EventReceivingTasks receivingTasks; + + @BeforeEach + void before() { + this.receivingTasks = EventReceivingTasks.createForRunningTasks(); + } + + @Test + void testEventHandling() throws Exception { + try (LockRemoverCoordinator lockRemoverCoordinator = createCoordinator()) { + + lockRemoverCoordinator.start(); + + setAllTasksReady(lockRemoverCoordinator, receivingTasks); + + lockRemoverCoordinator.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(lockRemoverCoordinator.pendingReleaseEvents()).hasSize(1); + } + } + + private LockRemoverCoordinator createCoordinator() { + return new LockRemoverCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)) { + @Override + void runInCoordinatorThread(Runnable runnable, String actionString) { + try { + coordinatorExecutor().submit(runnable).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(actionString, e); + } + } + }; + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java new file mode 100644 index 000000000000..a0b615e463d9 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.FAILED_TASK_COUNTER; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.LAST_RUN_DURATION_MS; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.apache.flink.runtime.operators.coordination.MockOperatorEventGateway; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; +import org.apache.iceberg.flink.maintenance.api.TaskResult; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 10) +class TestLockRemoverOperation extends OperatorTestBase { + private static final String[] TASKS = new String[] {"task0", "task1", "task2"}; + private static final String OPERATOR_NAME = "TestCoordinator"; + private static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + + private LockRemoverCoordinator lockRemoverCoordinator; + + @BeforeEach + void before() { + MetricsReporterFactoryForTests.reset(); + this.lockRemoverCoordinator = createCoordinator(); + try { + lockRemoverCoordinator.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @AfterEach + void after() throws IOException { + super.after(); + try { + lockRemoverCoordinator.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void testProcess() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + LockRemoverOperator operator = + new LockRemoverOperator(null, mockGateway, DUMMY_TASK_NAME, Lists.newArrayList(TASKS[0])); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + testHarness.processElement( + new StreamRecord<>(new TaskResult(0, 0L, true, Lists.newArrayList()))); + assertThat(mockGateway.getEventsSent()).hasSize(0); + + testHarness.processWatermark(WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + } + } + + @Test + void testProcessMaxWaterMark() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + LockRemoverOperator operator = + new LockRemoverOperator(null, mockGateway, DUMMY_TASK_NAME, Lists.newArrayList(TASKS[0])); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + testHarness.processElement( + new StreamRecord<>(new TaskResult(0, 0L, true, Lists.newArrayList()))); + assertThat(mockGateway.getEventsSent()).hasSize(0); + + testHarness.processWatermark(WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + + testHarness.processWatermark(Watermark.MAX_WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + } + } + + @Test + void testMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = new ManualSource<>(env, TypeInformation.of(TaskResult.class)); + source + .dataStream() + .transform( + DUMMY_TASK_NAME, + TypeInformation.of(Void.class), + new LockRemoverOperatorFactory(DUMMY_TABLE_NAME, Lists.newArrayList(TASKS))) + .forceNonParallel(); + + JobClient jobClient = null; + long time = System.currentTimeMillis(); + try { + jobClient = env.executeAsync(); + // Start the 2 successful and one failed result trigger for task1, and 3 successful for task2 + processAndCheck(source, new TaskResult(0, time, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(0, time, false, Lists.newArrayList())); + processAndCheck(source, new TaskResult(0, time, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of( + DUMMY_TASK_NAME, + DUMMY_TABLE_NAME, + TASKS[1], + "1", + SUCCEEDED_TASK_COUNTER)) + .equals(3L)); + + // Final check all the counters + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", SUCCEEDED_TASK_COUNTER), + 2L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", FAILED_TASK_COUNTER), + 1L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", SUCCEEDED_TASK_COUNTER), + 3L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", FAILED_TASK_COUNTER), + 0L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", SUCCEEDED_TASK_COUNTER), + 0L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", FAILED_TASK_COUNTER), + 0L) + .build()); + + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", LAST_RUN_DURATION_MS))) + .isPositive(); + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", LAST_RUN_DURATION_MS))) + .isGreaterThan(time); + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", LAST_RUN_DURATION_MS))) + .isZero(); + } finally { + closeJobClient(jobClient); + } + } + + private void processAndCheck(ManualSource source, TaskResult input) { + List counterKey = + ImmutableList.of( + DUMMY_TASK_NAME, + DUMMY_TABLE_NAME, + TASKS[input.taskIndex()], + String.valueOf(input.taskIndex()), + input.success() ? SUCCEEDED_TASK_COUNTER : FAILED_TASK_COUNTER); + Long counterValue = MetricsReporterFactoryForTests.counter(counterKey); + Long expected = counterValue != null ? counterValue + 1 : 1L; + + source.sendRecord(input); + source.sendWatermark(input.startEpoch()); + + Awaitility.await() + .until(() -> expected.equals(MetricsReporterFactoryForTests.counter(counterKey))); + } + + private OneInputStreamOperatorTestHarness createHarness( + LockRemoverOperator lockRemoverOperator) throws Exception { + OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(lockRemoverOperator); + harness.open(); + return harness; + } + + private static LockRemoverCoordinator createCoordinator() { + return new LockRemoverCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java index c561c7054eae..34bb14336511 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java @@ -40,6 +40,7 @@ import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.data.GenericAppenderHelper; import org.apache.iceberg.data.RandomGenericData; @@ -308,7 +309,7 @@ void testSkipReplace() throws IOException { // Create a DataOperations.REPLACE snapshot DataFile dataFile = - table.snapshots().iterator().next().addedDataFiles(table.io()).iterator().next(); + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); RewriteFiles rewrite = tableLoader.loadTable().newRewrite(); // Replace the file with itself for testing purposes rewrite.deleteFile(dataFile); @@ -320,14 +321,18 @@ void testSkipReplace() throws IOException { } private static long firstFileLength(Table table) { - return table.currentSnapshot().addedDataFiles(table.io()).iterator().next().fileSizeInBytes(); + return SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .iterator() + .next() + .fileSizeInBytes(); } private static TableChange tableChangeWithLastSnapshot(Table table, TableChange previous) { - List dataFiles = - Lists.newArrayList(table.currentSnapshot().addedDataFiles(table.io()).iterator()); - List deleteFiles = - Lists.newArrayList(table.currentSnapshot().addedDeleteFiles(table.io()).iterator()); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + List dataFiles = Lists.newArrayList(changes.addedDataFiles().iterator()); + List deleteFiles = Lists.newArrayList(changes.addedDeleteFiles().iterator()); long dataSize = dataFiles.stream().mapToLong(ContentFile::fileSizeInBytes).sum(); long deleteRecordCount = deleteFiles.stream().mapToLong(DeleteFile::recordCount).sum(); diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java new file mode 100644 index 000000000000..5fc6695f3e65 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.ExecutionException; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Timeout(value = 10) +@Execution(ExecutionMode.SAME_THREAD) +class TestTriggerManagerCoordinator extends CoordinatorTestBase { + + private EventReceivingTasks receivingTasks; + private EventReceivingTasks receivingTasks1; + + @BeforeEach + void before() { + this.receivingTasks = EventReceivingTasks.createForRunningTasks(); + this.receivingTasks1 = EventReceivingTasks.createForRunningTasks(); + } + + @Test + void testEventHandling() throws Exception { + try (TriggerManagerCoordinator triggerManagerCoordinator = + createCoordinator(OPERATOR_NAME, TEST_OPERATOR_ID); + TriggerManagerCoordinator triggerManagerCoordinator1 = + createCoordinator(OPERATOR_NAME_1, TEST_OPERATOR_ID_1)) { + + triggerManagerCoordinator.start(); + triggerManagerCoordinator1.start(); + + setAllTasksReady(triggerManagerCoordinator, receivingTasks); + setAllTasksReady(triggerManagerCoordinator1, receivingTasks1); + + triggerManagerCoordinator.handleEventFromOperator(0, 0, LOCK_REGISTER_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(0); + + // release lock from coordinator1 and get one event from coordinator + triggerManagerCoordinator1.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(1); + assertThat(receivingTasks1.getSentEventsForSubtask(0).size()).isEqualTo(0); + } + } + + @Test + void testEventArriveBeforeRegister() throws Exception { + try (TriggerManagerCoordinator triggerManagerCoordinator = + createCoordinator(OPERATOR_NAME, TEST_OPERATOR_ID)) { + + triggerManagerCoordinator.start(); + setAllTasksReady(triggerManagerCoordinator, receivingTasks); + + // release event arrive before register + triggerManagerCoordinator.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(triggerManagerCoordinator.pendingReleaseEvents()).hasSize(1); + + triggerManagerCoordinator.handleEventFromOperator(0, 0, LOCK_REGISTER_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(1); + + assertThat(triggerManagerCoordinator.pendingReleaseEvents()).hasSize(0); + } + } + + private static TriggerManagerCoordinator createCoordinator( + String operatorName, OperatorID operatorID) { + return new TriggerManagerCoordinator( + operatorName, new MockOperatorCoordinatorContext(operatorID, 1)) { + @Override + void runInCoordinatorThread(Runnable runnable, String actionString) { + try { + coordinatorExecutor().submit(runnable).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(actionString, e); + } + } + }; + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java new file mode 100644 index 000000000000..1860236b0199 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java @@ -0,0 +1,673 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.CONCURRENT_RUN_THROTTLED; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.NOTHING_TO_TRIGGER; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.RATE_LIMITER_TRIGGERED; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.TRIGGERED; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.runtime.checkpoint.OperatorSubtaskState; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.apache.flink.runtime.operators.coordination.MockOperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.flink.streaming.runtime.tasks.TestProcessingTimeService; +import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TestTriggerManagerOperator extends OperatorTestBase { + private static final long DELAY = 10L; + private static final String OPERATOR_NAME = "TestCoordinator"; + private static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + private static final String[] TASKS = new String[] {"task0", "task1"}; + private long processingTime = 0L; + private String tableName; + private TriggerManagerCoordinator triggerManagerCoordinator; + private LockReleaseEvent lockReleaseEvent; + + @BeforeEach + void before() { + super.before(); + Table table = createTable(); + this.tableName = table.name(); + lockReleaseEvent = new LockReleaseEvent(tableName, 1L); + this.triggerManagerCoordinator = createCoordinator(); + try { + triggerManagerCoordinator.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @AfterEach + void after() throws IOException { + super.after(); + try { + triggerManagerCoordinator.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void testCommitCount() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + TriggerManagerOperator operator = + createOperator(new TriggerEvaluator.Builder().commitCount(3).build(), mockGateway); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 4); + } + } + + @Test + void testDataFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().dataFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(1).build(), 0); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(5).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(2).build(), 4); + } + } + + @Test + void testDataFileSizeInBytes() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().dataFileSizeInBytes(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(2L).build(), 3); + } + } + + @Test + void testPosDeleteFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().posDeleteFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 4); + } + } + + @Test + void testPosDeleteRecordCount() throws Exception { + + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().posDeleteRecordCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(2L).build(), 3); + } + } + + @Test + void testEqDeleteFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().eqDeleteFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 4); + } + } + + @Test + void testEqDeleteRecordCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().eqDeleteRecordCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(2L).build(), 3); + } + } + + @Test + void testTimeout() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().timeout(Duration.ofSeconds(1)).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + TableChange event = TableChange.builder().dataFileCount(1).commitCount(1).build(); + + // Wait for some time + testHarness.processElement(event, EVENT_TIME); + assertThat(testHarness.extractOutputValues()).isEmpty(); + + // Wait for the timeout to expire + long newTime = EVENT_TIME + Duration.ofSeconds(1).toMillis(); + testHarness.setProcessingTime(newTime); + testHarness.processElement(event, newTime); + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Remove the lock to allow the next trigger + operator.handleLockRelease(new LockReleaseEvent(tableName, newTime)); + + // Send a new event + testHarness.setProcessingTime(newTime + 1); + testHarness.processElement(event, newTime); + + // No trigger yet + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Send a new event + newTime += Duration.ofSeconds(1).toMillis(); + testHarness.setProcessingTime(newTime); + testHarness.processElement(event, newTime); + + // New trigger should arrive + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testStateRestore() throws Exception { + OperatorSubtaskState state; + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().commitCount(2).build(), new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.processElement( + TableChange.builder().dataFileCount(1).commitCount(1).build(), EVENT_TIME); + + assertThat(testHarness.extractOutputValues()).isEmpty(); + + state = testHarness.snapshot(1, EVENT_TIME); + } + + // Restore the state, write some more data, create a checkpoint, check the data which is written + TriggerManagerOperator newOperator = + createOperator( + new TriggerEvaluator.Builder().commitCount(2).build(), new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + new OneInputStreamOperatorTestHarness<>(newOperator)) { + testHarness.initializeState(state); + testHarness.open(); + + // Mock a recovery trigger lock + assertTriggers( + testHarness.extractOutputValues(), + Lists.newArrayList(Trigger.recovery(testHarness.getProcessingTime()))); + + testHarness.processElement(TableChange.builder().commitCount(1).build(), EVENT_TIME_2); + + // Remove the lock to allow the next trigger + newOperator.handleOperatorEvent(lockReleaseEvent); + testHarness.setProcessingTime(EVENT_TIME_2); + + // At this point the output contains the recovery trigger and the real trigger + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testMinFireDelay() throws Exception { + TriggerManagerOperator operator = + createOperator( + tableName, + new TriggerEvaluator.Builder().commitCount(2).build(), + new MockOperatorEventGateway(), + DELAY, + 1); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.open(); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + long currentTime = testHarness.getProcessingTime(); + + // No new fire yet + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + + // Check that the trigger fired after the delay + testHarness.setProcessingTime(currentTime + DELAY); + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testLockCheckDelay() throws Exception { + TriggerManagerOperator operator = + createOperator( + tableName, + new TriggerEvaluator.Builder().commitCount(2).build(), + new MockOperatorEventGateway(), + 1, + DELAY); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.open(); + + // Create a lock to prevent execution, and check that there is no result + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1, false); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1, false); + long currentTime = testHarness.getProcessingTime(); + + // Remove the lock, and still no trigger + operator.handleOperatorEvent(lockReleaseEvent); + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Check that the trigger fired after the delay + testHarness.setProcessingTime(currentTime + DELAY); + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testTriggerMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + TriggerManagerOperatorFactory triggerManagerOperatorFactory = + new TriggerManagerOperatorFactory( + tableName, + Lists.newArrayList(TASKS), + Lists.newArrayList( + new TriggerEvaluator.Builder().commitCount(2).build(), + new TriggerEvaluator.Builder().commitCount(4).build()), + 1L, + 1L); + source + .dataStream() + .keyBy(unused -> true) + .transform( + DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), triggerManagerOperatorFactory) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // This one doesn't trigger - tests NOTHING_TO_TRIGGER + source.sendRecord(TableChange.builder().commitCount(1).build()); + + Awaitility.await() + .until( + () -> { + Long notingCounter = + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER)); + return notingCounter != null && notingCounter.equals(1L); + }); + + // Trigger one of the tasks - tests TRIGGERED + source.sendRecord(TableChange.builder().commitCount(1).build()); + // Wait until we receive the trigger + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED))) + .isEqualTo(1L); + + // manual unlock + triggerManagerCoordinator.handleReleaseLock(new LockReleaseEvent(tableName, Long.MAX_VALUE)); + // Trigger both of the tasks - tests TRIGGERED + source.sendRecord(TableChange.builder().commitCount(2).build()); + // Wait until we receive the trigger + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // manual unlock + triggerManagerCoordinator.handleReleaseLock(new LockReleaseEvent(tableName, Long.MAX_VALUE)); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED))) + .isEqualTo(2L); + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[1], "1", TRIGGERED))) + .isEqualTo(1L); + + // Final check all the counters + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED), -1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED), -1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED), 2L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[1], "1", TRIGGERED), 1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER), 1L) + .build()); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testRateLimiterMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + // High delay, so only triggered once + TriggerManagerOperatorFactory manager = manager(1_000_000L, 1L); + + source + .dataStream() + .keyBy(unused -> true) + .transform(DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), manager) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Start the first trigger + source.sendRecord(TableChange.builder().commitCount(2).build()); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // Remove the lock to allow the next trigger + triggerManagerCoordinator.handleReleaseLock(lockReleaseEvent); + + // The second trigger will be blocked + source.sendRecord(TableChange.builder().commitCount(2).build()); + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED)) + .equals(1L)); + + // Final check all the counters + assertCounters(1L, 0L); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testConcurrentRunMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + // High delay, so only triggered once + TriggerManagerOperatorFactory manager = manager(1L, 1_000_000L); + + source + .dataStream() + .keyBy(unused -> true) + .transform(DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), manager) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Start the first trigger - notice that we do not remove the lock after the trigger + source.sendRecord(TableChange.builder().commitCount(2).build()); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // The second trigger will be blocked by the lock + source.sendRecord(TableChange.builder().commitCount(2).build()); + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED)) + .equals(1L)); + + // Final check all the counters + assertCounters(0L, 1L); + } finally { + closeJobClient(jobClient); + } + } + + private void assertCounters(long rateLimiterTrigger, long concurrentRunTrigger) { + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put( + ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED), + rateLimiterTrigger) + .put( + ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED), + concurrentRunTrigger) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED), 1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER), 0L) + .build()); + } + + private void addEventAndCheckResult( + TriggerManagerOperator operator, + OneInputStreamOperatorTestHarness testHarness, + TableChange event, + int expectedSize) + throws Exception { + addEventAndCheckResult(operator, testHarness, event, expectedSize, true); + } + + private void addEventAndCheckResult( + TriggerManagerOperator operator, + OneInputStreamOperatorTestHarness testHarness, + TableChange event, + int expectedSize, + boolean removeLock) + throws Exception { + ++processingTime; + testHarness.setProcessingTime(processingTime); + testHarness.processElement(event, processingTime); + assertThat(testHarness.extractOutputValues()).hasSize(expectedSize); + if (removeLock && operator.lockTime() != null) { + // Remove the lock to allow the next trigger + operator.handleLockRelease(new LockReleaseEvent(tableName, processingTime)); + } + } + + private TriggerManagerOperatorFactory manager(long minFireDelayMs, long lockCheckDelayMs) { + return new TriggerManagerOperatorFactory( + tableName, + Lists.newArrayList(TASKS[0]), + Lists.newArrayList(new TriggerEvaluator.Builder().commitCount(2).build()), + minFireDelayMs, + lockCheckDelayMs); + } + + private static void assertTriggers(List expected, List actual) { + assertThat(actual).hasSize(expected.size()); + for (int i = 0; i < expected.size(); ++i) { + Trigger expectedTrigger = expected.get(i); + Trigger actualTrigger = actual.get(i); + assertThat(actualTrigger.timestamp()).isEqualTo(expectedTrigger.timestamp()); + assertThat(actualTrigger.taskId()).isEqualTo(expectedTrigger.taskId()); + assertThat(actualTrigger.isRecovery()).isEqualTo(expectedTrigger.isRecovery()); + } + } + + private static TriggerManagerCoordinator createCoordinator() { + return new TriggerManagerCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)); + } + + private TriggerManagerOperator createOperator( + TriggerEvaluator evaluator, OperatorEventGateway mockGateway) { + return createOperator(tableName, evaluator, mockGateway, 1, 1); + } + + private TriggerManagerOperator createOperator( + String lockId, + TriggerEvaluator evaluator, + OperatorEventGateway mockGateway, + long minFireDelayMs, + long lockCheckDelayMs) { + StreamOperatorParameters parameters = + new StreamOperatorParameters<>( + null, null, null, TestProcessingTimeService::new, null, null); + return new TriggerManagerOperator( + parameters, + mockGateway, + Lists.newArrayList(TASKS[0]), + Lists.newArrayList(evaluator), + minFireDelayMs, + lockCheckDelayMs, + lockId); + } + + private OneInputStreamOperatorTestHarness createHarness( + TriggerManagerOperator triggerManagerOperator) throws Exception { + OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(triggerManagerOperator); + harness.open(); + return harness; + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java index da5b5f6c28f0..339cd0510efb 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java @@ -35,7 +35,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.common.DynFields; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.SimpleDataUtil; @@ -238,21 +238,21 @@ private static Map appenderProperties( testHarness.processElement(SimpleDataUtil.createRowData(1, "hello"), 1); testHarness.prepareSnapshotPreBarrier(1L); - DynFields.BoundField operatorField = + DynFields.BoundField> operatorField = DynFields.builder() .hiddenImpl(testHarness.getOperatorFactory().getClass(), "operator") .build(testHarness.getOperatorFactory()); - DynFields.BoundField writerField = + DynFields.BoundField> writerField = DynFields.builder() .hiddenImpl(IcebergStreamWriter.class, "writer") .build(operatorField.get()); - DynFields.BoundField writerFactoryField = + DynFields.BoundField> writerFactoryField = DynFields.builder() .hiddenImpl(BaseTaskWriter.class, "writerFactory") .build(writerField.get()); DynFields.BoundField> propsField = DynFields.builder() - .hiddenImpl(BaseFileWriterFactory.class, "writerProperties") + .hiddenImpl(RegistryBasedFileWriterFactory.class, "writerProperties") .build(writerFactoryField.get()); return propsField.get(); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java index fefd88cf57fe..32f8c5a7998d 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java @@ -69,15 +69,15 @@ public class TestFlinkIcebergSinkBranch extends TestFlinkIcebergSinkBase { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {"1", "main", true}, + {"1", SnapshotRef.MAIN_BRANCH, true}, {"1", "testBranch", true}, - {"2", "main", true}, + {"2", SnapshotRef.MAIN_BRANCH, true}, {"2", "testBranch", true}, // Remove after the deprecation of TableSchema - END - {"1", "main", false}, + {"1", SnapshotRef.MAIN_BRANCH, false}, {"1", "testBranch", false}, - {"2", "main", false}, + {"2", SnapshotRef.MAIN_BRANCH, false}, {"2", "testBranch", false}, }; } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java index 04bc5da6a9be..d25e0d68391d 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.HadoopCatalogExtension; @@ -279,7 +280,14 @@ public void testRangeDistributionWithoutSortOrderPartitioned() throws Exception // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -322,7 +330,14 @@ public void testRangeDistributionWithNullValue() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -367,7 +382,14 @@ public void testRangeDistributionWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -383,14 +405,24 @@ public void testRangeDistributionWithSortOrder() throws Exception { if (partitioned) { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // up to 26 partitions assertThat(addedDataFiles).hasSizeLessThanOrEqualTo(26); } } else { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -438,7 +470,14 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -456,7 +495,12 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -517,7 +561,14 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -535,7 +586,12 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column // sometimes assertThat(addedDataFiles).hasSize(writeParallelism); diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java index a5799288b5e3..8d425cb30aaf 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java @@ -47,6 +47,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkSchemaUtil; @@ -202,7 +203,14 @@ private void testParallelism(int parallelism) throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Source rate limit per checkpoint cycle may not be super precise. @@ -217,7 +225,12 @@ private void testParallelism(int parallelism) throws Exception { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); assertThat(addedDataFiles) .hasSizeLessThanOrEqualTo(maxAddedDataFilesPerCheckpoint(parallelism)); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java index ffd40b6cdc95..fac1b8226d3c 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java @@ -35,6 +35,7 @@ import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.TableProperties; import org.apache.iceberg.data.Record; @@ -242,13 +243,14 @@ public void testDeleteStats() throws Exception { false, elementsPerCheckpoint, expectedRecords, - "main"); + SnapshotRef.MAIN_BRANCH); - DeleteFile deleteFile = table.currentSnapshot().addedDeleteFiles(table.io()).iterator().next(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + DeleteFile deleteFile = changes.addedDeleteFiles().iterator().next(); String fromStat = new String( deleteFile.lowerBounds().get(MetadataColumns.DELETE_FILE_PATH.fieldId()).array()); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = changes.addedDataFiles().iterator().next(); assumeThat(fromStat).isEqualTo(dataFile.location()); } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java index 8ce3e1886f40..9117534ed63b 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java @@ -55,11 +55,25 @@ public class TestFlinkIcebergSinkV2Branch extends TestFlinkIcebergSinkV2Base { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, true, "main"}, + { + FileFormat.AVRO, + 1, + false, + TableProperties.WRITE_DISTRIBUTION_MODE_NONE, + true, + SnapshotRef.MAIN_BRANCH + }, {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, true, "testBranch"}, // Remove after the deprecation of TableSchema - END - {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, false, "main"}, + { + FileFormat.AVRO, + 1, + false, + TableProperties.WRITE_DISTRIBUTION_MODE_NONE, + false, + SnapshotRef.MAIN_BRANCH + }, { FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, false, "testBranch" }, diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java index 0feb4cc282d2..9b208283c857 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.HadoopCatalogExtension; @@ -289,7 +290,14 @@ public void testRangeDistributionWithoutSortOrderPartitioned() throws Exception // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -335,7 +343,14 @@ public void testRangeDistributionWithNullValue() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -381,7 +396,14 @@ public void testRangeDistributionWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -397,14 +419,24 @@ public void testRangeDistributionWithSortOrder() throws Exception { if (partitioned) { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // up to 26 partitions assertThat(addedDataFiles).hasSizeLessThanOrEqualTo(26); } } else { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -453,7 +485,14 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -471,7 +510,12 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -533,7 +577,14 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -551,7 +602,12 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column // sometimes assertThat(addedDataFiles).hasSize(writeParallelism); @@ -562,6 +618,39 @@ public void testRangeDistributionStatisticsMigration() throws Exception { } } + @TestTemplate + public void testHashDistributionWithPartitionNotInEqualityFields() { + assumeThat(partitioned).isTrue(); + + List rows = createRows(""); + DataStream dataStream = env.addSource(createBoundedSource(rows), ROW_TYPE_INFO); + + if (isTableSchema) { + IcebergSink.forRow(dataStream, SimpleDataUtil.FLINK_TABLE_SCHEMA) + .table(table) + .tableLoader(tableLoader) + .writeParallelism(writeParallelism) + .distributionMode(DistributionMode.HASH) + .upsert(false) + .equalityFieldColumns(ImmutableList.of("id")) + .append(); + } else { + IcebergSink.forRow(dataStream, SimpleDataUtil.FLINK_SCHEMA) + .table(table) + .tableLoader(tableLoader) + .writeParallelism(writeParallelism) + .distributionMode(DistributionMode.HASH) + .upsert(false) + .equalityFieldColumns(ImmutableList.of("id")) + .append(); + } + + assertThatThrownBy(env::execute) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "In 'hash' distribution mode with equality fields set, source column 'data' of partition field '1000: data: identity(2)' should be included in equality fields: '[id]'"); + } + private BoundedTestSource createRangeDistributionBoundedSource( List> rowsPerCheckpoint) { return new BoundedTestSource<>(rowsPerCheckpoint); diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java index abe17e710fde..d478f91498c8 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java @@ -63,6 +63,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionData; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.StructLike; import org.apache.iceberg.TestBase; import org.apache.iceberg.flink.SimpleDataUtil; @@ -95,11 +96,11 @@ public class TestIcebergFilesCommitter extends TestBase { @Parameters(name = "formatVersion = {0}, fileFormat = {1}, branch = {2}") protected static List parameters() { return Arrays.asList( - new Object[] {1, FileFormat.AVRO, "main"}, + new Object[] {1, FileFormat.AVRO, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.AVRO, "test-branch"}, - new Object[] {1, FileFormat.PARQUET, "main"}, + new Object[] {1, FileFormat.PARQUET, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.PARQUET, "test-branch"}, - new Object[] {1, FileFormat.ORC, "main"}, + new Object[] {1, FileFormat.ORC, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.ORC, "test-branch"}); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java index ee5560712657..cbacd15ae8f5 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java @@ -57,11 +57,11 @@ public class TestIcebergSinkBranch extends TestFlinkIcebergSinkBase { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {"main", true}, + {SnapshotRef.MAIN_BRANCH, true}, {"testBranch", true}, // Remove after the deprecation of TableSchema - END - {"main", false}, + {SnapshotRef.MAIN_BRANCH, false}, {"testBranch", false}, }; } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java deleted file mode 100644 index b84d21d020b3..000000000000 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java +++ /dev/null @@ -1,149 +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. - */ -package org.apache.iceberg.flink.sink; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import org.apache.flink.runtime.jobgraph.JobVertex; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraph; -import org.apache.flink.table.data.RowData; -import org.apache.flink.types.Row; -import org.apache.iceberg.DataFile; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.ManifestFiles; -import org.apache.iceberg.ManifestReader; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; -import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; -import org.apache.iceberg.flink.FlinkWriteOptions; -import org.apache.iceberg.flink.MiniFlinkClusterExtension; -import org.apache.iceberg.flink.SimpleDataUtil; -import org.apache.iceberg.flink.TestFixtures; -import org.apache.iceberg.flink.maintenance.api.LockConfig; -import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; -import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; -import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class TestIcebergSinkCompact extends TestFlinkIcebergSinkBase { - - private Map flinkConf; - - @BeforeEach - void before() throws IOException { - this.flinkConf = Maps.newHashMap(); - flinkConf.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); - flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), LockConfig.JdbcLockConfig.JDBC); - flinkConf.put( - LockConfig.JdbcLockConfig.JDBC_URI_OPTION.key(), - "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", "")); - flinkConf.put(LockConfig.LOCK_ID_OPTION.key(), "test-lock-id"); - flinkConf.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); - - flinkConf.put(LockConfig.JdbcLockConfig.JDBC_INIT_LOCK_TABLE_OPTION.key(), "true"); - flinkConf.put(RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); - - table = - CATALOG_EXTENSION - .catalog() - .createTable( - TestFixtures.TABLE_IDENTIFIER, - SimpleDataUtil.SCHEMA, - PartitionSpec.unpartitioned(), - Maps.newHashMap()); - - env = - StreamExecutionEnvironment.getExecutionEnvironment( - MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG) - .enableCheckpointing(100); - - tableLoader = CATALOG_EXTENSION.tableLoader(); - } - - @Test - public void testCompactFileE2e() throws Exception { - List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); - DataStream dataStream = - env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) - .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); - - IcebergSink.forRowData(dataStream) - .setAll(flinkConf) - .table(table) - .tableLoader(tableLoader) - .append(); - - env.execute("Test Iceberg Compaction DataStream"); - - table.refresh(); - // check the data file count after compact - List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); - assertThat(afterCompactDataFiles).hasSize(1); - - // check the data file count before compact - List preCompactDataFiles = - getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); - assertThat(preCompactDataFiles).hasSize(3); - } - - private List getDataFiles(Snapshot snapshot, Table table) throws IOException { - List dataFiles = Lists.newArrayList(); - for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { - try (ManifestReader reader = ManifestFiles.read(dataManifest, table.io())) { - reader.iterator().forEachRemaining(dataFiles::add); - } - } - - return dataFiles; - } - - @Test - public void testTableMaintenanceOperatorAdded() { - List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); - DataStream dataStream = - env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) - .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); - - IcebergSink.forRowData(dataStream) - .table(table) - .tableLoader(tableLoader) - .setAll(flinkConf) - .append(); - - boolean containRewrite = false; - StreamGraph streamGraph = env.getStreamGraph(); - for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { - if (vertex.getName().contains("Rewrite")) { - containRewrite = true; - break; - } - } - - assertThat(containRewrite).isTrue(); - } -} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java new file mode 100644 index 000000000000..5c926d7c25d5 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java @@ -0,0 +1,321 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.flink.runtime.jobgraph.JobVertex; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.graph.StreamGraph; +import org.apache.flink.table.data.RowData; +import org.apache.flink.types.Row; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.flink.MiniFlinkClusterExtension; +import org.apache.iceberg.flink.SimpleDataUtil; +import org.apache.iceberg.flink.TestFixtures; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; +import org.apache.iceberg.flink.maintenance.api.LockConfig; +import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; +import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +class TestIcebergSinkTableMaintenance extends TestFlinkIcebergSinkBase { + private static final String[] LOCK_TYPES = new String[] {LockConfig.JdbcLockConfig.JDBC, ""}; + + private Map flinkConf; + + @BeforeEach + void before() throws IOException { + this.flinkConf = Maps.newHashMap(); + + table = + CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SimpleDataUtil.SCHEMA, + PartitionSpec.unpartitioned(), + Maps.newHashMap()); + + env = + StreamExecutionEnvironment.getExecutionEnvironment( + MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG) + .enableCheckpointing(100); + + tableLoader = CATALOG_EXTENSION.tableLoader(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testCompactFileE2e(String lockType) throws Exception { + setupLockConfig(lockType); + setupCompactionConfig(); + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .setAll(flinkConf) + .table(table) + .tableLoader(tableLoader) + .append(); + + env.execute("Test Iceberg Compaction DataStream"); + + table.refresh(); + // check the data file count after compact + List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); + assertThat(afterCompactDataFiles).hasSize(1); + + // check the data file count before compact + List preCompactDataFiles = + getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); + assertThat(preCompactDataFiles).hasSize(3); + } + + private List getDataFiles(Snapshot snapshot, Table table) throws IOException { + List dataFiles = Lists.newArrayList(); + for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { + try (ManifestReader reader = + ManifestFiles.read(dataManifest, table.io(), table.specs())) { + reader.iterator().forEachRemaining(dataFiles::add); + } + } + + return dataFiles; + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testTableMaintenanceOperatorAdded(String lockType) { + setupLockConfig(lockType); + setupCompactionConfig(); + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + boolean containRewrite = false; + StreamGraph streamGraph = env.getStreamGraph(); + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Rewrite")) { + containRewrite = true; + break; + } + } + + assertThat(containRewrite).isTrue(); + } + + private void setupCompactionConfig() { + flinkConf.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); + flinkConf.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); + flinkConf.put(RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); + } + + private void setupExpireSnapshotsConfig() { + flinkConf.put(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key(), "true"); + flinkConf.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + } + + private void setupDeleteOrphanFilesConfig() { + flinkConf.put(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key(), "true"); + flinkConf.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + } + + private void setupLockConfig(String lockType) { + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), LockConfig.JdbcLockConfig.JDBC); + flinkConf.put( + LockConfig.JdbcLockConfig.JDBC_URI_OPTION.key(), + "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", "")); + flinkConf.put(LockConfig.LOCK_ID_OPTION.key(), "test-lock-id"); + flinkConf.put(LockConfig.JdbcLockConfig.JDBC_INIT_LOCK_TABLE_OPTION.key(), "true"); + } else { + flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), ""); + } + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testExpireSnapshotsEnabled(String lockType) { + setupLockConfig(lockType); + setupExpireSnapshotsConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containExpire = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Expire")) { + containExpire = true; + break; + } + } + + assertThat(containExpire).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testDeleteOrphanFilesEnabled(String lockType) { + setupLockConfig(lockType); + setupDeleteOrphanFilesConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containOrphan = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Orphan")) { + containOrphan = true; + break; + } + } + + assertThat(containOrphan).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testAllMaintenanceTasksCombined(String lockType) { + setupLockConfig(lockType); + setupCompactionConfig(); + setupExpireSnapshotsConfig(); + setupDeleteOrphanFilesConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containRewrite = false; + boolean containExpire = false; + boolean containOrphan = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Rewrite")) { + containRewrite = true; + } + + if (vertex.getName().contains("Expire")) { + containExpire = true; + } + + if (vertex.getName().contains("Orphan")) { + containOrphan = true; + } + } + + assertThat(containRewrite).isTrue(); + assertThat(containExpire).isTrue(); + assertThat(containOrphan).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testAllMaintenanceE2e(String lockType) throws Exception { + setupLockConfig(lockType); + + Map compactionConfig = Maps.newHashMap(); + compactionConfig.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); + compactionConfig.put( + RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); + + Map expireConfig = Maps.newHashMap(); + expireConfig.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + + Map orphanConfig = Maps.newHashMap(); + orphanConfig.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .setAll(flinkConf) + .table(table) + .tableLoader(tableLoader) + .rewriteDataFiles(compactionConfig) + .expireSnapshots(expireConfig) + .deleteOrphanFiles(orphanConfig) + .append(); + + env.execute("Test All Maintenance E2E"); + + table.refresh(); + // Compaction should have merged the 3 data files into 1 + List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); + assertThat(afterCompactDataFiles).hasSize(1); + + List preCompactDataFiles = + getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); + assertThat(preCompactDataFiles).hasSize(3); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java index f873dcd99c06..71cec082a09e 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java @@ -36,6 +36,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.TableProperties; import org.apache.iceberg.data.Record; @@ -225,13 +226,14 @@ public void testDeleteStats() throws Exception { false, elementsPerCheckpoint, expectedRecords, - "main"); + SnapshotRef.MAIN_BRANCH); - DeleteFile deleteFile = table.currentSnapshot().addedDeleteFiles(table.io()).iterator().next(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + DeleteFile deleteFile = changes.addedDeleteFiles().iterator().next(); String fromStat = new String( deleteFile.lowerBounds().get(MetadataColumns.DELETE_FILE_PATH.fieldId()).array()); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = changes.addedDataFiles().iterator().next(); assumeThat(fromStat).isEqualTo(dataFile.location()); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java new file mode 100644 index 000000000000..42bbfc0d3628 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; +import org.apache.iceberg.io.WriteResult; +import org.junit.jupiter.api.Test; + +public class TestIcebergStreamWriterMetrics { + + @Test + void histogramsCreatedWhenDropwizardAvailable() { + IcebergStreamWriterMetrics metrics = + new IcebergStreamWriterMetrics( + UnregisteredMetricsGroup.createSinkWriterMetricGroup(), "db.table"); + + assertThat(metrics.dataFilesSizeHistogram()).isNotNull(); + assertThat(metrics.deleteFilesSizeHistogram()).isNotNull(); + + assertThatNoException() + .isThrownBy(() -> metrics.updateFlushResult(WriteResult.builder().build())); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java index 919fef579ab0..2e7e6db176b2 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java @@ -57,7 +57,11 @@ public class TestRowDataPartitionKey { Types.NestedField.required(12, "decimalType2", Types.DecimalType.of(10, 5)), Types.NestedField.required(13, "decimalType3", Types.DecimalType.of(38, 19)), Types.NestedField.required(14, "floatType", Types.FloatType.get()), - Types.NestedField.required(15, "doubleType", Types.DoubleType.get())); + Types.NestedField.required(15, "doubleType", Types.DoubleType.get()), + Types.NestedField.required( + 16, "timestampNanoWithoutZone", Types.TimestampNanoType.withoutZone()), + Types.NestedField.required( + 17, "timestampNanoWithZone", Types.TimestampNanoType.withZone())); private static final List SUPPORTED_PRIMITIVES = SCHEMA.asStruct().fields().stream().map(Types.NestedField::name).collect(Collectors.toList()); @@ -248,4 +252,47 @@ public void testNestedPartitionValues() { } } } + + @Test + public void testTimestampNanoPartitionTransforms() { + RowType rowType = FlinkSchemaUtil.convert(SCHEMA); + RowDataWrapper rowWrapper = new RowDataWrapper(rowType, SCHEMA.asStruct()); + InternalRecordWrapper recordWrapper = new InternalRecordWrapper(SCHEMA.asStruct()); + + List records = RandomGenericData.generate(SCHEMA, 10, 1995); + List rows = Lists.newArrayList(RandomRowData.convert(SCHEMA, records)); + + String[] columns = {"timestampNanoWithoutZone", "timestampNanoWithZone"}; + for (String column : columns) { + List specs = + Lists.newArrayList( + PartitionSpec.builderFor(SCHEMA).identity(column).build(), + PartitionSpec.builderFor(SCHEMA).year(column).build(), + PartitionSpec.builderFor(SCHEMA).month(column).build(), + PartitionSpec.builderFor(SCHEMA).day(column).build(), + PartitionSpec.builderFor(SCHEMA).hour(column).build(), + PartitionSpec.builderFor(SCHEMA).bucket(column, 16).build()); + + for (PartitionSpec spec : specs) { + Class[] javaClasses = spec.javaClasses(); + PartitionKey pk = new PartitionKey(spec, SCHEMA); + PartitionKey expectedPK = new PartitionKey(spec, SCHEMA); + + for (int j = 0; j < rows.size(); j++) { + pk.partition(rowWrapper.wrap(rows.get(j))); + expectedPK.partition(recordWrapper.wrap(records.get(j))); + + assertThat(pk.size()).isEqualTo(1); + assertThat(pk.get(0, javaClasses[0])) + .as( + "Partition with column " + + column + + " and spec " + + spec + + " should match Iceberg-side computation") + .isEqualTo(expectedPK.get(0, javaClasses[0])); + } + } + } + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java index 30782e8d4170..07096b891a95 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java @@ -55,15 +55,19 @@ abstract class DynamicRecordInternalSerializerTestBase static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).bucket("id", 10).build(); private boolean writeFullSchemaAndSpec; + private final boolean writeLongUTF; - DynamicRecordInternalSerializerTestBase(boolean writeFullSchemaAndSpec) { + DynamicRecordInternalSerializerTestBase(boolean writeFullSchemaAndSpec, boolean writeLongUTF) { this.writeFullSchemaAndSpec = writeFullSchemaAndSpec; + this.writeLongUTF = writeLongUTF; } @Override protected TypeSerializer createSerializer() { return new DynamicRecordInternalSerializer( - new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), writeFullSchemaAndSpec); + new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), + writeFullSchemaAndSpec, + writeLongUTF); } @BeforeEach diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java index 385a354889fb..9e4d600f9325 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java @@ -33,6 +33,12 @@ class TestCompareSchemasVisitor { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + @Test void testSchema() { assertThat( @@ -44,7 +50,9 @@ void testSchema() { new Schema( optional(1, "id", IntegerType.get(), "comment"), optional(2, "data", StringType.get()), - optional(3, "extra", StringType.get())))) + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -59,7 +67,9 @@ void testSchemaDifferentId() { new Schema( optional(1, "id", IntegerType.get()), optional(2, "data", StringType.get()), - optional(3, "extra", StringType.get())))) + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -72,7 +82,9 @@ void testSchemaDifferent() { optional(1, "data", StringType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get())))) + optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -85,7 +97,9 @@ void testSchemaWithMoreColumns() { new Schema( optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get()), - optional(2, "extra", StringType.get())))) + optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -96,7 +110,9 @@ void testDifferentType() { new Schema( optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())))) + optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -107,7 +123,9 @@ void testCompatibleType() { new Schema( optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())))) + optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -117,9 +135,11 @@ void testRequiredChangeForMatchingField() { new Schema(optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); Schema tableSchema = new Schema(required(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); - assertThat(CompareSchemasVisitor.visit(tableSchema, dataSchema)) + assertThat( + CompareSchemasVisitor.visit(tableSchema, dataSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -128,9 +148,11 @@ void testRequiredChangeForNonMatchingField() { Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); Schema tableSchema = new Schema(optional(1, "id", IntegerType.get()), required(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); - assertThat(CompareSchemasVisitor.visit(tableSchema, dataSchema)) + assertThat( + CompareSchemasVisitor.visit(tableSchema, dataSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -139,7 +161,8 @@ void testNoRequiredChangeForNonMatchingField() { Schema dataSchema = new Schema(required(1, "id", IntegerType.get())); Schema tableSchema = new Schema(required(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -152,8 +175,9 @@ void testStructDifferentId() { optional(2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))), new Schema( optional(0, "id", IntegerType.get()), - optional( - 1, "struct1", StructType.of(optional(2, "extra", IntegerType.get())))))) + optional(1, "struct1", StructType.of(optional(2, "extra", IntegerType.get())))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -166,8 +190,9 @@ void testStructChanged() { optional(1, "struct1", StructType.of(optional(2, "extra", LongType.get())))), new Schema( optional(1, "id", IntegerType.get()), - optional( - 2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))))) + optional(2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -182,7 +207,9 @@ void testMapDifferentId() { new Schema( optional(0, "id", IntegerType.get()), optional( - 1, "map1", MapType.ofOptional(2, 3, IntegerType.get(), StringType.get()))))) + 1, "map1", MapType.ofOptional(2, 3, IntegerType.get(), StringType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -197,7 +224,9 @@ void testMapChanged() { new Schema( optional(1, "id", IntegerType.get()), optional( - 2, "map1", MapType.ofOptional(3, 4, IntegerType.get(), StringType.get()))))) + 2, "map1", MapType.ofOptional(3, 4, IntegerType.get(), StringType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -210,7 +239,9 @@ void testListDifferentId() { optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))), new Schema( optional(0, "id", IntegerType.get()), - optional(1, "list1", ListType.ofOptional(2, IntegerType.get()))))) + optional(1, "list1", ListType.ofOptional(2, IntegerType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -223,7 +254,133 @@ void testListChanged() { optional(1, "list1", ListType.ofOptional(2, LongType.get()))), new Schema( optional(1, "id", IntegerType.get()), - optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))))) + optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testCaseInsensitiveFieldMatching() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "Data", StringType.get()), + optional(3, "EXTRA", StringType.get())), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testCaseSensitiveFieldMatchingDefault() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "Data", StringType.get()), + optional(3, "EXTRA", StringType.get())), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testCaseInsensitiveNestedStruct() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "STRUCT1", StructType.of(optional(3, "NESTED", StringType.get())))), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "struct1", StructType.of(optional(3, "nested", StringType.get())))), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testCaseInsensitiveWithMoreColumns() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(0, "ID", IntegerType.get()), optional(1, "DATA", StringType.get())), + new Schema( + optional(0, "id", IntegerType.get()), + optional(1, "data", StringType.get()), + optional(2, "extra", StringType.get())), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); + } + + @Test + void testDropUnusedColumnsEnabled() { + Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); + Schema tableSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } + + @Test + void testDropUnusedColumnsWithRequiredField() { + Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); + Schema tableSchema = + new Schema(optional(1, "id", IntegerType.get()), required(2, "data", StringType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testDropUnusedColumnsWhenInputHasMoreFields() { + Schema dataSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())); + Schema tableSchema = new Schema(optional(1, "id", IntegerType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testDropUnusedColumnsInNestedStruct() { + Schema dataSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "struct1", StructType.of(optional(3, "field1", StringType.get())))); + Schema tableSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional( + 2, + "struct1", + StructType.of( + optional(3, "field1", StringType.get()), + optional(4, "field2", IntegerType.get())))); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java index 13a06d362717..16890d1f63d0 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java @@ -21,42 +21,94 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.Set; import org.apache.flink.api.common.JobID; +import org.apache.flink.core.memory.DataOutputViewStreamWrapper; import org.apache.flink.runtime.jobgraph.OperatorID; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.junit.jupiter.api.Test; class TestDynamicCommittableSerializer { + private static final DynamicCommittable COMMITTABLE = + new DynamicCommittable( + new TableKey("table", "branch"), + new byte[][] {{3, 4}, {5, 6}}, + JobID.generate().toHexString(), + new OperatorID().toHexString(), + 5); @Test - void testRoundtrip() throws IOException { - DynamicCommittable committable = + void testV1() throws IOException { + var committable = new DynamicCommittable( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - new byte[] {3, 4}, + new TableKey("table", "branch"), + new byte[][] {{3, 4}}, JobID.generate().toHexString(), new OperatorID().toHexString(), 5); - DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); - assertThat(serializer.deserialize(serializer.getVersion(), serializer.serialize(committable))) - .isEqualTo(committable); + assertThat(serializer.deserialize(1, serializeV1(committable))).isEqualTo(committable); } @Test - void testUnsupportedVersion() throws IOException { - DynamicCommittable committable = - new DynamicCommittable( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - new byte[] {3, 4}, - JobID.generate().toHexString(), - new OperatorID().toHexString(), - 5); + void testLatestVersion() throws IOException { + DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); + assertThat(serializer.deserialize(serializer.getVersion(), serializer.serialize(COMMITTABLE))) + .isEqualTo(COMMITTABLE); + } + @Test + void testUnsupportedVersion() { DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); - assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(committable))) + assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(COMMITTABLE))) .hasMessage("Unrecognized version or corrupt state: -1") .isInstanceOf(IOException.class); } + + byte[] serializeV1(DynamicCommittable committable) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); + + // Wrap TableKey into a testing WriteTarget to match the V1 format + WriteTarget writeTarget = + new WriteTarget( + committable.key().tableName(), + committable.key().branch(), + -1, + -1, + false, + Set.of(1, 2, 3)); + view.write(serializeV1(writeTarget)); + + view.writeUTF(committable.jobId()); + view.writeUTF(committable.operatorId()); + view.writeLong(committable.checkpointId()); + + Preconditions.checkArgument( + committable.manifests().length == 1, + "V1 serialization format must have only one manifest per committable."); + view.writeInt(committable.manifests()[0].length); + view.write(committable.manifests()[0]); + + return out.toByteArray(); + } + + byte[] serializeV1(WriteTarget writeTarget) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); + + view.writeUTF(writeTarget.tableName()); + view.writeUTF(writeTarget.branch()); + view.writeInt(writeTarget.schemaId()); + view.writeInt(writeTarget.specId()); + view.writeBoolean(writeTarget.upsertMode()); + view.writeInt(writeTarget.equalityFields().size()); + for (Integer equalityField : writeTarget.equalityFields()) { + view.writeInt(equalityField); + } + + return out.toByteArray(); + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java index 1497458e6083..4cc27151b094 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java @@ -54,6 +54,7 @@ import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; @@ -125,6 +126,15 @@ class TestDynamicCommitter { .ofPositionDeletes() .build(); + private static final Map> WRITE_RESULT_BY_SPEC = + Map.of( + DATA_FILE.specId(), + Lists.newArrayList(WriteResult.builder().addDataFiles(DATA_FILE).build())); + private static final Map> WRITE_RESULT_BY_SPEC_2 = + Map.of( + DATA_FILE_2.specId(), + Lists.newArrayList(WriteResult.builder().addDataFiles(DATA_FILE_2).build())); + @BeforeEach void before() { catalog = CATALOG_EXTENSION.catalog(); @@ -155,12 +165,9 @@ void testCommit() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget1 = - new WriteTarget(TABLE1, "branch", 42, 0, true, Sets.newHashSet(1, 2)); - WriteTarget writeTarget2 = - new WriteTarget(TABLE1, "branch2", 43, 0, true, Sets.newHashSet(1, 2)); - WriteTarget writeTarget3 = - new WriteTarget(TABLE2, "branch2", 43, 0, true, Sets.newHashSet(1, 2)); + TableKey tableKey1 = new TableKey(TABLE1, "branch"); + TableKey tableKey2 = new TableKey(TABLE1, "branch2"); + TableKey tableKey3 = new TableKey(TABLE2, "branch2"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -168,27 +175,12 @@ void testCommit() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget1, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget2, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget3, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests2 = + aggregator.writeToManifests(tableKey2.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey3.tableName(), WRITE_RESULT_BY_SPEC, 0); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); @@ -196,15 +188,15 @@ void testCommit() throws Exception { CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey1, deltaManifests1, jobId, operatorId, checkpointId)); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey2, deltaManifests2, jobId, operatorId, checkpointId)); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey3, deltaManifests3, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest1, commitRequest2, commitRequest3)); @@ -267,7 +259,7 @@ void testCommit() throws Exception { } @Test - void testAlreadyCommitted() throws Exception { + void testSkipsCommitRequestsForPreviousCheckpoints() throws Exception { Table table1 = catalog.loadTable(TableIdentifier.of(TABLE1)); assertThat(table1.snapshots()).isEmpty(); @@ -285,8 +277,7 @@ void testAlreadyCommitted() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -298,24 +289,18 @@ void testAlreadyCommitted() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); CommitRequest oldCommitRequest = new MockCommitRequest<>( - new DynamicCommittable( - writeTarget, deltaManifest, jobId, operatorId, checkpointId - 1)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId - 1)); // Old commits requests shouldn't affect the result dynamicCommitter.commit(Sets.newHashSet(oldCommitRequest)); @@ -360,25 +345,23 @@ void testCommitDeleteInDifferentFormatVersion() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); - DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); OneInputStreamOperatorTestHarness aggregatorHarness = new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); + TableKey tableKey = new TableKey(TABLE1, "branch"); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, + byte[][] deltaManifests = + aggregator.writeToManifests( + tableKey.tableName(), + Map.of( + DATA_FILE.specId(), + Sets.newHashSet( WriteResult.builder() .addDataFiles(DATA_FILE) .addDeleteFiles(DELETE_FILE) @@ -387,7 +370,7 @@ void testCommitDeleteInDifferentFormatVersion() throws Exception { CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); // Upgrade the table version UpdateProperties updateApi = table1.updateProperties(); @@ -421,8 +404,7 @@ void testCommitOnlyDataInDifferentFormatVersion() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -434,17 +416,12 @@ void testCommitOnlyDataInDifferentFormatVersion() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); @@ -486,50 +463,34 @@ void testTableBranchAtomicCommitForAppendOnlyData() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - WriteTarget writeTarget1 = - new WriteTarget(TABLE1, "branch1", 42, 0, true, Sets.newHashSet(1, 2)); - // writeTarget2 has a different schema - WriteTarget writeTarget2 = new WriteTarget(TABLE1, "branch1", 23, 0, true, Sets.newHashSet()); - // Different branch for writeTarget3 - WriteTarget writeTarget3 = new WriteTarget(TABLE1, "branch2", 23, 0, true, Sets.newHashSet()); - - WriteResult writeResult1 = WriteResult.builder().addDataFiles(DATA_FILE).build(); - WriteResult writeResult2 = WriteResult.builder().addDataFiles(DATA_FILE_2).build(); + TableKey tableKey1 = new TableKey(TABLE1, "branch1"); + TableKey tableKey2 = new TableKey(TABLE1, "branch2"); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId1 = 1; final int checkpointId2 = 2; - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet(new DynamicWriteResult(writeTarget1, writeResult1)), - checkpointId1); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC, checkpointId1); CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId1)); + new DynamicCommittable(tableKey1, deltaManifests1, jobId, operatorId, checkpointId1)); - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet(new DynamicWriteResult(writeTarget2, writeResult2)), - checkpointId1); + byte[][] deltaManifests2 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId1); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId1)); + new DynamicCommittable(tableKey1, deltaManifests2, jobId, operatorId, checkpointId1)); - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet(new DynamicWriteResult(writeTarget3, writeResult2)), - checkpointId2); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey2.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId2); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId2)); + new DynamicCommittable(tableKey2, deltaManifests3, jobId, operatorId, checkpointId2)); boolean overwriteMode = false; int workerPoolSize = 1; @@ -601,49 +562,36 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - WriteTarget writeTarget1 = new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet()); - // writeTarget2 has a different schema - WriteTarget writeTarget2 = new WriteTarget(TABLE1, "branch", 23, 0, false, Sets.newHashSet()); - WriteTarget writeTarget3 = new WriteTarget(TABLE1, "branch", 23, 0, false, Sets.newHashSet()); + TableKey tableKey = new TableKey(TABLE1, "branch"); + + Map> writeResults = + Map.of( + DELETE_FILE.specId(), + Lists.newArrayList(WriteResult.builder().addDeleteFiles(DELETE_FILE).build())); - WriteResult writeResult1 = WriteResult.builder().addDataFiles(DATA_FILE).build(); - WriteResult writeResult2 = WriteResult.builder().addDeleteFiles(DELETE_FILE).build(); - WriteResult writeResult3 = WriteResult.builder().addDataFiles(DATA_FILE).build(); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests2 = aggregator.writeToManifests(tableKey.tableName(), writeResults, 0); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC_2, 0); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId1 = 1; final int checkpointId2 = 2; - - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet(new DynamicWriteResult(writeTarget1, writeResult1)), - checkpointId1); + final int checkpointId3 = 3; CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId1)); - - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet(new DynamicWriteResult(writeTarget2, writeResult2)), - checkpointId2); + new DynamicCommittable(tableKey, deltaManifests1, jobId, operatorId, checkpointId1)); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId2)); - - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet(new DynamicWriteResult(writeTarget3, writeResult3)), - checkpointId2); + new DynamicCommittable(tableKey, deltaManifests2, jobId, operatorId, checkpointId2)); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId2)); + new DynamicCommittable(tableKey, deltaManifests3, jobId, operatorId, checkpointId3)); boolean overwriteMode = false; int workerPoolSize = 1; @@ -693,10 +641,7 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { } table.refresh(); - // Three committables, but only two snapshots! WriteResults from different checkpoints are not - // getting - // combined due to one writeResult2 containing a delete file. - assertThat(table.snapshots()).hasSize(2); + assertThat(table.snapshots()).hasSize(3); Snapshot snapshot1 = Iterables.getFirst(table.snapshots(), null); assertThat(snapshot1.summary()) @@ -720,21 +665,90 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { assertThat(snapshot2.summary()) .containsAllEntriesOf( ImmutableMap.builder() - .put("added-data-files", "1") - .put("added-records", "42") .put("changed-partition-count", "1") .put("flink.job-id", jobId) .put("flink.max-committed-checkpoint-id", "" + checkpointId2) .put("flink.operator-id", operatorId) + .put("total-data-files", "1") + .put("total-delete-files", "1") + .put("total-equality-deletes", "0") + .put("total-files-size", "0") + .put("total-position-deletes", "24") + .put("total-records", "42") + .build()); + + Snapshot snapshot3 = Iterables.get(table.snapshots(), 2); + assertThat(snapshot3.summary()) + .containsAllEntriesOf( + ImmutableMap.builder() + .put("added-data-files", "1") + .put("added-records", "24") + .put("changed-partition-count", "1") + .put("flink.job-id", jobId) + .put("flink.max-committed-checkpoint-id", "" + checkpointId3) + .put("flink.operator-id", operatorId) .put("total-data-files", "2") .put("total-delete-files", "1") .put("total-equality-deletes", "0") .put("total-files-size", "0") .put("total-position-deletes", "24") - .put("total-records", "84") + .put("total-records", "66") .build()); } + @Test + void testCommitDeltaTxnWithAppendFiles() throws Exception { + Table table = catalog.loadTable(TableIdentifier.of(TABLE1)); + assertThat(table.snapshots()).isEmpty(); + + DynamicWriteResultAggregator aggregator = + new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); + OneInputStreamOperatorTestHarness aggregatorHarness = + new OneInputStreamOperatorTestHarness(aggregator); + aggregatorHarness.open(); + + TableKey tableKey = new TableKey(TABLE1, "branch1"); + final String jobId = JobID.generate().toHexString(); + final String operatorId = new OperatorID().toHexString(); + final int checkpointId = 1; + + byte[][] deltaManifest1 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); + + CommitRequest commitRequest1 = + new MockCommitRequest<>( + new DynamicCommittable(tableKey, deltaManifest1, jobId, operatorId, checkpointId)); + + byte[][] deltaManifest2 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId); + + CommitRequest commitRequest2 = + new MockCommitRequest<>( + new DynamicCommittable(tableKey, deltaManifest2, jobId, operatorId, checkpointId)); + + boolean overwriteMode = false; + int workerPoolSize = 1; + String sinkId = "sinkId"; + UnregisteredMetricsGroup metricGroup = new UnregisteredMetricsGroup(); + DynamicCommitterMetrics committerMetrics = new DynamicCommitterMetrics(metricGroup); + DynamicCommitter dynamicCommitter = + new DynamicCommitter( + CATALOG_EXTENSION.catalog(), + Maps.newHashMap(), + overwriteMode, + workerPoolSize, + sinkId, + committerMetrics); + + dynamicCommitter.commit(Sets.newHashSet(commitRequest1, commitRequest2)); + + table.refresh(); + assertThat(table.snapshots()).hasSize(1); + + Snapshot snapshot = Iterables.getFirst(table.snapshots(), null); + assertThat(snapshot.operation()).isEqualTo("append"); + } + @Test void testReplacePartitions() throws Exception { Table table1 = catalog.loadTable(TableIdentifier.of(TABLE1)); @@ -755,8 +769,7 @@ void testReplacePartitions() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -768,32 +781,22 @@ void testReplacePartitions() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); - byte[] overwriteManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId + 1); + byte[][] overwriteManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest overwriteRequest = new MockCommitRequest<>( new DynamicCommittable( - writeTarget, overwriteManifest, jobId, operatorId, checkpointId + 1)); + tableKey, overwriteManifests, jobId, operatorId, checkpointId + 1)); dynamicCommitter.commit(Sets.newHashSet(overwriteRequest)); @@ -836,18 +839,13 @@ void testThrowsValidationExceptionOnDuplicateCommit(boolean overwriteMode) throw final int checkpointId = 1; final String branch = SnapshotRef.MAIN_BRANCH; - WriteTarget writeTarget = new WriteTarget(TABLE1, branch, 42, 0, false, Sets.newHashSet(1, 2)); - byte[] manifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + TableKey tableKey = new TableKey(TABLE1, branch); + byte[][] manifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, manifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, manifests, jobId, operatorId, checkpointId)); Collection> commitRequests = Sets.newHashSet(commitRequest1); int workerPoolSize = 1; diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java index b660d8e285d9..bafd0276b7ce 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java @@ -29,8 +29,11 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import javax.annotation.Nullable; import org.apache.flink.api.common.typeinfo.TypeHint; import org.apache.flink.api.common.typeinfo.TypeInformation; @@ -41,8 +44,12 @@ import org.apache.flink.configuration.RestartStrategyOptions; import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; import org.apache.flink.runtime.client.JobExecutionException; +import org.apache.flink.runtime.jobgraph.JobVertex; +import org.apache.flink.streaming.api.connector.sink2.CommittableMessage; import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.datastream.DataStreamSource; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.graph.StreamNode; import org.apache.flink.table.catalog.ResolvedSchema; import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.util.DataFormatConverters; @@ -58,6 +65,8 @@ import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.Catalog; @@ -68,6 +77,7 @@ import org.apache.iceberg.flink.CatalogLoader; import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.MiniFlinkClusterExtension; import org.apache.iceberg.flink.SimpleDataUtil; import org.apache.iceberg.flink.TestHelpers; @@ -76,8 +86,10 @@ import org.apache.iceberg.flink.sink.dynamic.TestDynamicCommitter.FailBeforeAndAfterCommit; import org.apache.iceberg.inmemory.InMemoryInputFile; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -111,6 +123,7 @@ private static class DynamicIcebergDataImpl implements Serializable { PartitionSpec partitionSpec; boolean upsertMode; Set equalityFields; + int writeParallelism; private DynamicIcebergDataImpl( Schema schemaProvided, String tableName, String branch, PartitionSpec partitionSpec) { @@ -122,7 +135,8 @@ private DynamicIcebergDataImpl( partitionSpec, false, Collections.emptySet(), - false); + false, + 10); } private DynamicIcebergDataImpl( @@ -139,7 +153,8 @@ private DynamicIcebergDataImpl( partitionSpec, false, Collections.emptySet(), - false); + false, + 10); } private DynamicIcebergDataImpl( @@ -158,7 +173,8 @@ private DynamicIcebergDataImpl( partitionSpec, upsertMode, equalityFields, - isDuplicate); + isDuplicate, + 10); } private DynamicIcebergDataImpl( @@ -169,7 +185,8 @@ private DynamicIcebergDataImpl( PartitionSpec partitionSpec, boolean upsertMode, Set equalityFields, - boolean isDuplicate) { + boolean isDuplicate, + int writeParallelism) { this.rowProvided = randomRow(schemaProvided, isDuplicate ? seed : ++seed); this.rowExpected = isDuplicate ? null : rowProvided; this.schemaProvided = schemaProvided; @@ -179,6 +196,7 @@ private DynamicIcebergDataImpl( this.partitionSpec = partitionSpec; this.upsertMode = upsertMode; this.equalityFields = equalityFields; + this.writeParallelism = writeParallelism; } } @@ -198,6 +216,56 @@ public void generate(DynamicIcebergDataImpl row, Collector out) { converter(schema).toInternal(row.rowProvided), spec, spec.isPartitioned() ? DistributionMode.HASH : DistributionMode.NONE, + row.writeParallelism); + dynamicRecord.setUpsertMode(row.upsertMode); + dynamicRecord.setEqualityFields(row.equalityFields); + out.collect(dynamicRecord); + } + } + + /** Generator that always emits forward (null distributionMode) records. */ + private static class ForwardGenerator implements DynamicRecordGenerator { + @Override + public void generate(DynamicIcebergDataImpl row, Collector out) { + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, row.tableName); + Schema schema = row.schemaProvided; + PartitionSpec spec = row.partitionSpec; + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + row.branch, + schema, + converter(schema).toInternal(row.rowProvided), + spec); + dynamicRecord.setUpsertMode(row.upsertMode); + dynamicRecord.setEqualityFields(row.equalityFields); + out.collect(dynamicRecord); + } + } + + /** + * Generator that alternates between forward (null distributionMode) and shuffle records. Even + * indices go forward, odd indices go through shuffle. + */ + private static class MixedGenerator implements DynamicRecordGenerator { + private int count = 0; + + @Override + public void generate(DynamicIcebergDataImpl row, Collector out) { + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, row.tableName); + Schema schema = row.schemaProvided; + PartitionSpec spec = row.partitionSpec; + boolean forward = (count++ % 2 == 0); + DistributionMode mode = + forward ? null : (spec.isPartitioned() ? DistributionMode.HASH : DistributionMode.NONE); + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + row.branch, + schema, + converter(schema).toInternal(row.rowProvided), + spec, + mode, 10); dynamicRecord.setUpsertMode(row.upsertMode); dynamicRecord.setEqualityFields(row.equalityFields); @@ -217,24 +285,122 @@ void testWrite() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } + @Test + void testNoShuffleTopology() throws Exception { + DataStream dataStream = + env.fromData( + Collections.emptyList(), TypeInformation.of(new TypeHint() {})); + DynamicIcebergSink.forInput(dataStream) + .generator(new ForwardGenerator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(false) + .overwrite(false) + .append(); + + boolean generatorAndSinkChained = false; + for (JobVertex vertex : env.getStreamGraph().getJobGraph().getVertices()) { + String vertexName = vertex.getName(); + boolean generatorInThisVertex = vertexName.contains("-generator"); + boolean sinkInThisVertex = vertexName.contains("-Forward-Writer"); + + generatorAndSinkChained = generatorInThisVertex && sinkInThisVertex; + if (generatorAndSinkChained) { + break; + } + } + + assertThat(generatorAndSinkChained).isTrue(); + } + + @Test + void testForwardWrite() throws Exception { + runForwardWriteTest(new ForwardGenerator()); + } + + @Test + void testMixedForwardAndShuffleWrite() throws Exception { + runForwardWriteTest(new MixedGenerator()); + } + + private void runForwardWriteTest(DynamicRecordGenerator generator) + throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(1); + + DynamicIcebergSink.forInput(dataStream) + .generator(generator) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(1) + .immediateTableUpdate(true) + .append(); + + env.execute(); + + verifyResults(rows); + } + + @Test + void testWriteWithNullBranch() throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, "t1", null, PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, "t1", null, PartitionSpec.unpartitioned())); + + runTest( + rows, this.env, false, 1, ImmutableMap.of(FlinkWriteOptions.BRANCH.key(), "test-branch")); + } + @Test void testWritePartitioned() throws Exception { PartitionSpec spec = PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).bucket("id", 10).build(); List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec)); + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec)); runTest(rows); } @@ -257,11 +423,11 @@ void testWritePartitionedAdjustSchemaIdsInSpec() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema2, "t1", "main", spec2), - new DynamicIcebergDataImpl(schema2, "t1", "main", spec2)); + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema2, "t1", SnapshotRef.MAIN_BRANCH, spec2), + new DynamicIcebergDataImpl(schema2, "t1", SnapshotRef.MAIN_BRANCH, spec2)); runTest(rows); } @@ -291,15 +457,35 @@ void testSchemaEvolutionFieldOrderChanges() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema2, expectedSchema2, "t1", "main", PartitionSpec.unpartitioned()), + schema2, + expectedSchema2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema2, expectedSchema2, "t1", "main", PartitionSpec.unpartitioned())); + schema2, + expectedSchema2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); for (DynamicIcebergDataImpl row : rows) { if (row.schemaExpected == expectedSchema) { @@ -316,9 +502,15 @@ void testMultipleTables() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -329,8 +521,8 @@ void testMultipleTablesPartitioned() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t2", "main", spec)); + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t2", SnapshotRef.MAIN_BRANCH, spec)); runTest(rows); } @@ -340,9 +532,15 @@ void testSchemaEvolutionAddField() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); } @@ -352,9 +550,15 @@ void testRowEvolutionNullMissingOptionalField() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); } @@ -379,7 +583,7 @@ void testRowEvolutionMakeMissingRequiredFieldOptional() throws Exception { writeSchemaWithoutRequiredField, existingSchemaWithRequiredField, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); @@ -393,9 +597,10 @@ void testSchemaEvolutionNonBackwardsCompatible() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(initialSchema, "t1", "main", PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - erroringSchema, "t1", "main", PartitionSpec.unpartitioned())); + initialSchema, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + erroringSchema, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); try { runTest(rows, StreamExecutionEnvironment.getExecutionEnvironment(), 1); @@ -416,20 +621,45 @@ void testPartitionSpecEvolution() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2)); + SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2)); runTest(rows); + + // Validate the table has expected partition specs + Table table = CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1")); + Map tableSpecs = table.specs(); + List expectedSpecs = List.of(spec1, spec2, PartitionSpec.unpartitioned()); + + assertThat(tableSpecs).hasSize(expectedSpecs.size()); + expectedSpecs.forEach( + expectedSpec -> + assertThat( + tableSpecs.values().stream() + .anyMatch( + spec -> PartitionSpecEvolution.checkCompatibility(spec, expectedSpec))) + .withFailMessage("Table spec not found: %s.", expectedSpec) + .isTrue()); } @Test @@ -439,7 +669,10 @@ void testMultipleBranches() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", "branch1", PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -449,23 +682,50 @@ void testWriteMultipleTablesWithSchemaChanges() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -478,7 +738,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -487,7 +747,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -495,7 +755,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -503,7 +763,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -547,7 +807,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -556,7 +816,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -564,7 +824,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -572,7 +832,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -625,7 +885,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -634,7 +894,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -642,7 +902,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -650,7 +910,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -662,7 +922,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -671,7 +931,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -679,7 +939,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -687,7 +947,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -745,9 +1005,15 @@ void testCommitFailedBeforeOrAfterCommit() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); final CommitHook commitHook = new FailBeforeAndAfterCommit(); assertThat(FailBeforeAndAfterCommit.failedBeforeCommit).isFalse(); @@ -769,9 +1035,15 @@ void testCommitConcurrency() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); TableIdentifier tableIdentifier = TableIdentifier.of("default", "t1"); Catalog catalog = CATALOG_EXTENSION.catalog(); @@ -789,9 +1061,15 @@ void testCommitsOnceWhenConcurrentDuplicateCommit(boolean overwriteMode) throws List records = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, tableId.name(), "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + tableId.name(), + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, tableId.name(), "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + tableId.name(), + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); CommitHook duplicateCommit = new DuplicateCommitHook( @@ -822,6 +1100,334 @@ void testCommitsOnceWhenConcurrentDuplicateCommit(boolean overwriteMode) throws assertThat(totalAddedRecords).isEqualTo(records.size()); } + @Test + void testCommitsOncePerTableBranchAndCheckpoint() throws Exception { + String tableName = "t1"; + String branch = SnapshotRef.MAIN_BRANCH; + PartitionSpec spec1 = PartitionSpec.unpartitioned(); + PartitionSpec spec2 = PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).bucket("id", 10).build(); + Set equalityFields = Sets.newHashSet("id"); + + List inputRecords = + Lists.newArrayList( + // Two schemas + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA2, tableName, branch, spec1), + // Two specs + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec2), + // Some upserts + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, tableName, branch, spec1, true, equalityFields, false), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, tableName, branch, spec1, true, equalityFields, true)); + + executeDynamicSink(inputRecords, env, true, 1, null); + + List actualRecords; + try (CloseableIterable iterable = + IcebergGenerics.read( + CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of("default", "t1"))) + .build()) { + actualRecords = Lists.newArrayList(iterable); + } + + // Validate records + int expectedRecords = inputRecords.size() - 1; // 1 duplicate + assertThat(actualRecords).hasSize(expectedRecords); + + for (int i = 0; i < expectedRecords; i++) { + Record actual = actualRecords.get(0); + assertThat(inputRecords) + .anySatisfy( + inputRecord -> { + assertThat(actual.get(0)).isEqualTo(inputRecord.rowProvided.getField(0)); + assertThat(actual.get(1)).isEqualTo(inputRecord.rowProvided.getField(1)); + if (inputRecord.schemaProvided.equals(SimpleDataUtil.SCHEMA2)) { + assertThat(actual.get(2)).isEqualTo(inputRecord.rowProvided.getField(2)); + } + // There is an additional _pos field which gets added + }); + } + + TableIdentifier tableIdentifier = TableIdentifier.of("default", tableName); + Table table = CATALOG_EXTENSION.catalog().loadTable(tableIdentifier); + + Snapshot lastSnapshot = Iterables.getLast(table.snapshots()); + assertThat(lastSnapshot).isNotNull(); + assertThat(lastSnapshot.summary()) + .containsAllEntriesOf( + ImmutableMap.builder() + .put("total-delete-files", "2") + .put("total-records", "6") + .build()); + + // Count commits per checkpoint + Map commitsPerCheckpoint = + StreamSupport.stream(table.snapshots().spliterator(), false) + .map(snapshot -> snapshot.summary().get("flink.max-committed-checkpoint-id")) + .filter(Objects::nonNull) + .map(Long::parseLong) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + assertThat(commitsPerCheckpoint.values()).allMatch(count -> count == 1); + } + + @Test + void testOptInDropUnusedColumns() throws Exception { + Schema schema1 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "extra", Types.StringType.get())); + + Schema schema2 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, "t1"); + catalog.createTable(tableIdentifier, schema1); + + List rows = + Lists.newArrayList( + // Drop columns + new DynamicIcebergDataImpl( + schema2, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned()), + // Re-add columns + new DynamicIcebergDataImpl( + schema1, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(1); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .immediateTableUpdate(true) + .dropUnusedColumns(true) + .append(); + + env.execute("Test Drop Unused Columns"); + + Table table = catalog.loadTable(tableIdentifier); + table.refresh(); + + assertThat(table.schema().columns()).hasSize(2); + assertThat(table.schema().findField("id")).isNotNull(); + assertThat(table.schema().findField("data")).isNotNull(); + assertThat(table.schema().findField("extra")).isNull(); + + List records = Lists.newArrayList(IcebergGenerics.read(table).build()); + assertThat(records).hasSize(2); + } + + @Test + void testCaseInsensitiveSchemaMatching() throws Exception { + Schema lowerCaseSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema upperCaseSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + Schema mixedCaseSchema = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get())); + + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + lowerCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + upperCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + mixedCaseSchema, "t1", "main", PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(2); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(true) + .caseSensitive(false) + .append(); + + env.execute("Test Case Insensitive Iceberg DataStream"); + + verifyResults(rows); + } + + @Test + void testCaseSensitiveSchemaMatchingCreatesNewFields() throws Exception { + Schema lowerCaseSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema upperCaseSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + lowerCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + upperCaseSchema, "t1", "main", PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(2); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(true) + .caseSensitive(true) + .append(); + + env.execute("Test Case Sensitive Iceberg DataStream"); + + Table table = CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1")); + Schema resultSchema = table.schema(); + assertThat(resultSchema.columns()).hasSize(4); + assertThat(resultSchema.findField("id")).isNotNull(); + assertThat(resultSchema.findField("ID")).isNotNull(); + assertThat(resultSchema.findField("data")).isNotNull(); + assertThat(resultSchema.findField("DATA")).isNotNull(); + } + + @Test + void testOperatorUidsAreDeterministic() { + assertThat(createSinkAndReturnUIds("test")).isEqualTo(createSinkAndReturnUIds("test")); + assertThat(createSinkAndReturnUIds("test")) + .doesNotContainAnyElementsOf(createSinkAndReturnUIds("test2")); + } + + @Test + void testOperatorUidsFormat() { + Set sinkUids = createSinkAndReturnUIds("test"); + // These look odd, but we need to keep the UIDs consistent. We had a bug where the UID of the + // pre commit topology was off, but since it is stateless, users will still be able to restore + // state, but we must keep the stateful operators UUIds like the committer consistent. + assertThat(sinkUids) + .containsOnly( + "test--sink", + "test--forward-writer", + "test--generator", + "test--updater", + "test--sink: test--pre-commit-topology", + "Sink Committer: test--sink"); + + sinkUids = createSinkAndReturnUIds(""); + assertThat(sinkUids) + .containsOnly( + "--sink", + "--forward-writer", + "--generator", + "--updater", + "--sink: --pre-commit-topology", + "Sink Committer: --sink"); + + sinkUids = createSinkAndReturnUIds(null); + assertThat(sinkUids) + .containsOnly( + "--sink", + "--forward-writer", + "--generator", + "--updater", + "--sink: --pre-commit-topology", + "Sink Committer: --sink"); + } + + @Test + void testGeneratorDefaultParallelism() { + StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + streamEnv.setParallelism(4); + + DataStreamSource source = + streamEnv.fromData(Collections.emptySet(), TypeInformation.of(new TypeHint<>() {})); + source.setParallelism(8); + + DynamicIcebergSink.forInput(source) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .uidPrefix("test") + .append(); + + // Since the generator parallelism is not directly accessible via the returned DataStreamSink, + // inspect the stream graph to verify the generator inherits the input source parallelism. + int generatorParallelism = + streamEnv.getStreamGraph().getStreamNodes().stream() + .filter(node -> "test--generator".equals(node.getTransformationUID())) + .findFirst() + .map(StreamNode::getParallelism) + .orElseThrow(() -> new AssertionError("Generator node not found")); + + assertThat(generatorParallelism).isEqualTo(source.getParallelism()); + } + + @Test + void testFallBackParallelismFromConfig() throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned(), + false, + Collections.emptySet(), + false, + -1), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned(), + false, + Collections.emptySet(), + false, + 0)); + + runTest( + rows, this.env, true, 2, ImmutableMap.of(FlinkWriteOptions.WRITE_PARALLELISM.key(), "1")); + } + + private Set createSinkAndReturnUIds(String uidPrefix) { + StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + + DataStreamSource source = + streamEnv.fromData(Collections.emptySet(), TypeInformation.of(new TypeHint<>() {})); + source.uid("source"); + + DynamicIcebergSink.forInput(source) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .uidPrefix(uidPrefix) + .append(); + + // Make sure to get the expanded graph with all sink nodes + return streamEnv.getStreamGraph().getStreamNodes().stream() + .map(StreamNode::getTransformationUID) + // We are not interested in the source + .filter(uid -> !uid.equals("source")) + .collect(Collectors.toSet()); + } + /** * Represents a concurrent duplicate commit during an ongoing commit operation, which can happen * in production scenarios when using REST catalog. @@ -909,6 +1515,18 @@ private void runTest( verifyResults(dynamicData); } + private void runTest( + List dynamicData, + StreamExecutionEnvironment env, + boolean immediateUpdate, + int parallelism, + Map writeProperties) + throws Exception { + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, null, false, writeProperties); + verifyResults(dynamicData, writeProperties); + } + private void executeDynamicSink( List dynamicData, StreamExecutionEnvironment env, @@ -916,7 +1534,8 @@ private void executeDynamicSink( int parallelism, @Nullable CommitHook commitHook) throws Exception { - executeDynamicSink(dynamicData, env, immediateUpdate, parallelism, commitHook, false); + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, commitHook, false, Maps.newHashMap()); } private void executeDynamicSink( @@ -927,6 +1546,19 @@ private void executeDynamicSink( @Nullable CommitHook commitHook, boolean overwrite) throws Exception { + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, commitHook, overwrite, Maps.newHashMap()); + } + + private void executeDynamicSink( + List dynamicData, + StreamExecutionEnvironment env, + boolean immediateUpdate, + int parallelism, + @Nullable CommitHook commitHook, + boolean overwrite, + Map writeProperties) + throws Exception { DataStream dataStream = env.fromData(dynamicData, TypeInformation.of(new TypeHint<>() {})); env.setParallelism(parallelism); @@ -940,6 +1572,7 @@ private void executeDynamicSink( .immediateTableUpdate(immediateUpdate) .setSnapshotProperty("commit.retry.num-retries", "0") .overwrite(overwrite) + .setAll(writeProperties) .append(); } else { DynamicIcebergSink.forInput(dataStream) @@ -948,6 +1581,7 @@ private void executeDynamicSink( .writeParallelism(parallelism) .immediateTableUpdate(immediateUpdate) .overwrite(overwrite) + .setAll(writeProperties) .append(); } @@ -964,15 +1598,17 @@ static class CommitHookEnabledDynamicIcebergSink extends DynamicIcebergSink.B @Override DynamicIcebergSink instantiateSink( - Map writeProperties, FlinkWriteConf flinkWriteConf) { + Map writeProperties, + Configuration flinkConfig, + DataStream> forwardWriteResults) { return new CommitHookDynamicIcebergSink( commitHook, CATALOG_EXTENSION.catalogLoader(), Collections.emptyMap(), "uidPrefix", writeProperties, - flinkWriteConf, - 100); + flinkConfig, + forwardWriteResults); } } @@ -987,17 +1623,18 @@ static class CommitHookDynamicIcebergSink extends DynamicIcebergSink { Map snapshotProperties, String uidPrefix, Map writeProperties, - FlinkWriteConf flinkWriteConf, - int cacheMaximumSize) { + Configuration flinkConfig, + DataStream> forwardWritten) { super( catalogLoader, snapshotProperties, uidPrefix, writeProperties, - flinkWriteConf, - cacheMaximumSize); + flinkConfig, + 100, + forwardWritten); this.commitHook = commitHook; - this.overwriteMode = flinkWriteConf.overwriteMode(); + this.overwriteMode = new FlinkWriteConf(writeProperties, flinkConfig).overwriteMode(); } @Override @@ -1014,6 +1651,12 @@ public Committer createCommitter(CommitterInitContext contex } private void verifyResults(List dynamicData) throws IOException { + verifyResults(dynamicData, Maps.newHashMap()); + } + + private void verifyResults( + List dynamicData, Map writeProperties) + throws IOException { // Calculate the expected result Map, List> expectedData = Maps.newHashMap(); Map expectedSchema = Maps.newHashMap(); @@ -1027,9 +1670,12 @@ private void verifyResults(List dynamicData) throws IOEx dynamicData.forEach( r -> { + String branch = + MoreObjects.firstNonNull( + r.branch, writeProperties.get(FlinkWriteOptions.BRANCH.key())); List data = expectedData.computeIfAbsent( - Tuple2.of(r.tableName, r.branch), unused -> Lists.newArrayList()); + Tuple2.of(r.tableName, branch), unused -> Lists.newArrayList()); data.addAll( convertToRowData(expectedSchema.get(r.tableName), ImmutableList.of(r.rowExpected))); }); diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java index ae5b2f67120b..81f6a4fb803a 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java @@ -20,7 +20,6 @@ import static org.apache.iceberg.flink.TestFixtures.DATABASE; import static org.apache.iceberg.flink.TestFixtures.TABLE; -import static org.apache.iceberg.flink.sink.dynamic.DynamicCommitter.MAX_CONTINUOUS_EMPTY_COMMITS; import java.util.List; import java.util.function.Consumer; @@ -39,6 +38,8 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.data.RandomGenericData; @@ -122,12 +123,9 @@ void before() { CATALOG_EXTENSION .catalog() .createTable( - IDENTIFIERS[i], - SCHEMA, - PartitionSpec.unpartitioned(), - ImmutableMap.of(MAX_CONTINUOUS_EMPTY_COMMITS, "100000")); + IDENTIFIERS[i], SCHEMA, PartitionSpec.unpartitioned(), ImmutableMap.of()); - table.manageSnapshots().createBranch("main").commit(); + table.manageSnapshots().createBranch(SnapshotRef.MAIN_BRANCH).commit(); } List records = RandomGenericData.generate(SCHEMA, SAMPLE_SIZE, 1L); @@ -136,7 +134,7 @@ void before() { rows.add( new DynamicRecord( IDENTIFIERS[i % TABLE_NUM], - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA, RowDataConverter.convert(SCHEMA, records.get(i)), PartitionSpec.unpartitioned(), @@ -228,7 +226,8 @@ private void runTest(Consumer> sink) throws Exception { Table table = CATALOG_EXTENSION.catalog().loadTable(identifier); for (Snapshot snapshot : table.snapshots()) { long records = 0; - for (DataFile dataFile : snapshot.addedDataFiles(table.io())) { + for (DataFile dataFile : + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) { records += dataFile.recordCount(); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java new file mode 100644 index 000000000000..388ff303339c --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import org.apache.flink.api.common.typeutils.TypeSerializer; +import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot; +import org.apache.flink.core.memory.DataInputDeserializer; +import org.apache.flink.core.memory.DataOutputSerializer; +import org.apache.iceberg.flink.HadoopCatalogExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class TestDynamicRecordInternalSerializer { + + @RegisterExtension + static final HadoopCatalogExtension CATALOG_EXTENSION = new HadoopCatalogExtension("db", "table"); + + @Test + void testCurrentTypeSerializerSnapshotVersion() { + TypeSerializer serializer = createSerializer(); + assertThat(serializer).isNotNull().isInstanceOf(DynamicRecordInternalSerializer.class); + TypeSerializerSnapshot snapshot = serializer.snapshotConfiguration(); + assertThat(snapshot.getCurrentVersion()).isEqualTo(1); + } + + @Test + void testCurrentTypeSerializerSnapshotCompatibility() { + TypeSerializer serializer = createSerializer(); + assertThat(serializer).isNotNull().isInstanceOf(DynamicRecordInternalSerializer.class); + TypeSerializerSnapshot snapshot = serializer.snapshotConfiguration(); + assertThat( + snapshot + .resolveSchemaCompatibility(serializer.snapshotConfiguration()) + .isCompatibleAsIs()) + .isTrue(); + } + + @Test + void testRestoreFromOldVersion() throws IOException { + // Create a serialized snapshot of the TypeSerializer + final int oldVersion = 0; + OldTypeSerializerSnapshot oldTypeSerializerSnapshot = new OldTypeSerializerSnapshot(oldVersion); + assertThat(oldTypeSerializerSnapshot.getCurrentVersion()).isEqualTo(oldVersion); + DataOutputSerializer dataOutputSerializer = new DataOutputSerializer(128); + oldTypeSerializerSnapshot.writeSnapshot(dataOutputSerializer); + + // Load the serialized state + DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot restoreSnapshot = + (DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot) + createSerializer().snapshotConfiguration(); + restoreSnapshot.readSnapshot( + oldVersion, + new DataInputDeserializer(dataOutputSerializer.getSharedBuffer()), + getClass().getClassLoader()); + // Check that it matches the original one + assertThat(restoreSnapshot.getCurrentVersion()).isEqualTo(oldVersion); + assertThat( + restoreSnapshot + .resolveSchemaCompatibility(oldTypeSerializerSnapshot) + .isCompatibleAsIs()) + .isTrue(); + TypeSerializer restoreSerializer = restoreSnapshot.restoreSerializer(); + assertThat(restoreSerializer).isInstanceOf(DynamicRecordInternalSerializer.class); + assertThat(((DynamicRecordInternalSerializer) restoreSerializer).getSerializerCache()) + .isNotNull(); + + // Compare against the latest version of a snapshot + TypeSerializerSnapshot latestVersion = + createSerializer().snapshotConfiguration(); + assertThat(latestVersion.getCurrentVersion()).isEqualTo(1); + assertThat( + latestVersion + .resolveSchemaCompatibility(oldTypeSerializerSnapshot) + .isCompatibleAfterMigration()) + .isTrue(); + assertThat( + latestVersion.resolveSchemaCompatibility(restoreSnapshot).isCompatibleAfterMigration()) + .isTrue(); + } + + private DynamicRecordInternalSerializer createSerializer() { + return new DynamicRecordInternalSerializer( + new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), true); + } + + private static class OldTypeSerializerSnapshot + extends DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot { + + private final int version; + + OldTypeSerializerSnapshot(int version) { + this.version = version; + } + + @Override + public int getCurrentVersion() { + return version; + } + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java index ab8ce98c3594..e7aa2d24d9d0 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java @@ -18,11 +18,11 @@ */ package org.apache.iceberg.flink.sink.dynamic; -/** Test writing DynamicRecord with the full schema */ +/** Test writing DynamicRecord with the full schema and standard UTF encoding */ class TestDynamicRecordInternalSerializerWriteSchema extends DynamicRecordInternalSerializerTestBase { TestDynamicRecordInternalSerializerWriteSchema() { - super(true); + super(true /* writeFullSchemaAndSpec */, false /* writeLongUTF */); } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java index 1d8890546214..bff0fd5c6aad 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java @@ -18,11 +18,11 @@ */ package org.apache.iceberg.flink.sink.dynamic; -/** Test writing DynamicRecord with only the schema id. */ +/** Test writing DynamicRecord with only the schema id and standard UTF encoding */ class TestDynamicRecordInternalSerializerWriteSchemaId extends DynamicRecordInternalSerializerTestBase { TestDynamicRecordInternalSerializerWriteSchemaId() { - super(false); + super(false /* writeFullSchemaAndSpec */, false /* writeLongUTF */); } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java new file mode 100644 index 000000000000..7a1ae3df3806 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +/** Test writing DynamicRecord with only the schema id and long UTF encoding */ +class TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF + extends DynamicRecordInternalSerializerTestBase { + + TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF() { + super(false /* writeFullSchemaAndSpec */, true /* writeLongUTF */); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java new file mode 100644 index 000000000000..faff8921db5d --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +/** Test writing DynamicRecord with the full schema and long UTF encoding */ +class TestDynamicRecordInternalSerializerWriteSchemaLongUTF + extends DynamicRecordInternalSerializerTestBase { + + TestDynamicRecordInternalSerializerWriteSchemaLongUTF() { + super(true /* writeFullSchemaAndSpec */, true /* writeLongUTF */); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java new file mode 100644 index 000000000000..de55621475ed --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.Set; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +class TestDynamicRecordWithConfig { + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + + private static final TableIdentifier TABLE_IDENTIFIER = TableIdentifier.of("db", "table"); + private static final PartitionSpec UNPARTITIONED = PartitionSpec.unpartitioned(); + private static final RowData ROW_DATA = GenericRowData.of(1, StringData.fromString("test")); + + @Test + void testBranchFallBack() { + String defaultBranch = "default-branch"; + FlinkWriteConf conf = + new FlinkWriteConf( + ImmutableMap.of(FlinkWriteOptions.BRANCH.key(), defaultBranch), new Configuration()); + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(conf); + + DynamicRecord dynamicRecord = + new DynamicRecord(TABLE_IDENTIFIER, null, SCHEMA, ROW_DATA, UNPARTITIONED); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).branch()).isEqualTo(defaultBranch); + + String customBranch = "custom-branch"; + dynamicRecord.setBranch(customBranch); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).branch()).isEqualTo(customBranch); + } + + @Test + void testWriteParallelismFallBack() { + int defaultParallelism = 4; + FlinkWriteConf conf = + new FlinkWriteConf( + ImmutableMap.of( + FlinkWriteOptions.WRITE_PARALLELISM.key(), String.valueOf(defaultParallelism)), + new Configuration()); + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(conf); + + DynamicRecord dynamicRecord = + new DynamicRecord(TABLE_IDENTIFIER, null, SCHEMA, ROW_DATA, UNPARTITIONED, null, -1); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()) + .isEqualTo(defaultParallelism); + + dynamicRecord.writeParallelism(0); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()) + .isEqualTo(defaultParallelism); + + dynamicRecord.writeParallelism(8); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()).isEqualTo(8); + } + + @Test + void testDelegatesToWrappedRecord() { + FlinkWriteConf conf = new FlinkWriteConf(Collections.emptyMap(), new Configuration()); + PartitionSpec partitioned = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + Set equalityFields = ImmutableSet.of("id", "data"); + + DynamicRecord dynamicRecord = + new DynamicRecord( + TABLE_IDENTIFIER, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + ROW_DATA, + partitioned, + DistributionMode.HASH, + 2); + dynamicRecord.setUpsertMode(true); + dynamicRecord.setEqualityFields(equalityFields); + + DynamicRecordWithConfig record = new DynamicRecordWithConfig(conf).wrap(dynamicRecord); + + assertThat(record.tableIdentifier()).isEqualTo(TABLE_IDENTIFIER); + assertThat(record.schema()).isEqualTo(SCHEMA); + assertThat(record.spec()).isEqualTo(partitioned); + assertThat(record.rowData()).isSameAs(ROW_DATA); + assertThat(record.distributionMode()).isEqualTo(DistributionMode.HASH); + assertThat(record.upsertMode()).isTrue(); + assertThat(record.equalityFields()).isEqualTo(equalityFields); + } +} diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java index 5ee766231b9d..fdc12951264e 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java @@ -24,18 +24,28 @@ import java.util.Collections; import org.apache.flink.api.common.functions.OpenContext; +import org.apache.flink.configuration.Configuration; import org.apache.flink.table.data.GenericRowData; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.HadoopCatalogExtension; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class TestDynamicTableUpdateOperator { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + @RegisterExtension private static final HadoopCatalogExtension CATALOG_EXTENSION = new HadoopCatalogExtension(DATABASE, TABLE); @@ -50,9 +60,6 @@ class TestDynamicTableUpdateOperator { @Test void testDynamicTableUpdateOperatorNewTable() throws Exception { - int cacheMaximumSize = 10; - int cacheRefreshMs = 1000; - int inputSchemaCacheMaximumSize = 10; Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier table = TableIdentifier.of(TABLE); @@ -60,10 +67,8 @@ void testDynamicTableUpdateOperatorNewTable() throws Exception { DynamicTableUpdateOperator operator = new DynamicTableUpdateOperator( CATALOG_EXTENSION.catalogLoader(), - cacheMaximumSize, - cacheRefreshMs, - inputSchemaCacheMaximumSize, - TableCreator.DEFAULT); + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); operator.open((OpenContext) null); DynamicRecordInternal input = @@ -84,19 +89,14 @@ void testDynamicTableUpdateOperatorNewTable() throws Exception { @Test void testDynamicTableUpdateOperatorSchemaChange() throws Exception { - int cacheMaximumSize = 10; - int cacheRefreshMs = 1000; - int inputSchemaCacheMaximumSize = 10; Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier table = TableIdentifier.of(TABLE); DynamicTableUpdateOperator operator = new DynamicTableUpdateOperator( CATALOG_EXTENSION.catalogLoader(), - cacheMaximumSize, - cacheRefreshMs, - inputSchemaCacheMaximumSize, - TableCreator.DEFAULT); + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); operator.open((OpenContext) null); catalog.createTable(table, SCHEMA1); @@ -120,4 +120,126 @@ void testDynamicTableUpdateOperatorSchemaChange() throws Exception { assertThat(output2).isEqualTo(output); assertThat(catalog.loadTable(table).schema().schemaId()).isEqualTo(output.schema().schemaId()); } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testCaseInSensitivity(boolean caseSensitive) throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + Schema initialSchema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + Schema caseSensitiveSchema = + new Schema(Types.NestedField.required(1, "Id", Types.IntegerType.get())); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(caseSensitive, PRESERVE_COLUMNS)); + operator.open((OpenContext) null); + + catalog.createTable(table, initialSchema); + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + caseSensitiveSchema, + GenericRowData.of(1, "test"), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + if (caseSensitive) { + // Schema changes due to case sensitivity + Schema expectedSchema = + new Schema( + Types.NestedField.optional(2, "Id", Types.IntegerType.get()), + Types.NestedField.optional(1, "id", Types.IntegerType.get())); + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.sameSchema(expectedSchema)).isTrue(); + assertThat(output.schema().sameSchema(expectedSchema)).isTrue(); + } else { + // No schema change due to case insensitivity + assertThat(catalog.loadTable(table).schema().sameSchema(initialSchema)).isTrue(); + assertThat(output.schema().sameSchema(initialSchema)).isTrue(); + } + } + + @Test + void testDynamicTableUpdateOperatorPreserveUnusedColumns() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); + operator.open((OpenContext) null); + + catalog.createTable(table, SCHEMA2); + + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + SCHEMA1, + GenericRowData.of(1), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.columns()).hasSize(2); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNotNull(); + assertThat(tableSchema.findField("data").isOptional()).isTrue(); + assertThat(input).isEqualTo(output); + } + + @Test + void testDynamicTableUpdateOperatorDropUnusedColumns() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_INSENSITIVE, DROP_COLUMNS)); + operator.open((OpenContext) null); + + catalog.createTable(table, SCHEMA2); + + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + SCHEMA1, + GenericRowData.of(1), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.columns()).hasSize(1); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNull(); + assertThat(input).isEqualTo(output); + } + + private static FlinkDynamicSinkConf flinkDynamicSinkConfiguration( + boolean caseSensitive, boolean dropUnusedColumns) { + return new FlinkDynamicSinkConf( + ImmutableMap.of( + FlinkDynamicSinkOptions.CASE_SENSITIVE.key(), String.valueOf(caseSensitive), + FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key(), String.valueOf(dropUnusedColumns)), + new Configuration()); + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java index 17a4d98b2938..f68e8dae174e 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java @@ -27,6 +27,7 @@ import org.apache.flink.streaming.api.connector.sink2.CommittableMessage; import org.apache.flink.streaming.api.connector.sink2.CommittableSummary; import org.apache.flink.streaming.api.connector.sink2.CommittableWithLineage; +import org.apache.flink.streaming.api.connector.sink2.SinkV2Assertions; import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.iceberg.DataFile; @@ -58,7 +59,7 @@ class TestDynamicWriteResultAggregator { .build(); @Test - void testAggregator() throws Exception { + void testAggregatesWriteResultsForTwoTables() throws Exception { CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table"), new Schema()); CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table2"), new Schema()); @@ -69,13 +70,12 @@ void testAggregator() throws Exception { testHarness = new OneInputStreamOperatorTestHarness<>(aggregator)) { testHarness.open(); - WriteTarget writeTarget1 = new WriteTarget("table", "branch", 42, 0, true, Sets.newHashSet()); + TableKey tableKey1 = new TableKey("table", "branch"); DynamicWriteResult dynamicWriteResult1 = - new DynamicWriteResult(writeTarget1, WriteResult.builder().build()); - WriteTarget writeTarget2 = - new WriteTarget("table2", "branch", 42, 0, true, Sets.newHashSet(1, 2)); + new DynamicWriteResult(tableKey1, -1, WriteResult.builder().build()); + TableKey tableKey2 = new TableKey("table2", "branch"); DynamicWriteResult dynamicWriteResult2 = - new DynamicWriteResult(writeTarget2, WriteResult.builder().build()); + new DynamicWriteResult(tableKey2, -1, WriteResult.builder().build()); CommittableWithLineage committable1 = new CommittableWithLineage<>(dynamicWriteResult1, 0, 0); @@ -113,18 +113,16 @@ void testPreventOutputFileFactoryCacheEvictionDuringFlush() throws Exception { testHarness = new OneInputStreamOperatorTestHarness<>(aggregator)) { testHarness.open(); - WriteTarget writeTarget1 = - new WriteTarget("table", "branch", 42, 0, false, Sets.newHashSet()); + TableKey tableKey1 = new TableKey("table", "branch"); DynamicWriteResult dynamicWriteResult1 = new DynamicWriteResult( - writeTarget1, WriteResult.builder().addDataFiles(DATA_FILE).build()); + tableKey1, DATA_FILE.specId(), WriteResult.builder().addDataFiles(DATA_FILE).build()); - // Different WriteTarget - WriteTarget writeTarget2 = - new WriteTarget("table", "branch2", 23, 0, true, Sets.newHashSet()); + // Different TableKey + TableKey tableKey2 = new TableKey("table", "branch2"); DynamicWriteResult dynamicWriteResult2 = new DynamicWriteResult( - writeTarget2, WriteResult.builder().addDataFiles(DATA_FILE).build()); + tableKey2, DATA_FILE.specId(), WriteResult.builder().addDataFiles(DATA_FILE).build()); CommittableWithLineage committable1 = new CommittableWithLineage<>(dynamicWriteResult1, 0, 0); @@ -151,6 +149,70 @@ void testPreventOutputFileFactoryCacheEvictionDuringFlush() throws Exception { } } + @Test + void testAggregatesWriteResultsForOneTable() throws Exception { + CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table"), new Schema()); + CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table2"), new Schema()); + + long checkpointId = 1L; + + try (OneInputStreamOperatorTestHarness< + CommittableMessage, CommittableMessage> + testHarness = + new OneInputStreamOperatorTestHarness<>( + new DynamicWriteResultAggregator( + CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize))) { + testHarness.open(); + + TableKey tableKey = new TableKey("table", "branch"); + DataFile dataFile1 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data-1.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + DataFile dataFile2 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data-2.parquet") + .withFileSizeInBytes(20) + .withRecordCount(2) + .build(); + + testHarness.processElement( + createRecord(tableKey, checkpointId, dataFile1.specId(), dataFile1)); + testHarness.processElement( + createRecord(tableKey, checkpointId, dataFile2.specId(), dataFile2)); + + assertThat(testHarness.getOutput()).isEmpty(); + + testHarness.prepareSnapshotPreBarrier(checkpointId); + + List> outputValues = testHarness.extractOutputValues(); + // Contains a CommittableSummary + DynamicCommittable + assertThat(outputValues).hasSize(2); + + SinkV2Assertions.assertThat(extractAndAssertCommittableSummary(outputValues.get(0))) + .hasOverallCommittables(1) + .hasFailedCommittables(0) + .hasCheckpointId(checkpointId); + + CommittableWithLineage committable = + extractAndAssertCommittableWithLineage(outputValues.get(1)); + + SinkV2Assertions.assertThat(committable).hasCheckpointId(checkpointId); + + DynamicCommittable dynamicCommittable = committable.getCommittable(); + + assertThat(dynamicCommittable.manifests()).hasNumberOfRows(1); + assertThat(dynamicCommittable.key()).isEqualTo(tableKey); + assertThat(dynamicCommittable.checkpointId()).isEqualTo(checkpointId); + assertThat(dynamicCommittable.jobId()) + .isEqualTo(testHarness.getEnvironment().getJobID().toString()); + assertThat(dynamicCommittable.operatorId()) + .isEqualTo(testHarness.getOperator().getOperatorID().toString()); + } + } + private static Set getManifestPaths( List>> messages) throws IOException { Set manifestPaths = Sets.newHashSet(); @@ -158,15 +220,40 @@ private static Set getManifestPaths( for (StreamRecord> record : messages) { CommittableMessage message = record.getValue(); if (message instanceof CommittableWithLineage) { - DeltaManifests deltaManifests = - SimpleVersionedSerialization.readVersionAndDeSerialize( - DeltaManifestsSerializer.INSTANCE, - (((CommittableWithLineage) message).getCommittable()) - .manifest()); - deltaManifests.manifests().forEach(manifest -> manifestPaths.add(manifest.path())); + for (byte[] manifest : + (((CommittableWithLineage) message).getCommittable()).manifests()) { + DeltaManifests deltaManifests = + SimpleVersionedSerialization.readVersionAndDeSerialize( + DeltaManifestsSerializer.INSTANCE, manifest); + deltaManifests + .manifests() + .forEach(manifestFile -> manifestPaths.add(manifestFile.path())); + } } } return manifestPaths; } + + private static StreamRecord> createRecord( + TableKey tableKey, long checkpointId, int specId, DataFile... dataFiles) { + return new StreamRecord<>( + new CommittableWithLineage<>( + new DynamicWriteResult( + tableKey, specId, WriteResult.builder().addDataFiles(dataFiles).build()), + checkpointId, + 0)); + } + + static CommittableSummary extractAndAssertCommittableSummary( + CommittableMessage message) { + assertThat(message).isInstanceOf(CommittableSummary.class); + return (CommittableSummary) message; + } + + static CommittableWithLineage extractAndAssertCommittableWithLineage( + CommittableMessage message) { + assertThat(message).isInstanceOf(CommittableWithLineage.class); + return (CommittableWithLineage) message; + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java index b1bca6099d7a..35dc45d8fd41 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java @@ -29,7 +29,6 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.junit.jupiter.api.Test; class TestDynamicWriteResultSerializer { @@ -48,13 +47,12 @@ class TestDynamicWriteResultSerializer { ImmutableMap.of(1, ByteBuffer.allocate(1)), ImmutableMap.of(1, ByteBuffer.allocate(1)))) .build(); + private static final TableKey TABLE_KEY = new TableKey("table", "branch"); @Test void testRoundtrip() throws IOException { DynamicWriteResult dynamicWriteResult = - new DynamicWriteResult( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - WriteResult.builder().addDataFiles(DATA_FILE).build()); + new DynamicWriteResult(TABLE_KEY, 1, WriteResult.builder().addDataFiles(DATA_FILE).build()); DynamicWriteResultSerializer serializer = new DynamicWriteResultSerializer(); DynamicWriteResult copy = @@ -68,11 +66,9 @@ void testRoundtrip() throws IOException { } @Test - void testUnsupportedVersion() throws IOException { + void testUnsupportedVersion() { DynamicWriteResult dynamicWriteResult = - new DynamicWriteResult( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - WriteResult.builder().addDataFiles(DATA_FILE).build()); + new DynamicWriteResult(TABLE_KEY, 1, WriteResult.builder().addDataFiles(DATA_FILE).build()); DynamicWriteResultSerializer serializer = new DynamicWriteResultSerializer(); assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(dynamicWriteResult))) diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java index 689fd20483c1..f604f639f217 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java @@ -26,6 +26,7 @@ import java.util.Collection; import java.util.Map; import javax.annotation.Nonnull; +import org.apache.flink.configuration.Configuration; import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; import org.apache.flink.table.data.RowData; import org.apache.iceberg.FileFormat; @@ -33,7 +34,8 @@ import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.common.DynFields; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; +import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.SimpleDataUtil; import org.apache.iceberg.flink.sink.TestFlinkIcebergSinkBase; import org.apache.iceberg.io.BaseTaskWriter; @@ -140,7 +142,7 @@ void testDynamicWriterPropertiesDefault() throws Exception { } @Test - void testDynamicWriterPropertiesPriority() throws Exception { + void testFlinkConfigOverridesTableProperties() throws Exception { Catalog catalog = CATALOG_EXTENSION.catalog(); Table table1 = catalog.createTable( @@ -149,11 +151,45 @@ void testDynamicWriterPropertiesPriority() throws Exception { null, ImmutableMap.of("write.parquet.compression-codec", "zstd")); + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.COMPRESSION_CODEC, "snappy"); + DynamicWriter dynamicWriter = - createDynamicWriter(catalog, ImmutableMap.of("write.parquet.compression-codec", "gzip")); + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); DynamicRecordInternal record1 = getDynamicRecordInternal(table1); - assertThat(getNumDataFiles(table1)).isEqualTo(0); + dynamicWriter.write(record1, null); + Map properties = properties(dynamicWriter); + assertThat(properties).containsEntry("write.parquet.compression-codec", "snappy"); + + dynamicWriter.close(); + } + + @Test + void testWritePropertiesOverrideFlinkConfig() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.COMPRESSION_CODEC, "snappy"); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + ImmutableMap.of("compression-codec", "gzip"), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); dynamicWriter.write(record1, null); Map properties = properties(dynamicWriter); @@ -162,6 +198,62 @@ void testDynamicWriterPropertiesPriority() throws Exception { dynamicWriter.close(); } + @Test + void testFlinkConfigFileFormat() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.WRITE_FORMAT, "orc"); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); + + dynamicWriter.write(record1, null); + dynamicWriter.prepareCommit(); + + File dataDir = new File(URI.create(table1.location()).getPath(), "data"); + File[] files = dataDir.listFiles((dir, name) -> name.endsWith(".orc")); + assertThat(files).isNotNull().hasSize(1); + + dynamicWriter.close(); + } + + @Test + void testFlinkConfigTargetFileSize() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.TARGET_FILE_SIZE_BYTES, 2048L); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); + + dynamicWriter.write(record1, null); + dynamicWriter.prepareCommit(); + + assertThat(getNumDataFiles(table1)).isEqualTo(1); + + dynamicWriter.close(); + } + @Test void testDynamicWriterUpsert() throws Exception { Catalog catalog = CATALOG_EXTENSION.catalog(); @@ -239,11 +331,10 @@ void testUniqueFileSuffixOnFactoryRecreation() throws Exception { DynamicWriter dynamicWriter = new DynamicWriter( catalog, - FileFormat.PARQUET, - 1024L, properties, + new Configuration(), 100, - new DynamicWriterMetrics(new UnregisteredMetricsGroup()), + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), 0, 0); return dynamicWriter; @@ -274,13 +365,13 @@ private Map properties(DynamicWriter dynamicWriter) { DynFields.BoundField>> writerField = DynFields.builder().hiddenImpl(dynamicWriter.getClass(), "writers").build(dynamicWriter); - DynFields.BoundField writerFactoryField = + DynFields.BoundField> writerFactoryField = DynFields.builder() .hiddenImpl(BaseTaskWriter.class, "writerFactory") .build(writerField.get().values().iterator().next()); DynFields.BoundField> propsField = DynFields.builder() - .hiddenImpl(BaseFileWriterFactory.class, "writerProperties") + .hiddenImpl(RegistryBasedFileWriterFactory.class, "writerProperties") .build(writerFactoryField.get()); return propsField.get(); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java index d416e7ec1fc6..d2da73c66973 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java @@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.apache.iceberg.Schema; import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Type.PrimitiveType; import org.apache.iceberg.types.Types; @@ -48,6 +49,14 @@ public class TestEvolveSchemaVisitor { + private static final TableIdentifier TABLE = TableIdentifier.of("table"); + + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + private static List primitiveTypes() { return Lists.newArrayList( StringType.get(), @@ -89,7 +98,8 @@ private static Types.NestedField[] primitiveFields( public void testAddTopLevelPrimitives() { Schema targetSchema = new Schema(primitiveFields(0, primitiveTypes())); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(targetSchema.asStruct()).isEqualTo(updateApi.apply().asStruct()); } @@ -99,12 +109,62 @@ public void testMakeTopLevelPrimitivesOptional() { assertThat(existingSchema.columns().stream().allMatch(Types.NestedField::isRequired)).isTrue(); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, new Schema()); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, new Schema(), CASE_SENSITIVE, PRESERVE_COLUMNS); Schema newSchema = updateApi.apply(); assertThat(newSchema.asStruct().fields()).hasSize(14); assertThat(newSchema.columns().stream().allMatch(Types.NestedField::isOptional)).isTrue(); } + @Test + public void testDropUnusedColumns() { + Schema existingSchema = + new Schema( + optional(1, "a", StringType.get()), + optional( + 2, + "b", + StructType.of( + optional(4, "nested1", StringType.get()), + optional(5, "nested2", StringType.get()))), + optional(3, "c", IntegerType.get())); + + Schema targetSchema = + new Schema( + optional(1, "a", StringType.get()), + optional(2, "b", StructType.of(optional(5, "nested2", StringType.get())))); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, DROP_COLUMNS); + + Schema newSchema = updateApi.apply(); + assertThat(newSchema.sameSchema(targetSchema)).isTrue(); + } + + @Test + public void testPreserveUnusedColumns() { + Schema existingSchema = + new Schema( + optional(1, "a", StringType.get()), + optional( + 2, + "b", + StructType.of( + optional(4, "nested1", StringType.get()), + optional(5, "nested2", StringType.get()))), + optional(3, "c", IntegerType.get())); + + Schema targetSchema = new Schema(optional(1, "a", StringType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); + + Schema newSchema = updateApi.apply(); + assertThat(newSchema.sameSchema(existingSchema)).isTrue(); + } + @Test public void testIdentifyFieldsByName() { Schema existingSchema = @@ -112,7 +172,8 @@ public void testIdentifyFieldsByName() { UpdateSchema updateApi = loadUpdateApi(existingSchema); Schema newSchema = new Schema(Arrays.asList(Types.NestedField.optional(-1, "myField", Types.LongType.get()))); - EvolveSchemaVisitor.visit(updateApi, existingSchema, newSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, newSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().sameSchema(existingSchema)).isTrue(); } @@ -125,7 +186,8 @@ public void testChangeOrderTopLevelPrimitives() { new Schema( Arrays.asList(optional(2, "b", StringType.get()), optional(1, "a", StringType.get()))); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -134,7 +196,8 @@ public void testAddTopLevelListOfPrimitives() { for (PrimitiveType primitiveType : primitiveTypes()) { Schema targetSchema = new Schema(optional(1, "aList", ListType.ofOptional(2, primitiveType))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -146,7 +209,8 @@ public void testMakeTopLevelListOfPrimitivesOptional() { new Schema(optional(1, "aList", ListType.ofRequired(2, primitiveType))); Schema targetSchema = new Schema(); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema expectedSchema = new Schema(optional(1, "aList", ListType.ofRequired(2, primitiveType))); assertThat(updateApi.apply().asStruct()).isEqualTo(expectedSchema.asStruct()); @@ -159,7 +223,8 @@ public void testAddTopLevelMapOfPrimitives() { Schema targetSchema = new Schema(optional(1, "aMap", MapType.ofOptional(2, 3, primitiveType, primitiveType))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -171,7 +236,8 @@ public void testAddTopLevelStructOfPrimitives() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), currentSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), currentSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(currentSchema.asStruct()); } } @@ -184,7 +250,8 @@ public void testAddNestedPrimitive() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -199,7 +266,8 @@ public void testMakeNestedPrimitiveOptional() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -210,7 +278,8 @@ public void testAddNestedPrimitives() { Schema targetSchema = new Schema(optional(1, "aStruct", StructType.of(primitiveFields(1, primitiveTypes())))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -240,7 +309,8 @@ public void testAddNestedLists() { ListType.ofOptional( 10, DecimalType.of(11, 20)))))))))))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -279,7 +349,8 @@ public void testAddNestedStruct() { "aString", StringType.get())))))))))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -314,7 +385,8 @@ public void testAddNestedMaps() { 12, 13, StringType.get(), StringType.get())))))))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -326,7 +398,12 @@ public void testDetectInvalidTopLevelList() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aList.element: string -> long") .isInstanceOf(IllegalArgumentException.class); } @@ -343,7 +420,12 @@ public void testDetectInvalidTopLevelMapValue() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aMap.value: string -> long") .isInstanceOf(IllegalArgumentException.class); } @@ -358,7 +440,12 @@ public void testDetectInvalidTopLevelMapKey() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aMap.key: string -> uuid") .isInstanceOf(IllegalArgumentException.class); } @@ -370,7 +457,8 @@ public void testTypePromoteIntegerToLong() { Schema targetSchema = new Schema(required(1, "aCol", LongType.get())); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema applied = updateApi.apply(); assertThat(applied.asStruct().fields()).hasSize(1); assertThat(applied.asStruct().fields().get(0).type()).isEqualTo(LongType.get()); @@ -383,7 +471,8 @@ public void testTypePromoteFloatToDouble() { Schema targetSchema = new Schema(required(1, "aCol", DoubleType.get())); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema applied = updateApi.apply(); assertThat(applied.asStruct().fields()).hasSize(1); assertThat(applied.asStruct().fields().get(0).type()).isEqualTo(DoubleType.get()); @@ -396,7 +485,12 @@ public void testInvalidTypePromoteDoubleToFloat() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aCol: double -> float") .isInstanceOf(IllegalArgumentException.class); } @@ -409,7 +503,8 @@ public void testTypePromoteDecimalToFixedScaleWithWiderPrecision() { Schema targetSchema = new Schema(required(1, "aCol", DecimalType.of(22, 1))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -452,7 +547,8 @@ public void testAddPrimitiveToNestedStruct() { optional(6, "time", TimeType.get()))))))))); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -464,7 +560,12 @@ public void testReplaceListWithPrimitive() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aColumn: list -> string") .isInstanceOf(IllegalArgumentException.class); } @@ -501,7 +602,8 @@ public void addNewTopLevelStruct() { optional(7, "d1", StructType.of(optional(8, "d2", StringType.get())))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -553,7 +655,8 @@ public void testAppendNestedStruct() { StringType.get())))))))))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -573,7 +676,8 @@ public void testMakeNestedStructOptional() { optional( 3, "s3", StructType.of(optional(4, "s4", StringType.get())))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(getNestedSchemaWithOptionalModifier(true).asStruct()) .isEqualTo(updateApi.apply().asStruct()); } @@ -610,6 +714,82 @@ private static Schema getNestedSchemaWithOptionalModifier(boolean nestedIsOption 9, "s4", StringType.get())))))))))))))); } + @Test + public void testCaseInsensitiveAddField() { + Schema existingSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + Schema targetSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "NAME", Types.StringType.get()), + Types.NestedField.optional(3, "AGE", Types.IntegerType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.columns()).hasSize(3); + assertThat(result.findField("AGE")).isNotNull(); + } + + @Test + public void testCaseInsensitiveMakeFieldOptional() { + Schema existingSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "name", Types.StringType.get())); + Schema targetSchema = new Schema(Types.NestedField.optional(1, "ID", Types.IntegerType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.findField("name").isOptional()).isTrue(); + } + + @Test + public void testCaseInsensitiveNestedStructField() { + Schema existingSchema = + new Schema( + optional(1, "struct1", StructType.of(optional(2, "field1", Types.StringType.get())))); + Schema targetSchema = + new Schema( + optional( + 1, + "STRUCT1", + StructType.of( + optional(2, "FIELD1", Types.StringType.get()), + optional(3, "FIELD2", Types.IntegerType.get())))); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + Types.StructType struct = result.findField("struct1").type().asStructType(); + assertThat(struct.fields()).hasSize(2); + assertThat(struct.field("FIELD2")).isNotNull(); + } + + @Test + public void testCaseSensitiveDoesNotMatch() { + Schema existingSchema = + new Schema(Types.NestedField.optional(1, "id", Types.IntegerType.get())); + Schema targetSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.columns()).hasSize(3); + assertThat(result.findField("ID")).isNotNull(); + assertThat(result.findField("id")).isNotNull(); + } + private static UpdateSchema loadUpdateApi(Schema schema) { try { Constructor constructor = diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java index 04246bf03996..9a485fafaf47 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.Set; import org.apache.flink.api.java.functions.KeySelector; +import org.apache.flink.configuration.Configuration; import org.apache.flink.runtime.state.KeyGroupRangeAssignment; import org.apache.flink.table.data.GenericRowData; import org.apache.flink.table.data.RowData; @@ -32,7 +33,11 @@ import org.apache.iceberg.DistributionMode; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; @@ -44,7 +49,7 @@ class TestHashKeyGenerator { Types.NestedField.required(1, "id", Types.IntegerType.get()), Types.NestedField.required(2, "data", Types.StringType.get())); - private static final String BRANCH = "main"; + private static final String BRANCH = SnapshotRef.MAIN_BRANCH; private static final TableIdentifier TABLE_IDENTIFIER = TableIdentifier.of("default", "table"); @Test @@ -158,6 +163,48 @@ void testEqualityKeys() throws Exception { assertThat(getSubTaskId(writeKey3, writeParallelism, maxWriteParallelism)).isEqualTo(0); } + @Test + void testHashModeWithPartitionFieldAndEqualityField() throws Exception { + int writeParallelism = 2; + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("id", 4).build(); + + GenericRowData row1 = GenericRowData.of(1, StringData.fromString("foo")); + GenericRowData row2 = GenericRowData.of(1, StringData.fromString("bar")); + Set equalityColumns = Sets.newHashSet("id", "data"); + + int writeKey1 = + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row1); + int writeKey2 = + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row2); + + assertThat(writeKey1).isEqualTo(writeKey2); + } + + @Test + void testHashModeWithPartitionFieldNotInEqualityFieldsFails() { + int writeParallelism = 2; + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("id", 4).build(); + Set equalityColumns = Collections.singleton("data"); + + GenericRowData row = GenericRowData.of(1, StringData.fromString("foo")); + + assertThatThrownBy( + () -> + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("partition field") + .hasMessageContaining(spec.fields().get(0).toString()) + .hasMessageContaining("should be included in equality fields"); + } + @Test void testFailOnNonPositiveWriteParallelism() { final int maxWriteParallelism = 5; @@ -186,6 +233,38 @@ void testFailOnNonPositiveWriteParallelism() { }); } + @Test + void testNonPositiveWriteParallelismConfigFallback() throws Exception { + int maxWriteParallelism = 5; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + PartitionSpec unpartitioned = PartitionSpec.unpartitioned(); + FlinkWriteConf flinkWriteConf = + new FlinkWriteConf( + ImmutableMap.of(FlinkWriteOptions.WRITE_PARALLELISM.key(), "2"), new Configuration()); + + Set writeKeys = Sets.newHashSet(); + for (int i = 0; i < 20; i++) { + GenericRowData row = GenericRowData.of(i, StringData.fromString("z")); + writeKeys.add( + getWriteKey( + generator, + unpartitioned, + DistributionMode.NONE, + i % 2 == 0 ? 0 : -1, + Collections.emptySet(), + row, + flinkWriteConf)); + } + + assertThat(writeKeys).hasSize(2); + assertThat( + writeKeys.stream() + .map(key -> getSubTaskId(key, 2, maxWriteParallelism)) + .distinct() + .count()) + .isEqualTo(2); + } + @Test void testCapAtMaxWriteParallelism() throws Exception { int writeParallelism = 10; @@ -358,6 +437,74 @@ void testCaching() throws Exception { assertThat(writeKey1).isEqualTo(writeKey3); } + @Test + void testCacheMissOnWriteParallelismChange() throws Exception { + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(10, maxWriteParallelism); + Map> keySelectorCache = + generator.getKeySelectorCache(); + + PartitionSpec unpartitioned = PartitionSpec.unpartitioned(); + DynamicRecord record1 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + unpartitioned, + DistributionMode.NONE, + 2); + DynamicRecord record2 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + unpartitioned, + DistributionMode.NONE, + 4); + + generator.generateKey(record1); + assertThat(keySelectorCache).hasSize(1); + + generator.generateKey(record2); + assertThat(keySelectorCache).hasSize(2); + } + + @Test + void testCacheMissOnDistributionModeChange() throws Exception { + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(10, maxWriteParallelism); + Map> keySelectorCache = + generator.getKeySelectorCache(); + + PartitionSpec partitioned = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + DynamicRecord record1 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + partitioned, + DistributionMode.NONE, + 2); + DynamicRecord record2 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + partitioned, + DistributionMode.HASH, + 2); + + generator.generateKey(record1); + assertThat(keySelectorCache).hasSize(1); + + generator.generateKey(record2); + assertThat(keySelectorCache).hasSize(2); + } + private static int getWriteKey( HashKeyGenerator generator, PartitionSpec spec, @@ -366,10 +513,31 @@ private static int getWriteKey( Set equalityFields, GenericRowData row) throws Exception { - DynamicRecord record = + return getWriteKey( + generator, + spec, + mode, + writeParallelism, + equalityFields, + row, + new FlinkWriteConf(Collections.emptyMap(), new Configuration())); + } + + private static int getWriteKey( + HashKeyGenerator generator, + PartitionSpec spec, + DistributionMode mode, + int writeParallelism, + Set equalityFields, + GenericRowData row, + FlinkWriteConf flinkWriteConf) + throws Exception { + DynamicRecord inputRecord = new DynamicRecord(TABLE_IDENTIFIER, BRANCH, SCHEMA, row, spec, mode, writeParallelism); - record.setEqualityFields(equalityFields); - return generator.generateKey(record); + inputRecord.setEqualityFields(equalityFields); + + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(flinkWriteConf); + return generator.generateKey(dynamicRecordWithConfig.wrap(inputRecord)); } private static int getSubTaskId(int writeKey1, int writeParallelism, int maxWriteParallelism) { diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java index bf5b9f562f9a..8a17c707f84a 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java @@ -26,6 +26,7 @@ import org.apache.commons.lang3.SerializationUtils; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; @@ -35,6 +36,12 @@ public class TestTableMetadataCache extends TestFlinkIcebergSinkBase { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + static final Schema SCHEMA = new Schema( Types.NestedField.optional(1, "id", Types.IntegerType.get()), @@ -46,12 +53,23 @@ public class TestTableMetadataCache extends TestFlinkIcebergSinkBase { Types.NestedField.optional(2, "data", Types.StringType.get()), Types.NestedField.optional(3, "extra", Types.StringType.get())); + static final Schema SCHEMA_UPPERCASE = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + static final Schema SCHEMA_MIXEDCASE = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get())); + @Test void testCaching() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); assertThat(schema1.sameSchema(SCHEMA)).isTrue(); @@ -72,8 +90,9 @@ void testCacheInvalidationAfterSchemaChange() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); assertThat(schema1.sameSchema(SCHEMA)).isTrue(); @@ -81,7 +100,11 @@ void testCacheInvalidationAfterSchemaChange() { catalog.dropTable(tableIdentifier); catalog.createTable(tableIdentifier, SCHEMA2); tableUpdater.update( - tableIdentifier, "main", SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA2, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); Schema schema2 = cache.schema(tableIdentifier, SCHEMA2).resolvedTableSchema(); assertThat(schema2.sameSchema(SCHEMA2)).isTrue(); @@ -92,7 +115,8 @@ void testCachingDisabled() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 0, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 0, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(cache.getInternalCache()).isEmpty(); } @@ -107,7 +131,13 @@ void testNoCacheRefreshingBeforeRefreshIntervalElapses() { // Init cache TableMetadataCache cache = new TableMetadataCache( - catalog, 10, 100L, 10, Clock.fixed(Instant.now(), ZoneId.systemDefault())); + catalog, + 10, + 100L, + 10, + CASE_INSENSITIVE, + PRESERVE_COLUMNS, + Clock.fixed(Instant.now(), ZoneId.systemDefault())); cache.update(tableIdentifier, table); // Cache schema @@ -125,4 +155,66 @@ void testNoCacheRefreshingBeforeRefreshIntervalElapses() { assertThat(cacheItem).isNotNull(); assertThat(cacheItem.inputSchemas()).containsKeys(SCHEMA, SCHEMA2); } + + @Test + void testNoSuchNamespaceExceptionHandling() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.of("nonexistent_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + TableMetadataCache.ResolvedSchemaInfo result = cache.schema(tableIdentifier, SCHEMA); + + assertThat(result).isEqualTo(TableMetadataCache.NOT_FOUND); + assertThat(cache.getInternalCache().get(tableIdentifier)).isNotNull(); + } + + @Test + void testNoSuchTableExceptionHandling() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.nonexistent_table"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + TableMetadataCache.ResolvedSchemaInfo result = cache.schema(tableIdentifier, SCHEMA); + + assertThat(result).isEqualTo(TableMetadataCache.NOT_FOUND); + assertThat(cache.getInternalCache().get(tableIdentifier)).isNotNull(); + } + + @Test + void testCaseInsensitiveCaching() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); + catalog.createTable(tableIdentifier, SCHEMA); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_INSENSITIVE, PRESERVE_COLUMNS); + + Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); + assertThat(schema1.sameSchema(SCHEMA)).isTrue(); + + Schema schemaUpperCase = cache.schema(tableIdentifier, SCHEMA_UPPERCASE).resolvedTableSchema(); + assertThat(schemaUpperCase).isEqualTo(schema1); + + Schema schemaMixedCase = cache.schema(tableIdentifier, SCHEMA_MIXEDCASE).resolvedTableSchema(); + assertThat(schemaMixedCase).isEqualTo(schema1); + } + + @Test + void testCaseSensitiveCachingDoesNotMatch() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); + catalog.createTable(tableIdentifier, SCHEMA); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); + assertThat(schema1.sameSchema(SCHEMA)).isTrue(); + + assertThat(cache.schema(tableIdentifier, SCHEMA_UPPERCASE)) + .isEqualTo(TableMetadataCache.NOT_FOUND); + + assertThat(cache.schema(tableIdentifier, SCHEMA_MIXEDCASE)) + .isEqualTo(TableMetadataCache.NOT_FOUND); + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java index 1d4461698746..bdc825b44f2a 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java @@ -25,18 +25,28 @@ import org.apache.flink.api.java.tuple.Tuple2; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.sink.TestFlinkIcebergSinkBase; import org.apache.iceberg.inmemory.InMemoryCatalog; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class TestTableUpdater extends TestFlinkIcebergSinkBase { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + static final Schema SCHEMA = new Schema( Types.NestedField.optional(1, "id", Types.IntegerType.get()), @@ -55,8 +65,9 @@ void testTableCreation(@TempDir Path tempDir) { catalog.initialize("catalog", Map.of()); catalog.createNamespace(Namespace.of("myNamespace")); TableIdentifier tableIdentifier = TableIdentifier.parse("myNamespace.myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); String locationOverride = tempDir.toString() + "/custom-path"; Map tableProperties = Map.of("key", "value"); @@ -65,7 +76,11 @@ void testTableCreation(@TempDir Path tempDir) { catalog1.createTable(identifier, schema, spec, locationOverride, tableProperties); tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), tableCreator); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + tableCreator); assertThat(catalog.tableExists(tableIdentifier)).isTrue(); assertThat(catalog.loadTable(tableIdentifier).properties().get("key")).isEqualTo("value"); assertThat(catalog.loadTable(tableIdentifier).location()).isEqualTo(locationOverride); @@ -77,8 +92,9 @@ void testTableCreation(@TempDir Path tempDir) { void testTableAlreadyExists() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); // Make the table non-existent in cache cache.exists(tableIdentifier); @@ -87,7 +103,11 @@ void testTableAlreadyExists() { // Make sure that the cache is invalidated and the table refreshed without an error Tuple2 result = tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); assertThat(result.f1).isEqualTo(PartitionSpec.unpartitioned()); @@ -97,8 +117,9 @@ void testTableAlreadyExists() { void testBranchCreationAndCaching() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); catalog.createTable(tableIdentifier, SCHEMA); tableUpdater.update( @@ -115,11 +136,13 @@ void testBranchCreationAndCaching() { void testSpecCreation() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("data", 10).build(); - tableUpdater.update(tableIdentifier, "main", SCHEMA, spec, TableCreator.DEFAULT); + tableUpdater.update( + tableIdentifier, SnapshotRef.MAIN_BRANCH, SCHEMA, spec, TableCreator.DEFAULT); Table table = catalog.loadTable(tableIdentifier); assertThat(table).isNotNull(); @@ -131,15 +154,16 @@ void testInvalidateOldCacheEntryOnUpdate() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); cache.schema(tableIdentifier, SCHEMA); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema updated = tableUpdater .update( tableIdentifier, - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT) @@ -155,12 +179,17 @@ void testLastResultInvalidation() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); // Initialize cache tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); // Update table behind the scenes catalog.dropTable(tableIdentifier); @@ -174,7 +203,7 @@ void testLastResultInvalidation() { tableUpdater .update( tableIdentifier, - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT) @@ -186,4 +215,132 @@ void testLastResultInvalidation() { assertThat(cache.getInternalCache().get(tableIdentifier).inputSchemas()) .doesNotContainKey(SCHEMA2); } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testCaseSensitivity(boolean caseSensitive) { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, caseSensitive, DROP_COLUMNS); + + TableUpdater tableUpdater = new TableUpdater(cache, catalog, caseSensitive, DROP_COLUMNS); + + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "extra", Types.StringType.get())); + + catalog.createTable(tableIdentifier, schema); + + Schema schemaWithUpperCase = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get()), + Types.NestedField.optional(3, "Extra", Types.StringType.get())); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + schemaWithUpperCase, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + + Schema tableSchema = catalog.loadTable(tableIdentifier).schema(); + if (caseSensitive) { + assertThat(tableSchema.columns()).hasSize(3); + assertThat(tableSchema.findField("Id")).isNotNull(); + assertThat(tableSchema.findField("Data")).isNotNull(); + assertThat(tableSchema.findField("Extra")).isNotNull(); + } else { + assertThat(tableSchema.sameSchema(schema)).isTrue(); + } + } + + @Test + void testDropUnusedColumns() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, DROP_COLUMNS); + + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, DROP_COLUMNS); + + catalog.createTable(tableIdentifier, SCHEMA2); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + Schema tableSchema = catalog.loadTable(tableIdentifier).schema(); + assertThat(tableSchema.columns()).hasSize(2); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNotNull(); + assertThat(tableSchema.findField("extra")).isNull(); + } + + @Test + void testNamespaceAndTableCreation() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + SupportsNamespaces namespaceCatalog = (SupportsNamespaces) catalog; + TableIdentifier tableIdentifier = TableIdentifier.of("new_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); + + assertThat(namespaceCatalog.namespaceExists(Namespace.of("new_namespace"))).isFalse(); + assertThat(catalog.tableExists(tableIdentifier)).isFalse(); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(namespaceCatalog.namespaceExists(Namespace.of("new_namespace"))).isTrue(); + + assertThat(catalog.tableExists(tableIdentifier)).isTrue(); + assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testTableCreationWithExistingNamespace() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + SupportsNamespaces namespaceCatalog = (SupportsNamespaces) catalog; + Namespace namespace = Namespace.of("existing_namespace"); + namespaceCatalog.createNamespace(namespace); + + TableIdentifier tableIdentifier = TableIdentifier.of("existing_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); + + assertThat(namespaceCatalog.namespaceExists(namespace)).isTrue(); + assertThat(catalog.tableExists(tableIdentifier)).isFalse(); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(namespaceCatalog.namespaceExists(namespace)).isTrue(); + assertThat(catalog.tableExists(tableIdentifier)).isTrue(); + assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + } } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java index ac2e2784e681..44791094de75 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java @@ -54,6 +54,9 @@ protected SortOrder sortOrder() { .sortBy(Expressions.bucket("uuid_field", 16), SortDirection.ASC, NullOrder.NULLS_FIRST) .sortBy(Expressions.hour("ts_with_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) .sortBy(Expressions.day("ts_without_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) + .asc("ts_ns_with_zone_field") + .sortBy( + Expressions.hour("ts_ns_without_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) // can not test HeapByteBuffer due to equality test inside SerializerTestBase // .sortBy(Expressions.truncate("binary_field", 2), SortDirection.ASC, // NullOrder.NULLS_FIRST) diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java index 8352924d042a..6ae7bfb53a2e 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java @@ -50,6 +50,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.avro.Avro; @@ -625,8 +626,9 @@ public void testMetadataLogEntries() { assertThat(metadataLog.getField("latest_snapshot_id")).isEqualTo(parentSnapshot.snapshotId()); metadataLog = metadataLogs.get(2); - assertThat(metadataLog.getField("timestamp")) - .isEqualTo(Instant.ofEpochMilli(currentSnapshot.timestampMillis())); + assertThat((Instant) metadataLog.getFieldAs("timestamp")) + .isAfterOrEqualTo(Instant.ofEpochMilli(currentSnapshot.timestampMillis())) + .isEqualTo(Instant.ofEpochMilli(tableMetadata.lastUpdatedMillis())); assertThat(metadataLog.getField("file")).isEqualTo(tableMetadata.metadataFileLocation()); assertThat(metadataLog.getField("latest_snapshot_id")).isEqualTo(currentSnapshot.snapshotId()); assertThat(metadataLog.getField("latest_schema_id")).isEqualTo(currentSnapshot.schemaId()); @@ -640,7 +642,7 @@ public void testMetadataLogEntries() { TABLE_NAME, currentSnapshotId); assertThat(metadataLogWithFilters).hasSize(1); metadataLog = metadataLogWithFilters.get(0); - assertThat(Instant.ofEpochMilli(tableMetadata.currentSnapshot().timestampMillis())) + assertThat(Instant.ofEpochMilli(tableMetadata.lastUpdatedMillis())) .isEqualTo(metadataLog.getField("timestamp")); assertThat(metadataLog.getField("file")).isEqualTo(tableMetadata.metadataFileLocation()); @@ -695,7 +697,7 @@ public void testSnapshotReferencesMetatable() { // Check branch entries in refs table List mainBranch = sql("SELECT * FROM %s$refs WHERE name='main' AND type='BRANCH'", TABLE_NAME); - assertThat((String) mainBranch.get(0).getFieldAs("name")).isEqualTo("main"); + assertThat((String) mainBranch.get(0).getFieldAs("name")).isEqualTo(SnapshotRef.MAIN_BRANCH); assertThat((String) mainBranch.get(0).getFieldAs("type")).isEqualTo("BRANCH"); assertThat((Long) mainBranch.get(0).getFieldAs("snapshot_id")).isEqualTo(currentSnapshotId); List testBranch = @@ -732,7 +734,8 @@ public void testSnapshotReferencesMetatable() { assertThat((String) testTagProjection.get(0).getFieldAs("min_snapshots_to_keep")).isNull(); List mainBranchProjection = sql("SELECT name, type FROM %s$refs WHERE name='main' AND type = 'BRANCH'", TABLE_NAME); - assertThat((String) mainBranchProjection.get(0).getFieldAs("name")).isEqualTo("main"); + assertThat((String) mainBranchProjection.get(0).getFieldAs("name")) + .isEqualTo(SnapshotRef.MAIN_BRANCH); assertThat((String) mainBranchProjection.get(0).getFieldAs("type")).isEqualTo("BRANCH"); List testBranchProjection = sql( diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java index 70889f4f76aa..fff9b96b3b7c 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java @@ -225,6 +225,68 @@ public void apply( 3))); } + /** + * Integration test verifying that records with eventTime equal to the minimum timestamp of their + * split are correctly included in windows. The {@link + * org.apache.iceberg.flink.source.reader.WatermarkExtractorRecordEmitter} emits the watermark as + * {@code minSplitTs - 1}, so records at exactly {@code minSplitTs} are on-time rather than late. + * + *

    The test writes 3 records at epoch (t=0). The split's column-stats lower-bound is 0, so the + * extracted watermark is 0ms and the emitted watermark is -1ms. Records at t=0 are strictly after + * that watermark and therefore belong to the [0, 5min) window. A later split is then appended to + * advance the watermark past the window boundary and trigger its evaluation. + */ + @Test + public void testWindowingWithRecordsAtSplitMinTimestamp() throws Exception { + GenericAppenderHelper dataAppender = appender(); + + // File 1: 3 records at exactly t=0 (epoch). Extracted watermark = 0ms, emitted = -1ms. + List batch = + ImmutableList.of( + generateRecord(0, "file_1-recordTs_0_a"), + generateRecord(0, "file_1-recordTs_0_b"), + generateRecord(0, "file_1-recordTs_0_c")); + dataAppender.appendToTable(batch); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + DataStream stream = + env.fromSource( + source(), + WatermarkStrategy.noWatermarks() + .withTimestampAssigner(new RowDataTimestampAssigner()), + SOURCE_NAME, + TypeInformation.of(RowData.class)); + + stream + .windowAll(TumblingEventTimeWindows.of(Time.minutes(5))) + .apply( + new AllWindowFunction() { + @Override + public void apply( + TimeWindow window, Iterable values, Collector out) { + AtomicInteger count = new AtomicInteger(0); + values.forEach(a -> count.incrementAndGet()); + out.collect(row(window.getStart(), count.get())); + WINDOWS.put(window.getStart(), count.get()); + } + }); + + WINDOWS.clear(); + env.executeAsync("Iceberg Source Min Timestamp Windowing Test"); + + // Append a file with much later timestamps to advance the watermark past [0, 5min) + dataAppender.appendToTable( + dataAppender.writeFile(ImmutableList.of(generateRecord(1500, "last-record")))); + + // The [0, 5min) window should fire with all 3 records written at epoch + Awaitility.await() + .pollInterval(Duration.ofMillis(10)) + .atMost(30, TimeUnit.SECONDS) + .until(() -> Integer.valueOf(3).equals(WINDOWS.get(0L))); + } + /** * This is an integration test for watermark handling and throttling. Integration testing the * following: diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java index 488969bab045..af3a79ff67b8 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; @@ -222,7 +223,8 @@ public void testPrimitiveColumns() throws Exception { // size of the column to increase. For example, with Parquet 1.14.x the // uncompressed size has been added to allow for better allocation of memory upfront. // Therefore, we look the sizes up, rather than hardcoding them - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); Map columnSizeStats = dataFile.columnSizes(); Row binaryCol = diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java index 5dd7de545e11..09639a8a9568 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java @@ -31,7 +31,7 @@ void testIncrementalFromSnapshotId() { .startingStrategy(StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_ID) .build(); assertException( - context, "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: null"); + context, "Invalid starting snapshot id for INCREMENTAL_FROM_SNAPSHOT_ID strategy: null"); context = ScanContext.builder() @@ -42,7 +42,7 @@ void testIncrementalFromSnapshotId() { .build(); assertException( context, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot timestamp for INCREMENTAL_FROM_SNAPSHOT_ID strategy: not null"); } @Test @@ -54,7 +54,7 @@ void testIncrementalFromSnapshotTimestamp() { .build(); assertException( context, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_TIMESTAMP strategy: null"); + "Invalid starting snapshot timestamp for INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP strategy: null"); context = ScanContext.builder() @@ -64,7 +64,8 @@ void testIncrementalFromSnapshotTimestamp() { .startSnapshotTimestamp(1L) .build(); assertException( - context, "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + context, + "Invalid starting snapshot id for INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP strategy: not null"); } @Test diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java index 332244732928..3b094ba02298 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.List; import org.apache.flink.table.data.RowData; +import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.BaseCombinedScanTask; import org.apache.iceberg.BaseFileScanTask; import org.apache.iceberg.CombinedScanTask; @@ -78,11 +79,16 @@ public static FileScanTask createFileTask( } public static DataIterator createDataIterator(CombinedScanTask combinedTask) { + return createDataIterator(combinedTask, TestFixtures.SCHEMA, TestFixtures.SCHEMA); + } + + public static DataIterator createDataIterator( + CombinedScanTask combinedTask, Schema tableSchema, Schema projectSchema) { return new DataIterator<>( new RowDataFileScanTaskReader( - TestFixtures.SCHEMA, TestFixtures.SCHEMA, null, true, Collections.emptyList()), + tableSchema, projectSchema, null, true, Collections.emptyList()), combinedTask, - new HadoopFileIO(new org.apache.hadoop.conf.Configuration()), + new HadoopFileIO(new Configuration()), PlaintextEncryptionManager.instance()); } diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java index 761e5622277d..191dfe088cdf 100644 --- a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java @@ -61,6 +61,13 @@ public class TestColumnStatsWatermarkExtractor { required(3, "long_column", Types.LongType.get()), required(4, "string_column", Types.StringType.get())); + // Separate schema for nanosecond columns: TIMESTAMP_NANO requires table format v3, which the + // HadoopTableExtension above does not provision. Tested via constructor preconditions only. + private static final Schema NANO_SCHEMA = + new Schema( + required(1, "timestamp_ns_column", Types.TimestampNanoType.withoutZone()), + required(2, "timestamptz_ns_column", Types.TimestampNanoType.withZone())); + private static final List> TEST_RECORDS = ImmutableList.of( RandomGenericData.generate(SCHEMA, 3, 2L), RandomGenericData.generate(SCHEMA, 3, 19L)); @@ -147,7 +154,17 @@ public void testWrongColumn() { assertThatThrownBy(() -> new ColumnStatsWatermarkExtractor(SCHEMA, columnName, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining( - "Found STRING, expected a LONG or TIMESTAMP column for watermark generation."); + "Found STRING, expected a LONG, TIMESTAMP, or TIMESTAMP_NANO column for watermark generation."); + } + + @TestTemplate + public void testTimestampNanoAccepted() { + // Run the precondition check exactly once across the parameterized matrix. + assumeThat(columnName).isEqualTo("timestamp_column"); + + // Both flavours of TIMESTAMP_NANO must be accepted by the extractor's precondition check. + new ColumnStatsWatermarkExtractor(NANO_SCHEMA, "timestamp_ns_column", null); + new ColumnStatsWatermarkExtractor(NANO_SCHEMA, "timestamptz_ns_column", null); } @TestTemplate diff --git a/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java new file mode 100644 index 000000000000..1cb7be03c6a7 --- /dev/null +++ b/flink/v1.20/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.source.reader; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.flink.api.common.eventtime.Watermark; +import org.apache.flink.api.connector.source.SourceOutput; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.TestFixtures; +import org.apache.iceberg.flink.source.split.IcebergSourceSplit; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestWatermarkExtractorRecordEmitter { + @TempDir protected Path temporaryFolder; + + @Test + public void testWatermarkIsDecrementedByOne() throws IOException { + long extractedWatermark = 1000L; + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> extractedWatermark); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + emitter.emitRecord(new RecordAndPosition<>("record", 0, 0L), output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(extractedWatermark - 1); + } + + @Test + public void testWatermarkEmittedOnlyOncePerSplit() throws IOException { + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> 1000L); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split); + emitter.emitRecord(element, output, split); + emitter.emitRecord(element, output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.records).hasSize(3); + } + + @Test + public void testWatermarkNotEmittedWhenNewSplitHasLowerValue() throws IOException { + IcebergSourceSplit split1 = createSplit(1L); + IcebergSourceSplit split2 = createSplit(2L); + + Map watermarkMap = Maps.newHashMap(); + watermarkMap.put(split1.splitId(), 2000L); + watermarkMap.put(split2.splitId(), 1000L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> watermarkMap.get(s.splitId())); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split1); + emitter.emitRecord(element, output, split2); + + // Only split1's watermark is emitted; split2 has a lower value so it's skipped + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(1999L); + } + + @Test + public void testWatermarkEmittedForEachHigherSplit() throws IOException { + IcebergSourceSplit split1 = createSplit(1L); + IcebergSourceSplit split2 = createSplit(2L); + + Map watermarkMap = Maps.newHashMap(); + watermarkMap.put(split1.splitId(), 1000L); + watermarkMap.put(split2.splitId(), 2000L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> watermarkMap.get(s.splitId())); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split1); + emitter.emitRecord(element, output, split2); + + assertThat(output.watermarks).hasSize(2); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(999L); + assertThat(output.watermarks.get(1).getTimestamp()).isEqualTo(1999L); + } + + @Test + public void testWatermarkAtLongMinValueDoesNotOverflow() throws IOException { + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> Long.MIN_VALUE); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + emitter.emitRecord(new RecordAndPosition<>("record", 0, 0L), output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(Long.MIN_VALUE); + } + + private IcebergSourceSplit createSplit(long seed) throws IOException { + List> recordBatchList = + ReaderUtil.createRecordBatchList(seed, TestFixtures.SCHEMA, 1, 1); + return IcebergSourceSplit.fromCombinedScanTask( + ReaderUtil.createCombinedScanTask( + recordBatchList, temporaryFolder, FileFormat.PARQUET, TestFixtures.SCHEMA)); + } + + private static class CapturingSourceOutput implements SourceOutput { + final List watermarks = Lists.newArrayList(); + final List records = Lists.newArrayList(); + + @Override + public void collect(T record) { + records.add(record); + } + + @Override + public void collect(T record, long timestamp) { + records.add(record); + } + + @Override + public void emitWatermark(Watermark watermark) { + watermarks.add(watermark); + } + + @Override + public void markIdle() {} + + @Override + public void markActive() {} + } +} diff --git a/flink/v2.0/build.gradle b/flink/v2.0/build.gradle index 5907f41b3544..7bc37b30e5a1 100644 --- a/flink/v2.0/build.gradle +++ b/flink/v2.0/build.gradle @@ -33,7 +33,8 @@ project(":iceberg-flink:iceberg-flink-${flinkMajorVersion}") { implementation project(':iceberg-hive-metastore') compileOnly libs.flink20.avro - // for dropwizard histogram metrics implementation + compileOnly libs.joda.time + // dropwizard histogram metrics (optional in Flink) compileOnly libs.flink20.metrics.dropwizard compileOnly libs.flink20.streaming.java compileOnly "${libs.flink20.streaming.java.get().module}:${libs.flink20.streaming.java.get().getVersion()}:tests" @@ -83,6 +84,8 @@ project(":iceberg-flink:iceberg-flink-${flinkMajorVersion}") { testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation(testFixtures(project(':iceberg-parquet'))) // By default, hive-exec is a fat/uber jar and it exports a guava library // that's really old. We use the core classifier to be able to override our guava @@ -169,9 +172,6 @@ project(":iceberg-flink:iceberg-flink-runtime-${flinkMajorVersion}") { exclude group: 'com.google.code.findbugs', module: 'jsr305' } - // for dropwizard histogram metrics implementation - implementation libs.flink20.metrics.dropwizard - // for integration testing with the flink-runtime-jar // all of those dependencies are required because the integration test extends FlinkTestBase integrationCompileOnly project(':iceberg-api') @@ -266,4 +266,6 @@ project(":iceberg-flink:iceberg-flink-runtime-${flinkMajorVersion}") { jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/flink/v2.0/flink-runtime/LICENSE b/flink/v2.0/flink-runtime/LICENSE index 5f8bfb4edbf4..d73eda0104b9 100644 --- a/flink/v2.0/flink-runtime/LICENSE +++ b/flink/v2.0/flink-runtime/LICENSE @@ -203,138 +203,170 @@ -------------------------------------------------------------------------------- -This binary artifact contains Apache Avro. +This product bundles Apache Avro. Copyright: 2014-2020 The Apache Software Foundation. -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://avro.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains the Jackson JSON processor. +This product bundles Jackson JSON Processor. Copyright: 2007-2020 Tatu Saloranta and other contributors -Home page: http://jackson.codehaus.org/ -License: http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Parquet. +This product bundles FastDoubleParser (via Jackson JSON Processor). -Copyright: 2014-2020 The Apache Software Foundation. -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -This binary artifact contains Apache Thrift. +This product bundles fast_float (bundled by FastDoubleParser). -Copyright: 2006-2010 The Apache Software Foundation. -Home page: https://thrift.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -This binary artifact contains fastutil. +This product bundles bigint (bundled by FastDoubleParser). -Copyright: 2002-2014 Sebastiano Vigna -Home page: http://fastutil.di.unimi.it/ -License: http://www.apache.org/licenses/LICENSE-2.0.html +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -This binary artifact contains Apache ORC. +This product bundles Apache Parquet. -Copyright: 2013-2020 The Apache Software Foundation. -Home page: https://orc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2014-2020 The Apache Software Foundation. +Project URL: https://parquet.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Hive's storage API via ORC. +This product bundles Apache Thrift (bundled by Parquet). -Copyright: 2013-2020 The Apache Software Foundation. -Home page: https://hive.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2006-2010 The Apache Software Foundation. +Project URL: https://thrift.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Airlift Aircompressor. +This product includes code from Daniel Lemire's JavaFastPFOR project (bundled by Parquet). -Copyright: 2011-2020 Aircompressor authors. -Home page: https://github.com/airlift/aircompressor -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2013 Daniel Lemire +Project URL: https://github.com/lemire/JavaFastPFOR +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Guava. +This product bundles fastutil (bundled by Parquet). -Copyright: 2006-2020 The Guava Authors -Home page: https://github.com/google/guava -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2002-2014 Sebastiano Vigna +Project URL: http://fastutil.di.unimi.it/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Error Prone Annotations. +This product bundles Zero-Allocation Hashing (bundled by Parquet). -Copyright: Copyright 2011-2019 The Error Prone Authors -Home page: https://github.com/google/error-prone -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/OpenHFT/Zero-Allocation-Hashing +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains checkerframework checker-qual Annotations. - -Copyright: 2004-2020 the Checker Framework developers -Home page: https://github.com/typetools/checker-framework -License: https://github.com/typetools/checker-framework/blob/master/LICENSE.txt (MIT license) +This product bundles Apache ORC. -License text: -| The annotations are licensed under the MIT License. (The text of this -| license appears below.) More specifically, all the parts of the Checker -| Framework that you might want to include with your own program use the -| MIT License. This is the checker-qual.jar file and all the files that -| appear in it: every file in a qual/ directory, plus utility files such -| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. -| In addition, the cleanroom implementations of third-party annotations, -| which the Checker Framework recognizes as aliases for its own -| annotations, are licensed under the MIT License. -| -| Permission is hereby granted, free of charge, to any person obtaining a copy -| of this software and associated documentation files (the "Software"), to deal -| in the Software without restriction, including without limitation the rights -| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -| copies of the Software, and to permit persons to whom the Software is -| furnished to do so, subject to the following conditions: -| -| The above copyright notice and this permission notice shall be included in -| all copies or substantial portions of the Software. -| -| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -| THE SOFTWARE. +Copyright: 2013-2020 The Apache Software Foundation. +Project URL: https://orc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Caffeine by Ben Manes. +This product bundles Apache Hive's Storage API (bundled by ORC). -Copyright: 2014-2020 Ben Manes and contributors -Home page: https://github.com/ben-manes/caffeine -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2008-2020 The Apache Software Foundation. +Project URL: https://hive.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google protobuf. +This product bundles Google protobuf (bundled by ORC). Copyright: 2008 Google Inc. -Home page: https://developers.google.com/protocol-buffers -License: https://github.com/protocolbuffers/protobuf/blob/master/LICENSE (BSD) - -License text: +Project URL: https://developers.google.com/protocol-buffers +License: BSD 3-Clause | Copyright 2008 Google Inc. All rights reserved. | @@ -371,14 +403,88 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains ThreeTen. +This product bundles Apache Datasketches. -Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. -Home page: https://www.threeten.org/threeten-extra/ -License: https://github.com/ThreeTen/threeten-extra/blob/master/LICENSE.txt (BSD 3-clause) +Project URL: https://datasketches.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Airlift Aircompressor. + +Copyright: 2011-2020 Aircompressor authors. +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Google Guava. + +Copyright: 2006-2020 The Guava Authors +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- -License text: +This product bundles Google Error Prone Annotations. +Copyright: Copyright 2011-2019 The Error Prone Authors +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles checkerframework checker-qual. + +Copyright: 2004-2020 the Checker Framework developers +Project URL: https://github.com/typetools/checker-framework +License: MIT + +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Caffeine by Ben Manes. + +Copyright: 2014-2020 Ben Manes and contributors +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles ThreeTen Extra. + +Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. +Project URL: https://www.threeten.org/threeten-extra/ +License: BSD 3-Clause + +| Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. | All rights reserved. | | * Redistribution and use in source and binary forms, with or without @@ -409,80 +515,63 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Project Nessie. +This product bundles Project Nessie. Copyright: Copyright 2015-2025 Dremio Corporation -Home page: https://projectnessie.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://projectnessie.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary includes code from Apache Commons. +This product includes code from Apache Commons. * Core ArrayUtil. Copyright: 2020 The Apache Software Foundation -Home page: https://commons.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- - -This binary artifact contains Apache HttpComponents Client. - -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://commons.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache HttpComponents Client. +This product bundles and includes code from Apache HttpComponents (core/client). * retry and error handling logic in ExponentialHttpRequestRetryStrategy.java Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains failsafe. +This product bundles failsafe. Copyright: Jonathan Halterman and friends -Home page: https://failsafe.dev/ -License: https://www.apache.org/licenses/LICENSE-2.0.html - --------------------------------------------------------------------------------- - -This binary artifact contains Codehale Metrics. - -Copyright: (c) 2010-2013 Coda Hale, Yammer.com, 2014-2021 Dropwizard Team -Home page: https://github.com/dropwizard/metrics -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains RoaringBitmap. +This product bundles RoaringBitmap. Copyright: (c) 2013-... the RoaringBitmap authors -Home page: https://github.com/RoaringBitmap/RoaringBitmap -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Eclipse Microprofile OpenAPI. +This product bundles Eclipse MicroProfile OpenAPI. Copyright: Copyright (c) 2017 Contributors to the Eclipse Foundation -Home page: https://github.com/microprofile/microprofile-open-api -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/microprofile/microprofile-open-api +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Luben Zstd. +This product bundles Luben Zstd. Copyright: Copyright (c) 2015-present, Luben Karavelov/ All rights reserved. -Home page: https://github.com/luben/zstd-jni/ -License: BSD-2 License -License text: +Project URL: https://github.com/luben/zstd-jni/ +License: BSD 2-Clause | Zstd-jni: JNI bindings to Zstd Library | @@ -510,3 +599,426 @@ License text: | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles JTS Topology Suite. + +Project URL: https://github.com/locationtech/jts +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| - Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| - Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| - Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles the Mozilla Public Suffix List, distributed by Apache HttpComponents. + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + diff --git a/flink/v2.0/flink-runtime/NOTICE b/flink/v2.0/flink-runtime/NOTICE index 0838a76b3473..61b02129d0e1 100644 --- a/flink/v2.0/flink-runtime/NOTICE +++ b/flink/v2.0/flink-runtime/NOTICE @@ -1,15 +1,13 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -This binary artifact includes Airlift Aircompressor with the following in its -NOTICE file: - +This product bundles Airlift Aircompressor with the following in its NOTICE file: | Snappy Copyright Notices | ========================= | @@ -50,9 +48,7 @@ NOTICE file: -------------------------------------------------------------------------------- -This binary artifact includes Project Nessie with the following in its NOTICE -file: - +This product bundles Project Nessie with the following in its NOTICE file: | Nessie | Copyright 2015-2025 Dremio Corporation | @@ -337,8 +333,7 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Eclipse Microprofile OpenAPI with the following in its NOTICE file: - +This product bundles Eclipse Microprofile OpenAPI with the following in its NOTICE file: | ========================================================================= | == NOTICE file corresponding to section 4(d) of the Apache License, == | == Version 2.0, in this case for MicroProfile OpenAPI == @@ -358,3 +353,39 @@ This binary artifact includes Eclipse Microprofile OpenAPI with the following in | PackageCopyrightText: | Arthur De Magalhaes arthurdm@ca.ibm.com | + +-------------------------------------------------------------------------------- + +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. diff --git a/flink/v2.0/flink-runtime/runtime-deps.txt b/flink/v2.0/flink-runtime/runtime-deps.txt new file mode 100644 index 000000000000..431cbe0d114e --- /dev/null +++ b/flink/v2.0/flink-runtime/runtime-deps.txt @@ -0,0 +1,31 @@ +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.21.3 +com.fasterxml.jackson.core:jackson-databind:2.21.3 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.github.luben:zstd-jni:1.5.7-3 +com.google.errorprone:error_prone_annotations:2.10.0 +dev.failsafe:failsafe:3.3.2 +io.airlift:aircompressor:2.0.3 +org.apache.avro:avro:1.12.1 +org.apache.datasketches:datasketches-java:6.2.0 +org.apache.datasketches:datasketches-memory:3.0.2 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.checkerframework:checker-qual:3.19.0 +org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1 +org.locationtech.jts:jts-core:1.20.0 +org.projectnessie.nessie:nessie-client:0.107.5 +org.projectnessie.nessie:nessie-model:0.107.5 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.threeten:threeten-extra:1.7.1 diff --git a/flink/v2.0/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java b/flink/v2.0/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java index d7c3a7b32bc8..6e5d04499e75 100644 --- a/flink/v2.0/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java +++ b/flink/v2.0/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java @@ -32,6 +32,7 @@ import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.data.RandomGenericData; import org.apache.iceberg.data.Record; import org.apache.iceberg.flink.CatalogLoader; @@ -94,7 +95,7 @@ public void setupBenchmark() throws IOException { r -> new DynamicRecordInternal( "t", - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA, RowDataConverter.convert(SCHEMA, r), PartitionSpec.unpartitioned(), diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java index e0672811cf5f..7661372c88e8 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java @@ -44,7 +44,7 @@ public FlinkConfParser(Table table, Map options, ReadableConfig this.readableConfig = readableConfig; } - FlinkConfParser(Map options, ReadableConfig readableConfig) { + public FlinkConfParser(Map options, ReadableConfig readableConfig) { this.tableProperties = ImmutableMap.of(); this.options = options; this.readableConfig = readableConfig; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java index ab69ec5adc7f..0612260bfe7d 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java @@ -67,6 +67,24 @@ private FlinkCreateTableOptions( .noDefaultValue() .withDescription("Properties for the underlying catalog for iceberg table."); + public static final ConfigOption USE_DYNAMIC_ICEBERG_SINK = + ConfigOptions.key("use-dynamic-iceberg-sink") + .booleanType() + .defaultValue(false) + .withDescription( + "Whether to use dynamic iceberg sink for routing data to multiple tables. " + + "When enabled, a single sink instance can dynamically route records to different " + + "Iceberg tables based on the logic defined in the DynamicRecordGenerator implementation. " + + "Requires 'dynamic-record-generator-impl' to be specified. " + + "Default is false (uses standard static sink behavior)."); + + public static final ConfigOption DYNAMIC_RECORD_GENERATOR_IMPL = + ConfigOptions.key("dynamic-record-generator-impl") + .stringType() + .noDefaultValue() + .withDescription( + "Implementation of DynamicTableRecordGenerator class when use-dynamic-iceberg-sink is enabled."); + public static final String SRC_CATALOG_PROPS_KEY = "src-catalog"; public static final String CONNECTOR_PROPS_KEY = "connector"; public static final String LOCATION_KEY = "location"; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java index bd79c1156090..b2e2e33b9291 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java @@ -88,26 +88,33 @@ public DynamicTableSink createDynamicTableSink(Context context) { ObjectIdentifier objectIdentifier = context.getObjectIdentifier(); ResolvedCatalogTable resolvedCatalogTable = context.getCatalogTable(); Map writeProps = resolvedCatalogTable.getOptions(); - ResolvedSchema resolvedSchema = - ResolvedSchema.of( - resolvedCatalogTable.getResolvedSchema().getColumns().stream() - .filter(Column::isPhysical) - .collect(Collectors.toList())); - TableLoader tableLoader; - if (catalog != null) { - tableLoader = createTableLoader(catalog, objectIdentifier.toObjectPath()); + Configuration flinkConf = new Configuration(); + writeProps.forEach(flinkConf::setString); + + boolean useDynamicSink = flinkConf.get(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK); + + if (useDynamicSink) { + return getIcebergTableSinkWithDynamicSinkProps(context, flinkConf, writeProps); } else { - tableLoader = - createTableLoader( - resolvedCatalogTable, - writeProps, - objectIdentifier.getDatabaseName(), - objectIdentifier.getObjectName()); - } + TableLoader tableLoader; + if (catalog != null) { + tableLoader = createTableLoader(catalog, objectIdentifier.toObjectPath()); + } else { + tableLoader = + createTableLoader( + resolvedCatalogTable, + writeProps, + objectIdentifier.getDatabaseName(), + objectIdentifier.getObjectName()); + } - return new IcebergTableSink( - tableLoader, resolvedSchema, context.getConfiguration(), writeProps); + return new IcebergTableSink( + tableLoader, + resolvedCatalogTable.getResolvedSchema(), + context.getConfiguration(), + writeProps); + } } @Override @@ -123,6 +130,8 @@ public Set> optionalOptions() { Set> options = Sets.newHashSet(); options.add(FlinkCreateTableOptions.CATALOG_DATABASE); options.add(FlinkCreateTableOptions.CATALOG_TABLE); + options.add(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK); + options.add(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL); return options; } @@ -131,6 +140,46 @@ public String factoryIdentifier() { return FACTORY_IDENTIFIER; } + private IcebergTableSink getIcebergTableSinkWithDynamicSinkProps( + Context context, Configuration flinkConf, Map writeProps) { + String dynamicRecordGeneratorImpl = + flinkConf.get(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL); + Preconditions.checkArgument( + dynamicRecordGeneratorImpl != null, + "Invalid dynamic record generator value: null. %s must be specified when use-dynamic-iceberg-sink is true.", + FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL.key()); + + CatalogLoader catalogLoader; + if (catalog != null) { + catalogLoader = catalog.getCatalogLoader(); + } else { + FlinkCatalog flinkCatalog = + createCatalogLoader(writeProps, flinkConf.get(FlinkCreateTableOptions.CATALOG_NAME)); + catalogLoader = flinkCatalog.getCatalogLoader(); + } + + ResolvedCatalogTable resolvedCatalogTable = context.getCatalogTable(); + + return new IcebergTableSink( + catalogLoader, + dynamicRecordGeneratorImpl, + resolvedCatalogTable.getResolvedSchema(), + context.getConfiguration(), + writeProps); + } + + private static FlinkCatalog createCatalogLoader( + Map tableProps, String catalogName) { + Preconditions.checkArgument( + catalogName != null, + "Invalid catalog name: null. Set %s create table option.", + FlinkCreateTableOptions.CATALOG_NAME); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + return (FlinkCatalog) + factory.createCatalog(catalogName, tableProps, FlinkCatalogFactory.clusterHadoopConf()); + } + private static TableLoader createTableLoader( ResolvedCatalogTable resolvedCatalogTable, Map tableProps, @@ -143,21 +192,16 @@ private static TableLoader createTableLoader( mergedProps.forEach(flinkConf::setString); String catalogName = flinkConf.get(FlinkCreateTableOptions.CATALOG_NAME); - Preconditions.checkNotNull( - catalogName, - "Table property '%s' cannot be null", - FlinkCreateTableOptions.CATALOG_NAME.key()); String catalogDatabase = flinkConf.get(FlinkCreateTableOptions.CATALOG_DATABASE, databaseName); - Preconditions.checkNotNull(catalogDatabase, "The iceberg database name cannot be null"); + Preconditions.checkArgument( + catalogDatabase != null, + "Invalid database name: null. Set %s create table option or specify fully qualified table name.", + FlinkCreateTableOptions.CATALOG_DATABASE); String catalogTable = flinkConf.get(FlinkCreateTableOptions.CATALOG_TABLE, tableName); - Preconditions.checkNotNull(catalogTable, "The iceberg table name cannot be null"); - org.apache.hadoop.conf.Configuration hadoopConf = FlinkCatalogFactory.clusterHadoopConf(); - FlinkCatalogFactory factory = new FlinkCatalogFactory(); - FlinkCatalog flinkCatalog = - (FlinkCatalog) factory.createCatalog(catalogName, mergedProps, hadoopConf); + FlinkCatalog flinkCatalog = createCatalogLoader(mergedProps, catalogName); ObjectPath objectPath = new ObjectPath(catalogDatabase, catalogTable); // Create database if not exists in the external catalog. @@ -229,7 +273,6 @@ private static Map mergeSrcCatalogProps(Map tabl } private static TableLoader createTableLoader(FlinkCatalog catalog, ObjectPath objectPath) { - Preconditions.checkNotNull(catalog, "Flink catalog cannot be null"); return TableLoader.fromCatalog(catalog.getCatalogLoader(), catalog.toIdentifier(objectPath)); } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java index 408065f06057..8f106da8d56b 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java @@ -137,11 +137,17 @@ public Type visit(TimeType timeType) { @Override public Type visit(TimestampType timestampType) { + if (timestampType.getPrecision() > 6) { + return Types.TimestampNanoType.withoutZone(); + } return Types.TimestampType.withoutZone(); } @Override public Type visit(LocalZonedTimestampType localZonedTimestampType) { + if (localZonedTimestampType.getPrecision() > 6) { + return Types.TimestampNanoType.withZone(); + } return Types.TimestampType.withZone(); } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java index 66fd09807794..990d23f2aaff 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java @@ -220,6 +220,24 @@ public Integer writeParallelism() { return confParser.intConf().option(FlinkWriteOptions.WRITE_PARALLELISM.key()).parseOptional(); } + public boolean expireSnapshotsMode() { + return confParser + .booleanConf() + .option(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key()) + .flinkConfig(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE) + .defaultValue(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.defaultValue()) + .parse(); + } + + public boolean deleteOrphanFilesMode() { + return confParser + .booleanConf() + .option(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key()) + .flinkConfig(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE) + .defaultValue(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.defaultValue()) + .parse(); + } + public boolean compactMode() { return confParser .booleanConf() diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java index e68e64ac573d..ee2aeaa45007 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java @@ -23,6 +23,9 @@ import org.apache.flink.configuration.ConfigOption; import org.apache.flink.configuration.ConfigOptions; import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; +import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; import org.apache.iceberg.flink.sink.shuffle.StatisticsType; /** Flink sink write options */ @@ -82,7 +85,18 @@ private FlinkWriteOptions() {} ConfigOptions.key("write-parallelism").intType().noDefaultValue(); public static final ConfigOption COMPACTION_ENABLE = - ConfigOptions.key("compaction-enabled").booleanType().defaultValue(false); + ConfigOptions.key(RewriteDataFilesConfig.PREFIX + "enabled") + .booleanType() + .defaultValue(false) + .withDeprecatedKeys("compaction-enabled"); + + public static final ConfigOption EXPIRE_SNAPSHOTS_ENABLE = + ConfigOptions.key(ExpireSnapshotsConfig.PREFIX + "enabled").booleanType().defaultValue(false); + + public static final ConfigOption DELETE_ORPHAN_FILES_ENABLE = + ConfigOptions.key(DeleteOrphanFilesConfig.PREFIX + "enabled") + .booleanType() + .defaultValue(false); @Experimental public static final ConfigOption TABLE_REFRESH_INTERVAL = diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java index c8c11474177c..3fc3d093f610 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java @@ -20,7 +20,11 @@ import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.flink.configuration.ReadableConfig; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.datastream.DataStreamSink; +import org.apache.flink.table.catalog.Column; import org.apache.flink.table.catalog.ResolvedSchema; import org.apache.flink.table.catalog.UniqueConstraint; import org.apache.flink.table.connector.ChangelogMode; @@ -28,29 +32,46 @@ import org.apache.flink.table.connector.sink.DynamicTableSink; import org.apache.flink.table.connector.sink.abilities.SupportsOverwrite; import org.apache.flink.table.connector.sink.abilities.SupportsPartitioning; +import org.apache.flink.table.data.RowData; import org.apache.flink.table.legacy.api.TableSchema; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.types.RowKind; +import org.apache.iceberg.common.DynConstructors; import org.apache.iceberg.flink.sink.FlinkSink; import org.apache.iceberg.flink.sink.IcebergSink; +import org.apache.iceberg.flink.sink.dynamic.DynamicIcebergSink; +import org.apache.iceberg.flink.sink.dynamic.DynamicRecordGenerator; +import org.apache.iceberg.flink.sink.dynamic.DynamicTableRecordGenerator; +import org.apache.iceberg.flink.sink.dynamic.TableCreator; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.util.PropertyUtil; public class IcebergTableSink implements DynamicTableSink, SupportsPartitioning, SupportsOverwrite { private final TableLoader tableLoader; - @Deprecated private final TableSchema tableSchema; + private final CatalogLoader catalogLoader; + + @SuppressWarnings("deprecation") + @Deprecated + private final TableSchema tableSchema; + private final ResolvedSchema resolvedSchema; private final ReadableConfig readableConfig; private final Map writeProps; - + private final String dynamicRecordGeneratorImpl; private boolean overwrite = false; + private boolean useDynamicSink = false; private IcebergTableSink(IcebergTableSink toCopy) { this.tableLoader = toCopy.tableLoader; + this.catalogLoader = toCopy.catalogLoader; this.tableSchema = toCopy.tableSchema; this.resolvedSchema = toCopy.resolvedSchema; this.overwrite = toCopy.overwrite; this.readableConfig = toCopy.readableConfig; this.writeProps = toCopy.writeProps; + this.dynamicRecordGeneratorImpl = toCopy.dynamicRecordGeneratorImpl; + this.useDynamicSink = toCopy.useDynamicSink; } /** @@ -64,10 +85,12 @@ public IcebergTableSink( ReadableConfig readableConfig, Map writeProps) { this.tableLoader = tableLoader; + this.catalogLoader = null; this.tableSchema = tableSchema; this.resolvedSchema = null; this.readableConfig = readableConfig; this.writeProps = writeProps; + this.dynamicRecordGeneratorImpl = null; } public IcebergTableSink( @@ -76,78 +99,71 @@ public IcebergTableSink( ReadableConfig readableConfig, Map writeProps) { this.tableLoader = tableLoader; + this.catalogLoader = null; this.tableSchema = null; this.resolvedSchema = resolvedSchema; this.readableConfig = readableConfig; this.writeProps = writeProps; + this.dynamicRecordGeneratorImpl = null; + } + + public IcebergTableSink( + CatalogLoader catalogLoader, + String dynamicRecordGeneratorImpl, + ResolvedSchema resolvedSchema, + ReadableConfig readableConfig, + Map writeProps) { + this.tableLoader = null; + this.catalogLoader = catalogLoader; + this.dynamicRecordGeneratorImpl = dynamicRecordGeneratorImpl; + this.readableConfig = readableConfig; + this.writeProps = writeProps; + this.tableSchema = null; + this.resolvedSchema = resolvedSchema; + this.useDynamicSink = true; } + @SuppressWarnings("deprecation") @Override public SinkRuntimeProvider getSinkRuntimeProvider(Context context) { Preconditions.checkState( !overwrite || context.isBounded(), "Unbounded data stream doesn't support overwrite operation."); - if (resolvedSchema != null) { - List equalityColumns = - resolvedSchema - .getPrimaryKey() - .map(UniqueConstraint::getColumns) - .orElseGet(ImmutableList::of); - - return (DataStreamSinkProvider) - (providerContext, dataStream) -> { - if (Boolean.TRUE.equals( - readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK))) { - return IcebergSink.forRowData(dataStream) - .tableLoader(tableLoader) - .resolvedSchema(resolvedSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } else { - return FlinkSink.forRowData(dataStream) - .tableLoader(tableLoader) - .resolvedSchema(resolvedSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } - }; - } else { - List equalityColumns = - tableSchema - .getPrimaryKey() - .map(org.apache.flink.table.legacy.api.constraints.UniqueConstraint::getColumns) - .orElseGet(ImmutableList::of); - - return (DataStreamSinkProvider) - (providerContext, dataStream) -> { - if (readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK)) { - return IcebergSink.forRowData(dataStream) - .tableLoader(tableLoader) - .tableSchema(tableSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } else { - return FlinkSink.forRowData(dataStream) - .tableLoader(tableLoader) - .tableSchema(tableSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } - }; - } + return (DataStreamSinkProvider) + (providerContext, dataStream) -> { + if (useDynamicSink) { + return createDynamicIcebergSink(dataStream); + } + + ResolvedSchema physicalColumnsOnlySchema = null; + List equalityColumns; + if (resolvedSchema != null) { + physicalColumnsOnlySchema = + ResolvedSchema.of( + resolvedSchema.getColumns().stream() + .filter(Column::isPhysical) + .collect(Collectors.toList())); + + equalityColumns = + physicalColumnsOnlySchema + .getPrimaryKey() + .map(UniqueConstraint::getColumns) + .orElseGet(ImmutableList::of); + } else { + equalityColumns = + tableSchema + .getPrimaryKey() + .map(org.apache.flink.table.legacy.api.constraints.UniqueConstraint::getColumns) + .orElseGet(ImmutableList::of); + } + + if (readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK)) { + return createIcebergSink(dataStream, equalityColumns, physicalColumnsOnlySchema); + } else { + return createLegacySink(dataStream, equalityColumns, physicalColumnsOnlySchema); + } + }; } @Override @@ -179,4 +195,104 @@ public String asSummaryString() { public void applyOverwrite(boolean newOverwrite) { this.overwrite = newOverwrite; } + + private DataStreamSink createLegacySink( + DataStream dataStream, + List equalityColumns, + ResolvedSchema physicalColumnsOnlySchema) { + FlinkSink.Builder builder = + FlinkSink.forRowData(dataStream) + .tableLoader(tableLoader) + .equalityFieldColumns(equalityColumns) + .overwrite(overwrite) + .setAll(writeProps) + .flinkConf(readableConfig); + + if (physicalColumnsOnlySchema != null) { + builder = builder.resolvedSchema(physicalColumnsOnlySchema); + } else { + builder = builder.tableSchema(tableSchema); + } + + return builder.append(); + } + + private DataStreamSink createIcebergSink( + DataStream dataStream, + List equalityColumns, + ResolvedSchema physicalColumnsOnlySchema) { + IcebergSink.Builder builder = + IcebergSink.forRowData(dataStream) + .tableLoader(tableLoader) + .equalityFieldColumns(equalityColumns) + .overwrite(overwrite) + .setAll(writeProps) + .flinkConf(readableConfig); + + if (physicalColumnsOnlySchema != null) { + builder = builder.resolvedSchema(physicalColumnsOnlySchema); + } else { + builder = builder.tableSchema(tableSchema); + } + + return builder.append(); + } + + private DataStreamSink createDynamicIcebergSink(DataStream dataStream) { + Preconditions.checkArgument( + catalogLoader != null && dynamicRecordGeneratorImpl != null, + "Invalid value catalogLoader: %s, DynamicRecordGenerator Implementation class: %s. " + + "Both should be not null to use dynamic iceberg sink.", + catalogLoader, + dynamicRecordGeneratorImpl); + + TableCreator tableCreator = createTableCreator(); + DynamicRecordGenerator generator = + createDynamicRecordGenerator(dynamicRecordGeneratorImpl); + + DynamicIcebergSink.Builder builder = + DynamicIcebergSink.forInput(dataStream) + .generator(generator) + .catalogLoader(catalogLoader) + .setAll(writeProps) + .tableCreator(tableCreator) + .flinkConf(readableConfig); + + return builder.append(); + } + + private TableCreator createTableCreator() { + final Map tableProperties = + PropertyUtil.propertiesWithPrefix(writeProps, "table.props."); + final String location = writeProps.get("location"); + + return (catalog, identifier, schema, spec) -> + catalog + .buildTable(identifier, schema) + .withPartitionSpec(spec) + .withLocation(location) + .withProperties(tableProperties) + .create(); + } + + private DynamicTableRecordGenerator createDynamicRecordGenerator(String generatorImpl) { + RowType rowType = (RowType) resolvedSchema.toSourceRowDataType().getLogicalType(); + + DynConstructors.Ctor ctor; + + try { + ctor = + DynConstructors.builder(DynamicTableRecordGenerator.class) + .loader(IcebergTableSink.class.getClassLoader()) + .impl(generatorImpl, RowType.class) + .buildChecked(); + return ctor.newInstance(rowType); + } catch (ClassCastException e) { + throw new IllegalArgumentException( + String.format("Class %s does not implement DynamicRecordGeneratorSQL", generatorImpl), e); + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to instantiate DynamicRecordGeneratorSQL %s", generatorImpl), e); + } + } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java index 3ef611f2ded5..920e44b24b31 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java @@ -114,19 +114,35 @@ private static PositionalGetter buildGetter(LogicalType logicalType, Type typ case TIMESTAMP_WITHOUT_TIME_ZONE: TimestampType timestampType = (TimestampType) logicalType; - return (row, pos) -> { - LocalDateTime localDateTime = - row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); - return DateTimeUtil.microsFromTimestamp(localDateTime); - }; + if (type.typeId() == Type.TypeID.TIMESTAMP_NANO) { + return (row, pos) -> { + LocalDateTime localDateTime = + row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); + return DateTimeUtil.nanosFromTimestamp(localDateTime); + }; + } else { + return (row, pos) -> { + LocalDateTime localDateTime = + row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); + return DateTimeUtil.microsFromTimestamp(localDateTime); + }; + } case TIMESTAMP_WITH_LOCAL_TIME_ZONE: LocalZonedTimestampType lzTs = (LocalZonedTimestampType) logicalType; - return (row, pos) -> { - TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); - return timestampData.getMillisecond() * 1000 - + timestampData.getNanoOfMillisecond() / 1000; - }; + if (type.typeId() == Type.TypeID.TIMESTAMP_NANO) { + return (row, pos) -> { + TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + }; + } else { + return (row, pos) -> { + TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + }; + } case ROW: RowType rowType = (RowType) logicalType; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java index 4cf30ed90418..dcd4ac4e58a5 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java @@ -53,7 +53,7 @@ protected FileIO fileIO() { protected List rewriteDataForTasks(List combinedScanTasks) { int size = combinedScanTasks.size(); int parallelism = Math.min(size, maxParallelism); - DataStream dataStream = env.fromCollection(combinedScanTasks); + DataStream dataStream = env.fromData(combinedScanTasks); RowDataRewriter rowDataRewriter = new RowDataRewriter(table(), caseSensitive(), fileIO(), encryptionManager()); try { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java index 66ed95792e62..b0f8a4217115 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java @@ -33,6 +33,7 @@ import org.apache.iceberg.avro.MetricsAwareDatumWriter; import org.apache.iceberg.avro.ValueWriter; import org.apache.iceberg.avro.ValueWriters; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; public class FlinkAvroWriter implements MetricsAwareDatumWriter { @@ -43,6 +44,10 @@ public FlinkAvroWriter(RowType rowType) { this.rowType = rowType; } + public FlinkAvroWriter(org.apache.iceberg.Schema icebergSchema, RowType engineSchema) { + this(engineSchema != null ? engineSchema : FlinkSchemaUtil.convert(icebergSchema)); + } + @Override @SuppressWarnings("unchecked") public void setSchema(Schema schema) { @@ -127,7 +132,7 @@ public ValueWriter primitive(LogicalType type, Schema primitive) { return FlinkValueWriters.decimal(decimal.getPrecision(), decimal.getScale()); case "uuid": - return ValueWriters.uuids(); + return FlinkValueWriters.uuids(); default: throw new IllegalArgumentException("Unsupported logical type: " + logicalType); diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java new file mode 100644 index 000000000000..dd713b0dce2a --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.data; + +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; + +public class FlinkFormatModels { + public static void register() { + FormatModelRegistry.register( + ParquetFormatModel.create( + RowData.class, + RowType.class, + FlinkParquetWriters::buildWriter, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + FlinkParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + AvroFormatModel.create( + RowData.class, + RowType.class, + (icebergSchema, fileSchema, engineSchema) -> + new FlinkAvroWriter(icebergSchema, engineSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + FlinkPlannedAvroReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + RowData.class, + RowType.class, + (icebergSchema, fileSchema, engineSchema) -> + FlinkOrcWriter.buildWriter(engineSchema, icebergSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + new FlinkOrcReader(icebergSchema, fileSchema, idToConstant))); + } + + private FlinkFormatModels() {} +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java index 65b9d44ad4b8..77f16bfdb2ab 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java @@ -70,7 +70,7 @@ public OrcValueReader record( TypeDescription record, List names, List> fields) { - return FlinkOrcReaders.struct(fields, iStruct, idToConstant); + return FlinkOrcReaders.struct(record, fields, iStruct, idToConstant); } @Override @@ -112,6 +112,13 @@ public OrcValueReader primitive(Type.PrimitiveType iPrimitive, TypeDescriptio } else { return FlinkOrcReaders.timestamps(); } + case TIMESTAMP_NANO: + Types.TimestampNanoType timestampNanoType = (Types.TimestampNanoType) iPrimitive; + if (timestampNanoType.shouldAdjustToUTC()) { + return FlinkOrcReaders.timestampTzs(); + } else { + return FlinkOrcReaders.timestamps(); + } case STRING: return FlinkOrcReaders.strings(); case UUID: diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java index 7a4a15c7e600..c5c958fbdb04 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java @@ -39,6 +39,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.types.Types; +import org.apache.orc.TypeDescription; import org.apache.orc.storage.ql.exec.vector.BytesColumnVector; import org.apache.orc.storage.ql.exec.vector.ColumnVector; import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector; @@ -91,8 +92,11 @@ public static OrcValueReader map( } public static OrcValueReader struct( - List> readers, Types.StructType struct, Map idToConstant) { - return new StructReader(readers, struct, idToConstant); + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + return new StructReader(record, readers, struct, idToConstant); } private static class StringReader implements OrcValueReader { @@ -265,8 +269,11 @@ private static class StructReader extends OrcValueReaders.StructReader private final int numFields; StructReader( - List> readers, Types.StructType struct, Map idToConstant) { - super(readers, struct, idToConstant); + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + super(record, readers, struct, idToConstant); this.numFields = struct.fields().size(); } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java index 6a31accffd22..c1b46252e18a 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java @@ -27,6 +27,7 @@ import org.apache.iceberg.FieldMetrics; import org.apache.iceberg.Schema; import org.apache.iceberg.data.orc.GenericOrcWriters; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.orc.OrcRowWriter; import org.apache.iceberg.orc.OrcValueWriter; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -45,7 +46,8 @@ private FlinkOrcWriter(RowType rowType, Schema iSchema) { } public static OrcRowWriter buildWriter(RowType rowType, Schema iSchema) { - return new FlinkOrcWriter(rowType, iSchema); + return new FlinkOrcWriter( + rowType != null ? rowType : FlinkSchemaUtil.convert(iSchema), iSchema); } @Override @@ -143,6 +145,13 @@ public OrcValueWriter primitive(Type.PrimitiveType iPrimitive, LogicalType fl } else { return FlinkOrcWriters.timestamps(); } + case TIMESTAMP_NANO: + Types.TimestampNanoType timestampNanoType = (Types.TimestampNanoType) iPrimitive; + if (timestampNanoType.shouldAdjustToUTC()) { + return FlinkOrcWriters.timestampNanoTzs(); + } else { + return FlinkOrcWriters.timestampNanos(); + } case STRING: return FlinkOrcWriters.strings(); case UUID: diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java index afce2cda1db1..bf19a46c05fb 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java @@ -30,6 +30,7 @@ import org.apache.flink.table.data.StringData; import org.apache.flink.table.data.TimestampData; import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeRoot; import org.apache.iceberg.FieldMetrics; import org.apache.iceberg.data.orc.GenericOrcWriters; import org.apache.iceberg.flink.FlinkRowData; @@ -69,6 +70,14 @@ static OrcValueWriter timestampTzs() { return TimestampTzWriter.INSTANCE; } + static OrcValueWriter timestampNanos() { + return TimestampNanoWriter.INSTANCE; + } + + static OrcValueWriter timestampNanoTzs() { + return TimestampNanoTzWriter.INSTANCE; + } + static OrcValueWriter decimals(int precision, int scale) { if (precision <= 18) { return new Decimal18Writer(precision, scale); @@ -93,7 +102,19 @@ static OrcValueWriter map( } static OrcValueWriter struct(List> writers, List types) { - return new RowDataWriter(writers, types); + int[] fieldIndexes = new int[writers.size()]; + int fieldIndex = 0; + List logicalTypes = Lists.newArrayList(); + for (int i = 0; i < types.size(); i += 1) { + LogicalType logicalType = types.get(i); + if (!logicalType.is(LogicalTypeRoot.NULL)) { + fieldIndexes[fieldIndex] = i; + fieldIndex += 1; + logicalTypes.add(logicalType); + } + } + + return new RowDataWriter(fieldIndexes, writers, logicalTypes); } private static class StringWriter implements OrcValueWriter { @@ -157,6 +178,35 @@ public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { } } + private static class TimestampNanoWriter implements OrcValueWriter { + private static final TimestampNanoWriter INSTANCE = new TimestampNanoWriter(); + + @Override + public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { + TimestampColumnVector cv = (TimestampColumnVector) output; + cv.setIsUTC(true); + // millis + OffsetDateTime offsetDateTime = data.toInstant().atOffset(ZoneOffset.UTC); + cv.time[rowId] = + offsetDateTime.toEpochSecond() * 1_000 + offsetDateTime.getNano() / 1_000_000; + cv.nanos[rowId] = offsetDateTime.getNano(); + } + } + + private static class TimestampNanoTzWriter implements OrcValueWriter { + private static final TimestampNanoTzWriter INSTANCE = new TimestampNanoTzWriter(); + + @SuppressWarnings("JavaInstantGetSecondsGetNano") + @Override + public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { + TimestampColumnVector cv = (TimestampColumnVector) output; + // millis + Instant instant = data.toInstant(); + cv.time[rowId] = instant.toEpochMilli(); + cv.nanos[rowId] = instant.getNano(); + } + } + private static class Decimal18Writer implements OrcValueWriter { private final int precision; private final int scale; @@ -294,12 +344,12 @@ public Stream> metrics() { static class RowDataWriter extends GenericOrcWriters.StructWriter { private final List fieldGetters; - RowDataWriter(List> writers, List types) { + RowDataWriter(int[] fieldIndexes, List> writers, List types) { super(writers); this.fieldGetters = Lists.newArrayListWithExpectedSize(types.size()); for (int i = 0; i < types.size(); i++) { - fieldGetters.add(FlinkRowData.createFieldGetter(types.get(i), i)); + fieldGetters.add(FlinkRowData.createFieldGetter(types.get(i), fieldIndexes[i])); } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java index 4e650e9574e3..3de64aa99865 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java @@ -294,6 +294,12 @@ public Optional> visit( LogicalTypeAnnotation.BsonLogicalTypeAnnotation bsonLogicalType) { return Optional.of(new ParquetValueReaders.ByteArrayReader(desc)); } + + @Override + public Optional> visit( + LogicalTypeAnnotation.UUIDLogicalTypeAnnotation uuidLogicalType) { + return Optional.of(new ParquetValueReaders.ByteArrayReader(desc)); + } } @Override diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java index 5c90252723bd..2957a4837893 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java @@ -37,7 +37,9 @@ import org.apache.flink.table.types.logical.RowType.RowField; import org.apache.flink.table.types.logical.SmallIntType; import org.apache.flink.table.types.logical.TinyIntType; +import org.apache.iceberg.Schema; import org.apache.iceberg.flink.FlinkRowData; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.parquet.ParquetValueReaders; import org.apache.iceberg.parquet.ParquetValueWriter; import org.apache.iceberg.parquet.ParquetValueWriters; @@ -59,6 +61,7 @@ import org.apache.parquet.schema.LogicalTypeAnnotation.StringLogicalTypeAnnotation; import org.apache.parquet.schema.LogicalTypeAnnotation.TimeLogicalTypeAnnotation; import org.apache.parquet.schema.LogicalTypeAnnotation.TimestampLogicalTypeAnnotation; +import org.apache.parquet.schema.LogicalTypeAnnotation.UUIDLogicalTypeAnnotation; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; @@ -66,6 +69,12 @@ public class FlinkParquetWriters { private FlinkParquetWriters() {} + public static ParquetValueWriter buildWriter( + Schema icebergSchema, MessageType type, RowType engineSchema) { + return buildWriter( + engineSchema != null ? engineSchema : FlinkSchemaUtil.convert(icebergSchema), type); + } + @SuppressWarnings("unchecked") public static ParquetValueWriter buildWriter(LogicalType schema, MessageType type) { return (ParquetValueWriter) @@ -279,6 +288,11 @@ public Optional> visit(JsonLogicalTypeAnnotation ignored) public Optional> visit(BsonLogicalTypeAnnotation ignored) { return Optional.of(byteArrays(desc)); } + + @Override + public Optional> visit(UUIDLogicalTypeAnnotation uuid) { + return Optional.of(byteArrays(desc)); + } } private static ParquetValueWriter ints(LogicalType type, ColumnDescriptor desc) { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java index ba4e1a7a7aec..1440fde3248c 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java @@ -106,7 +106,9 @@ private static T visitRecord( visitor.beforeField(iField); try { - results.add(visit(fieldFlinkType, iField.type(), visitor)); + if (iField.type() != Types.UnknownType.get()) { + results.add(visit(fieldFlinkType, iField.type(), visitor)); + } } finally { visitor.afterField(iField); } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java index f87e63704965..66e944eb52e3 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java @@ -55,6 +55,10 @@ static ValueWriter timestampNanos() { return TimestampNanosWriter.INSTANCE; } + static ValueWriter uuids() { + return UUIDWriter.INSTANCE; + } + static ValueWriter decimal(int precision, int scale) { return new DecimalWriter(precision, scale); } @@ -145,6 +149,15 @@ public void write(TimestampData timestampData, Encoder encoder) throws IOExcepti } } + private static class UUIDWriter implements ValueWriter { + private static final UUIDWriter INSTANCE = new UUIDWriter(); + + @Override + public void write(byte[] bytes, Encoder encoder) throws IOException { + encoder.writeFixed(bytes); + } + } + private static class ArrayWriter implements ValueWriter { private final ValueWriter elementWriter; private final ArrayData.ElementGetter elementGetter; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java index f23a7ee3d0d3..81bb55967992 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java @@ -69,6 +69,8 @@ public static Object convertConstant(Type type, Object value) { return (int) ((Long) value / 1000); case TIMESTAMP: // TimestampData return TimestampData.fromLocalDateTime(DateTimeUtil.timestampFromMicros((Long) value)); + case TIMESTAMP_NANO: + return TimestampData.fromLocalDateTime(DateTimeUtil.timestampFromNanos((Long) value)); case UUID: return UUIDUtil.convert((UUID) value); default: diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java index 34576a1e5c0b..b469f2310f42 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java @@ -48,6 +48,7 @@ import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.DateTimeUtil; @Internal public class StructRowData implements RowData { @@ -120,8 +121,8 @@ public int getInt(int pos) { if (integer instanceof Integer) { return (int) integer; - } else if (integer instanceof LocalDate) { - return (int) ((LocalDate) integer).toEpochDay(); + } else if (integer instanceof LocalDate localDate) { + return (int) localDate.toEpochDay(); } else if (integer instanceof LocalTime) { return (int) (((LocalTime) integer).toNanoOfDay() / 1000_000); } else { @@ -185,8 +186,27 @@ private BigDecimal getDecimalInternal(int pos) { @Override public TimestampData getTimestamp(int pos, int precision) { + if (precision > 6) { + Object timeVal = struct.get(pos, Object.class); + if (timeVal instanceof OffsetDateTime) { + OffsetDateTime odt = (OffsetDateTime) timeVal; + return TimestampData.fromEpochMillis( + odt.toInstant().toEpochMilli(), odt.getNano() % 1_000_000); + } else if (timeVal instanceof LocalDateTime) { + LocalDateTime ldt = (LocalDateTime) timeVal; + return TimestampData.fromEpochMillis( + ldt.toInstant(ZoneOffset.UTC).toEpochMilli(), ldt.getNano() % 1_000_000); + } else if (timeVal instanceof Long) { + long timeLong = (Long) timeVal; + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1_000_000L), (int) Math.floorMod(timeLong, 1_000_000L)); + } else { + throw new IllegalStateException("Unknown type for timestamp_ns: " + timeVal.getClass()); + } + } long timeLong = getLong(pos); - return TimestampData.fromEpochMillis(timeLong / 1000, (int) (timeLong % 1000) * 1000); + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1000L), (int) Math.floorMod(timeLong, 1000L) * 1000); } @Override @@ -257,9 +277,29 @@ private Object convertValue(Type elementType, Object value) { case DECIMAL: return value; case TIMESTAMP: - long millisecond = (long) value / 1000; - int nanoOfMillisecond = (int) ((Long) value % 1000) * 1000; - return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); + long timeMillis; + if (value instanceof LocalDateTime localDateTime) { + timeMillis = DateTimeUtil.microsFromTimestamp(localDateTime) / 1000L; + } else if (value instanceof OffsetDateTime offsetDateTime) { + timeMillis = DateTimeUtil.microsFromTimestamptz(offsetDateTime) / 1000L; + } else { + timeMillis = Math.floorDiv((Long) value, 1000L); + } + return TimestampData.fromEpochMillis( + timeMillis, + (int) Math.floorMod(value instanceof Long ? (Long) value : timeMillis * 1000L, 1000L) + * 1000); + case TIMESTAMP_NANO: + long nanoLong; + if (value instanceof LocalDateTime localDateTime) { + nanoLong = DateTimeUtil.nanosFromTimestamp(localDateTime); + } else if (value instanceof OffsetDateTime offsetDateTime) { + nanoLong = DateTimeUtil.nanosFromTimestamptz(offsetDateTime); + } else { + nanoLong = (Long) value; + } + return TimestampData.fromEpochMillis( + Math.floorDiv(nanoLong, 1_000_000L), (int) Math.floorMod(nanoLong, 1_000_000L)); case STRING: return StringData.fromString(value.toString()); case FIXED: diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java new file mode 100644 index 000000000000..0f70e60a1b9f --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java @@ -0,0 +1,303 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import java.io.Serializable; +import java.lang.reflect.Array; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoField; +import java.util.List; +import java.util.Map; +import org.apache.avro.generic.GenericFixed; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.generic.IndexedRecord; +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.GenericArrayData; +import org.apache.flink.table.data.GenericMapData; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.utils.LogicalTypeUtils; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; + +/** + * Tool class used to convert from Avro {@link GenericRecord} to {@link RowData}. + * + *

    This class is adapted in Iceberg to add support for nanosecond precision timestamps + * (FLINK-39251). Once that ticket is resolved in Flink, this custom converter may be removed. + */ +@Internal +public class AvroToRowDataConverters { + + private AvroToRowDataConverters() {} + + /** + * Runtime converter that converts Avro data structures into objects of Flink Table & SQL + * internal data structures. + */ + @FunctionalInterface + public interface AvroToRowDataConverter extends Serializable { + Object convert(Object object); + } + + // ------------------------------------------------------------------------------------- + // Runtime Converters + // ------------------------------------------------------------------------------------- + + public static AvroToRowDataConverter createRowConverter(RowType rowType) { + return createRowConverter(rowType, true); + } + + public static AvroToRowDataConverter createRowConverter( + RowType rowType, boolean legacyTimestampMapping) { + final AvroToRowDataConverter[] fieldConverters = + rowType.getFields().stream() + .map(RowType.RowField::getType) + .map(type -> createNullableConverter(type, legacyTimestampMapping)) + .toArray(AvroToRowDataConverter[]::new); + final int arity = rowType.getFieldCount(); + + return avroObject -> { + IndexedRecord record = (IndexedRecord) avroObject; + GenericRowData row = new GenericRowData(arity); + for (int i = 0; i < arity; ++i) { + // avro always deserialize successfully even though the type isn't matched + // so no need to throw exception about which field can't be deserialized + row.setField(i, fieldConverters[i].convert(record.get(i))); + } + return row; + }; + } + + /** Creates a runtime converter which is null safe. */ + private static AvroToRowDataConverter createNullableConverter( + LogicalType type, boolean legacyTimestampMapping) { + final AvroToRowDataConverter converter = createConverter(type, legacyTimestampMapping); + return avroObject -> { + if (avroObject == null) { + return null; + } + return converter.convert(avroObject); + }; + } + + /** Creates a runtime converter which assuming input object is not null. */ + private static AvroToRowDataConverter createConverter( + LogicalType type, boolean legacyTimestampMapping) { + switch (type.getTypeRoot()) { + case NULL: + return avroObject -> null; + case TINYINT: + return avroObject -> ((Integer) avroObject).byteValue(); + case SMALLINT: + return avroObject -> ((Integer) avroObject).shortValue(); + case BOOLEAN: // boolean + case INTEGER: // int + case INTERVAL_YEAR_MONTH: // long + case BIGINT: // long + case INTERVAL_DAY_TIME: // long + case FLOAT: // float + case DOUBLE: // double + return avroObject -> avroObject; + case DATE: + return AvroToRowDataConverters::convertToDate; + case TIME_WITHOUT_TIME_ZONE: + return AvroToRowDataConverters::convertToTime; + case TIMESTAMP_WITHOUT_TIME_ZONE: + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return avroObject -> convertToTimestamp(avroObject, type); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + if (legacyTimestampMapping) { + throw new UnsupportedOperationException("Unsupported type: " + type); + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return avroObject -> convertToTimestamp(avroObject, type); + } + case CHAR: + case VARCHAR: + return avroObject -> StringData.fromString(avroObject.toString()); + case BINARY: + case VARBINARY: + return AvroToRowDataConverters::convertToBytes; + case DECIMAL: + return createDecimalConverter((DecimalType) type); + case ARRAY: + return createArrayConverter((ArrayType) type, legacyTimestampMapping); + case ROW: + return createRowConverter((RowType) type); + case MAP: + case MULTISET: + return createMapConverter(type, legacyTimestampMapping); + case RAW: + default: + throw new UnsupportedOperationException("Unsupported type: " + type); + } + } + + private static AvroToRowDataConverter createDecimalConverter(DecimalType decimalType) { + final int precision = decimalType.getPrecision(); + final int scale = decimalType.getScale(); + return avroObject -> { + final byte[] bytes; + if (avroObject instanceof GenericFixed) { + bytes = ((GenericFixed) avroObject).bytes(); + } else if (avroObject instanceof ByteBuffer) { + ByteBuffer byteBuffer = (ByteBuffer) avroObject; + bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + } else { + bytes = (byte[]) avroObject; + } + return DecimalData.fromUnscaledBytes(bytes, precision, scale); + }; + } + + private static AvroToRowDataConverter createArrayConverter( + ArrayType arrayType, boolean legacyTimestampMapping) { + final AvroToRowDataConverter elementConverter = + createNullableConverter(arrayType.getElementType(), legacyTimestampMapping); + final Class elementClass = + LogicalTypeUtils.toInternalConversionClass(arrayType.getElementType()); + + return avroObject -> { + final List list = (List) avroObject; + final int length = list.size(); + final Object[] array = (Object[]) Array.newInstance(elementClass, length); + for (int i = 0; i < length; ++i) { + array[i] = elementConverter.convert(list.get(i)); + } + return new GenericArrayData(array); + }; + } + + private static AvroToRowDataConverter createMapConverter( + LogicalType type, boolean legacyTimestampMapping) { + final AvroToRowDataConverter keyConverter = + createConverter(DataTypes.STRING().getLogicalType(), legacyTimestampMapping); + final AvroToRowDataConverter valueConverter = + createNullableConverter( + AvroSchemaConverter.extractValueTypeToAvroMap(type), legacyTimestampMapping); + + return avroObject -> { + final Map map = (Map) avroObject; + Map result = Maps.newHashMap(); + for (Map.Entry entry : map.entrySet()) { + Object key = keyConverter.convert(entry.getKey()); + Object value = valueConverter.convert(entry.getValue()); + result.put(key, value); + } + return new GenericMapData(result); + }; + } + + private static TimestampData convertToTimestamp(Object object, LogicalType type) { + int precision = 3; + if (type instanceof org.apache.flink.table.types.logical.TimestampType) { + precision = ((org.apache.flink.table.types.logical.TimestampType) type).getPrecision(); + } else if (type instanceof org.apache.flink.table.types.logical.LocalZonedTimestampType) { + precision = + ((org.apache.flink.table.types.logical.LocalZonedTimestampType) type).getPrecision(); + } + + if (object instanceof Long) { + long timeLong = (Long) object; + if (precision <= 3) { + return TimestampData.fromEpochMillis(timeLong); + } else if (precision <= 6) { + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1000L), (int) Math.floorMod(timeLong, 1000L) * 1_000_000); + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1_000_000L), (int) Math.floorMod(timeLong, 1_000_000L)); + } + } else if (object instanceof Instant) { + return TimestampData.fromInstant((Instant) object); + } else if (object instanceof LocalDateTime) { + return TimestampData.fromLocalDateTime((LocalDateTime) object); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + return TimestampData.fromEpochMillis(jodaConverter.convertTimestamp(object)); + } else { + throw new IllegalArgumentException( + "Unexpected object type for TIMESTAMP logical type. Received: " + object); + } + } + } + + private static int convertToDate(Object object) { + if (object instanceof Integer) { + return (Integer) object; + } else if (object instanceof LocalDate) { + return (int) ((LocalDate) object).toEpochDay(); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + return (int) jodaConverter.convertDate(object); + } else { + throw new IllegalArgumentException( + "Unexpected object type for DATE logical type. Received: " + object); + } + } + } + + private static int convertToTime(Object object) { + final int millis; + if (object instanceof Integer) { + millis = (Integer) object; + } else if (object instanceof LocalTime) { + millis = ((LocalTime) object).get(ChronoField.MILLI_OF_DAY); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + millis = jodaConverter.convertTime(object); + } else { + throw new IllegalArgumentException( + "Unexpected object type for TIME logical type. Received: " + object); + } + } + return millis; + } + + private static byte[] convertToBytes(Object object) { + if (object instanceof GenericFixed) { + return ((GenericFixed) object).bytes(); + } else if (object instanceof ByteBuffer) { + ByteBuffer byteBuffer = (ByteBuffer) object; + byte[] bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + return bytes; + } else { + return (byte[]) object; + } + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java new file mode 100644 index 000000000000..c30b78023345 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeFieldType; +import org.joda.time.LocalDate; +import org.joda.time.LocalTime; + +/** + * Encapsulates joda optional dependency. Instantiates this class only if joda is available on the + * classpath. + */ +@SuppressWarnings("JavaUtilDate") +class JodaConverter { + + private static JodaConverter instance; + private static boolean instantiated = false; + + public static JodaConverter getConverter() { + if (instantiated) { + return instance; + } + + try { + Class.forName( + "org.joda.time.DateTime", false, Thread.currentThread().getContextClassLoader()); + instance = new JodaConverter(); + } catch (ClassNotFoundException e) { + instance = null; + } finally { + instantiated = true; + } + return instance; + } + + public long convertDate(Object object) { + final LocalDate value = (LocalDate) object; + return value.toDate().getTime(); + } + + public int convertTime(Object object) { + final LocalTime value = (LocalTime) object; + return value.get(DateTimeFieldType.millisOfDay()); + } + + public long convertTimestamp(Object object) { + final DateTime value = (DateTime) object; + return value.toDate().getTime(); + } + + private JodaConverter() {} +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java new file mode 100644 index 000000000000..d4c7e4282d6e --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java @@ -0,0 +1,394 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.util.Utf8; +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.data.ArrayData; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.util.CollectionUtil; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; + +/** + * Tool class used to convert from {@link RowData} to Avro {@link GenericRecord}. + * + *

    This class is adapted in Iceberg to add support for nanosecond precision timestamps + * (FLINK-39251). Once that ticket is resolved in Flink, this custom converter may be removed. + */ +@Internal +public class RowDataToAvroConverters { + + private RowDataToAvroConverters() {} + + // -------------------------------------------------------------------------------- + // Runtime Converters + // -------------------------------------------------------------------------------- + + /** + * Runtime converter that converts objects of Flink Table & SQL internal data structures to + * corresponding Avro data structures. + */ + @FunctionalInterface + public interface RowDataToAvroConverter extends Serializable { + Object convert(Schema schema, Object object); + } + + // -------------------------------------------------------------------------------- + // IMPORTANT! We use anonymous classes instead of lambdas for a reason here. It is + // necessary because the maven shade plugin cannot relocate classes in + // SerializedLambdas (MSHADE-260). On the other hand we want to relocate Avro for + // sql-client uber jars. + // -------------------------------------------------------------------------------- + + /** + * Creates a runtime converter according to the given logical type that converts objects of Flink + * Table & SQL internal data structures to corresponding Avro data structures. + */ + public static RowDataToAvroConverter createConverter(LogicalType type) { + return createConverter(type, true); + } + + @SuppressWarnings("checkstyle:MethodLength") + public static RowDataToAvroConverter createConverter( + LogicalType type, boolean legacyTimestampMapping) { + final RowDataToAvroConverter converter; + switch (type.getTypeRoot()) { + case NULL: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return null; + } + }; + break; + case TINYINT: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ((Byte) object).intValue(); + } + }; + break; + case SMALLINT: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ((Short) object).intValue(); + } + }; + break; + case BOOLEAN: // boolean + case INTEGER: // int + case INTERVAL_YEAR_MONTH: // long + case BIGINT: // long + case INTERVAL_DAY_TIME: // long + case FLOAT: // float + case DOUBLE: // double + case TIME_WITHOUT_TIME_ZONE: // int + case DATE: // int + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return object; + } + }; + break; + case CHAR: + case VARCHAR: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return new Utf8(object.toString()); + } + }; + break; + case BINARY: + case VARBINARY: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ByteBuffer.wrap((byte[]) object); + } + }; + break; + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + case TIMESTAMP_WITHOUT_TIME_ZONE: + final int tzPrecision; + if (type instanceof org.apache.flink.table.types.logical.TimestampType) { + tzPrecision = ((org.apache.flink.table.types.logical.TimestampType) type).getPrecision(); + } else { + tzPrecision = 3; + } + if (legacyTimestampMapping) { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + if (tzPrecision <= 3) { + return timestampData.getMillisecond(); + } else if (tzPrecision <= 6) { + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + } + } + }; + } else { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + java.time.Instant instant = + timestampData.toLocalDateTime().toInstant(ZoneOffset.UTC); + if (tzPrecision <= 3) { + return instant.toEpochMilli(); + } else if (tzPrecision <= 6) { + return instant.getEpochSecond() * 1_000_000L + instant.getNano() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return instant.getEpochSecond() * 1_000_000_000L + instant.getNano(); + } + } + }; + } + break; + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + final int ltzPrecision; + if (type instanceof org.apache.flink.table.types.logical.LocalZonedTimestampType) { + ltzPrecision = + ((org.apache.flink.table.types.logical.LocalZonedTimestampType) type).getPrecision(); + } else { + ltzPrecision = 3; + } + if (legacyTimestampMapping) { + throw new UnsupportedOperationException("Unsupported type: " + type); + } else { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + if (ltzPrecision <= 3) { + return timestampData.getMillisecond(); + } else if (ltzPrecision <= 6) { + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + } + } + }; + } + break; + case DECIMAL: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ByteBuffer.wrap(((DecimalData) object).toUnscaledBytes()); + } + }; + break; + case ARRAY: + converter = createArrayConverter((ArrayType) type, legacyTimestampMapping); + break; + case ROW: + converter = createRowConverter((RowType) type, legacyTimestampMapping); + break; + case MAP: + case MULTISET: + converter = createMapConverter(type, legacyTimestampMapping); + break; + case RAW: + default: + throw new UnsupportedOperationException("Unsupported type: " + type); + } + + // wrap into nullable converter + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + if (object == null) { + return null; + } + + // get actual schema if it is a nullable schema + Schema actualSchema; + if (schema.getType() == Schema.Type.UNION) { + List types = schema.getTypes(); + int size = types.size(); + if (size == 2 && types.get(1).getType() == Schema.Type.NULL) { + actualSchema = types.get(0); + } else if (size == 2 && types.get(0).getType() == Schema.Type.NULL) { + actualSchema = types.get(1); + } else { + throw new IllegalArgumentException( + "The Avro schema is not a nullable type: " + schema.toString()); + } + } else { + actualSchema = schema; + } + return converter.convert(actualSchema, object); + } + }; + } + + private static RowDataToAvroConverter createRowConverter( + RowType rowType, boolean legacyTimestampMapping) { + final RowDataToAvroConverter[] fieldConverters = + rowType.getChildren().stream() + .map(legacyType -> createConverter(legacyType, legacyTimestampMapping)) + .toArray(RowDataToAvroConverter[]::new); + final LogicalType[] fieldTypes = + rowType.getFields().stream().map(RowType.RowField::getType).toArray(LogicalType[]::new); + final RowData.FieldGetter[] fieldGetters = new RowData.FieldGetter[fieldTypes.length]; + for (int i = 0; i < fieldTypes.length; i++) { + fieldGetters[i] = RowData.createFieldGetter(fieldTypes[i], i); + } + final int length = rowType.getFieldCount(); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final RowData row = (RowData) object; + final List fields = schema.getFields(); + final GenericRecord record = new GenericData.Record(schema); + for (int i = 0; i < length; ++i) { + final Schema.Field schemaField = fields.get(i); + try { + Object avroObject = + fieldConverters[i].convert( + schemaField.schema(), fieldGetters[i].getFieldOrNull(row)); + record.put(i, avroObject); + } catch (Throwable t) { + throw new RuntimeException( + String.format("Fail to serialize at field: %s.", schemaField.name()), t); + } + } + return record; + } + }; + } + + private static RowDataToAvroConverter createArrayConverter( + ArrayType arrayType, boolean legacyTimestampMapping) { + LogicalType elementType = arrayType.getElementType(); + final ArrayData.ElementGetter elementGetter = ArrayData.createElementGetter(elementType); + final RowDataToAvroConverter elementConverter = + createConverter(arrayType.getElementType(), legacyTimestampMapping); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final Schema elementSchema = schema.getElementType(); + ArrayData arrayData = (ArrayData) object; + List list = Lists.newArrayList(); + for (int i = 0; i < arrayData.size(); ++i) { + list.add( + elementConverter.convert( + elementSchema, elementGetter.getElementOrNull(arrayData, i))); + } + return list; + } + }; + } + + private static RowDataToAvroConverter createMapConverter( + LogicalType type, boolean legacyTimestampMapping) { + LogicalType valueType = AvroSchemaConverter.extractValueTypeToAvroMap(type); + final ArrayData.ElementGetter valueGetter = ArrayData.createElementGetter(valueType); + final RowDataToAvroConverter valueConverter = + createConverter(valueType, legacyTimestampMapping); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final Schema valueSchema = schema.getValueType(); + final MapData mapData = (MapData) object; + final ArrayData keyArray = mapData.keyArray(); + final ArrayData valueArray = mapData.valueArray(); + final Map map = CollectionUtil.newHashMapWithExpectedSize(mapData.size()); + for (int i = 0; i < mapData.size(); ++i) { + final String key = keyArray.getString(i).toString(); + final Object value = + valueConverter.convert(valueSchema, valueGetter.getElementOrNull(valueArray, i)); + map.put(key, value); + } + return map; + } + }; + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java new file mode 100644 index 000000000000..fb77c124e504 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java @@ -0,0 +1,625 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro.typeutils; + +import java.util.List; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.avro.SchemaParseException; +import org.apache.avro.specific.SpecificData; +import org.apache.avro.specific.SpecificRecord; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.api.java.typeutils.RowTypeInfo; +import org.apache.flink.formats.avro.AvroRowDataDeserializationSchema; +import org.apache.flink.formats.avro.AvroRowDataSerializationSchema; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.legacy.types.logical.TypeInformationRawType; +import org.apache.flink.table.types.AtomicDataType; +import org.apache.flink.table.types.DataType; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LocalZonedTimestampType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeFamily; +import org.apache.flink.table.types.logical.MapType; +import org.apache.flink.table.types.logical.MultisetType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.TimeType; +import org.apache.flink.table.types.logical.TimestampType; +import org.apache.flink.types.Row; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +/** + * Converts an Avro schema into Flink's type information. It uses {@link RowTypeInfo} for + * representing objects and converts Avro types into types that are compatible with Flink's Table + * & SQL API. + * + *

    Note: Changes in this class need to be kept in sync with the corresponding runtime classes + * {@link AvroRowDataDeserializationSchema} and {@link AvroRowDataSerializationSchema}. + * + *

    This class is adapted in Iceberg to support custom 'timestamp-nanos' and + * 'local-timestamp-nanos' logical types (FLINK-39251). Once that ticket is resolved in Flink, these + * custom types may be removed. + */ +public class AvroSchemaConverter { + + private AvroSchemaConverter() { + // private + } + + /** + * Converts an Avro class into a nested row structure with deterministic field order and data + * types that are compatible with Flink's Table & SQL API. + * + * @param avroClass Avro specific record that contains schema information + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + Class avroClass) { + return convertToTypeInfo(avroClass, true); + } + + /** + * Converts an Avro class into a nested row structure with deterministic field order and data + * types that are compatible with Flink's Table & SQL API. + * + * @param avroClass Avro specific record that contains schema information + * @param legacyTimestampMapping legacy mapping of timestamp types + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + Class avroClass, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroClass, "Avro specific record class must not be null."); + // determine schema to retrieve deterministic field order + final Schema schema = SpecificData.get().getSchema(avroClass); + return (TypeInformation) convertToTypeInfo(schema, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo(String avroSchemaString) { + return convertToTypeInfo(avroSchemaString, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @param legacyTimestampMapping legacy mapping of timestamp types + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + String avroSchemaString, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroSchemaString, "Avro schema must not be null."); + final Schema schema; + try { + schema = new Schema.Parser().parse(avroSchemaString); + } catch (SchemaParseException e) { + throw new IllegalArgumentException("Could not parse Avro schema string.", e); + } + return (TypeInformation) convertToTypeInfo(schema, legacyTimestampMapping); + } + + private static TypeInformation convertToTypeInfo( + Schema schema, boolean legacyTimestampMapping) { + switch (schema.getType()) { + case RECORD: + final List fields = schema.getFields(); + + final TypeInformation[] types = new TypeInformation[fields.size()]; + final String[] names = new String[fields.size()]; + for (int i = 0; i < fields.size(); i++) { + final Schema.Field field = fields.get(i); + types[i] = convertToTypeInfo(field.schema(), legacyTimestampMapping); + names[i] = field.name(); + } + return Types.ROW_NAMED(names, types); + case ENUM: + return Types.STRING; + case ARRAY: + // result type might either be ObjectArrayTypeInfo or BasicArrayTypeInfo for Strings + return Types.OBJECT_ARRAY( + convertToTypeInfo(schema.getElementType(), legacyTimestampMapping)); + case MAP: + return Types.MAP( + Types.STRING, convertToTypeInfo(schema.getValueType(), legacyTimestampMapping)); + case UNION: + final Schema actualSchema; + if (schema.getTypes().size() == 2 + && schema.getTypes().get(0).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(1); + } else if (schema.getTypes().size() == 2 + && schema.getTypes().get(1).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(0); + } else if (schema.getTypes().size() == 1) { + actualSchema = schema.getTypes().get(0); + } else { + // use Kryo for serialization + return Types.GENERIC(Object.class); + } + return convertToTypeInfo(actualSchema, legacyTimestampMapping); + case FIXED: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + return Types.BIG_DEC; + } + // convert fixed size binary data to primitive byte arrays + return Types.PRIMITIVE_ARRAY(Types.BYTE); + case STRING: + // convert Avro's Utf8/CharSequence to String + return Types.STRING; + case BYTES: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + return Types.BIG_DEC; + } + return Types.PRIMITIVE_ARRAY(Types.BYTE); + case INT: + // logical date and time type + final org.apache.avro.LogicalType logicalType = schema.getLogicalType(); + if (logicalType == LogicalTypes.date()) { + return Types.SQL_DATE; + } else if (logicalType == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + return Types.INT; + case LONG: + if (legacyTimestampMapping) { + if (schema.getLogicalType() == LogicalTypes.timestampMillis() + || schema.getLogicalType() == LogicalTypes.timestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos"))) { + return Types.SQL_TIMESTAMP; + } else if (schema.getLogicalType() == LogicalTypes.timeMicros() + || schema.getLogicalType() == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + } else { + // Avro logical timestamp types to Flink DataStream timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis() + || schema.getLogicalType() == LogicalTypes.timestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos"))) { + return Types.INSTANT; + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMillis() + || schema.getLogicalType() == LogicalTypes.localTimestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("local-timestamp-nanos"))) { + return Types.LOCAL_DATE_TIME; + } else if (schema.getLogicalType() == LogicalTypes.timeMicros() + || schema.getLogicalType() == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + } + return Types.LONG; + case FLOAT: + return Types.FLOAT; + case DOUBLE: + return Types.DOUBLE; + case BOOLEAN: + return Types.BOOLEAN; + case NULL: + return Types.VOID; + } + throw new IllegalArgumentException("Unsupported Avro type '" + schema.getType() + "'."); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @return data type matching the schema + */ + public static DataType convertToDataType(String avroSchemaString) { + return convertToDataType(avroSchemaString, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @param legacyTimestampMapping legacy mapping of local timestamps + * @return data type matching the schema + */ + public static DataType convertToDataType( + String avroSchemaString, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroSchemaString, "Avro schema must not be null."); + final Schema schema; + try { + schema = new Schema.Parser().parse(avroSchemaString); + } catch (SchemaParseException e) { + throw new IllegalArgumentException("Could not parse Avro schema string.", e); + } + return convertToDataType(schema, legacyTimestampMapping); + } + + @SuppressWarnings("deprecation") + private static DataType convertToDataType(Schema schema, boolean legacyMapping) { + switch (schema.getType()) { + case RECORD: + final List schemaFields = schema.getFields(); + + final DataTypes.Field[] fields = new DataTypes.Field[schemaFields.size()]; + for (int i = 0; i < schemaFields.size(); i++) { + final Schema.Field field = schemaFields.get(i); + fields[i] = + DataTypes.FIELD(field.name(), convertToDataType(field.schema(), legacyMapping)); + } + return DataTypes.ROW(fields).notNull(); + case ENUM: + return DataTypes.STRING().notNull(); + case ARRAY: + return DataTypes.ARRAY(convertToDataType(schema.getElementType(), legacyMapping)).notNull(); + case MAP: + return DataTypes.MAP( + DataTypes.STRING().notNull(), + convertToDataType(schema.getValueType(), legacyMapping)) + .notNull(); + case UNION: + final Schema actualSchema; + final boolean nullable; + if (schema.getTypes().size() == 2 + && schema.getTypes().get(0).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(1); + nullable = true; + } else if (schema.getTypes().size() == 2 + && schema.getTypes().get(1).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(0); + nullable = true; + } else if (schema.getTypes().size() == 1) { + actualSchema = schema.getTypes().get(0); + nullable = false; + } else { + // use Kryo for serialization + return new AtomicDataType( + new TypeInformationRawType<>(false, Types.GENERIC(Object.class))); + } + DataType converted = convertToDataType(actualSchema, legacyMapping); + return nullable ? converted.nullable() : converted; + case FIXED: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + final LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) schema.getLogicalType(); + return DataTypes.DECIMAL(decimalType.getPrecision(), decimalType.getScale()).notNull(); + } + // convert fixed size binary data to primitive byte arrays + return DataTypes.VARBINARY(schema.getFixedSize()).notNull(); + case STRING: + // convert Avro's Utf8/CharSequence to String + return DataTypes.STRING().notNull(); + case BYTES: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + final LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) schema.getLogicalType(); + return DataTypes.DECIMAL(decimalType.getPrecision(), decimalType.getScale()).notNull(); + } + return DataTypes.BYTES().notNull(); + case INT: + // logical date and time type + final org.apache.avro.LogicalType logicalType = schema.getLogicalType(); + if (logicalType == LogicalTypes.date()) { + return DataTypes.DATE().notNull(); + } else if (logicalType == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } + return DataTypes.INT().notNull(); + case LONG: + if (legacyMapping) { + // Avro logical timestamp types to Flink SQL timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis()) { + return DataTypes.TIMESTAMP(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timestampMicros()) { + return DataTypes.TIMESTAMP(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP(9).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMicros()) { + return DataTypes.TIME(6).notNull(); + } + } else { + // Avro logical timestamp types to Flink SQL timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis()) { + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timestampMicros()) { + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(9).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMicros()) { + return DataTypes.TIME(6).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMillis()) { + return DataTypes.TIMESTAMP(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMicros()) { + return DataTypes.TIMESTAMP(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("local-timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP(9).notNull(); + } + } + + return DataTypes.BIGINT().notNull(); + case FLOAT: + return DataTypes.FLOAT().notNull(); + case DOUBLE: + return DataTypes.DOUBLE().notNull(); + case BOOLEAN: + return DataTypes.BOOLEAN().notNull(); + case NULL: + return DataTypes.NULL(); + } + throw new IllegalArgumentException("Unsupported Avro type '" + schema.getType() + "'."); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    Use "org.apache.flink.avro.generated.record" as the type name. + * + * @param schema the schema type, usually it should be the top level record type, e.g. not a + * nested type + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType schema) { + return convertToSchema(schema, true); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    Use "org.apache.flink.avro.generated.record" as the type name. + * + * @param schema the schema type, usually it should be the top level record type, e.g. not a + * nested type + * @param legacyTimestampMapping whether to use the legacy timestamp mapping + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType schema, boolean legacyTimestampMapping) { + return convertToSchema( + schema, "org.apache.flink.avro.generated.record", legacyTimestampMapping); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    The "{rowName}_" is used as the nested row type name prefix in order to generate the right + * schema. Nested record type that only differs with type name is still compatible. + * + * @param logicalType logical type + * @param rowName the record name + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType logicalType, String rowName) { + return convertToSchema(logicalType, rowName, true); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    The "{rowName}_" is used as the nested row type name prefix in order to generate the right + * schema. Nested record type that only differs with type name is still compatible. + * + * @param logicalType logical type + * @param rowName the record name + * @param legacyTimestampMapping whether to use legal timestamp mapping + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema( + LogicalType logicalType, String rowName, boolean legacyTimestampMapping) { + int precision; + boolean nullable = logicalType.isNullable(); + switch (logicalType.getTypeRoot()) { + case NULL: + return SchemaBuilder.builder().nullType(); + case BOOLEAN: + Schema bool = SchemaBuilder.builder().booleanType(); + return nullable ? nullableSchema(bool) : bool; + case TINYINT: + case SMALLINT: + case INTEGER: + Schema integer = SchemaBuilder.builder().intType(); + return nullable ? nullableSchema(integer) : integer; + case BIGINT: + Schema bigint = SchemaBuilder.builder().longType(); + return nullable ? nullableSchema(bigint) : bigint; + case FLOAT: + Schema floatSchema = SchemaBuilder.builder().floatType(); + return nullable ? nullableSchema(floatSchema) : floatSchema; + case DOUBLE: + Schema doubleSchema = SchemaBuilder.builder().doubleType(); + return nullable ? nullableSchema(doubleSchema) : doubleSchema; + case CHAR: + case VARCHAR: + Schema str = SchemaBuilder.builder().stringType(); + return nullable ? nullableSchema(str) : str; + case BINARY: + case VARBINARY: + Schema binary = SchemaBuilder.builder().bytesType(); + return nullable ? nullableSchema(binary) : binary; + case TIMESTAMP_WITHOUT_TIME_ZONE: + // use long to represents Timestamp + final TimestampType timestampType = (TimestampType) logicalType; + precision = timestampType.getPrecision(); + org.apache.avro.LogicalType avroLogicalType; + if (legacyTimestampMapping) { + if (precision <= 3) { + avroLogicalType = LogicalTypes.timestampMillis(); + } else { + throw new IllegalArgumentException( + "Avro does not support TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 3."); + } + } else { + if (precision <= 3) { + avroLogicalType = LogicalTypes.localTimestampMillis(); + } else if (precision <= 6) { + avroLogicalType = LogicalTypes.localTimestampMicros(); + } else { + throw new IllegalArgumentException( + "Avro does not support LOCAL TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 6."); + } + } + Schema timestamp = avroLogicalType.addToSchema(SchemaBuilder.builder().longType()); + return nullable ? nullableSchema(timestamp) : timestamp; + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + if (legacyTimestampMapping) { + throw new UnsupportedOperationException( + "Unsupported to derive Schema for type: " + logicalType); + } else { + final LocalZonedTimestampType localZonedTimestampType = + (LocalZonedTimestampType) logicalType; + precision = localZonedTimestampType.getPrecision(); + if (precision <= 3) { + avroLogicalType = LogicalTypes.timestampMillis(); + } else if (precision <= 6) { + avroLogicalType = LogicalTypes.timestampMicros(); + } else { + throw new IllegalArgumentException( + "Avro does not support TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 6."); + } + timestamp = avroLogicalType.addToSchema(SchemaBuilder.builder().longType()); + return nullable ? nullableSchema(timestamp) : timestamp; + } + case DATE: + // use int to represents Date + Schema date = LogicalTypes.date().addToSchema(SchemaBuilder.builder().intType()); + return nullable ? nullableSchema(date) : date; + case TIME_WITHOUT_TIME_ZONE: + precision = ((TimeType) logicalType).getPrecision(); + if (precision > 3) { + throw new IllegalArgumentException( + "Avro does not support TIME type with precision: " + + precision + + ", it only supports precision less than 3."); + } + // use int to represents Time, we only support millisecond when deserialization + Schema time = LogicalTypes.timeMillis().addToSchema(SchemaBuilder.builder().intType()); + return nullable ? nullableSchema(time) : time; + case DECIMAL: + DecimalType decimalType = (DecimalType) logicalType; + // store BigDecimal as byte[] + Schema decimal = + LogicalTypes.decimal(decimalType.getPrecision(), decimalType.getScale()) + .addToSchema(SchemaBuilder.builder().bytesType()); + return nullable ? nullableSchema(decimal) : decimal; + case ROW: + RowType rowType = (RowType) logicalType; + List fieldNames = rowType.getFieldNames(); + // we have to make sure the record name is different in a Schema + SchemaBuilder.FieldAssembler builder = + SchemaBuilder.builder().record(rowName).fields(); + for (int i = 0; i < rowType.getFieldCount(); i++) { + String fieldName = fieldNames.get(i); + LogicalType fieldType = rowType.getTypeAt(i); + SchemaBuilder.GenericDefault fieldBuilder = + builder + .name(fieldName) + .type( + convertToSchema( + fieldType, rowName + "_" + fieldName, legacyTimestampMapping)); + + if (fieldType.isNullable()) { + builder = fieldBuilder.withDefault(null); + } else { + builder = fieldBuilder.noDefault(); + } + } + Schema record = builder.endRecord(); + return nullable ? nullableSchema(record) : record; + case MULTISET: + case MAP: + Schema map = + SchemaBuilder.builder() + .map() + .values(convertToSchema(extractValueTypeToAvroMap(logicalType), rowName)); + return nullable ? nullableSchema(map) : map; + case ARRAY: + ArrayType arrayType = (ArrayType) logicalType; + Schema array = + SchemaBuilder.builder() + .array() + .items(convertToSchema(arrayType.getElementType(), rowName)); + return nullable ? nullableSchema(array) : array; + case RAW: + default: + throw new UnsupportedOperationException( + "Unsupported to derive Schema for type: " + logicalType); + } + } + + public static LogicalType extractValueTypeToAvroMap(LogicalType type) { + LogicalType keyType; + LogicalType valueType; + if (type instanceof MapType) { + MapType mapType = (MapType) type; + keyType = mapType.getKeyType(); + valueType = mapType.getValueType(); + } else { + MultisetType multisetType = (MultisetType) type; + keyType = multisetType.getElementType(); + valueType = new IntType(); + } + if (!keyType.is(LogicalTypeFamily.CHARACTER_STRING)) { + throw new UnsupportedOperationException( + "Avro format doesn't support non-string as key type of map. " + + "The key type is: " + + keyType.asSummaryString()); + } + return valueType; + } + + /** Returns schema with nullable true. */ + private static Schema nullableSchema(Schema schema) { + return schema.isNullable() + ? schema + : Schema.createUnion(SchemaBuilder.builder().nullType(), schema); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java index 2fce5e0b3e80..377413c3c3ab 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java @@ -53,6 +53,11 @@ public class DeleteOrphanFiles { ScanContext.builder().streaming(true).project(FILE_PATH_SCHEMA).build(); private static final Splitter COMMA_SPLITTER = Splitter.on(","); + static final Map DEFAULT_EQUAL_SCHEMES = + ImmutableMap.of( + "s3n", "s3", + "s3a", "s3"); + @Internal public static final OutputTag ERROR_STREAM = new OutputTag<>("error-stream", TypeInformation.of(Exception.class)); @@ -79,12 +84,8 @@ public static class Builder extends MaintenanceTaskBuilder equalSchemes = - Maps.newHashMap( - ImmutableMap.of( - "s3n", "s3", - "s3a", "s3")); + private boolean usePrefixListing = true; + private Map equalSchemes = Maps.newHashMap(DEFAULT_EQUAL_SCHEMES); private final Map equalAuthorities = Maps.newHashMap(); private PrefixMismatchMode prefixMismatchMode = PrefixMismatchMode.ERROR; @@ -189,78 +190,92 @@ public Builder deleteBatchSize(int newDeleteBatchSize) { return this; } + public Builder config(DeleteOrphanFilesConfig deleteOrphanFilesConfig) { + return this.scheduleOnInterval( + Duration.ofSeconds(deleteOrphanFilesConfig.scheduleOnIntervalSecond())) + .minAge(Duration.ofSeconds(deleteOrphanFilesConfig.minAgeSeconds())) + .deleteBatchSize(deleteOrphanFilesConfig.deleteBatchSize()) + .usePrefixListing(deleteOrphanFilesConfig.usePrefixListing()) + .prefixMismatchMode(deleteOrphanFilesConfig.prefixMismatchMode()) + .location(deleteOrphanFilesConfig.location()) + .planningWorkerPoolSize(deleteOrphanFilesConfig.planningWorkerPoolSize()) + .equalSchemes(deleteOrphanFilesConfig.equalSchemes()) + .equalAuthorities(deleteOrphanFilesConfig.equalAuthorities()); + } + @Override DataStream append(DataStream trigger) { tableLoader().open(); // Collect all data files SingleOutputStreamOperator splits = - trigger - .process( - new MetadataTablePlanner( - taskName(), - index(), - tableLoader(), - FILE_PATH_SCAN_CONTEXT, - MetadataTableType.ALL_FILES, - planningWorkerPoolSize)) - .name(operatorName(PLANNER_TASK_NAME)) - .uid(PLANNER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new MetadataTablePlanner( + taskName(), + index(), + tableLoader(), + FILE_PATH_SCAN_CONTEXT, + MetadataTableType.ALL_FILES, + planningWorkerPoolSize)) + .name(operatorName(PLANNER_TASK_NAME)) + .uid(PLANNER_TASK_NAME + uidSuffix()) + .forceNonParallel()); // Read the records and get all data files SingleOutputStreamOperator tableDataFiles = - splits - .rebalance() - .process( - new FileNameReader( - taskName(), - index(), - tableLoader(), - FILE_PATH_SCHEMA, - FILE_PATH_SCAN_CONTEXT, - MetadataTableType.ALL_FILES)) - .name(operatorName(READER_TASK_NAME)) - .uid(READER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + splits + .rebalance() + .process( + new FileNameReader( + taskName(), + index(), + tableLoader(), + FILE_PATH_SCHEMA, + FILE_PATH_SCAN_CONTEXT, + MetadataTableType.ALL_FILES)) + .name(operatorName(READER_TASK_NAME)) + .uid(READER_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); // Collect all meta data files SingleOutputStreamOperator tableMetadataFiles = - trigger - .process(new ListMetadataFiles(taskName(), index(), tableLoader())) - .name(operatorName(METADATA_FILES_TASK_NAME)) - .uid(METADATA_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process(new ListMetadataFiles(taskName(), index(), tableLoader())) + .name(operatorName(METADATA_FILES_TASK_NAME)) + .uid(METADATA_FILES_TASK_NAME + uidSuffix()) + .forceNonParallel()); // List the all file system files SingleOutputStreamOperator allFsFiles = - trigger - .process( - new ListFileSystemFiles( - taskName(), - index(), - tableLoader(), - location, - minAge.toMillis(), - usePrefixListing)) - .name(operatorName(FILESYSTEM_FILES_TASK_NAME)) - .uid(FILESYSTEM_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new ListFileSystemFiles( + taskName(), + index(), + tableLoader(), + location, + minAge.toMillis(), + usePrefixListing)) + .name(operatorName(FILESYSTEM_FILES_TASK_NAME)) + .uid(FILESYSTEM_FILES_TASK_NAME + uidSuffix()) + .forceNonParallel()); SingleOutputStreamOperator filesToDelete = - tableMetadataFiles - .union(tableDataFiles) - .keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities)) - .connect(allFsFiles.keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities))) - .process(new OrphanFilesDetector(prefixMismatchMode, equalSchemes, equalAuthorities)) - .slotSharingGroup(slotSharingGroup()) - .name(operatorName(FILTER_FILES_TASK_NAME)) - .uid(FILTER_FILES_TASK_NAME + uidSuffix()) - .setParallelism(parallelism()); + setSlotSharingGroup( + tableMetadataFiles + .union(tableDataFiles) + .keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities)) + .connect(allFsFiles.keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities))) + .process( + new OrphanFilesDetector(prefixMismatchMode, equalSchemes, equalAuthorities)) + .name(operatorName(FILTER_FILES_TASK_NAME)) + .uid(FILTER_FILES_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); DataStream errorStream = tableMetadataFiles @@ -273,38 +288,38 @@ DataStream append(DataStream trigger) { // Stop deleting the files if there is an error SingleOutputStreamOperator filesOrSkip = - filesToDelete - .connect(errorStream) - .transform( - operatorName(SKIP_ON_ERROR_TASK_NAME), - TypeInformation.of(String.class), - new SkipOnError()) - .uid(SKIP_ON_ERROR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + filesToDelete + .connect(errorStream) + .transform( + operatorName(SKIP_ON_ERROR_TASK_NAME), + TypeInformation.of(String.class), + new SkipOnError()) + .uid(SKIP_ON_ERROR_TASK_NAME + uidSuffix()) + .forceNonParallel()); // delete the files - filesOrSkip - .rebalance() - .transform( - operatorName(DELETE_FILES_TASK_NAME), - TypeInformation.of(Void.class), - new DeleteFilesProcessor( - tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) - .uid(DELETE_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + filesOrSkip + .rebalance() + .transform( + operatorName(DELETE_FILES_TASK_NAME), + TypeInformation.of(Void.class), + new DeleteFilesProcessor( + tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) + .uid(DELETE_FILES_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); // Ignore the file deletion result and return the DataStream directly - return trigger - .connect(errorStream) - .transform( - operatorName(AGGREGATOR_TASK_NAME), - TypeInformation.of(TaskResult.class), - new TaskResultAggregator(tableName(), taskName(), index())) - .uid(AGGREGATOR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .connect(errorStream) + .transform( + operatorName(AGGREGATOR_TASK_NAME), + TypeInformation.of(TaskResult.class), + new TaskResultAggregator(tableName(), taskName(), index())) + .uid(AGGREGATOR_TASK_NAME + uidSuffix()) + .forceNonParallel()); } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java new file mode 100644 index 000000000000..af347357815c --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import java.util.List; +import java.util.Map; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles.PrefixMismatchMode; +import org.apache.iceberg.flink.FlinkConfParser; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; + +public class DeleteOrphanFilesConfig { + public static final String PREFIX = FlinkMaintenanceConfig.PREFIX + "delete-orphan-files."; + + private static final Splitter COMMA_SPLITTER = Splitter.on(","); + private static final Splitter EQUALS_SPLITTER = Splitter.on("=").limit(2); + + public static final String SCHEDULE_ON_INTERVAL_SECOND = PREFIX + "schedule.interval-second"; + public static final ConfigOption SCHEDULE_ON_INTERVAL_SECOND_OPTION = + ConfigOptions.key(SCHEDULE_ON_INTERVAL_SECOND) + .longType() + .defaultValue(60 * 60L) // Default is 1 hour + .withDescription( + "The time interval (in seconds) between two consecutive delete orphan files operations."); + + public static final String MIN_AGE_SECONDS = PREFIX + "min-age-seconds"; + public static final ConfigOption MIN_AGE_SECONDS_OPTION = + ConfigOptions.key(MIN_AGE_SECONDS) + .longType() + .defaultValue(3L * 24 * 60 * 60) // Default is 3 days + .withDescription( + "The minimum age (in seconds) of files to be considered for deletion. " + + "Files newer than this will not be removed."); + + public static final String DELETE_BATCH_SIZE = PREFIX + "delete-batch-size"; + public static final ConfigOption DELETE_BATCH_SIZE_OPTION = + ConfigOptions.key(DELETE_BATCH_SIZE) + .intType() + .defaultValue(1000) + .withDescription("The batch size used for deleting orphan files."); + + public static final String LOCATION = PREFIX + "location"; + public static final ConfigOption LOCATION_OPTION = + ConfigOptions.key(LOCATION) + .stringType() + .noDefaultValue() + .withDescription( + "The location to start recursive listing of candidate files for removal. " + + "By default, the table location is used."); + + public static final String USE_PREFIX_LISTING = PREFIX + "use-prefix-listing"; + public static final ConfigOption USE_PREFIX_LISTING_OPTION = + ConfigOptions.key(USE_PREFIX_LISTING) + .booleanType() + .defaultValue(true) + .withDescription( + "Whether to use prefix listing when listing files from the file system."); + + public static final String PLANNING_WORKER_POOL_SIZE = PREFIX + "planning-worker-pool-size"; + public static final ConfigOption PLANNING_WORKER_POOL_SIZE_OPTION = + ConfigOptions.key(PLANNING_WORKER_POOL_SIZE) + .intType() + .noDefaultValue() + .withDescription( + "The worker pool size used for planning the scan of the ALL_FILES table. " + + "If not set, the shared worker pool is used."); + + public static final String EQUAL_SCHEMES = PREFIX + "equal-schemes"; + public static final ConfigOption EQUAL_SCHEMES_OPTION = + ConfigOptions.key(EQUAL_SCHEMES) + .stringType() + .noDefaultValue() + .withDescription( + "Schemes that should be considered equal, in the format 'scheme1=scheme2,scheme3=scheme4'."); + + public static final String EQUAL_AUTHORITIES = PREFIX + "equal-authorities"; + public static final ConfigOption EQUAL_AUTHORITIES_OPTION = + ConfigOptions.key(EQUAL_AUTHORITIES) + .stringType() + .noDefaultValue() + .withDescription( + "Authorities that should be considered equal, in the format 'auth1=auth2,auth3=auth4'."); + + public static final String PREFIX_MISMATCH_MODE = PREFIX + "prefix-mismatch-mode"; + public static final ConfigOption PREFIX_MISMATCH_MODE_OPTION = + ConfigOptions.key(PREFIX_MISMATCH_MODE) + .stringType() + .defaultValue(PrefixMismatchMode.ERROR.name()) + .withDescription( + "Action behavior when location prefixes (schemes/authorities) mismatch. " + + "Valid values: ERROR, IGNORE, DELETE."); + + private final FlinkConfParser confParser; + + public DeleteOrphanFilesConfig( + Table table, Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(table, writeOptions, readableConfig); + } + + public long scheduleOnIntervalSecond() { + return confParser + .longConf() + .option(SCHEDULE_ON_INTERVAL_SECOND) + .flinkConfig(SCHEDULE_ON_INTERVAL_SECOND_OPTION) + .defaultValue(SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()) + .parse(); + } + + public long minAgeSeconds() { + return confParser + .longConf() + .option(MIN_AGE_SECONDS) + .flinkConfig(MIN_AGE_SECONDS_OPTION) + .defaultValue(MIN_AGE_SECONDS_OPTION.defaultValue()) + .parse(); + } + + public int deleteBatchSize() { + return confParser + .intConf() + .option(DELETE_BATCH_SIZE) + .flinkConfig(DELETE_BATCH_SIZE_OPTION) + .defaultValue(DELETE_BATCH_SIZE_OPTION.defaultValue()) + .parse(); + } + + public String location() { + return confParser.stringConf().option(LOCATION).flinkConfig(LOCATION_OPTION).parseOptional(); + } + + public boolean usePrefixListing() { + return confParser + .booleanConf() + .option(USE_PREFIX_LISTING) + .flinkConfig(USE_PREFIX_LISTING_OPTION) + .defaultValue(USE_PREFIX_LISTING_OPTION.defaultValue()) + .parse(); + } + + public Integer planningWorkerPoolSize() { + return confParser + .intConf() + .option(PLANNING_WORKER_POOL_SIZE) + .flinkConfig(PLANNING_WORKER_POOL_SIZE_OPTION) + .defaultValue(ThreadPools.WORKER_THREAD_POOL_SIZE) + .parse(); + } + + public Map equalSchemes() { + String equalSchemes = + confParser + .stringConf() + .option(EQUAL_SCHEMES) + .flinkConfig(EQUAL_SCHEMES_OPTION) + .parseOptional(); + + return equalSchemes != null + ? parseKeyValuePairs(equalSchemes) + : Maps.newHashMap(DeleteOrphanFiles.DEFAULT_EQUAL_SCHEMES); + } + + public Map equalAuthorities() { + String equalAuthorities = + confParser + .stringConf() + .option(EQUAL_AUTHORITIES) + .flinkConfig(EQUAL_AUTHORITIES_OPTION) + .parseOptional(); + + return equalAuthorities != null ? parseKeyValuePairs(equalAuthorities) : Map.of(); + } + + public PrefixMismatchMode prefixMismatchMode() { + String value = + confParser + .stringConf() + .option(PREFIX_MISMATCH_MODE) + .flinkConfig(PREFIX_MISMATCH_MODE_OPTION) + .defaultValue(PREFIX_MISMATCH_MODE_OPTION.defaultValue()) + .parse(); + return PrefixMismatchMode.valueOf(value); + } + + private static Map parseKeyValuePairs(String value) { + Map result = Maps.newHashMap(); + for (String pair : COMMA_SPLITTER.split(value)) { + List parts = EQUALS_SPLITTER.splitToList(pair); + Preconditions.checkArgument(parts.size() == 2, "Invalid key-value pair: %s", pair); + result.put(parts.get(0).trim(), parts.get(1).trim()); + } + + return result; + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java index 628a91141474..7c524175c42b 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java @@ -19,6 +19,7 @@ package org.apache.iceberg.flink.maintenance.api; import java.time.Duration; +import java.util.Optional; import org.apache.flink.api.common.typeinfo.TypeInformation; import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; @@ -107,35 +108,48 @@ public Builder cleanExpiredMetadata(boolean newCleanExpiredMetadata) { return this; } + public Builder config(ExpireSnapshotsConfig expireSnapshotsConfig) { + return this.scheduleOnCommitCount(expireSnapshotsConfig.scheduleOnCommitCount()) + .scheduleOnInterval(Duration.ofSeconds(expireSnapshotsConfig.scheduleOnIntervalSecond())) + .deleteBatchSize(expireSnapshotsConfig.deleteBatchSize()) + .maxSnapshotAge( + Optional.ofNullable(expireSnapshotsConfig.maxSnapshotAgeSeconds()) + .map(Duration::ofSeconds) + .orElse(null)) + .retainLast(expireSnapshotsConfig.retainLast()) + .cleanExpiredMetadata(expireSnapshotsConfig.cleanExpiredMetadata()) + .planningWorkerPoolSize(expireSnapshotsConfig.planningWorkerPoolSize()); + } + @Override DataStream append(DataStream trigger) { Preconditions.checkNotNull(tableLoader(), "TableLoader should not be null"); SingleOutputStreamOperator result = - trigger - .process( - new ExpireSnapshotsProcessor( - tableLoader(), - maxSnapshotAge == null ? null : maxSnapshotAge.toMillis(), - numSnapshots, - planningWorkerPoolSize, - cleanExpiredMetadata)) - .name(operatorName(EXECUTOR_OPERATOR_NAME)) - .uid(EXECUTOR_OPERATOR_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); - - result - .getSideOutput(ExpireSnapshotsProcessor.DELETE_STREAM) - .rebalance() - .transform( - operatorName(DELETE_FILES_OPERATOR_NAME), - TypeInformation.of(Void.class), - new DeleteFilesProcessor( - tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) - .uid(DELETE_FILES_OPERATOR_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + trigger + .process( + new ExpireSnapshotsProcessor( + tableLoader(), + maxSnapshotAge == null ? null : maxSnapshotAge.toMillis(), + numSnapshots, + planningWorkerPoolSize, + cleanExpiredMetadata)) + .name(operatorName(EXECUTOR_OPERATOR_NAME)) + .uid(EXECUTOR_OPERATOR_NAME + uidSuffix()) + .forceNonParallel()); + + setSlotSharingGroup( + result + .getSideOutput(ExpireSnapshotsProcessor.DELETE_STREAM) + .rebalance() + .transform( + operatorName(DELETE_FILES_OPERATOR_NAME), + TypeInformation.of(Void.class), + new DeleteFilesProcessor( + tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) + .uid(DELETE_FILES_OPERATOR_NAME + uidSuffix()) + .setParallelism(parallelism())); // Ignore the file deletion result and return the DataStream directly return result; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java new file mode 100644 index 000000000000..13436975e182 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import java.util.Map; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.FlinkConfParser; +import org.apache.iceberg.util.ThreadPools; + +public class ExpireSnapshotsConfig { + public static final String PREFIX = FlinkMaintenanceConfig.PREFIX + "expire-snapshots."; + + public static final String SCHEDULE_ON_COMMIT_COUNT = PREFIX + "schedule.commit-count"; + public static final ConfigOption SCHEDULE_ON_COMMIT_COUNT_OPTION = + ConfigOptions.key(SCHEDULE_ON_COMMIT_COUNT) + .intType() + .defaultValue(10) + .withDescription( + "The number of commits after which to trigger a new expire snapshots operation."); + + public static final String SCHEDULE_ON_INTERVAL_SECOND = PREFIX + "schedule.interval-second"; + public static final ConfigOption SCHEDULE_ON_INTERVAL_SECOND_OPTION = + ConfigOptions.key(SCHEDULE_ON_INTERVAL_SECOND) + .longType() + .defaultValue(60 * 60L) // Default is 1 hour + .withDescription( + "The time interval (in seconds) between two consecutive expire snapshots operations."); + + public static final String MAX_SNAPSHOT_AGE_SECONDS = PREFIX + "max-snapshot-age-seconds"; + public static final ConfigOption MAX_SNAPSHOT_AGE_SECONDS_OPTION = + ConfigOptions.key(MAX_SNAPSHOT_AGE_SECONDS) + .longType() + .noDefaultValue() + .withDescription( + "The maximum age (in seconds) of snapshots to retain. " + + "Snapshots older than this will be expired."); + + public static final String RETAIN_LAST = PREFIX + "retain-last"; + public static final ConfigOption RETAIN_LAST_OPTION = + ConfigOptions.key(RETAIN_LAST) + .intType() + .noDefaultValue() + .withDescription("The minimum number of snapshots to retain."); + + public static final String DELETE_BATCH_SIZE = PREFIX + "delete-batch-size"; + public static final ConfigOption DELETE_BATCH_SIZE_OPTION = + ConfigOptions.key(DELETE_BATCH_SIZE) + .intType() + .defaultValue(1000) + .withDescription("The batch size used for deleting expired files."); + + public static final String CLEAN_EXPIRED_METADATA = PREFIX + "clean-expired-metadata"; + public static final ConfigOption CLEAN_EXPIRED_METADATA_OPTION = + ConfigOptions.key(CLEAN_EXPIRED_METADATA) + .booleanType() + .defaultValue(true) + .withDescription( + "Whether to clean expired metadata such as partition specs and schemas."); + + public static final String PLANNING_WORKER_POOL_SIZE = PREFIX + "planning-worker-pool-size"; + public static final ConfigOption PLANNING_WORKER_POOL_SIZE_OPTION = + ConfigOptions.key(PLANNING_WORKER_POOL_SIZE) + .intType() + .noDefaultValue() + .withDescription( + "The worker pool size used to calculate the files to delete. " + + "If not set, the shared worker pool is used."); + + private final FlinkConfParser confParser; + + public ExpireSnapshotsConfig( + Table table, Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(table, writeOptions, readableConfig); + } + + public int scheduleOnCommitCount() { + return confParser + .intConf() + .option(SCHEDULE_ON_COMMIT_COUNT) + .flinkConfig(SCHEDULE_ON_COMMIT_COUNT_OPTION) + .defaultValue(SCHEDULE_ON_COMMIT_COUNT_OPTION.defaultValue()) + .parse(); + } + + public long scheduleOnIntervalSecond() { + return confParser + .longConf() + .option(SCHEDULE_ON_INTERVAL_SECOND) + .flinkConfig(SCHEDULE_ON_INTERVAL_SECOND_OPTION) + .defaultValue(SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()) + .parse(); + } + + public Long maxSnapshotAgeSeconds() { + return confParser + .longConf() + .option(MAX_SNAPSHOT_AGE_SECONDS) + .flinkConfig(MAX_SNAPSHOT_AGE_SECONDS_OPTION) + .parseOptional(); + } + + public Integer retainLast() { + return confParser.intConf().option(RETAIN_LAST).flinkConfig(RETAIN_LAST_OPTION).parseOptional(); + } + + public int deleteBatchSize() { + return confParser + .intConf() + .option(DELETE_BATCH_SIZE) + .flinkConfig(DELETE_BATCH_SIZE_OPTION) + .defaultValue(DELETE_BATCH_SIZE_OPTION.defaultValue()) + .parse(); + } + + public Boolean cleanExpiredMetadata() { + return confParser + .booleanConf() + .option(CLEAN_EXPIRED_METADATA) + .flinkConfig(CLEAN_EXPIRED_METADATA_OPTION) + .defaultValue(CLEAN_EXPIRED_METADATA_OPTION.defaultValue()) + .parse(); + } + + public Integer planningWorkerPoolSize() { + return confParser + .intConf() + .option(PLANNING_WORKER_POOL_SIZE) + .flinkConfig(PLANNING_WORKER_POOL_SIZE_OPTION) + .defaultValue(ThreadPools.WORKER_THREAD_POOL_SIZE) + .parse(); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java index 0c88abf82099..34d7330c5913 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java @@ -23,7 +23,6 @@ import org.apache.flink.configuration.ConfigOption; import org.apache.flink.configuration.ConfigOptions; import org.apache.flink.configuration.ReadableConfig; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.iceberg.Table; import org.apache.iceberg.flink.FlinkConfParser; @@ -60,7 +59,7 @@ public class FlinkMaintenanceConfig { public static final ConfigOption SLOT_SHARING_GROUP_OPTION = ConfigOptions.key(SLOT_SHARING_GROUP) .stringType() - .defaultValue(StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP) + .noDefaultValue() .withDescription( "The slot sharing group for maintenance tasks. " + "Determines which operators can share slots in the Flink execution environment."); @@ -114,14 +113,21 @@ public String slotSharingGroup() { .stringConf() .option(SLOT_SHARING_GROUP) .flinkConfig(SLOT_SHARING_GROUP_OPTION) - .defaultValue(SLOT_SHARING_GROUP_OPTION.defaultValue()) - .parse(); + .parseOptional(); } public RewriteDataFilesConfig createRewriteDataFilesConfig() { return new RewriteDataFilesConfig(table, writeProperties, readableConfig); } + public ExpireSnapshotsConfig createExpireSnapshotsConfig() { + return new ExpireSnapshotsConfig(table, writeProperties, readableConfig); + } + + public DeleteOrphanFilesConfig createDeleteOrphanFilesConfig() { + return new DeleteOrphanFilesConfig(table, writeProperties, readableConfig); + } + public LockConfig createLockConfig() { return new LockConfig(table, writeProperties, readableConfig); } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java index f68605accc57..30e95b1edba0 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java @@ -260,10 +260,6 @@ public void unlock() { this, instanceId, count); - } catch (SQLException e) { - // SQL exception happened when deleting lock information - throw new UncheckedSQLException( - e, "Failed to delete %s lock with instanceId %s", this, instanceId); } return null; @@ -298,9 +294,6 @@ private String instanceId() { return null; } } - } catch (SQLException e) { - // SQL exception happened when getting lock information - throw new UncheckedSQLException(e, "Failed to get lock information for %s", type); } }); } catch (InterruptedException e) { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java index 5d5f17b0a80e..04df6106bd4c 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java @@ -226,4 +226,8 @@ DataStream append( return append(sourceStream); } + + SingleOutputStreamOperator setSlotSharingGroup(SingleOutputStreamOperator operator) { + return slotSharingGroup == null ? operator : operator.slotSharingGroup(slotSharingGroup); + } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java index bedf70725a63..be77fda23d29 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java @@ -24,6 +24,8 @@ import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.util.function.SerializableSupplier; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.actions.BinPackRewriteFilePlanner; import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; import org.apache.iceberg.expressions.Expression; @@ -58,7 +60,8 @@ public static class Builder extends MaintenanceTaskBuilder rewriteOptions = Maps.newHashMapWithExpectedSize(6); private long maxRewriteBytes = Long.MAX_VALUE; - private Expression filter = Expressions.alwaysTrue(); + private SerializableSupplier filterSupplier = Expressions::alwaysTrue; + private String branch = SnapshotRef.MAIN_BRANCH; @Override String maintenanceTaskName() { @@ -181,6 +184,19 @@ public Builder maxFileGroupSizeBytes(long maxFileGroupSizeBytes) { return this; } + /** + * Configures the max file count for rewriting. See {@link + * SizeBasedFileRewritePlanner#MAX_FILE_GROUP_INPUT_FILES} for more details. + * + * @param maxFileGroupInputFiles file count for rewrite + */ + public Builder maxFileGroupInputFiles(long maxFileGroupInputFiles) { + this.rewriteOptions.put( + SizeBasedFileRewritePlanner.MAX_FILE_GROUP_INPUT_FILES, + String.valueOf(maxFileGroupInputFiles)); + return this; + } + /** * Configures max files to rewrite. See {@link BinPackRewriteFilePlanner#MAX_FILES_TO_REWRITE} * for more details. @@ -199,9 +215,44 @@ public Builder maxFilesToRewrite(int maxFilesToRewrite) { * * @param newFilter the filter expression to apply * @return this for method chaining + * @deprecated will be removed in 1.12.0. Use {@link #filter(SerializableSupplier)} instead */ + @Deprecated public Builder filter(Expression newFilter) { - this.filter = newFilter; + this.filterSupplier = () -> newFilter; + return this; + } + + /** + * A user-provided supplier of a filter expression that determines which files are considered by + * the rewrite strategy. + * + *

    The supplier is evaluated by the planner on every compaction trigger, allowing a fresh + * filter to be produced for each compaction run. + * + *

    This is particularly useful for time-relative filters. For example, a supplier such as + * {@code () -> Expressions.greaterThanOrEqual("ts", + * LocalDateTime.now(ZoneOffset.UTC).minus(Duration.ofDays(3)).toString())} ensures that each + * compaction rewrites files from the last 3 days relative to the time the compaction is + * planned, rather than relative to when the job was started. + * + * @param newFilterSupplier the supplier providing the filter expression to apply + * @return this for method chaining + */ + public Builder filter(SerializableSupplier newFilterSupplier) { + this.filterSupplier = newFilterSupplier; + return this; + } + + /** + * Sets the branch to compact. When set, the planner reads from the branch's snapshot and + * commits are made to this branch. + * + * @param newBranch the branch name + * @return this for method chaining + */ + public Builder branch(String newBranch) { + this.branch = newBranch; return this; } @@ -239,56 +290,58 @@ public Builder config(RewriteDataFilesConfig rewriteDataFilesConfig) { @Override DataStream append(DataStream trigger) { SingleOutputStreamOperator planned = - trigger - .process( - new DataFileRewritePlanner( - tableName(), - taskName(), - index(), - tableLoader(), - partialProgressEnabled ? partialProgressMaxCommits : 1, - maxRewriteBytes, - rewriteOptions, - filter)) - .name(operatorName(PLANNER_TASK_NAME)) - .uid(PLANNER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new DataFileRewritePlanner( + tableName(), + taskName(), + index(), + tableLoader(), + partialProgressEnabled ? partialProgressMaxCommits : 1, + maxRewriteBytes, + rewriteOptions, + filterSupplier, + branch)) + .name(operatorName(PLANNER_TASK_NAME)) + .uid(PLANNER_TASK_NAME + uidSuffix()) + .forceNonParallel()); SingleOutputStreamOperator rewritten = - planned - .rebalance() - .process(new DataFileRewriteRunner(tableName(), taskName(), index())) - .name(operatorName(REWRITE_TASK_NAME)) - .uid(REWRITE_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + planned + .rebalance() + .process(new DataFileRewriteRunner(tableName(), taskName(), index())) + .name(operatorName(REWRITE_TASK_NAME)) + .uid(REWRITE_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); SingleOutputStreamOperator updated = - rewritten - .transform( - operatorName(COMMIT_TASK_NAME), - TypeInformation.of(Trigger.class), - new DataFileRewriteCommitter(tableName(), taskName(), index(), tableLoader())) - .uid(COMMIT_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + rewritten + .transform( + operatorName(COMMIT_TASK_NAME), + TypeInformation.of(Trigger.class), + new DataFileRewriteCommitter( + tableName(), taskName(), index(), tableLoader(), branch)) + .uid(COMMIT_TASK_NAME + uidSuffix()) + .forceNonParallel()); - return trigger - .union(updated) - .connect( - planned - .getSideOutput(TaskResultAggregator.ERROR_STREAM) - .union( - rewritten.getSideOutput(TaskResultAggregator.ERROR_STREAM), - updated.getSideOutput(TaskResultAggregator.ERROR_STREAM))) - .transform( - operatorName(AGGREGATOR_TASK_NAME), - TypeInformation.of(TaskResult.class), - new TaskResultAggregator(tableName(), taskName(), index())) - .uid(AGGREGATOR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .union(updated) + .connect( + planned + .getSideOutput(TaskResultAggregator.ERROR_STREAM) + .union( + rewritten.getSideOutput(TaskResultAggregator.ERROR_STREAM), + updated.getSideOutput(TaskResultAggregator.ERROR_STREAM))) + .transform( + operatorName(AGGREGATOR_TASK_NAME), + TypeInformation.of(TaskResult.class), + new TaskResultAggregator(tableName(), taskName(), index())) + .uid(AGGREGATOR_TASK_NAME + uidSuffix()) + .forceNonParallel()); } } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java index 1a2b0607dd1e..025a6d17c023 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java @@ -20,9 +20,11 @@ import java.io.IOException; import java.time.Duration; +import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.UUID; +import javax.annotation.Nullable; import org.apache.flink.annotation.Internal; import org.apache.flink.api.common.ExecutionConfig; import org.apache.flink.api.common.eventtime.TimestampAssigner; @@ -40,13 +42,14 @@ import org.apache.flink.streaming.api.datastream.DataStreamUtils; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.iceberg.flink.TableLoader; import org.apache.iceberg.flink.maintenance.operator.LockRemover; +import org.apache.iceberg.flink.maintenance.operator.LockRemoverOperatorFactory; import org.apache.iceberg.flink.maintenance.operator.MonitorSource; import org.apache.iceberg.flink.maintenance.operator.TableChange; import org.apache.iceberg.flink.maintenance.operator.TriggerEvaluator; import org.apache.iceberg.flink.maintenance.operator.TriggerManager; +import org.apache.iceberg.flink.maintenance.operator.TriggerManagerOperatorFactory; import org.apache.iceberg.flink.sink.IcebergSink; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -71,39 +74,77 @@ private TableMaintenance() {} * * @param changeStream the table changes * @param tableLoader used for accessing the table - * @param lockFactory used for preventing concurrent task runs + * @param lockFactory used for preventing concurrent task runs, if null, use coordination lock. * @return builder for the maintenance stream + * @deprecated since 1.12.0, will be removed in 2.0.0. Use {@link #forChangeStream(DataStream, + * TableLoader)} instead. */ + @Deprecated @Internal public static Builder forChangeStream( DataStream changeStream, TableLoader tableLoader, - TriggerLockFactory lockFactory) { + @Nullable TriggerLockFactory lockFactory) { Preconditions.checkNotNull(changeStream, "The change stream should not be null"); Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); - Preconditions.checkNotNull(lockFactory, "LockFactory should not be null"); return new Builder(null, changeStream, tableLoader, lockFactory); } + /** + * Use when the change stream is already provided, like in the {@link + * IcebergSink#addPostCommitTopology(DataStream)}. + * + * @param changeStream the table changes + * @param tableLoader used for accessing the table + * @return builder for the maintenance stream + */ + @Internal + public static Builder forChangeStream( + DataStream changeStream, TableLoader tableLoader) { + Preconditions.checkNotNull(changeStream, "The change stream should not be null"); + Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); + + return new Builder(null, changeStream, tableLoader, null); + } + /** * Use this for standalone maintenance job. It creates a monitor source that detect table changes * and build the maintenance pipelines afterwards. * * @param env used to register the monitor source * @param tableLoader used for accessing the table - * @param lockFactory used for preventing concurrent task runs + * @param lockFactory used for preventing concurrent task runs. If null, use coordination lock. * @return builder for the maintenance stream + * @deprecated since 1.12.0, will be removed in 2.0.0. Use {@link + * #forTable(StreamExecutionEnvironment, TableLoader)} instead. */ + @Deprecated public static Builder forTable( - StreamExecutionEnvironment env, TableLoader tableLoader, TriggerLockFactory lockFactory) { + StreamExecutionEnvironment env, + TableLoader tableLoader, + @Nullable TriggerLockFactory lockFactory) { Preconditions.checkNotNull(env, "StreamExecutionEnvironment should not be null"); Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); - Preconditions.checkNotNull(lockFactory, "LockFactory should not be null"); return new Builder(env, null, tableLoader, lockFactory); } + /** + * Use this for standalone maintenance job. It creates a monitor source that detect table changes + * and build the maintenance pipelines afterwards. But use coordination lock default. + * + * @param env used to register the monitor source + * @param tableLoader used for accessing the table + * @return builder for the maintenance stream + */ + public static Builder forTable(StreamExecutionEnvironment env, TableLoader tableLoader) { + Preconditions.checkNotNull(env, "StreamExecutionEnvironment should not be null"); + Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); + + return new Builder(env, null, tableLoader, null); + } + public static class Builder { private final StreamExecutionEnvironment env; private final DataStream inputStream; @@ -112,7 +153,7 @@ public static class Builder { private final TriggerLockFactory lockFactory; private String uidSuffix = "TableMaintenance-" + UUID.randomUUID(); - private String slotSharingGroup = StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP; + private String slotSharingGroup = null; private Duration rateLimit = Duration.ofSeconds(RATE_LIMIT_SECOND_DEFAULT); private Duration lockCheckDelay = Duration.ofSeconds(LOCK_CHECK_DELAY_SECOND_DEFAULT); private int parallelism = ExecutionConfig.PARALLELISM_DEFAULT; @@ -211,6 +252,17 @@ public Builder add(MaintenanceTaskBuilder task) { return this; } + /** + * Adds multiple tasks with the given schedules. + * + * @param tasks to add + */ + public Builder add(Collection> tasks) { + Preconditions.checkNotNull(tasks, "Tasks collection should not be null"); + taskBuilders.addAll(tasks); + return this; + } + /** Builds the task graph for the maintenance tasks. */ public void append() throws IOException { Preconditions.checkArgument(!taskBuilders.isEmpty(), "Provide at least one task"); @@ -226,38 +278,60 @@ public void append() throws IOException { try (TableLoader loader = tableLoader.clone()) { loader.open(); String tableName = loader.loadTable().name(); - DataStream triggers = - DataStreamUtils.reinterpretAsKeyedStream( - changeStream(tableName, loader), unused -> true) - .process( - new TriggerManager( - loader, - lockFactory, - taskNames, - evaluators, - rateLimit.toMillis(), - lockCheckDelay.toMillis())) - .name(TRIGGER_MANAGER_OPERATOR_NAME) - .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel() - .assignTimestampsAndWatermarks(new PunctuatedWatermarkStrategy()) - .name(WATERMARK_ASSIGNER_OPERATOR_NAME) - .uid(WATERMARK_ASSIGNER_OPERATOR_NAME + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel(); + DataStream triggers; + if (lockFactory == null) { + triggers = + setSlotSharingGroup( + DataStreamUtils.reinterpretAsKeyedStream( + changeStream(tableName, loader), unused -> true) + .transform( + TRIGGER_MANAGER_OPERATOR_NAME, + TypeInformation.of(Trigger.class), + new TriggerManagerOperatorFactory( + tableName, + taskNames, + evaluators, + rateLimit.toMillis(), + lockCheckDelay.toMillis())) + .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); + } else { + triggers = + setSlotSharingGroup( + DataStreamUtils.reinterpretAsKeyedStream( + changeStream(tableName, loader), unused -> true) + .process( + new TriggerManager( + loader, + lockFactory, + taskNames, + evaluators, + rateLimit.toMillis(), + lockCheckDelay.toMillis())) + .name(TRIGGER_MANAGER_OPERATOR_NAME) + .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); + } + + triggers = + setSlotSharingGroup( + triggers + .assignTimestampsAndWatermarks(new PunctuatedWatermarkStrategy()) + .name(WATERMARK_ASSIGNER_OPERATOR_NAME) + .uid(WATERMARK_ASSIGNER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); // Add the specific tasks DataStream unioned = null; for (int i = 0; i < taskBuilders.size(); ++i) { int taskIndex = i; DataStream filtered = - triggers - .filter(t -> t.taskId() != null && t.taskId() == taskIndex) - .name(FILTER_OPERATOR_NAME_PREFIX + taskIndex) - .forceNonParallel() - .uid(FILTER_OPERATOR_NAME_PREFIX + taskIndex + "-" + uidSuffix) - .slotSharingGroup(slotSharingGroup); + setSlotSharingGroup( + triggers + .filter(t -> t.taskId() != null && t.taskId() == taskIndex) + .name(FILTER_OPERATOR_NAME_PREFIX + taskIndex) + .forceNonParallel() + .uid(FILTER_OPERATOR_NAME_PREFIX + taskIndex + "-" + uidSuffix)); MaintenanceTaskBuilder builder = taskBuilders.get(taskIndex); DataStream result = builder.append( @@ -277,14 +351,25 @@ public void append() throws IOException { } // Add the LockRemover to the end - unioned - .transform( - LOCK_REMOVER_OPERATOR_NAME, - TypeInformation.of(Void.class), - new LockRemover(tableName, lockFactory, taskNames)) - .forceNonParallel() - .uid("lock-remover-" + uidSuffix) - .slotSharingGroup(slotSharingGroup); + if (lockFactory == null) { + setSlotSharingGroup( + unioned + .transform( + LOCK_REMOVER_OPERATOR_NAME, + TypeInformation.of(Void.class), + new LockRemoverOperatorFactory(tableName, taskNames)) + .uid("lock-remover-" + uidSuffix) + .forceNonParallel()); + } else { + setSlotSharingGroup( + unioned + .transform( + LOCK_REMOVER_OPERATOR_NAME, + TypeInformation.of(Void.class), + new LockRemover(tableName, lockFactory, taskNames)) + .forceNonParallel() + .uid("lock-remover-" + uidSuffix)); + } } } @@ -294,11 +379,13 @@ private DataStream changeStream(String tableName, TableLoader loade MonitorSource source = new MonitorSource( loader, RateLimiterStrategy.perSecond(1.0 / rateLimit.getSeconds()), maxReadBack); - return env.fromSource( - source, WatermarkStrategy.noWatermarks(), SOURCE_OPERATOR_NAME_PREFIX + tableName) - .uid(SOURCE_OPERATOR_NAME_PREFIX + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel(); + return setSlotSharingGroup( + env.fromSource( + source, + WatermarkStrategy.noWatermarks(), + SOURCE_OPERATOR_NAME_PREFIX + tableName) + .uid(SOURCE_OPERATOR_NAME_PREFIX + uidSuffix) + .forceNonParallel()); } else { return inputStream.global(); } @@ -307,6 +394,11 @@ private DataStream changeStream(String tableName, TableLoader loade private static String nameFor(MaintenanceTaskBuilder streamBuilder, int taskIndex) { return String.format(Locale.ROOT, "%s [%d]", streamBuilder.maintenanceTaskName(), taskIndex); } + + private SingleOutputStreamOperator setSlotSharingGroup( + SingleOutputStreamOperator operator) { + return slotSharingGroup == null ? operator : operator.slotSharingGroup(slotSharingGroup); + } } @Internal diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java index 27c0a43a858a..7797da0caac3 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java @@ -20,7 +20,6 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; -import org.apache.curator.shaded.com.google.common.annotations.VisibleForTesting; import org.apache.flink.shaded.curator5.org.apache.curator.RetryPolicy; import org.apache.flink.shaded.curator5.org.apache.curator.framework.CuratorFramework; import org.apache.flink.shaded.curator5.org.apache.curator.framework.CuratorFrameworkFactory; @@ -31,6 +30,7 @@ import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryNTimes; import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryOneTime; import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryUntilElapsed; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java new file mode 100644 index 000000000000..21e9ca43f304 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import org.apache.flink.annotation.VisibleForTesting; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.util.FatalExitExceptionHandler; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base coordinator for table maintenance operators. Provides common functionality for thread + * management, subtask gateway management, and checkpoint handling. + */ +abstract class BaseCoordinator implements OperatorCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(BaseCoordinator.class); + private static final Map> LOCK_RELEASE_CONSUMERS = + Maps.newConcurrentMap(); + private static final List PENDING_RELEASE_EVENTS = Lists.newArrayList(); + + private final String operatorName; + private final Context context; + private final ExecutorService coordinatorExecutor; + private final CoordinatorExecutorThreadFactory coordinatorThreadFactory; + private final SubtaskGateways subtaskGateways; + + private boolean started; + + BaseCoordinator(String operatorName, Context context) { + this.operatorName = operatorName; + this.context = context; + + this.coordinatorThreadFactory = + new CoordinatorExecutorThreadFactory( + "Coordinator-" + operatorName, context.getUserCodeClassloader()); + this.coordinatorExecutor = Executors.newSingleThreadExecutor(coordinatorThreadFactory); + Preconditions.checkState( + context.currentParallelism() == 1, "Coordinator must run with parallelism 1"); + this.subtaskGateways = SubtaskGateways.create(operatorName); + LOG.info("Created coordinator: {}", operatorName); + } + + @SuppressWarnings("FutureReturnValueIgnored") + void registerLock(LockRegisterEvent lockRegisterEvent) { + LOCK_RELEASE_CONSUMERS.put( + lockRegisterEvent.lockId(), + lock -> { + LOG.info( + "Send release event for lock id {}, timestamp: {} to Operator {}", + lock.lockId(), + lock.timestamp(), + operatorName()); + subtaskGateways.subtaskGateway().sendEvent(lock); + }); + + synchronized (PENDING_RELEASE_EVENTS) { + if (!PENDING_RELEASE_EVENTS.isEmpty()) { + PENDING_RELEASE_EVENTS.forEach(this::handleReleaseLock); + PENDING_RELEASE_EVENTS.clear(); + } + } + } + + void handleReleaseLock(LockReleaseEvent lockReleaseEvent) { + synchronized (PENDING_RELEASE_EVENTS) { + if (LOCK_RELEASE_CONSUMERS.containsKey(lockReleaseEvent.lockId())) { + LOCK_RELEASE_CONSUMERS.get(lockReleaseEvent.lockId()).accept(lockReleaseEvent); + LOG.info( + "Send release event for lock id {}, timestamp: {}", + lockReleaseEvent.lockId(), + lockReleaseEvent.timestamp()); + } else { + PENDING_RELEASE_EVENTS.add(lockReleaseEvent); + LOG.info( + "No consumer for lock id {}, timestamp: {}", + lockReleaseEvent.lockId(), + lockReleaseEvent.timestamp()); + } + } + } + + @Override + public void start() throws Exception { + LOG.info("Starting coordinator: {}", operatorName); + this.started = true; + } + + @Override + public void close() throws Exception { + coordinatorExecutor.shutdown(); + this.started = false; + synchronized (PENDING_RELEASE_EVENTS) { + LOCK_RELEASE_CONSUMERS.clear(); + PENDING_RELEASE_EVENTS.clear(); + } + + LOG.info("Closed coordinator: {}", operatorName); + } + + @Override + public void checkpointCoordinator(long checkpointId, CompletableFuture resultFuture) { + runInCoordinatorThread( + () -> resultFuture.complete(new byte[0]), + String.format(Locale.ROOT, "taking checkpoint %d", checkpointId)); + } + + @Override + public void resetToCheckpoint(long checkpointId, byte[] checkpointData) { + Preconditions.checkState( + !started, "The coordinator %s can only be reset if it was not yet started", operatorName); + LOG.info("Reset to checkpoint {}", checkpointId); + synchronized (PENDING_RELEASE_EVENTS) { + LOCK_RELEASE_CONSUMERS.clear(); + PENDING_RELEASE_EVENTS.clear(); + } + } + + @Override + public void notifyCheckpointComplete(long checkpointId) {} + + @Override + public void subtaskReset(int subtask, long checkpointId) { + runInCoordinatorThread( + () -> { + LOG.info("Subtask {} is reset to checkpoint {}", subtask, checkpointId); + Preconditions.checkState(coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.reset(); + }, + String.format( + Locale.ROOT, "handling subtask %d recovery to checkpoint %d", subtask, checkpointId)); + } + + @Override + public void executionAttemptFailed(int subtask, int attemptNumber, Throwable reason) { + runInCoordinatorThread( + () -> { + LOG.info( + "Unregistering gateway after failure for subtask {} (#{}) of data statistics {}", + subtask, + attemptNumber, + operatorName); + Preconditions.checkState( + this.coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.unregisterSubtaskGateway(subtask, attemptNumber); + }, + String.format(Locale.ROOT, "handling subtask %d (#%d) failure", subtask, attemptNumber)); + } + + @Override + public void executionAttemptReady(int subtask, int attemptNumber, SubtaskGateway gateway) { + Preconditions.checkArgument(subtask == gateway.getSubtask()); + Preconditions.checkArgument(attemptNumber == gateway.getExecution().getAttemptNumber()); + runInCoordinatorThread( + () -> { + Preconditions.checkState( + this.coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.registerSubtaskGateway(gateway); + }, + String.format( + Locale.ROOT, + "making event gateway to subtask %d (#%d) available", + subtask, + attemptNumber)); + } + + String operatorName() { + return operatorName; + } + + void runInCoordinatorThread(Runnable runnable, String actionString) { + ensureStarted(); + coordinatorExecutor.execute( + () -> { + try { + runnable.run(); + } catch (Throwable t) { + LOG.error( + "Uncaught exception in coordinator while {}: {}", actionString, t.getMessage(), t); + context.failJob(t); + } + }); + } + + private void ensureStarted() { + Preconditions.checkState(started, "The coordinator has not started yet."); + } + + /** Inner class to manage subtask gateways. */ + private record SubtaskGateways(String operatorName, Map gateways) { + + private static SubtaskGateways create(String operatorName) { + return new SubtaskGateways(operatorName, Maps.newHashMap()); + } + + private void registerSubtaskGateway(SubtaskGateway gateway) { + int attemptNumber = gateway.getExecution().getAttemptNumber(); + Preconditions.checkState( + !gateways.containsKey(attemptNumber), + "Coordinator of %s already has a subtask gateway for (#%d)", + operatorName, + attemptNumber); + LOG.debug("Coordinator of {} registers gateway for attempt {}", operatorName, attemptNumber); + gateways.put(attemptNumber, gateway); + LOG.debug("Registered gateway for attempt {}", attemptNumber); + } + + private void unregisterSubtaskGateway(int subtaskIndex, int attemptNumber) { + gateways.remove(attemptNumber); + LOG.debug("Unregistered gateway for subtask {} attempt {}", subtaskIndex, attemptNumber); + } + + private SubtaskGateway subtaskGateway() { + Preconditions.checkState( + !gateways.isEmpty(), + "Coordinator of %s is not ready yet to receive events", + operatorName); + return Iterables.getOnlyElement(gateways.values()); + } + + private void reset() { + gateways.clear(); + } + } + + /** Custom thread factory for the coordinator executor. */ + private static class CoordinatorExecutorThreadFactory + implements ThreadFactory, Thread.UncaughtExceptionHandler { + + private final String coordinatorThreadName; + private final ClassLoader classLoader; + private final Thread.UncaughtExceptionHandler errorHandler; + + private Thread thread; + + private CoordinatorExecutorThreadFactory( + String coordinatorThreadName, ClassLoader contextClassLoader) { + this(coordinatorThreadName, contextClassLoader, FatalExitExceptionHandler.INSTANCE); + } + + private CoordinatorExecutorThreadFactory( + String coordinatorThreadName, + ClassLoader contextClassLoader, + Thread.UncaughtExceptionHandler errorHandler) { + this.coordinatorThreadName = coordinatorThreadName; + this.classLoader = contextClassLoader; + this.errorHandler = errorHandler; + } + + @Override + public synchronized Thread newThread(@Nonnull Runnable runnable) { + thread = new Thread(runnable, coordinatorThreadName); + thread.setContextClassLoader(classLoader); + thread.setUncaughtExceptionHandler(this); + return thread; + } + + @Override + public synchronized void uncaughtException(Thread t, Throwable e) { + errorHandler.uncaughtException(t, e); + } + + private boolean isCurrentThreadCoordinatorThread() { + return Thread.currentThread() == thread; + } + } + + @VisibleForTesting + List pendingReleaseEvents() { + return PENDING_RELEASE_EVENTS; + } + + @VisibleForTesting + ExecutorService coordinatorExecutor() { + return coordinatorExecutor; + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java index 135d3d9b42db..1125e5d9b6a5 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java @@ -29,12 +29,14 @@ import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; import org.apache.iceberg.DataFile; import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteDataFiles; import org.apache.iceberg.actions.RewriteDataFilesCommitManager; import org.apache.iceberg.actions.RewriteDataFilesCommitManager.CommitService; import org.apache.iceberg.actions.RewriteFileGroup; import org.apache.iceberg.flink.TableLoader; import org.apache.iceberg.flink.maintenance.api.Trigger; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,6 +54,7 @@ public class DataFileRewriteCommitter extends AbstractStreamOperator private final String taskName; private final int taskIndex; private final TableLoader tableLoader; + private final String branch; private transient Table table; private transient CommitService commitService; @@ -62,15 +65,17 @@ public class DataFileRewriteCommitter extends AbstractStreamOperator private transient Counter removedDataFileSizeCounter; public DataFileRewriteCommitter( - String tableName, String taskName, int taskIndex, TableLoader tableLoader) { + String tableName, String taskName, int taskIndex, TableLoader tableLoader, String branch) { Preconditions.checkNotNull(tableName, "Table name should no be null"); Preconditions.checkNotNull(taskName, "Task name should no be null"); Preconditions.checkNotNull(tableLoader, "Table loader should no be null"); + Preconditions.checkNotNull(branch, "Branch should not be null"); this.tableName = tableName; this.taskName = taskName; this.taskIndex = taskIndex; this.tableLoader = tableLoader; + this.branch = branch; } @Override @@ -103,7 +108,7 @@ public void processElement(StreamRecord str FlinkRewriteDataFilesCommitManager commitManager = new FlinkRewriteDataFilesCommitManager( - table, executedGroup.snapshotId(), streamRecord.getTimestamp()); + table, executedGroup.snapshotId(), streamRecord.getTimestamp(), branch); this.commitService = commitManager.service(executedGroup.groupsPerCommit()); commitService.start(); } @@ -164,8 +169,14 @@ public void close() throws IOException { private class FlinkRewriteDataFilesCommitManager extends RewriteDataFilesCommitManager { private final long timestamp; - FlinkRewriteDataFilesCommitManager(Table table, long startingSnapshotId, long timestamp) { - super(table, startingSnapshotId); + FlinkRewriteDataFilesCommitManager( + Table table, long startingSnapshotId, long timestamp, String branch) { + super( + table, + startingSnapshotId, + RewriteDataFiles.USE_STARTING_SEQUENCE_NUMBER_DEFAULT, + ImmutableMap.of(), + branch); this.timestamp = timestamp; } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java index c50060e16a6c..b78c602c647f 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java @@ -26,9 +26,11 @@ import org.apache.flink.metrics.Counter; import org.apache.flink.streaming.api.functions.ProcessFunction; import org.apache.flink.util.Collector; +import org.apache.flink.util.function.SerializableSupplier; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.SerializableTable; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.actions.BinPackRewriteFilePlanner; import org.apache.iceberg.actions.FileRewritePlan; import org.apache.iceberg.actions.RewriteDataFiles; @@ -61,7 +63,8 @@ public class DataFileRewritePlanner private final long maxRewriteBytes; private final Map rewriterOptions; private transient Counter errorCounter; - private final Expression filter; + private final String branch; + private final SerializableSupplier filterSupplier; public DataFileRewritePlanner( String tableName, @@ -71,12 +74,14 @@ public DataFileRewritePlanner( int newPartialProgressMaxCommits, long maxRewriteBytes, Map rewriterOptions, - Expression filter) { + SerializableSupplier filterSupplier, + String branch) { Preconditions.checkNotNull(tableName, "Table name should no be null"); Preconditions.checkNotNull(taskName, "Task name should no be null"); Preconditions.checkNotNull(tableLoader, "Table loader should no be null"); Preconditions.checkNotNull(rewriterOptions, "Options map should no be null"); + Preconditions.checkNotNull(branch, "Branch should no be null"); this.tableName = tableName; this.taskName = taskName; @@ -85,7 +90,8 @@ public DataFileRewritePlanner( this.partialProgressMaxCommits = newPartialProgressMaxCommits; this.maxRewriteBytes = maxRewriteBytes; this.rewriterOptions = rewriterOptions; - this.filter = filter; + this.branch = branch; + this.filterSupplier = filterSupplier; } @Override @@ -108,7 +114,8 @@ public void processElement(Trigger value, Context ctx, Collector o try { SerializableTable table = (SerializableTable) SerializableTable.copyOf(tableLoader.loadTable()); - if (table.currentSnapshot() == null) { + Snapshot snapshot = table.snapshot(branch); + if (snapshot == null) { LOG.info( DataFileRewritePlanner.MESSAGE_PREFIX + "Nothing to plan for in an empty table", tableName, @@ -118,7 +125,8 @@ public void processElement(Trigger value, Context ctx, Collector o return; } - BinPackRewriteFilePlanner planner = new BinPackRewriteFilePlanner(table, filter); + BinPackRewriteFilePlanner planner = + new BinPackRewriteFilePlanner(table, filterSupplier.get(), snapshot.snapshotId(), false); planner.init(rewriterOptions); FileRewritePlan @@ -164,7 +172,7 @@ public void processElement(Trigger value, Context ctx, Collector o taskIndex, ctx.timestamp(), group); - out.collect(new PlannedGroup(table, groupsPerCommit, group)); + out.collect(new PlannedGroup(table, groupsPerCommit, group, branch)); } } catch (Exception e) { LOG.warn( @@ -189,11 +197,14 @@ public static class PlannedGroup { private final SerializableTable table; private final int groupsPerCommit; private final RewriteFileGroup group; + private final String branch; - private PlannedGroup(SerializableTable table, int groupsPerCommit, RewriteFileGroup group) { + private PlannedGroup( + SerializableTable table, int groupsPerCommit, RewriteFileGroup group, String branch) { this.table = table; this.groupsPerCommit = groupsPerCommit; this.group = group; + this.branch = branch; } SerializableTable table() { @@ -207,5 +218,9 @@ int groupsPerCommit() { RewriteFileGroup group() { return group; } + + String branch() { + return branch; + } } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java index 57b0e53d86e6..6fbfacf9f6c9 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java @@ -124,7 +124,7 @@ public void processElement(PlannedGroup value, Context ctx, Collector o ctx.output(DELETE_STREAM, file); deleteFileCounter.incrementAndGet(); }) - .cleanExpiredFiles(true) + .cleanupLevel(ExpireSnapshots.CleanupLevel.ALL) .commit(); LOG.info( diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java index 3ae42c60831c..f9000511c11f 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java @@ -66,6 +66,7 @@ public void open(OpenContext openContext) throws Exception { public void processElement(Trigger trigger, Context ctx, Collector collector) throws Exception { try { + table.refresh(); table .snapshots() .forEach( diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java new file mode 100644 index 000000000000..0dcd15a66559 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +/** + * Event sent from TriggerManagerOperator to TriggerManagerCoordinator to register a lock release + * handler. This handler will be used to forward lock release events back to the operator when + * triggered by downstream operators. + */ +@Internal +public class LockRegisterEvent implements OperatorEvent { + + private final String lockId; + + public LockRegisterEvent(String lockId) { + this.lockId = lockId; + } + + public String lockId() { + return lockId; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("lockId", lockId).toString(); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java new file mode 100644 index 000000000000..8c6e71ca9761 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +/** + * Event sent from LockRemoverOperator to LockRemoverCoordinator to notify that a lock has been + * released. The LockRemoverCoordinator then forwards this event to the TriggerManagerOperator via + * the registered lock release handler. + */ +@Internal +public class LockReleaseEvent implements OperatorEvent { + + private final String lockId; + private final long timestamp; + + public LockReleaseEvent(String lockId, long timestamp) { + this.lockId = lockId; + this.timestamp = timestamp; + } + + public long timestamp() { + return timestamp; + } + + public String lockId() { + return lockId; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("lockId", lockId) + .add("timestamp", timestamp) + .toString(); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java new file mode 100644 index 000000000000..39020cd5d30a --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.Locale; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Coordinator for LockRemoverOperator. Handles lock release events from downstream operators. */ +class LockRemoverCoordinator extends BaseCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(LockRemoverCoordinator.class); + + LockRemoverCoordinator(String operatorName, Context context) { + super(operatorName, context); + LOG.info("Created LockRemoverCoordinator: {}", operatorName); + } + + @Override + public void handleEventFromOperator(int subtask, int attemptNumber, OperatorEvent event) { + runInCoordinatorThread( + () -> { + LOG.debug( + "Handling event from subtask {} (#{}) of {}: {}", + subtask, + attemptNumber, + operatorName(), + event); + if (event instanceof LockReleaseEvent) { + handleReleaseLock((LockReleaseEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + }, + String.format( + Locale.ROOT, + "handling operator event %s from subtask %d (#%d)", + event.getClass(), + subtask, + attemptNumber)); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java new file mode 100644 index 000000000000..d5c1be2e322d --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.io.Serial; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.flink.metrics.Counter; +import org.apache.flink.metrics.MetricGroup; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventHandler; +import org.apache.flink.streaming.api.operators.AbstractStreamOperator; +import org.apache.flink.streaming.api.operators.OneInputStreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.iceberg.flink.maintenance.api.TaskResult; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class LockRemoverOperator extends AbstractStreamOperator + implements OneInputStreamOperator, OperatorEventHandler { + + @Serial private static final long serialVersionUID = 1L; + private static final Logger LOG = LoggerFactory.getLogger(LockRemoverOperator.class); + + private final String tableName; + private final OperatorEventGateway operatorEventGateway; + private final List maintenanceTaskNames; + private transient List succeededTaskResultCounters; + private transient List failedTaskResultCounters; + private transient List taskLastRunDurationMs; + + LockRemoverOperator( + StreamOperatorParameters parameters, + OperatorEventGateway operatorEventGateway, + String tableName, + List maintenanceTaskNames) { + super(parameters); + this.tableName = tableName; + this.operatorEventGateway = operatorEventGateway; + this.maintenanceTaskNames = maintenanceTaskNames; + } + + @Override + public void open() throws Exception { + this.succeededTaskResultCounters = + Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + this.failedTaskResultCounters = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + this.taskLastRunDurationMs = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + for (int taskIndex = 0; taskIndex < maintenanceTaskNames.size(); ++taskIndex) { + MetricGroup taskMetricGroup = + TableMaintenanceMetrics.groupFor( + getRuntimeContext(), tableName, maintenanceTaskNames.get(taskIndex), taskIndex); + succeededTaskResultCounters.add( + taskMetricGroup.counter(TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER)); + failedTaskResultCounters.add( + taskMetricGroup.counter(TableMaintenanceMetrics.FAILED_TASK_COUNTER)); + AtomicLong duration = new AtomicLong(0); + taskLastRunDurationMs.add(duration); + taskMetricGroup.gauge(TableMaintenanceMetrics.LAST_RUN_DURATION_MS, duration::get); + } + } + + @Override + public void handleOperatorEvent(OperatorEvent event) { + // no incoming events + } + + @SuppressWarnings("FutureReturnValueIgnored") + @Override + public void processElement(StreamRecord streamRecord) { + TaskResult taskResult = streamRecord.getValue(); + LOG.info( + "Processing result {} for task {}", + taskResult, + maintenanceTaskNames.get(taskResult.taskIndex())); + long duration = System.currentTimeMillis() - taskResult.startEpoch(); + // Update the metrics + taskLastRunDurationMs.get(taskResult.taskIndex()).set(duration); + if (taskResult.success()) { + succeededTaskResultCounters.get(taskResult.taskIndex()).inc(); + } else { + failedTaskResultCounters.get(taskResult.taskIndex()).inc(); + } + } + + @Override + public void processWatermark(Watermark mark) throws Exception { + if (Watermark.MAX_WATERMARK.getTimestamp() != mark.getTimestamp()) { + operatorEventGateway.sendEventToCoordinator( + new LockReleaseEvent(tableName, mark.getTimestamp())); + } + + super.processWatermark(mark); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java new file mode 100644 index 000000000000..b43c3f15fcc3 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.RecreateOnResetOperatorCoordinator; +import org.apache.flink.streaming.api.operators.AbstractStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.CoordinatedOperatorFactory; +import org.apache.flink.streaming.api.operators.OneInputStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.StreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.iceberg.flink.maintenance.api.TaskResult; + +@Internal +public class LockRemoverOperatorFactory extends AbstractStreamOperatorFactory + implements CoordinatedOperatorFactory, OneInputStreamOperatorFactory { + private final String tableName; + private final List maintenanceTaskNames; + + public LockRemoverOperatorFactory(String tableName, List maintenanceTaskNames) { + this.tableName = tableName; + this.maintenanceTaskNames = maintenanceTaskNames; + } + + @Override + public OperatorCoordinator.Provider getCoordinatorProvider( + String operatorName, OperatorID operatorID) { + return new LockRemoverCoordinatorProvider(operatorName, operatorID); + } + + @SuppressWarnings("unchecked") + @Override + public > T createStreamOperator( + StreamOperatorParameters parameters) { + OperatorID operatorId = parameters.getStreamConfig().getOperatorID(); + OperatorEventGateway gateway = + parameters.getOperatorEventDispatcher().getOperatorEventGateway(operatorId); + LockRemoverOperator lockRemoverOperator = + new LockRemoverOperator(parameters, gateway, tableName, maintenanceTaskNames); + parameters.getOperatorEventDispatcher().registerEventHandler(operatorId, lockRemoverOperator); + + return (T) lockRemoverOperator; + } + + @SuppressWarnings("rawtypes") + @Override + public Class getStreamOperatorClass(ClassLoader classLoader) { + return LockRemoverOperator.class; + } + + private static class LockRemoverCoordinatorProvider + extends RecreateOnResetOperatorCoordinator.Provider { + + private final String operatorName; + + private LockRemoverCoordinatorProvider(String operatorName, OperatorID operatorID) { + super(operatorID); + this.operatorName = operatorName; + } + + @Override + public OperatorCoordinator getCoordinator(OperatorCoordinator.Context context) { + return new LockRemoverCoordinator(operatorName, context); + } + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java index d74b2349b1de..f468c2f6eb74 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java @@ -129,7 +129,7 @@ public TableChange next() { if (snapshot != null) { if (!DataOperations.REPLACE.equals(snapshot.operation())) { LOG.debug("Reading snapshot {}", snapshot.snapshotId()); - event.merge(new TableChange(snapshot, table.io())); + event.merge(new TableChange(snapshot, table)); } else { LOG.debug("Skipping replace snapshot {}", snapshot.snapshotId()); } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java index 87600c52304a..b363e3443d47 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java @@ -23,7 +23,8 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.Snapshot; -import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; /** Event describing changes in an Iceberg table */ @@ -54,8 +55,12 @@ private TableChange( this.commitCount = commitCount; } - TableChange(Snapshot snapshot, FileIO io) { - this(snapshot.addedDataFiles(io), snapshot.addedDeleteFiles(io)); + TableChange(Snapshot snapshot, Table table) { + this(SnapshotChanges.builderFor(table).snapshot(snapshot).build()); + } + + private TableChange(SnapshotChanges changes) { + this(changes.addedDataFiles(), changes.addedDeleteFiles()); } public TableChange(Iterable dataFiles, Iterable deleteFiles) { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java index f1f2b51c0943..6d8326645d42 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java @@ -226,7 +226,8 @@ private void checkAndFire(long current, TimerService timerService, Collector evaluators, - List changes, - List lastTriggerTimes, - long currentTime, - int startPos) { - int current = startPos; - do { - if (evaluators - .get(current) - .check(changes.get(current), lastTriggerTimes.get(current), currentTime)) { - return current; - } - - current = (current + 1) % evaluators.size(); - } while (current != startPos); - - return null; - } - private void init(Collector out, TimerService timerService) throws Exception { if (!inited) { long current = timerService.currentProcessingTime(); diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java new file mode 100644 index 000000000000..b5af41da279a --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.Locale; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class TriggerManagerCoordinator extends BaseCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(TriggerManagerCoordinator.class); + + TriggerManagerCoordinator(String operatorName, Context context) { + super(operatorName, context); + LOG.info("Created TriggerManagerCoordinator: {}", operatorName); + } + + @Override + public void handleEventFromOperator(int subtask, int attemptNumber, OperatorEvent event) { + runInCoordinatorThread( + () -> { + LOG.debug( + "Handling event from subtask {} (#{}) of {}: {}", + subtask, + attemptNumber, + operatorName(), + event); + if (event instanceof LockRegisterEvent) { + registerLock((LockRegisterEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + }, + String.format( + Locale.ROOT, + "handling operator event %s from subtask %d (#%d)", + event.getClass(), + subtask, + attemptNumber)); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java new file mode 100644 index 000000000000..f29ac9670b70 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java @@ -0,0 +1,322 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.io.Serial; +import java.util.List; +import org.apache.flink.annotation.VisibleForTesting; +import org.apache.flink.api.common.operators.ProcessingTimeService.ProcessingTimeCallback; +import org.apache.flink.api.common.state.ListState; +import org.apache.flink.api.common.state.ListStateDescriptor; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.metrics.Counter; +import org.apache.flink.metrics.MetricGroup; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventHandler; +import org.apache.flink.runtime.state.StateInitializationContext; +import org.apache.flink.runtime.state.StateSnapshotContext; +import org.apache.flink.streaming.api.operators.AbstractStreamOperator; +import org.apache.flink.streaming.api.operators.OneInputStreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.flink.streaming.runtime.tasks.ProcessingTimeService; +import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The TriggerManagerOperator itself holds the lock and registers a callback method with the + * coordinator. When a task finishes, it sends a signal from downstream to the coordinator to + * trigger this callback, allowing the TriggerManagerOperator to release the lock. + */ +class TriggerManagerOperator extends AbstractStreamOperator + implements OneInputStreamOperator, + OperatorEventHandler, + ProcessingTimeCallback { + + @Serial private static final long serialVersionUID = 1L; + private static final Logger LOG = LoggerFactory.getLogger(TriggerManagerOperator.class); + + private final OperatorEventGateway operatorEventGateway; + private final List maintenanceTaskNames; + private final List evaluators; + private final long minFireDelayMs; + private final long lockCheckDelayMs; + private final String tableName; + + private transient Counter rateLimiterTriggeredCounter; + private transient Counter concurrentRunThrottledCounter; + private transient Counter nothingToTriggerCounter; + private transient List triggerCounters; + private transient ListState nextEvaluationTimeState; + private transient ListState accumulatedChangesState; + private transient ListState lastTriggerTimesState; + private transient Long nextEvaluationTime; + private transient List accumulatedChanges; + private transient List lastTriggerTimes; + // To keep the task scheduling fair we keep the last triggered task position in memory. + // If we find a task to trigger, then we run it, but after it is finished, we start from the given + // position to prevent "starvation" of the tasks. + // When there is nothing to trigger, we start from the beginning, as the order of the tasks might + // be important (RewriteDataFiles first, and then RewriteManifestFiles later) + private transient int startsFrom = 0; + private transient boolean triggered = false; + private transient Long lockTime; + private transient boolean shouldRestoreTasks = false; + + TriggerManagerOperator( + StreamOperatorParameters parameters, + OperatorEventGateway operatorEventGateway, + List maintenanceTaskNames, + List evaluators, + long minFireDelayMs, + long lockCheckDelayMs, + String tableName) { + super(parameters); + Preconditions.checkArgument( + maintenanceTaskNames != null && !maintenanceTaskNames.isEmpty(), + "Invalid maintenance task names: null or empty"); + Preconditions.checkArgument( + evaluators != null && !evaluators.isEmpty(), "Invalid evaluators: null or empty"); + Preconditions.checkArgument( + maintenanceTaskNames.size() == evaluators.size(), + "Provide a name and evaluator for all of the maintenance tasks"); + Preconditions.checkArgument(minFireDelayMs > 0, "Minimum fire delay should be at least 1."); + Preconditions.checkArgument( + lockCheckDelayMs > 0, "Minimum lock delay rate should be at least 1 ms."); + + this.maintenanceTaskNames = maintenanceTaskNames; + this.evaluators = evaluators; + this.minFireDelayMs = minFireDelayMs; + this.lockCheckDelayMs = lockCheckDelayMs; + this.tableName = tableName; + this.operatorEventGateway = operatorEventGateway; + } + + @Override + public void open() throws Exception { + super.open(); + MetricGroup mainGroup = TableMaintenanceMetrics.groupFor(getRuntimeContext(), tableName); + this.rateLimiterTriggeredCounter = + mainGroup.counter(TableMaintenanceMetrics.RATE_LIMITER_TRIGGERED); + this.concurrentRunThrottledCounter = + mainGroup.counter(TableMaintenanceMetrics.CONCURRENT_RUN_THROTTLED); + this.nothingToTriggerCounter = mainGroup.counter(TableMaintenanceMetrics.NOTHING_TO_TRIGGER); + this.triggerCounters = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + for (int taskIndex = 0; taskIndex < maintenanceTaskNames.size(); ++taskIndex) { + triggerCounters.add( + TableMaintenanceMetrics.groupFor( + mainGroup, maintenanceTaskNames.get(taskIndex), taskIndex) + .counter(TableMaintenanceMetrics.TRIGGERED)); + } + } + + @Override + public void initializeState(StateInitializationContext context) throws Exception { + super.initializeState(context); + this.nextEvaluationTimeState = + context + .getOperatorStateStore() + .getListState(new ListStateDescriptor<>("triggerManagerNextTriggerTime", Types.LONG)); + + this.accumulatedChangesState = + context + .getOperatorStateStore() + .getListState( + new ListStateDescriptor<>( + "triggerManagerAccumulatedChange", TypeInformation.of(TableChange.class))); + + this.lastTriggerTimesState = + context + .getOperatorStateStore() + .getListState(new ListStateDescriptor<>("triggerManagerLastTriggerTime", Types.LONG)); + + long current = getProcessingTimeService().getCurrentProcessingTime(); + + // Initialize from state + if (!Iterables.isEmpty(nextEvaluationTimeState.get())) { + nextEvaluationTime = Iterables.getOnlyElement(nextEvaluationTimeState.get()); + } + + this.accumulatedChanges = Lists.newArrayList(accumulatedChangesState.get()); + this.lastTriggerTimes = Lists.newArrayList(lastTriggerTimesState.get()); + + // Initialize if the state was empty + if (accumulatedChanges.isEmpty()) { + for (int i = 0; i < evaluators.size(); ++i) { + accumulatedChanges.add(TableChange.empty()); + lastTriggerTimes.add(current); + } + } + + // register the lock register event + operatorEventGateway.sendEventToCoordinator(new LockRegisterEvent(tableName)); + + if (context.isRestored()) { + // When the job state is restored, there could be ongoing tasks. + // To prevent collision with the new triggers the following is done: + // - add a recovery lock + // This ensures that the tasks of the previous trigger are executed, and the lock is removed + // in the end. The result of the 'tryLock' is ignored as an already existing lock prevents + // collisions as well. + // register the recover lock + this.lockTime = current; + this.shouldRestoreTasks = true; + output.collect(new StreamRecord<>(Trigger.recovery(current), current)); + if (nextEvaluationTime == null) { + schedule(getProcessingTimeService(), current + minFireDelayMs); + } else { + schedule(getProcessingTimeService(), nextEvaluationTime); + } + } else { + this.lockTime = null; + } + } + + @Override + public void snapshotState(StateSnapshotContext context) throws Exception { + nextEvaluationTimeState.clear(); + if (nextEvaluationTime != null) { + nextEvaluationTimeState.add(nextEvaluationTime); + } + + accumulatedChangesState.update(accumulatedChanges); + lastTriggerTimesState.update(lastTriggerTimes); + LOG.info( + "Storing state: nextEvaluationTime {}, accumulatedChanges {}, lastTriggerTimes {}", + nextEvaluationTime, + accumulatedChanges, + lastTriggerTimes); + } + + @Override + public void handleOperatorEvent(OperatorEvent event) { + if (event instanceof LockReleaseEvent) { + LOG.info("Received lock released event: {}", event); + handleLockRelease((LockReleaseEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + } + + @Override + public void processElement(StreamRecord streamRecord) throws Exception { + TableChange change = streamRecord.getValue(); + accumulatedChanges.forEach(tableChange -> tableChange.merge(change)); + if (nextEvaluationTime == null) { + checkAndFire(getProcessingTimeService()); + } else { + LOG.info( + "Trigger manager rate limiter triggered current: {}, next: {}, accumulated changes: {},{}", + getProcessingTimeService().getCurrentProcessingTime(), + nextEvaluationTime, + accumulatedChanges, + maintenanceTaskNames); + rateLimiterTriggeredCounter.inc(); + } + } + + @Override + public void onProcessingTime(long l) { + this.nextEvaluationTime = null; + checkAndFire(getProcessingTimeService()); + } + + @Override + public void close() throws Exception { + super.close(); + this.lockTime = null; + } + + @VisibleForTesting + void handleLockRelease(LockReleaseEvent event) { + Preconditions.checkArgument(lockTime != null, "Lock time is null, Can't release lock"); + + if (event.timestamp() >= lockTime) { + this.lockTime = null; + this.shouldRestoreTasks = false; + } + } + + private void checkAndFire(ProcessingTimeService timerService) { + long current = timerService.getCurrentProcessingTime(); + if (shouldRestoreTasks) { + if (lockTime != null) { + // Recovered tasks in progress. Skip trigger check + LOG.info("The recovery lock is still held at {}", current); + schedule(timerService, current + lockCheckDelayMs); + return; + } + } + + Integer taskToStart = + TriggerUtil.nextTrigger( + evaluators, accumulatedChanges, lastTriggerTimes, current, startsFrom); + if (taskToStart == null) { + // Nothing to execute + if (!triggered) { + nothingToTriggerCounter.inc(); + LOG.debug("Nothing to execute at {} for collected: {}", current, accumulatedChanges); + } else { + LOG.debug("Execution check finished"); + } + + // Next time start from the beginning + startsFrom = 0; + triggered = false; + return; + } + + if (lockTime == null) { + this.lockTime = current; + TableChange change = accumulatedChanges.get(taskToStart); + output.collect(new StreamRecord<>(Trigger.create(current, taskToStart), current)); + LOG.debug("Fired event with time: {}, collected: {} for {}", current, change, tableName); + triggerCounters.get(taskToStart).inc(); + accumulatedChanges.set(taskToStart, TableChange.empty()); + lastTriggerTimes.set(taskToStart, current); + schedule(timerService, current + minFireDelayMs); + startsFrom = (taskToStart + 1) % evaluators.size(); + triggered = true; + } else { + // A task is already running, waiting for it to finish + LOG.info("Failed to acquire lock. Delaying task to {}", current + lockCheckDelayMs); + + startsFrom = taskToStart; + concurrentRunThrottledCounter.inc(); + schedule(timerService, current + lockCheckDelayMs); + } + } + + private void schedule(ProcessingTimeService timerService, long time) { + this.nextEvaluationTime = time; + timerService.registerTimer(time, this); + } + + @VisibleForTesting + Long lockTime() { + return lockTime; + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java new file mode 100644 index 000000000000..bace5e3afe2b --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.RecreateOnResetOperatorCoordinator; +import org.apache.flink.streaming.api.operators.AbstractStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.CoordinatedOperatorFactory; +import org.apache.flink.streaming.api.operators.OneInputStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.StreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.iceberg.flink.maintenance.api.Trigger; + +@Internal +public class TriggerManagerOperatorFactory extends AbstractStreamOperatorFactory + implements CoordinatedOperatorFactory, + OneInputStreamOperatorFactory { + + private final String lockId; + private final List maintenanceTaskNames; + private final List evaluators; + private final long minFireDelayMs; + private final long lockCheckDelayMs; + + public TriggerManagerOperatorFactory( + String lockId, + List maintenanceTaskNames, + List evaluators, + long minFireDelayMs, + long lockCheckDelayMs) { + this.lockId = lockId; + this.maintenanceTaskNames = maintenanceTaskNames; + this.evaluators = evaluators; + this.minFireDelayMs = minFireDelayMs; + this.lockCheckDelayMs = lockCheckDelayMs; + } + + @Override + public OperatorCoordinator.Provider getCoordinatorProvider( + String operatorName, OperatorID operatorID) { + return new TriggerManagerCoordinatorProvider(operatorName, operatorID); + } + + @SuppressWarnings("unchecked") + @Override + public > T createStreamOperator( + StreamOperatorParameters parameters) { + OperatorID operatorId = parameters.getStreamConfig().getOperatorID(); + OperatorEventGateway gateway = + parameters.getOperatorEventDispatcher().getOperatorEventGateway(operatorId); + + TriggerManagerOperator triggerManagerOperator = + new TriggerManagerOperator( + parameters, + gateway, + maintenanceTaskNames, + evaluators, + minFireDelayMs, + lockCheckDelayMs, + lockId); + + parameters + .getOperatorEventDispatcher() + .registerEventHandler(operatorId, triggerManagerOperator); + + return (T) triggerManagerOperator; + } + + @SuppressWarnings("rawtypes") + @Override + public Class getStreamOperatorClass(ClassLoader classLoader) { + return TriggerManagerOperator.class; + } + + private static class TriggerManagerCoordinatorProvider + extends RecreateOnResetOperatorCoordinator.Provider { + + private final String operatorName; + + private TriggerManagerCoordinatorProvider(String operatorName, OperatorID operatorID) { + super(operatorID); + this.operatorName = operatorName; + } + + @Override + public OperatorCoordinator getCoordinator(OperatorCoordinator.Context context) { + return new TriggerManagerCoordinator(operatorName, context); + } + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java new file mode 100644 index 000000000000..634e9a0d03c6 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; + +class TriggerUtil { + + private TriggerUtil() {} + + static Integer nextTrigger( + List evaluators, + List changes, + List lastTriggerTimes, + long currentTime, + int startPos) { + int current = startPos; + do { + if (evaluators + .get(current) + .check(changes.get(current), lastTriggerTimes.get(current), currentTime)) { + return current; + } + + current = (current + 1) % evaluators.size(); + } while (current != startPos); + + return null; + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java index f7e8e0c884cf..5f3494330cfc 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java @@ -21,14 +21,14 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.api.common.functions.MapFunction; -import org.apache.flink.formats.avro.AvroToRowDataConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.RowType; import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; +import org.apache.iceberg.flink.formats.avro.AvroToRowDataConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; /** * This util class converts Avro GenericRecord to Flink RowData.
    diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java index 1b786e46452f..1da0f8564938 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java @@ -19,6 +19,7 @@ package org.apache.iceberg.flink.sink; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.NavigableMap; import java.util.concurrent.atomic.AtomicLong; @@ -44,7 +45,11 @@ public CommitSummary(NavigableMap pendingResults) { } public void addAll(NavigableMap> pendingResults) { - pendingResults.values().forEach(writeResults -> writeResults.forEach(this::addWriteResult)); + pendingResults.values().forEach(this::addAll); + } + + public void addAll(Collection pendingResults) { + pendingResults.forEach(this::addWriteResult); } private void addWriteResult(WriteResult writeResult) { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java index b3ada41737bc..d5247941d863 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java @@ -25,28 +25,19 @@ import java.io.Serializable; import java.util.Map; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.data.StringData; import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.FileFormat; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; -import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; import org.apache.iceberg.flink.FlinkSchemaUtil; -import org.apache.iceberg.flink.data.FlinkAvroWriter; -import org.apache.iceberg.flink.data.FlinkOrcWriter; -import org.apache.iceberg.flink.data.FlinkParquetWriters; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -public class FlinkFileWriterFactory extends BaseFileWriterFactory implements Serializable { - private RowType dataFlinkType; - private RowType equalityDeleteFlinkType; - - private FlinkFileWriterFactory( +public class FlinkFileWriterFactory extends RegistryBasedFileWriterFactory + implements Serializable { + FlinkFileWriterFactory( Table table, FileFormat dataFileFormat, Schema dataSchema, @@ -62,85 +53,30 @@ private FlinkFileWriterFactory( super( table, dataFileFormat, + RowData.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - writeProperties); - - this.dataFlinkType = dataFlinkType; - this.equalityDeleteFlinkType = equalityDeleteFlinkType; - } - - static Builder builderFor(Table table) { - return new Builder(table); - } - - @Override - protected void configureDataWrite(Avro.DataWriteBuilder builder) { - builder.createWriterFunc(ignore -> new FlinkAvroWriter(dataFlinkType())); - } - - @Override - protected void configureEqualityDelete(Avro.DeleteWriteBuilder builder) { - builder.createWriterFunc(ignored -> new FlinkAvroWriter(equalityDeleteFlinkType())); - } - - @Override - protected void configurePositionDelete(Avro.DeleteWriteBuilder builder) {} - - @Override - protected void configureDataWrite(Parquet.DataWriteBuilder builder) { - builder.createWriterFunc(msgType -> FlinkParquetWriters.buildWriter(dataFlinkType(), msgType)); - } - - @Override - protected void configureEqualityDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> FlinkParquetWriters.buildWriter(equalityDeleteFlinkType(), msgType)); - } - - @Override - protected void configurePositionDelete(Parquet.DeleteWriteBuilder builder) { - builder.transformPaths(path -> StringData.fromString(path.toString())); - } - - @Override - protected void configureDataWrite(ORC.DataWriteBuilder builder) { - builder.createWriterFunc( - (iSchema, typDesc) -> FlinkOrcWriter.buildWriter(dataFlinkType(), iSchema)); - } - - @Override - protected void configureEqualityDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc( - (iSchema, typDesc) -> FlinkOrcWriter.buildWriter(equalityDeleteFlinkType(), iSchema)); + writeProperties, + dataFlinkType == null ? FlinkSchemaUtil.convert(dataSchema) : dataFlinkType, + equalityDeleteInputSchema(equalityDeleteFlinkType, equalityDeleteRowSchema)); } - @Override - protected void configurePositionDelete(ORC.DeleteWriteBuilder builder) { - builder.transformPaths(path -> StringData.fromString(path.toString())); - } - - private RowType dataFlinkType() { - if (dataFlinkType == null) { - Preconditions.checkNotNull(dataSchema(), "Data schema must not be null"); - this.dataFlinkType = FlinkSchemaUtil.convert(dataSchema()); + private static RowType equalityDeleteInputSchema(RowType rowType, Schema rowSchema) { + if (rowType != null) { + return rowType; + } else if (rowSchema != null) { + return FlinkSchemaUtil.convert(rowSchema); + } else { + return null; } - - return dataFlinkType; } - private RowType equalityDeleteFlinkType() { - if (equalityDeleteFlinkType == null) { - Preconditions.checkNotNull( - equalityDeleteRowSchema(), "Equality delete schema must not be null"); - this.equalityDeleteFlinkType = FlinkSchemaUtil.convert(equalityDeleteRowSchema()); - } - - return equalityDeleteFlinkType; + static Builder builderFor(Table table) { + return new Builder(table); } public static class Builder { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java index d83a11d0f462..86194b407135 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java @@ -159,7 +159,11 @@ public static class Builder implements IcebergSinkBuilder { private Function> inputCreator = null; private TableLoader tableLoader; private Table table; - @Deprecated private TableSchema tableSchema; + + @SuppressWarnings("deprecation") + @Deprecated + private TableSchema tableSchema; + private ResolvedSchema resolvedSchema; private List equalityFieldColumns = null; private String uidPrefix = null; @@ -240,6 +244,7 @@ public Builder setAll(Map properties) { return this; } + @SuppressWarnings("deprecation") @Override public Builder tableSchema(TableSchema newTableSchema) { this.tableSchema = newTableSchema; @@ -727,9 +732,8 @@ private DataStream distributeDataStream( @Deprecated static RowType toFlinkRowType(Schema schema, TableSchema requestedSchema) { if (requestedSchema != null) { - // Convert the flink schema to iceberg schema firstly, then reassign ids to match the existing - // iceberg schema. - Schema writeSchema = TypeUtil.reassignIds(FlinkSchemaUtil.convert(requestedSchema), schema); + // Convert the flink schema to iceberg schema using the table schema as the reference. + Schema writeSchema = FlinkSchemaUtil.convert(schema, requestedSchema); TypeUtil.validateWriteSchema(schema, writeSchema, true, true); // We use this flink schema to read values from RowData. The flink's TINYINT and SMALLINT will @@ -744,9 +748,8 @@ static RowType toFlinkRowType(Schema schema, TableSchema requestedSchema) { static RowType toFlinkRowType(Schema schema, ResolvedSchema requestedSchema) { if (requestedSchema != null) { - // Convert the flink schema to iceberg schema firstly, then reassign ids to match the existing - // iceberg schema. - Schema writeSchema = TypeUtil.reassignIds(FlinkSchemaUtil.convert(requestedSchema), schema); + // Convert the flink schema to iceberg schema using the table schema as the reference. + Schema writeSchema = FlinkSchemaUtil.convert(schema, requestedSchema); TypeUtil.validateWriteSchema(schema, writeSchema, true, true); // We use this flink schema to read values from RowData. The flink's TINYINT and SMALLINT will diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java index c05e7d918093..8e45a2db30b2 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java @@ -78,7 +78,7 @@ class IcebergCommitter implements Committer { private int maxContinuousEmptyCommits; private ExecutorService workerPool; private int continuousEmptyCheckpoints = 0; - private boolean compactMode = false; + private final boolean tableMaintenanceEnabled; IcebergCommitter( TableLoader tableLoader, @@ -88,7 +88,7 @@ class IcebergCommitter implements Committer { int workerPoolSize, String sinkId, IcebergFilesCommitterMetrics committerMetrics, - boolean compactMode) { + boolean tableMaintenanceEnabled) { this.branch = branch; this.snapshotProperties = snapshotProperties; this.replacePartitions = replacePartitions; @@ -107,7 +107,7 @@ class IcebergCommitter implements Committer { ThreadPools.newFixedThreadPool( "iceberg-committer-pool-" + table.name() + "-" + sinkId, workerPoolSize); this.continuousEmptyCheckpoints = 0; - this.compactMode = compactMode; + this.tableMaintenanceEnabled = tableMaintenanceEnabled; } @Override @@ -177,7 +177,7 @@ private void commitPendingRequests( committerMetrics.updateCommitSummary(summary); } - if (!compactMode) { + if (!tableMaintenanceEnabled) { FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java index 593230262fde..440fdb278be8 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java @@ -29,6 +29,7 @@ import java.util.Set; import java.util.UUID; import java.util.function.Function; +import org.apache.commons.lang3.StringUtils; import org.apache.flink.annotation.Experimental; import org.apache.flink.api.common.SupportsConcurrentExecutionAttempts; import org.apache.flink.api.common.functions.FlatMapFunction; @@ -72,12 +73,16 @@ import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.TableLoader; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFiles; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshots; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; import org.apache.iceberg.flink.maintenance.api.FlinkMaintenanceConfig; import org.apache.iceberg.flink.maintenance.api.LockConfig; +import org.apache.iceberg.flink.maintenance.api.MaintenanceTaskBuilder; import org.apache.iceberg.flink.maintenance.api.RewriteDataFiles; import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; import org.apache.iceberg.flink.maintenance.api.TableMaintenance; -import org.apache.iceberg.flink.maintenance.api.TriggerLockFactory; import org.apache.iceberg.flink.maintenance.operator.LockFactoryBuilder; import org.apache.iceberg.flink.maintenance.operator.TableChange; import org.apache.iceberg.flink.sink.shuffle.DataStatisticsOperatorFactory; @@ -88,7 +93,9 @@ import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.SerializableSupplier; import org.slf4j.Logger; @@ -158,11 +165,14 @@ public class IcebergSink private final String branch; private final boolean overwriteMode; private final int workerPoolSize; - private final boolean compactMode; - private final transient FlinkMaintenanceConfig flinkMaintenanceConfig; - + private final boolean maintenanceEnabled; private final Table table; - private final Set equalityFieldColumns = null; + // This should only be used for logging/error messages. For any actual logic always use + // equalityFieldIds instead. + private final Set equalityFieldColumns; + + private final transient List> maintenanceTasks; + private final transient FlinkMaintenanceConfig flinkMaintenanceConfig; private IcebergSink( TableLoader tableLoader, @@ -176,7 +186,9 @@ private IcebergSink( Set equalityFieldIds, String branch, boolean overwriteMode, - FlinkMaintenanceConfig flinkMaintenanceConfig) { + List> maintenanceTasks, + FlinkMaintenanceConfig flinkMaintenanceConfig, + Set equalityFieldColumns) { this.tableLoader = tableLoader; this.snapshotProperties = snapshotProperties; this.uidSuffix = uidSuffix; @@ -196,8 +208,10 @@ private IcebergSink( // This is used to separate files generated by different sinks writing the same table. // Also used to generate the aggregator operator name this.sinkId = UUID.randomUUID().toString(); - this.compactMode = flinkWriteConf.compactMode(); + this.maintenanceEnabled = !maintenanceTasks.isEmpty(); + this.maintenanceTasks = maintenanceTasks; this.flinkMaintenanceConfig = flinkMaintenanceConfig; + this.equalityFieldColumns = equalityFieldColumns; } @Override @@ -233,7 +247,7 @@ public Committer createCommitter(CommitterInitContext contex workerPoolSize, sinkId, metrics, - compactMode); + maintenanceEnabled); } @Override @@ -245,7 +259,7 @@ public SimpleVersionedSerializer getCommittableSerializer() public void addPostCommitTopology( DataStream> committables) { - if (!compactMode) { + if (maintenanceTasks.isEmpty()) { return; } @@ -259,25 +273,31 @@ public void addPostCommitTopology( .uid(postCommitUid) .forceNonParallel(); try { - RewriteDataFilesConfig rewriteDataFilesConfig = - flinkMaintenanceConfig.createRewriteDataFilesConfig(); - RewriteDataFiles.Builder rewriteBuilder = - RewriteDataFiles.builder().config(rewriteDataFilesConfig); - LockConfig lockConfig = flinkMaintenanceConfig.createLockConfig(); - TriggerLockFactory triggerLockFactory = LockFactoryBuilder.build(lockConfig, table.name()); String tableMaintenanceUid = String.format("TableMaintenance : %s", suffix); - TableMaintenance.Builder builder = - TableMaintenance.forChangeStream(tableChangeStream, tableLoader, triggerLockFactory) - .uidSuffix(tableMaintenanceUid) - .add(rewriteBuilder); + + TableMaintenance.Builder builder; + if (StringUtils.isNotEmpty(lockConfig.lockType())) { + builder = + TableMaintenance.forChangeStream( + tableChangeStream, tableLoader, LockFactoryBuilder.build(lockConfig, table.name())); + } else { + builder = TableMaintenance.forChangeStream(tableChangeStream, tableLoader); + } builder + .uidSuffix(tableMaintenanceUid) + .add(maintenanceTasks) .rateLimit(Duration.ofSeconds(flinkMaintenanceConfig.rateLimit())) .lockCheckDelay(Duration.ofSeconds(flinkMaintenanceConfig.lockCheckDelay())) - .slotSharingGroup(flinkMaintenanceConfig.slotSharingGroup()) - .parallelism(flinkMaintenanceConfig.parallelism()) - .append(); + .parallelism(flinkMaintenanceConfig.parallelism()); + + String slotSharingGroup = flinkMaintenanceConfig.slotSharingGroup(); + if (slotSharingGroup != null) { + builder.slotSharingGroup(slotSharingGroup); + } + + builder.append(); } catch (IOException e) { throw new UncheckedIOException("Failed to create tableMaintenance ", e); } @@ -322,13 +342,18 @@ public SimpleVersionedSerializer getWriteResultSerializer() { public static class Builder implements IcebergSinkBuilder { private TableLoader tableLoader; private Function> inputCreator = null; - @Deprecated private TableSchema tableSchema; + + @SuppressWarnings("deprecation") + @Deprecated + private TableSchema tableSchema; + private ResolvedSchema resolvedSchema; private SerializableTable table; private final Map writeOptions = Maps.newHashMap(); private final Map snapshotSummary = Maps.newHashMap(); private ReadableConfig readableConfig = new Configuration(); private List equalityFieldColumns = null; + private final List> maintenanceTasks = Lists.newArrayList(); private Builder() {} @@ -437,6 +462,7 @@ public Builder setAll(Map properties) { return this; } + @SuppressWarnings("deprecation") @Override public Builder tableSchema(TableSchema newTableSchema) { this.tableSchema = newTableSchema; @@ -610,6 +636,85 @@ public Builder setSnapshotProperty(String property, String value) { return this; } + /** + * Enables or disables compaction (rewriting data files) as a post-commit maintenance task. + * + * @param enabled whether to enable compaction + * @see RewriteDataFilesConfig for the default config. + * @deprecated See {@code rewriteDatafiles(..)} + */ + @Deprecated + public Builder compaction(boolean enabled) { + writeOptions.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), Boolean.toString(enabled)); + return this; + } + + /** + * Enables rewriting data files (compaction) as a post-commit maintenance task. + * + * @see RewriteDataFilesConfig for the default config. + */ + public Builder rewriteDataFiles() { + writeOptions.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); + return this; + } + + /** + * Enables rewriting data files (compaction) as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link RewriteDataFilesConfig} for available + * keys + */ + public Builder rewriteDataFiles(Map config) { + rewriteDataFiles(); + writeOptions.putAll(config); + return this; + } + + /** + * Enables expire snapshots as a post-commit maintenance task. + * + * @see ExpireSnapshotsConfig for the default config. + */ + public Builder expireSnapshots() { + writeOptions.put(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key(), "true"); + return this; + } + + /** + * Enables or disables expire snapshots as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link ExpireSnapshotsConfig} for available + * keys + */ + public Builder expireSnapshots(Map config) { + expireSnapshots(); + writeOptions.putAll(config); + return this; + } + + /** + * Enables delete orphan files as a post-commit maintenance task. + * + * @see DeleteOrphanFilesConfig for the default config. + */ + public Builder deleteOrphanFiles() { + writeOptions.put(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key(), "true"); + return this; + } + + /** + * Enables delete orphan files as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link DeleteOrphanFilesConfig} for available + * keys. + */ + public Builder deleteOrphanFiles(Map config) { + deleteOrphanFiles(); + writeOptions.putAll(config); + return this; + } + @Override public Builder toBranch(String branch) { writeOptions.put(FlinkWriteOptions.BRANCH.key(), branch); @@ -666,6 +771,30 @@ IcebergSink build() { FlinkMaintenanceConfig flinkMaintenanceConfig = new FlinkMaintenanceConfig(table, writeOptions, readableConfig); + if (flinkWriteConf.compactMode()) { + RewriteDataFilesConfig rewriteDataFilesConfig = + flinkMaintenanceConfig.createRewriteDataFilesConfig(); + maintenanceTasks.add( + RewriteDataFiles.builder() + .branch(flinkWriteConf.branch()) + .config(rewriteDataFilesConfig)); + } + + if (flinkWriteConf.expireSnapshotsMode()) { + ExpireSnapshotsConfig expireSnapshotsConfig = + flinkMaintenanceConfig.createExpireSnapshotsConfig(); + maintenanceTasks.add(ExpireSnapshots.builder().config(expireSnapshotsConfig)); + } + + if (flinkWriteConf.deleteOrphanFilesMode()) { + DeleteOrphanFilesConfig deleteOrphanFilesConfig = + flinkMaintenanceConfig.createDeleteOrphanFilesConfig(); + maintenanceTasks.add(DeleteOrphanFiles.builder().config(deleteOrphanFilesConfig)); + } + + Set equalityFieldColumnsSet = + equalityFieldColumns != null ? Sets.newHashSet(equalityFieldColumns) : null; + return new IcebergSink( tableLoader, table, @@ -680,7 +809,9 @@ IcebergSink build() { equalityFieldIds, flinkWriteConf.branch(), overwriteMode, - flinkMaintenanceConfig); + maintenanceTasks, + flinkMaintenanceConfig, + equalityFieldColumnsSet); } /** diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java index 434f3969577f..6cf15ff713fb 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java @@ -18,23 +18,33 @@ */ package org.apache.iceberg.flink.sink; -import com.codahale.metrics.SlidingWindowReservoir; import java.util.Arrays; import java.util.concurrent.atomic.AtomicLong; import org.apache.flink.annotation.Internal; -import org.apache.flink.dropwizard.metrics.DropwizardHistogramWrapper; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.metrics.Counter; import org.apache.flink.metrics.Histogram; import org.apache.flink.metrics.MetricGroup; +import org.apache.iceberg.common.DynClasses; +import org.apache.iceberg.common.DynConstructors; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.util.ScanTaskUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Internal public class IcebergStreamWriterMetrics { + + private static final Logger LOG = LoggerFactory.getLogger(IcebergStreamWriterMetrics.class); + // 1,024 reservoir size should cost about 8KB, which is quite small. // It should also produce good accuracy for histogram distribution (like percentiles). private static final int HISTOGRAM_RESERVOIR_SIZE = 1024; + // Histogram metrics loaded through Flink's optional flink-metrics-dropwizard dependency. + // Will be null if not available. + private static final DropwizardCtors DROPWIZARD = loadDropwizardCtors(); + private final Counter flushedDataFiles; private final Counter flushedDeleteFiles; private final Counter flushedReferencedDataFiles; @@ -51,18 +61,8 @@ public IcebergStreamWriterMetrics(MetricGroup metrics, String fullTableName) { this.lastFlushDurationMs = new AtomicLong(); writerMetrics.gauge("lastFlushDurationMs", lastFlushDurationMs::get); - com.codahale.metrics.Histogram dropwizardDataFilesSizeHistogram = - new com.codahale.metrics.Histogram(new SlidingWindowReservoir(HISTOGRAM_RESERVOIR_SIZE)); - this.dataFilesSizeHistogram = - writerMetrics.histogram( - "dataFilesSizeHistogram", - new DropwizardHistogramWrapper(dropwizardDataFilesSizeHistogram)); - com.codahale.metrics.Histogram dropwizardDeleteFilesSizeHistogram = - new com.codahale.metrics.Histogram(new SlidingWindowReservoir(HISTOGRAM_RESERVOIR_SIZE)); - this.deleteFilesSizeHistogram = - writerMetrics.histogram( - "deleteFilesSizeHistogram", - new DropwizardHistogramWrapper(dropwizardDeleteFilesSizeHistogram)); + this.dataFilesSizeHistogram = registerHistogram(writerMetrics, "dataFilesSizeHistogram"); + this.deleteFilesSizeHistogram = registerHistogram(writerMetrics, "deleteFilesSizeHistogram"); } public void updateFlushResult(WriteResult result) { @@ -74,16 +74,21 @@ public void updateFlushResult(WriteResult result) { // This should works equally well and we avoided the overhead of tracking the list of file sizes // in the {@link CommitSummary}, which currently stores simple stats for counters and gauges // metrics. - Arrays.stream(result.dataFiles()) - .forEach( - dataFile -> { - dataFilesSizeHistogram.update(dataFile.fileSizeInBytes()); - }); - Arrays.stream(result.deleteFiles()) - .forEach( - deleteFile -> { - deleteFilesSizeHistogram.update(ScanTaskUtil.contentSizeInBytes(deleteFile)); - }); + if (dataFilesSizeHistogram != null) { + Arrays.stream(result.dataFiles()) + .forEach( + dataFile -> { + dataFilesSizeHistogram.update(dataFile.fileSizeInBytes()); + }); + } + + if (deleteFilesSizeHistogram != null) { + Arrays.stream(result.deleteFiles()) + .forEach( + deleteFile -> { + deleteFilesSizeHistogram.update(ScanTaskUtil.contentSizeInBytes(deleteFile)); + }); + } } public void flushDuration(long flushDurationMs) { @@ -97,4 +102,60 @@ public Counter getFlushedDataFiles() { public Counter getFlushedDeleteFiles() { return flushedDeleteFiles; } + + @VisibleForTesting + Histogram dataFilesSizeHistogram() { + return dataFilesSizeHistogram; + } + + @VisibleForTesting + Histogram deleteFilesSizeHistogram() { + return deleteFilesSizeHistogram; + } + + private static Histogram registerHistogram(MetricGroup group, String name) { + Histogram histogram = newDropwizardHistogram(); + return histogram != null ? group.histogram(name, histogram) : null; + } + + private static Histogram newDropwizardHistogram() { + if (DROPWIZARD == null) { + return null; + } + + Object reservoir = DROPWIZARD.reservoirCtor.newInstance(HISTOGRAM_RESERVOIR_SIZE); + Object codahaleHistogram = DROPWIZARD.histogramCtor.newInstance(reservoir); + return DROPWIZARD.wrapperCtor.newInstance(codahaleHistogram); + } + + private static DropwizardCtors loadDropwizardCtors() { + try { + Class reservoirInterface = + DynClasses.builder().impl("com.codahale.metrics.Reservoir").buildChecked(); + Class codahaleHistogramClass = + DynClasses.builder().impl("com.codahale.metrics.Histogram").buildChecked(); + return new DropwizardCtors( + DynConstructors.builder() + .impl("com.codahale.metrics.SlidingWindowReservoir", int.class) + .buildChecked(), + DynConstructors.builder() + .impl("com.codahale.metrics.Histogram", reservoirInterface) + .buildChecked(), + DynConstructors.builder(Histogram.class) + .impl( + "org.apache.flink.dropwizard.metrics.DropwizardHistogramWrapper", + codahaleHistogramClass) + .buildChecked()); + } catch (ClassNotFoundException | NoSuchMethodException e) { + LOG.warn( + "Cannot load Dropwizard metrics; is org.apache.flink:flink-metrics-dropwizard on the classpath?", + e); + return null; + } + } + + private record DropwizardCtors( + DynConstructors.Ctor reservoirCtor, + DynConstructors.Ctor histogramCtor, + DynConstructors.Ctor wrapperCtor) {} } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java index 41ffa609540b..cb4fc62c01b8 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.Map; +import javax.annotation.Nullable; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.iceberg.Schema; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.schema.SchemaWithPartnerVisitor; @@ -43,23 +45,31 @@ public class CompareSchemasVisitor extends SchemaWithPartnerVisitor { private final Schema tableSchema; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; - private CompareSchemasVisitor(Schema tableSchema) { + private CompareSchemasVisitor( + Schema tableSchema, boolean caseSensitive, boolean dropUnusedColumns) { this.tableSchema = tableSchema; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } - public static Result visit(Schema dataSchema, Schema tableSchema) { - return visit(dataSchema, tableSchema, true); - } - - public static Result visit(Schema dataSchema, Schema tableSchema, boolean caseSensitive) { + public static Result visit( + Schema dataSchema, Schema tableSchema, boolean caseSensitive, boolean dropUnusedColumns) { return visit( dataSchema, -1, - new CompareSchemasVisitor(tableSchema), + new CompareSchemasVisitor(tableSchema, caseSensitive, dropUnusedColumns), new PartnerIdByNameAccessors(tableSchema, caseSensitive)); } + @VisibleForTesting + @Deprecated + public static Result visit(Schema dataSchema, Schema tableSchema) { + return visit(dataSchema, tableSchema, true, false); + } + @Override public Result schema(Schema dataSchema, Integer tableSchemaId, Result downstream) { if (tableSchemaId == null) { @@ -70,6 +80,7 @@ public Result schema(Schema dataSchema, Integer tableSchemaId, Result downstream } @Override + @SuppressWarnings("CyclomaticComplexity") public Result struct(Types.StructType struct, Integer tableSchemaId, List fields) { if (tableSchemaId == null) { return Result.SCHEMA_UPDATE_NEEDED; @@ -88,10 +99,10 @@ public Result struct(Types.StructType struct, Integer tableSchemaId, List { private final Schema tableSchema; - private boolean caseSensitive = true; + private boolean caseSensitive; - PartnerIdByNameAccessors(Schema tableSchema) { + PartnerIdByNameAccessors(Schema tableSchema, boolean caseSensitive) { this.tableSchema = tableSchema; - } - - private PartnerIdByNameAccessors(Schema tableSchema, boolean caseSensitive) { - this(tableSchema); this.caseSensitive = caseSensitive; } @@ -207,8 +219,7 @@ public Integer fieldPartner(Integer tableSchemaFieldId, int fieldId, String name struct = tableSchema.findField(tableSchemaFieldId).type().asStructType(); } - Types.NestedField field = - caseSensitive ? struct.field(name) : struct.caseInsensitiveField(name); + Types.NestedField field = getFieldFromStruct(name, struct, caseSensitive); if (field != null) { return field.fieldId(); } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java index 33edefe71eb0..4f0b68573ff5 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java @@ -26,7 +26,7 @@ /** * The aggregated results of a single checkpoint which should be committed. Containing the - * serialized {@link DeltaManifests} file - which contains the commit data, and the jobId, + * serialized {@link DeltaManifests} files - which contains the commit data, and the jobId, * operatorId, checkpointId triplet to identify the specific commit. * *

    {@link DynamicCommittableSerializer} is used to serialize {@link DynamicCommittable} between @@ -34,27 +34,27 @@ */ class DynamicCommittable implements Serializable { - private final WriteTarget key; - private final byte[] manifest; + private final TableKey key; + private final byte[][] manifests; private final String jobId; private final String operatorId; private final long checkpointId; DynamicCommittable( - WriteTarget key, byte[] manifest, String jobId, String operatorId, long checkpointId) { + TableKey key, byte[][] manifests, String jobId, String operatorId, long checkpointId) { this.key = key; - this.manifest = manifest; + this.manifests = manifests; this.jobId = jobId; this.operatorId = operatorId; this.checkpointId = checkpointId; } - WriteTarget key() { + TableKey key() { return key; } - byte[] manifest() { - return manifest; + byte[][] manifests() { + return manifests; } String jobId() { @@ -78,14 +78,14 @@ public boolean equals(Object o) { DynamicCommittable that = (DynamicCommittable) o; return checkpointId == that.checkpointId && Objects.equals(key, that.key) - && Objects.deepEquals(manifest, that.manifest) + && Arrays.deepEquals(manifests, that.manifests) && Objects.equals(jobId, that.jobId) && Objects.equals(operatorId, that.operatorId); } @Override public int hashCode() { - return Objects.hash(key, Arrays.hashCode(manifest), jobId, operatorId, checkpointId); + return Objects.hash(key, Arrays.deepHashCode(manifests), jobId, operatorId, checkpointId); } @Override @@ -97,8 +97,4 @@ public String toString() { .add("operatorId", operatorId) .toString(); } - - public WriteTarget writeTarget() { - return key; - } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java index 4aadcf1f3620..d599d29dba01 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java @@ -31,11 +31,12 @@ */ class DynamicCommittableSerializer implements SimpleVersionedSerializer { - private static final int VERSION = 1; + private static final int VERSION_1 = 1; + private static final int VERSION_2 = 2; @Override public int getVersion() { - return VERSION; + return VERSION_2; } @Override @@ -46,26 +47,60 @@ public byte[] serialize(DynamicCommittable committable) throws IOException { view.writeUTF(committable.jobId()); view.writeUTF(committable.operatorId()); view.writeLong(committable.checkpointId()); - view.writeInt(committable.manifest().length); - view.write(committable.manifest()); + + int numManifests = committable.manifests().length; + view.writeInt(numManifests); + for (int i = 0; i < numManifests; i++) { + byte[] manifest = committable.manifests()[i]; + view.writeInt(manifest.length); + view.write(manifest); + } + return out.toByteArray(); } @Override public DynamicCommittable deserialize(int version, byte[] serialized) throws IOException { - if (version == 1) { - DataInputDeserializer view = new DataInputDeserializer(serialized); - WriteTarget key = WriteTarget.deserializeFrom(view); - String jobId = view.readUTF(); - String operatorId = view.readUTF(); - long checkpointId = view.readLong(); - int manifestLen = view.readInt(); - byte[] manifestBuf; - manifestBuf = new byte[manifestLen]; - view.read(manifestBuf); - return new DynamicCommittable(key, manifestBuf, jobId, operatorId, checkpointId); + if (version == VERSION_1) { + return deserializeV1(serialized); + } else if (version == VERSION_2) { + return deserializeV2(serialized); } throw new IOException("Unrecognized version or corrupt state: " + version); } + + private DynamicCommittable deserializeV1(byte[] serialized) throws IOException { + DataInputDeserializer view = new DataInputDeserializer(serialized); + WriteTarget key = WriteTarget.deserializeFrom(view); + String jobId = view.readUTF(); + String operatorId = view.readUTF(); + long checkpointId = view.readLong(); + int manifestLen = view.readInt(); + byte[] manifestBuf = new byte[manifestLen]; + view.read(manifestBuf); + return new DynamicCommittable( + new TableKey(key.tableName(), key.branch()), + new byte[][] {manifestBuf}, + jobId, + operatorId, + checkpointId); + } + + private DynamicCommittable deserializeV2(byte[] serialized) throws IOException { + DataInputDeserializer view = new DataInputDeserializer(serialized); + TableKey key = TableKey.deserializeFrom(view); + String jobId = view.readUTF(); + String operatorId = view.readUTF(); + long checkpointId = view.readLong(); + + byte[][] manifestsBuf = new byte[view.readInt()][]; + for (int i = 0; i < manifestsBuf.length; i++) { + byte[] manifest = new byte[view.readInt()]; + view.read(manifest); + manifestsBuf[i] = manifest; + } + + return new DynamicCommittable(key, manifestsBuf, jobId, operatorId, checkpointId); + } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java index 1cddc64d6016..5e824773f4bf 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java @@ -19,15 +19,15 @@ package org.apache.iceberg.flink.sink.dynamic; import java.io.IOException; -import java.io.Serializable; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.NavigableMap; -import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import org.apache.flink.annotation.Internal; import org.apache.flink.api.connector.sink2.Committer; import org.apache.flink.core.io.SimpleVersionedSerialization; @@ -51,12 +51,10 @@ import org.apache.iceberg.flink.sink.FlinkManifestUtil; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; -import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.ContentFileUtil; -import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.util.SnapshotUtil; import org.apache.iceberg.util.ThreadPools; import org.slf4j.Logger; @@ -79,26 +77,15 @@ class DynamicCommitter implements Committer { private static final String MAX_COMMITTED_CHECKPOINT_ID = "flink.max-committed-checkpoint-id"; private static final Logger LOG = LoggerFactory.getLogger(DynamicCommitter.class); - private static final byte[] EMPTY_MANIFEST_DATA = new byte[0]; - private static final WriteResult EMPTY_WRITE_RESULT = - WriteResult.builder() - .addDataFiles(Lists.newArrayList()) - .addDeleteFiles(Lists.newArrayList()) - .build(); private static final long INITIAL_CHECKPOINT_ID = -1L; - @VisibleForTesting - static final String MAX_CONTINUOUS_EMPTY_COMMITS = "flink.max-continuous-empty-commits"; - private static final String FLINK_JOB_ID = "flink.job-id"; private static final String OPERATOR_ID = "flink.operator-id"; private final Map snapshotProperties; private final boolean replacePartitions; private final DynamicCommitterMetrics committerMetrics; private final Catalog catalog; - private final Map maxContinuousEmptyCommitsMap; - private final Map continuousEmptyCheckpointsMap; private final ExecutorService workerPool; DynamicCommitter( @@ -112,9 +99,6 @@ class DynamicCommitter implements Committer { this.replacePartitions = replacePartitions; this.committerMetrics = committerMetrics; this.catalog = catalog; - this.maxContinuousEmptyCommitsMap = Maps.newHashMap(); - this.continuousEmptyCheckpointsMap = Maps.newHashMap(); - this.workerPool = ThreadPools.newFixedThreadPool("iceberg-committer-pool-" + sinkId, workerPoolSize); } @@ -126,9 +110,15 @@ public void commit(Collection> commitRequests) return; } - // For every table and every checkpoint, we store the list of to-be-committed - // DynamicCommittable. - // There may be DynamicCommittable from previous checkpoints which have not been committed yet. + /* + Each (table, branch, checkpoint) triplet must have only one commit request. + There may be commit requests from previous checkpoints which have not been committed yet. + + We currently keep a List of commit requests per checkpoint instead of a single CommitRequest + to process the Flink state from previous releases, which had multiple commit requests created by the upstream + DynamicWriteResultAggregator. Iceberg 1.12 will remove this, and users should upgrade to the 1.11 release first + to migrate their state to a single commit request per checkpoint. + */ Map>>> commitRequestMap = Maps.newHashMap(); for (CommitRequest request : commitRequests) { @@ -151,12 +141,16 @@ public void commit(Collection> commitRequests) : List.of(); long maxCommittedCheckpointId = getMaxCommittedCheckpointId(ancestors, last.jobId(), last.operatorId()); + + NavigableMap>> skippedCommitRequests = + entry.getValue().headMap(maxCommittedCheckpointId, true); + LOG.debug( + "Skipping {} commit requests: {}", skippedCommitRequests.size(), skippedCommitRequests); // Mark the already committed FilesCommittable(s) as finished - entry - .getValue() - .headMap(maxCommittedCheckpointId, true) + skippedCommitRequests .values() .forEach(list -> list.forEach(CommitRequest::signalAlreadyCommitted)); + NavigableMap>> uncommitted = entry.getValue().tailMap(maxCommittedCheckpointId, false); if (!uncommitted.isEmpty()) { @@ -210,89 +204,45 @@ private void commitPendingRequests( NavigableMap> pendingResults = Maps.newTreeMap(); for (Map.Entry>> e : commitRequestMap.entrySet()) { for (CommitRequest committable : e.getValue()) { - if (Arrays.equals(EMPTY_MANIFEST_DATA, committable.getCommittable().manifest())) { - pendingResults - .computeIfAbsent(e.getKey(), unused -> Lists.newArrayList()) - .add(EMPTY_WRITE_RESULT); - } else { + for (byte[] manifest : committable.getCommittable().manifests()) { DeltaManifests deltaManifests = SimpleVersionedSerialization.readVersionAndDeSerialize( - DeltaManifestsSerializer.INSTANCE, committable.getCommittable().manifest()); - - WriteResult writeResult = - FlinkManifestUtil.readCompletedFiles(deltaManifests, table.io(), table.specs()); - if (TableUtil.formatVersion(table) > 2) { - for (DeleteFile deleteFile : writeResult.deleteFiles()) { - if (deleteFile.content() == FileContent.POSITION_DELETES) { - Preconditions.checkArgument( - ContentFileUtil.isDV(deleteFile), - "Can't add position delete file to the %s table. Concurrent table upgrade to V3 is not supported.", - table.name()); - } - } - } - + DeltaManifestsSerializer.INSTANCE, manifest); pendingResults .computeIfAbsent(e.getKey(), unused -> Lists.newArrayList()) - .add(writeResult); + .add(FlinkManifestUtil.readCompletedFiles(deltaManifests, table.io(), table.specs())); manifests.addAll(deltaManifests.manifests()); } } } - CommitSummary summary = new CommitSummary(); - summary.addAll(pendingResults); - commitPendingResult(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - if (committerMetrics != null) { - committerMetrics.updateCommitSummary(table.name(), summary); + if (TableUtil.formatVersion(table) > 2) { + Optional positionalDelete = + pendingResults.values().stream() + .flatMap(List::stream) + .flatMap(writeResult -> Arrays.stream(writeResult.deleteFiles())) + .filter(deleteFile -> deleteFile.content() == FileContent.POSITION_DELETES) + .filter(Predicate.not(ContentFileUtil::isDV)) + .findAny(); + Preconditions.checkArgument( + positionalDelete.isEmpty(), + "Can't add position delete file to the %s table. Concurrent table upgrade to V3 is not supported.", + table.name()); } - FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); - } - - private void commitPendingResult( - Table table, - String branch, - NavigableMap> pendingResults, - CommitSummary summary, - String newFlinkJobId, - String operatorId) { - long totalFiles = summary.dataFilesCount() + summary.deleteFilesCount(); - TableKey key = new TableKey(table.name(), branch); - int continuousEmptyCheckpoints = - continuousEmptyCheckpointsMap.computeIfAbsent(key, unused -> 0); - int maxContinuousEmptyCommits = - maxContinuousEmptyCommitsMap.computeIfAbsent( - key, - unused -> { - int result = - PropertyUtil.propertyAsInt(table.properties(), MAX_CONTINUOUS_EMPTY_COMMITS, 10); - Preconditions.checkArgument( - result > 0, MAX_CONTINUOUS_EMPTY_COMMITS + " must be positive"); - return result; - }); - continuousEmptyCheckpoints = totalFiles == 0 ? continuousEmptyCheckpoints + 1 : 0; - if (totalFiles != 0 || continuousEmptyCheckpoints % maxContinuousEmptyCommits == 0) { - if (replacePartitions) { - replacePartitions(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - } else { - commitDeltaTxn(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - } - - continuousEmptyCheckpoints = 0; + if (replacePartitions) { + replacePartitions(table, branch, pendingResults, newFlinkJobId, operatorId); } else { - long checkpointId = pendingResults.lastKey(); - LOG.info("Skip commit for checkpoint {} due to no data files or delete files.", checkpointId); + commitDeltaTxn(table, branch, pendingResults, newFlinkJobId, operatorId); } - continuousEmptyCheckpointsMap.put(key, continuousEmptyCheckpoints); + FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); } private void replacePartitions( Table table, String branch, NavigableMap> pendingResults, - CommitSummary summary, String newFlinkJobId, String operatorId) { // Iceberg tables are unsorted. So the order of the append data does not matter. @@ -305,6 +255,9 @@ private void replacePartitions( } } + CommitSummary summary = new CommitSummary(); + summary.addAll(pendingResults); + commitOperation( table, branch, @@ -320,7 +273,6 @@ private void commitDeltaTxn( Table table, String branch, NavigableMap> pendingResults, - CommitSummary summary, String newFlinkJobId, String operatorId) { for (Map.Entry> e : pendingResults.entrySet()) { @@ -340,6 +292,9 @@ private void commitDeltaTxn( Arrays.stream(result.deleteFiles()).forEach(rowDelta::addDeletes); } + CommitSummary summary = new CommitSummary(); + summary.addAll(writeResults); + // Every Flink checkpoint contains a set of independent changes which can be committed // together. While it is technically feasible to combine append-only data across checkpoints, // for the sake of simplicity, we do not implement this (premature) optimization. Multiple @@ -434,6 +389,7 @@ void commitOperation( durationMs); if (committerMetrics != null) { committerMetrics.commitDuration(table.name(), durationMs); + committerMetrics.updateCommitSummary(table.name(), summary); } } @@ -441,54 +397,4 @@ void commitOperation( public void close() throws IOException { workerPool.shutdown(); } - - private static class TableKey implements Serializable { - private String tableName; - private String branch; - - TableKey(String tableName, String branch) { - this.tableName = tableName; - this.branch = branch; - } - - TableKey(DynamicCommittable committable) { - this.tableName = committable.key().tableName(); - this.branch = committable.key().branch(); - } - - String tableName() { - return tableName; - } - - String branch() { - return branch; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - - if (other == null || getClass() != other.getClass()) { - return false; - } - - TableKey that = (TableKey) other; - return tableName.equals(that.tableName) && branch.equals(that.branch); - } - - @Override - public int hashCode() { - return Objects.hash(tableName, branch); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("tableName", tableName) - .add("branch", branch) - .toString(); - } - } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java index cbc50a5839e8..ad430cbf13f8 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java @@ -44,15 +44,14 @@ import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.DataStreamSink; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; +import org.apache.flink.streaming.runtime.operators.sink.SinkWriterOperatorFactory; import org.apache.flink.table.data.RowData; import org.apache.flink.util.OutputTag; -import org.apache.iceberg.FileFormat; import org.apache.iceberg.Table; import org.apache.iceberg.flink.CatalogLoader; import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.sink.IcebergSink; -import org.apache.iceberg.flink.sink.SinkUtil; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -78,43 +77,39 @@ public class DynamicIcebergSink private final String uidPrefix; private final String sinkId; private final Map writeProperties; - private final transient FlinkWriteConf flinkWriteConf; - private final FileFormat dataFileFormat; - private final long targetDataFileSize; - private final boolean overwriteMode; - private final int workerPoolSize; + private final Configuration flinkConfig; private final int cacheMaximumSize; + // Set by the builder before sinkTo() — forward writer results to union into pre-commit topology + private final transient DataStream> forwardWriteResults; + DynamicIcebergSink( CatalogLoader catalogLoader, Map snapshotProperties, String uidPrefix, Map writeProperties, - FlinkWriteConf flinkWriteConf, - int cacheMaximumSize) { + Configuration flinkConfig, + int cacheMaximumSize, + DataStream> forwardWriteResults) { this.catalogLoader = catalogLoader; this.snapshotProperties = snapshotProperties; this.uidPrefix = uidPrefix; this.writeProperties = writeProperties; - this.flinkWriteConf = flinkWriteConf; - this.dataFileFormat = flinkWriteConf.dataFileFormat(); - this.targetDataFileSize = flinkWriteConf.targetDataFileSize(); - this.overwriteMode = flinkWriteConf.overwriteMode(); - this.workerPoolSize = flinkWriteConf.workerPoolSize(); + this.flinkConfig = flinkConfig; this.cacheMaximumSize = cacheMaximumSize; // We generate a random UUID every time when a sink is created. // This is used to separate files generated by different sinks writing the same table. // Also used to generate the aggregator operator name this.sinkId = UUID.randomUUID().toString(); + this.forwardWriteResults = forwardWriteResults; } @Override public SinkWriter createWriter(WriterInitContext context) { return new DynamicWriter( catalogLoader.loadCatalog(), - dataFileFormat, - targetDataFileSize, writeProperties, + flinkConfig, cacheMaximumSize, new DynamicWriterMetrics(context.metricGroup()), context.getTaskInfo().getIndexOfThisSubtask(), @@ -123,12 +118,13 @@ public SinkWriter createWriter(WriterInitContext context) @Override public Committer createCommitter(CommitterInitContext context) { + FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeProperties, flinkConfig); DynamicCommitterMetrics metrics = new DynamicCommitterMetrics(context.metricGroup()); return new DynamicCommitter( catalogLoader.loadCatalog(), snapshotProperties, - overwriteMode, - workerPoolSize, + flinkWriteConf.overwriteMode(), + flinkWriteConf.workerPoolSize(), sinkId, metrics); } @@ -154,7 +150,11 @@ public DataStream> addPreCommitTopology( TypeInformation> typeInformation = CommittableMessageTypeInfo.of(this::getCommittableSerializer); - return writeResults + // Union forward writer results with the shuffle writer results + DataStream> allResults = + writeResults.union(forwardWriteResults); + + return allResults .keyBy( committable -> { if (committable instanceof CommittableSummary) { @@ -169,7 +169,7 @@ public DataStream> addPreCommitTopology( prefixIfNotNull(uidPrefix, sinkId + " Pre Commit"), typeInformation, new DynamicWriteResultAggregator(catalogLoader, cacheMaximumSize)) - .uid(prefixIfNotNull(uidPrefix, sinkId + "-pre-commit-topology")); + .uid(prefixIfNotNull(uidPrefix, "-pre-commit-topology")); } @Override @@ -177,6 +177,55 @@ public SimpleVersionedSerializer getWriteResultSerializer() return new DynamicWriteResultSerializer(); } + /** + * A lightweight Sink used with {@link SinkWriterOperatorFactory} for the forward write path. + * Implements {@link SupportsCommitter} so that {@code SinkWriterOperator} emits committables + * downstream. The committer is never called — committing is handled by the main sink. + */ + @VisibleForTesting + static class ForwardWriterSink + implements Sink, SupportsCommitter { + + private final CatalogLoader catalogLoader; + private final Map writeProperties; + private final Configuration flinkConfig; + private final int cacheMaximumSize; + + ForwardWriterSink( + CatalogLoader catalogLoader, + Map writeProperties, + Configuration flinkConfig, + int cacheMaximumSize) { + this.catalogLoader = catalogLoader; + this.writeProperties = writeProperties; + this.flinkConfig = flinkConfig; + this.cacheMaximumSize = cacheMaximumSize; + } + + @Override + public SinkWriter createWriter(WriterInitContext context) { + return new DynamicWriter( + catalogLoader.loadCatalog(), + writeProperties, + flinkConfig, + cacheMaximumSize, + new DynamicWriterMetrics(context.metricGroup()), + context.getTaskInfo().getIndexOfThisSubtask(), + context.getTaskInfo().getAttemptNumber()); + } + + @Override + public Committer createCommitter(CommitterInitContext context) { + throw new UnsupportedOperationException( + "WriterSink is used only for writing; committing is handled by the main sink"); + } + + @Override + public SimpleVersionedSerializer getCommittableSerializer() { + return new DynamicWriteResultSerializer(); + } + } + public static class Builder { private DataStream input; private DynamicRecordGenerator generator; @@ -186,10 +235,6 @@ public static class Builder { private final Map snapshotSummary = Maps.newHashMap(); private ReadableConfig readableConfig = new Configuration(); private TableCreator tableCreator = TableCreator.DEFAULT; - private boolean immediateUpdate = false; - private int cacheMaximumSize = 100; - private long cacheRefreshMs = 1_000; - private int inputSchemasPerTableCacheMaximumSize = 10; Builder() {} @@ -310,19 +355,39 @@ public Builder toBranch(String branch) { } public Builder immediateTableUpdate(boolean newImmediateUpdate) { - this.immediateUpdate = newImmediateUpdate; + writeOptions.put( + FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.key(), + Boolean.toString(newImmediateUpdate)); + return this; + } + + /** + * Dropping columns is disabled by default to prevent issues with late or out-of-order data, as + * removed fields cannot be easily restored without data loss. + * + *

    You can opt-in to allow dropping columns. Once a column has been dropped, it is + * technically still possible to write data to that column because Iceberg maintains all past + * table schemas. However, regular queries won't be able to reference the column. If the field + * was to re-appear as part of a new schema, an entirely new column would be added, which apart + * from the name, has nothing in common with the old column, i.e. queries for the new column + * will never return data of the old column. + */ + public Builder dropUnusedColumns(boolean newDropUnusedColumns) { + writeOptions.put( + FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key(), + Boolean.toString(newDropUnusedColumns)); return this; } /** Maximum size of the caches used in Dynamic Sink for table data and serializers. */ public Builder cacheMaxSize(int maxSize) { - this.cacheMaximumSize = maxSize; + writeOptions.put(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.key(), Integer.toString(maxSize)); return this; } /** Maximum interval for cache items renewals. */ public Builder cacheRefreshMs(long refreshMs) { - this.cacheRefreshMs = refreshMs; + writeOptions.put(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.key(), Long.toString(refreshMs)); return this; } @@ -332,7 +397,19 @@ public Builder cacheRefreshMs(long refreshMs) { * comparison results. */ public Builder inputSchemasPerTableCacheMaxSize(int inputSchemasPerTableCacheMaxSize) { - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaxSize; + writeOptions.put( + FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.key(), + Integer.toString(inputSchemasPerTableCacheMaxSize)); + return this; + } + + /** + * Set whether schema field name matching should be case-sensitive. The default is to match the + * field names case-sensitive. + */ + public Builder caseSensitive(boolean newCaseSensitive) { + writeOptions.put( + FlinkDynamicSinkOptions.CASE_SENSITIVE.key(), Boolean.toString(newCaseSensitive)); return this; } @@ -340,81 +417,134 @@ private String operatorName(String suffix) { return uidPrefix != null ? uidPrefix + "-" + suffix : suffix; } - private DynamicIcebergSink build() { + private DynamicIcebergSink build( + SingleOutputStreamOperator converted, + DynamicRecordInternalType sideOutputType) { Preconditions.checkArgument( generator != null, "Please use withGenerator() to convert the input DataStream."); Preconditions.checkNotNull(catalogLoader, "Catalog loader shouldn't be null"); - FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeOptions, readableConfig); - Map writeProperties = - SinkUtil.writeProperties(flinkWriteConf.dataFileFormat(), flinkWriteConf, null); - uidPrefix = Optional.ofNullable(uidPrefix).orElse(""); + Configuration flinkConfig = fromReadableConfig(); + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeOptions, flinkConfig); + + // Forward writer: chained with generator via forward edge, no data shuffle + ForwardWriterSink forwardWriterSink = + new ForwardWriterSink( + catalogLoader, writeOptions, flinkConfig, flinkDynamicSinkConf.cacheMaxSize()); + TypeInformation> writeResultTypeInfo = + CommittableMessageTypeInfo.of(DynamicWriteResultSerializer::new); - return instantiateSink(writeProperties, flinkWriteConf); + DataStream> forwardWriteResults = + converted + .getSideOutput( + new OutputTag<>(DynamicRecordProcessor.DYNAMIC_FORWARD_STREAM, sideOutputType)) + .transform( + operatorName("Forward-Writer"), + writeResultTypeInfo, + new SinkWriterOperatorFactory<>(forwardWriterSink)) + .setParallelism(converted.getParallelism()) + .uid(prefixIfNotNull(uidPrefix, "-forward-writer")); + + // Inject forward write results into sink — they'll be unioned in addPreCommitTopology + return instantiateSink(writeOptions, flinkConfig, forwardWriteResults); } @VisibleForTesting DynamicIcebergSink instantiateSink( - Map writeProperties, FlinkWriteConf flinkWriteConf) { + Map writeProperties, + Configuration flinkWriteConf, + DataStream> forwardWriteResults) { + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeProperties, flinkWriteConf); return new DynamicIcebergSink( catalogLoader, snapshotSummary, uidPrefix, writeProperties, flinkWriteConf, - cacheMaximumSize); + flinkDynamicSinkConf.cacheMaxSize(), + forwardWriteResults); } /** * Append the iceberg sink operators to write records to iceberg table. * + *

    The topology splits records by distribution mode: + * + *

      + *
    • Forward records ({@code null} distributionMode) go through a forward edge to a chained + * writer, avoiding any data shuffle. + *
    • Shuffle records (non-null distributionMode) go through the standard Sink2 pipeline with + * hash/round-robin distribution. + *
    + * + * Both writers feed into a single shared pre-commit aggregator and committer, ensuring atomic + * commits across both paths. + * * @return {@link DataStreamSink} for sink. */ public DataStreamSink append() { + uidPrefix = Optional.ofNullable(uidPrefix).orElse(""); + + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeOptions, readableConfig); + Configuration flinkConfig = fromReadableConfig(); + DynamicRecordInternalType type = - new DynamicRecordInternalType(catalogLoader, false, cacheMaximumSize); - DynamicIcebergSink sink = build(); + new DynamicRecordInternalType(catalogLoader, false, flinkDynamicSinkConf.cacheMaxSize()); + DynamicRecordInternalType sideOutputType = + new DynamicRecordInternalType(catalogLoader, true, flinkDynamicSinkConf.cacheMaxSize()); + SingleOutputStreamOperator converted = input .process( new DynamicRecordProcessor<>( generator, catalogLoader, - immediateUpdate, - cacheMaximumSize, - cacheRefreshMs, - inputSchemasPerTableCacheMaximumSize, - tableCreator)) + tableCreator, + flinkDynamicSinkConf, + writeOptions, + flinkConfig)) + .setParallelism(input.getParallelism()) .uid(prefixIfNotNull(uidPrefix, "-generator")) .name(operatorName("generator")) .returns(type); - DataStreamSink rowDataDataStreamSink = + DynamicIcebergSink sink = build(converted, sideOutputType); + + // Shuffle path: table update side output + main output → sinkTo() + DataStream shuffleInput = converted .getSideOutput( new OutputTag<>( - DynamicRecordProcessor.DYNAMIC_TABLE_UPDATE_STREAM, - new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize))) + DynamicRecordProcessor.DYNAMIC_TABLE_UPDATE_STREAM, sideOutputType)) .keyBy((KeySelector) DynamicRecordInternal::tableName) .map( - new DynamicTableUpdateOperator( - catalogLoader, - cacheMaximumSize, - cacheRefreshMs, - inputSchemasPerTableCacheMaximumSize, - tableCreator)) + new DynamicTableUpdateOperator(catalogLoader, tableCreator, flinkDynamicSinkConf)) .uid(prefixIfNotNull(uidPrefix, "-updater")) .name(operatorName("Updater")) .returns(type) - .union(converted) - .sinkTo(sink) + .union(converted); + + DataStreamSink result = + shuffleInput + .sinkTo(sink) // Forward write results are implicitly injected here .uid(prefixIfNotNull(uidPrefix, "-sink")); - if (sink.flinkWriteConf.writeParallelism() != null) { - rowDataDataStreamSink.setParallelism(sink.flinkWriteConf.writeParallelism()); + + FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeOptions, readableConfig); + if (flinkWriteConf.writeParallelism() != null) { + result.setParallelism(flinkWriteConf.writeParallelism()); } - return rowDataDataStreamSink; + return result; + } + + private Configuration fromReadableConfig() { + return readableConfig instanceof Configuration + ? (Configuration) readableConfig + : Configuration.fromMap(readableConfig.toMap()); } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java index 9f445766083e..6507a575c2af 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java @@ -20,6 +20,7 @@ import java.util.Set; import javax.annotation.Nullable; +import org.apache.flink.annotation.Internal; import org.apache.flink.table.data.RowData; import org.apache.iceberg.DistributionMode; import org.apache.iceberg.PartitionSpec; @@ -34,20 +35,43 @@ public class DynamicRecord { private Schema schema; private RowData rowData; private PartitionSpec partitionSpec; - private DistributionMode distributionMode; + @Nullable private DistributionMode distributionMode; private int writeParallelism; private boolean upsertMode; @Nullable private Set equalityFields; + @Internal + DynamicRecord() {} + + /** + * Constructs a new DynamicRecord with forward (no shuffle) writes. + * + * @param tableIdentifier The target table identifier. + * @param branch The target table branch. + * @param schema The target table schema. + * @param rowData The data matching the provided schema. + * @param partitionSpec The target table {@link PartitionSpec}. + */ + public DynamicRecord( + TableIdentifier tableIdentifier, + String branch, + Schema schema, + RowData rowData, + PartitionSpec partitionSpec) { + this(tableIdentifier, branch, schema, rowData, partitionSpec, null, -1); + } + /** - * Constructs a new DynamicRecord. + * Constructs a new DynamicRecord. This record will be shuffled as specified by {@code + * distributionMode}. * * @param tableIdentifier The target table identifier. * @param branch The target table branch. * @param schema The target table schema. * @param rowData The data matching the provided schema. * @param partitionSpec The target table {@link PartitionSpec}. - * @param distributionMode The {@link DistributionMode}. + * @param distributionMode The {@link DistributionMode}. {@code null} indicates forward (no + * shuffle) writes. * @param writeParallelism The number of parallel writers. Can be set to any value {@literal > 0}, * but will always be automatically capped by the maximum write parallelism, which is the * parallelism of the sink. Set to Integer.MAX_VALUE for always using the maximum available diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java index 22b7742913e7..0d758ace1b19 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.Set; import org.apache.flink.annotation.Internal; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.common.typeutils.TypeSerializer; import org.apache.flink.api.common.typeutils.TypeSerializerSchemaCompatibility; import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot; @@ -34,6 +35,9 @@ import org.apache.iceberg.PartitionSpecParser; import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.flink.util.SerializerHelper; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @Internal @@ -43,18 +47,27 @@ class DynamicRecordInternalSerializer extends TypeSerializer duplicate() { return new DynamicRecordInternalSerializer( new TableSerializerCache(serializerCache.catalogLoader(), serializerCache.maximumSize()), - writeSchemaAndSpec); + writeSchemaAndSpec, + writeLongUTF); } @Override @@ -68,7 +81,12 @@ public void serialize(DynamicRecordInternal toSerialize, DataOutputView dataOutp dataOutputView.writeUTF(toSerialize.tableName()); dataOutputView.writeUTF(toSerialize.branch()); if (writeSchemaAndSpec) { - dataOutputView.writeUTF(SchemaParser.toJson(toSerialize.schema())); + if (writeLongUTF) { + SerializerHelper.writeLongUTF(dataOutputView, SchemaParser.toJson(toSerialize.schema())); + } else { + dataOutputView.writeUTF(SchemaParser.toJson(toSerialize.schema())); + } + dataOutputView.writeUTF(PartitionSpecParser.toJson(toSerialize.spec())); } else { dataOutputView.writeInt(toSerialize.schema().schemaId()); @@ -108,7 +126,12 @@ public DynamicRecordInternal deserialize(DataInputView dataInputView) throws IOE final PartitionSpec spec; final RowDataSerializer rowDataSerializer; if (writeSchemaAndSpec) { - schema = SchemaParser.fromJson(dataInputView.readUTF()); + if (writeLongUTF) { + schema = SchemaParser.fromJson(SerializerHelper.readLongUTF(dataInputView)); + } else { + schema = SchemaParser.fromJson(dataInputView.readUTF()); + } + spec = PartitionSpecParser.fromJson(schema, dataInputView.readUTF()); rowDataSerializer = serializerCache.serializer(tableName, schema, spec); } else { @@ -152,7 +175,12 @@ public DynamicRecordInternal deserialize(DynamicRecordInternal reuse, DataInputV final PartitionSpec spec; final RowDataSerializer rowDataSerializer; if (writeSchemaAndSpec) { - schema = SchemaParser.fromJson(dataInputView.readUTF()); + if (writeLongUTF) { + schema = SchemaParser.fromJson(SerializerHelper.readLongUTF(dataInputView)); + } else { + schema = SchemaParser.fromJson(dataInputView.readUTF()); + } + spec = PartitionSpecParser.fromJson(schema, dataInputView.readUTF()); reuse.setSchema(schema); reuse.setSpec(spec); @@ -245,25 +273,32 @@ public int getLength() { @Override public TypeSerializerSnapshot snapshotConfiguration() { - return new DynamicRecordInternalTypeSerializerSnapshot(writeSchemaAndSpec); + return new DynamicRecordInternalTypeSerializerSnapshot(writeSchemaAndSpec, serializerCache); } public static class DynamicRecordInternalTypeSerializerSnapshot implements TypeSerializerSnapshot { + private static final int MOST_RECENT_VERSION = 1; + private boolean writeSchemaAndSpec; + private int version; + private TableSerializerCache serializerCache; - // Zero args constructor is required to instantiate this class on restore + // Zero args constructor is required to instantiate this class on restore via readSnapshot(..) @SuppressWarnings({"unused", "checkstyle:RedundantModifier"}) public DynamicRecordInternalTypeSerializerSnapshot() {} - DynamicRecordInternalTypeSerializerSnapshot(boolean writeSchemaAndSpec) { + DynamicRecordInternalTypeSerializerSnapshot( + boolean writeSchemaAndSpec, TableSerializerCache serializerCache) { this.writeSchemaAndSpec = writeSchemaAndSpec; + this.serializerCache = serializerCache; + this.version = MOST_RECENT_VERSION; } @Override public int getCurrentVersion() { - return 0; + return version; } @Override @@ -274,22 +309,62 @@ public void writeSnapshot(DataOutputView out) throws IOException { @Override public void readSnapshot(int readVersion, DataInputView in, ClassLoader userCodeClassLoader) throws IOException { + this.version = readVersion; this.writeSchemaAndSpec = in.readBoolean(); } @Override public TypeSerializerSchemaCompatibility resolveSchemaCompatibility( TypeSerializerSnapshot oldSerializerSnapshot) { - return TypeSerializerSchemaCompatibility.compatibleAsIs(); + if (oldSerializerSnapshot.getCurrentVersion() == getCurrentVersion()) { + return TypeSerializerSchemaCompatibility.compatibleAsIs(); + } + + // Old TypeSerializerSnapshots do not contain the serializer cache, but the newest one does. + // This will also ensure that we always use the up-to-date cache alongside with its catalog + // configuration. + Preconditions.checkNotNull(serializerCache, "serializerCache should not be null"); + try { + DynMethods.builder("initializeSerializerCache") + .hiddenImpl( + DynamicRecordInternalTypeSerializerSnapshot.class, TableSerializerCache.class) + .build() + .invoke(oldSerializerSnapshot, serializerCache); + } catch (Exception e) { + throw new RuntimeException( + "Failed to initialize serializerCache for reading data with old serializer", e); + } + + // This will first read data with the old serializer, then switch to the most recent one. + return TypeSerializerSchemaCompatibility.compatibleAfterMigration(); } @Override public TypeSerializer restoreSerializer() { - // Note: We pass in a null serializer cache which would create issues if we tried to use this - // restored serializer, but since we are using {@code - // TypeSerializerSchemaCompatibility.compatibleAsIs()} above, this serializer will never be - // used. A new one will be created via {@code DynamicRecordInternalType}. - return new DynamicRecordInternalSerializer(null, writeSchemaAndSpec); + if (getCurrentVersion() < MOST_RECENT_VERSION) { + // If this serializer is not the most recent one, we need to read old data with the correct + // parameters. + return new DynamicRecordInternalSerializer(serializerCache, writeSchemaAndSpec, false); + } + + // In all other cases, we just use the newest serializer. + return new DynamicRecordInternalSerializer(serializerCache, writeSchemaAndSpec, true); + } + + /** + * We need to lazily initialize the cache from the up-to-date serializer which has the current + * CatalogLoader available. + * + *

    This method must not be removed! + */ + @SuppressWarnings("unused") + private void initializeSerializerCache(TableSerializerCache cache) { + this.serializerCache = cache; } } + + @VisibleForTesting + TableSerializerCache getSerializerCache() { + return serializerCache; + } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java index bc3a25468d84..c752b8e9b8d9 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java @@ -18,10 +18,12 @@ */ package org.apache.iceberg.flink.sink.dynamic; +import java.util.Map; import org.apache.flink.annotation.Internal; import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.common.functions.OpenContext; import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.configuration.Configuration; import org.apache.flink.streaming.api.functions.ProcessFunction; import org.apache.flink.table.data.RowData; import org.apache.flink.util.Collector; @@ -30,6 +32,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.flink.CatalogLoader; +import org.apache.iceberg.flink.FlinkWriteConf; @Internal class DynamicRecordProcessor extends ProcessFunction @@ -37,36 +40,47 @@ class DynamicRecordProcessor extends ProcessFunction generator; private final CatalogLoader catalogLoader; + private final Map writeProperties; + private final Configuration flinkConfig; private final boolean immediateUpdate; + private final boolean dropUnusedColumns; private final int cacheMaximumSize; private final long cacheRefreshMs; private final int inputSchemasPerTableCacheMaximumSize; private final TableCreator tableCreator; + private final boolean caseSensitive; private transient TableMetadataCache tableCache; private transient HashKeyGenerator hashKeyGenerator; private transient TableUpdater updater; private transient OutputTag updateStream; + private transient OutputTag forwardStream; private transient Collector collector; + private transient DynamicRecordWithConfig dynamicRecordWithConfig; private transient Context context; DynamicRecordProcessor( DynamicRecordGenerator generator, CatalogLoader catalogLoader, - boolean immediateUpdate, - int cacheMaximumSize, - long cacheRefreshMs, - int inputSchemasPerTableCacheMaximumSize, - TableCreator tableCreator) { + TableCreator tableCreator, + FlinkDynamicSinkConf sinkConfig, + Map writeProperties, + Configuration flinkConfig) { this.generator = generator; this.catalogLoader = catalogLoader; - this.immediateUpdate = immediateUpdate; - this.cacheMaximumSize = cacheMaximumSize; - this.cacheRefreshMs = cacheRefreshMs; - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; + this.flinkConfig = flinkConfig; + this.writeProperties = writeProperties; + this.immediateUpdate = sinkConfig.immediateTableUpdate(); + this.cacheMaximumSize = sinkConfig.cacheMaxSize(); + this.cacheRefreshMs = sinkConfig.cacheRefreshMs(); + this.inputSchemasPerTableCacheMaximumSize = sinkConfig.inputSchemasPerTableCacheMaxSize(); this.tableCreator = tableCreator; + this.caseSensitive = sinkConfig.caseSensitive(); + this.dropUnusedColumns = sinkConfig.dropUnusedColumns(); } @Override @@ -75,19 +89,31 @@ public void open(OpenContext openContext) throws Exception { Catalog catalog = catalogLoader.loadCatalog(); this.tableCache = new TableMetadataCache( - catalog, cacheMaximumSize, cacheRefreshMs, inputSchemasPerTableCacheMaximumSize); + catalog, + cacheMaximumSize, + cacheRefreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns); this.hashKeyGenerator = new HashKeyGenerator( cacheMaximumSize, getRuntimeContext().getTaskInfo().getMaxNumberOfParallelSubtasks()); - if (immediateUpdate) { - updater = new TableUpdater(tableCache, catalog); - } else { + // Always create updater — needed for forced immediate updates on forward records + this.updater = new TableUpdater(tableCache, catalog, caseSensitive, dropUnusedColumns); + // Always create forward stream tag for forward (distributionMode == null) records + this.forwardStream = + new OutputTag<>( + DYNAMIC_FORWARD_STREAM, + new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize)) {}; + if (!immediateUpdate) { updateStream = new OutputTag<>( DYNAMIC_TABLE_UPDATE_STREAM, new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize)) {}; } + this.dynamicRecordWithConfig = + new DynamicRecordWithConfig(new FlinkWriteConf(writeProperties, flinkConfig)); generator.open(openContext); } @@ -100,7 +126,10 @@ public void processElement(T element, Context ctx, Collector newData = updater.update( data.tableIdentifier(), data.branch(), data.schema(), data.spec(), tableCreator); emit( - collector, data, newData.f0.resolvedTableSchema(), newData.f0.recordConverter(), - newData.f1); + newData.f1, + isForward); } else { + // Shuffled records with immediateUpdate=false go to the update side output int writerKey = hashKeyGenerator.generateKey( data, @@ -148,33 +182,38 @@ public void collect(DynamicRecord data) { } } else { emit( - collector, data, foundSchema.resolvedTableSchema(), foundSchema.recordConverter(), - foundSpec); + foundSpec, + isForward); } } private void emit( - Collector out, DynamicRecord data, Schema schema, DataConverter recordConverter, - PartitionSpec spec) { + PartitionSpec spec, + boolean forward) { RowData rowData = (RowData) recordConverter.convert(data.rowData()); - int writerKey = hashKeyGenerator.generateKey(data, schema, spec, rowData); - String tableName = data.tableIdentifier().toString(); - out.collect( + // writerKey is unused in the forward path. + int writerKey = forward ? -1 : hashKeyGenerator.generateKey(data, schema, spec, rowData); + DynamicRecordInternal record = new DynamicRecordInternal( - tableName, + data.tableIdentifier().toString(), data.branch(), schema, rowData, spec, writerKey, data.upsertMode(), - DynamicSinkUtil.getEqualityFieldIds(data.equalityFields(), schema))); + DynamicSinkUtil.getEqualityFieldIds(data.equalityFields(), schema)); + if (forward) { + context.output(forwardStream, record); + } else { + collector.collect(record); + } } @Override diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java new file mode 100644 index 000000000000..32716c3e4ac7 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.util.Set; +import org.apache.flink.table.data.RowData; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; + +class DynamicRecordWithConfig extends DynamicRecord { + private final String defaultBranch; + private final Integer defaultWriteParallelism; + + private DynamicRecord wrapped; + + DynamicRecordWithConfig(FlinkWriteConf flinkWriteConf) { + this.defaultBranch = flinkWriteConf.branch(); + this.defaultWriteParallelism = flinkWriteConf.writeParallelism(); + } + + DynamicRecordWithConfig wrap(DynamicRecord newWrapped) { + this.wrapped = newWrapped; + return this; + } + + @Override + public String branch() { + return wrapped.branch() != null ? wrapped.branch() : defaultBranch; + } + + @Override + public DistributionMode distributionMode() { + return wrapped.distributionMode(); + } + + @Override + public int writeParallelism() { + int originalParallelism = wrapped.writeParallelism(); + if (originalParallelism > 0 || defaultWriteParallelism == null) { + return originalParallelism; + } + + return defaultWriteParallelism; + } + + @Override + public TableIdentifier tableIdentifier() { + return wrapped.tableIdentifier(); + } + + @Override + public Schema schema() { + return wrapped.schema(); + } + + @Override + public PartitionSpec spec() { + return wrapped.spec(); + } + + @Override + public RowData rowData() { + return wrapped.rowData(); + } + + @Override + public boolean upsertMode() { + return wrapped.upsertMode(); + } + + @Override + public Set equalityFields() { + return wrapped.equalityFields(); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java new file mode 100644 index 000000000000..684da9bb54c7 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; + +/** + * Abstract base class for SQL-based dynamic record generators. Users will extend this class to + * create a DynamicRecord from RowData. + */ +public abstract class DynamicTableRecordGenerator implements DynamicRecordGenerator { + + private final RowType rowType; + + public DynamicTableRecordGenerator(RowType rowType) { + this.rowType = rowType; + } + + protected RowType rowType() { + return rowType; + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java index 586239b54bca..93c268ff86ad 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java @@ -38,24 +38,25 @@ class DynamicTableUpdateOperator extends RichMapFunction { private final CatalogLoader catalogLoader; + private final boolean dropUnusedColumns; private final int cacheMaximumSize; private final long cacheRefreshMs; private final int inputSchemasPerTableCacheMaximumSize; private final TableCreator tableCreator; + private final boolean caseSensitive; private transient TableUpdater updater; DynamicTableUpdateOperator( - CatalogLoader catalogLoader, - int cacheMaximumSize, - long cacheRefreshMs, - int inputSchemasPerTableCacheMaximumSize, - TableCreator tableCreator) { + CatalogLoader catalogLoader, TableCreator tableCreator, FlinkDynamicSinkConf configuration) { this.catalogLoader = catalogLoader; - this.cacheMaximumSize = cacheMaximumSize; - this.cacheRefreshMs = cacheRefreshMs; - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; this.tableCreator = tableCreator; + + this.cacheMaximumSize = configuration.cacheMaxSize(); + this.cacheRefreshMs = configuration.cacheRefreshMs(); + this.inputSchemasPerTableCacheMaximumSize = configuration.inputSchemasPerTableCacheMaxSize(); + this.caseSensitive = configuration.caseSensitive(); + this.dropUnusedColumns = configuration.dropUnusedColumns(); } @Override @@ -65,8 +66,15 @@ public void open(OpenContext openContext) throws Exception { this.updater = new TableUpdater( new TableMetadataCache( - catalog, cacheMaximumSize, cacheRefreshMs, inputSchemasPerTableCacheMaximumSize), - catalog); + catalog, + cacheMaximumSize, + cacheRefreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns), + catalog, + caseSensitive, + dropUnusedColumns); } @Override diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java index 85806f932ad5..d8d0ed6b573e 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java @@ -19,22 +19,37 @@ package org.apache.iceberg.flink.sink.dynamic; import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; class DynamicWriteResult { - - private final WriteTarget key; + private final TableKey key; + private final int specId; private final WriteResult writeResult; - DynamicWriteResult(WriteTarget key, WriteResult writeResult) { + DynamicWriteResult(TableKey key, int specId, WriteResult writeResult) { this.key = key; + this.specId = specId; this.writeResult = writeResult; } - WriteTarget key() { + TableKey key() { return key; } + public int specId() { + return specId; + } + WriteResult writeResult() { return writeResult; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("key", key) + .add("specId", specId) + .add("writeResult", writeResult) + .toString(); + } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java index 927491fa89ea..47d239200b06 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java @@ -42,6 +42,7 @@ import org.apache.iceberg.flink.sink.FlinkManifestUtil; import org.apache.iceberg.flink.sink.ManifestOutputFileFactory; import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.slf4j.Logger; @@ -57,11 +58,10 @@ class DynamicWriteResultAggregator implements OneInputStreamOperator< CommittableMessage, CommittableMessage> { private static final Logger LOG = LoggerFactory.getLogger(DynamicWriteResultAggregator.class); - private static final byte[] EMPTY_MANIFEST_DATA = new byte[0]; private final CatalogLoader catalogLoader; private final int cacheMaximumSize; - private transient Map> results; + private transient Map>> resultsByTableKeyAndSpec; private transient Map> specs; private transient Map> outputFileFactoriesAndFormatVersions; @@ -82,7 +82,7 @@ public void open() throws Exception { this.operatorId = getOperatorID().toString(); this.subTaskId = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask(); this.attemptId = getRuntimeContext().getTaskInfo().getAttemptNumber(); - this.results = Maps.newHashMap(); + this.resultsByTableKeyAndSpec = Maps.newHashMap(); this.specs = new LRUCache<>(cacheMaximumSize); this.outputFileFactoriesAndFormatVersions = new LRUCache<>(cacheMaximumSize); this.catalog = catalogLoader.loadCatalog(); @@ -96,14 +96,15 @@ public void finish() throws IOException { @Override public void prepareSnapshotPreBarrier(long checkpointId) throws IOException { Collection> committables = - Sets.newHashSetWithExpectedSize(results.size()); + Sets.newHashSetWithExpectedSize(resultsByTableKeyAndSpec.size()); int count = 0; - for (Map.Entry> entries : results.entrySet()) { + for (Map.Entry>> entries : + resultsByTableKeyAndSpec.entrySet()) { committables.add( new CommittableWithLineage<>( new DynamicCommittable( entries.getKey(), - writeToManifest(entries.getKey(), entries.getValue(), checkpointId), + writeToManifests(entries.getKey().tableName(), entries.getValue(), checkpointId), getContainingTask().getEnvironment().getJobID().toString(), getRuntimeContext().getOperatorUniqueID(), checkpointId), @@ -121,32 +122,42 @@ public void prepareSnapshotPreBarrier(long checkpointId) throws IOException { new StreamRecord<>( new CommittableWithLineage<>(c.getCommittable(), checkpointId, subTaskId)))); LOG.info("Emitted {} commit message to downstream committer operator", count); - results.clear(); + resultsByTableKeyAndSpec.clear(); } /** - * Write all the completed data files to a newly created manifest file and return the manifest's + * Write all the completed data files to a newly created manifest files and return the manifests' * avro serialized bytes. */ @VisibleForTesting - byte[] writeToManifest( - WriteTarget key, Collection writeResults, long checkpointId) + byte[][] writeToManifests( + String tableName, Map> writeResultsBySpec, long checkpointId) throws IOException { - if (writeResults.isEmpty()) { - return EMPTY_MANIFEST_DATA; + byte[][] deltaManifestsBySpec = new byte[writeResultsBySpec.size()][]; + int idx = 0; + for (Map.Entry> entry : writeResultsBySpec.entrySet()) { + deltaManifestsBySpec[idx] = + writeToManifest(tableName, entry.getKey(), entry.getValue(), checkpointId); + idx++; } + return deltaManifestsBySpec; + } + + private byte[] writeToManifest( + String tableName, int specId, Collection writeResults, long checkpointId) + throws IOException { WriteResult.Builder builder = WriteResult.builder(); - writeResults.forEach(w -> builder.add(w.writeResult())); + writeResults.forEach(builder::add); WriteResult result = builder.build(); Tuple2 outputFileFactoryAndVersion = - outputFileFactoryAndFormatVersion(key.tableName()); + outputFileFactoryAndFormatVersion(tableName); DeltaManifests deltaManifests = FlinkManifestUtil.writeCompletedFiles( result, () -> outputFileFactoryAndVersion.f0.create(checkpointId), - spec(key.tableName(), key.specId()), + spec(tableName, specId), outputFileFactoryAndVersion.f1); return SimpleVersionedSerialization.writeVersionAndSerialize( @@ -160,8 +171,16 @@ public void processElement(StreamRecord> if (element.isRecord() && element.getValue() instanceof CommittableWithLineage) { DynamicWriteResult result = ((CommittableWithLineage) element.getValue()).getCommittable(); - WriteTarget key = result.key(); - results.computeIfAbsent(key, unused -> Sets.newHashSet()).add(result); + Collection resultsPerTableKeyAndSpec = + resultsByTableKeyAndSpec + .computeIfAbsent(result.key(), unused -> Maps.newHashMap()) + .computeIfAbsent(result.specId(), unused -> Lists.newArrayList()); + resultsPerTableKeyAndSpec.add(result.writeResult()); + LOG.debug( + "Added {}, specId={}, totalResults={}", + result, + result.specId(), + resultsPerTableKeyAndSpec.size()); } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java index cf5f423fd7ff..5153ec6a49ee 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java @@ -41,6 +41,7 @@ public byte[] serialize(DynamicWriteResult writeResult) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); writeResult.key().serializeTo(view); + view.writeInt(writeResult.specId()); byte[] result = WRITE_RESULT_SERIALIZER.serialize(writeResult.writeResult()); view.write(result); return out.toByteArray(); @@ -50,11 +51,12 @@ public byte[] serialize(DynamicWriteResult writeResult) throws IOException { public DynamicWriteResult deserialize(int version, byte[] serialized) throws IOException { if (version == 1) { DataInputDeserializer view = new DataInputDeserializer(serialized); - WriteTarget key = WriteTarget.deserializeFrom(view); + TableKey key = TableKey.deserializeFrom(view); + int specId = view.readInt(); byte[] resultBuf = new byte[view.available()]; view.read(resultBuf); WriteResult writeResult = WRITE_RESULT_SERIALIZER.deserialize(version, resultBuf); - return new DynamicWriteResult(key, writeResult); + return new DynamicWriteResult(key, specId, writeResult); } throw new IOException("Unrecognized version or corrupt state: " + version); diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java index c2a303285801..fcd0d082704a 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java @@ -28,14 +28,16 @@ import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.connector.sink2.CommittingSinkWriter; import org.apache.flink.api.connector.sink2.SinkWriter; +import org.apache.flink.configuration.Configuration; import org.apache.flink.table.data.RowData; -import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionField; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.sink.SinkUtil; import org.apache.iceberg.io.TaskWriter; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; @@ -56,27 +58,24 @@ class DynamicWriter implements CommittingSinkWriter taskWriterFactories; private final Map> writers; + private final Configuration flinkConfig; + private final Map commonWriteProperties; private final DynamicWriterMetrics metrics; private final int subTaskId; private final int attemptId; private final Catalog catalog; - private final FileFormat dataFileFormat; - private final long targetDataFileSize; - private final Map commonWriteProperties; DynamicWriter( Catalog catalog, - FileFormat dataFileFormat, - long targetDataFileSize, Map commonWriteProperties, + Configuration flinkConfig, int cacheMaximumSize, DynamicWriterMetrics metrics, int subTaskId, int attemptId) { this.catalog = catalog; - this.dataFileFormat = dataFileFormat; - this.targetDataFileSize = targetDataFileSize; this.commonWriteProperties = commonWriteProperties; + this.flinkConfig = flinkConfig; this.metrics = metrics; this.subTaskId = subTaskId; this.attemptId = attemptId; @@ -106,10 +105,6 @@ public void write(DynamicRecordInternal element, Context context) Table table = catalog.loadTable(TableIdentifier.parse(factoryKey.tableName())); - Map tableWriteProperties = - Maps.newHashMap(table.properties()); - tableWriteProperties.putAll(commonWriteProperties); - Set equalityFieldIds = getEqualityFields(table, element.equalityFields()); if (element.upsertMode()) { @@ -128,12 +123,18 @@ public void write(DynamicRecordInternal element, Context context) } } + FlinkWriteConf flinkWriteConf = + new FlinkWriteConf(table, commonWriteProperties, flinkConfig); + Map tableWriteProperties = + SinkUtil.writeProperties( + flinkWriteConf.dataFileFormat(), flinkWriteConf, table); + LOG.debug("Creating new writer factory for table '{}'", table.name()); return new RowDataTaskWriterFactory( () -> table, FlinkSchemaUtil.convert(element.schema()), - targetDataFileSize, - dataFileFormat, + flinkWriteConf.targetDataFileSize(), + flinkWriteConf.dataFileFormat(), tableWriteProperties, Lists.newArrayList(equalityFieldIds), element.upsertMode(), @@ -145,6 +146,7 @@ public void write(DynamicRecordInternal element, Context context) return taskWriterFactory.create(); }) .write(element.rowData()); + metrics.mainMetricsGroup().getNumRecordsSendCounter().inc(); } @Override @@ -164,8 +166,6 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("subtaskId", subTaskId) .add("attemptId", attemptId) - .add("dataFileFormat", dataFileFormat) - .add("targetDataFileSize", targetDataFileSize) .add("writeProperties", commonWriteProperties) .toString(); } @@ -188,7 +188,11 @@ public Collection prepareCommit() throws IOException { writeResult.dataFiles().length, writeResult.deleteFiles().length); - result.add(new DynamicWriteResult(writeTarget, writeResult)); + result.add( + new DynamicWriteResult( + new TableKey(writeTarget.tableName(), writeTarget.branch()), + writeTarget.specId(), + writeResult)); } writers.clear(); diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java index 2e1f82df9d2d..d50a41512a15 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java @@ -18,24 +18,37 @@ */ package org.apache.iceberg.flink.sink.dynamic; +import java.util.Arrays; import java.util.Map; -import org.apache.flink.metrics.MetricGroup; +import java.util.function.ToLongFunction; +import org.apache.flink.metrics.groups.SinkWriterMetricGroup; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; import org.apache.iceberg.flink.sink.IcebergStreamWriterMetrics; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ScanTaskUtil; class DynamicWriterMetrics { private final Map metrics; - private final MetricGroup mainMetricsGroup; + private final SinkWriterMetricGroup mainMetricsGroup; - DynamicWriterMetrics(MetricGroup mainMetricsGroup) { + DynamicWriterMetrics(SinkWriterMetricGroup mainMetricsGroup) { this.mainMetricsGroup = mainMetricsGroup; this.metrics = Maps.newHashMap(); } + SinkWriterMetricGroup mainMetricsGroup() { + return this.mainMetricsGroup; + } + public void updateFlushResult(String fullTableName, WriteResult result) { writerMetrics(fullTableName).updateFlushResult(result); + + long bytesOutTotal = sum(result.dataFiles()) + sum(result.deleteFiles()); + this.mainMetricsGroup.getNumBytesSendCounter().inc(bytesOutTotal); } public void flushDuration(String fullTableName, long flushDurationMs) { @@ -46,4 +59,16 @@ IcebergStreamWriterMetrics writerMetrics(String fullTableName) { return metrics.computeIfAbsent( fullTableName, tableName -> new IcebergStreamWriterMetrics(mainMetricsGroup, tableName)); } + + private static long sum(DataFile[] files) { + return sum(files, DataFile::fileSizeInBytes); + } + + private static long sum(DeleteFile[] files) { + return sum(files, ScanTaskUtil::contentSizeInBytes); + } + + private static > long sum(T[] files, ToLongFunction sizeExtractor) { + return Arrays.stream(files).mapToLong(sizeExtractor).sum(); + } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java index ee0549997178..d9747d201e3d 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java @@ -21,10 +21,13 @@ import java.util.List; import org.apache.iceberg.Schema; import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.schema.SchemaWithPartnerVisitor; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Visitor class that accumulates the set of changes needed to evolve an existing schema into the @@ -36,30 +39,42 @@ *

  • Adding new columns *
  • Widening the type of existing columsn *
  • Reordering columns + *
  • Dropping columns (when dropUnusedColumns is enabled) * * * We don't support: * *
      - *
    • Dropping columns *
    • Renaming columns *
    * - * The reason is that dropping columns would create issues with late / out of order data. Once we - * drop fields, we wouldn't be able to easily add them back later without losing the associated - * data. Renaming columns is not supported because we compare schemas by name, which doesn't allow - * for renaming without additional hints. + * By default, any columns present in the table but absent from the input schema are marked as + * optional to prevent issues caused by late or out-of-order data. If dropUnusedColumns is enabled, + * these columns are removed instead to ensure a strict one-to-one schema alignment. */ public class EvolveSchemaVisitor extends SchemaWithPartnerVisitor { + private static final Logger LOG = LoggerFactory.getLogger(EvolveSchemaVisitor.class); + private final TableIdentifier identifier; private final UpdateSchema api; private final Schema existingSchema; private final Schema targetSchema; - - private EvolveSchemaVisitor(UpdateSchema api, Schema existingSchema, Schema targetSchema) { - this.api = api; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; + + private EvolveSchemaVisitor( + TableIdentifier identifier, + UpdateSchema api, + Schema existingSchema, + Schema targetSchema, + boolean caseSensitive, + boolean dropUnusedColumns) { + this.identifier = identifier; + this.api = api.caseSensitive(caseSensitive); this.existingSchema = existingSchema; this.targetSchema = targetSchema; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } /** @@ -70,13 +85,22 @@ private EvolveSchemaVisitor(UpdateSchema api, Schema existingSchema, Schema targ * @param api an UpdateSchema for adding changes * @param existingSchema an existing schema * @param targetSchema a new schema to compare with the existing + * @param caseSensitive whether field name matching should be case-sensitive + * @param dropUnusedColumns whether to drop columns not present in target schema */ - public static void visit(UpdateSchema api, Schema existingSchema, Schema targetSchema) { + public static void visit( + TableIdentifier identifier, + UpdateSchema api, + Schema existingSchema, + Schema targetSchema, + boolean caseSensitive, + boolean dropUnusedColumns) { visit( targetSchema, -1, - new EvolveSchemaVisitor(api, existingSchema, targetSchema), - new CompareSchemasVisitor.PartnerIdByNameAccessors(existingSchema)); + new EvolveSchemaVisitor( + identifier, api, existingSchema, targetSchema, caseSensitive, dropUnusedColumns), + new CompareSchemasVisitor.PartnerIdByNameAccessors(existingSchema, caseSensitive)); } @Override @@ -89,25 +113,36 @@ public Boolean struct(Types.StructType struct, Integer partnerId, List Types.StructType partnerStruct = findFieldType(partnerId).asStructType(); String after = null; for (Types.NestedField targetField : struct.fields()) { - Types.NestedField nestedField = partnerStruct.field(targetField.name()); + Types.NestedField nestedField = + CompareSchemasVisitor.getFieldFromStruct( + targetField.name(), partnerStruct, caseSensitive); final String columnName; if (nestedField != null) { updateColumn(nestedField, targetField); columnName = this.existingSchema.findColumnName(nestedField.fieldId()); } else { addColumn(partnerId, targetField); - columnName = this.targetSchema.findColumnName(targetField.fieldId()); + columnName = targetSchema.findColumnName(targetField.fieldId()); } setPosition(columnName, after); after = columnName; } - // Ensure that unused fields are made optional for (Types.NestedField existingField : partnerStruct.fields()) { - if (struct.field(existingField.name()) == null) { - if (existingField.isRequired()) { - this.api.makeColumnOptional(this.existingSchema.findColumnName(existingField.fieldId())); + Types.NestedField targetField = + caseSensitive + ? struct.field(existingField.name()) + : struct.caseInsensitiveField(existingField.name()); + if (targetField == null) { + String columnName = this.existingSchema.findColumnName(existingField.fieldId()); + if (dropUnusedColumns) { + LOG.debug("{}: Dropping column: {}", identifier.name(), columnName); + this.api.deleteColumn(columnName); + } else { + if (existingField.isRequired()) { + this.api.makeColumnOptional(columnName); + } } } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java new file mode 100644 index 000000000000..75b169c4b533 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.util.Map; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.flink.FlinkConfParser; + +/** + * A class for common Dynamic Iceberg sink configs for Flink writes. + * + *

    If a config is set at multiple levels, the following order of precedence is used (top to + * bottom): + * + *

      + *
    1. Write options + *
    2. Flink ReadableConfig + *
    3. Default values + *
    + * + * The most specific value is set in write options and takes precedence over all other configs. If + * no write option is provided, this class checks the flink configuration for any overrides. If no + * applicable value is found in the write options, this class uses the default values. + */ +class FlinkDynamicSinkConf { + + private final FlinkConfParser confParser; + + FlinkDynamicSinkConf(Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(writeOptions, readableConfig); + } + + int cacheMaxSize() { + return confParser + .intConf() + .option(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.key()) + .flinkConfig(FlinkDynamicSinkOptions.CACHE_MAX_SIZE) + .defaultValue(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.defaultValue()) + .parse(); + } + + boolean immediateTableUpdate() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.key()) + .flinkConfig(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE) + .defaultValue(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.defaultValue()) + .parse(); + } + + boolean dropUnusedColumns() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key()) + .flinkConfig(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS) + .defaultValue(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.defaultValue()) + .parse(); + } + + long cacheRefreshMs() { + return confParser + .longConf() + .option(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.key()) + .flinkConfig(FlinkDynamicSinkOptions.CACHE_REFRESH_MS) + .defaultValue(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.defaultValue()) + .parse(); + } + + int inputSchemasPerTableCacheMaxSize() { + return confParser + .intConf() + .option(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.key()) + .flinkConfig(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE) + .defaultValue(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.defaultValue()) + .parse(); + } + + boolean caseSensitive() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.CASE_SENSITIVE.key()) + .flinkConfig(FlinkDynamicSinkOptions.CASE_SENSITIVE) + .defaultValue(FlinkDynamicSinkOptions.CASE_SENSITIVE.defaultValue()) + .parse(); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java new file mode 100644 index 000000000000..7a4f038219d9 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; + +@Experimental +public class FlinkDynamicSinkOptions { + + private FlinkDynamicSinkOptions() {} + + public static final ConfigOption CACHE_MAX_SIZE = + ConfigOptions.key("dynamic-sink.cache-max-size") + .intType() + .defaultValue(100) + .withDescription( + "Maximum size of the caches used in Dynamic Sink for table data and serializers."); + + public static final ConfigOption IMMEDIATE_TABLE_UPDATE = + ConfigOptions.key("dynamic-sink.immediate-table-update") + .booleanType() + .defaultValue(false) + .withDescription( + "Controls whether table schema and partition updates should be applied immediately in Dynamic Sink."); + + public static final ConfigOption DROP_UNUSED_COLUMNS = + ConfigOptions.key("dynamic-sink.drop-unused-columns") + .booleanType() + .defaultValue(false) + .withDescription( + "Allows dropping unused columns during schema evolution in Dynamic Sink."); + + public static final ConfigOption CACHE_REFRESH_MS = + ConfigOptions.key("dynamic-sink.cache-refresh-ms") + .longType() + .defaultValue(1_000L) + .withDescription( + "Cache refresh interval for dynamic table metadata in Dynamic Sink in milliseconds."); + + public static final ConfigOption INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE = + ConfigOptions.key("dynamic-sink.input-schemas-per-table-cache-max-size") + .intType() + .defaultValue(10) + .withDescription( + "Maximum input schema objects to cache per each table in Dynamic Sink for performance."); + + public static final ConfigOption CASE_SENSITIVE = + ConfigOptions.key("dynamic-sink.case-sensitive") + .booleanType() + .defaultValue(true) + .withDescription( + "Controls whether schema field name matching should be case-sensitive in Dynamic Sink."); +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java index 1c611c46b90c..61a850212bf4 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java @@ -40,6 +40,7 @@ import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,7 +87,9 @@ int generateKey( tableSpec != null ? tableSpec.specId() : null, dynamicRecord.schema(), dynamicRecord.spec(), - dynamicRecord.equalityFields()); + dynamicRecord.equalityFields(), + dynamicRecord.distributionMode(), + Math.min(dynamicRecord.writeParallelism(), maxWriteParallelism)); KeySelector keySelector = keySelectorCache.computeIfAbsent( cacheKey, @@ -95,8 +98,7 @@ int generateKey( tableIdent, MoreObjects.firstNonNull(tableSchema, dynamicRecord.schema()), MoreObjects.firstNonNull(tableSpec, dynamicRecord.spec()), - MoreObjects.firstNonNull( - dynamicRecord.distributionMode(), DistributionMode.NONE), + dynamicRecord.distributionMode(), MoreObjects.firstNonNull( dynamicRecord.equalityFields(), Collections.emptySet()), Math.min(dynamicRecord.writeParallelism(), maxWriteParallelism))); @@ -151,8 +153,9 @@ private KeySelector getKeySelector( tableName, schema, equalityFields, writeParallelism, maxWriteParallelism); } else { for (PartitionField partitionField : spec.fields()) { + Types.NestedField sourceField = schema.findField(partitionField.sourceId()); Preconditions.checkState( - equalityFields.contains(partitionField.name()), + sourceField != null && equalityFields.contains(sourceField.name()), "%s: In 'hash' distribution mode with equality fields set, partition field '%s' " + "should be included in equality fields: '%s'", tableName, @@ -320,6 +323,8 @@ static class SelectorKey { private final Schema schema; private final PartitionSpec spec; private final Set equalityFields; + private final DistributionMode distributionMode; + private final int writeParallelism; SelectorKey( String tableName, @@ -328,7 +333,9 @@ static class SelectorKey { @Nullable Integer tableSpecId, Schema schema, PartitionSpec spec, - Set equalityFields) { + Set equalityFields, + DistributionMode distributionMode, + int writeParallelism) { this.tableName = tableName; this.branch = branch; this.schemaId = tableSchemaId; @@ -336,6 +343,8 @@ static class SelectorKey { this.schema = tableSchemaId == null ? schema : null; this.spec = tableSpecId == null ? spec : null; this.equalityFields = equalityFields; + this.distributionMode = distributionMode; + this.writeParallelism = writeParallelism; } @Override @@ -355,12 +364,23 @@ public boolean equals(Object other) { && Objects.equals(specId, that.specId) && Objects.equals(schema, that.schema) && Objects.equals(spec, that.spec) - && Objects.equals(equalityFields, that.equalityFields); + && Objects.equals(equalityFields, that.equalityFields) + && distributionMode == that.distributionMode + && writeParallelism == that.writeParallelism; } @Override public int hashCode() { - return Objects.hash(tableName, branch, schemaId, specId, schema, spec, equalityFields); + return Objects.hash( + tableName, + branch, + schemaId, + specId, + schema, + spec, + equalityFields, + distributionMode, + writeParallelism); } @Override @@ -373,6 +393,8 @@ public String toString() { .add("schema", schema) .add("spec", spec) .add("equalityFields", equalityFields) + .add("distributionMode", distributionMode) + .add("writeParallelism", writeParallelism) .toString(); } } diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java new file mode 100644 index 000000000000..08b755fe14a8 --- /dev/null +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.io.IOException; +import java.util.Objects; +import org.apache.flink.core.memory.DataInputView; +import org.apache.flink.core.memory.DataOutputView; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +class TableKey { + private final String tableName; + private final String branch; + + TableKey(String tableName, String branch) { + this.tableName = tableName; + this.branch = branch; + } + + TableKey(DynamicCommittable committable) { + this.tableName = committable.key().tableName(); + this.branch = committable.key().branch(); + } + + String tableName() { + return tableName; + } + + String branch() { + return branch; + } + + void serializeTo(DataOutputView view) throws IOException { + view.writeUTF(tableName); + view.writeUTF(branch); + } + + static TableKey deserializeFrom(DataInputView view) throws IOException { + return new TableKey(view.readUTF(), view.readUTF()); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other == null || getClass() != other.getClass()) { + return false; + } + + TableKey that = (TableKey) other; + return tableName.equals(that.tableName) && branch.equals(that.branch); + } + + @Override + public int hashCode() { + return Objects.hash(tableName, branch); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("tableName", tableName) + .add("branch", branch) + .toString(); + } +} diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java index 2c08a3486e7c..fdefc01402ac 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java @@ -29,6 +29,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.flink.FlinkSchemaUtil; import org.slf4j.Logger; @@ -54,10 +55,24 @@ class TableMetadataCache { private final Clock cacheRefreshClock; private final int inputSchemasPerTableCacheMaximumSize; private final Map tableCache; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; TableMetadataCache( - Catalog catalog, int maximumSize, long refreshMs, int inputSchemasPerTableCacheMaximumSize) { - this(catalog, maximumSize, refreshMs, inputSchemasPerTableCacheMaximumSize, Clock.systemUTC()); + Catalog catalog, + int maximumSize, + long refreshMs, + int inputSchemasPerTableCacheMaximumSize, + boolean caseSensitive, + boolean dropUnusedColumns) { + this( + catalog, + maximumSize, + refreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns, + Clock.systemUTC()); } @VisibleForTesting @@ -66,19 +81,23 @@ class TableMetadataCache { int maximumSize, long refreshMs, int inputSchemasPerTableCacheMaximumSize, + boolean caseSensitive, + boolean dropUnusedColumns, Clock cacheRefreshClock) { this.catalog = catalog; this.refreshMs = refreshMs; - this.cacheRefreshClock = cacheRefreshClock; this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; this.tableCache = new LRUCache<>(maximumSize); + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; + this.cacheRefreshClock = cacheRefreshClock; } Tuple2 exists(TableIdentifier identifier) { CacheItem cached = tableCache.get(identifier); if (cached != null && Boolean.TRUE.equals(cached.tableExists)) { return EXISTS; - } else if (needsRefresh(cached, true)) { + } else if (needsRefresh(identifier, cached, true)) { return refreshTable(identifier); } else { return NOT_EXISTS; @@ -115,7 +134,7 @@ private String branch(TableIdentifier identifier, String branch, boolean allowRe return branch; } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return branch(identifier, branch, false); } else { @@ -139,7 +158,8 @@ private ResolvedSchemaInfo schema( for (Map.Entry tableSchema : cached.tableSchemas.entrySet()) { CompareSchemasVisitor.Result result = - CompareSchemasVisitor.visit(input, tableSchema.getValue(), true); + CompareSchemasVisitor.visit( + input, tableSchema.getValue(), caseSensitive, dropUnusedColumns); if (result == CompareSchemasVisitor.Result.SAME) { ResolvedSchemaInfo newResult = new ResolvedSchemaInfo( @@ -155,7 +175,7 @@ private ResolvedSchemaInfo schema( } } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return schema(identifier, input, false); } else if (compatible != null) { @@ -185,7 +205,7 @@ private PartitionSpec spec(TableIdentifier identifier, PartitionSpec spec, boole } } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return spec(identifier, spec, false); } else { @@ -198,18 +218,32 @@ private Tuple2 refreshTable(TableIdentifier identifier) { Table table = catalog.loadTable(identifier); update(identifier, table); return EXISTS; - } catch (NoSuchTableException e) { - LOG.debug("Table doesn't exist {}", identifier, e); + } catch (NoSuchTableException | NoSuchNamespaceException e) { + LOG.debug("Table or namespace doesn't exist {}", identifier, e); tableCache.put( identifier, new CacheItem(cacheRefreshClock.millis(), false, null, null, null, 1)); return Tuple2.of(false, e); } } - private boolean needsRefresh(CacheItem cacheItem, boolean allowRefresh) { - return allowRefresh - && (cacheItem == null - || cacheRefreshClock.millis() - cacheItem.createdTimestampMillis > refreshMs); + private boolean needsRefresh( + TableIdentifier identifier, CacheItem cacheItem, boolean allowRefresh) { + if (!allowRefresh) { + return false; + } + + if (cacheItem == null) { + return true; + } + + long nowMillis = cacheRefreshClock.millis(); + long timeElapsedMillis = nowMillis - cacheItem.createdTimestampMillis; + if (timeElapsedMillis > refreshMs) { + LOG.info("Refreshing table metadata for {} after {} millis", identifier, timeElapsedMillis); + return true; + } + + return false; } public void invalidate(TableIdentifier identifier) { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java index cadfe345980c..b0bdad8ed1e1 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java @@ -43,10 +43,15 @@ class TableUpdater { private static final Logger LOG = LoggerFactory.getLogger(TableUpdater.class); private final TableMetadataCache cache; private final Catalog catalog; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; - TableUpdater(TableMetadataCache cache, Catalog catalog) { + TableUpdater( + TableMetadataCache cache, Catalog catalog, boolean caseSensitive, boolean dropUnusedColumns) { this.cache = cache; this.catalog = catalog; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } /** @@ -124,7 +129,8 @@ private TableMetadataCache.ResolvedSchemaInfo findOrCreateSchema( } else { Table table = catalog.loadTable(identifier); Schema tableSchema = table.schema(); - CompareSchemasVisitor.Result result = CompareSchemasVisitor.visit(schema, tableSchema, true); + CompareSchemasVisitor.Result result = + CompareSchemasVisitor.visit(schema, tableSchema, caseSensitive, dropUnusedColumns); switch (result) { case SAME: cache.update(identifier, table); @@ -141,7 +147,8 @@ private TableMetadataCache.ResolvedSchemaInfo findOrCreateSchema( LOG.info( "Triggering schema update for table {} {} to {}", identifier, tableSchema, schema); UpdateSchema updateApi = table.updateSchema(); - EvolveSchemaVisitor.visit(updateApi, tableSchema, schema); + EvolveSchemaVisitor.visit( + identifier, updateApi, tableSchema, schema, caseSensitive, dropUnusedColumns); try { updateApi.commit(); diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java index a01b82539c5a..211f28c6518a 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java @@ -23,7 +23,7 @@ import java.util.Objects; import java.util.Set; import org.apache.flink.core.memory.DataInputView; -import org.apache.flink.core.memory.DataOutputView; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -44,7 +44,7 @@ class WriteTarget implements Serializable { boolean upsertMode, Set equalityFields) { this.tableName = tableName; - this.branch = branch != null ? branch : "main"; + this.branch = branch != null ? branch : SnapshotRef.MAIN_BRANCH; this.schemaId = schemaId; this.specId = specId; this.upsertMode = upsertMode; @@ -75,18 +75,6 @@ Set equalityFields() { return equalityFields; } - void serializeTo(DataOutputView view) throws IOException { - view.writeUTF(tableName); - view.writeUTF(branch); - view.writeInt(schemaId); - view.writeInt(specId); - view.writeBoolean(upsertMode); - view.writeInt(equalityFields.size()); - for (Integer equalityField : equalityFields) { - view.writeInt(equalityField); - } - } - static WriteTarget deserializeFrom(DataInputView view) throws IOException { return new WriteTarget( view.readUTF(), diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java index 6f5bb6722771..ec099fbfe80a 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java @@ -159,6 +159,7 @@ public void serialize(SortKey record, DataOutputView target) throws IOException case LONG: case TIME: case TIMESTAMP: + case TIMESTAMP_NANO: target.writeLong(record.get(i, Long.class)); break; case FLOAT: @@ -237,6 +238,7 @@ public SortKey deserialize(SortKey reuse, DataInputView source) throws IOExcepti case LONG: case TIME: case TIMESTAMP: + case TIMESTAMP_NANO: reuse.set(i, source.readLong()); break; case FLOAT: diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java index ec7cb010b6be..a55402d2d0be 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java @@ -292,7 +292,10 @@ public static class Builder { private RowDataConverter converter; private ReadableConfig flinkConfig = new Configuration(); private final ScanContext.Builder contextBuilder = ScanContext.builder(); + + @SuppressWarnings("deprecation") private TableSchema projectedTableSchema; + private ResolvedSchema projectedFlinkSchema; private Boolean exposeLocality; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java index aeecd43e7f14..40df25b59fe6 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java @@ -121,6 +121,7 @@ public void applyProjection(int[][] projectFields, DataType producedDataType) { } } + @SuppressWarnings("deprecation") private DataStream createDataStream(StreamExecutionEnvironment execEnv) { return FlinkSource.forRowData() .env(execEnv) diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java index b8fb1ba32edf..ee4aaf4a3da1 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java @@ -24,10 +24,8 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; import org.apache.iceberg.StructLike; -import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.encryption.InputFilesDecryptor; import org.apache.iceberg.expressions.Expression; @@ -35,19 +33,14 @@ import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.flink.FlinkSourceFilter; import org.apache.iceberg.flink.RowDataWrapper; -import org.apache.iceberg.flink.data.FlinkOrcReader; -import org.apache.iceberg.flink.data.FlinkParquetReaders; -import org.apache.iceberg.flink.data.FlinkPlannedAvroReader; import org.apache.iceberg.flink.data.RowDataProjection; import org.apache.iceberg.flink.data.RowDataUtil; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.mapping.NameMappingParser; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.PartitionUtil; @Internal @@ -73,8 +66,7 @@ public RowDataFileScanTaskReader( if (filters != null && !filters.isEmpty()) { Expression combinedExpression = filters.stream().reduce(Expressions.alwaysTrue(), Expressions::and); - this.rowFilter = - new FlinkSourceFilter(this.projectedSchema, combinedExpression, this.caseSensitive); + this.rowFilter = new FlinkSourceFilter(projectedSchema, combinedExpression, caseSensitive); } else { this.rowFilter = null; } @@ -112,23 +104,23 @@ private CloseableIterable newIterable( if (task.isDataTask()) { throw new UnsupportedOperationException("Cannot read data task."); } else { - switch (task.file().format()) { - case PARQUET: - iter = newParquetIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - case AVRO: - iter = newAvroIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - case ORC: - iter = newOrcIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - default: - throw new UnsupportedOperationException( - "Cannot read unknown format: " + task.file().format()); + ReadBuilder builder = + FormatModelRegistry.readBuilder( + task.file().format(), RowData.class, inputFilesDecryptor.getInputFile(task)); + + if (nameMapping != null) { + builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); } + + iter = + builder + .project(schema) + .idToConstant(idToConstant) + .split(task.start(), task.length()) + .caseSensitive(caseSensitive) + .filter(task.residual()) + .reuseContainers() + .build(); } if (rowFilter != null) { @@ -137,72 +129,6 @@ private CloseableIterable newIterable( return iter; } - private CloseableIterable newAvroIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Avro.ReadBuilder builder = - Avro.read(inputFilesDecryptor.getInputFile(task)) - .reuseContainers() - .project(schema) - .split(task.start(), task.length()) - .createReaderFunc(readSchema -> FlinkPlannedAvroReader.create(schema, idToConstant)); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - - private CloseableIterable newParquetIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Parquet.ReadBuilder builder = - Parquet.read(inputFilesDecryptor.getInputFile(task)) - .split(task.start(), task.length()) - .project(schema) - .createReaderFunc( - fileSchema -> FlinkParquetReaders.buildReader(schema, fileSchema, idToConstant)) - .filter(task.residual()) - .caseSensitive(caseSensitive) - .reuseContainers(); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - - private CloseableIterable newOrcIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Schema readSchemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - schema, Sets.union(idToConstant.keySet(), MetadataColumns.metadataFieldIds())); - - ORC.ReadBuilder builder = - ORC.read(inputFilesDecryptor.getInputFile(task)) - .project(readSchemaWithoutConstantAndMetadataFields) - .split(task.start(), task.length()) - .createReaderFunc( - readOrcSchema -> new FlinkOrcReader(schema, readOrcSchema, idToConstant)) - .filter(task.residual()) - .caseSensitive(caseSensitive); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - private static class FlinkDeleteFilter extends DeleteFilter { private final RowType requiredRowType; private final RowDataWrapper asStructLike; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java index 8ef1f1fbb833..d74b8b9d620f 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java @@ -23,8 +23,6 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.annotation.Internal; -import org.apache.flink.formats.avro.RowDataToAvroConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.LogicalType; @@ -32,6 +30,8 @@ import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.formats.avro.RowDataToAvroConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; /** * This is not serializable because Avro {@link Schema} is not actually serializable, even though it diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java index bac7c05bdfef..1c6644238c3d 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java @@ -136,23 +136,26 @@ void validate() { if (startingStrategy == StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_ID) { Preconditions.checkArgument( startSnapshotId != null, - "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: null"); + "Invalid starting snapshot id for %s strategy: null", + startingStrategy); Preconditions.checkArgument( startSnapshotTimestamp == null, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot timestamp for %s strategy: not null", + startingStrategy); } if (startingStrategy == StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP) { Preconditions.checkArgument( startSnapshotTimestamp != null, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_TIMESTAMP strategy: null"); + "Invalid starting snapshot timestamp for %s strategy: null", + startingStrategy); Preconditions.checkArgument( startSnapshotId == null, - "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot id for %s strategy: not null", + startingStrategy); } Preconditions.checkArgument( - tag == null, - String.format("Cannot scan table using ref %s configured for streaming reader", tag)); + tag == null, "Cannot scan table using ref %s configured for streaming reader", tag); Preconditions.checkArgument( snapshotId == null, "Cannot set snapshot-id option for streaming reader"); Preconditions.checkArgument( diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java index 133859b657e5..c7db4ef933d9 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java @@ -56,6 +56,7 @@ *

    The splits to be read are forwarded to the downstream {@link StreamingReaderOperator} which * can have parallelism greater than one. */ +@SuppressWarnings("deprecation") public class StreamingMonitorFunction extends RichSourceFunction implements CheckpointedFunction { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java index 6cc2ccd2c353..a8abd48a7175 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java @@ -67,6 +67,7 @@ public class StreamingReaderOperator extends AbstractStreamOperator private final MailboxExecutor executor; private FlinkInputFormat format; + @SuppressWarnings("deprecation") private transient SourceFunction.SourceContext sourceContext; private transient ListState inputSplitsState; @@ -93,6 +94,7 @@ private StreamingReaderOperator( Preconditions.checkNotNull(mailboxExecutor, "The mailboxExecutor should not be null."); } + @SuppressWarnings("deprecation") @Override public void initializeState(StateInitializationContext context) throws Exception { super.initializeState(context); @@ -213,6 +215,7 @@ private enum SplitState { RUNNING } + @SuppressWarnings("deprecation") private static class OperatorFactory extends AbstractStreamOperatorFactory implements YieldingOperatorFactory, OneInputStreamOperatorFactory { diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java index b158b0871a53..cfef780a4daa 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java @@ -21,8 +21,6 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.api.common.typeinfo.TypeInformation; -import org.apache.flink.formats.avro.RowDataToAvroConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.formats.avro.typeutils.GenericRecordAvroTypeInfo; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; @@ -31,6 +29,8 @@ import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.formats.avro.RowDataToAvroConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; public class AvroGenericRecordConverter implements RowDataConverter { private final Schema avroSchema; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java index 4bb6f0a98c4c..34de689c6c5d 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java @@ -53,13 +53,24 @@ public ColumnStatsWatermarkExtractor( Types.NestedField field = schema.findField(eventTimeFieldName); TypeID typeID = field.type().typeId(); Preconditions.checkArgument( - typeID.equals(TypeID.LONG) || typeID.equals(TypeID.TIMESTAMP), - "Found %s, expected a LONG or TIMESTAMP column for watermark generation.", + typeID.equals(TypeID.LONG) + || typeID.equals(TypeID.TIMESTAMP) + || typeID.equals(TypeID.TIMESTAMP_NANO), + "Found %s, expected a LONG, TIMESTAMP, or TIMESTAMP_NANO column for watermark generation.", typeID); this.eventTimeFieldId = field.fieldId(); this.eventTimeFieldName = eventTimeFieldName; - // Use the timeUnit only for Long columns. - this.timeUnit = typeID.equals(TypeID.LONG) ? timeUnit : TimeUnit.MICROSECONDS; + // Use the timeUnit only for Long columns; timestamp columns store fixed-precision longs. + switch (typeID) { + case LONG: + this.timeUnit = timeUnit; + break; + case TIMESTAMP_NANO: + this.timeUnit = TimeUnit.NANOSECONDS; + break; + default: + this.timeUnit = TimeUnit.MICROSECONDS; + } } @VisibleForTesting diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java index 02ef57d344b1..3af9957875e8 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java @@ -34,7 +34,7 @@ class WatermarkExtractorRecordEmitter implements SerializableRecordEmitter private static final Logger LOG = LoggerFactory.getLogger(WatermarkExtractorRecordEmitter.class); private final SplitWatermarkExtractor timeExtractor; private String lastSplitId = null; - private long watermark; + private long watermark = Long.MIN_VALUE; WatermarkExtractorRecordEmitter(SplitWatermarkExtractor timeExtractor) { this.timeExtractor = timeExtractor; @@ -44,7 +44,10 @@ class WatermarkExtractorRecordEmitter implements SerializableRecordEmitter public void emitRecord( RecordAndPosition element, SourceOutput output, IcebergSourceSplit split) { if (!split.splitId().equals(lastSplitId)) { - long newWatermark = timeExtractor.extractWatermark(split); + long extracted = timeExtractor.extractWatermark(split); + // Subtract 1 because watermark W means all records with eventTime <= W have arrived; + // records in this split have eventTime == extracted, so watermark must be extracted - 1. + long newWatermark = extracted > Long.MIN_VALUE ? extracted - 1 : Long.MIN_VALUE; if (newWatermark < watermark) { LOG.info( "Received a new split with lower watermark. Previous watermark = {}, current watermark = {}, previous split = {}, current split = {}", diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java index b6d6f60ef673..0cc1c633f906 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java @@ -33,6 +33,7 @@ import org.apache.iceberg.CombinedScanTask; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.ScanTaskParser; +import org.apache.iceberg.flink.util.SerializerHelper; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; diff --git a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java similarity index 92% rename from flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java rename to flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java index 841969666ee5..3a161ea2631f 100644 --- a/flink/v1.20/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java +++ b/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java @@ -16,20 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.flink.source.split; +package org.apache.iceberg.flink.util; import java.io.IOException; import java.io.Serializable; import java.io.UTFDataFormatException; +import org.apache.flink.annotation.Internal; import org.apache.flink.core.memory.DataInputDeserializer; +import org.apache.flink.core.memory.DataInputView; import org.apache.flink.core.memory.DataOutputSerializer; +import org.apache.flink.core.memory.DataOutputView; /** * Helper class to serialize and deserialize strings longer than 65K. The inspiration is mostly * taken from the class org.apache.flink.core.memory.DataInputSerializer.readUTF and * org.apache.flink.core.memory.DataOutputSerializer.writeUTF. */ -class SerializerHelper implements Serializable { +@Internal +public class SerializerHelper implements Serializable { private SerializerHelper() {} @@ -47,7 +51,7 @@ private SerializerHelper() {} * @param out the output stream to write the string to. * @param str the string value to be written. */ - public static void writeLongUTF(DataOutputSerializer out, String str) throws IOException { + public static void writeLongUTF(DataOutputView out, String str) throws IOException { int strlen = str.length(); long utflen = 0; int ch; @@ -85,7 +89,7 @@ public static void writeLongUTF(DataOutputSerializer out, String str) throws IOE * @return the string value read from the input stream. * @throws IOException if an I/O error occurs when reading from the input stream. */ - public static String readLongUTF(DataInputDeserializer in) throws IOException { + public static String readLongUTF(DataInputView in) throws IOException { int utflen = in.readInt(); byte[] bytearr = new byte[utflen]; char[] chararr = new char[utflen]; @@ -168,8 +172,7 @@ private static int getUTFBytesSize(int ch) { } } - private static void writeUTFBytes(DataOutputSerializer out, String str, int utflen) - throws IOException { + private static void writeUTFBytes(DataOutputView out, String str, int utflen) throws IOException { int strlen = str.length(); int ch; diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java index e2cd411d7069..795c4fa5a766 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java @@ -75,6 +75,11 @@ public static class Primitives implements DataGenerator { OffsetDateTime.of(2022, 1, 10, 0, 0, 0, 0, ZoneOffset.UTC); private static final LocalDateTime JAVA_LOCAL_DATE_TIME_20220110 = LocalDateTime.of(2022, 1, 10, 0, 0, 0); + private static final OffsetDateTime JAVA_OFFSET_DATE_TIME_MAX_NANO = + OffsetDateTime.of(2262, 4, 11, 23, 47, 16, 854_775_807, ZoneOffset.UTC); + private static final LocalDateTime JAVA_LOCAL_DATE_TIME_MAX_NANO = + LocalDateTime.of(2262, 4, 11, 23, 47, 16, 854_775_807); + private static final long ICEBERG_MAX_NANOS_EPOCH = 9223372036854775807L; private static final BigDecimal BIG_DECIMAL_NEGATIVE = new BigDecimal("-1.50"); private static final byte[] FIXED_BYTES = "012345689012345".getBytes(StandardCharsets.UTF_8); @@ -96,7 +101,11 @@ public static class Primitives implements DataGenerator { Types.NestedField.required(12, "uuid_field", Types.UUIDType.get()), Types.NestedField.required(13, "binary_field", Types.BinaryType.get()), Types.NestedField.required(14, "decimal_field", Types.DecimalType.of(9, 2)), - Types.NestedField.required(15, "fixed_field", Types.FixedType.ofLength(16))); + Types.NestedField.required(15, "fixed_field", Types.FixedType.ofLength(16)), + Types.NestedField.required( + 16, "ts_ns_with_zone_field", Types.TimestampNanoType.withZone()), + Types.NestedField.required( + 17, "ts_ns_without_zone_field", Types.TimestampNanoType.withoutZone())); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -171,6 +180,8 @@ public GenericRecord generateIcebergGenericRecord() { genericRecord.setField("time_field", JAVA_LOCAL_TIME_HOUR8); genericRecord.setField("ts_with_zone_field", JAVA_OFFSET_DATE_TIME_20220110); genericRecord.setField("ts_without_zone_field", JAVA_LOCAL_DATE_TIME_20220110); + genericRecord.setField("ts_ns_with_zone_field", JAVA_OFFSET_DATE_TIME_MAX_NANO); + genericRecord.setField("ts_ns_without_zone_field", JAVA_LOCAL_DATE_TIME_MAX_NANO); byte[] uuidBytes = new byte[16]; for (int i = 0; i < 16; ++i) { @@ -220,7 +231,11 @@ public GenericRowData generateFlinkRowData() { uuidBytes, binaryBytes, DecimalData.fromBigDecimal(BIG_DECIMAL_NEGATIVE, 9, 2), - FIXED_BYTES); + FIXED_BYTES, + TimestampData.fromEpochMillis( + ICEBERG_MAX_NANOS_EPOCH / 1_000_000, (int) (ICEBERG_MAX_NANOS_EPOCH % 1_000_000)), + TimestampData.fromEpochMillis( + ICEBERG_MAX_NANOS_EPOCH / 1_000_000, (int) (ICEBERG_MAX_NANOS_EPOCH % 1_000_000))); } @Override @@ -236,10 +251,12 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { genericRecord.put("date_field", DAYS_BTW_EPOC_AND_20220110); genericRecord.put("time_field", HOUR_8_IN_MILLI); - // Although Avro logical type for timestamp fields are in micro seconds, - // AvroToRowDataConverters only looks for long value in milliseconds. - genericRecord.put("ts_with_zone_field", JODA_DATETIME_20220110.getMillis()); - genericRecord.put("ts_without_zone_field", JODA_DATETIME_20220110.getMillis()); + // Now that AvroToRowDataConverters correctly supports microseconds, + // we must inject correct microsecond scale values into the Avro data. + genericRecord.put("ts_with_zone_field", JODA_DATETIME_20220110.getMillis() * 1000L); + genericRecord.put("ts_without_zone_field", JODA_DATETIME_20220110.getMillis() * 1000L); + genericRecord.put("ts_ns_with_zone_field", ICEBERG_MAX_NANOS_EPOCH); + genericRecord.put("ts_ns_without_zone_field", ICEBERG_MAX_NANOS_EPOCH); byte[] uuidBytes = new byte[16]; for (int i = 0; i < 16; ++i) { @@ -554,7 +571,11 @@ public static class ArrayOfPrimitive implements DataGenerator { new Schema( Types.NestedField.required(1, "row_id", Types.StringType.get()), Types.NestedField.required( - 2, "array_of_int", Types.ListType.ofOptional(101, Types.IntegerType.get()))); + 2, "array_of_int", Types.ListType.ofOptional(101, Types.IntegerType.get())), + Types.NestedField.optional( + 3, + "array_of_ts_ns", + Types.ListType.ofRequired(102, Types.TimestampNanoType.withoutZone()))); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -581,13 +602,33 @@ public GenericRecord generateIcebergGenericRecord() { GenericRecord genericRecord = GenericRecord.create(icebergSchema); genericRecord.setField("row_id", "row_id_value"); genericRecord.setField("array_of_int", Arrays.asList(1, 2, 3)); + + LocalDateTime posNanos = LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789); + LocalDateTime negNanos = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321); + genericRecord.setField("array_of_ts_ns", Arrays.asList(posNanos, negNanos)); return genericRecord; } @Override public GenericRowData generateFlinkRowData() { Integer[] arr = {1, 2, 3}; - return GenericRowData.of(StringData.fromString("row_id_value"), new GenericArrayData(arr)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + TimestampData[] tsArr = { + TimestampData.fromEpochMillis( + Math.floorDiv(posNanos, 1_000_000L), (int) Math.floorMod(posNanos, 1_000_000L)), + TimestampData.fromEpochMillis( + Math.floorDiv(negNanos, 1_000_000L), (int) Math.floorMod(negNanos, 1_000_000L)) + }; + return GenericRowData.of( + StringData.fromString("row_id_value"), + new GenericArrayData(arr), + new GenericArrayData(tsArr)); } @Override @@ -595,6 +636,14 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { org.apache.avro.generic.GenericRecord genericRecord = new GenericData.Record(avroSchema); genericRecord.put("row_id", "row_id_value"); genericRecord.put("array_of_int", Arrays.asList(1, 2, 3)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + genericRecord.put("array_of_ts_ns", Arrays.asList(posNanos, negNanos)); return genericRecord; } } @@ -808,7 +857,12 @@ public static class MapOfPrimitives implements DataGenerator { 2, "map_of_primitives", Types.MapType.ofRequired( - 101, 102, Types.StringType.get(), Types.IntegerType.get()))); + 101, 102, Types.StringType.get(), Types.IntegerType.get())), + Types.NestedField.optional( + 3, + "map_of_ts_ns", + Types.MapType.ofRequired( + 103, 104, Types.StringType.get(), Types.TimestampNanoType.withoutZone()))); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -835,15 +889,37 @@ public GenericRecord generateIcebergGenericRecord() { GenericRecord genericRecord = GenericRecord.create(icebergSchema); genericRecord.setField("row_id", "row_id_value"); genericRecord.setField("map_of_primitives", ImmutableMap.of("Jane", 1, "Joe", 2)); + + LocalDateTime posNanos = LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789); + LocalDateTime negNanos = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321); + genericRecord.setField( + "map_of_ts_ns", ImmutableMap.of("positive", posNanos, "negative", negNanos)); return genericRecord; } @Override public GenericRowData generateFlinkRowData() { + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + return GenericRowData.of( StringData.fromString("row_id_value"), new GenericMapData( - ImmutableMap.of(StringData.fromString("Jane"), 1, StringData.fromString("Joe"), 2))); + ImmutableMap.of(StringData.fromString("Jane"), 1, StringData.fromString("Joe"), 2)), + new GenericMapData( + ImmutableMap.of( + StringData.fromString("positive"), + TimestampData.fromEpochMillis( + Math.floorDiv(posNanos, 1_000_000L), + (int) Math.floorMod(posNanos, 1_000_000L)), + StringData.fromString("negative"), + TimestampData.fromEpochMillis( + Math.floorDiv(negNanos, 1_000_000L), + (int) Math.floorMod(negNanos, 1_000_000L))))); } @Override @@ -851,6 +927,15 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { org.apache.avro.generic.GenericRecord genericRecord = new GenericData.Record(avroSchema); genericRecord.put("row_id", "row_id_value"); genericRecord.put("map_of_primitives", ImmutableMap.of("Jane", 1, "Joe", 2)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + genericRecord.put( + "map_of_ts_ns", ImmutableMap.of("positive", posNanos, "negative", negNanos)); return genericRecord; } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java index 03d96ac2c573..e5e478fe4ad1 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java @@ -39,11 +39,13 @@ import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.ManifestReader; import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.maintenance.api.LockConfig; import org.apache.iceberg.flink.source.BoundedTableFactory; import org.apache.iceberg.flink.source.BoundedTestSource; import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; @@ -51,7 +53,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +@ExtendWith(ParameterizedTestExtension.class) public class TestFlinkTableSinkCompaction extends CatalogTestBase { private static final TypeInformation ROW_TYPE_INFO = @@ -75,15 +79,31 @@ public class TestFlinkTableSinkCompaction extends CatalogTestBase { + "'flink-maintenance.rewrite.rewrite-all'='true'," + "'flink-maintenance.rewrite.schedule.data-file-size'='1'," + "'flink-maintenance.lock-check-delay-seconds'='60'"; + private static final String TABLE_PROPERTIES_COORDINATOR = + "'flink-maintenance.rewrite.rewrite-all'='true'," + + "'flink-maintenance.rewrite.schedule.data-file-size'='1'," + + "'flink-maintenance.lock-check-delay-seconds'='60'"; @Parameter(index = 2) private boolean userSqlHint; - @Parameters(name = "catalogName={0}, baseNamespace={1}, userSqlHint={2}") + @Parameter(index = 3) + private String lockType; + + @Parameters(name = "catalogName={0}, baseNamespace={1}, userSqlHint={2}, lockType={3}") public static List parameters() { return Arrays.asList( - new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), true}, - new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), false}); + new Object[] { + "testhadoop_basenamespace", Namespace.of("l0", "l1"), true, LockConfig.JdbcLockConfig.JDBC + }, + new Object[] { + "testhadoop_basenamespace", + Namespace.of("l0", "l1"), + false, + LockConfig.JdbcLockConfig.JDBC + }, + new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), true, ""}, + new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), false, ""}); } @Override @@ -118,7 +138,13 @@ public void before() { if (userSqlHint) { sql("CREATE TABLE %s (id int, data varchar)", TABLE_NAME); } else { - sql("CREATE TABLE %s (id int, data varchar) with (%s)", TABLE_NAME, TABLE_PROPERTIES); + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + sql("CREATE TABLE %s (id int, data varchar) with (%s)", TABLE_NAME, TABLE_PROPERTIES); + } else { + sql( + "CREATE TABLE %s (id int, data varchar) with (%s)", + TABLE_NAME, TABLE_PROPERTIES_COORDINATOR); + } } icebergTable = validationCatalog.loadTable(TableIdentifier.of(icebergNamespace, TABLE_NAME)); @@ -144,9 +170,15 @@ public void testSQLCompactionE2e() throws Exception { // Redirect the records from source table to destination table. if (userSqlHint) { - sql( - "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", - TABLE_NAME, TABLE_PROPERTIES); + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + sql( + "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", + TABLE_NAME, TABLE_PROPERTIES); + } else { + sql( + "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", + TABLE_NAME, TABLE_PROPERTIES_COORDINATOR); + } } else { sql("INSERT INTO %s SELECT id,data from sourceTable", TABLE_NAME); } @@ -174,7 +206,8 @@ public void testSQLCompactionE2e() throws Exception { private List getDataFiles(Snapshot snapshot, Table table) throws IOException { List dataFiles = Lists.newArrayList(); for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { - try (ManifestReader reader = ManifestFiles.read(dataManifest, table.io())) { + try (ManifestReader reader = + ManifestFiles.read(dataManifest, table.io(), table.specs())) { reader.iterator().forEachRemaining(dataFiles::add); } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java index 3afabf6e0795..2cc94bfe4977 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java @@ -50,6 +50,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.Namespace; @@ -356,7 +357,14 @@ public void testRangeDistributionPartitionColumn() { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -371,7 +379,12 @@ public void testRangeDistributionPartitionColumn() { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // range partition results in each partition only assigned to one writer task // maybe less than 26 partitions as BoundedSource doesn't always precisely // control the checkpoint boundary. diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java new file mode 100644 index 000000000000..aaf0ebda0e2b --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink; + +import static org.apache.iceberg.flink.TestFixtures.DATABASE; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.util.List; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericAppenderHelper; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.sink.TaskWriterFactory; +import org.apache.iceberg.flink.source.DataIterator; +import org.apache.iceberg.flink.source.reader.ReaderUtil; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.TaskWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +class TestFlinkUnknownType { + private static final long TARGET_FILE_SIZE = 128 * 1024 * 1024; + private static final Schema SCHEMA_WITH_UNKNOWN_COL = + new Schema( + Lists.newArrayList( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "unknown_col", Types.UnknownType.get()), + Types.NestedField.optional(4, "data1", Types.StringType.get()))); + private static final List EXCEPTED_ROW_DATA = + Lists.newArrayList( + GenericRowData.of(1, StringData.fromString("data"), null, StringData.fromString("data1")), + GenericRowData.of( + 2, StringData.fromString("data"), null, StringData.fromString("data1"))); + private static final List EXPECTED_RECORDS = exceptedRecords(); + + @RegisterExtension + private static final HadoopCatalogExtension CATALOG_EXTENSION = + new HadoopCatalogExtension(DATABASE, TestFixtures.TABLE); + + @TempDir private Path warehouseDir; + + @Parameter private FileFormat fileFormat; + + private Table table; + + @Parameters(name = "fileFormat={0}") + public static Iterable parameters() { + return ImmutableList.of( + new Object[] {FileFormat.PARQUET}, + new Object[] {FileFormat.AVRO}, + new Object[] {FileFormat.ORC}); + } + + @BeforeEach + public void before() { + table = + CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SCHEMA_WITH_UNKNOWN_COL, + PartitionSpec.unpartitioned(), + null, + ImmutableMap.of("format-version", "3", "write.format.default", fileFormat.name())); + } + + @TestTemplate + void testV3TableUnknownTypeRead() throws Exception { + new GenericAppenderHelper(table, fileFormat, warehouseDir).appendToTable(EXPECTED_RECORDS); + table.refresh(); + + List genericRowData = Lists.newArrayList(); + CloseableIterable combinedScanTasks = table.newScan().planTasks(); + for (CombinedScanTask combinedScanTask : combinedScanTasks) { + DataIterator dataIterator = + ReaderUtil.createDataIterator(combinedScanTask, table.schema(), table.schema()); + while (dataIterator.hasNext()) { + GenericRowData rowData = (GenericRowData) dataIterator.next(); + genericRowData.add( + GenericRowData.of( + rowData.getInt(0), + rowData.getString(1), + rowData.getField(2), + rowData.getString(3))); + } + } + + assertThat(genericRowData).containsExactlyInAnyOrderElementsOf(EXCEPTED_ROW_DATA); + } + + @TestTemplate + void testV3TableUnknownTypeWrite() throws Exception { + try (TaskWriter taskWriter = createTaskWriter()) { + for (GenericRowData rowData : EXCEPTED_ROW_DATA) { + taskWriter.write(rowData); + } + + taskWriter.close(); + AppendFiles appendFiles = table.newAppend(); + for (DataFile dataFile : taskWriter.dataFiles()) { + appendFiles.appendFile(dataFile); + } + + appendFiles.commit(); + List records = SimpleDataUtil.tableRecords(table); + assertThat(records).containsExactlyInAnyOrderElementsOf(exceptedRecords()); + } + } + + private TaskWriter createTaskWriter() { + RowType flinkWriteType = FlinkSchemaUtil.convert(table.schema()); + TaskWriterFactory taskWriterFactory = + new RowDataTaskWriterFactory( + table, flinkWriteType, TARGET_FILE_SIZE, fileFormat, table.properties(), null, false); + taskWriterFactory.initialize(1, 1); + return taskWriterFactory.create(); + } + + private static List exceptedRecords() { + GenericRecord record = GenericRecord.create(SCHEMA_WITH_UNKNOWN_COL); + ImmutableList.Builder builder = ImmutableList.builder(); + EXCEPTED_ROW_DATA.forEach( + recordData -> { + GenericRecord copy = record.copy(); + for (int i = 0; i < recordData.getArity(); i++) { + Object field = recordData.getField(i); + if (field instanceof StringData) { + copy.set(i, recordData.getField(i).toString()); + } else { + copy.set(i, recordData.getField(i)); + } + } + + builder.add(copy); + }); + + return builder.build(); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java new file mode 100644 index 000000000000..cac258e7adcc --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.GenericAppenderHelper; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.source.DataIterator; +import org.apache.iceberg.flink.source.reader.ReaderUtil; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.TaskWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.UUIDUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.io.TempDir; + +class TestFlinkUuidType extends CatalogTestBase { + private static final String TABLE_NAME = "test_table"; + private static final long TARGET_FILE_SIZE = 128 * 1024 * 1024; + private static final UUID EXPECTED_UUID = UUID.fromString("0f8fad5b-d9cb-469f-a165-70867728950e"); + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "uuid", Types.UUIDType.get())); + + private Table icebergTable; + @TempDir private Path warehouseDir; + + @Parameter(index = 2) + private FileFormat fileFormat; + + @Parameters(name = "catalogName={0}, baseNamespace={1}, fileFormat={2}") + protected static List parameters() { + return Arrays.asList( + new Object[] {"testhadoop", Namespace.empty(), FileFormat.PARQUET}, + new Object[] {"testhadoop", Namespace.empty(), FileFormat.AVRO}, + new Object[] {"testhadoop", Namespace.empty(), FileFormat.ORC}); + } + + @Override + @BeforeEach + public void before() { + super.before(); + sql("CREATE DATABASE %s", flinkDatabase); + sql("USE CATALOG %s", catalogName); + sql("USE %s", DATABASE); + } + + /** Writes UUID via Generic writer, reads via Flink. */ + @TestTemplate + void uuidWrittenByGenericWriter() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + Record record = + GenericRecord.create(icebergTable.schema()).copy("id", 1, "uuid", EXPECTED_UUID); + new GenericAppenderHelper(icebergTable, fileFormat, warehouseDir) + .appendToTable(ImmutableList.of(record)); + icebergTable.refresh(); + + List genericRowData = Lists.newArrayList(); + try (CloseableIterable combinedScanTasks = + icebergTable.newScan().planTasks()) { + for (CombinedScanTask combinedScanTask : combinedScanTasks) { + try (DataIterator dataIterator = + ReaderUtil.createDataIterator( + combinedScanTask, icebergTable.schema(), icebergTable.schema())) { + while (dataIterator.hasNext()) { + GenericRowData rowData = (GenericRowData) dataIterator.next(); + genericRowData.add(rowData); + } + } + } + } + + assertThat(genericRowData).hasSize(1); + assertThat(genericRowData.get(0).getField(1)).isInstanceOf(byte[].class); + byte[] bytes = (byte[]) genericRowData.get(0).getField(1); + assertThat(bytes).hasSize(16); + + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + UUID actualUuid = new UUID(byteBuffer.getLong(), byteBuffer.getLong()); + assertThat(actualUuid).isEqualTo(EXPECTED_UUID); + } + + /** Writes UUID via Flink TaskWriter, reads via Generic reader. */ + @TestTemplate + void writeUuidViaFlinkWriter() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + RowType rowType = FlinkSchemaUtil.convert(SCHEMA); + RowDataTaskWriterFactory rowDataTaskWriterFactory = + new RowDataTaskWriterFactory( + icebergTable, + rowType, + TARGET_FILE_SIZE, + fileFormat, + icebergTable.properties(), + null, + false); + rowDataTaskWriterFactory.initialize(1, 1); + + byte[] uuidBytes = UUIDUtil.convert(EXPECTED_UUID); + GenericRowData genericRowData = GenericRowData.of(1, uuidBytes); + + try (TaskWriter writer = rowDataTaskWriterFactory.create()) { + writer.write(genericRowData); + writer.close(); + + AppendFiles append = icebergTable.newAppend(); + for (DataFile dataFile : writer.dataFiles()) { + append.appendFile(dataFile); + } + + append.commit(); + } + + List records = SimpleDataUtil.tableRecords(icebergTable); + assertThat(records).hasSize(1); + assertThat(records.get(0).getField("uuid")).isEqualTo(EXPECTED_UUID); + } + + /** Writes UUID via SQL INSERT, reads via Generic reader. */ + @TestTemplate + void sqlInsertUuid() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + String uuidHex = EXPECTED_UUID.toString().replace("-", ""); + sql("INSERT INTO %s VALUES (1, CAST(X'%s' AS BINARY(16)))", TABLE_NAME, uuidHex); + + List records = SimpleDataUtil.tableRecords(icebergTable); + assertThat(records).hasSize(1); + assertThat(records.get(0).getField("uuid")).isEqualTo(EXPECTED_UUID); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java index 0a6d5e44caa2..309b55c115c5 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java @@ -25,23 +25,37 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.util.Map; +import org.apache.flink.api.common.functions.OpenContext; import org.apache.flink.configuration.CoreOptions; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.table.api.EnvironmentSettings; import org.apache.flink.table.api.TableEnvironment; import org.apache.flink.table.api.bridge.java.StreamTableEnvironment; import org.apache.flink.table.catalog.Catalog; +import org.apache.flink.table.catalog.CatalogDatabaseImpl; import org.apache.flink.table.catalog.ObjectPath; +import org.apache.flink.table.catalog.exceptions.DatabaseAlreadyExistException; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.types.Row; +import org.apache.flink.util.Collector; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.DistributionMode; import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.sink.dynamic.DynamicRecord; +import org.apache.iceberg.flink.sink.dynamic.DynamicTableRecordGenerator; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; import org.apache.thrift.TException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.TestTemplate; @@ -70,16 +84,25 @@ public static Iterable parameters() { new Object[] { "testhadoop", ImmutableMap.of( - "connector", "iceberg", - "catalog-type", "hadoop"), + "connector", + "iceberg", + "catalog-type", + "hadoop", + "catalog-database", + "test_database"), true }, new Object[] { "testhadoop", ImmutableMap.of( - "connector", "iceberg", - "catalog-type", "hadoop", - "catalog-table", "not_existing_table"), + "connector", + "iceberg", + "catalog-type", + "hadoop", + "catalog-database", + "test_database", + "catalog-table", + "not_existing_table"), true }, new Object[] { @@ -328,4 +351,126 @@ private String createWarehouse() { throw new UncheckedIOException(e); } } + + @TestTemplate + public void testCreateDynamicIcebergSink() throws DatabaseAlreadyExistException { + Map tableProps = createTableProps(); + Map dynamicTableProps = Maps.newHashMap(tableProps); + dynamicTableProps.put("use-dynamic-iceberg-sink", "true"); + dynamicTableProps.put( + "dynamic-record-generator-impl", SimpleRowDataTableRecordGenerator.class.getName()); + dynamicTableProps.put("table.props.key1", "val1"); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + FlinkCatalog flinkCatalog = + (FlinkCatalog) factory.createCatalog(catalogName, tableProps, new Configuration()); + flinkCatalog.createDatabase( + databaseName(), new CatalogDatabaseImpl(Maps.newHashMap(), null), true); + + // Create table with dynamic sink enabled + sql( + "CREATE TABLE %s (id BIGINT, data STRING, database_name STRING, table_name STRING) WITH %s", + TABLE_NAME + "_dynamic", toWithClause(dynamicTableProps)); + + // Insert data with database and table information + sql( + "INSERT INTO %s VALUES (1, 'AAA', '%s', '%s'), (2, 'BBB', '%s', '%s'), (3, 'CCC', '%s', '%s')", + TABLE_NAME + "_dynamic", + databaseName(), + tableName(), + databaseName(), + tableName(), + databaseName(), + tableName()); + + // Verify the table and data exists + ObjectPath objectPath = new ObjectPath(databaseName(), tableName()); + assertThat(flinkCatalog.tableExists(objectPath)).isTrue(); + Table table = + flinkCatalog + .getCatalogLoader() + .loadCatalog() + .loadTable(TableIdentifier.of(databaseName(), tableName())); + assertThat(table.properties()).containsEntry("key1", "val1"); + + tableProps.put("catalog-database", databaseName()); + sql("CREATE TABLE %s (id BIGINT, data STRING) WITH %s", tableName(), toWithClause(tableProps)); + assertThat(sql("SELECT * FROM %s", tableName())) + .containsExactlyInAnyOrder(Row.of(1L, "AAA"), Row.of(2L, "BBB"), Row.of(3L, "CCC")); + } + + @TestTemplate + public void testMissingDynamicRecordGeneratorImpl() throws DatabaseAlreadyExistException { + Map tableProps = createTableProps(); + tableProps.put("use-dynamic-iceberg-sink", "true"); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + FlinkCatalog flinkCatalog = + (FlinkCatalog) factory.createCatalog(catalogName, tableProps, new Configuration()); + flinkCatalog.createDatabase( + databaseName(), new CatalogDatabaseImpl(Maps.newHashMap(), null), true); + + sql( + "CREATE TABLE %s (id BIGINT, data STRING, database_name STRING, table_name STRING) WITH %s", + TABLE_NAME + "_dynamic", toWithClause(tableProps)); + + assertThatThrownBy( + () -> + sql( + "INSERT INTO %s VALUES (1, 'AAA', '%s', '%s')", + TABLE_NAME + "_dynamic", databaseName(), tableName())) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Invalid dynamic record generator value: null. dynamic-record-generator-impl must be specified when use-dynamic-iceberg-sink is true."); + } + + public static class SimpleRowDataTableRecordGenerator extends DynamicTableRecordGenerator { + + private int databaseFieldIndex = -1; + private int tableFieldIndex = -1; + + public SimpleRowDataTableRecordGenerator(RowType rowType) { + super(rowType); + } + + @Override + public void open(OpenContext openContext) throws Exception { + String[] fieldNames = rowType().getFieldNames().toArray(new String[0]); + + for (int i = 0; i < fieldNames.length; i++) { + if ("database_name".equals(fieldNames[i])) { + databaseFieldIndex = i; + } else if ("table_name".equals(fieldNames[i])) { + tableFieldIndex = i; + } + } + } + + @Override + public void generate(RowData inputRecord, Collector out) throws Exception { + // Extract database and table names using the discovered field indexes + String databaseName = inputRecord.getString(databaseFieldIndex).toString(); + String tableName = inputRecord.getString(tableFieldIndex).toString(); + + // Create schema for the actual data fields (excluding metadata fields) + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.required(1, "data", Types.StringType.get())); + + TableIdentifier tableIdentifier = TableIdentifier.of(databaseName, tableName); + + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + "main", + schema, + inputRecord, + PartitionSpec.unpartitioned(), + DistributionMode.NONE, + 1); + out.collect(dynamicRecord); + } + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java new file mode 100644 index 000000000000..1f0fe70ac53b --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.data; + +import java.util.List; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseFormatModelTests; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.RowDataConverter; +import org.apache.iceberg.flink.TestHelpers; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +public class TestFlinkFormatModel extends BaseFormatModelTests { + + @Override + protected Class engineType() { + return RowData.class; + } + + @Override + protected Object engineSchema(Schema schema) { + return FlinkSchemaUtil.convert(schema); + } + + @Override + protected RowData convertToEngine(Record record, Schema schema) { + return RowDataConverter.convert(schema, record); + } + + @Override + protected void assertEquals(Schema schema, List expected, List actual) { + TestHelpers.assertRows(actual, expected, FlinkSchemaUtil.convert(schema)); + } + + @Override + protected Object convertConstantToEngine(Type type, Object value) { + if (value instanceof PartitionData partitionData) { + Types.StructType structType = type.asStructType(); + List fields = structType.fields(); + GenericRowData rowData = new GenericRowData(fields.size()); + int sourceSize = partitionData.size(); + for (int i = 0; i < fields.size(); i++) { + if (i < sourceSize) { + Object fieldValue = partitionData.get(i, Object.class); + rowData.setField(i, convertConstantToEngine(fields.get(i).type(), fieldValue)); + } else { + rowData.setField(i, null); + } + } + + return rowData; + } + + return RowDataUtil.convertConstant(type, value); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java index 4a70802f2a2e..b7b0a54156cc 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java @@ -49,6 +49,11 @@ protected boolean allowsWritingNullValuesForRequiredFields() { return true; } + @Override + protected boolean supportsTimestampNanos() { + return true; + } + @Override protected void writeAndValidate(Schema schema) throws IOException { List expectedRecords = RandomGenericData.generate(schema, NUM_RECORDS, 1990L); diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java index d181d3351410..1eaf539df4a0 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java @@ -25,9 +25,8 @@ import java.util.Iterator; import java.util.List; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.data.binary.BinaryRowData; -import org.apache.flink.table.runtime.typeutils.RowDataSerializer; import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.Schema; import org.apache.iceberg.data.DataTestBase; import org.apache.iceberg.data.RandomGenericData; @@ -59,63 +58,66 @@ protected boolean supportsTimestampNanos() { return true; } - private void writeAndValidate(Iterable iterable, Schema schema) throws IOException { - OutputFile outputFile = new InMemoryOutputFile(); + @Override + protected boolean supportsDefaultValues() { + return true; + } - LogicalType logicalType = FlinkSchemaUtil.convert(schema); + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema, List data) + throws IOException { + OutputFile outputFile = new InMemoryOutputFile(); + LogicalType logicalType = FlinkSchemaUtil.convert(writeSchema); try (FileAppender writer = Parquet.write(outputFile) - .schema(schema) + .schema(writeSchema) .createWriterFunc(msgType -> FlinkParquetWriters.buildWriter(logicalType, msgType)) .build()) { - writer.addAll(iterable); + writer.addAll(RandomRowData.convert(writeSchema, data)); } try (CloseableIterable reader = Parquet.read(outputFile.toInputFile()) - .project(schema) - .createReaderFunc(msgType -> GenericParquetReaders.buildReader(schema, msgType)) + .project(expectedSchema) + .createReaderFunc( + fileSchema -> GenericParquetReaders.buildReader(expectedSchema, fileSchema)) .build()) { - Iterator expected = iterable.iterator(); Iterator actual = reader.iterator(); - LogicalType rowType = FlinkSchemaUtil.convert(schema); - for (int i = 0; i < NUM_RECORDS; i += 1) { + RowType rowType = FlinkSchemaUtil.convert(expectedSchema); + for (Record expected : data) { assertThat(actual).hasNext(); - TestHelpers.assertRowData(schema.asStruct(), rowType, actual.next(), expected.next()); + RowData actualRowData = RowDataConverter.convert(expectedSchema, actual.next()); + TestHelpers.assertRowData(expectedSchema.asStruct(), rowType, expected, actualRowData); } + assertThat(actual).isExhausted(); } } @Override protected void writeAndValidate(Schema schema) throws IOException { - writeAndValidate(RandomRowData.generate(schema, NUM_RECORDS, 19981), schema); + writeAndValidate(schema, RandomGenericData.generate(schema, NUM_RECORDS, 19981)); writeAndValidate( - RandomRowData.convert( - schema, - RandomGenericData.generateDictionaryEncodableRecords(schema, NUM_RECORDS, 21124)), - schema); + schema, + Lists.newArrayList( + RandomGenericData.generateDictionaryEncodableRecords(schema, NUM_RECORDS, 21124))); writeAndValidate( - RandomRowData.convert( - schema, + schema, + Lists.newArrayList( RandomGenericData.generateFallbackRecords( - schema, NUM_RECORDS, 21124, NUM_RECORDS / 20)), - schema); + schema, NUM_RECORDS, 21124, NUM_RECORDS / 20))); } @Override - protected void writeAndValidate(Schema schema, List expectedData) throws IOException { - RowDataSerializer rowDataSerializer = new RowDataSerializer(FlinkSchemaUtil.convert(schema)); - List binaryRowList = Lists.newArrayList(); - for (Record record : expectedData) { - RowData rowData = RowDataConverter.convert(schema, record); - BinaryRowData binaryRow = rowDataSerializer.toBinaryRow(rowData); - binaryRowList.add(binaryRow); - } + protected void writeAndValidate(Schema schema, List data) throws IOException { + writeAndValidate(schema, schema, data); + } - writeAndValidate(binaryRowList, schema); + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + List data = RandomGenericData.generate(writeSchema, NUM_RECORDS, 1991L); + writeAndValidate(writeSchema, expectedSchema, data); } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java index 4e5b38ffb026..a2411da1e344 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java @@ -271,18 +271,19 @@ public void testMapOfPrimitivesProjection() { GenericRowData.of( StringData.fromString("row_id_value"), new GenericMapData( - ImmutableMap.of(StringData.fromString("foo"), 1, StringData.fromString("bar"), 2))); + ImmutableMap.of(StringData.fromString("foo"), 1, StringData.fromString("bar"), 2)), + null); testEqualsAndHashCode(schema, idOnly, rowData, copyRowData, otherRowData, true); testEqualsAndHashCode(schema, mapOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, schema, rowData, copyRowData, otherRowData); GenericRowData rowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("row_id_value"), null); + GenericRowData.of(StringData.fromString("row_id_value"), null, null); GenericRowData copyRowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("row_id_value"), null); + GenericRowData.of(StringData.fromString("row_id_value"), null, null); // modify the map field value GenericRowData otherRowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("other_row_id_value"), null); + GenericRowData.of(StringData.fromString("other_row_id_value"), null, null); testEqualsAndHashCode( schema, idOnly, @@ -432,7 +433,8 @@ public void testArrayOfPrimitiveProjection() { GenericRowData otherRowData = GenericRowData.of( StringData.fromString("other_row_id_value"), - new GenericArrayData(new Integer[] {4, 5, 6})); + new GenericArrayData(new Integer[] {4, 5, 6}), + null); testEqualsAndHashCode(schema, idOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, arrayOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, schema, rowData, copyRowData, otherRowData); @@ -440,16 +442,19 @@ public void testArrayOfPrimitiveProjection() { GenericRowData rowDataNullOptionalFields = GenericRowData.of( StringData.fromString("row_id_value"), - new GenericArrayData(new Integer[] {1, null, 3})); + new GenericArrayData(new Integer[] {1, null, 3}), + null); GenericRowData copyRowDataNullOptionalFields = GenericRowData.of( StringData.fromString("row_id_value"), - new GenericArrayData(new Integer[] {1, null, 3})); + new GenericArrayData(new Integer[] {1, null, 3}), + null); // modify the map field value GenericRowData otherRowDataNullOptionalFields = GenericRowData.of( StringData.fromString("other_row_id_value"), - new GenericArrayData(new Integer[] {4, null, 6})); + new GenericArrayData(new Integer[] {4, null, 6}), + null); testEqualsAndHashCode( schema, idOnly, diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java new file mode 100644 index 000000000000..f26f608ffa61 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.flink.configuration.Configuration; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles.PrefixMismatchMode; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestDeleteOrphanFilesConfig extends OperatorTestBase { + private Table table; + private Map input = Maps.newHashMap(); + + @BeforeEach + public void before() { + this.table = createTable(); + input.put(DeleteOrphanFilesConfig.SCHEDULE_ON_INTERVAL_SECOND, "60"); + input.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + input.put(DeleteOrphanFilesConfig.DELETE_BATCH_SIZE, "500"); + input.put(DeleteOrphanFilesConfig.LOCATION, "/tmp/test-location"); + input.put(DeleteOrphanFilesConfig.USE_PREFIX_LISTING, "true"); + input.put(DeleteOrphanFilesConfig.PLANNING_WORKER_POOL_SIZE, "4"); + input.put(DeleteOrphanFilesConfig.EQUAL_SCHEMES, "s3n=s3,s3a=s3"); + input.put(DeleteOrphanFilesConfig.EQUAL_AUTHORITIES, "auth1=auth2"); + input.put(DeleteOrphanFilesConfig.PREFIX_MISMATCH_MODE, "IGNORE"); + input.put("other.config", "should-be-ignored"); + } + + @AfterEach + public void after() { + input.clear(); + } + + @Test + void testConfigParsing() { + DeleteOrphanFilesConfig config = new DeleteOrphanFilesConfig(table, input, new Configuration()); + + assertThat(config.scheduleOnIntervalSecond()).isEqualTo(60); + assertThat(config.minAgeSeconds()).isEqualTo(86400L); + assertThat(config.deleteBatchSize()).isEqualTo(500); + assertThat(config.location()).isEqualTo("/tmp/test-location"); + assertThat(config.usePrefixListing()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(4); + assertThat(config.equalSchemes()).containsEntry("s3n", "s3").containsEntry("s3a", "s3"); + assertThat(config.equalAuthorities()).containsEntry("auth1", "auth2"); + assertThat(config.prefixMismatchMode()).isEqualTo(PrefixMismatchMode.IGNORE); + } + + @Test + void testConfigDefaults() { + DeleteOrphanFilesConfig config = + new DeleteOrphanFilesConfig(table, Maps.newHashMap(), new Configuration()); + + assertThat(config.scheduleOnIntervalSecond()) + .isEqualTo(DeleteOrphanFilesConfig.SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()); + assertThat(config.minAgeSeconds()) + .isEqualTo(DeleteOrphanFilesConfig.MIN_AGE_SECONDS_OPTION.defaultValue()); + assertThat(config.deleteBatchSize()) + .isEqualTo(DeleteOrphanFilesConfig.DELETE_BATCH_SIZE_OPTION.defaultValue()); + assertThat(config.location()).isNull(); + assertThat(config.usePrefixListing()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(ThreadPools.WORKER_THREAD_POOL_SIZE); + assertThat(config.equalSchemes()).containsEntry("s3n", "s3").containsEntry("s3a", "s3"); + assertThat(config.equalAuthorities()).isEqualTo(Map.of()); + assertThat(config.prefixMismatchMode()).isEqualTo(PrefixMismatchMode.ERROR); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java index b8aa259e2f17..a23850d82341 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java @@ -164,7 +164,7 @@ void testUidAndSlotSharingGroupUnset() { 0, tableLoader(), UID_SUFFIX, - StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP, + null, 1) .sinkTo(infra.sink()); diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java new file mode 100644 index 000000000000..3bcec8114b0c --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.flink.configuration.Configuration; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestExpireSnapshotsConfig extends OperatorTestBase { + private Table table; + private Map input = Maps.newHashMap(); + + @BeforeEach + public void before() { + this.table = createTable(); + input.put(ExpireSnapshotsConfig.SCHEDULE_ON_COMMIT_COUNT, "10"); + input.put(ExpireSnapshotsConfig.SCHEDULE_ON_INTERVAL_SECOND, "60"); + input.put(ExpireSnapshotsConfig.MAX_SNAPSHOT_AGE_SECONDS, "7200"); + input.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + input.put(ExpireSnapshotsConfig.DELETE_BATCH_SIZE, "500"); + input.put(ExpireSnapshotsConfig.CLEAN_EXPIRED_METADATA, "true"); + input.put(ExpireSnapshotsConfig.PLANNING_WORKER_POOL_SIZE, "4"); + input.put("other.config", "should-be-ignored"); + } + + @AfterEach + public void after() { + input.clear(); + } + + @Test + void testConfigParsing() { + ExpireSnapshotsConfig config = new ExpireSnapshotsConfig(table, input, new Configuration()); + + assertThat(config.scheduleOnCommitCount()).isEqualTo(10); + assertThat(config.scheduleOnIntervalSecond()).isEqualTo(60); + assertThat(config.maxSnapshotAgeSeconds()).isEqualTo(7200L); + assertThat(config.retainLast()).isEqualTo(5); + assertThat(config.deleteBatchSize()).isEqualTo(500); + assertThat(config.cleanExpiredMetadata()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(4); + } + + @Test + void testConfigDefaults() { + ExpireSnapshotsConfig config = + new ExpireSnapshotsConfig(table, Maps.newHashMap(), new Configuration()); + + assertThat(config.scheduleOnCommitCount()) + .isEqualTo(ExpireSnapshotsConfig.SCHEDULE_ON_COMMIT_COUNT_OPTION.defaultValue()); + assertThat(config.scheduleOnIntervalSecond()) + .isEqualTo(ExpireSnapshotsConfig.SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()); + assertThat(config.maxSnapshotAgeSeconds()).isNull(); + assertThat(config.retainLast()).isNull(); + assertThat(config.deleteBatchSize()) + .isEqualTo(ExpireSnapshotsConfig.DELETE_BATCH_SIZE_OPTION.defaultValue()); + assertThat(config.cleanExpiredMetadata()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(ThreadPools.WORKER_THREAD_POOL_SIZE); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java index 3cb18ffbb77e..4d35792e440e 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java @@ -19,11 +19,18 @@ package org.apache.iceberg.flink.maintenance.api; import static org.apache.iceberg.flink.maintenance.api.JdbcLockFactory.INIT_LOCK_TABLES_PROPERTY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.sql.SQLTransientConnectionException; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.iceberg.jdbc.JdbcCatalog; +import org.apache.iceberg.jdbc.JdbcClientPool; +import org.apache.iceberg.jdbc.UncheckedSQLException; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.Test; class TestJdbcLockFactory extends TestLockFactoryBase { @Override @@ -38,4 +45,62 @@ TriggerLockFactory lockFactory(String tableName) { tableName, properties); } + + @Test + void testSQLExceptionEnablesRetryInClientPool() throws Exception { + // Regression test for #15759: verify that removing the inner try-catch allows + // ClientPoolImpl to retry on transient connection failures. + // + // Before the fix: inner catch converted SQLException -> UncheckedSQLException + // (RuntimeException) inside the lambda. ClientPoolImpl only catches the declared + // exception type (SQLException), so RuntimeException bypasses retry entirely. + // After the fix: SQLException propagates naturally, ClientPoolImpl catches it, + // and retries on transient connection exceptions. + Map props = Maps.newHashMap(); + props.put("username", "user"); + props.put("password", "password"); + String uri = "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", ""); + + try (JdbcClientPool pool = new JdbcClientPool(1, uri, props)) { + AtomicInteger attempts = new AtomicInteger(0); + + String result = + pool.run( + conn -> { + if (attempts.incrementAndGet() == 1) { + throw new SQLTransientConnectionException("transient failure"); + } + + return "success"; + }); + + assertThat(result).isEqualTo("success"); + assertThat(attempts.get()).isGreaterThan(1); + } + } + + @Test + void testUncheckedSQLExceptionBypassesRetry() throws Exception { + // Companion test: demonstrates that wrapping SQLException as UncheckedSQLException + // (the OLD behavior before the fix) prevents ClientPoolImpl from retrying. + Map props = Maps.newHashMap(); + props.put("username", "user"); + props.put("password", "password"); + String uri = "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", ""); + + try (JdbcClientPool pool = new JdbcClientPool(1, uri, props)) { + assertThatThrownBy( + () -> + pool.run( + conn -> { + try { + throw new SQLTransientConnectionException("transient failure"); + } catch (java.sql.SQLException e) { + throw new UncheckedSQLException(e, "wrapped"); + } + })) + .isInstanceOf(UncheckedSQLException.class) + .hasMessageContaining("wrapped"); + } + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java index 0a860fec4799..fe8457167a1f 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java @@ -77,4 +77,43 @@ void testE2e() throws Exception { closeJobClient(jobClient); } } + + @Test + void testE2eUseCoordinator() throws Exception { + TableMaintenance.forTable(env, tableLoader()) + .uidSuffix("E2eTestUID") + .rateLimit(Duration.ofMinutes(10)) + .lockCheckDelay(Duration.ofSeconds(10)) + .add( + ExpireSnapshots.builder() + .scheduleOnCommitCount(10) + .maxSnapshotAge(Duration.ofMinutes(10)) + .retainLast(5) + .deleteBatchSize(5) + .parallelism(8)) + .add( + RewriteDataFiles.builder() + .scheduleOnDataFileCount(10) + .partialProgressEnabled(true) + .partialProgressMaxCommits(10) + .maxRewriteBytes(1000L) + .targetFileSizeBytes(1000L) + .minFileSizeBytes(1000L) + .maxFileSizeBytes(1000L) + .minInputFiles(10) + .deleteFileThreshold(10) + .rewriteAll(false) + .maxFileGroupSizeBytes(1000L)) + .append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Just make sure that we are able to instantiate the flow + assertThat(jobClient).isNotNull(); + } finally { + closeJobClient(jobClient); + } + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java index 707038c925d5..c27f6081af5a 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java @@ -29,9 +29,11 @@ import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.REMOVED_DATA_FILE_SIZE_METRIC; import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; import java.util.List; import java.util.stream.StreamSupport; import org.apache.flink.streaming.api.graph.StreamGraphGenerator; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; @@ -43,8 +45,14 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; class TestRewriteDataFiles extends MaintenanceTaskTestBase { + + private static final FileFormat[] FILE_FORMATS = + new FileFormat[] {FileFormat.AVRO, FileFormat.PARQUET, FileFormat.ORC}; + @Test void testRewriteUnpartitioned() throws Exception { Table table = createTable(); @@ -82,13 +90,14 @@ void testRewriteUnpartitioned() throws Exception { createRecord(4, "d"))); } - @Test - void testRewriteUnpartitionedPreserveLineage() throws Exception { - Table table = createTable(3); - insert(table, 1, "a"); - insert(table, 2, "b"); - insert(table, 3, "c"); - insert(table, 4, "d"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewriteUnpartitionedPreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createTable(3, fileFormat); + insert(table, 1, "a", fileFormat); + insert(table, 2, "b", fileFormat); + insert(table, 3, "c", fileFormat); + insert(table, 4, "d", fileFormat); assertFileNum(table, 4, 0); @@ -122,15 +131,17 @@ void testRewriteUnpartitionedPreserveLineage() throws Exception { schema); } - @Test - void testRewriteTheSameFilePreserveLineage() throws Exception { - Table table = createTable(3); - insert(table, 1, "a"); - insert(table, 2, "b"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewriteTheSameFilePreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createTable(3, fileFormat); + insert(table, 1, "a", fileFormat); + insert(table, 2, "b", fileFormat); // Create a file with two lines of data to verify that the rowid is read correctly. insert( table, - ImmutableList.of(SimpleDataUtil.createRecord(3, "c"), SimpleDataUtil.createRecord(4, "d"))); + ImmutableList.of(SimpleDataUtil.createRecord(3, "c"), SimpleDataUtil.createRecord(4, "d")), + fileFormat); assertFileNum(table, 3, 0); @@ -166,13 +177,14 @@ void testRewriteTheSameFilePreserveLineage() throws Exception { schema); } - @Test - void testRewritePartitionedPreserveLineage() throws Exception { - Table table = createPartitionedTable(3); - insertPartitioned(table, 1, "p1"); - insertPartitioned(table, 2, "p1"); - insertPartitioned(table, 3, "p2"); - insertPartitioned(table, 4, "p2"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewritePartitionedPreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createPartitionedTable(3, fileFormat); + insertPartitioned(table, 1, "p1", fileFormat); + insertPartitioned(table, 2, "p1", fileFormat); + insertPartitioned(table, 3, "p2", fileFormat); + insertPartitioned(table, 4, "p2", fileFormat); assertFileNum(table, 4, 0); @@ -328,7 +340,7 @@ void testUidAndSlotSharingGroupUnset() { 0, tableLoader(), UID_SUFFIX, - StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP, + null, 1) .sinkTo(infra.sink()); @@ -529,6 +541,92 @@ void testRewriteWithFilter() throws Exception { createRecord(4, "d"))); } + /** + * By verifying that the creation time of the data content in the builder is later than the + * creation time of the filter condition — if the filter condition is actually created in the + * planner, then all files can be compacted; otherwise, not all files can be compacted — we can + * confirm whether the filter condition is actually created in the planner. + */ + @Test + void testRewriteWithFilterSupplier() throws Exception { + Table table = createTable(); + + appendRewriteDataFiles( + RewriteDataFiles.builder() + .parallelism(2) + .deleteFileThreshold(10) + .targetFileSizeBytes(1_000_000L) + .maxFileGroupSizeBytes(10_000_000L) + .maxFileSizeBytes(2_000_000L) + .minFileSizeBytes(500_000L) + .minInputFiles(2) + // Rewrite data files where id is less than current timestamp in planner + .filter(() -> Expressions.lessThan("id", (int) Instant.now().getEpochSecond())) + .partialProgressEnabled(true) + .partialProgressMaxCommits(1) + .maxRewriteBytes(100_000L) + .rewriteAll(false)); + + insert(table, 1, "a"); + insert(table, 2, "b"); + insert(table, 3, "c"); + + int epochSecond = (int) Instant.now().getEpochSecond(); + insert(table, epochSecond, "d"); + + assertFileNum(table, 4, 0); + + Thread.sleep(1_000L); + runAndWaitForSuccess(infra.env(), infra.source(), infra.sink()); + + // There is four files, only id is less than current timestamp will be rewritten. so expect 2 + // files. + assertFileNum(table, 1, 0); + + SimpleDataUtil.assertTableRecords( + table, + ImmutableList.of( + createRecord(1, "a"), + createRecord(2, "b"), + createRecord(3, "c"), + createRecord(epochSecond, "d"))); + } + + @Test + void testBranch() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + insert(table, 2, "b"); + + // Create branch based on above inserts + String branchName = "test-branch"; + table.manageSnapshots().createBranch(branchName).commit(); + + // Insert another file on main only (main has 3 files, branch stays at 2) + insert(table, 3, "c"); + + appendRewriteDataFiles(RewriteDataFiles.builder().rewriteAll(true).branch(branchName)); + + runAndWaitForSuccess(infra.env(), infra.source(), infra.sink()); + + table.refresh(); + + // Branch should be compacted from 2 files to 1 + assertThat( + table.snapshot(branchName).dataManifests(table.io()).stream() + .flatMap( + m -> + StreamSupport.stream( + ManifestFiles.read(m, table.io(), table.specs()).spliterator(), false)) + .count()) + .isEqualTo(1); + SimpleDataUtil.assertTableRecords( + table, ImmutableList.of(createRecord(1, "a"), createRecord(2, "b")), branchName); + + // Main should be untouched with 3 files + assertFileNum(table, 3, 0); + } + private void appendRewriteDataFiles() { appendRewriteDataFiles(RewriteDataFiles.builder().rewriteAll(true)); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java index eaa5b5e1b5b1..49219d5b4698 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java @@ -424,12 +424,12 @@ String maintenanceTaskName() { @Override DataStream append(DataStream trigger) { String name = TASKS[id]; - return trigger - .map(new DummyMaintenanceTask(success)) - .name(name) - .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .map(new DummyMaintenanceTask(success)) + .name(name) + .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) + .forceNonParallel()); } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java new file mode 100644 index 000000000000..9b7941c2d306 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java @@ -0,0 +1,344 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.apache.iceberg.flink.SimpleDataUtil.createRowData; +import static org.apache.iceberg.flink.maintenance.api.TableMaintenance.SOURCE_OPERATOR_NAME_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import org.apache.flink.api.common.functions.MapFunction; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.dag.Transformation; +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.api.java.typeutils.ResultTypeQueryable; +import org.apache.flink.configuration.CheckpointingOptions; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.transformations.SourceTransformation; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.runtime.typeutils.InternalTypeInfo; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.TableLoader; +import org.apache.iceberg.flink.maintenance.operator.ManualSource; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.flink.maintenance.operator.TableChange; +import org.apache.iceberg.flink.sink.FlinkSink; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TestTableMaintenanceCoordinationLock extends OperatorTestBase { + private static final String MAINTENANCE_TASK_NAME = "TestTableMaintenance"; + private static final String[] TASKS = + new String[] {MAINTENANCE_TASK_NAME + " [0]", MAINTENANCE_TASK_NAME + " [1]"}; + private static final TableChange DUMMY_CHANGE = TableChange.builder().commitCount(1).build(); + private static final List PROCESSED = + Collections.synchronizedList(Lists.newArrayListWithCapacity(1)); + + private StreamExecutionEnvironment env; + private Table table; + + @TempDir private File checkpointDir; + + @BeforeEach + void beforeEach() throws IOException { + Configuration config = new Configuration(); + config.set(CheckpointingOptions.CHECKPOINT_STORAGE, "filesystem"); + config.set(CheckpointingOptions.CHECKPOINTS_DIRECTORY, "file://" + checkpointDir.getPath()); + this.env = StreamExecutionEnvironment.getExecutionEnvironment(config); + this.table = createTable(); + insert(table, 1, "a"); + + PROCESSED.clear(); + MaintenanceTaskBuilderForTest.counter = 0; + } + + @Test + void testForChangeStream() throws Exception { + ManualSource schedulerSource = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + + TableMaintenance.Builder streamBuilder = + TableMaintenance.forChangeStream(schedulerSource.dataStream(), tableLoader()) + .rateLimit(Duration.ofMillis(2)) + .lockCheckDelay(Duration.ofSeconds(3)) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .scheduleOnDataFileCount(2) + .scheduleOnDataFileSize(3L) + .scheduleOnEqDeleteFileCount(4) + .scheduleOnEqDeleteRecordCount(5L) + .scheduleOnPosDeleteFileCount(6) + .scheduleOnPosDeleteRecordCount(7L) + .scheduleOnInterval(Duration.ofHours(1))); + + sendEvents(schedulerSource, streamBuilder, ImmutableList.of(Tuple2.of(DUMMY_CHANGE, 1))); + } + + @Test + void testForTable() throws Exception { + TableLoader tableLoader = tableLoader(); + + env.enableCheckpointing(10); + + TableMaintenance.forTable(env, tableLoader) + .rateLimit(Duration.ofMillis(2)) + .maxReadBack(2) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(2)) + .append(); + + // Creating a stream for inserting data into the table concurrently + ManualSource insertSource = + new ManualSource<>(env, InternalTypeInfo.of(FlinkSchemaUtil.convert(table.schema()))); + FlinkSink.forRowData(insertSource.dataStream()) + .tableLoader(tableLoader) + .uidPrefix(UID_SUFFIX + "-iceberg-sink") + .append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + insertSource.sendRecord(createRowData(2, "b")); + + Awaitility.await().until(() -> PROCESSED.size() == 1); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testUidAndSlotSharingGroup() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP)) + .append(); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + @Test + void testUidAndSlotSharingGroupUnset() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(1)) + .append(); + + checkUidsAreSet(env, null); + checkSlotSharingGroupsAreSet(env, null); + } + + @Test + void testUidAndSlotSharingGroupInherit() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(1)) + .append(); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + @Test + void testUidAndSlotSharingGroupOverWrite() throws IOException { + String anotherUid = "Another-UID"; + String anotherSlotSharingGroup = "Another-SlotSharingGroup"; + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(anotherUid) + .slotSharingGroup(anotherSlotSharingGroup)) + .append(); + + // Choose an operator from the scheduler part of the graph + Transformation schedulerTransformation = + env.getTransformations().stream() + .filter(t -> t.getName().equals("Trigger manager")) + .findFirst() + .orElseThrow(); + assertThat(schedulerTransformation.getUid()).contains(UID_SUFFIX); + assertThat(schedulerTransformation.getSlotSharingGroup()).isPresent(); + assertThat(schedulerTransformation.getSlotSharingGroup().get().getName()) + .isEqualTo(SLOT_SHARING_GROUP); + + // Choose an operator from the maintenance task part of the graph + Transformation scheduledTransformation = + env.getTransformations().stream() + .filter(t -> t.getName().startsWith(MAINTENANCE_TASK_NAME)) + .findFirst() + .orElseThrow(); + assertThat(scheduledTransformation.getUid()).contains(anotherUid); + assertThat(scheduledTransformation.getSlotSharingGroup()).isPresent(); + assertThat(scheduledTransformation.getSlotSharingGroup().get().getName()) + .isEqualTo(anotherSlotSharingGroup); + } + + @Test + void testUidAndSlotSharingGroupForMonitorSource() throws IOException { + TableMaintenance.forTable(env, tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP)) + .append(); + + Transformation source = monitorSource(); + assertThat(source).isNotNull(); + assertThat(source.getUid()).contains(UID_SUFFIX); + assertThat(source.getSlotSharingGroup()).isPresent(); + assertThat(source.getSlotSharingGroup().get().getName()).isEqualTo(SLOT_SHARING_GROUP); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + /** + * Sends the events though the {@link ManualSource} provided, and waits until the given number of + * records are processed. + * + * @param schedulerSource used for sending the events + * @param streamBuilder used for generating the job + * @param eventsAndResultNumbers the pair of the event and the expected processed records + * @throws Exception if any + */ + private void sendEvents( + ManualSource schedulerSource, + TableMaintenance.Builder streamBuilder, + List> eventsAndResultNumbers) + throws Exception { + streamBuilder.append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + eventsAndResultNumbers.forEach( + eventsAndResultNumber -> { + int expectedSize = PROCESSED.size() + eventsAndResultNumber.f1; + schedulerSource.sendRecord(eventsAndResultNumber.f0); + Awaitility.await().until(() -> PROCESSED.size() == expectedSize); + }); + } finally { + closeJobClient(jobClient); + } + } + + /** + * Finds the {@link org.apache.iceberg.flink.maintenance.operator.MonitorSource} for testing + * purposes by parsing the transformation tree. + * + * @return The monitor source if we found it + */ + private Transformation monitorSource() { + assertThat(env.getTransformations()).isNotEmpty(); + assertThat(env.getTransformations().get(0).getInputs()).isNotEmpty(); + assertThat(env.getTransformations().get(0).getInputs().get(0).getInputs()).isNotEmpty(); + + Transformation result = + env.getTransformations().get(0).getInputs().get(0).getInputs().get(0); + + // Some checks to make sure this is the transformation we are looking for + assertThat(result).isInstanceOf(SourceTransformation.class); + assertThat(result.getName()).startsWith(SOURCE_OPERATOR_NAME_PREFIX); + + return result; + } + + private static class MaintenanceTaskBuilderForTest + extends MaintenanceTaskBuilder { + private final boolean success; + private final int id; + private static int counter = 0; + + MaintenanceTaskBuilderForTest(boolean success) { + this.success = success; + this.id = counter; + ++counter; + } + + @Override + String maintenanceTaskName() { + return MAINTENANCE_TASK_NAME; + } + + @Override + DataStream append(DataStream trigger) { + String name = TASKS[id]; + return setSlotSharingGroup( + trigger + .map(new DummyMaintenanceTask(success)) + .name(name) + .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) + .forceNonParallel()); + } + } + + private record DummyMaintenanceTask(boolean success) + implements MapFunction, ResultTypeQueryable, Serializable { + + @Override + public TaskResult map(Trigger trigger) { + PROCESSED.add(trigger); + + return new TaskResult( + trigger.taskId(), + trigger.timestamp(), + success, + success ? Collections.emptyList() : Lists.newArrayList(new Exception("Testing error"))); + } + + @Override + public TypeInformation getProducedType() { + return TypeInformation.of(TaskResult.class); + } + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java new file mode 100644 index 000000000000..5bfc889f2720 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 10) +class CoordinatorTestBase extends OperatorTestBase { + protected static final String OPERATOR_NAME = "TestCoordinator"; + protected static final String OPERATOR_NAME_1 = "TestCoordinator_1"; + protected static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + protected static final OperatorID TEST_OPERATOR_ID_1 = new OperatorID(1235L, 5679L); + protected static final int NUM_SUBTASKS = 1; + protected static final LockRegisterEvent LOCK_REGISTER_EVENT = + new LockRegisterEvent(DUMMY_TABLE_NAME); + protected static final LockReleaseEvent LOCK_RELEASE_EVENT = + new LockReleaseEvent(DUMMY_TABLE_NAME, 1L); + + protected static void setAllTasksReady( + BaseCoordinator baseCoordinator, EventReceivingTasks receivingTasks) { + for (int i = 0; i < NUM_SUBTASKS; i++) { + baseCoordinator.executionAttemptReady(i, 0, receivingTasks.createGatewayForSubtask(i, 0)); + } + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java index b9422a63d646..4f394be76f4f 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java @@ -24,7 +24,10 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; +import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; import org.apache.flink.configuration.Configuration; import org.apache.flink.configuration.MetricOptions; @@ -32,7 +35,6 @@ import org.apache.flink.core.execution.SavepointFormatType; import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.flink.streaming.api.transformations.SinkTransformation; import org.apache.flink.streaming.api.watermark.Watermark; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; @@ -79,6 +81,12 @@ public class OperatorTestBase { ImmutableMap.of(), ImmutableSet.of(SimpleDataUtil.SCHEMA.columns().get(0).fieldId())); + private static final Schema SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "ts", Types.TimestampType.withoutZone())); + protected static final String UID_SUFFIX = "UID-Dummy"; protected static final String SLOT_SHARING_GROUP = "SlotSharingGroup"; protected static final TriggerLockFactory LOCK_FACTORY = new MemoryLockFactory(); @@ -124,10 +132,14 @@ void after() throws IOException { } protected static Table createTable() { - return createTable(2); + return createTable(2, FileFormat.PARQUET); } protected static Table createTable(int formatVersion) { + return createPartitionedTable(formatVersion, FileFormat.PARQUET); + } + + protected static Table createTable(int formatVersion, FileFormat fileFormat) { return CATALOG_EXTENSION .catalog() .createTable( @@ -136,12 +148,29 @@ protected static Table createTable(int formatVersion) { PartitionSpec.unpartitioned(), null, ImmutableMap.of( + "write.format.default", + fileFormat.name(), TableProperties.FORMAT_VERSION, String.valueOf(formatVersion), "flink.max-continuous-empty-commits", "100000")); } + protected static Table createTableWithTimestampWithoutZone() { + return CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE, + PartitionSpec.builderFor(SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE).identity("ts").build(), + null, + ImmutableMap.of( + TableProperties.FORMAT_VERSION, + "2", + "flink.max-continuous-empty-commits", + "100000")); + } + protected static Table createTableWithDelete() { return createTableWithDelete(2); } @@ -158,7 +187,7 @@ protected static Table createTableWithDelete(int formatVersion) { "format-version", String.valueOf(formatVersion), "write.upsert.enabled", "true")); } - protected static Table createPartitionedTable(int formatVersion) { + protected static Table createPartitionedTable(int formatVersion, FileFormat fileFormat) { return CATALOG_EXTENSION .catalog() .createTable( @@ -167,6 +196,8 @@ protected static Table createPartitionedTable(int formatVersion) { PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).identity("data").build(), null, ImmutableMap.of( + "write.format.default", + fileFormat.name(), "format-version", String.valueOf(formatVersion), "flink.max-continuous-empty-commits", @@ -174,17 +205,27 @@ protected static Table createPartitionedTable(int formatVersion) { } protected static Table createPartitionedTable() { - return createPartitionedTable(2); + return createPartitionedTable(2, FileFormat.PARQUET); } protected void insert(Table table, Integer id, String data) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + insert(table, id, data, FileFormat.PARQUET); + } + + protected void insert(Table table, Integer id, String data, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir) .appendToTable(Lists.newArrayList(SimpleDataUtil.createRecord(id, data))); table.refresh(); } protected void insert(Table table, List records) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir).appendToTable(records); + insert(table, records, FileFormat.PARQUET); + } + + protected void insert(Table table, List records, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir).appendToTable(records); table.refresh(); } @@ -194,6 +235,20 @@ protected void insert(Table table, Integer id, String data, String extra) throws table.refresh(); } + protected void insertWithTimestampWithoutZone( + Table table, Integer id, String data, LocalDateTime ts) throws IOException { + GenericRecord record = GenericRecord.create(SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE); + record.setField("id", id); + record.setField("data", data); + record.setField("ts", ts); + long tsMicros = + TimeUnit.SECONDS.toMicros(ts.toEpochSecond(ZoneOffset.UTC)) + + TimeUnit.NANOSECONDS.toMicros(ts.getNano()); + new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + .appendToTable(TestHelpers.Row.of(tsMicros), Lists.newArrayList(record)); + table.refresh(); + } + /** * For the same identifier column id this methods simulate the following row operations: *

  • add an equality delete on oldData @@ -271,7 +326,12 @@ protected void update(Table table, Integer id, String oldData, String tempData, } protected void insertPartitioned(Table table, Integer id, String data) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + insertPartitioned(table, id, data, FileFormat.PARQUET); + } + + protected void insertPartitioned(Table table, Integer id, String data, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir) .appendToTable( TestHelpers.Row.of(data), Lists.newArrayList(SimpleDataUtil.createRecord(id, data))); table.refresh(); @@ -352,15 +412,17 @@ protected static void checkUidsAreSet(StreamExecutionEnvironment env, String uid } protected static void checkSlotSharingGroupsAreSet(StreamExecutionEnvironment env, String name) { - String nameToCheck = name != null ? name : StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP; - env.getTransformations().stream() .filter( t -> !(t instanceof SinkTransformation) && !(t.getName().equals(IGNORED_OPERATOR_NAME))) .forEach( t -> { - assertThat(t.getSlotSharingGroup()).isPresent(); - assertThat(t.getSlotSharingGroup().get().getName()).isEqualTo(nameToCheck); + if (name == null) { + assertThat(t.getSlotSharingGroup()).isNotPresent(); + } else { + assertThat(t.getSlotSharingGroup()).isPresent(); + assertThat(t.getSlotSharingGroup().get().getName()).isEqualTo(name); + } }); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java index 68aaf29ac0d1..7b8f638b7e2f 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java @@ -22,10 +22,13 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Map; import java.util.Set; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses; import org.apache.iceberg.DataFile; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.flink.TableLoader; @@ -38,6 +41,11 @@ private RewriteUtil() {} static List planDataFileRewrite(TableLoader tableLoader) throws Exception { + return planDataFileRewrite(tableLoader, ImmutableMap.of(MIN_INPUT_FILES, "2")); + } + + static List planDataFileRewrite( + TableLoader tableLoader, Map rewriterOptions) throws Exception { try (OneInputStreamOperatorTestHarness testHarness = ProcessFunctionTestHarnesses.forProcessFunction( @@ -48,8 +56,9 @@ static List planDataFileRewrite(TableLoader tableLoader, 11, 10_000_000L, - ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + rewriterOptions, + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); @@ -80,6 +89,6 @@ static List executeRewrite( static Set newDataFiles(Table table) { table.refresh(); - return Sets.newHashSet(table.currentSnapshot().addedDataFiles(table.io())); + return Sets.newHashSet(SnapshotChanges.builderFor(table).build().addedDataFiles()); } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java index 9e8f2ec92162..ded958538ff0 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java @@ -31,6 +31,8 @@ import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.flink.maintenance.api.Trigger; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -225,7 +227,8 @@ private OneInputStreamOperatorTestHarness actualAdded = Sets.newHashSet(table.currentSnapshot().addedDataFiles(table.io())); - Set actualRemoved = - Sets.newHashSet(table.currentSnapshot().removedDataFiles(table.io())); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + Set actualAdded = Sets.newHashSet(changes.addedDataFiles()); + Set actualRemoved = Sets.newHashSet(changes.removedDataFiles()); assertThat(actualAdded.stream().map(DataFile::location).collect(Collectors.toSet())) .isEqualTo(expectedAdded.stream().map(DataFile::location).collect(Collectors.toSet())); assertThat(actualRemoved.stream().map(DataFile::location).collect(Collectors.toSet())) diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java index 9f4f96e1065b..8300df8c94eb 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java @@ -18,11 +18,15 @@ */ package org.apache.iceberg.flink.maintenance.operator; +import static org.apache.iceberg.actions.SizeBasedFileRewritePlanner.MAX_FILE_GROUP_INPUT_FILES; import static org.apache.iceberg.actions.SizeBasedFileRewritePlanner.MIN_INPUT_FILES; import static org.apache.iceberg.flink.maintenance.operator.RewriteUtil.newDataFiles; import static org.apache.iceberg.flink.maintenance.operator.RewriteUtil.planDataFileRewrite; import static org.assertj.core.api.Assertions.assertThat; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -32,6 +36,7 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.FileContent; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.flink.maintenance.api.Trigger; @@ -105,7 +110,8 @@ void testError() throws Exception { 11, 1L, ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); // Cause an exception @@ -171,7 +177,8 @@ void testMaxRewriteBytes() throws Exception { 11, maxRewriteBytes, ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); @@ -182,6 +189,103 @@ void testMaxRewriteBytes() throws Exception { } } + @Test + void testMaxFileGroupCount() throws Exception { + Table table = createPartitionedTable(); + insertPartitioned(table, 1, "p1"); + insertPartitioned(table, 2, "p1"); + insertPartitioned(table, 3, "p2"); + insertPartitioned(table, 4, "p2"); + insertPartitioned(table, 5, "p2"); + insertPartitioned(table, 6, "p2"); + + List planWithNoLimit = planDataFileRewrite(tableLoader()); + assertThat(planWithNoLimit).hasSize(2); + + List planWithMaxFileGroupCount = + planDataFileRewrite( + tableLoader(), ImmutableMap.of(MIN_INPUT_FILES, "2", MAX_FILE_GROUP_INPUT_FILES, "2")); + assertThat(planWithMaxFileGroupCount).hasSize(3); + } + + @Test + void testBranch() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + insert(table, 2, "b"); + + String branchName = "test-branch"; + table.manageSnapshots().createBranch(branchName).commit(); + + // Insert more data on main only + insert(table, 3, "c"); + + try (OneInputStreamOperatorTestHarness + testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new DataFileRewritePlanner( + OperatorTestBase.DUMMY_TABLE_NAME, + OperatorTestBase.DUMMY_TABLE_NAME, + 0, + tableLoader(), + 11, + 10_000_000L, + ImmutableMap.of(MIN_INPUT_FILES, "2"), + Expressions::alwaysTrue, + branchName))) { + testHarness.open(); + + trigger(testHarness); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + List planned = testHarness.extractOutputValues(); + assertThat(planned).hasSize(1); + // Branch has 2 files, main has 3 + assertThat(planned.get(0).group().fileScanTasks()).hasSize(2); + assertThat(planned.get(0).branch()).isEqualTo(branchName); + } + } + + @Test + void testFilterSupplierWithTimestamp() throws Exception { + Table table = createTableWithTimestampWithoutZone(); + + LocalDateTime oldTs = LocalDateTime.now().minusDays(10); + insertWithTimestampWithoutZone(table, 1, "old_a", oldTs); + insertWithTimestampWithoutZone(table, 2, "old_b", oldTs); + + LocalDateTime recentTs = LocalDateTime.now().minusHours(1); + insertWithTimestampWithoutZone(table, 3, "new_a", recentTs); + insertWithTimestampWithoutZone(table, 4, "new_b", recentTs); + + try (OneInputStreamOperatorTestHarness + testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new DataFileRewritePlanner( + OperatorTestBase.DUMMY_TABLE_NAME, + OperatorTestBase.DUMMY_TABLE_NAME, + 0, + tableLoader(), + 11, + 10_000_000L, + ImmutableMap.of(MIN_INPUT_FILES, "2"), + () -> + Expressions.greaterThanOrEqual( + "ts", + LocalDateTime.now(ZoneOffset.UTC).minus(Duration.ofDays(3)).toString()), + SnapshotRef.MAIN_BRANCH))) { + testHarness.open(); + + trigger(testHarness); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + List planned = testHarness.extractOutputValues(); + + assertThat(planned).hasSize(1); + assertThat(planned.get(0).group().fileScanTasks()).hasSize(2); + } + } + void assertRewriteFileGroup( DataFileRewritePlanner.PlannedGroup plannedGroup, Table table, Set files) { assertThat(plannedGroup.table().currentSnapshot().snapshotId()) diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java index 4e21c7a956e4..62b29e7c017a 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java @@ -38,6 +38,7 @@ import org.apache.iceberg.PartitionData; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.data.GenericAppenderHelper; @@ -308,7 +309,8 @@ void testSplitSize() throws Exception { "2", TARGET_FILE_SIZE_BYTES, String.valueOf(targetFileSize)), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java index bb8c74f3d5e9..8614c634f125 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java @@ -23,8 +23,10 @@ import java.util.List; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.junit.jupiter.api.Test; class TestListMetadataFiles extends OperatorTestBase { @@ -87,4 +89,37 @@ void testMetadataFilesWithEmptyTable() throws Exception { assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); } } + + @Test + void testMetadataFilesIncludesSnapshotsAddedAfterOpen() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + + try (OneInputStreamOperatorTestHarness testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new ListMetadataFiles(OperatorTestBase.DUMMY_TABLE_NAME, 0, tableLoader()))) { + testHarness.open(); + + // Add more snapshots AFTER the operator has been opened + insert(table, 2, "b"); + insert(table, 3, "c"); + + OperatorTestBase.trigger(testHarness); + + List tableMetadataFiles = testHarness.extractOutputValues(); + + // Verify that manifest lists from ALL 3 snapshots are present, not just the first one. + // Without table.refresh() in processElement, only snapshot 1's files would be emitted. + table.refresh(); + List snapshots = Lists.newArrayList(table.snapshots()); + assertThat(snapshots).hasSize(3); + for (Snapshot snapshot : snapshots) { + assertThat(tableMetadataFiles).contains(snapshot.manifestListLocation()); + } + // Verify total count matches what 3 snapshots should produce + assertThat(tableMetadataFiles).hasSize(24); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + } + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java new file mode 100644 index 000000000000..3427d2abe0ad --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.ExecutionException; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Timeout(value = 10) +@Execution(ExecutionMode.SAME_THREAD) +class TestLockRemoveCoordinator extends CoordinatorTestBase { + + private EventReceivingTasks receivingTasks; + + @BeforeEach + void before() { + this.receivingTasks = EventReceivingTasks.createForRunningTasks(); + } + + @Test + void testEventHandling() throws Exception { + try (LockRemoverCoordinator lockRemoverCoordinator = createCoordinator()) { + + lockRemoverCoordinator.start(); + + setAllTasksReady(lockRemoverCoordinator, receivingTasks); + + lockRemoverCoordinator.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(lockRemoverCoordinator.pendingReleaseEvents()).hasSize(1); + } + } + + private LockRemoverCoordinator createCoordinator() { + return new LockRemoverCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)) { + @Override + void runInCoordinatorThread(Runnable runnable, String actionString) { + try { + coordinatorExecutor().submit(runnable).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(actionString, e); + } + } + }; + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java new file mode 100644 index 000000000000..a0b615e463d9 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.FAILED_TASK_COUNTER; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.LAST_RUN_DURATION_MS; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.apache.flink.runtime.operators.coordination.MockOperatorEventGateway; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; +import org.apache.iceberg.flink.maintenance.api.TaskResult; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 10) +class TestLockRemoverOperation extends OperatorTestBase { + private static final String[] TASKS = new String[] {"task0", "task1", "task2"}; + private static final String OPERATOR_NAME = "TestCoordinator"; + private static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + + private LockRemoverCoordinator lockRemoverCoordinator; + + @BeforeEach + void before() { + MetricsReporterFactoryForTests.reset(); + this.lockRemoverCoordinator = createCoordinator(); + try { + lockRemoverCoordinator.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @AfterEach + void after() throws IOException { + super.after(); + try { + lockRemoverCoordinator.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void testProcess() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + LockRemoverOperator operator = + new LockRemoverOperator(null, mockGateway, DUMMY_TASK_NAME, Lists.newArrayList(TASKS[0])); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + testHarness.processElement( + new StreamRecord<>(new TaskResult(0, 0L, true, Lists.newArrayList()))); + assertThat(mockGateway.getEventsSent()).hasSize(0); + + testHarness.processWatermark(WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + } + } + + @Test + void testProcessMaxWaterMark() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + LockRemoverOperator operator = + new LockRemoverOperator(null, mockGateway, DUMMY_TASK_NAME, Lists.newArrayList(TASKS[0])); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + testHarness.processElement( + new StreamRecord<>(new TaskResult(0, 0L, true, Lists.newArrayList()))); + assertThat(mockGateway.getEventsSent()).hasSize(0); + + testHarness.processWatermark(WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + + testHarness.processWatermark(Watermark.MAX_WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + } + } + + @Test + void testMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = new ManualSource<>(env, TypeInformation.of(TaskResult.class)); + source + .dataStream() + .transform( + DUMMY_TASK_NAME, + TypeInformation.of(Void.class), + new LockRemoverOperatorFactory(DUMMY_TABLE_NAME, Lists.newArrayList(TASKS))) + .forceNonParallel(); + + JobClient jobClient = null; + long time = System.currentTimeMillis(); + try { + jobClient = env.executeAsync(); + // Start the 2 successful and one failed result trigger for task1, and 3 successful for task2 + processAndCheck(source, new TaskResult(0, time, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(0, time, false, Lists.newArrayList())); + processAndCheck(source, new TaskResult(0, time, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of( + DUMMY_TASK_NAME, + DUMMY_TABLE_NAME, + TASKS[1], + "1", + SUCCEEDED_TASK_COUNTER)) + .equals(3L)); + + // Final check all the counters + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", SUCCEEDED_TASK_COUNTER), + 2L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", FAILED_TASK_COUNTER), + 1L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", SUCCEEDED_TASK_COUNTER), + 3L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", FAILED_TASK_COUNTER), + 0L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", SUCCEEDED_TASK_COUNTER), + 0L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", FAILED_TASK_COUNTER), + 0L) + .build()); + + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", LAST_RUN_DURATION_MS))) + .isPositive(); + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", LAST_RUN_DURATION_MS))) + .isGreaterThan(time); + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", LAST_RUN_DURATION_MS))) + .isZero(); + } finally { + closeJobClient(jobClient); + } + } + + private void processAndCheck(ManualSource source, TaskResult input) { + List counterKey = + ImmutableList.of( + DUMMY_TASK_NAME, + DUMMY_TABLE_NAME, + TASKS[input.taskIndex()], + String.valueOf(input.taskIndex()), + input.success() ? SUCCEEDED_TASK_COUNTER : FAILED_TASK_COUNTER); + Long counterValue = MetricsReporterFactoryForTests.counter(counterKey); + Long expected = counterValue != null ? counterValue + 1 : 1L; + + source.sendRecord(input); + source.sendWatermark(input.startEpoch()); + + Awaitility.await() + .until(() -> expected.equals(MetricsReporterFactoryForTests.counter(counterKey))); + } + + private OneInputStreamOperatorTestHarness createHarness( + LockRemoverOperator lockRemoverOperator) throws Exception { + OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(lockRemoverOperator); + harness.open(); + return harness; + } + + private static LockRemoverCoordinator createCoordinator() { + return new LockRemoverCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java index 9c1ea2f2295a..3dca6c421c76 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java @@ -46,6 +46,7 @@ import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.data.GenericAppenderHelper; import org.apache.iceberg.data.RandomGenericData; @@ -316,7 +317,7 @@ void testSkipReplace() throws IOException { // Create a DataOperations.REPLACE snapshot DataFile dataFile = - table.snapshots().iterator().next().addedDataFiles(table.io()).iterator().next(); + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); RewriteFiles rewrite = tableLoader.loadTable().newRewrite(); // Replace the file with itself for testing purposes rewrite.deleteFile(dataFile); @@ -328,14 +329,18 @@ void testSkipReplace() throws IOException { } private static long firstFileLength(Table table) { - return table.currentSnapshot().addedDataFiles(table.io()).iterator().next().fileSizeInBytes(); + return SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .iterator() + .next() + .fileSizeInBytes(); } private static TableChange tableChangeWithLastSnapshot(Table table, TableChange previous) { - List dataFiles = - Lists.newArrayList(table.currentSnapshot().addedDataFiles(table.io()).iterator()); - List deleteFiles = - Lists.newArrayList(table.currentSnapshot().addedDeleteFiles(table.io()).iterator()); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + List dataFiles = Lists.newArrayList(changes.addedDataFiles().iterator()); + List deleteFiles = Lists.newArrayList(changes.addedDeleteFiles().iterator()); long dataSize = dataFiles.stream().mapToLong(ContentFile::fileSizeInBytes).sum(); long deleteRecordCount = deleteFiles.stream().mapToLong(DeleteFile::recordCount).sum(); diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java new file mode 100644 index 000000000000..5fc6695f3e65 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.ExecutionException; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Timeout(value = 10) +@Execution(ExecutionMode.SAME_THREAD) +class TestTriggerManagerCoordinator extends CoordinatorTestBase { + + private EventReceivingTasks receivingTasks; + private EventReceivingTasks receivingTasks1; + + @BeforeEach + void before() { + this.receivingTasks = EventReceivingTasks.createForRunningTasks(); + this.receivingTasks1 = EventReceivingTasks.createForRunningTasks(); + } + + @Test + void testEventHandling() throws Exception { + try (TriggerManagerCoordinator triggerManagerCoordinator = + createCoordinator(OPERATOR_NAME, TEST_OPERATOR_ID); + TriggerManagerCoordinator triggerManagerCoordinator1 = + createCoordinator(OPERATOR_NAME_1, TEST_OPERATOR_ID_1)) { + + triggerManagerCoordinator.start(); + triggerManagerCoordinator1.start(); + + setAllTasksReady(triggerManagerCoordinator, receivingTasks); + setAllTasksReady(triggerManagerCoordinator1, receivingTasks1); + + triggerManagerCoordinator.handleEventFromOperator(0, 0, LOCK_REGISTER_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(0); + + // release lock from coordinator1 and get one event from coordinator + triggerManagerCoordinator1.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(1); + assertThat(receivingTasks1.getSentEventsForSubtask(0).size()).isEqualTo(0); + } + } + + @Test + void testEventArriveBeforeRegister() throws Exception { + try (TriggerManagerCoordinator triggerManagerCoordinator = + createCoordinator(OPERATOR_NAME, TEST_OPERATOR_ID)) { + + triggerManagerCoordinator.start(); + setAllTasksReady(triggerManagerCoordinator, receivingTasks); + + // release event arrive before register + triggerManagerCoordinator.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(triggerManagerCoordinator.pendingReleaseEvents()).hasSize(1); + + triggerManagerCoordinator.handleEventFromOperator(0, 0, LOCK_REGISTER_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(1); + + assertThat(triggerManagerCoordinator.pendingReleaseEvents()).hasSize(0); + } + } + + private static TriggerManagerCoordinator createCoordinator( + String operatorName, OperatorID operatorID) { + return new TriggerManagerCoordinator( + operatorName, new MockOperatorCoordinatorContext(operatorID, 1)) { + @Override + void runInCoordinatorThread(Runnable runnable, String actionString) { + try { + coordinatorExecutor().submit(runnable).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(actionString, e); + } + } + }; + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java new file mode 100644 index 000000000000..ea7d8b96253f --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java @@ -0,0 +1,668 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.CONCURRENT_RUN_THROTTLED; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.NOTHING_TO_TRIGGER; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.RATE_LIMITER_TRIGGERED; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.TRIGGERED; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.runtime.checkpoint.OperatorSubtaskState; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.apache.flink.runtime.operators.coordination.MockOperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TestTriggerManagerOperator extends OperatorTestBase { + private static final long DELAY = 10L; + private static final String OPERATOR_NAME = "TestCoordinator"; + private static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + private static final String[] TASKS = new String[] {"task0", "task1"}; + private long processingTime = 0L; + private String tableName; + private TriggerManagerCoordinator triggerManagerCoordinator; + private LockReleaseEvent lockReleaseEvent; + + @BeforeEach + void before() { + super.before(); + Table table = createTable(); + this.tableName = table.name(); + lockReleaseEvent = new LockReleaseEvent(tableName, 1L); + this.triggerManagerCoordinator = createCoordinator(); + try { + triggerManagerCoordinator.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @AfterEach + void after() throws IOException { + super.after(); + try { + triggerManagerCoordinator.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void testCommitCount() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + TriggerManagerOperator operator = + createOperator(new TriggerEvaluator.Builder().commitCount(3).build(), mockGateway); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 4); + } + } + + @Test + void testDataFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().dataFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(1).build(), 0); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(5).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(2).build(), 4); + } + } + + @Test + void testDataFileSizeInBytes() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().dataFileSizeInBytes(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(2L).build(), 3); + } + } + + @Test + void testPosDeleteFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().posDeleteFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 4); + } + } + + @Test + void testPosDeleteRecordCount() throws Exception { + + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().posDeleteRecordCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(2L).build(), 3); + } + } + + @Test + void testEqDeleteFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().eqDeleteFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 4); + } + } + + @Test + void testEqDeleteRecordCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().eqDeleteRecordCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(2L).build(), 3); + } + } + + @Test + void testTimeout() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().timeout(Duration.ofSeconds(1)).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + TableChange event = TableChange.builder().dataFileCount(1).commitCount(1).build(); + + // Wait for some time + testHarness.processElement(event, EVENT_TIME); + assertThat(testHarness.extractOutputValues()).isEmpty(); + + // Wait for the timeout to expire + long newTime = EVENT_TIME + Duration.ofSeconds(1).toMillis(); + testHarness.setProcessingTime(newTime); + testHarness.processElement(event, newTime); + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Remove the lock to allow the next trigger + operator.handleLockRelease(new LockReleaseEvent(tableName, newTime)); + + // Send a new event + testHarness.setProcessingTime(newTime + 1); + testHarness.processElement(event, newTime); + + // No trigger yet + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Send a new event + newTime += Duration.ofSeconds(1).toMillis(); + testHarness.setProcessingTime(newTime); + testHarness.processElement(event, newTime); + + // New trigger should arrive + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testStateRestore() throws Exception { + OperatorSubtaskState state; + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().commitCount(2).build(), new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.processElement( + TableChange.builder().dataFileCount(1).commitCount(1).build(), EVENT_TIME); + + assertThat(testHarness.extractOutputValues()).isEmpty(); + + state = testHarness.snapshot(1, EVENT_TIME); + } + + // Restore the state, write some more data, create a checkpoint, check the data which is written + TriggerManagerOperator newOperator = + createOperator( + new TriggerEvaluator.Builder().commitCount(2).build(), new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + new OneInputStreamOperatorTestHarness<>(newOperator)) { + testHarness.initializeState(state); + testHarness.open(); + + // Mock a recovery trigger lock + assertTriggers( + testHarness.extractOutputValues(), + Lists.newArrayList(Trigger.recovery(testHarness.getProcessingTime()))); + + testHarness.processElement(TableChange.builder().commitCount(1).build(), EVENT_TIME_2); + + // Remove the lock to allow the next trigger + newOperator.handleOperatorEvent(lockReleaseEvent); + testHarness.setProcessingTime(EVENT_TIME_2); + + // At this point the output contains the recovery trigger and the real trigger + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testMinFireDelay() throws Exception { + TriggerManagerOperator operator = + createOperator( + tableName, + new TriggerEvaluator.Builder().commitCount(2).build(), + new MockOperatorEventGateway(), + DELAY, + 1); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.open(); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + long currentTime = testHarness.getProcessingTime(); + + // No new fire yet + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + + // Check that the trigger fired after the delay + testHarness.setProcessingTime(currentTime + DELAY); + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testLockCheckDelay() throws Exception { + TriggerManagerOperator operator = + createOperator( + tableName, + new TriggerEvaluator.Builder().commitCount(2).build(), + new MockOperatorEventGateway(), + 1, + DELAY); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.open(); + + // Create a lock to prevent execution, and check that there is no result + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1, false); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1, false); + long currentTime = testHarness.getProcessingTime(); + + // Remove the lock, and still no trigger + operator.handleOperatorEvent(lockReleaseEvent); + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Check that the trigger fired after the delay + testHarness.setProcessingTime(currentTime + DELAY); + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testTriggerMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + TriggerManagerOperatorFactory triggerManagerOperatorFactory = + new TriggerManagerOperatorFactory( + tableName, + Lists.newArrayList(TASKS), + Lists.newArrayList( + new TriggerEvaluator.Builder().commitCount(2).build(), + new TriggerEvaluator.Builder().commitCount(4).build()), + 1L, + 1L); + source + .dataStream() + .keyBy(unused -> true) + .transform( + DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), triggerManagerOperatorFactory) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // This one doesn't trigger - tests NOTHING_TO_TRIGGER + source.sendRecord(TableChange.builder().commitCount(1).build()); + + Awaitility.await() + .until( + () -> { + Long notingCounter = + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER)); + return notingCounter != null && notingCounter.equals(1L); + }); + + // Trigger one of the tasks - tests TRIGGERED + source.sendRecord(TableChange.builder().commitCount(1).build()); + // Wait until we receive the trigger + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED))) + .isEqualTo(1L); + + // manual unlock + triggerManagerCoordinator.handleReleaseLock(new LockReleaseEvent(tableName, Long.MAX_VALUE)); + // Trigger both of the tasks - tests TRIGGERED + source.sendRecord(TableChange.builder().commitCount(2).build()); + // Wait until we receive the trigger + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // manual unlock + triggerManagerCoordinator.handleReleaseLock(new LockReleaseEvent(tableName, Long.MAX_VALUE)); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED))) + .isEqualTo(2L); + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[1], "1", TRIGGERED))) + .isEqualTo(1L); + + // Final check all the counters + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED), -1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED), -1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED), 2L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[1], "1", TRIGGERED), 1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER), 1L) + .build()); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testRateLimiterMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + // High delay, so only triggered once + TriggerManagerOperatorFactory manager = manager(1_000_000L, 1L); + + source + .dataStream() + .keyBy(unused -> true) + .transform(DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), manager) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Start the first trigger + source.sendRecord(TableChange.builder().commitCount(2).build()); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // Remove the lock to allow the next trigger + triggerManagerCoordinator.handleReleaseLock(lockReleaseEvent); + + // The second trigger will be blocked + source.sendRecord(TableChange.builder().commitCount(2).build()); + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED)) + .equals(1L)); + + // Final check all the counters + assertCounters(1L, 0L); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testConcurrentRunMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + // High delay, so only triggered once + TriggerManagerOperatorFactory manager = manager(1L, 1_000_000L); + + source + .dataStream() + .keyBy(unused -> true) + .transform(DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), manager) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Start the first trigger - notice that we do not remove the lock after the trigger + source.sendRecord(TableChange.builder().commitCount(2).build()); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // The second trigger will be blocked by the lock + source.sendRecord(TableChange.builder().commitCount(2).build()); + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED)) + .equals(1L)); + + // Final check all the counters + assertCounters(0L, 1L); + } finally { + closeJobClient(jobClient); + } + } + + private void assertCounters(long rateLimiterTrigger, long concurrentRunTrigger) { + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put( + ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED), + rateLimiterTrigger) + .put( + ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED), + concurrentRunTrigger) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED), 1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER), 0L) + .build()); + } + + private void addEventAndCheckResult( + TriggerManagerOperator operator, + OneInputStreamOperatorTestHarness testHarness, + TableChange event, + int expectedSize) + throws Exception { + addEventAndCheckResult(operator, testHarness, event, expectedSize, true); + } + + private void addEventAndCheckResult( + TriggerManagerOperator operator, + OneInputStreamOperatorTestHarness testHarness, + TableChange event, + int expectedSize, + boolean removeLock) + throws Exception { + ++processingTime; + testHarness.setProcessingTime(processingTime); + testHarness.processElement(event, processingTime); + assertThat(testHarness.extractOutputValues()).hasSize(expectedSize); + if (removeLock && operator.lockTime() != null) { + // Remove the lock to allow the next trigger + operator.handleLockRelease(new LockReleaseEvent(tableName, processingTime)); + } + } + + private TriggerManagerOperatorFactory manager(long minFireDelayMs, long lockCheckDelayMs) { + return new TriggerManagerOperatorFactory( + tableName, + Lists.newArrayList(TASKS[0]), + Lists.newArrayList(new TriggerEvaluator.Builder().commitCount(2).build()), + minFireDelayMs, + lockCheckDelayMs); + } + + private static void assertTriggers(List expected, List actual) { + assertThat(actual).hasSize(expected.size()); + for (int i = 0; i < expected.size(); ++i) { + Trigger expectedTrigger = expected.get(i); + Trigger actualTrigger = actual.get(i); + assertThat(actualTrigger.timestamp()).isEqualTo(expectedTrigger.timestamp()); + assertThat(actualTrigger.taskId()).isEqualTo(expectedTrigger.taskId()); + assertThat(actualTrigger.isRecovery()).isEqualTo(expectedTrigger.isRecovery()); + } + } + + private static TriggerManagerCoordinator createCoordinator() { + return new TriggerManagerCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)); + } + + private TriggerManagerOperator createOperator( + TriggerEvaluator evaluator, OperatorEventGateway mockGateway) { + return createOperator(tableName, evaluator, mockGateway, 1, 1); + } + + private TriggerManagerOperator createOperator( + String lockId, + TriggerEvaluator evaluator, + OperatorEventGateway mockGateway, + long minFireDelayMs, + long lockCheckDelayMs) { + return new TriggerManagerOperator( + null, + mockGateway, + Lists.newArrayList(TASKS[0]), + Lists.newArrayList(evaluator), + minFireDelayMs, + lockCheckDelayMs, + lockId); + } + + private OneInputStreamOperatorTestHarness createHarness( + TriggerManagerOperator triggerManagerOperator) throws Exception { + OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(triggerManagerOperator); + harness.open(); + return harness; + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java index da5b5f6c28f0..339cd0510efb 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java @@ -35,7 +35,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.common.DynFields; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.SimpleDataUtil; @@ -238,21 +238,21 @@ private static Map appenderProperties( testHarness.processElement(SimpleDataUtil.createRowData(1, "hello"), 1); testHarness.prepareSnapshotPreBarrier(1L); - DynFields.BoundField operatorField = + DynFields.BoundField> operatorField = DynFields.builder() .hiddenImpl(testHarness.getOperatorFactory().getClass(), "operator") .build(testHarness.getOperatorFactory()); - DynFields.BoundField writerField = + DynFields.BoundField> writerField = DynFields.builder() .hiddenImpl(IcebergStreamWriter.class, "writer") .build(operatorField.get()); - DynFields.BoundField writerFactoryField = + DynFields.BoundField> writerFactoryField = DynFields.builder() .hiddenImpl(BaseTaskWriter.class, "writerFactory") .build(writerField.get()); DynFields.BoundField> propsField = DynFields.builder() - .hiddenImpl(BaseFileWriterFactory.class, "writerProperties") + .hiddenImpl(RegistryBasedFileWriterFactory.class, "writerProperties") .build(writerFactoryField.get()); return propsField.get(); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java index a77ddead3003..0497ba6c680f 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java @@ -69,15 +69,15 @@ public class TestFlinkIcebergSinkBranch extends TestFlinkIcebergSinkBase { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {"1", "main", true}, + {"1", SnapshotRef.MAIN_BRANCH, true}, {"1", "testBranch", true}, - {"2", "main", true}, + {"2", SnapshotRef.MAIN_BRANCH, true}, {"2", "testBranch", true}, // Remove after the deprecation of TableSchema - END - {"1", "main", false}, + {"1", SnapshotRef.MAIN_BRANCH, false}, {"1", "testBranch", false}, - {"2", "main", false}, + {"2", SnapshotRef.MAIN_BRANCH, false}, {"2", "testBranch", false}, }; } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java index 04bc5da6a9be..d25e0d68391d 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.HadoopCatalogExtension; @@ -279,7 +280,14 @@ public void testRangeDistributionWithoutSortOrderPartitioned() throws Exception // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -322,7 +330,14 @@ public void testRangeDistributionWithNullValue() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -367,7 +382,14 @@ public void testRangeDistributionWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -383,14 +405,24 @@ public void testRangeDistributionWithSortOrder() throws Exception { if (partitioned) { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // up to 26 partitions assertThat(addedDataFiles).hasSizeLessThanOrEqualTo(26); } } else { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -438,7 +470,14 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -456,7 +495,12 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -517,7 +561,14 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -535,7 +586,12 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column // sometimes assertThat(addedDataFiles).hasSize(writeParallelism); diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java index a5799288b5e3..8d425cb30aaf 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java @@ -47,6 +47,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkSchemaUtil; @@ -202,7 +203,14 @@ private void testParallelism(int parallelism) throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Source rate limit per checkpoint cycle may not be super precise. @@ -217,7 +225,12 @@ private void testParallelism(int parallelism) throws Exception { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); assertThat(addedDataFiles) .hasSizeLessThanOrEqualTo(maxAddedDataFilesPerCheckpoint(parallelism)); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java index ffd40b6cdc95..fac1b8226d3c 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java @@ -35,6 +35,7 @@ import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.TableProperties; import org.apache.iceberg.data.Record; @@ -242,13 +243,14 @@ public void testDeleteStats() throws Exception { false, elementsPerCheckpoint, expectedRecords, - "main"); + SnapshotRef.MAIN_BRANCH); - DeleteFile deleteFile = table.currentSnapshot().addedDeleteFiles(table.io()).iterator().next(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + DeleteFile deleteFile = changes.addedDeleteFiles().iterator().next(); String fromStat = new String( deleteFile.lowerBounds().get(MetadataColumns.DELETE_FILE_PATH.fieldId()).array()); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = changes.addedDataFiles().iterator().next(); assumeThat(fromStat).isEqualTo(dataFile.location()); } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java index 8ce3e1886f40..9117534ed63b 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java @@ -55,11 +55,25 @@ public class TestFlinkIcebergSinkV2Branch extends TestFlinkIcebergSinkV2Base { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, true, "main"}, + { + FileFormat.AVRO, + 1, + false, + TableProperties.WRITE_DISTRIBUTION_MODE_NONE, + true, + SnapshotRef.MAIN_BRANCH + }, {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, true, "testBranch"}, // Remove after the deprecation of TableSchema - END - {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, false, "main"}, + { + FileFormat.AVRO, + 1, + false, + TableProperties.WRITE_DISTRIBUTION_MODE_NONE, + false, + SnapshotRef.MAIN_BRANCH + }, { FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, false, "testBranch" }, diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java index 0feb4cc282d2..9b208283c857 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.HadoopCatalogExtension; @@ -289,7 +290,14 @@ public void testRangeDistributionWithoutSortOrderPartitioned() throws Exception // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -335,7 +343,14 @@ public void testRangeDistributionWithNullValue() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -381,7 +396,14 @@ public void testRangeDistributionWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -397,14 +419,24 @@ public void testRangeDistributionWithSortOrder() throws Exception { if (partitioned) { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // up to 26 partitions assertThat(addedDataFiles).hasSizeLessThanOrEqualTo(26); } } else { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -453,7 +485,14 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -471,7 +510,12 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -533,7 +577,14 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -551,7 +602,12 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column // sometimes assertThat(addedDataFiles).hasSize(writeParallelism); @@ -562,6 +618,39 @@ public void testRangeDistributionStatisticsMigration() throws Exception { } } + @TestTemplate + public void testHashDistributionWithPartitionNotInEqualityFields() { + assumeThat(partitioned).isTrue(); + + List rows = createRows(""); + DataStream dataStream = env.addSource(createBoundedSource(rows), ROW_TYPE_INFO); + + if (isTableSchema) { + IcebergSink.forRow(dataStream, SimpleDataUtil.FLINK_TABLE_SCHEMA) + .table(table) + .tableLoader(tableLoader) + .writeParallelism(writeParallelism) + .distributionMode(DistributionMode.HASH) + .upsert(false) + .equalityFieldColumns(ImmutableList.of("id")) + .append(); + } else { + IcebergSink.forRow(dataStream, SimpleDataUtil.FLINK_SCHEMA) + .table(table) + .tableLoader(tableLoader) + .writeParallelism(writeParallelism) + .distributionMode(DistributionMode.HASH) + .upsert(false) + .equalityFieldColumns(ImmutableList.of("id")) + .append(); + } + + assertThatThrownBy(env::execute) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "In 'hash' distribution mode with equality fields set, source column 'data' of partition field '1000: data: identity(2)' should be included in equality fields: '[id]'"); + } + private BoundedTestSource createRangeDistributionBoundedSource( List> rowsPerCheckpoint) { return new BoundedTestSource<>(rowsPerCheckpoint); diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java index 68621018be57..abe77e795b09 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java @@ -63,6 +63,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionData; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.StructLike; import org.apache.iceberg.TestBase; import org.apache.iceberg.flink.SimpleDataUtil; @@ -95,11 +96,11 @@ public class TestIcebergFilesCommitter extends TestBase { @Parameters(name = "formatVersion = {0}, fileFormat = {1}, branch = {2}") protected static List parameters() { return Arrays.asList( - new Object[] {1, FileFormat.AVRO, "main"}, + new Object[] {1, FileFormat.AVRO, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.AVRO, "test-branch"}, - new Object[] {1, FileFormat.PARQUET, "main"}, + new Object[] {1, FileFormat.PARQUET, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.PARQUET, "test-branch"}, - new Object[] {1, FileFormat.ORC, "main"}, + new Object[] {1, FileFormat.ORC, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.ORC, "test-branch"}); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java index ddcb57f6ca33..0789be5b03cd 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java @@ -57,11 +57,11 @@ public class TestIcebergSinkBranch extends TestFlinkIcebergSinkBase { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {"main", true}, + {SnapshotRef.MAIN_BRANCH, true}, {"testBranch", true}, // Remove after the deprecation of TableSchema - END - {"main", false}, + {SnapshotRef.MAIN_BRANCH, false}, {"testBranch", false}, }; } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java deleted file mode 100644 index b84d21d020b3..000000000000 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java +++ /dev/null @@ -1,149 +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. - */ -package org.apache.iceberg.flink.sink; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import org.apache.flink.runtime.jobgraph.JobVertex; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraph; -import org.apache.flink.table.data.RowData; -import org.apache.flink.types.Row; -import org.apache.iceberg.DataFile; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.ManifestFiles; -import org.apache.iceberg.ManifestReader; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; -import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; -import org.apache.iceberg.flink.FlinkWriteOptions; -import org.apache.iceberg.flink.MiniFlinkClusterExtension; -import org.apache.iceberg.flink.SimpleDataUtil; -import org.apache.iceberg.flink.TestFixtures; -import org.apache.iceberg.flink.maintenance.api.LockConfig; -import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; -import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; -import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class TestIcebergSinkCompact extends TestFlinkIcebergSinkBase { - - private Map flinkConf; - - @BeforeEach - void before() throws IOException { - this.flinkConf = Maps.newHashMap(); - flinkConf.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); - flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), LockConfig.JdbcLockConfig.JDBC); - flinkConf.put( - LockConfig.JdbcLockConfig.JDBC_URI_OPTION.key(), - "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", "")); - flinkConf.put(LockConfig.LOCK_ID_OPTION.key(), "test-lock-id"); - flinkConf.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); - - flinkConf.put(LockConfig.JdbcLockConfig.JDBC_INIT_LOCK_TABLE_OPTION.key(), "true"); - flinkConf.put(RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); - - table = - CATALOG_EXTENSION - .catalog() - .createTable( - TestFixtures.TABLE_IDENTIFIER, - SimpleDataUtil.SCHEMA, - PartitionSpec.unpartitioned(), - Maps.newHashMap()); - - env = - StreamExecutionEnvironment.getExecutionEnvironment( - MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG) - .enableCheckpointing(100); - - tableLoader = CATALOG_EXTENSION.tableLoader(); - } - - @Test - public void testCompactFileE2e() throws Exception { - List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); - DataStream dataStream = - env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) - .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); - - IcebergSink.forRowData(dataStream) - .setAll(flinkConf) - .table(table) - .tableLoader(tableLoader) - .append(); - - env.execute("Test Iceberg Compaction DataStream"); - - table.refresh(); - // check the data file count after compact - List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); - assertThat(afterCompactDataFiles).hasSize(1); - - // check the data file count before compact - List preCompactDataFiles = - getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); - assertThat(preCompactDataFiles).hasSize(3); - } - - private List getDataFiles(Snapshot snapshot, Table table) throws IOException { - List dataFiles = Lists.newArrayList(); - for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { - try (ManifestReader reader = ManifestFiles.read(dataManifest, table.io())) { - reader.iterator().forEachRemaining(dataFiles::add); - } - } - - return dataFiles; - } - - @Test - public void testTableMaintenanceOperatorAdded() { - List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); - DataStream dataStream = - env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) - .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); - - IcebergSink.forRowData(dataStream) - .table(table) - .tableLoader(tableLoader) - .setAll(flinkConf) - .append(); - - boolean containRewrite = false; - StreamGraph streamGraph = env.getStreamGraph(); - for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { - if (vertex.getName().contains("Rewrite")) { - containRewrite = true; - break; - } - } - - assertThat(containRewrite).isTrue(); - } -} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java new file mode 100644 index 000000000000..5c926d7c25d5 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java @@ -0,0 +1,321 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.flink.runtime.jobgraph.JobVertex; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.graph.StreamGraph; +import org.apache.flink.table.data.RowData; +import org.apache.flink.types.Row; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.flink.MiniFlinkClusterExtension; +import org.apache.iceberg.flink.SimpleDataUtil; +import org.apache.iceberg.flink.TestFixtures; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; +import org.apache.iceberg.flink.maintenance.api.LockConfig; +import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; +import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +class TestIcebergSinkTableMaintenance extends TestFlinkIcebergSinkBase { + private static final String[] LOCK_TYPES = new String[] {LockConfig.JdbcLockConfig.JDBC, ""}; + + private Map flinkConf; + + @BeforeEach + void before() throws IOException { + this.flinkConf = Maps.newHashMap(); + + table = + CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SimpleDataUtil.SCHEMA, + PartitionSpec.unpartitioned(), + Maps.newHashMap()); + + env = + StreamExecutionEnvironment.getExecutionEnvironment( + MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG) + .enableCheckpointing(100); + + tableLoader = CATALOG_EXTENSION.tableLoader(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testCompactFileE2e(String lockType) throws Exception { + setupLockConfig(lockType); + setupCompactionConfig(); + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .setAll(flinkConf) + .table(table) + .tableLoader(tableLoader) + .append(); + + env.execute("Test Iceberg Compaction DataStream"); + + table.refresh(); + // check the data file count after compact + List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); + assertThat(afterCompactDataFiles).hasSize(1); + + // check the data file count before compact + List preCompactDataFiles = + getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); + assertThat(preCompactDataFiles).hasSize(3); + } + + private List getDataFiles(Snapshot snapshot, Table table) throws IOException { + List dataFiles = Lists.newArrayList(); + for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { + try (ManifestReader reader = + ManifestFiles.read(dataManifest, table.io(), table.specs())) { + reader.iterator().forEachRemaining(dataFiles::add); + } + } + + return dataFiles; + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testTableMaintenanceOperatorAdded(String lockType) { + setupLockConfig(lockType); + setupCompactionConfig(); + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + boolean containRewrite = false; + StreamGraph streamGraph = env.getStreamGraph(); + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Rewrite")) { + containRewrite = true; + break; + } + } + + assertThat(containRewrite).isTrue(); + } + + private void setupCompactionConfig() { + flinkConf.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); + flinkConf.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); + flinkConf.put(RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); + } + + private void setupExpireSnapshotsConfig() { + flinkConf.put(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key(), "true"); + flinkConf.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + } + + private void setupDeleteOrphanFilesConfig() { + flinkConf.put(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key(), "true"); + flinkConf.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + } + + private void setupLockConfig(String lockType) { + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), LockConfig.JdbcLockConfig.JDBC); + flinkConf.put( + LockConfig.JdbcLockConfig.JDBC_URI_OPTION.key(), + "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", "")); + flinkConf.put(LockConfig.LOCK_ID_OPTION.key(), "test-lock-id"); + flinkConf.put(LockConfig.JdbcLockConfig.JDBC_INIT_LOCK_TABLE_OPTION.key(), "true"); + } else { + flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), ""); + } + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testExpireSnapshotsEnabled(String lockType) { + setupLockConfig(lockType); + setupExpireSnapshotsConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containExpire = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Expire")) { + containExpire = true; + break; + } + } + + assertThat(containExpire).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testDeleteOrphanFilesEnabled(String lockType) { + setupLockConfig(lockType); + setupDeleteOrphanFilesConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containOrphan = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Orphan")) { + containOrphan = true; + break; + } + } + + assertThat(containOrphan).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testAllMaintenanceTasksCombined(String lockType) { + setupLockConfig(lockType); + setupCompactionConfig(); + setupExpireSnapshotsConfig(); + setupDeleteOrphanFilesConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containRewrite = false; + boolean containExpire = false; + boolean containOrphan = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Rewrite")) { + containRewrite = true; + } + + if (vertex.getName().contains("Expire")) { + containExpire = true; + } + + if (vertex.getName().contains("Orphan")) { + containOrphan = true; + } + } + + assertThat(containRewrite).isTrue(); + assertThat(containExpire).isTrue(); + assertThat(containOrphan).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testAllMaintenanceE2e(String lockType) throws Exception { + setupLockConfig(lockType); + + Map compactionConfig = Maps.newHashMap(); + compactionConfig.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); + compactionConfig.put( + RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); + + Map expireConfig = Maps.newHashMap(); + expireConfig.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + + Map orphanConfig = Maps.newHashMap(); + orphanConfig.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .setAll(flinkConf) + .table(table) + .tableLoader(tableLoader) + .rewriteDataFiles(compactionConfig) + .expireSnapshots(expireConfig) + .deleteOrphanFiles(orphanConfig) + .append(); + + env.execute("Test All Maintenance E2E"); + + table.refresh(); + // Compaction should have merged the 3 data files into 1 + List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); + assertThat(afterCompactDataFiles).hasSize(1); + + List preCompactDataFiles = + getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); + assertThat(preCompactDataFiles).hasSize(3); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java index f873dcd99c06..71cec082a09e 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java @@ -36,6 +36,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.TableProperties; import org.apache.iceberg.data.Record; @@ -225,13 +226,14 @@ public void testDeleteStats() throws Exception { false, elementsPerCheckpoint, expectedRecords, - "main"); + SnapshotRef.MAIN_BRANCH); - DeleteFile deleteFile = table.currentSnapshot().addedDeleteFiles(table.io()).iterator().next(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + DeleteFile deleteFile = changes.addedDeleteFiles().iterator().next(); String fromStat = new String( deleteFile.lowerBounds().get(MetadataColumns.DELETE_FILE_PATH.fieldId()).array()); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = changes.addedDataFiles().iterator().next(); assumeThat(fromStat).isEqualTo(dataFile.location()); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java new file mode 100644 index 000000000000..42bbfc0d3628 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; +import org.apache.iceberg.io.WriteResult; +import org.junit.jupiter.api.Test; + +public class TestIcebergStreamWriterMetrics { + + @Test + void histogramsCreatedWhenDropwizardAvailable() { + IcebergStreamWriterMetrics metrics = + new IcebergStreamWriterMetrics( + UnregisteredMetricsGroup.createSinkWriterMetricGroup(), "db.table"); + + assertThat(metrics.dataFilesSizeHistogram()).isNotNull(); + assertThat(metrics.deleteFilesSizeHistogram()).isNotNull(); + + assertThatNoException() + .isThrownBy(() -> metrics.updateFlushResult(WriteResult.builder().build())); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java index 919fef579ab0..2e7e6db176b2 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java @@ -57,7 +57,11 @@ public class TestRowDataPartitionKey { Types.NestedField.required(12, "decimalType2", Types.DecimalType.of(10, 5)), Types.NestedField.required(13, "decimalType3", Types.DecimalType.of(38, 19)), Types.NestedField.required(14, "floatType", Types.FloatType.get()), - Types.NestedField.required(15, "doubleType", Types.DoubleType.get())); + Types.NestedField.required(15, "doubleType", Types.DoubleType.get()), + Types.NestedField.required( + 16, "timestampNanoWithoutZone", Types.TimestampNanoType.withoutZone()), + Types.NestedField.required( + 17, "timestampNanoWithZone", Types.TimestampNanoType.withZone())); private static final List SUPPORTED_PRIMITIVES = SCHEMA.asStruct().fields().stream().map(Types.NestedField::name).collect(Collectors.toList()); @@ -248,4 +252,47 @@ public void testNestedPartitionValues() { } } } + + @Test + public void testTimestampNanoPartitionTransforms() { + RowType rowType = FlinkSchemaUtil.convert(SCHEMA); + RowDataWrapper rowWrapper = new RowDataWrapper(rowType, SCHEMA.asStruct()); + InternalRecordWrapper recordWrapper = new InternalRecordWrapper(SCHEMA.asStruct()); + + List records = RandomGenericData.generate(SCHEMA, 10, 1995); + List rows = Lists.newArrayList(RandomRowData.convert(SCHEMA, records)); + + String[] columns = {"timestampNanoWithoutZone", "timestampNanoWithZone"}; + for (String column : columns) { + List specs = + Lists.newArrayList( + PartitionSpec.builderFor(SCHEMA).identity(column).build(), + PartitionSpec.builderFor(SCHEMA).year(column).build(), + PartitionSpec.builderFor(SCHEMA).month(column).build(), + PartitionSpec.builderFor(SCHEMA).day(column).build(), + PartitionSpec.builderFor(SCHEMA).hour(column).build(), + PartitionSpec.builderFor(SCHEMA).bucket(column, 16).build()); + + for (PartitionSpec spec : specs) { + Class[] javaClasses = spec.javaClasses(); + PartitionKey pk = new PartitionKey(spec, SCHEMA); + PartitionKey expectedPK = new PartitionKey(spec, SCHEMA); + + for (int j = 0; j < rows.size(); j++) { + pk.partition(rowWrapper.wrap(rows.get(j))); + expectedPK.partition(recordWrapper.wrap(records.get(j))); + + assertThat(pk.size()).isEqualTo(1); + assertThat(pk.get(0, javaClasses[0])) + .as( + "Partition with column " + + column + + " and spec " + + spec + + " should match Iceberg-side computation") + .isEqualTo(expectedPK.get(0, javaClasses[0])); + } + } + } + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java index 30782e8d4170..07096b891a95 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java @@ -55,15 +55,19 @@ abstract class DynamicRecordInternalSerializerTestBase static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).bucket("id", 10).build(); private boolean writeFullSchemaAndSpec; + private final boolean writeLongUTF; - DynamicRecordInternalSerializerTestBase(boolean writeFullSchemaAndSpec) { + DynamicRecordInternalSerializerTestBase(boolean writeFullSchemaAndSpec, boolean writeLongUTF) { this.writeFullSchemaAndSpec = writeFullSchemaAndSpec; + this.writeLongUTF = writeLongUTF; } @Override protected TypeSerializer createSerializer() { return new DynamicRecordInternalSerializer( - new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), writeFullSchemaAndSpec); + new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), + writeFullSchemaAndSpec, + writeLongUTF); } @BeforeEach diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java index 385a354889fb..9e4d600f9325 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java @@ -33,6 +33,12 @@ class TestCompareSchemasVisitor { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + @Test void testSchema() { assertThat( @@ -44,7 +50,9 @@ void testSchema() { new Schema( optional(1, "id", IntegerType.get(), "comment"), optional(2, "data", StringType.get()), - optional(3, "extra", StringType.get())))) + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -59,7 +67,9 @@ void testSchemaDifferentId() { new Schema( optional(1, "id", IntegerType.get()), optional(2, "data", StringType.get()), - optional(3, "extra", StringType.get())))) + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -72,7 +82,9 @@ void testSchemaDifferent() { optional(1, "data", StringType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get())))) + optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -85,7 +97,9 @@ void testSchemaWithMoreColumns() { new Schema( optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get()), - optional(2, "extra", StringType.get())))) + optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -96,7 +110,9 @@ void testDifferentType() { new Schema( optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())))) + optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -107,7 +123,9 @@ void testCompatibleType() { new Schema( optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())))) + optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -117,9 +135,11 @@ void testRequiredChangeForMatchingField() { new Schema(optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); Schema tableSchema = new Schema(required(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); - assertThat(CompareSchemasVisitor.visit(tableSchema, dataSchema)) + assertThat( + CompareSchemasVisitor.visit(tableSchema, dataSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -128,9 +148,11 @@ void testRequiredChangeForNonMatchingField() { Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); Schema tableSchema = new Schema(optional(1, "id", IntegerType.get()), required(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); - assertThat(CompareSchemasVisitor.visit(tableSchema, dataSchema)) + assertThat( + CompareSchemasVisitor.visit(tableSchema, dataSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -139,7 +161,8 @@ void testNoRequiredChangeForNonMatchingField() { Schema dataSchema = new Schema(required(1, "id", IntegerType.get())); Schema tableSchema = new Schema(required(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -152,8 +175,9 @@ void testStructDifferentId() { optional(2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))), new Schema( optional(0, "id", IntegerType.get()), - optional( - 1, "struct1", StructType.of(optional(2, "extra", IntegerType.get())))))) + optional(1, "struct1", StructType.of(optional(2, "extra", IntegerType.get())))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -166,8 +190,9 @@ void testStructChanged() { optional(1, "struct1", StructType.of(optional(2, "extra", LongType.get())))), new Schema( optional(1, "id", IntegerType.get()), - optional( - 2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))))) + optional(2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -182,7 +207,9 @@ void testMapDifferentId() { new Schema( optional(0, "id", IntegerType.get()), optional( - 1, "map1", MapType.ofOptional(2, 3, IntegerType.get(), StringType.get()))))) + 1, "map1", MapType.ofOptional(2, 3, IntegerType.get(), StringType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -197,7 +224,9 @@ void testMapChanged() { new Schema( optional(1, "id", IntegerType.get()), optional( - 2, "map1", MapType.ofOptional(3, 4, IntegerType.get(), StringType.get()))))) + 2, "map1", MapType.ofOptional(3, 4, IntegerType.get(), StringType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -210,7 +239,9 @@ void testListDifferentId() { optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))), new Schema( optional(0, "id", IntegerType.get()), - optional(1, "list1", ListType.ofOptional(2, IntegerType.get()))))) + optional(1, "list1", ListType.ofOptional(2, IntegerType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -223,7 +254,133 @@ void testListChanged() { optional(1, "list1", ListType.ofOptional(2, LongType.get()))), new Schema( optional(1, "id", IntegerType.get()), - optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))))) + optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testCaseInsensitiveFieldMatching() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "Data", StringType.get()), + optional(3, "EXTRA", StringType.get())), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testCaseSensitiveFieldMatchingDefault() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "Data", StringType.get()), + optional(3, "EXTRA", StringType.get())), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testCaseInsensitiveNestedStruct() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "STRUCT1", StructType.of(optional(3, "NESTED", StringType.get())))), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "struct1", StructType.of(optional(3, "nested", StringType.get())))), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testCaseInsensitiveWithMoreColumns() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(0, "ID", IntegerType.get()), optional(1, "DATA", StringType.get())), + new Schema( + optional(0, "id", IntegerType.get()), + optional(1, "data", StringType.get()), + optional(2, "extra", StringType.get())), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); + } + + @Test + void testDropUnusedColumnsEnabled() { + Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); + Schema tableSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } + + @Test + void testDropUnusedColumnsWithRequiredField() { + Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); + Schema tableSchema = + new Schema(optional(1, "id", IntegerType.get()), required(2, "data", StringType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testDropUnusedColumnsWhenInputHasMoreFields() { + Schema dataSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())); + Schema tableSchema = new Schema(optional(1, "id", IntegerType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testDropUnusedColumnsInNestedStruct() { + Schema dataSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "struct1", StructType.of(optional(3, "field1", StringType.get())))); + Schema tableSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional( + 2, + "struct1", + StructType.of( + optional(3, "field1", StringType.get()), + optional(4, "field2", IntegerType.get())))); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java index 13a06d362717..16890d1f63d0 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java @@ -21,42 +21,94 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.Set; import org.apache.flink.api.common.JobID; +import org.apache.flink.core.memory.DataOutputViewStreamWrapper; import org.apache.flink.runtime.jobgraph.OperatorID; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.junit.jupiter.api.Test; class TestDynamicCommittableSerializer { + private static final DynamicCommittable COMMITTABLE = + new DynamicCommittable( + new TableKey("table", "branch"), + new byte[][] {{3, 4}, {5, 6}}, + JobID.generate().toHexString(), + new OperatorID().toHexString(), + 5); @Test - void testRoundtrip() throws IOException { - DynamicCommittable committable = + void testV1() throws IOException { + var committable = new DynamicCommittable( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - new byte[] {3, 4}, + new TableKey("table", "branch"), + new byte[][] {{3, 4}}, JobID.generate().toHexString(), new OperatorID().toHexString(), 5); - DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); - assertThat(serializer.deserialize(serializer.getVersion(), serializer.serialize(committable))) - .isEqualTo(committable); + assertThat(serializer.deserialize(1, serializeV1(committable))).isEqualTo(committable); } @Test - void testUnsupportedVersion() throws IOException { - DynamicCommittable committable = - new DynamicCommittable( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - new byte[] {3, 4}, - JobID.generate().toHexString(), - new OperatorID().toHexString(), - 5); + void testLatestVersion() throws IOException { + DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); + assertThat(serializer.deserialize(serializer.getVersion(), serializer.serialize(COMMITTABLE))) + .isEqualTo(COMMITTABLE); + } + @Test + void testUnsupportedVersion() { DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); - assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(committable))) + assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(COMMITTABLE))) .hasMessage("Unrecognized version or corrupt state: -1") .isInstanceOf(IOException.class); } + + byte[] serializeV1(DynamicCommittable committable) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); + + // Wrap TableKey into a testing WriteTarget to match the V1 format + WriteTarget writeTarget = + new WriteTarget( + committable.key().tableName(), + committable.key().branch(), + -1, + -1, + false, + Set.of(1, 2, 3)); + view.write(serializeV1(writeTarget)); + + view.writeUTF(committable.jobId()); + view.writeUTF(committable.operatorId()); + view.writeLong(committable.checkpointId()); + + Preconditions.checkArgument( + committable.manifests().length == 1, + "V1 serialization format must have only one manifest per committable."); + view.writeInt(committable.manifests()[0].length); + view.write(committable.manifests()[0]); + + return out.toByteArray(); + } + + byte[] serializeV1(WriteTarget writeTarget) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); + + view.writeUTF(writeTarget.tableName()); + view.writeUTF(writeTarget.branch()); + view.writeInt(writeTarget.schemaId()); + view.writeInt(writeTarget.specId()); + view.writeBoolean(writeTarget.upsertMode()); + view.writeInt(writeTarget.equalityFields().size()); + for (Integer equalityField : writeTarget.equalityFields()) { + view.writeInt(equalityField); + } + + return out.toByteArray(); + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java index 1497458e6083..4cc27151b094 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java @@ -54,6 +54,7 @@ import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; @@ -125,6 +126,15 @@ class TestDynamicCommitter { .ofPositionDeletes() .build(); + private static final Map> WRITE_RESULT_BY_SPEC = + Map.of( + DATA_FILE.specId(), + Lists.newArrayList(WriteResult.builder().addDataFiles(DATA_FILE).build())); + private static final Map> WRITE_RESULT_BY_SPEC_2 = + Map.of( + DATA_FILE_2.specId(), + Lists.newArrayList(WriteResult.builder().addDataFiles(DATA_FILE_2).build())); + @BeforeEach void before() { catalog = CATALOG_EXTENSION.catalog(); @@ -155,12 +165,9 @@ void testCommit() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget1 = - new WriteTarget(TABLE1, "branch", 42, 0, true, Sets.newHashSet(1, 2)); - WriteTarget writeTarget2 = - new WriteTarget(TABLE1, "branch2", 43, 0, true, Sets.newHashSet(1, 2)); - WriteTarget writeTarget3 = - new WriteTarget(TABLE2, "branch2", 43, 0, true, Sets.newHashSet(1, 2)); + TableKey tableKey1 = new TableKey(TABLE1, "branch"); + TableKey tableKey2 = new TableKey(TABLE1, "branch2"); + TableKey tableKey3 = new TableKey(TABLE2, "branch2"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -168,27 +175,12 @@ void testCommit() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget1, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget2, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget3, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests2 = + aggregator.writeToManifests(tableKey2.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey3.tableName(), WRITE_RESULT_BY_SPEC, 0); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); @@ -196,15 +188,15 @@ void testCommit() throws Exception { CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey1, deltaManifests1, jobId, operatorId, checkpointId)); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey2, deltaManifests2, jobId, operatorId, checkpointId)); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey3, deltaManifests3, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest1, commitRequest2, commitRequest3)); @@ -267,7 +259,7 @@ void testCommit() throws Exception { } @Test - void testAlreadyCommitted() throws Exception { + void testSkipsCommitRequestsForPreviousCheckpoints() throws Exception { Table table1 = catalog.loadTable(TableIdentifier.of(TABLE1)); assertThat(table1.snapshots()).isEmpty(); @@ -285,8 +277,7 @@ void testAlreadyCommitted() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -298,24 +289,18 @@ void testAlreadyCommitted() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); CommitRequest oldCommitRequest = new MockCommitRequest<>( - new DynamicCommittable( - writeTarget, deltaManifest, jobId, operatorId, checkpointId - 1)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId - 1)); // Old commits requests shouldn't affect the result dynamicCommitter.commit(Sets.newHashSet(oldCommitRequest)); @@ -360,25 +345,23 @@ void testCommitDeleteInDifferentFormatVersion() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); - DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); OneInputStreamOperatorTestHarness aggregatorHarness = new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); + TableKey tableKey = new TableKey(TABLE1, "branch"); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, + byte[][] deltaManifests = + aggregator.writeToManifests( + tableKey.tableName(), + Map.of( + DATA_FILE.specId(), + Sets.newHashSet( WriteResult.builder() .addDataFiles(DATA_FILE) .addDeleteFiles(DELETE_FILE) @@ -387,7 +370,7 @@ void testCommitDeleteInDifferentFormatVersion() throws Exception { CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); // Upgrade the table version UpdateProperties updateApi = table1.updateProperties(); @@ -421,8 +404,7 @@ void testCommitOnlyDataInDifferentFormatVersion() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -434,17 +416,12 @@ void testCommitOnlyDataInDifferentFormatVersion() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); @@ -486,50 +463,34 @@ void testTableBranchAtomicCommitForAppendOnlyData() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - WriteTarget writeTarget1 = - new WriteTarget(TABLE1, "branch1", 42, 0, true, Sets.newHashSet(1, 2)); - // writeTarget2 has a different schema - WriteTarget writeTarget2 = new WriteTarget(TABLE1, "branch1", 23, 0, true, Sets.newHashSet()); - // Different branch for writeTarget3 - WriteTarget writeTarget3 = new WriteTarget(TABLE1, "branch2", 23, 0, true, Sets.newHashSet()); - - WriteResult writeResult1 = WriteResult.builder().addDataFiles(DATA_FILE).build(); - WriteResult writeResult2 = WriteResult.builder().addDataFiles(DATA_FILE_2).build(); + TableKey tableKey1 = new TableKey(TABLE1, "branch1"); + TableKey tableKey2 = new TableKey(TABLE1, "branch2"); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId1 = 1; final int checkpointId2 = 2; - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet(new DynamicWriteResult(writeTarget1, writeResult1)), - checkpointId1); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC, checkpointId1); CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId1)); + new DynamicCommittable(tableKey1, deltaManifests1, jobId, operatorId, checkpointId1)); - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet(new DynamicWriteResult(writeTarget2, writeResult2)), - checkpointId1); + byte[][] deltaManifests2 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId1); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId1)); + new DynamicCommittable(tableKey1, deltaManifests2, jobId, operatorId, checkpointId1)); - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet(new DynamicWriteResult(writeTarget3, writeResult2)), - checkpointId2); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey2.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId2); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId2)); + new DynamicCommittable(tableKey2, deltaManifests3, jobId, operatorId, checkpointId2)); boolean overwriteMode = false; int workerPoolSize = 1; @@ -601,49 +562,36 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - WriteTarget writeTarget1 = new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet()); - // writeTarget2 has a different schema - WriteTarget writeTarget2 = new WriteTarget(TABLE1, "branch", 23, 0, false, Sets.newHashSet()); - WriteTarget writeTarget3 = new WriteTarget(TABLE1, "branch", 23, 0, false, Sets.newHashSet()); + TableKey tableKey = new TableKey(TABLE1, "branch"); + + Map> writeResults = + Map.of( + DELETE_FILE.specId(), + Lists.newArrayList(WriteResult.builder().addDeleteFiles(DELETE_FILE).build())); - WriteResult writeResult1 = WriteResult.builder().addDataFiles(DATA_FILE).build(); - WriteResult writeResult2 = WriteResult.builder().addDeleteFiles(DELETE_FILE).build(); - WriteResult writeResult3 = WriteResult.builder().addDataFiles(DATA_FILE).build(); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests2 = aggregator.writeToManifests(tableKey.tableName(), writeResults, 0); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC_2, 0); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId1 = 1; final int checkpointId2 = 2; - - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet(new DynamicWriteResult(writeTarget1, writeResult1)), - checkpointId1); + final int checkpointId3 = 3; CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId1)); - - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet(new DynamicWriteResult(writeTarget2, writeResult2)), - checkpointId2); + new DynamicCommittable(tableKey, deltaManifests1, jobId, operatorId, checkpointId1)); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId2)); - - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet(new DynamicWriteResult(writeTarget3, writeResult3)), - checkpointId2); + new DynamicCommittable(tableKey, deltaManifests2, jobId, operatorId, checkpointId2)); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId2)); + new DynamicCommittable(tableKey, deltaManifests3, jobId, operatorId, checkpointId3)); boolean overwriteMode = false; int workerPoolSize = 1; @@ -693,10 +641,7 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { } table.refresh(); - // Three committables, but only two snapshots! WriteResults from different checkpoints are not - // getting - // combined due to one writeResult2 containing a delete file. - assertThat(table.snapshots()).hasSize(2); + assertThat(table.snapshots()).hasSize(3); Snapshot snapshot1 = Iterables.getFirst(table.snapshots(), null); assertThat(snapshot1.summary()) @@ -720,21 +665,90 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { assertThat(snapshot2.summary()) .containsAllEntriesOf( ImmutableMap.builder() - .put("added-data-files", "1") - .put("added-records", "42") .put("changed-partition-count", "1") .put("flink.job-id", jobId) .put("flink.max-committed-checkpoint-id", "" + checkpointId2) .put("flink.operator-id", operatorId) + .put("total-data-files", "1") + .put("total-delete-files", "1") + .put("total-equality-deletes", "0") + .put("total-files-size", "0") + .put("total-position-deletes", "24") + .put("total-records", "42") + .build()); + + Snapshot snapshot3 = Iterables.get(table.snapshots(), 2); + assertThat(snapshot3.summary()) + .containsAllEntriesOf( + ImmutableMap.builder() + .put("added-data-files", "1") + .put("added-records", "24") + .put("changed-partition-count", "1") + .put("flink.job-id", jobId) + .put("flink.max-committed-checkpoint-id", "" + checkpointId3) + .put("flink.operator-id", operatorId) .put("total-data-files", "2") .put("total-delete-files", "1") .put("total-equality-deletes", "0") .put("total-files-size", "0") .put("total-position-deletes", "24") - .put("total-records", "84") + .put("total-records", "66") .build()); } + @Test + void testCommitDeltaTxnWithAppendFiles() throws Exception { + Table table = catalog.loadTable(TableIdentifier.of(TABLE1)); + assertThat(table.snapshots()).isEmpty(); + + DynamicWriteResultAggregator aggregator = + new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); + OneInputStreamOperatorTestHarness aggregatorHarness = + new OneInputStreamOperatorTestHarness(aggregator); + aggregatorHarness.open(); + + TableKey tableKey = new TableKey(TABLE1, "branch1"); + final String jobId = JobID.generate().toHexString(); + final String operatorId = new OperatorID().toHexString(); + final int checkpointId = 1; + + byte[][] deltaManifest1 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); + + CommitRequest commitRequest1 = + new MockCommitRequest<>( + new DynamicCommittable(tableKey, deltaManifest1, jobId, operatorId, checkpointId)); + + byte[][] deltaManifest2 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId); + + CommitRequest commitRequest2 = + new MockCommitRequest<>( + new DynamicCommittable(tableKey, deltaManifest2, jobId, operatorId, checkpointId)); + + boolean overwriteMode = false; + int workerPoolSize = 1; + String sinkId = "sinkId"; + UnregisteredMetricsGroup metricGroup = new UnregisteredMetricsGroup(); + DynamicCommitterMetrics committerMetrics = new DynamicCommitterMetrics(metricGroup); + DynamicCommitter dynamicCommitter = + new DynamicCommitter( + CATALOG_EXTENSION.catalog(), + Maps.newHashMap(), + overwriteMode, + workerPoolSize, + sinkId, + committerMetrics); + + dynamicCommitter.commit(Sets.newHashSet(commitRequest1, commitRequest2)); + + table.refresh(); + assertThat(table.snapshots()).hasSize(1); + + Snapshot snapshot = Iterables.getFirst(table.snapshots(), null); + assertThat(snapshot.operation()).isEqualTo("append"); + } + @Test void testReplacePartitions() throws Exception { Table table1 = catalog.loadTable(TableIdentifier.of(TABLE1)); @@ -755,8 +769,7 @@ void testReplacePartitions() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -768,32 +781,22 @@ void testReplacePartitions() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); - byte[] overwriteManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId + 1); + byte[][] overwriteManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest overwriteRequest = new MockCommitRequest<>( new DynamicCommittable( - writeTarget, overwriteManifest, jobId, operatorId, checkpointId + 1)); + tableKey, overwriteManifests, jobId, operatorId, checkpointId + 1)); dynamicCommitter.commit(Sets.newHashSet(overwriteRequest)); @@ -836,18 +839,13 @@ void testThrowsValidationExceptionOnDuplicateCommit(boolean overwriteMode) throw final int checkpointId = 1; final String branch = SnapshotRef.MAIN_BRANCH; - WriteTarget writeTarget = new WriteTarget(TABLE1, branch, 42, 0, false, Sets.newHashSet(1, 2)); - byte[] manifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + TableKey tableKey = new TableKey(TABLE1, branch); + byte[][] manifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, manifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, manifests, jobId, operatorId, checkpointId)); Collection> commitRequests = Sets.newHashSet(commitRequest1); int workerPoolSize = 1; diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java index b660d8e285d9..89befb9e8ea2 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java @@ -29,8 +29,11 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import javax.annotation.Nullable; import org.apache.flink.api.common.typeinfo.TypeHint; import org.apache.flink.api.common.typeinfo.TypeInformation; @@ -40,9 +43,14 @@ import org.apache.flink.configuration.Configuration; import org.apache.flink.configuration.RestartStrategyOptions; import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; +import org.apache.flink.runtime.OperatorIDPair; import org.apache.flink.runtime.client.JobExecutionException; +import org.apache.flink.runtime.jobgraph.JobVertex; +import org.apache.flink.streaming.api.connector.sink2.CommittableMessage; import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.datastream.DataStreamSource; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.graph.StreamNode; import org.apache.flink.table.catalog.ResolvedSchema; import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.util.DataFormatConverters; @@ -58,6 +66,8 @@ import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.Catalog; @@ -68,6 +78,7 @@ import org.apache.iceberg.flink.CatalogLoader; import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.MiniFlinkClusterExtension; import org.apache.iceberg.flink.SimpleDataUtil; import org.apache.iceberg.flink.TestHelpers; @@ -76,8 +87,10 @@ import org.apache.iceberg.flink.sink.dynamic.TestDynamicCommitter.FailBeforeAndAfterCommit; import org.apache.iceberg.inmemory.InMemoryInputFile; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -111,6 +124,7 @@ private static class DynamicIcebergDataImpl implements Serializable { PartitionSpec partitionSpec; boolean upsertMode; Set equalityFields; + int writeParallelism; private DynamicIcebergDataImpl( Schema schemaProvided, String tableName, String branch, PartitionSpec partitionSpec) { @@ -122,7 +136,8 @@ private DynamicIcebergDataImpl( partitionSpec, false, Collections.emptySet(), - false); + false, + 10); } private DynamicIcebergDataImpl( @@ -139,7 +154,8 @@ private DynamicIcebergDataImpl( partitionSpec, false, Collections.emptySet(), - false); + false, + 10); } private DynamicIcebergDataImpl( @@ -158,7 +174,8 @@ private DynamicIcebergDataImpl( partitionSpec, upsertMode, equalityFields, - isDuplicate); + isDuplicate, + 10); } private DynamicIcebergDataImpl( @@ -169,7 +186,8 @@ private DynamicIcebergDataImpl( PartitionSpec partitionSpec, boolean upsertMode, Set equalityFields, - boolean isDuplicate) { + boolean isDuplicate, + int writeParallelism) { this.rowProvided = randomRow(schemaProvided, isDuplicate ? seed : ++seed); this.rowExpected = isDuplicate ? null : rowProvided; this.schemaProvided = schemaProvided; @@ -179,6 +197,7 @@ private DynamicIcebergDataImpl( this.partitionSpec = partitionSpec; this.upsertMode = upsertMode; this.equalityFields = equalityFields; + this.writeParallelism = writeParallelism; } } @@ -198,6 +217,56 @@ public void generate(DynamicIcebergDataImpl row, Collector out) { converter(schema).toInternal(row.rowProvided), spec, spec.isPartitioned() ? DistributionMode.HASH : DistributionMode.NONE, + row.writeParallelism); + dynamicRecord.setUpsertMode(row.upsertMode); + dynamicRecord.setEqualityFields(row.equalityFields); + out.collect(dynamicRecord); + } + } + + /** Generator that always emits forward (null distributionMode) records. */ + private static class ForwardGenerator implements DynamicRecordGenerator { + @Override + public void generate(DynamicIcebergDataImpl row, Collector out) { + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, row.tableName); + Schema schema = row.schemaProvided; + PartitionSpec spec = row.partitionSpec; + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + row.branch, + schema, + converter(schema).toInternal(row.rowProvided), + spec); + dynamicRecord.setUpsertMode(row.upsertMode); + dynamicRecord.setEqualityFields(row.equalityFields); + out.collect(dynamicRecord); + } + } + + /** + * Generator that alternates between forward (null distributionMode) and shuffle records. Even + * indices go forward, odd indices go through shuffle. + */ + private static class MixedGenerator implements DynamicRecordGenerator { + private int count = 0; + + @Override + public void generate(DynamicIcebergDataImpl row, Collector out) { + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, row.tableName); + Schema schema = row.schemaProvided; + PartitionSpec spec = row.partitionSpec; + boolean forward = (count++ % 2 == 0); + DistributionMode mode = + forward ? null : (spec.isPartitioned() ? DistributionMode.HASH : DistributionMode.NONE); + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + row.branch, + schema, + converter(schema).toInternal(row.rowProvided), + spec, + mode, 10); dynamicRecord.setUpsertMode(row.upsertMode); dynamicRecord.setEqualityFields(row.equalityFields); @@ -217,24 +286,133 @@ void testWrite() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } + @Test + void testNoShuffleTopology() throws Exception { + DataStream dataStream = + env.fromData( + Collections.emptyList(), TypeInformation.of(new TypeHint() {})); + DynamicIcebergSink.forInput(dataStream) + .generator(new ForwardGenerator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(false) + .overwrite(false) + .append(); + + boolean generatorAndSinkChained = false; + for (JobVertex vertex : env.getStreamGraph().getJobGraph().getVertices()) { + boolean generatorInThisVertex = false; + boolean sinkInThisVertex = false; + for (OperatorIDPair operatorID : vertex.getOperatorIDs()) { + String uid = operatorID.getUserDefinedOperatorUid(); + if (uid == null) { + continue; + } + + if (uid.endsWith("-forward-writer")) { + sinkInThisVertex = true; + } else if (uid.endsWith("-generator")) { + generatorInThisVertex = true; + } + } + + generatorAndSinkChained = generatorInThisVertex && sinkInThisVertex; + if (generatorAndSinkChained) { + break; + } + } + + assertThat(generatorAndSinkChained).isTrue(); + } + + @Test + void testForwardWrite() throws Exception { + runForwardWriteTest(new ForwardGenerator()); + } + + @Test + void testMixedForwardAndShuffleWrite() throws Exception { + runForwardWriteTest(new MixedGenerator()); + } + + private void runForwardWriteTest(DynamicRecordGenerator generator) + throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(1); + + DynamicIcebergSink.forInput(dataStream) + .generator(generator) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(1) + .immediateTableUpdate(true) + .append(); + + env.execute(); + + verifyResults(rows); + } + + @Test + void testWriteWithNullBranch() throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, "t1", null, PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, "t1", null, PartitionSpec.unpartitioned())); + + runTest( + rows, this.env, false, 1, ImmutableMap.of(FlinkWriteOptions.BRANCH.key(), "test-branch")); + } + @Test void testWritePartitioned() throws Exception { PartitionSpec spec = PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).bucket("id", 10).build(); List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec)); + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec)); runTest(rows); } @@ -257,11 +435,11 @@ void testWritePartitionedAdjustSchemaIdsInSpec() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema2, "t1", "main", spec2), - new DynamicIcebergDataImpl(schema2, "t1", "main", spec2)); + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema2, "t1", SnapshotRef.MAIN_BRANCH, spec2), + new DynamicIcebergDataImpl(schema2, "t1", SnapshotRef.MAIN_BRANCH, spec2)); runTest(rows); } @@ -291,15 +469,35 @@ void testSchemaEvolutionFieldOrderChanges() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema2, expectedSchema2, "t1", "main", PartitionSpec.unpartitioned()), + schema2, + expectedSchema2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema2, expectedSchema2, "t1", "main", PartitionSpec.unpartitioned())); + schema2, + expectedSchema2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); for (DynamicIcebergDataImpl row : rows) { if (row.schemaExpected == expectedSchema) { @@ -316,9 +514,15 @@ void testMultipleTables() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -329,8 +533,8 @@ void testMultipleTablesPartitioned() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t2", "main", spec)); + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t2", SnapshotRef.MAIN_BRANCH, spec)); runTest(rows); } @@ -340,9 +544,15 @@ void testSchemaEvolutionAddField() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); } @@ -352,9 +562,15 @@ void testRowEvolutionNullMissingOptionalField() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); } @@ -379,7 +595,7 @@ void testRowEvolutionMakeMissingRequiredFieldOptional() throws Exception { writeSchemaWithoutRequiredField, existingSchemaWithRequiredField, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); @@ -393,9 +609,10 @@ void testSchemaEvolutionNonBackwardsCompatible() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(initialSchema, "t1", "main", PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - erroringSchema, "t1", "main", PartitionSpec.unpartitioned())); + initialSchema, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + erroringSchema, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); try { runTest(rows, StreamExecutionEnvironment.getExecutionEnvironment(), 1); @@ -416,20 +633,45 @@ void testPartitionSpecEvolution() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2)); + SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2)); runTest(rows); + + // Validate the table has expected partition specs + Table table = CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1")); + Map tableSpecs = table.specs(); + List expectedSpecs = List.of(spec1, spec2, PartitionSpec.unpartitioned()); + + assertThat(tableSpecs).hasSize(expectedSpecs.size()); + expectedSpecs.forEach( + expectedSpec -> + assertThat( + tableSpecs.values().stream() + .anyMatch( + spec -> PartitionSpecEvolution.checkCompatibility(spec, expectedSpec))) + .withFailMessage("Table spec not found: %s.", expectedSpec) + .isTrue()); } @Test @@ -439,7 +681,10 @@ void testMultipleBranches() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", "branch1", PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -449,23 +694,50 @@ void testWriteMultipleTablesWithSchemaChanges() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -478,7 +750,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -487,7 +759,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -495,7 +767,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -503,7 +775,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -547,7 +819,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -556,7 +828,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -564,7 +836,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -572,7 +844,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -625,7 +897,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -634,7 +906,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -642,7 +914,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -650,7 +922,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -662,7 +934,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -671,7 +943,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -679,7 +951,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -687,7 +959,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -745,9 +1017,15 @@ void testCommitFailedBeforeOrAfterCommit() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); final CommitHook commitHook = new FailBeforeAndAfterCommit(); assertThat(FailBeforeAndAfterCommit.failedBeforeCommit).isFalse(); @@ -769,9 +1047,15 @@ void testCommitConcurrency() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); TableIdentifier tableIdentifier = TableIdentifier.of("default", "t1"); Catalog catalog = CATALOG_EXTENSION.catalog(); @@ -789,9 +1073,15 @@ void testCommitsOnceWhenConcurrentDuplicateCommit(boolean overwriteMode) throws List records = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, tableId.name(), "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + tableId.name(), + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, tableId.name(), "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + tableId.name(), + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); CommitHook duplicateCommit = new DuplicateCommitHook( @@ -822,6 +1112,334 @@ void testCommitsOnceWhenConcurrentDuplicateCommit(boolean overwriteMode) throws assertThat(totalAddedRecords).isEqualTo(records.size()); } + @Test + void testCommitsOncePerTableBranchAndCheckpoint() throws Exception { + String tableName = "t1"; + String branch = SnapshotRef.MAIN_BRANCH; + PartitionSpec spec1 = PartitionSpec.unpartitioned(); + PartitionSpec spec2 = PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).bucket("id", 10).build(); + Set equalityFields = Sets.newHashSet("id"); + + List inputRecords = + Lists.newArrayList( + // Two schemas + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA2, tableName, branch, spec1), + // Two specs + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec2), + // Some upserts + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, tableName, branch, spec1, true, equalityFields, false), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, tableName, branch, spec1, true, equalityFields, true)); + + executeDynamicSink(inputRecords, env, true, 1, null); + + List actualRecords; + try (CloseableIterable iterable = + IcebergGenerics.read( + CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of("default", "t1"))) + .build()) { + actualRecords = Lists.newArrayList(iterable); + } + + // Validate records + int expectedRecords = inputRecords.size() - 1; // 1 duplicate + assertThat(actualRecords).hasSize(expectedRecords); + + for (int i = 0; i < expectedRecords; i++) { + Record actual = actualRecords.get(0); + assertThat(inputRecords) + .anySatisfy( + inputRecord -> { + assertThat(actual.get(0)).isEqualTo(inputRecord.rowProvided.getField(0)); + assertThat(actual.get(1)).isEqualTo(inputRecord.rowProvided.getField(1)); + if (inputRecord.schemaProvided.equals(SimpleDataUtil.SCHEMA2)) { + assertThat(actual.get(2)).isEqualTo(inputRecord.rowProvided.getField(2)); + } + // There is an additional _pos field which gets added + }); + } + + TableIdentifier tableIdentifier = TableIdentifier.of("default", tableName); + Table table = CATALOG_EXTENSION.catalog().loadTable(tableIdentifier); + + Snapshot lastSnapshot = Iterables.getLast(table.snapshots()); + assertThat(lastSnapshot).isNotNull(); + assertThat(lastSnapshot.summary()) + .containsAllEntriesOf( + ImmutableMap.builder() + .put("total-delete-files", "2") + .put("total-records", "6") + .build()); + + // Count commits per checkpoint + Map commitsPerCheckpoint = + StreamSupport.stream(table.snapshots().spliterator(), false) + .map(snapshot -> snapshot.summary().get("flink.max-committed-checkpoint-id")) + .filter(Objects::nonNull) + .map(Long::parseLong) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + assertThat(commitsPerCheckpoint.values()).allMatch(count -> count == 1); + } + + @Test + void testOptInDropUnusedColumns() throws Exception { + Schema schema1 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "extra", Types.StringType.get())); + + Schema schema2 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, "t1"); + catalog.createTable(tableIdentifier, schema1); + + List rows = + Lists.newArrayList( + // Drop columns + new DynamicIcebergDataImpl( + schema2, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned()), + // Re-add columns + new DynamicIcebergDataImpl( + schema1, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(1); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .immediateTableUpdate(true) + .dropUnusedColumns(true) + .append(); + + env.execute("Test Drop Unused Columns"); + + Table table = catalog.loadTable(tableIdentifier); + table.refresh(); + + assertThat(table.schema().columns()).hasSize(2); + assertThat(table.schema().findField("id")).isNotNull(); + assertThat(table.schema().findField("data")).isNotNull(); + assertThat(table.schema().findField("extra")).isNull(); + + List records = Lists.newArrayList(IcebergGenerics.read(table).build()); + assertThat(records).hasSize(2); + } + + @Test + void testCaseInsensitiveSchemaMatching() throws Exception { + Schema lowerCaseSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema upperCaseSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + Schema mixedCaseSchema = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get())); + + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + lowerCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + upperCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + mixedCaseSchema, "t1", "main", PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(2); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(true) + .caseSensitive(false) + .append(); + + env.execute("Test Case Insensitive Iceberg DataStream"); + + verifyResults(rows); + } + + @Test + void testCaseSensitiveSchemaMatchingCreatesNewFields() throws Exception { + Schema lowerCaseSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema upperCaseSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + lowerCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + upperCaseSchema, "t1", "main", PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(2); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(true) + .caseSensitive(true) + .append(); + + env.execute("Test Case Sensitive Iceberg DataStream"); + + Table table = CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1")); + Schema resultSchema = table.schema(); + assertThat(resultSchema.columns()).hasSize(4); + assertThat(resultSchema.findField("id")).isNotNull(); + assertThat(resultSchema.findField("ID")).isNotNull(); + assertThat(resultSchema.findField("data")).isNotNull(); + assertThat(resultSchema.findField("DATA")).isNotNull(); + } + + @Test + void testOperatorUidsAreDeterministic() { + assertThat(createSinkAndReturnUIds("test")).isEqualTo(createSinkAndReturnUIds("test")); + assertThat(createSinkAndReturnUIds("test")) + .doesNotContainAnyElementsOf(createSinkAndReturnUIds("test2")); + } + + @Test + void testOperatorUidsFormat() { + Set sinkUids = createSinkAndReturnUIds("test"); + // These look odd, but we need to keep the UIDs consistent. We had a bug where the UID of the + // pre commit topology was off, but since it is stateless, users will still be able to restore + // state, but we must keep the stateful operators UUIds like the committer consistent. + assertThat(sinkUids) + .containsOnly( + "test--sink", + "test--forward-writer", + "test--generator", + "test--updater", + "test--sink: test--pre-commit-topology", + "Sink Committer: test--sink"); + + sinkUids = createSinkAndReturnUIds(""); + assertThat(sinkUids) + .containsOnly( + "--sink", + "--forward-writer", + "--generator", + "--updater", + "--sink: --pre-commit-topology", + "Sink Committer: --sink"); + + sinkUids = createSinkAndReturnUIds(null); + assertThat(sinkUids) + .containsOnly( + "--sink", + "--forward-writer", + "--generator", + "--updater", + "--sink: --pre-commit-topology", + "Sink Committer: --sink"); + } + + @Test + void testGeneratorDefaultParallelism() { + StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + streamEnv.setParallelism(4); + + DataStreamSource source = + streamEnv.fromData(Collections.emptySet(), TypeInformation.of(new TypeHint<>() {})); + source.setParallelism(8); + + DynamicIcebergSink.forInput(source) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .uidPrefix("test") + .append(); + + // Since the generator parallelism is not directly accessible via the returned DataStreamSink, + // inspect the stream graph to verify the generator inherits the input source parallelism. + int generatorParallelism = + streamEnv.getStreamGraph().getStreamNodes().stream() + .filter(node -> "test--generator".equals(node.getTransformationUID())) + .findFirst() + .map(StreamNode::getParallelism) + .orElseThrow(() -> new AssertionError("Generator node not found")); + + assertThat(generatorParallelism).isEqualTo(source.getParallelism()); + } + + @Test + void testFallBackParallelismFromConfig() throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned(), + false, + Collections.emptySet(), + false, + -1), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned(), + false, + Collections.emptySet(), + false, + 0)); + + runTest( + rows, this.env, true, 2, ImmutableMap.of(FlinkWriteOptions.WRITE_PARALLELISM.key(), "1")); + } + + private Set createSinkAndReturnUIds(String uidPrefix) { + StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + + DataStreamSource source = + streamEnv.fromData(Collections.emptySet(), TypeInformation.of(new TypeHint<>() {})); + source.uid("source"); + + DynamicIcebergSink.forInput(source) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .uidPrefix(uidPrefix) + .append(); + + // Make sure to get the expanded graph with all sink nodes + return streamEnv.getStreamGraph().getStreamNodes().stream() + .map(StreamNode::getTransformationUID) + // We are not interested in the source + .filter(uid -> !uid.equals("source")) + .collect(Collectors.toSet()); + } + /** * Represents a concurrent duplicate commit during an ongoing commit operation, which can happen * in production scenarios when using REST catalog. @@ -909,6 +1527,18 @@ private void runTest( verifyResults(dynamicData); } + private void runTest( + List dynamicData, + StreamExecutionEnvironment env, + boolean immediateUpdate, + int parallelism, + Map writeProperties) + throws Exception { + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, null, false, writeProperties); + verifyResults(dynamicData, writeProperties); + } + private void executeDynamicSink( List dynamicData, StreamExecutionEnvironment env, @@ -916,7 +1546,8 @@ private void executeDynamicSink( int parallelism, @Nullable CommitHook commitHook) throws Exception { - executeDynamicSink(dynamicData, env, immediateUpdate, parallelism, commitHook, false); + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, commitHook, false, Maps.newHashMap()); } private void executeDynamicSink( @@ -927,6 +1558,19 @@ private void executeDynamicSink( @Nullable CommitHook commitHook, boolean overwrite) throws Exception { + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, commitHook, overwrite, Maps.newHashMap()); + } + + private void executeDynamicSink( + List dynamicData, + StreamExecutionEnvironment env, + boolean immediateUpdate, + int parallelism, + @Nullable CommitHook commitHook, + boolean overwrite, + Map writeProperties) + throws Exception { DataStream dataStream = env.fromData(dynamicData, TypeInformation.of(new TypeHint<>() {})); env.setParallelism(parallelism); @@ -940,6 +1584,7 @@ private void executeDynamicSink( .immediateTableUpdate(immediateUpdate) .setSnapshotProperty("commit.retry.num-retries", "0") .overwrite(overwrite) + .setAll(writeProperties) .append(); } else { DynamicIcebergSink.forInput(dataStream) @@ -948,6 +1593,7 @@ private void executeDynamicSink( .writeParallelism(parallelism) .immediateTableUpdate(immediateUpdate) .overwrite(overwrite) + .setAll(writeProperties) .append(); } @@ -964,15 +1610,17 @@ static class CommitHookEnabledDynamicIcebergSink extends DynamicIcebergSink.B @Override DynamicIcebergSink instantiateSink( - Map writeProperties, FlinkWriteConf flinkWriteConf) { + Map writeProperties, + Configuration flinkConfig, + DataStream> forwardWriteResults) { return new CommitHookDynamicIcebergSink( commitHook, CATALOG_EXTENSION.catalogLoader(), Collections.emptyMap(), "uidPrefix", writeProperties, - flinkWriteConf, - 100); + flinkConfig, + forwardWriteResults); } } @@ -987,17 +1635,18 @@ static class CommitHookDynamicIcebergSink extends DynamicIcebergSink { Map snapshotProperties, String uidPrefix, Map writeProperties, - FlinkWriteConf flinkWriteConf, - int cacheMaximumSize) { + Configuration flinkConfig, + DataStream> forwardWritten) { super( catalogLoader, snapshotProperties, uidPrefix, writeProperties, - flinkWriteConf, - cacheMaximumSize); + flinkConfig, + 100, + forwardWritten); this.commitHook = commitHook; - this.overwriteMode = flinkWriteConf.overwriteMode(); + this.overwriteMode = new FlinkWriteConf(writeProperties, flinkConfig).overwriteMode(); } @Override @@ -1014,6 +1663,12 @@ public Committer createCommitter(CommitterInitContext contex } private void verifyResults(List dynamicData) throws IOException { + verifyResults(dynamicData, Maps.newHashMap()); + } + + private void verifyResults( + List dynamicData, Map writeProperties) + throws IOException { // Calculate the expected result Map, List> expectedData = Maps.newHashMap(); Map expectedSchema = Maps.newHashMap(); @@ -1027,9 +1682,12 @@ private void verifyResults(List dynamicData) throws IOEx dynamicData.forEach( r -> { + String branch = + MoreObjects.firstNonNull( + r.branch, writeProperties.get(FlinkWriteOptions.BRANCH.key())); List data = expectedData.computeIfAbsent( - Tuple2.of(r.tableName, r.branch), unused -> Lists.newArrayList()); + Tuple2.of(r.tableName, branch), unused -> Lists.newArrayList()); data.addAll( convertToRowData(expectedSchema.get(r.tableName), ImmutableList.of(r.rowExpected))); }); diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java index ae5b2f67120b..81f6a4fb803a 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java @@ -20,7 +20,6 @@ import static org.apache.iceberg.flink.TestFixtures.DATABASE; import static org.apache.iceberg.flink.TestFixtures.TABLE; -import static org.apache.iceberg.flink.sink.dynamic.DynamicCommitter.MAX_CONTINUOUS_EMPTY_COMMITS; import java.util.List; import java.util.function.Consumer; @@ -39,6 +38,8 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.data.RandomGenericData; @@ -122,12 +123,9 @@ void before() { CATALOG_EXTENSION .catalog() .createTable( - IDENTIFIERS[i], - SCHEMA, - PartitionSpec.unpartitioned(), - ImmutableMap.of(MAX_CONTINUOUS_EMPTY_COMMITS, "100000")); + IDENTIFIERS[i], SCHEMA, PartitionSpec.unpartitioned(), ImmutableMap.of()); - table.manageSnapshots().createBranch("main").commit(); + table.manageSnapshots().createBranch(SnapshotRef.MAIN_BRANCH).commit(); } List records = RandomGenericData.generate(SCHEMA, SAMPLE_SIZE, 1L); @@ -136,7 +134,7 @@ void before() { rows.add( new DynamicRecord( IDENTIFIERS[i % TABLE_NUM], - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA, RowDataConverter.convert(SCHEMA, records.get(i)), PartitionSpec.unpartitioned(), @@ -228,7 +226,8 @@ private void runTest(Consumer> sink) throws Exception { Table table = CATALOG_EXTENSION.catalog().loadTable(identifier); for (Snapshot snapshot : table.snapshots()) { long records = 0; - for (DataFile dataFile : snapshot.addedDataFiles(table.io())) { + for (DataFile dataFile : + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) { records += dataFile.recordCount(); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java new file mode 100644 index 000000000000..388ff303339c --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import org.apache.flink.api.common.typeutils.TypeSerializer; +import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot; +import org.apache.flink.core.memory.DataInputDeserializer; +import org.apache.flink.core.memory.DataOutputSerializer; +import org.apache.iceberg.flink.HadoopCatalogExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class TestDynamicRecordInternalSerializer { + + @RegisterExtension + static final HadoopCatalogExtension CATALOG_EXTENSION = new HadoopCatalogExtension("db", "table"); + + @Test + void testCurrentTypeSerializerSnapshotVersion() { + TypeSerializer serializer = createSerializer(); + assertThat(serializer).isNotNull().isInstanceOf(DynamicRecordInternalSerializer.class); + TypeSerializerSnapshot snapshot = serializer.snapshotConfiguration(); + assertThat(snapshot.getCurrentVersion()).isEqualTo(1); + } + + @Test + void testCurrentTypeSerializerSnapshotCompatibility() { + TypeSerializer serializer = createSerializer(); + assertThat(serializer).isNotNull().isInstanceOf(DynamicRecordInternalSerializer.class); + TypeSerializerSnapshot snapshot = serializer.snapshotConfiguration(); + assertThat( + snapshot + .resolveSchemaCompatibility(serializer.snapshotConfiguration()) + .isCompatibleAsIs()) + .isTrue(); + } + + @Test + void testRestoreFromOldVersion() throws IOException { + // Create a serialized snapshot of the TypeSerializer + final int oldVersion = 0; + OldTypeSerializerSnapshot oldTypeSerializerSnapshot = new OldTypeSerializerSnapshot(oldVersion); + assertThat(oldTypeSerializerSnapshot.getCurrentVersion()).isEqualTo(oldVersion); + DataOutputSerializer dataOutputSerializer = new DataOutputSerializer(128); + oldTypeSerializerSnapshot.writeSnapshot(dataOutputSerializer); + + // Load the serialized state + DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot restoreSnapshot = + (DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot) + createSerializer().snapshotConfiguration(); + restoreSnapshot.readSnapshot( + oldVersion, + new DataInputDeserializer(dataOutputSerializer.getSharedBuffer()), + getClass().getClassLoader()); + // Check that it matches the original one + assertThat(restoreSnapshot.getCurrentVersion()).isEqualTo(oldVersion); + assertThat( + restoreSnapshot + .resolveSchemaCompatibility(oldTypeSerializerSnapshot) + .isCompatibleAsIs()) + .isTrue(); + TypeSerializer restoreSerializer = restoreSnapshot.restoreSerializer(); + assertThat(restoreSerializer).isInstanceOf(DynamicRecordInternalSerializer.class); + assertThat(((DynamicRecordInternalSerializer) restoreSerializer).getSerializerCache()) + .isNotNull(); + + // Compare against the latest version of a snapshot + TypeSerializerSnapshot latestVersion = + createSerializer().snapshotConfiguration(); + assertThat(latestVersion.getCurrentVersion()).isEqualTo(1); + assertThat( + latestVersion + .resolveSchemaCompatibility(oldTypeSerializerSnapshot) + .isCompatibleAfterMigration()) + .isTrue(); + assertThat( + latestVersion.resolveSchemaCompatibility(restoreSnapshot).isCompatibleAfterMigration()) + .isTrue(); + } + + private DynamicRecordInternalSerializer createSerializer() { + return new DynamicRecordInternalSerializer( + new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), true); + } + + private static class OldTypeSerializerSnapshot + extends DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot { + + private final int version; + + OldTypeSerializerSnapshot(int version) { + this.version = version; + } + + @Override + public int getCurrentVersion() { + return version; + } + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java index ab8ce98c3594..e7aa2d24d9d0 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java @@ -18,11 +18,11 @@ */ package org.apache.iceberg.flink.sink.dynamic; -/** Test writing DynamicRecord with the full schema */ +/** Test writing DynamicRecord with the full schema and standard UTF encoding */ class TestDynamicRecordInternalSerializerWriteSchema extends DynamicRecordInternalSerializerTestBase { TestDynamicRecordInternalSerializerWriteSchema() { - super(true); + super(true /* writeFullSchemaAndSpec */, false /* writeLongUTF */); } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java index 1d8890546214..bff0fd5c6aad 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java @@ -18,11 +18,11 @@ */ package org.apache.iceberg.flink.sink.dynamic; -/** Test writing DynamicRecord with only the schema id. */ +/** Test writing DynamicRecord with only the schema id and standard UTF encoding */ class TestDynamicRecordInternalSerializerWriteSchemaId extends DynamicRecordInternalSerializerTestBase { TestDynamicRecordInternalSerializerWriteSchemaId() { - super(false); + super(false /* writeFullSchemaAndSpec */, false /* writeLongUTF */); } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java new file mode 100644 index 000000000000..7a1ae3df3806 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +/** Test writing DynamicRecord with only the schema id and long UTF encoding */ +class TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF + extends DynamicRecordInternalSerializerTestBase { + + TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF() { + super(false /* writeFullSchemaAndSpec */, true /* writeLongUTF */); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java new file mode 100644 index 000000000000..faff8921db5d --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +/** Test writing DynamicRecord with the full schema and long UTF encoding */ +class TestDynamicRecordInternalSerializerWriteSchemaLongUTF + extends DynamicRecordInternalSerializerTestBase { + + TestDynamicRecordInternalSerializerWriteSchemaLongUTF() { + super(true /* writeFullSchemaAndSpec */, true /* writeLongUTF */); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java new file mode 100644 index 000000000000..de55621475ed --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.Set; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +class TestDynamicRecordWithConfig { + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + + private static final TableIdentifier TABLE_IDENTIFIER = TableIdentifier.of("db", "table"); + private static final PartitionSpec UNPARTITIONED = PartitionSpec.unpartitioned(); + private static final RowData ROW_DATA = GenericRowData.of(1, StringData.fromString("test")); + + @Test + void testBranchFallBack() { + String defaultBranch = "default-branch"; + FlinkWriteConf conf = + new FlinkWriteConf( + ImmutableMap.of(FlinkWriteOptions.BRANCH.key(), defaultBranch), new Configuration()); + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(conf); + + DynamicRecord dynamicRecord = + new DynamicRecord(TABLE_IDENTIFIER, null, SCHEMA, ROW_DATA, UNPARTITIONED); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).branch()).isEqualTo(defaultBranch); + + String customBranch = "custom-branch"; + dynamicRecord.setBranch(customBranch); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).branch()).isEqualTo(customBranch); + } + + @Test + void testWriteParallelismFallBack() { + int defaultParallelism = 4; + FlinkWriteConf conf = + new FlinkWriteConf( + ImmutableMap.of( + FlinkWriteOptions.WRITE_PARALLELISM.key(), String.valueOf(defaultParallelism)), + new Configuration()); + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(conf); + + DynamicRecord dynamicRecord = + new DynamicRecord(TABLE_IDENTIFIER, null, SCHEMA, ROW_DATA, UNPARTITIONED, null, -1); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()) + .isEqualTo(defaultParallelism); + + dynamicRecord.writeParallelism(0); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()) + .isEqualTo(defaultParallelism); + + dynamicRecord.writeParallelism(8); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()).isEqualTo(8); + } + + @Test + void testDelegatesToWrappedRecord() { + FlinkWriteConf conf = new FlinkWriteConf(Collections.emptyMap(), new Configuration()); + PartitionSpec partitioned = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + Set equalityFields = ImmutableSet.of("id", "data"); + + DynamicRecord dynamicRecord = + new DynamicRecord( + TABLE_IDENTIFIER, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + ROW_DATA, + partitioned, + DistributionMode.HASH, + 2); + dynamicRecord.setUpsertMode(true); + dynamicRecord.setEqualityFields(equalityFields); + + DynamicRecordWithConfig record = new DynamicRecordWithConfig(conf).wrap(dynamicRecord); + + assertThat(record.tableIdentifier()).isEqualTo(TABLE_IDENTIFIER); + assertThat(record.schema()).isEqualTo(SCHEMA); + assertThat(record.spec()).isEqualTo(partitioned); + assertThat(record.rowData()).isSameAs(ROW_DATA); + assertThat(record.distributionMode()).isEqualTo(DistributionMode.HASH); + assertThat(record.upsertMode()).isTrue(); + assertThat(record.equalityFields()).isEqualTo(equalityFields); + } +} diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java index 22655ff99f86..f6b2b368c2be 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java @@ -23,18 +23,28 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Collections; +import org.apache.flink.configuration.Configuration; import org.apache.flink.table.data.GenericRowData; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.HadoopCatalogExtension; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class TestDynamicTableUpdateOperator { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + @RegisterExtension private static final HadoopCatalogExtension CATALOG_EXTENSION = new HadoopCatalogExtension(DATABASE, TABLE); @@ -49,9 +59,6 @@ class TestDynamicTableUpdateOperator { @Test void testDynamicTableUpdateOperatorNewTable() throws Exception { - int cacheMaximumSize = 10; - int cacheRefreshMs = 1000; - int inputSchemaCacheMaximumSize = 10; Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier table = TableIdentifier.of(TABLE); @@ -59,10 +66,8 @@ void testDynamicTableUpdateOperatorNewTable() throws Exception { DynamicTableUpdateOperator operator = new DynamicTableUpdateOperator( CATALOG_EXTENSION.catalogLoader(), - cacheMaximumSize, - cacheRefreshMs, - inputSchemaCacheMaximumSize, - TableCreator.DEFAULT); + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); operator.open(null); DynamicRecordInternal input = @@ -83,19 +88,14 @@ void testDynamicTableUpdateOperatorNewTable() throws Exception { @Test void testDynamicTableUpdateOperatorSchemaChange() throws Exception { - int cacheMaximumSize = 10; - int cacheRefreshMs = 1000; - int inputSchemaCacheMaximumSize = 10; Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier table = TableIdentifier.of(TABLE); DynamicTableUpdateOperator operator = new DynamicTableUpdateOperator( CATALOG_EXTENSION.catalogLoader(), - cacheMaximumSize, - cacheRefreshMs, - inputSchemaCacheMaximumSize, - TableCreator.DEFAULT); + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); operator.open(null); catalog.createTable(table, SCHEMA1); @@ -119,4 +119,126 @@ void testDynamicTableUpdateOperatorSchemaChange() throws Exception { assertThat(output2).isEqualTo(output); assertThat(catalog.loadTable(table).schema().schemaId()).isEqualTo(output.schema().schemaId()); } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testCaseInSensitivity(boolean caseSensitive) throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + Schema initialSchema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + Schema caseSensitiveSchema = + new Schema(Types.NestedField.required(1, "Id", Types.IntegerType.get())); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(caseSensitive, PRESERVE_COLUMNS)); + operator.open(null); + + catalog.createTable(table, initialSchema); + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + caseSensitiveSchema, + GenericRowData.of(1, "test"), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + if (caseSensitive) { + // Schema changes due to case sensitivity + Schema expectedSchema = + new Schema( + Types.NestedField.optional(2, "Id", Types.IntegerType.get()), + Types.NestedField.optional(1, "id", Types.IntegerType.get())); + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.sameSchema(expectedSchema)).isTrue(); + assertThat(output.schema().sameSchema(expectedSchema)).isTrue(); + } else { + // No schema change due to case insensitivity + assertThat(catalog.loadTable(table).schema().sameSchema(initialSchema)).isTrue(); + assertThat(output.schema().sameSchema(initialSchema)).isTrue(); + } + } + + @Test + void testDynamicTableUpdateOperatorPreserveUnusedColumns() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); + operator.open(null); + + catalog.createTable(table, SCHEMA2); + + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + SCHEMA1, + GenericRowData.of(1), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.columns()).hasSize(2); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNotNull(); + assertThat(tableSchema.findField("data").isOptional()).isTrue(); + assertThat(input).isEqualTo(output); + } + + @Test + void testDynamicTableUpdateOperatorDropUnusedColumns() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_INSENSITIVE, DROP_COLUMNS)); + operator.open(null); + + catalog.createTable(table, SCHEMA2); + + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + SCHEMA1, + GenericRowData.of(1), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.columns()).hasSize(1); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNull(); + assertThat(input).isEqualTo(output); + } + + private static FlinkDynamicSinkConf flinkDynamicSinkConfiguration( + boolean caseSensitive, boolean dropUnusedColumns) { + return new FlinkDynamicSinkConf( + ImmutableMap.of( + FlinkDynamicSinkOptions.CASE_SENSITIVE.key(), String.valueOf(caseSensitive), + FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key(), String.valueOf(dropUnusedColumns)), + new Configuration()); + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java index 17a4d98b2938..f68e8dae174e 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java @@ -27,6 +27,7 @@ import org.apache.flink.streaming.api.connector.sink2.CommittableMessage; import org.apache.flink.streaming.api.connector.sink2.CommittableSummary; import org.apache.flink.streaming.api.connector.sink2.CommittableWithLineage; +import org.apache.flink.streaming.api.connector.sink2.SinkV2Assertions; import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.iceberg.DataFile; @@ -58,7 +59,7 @@ class TestDynamicWriteResultAggregator { .build(); @Test - void testAggregator() throws Exception { + void testAggregatesWriteResultsForTwoTables() throws Exception { CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table"), new Schema()); CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table2"), new Schema()); @@ -69,13 +70,12 @@ void testAggregator() throws Exception { testHarness = new OneInputStreamOperatorTestHarness<>(aggregator)) { testHarness.open(); - WriteTarget writeTarget1 = new WriteTarget("table", "branch", 42, 0, true, Sets.newHashSet()); + TableKey tableKey1 = new TableKey("table", "branch"); DynamicWriteResult dynamicWriteResult1 = - new DynamicWriteResult(writeTarget1, WriteResult.builder().build()); - WriteTarget writeTarget2 = - new WriteTarget("table2", "branch", 42, 0, true, Sets.newHashSet(1, 2)); + new DynamicWriteResult(tableKey1, -1, WriteResult.builder().build()); + TableKey tableKey2 = new TableKey("table2", "branch"); DynamicWriteResult dynamicWriteResult2 = - new DynamicWriteResult(writeTarget2, WriteResult.builder().build()); + new DynamicWriteResult(tableKey2, -1, WriteResult.builder().build()); CommittableWithLineage committable1 = new CommittableWithLineage<>(dynamicWriteResult1, 0, 0); @@ -113,18 +113,16 @@ void testPreventOutputFileFactoryCacheEvictionDuringFlush() throws Exception { testHarness = new OneInputStreamOperatorTestHarness<>(aggregator)) { testHarness.open(); - WriteTarget writeTarget1 = - new WriteTarget("table", "branch", 42, 0, false, Sets.newHashSet()); + TableKey tableKey1 = new TableKey("table", "branch"); DynamicWriteResult dynamicWriteResult1 = new DynamicWriteResult( - writeTarget1, WriteResult.builder().addDataFiles(DATA_FILE).build()); + tableKey1, DATA_FILE.specId(), WriteResult.builder().addDataFiles(DATA_FILE).build()); - // Different WriteTarget - WriteTarget writeTarget2 = - new WriteTarget("table", "branch2", 23, 0, true, Sets.newHashSet()); + // Different TableKey + TableKey tableKey2 = new TableKey("table", "branch2"); DynamicWriteResult dynamicWriteResult2 = new DynamicWriteResult( - writeTarget2, WriteResult.builder().addDataFiles(DATA_FILE).build()); + tableKey2, DATA_FILE.specId(), WriteResult.builder().addDataFiles(DATA_FILE).build()); CommittableWithLineage committable1 = new CommittableWithLineage<>(dynamicWriteResult1, 0, 0); @@ -151,6 +149,70 @@ void testPreventOutputFileFactoryCacheEvictionDuringFlush() throws Exception { } } + @Test + void testAggregatesWriteResultsForOneTable() throws Exception { + CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table"), new Schema()); + CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table2"), new Schema()); + + long checkpointId = 1L; + + try (OneInputStreamOperatorTestHarness< + CommittableMessage, CommittableMessage> + testHarness = + new OneInputStreamOperatorTestHarness<>( + new DynamicWriteResultAggregator( + CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize))) { + testHarness.open(); + + TableKey tableKey = new TableKey("table", "branch"); + DataFile dataFile1 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data-1.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + DataFile dataFile2 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data-2.parquet") + .withFileSizeInBytes(20) + .withRecordCount(2) + .build(); + + testHarness.processElement( + createRecord(tableKey, checkpointId, dataFile1.specId(), dataFile1)); + testHarness.processElement( + createRecord(tableKey, checkpointId, dataFile2.specId(), dataFile2)); + + assertThat(testHarness.getOutput()).isEmpty(); + + testHarness.prepareSnapshotPreBarrier(checkpointId); + + List> outputValues = testHarness.extractOutputValues(); + // Contains a CommittableSummary + DynamicCommittable + assertThat(outputValues).hasSize(2); + + SinkV2Assertions.assertThat(extractAndAssertCommittableSummary(outputValues.get(0))) + .hasOverallCommittables(1) + .hasFailedCommittables(0) + .hasCheckpointId(checkpointId); + + CommittableWithLineage committable = + extractAndAssertCommittableWithLineage(outputValues.get(1)); + + SinkV2Assertions.assertThat(committable).hasCheckpointId(checkpointId); + + DynamicCommittable dynamicCommittable = committable.getCommittable(); + + assertThat(dynamicCommittable.manifests()).hasNumberOfRows(1); + assertThat(dynamicCommittable.key()).isEqualTo(tableKey); + assertThat(dynamicCommittable.checkpointId()).isEqualTo(checkpointId); + assertThat(dynamicCommittable.jobId()) + .isEqualTo(testHarness.getEnvironment().getJobID().toString()); + assertThat(dynamicCommittable.operatorId()) + .isEqualTo(testHarness.getOperator().getOperatorID().toString()); + } + } + private static Set getManifestPaths( List>> messages) throws IOException { Set manifestPaths = Sets.newHashSet(); @@ -158,15 +220,40 @@ private static Set getManifestPaths( for (StreamRecord> record : messages) { CommittableMessage message = record.getValue(); if (message instanceof CommittableWithLineage) { - DeltaManifests deltaManifests = - SimpleVersionedSerialization.readVersionAndDeSerialize( - DeltaManifestsSerializer.INSTANCE, - (((CommittableWithLineage) message).getCommittable()) - .manifest()); - deltaManifests.manifests().forEach(manifest -> manifestPaths.add(manifest.path())); + for (byte[] manifest : + (((CommittableWithLineage) message).getCommittable()).manifests()) { + DeltaManifests deltaManifests = + SimpleVersionedSerialization.readVersionAndDeSerialize( + DeltaManifestsSerializer.INSTANCE, manifest); + deltaManifests + .manifests() + .forEach(manifestFile -> manifestPaths.add(manifestFile.path())); + } } } return manifestPaths; } + + private static StreamRecord> createRecord( + TableKey tableKey, long checkpointId, int specId, DataFile... dataFiles) { + return new StreamRecord<>( + new CommittableWithLineage<>( + new DynamicWriteResult( + tableKey, specId, WriteResult.builder().addDataFiles(dataFiles).build()), + checkpointId, + 0)); + } + + static CommittableSummary extractAndAssertCommittableSummary( + CommittableMessage message) { + assertThat(message).isInstanceOf(CommittableSummary.class); + return (CommittableSummary) message; + } + + static CommittableWithLineage extractAndAssertCommittableWithLineage( + CommittableMessage message) { + assertThat(message).isInstanceOf(CommittableWithLineage.class); + return (CommittableWithLineage) message; + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java index b1bca6099d7a..35dc45d8fd41 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java @@ -29,7 +29,6 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.junit.jupiter.api.Test; class TestDynamicWriteResultSerializer { @@ -48,13 +47,12 @@ class TestDynamicWriteResultSerializer { ImmutableMap.of(1, ByteBuffer.allocate(1)), ImmutableMap.of(1, ByteBuffer.allocate(1)))) .build(); + private static final TableKey TABLE_KEY = new TableKey("table", "branch"); @Test void testRoundtrip() throws IOException { DynamicWriteResult dynamicWriteResult = - new DynamicWriteResult( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - WriteResult.builder().addDataFiles(DATA_FILE).build()); + new DynamicWriteResult(TABLE_KEY, 1, WriteResult.builder().addDataFiles(DATA_FILE).build()); DynamicWriteResultSerializer serializer = new DynamicWriteResultSerializer(); DynamicWriteResult copy = @@ -68,11 +66,9 @@ void testRoundtrip() throws IOException { } @Test - void testUnsupportedVersion() throws IOException { + void testUnsupportedVersion() { DynamicWriteResult dynamicWriteResult = - new DynamicWriteResult( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - WriteResult.builder().addDataFiles(DATA_FILE).build()); + new DynamicWriteResult(TABLE_KEY, 1, WriteResult.builder().addDataFiles(DATA_FILE).build()); DynamicWriteResultSerializer serializer = new DynamicWriteResultSerializer(); assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(dynamicWriteResult))) diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java index 689fd20483c1..f604f639f217 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java @@ -26,6 +26,7 @@ import java.util.Collection; import java.util.Map; import javax.annotation.Nonnull; +import org.apache.flink.configuration.Configuration; import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; import org.apache.flink.table.data.RowData; import org.apache.iceberg.FileFormat; @@ -33,7 +34,8 @@ import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.common.DynFields; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; +import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.SimpleDataUtil; import org.apache.iceberg.flink.sink.TestFlinkIcebergSinkBase; import org.apache.iceberg.io.BaseTaskWriter; @@ -140,7 +142,7 @@ void testDynamicWriterPropertiesDefault() throws Exception { } @Test - void testDynamicWriterPropertiesPriority() throws Exception { + void testFlinkConfigOverridesTableProperties() throws Exception { Catalog catalog = CATALOG_EXTENSION.catalog(); Table table1 = catalog.createTable( @@ -149,11 +151,45 @@ void testDynamicWriterPropertiesPriority() throws Exception { null, ImmutableMap.of("write.parquet.compression-codec", "zstd")); + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.COMPRESSION_CODEC, "snappy"); + DynamicWriter dynamicWriter = - createDynamicWriter(catalog, ImmutableMap.of("write.parquet.compression-codec", "gzip")); + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); DynamicRecordInternal record1 = getDynamicRecordInternal(table1); - assertThat(getNumDataFiles(table1)).isEqualTo(0); + dynamicWriter.write(record1, null); + Map properties = properties(dynamicWriter); + assertThat(properties).containsEntry("write.parquet.compression-codec", "snappy"); + + dynamicWriter.close(); + } + + @Test + void testWritePropertiesOverrideFlinkConfig() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.COMPRESSION_CODEC, "snappy"); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + ImmutableMap.of("compression-codec", "gzip"), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); dynamicWriter.write(record1, null); Map properties = properties(dynamicWriter); @@ -162,6 +198,62 @@ void testDynamicWriterPropertiesPriority() throws Exception { dynamicWriter.close(); } + @Test + void testFlinkConfigFileFormat() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.WRITE_FORMAT, "orc"); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); + + dynamicWriter.write(record1, null); + dynamicWriter.prepareCommit(); + + File dataDir = new File(URI.create(table1.location()).getPath(), "data"); + File[] files = dataDir.listFiles((dir, name) -> name.endsWith(".orc")); + assertThat(files).isNotNull().hasSize(1); + + dynamicWriter.close(); + } + + @Test + void testFlinkConfigTargetFileSize() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.TARGET_FILE_SIZE_BYTES, 2048L); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); + + dynamicWriter.write(record1, null); + dynamicWriter.prepareCommit(); + + assertThat(getNumDataFiles(table1)).isEqualTo(1); + + dynamicWriter.close(); + } + @Test void testDynamicWriterUpsert() throws Exception { Catalog catalog = CATALOG_EXTENSION.catalog(); @@ -239,11 +331,10 @@ void testUniqueFileSuffixOnFactoryRecreation() throws Exception { DynamicWriter dynamicWriter = new DynamicWriter( catalog, - FileFormat.PARQUET, - 1024L, properties, + new Configuration(), 100, - new DynamicWriterMetrics(new UnregisteredMetricsGroup()), + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), 0, 0); return dynamicWriter; @@ -274,13 +365,13 @@ private Map properties(DynamicWriter dynamicWriter) { DynFields.BoundField>> writerField = DynFields.builder().hiddenImpl(dynamicWriter.getClass(), "writers").build(dynamicWriter); - DynFields.BoundField writerFactoryField = + DynFields.BoundField> writerFactoryField = DynFields.builder() .hiddenImpl(BaseTaskWriter.class, "writerFactory") .build(writerField.get().values().iterator().next()); DynFields.BoundField> propsField = DynFields.builder() - .hiddenImpl(BaseFileWriterFactory.class, "writerProperties") + .hiddenImpl(RegistryBasedFileWriterFactory.class, "writerProperties") .build(writerFactoryField.get()); return propsField.get(); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java index d416e7ec1fc6..d2da73c66973 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java @@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.apache.iceberg.Schema; import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Type.PrimitiveType; import org.apache.iceberg.types.Types; @@ -48,6 +49,14 @@ public class TestEvolveSchemaVisitor { + private static final TableIdentifier TABLE = TableIdentifier.of("table"); + + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + private static List primitiveTypes() { return Lists.newArrayList( StringType.get(), @@ -89,7 +98,8 @@ private static Types.NestedField[] primitiveFields( public void testAddTopLevelPrimitives() { Schema targetSchema = new Schema(primitiveFields(0, primitiveTypes())); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(targetSchema.asStruct()).isEqualTo(updateApi.apply().asStruct()); } @@ -99,12 +109,62 @@ public void testMakeTopLevelPrimitivesOptional() { assertThat(existingSchema.columns().stream().allMatch(Types.NestedField::isRequired)).isTrue(); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, new Schema()); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, new Schema(), CASE_SENSITIVE, PRESERVE_COLUMNS); Schema newSchema = updateApi.apply(); assertThat(newSchema.asStruct().fields()).hasSize(14); assertThat(newSchema.columns().stream().allMatch(Types.NestedField::isOptional)).isTrue(); } + @Test + public void testDropUnusedColumns() { + Schema existingSchema = + new Schema( + optional(1, "a", StringType.get()), + optional( + 2, + "b", + StructType.of( + optional(4, "nested1", StringType.get()), + optional(5, "nested2", StringType.get()))), + optional(3, "c", IntegerType.get())); + + Schema targetSchema = + new Schema( + optional(1, "a", StringType.get()), + optional(2, "b", StructType.of(optional(5, "nested2", StringType.get())))); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, DROP_COLUMNS); + + Schema newSchema = updateApi.apply(); + assertThat(newSchema.sameSchema(targetSchema)).isTrue(); + } + + @Test + public void testPreserveUnusedColumns() { + Schema existingSchema = + new Schema( + optional(1, "a", StringType.get()), + optional( + 2, + "b", + StructType.of( + optional(4, "nested1", StringType.get()), + optional(5, "nested2", StringType.get()))), + optional(3, "c", IntegerType.get())); + + Schema targetSchema = new Schema(optional(1, "a", StringType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); + + Schema newSchema = updateApi.apply(); + assertThat(newSchema.sameSchema(existingSchema)).isTrue(); + } + @Test public void testIdentifyFieldsByName() { Schema existingSchema = @@ -112,7 +172,8 @@ public void testIdentifyFieldsByName() { UpdateSchema updateApi = loadUpdateApi(existingSchema); Schema newSchema = new Schema(Arrays.asList(Types.NestedField.optional(-1, "myField", Types.LongType.get()))); - EvolveSchemaVisitor.visit(updateApi, existingSchema, newSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, newSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().sameSchema(existingSchema)).isTrue(); } @@ -125,7 +186,8 @@ public void testChangeOrderTopLevelPrimitives() { new Schema( Arrays.asList(optional(2, "b", StringType.get()), optional(1, "a", StringType.get()))); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -134,7 +196,8 @@ public void testAddTopLevelListOfPrimitives() { for (PrimitiveType primitiveType : primitiveTypes()) { Schema targetSchema = new Schema(optional(1, "aList", ListType.ofOptional(2, primitiveType))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -146,7 +209,8 @@ public void testMakeTopLevelListOfPrimitivesOptional() { new Schema(optional(1, "aList", ListType.ofRequired(2, primitiveType))); Schema targetSchema = new Schema(); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema expectedSchema = new Schema(optional(1, "aList", ListType.ofRequired(2, primitiveType))); assertThat(updateApi.apply().asStruct()).isEqualTo(expectedSchema.asStruct()); @@ -159,7 +223,8 @@ public void testAddTopLevelMapOfPrimitives() { Schema targetSchema = new Schema(optional(1, "aMap", MapType.ofOptional(2, 3, primitiveType, primitiveType))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -171,7 +236,8 @@ public void testAddTopLevelStructOfPrimitives() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), currentSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), currentSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(currentSchema.asStruct()); } } @@ -184,7 +250,8 @@ public void testAddNestedPrimitive() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -199,7 +266,8 @@ public void testMakeNestedPrimitiveOptional() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -210,7 +278,8 @@ public void testAddNestedPrimitives() { Schema targetSchema = new Schema(optional(1, "aStruct", StructType.of(primitiveFields(1, primitiveTypes())))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -240,7 +309,8 @@ public void testAddNestedLists() { ListType.ofOptional( 10, DecimalType.of(11, 20)))))))))))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -279,7 +349,8 @@ public void testAddNestedStruct() { "aString", StringType.get())))))))))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -314,7 +385,8 @@ public void testAddNestedMaps() { 12, 13, StringType.get(), StringType.get())))))))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -326,7 +398,12 @@ public void testDetectInvalidTopLevelList() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aList.element: string -> long") .isInstanceOf(IllegalArgumentException.class); } @@ -343,7 +420,12 @@ public void testDetectInvalidTopLevelMapValue() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aMap.value: string -> long") .isInstanceOf(IllegalArgumentException.class); } @@ -358,7 +440,12 @@ public void testDetectInvalidTopLevelMapKey() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aMap.key: string -> uuid") .isInstanceOf(IllegalArgumentException.class); } @@ -370,7 +457,8 @@ public void testTypePromoteIntegerToLong() { Schema targetSchema = new Schema(required(1, "aCol", LongType.get())); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema applied = updateApi.apply(); assertThat(applied.asStruct().fields()).hasSize(1); assertThat(applied.asStruct().fields().get(0).type()).isEqualTo(LongType.get()); @@ -383,7 +471,8 @@ public void testTypePromoteFloatToDouble() { Schema targetSchema = new Schema(required(1, "aCol", DoubleType.get())); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema applied = updateApi.apply(); assertThat(applied.asStruct().fields()).hasSize(1); assertThat(applied.asStruct().fields().get(0).type()).isEqualTo(DoubleType.get()); @@ -396,7 +485,12 @@ public void testInvalidTypePromoteDoubleToFloat() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aCol: double -> float") .isInstanceOf(IllegalArgumentException.class); } @@ -409,7 +503,8 @@ public void testTypePromoteDecimalToFixedScaleWithWiderPrecision() { Schema targetSchema = new Schema(required(1, "aCol", DecimalType.of(22, 1))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -452,7 +547,8 @@ public void testAddPrimitiveToNestedStruct() { optional(6, "time", TimeType.get()))))))))); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -464,7 +560,12 @@ public void testReplaceListWithPrimitive() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aColumn: list -> string") .isInstanceOf(IllegalArgumentException.class); } @@ -501,7 +602,8 @@ public void addNewTopLevelStruct() { optional(7, "d1", StructType.of(optional(8, "d2", StringType.get())))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -553,7 +655,8 @@ public void testAppendNestedStruct() { StringType.get())))))))))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -573,7 +676,8 @@ public void testMakeNestedStructOptional() { optional( 3, "s3", StructType.of(optional(4, "s4", StringType.get())))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(getNestedSchemaWithOptionalModifier(true).asStruct()) .isEqualTo(updateApi.apply().asStruct()); } @@ -610,6 +714,82 @@ private static Schema getNestedSchemaWithOptionalModifier(boolean nestedIsOption 9, "s4", StringType.get())))))))))))))); } + @Test + public void testCaseInsensitiveAddField() { + Schema existingSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + Schema targetSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "NAME", Types.StringType.get()), + Types.NestedField.optional(3, "AGE", Types.IntegerType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.columns()).hasSize(3); + assertThat(result.findField("AGE")).isNotNull(); + } + + @Test + public void testCaseInsensitiveMakeFieldOptional() { + Schema existingSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "name", Types.StringType.get())); + Schema targetSchema = new Schema(Types.NestedField.optional(1, "ID", Types.IntegerType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.findField("name").isOptional()).isTrue(); + } + + @Test + public void testCaseInsensitiveNestedStructField() { + Schema existingSchema = + new Schema( + optional(1, "struct1", StructType.of(optional(2, "field1", Types.StringType.get())))); + Schema targetSchema = + new Schema( + optional( + 1, + "STRUCT1", + StructType.of( + optional(2, "FIELD1", Types.StringType.get()), + optional(3, "FIELD2", Types.IntegerType.get())))); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + Types.StructType struct = result.findField("struct1").type().asStructType(); + assertThat(struct.fields()).hasSize(2); + assertThat(struct.field("FIELD2")).isNotNull(); + } + + @Test + public void testCaseSensitiveDoesNotMatch() { + Schema existingSchema = + new Schema(Types.NestedField.optional(1, "id", Types.IntegerType.get())); + Schema targetSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.columns()).hasSize(3); + assertThat(result.findField("ID")).isNotNull(); + assertThat(result.findField("id")).isNotNull(); + } + private static UpdateSchema loadUpdateApi(Schema schema) { try { Constructor constructor = diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java index 04246bf03996..9a485fafaf47 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.Set; import org.apache.flink.api.java.functions.KeySelector; +import org.apache.flink.configuration.Configuration; import org.apache.flink.runtime.state.KeyGroupRangeAssignment; import org.apache.flink.table.data.GenericRowData; import org.apache.flink.table.data.RowData; @@ -32,7 +33,11 @@ import org.apache.iceberg.DistributionMode; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; @@ -44,7 +49,7 @@ class TestHashKeyGenerator { Types.NestedField.required(1, "id", Types.IntegerType.get()), Types.NestedField.required(2, "data", Types.StringType.get())); - private static final String BRANCH = "main"; + private static final String BRANCH = SnapshotRef.MAIN_BRANCH; private static final TableIdentifier TABLE_IDENTIFIER = TableIdentifier.of("default", "table"); @Test @@ -158,6 +163,48 @@ void testEqualityKeys() throws Exception { assertThat(getSubTaskId(writeKey3, writeParallelism, maxWriteParallelism)).isEqualTo(0); } + @Test + void testHashModeWithPartitionFieldAndEqualityField() throws Exception { + int writeParallelism = 2; + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("id", 4).build(); + + GenericRowData row1 = GenericRowData.of(1, StringData.fromString("foo")); + GenericRowData row2 = GenericRowData.of(1, StringData.fromString("bar")); + Set equalityColumns = Sets.newHashSet("id", "data"); + + int writeKey1 = + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row1); + int writeKey2 = + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row2); + + assertThat(writeKey1).isEqualTo(writeKey2); + } + + @Test + void testHashModeWithPartitionFieldNotInEqualityFieldsFails() { + int writeParallelism = 2; + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("id", 4).build(); + Set equalityColumns = Collections.singleton("data"); + + GenericRowData row = GenericRowData.of(1, StringData.fromString("foo")); + + assertThatThrownBy( + () -> + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("partition field") + .hasMessageContaining(spec.fields().get(0).toString()) + .hasMessageContaining("should be included in equality fields"); + } + @Test void testFailOnNonPositiveWriteParallelism() { final int maxWriteParallelism = 5; @@ -186,6 +233,38 @@ void testFailOnNonPositiveWriteParallelism() { }); } + @Test + void testNonPositiveWriteParallelismConfigFallback() throws Exception { + int maxWriteParallelism = 5; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + PartitionSpec unpartitioned = PartitionSpec.unpartitioned(); + FlinkWriteConf flinkWriteConf = + new FlinkWriteConf( + ImmutableMap.of(FlinkWriteOptions.WRITE_PARALLELISM.key(), "2"), new Configuration()); + + Set writeKeys = Sets.newHashSet(); + for (int i = 0; i < 20; i++) { + GenericRowData row = GenericRowData.of(i, StringData.fromString("z")); + writeKeys.add( + getWriteKey( + generator, + unpartitioned, + DistributionMode.NONE, + i % 2 == 0 ? 0 : -1, + Collections.emptySet(), + row, + flinkWriteConf)); + } + + assertThat(writeKeys).hasSize(2); + assertThat( + writeKeys.stream() + .map(key -> getSubTaskId(key, 2, maxWriteParallelism)) + .distinct() + .count()) + .isEqualTo(2); + } + @Test void testCapAtMaxWriteParallelism() throws Exception { int writeParallelism = 10; @@ -358,6 +437,74 @@ void testCaching() throws Exception { assertThat(writeKey1).isEqualTo(writeKey3); } + @Test + void testCacheMissOnWriteParallelismChange() throws Exception { + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(10, maxWriteParallelism); + Map> keySelectorCache = + generator.getKeySelectorCache(); + + PartitionSpec unpartitioned = PartitionSpec.unpartitioned(); + DynamicRecord record1 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + unpartitioned, + DistributionMode.NONE, + 2); + DynamicRecord record2 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + unpartitioned, + DistributionMode.NONE, + 4); + + generator.generateKey(record1); + assertThat(keySelectorCache).hasSize(1); + + generator.generateKey(record2); + assertThat(keySelectorCache).hasSize(2); + } + + @Test + void testCacheMissOnDistributionModeChange() throws Exception { + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(10, maxWriteParallelism); + Map> keySelectorCache = + generator.getKeySelectorCache(); + + PartitionSpec partitioned = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + DynamicRecord record1 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + partitioned, + DistributionMode.NONE, + 2); + DynamicRecord record2 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + partitioned, + DistributionMode.HASH, + 2); + + generator.generateKey(record1); + assertThat(keySelectorCache).hasSize(1); + + generator.generateKey(record2); + assertThat(keySelectorCache).hasSize(2); + } + private static int getWriteKey( HashKeyGenerator generator, PartitionSpec spec, @@ -366,10 +513,31 @@ private static int getWriteKey( Set equalityFields, GenericRowData row) throws Exception { - DynamicRecord record = + return getWriteKey( + generator, + spec, + mode, + writeParallelism, + equalityFields, + row, + new FlinkWriteConf(Collections.emptyMap(), new Configuration())); + } + + private static int getWriteKey( + HashKeyGenerator generator, + PartitionSpec spec, + DistributionMode mode, + int writeParallelism, + Set equalityFields, + GenericRowData row, + FlinkWriteConf flinkWriteConf) + throws Exception { + DynamicRecord inputRecord = new DynamicRecord(TABLE_IDENTIFIER, BRANCH, SCHEMA, row, spec, mode, writeParallelism); - record.setEqualityFields(equalityFields); - return generator.generateKey(record); + inputRecord.setEqualityFields(equalityFields); + + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(flinkWriteConf); + return generator.generateKey(dynamicRecordWithConfig.wrap(inputRecord)); } private static int getSubTaskId(int writeKey1, int writeParallelism, int maxWriteParallelism) { diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java index bf5b9f562f9a..8a17c707f84a 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java @@ -26,6 +26,7 @@ import org.apache.commons.lang3.SerializationUtils; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; @@ -35,6 +36,12 @@ public class TestTableMetadataCache extends TestFlinkIcebergSinkBase { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + static final Schema SCHEMA = new Schema( Types.NestedField.optional(1, "id", Types.IntegerType.get()), @@ -46,12 +53,23 @@ public class TestTableMetadataCache extends TestFlinkIcebergSinkBase { Types.NestedField.optional(2, "data", Types.StringType.get()), Types.NestedField.optional(3, "extra", Types.StringType.get())); + static final Schema SCHEMA_UPPERCASE = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + static final Schema SCHEMA_MIXEDCASE = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get())); + @Test void testCaching() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); assertThat(schema1.sameSchema(SCHEMA)).isTrue(); @@ -72,8 +90,9 @@ void testCacheInvalidationAfterSchemaChange() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); assertThat(schema1.sameSchema(SCHEMA)).isTrue(); @@ -81,7 +100,11 @@ void testCacheInvalidationAfterSchemaChange() { catalog.dropTable(tableIdentifier); catalog.createTable(tableIdentifier, SCHEMA2); tableUpdater.update( - tableIdentifier, "main", SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA2, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); Schema schema2 = cache.schema(tableIdentifier, SCHEMA2).resolvedTableSchema(); assertThat(schema2.sameSchema(SCHEMA2)).isTrue(); @@ -92,7 +115,8 @@ void testCachingDisabled() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 0, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 0, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(cache.getInternalCache()).isEmpty(); } @@ -107,7 +131,13 @@ void testNoCacheRefreshingBeforeRefreshIntervalElapses() { // Init cache TableMetadataCache cache = new TableMetadataCache( - catalog, 10, 100L, 10, Clock.fixed(Instant.now(), ZoneId.systemDefault())); + catalog, + 10, + 100L, + 10, + CASE_INSENSITIVE, + PRESERVE_COLUMNS, + Clock.fixed(Instant.now(), ZoneId.systemDefault())); cache.update(tableIdentifier, table); // Cache schema @@ -125,4 +155,66 @@ void testNoCacheRefreshingBeforeRefreshIntervalElapses() { assertThat(cacheItem).isNotNull(); assertThat(cacheItem.inputSchemas()).containsKeys(SCHEMA, SCHEMA2); } + + @Test + void testNoSuchNamespaceExceptionHandling() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.of("nonexistent_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + TableMetadataCache.ResolvedSchemaInfo result = cache.schema(tableIdentifier, SCHEMA); + + assertThat(result).isEqualTo(TableMetadataCache.NOT_FOUND); + assertThat(cache.getInternalCache().get(tableIdentifier)).isNotNull(); + } + + @Test + void testNoSuchTableExceptionHandling() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.nonexistent_table"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + TableMetadataCache.ResolvedSchemaInfo result = cache.schema(tableIdentifier, SCHEMA); + + assertThat(result).isEqualTo(TableMetadataCache.NOT_FOUND); + assertThat(cache.getInternalCache().get(tableIdentifier)).isNotNull(); + } + + @Test + void testCaseInsensitiveCaching() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); + catalog.createTable(tableIdentifier, SCHEMA); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_INSENSITIVE, PRESERVE_COLUMNS); + + Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); + assertThat(schema1.sameSchema(SCHEMA)).isTrue(); + + Schema schemaUpperCase = cache.schema(tableIdentifier, SCHEMA_UPPERCASE).resolvedTableSchema(); + assertThat(schemaUpperCase).isEqualTo(schema1); + + Schema schemaMixedCase = cache.schema(tableIdentifier, SCHEMA_MIXEDCASE).resolvedTableSchema(); + assertThat(schemaMixedCase).isEqualTo(schema1); + } + + @Test + void testCaseSensitiveCachingDoesNotMatch() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); + catalog.createTable(tableIdentifier, SCHEMA); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); + assertThat(schema1.sameSchema(SCHEMA)).isTrue(); + + assertThat(cache.schema(tableIdentifier, SCHEMA_UPPERCASE)) + .isEqualTo(TableMetadataCache.NOT_FOUND); + + assertThat(cache.schema(tableIdentifier, SCHEMA_MIXEDCASE)) + .isEqualTo(TableMetadataCache.NOT_FOUND); + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java index 1d4461698746..bdc825b44f2a 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java @@ -25,18 +25,28 @@ import org.apache.flink.api.java.tuple.Tuple2; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.sink.TestFlinkIcebergSinkBase; import org.apache.iceberg.inmemory.InMemoryCatalog; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class TestTableUpdater extends TestFlinkIcebergSinkBase { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + static final Schema SCHEMA = new Schema( Types.NestedField.optional(1, "id", Types.IntegerType.get()), @@ -55,8 +65,9 @@ void testTableCreation(@TempDir Path tempDir) { catalog.initialize("catalog", Map.of()); catalog.createNamespace(Namespace.of("myNamespace")); TableIdentifier tableIdentifier = TableIdentifier.parse("myNamespace.myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); String locationOverride = tempDir.toString() + "/custom-path"; Map tableProperties = Map.of("key", "value"); @@ -65,7 +76,11 @@ void testTableCreation(@TempDir Path tempDir) { catalog1.createTable(identifier, schema, spec, locationOverride, tableProperties); tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), tableCreator); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + tableCreator); assertThat(catalog.tableExists(tableIdentifier)).isTrue(); assertThat(catalog.loadTable(tableIdentifier).properties().get("key")).isEqualTo("value"); assertThat(catalog.loadTable(tableIdentifier).location()).isEqualTo(locationOverride); @@ -77,8 +92,9 @@ void testTableCreation(@TempDir Path tempDir) { void testTableAlreadyExists() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); // Make the table non-existent in cache cache.exists(tableIdentifier); @@ -87,7 +103,11 @@ void testTableAlreadyExists() { // Make sure that the cache is invalidated and the table refreshed without an error Tuple2 result = tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); assertThat(result.f1).isEqualTo(PartitionSpec.unpartitioned()); @@ -97,8 +117,9 @@ void testTableAlreadyExists() { void testBranchCreationAndCaching() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); catalog.createTable(tableIdentifier, SCHEMA); tableUpdater.update( @@ -115,11 +136,13 @@ void testBranchCreationAndCaching() { void testSpecCreation() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("data", 10).build(); - tableUpdater.update(tableIdentifier, "main", SCHEMA, spec, TableCreator.DEFAULT); + tableUpdater.update( + tableIdentifier, SnapshotRef.MAIN_BRANCH, SCHEMA, spec, TableCreator.DEFAULT); Table table = catalog.loadTable(tableIdentifier); assertThat(table).isNotNull(); @@ -131,15 +154,16 @@ void testInvalidateOldCacheEntryOnUpdate() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); cache.schema(tableIdentifier, SCHEMA); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema updated = tableUpdater .update( tableIdentifier, - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT) @@ -155,12 +179,17 @@ void testLastResultInvalidation() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); // Initialize cache tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); // Update table behind the scenes catalog.dropTable(tableIdentifier); @@ -174,7 +203,7 @@ void testLastResultInvalidation() { tableUpdater .update( tableIdentifier, - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT) @@ -186,4 +215,132 @@ void testLastResultInvalidation() { assertThat(cache.getInternalCache().get(tableIdentifier).inputSchemas()) .doesNotContainKey(SCHEMA2); } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testCaseSensitivity(boolean caseSensitive) { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, caseSensitive, DROP_COLUMNS); + + TableUpdater tableUpdater = new TableUpdater(cache, catalog, caseSensitive, DROP_COLUMNS); + + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "extra", Types.StringType.get())); + + catalog.createTable(tableIdentifier, schema); + + Schema schemaWithUpperCase = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get()), + Types.NestedField.optional(3, "Extra", Types.StringType.get())); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + schemaWithUpperCase, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + + Schema tableSchema = catalog.loadTable(tableIdentifier).schema(); + if (caseSensitive) { + assertThat(tableSchema.columns()).hasSize(3); + assertThat(tableSchema.findField("Id")).isNotNull(); + assertThat(tableSchema.findField("Data")).isNotNull(); + assertThat(tableSchema.findField("Extra")).isNotNull(); + } else { + assertThat(tableSchema.sameSchema(schema)).isTrue(); + } + } + + @Test + void testDropUnusedColumns() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, DROP_COLUMNS); + + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, DROP_COLUMNS); + + catalog.createTable(tableIdentifier, SCHEMA2); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + Schema tableSchema = catalog.loadTable(tableIdentifier).schema(); + assertThat(tableSchema.columns()).hasSize(2); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNotNull(); + assertThat(tableSchema.findField("extra")).isNull(); + } + + @Test + void testNamespaceAndTableCreation() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + SupportsNamespaces namespaceCatalog = (SupportsNamespaces) catalog; + TableIdentifier tableIdentifier = TableIdentifier.of("new_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); + + assertThat(namespaceCatalog.namespaceExists(Namespace.of("new_namespace"))).isFalse(); + assertThat(catalog.tableExists(tableIdentifier)).isFalse(); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(namespaceCatalog.namespaceExists(Namespace.of("new_namespace"))).isTrue(); + + assertThat(catalog.tableExists(tableIdentifier)).isTrue(); + assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testTableCreationWithExistingNamespace() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + SupportsNamespaces namespaceCatalog = (SupportsNamespaces) catalog; + Namespace namespace = Namespace.of("existing_namespace"); + namespaceCatalog.createNamespace(namespace); + + TableIdentifier tableIdentifier = TableIdentifier.of("existing_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); + + assertThat(namespaceCatalog.namespaceExists(namespace)).isTrue(); + assertThat(catalog.tableExists(tableIdentifier)).isFalse(); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(namespaceCatalog.namespaceExists(namespace)).isTrue(); + assertThat(catalog.tableExists(tableIdentifier)).isTrue(); + assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + } } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java index ac2e2784e681..44791094de75 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java @@ -54,6 +54,9 @@ protected SortOrder sortOrder() { .sortBy(Expressions.bucket("uuid_field", 16), SortDirection.ASC, NullOrder.NULLS_FIRST) .sortBy(Expressions.hour("ts_with_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) .sortBy(Expressions.day("ts_without_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) + .asc("ts_ns_with_zone_field") + .sortBy( + Expressions.hour("ts_ns_without_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) // can not test HeapByteBuffer due to equality test inside SerializerTestBase // .sortBy(Expressions.truncate("binary_field", 2), SortDirection.ASC, // NullOrder.NULLS_FIRST) diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java index 8352924d042a..6ae7bfb53a2e 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java @@ -50,6 +50,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.avro.Avro; @@ -625,8 +626,9 @@ public void testMetadataLogEntries() { assertThat(metadataLog.getField("latest_snapshot_id")).isEqualTo(parentSnapshot.snapshotId()); metadataLog = metadataLogs.get(2); - assertThat(metadataLog.getField("timestamp")) - .isEqualTo(Instant.ofEpochMilli(currentSnapshot.timestampMillis())); + assertThat((Instant) metadataLog.getFieldAs("timestamp")) + .isAfterOrEqualTo(Instant.ofEpochMilli(currentSnapshot.timestampMillis())) + .isEqualTo(Instant.ofEpochMilli(tableMetadata.lastUpdatedMillis())); assertThat(metadataLog.getField("file")).isEqualTo(tableMetadata.metadataFileLocation()); assertThat(metadataLog.getField("latest_snapshot_id")).isEqualTo(currentSnapshot.snapshotId()); assertThat(metadataLog.getField("latest_schema_id")).isEqualTo(currentSnapshot.schemaId()); @@ -640,7 +642,7 @@ public void testMetadataLogEntries() { TABLE_NAME, currentSnapshotId); assertThat(metadataLogWithFilters).hasSize(1); metadataLog = metadataLogWithFilters.get(0); - assertThat(Instant.ofEpochMilli(tableMetadata.currentSnapshot().timestampMillis())) + assertThat(Instant.ofEpochMilli(tableMetadata.lastUpdatedMillis())) .isEqualTo(metadataLog.getField("timestamp")); assertThat(metadataLog.getField("file")).isEqualTo(tableMetadata.metadataFileLocation()); @@ -695,7 +697,7 @@ public void testSnapshotReferencesMetatable() { // Check branch entries in refs table List mainBranch = sql("SELECT * FROM %s$refs WHERE name='main' AND type='BRANCH'", TABLE_NAME); - assertThat((String) mainBranch.get(0).getFieldAs("name")).isEqualTo("main"); + assertThat((String) mainBranch.get(0).getFieldAs("name")).isEqualTo(SnapshotRef.MAIN_BRANCH); assertThat((String) mainBranch.get(0).getFieldAs("type")).isEqualTo("BRANCH"); assertThat((Long) mainBranch.get(0).getFieldAs("snapshot_id")).isEqualTo(currentSnapshotId); List testBranch = @@ -732,7 +734,8 @@ public void testSnapshotReferencesMetatable() { assertThat((String) testTagProjection.get(0).getFieldAs("min_snapshots_to_keep")).isNull(); List mainBranchProjection = sql("SELECT name, type FROM %s$refs WHERE name='main' AND type = 'BRANCH'", TABLE_NAME); - assertThat((String) mainBranchProjection.get(0).getFieldAs("name")).isEqualTo("main"); + assertThat((String) mainBranchProjection.get(0).getFieldAs("name")) + .isEqualTo(SnapshotRef.MAIN_BRANCH); assertThat((String) mainBranchProjection.get(0).getFieldAs("type")).isEqualTo("BRANCH"); List testBranchProjection = sql( diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java index f84cf7fb1aae..ec9333674d03 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java @@ -225,6 +225,68 @@ public void apply( 3))); } + /** + * Integration test verifying that records with eventTime equal to the minimum timestamp of their + * split are correctly included in windows. The {@link + * org.apache.iceberg.flink.source.reader.WatermarkExtractorRecordEmitter} emits the watermark as + * {@code minSplitTs - 1}, so records at exactly {@code minSplitTs} are on-time rather than late. + * + *

    The test writes 3 records at epoch (t=0). The split's column-stats lower-bound is 0, so the + * extracted watermark is 0ms and the emitted watermark is -1ms. Records at t=0 are strictly after + * that watermark and therefore belong to the [0, 5min) window. A later split is then appended to + * advance the watermark past the window boundary and trigger its evaluation. + */ + @Test + public void testWindowingWithRecordsAtSplitMinTimestamp() throws Exception { + GenericAppenderHelper dataAppender = appender(); + + // File 1: 3 records at exactly t=0 (epoch). Extracted watermark = 0ms, emitted = -1ms. + List batch = + ImmutableList.of( + generateRecord(0, "file_1-recordTs_0_a"), + generateRecord(0, "file_1-recordTs_0_b"), + generateRecord(0, "file_1-recordTs_0_c")); + dataAppender.appendToTable(batch); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + DataStream stream = + env.fromSource( + source(), + WatermarkStrategy.noWatermarks() + .withTimestampAssigner(new RowDataTimestampAssigner()), + SOURCE_NAME, + TypeInformation.of(RowData.class)); + + stream + .windowAll(TumblingEventTimeWindows.of(Duration.ofMinutes(5))) + .apply( + new AllWindowFunction() { + @Override + public void apply( + TimeWindow window, Iterable values, Collector out) { + AtomicInteger count = new AtomicInteger(0); + values.forEach(a -> count.incrementAndGet()); + out.collect(row(window.getStart(), count.get())); + WINDOWS.put(window.getStart(), count.get()); + } + }); + + WINDOWS.clear(); + env.executeAsync("Iceberg Source Min Timestamp Windowing Test"); + + // Append a file with much later timestamps to advance the watermark past [0, 5min) + dataAppender.appendToTable( + dataAppender.writeFile(ImmutableList.of(generateRecord(1500, "last-record")))); + + // The [0, 5min) window should fire with all 3 records written at epoch + Awaitility.await() + .pollInterval(Duration.ofMillis(10)) + .atMost(30, TimeUnit.SECONDS) + .until(() -> Integer.valueOf(3).equals(WINDOWS.get(0L))); + } + /** * This is an integration test for watermark handling and throttling. Integration testing the * following: diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java index 488969bab045..af3a79ff67b8 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; @@ -222,7 +223,8 @@ public void testPrimitiveColumns() throws Exception { // size of the column to increase. For example, with Parquet 1.14.x the // uncompressed size has been added to allow for better allocation of memory upfront. // Therefore, we look the sizes up, rather than hardcoding them - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); Map columnSizeStats = dataFile.columnSizes(); Row binaryCol = diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java index 5dd7de545e11..09639a8a9568 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java @@ -31,7 +31,7 @@ void testIncrementalFromSnapshotId() { .startingStrategy(StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_ID) .build(); assertException( - context, "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: null"); + context, "Invalid starting snapshot id for INCREMENTAL_FROM_SNAPSHOT_ID strategy: null"); context = ScanContext.builder() @@ -42,7 +42,7 @@ void testIncrementalFromSnapshotId() { .build(); assertException( context, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot timestamp for INCREMENTAL_FROM_SNAPSHOT_ID strategy: not null"); } @Test @@ -54,7 +54,7 @@ void testIncrementalFromSnapshotTimestamp() { .build(); assertException( context, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_TIMESTAMP strategy: null"); + "Invalid starting snapshot timestamp for INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP strategy: null"); context = ScanContext.builder() @@ -64,7 +64,8 @@ void testIncrementalFromSnapshotTimestamp() { .startSnapshotTimestamp(1L) .build(); assertException( - context, "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + context, + "Invalid starting snapshot id for INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP strategy: not null"); } @Test diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java index 332244732928..3b094ba02298 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.List; import org.apache.flink.table.data.RowData; +import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.BaseCombinedScanTask; import org.apache.iceberg.BaseFileScanTask; import org.apache.iceberg.CombinedScanTask; @@ -78,11 +79,16 @@ public static FileScanTask createFileTask( } public static DataIterator createDataIterator(CombinedScanTask combinedTask) { + return createDataIterator(combinedTask, TestFixtures.SCHEMA, TestFixtures.SCHEMA); + } + + public static DataIterator createDataIterator( + CombinedScanTask combinedTask, Schema tableSchema, Schema projectSchema) { return new DataIterator<>( new RowDataFileScanTaskReader( - TestFixtures.SCHEMA, TestFixtures.SCHEMA, null, true, Collections.emptyList()), + tableSchema, projectSchema, null, true, Collections.emptyList()), combinedTask, - new HadoopFileIO(new org.apache.hadoop.conf.Configuration()), + new HadoopFileIO(new Configuration()), PlaintextEncryptionManager.instance()); } diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java index 761e5622277d..191dfe088cdf 100644 --- a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java @@ -61,6 +61,13 @@ public class TestColumnStatsWatermarkExtractor { required(3, "long_column", Types.LongType.get()), required(4, "string_column", Types.StringType.get())); + // Separate schema for nanosecond columns: TIMESTAMP_NANO requires table format v3, which the + // HadoopTableExtension above does not provision. Tested via constructor preconditions only. + private static final Schema NANO_SCHEMA = + new Schema( + required(1, "timestamp_ns_column", Types.TimestampNanoType.withoutZone()), + required(2, "timestamptz_ns_column", Types.TimestampNanoType.withZone())); + private static final List> TEST_RECORDS = ImmutableList.of( RandomGenericData.generate(SCHEMA, 3, 2L), RandomGenericData.generate(SCHEMA, 3, 19L)); @@ -147,7 +154,17 @@ public void testWrongColumn() { assertThatThrownBy(() -> new ColumnStatsWatermarkExtractor(SCHEMA, columnName, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining( - "Found STRING, expected a LONG or TIMESTAMP column for watermark generation."); + "Found STRING, expected a LONG, TIMESTAMP, or TIMESTAMP_NANO column for watermark generation."); + } + + @TestTemplate + public void testTimestampNanoAccepted() { + // Run the precondition check exactly once across the parameterized matrix. + assumeThat(columnName).isEqualTo("timestamp_column"); + + // Both flavours of TIMESTAMP_NANO must be accepted by the extractor's precondition check. + new ColumnStatsWatermarkExtractor(NANO_SCHEMA, "timestamp_ns_column", null); + new ColumnStatsWatermarkExtractor(NANO_SCHEMA, "timestamptz_ns_column", null); } @TestTemplate diff --git a/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java new file mode 100644 index 000000000000..1cb7be03c6a7 --- /dev/null +++ b/flink/v2.0/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.source.reader; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.flink.api.common.eventtime.Watermark; +import org.apache.flink.api.connector.source.SourceOutput; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.TestFixtures; +import org.apache.iceberg.flink.source.split.IcebergSourceSplit; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestWatermarkExtractorRecordEmitter { + @TempDir protected Path temporaryFolder; + + @Test + public void testWatermarkIsDecrementedByOne() throws IOException { + long extractedWatermark = 1000L; + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> extractedWatermark); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + emitter.emitRecord(new RecordAndPosition<>("record", 0, 0L), output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(extractedWatermark - 1); + } + + @Test + public void testWatermarkEmittedOnlyOncePerSplit() throws IOException { + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> 1000L); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split); + emitter.emitRecord(element, output, split); + emitter.emitRecord(element, output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.records).hasSize(3); + } + + @Test + public void testWatermarkNotEmittedWhenNewSplitHasLowerValue() throws IOException { + IcebergSourceSplit split1 = createSplit(1L); + IcebergSourceSplit split2 = createSplit(2L); + + Map watermarkMap = Maps.newHashMap(); + watermarkMap.put(split1.splitId(), 2000L); + watermarkMap.put(split2.splitId(), 1000L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> watermarkMap.get(s.splitId())); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split1); + emitter.emitRecord(element, output, split2); + + // Only split1's watermark is emitted; split2 has a lower value so it's skipped + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(1999L); + } + + @Test + public void testWatermarkEmittedForEachHigherSplit() throws IOException { + IcebergSourceSplit split1 = createSplit(1L); + IcebergSourceSplit split2 = createSplit(2L); + + Map watermarkMap = Maps.newHashMap(); + watermarkMap.put(split1.splitId(), 1000L); + watermarkMap.put(split2.splitId(), 2000L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> watermarkMap.get(s.splitId())); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split1); + emitter.emitRecord(element, output, split2); + + assertThat(output.watermarks).hasSize(2); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(999L); + assertThat(output.watermarks.get(1).getTimestamp()).isEqualTo(1999L); + } + + @Test + public void testWatermarkAtLongMinValueDoesNotOverflow() throws IOException { + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> Long.MIN_VALUE); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + emitter.emitRecord(new RecordAndPosition<>("record", 0, 0L), output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(Long.MIN_VALUE); + } + + private IcebergSourceSplit createSplit(long seed) throws IOException { + List> recordBatchList = + ReaderUtil.createRecordBatchList(seed, TestFixtures.SCHEMA, 1, 1); + return IcebergSourceSplit.fromCombinedScanTask( + ReaderUtil.createCombinedScanTask( + recordBatchList, temporaryFolder, FileFormat.PARQUET, TestFixtures.SCHEMA)); + } + + private static class CapturingSourceOutput implements SourceOutput { + final List watermarks = Lists.newArrayList(); + final List records = Lists.newArrayList(); + + @Override + public void collect(T record) { + records.add(record); + } + + @Override + public void collect(T record, long timestamp) { + records.add(record); + } + + @Override + public void emitWatermark(Watermark watermark) { + watermarks.add(watermark); + } + + @Override + public void markIdle() {} + + @Override + public void markActive() {} + } +} diff --git a/flink/v2.1/build.gradle b/flink/v2.1/build.gradle index 91081bdc2e42..f93b61646e7c 100644 --- a/flink/v2.1/build.gradle +++ b/flink/v2.1/build.gradle @@ -33,7 +33,8 @@ project(":iceberg-flink:iceberg-flink-${flinkMajorVersion}") { implementation project(':iceberg-hive-metastore') compileOnly libs.flink21.avro - // for dropwizard histogram metrics implementation + compileOnly libs.joda.time + // dropwizard histogram metrics (optional in Flink) compileOnly libs.flink21.metrics.dropwizard compileOnly libs.flink21.streaming.java compileOnly "${libs.flink21.streaming.java.get().module}:${libs.flink21.streaming.java.get().getVersion()}:tests" @@ -83,6 +84,8 @@ project(":iceberg-flink:iceberg-flink-${flinkMajorVersion}") { testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation(testFixtures(project(':iceberg-parquet'))) // By default, hive-exec is a fat/uber jar and it exports a guava library // that's really old. We use the core classifier to be able to override our guava @@ -169,9 +172,6 @@ project(":iceberg-flink:iceberg-flink-runtime-${flinkMajorVersion}") { exclude group: 'com.google.code.findbugs', module: 'jsr305' } - // for dropwizard histogram metrics implementation - implementation libs.flink21.metrics.dropwizard - // for integration testing with the flink-runtime-jar // all of those dependencies are required because the integration test extends FlinkTestBase integrationCompileOnly project(':iceberg-api') @@ -266,4 +266,6 @@ project(":iceberg-flink:iceberg-flink-runtime-${flinkMajorVersion}") { jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/flink/v2.1/flink-runtime/LICENSE b/flink/v2.1/flink-runtime/LICENSE index 5f8bfb4edbf4..d73eda0104b9 100644 --- a/flink/v2.1/flink-runtime/LICENSE +++ b/flink/v2.1/flink-runtime/LICENSE @@ -203,138 +203,170 @@ -------------------------------------------------------------------------------- -This binary artifact contains Apache Avro. +This product bundles Apache Avro. Copyright: 2014-2020 The Apache Software Foundation. -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://avro.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains the Jackson JSON processor. +This product bundles Jackson JSON Processor. Copyright: 2007-2020 Tatu Saloranta and other contributors -Home page: http://jackson.codehaus.org/ -License: http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Parquet. +This product bundles FastDoubleParser (via Jackson JSON Processor). -Copyright: 2014-2020 The Apache Software Foundation. -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -This binary artifact contains Apache Thrift. +This product bundles fast_float (bundled by FastDoubleParser). -Copyright: 2006-2010 The Apache Software Foundation. -Home page: https://thrift.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -This binary artifact contains fastutil. +This product bundles bigint (bundled by FastDoubleParser). -Copyright: 2002-2014 Sebastiano Vigna -Home page: http://fastutil.di.unimi.it/ -License: http://www.apache.org/licenses/LICENSE-2.0.html +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -This binary artifact contains Apache ORC. +This product bundles Apache Parquet. -Copyright: 2013-2020 The Apache Software Foundation. -Home page: https://orc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2014-2020 The Apache Software Foundation. +Project URL: https://parquet.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Hive's storage API via ORC. +This product bundles Apache Thrift (bundled by Parquet). -Copyright: 2013-2020 The Apache Software Foundation. -Home page: https://hive.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2006-2010 The Apache Software Foundation. +Project URL: https://thrift.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Airlift Aircompressor. +This product includes code from Daniel Lemire's JavaFastPFOR project (bundled by Parquet). -Copyright: 2011-2020 Aircompressor authors. -Home page: https://github.com/airlift/aircompressor -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2013 Daniel Lemire +Project URL: https://github.com/lemire/JavaFastPFOR +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Guava. +This product bundles fastutil (bundled by Parquet). -Copyright: 2006-2020 The Guava Authors -Home page: https://github.com/google/guava -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2002-2014 Sebastiano Vigna +Project URL: http://fastutil.di.unimi.it/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Error Prone Annotations. +This product bundles Zero-Allocation Hashing (bundled by Parquet). -Copyright: Copyright 2011-2019 The Error Prone Authors -Home page: https://github.com/google/error-prone -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/OpenHFT/Zero-Allocation-Hashing +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains checkerframework checker-qual Annotations. - -Copyright: 2004-2020 the Checker Framework developers -Home page: https://github.com/typetools/checker-framework -License: https://github.com/typetools/checker-framework/blob/master/LICENSE.txt (MIT license) +This product bundles Apache ORC. -License text: -| The annotations are licensed under the MIT License. (The text of this -| license appears below.) More specifically, all the parts of the Checker -| Framework that you might want to include with your own program use the -| MIT License. This is the checker-qual.jar file and all the files that -| appear in it: every file in a qual/ directory, plus utility files such -| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. -| In addition, the cleanroom implementations of third-party annotations, -| which the Checker Framework recognizes as aliases for its own -| annotations, are licensed under the MIT License. -| -| Permission is hereby granted, free of charge, to any person obtaining a copy -| of this software and associated documentation files (the "Software"), to deal -| in the Software without restriction, including without limitation the rights -| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -| copies of the Software, and to permit persons to whom the Software is -| furnished to do so, subject to the following conditions: -| -| The above copyright notice and this permission notice shall be included in -| all copies or substantial portions of the Software. -| -| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -| THE SOFTWARE. +Copyright: 2013-2020 The Apache Software Foundation. +Project URL: https://orc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Caffeine by Ben Manes. +This product bundles Apache Hive's Storage API (bundled by ORC). -Copyright: 2014-2020 Ben Manes and contributors -Home page: https://github.com/ben-manes/caffeine -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 2008-2020 The Apache Software Foundation. +Project URL: https://hive.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google protobuf. +This product bundles Google protobuf (bundled by ORC). Copyright: 2008 Google Inc. -Home page: https://developers.google.com/protocol-buffers -License: https://github.com/protocolbuffers/protobuf/blob/master/LICENSE (BSD) - -License text: +Project URL: https://developers.google.com/protocol-buffers +License: BSD 3-Clause | Copyright 2008 Google Inc. All rights reserved. | @@ -371,14 +403,88 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains ThreeTen. +This product bundles Apache Datasketches. -Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. -Home page: https://www.threeten.org/threeten-extra/ -License: https://github.com/ThreeTen/threeten-extra/blob/master/LICENSE.txt (BSD 3-clause) +Project URL: https://datasketches.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Airlift Aircompressor. + +Copyright: 2011-2020 Aircompressor authors. +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Google Guava. + +Copyright: 2006-2020 The Guava Authors +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- -License text: +This product bundles Google Error Prone Annotations. +Copyright: Copyright 2011-2019 The Error Prone Authors +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles checkerframework checker-qual. + +Copyright: 2004-2020 the Checker Framework developers +Project URL: https://github.com/typetools/checker-framework +License: MIT + +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Caffeine by Ben Manes. + +Copyright: 2014-2020 Ben Manes and contributors +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles ThreeTen Extra. + +Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. +Project URL: https://www.threeten.org/threeten-extra/ +License: BSD 3-Clause + +| Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. | All rights reserved. | | * Redistribution and use in source and binary forms, with or without @@ -409,80 +515,63 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Project Nessie. +This product bundles Project Nessie. Copyright: Copyright 2015-2025 Dremio Corporation -Home page: https://projectnessie.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://projectnessie.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary includes code from Apache Commons. +This product includes code from Apache Commons. * Core ArrayUtil. Copyright: 2020 The Apache Software Foundation -Home page: https://commons.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- - -This binary artifact contains Apache HttpComponents Client. - -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://commons.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache HttpComponents Client. +This product bundles and includes code from Apache HttpComponents (core/client). * retry and error handling logic in ExponentialHttpRequestRetryStrategy.java Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains failsafe. +This product bundles failsafe. Copyright: Jonathan Halterman and friends -Home page: https://failsafe.dev/ -License: https://www.apache.org/licenses/LICENSE-2.0.html - --------------------------------------------------------------------------------- - -This binary artifact contains Codehale Metrics. - -Copyright: (c) 2010-2013 Coda Hale, Yammer.com, 2014-2021 Dropwizard Team -Home page: https://github.com/dropwizard/metrics -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains RoaringBitmap. +This product bundles RoaringBitmap. Copyright: (c) 2013-... the RoaringBitmap authors -Home page: https://github.com/RoaringBitmap/RoaringBitmap -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Eclipse Microprofile OpenAPI. +This product bundles Eclipse MicroProfile OpenAPI. Copyright: Copyright (c) 2017 Contributors to the Eclipse Foundation -Home page: https://github.com/microprofile/microprofile-open-api -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/microprofile/microprofile-open-api +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Luben Zstd. +This product bundles Luben Zstd. Copyright: Copyright (c) 2015-present, Luben Karavelov/ All rights reserved. -Home page: https://github.com/luben/zstd-jni/ -License: BSD-2 License -License text: +Project URL: https://github.com/luben/zstd-jni/ +License: BSD 2-Clause | Zstd-jni: JNI bindings to Zstd Library | @@ -510,3 +599,426 @@ License text: | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles JTS Topology Suite. + +Project URL: https://github.com/locationtech/jts +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| - Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| - Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| - Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles the Mozilla Public Suffix List, distributed by Apache HttpComponents. + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + diff --git a/flink/v2.1/flink-runtime/NOTICE b/flink/v2.1/flink-runtime/NOTICE index 0838a76b3473..61b02129d0e1 100644 --- a/flink/v2.1/flink-runtime/NOTICE +++ b/flink/v2.1/flink-runtime/NOTICE @@ -1,15 +1,13 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -This binary artifact includes Airlift Aircompressor with the following in its -NOTICE file: - +This product bundles Airlift Aircompressor with the following in its NOTICE file: | Snappy Copyright Notices | ========================= | @@ -50,9 +48,7 @@ NOTICE file: -------------------------------------------------------------------------------- -This binary artifact includes Project Nessie with the following in its NOTICE -file: - +This product bundles Project Nessie with the following in its NOTICE file: | Nessie | Copyright 2015-2025 Dremio Corporation | @@ -337,8 +333,7 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Eclipse Microprofile OpenAPI with the following in its NOTICE file: - +This product bundles Eclipse Microprofile OpenAPI with the following in its NOTICE file: | ========================================================================= | == NOTICE file corresponding to section 4(d) of the Apache License, == | == Version 2.0, in this case for MicroProfile OpenAPI == @@ -358,3 +353,39 @@ This binary artifact includes Eclipse Microprofile OpenAPI with the following in | PackageCopyrightText: | Arthur De Magalhaes arthurdm@ca.ibm.com | + +-------------------------------------------------------------------------------- + +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. diff --git a/flink/v2.1/flink-runtime/runtime-deps.txt b/flink/v2.1/flink-runtime/runtime-deps.txt new file mode 100644 index 000000000000..431cbe0d114e --- /dev/null +++ b/flink/v2.1/flink-runtime/runtime-deps.txt @@ -0,0 +1,31 @@ +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.21.3 +com.fasterxml.jackson.core:jackson-databind:2.21.3 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.github.luben:zstd-jni:1.5.7-3 +com.google.errorprone:error_prone_annotations:2.10.0 +dev.failsafe:failsafe:3.3.2 +io.airlift:aircompressor:2.0.3 +org.apache.avro:avro:1.12.1 +org.apache.datasketches:datasketches-java:6.2.0 +org.apache.datasketches:datasketches-memory:3.0.2 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.checkerframework:checker-qual:3.19.0 +org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1 +org.locationtech.jts:jts-core:1.20.0 +org.projectnessie.nessie:nessie-client:0.107.5 +org.projectnessie.nessie:nessie-model:0.107.5 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.threeten:threeten-extra:1.7.1 diff --git a/flink/v2.1/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java b/flink/v2.1/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java index d7c3a7b32bc8..6e5d04499e75 100644 --- a/flink/v2.1/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java +++ b/flink/v2.1/flink/src/jmh/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordSerializerDeserializerBenchmark.java @@ -32,6 +32,7 @@ import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.data.RandomGenericData; import org.apache.iceberg.data.Record; import org.apache.iceberg.flink.CatalogLoader; @@ -94,7 +95,7 @@ public void setupBenchmark() throws IOException { r -> new DynamicRecordInternal( "t", - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA, RowDataConverter.convert(SCHEMA, r), PartitionSpec.unpartitioned(), diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java index e0672811cf5f..7661372c88e8 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkConfParser.java @@ -44,7 +44,7 @@ public FlinkConfParser(Table table, Map options, ReadableConfig this.readableConfig = readableConfig; } - FlinkConfParser(Map options, ReadableConfig readableConfig) { + public FlinkConfParser(Map options, ReadableConfig readableConfig) { this.tableProperties = ImmutableMap.of(); this.options = options; this.readableConfig = readableConfig; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java index ab69ec5adc7f..0612260bfe7d 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkCreateTableOptions.java @@ -67,6 +67,24 @@ private FlinkCreateTableOptions( .noDefaultValue() .withDescription("Properties for the underlying catalog for iceberg table."); + public static final ConfigOption USE_DYNAMIC_ICEBERG_SINK = + ConfigOptions.key("use-dynamic-iceberg-sink") + .booleanType() + .defaultValue(false) + .withDescription( + "Whether to use dynamic iceberg sink for routing data to multiple tables. " + + "When enabled, a single sink instance can dynamically route records to different " + + "Iceberg tables based on the logic defined in the DynamicRecordGenerator implementation. " + + "Requires 'dynamic-record-generator-impl' to be specified. " + + "Default is false (uses standard static sink behavior)."); + + public static final ConfigOption DYNAMIC_RECORD_GENERATOR_IMPL = + ConfigOptions.key("dynamic-record-generator-impl") + .stringType() + .noDefaultValue() + .withDescription( + "Implementation of DynamicTableRecordGenerator class when use-dynamic-iceberg-sink is enabled."); + public static final String SRC_CATALOG_PROPS_KEY = "src-catalog"; public static final String CONNECTOR_PROPS_KEY = "connector"; public static final String LOCATION_KEY = "location"; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java index bd79c1156090..b2e2e33b9291 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkDynamicTableFactory.java @@ -88,26 +88,33 @@ public DynamicTableSink createDynamicTableSink(Context context) { ObjectIdentifier objectIdentifier = context.getObjectIdentifier(); ResolvedCatalogTable resolvedCatalogTable = context.getCatalogTable(); Map writeProps = resolvedCatalogTable.getOptions(); - ResolvedSchema resolvedSchema = - ResolvedSchema.of( - resolvedCatalogTable.getResolvedSchema().getColumns().stream() - .filter(Column::isPhysical) - .collect(Collectors.toList())); - TableLoader tableLoader; - if (catalog != null) { - tableLoader = createTableLoader(catalog, objectIdentifier.toObjectPath()); + Configuration flinkConf = new Configuration(); + writeProps.forEach(flinkConf::setString); + + boolean useDynamicSink = flinkConf.get(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK); + + if (useDynamicSink) { + return getIcebergTableSinkWithDynamicSinkProps(context, flinkConf, writeProps); } else { - tableLoader = - createTableLoader( - resolvedCatalogTable, - writeProps, - objectIdentifier.getDatabaseName(), - objectIdentifier.getObjectName()); - } + TableLoader tableLoader; + if (catalog != null) { + tableLoader = createTableLoader(catalog, objectIdentifier.toObjectPath()); + } else { + tableLoader = + createTableLoader( + resolvedCatalogTable, + writeProps, + objectIdentifier.getDatabaseName(), + objectIdentifier.getObjectName()); + } - return new IcebergTableSink( - tableLoader, resolvedSchema, context.getConfiguration(), writeProps); + return new IcebergTableSink( + tableLoader, + resolvedCatalogTable.getResolvedSchema(), + context.getConfiguration(), + writeProps); + } } @Override @@ -123,6 +130,8 @@ public Set> optionalOptions() { Set> options = Sets.newHashSet(); options.add(FlinkCreateTableOptions.CATALOG_DATABASE); options.add(FlinkCreateTableOptions.CATALOG_TABLE); + options.add(FlinkCreateTableOptions.USE_DYNAMIC_ICEBERG_SINK); + options.add(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL); return options; } @@ -131,6 +140,46 @@ public String factoryIdentifier() { return FACTORY_IDENTIFIER; } + private IcebergTableSink getIcebergTableSinkWithDynamicSinkProps( + Context context, Configuration flinkConf, Map writeProps) { + String dynamicRecordGeneratorImpl = + flinkConf.get(FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL); + Preconditions.checkArgument( + dynamicRecordGeneratorImpl != null, + "Invalid dynamic record generator value: null. %s must be specified when use-dynamic-iceberg-sink is true.", + FlinkCreateTableOptions.DYNAMIC_RECORD_GENERATOR_IMPL.key()); + + CatalogLoader catalogLoader; + if (catalog != null) { + catalogLoader = catalog.getCatalogLoader(); + } else { + FlinkCatalog flinkCatalog = + createCatalogLoader(writeProps, flinkConf.get(FlinkCreateTableOptions.CATALOG_NAME)); + catalogLoader = flinkCatalog.getCatalogLoader(); + } + + ResolvedCatalogTable resolvedCatalogTable = context.getCatalogTable(); + + return new IcebergTableSink( + catalogLoader, + dynamicRecordGeneratorImpl, + resolvedCatalogTable.getResolvedSchema(), + context.getConfiguration(), + writeProps); + } + + private static FlinkCatalog createCatalogLoader( + Map tableProps, String catalogName) { + Preconditions.checkArgument( + catalogName != null, + "Invalid catalog name: null. Set %s create table option.", + FlinkCreateTableOptions.CATALOG_NAME); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + return (FlinkCatalog) + factory.createCatalog(catalogName, tableProps, FlinkCatalogFactory.clusterHadoopConf()); + } + private static TableLoader createTableLoader( ResolvedCatalogTable resolvedCatalogTable, Map tableProps, @@ -143,21 +192,16 @@ private static TableLoader createTableLoader( mergedProps.forEach(flinkConf::setString); String catalogName = flinkConf.get(FlinkCreateTableOptions.CATALOG_NAME); - Preconditions.checkNotNull( - catalogName, - "Table property '%s' cannot be null", - FlinkCreateTableOptions.CATALOG_NAME.key()); String catalogDatabase = flinkConf.get(FlinkCreateTableOptions.CATALOG_DATABASE, databaseName); - Preconditions.checkNotNull(catalogDatabase, "The iceberg database name cannot be null"); + Preconditions.checkArgument( + catalogDatabase != null, + "Invalid database name: null. Set %s create table option or specify fully qualified table name.", + FlinkCreateTableOptions.CATALOG_DATABASE); String catalogTable = flinkConf.get(FlinkCreateTableOptions.CATALOG_TABLE, tableName); - Preconditions.checkNotNull(catalogTable, "The iceberg table name cannot be null"); - org.apache.hadoop.conf.Configuration hadoopConf = FlinkCatalogFactory.clusterHadoopConf(); - FlinkCatalogFactory factory = new FlinkCatalogFactory(); - FlinkCatalog flinkCatalog = - (FlinkCatalog) factory.createCatalog(catalogName, mergedProps, hadoopConf); + FlinkCatalog flinkCatalog = createCatalogLoader(mergedProps, catalogName); ObjectPath objectPath = new ObjectPath(catalogDatabase, catalogTable); // Create database if not exists in the external catalog. @@ -229,7 +273,6 @@ private static Map mergeSrcCatalogProps(Map tabl } private static TableLoader createTableLoader(FlinkCatalog catalog, ObjectPath objectPath) { - Preconditions.checkNotNull(catalog, "Flink catalog cannot be null"); return TableLoader.fromCatalog(catalog.getCatalogLoader(), catalog.toIdentifier(objectPath)); } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java index 408065f06057..bfcd34d0b999 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkTypeToType.java @@ -40,6 +40,7 @@ import org.apache.flink.table.types.logical.TinyIntType; import org.apache.flink.table.types.logical.VarBinaryType; import org.apache.flink.table.types.logical.VarCharType; +import org.apache.flink.table.types.logical.VariantType; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; @@ -137,11 +138,17 @@ public Type visit(TimeType timeType) { @Override public Type visit(TimestampType timestampType) { + if (timestampType.getPrecision() > 6) { + return Types.TimestampNanoType.withoutZone(); + } return Types.TimestampType.withoutZone(); } @Override public Type visit(LocalZonedTimestampType localZonedTimestampType) { + if (localZonedTimestampType.getPrecision() > 6) { + return Types.TimestampNanoType.withZone(); + } return Types.TimestampType.withZone(); } @@ -200,4 +207,9 @@ public Type visit(RowType rowType) { return Types.StructType.of(newFields); } + + @Override + public Type visit(VariantType variantType) { + return Types.VariantType.get(); + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java index 66fd09807794..990d23f2aaff 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteConf.java @@ -220,6 +220,24 @@ public Integer writeParallelism() { return confParser.intConf().option(FlinkWriteOptions.WRITE_PARALLELISM.key()).parseOptional(); } + public boolean expireSnapshotsMode() { + return confParser + .booleanConf() + .option(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key()) + .flinkConfig(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE) + .defaultValue(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.defaultValue()) + .parse(); + } + + public boolean deleteOrphanFilesMode() { + return confParser + .booleanConf() + .option(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key()) + .flinkConfig(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE) + .defaultValue(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.defaultValue()) + .parse(); + } + public boolean compactMode() { return confParser .booleanConf() diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java index e68e64ac573d..ee2aeaa45007 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/FlinkWriteOptions.java @@ -23,6 +23,9 @@ import org.apache.flink.configuration.ConfigOption; import org.apache.flink.configuration.ConfigOptions; import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; +import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; import org.apache.iceberg.flink.sink.shuffle.StatisticsType; /** Flink sink write options */ @@ -82,7 +85,18 @@ private FlinkWriteOptions() {} ConfigOptions.key("write-parallelism").intType().noDefaultValue(); public static final ConfigOption COMPACTION_ENABLE = - ConfigOptions.key("compaction-enabled").booleanType().defaultValue(false); + ConfigOptions.key(RewriteDataFilesConfig.PREFIX + "enabled") + .booleanType() + .defaultValue(false) + .withDeprecatedKeys("compaction-enabled"); + + public static final ConfigOption EXPIRE_SNAPSHOTS_ENABLE = + ConfigOptions.key(ExpireSnapshotsConfig.PREFIX + "enabled").booleanType().defaultValue(false); + + public static final ConfigOption DELETE_ORPHAN_FILES_ENABLE = + ConfigOptions.key(DeleteOrphanFilesConfig.PREFIX + "enabled") + .booleanType() + .defaultValue(false); @Experimental public static final ConfigOption TABLE_REFRESH_INTERVAL = diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java index c8c11474177c..3fc3d093f610 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/IcebergTableSink.java @@ -20,7 +20,11 @@ import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.flink.configuration.ReadableConfig; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.datastream.DataStreamSink; +import org.apache.flink.table.catalog.Column; import org.apache.flink.table.catalog.ResolvedSchema; import org.apache.flink.table.catalog.UniqueConstraint; import org.apache.flink.table.connector.ChangelogMode; @@ -28,29 +32,46 @@ import org.apache.flink.table.connector.sink.DynamicTableSink; import org.apache.flink.table.connector.sink.abilities.SupportsOverwrite; import org.apache.flink.table.connector.sink.abilities.SupportsPartitioning; +import org.apache.flink.table.data.RowData; import org.apache.flink.table.legacy.api.TableSchema; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.types.RowKind; +import org.apache.iceberg.common.DynConstructors; import org.apache.iceberg.flink.sink.FlinkSink; import org.apache.iceberg.flink.sink.IcebergSink; +import org.apache.iceberg.flink.sink.dynamic.DynamicIcebergSink; +import org.apache.iceberg.flink.sink.dynamic.DynamicRecordGenerator; +import org.apache.iceberg.flink.sink.dynamic.DynamicTableRecordGenerator; +import org.apache.iceberg.flink.sink.dynamic.TableCreator; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.util.PropertyUtil; public class IcebergTableSink implements DynamicTableSink, SupportsPartitioning, SupportsOverwrite { private final TableLoader tableLoader; - @Deprecated private final TableSchema tableSchema; + private final CatalogLoader catalogLoader; + + @SuppressWarnings("deprecation") + @Deprecated + private final TableSchema tableSchema; + private final ResolvedSchema resolvedSchema; private final ReadableConfig readableConfig; private final Map writeProps; - + private final String dynamicRecordGeneratorImpl; private boolean overwrite = false; + private boolean useDynamicSink = false; private IcebergTableSink(IcebergTableSink toCopy) { this.tableLoader = toCopy.tableLoader; + this.catalogLoader = toCopy.catalogLoader; this.tableSchema = toCopy.tableSchema; this.resolvedSchema = toCopy.resolvedSchema; this.overwrite = toCopy.overwrite; this.readableConfig = toCopy.readableConfig; this.writeProps = toCopy.writeProps; + this.dynamicRecordGeneratorImpl = toCopy.dynamicRecordGeneratorImpl; + this.useDynamicSink = toCopy.useDynamicSink; } /** @@ -64,10 +85,12 @@ public IcebergTableSink( ReadableConfig readableConfig, Map writeProps) { this.tableLoader = tableLoader; + this.catalogLoader = null; this.tableSchema = tableSchema; this.resolvedSchema = null; this.readableConfig = readableConfig; this.writeProps = writeProps; + this.dynamicRecordGeneratorImpl = null; } public IcebergTableSink( @@ -76,78 +99,71 @@ public IcebergTableSink( ReadableConfig readableConfig, Map writeProps) { this.tableLoader = tableLoader; + this.catalogLoader = null; this.tableSchema = null; this.resolvedSchema = resolvedSchema; this.readableConfig = readableConfig; this.writeProps = writeProps; + this.dynamicRecordGeneratorImpl = null; + } + + public IcebergTableSink( + CatalogLoader catalogLoader, + String dynamicRecordGeneratorImpl, + ResolvedSchema resolvedSchema, + ReadableConfig readableConfig, + Map writeProps) { + this.tableLoader = null; + this.catalogLoader = catalogLoader; + this.dynamicRecordGeneratorImpl = dynamicRecordGeneratorImpl; + this.readableConfig = readableConfig; + this.writeProps = writeProps; + this.tableSchema = null; + this.resolvedSchema = resolvedSchema; + this.useDynamicSink = true; } + @SuppressWarnings("deprecation") @Override public SinkRuntimeProvider getSinkRuntimeProvider(Context context) { Preconditions.checkState( !overwrite || context.isBounded(), "Unbounded data stream doesn't support overwrite operation."); - if (resolvedSchema != null) { - List equalityColumns = - resolvedSchema - .getPrimaryKey() - .map(UniqueConstraint::getColumns) - .orElseGet(ImmutableList::of); - - return (DataStreamSinkProvider) - (providerContext, dataStream) -> { - if (Boolean.TRUE.equals( - readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK))) { - return IcebergSink.forRowData(dataStream) - .tableLoader(tableLoader) - .resolvedSchema(resolvedSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } else { - return FlinkSink.forRowData(dataStream) - .tableLoader(tableLoader) - .resolvedSchema(resolvedSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } - }; - } else { - List equalityColumns = - tableSchema - .getPrimaryKey() - .map(org.apache.flink.table.legacy.api.constraints.UniqueConstraint::getColumns) - .orElseGet(ImmutableList::of); - - return (DataStreamSinkProvider) - (providerContext, dataStream) -> { - if (readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK)) { - return IcebergSink.forRowData(dataStream) - .tableLoader(tableLoader) - .tableSchema(tableSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } else { - return FlinkSink.forRowData(dataStream) - .tableLoader(tableLoader) - .tableSchema(tableSchema) - .equalityFieldColumns(equalityColumns) - .overwrite(overwrite) - .setAll(writeProps) - .flinkConf(readableConfig) - .append(); - } - }; - } + return (DataStreamSinkProvider) + (providerContext, dataStream) -> { + if (useDynamicSink) { + return createDynamicIcebergSink(dataStream); + } + + ResolvedSchema physicalColumnsOnlySchema = null; + List equalityColumns; + if (resolvedSchema != null) { + physicalColumnsOnlySchema = + ResolvedSchema.of( + resolvedSchema.getColumns().stream() + .filter(Column::isPhysical) + .collect(Collectors.toList())); + + equalityColumns = + physicalColumnsOnlySchema + .getPrimaryKey() + .map(UniqueConstraint::getColumns) + .orElseGet(ImmutableList::of); + } else { + equalityColumns = + tableSchema + .getPrimaryKey() + .map(org.apache.flink.table.legacy.api.constraints.UniqueConstraint::getColumns) + .orElseGet(ImmutableList::of); + } + + if (readableConfig.get(FlinkConfigOptions.TABLE_EXEC_ICEBERG_USE_V2_SINK)) { + return createIcebergSink(dataStream, equalityColumns, physicalColumnsOnlySchema); + } else { + return createLegacySink(dataStream, equalityColumns, physicalColumnsOnlySchema); + } + }; } @Override @@ -179,4 +195,104 @@ public String asSummaryString() { public void applyOverwrite(boolean newOverwrite) { this.overwrite = newOverwrite; } + + private DataStreamSink createLegacySink( + DataStream dataStream, + List equalityColumns, + ResolvedSchema physicalColumnsOnlySchema) { + FlinkSink.Builder builder = + FlinkSink.forRowData(dataStream) + .tableLoader(tableLoader) + .equalityFieldColumns(equalityColumns) + .overwrite(overwrite) + .setAll(writeProps) + .flinkConf(readableConfig); + + if (physicalColumnsOnlySchema != null) { + builder = builder.resolvedSchema(physicalColumnsOnlySchema); + } else { + builder = builder.tableSchema(tableSchema); + } + + return builder.append(); + } + + private DataStreamSink createIcebergSink( + DataStream dataStream, + List equalityColumns, + ResolvedSchema physicalColumnsOnlySchema) { + IcebergSink.Builder builder = + IcebergSink.forRowData(dataStream) + .tableLoader(tableLoader) + .equalityFieldColumns(equalityColumns) + .overwrite(overwrite) + .setAll(writeProps) + .flinkConf(readableConfig); + + if (physicalColumnsOnlySchema != null) { + builder = builder.resolvedSchema(physicalColumnsOnlySchema); + } else { + builder = builder.tableSchema(tableSchema); + } + + return builder.append(); + } + + private DataStreamSink createDynamicIcebergSink(DataStream dataStream) { + Preconditions.checkArgument( + catalogLoader != null && dynamicRecordGeneratorImpl != null, + "Invalid value catalogLoader: %s, DynamicRecordGenerator Implementation class: %s. " + + "Both should be not null to use dynamic iceberg sink.", + catalogLoader, + dynamicRecordGeneratorImpl); + + TableCreator tableCreator = createTableCreator(); + DynamicRecordGenerator generator = + createDynamicRecordGenerator(dynamicRecordGeneratorImpl); + + DynamicIcebergSink.Builder builder = + DynamicIcebergSink.forInput(dataStream) + .generator(generator) + .catalogLoader(catalogLoader) + .setAll(writeProps) + .tableCreator(tableCreator) + .flinkConf(readableConfig); + + return builder.append(); + } + + private TableCreator createTableCreator() { + final Map tableProperties = + PropertyUtil.propertiesWithPrefix(writeProps, "table.props."); + final String location = writeProps.get("location"); + + return (catalog, identifier, schema, spec) -> + catalog + .buildTable(identifier, schema) + .withPartitionSpec(spec) + .withLocation(location) + .withProperties(tableProperties) + .create(); + } + + private DynamicTableRecordGenerator createDynamicRecordGenerator(String generatorImpl) { + RowType rowType = (RowType) resolvedSchema.toSourceRowDataType().getLogicalType(); + + DynConstructors.Ctor ctor; + + try { + ctor = + DynConstructors.builder(DynamicTableRecordGenerator.class) + .loader(IcebergTableSink.class.getClassLoader()) + .impl(generatorImpl, RowType.class) + .buildChecked(); + return ctor.newInstance(rowType); + } catch (ClassCastException e) { + throw new IllegalArgumentException( + String.format("Class %s does not implement DynamicRecordGeneratorSQL", generatorImpl), e); + } catch (Exception e) { + throw new RuntimeException( + String.format("Failed to instantiate DynamicRecordGeneratorSQL %s", generatorImpl), e); + } + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java index 3ef611f2ded5..920e44b24b31 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/RowDataWrapper.java @@ -114,19 +114,35 @@ private static PositionalGetter buildGetter(LogicalType logicalType, Type typ case TIMESTAMP_WITHOUT_TIME_ZONE: TimestampType timestampType = (TimestampType) logicalType; - return (row, pos) -> { - LocalDateTime localDateTime = - row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); - return DateTimeUtil.microsFromTimestamp(localDateTime); - }; + if (type.typeId() == Type.TypeID.TIMESTAMP_NANO) { + return (row, pos) -> { + LocalDateTime localDateTime = + row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); + return DateTimeUtil.nanosFromTimestamp(localDateTime); + }; + } else { + return (row, pos) -> { + LocalDateTime localDateTime = + row.getTimestamp(pos, timestampType.getPrecision()).toLocalDateTime(); + return DateTimeUtil.microsFromTimestamp(localDateTime); + }; + } case TIMESTAMP_WITH_LOCAL_TIME_ZONE: LocalZonedTimestampType lzTs = (LocalZonedTimestampType) logicalType; - return (row, pos) -> { - TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); - return timestampData.getMillisecond() * 1000 - + timestampData.getNanoOfMillisecond() / 1000; - }; + if (type.typeId() == Type.TypeID.TIMESTAMP_NANO) { + return (row, pos) -> { + TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + }; + } else { + return (row, pos) -> { + TimestampData timestampData = row.getTimestamp(pos, lzTs.getPrecision()); + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + }; + } case ROW: RowType rowType = (RowType) logicalType; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/TypeToFlinkType.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/TypeToFlinkType.java index 72a646991456..e5b1186354fd 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/TypeToFlinkType.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/TypeToFlinkType.java @@ -37,6 +37,7 @@ import org.apache.flink.table.types.logical.TimestampType; import org.apache.flink.table.types.logical.VarBinaryType; import org.apache.flink.table.types.logical.VarCharType; +import org.apache.flink.table.types.logical.VariantType; import org.apache.iceberg.Schema; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Type; @@ -83,6 +84,11 @@ public LogicalType map(Types.MapType map, LogicalType keyResult, LogicalType val return new MapType(keyResult.copy(false), valueResult.copy(map.isValueOptional())); } + @Override + public LogicalType variant(Types.VariantType variant) { + return new VariantType(); + } + @Override public LogicalType primitive(Type.PrimitiveType primitive) { switch (primitive.typeId()) { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java index 4cf30ed90418..dcd4ac4e58a5 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/actions/RewriteDataFilesAction.java @@ -53,7 +53,7 @@ protected FileIO fileIO() { protected List rewriteDataForTasks(List combinedScanTasks) { int size = combinedScanTasks.size(); int parallelism = Math.min(size, maxParallelism); - DataStream dataStream = env.fromCollection(combinedScanTasks); + DataStream dataStream = env.fromData(combinedScanTasks); RowDataRewriter rowDataRewriter = new RowDataRewriter(table(), caseSensitive(), fileIO(), encryptionManager()); try { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java index 66ed95792e62..b0f8a4217115 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkAvroWriter.java @@ -33,6 +33,7 @@ import org.apache.iceberg.avro.MetricsAwareDatumWriter; import org.apache.iceberg.avro.ValueWriter; import org.apache.iceberg.avro.ValueWriters; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; public class FlinkAvroWriter implements MetricsAwareDatumWriter { @@ -43,6 +44,10 @@ public FlinkAvroWriter(RowType rowType) { this.rowType = rowType; } + public FlinkAvroWriter(org.apache.iceberg.Schema icebergSchema, RowType engineSchema) { + this(engineSchema != null ? engineSchema : FlinkSchemaUtil.convert(icebergSchema)); + } + @Override @SuppressWarnings("unchecked") public void setSchema(Schema schema) { @@ -127,7 +132,7 @@ public ValueWriter primitive(LogicalType type, Schema primitive) { return FlinkValueWriters.decimal(decimal.getPrecision(), decimal.getScale()); case "uuid": - return ValueWriters.uuids(); + return FlinkValueWriters.uuids(); default: throw new IllegalArgumentException("Unsupported logical type: " + logicalType); diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java new file mode 100644 index 000000000000..dd713b0dce2a --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkFormatModels.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.data; + +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; + +public class FlinkFormatModels { + public static void register() { + FormatModelRegistry.register( + ParquetFormatModel.create( + RowData.class, + RowType.class, + FlinkParquetWriters::buildWriter, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + FlinkParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + AvroFormatModel.create( + RowData.class, + RowType.class, + (icebergSchema, fileSchema, engineSchema) -> + new FlinkAvroWriter(icebergSchema, engineSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + FlinkPlannedAvroReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + RowData.class, + RowType.class, + (icebergSchema, fileSchema, engineSchema) -> + FlinkOrcWriter.buildWriter(engineSchema, icebergSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + new FlinkOrcReader(icebergSchema, fileSchema, idToConstant))); + } + + private FlinkFormatModels() {} +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java index 65b9d44ad4b8..77f16bfdb2ab 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReader.java @@ -70,7 +70,7 @@ public OrcValueReader record( TypeDescription record, List names, List> fields) { - return FlinkOrcReaders.struct(fields, iStruct, idToConstant); + return FlinkOrcReaders.struct(record, fields, iStruct, idToConstant); } @Override @@ -112,6 +112,13 @@ public OrcValueReader primitive(Type.PrimitiveType iPrimitive, TypeDescriptio } else { return FlinkOrcReaders.timestamps(); } + case TIMESTAMP_NANO: + Types.TimestampNanoType timestampNanoType = (Types.TimestampNanoType) iPrimitive; + if (timestampNanoType.shouldAdjustToUTC()) { + return FlinkOrcReaders.timestampTzs(); + } else { + return FlinkOrcReaders.timestamps(); + } case STRING: return FlinkOrcReaders.strings(); case UUID: diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java index 7a4a15c7e600..c5c958fbdb04 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcReaders.java @@ -39,6 +39,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.types.Types; +import org.apache.orc.TypeDescription; import org.apache.orc.storage.ql.exec.vector.BytesColumnVector; import org.apache.orc.storage.ql.exec.vector.ColumnVector; import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector; @@ -91,8 +92,11 @@ public static OrcValueReader map( } public static OrcValueReader struct( - List> readers, Types.StructType struct, Map idToConstant) { - return new StructReader(readers, struct, idToConstant); + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + return new StructReader(record, readers, struct, idToConstant); } private static class StringReader implements OrcValueReader { @@ -265,8 +269,11 @@ private static class StructReader extends OrcValueReaders.StructReader private final int numFields; StructReader( - List> readers, Types.StructType struct, Map idToConstant) { - super(readers, struct, idToConstant); + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + super(record, readers, struct, idToConstant); this.numFields = struct.fields().size(); } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java index 6a31accffd22..c1b46252e18a 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriter.java @@ -27,6 +27,7 @@ import org.apache.iceberg.FieldMetrics; import org.apache.iceberg.Schema; import org.apache.iceberg.data.orc.GenericOrcWriters; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.orc.OrcRowWriter; import org.apache.iceberg.orc.OrcValueWriter; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; @@ -45,7 +46,8 @@ private FlinkOrcWriter(RowType rowType, Schema iSchema) { } public static OrcRowWriter buildWriter(RowType rowType, Schema iSchema) { - return new FlinkOrcWriter(rowType, iSchema); + return new FlinkOrcWriter( + rowType != null ? rowType : FlinkSchemaUtil.convert(iSchema), iSchema); } @Override @@ -143,6 +145,13 @@ public OrcValueWriter primitive(Type.PrimitiveType iPrimitive, LogicalType fl } else { return FlinkOrcWriters.timestamps(); } + case TIMESTAMP_NANO: + Types.TimestampNanoType timestampNanoType = (Types.TimestampNanoType) iPrimitive; + if (timestampNanoType.shouldAdjustToUTC()) { + return FlinkOrcWriters.timestampNanoTzs(); + } else { + return FlinkOrcWriters.timestampNanos(); + } case STRING: return FlinkOrcWriters.strings(); case UUID: diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java index afce2cda1db1..bf19a46c05fb 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkOrcWriters.java @@ -30,6 +30,7 @@ import org.apache.flink.table.data.StringData; import org.apache.flink.table.data.TimestampData; import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeRoot; import org.apache.iceberg.FieldMetrics; import org.apache.iceberg.data.orc.GenericOrcWriters; import org.apache.iceberg.flink.FlinkRowData; @@ -69,6 +70,14 @@ static OrcValueWriter timestampTzs() { return TimestampTzWriter.INSTANCE; } + static OrcValueWriter timestampNanos() { + return TimestampNanoWriter.INSTANCE; + } + + static OrcValueWriter timestampNanoTzs() { + return TimestampNanoTzWriter.INSTANCE; + } + static OrcValueWriter decimals(int precision, int scale) { if (precision <= 18) { return new Decimal18Writer(precision, scale); @@ -93,7 +102,19 @@ static OrcValueWriter map( } static OrcValueWriter struct(List> writers, List types) { - return new RowDataWriter(writers, types); + int[] fieldIndexes = new int[writers.size()]; + int fieldIndex = 0; + List logicalTypes = Lists.newArrayList(); + for (int i = 0; i < types.size(); i += 1) { + LogicalType logicalType = types.get(i); + if (!logicalType.is(LogicalTypeRoot.NULL)) { + fieldIndexes[fieldIndex] = i; + fieldIndex += 1; + logicalTypes.add(logicalType); + } + } + + return new RowDataWriter(fieldIndexes, writers, logicalTypes); } private static class StringWriter implements OrcValueWriter { @@ -157,6 +178,35 @@ public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { } } + private static class TimestampNanoWriter implements OrcValueWriter { + private static final TimestampNanoWriter INSTANCE = new TimestampNanoWriter(); + + @Override + public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { + TimestampColumnVector cv = (TimestampColumnVector) output; + cv.setIsUTC(true); + // millis + OffsetDateTime offsetDateTime = data.toInstant().atOffset(ZoneOffset.UTC); + cv.time[rowId] = + offsetDateTime.toEpochSecond() * 1_000 + offsetDateTime.getNano() / 1_000_000; + cv.nanos[rowId] = offsetDateTime.getNano(); + } + } + + private static class TimestampNanoTzWriter implements OrcValueWriter { + private static final TimestampNanoTzWriter INSTANCE = new TimestampNanoTzWriter(); + + @SuppressWarnings("JavaInstantGetSecondsGetNano") + @Override + public void nonNullWrite(int rowId, TimestampData data, ColumnVector output) { + TimestampColumnVector cv = (TimestampColumnVector) output; + // millis + Instant instant = data.toInstant(); + cv.time[rowId] = instant.toEpochMilli(); + cv.nanos[rowId] = instant.getNano(); + } + } + private static class Decimal18Writer implements OrcValueWriter { private final int precision; private final int scale; @@ -294,12 +344,12 @@ public Stream> metrics() { static class RowDataWriter extends GenericOrcWriters.StructWriter { private final List fieldGetters; - RowDataWriter(List> writers, List types) { + RowDataWriter(int[] fieldIndexes, List> writers, List types) { super(writers); this.fieldGetters = Lists.newArrayListWithExpectedSize(types.size()); for (int i = 0; i < types.size(); i++) { - fieldGetters.add(FlinkRowData.createFieldGetter(types.get(i), i)); + fieldGetters.add(FlinkRowData.createFieldGetter(types.get(i), fieldIndexes[i])); } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java index efa1c85ba5b3..0e6856daa67a 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetReaders.java @@ -21,6 +21,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -34,12 +35,16 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.StringData; import org.apache.flink.table.data.TimestampData; +import org.apache.flink.types.variant.BinaryVariant; import org.apache.flink.types.variant.Variant; import org.apache.iceberg.Schema; import org.apache.iceberg.parquet.ParquetSchemaUtil; import org.apache.iceberg.parquet.ParquetValueReader; import org.apache.iceberg.parquet.ParquetValueReaders; +import org.apache.iceberg.parquet.ParquetVariantReaders.DelegatingValueReader; +import org.apache.iceberg.parquet.ParquetVariantVisitor; import org.apache.iceberg.parquet.TypeWithSchemaVisitor; +import org.apache.iceberg.parquet.VariantReaderBuilder; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -138,6 +143,17 @@ private ParquetValueReader defaultReader( throw new IllegalArgumentException(String.format("Missing required field: %s", field.name())); } + @Override + public ParquetVariantVisitor> variantVisitor() { + return new VariantReaderBuilder(type, Arrays.asList(currentPath())); + } + + @Override + public ParquetValueReader variant( + Types.VariantType expected, GroupType variant, ParquetValueReader variantReader) { + return new VariantReader(variantReader); + } + @Override public ParquetValueReader list( Types.ListType expectedList, GroupType array, ParquetValueReader elementReader) { @@ -295,6 +311,12 @@ public Optional> visit( LogicalTypeAnnotation.BsonLogicalTypeAnnotation bsonLogicalType) { return Optional.of(new ParquetValueReaders.ByteArrayReader(desc)); } + + @Override + public Optional> visit( + LogicalTypeAnnotation.UUIDLogicalTypeAnnotation uuidLogicalType) { + return Optional.of(new ParquetValueReaders.ByteArrayReader(desc)); + } } @Override @@ -853,4 +875,28 @@ public double[] toDoubleArray() { return ArrayUtil.toPrimitive((Double[]) values); } } + + /** Variant reader to convert from Iceberg Variant to Flink Variant */ + private static class VariantReader + extends DelegatingValueReader { + @SuppressWarnings("unchecked") + private VariantReader(ParquetValueReader reader) { + super((ParquetValueReader) reader); + } + + @Override + public Variant read(Variant reuse) { + org.apache.iceberg.variants.Variant icebergVariant = super.readFromDelegate(null); + + byte[] metadataBytes = new byte[icebergVariant.metadata().sizeInBytes()]; + ByteBuffer metadataBuffer = ByteBuffer.wrap(metadataBytes).order(ByteOrder.LITTLE_ENDIAN); + icebergVariant.metadata().writeTo(metadataBuffer, 0); + + byte[] valueBytes = new byte[icebergVariant.value().sizeInBytes()]; + ByteBuffer valueBuffer = ByteBuffer.wrap(valueBytes).order(ByteOrder.LITTLE_ENDIAN); + icebergVariant.value().writeTo(valueBuffer, 0); + + return new BinaryVariant(valueBytes, metadataBytes); + } + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java index 5c90252723bd..d9a7e0dab970 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkParquetWriters.java @@ -18,11 +18,15 @@ */ package org.apache.iceberg.flink.data; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.stream.Stream; import org.apache.flink.table.data.ArrayData; import org.apache.flink.table.data.DecimalData; import org.apache.flink.table.data.MapData; @@ -37,15 +41,27 @@ import org.apache.flink.table.types.logical.RowType.RowField; import org.apache.flink.table.types.logical.SmallIntType; import org.apache.flink.table.types.logical.TinyIntType; +import org.apache.flink.table.types.logical.VariantType; +import org.apache.flink.types.variant.BinaryVariant; +import org.apache.flink.types.variant.Variant; +import org.apache.iceberg.FieldMetrics; +import org.apache.iceberg.Schema; import org.apache.iceberg.flink.FlinkRowData; +import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.parquet.ParquetValueReaders; import org.apache.iceberg.parquet.ParquetValueWriter; import org.apache.iceberg.parquet.ParquetValueWriters; +import org.apache.iceberg.parquet.ParquetVariantVisitor; +import org.apache.iceberg.parquet.TripleWriter; +import org.apache.iceberg.parquet.VariantWriterBuilder; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.DecimalUtil; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.ColumnWriteStore; import org.apache.parquet.io.api.Binary; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.LogicalTypeAnnotation; @@ -59,6 +75,7 @@ import org.apache.parquet.schema.LogicalTypeAnnotation.StringLogicalTypeAnnotation; import org.apache.parquet.schema.LogicalTypeAnnotation.TimeLogicalTypeAnnotation; import org.apache.parquet.schema.LogicalTypeAnnotation.TimestampLogicalTypeAnnotation; +import org.apache.parquet.schema.LogicalTypeAnnotation.UUIDLogicalTypeAnnotation; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.PrimitiveType; import org.apache.parquet.schema.Type; @@ -66,6 +83,12 @@ public class FlinkParquetWriters { private FlinkParquetWriters() {} + public static ParquetValueWriter buildWriter( + Schema icebergSchema, MessageType type, RowType engineSchema) { + return buildWriter( + engineSchema != null ? engineSchema : FlinkSchemaUtil.convert(icebergSchema), type); + } + @SuppressWarnings("unchecked") public static ParquetValueWriter buildWriter(LogicalType schema, MessageType type) { return (ParquetValueWriter) @@ -85,6 +108,14 @@ public ParquetValueWriter message( return struct(sStruct, message.asGroupType(), fields); } + @Override + public ParquetValueWriter variant(VariantType sVariant, GroupType variant) { + ParquetValueWriter writer = + ParquetVariantVisitor.visit( + variant, new VariantWriterBuilder(type, Arrays.asList(currentPath()))); + return new VariantWriter(writer); + } + @Override public ParquetValueWriter struct( RowType sStruct, GroupType struct, List> fieldWriters) { @@ -279,6 +310,11 @@ public Optional> visit(JsonLogicalTypeAnnotation ignored) public Optional> visit(BsonLogicalTypeAnnotation ignored) { return Optional.of(byteArrays(desc)); } + + @Override + public Optional> visit(UUIDLogicalTypeAnnotation uuid) { + return Optional.of(byteArrays(desc)); + } } private static ParquetValueWriter ints(LogicalType type, ColumnDescriptor desc) { @@ -588,6 +624,49 @@ public Map.Entry next() { } } + /** Variant writer converts from VariantVal to Variant */ + private static class VariantWriter implements ParquetValueWriter { + private final ParquetValueWriter writer; + + @SuppressWarnings("unchecked") + private VariantWriter(ParquetValueWriter writer) { + this.writer = (ParquetValueWriter) writer; + } + + @Override + public void write(int repetitionLevel, Variant variant) { + Preconditions.checkArgument( + variant instanceof BinaryVariant, + "Expected BinaryVariant but got: %s", + variant.getClass().getSimpleName()); + + BinaryVariant binaryVariant = (BinaryVariant) variant; + VariantMetadata metadata = + VariantMetadata.from( + ByteBuffer.wrap(binaryVariant.getMetadata()).order(ByteOrder.LITTLE_ENDIAN)); + VariantValue value = + VariantValue.from( + metadata, ByteBuffer.wrap(binaryVariant.getValue()).order(ByteOrder.LITTLE_ENDIAN)); + + writer.write(repetitionLevel, org.apache.iceberg.variants.Variant.of(metadata, value)); + } + + @Override + public List> columns() { + return writer.columns(); + } + + @Override + public void setColumnStore(ColumnWriteStore columnStore) { + writer.setColumnStore(columnStore); + } + + @Override + public Stream> metrics() { + return writer.metrics(); + } + } + private static class RowDataWriter extends ParquetValueWriters.StructWriter { private final RowData.FieldGetter[] fieldGetter; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java index ba4e1a7a7aec..1440fde3248c 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkSchemaVisitor.java @@ -106,7 +106,9 @@ private static T visitRecord( visitor.beforeField(iField); try { - results.add(visit(fieldFlinkType, iField.type(), visitor)); + if (iField.type() != Types.UnknownType.get()) { + results.add(visit(fieldFlinkType, iField.type(), visitor)); + } } finally { visitor.afterField(iField); } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java index f87e63704965..66e944eb52e3 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/FlinkValueWriters.java @@ -55,6 +55,10 @@ static ValueWriter timestampNanos() { return TimestampNanosWriter.INSTANCE; } + static ValueWriter uuids() { + return UUIDWriter.INSTANCE; + } + static ValueWriter decimal(int precision, int scale) { return new DecimalWriter(precision, scale); } @@ -145,6 +149,15 @@ public void write(TimestampData timestampData, Encoder encoder) throws IOExcepti } } + private static class UUIDWriter implements ValueWriter { + private static final UUIDWriter INSTANCE = new UUIDWriter(); + + @Override + public void write(byte[] bytes, Encoder encoder) throws IOException { + encoder.writeFixed(bytes); + } + } + private static class ArrayWriter implements ValueWriter { private final ValueWriter elementWriter; private final ArrayData.ElementGetter elementGetter; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/ParquetWithFlinkSchemaVisitor.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/ParquetWithFlinkSchemaVisitor.java index 39aac237a8f6..9c1934ad0c7a 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/ParquetWithFlinkSchemaVisitor.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/ParquetWithFlinkSchemaVisitor.java @@ -26,9 +26,11 @@ import org.apache.flink.table.types.logical.MapType; import org.apache.flink.table.types.logical.RowType; import org.apache.flink.table.types.logical.RowType.RowField; +import org.apache.flink.table.types.logical.VariantType; import org.apache.iceberg.avro.AvroSchemaUtil; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.variants.Variant; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.LogicalTypeAnnotation; import org.apache.parquet.schema.MessageType; @@ -147,7 +149,20 @@ public static T visit( } finally { visitor.fieldNames.pop(); } + } else if (LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION).equals(annotation) + || sType instanceof VariantType) { + // For the Variant we both check the Parquet LogicalTypeAnnotation, and we rely on the + // Iceberg schema, since there are engines like Spark that produce VariantTypes without the + // annotation. + Preconditions.checkArgument( + sType instanceof VariantType, + "Invalid variant: Flink type %s is not a variant type", + sType); + VariantType variant = (VariantType) sType; + + return visitor.variant(variant, group); } + Preconditions.checkArgument( sType instanceof RowType, "Invalid struct: %s is not a struct", sType); RowType struct = (RowType) sType; @@ -211,6 +226,10 @@ public T primitive(LogicalType sPrimitive, PrimitiveType primitive) { return null; } + public T variant(VariantType sVariant, GroupType variant) { + return null; + } + protected String[] currentPath() { return Lists.newArrayList(fieldNames.descendingIterator()).toArray(new String[0]); } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java index f23a7ee3d0d3..81bb55967992 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/RowDataUtil.java @@ -69,6 +69,8 @@ public static Object convertConstant(Type type, Object value) { return (int) ((Long) value / 1000); case TIMESTAMP: // TimestampData return TimestampData.fromLocalDateTime(DateTimeUtil.timestampFromMicros((Long) value)); + case TIMESTAMP_NANO: + return TimestampData.fromLocalDateTime(DateTimeUtil.timestampFromNanos((Long) value)); case UUID: return UUIDUtil.convert((UUID) value); default: diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java index b93e4346a47a..40a2d91f87f8 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/data/StructRowData.java @@ -49,6 +49,7 @@ import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.DateTimeUtil; @Internal public class StructRowData implements RowData { @@ -121,8 +122,8 @@ public int getInt(int pos) { if (integer instanceof Integer) { return (int) integer; - } else if (integer instanceof LocalDate) { - return (int) ((LocalDate) integer).toEpochDay(); + } else if (integer instanceof LocalDate localDate) { + return (int) localDate.toEpochDay(); } else if (integer instanceof LocalTime) { return (int) (((LocalTime) integer).toNanoOfDay() / 1000_000); } else { @@ -186,8 +187,27 @@ private BigDecimal getDecimalInternal(int pos) { @Override public TimestampData getTimestamp(int pos, int precision) { + if (precision > 6) { + Object timeVal = struct.get(pos, Object.class); + if (timeVal instanceof OffsetDateTime) { + OffsetDateTime odt = (OffsetDateTime) timeVal; + return TimestampData.fromEpochMillis( + odt.toInstant().toEpochMilli(), odt.getNano() % 1_000_000); + } else if (timeVal instanceof LocalDateTime) { + LocalDateTime ldt = (LocalDateTime) timeVal; + return TimestampData.fromEpochMillis( + ldt.toInstant(ZoneOffset.UTC).toEpochMilli(), ldt.getNano() % 1_000_000); + } else if (timeVal instanceof Long) { + long timeLong = (Long) timeVal; + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1_000_000L), (int) Math.floorMod(timeLong, 1_000_000L)); + } else { + throw new IllegalStateException("Unknown type for timestamp_ns: " + timeVal.getClass()); + } + } long timeLong = getLong(pos); - return TimestampData.fromEpochMillis(timeLong / 1000, (int) (timeLong % 1000) * 1000); + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1000L), (int) Math.floorMod(timeLong, 1000L) * 1000); } @Override @@ -263,9 +283,29 @@ private Object convertValue(Type elementType, Object value) { case DECIMAL: return value; case TIMESTAMP: - long millisecond = (long) value / 1000; - int nanoOfMillisecond = (int) ((Long) value % 1000) * 1000; - return TimestampData.fromEpochMillis(millisecond, nanoOfMillisecond); + long timeMillis; + if (value instanceof LocalDateTime localDateTime) { + timeMillis = DateTimeUtil.microsFromTimestamp(localDateTime) / 1000L; + } else if (value instanceof OffsetDateTime offsetDateTime) { + timeMillis = DateTimeUtil.microsFromTimestamptz(offsetDateTime) / 1000L; + } else { + timeMillis = Math.floorDiv((Long) value, 1000L); + } + return TimestampData.fromEpochMillis( + timeMillis, + (int) Math.floorMod(value instanceof Long ? (Long) value : timeMillis * 1000L, 1000L) + * 1000); + case TIMESTAMP_NANO: + long nanoLong; + if (value instanceof LocalDateTime localDateTime) { + nanoLong = DateTimeUtil.nanosFromTimestamp(localDateTime); + } else if (value instanceof OffsetDateTime offsetDateTime) { + nanoLong = DateTimeUtil.nanosFromTimestamptz(offsetDateTime); + } else { + nanoLong = (Long) value; + } + return TimestampData.fromEpochMillis( + Math.floorDiv(nanoLong, 1_000_000L), (int) Math.floorMod(nanoLong, 1_000_000L)); case STRING: return StringData.fromString(value.toString()); case FIXED: diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java new file mode 100644 index 000000000000..0f70e60a1b9f --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/AvroToRowDataConverters.java @@ -0,0 +1,303 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import java.io.Serializable; +import java.lang.reflect.Array; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoField; +import java.util.List; +import java.util.Map; +import org.apache.avro.generic.GenericFixed; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.generic.IndexedRecord; +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.GenericArrayData; +import org.apache.flink.table.data.GenericMapData; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.utils.LogicalTypeUtils; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; + +/** + * Tool class used to convert from Avro {@link GenericRecord} to {@link RowData}. + * + *

    This class is adapted in Iceberg to add support for nanosecond precision timestamps + * (FLINK-39251). Once that ticket is resolved in Flink, this custom converter may be removed. + */ +@Internal +public class AvroToRowDataConverters { + + private AvroToRowDataConverters() {} + + /** + * Runtime converter that converts Avro data structures into objects of Flink Table & SQL + * internal data structures. + */ + @FunctionalInterface + public interface AvroToRowDataConverter extends Serializable { + Object convert(Object object); + } + + // ------------------------------------------------------------------------------------- + // Runtime Converters + // ------------------------------------------------------------------------------------- + + public static AvroToRowDataConverter createRowConverter(RowType rowType) { + return createRowConverter(rowType, true); + } + + public static AvroToRowDataConverter createRowConverter( + RowType rowType, boolean legacyTimestampMapping) { + final AvroToRowDataConverter[] fieldConverters = + rowType.getFields().stream() + .map(RowType.RowField::getType) + .map(type -> createNullableConverter(type, legacyTimestampMapping)) + .toArray(AvroToRowDataConverter[]::new); + final int arity = rowType.getFieldCount(); + + return avroObject -> { + IndexedRecord record = (IndexedRecord) avroObject; + GenericRowData row = new GenericRowData(arity); + for (int i = 0; i < arity; ++i) { + // avro always deserialize successfully even though the type isn't matched + // so no need to throw exception about which field can't be deserialized + row.setField(i, fieldConverters[i].convert(record.get(i))); + } + return row; + }; + } + + /** Creates a runtime converter which is null safe. */ + private static AvroToRowDataConverter createNullableConverter( + LogicalType type, boolean legacyTimestampMapping) { + final AvroToRowDataConverter converter = createConverter(type, legacyTimestampMapping); + return avroObject -> { + if (avroObject == null) { + return null; + } + return converter.convert(avroObject); + }; + } + + /** Creates a runtime converter which assuming input object is not null. */ + private static AvroToRowDataConverter createConverter( + LogicalType type, boolean legacyTimestampMapping) { + switch (type.getTypeRoot()) { + case NULL: + return avroObject -> null; + case TINYINT: + return avroObject -> ((Integer) avroObject).byteValue(); + case SMALLINT: + return avroObject -> ((Integer) avroObject).shortValue(); + case BOOLEAN: // boolean + case INTEGER: // int + case INTERVAL_YEAR_MONTH: // long + case BIGINT: // long + case INTERVAL_DAY_TIME: // long + case FLOAT: // float + case DOUBLE: // double + return avroObject -> avroObject; + case DATE: + return AvroToRowDataConverters::convertToDate; + case TIME_WITHOUT_TIME_ZONE: + return AvroToRowDataConverters::convertToTime; + case TIMESTAMP_WITHOUT_TIME_ZONE: + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return avroObject -> convertToTimestamp(avroObject, type); + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + if (legacyTimestampMapping) { + throw new UnsupportedOperationException("Unsupported type: " + type); + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return avroObject -> convertToTimestamp(avroObject, type); + } + case CHAR: + case VARCHAR: + return avroObject -> StringData.fromString(avroObject.toString()); + case BINARY: + case VARBINARY: + return AvroToRowDataConverters::convertToBytes; + case DECIMAL: + return createDecimalConverter((DecimalType) type); + case ARRAY: + return createArrayConverter((ArrayType) type, legacyTimestampMapping); + case ROW: + return createRowConverter((RowType) type); + case MAP: + case MULTISET: + return createMapConverter(type, legacyTimestampMapping); + case RAW: + default: + throw new UnsupportedOperationException("Unsupported type: " + type); + } + } + + private static AvroToRowDataConverter createDecimalConverter(DecimalType decimalType) { + final int precision = decimalType.getPrecision(); + final int scale = decimalType.getScale(); + return avroObject -> { + final byte[] bytes; + if (avroObject instanceof GenericFixed) { + bytes = ((GenericFixed) avroObject).bytes(); + } else if (avroObject instanceof ByteBuffer) { + ByteBuffer byteBuffer = (ByteBuffer) avroObject; + bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + } else { + bytes = (byte[]) avroObject; + } + return DecimalData.fromUnscaledBytes(bytes, precision, scale); + }; + } + + private static AvroToRowDataConverter createArrayConverter( + ArrayType arrayType, boolean legacyTimestampMapping) { + final AvroToRowDataConverter elementConverter = + createNullableConverter(arrayType.getElementType(), legacyTimestampMapping); + final Class elementClass = + LogicalTypeUtils.toInternalConversionClass(arrayType.getElementType()); + + return avroObject -> { + final List list = (List) avroObject; + final int length = list.size(); + final Object[] array = (Object[]) Array.newInstance(elementClass, length); + for (int i = 0; i < length; ++i) { + array[i] = elementConverter.convert(list.get(i)); + } + return new GenericArrayData(array); + }; + } + + private static AvroToRowDataConverter createMapConverter( + LogicalType type, boolean legacyTimestampMapping) { + final AvroToRowDataConverter keyConverter = + createConverter(DataTypes.STRING().getLogicalType(), legacyTimestampMapping); + final AvroToRowDataConverter valueConverter = + createNullableConverter( + AvroSchemaConverter.extractValueTypeToAvroMap(type), legacyTimestampMapping); + + return avroObject -> { + final Map map = (Map) avroObject; + Map result = Maps.newHashMap(); + for (Map.Entry entry : map.entrySet()) { + Object key = keyConverter.convert(entry.getKey()); + Object value = valueConverter.convert(entry.getValue()); + result.put(key, value); + } + return new GenericMapData(result); + }; + } + + private static TimestampData convertToTimestamp(Object object, LogicalType type) { + int precision = 3; + if (type instanceof org.apache.flink.table.types.logical.TimestampType) { + precision = ((org.apache.flink.table.types.logical.TimestampType) type).getPrecision(); + } else if (type instanceof org.apache.flink.table.types.logical.LocalZonedTimestampType) { + precision = + ((org.apache.flink.table.types.logical.LocalZonedTimestampType) type).getPrecision(); + } + + if (object instanceof Long) { + long timeLong = (Long) object; + if (precision <= 3) { + return TimestampData.fromEpochMillis(timeLong); + } else if (precision <= 6) { + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1000L), (int) Math.floorMod(timeLong, 1000L) * 1_000_000); + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return TimestampData.fromEpochMillis( + Math.floorDiv(timeLong, 1_000_000L), (int) Math.floorMod(timeLong, 1_000_000L)); + } + } else if (object instanceof Instant) { + return TimestampData.fromInstant((Instant) object); + } else if (object instanceof LocalDateTime) { + return TimestampData.fromLocalDateTime((LocalDateTime) object); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + return TimestampData.fromEpochMillis(jodaConverter.convertTimestamp(object)); + } else { + throw new IllegalArgumentException( + "Unexpected object type for TIMESTAMP logical type. Received: " + object); + } + } + } + + private static int convertToDate(Object object) { + if (object instanceof Integer) { + return (Integer) object; + } else if (object instanceof LocalDate) { + return (int) ((LocalDate) object).toEpochDay(); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + return (int) jodaConverter.convertDate(object); + } else { + throw new IllegalArgumentException( + "Unexpected object type for DATE logical type. Received: " + object); + } + } + } + + private static int convertToTime(Object object) { + final int millis; + if (object instanceof Integer) { + millis = (Integer) object; + } else if (object instanceof LocalTime) { + millis = ((LocalTime) object).get(ChronoField.MILLI_OF_DAY); + } else { + JodaConverter jodaConverter = JodaConverter.getConverter(); + if (jodaConverter != null) { + millis = jodaConverter.convertTime(object); + } else { + throw new IllegalArgumentException( + "Unexpected object type for TIME logical type. Received: " + object); + } + } + return millis; + } + + private static byte[] convertToBytes(Object object) { + if (object instanceof GenericFixed) { + return ((GenericFixed) object).bytes(); + } else if (object instanceof ByteBuffer) { + ByteBuffer byteBuffer = (ByteBuffer) object; + byte[] bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + return bytes; + } else { + return (byte[]) object; + } + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java new file mode 100644 index 000000000000..c30b78023345 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/JodaConverter.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeFieldType; +import org.joda.time.LocalDate; +import org.joda.time.LocalTime; + +/** + * Encapsulates joda optional dependency. Instantiates this class only if joda is available on the + * classpath. + */ +@SuppressWarnings("JavaUtilDate") +class JodaConverter { + + private static JodaConverter instance; + private static boolean instantiated = false; + + public static JodaConverter getConverter() { + if (instantiated) { + return instance; + } + + try { + Class.forName( + "org.joda.time.DateTime", false, Thread.currentThread().getContextClassLoader()); + instance = new JodaConverter(); + } catch (ClassNotFoundException e) { + instance = null; + } finally { + instantiated = true; + } + return instance; + } + + public long convertDate(Object object) { + final LocalDate value = (LocalDate) object; + return value.toDate().getTime(); + } + + public int convertTime(Object object) { + final LocalTime value = (LocalTime) object; + return value.get(DateTimeFieldType.millisOfDay()); + } + + public long convertTimestamp(Object object) { + final DateTime value = (DateTime) object; + return value.toDate().getTime(); + } + + private JodaConverter() {} +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java new file mode 100644 index 000000000000..d4c7e4282d6e --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/RowDataToAvroConverters.java @@ -0,0 +1,394 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.util.Utf8; +import org.apache.flink.annotation.Internal; +import org.apache.flink.table.data.ArrayData; +import org.apache.flink.table.data.DecimalData; +import org.apache.flink.table.data.MapData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.TimestampData; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.util.CollectionUtil; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; + +/** + * Tool class used to convert from {@link RowData} to Avro {@link GenericRecord}. + * + *

    This class is adapted in Iceberg to add support for nanosecond precision timestamps + * (FLINK-39251). Once that ticket is resolved in Flink, this custom converter may be removed. + */ +@Internal +public class RowDataToAvroConverters { + + private RowDataToAvroConverters() {} + + // -------------------------------------------------------------------------------- + // Runtime Converters + // -------------------------------------------------------------------------------- + + /** + * Runtime converter that converts objects of Flink Table & SQL internal data structures to + * corresponding Avro data structures. + */ + @FunctionalInterface + public interface RowDataToAvroConverter extends Serializable { + Object convert(Schema schema, Object object); + } + + // -------------------------------------------------------------------------------- + // IMPORTANT! We use anonymous classes instead of lambdas for a reason here. It is + // necessary because the maven shade plugin cannot relocate classes in + // SerializedLambdas (MSHADE-260). On the other hand we want to relocate Avro for + // sql-client uber jars. + // -------------------------------------------------------------------------------- + + /** + * Creates a runtime converter according to the given logical type that converts objects of Flink + * Table & SQL internal data structures to corresponding Avro data structures. + */ + public static RowDataToAvroConverter createConverter(LogicalType type) { + return createConverter(type, true); + } + + @SuppressWarnings("checkstyle:MethodLength") + public static RowDataToAvroConverter createConverter( + LogicalType type, boolean legacyTimestampMapping) { + final RowDataToAvroConverter converter; + switch (type.getTypeRoot()) { + case NULL: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return null; + } + }; + break; + case TINYINT: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ((Byte) object).intValue(); + } + }; + break; + case SMALLINT: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ((Short) object).intValue(); + } + }; + break; + case BOOLEAN: // boolean + case INTEGER: // int + case INTERVAL_YEAR_MONTH: // long + case BIGINT: // long + case INTERVAL_DAY_TIME: // long + case FLOAT: // float + case DOUBLE: // double + case TIME_WITHOUT_TIME_ZONE: // int + case DATE: // int + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return object; + } + }; + break; + case CHAR: + case VARCHAR: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return new Utf8(object.toString()); + } + }; + break; + case BINARY: + case VARBINARY: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ByteBuffer.wrap((byte[]) object); + } + }; + break; + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + case TIMESTAMP_WITHOUT_TIME_ZONE: + final int tzPrecision; + if (type instanceof org.apache.flink.table.types.logical.TimestampType) { + tzPrecision = ((org.apache.flink.table.types.logical.TimestampType) type).getPrecision(); + } else { + tzPrecision = 3; + } + if (legacyTimestampMapping) { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + if (tzPrecision <= 3) { + return timestampData.getMillisecond(); + } else if (tzPrecision <= 6) { + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + } + } + }; + } else { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + java.time.Instant instant = + timestampData.toLocalDateTime().toInstant(ZoneOffset.UTC); + if (tzPrecision <= 3) { + return instant.toEpochMilli(); + } else if (tzPrecision <= 6) { + return instant.getEpochSecond() * 1_000_000L + instant.getNano() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return instant.getEpochSecond() * 1_000_000_000L + instant.getNano(); + } + } + }; + } + break; + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + final int ltzPrecision; + if (type instanceof org.apache.flink.table.types.logical.LocalZonedTimestampType) { + ltzPrecision = + ((org.apache.flink.table.types.logical.LocalZonedTimestampType) type).getPrecision(); + } else { + ltzPrecision = 3; + } + if (legacyTimestampMapping) { + throw new UnsupportedOperationException("Unsupported type: " + type); + } else { + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + TimestampData timestampData = (TimestampData) object; + if (ltzPrecision <= 3) { + return timestampData.getMillisecond(); + } else if (ltzPrecision <= 6) { + return timestampData.getMillisecond() * 1000L + + timestampData.getNanoOfMillisecond() / 1000; + } else { + // Iceberg: Added support for nanoseconds precision (FLINK-39251) + return timestampData.getMillisecond() * 1_000_000L + + timestampData.getNanoOfMillisecond(); + } + } + }; + } + break; + case DECIMAL: + converter = + new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + return ByteBuffer.wrap(((DecimalData) object).toUnscaledBytes()); + } + }; + break; + case ARRAY: + converter = createArrayConverter((ArrayType) type, legacyTimestampMapping); + break; + case ROW: + converter = createRowConverter((RowType) type, legacyTimestampMapping); + break; + case MAP: + case MULTISET: + converter = createMapConverter(type, legacyTimestampMapping); + break; + case RAW: + default: + throw new UnsupportedOperationException("Unsupported type: " + type); + } + + // wrap into nullable converter + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + if (object == null) { + return null; + } + + // get actual schema if it is a nullable schema + Schema actualSchema; + if (schema.getType() == Schema.Type.UNION) { + List types = schema.getTypes(); + int size = types.size(); + if (size == 2 && types.get(1).getType() == Schema.Type.NULL) { + actualSchema = types.get(0); + } else if (size == 2 && types.get(0).getType() == Schema.Type.NULL) { + actualSchema = types.get(1); + } else { + throw new IllegalArgumentException( + "The Avro schema is not a nullable type: " + schema.toString()); + } + } else { + actualSchema = schema; + } + return converter.convert(actualSchema, object); + } + }; + } + + private static RowDataToAvroConverter createRowConverter( + RowType rowType, boolean legacyTimestampMapping) { + final RowDataToAvroConverter[] fieldConverters = + rowType.getChildren().stream() + .map(legacyType -> createConverter(legacyType, legacyTimestampMapping)) + .toArray(RowDataToAvroConverter[]::new); + final LogicalType[] fieldTypes = + rowType.getFields().stream().map(RowType.RowField::getType).toArray(LogicalType[]::new); + final RowData.FieldGetter[] fieldGetters = new RowData.FieldGetter[fieldTypes.length]; + for (int i = 0; i < fieldTypes.length; i++) { + fieldGetters[i] = RowData.createFieldGetter(fieldTypes[i], i); + } + final int length = rowType.getFieldCount(); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final RowData row = (RowData) object; + final List fields = schema.getFields(); + final GenericRecord record = new GenericData.Record(schema); + for (int i = 0; i < length; ++i) { + final Schema.Field schemaField = fields.get(i); + try { + Object avroObject = + fieldConverters[i].convert( + schemaField.schema(), fieldGetters[i].getFieldOrNull(row)); + record.put(i, avroObject); + } catch (Throwable t) { + throw new RuntimeException( + String.format("Fail to serialize at field: %s.", schemaField.name()), t); + } + } + return record; + } + }; + } + + private static RowDataToAvroConverter createArrayConverter( + ArrayType arrayType, boolean legacyTimestampMapping) { + LogicalType elementType = arrayType.getElementType(); + final ArrayData.ElementGetter elementGetter = ArrayData.createElementGetter(elementType); + final RowDataToAvroConverter elementConverter = + createConverter(arrayType.getElementType(), legacyTimestampMapping); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final Schema elementSchema = schema.getElementType(); + ArrayData arrayData = (ArrayData) object; + List list = Lists.newArrayList(); + for (int i = 0; i < arrayData.size(); ++i) { + list.add( + elementConverter.convert( + elementSchema, elementGetter.getElementOrNull(arrayData, i))); + } + return list; + } + }; + } + + private static RowDataToAvroConverter createMapConverter( + LogicalType type, boolean legacyTimestampMapping) { + LogicalType valueType = AvroSchemaConverter.extractValueTypeToAvroMap(type); + final ArrayData.ElementGetter valueGetter = ArrayData.createElementGetter(valueType); + final RowDataToAvroConverter valueConverter = + createConverter(valueType, legacyTimestampMapping); + + return new RowDataToAvroConverter() { + private static final long serialVersionUID = 1L; + + @Override + public Object convert(Schema schema, Object object) { + final Schema valueSchema = schema.getValueType(); + final MapData mapData = (MapData) object; + final ArrayData keyArray = mapData.keyArray(); + final ArrayData valueArray = mapData.valueArray(); + final Map map = CollectionUtil.newHashMapWithExpectedSize(mapData.size()); + for (int i = 0; i < mapData.size(); ++i) { + final String key = keyArray.getString(i).toString(); + final Object value = + valueConverter.convert(valueSchema, valueGetter.getElementOrNull(valueArray, i)); + map.put(key, value); + } + return map; + } + }; + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java new file mode 100644 index 000000000000..fb77c124e504 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/formats/avro/typeutils/AvroSchemaConverter.java @@ -0,0 +1,625 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.formats.avro.typeutils; + +import java.util.List; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.avro.SchemaParseException; +import org.apache.avro.specific.SpecificData; +import org.apache.avro.specific.SpecificRecord; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.api.java.typeutils.RowTypeInfo; +import org.apache.flink.formats.avro.AvroRowDataDeserializationSchema; +import org.apache.flink.formats.avro.AvroRowDataSerializationSchema; +import org.apache.flink.table.api.DataTypes; +import org.apache.flink.table.legacy.types.logical.TypeInformationRawType; +import org.apache.flink.table.types.AtomicDataType; +import org.apache.flink.table.types.DataType; +import org.apache.flink.table.types.logical.ArrayType; +import org.apache.flink.table.types.logical.DecimalType; +import org.apache.flink.table.types.logical.IntType; +import org.apache.flink.table.types.logical.LocalZonedTimestampType; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.LogicalTypeFamily; +import org.apache.flink.table.types.logical.MapType; +import org.apache.flink.table.types.logical.MultisetType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.TimeType; +import org.apache.flink.table.types.logical.TimestampType; +import org.apache.flink.types.Row; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; + +/** + * Converts an Avro schema into Flink's type information. It uses {@link RowTypeInfo} for + * representing objects and converts Avro types into types that are compatible with Flink's Table + * & SQL API. + * + *

    Note: Changes in this class need to be kept in sync with the corresponding runtime classes + * {@link AvroRowDataDeserializationSchema} and {@link AvroRowDataSerializationSchema}. + * + *

    This class is adapted in Iceberg to support custom 'timestamp-nanos' and + * 'local-timestamp-nanos' logical types (FLINK-39251). Once that ticket is resolved in Flink, these + * custom types may be removed. + */ +public class AvroSchemaConverter { + + private AvroSchemaConverter() { + // private + } + + /** + * Converts an Avro class into a nested row structure with deterministic field order and data + * types that are compatible with Flink's Table & SQL API. + * + * @param avroClass Avro specific record that contains schema information + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + Class avroClass) { + return convertToTypeInfo(avroClass, true); + } + + /** + * Converts an Avro class into a nested row structure with deterministic field order and data + * types that are compatible with Flink's Table & SQL API. + * + * @param avroClass Avro specific record that contains schema information + * @param legacyTimestampMapping legacy mapping of timestamp types + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + Class avroClass, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroClass, "Avro specific record class must not be null."); + // determine schema to retrieve deterministic field order + final Schema schema = SpecificData.get().getSchema(avroClass); + return (TypeInformation) convertToTypeInfo(schema, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo(String avroSchemaString) { + return convertToTypeInfo(avroSchemaString, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @param legacyTimestampMapping legacy mapping of timestamp types + * @return type information matching the schema + */ + @SuppressWarnings("unchecked") + public static TypeInformation convertToTypeInfo( + String avroSchemaString, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroSchemaString, "Avro schema must not be null."); + final Schema schema; + try { + schema = new Schema.Parser().parse(avroSchemaString); + } catch (SchemaParseException e) { + throw new IllegalArgumentException("Could not parse Avro schema string.", e); + } + return (TypeInformation) convertToTypeInfo(schema, legacyTimestampMapping); + } + + private static TypeInformation convertToTypeInfo( + Schema schema, boolean legacyTimestampMapping) { + switch (schema.getType()) { + case RECORD: + final List fields = schema.getFields(); + + final TypeInformation[] types = new TypeInformation[fields.size()]; + final String[] names = new String[fields.size()]; + for (int i = 0; i < fields.size(); i++) { + final Schema.Field field = fields.get(i); + types[i] = convertToTypeInfo(field.schema(), legacyTimestampMapping); + names[i] = field.name(); + } + return Types.ROW_NAMED(names, types); + case ENUM: + return Types.STRING; + case ARRAY: + // result type might either be ObjectArrayTypeInfo or BasicArrayTypeInfo for Strings + return Types.OBJECT_ARRAY( + convertToTypeInfo(schema.getElementType(), legacyTimestampMapping)); + case MAP: + return Types.MAP( + Types.STRING, convertToTypeInfo(schema.getValueType(), legacyTimestampMapping)); + case UNION: + final Schema actualSchema; + if (schema.getTypes().size() == 2 + && schema.getTypes().get(0).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(1); + } else if (schema.getTypes().size() == 2 + && schema.getTypes().get(1).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(0); + } else if (schema.getTypes().size() == 1) { + actualSchema = schema.getTypes().get(0); + } else { + // use Kryo for serialization + return Types.GENERIC(Object.class); + } + return convertToTypeInfo(actualSchema, legacyTimestampMapping); + case FIXED: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + return Types.BIG_DEC; + } + // convert fixed size binary data to primitive byte arrays + return Types.PRIMITIVE_ARRAY(Types.BYTE); + case STRING: + // convert Avro's Utf8/CharSequence to String + return Types.STRING; + case BYTES: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + return Types.BIG_DEC; + } + return Types.PRIMITIVE_ARRAY(Types.BYTE); + case INT: + // logical date and time type + final org.apache.avro.LogicalType logicalType = schema.getLogicalType(); + if (logicalType == LogicalTypes.date()) { + return Types.SQL_DATE; + } else if (logicalType == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + return Types.INT; + case LONG: + if (legacyTimestampMapping) { + if (schema.getLogicalType() == LogicalTypes.timestampMillis() + || schema.getLogicalType() == LogicalTypes.timestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos"))) { + return Types.SQL_TIMESTAMP; + } else if (schema.getLogicalType() == LogicalTypes.timeMicros() + || schema.getLogicalType() == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + } else { + // Avro logical timestamp types to Flink DataStream timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis() + || schema.getLogicalType() == LogicalTypes.timestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos"))) { + return Types.INSTANT; + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMillis() + || schema.getLogicalType() == LogicalTypes.localTimestampMicros() + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + || (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("local-timestamp-nanos"))) { + return Types.LOCAL_DATE_TIME; + } else if (schema.getLogicalType() == LogicalTypes.timeMicros() + || schema.getLogicalType() == LogicalTypes.timeMillis()) { + return Types.SQL_TIME; + } + } + return Types.LONG; + case FLOAT: + return Types.FLOAT; + case DOUBLE: + return Types.DOUBLE; + case BOOLEAN: + return Types.BOOLEAN; + case NULL: + return Types.VOID; + } + throw new IllegalArgumentException("Unsupported Avro type '" + schema.getType() + "'."); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @return data type matching the schema + */ + public static DataType convertToDataType(String avroSchemaString) { + return convertToDataType(avroSchemaString, true); + } + + /** + * Converts an Avro schema string into a nested row structure with deterministic field order and + * data types that are compatible with Flink's Table & SQL API. + * + * @param avroSchemaString Avro schema definition string + * @param legacyTimestampMapping legacy mapping of local timestamps + * @return data type matching the schema + */ + public static DataType convertToDataType( + String avroSchemaString, boolean legacyTimestampMapping) { + Preconditions.checkNotNull(avroSchemaString, "Avro schema must not be null."); + final Schema schema; + try { + schema = new Schema.Parser().parse(avroSchemaString); + } catch (SchemaParseException e) { + throw new IllegalArgumentException("Could not parse Avro schema string.", e); + } + return convertToDataType(schema, legacyTimestampMapping); + } + + @SuppressWarnings("deprecation") + private static DataType convertToDataType(Schema schema, boolean legacyMapping) { + switch (schema.getType()) { + case RECORD: + final List schemaFields = schema.getFields(); + + final DataTypes.Field[] fields = new DataTypes.Field[schemaFields.size()]; + for (int i = 0; i < schemaFields.size(); i++) { + final Schema.Field field = schemaFields.get(i); + fields[i] = + DataTypes.FIELD(field.name(), convertToDataType(field.schema(), legacyMapping)); + } + return DataTypes.ROW(fields).notNull(); + case ENUM: + return DataTypes.STRING().notNull(); + case ARRAY: + return DataTypes.ARRAY(convertToDataType(schema.getElementType(), legacyMapping)).notNull(); + case MAP: + return DataTypes.MAP( + DataTypes.STRING().notNull(), + convertToDataType(schema.getValueType(), legacyMapping)) + .notNull(); + case UNION: + final Schema actualSchema; + final boolean nullable; + if (schema.getTypes().size() == 2 + && schema.getTypes().get(0).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(1); + nullable = true; + } else if (schema.getTypes().size() == 2 + && schema.getTypes().get(1).getType() == Schema.Type.NULL) { + actualSchema = schema.getTypes().get(0); + nullable = true; + } else if (schema.getTypes().size() == 1) { + actualSchema = schema.getTypes().get(0); + nullable = false; + } else { + // use Kryo for serialization + return new AtomicDataType( + new TypeInformationRawType<>(false, Types.GENERIC(Object.class))); + } + DataType converted = convertToDataType(actualSchema, legacyMapping); + return nullable ? converted.nullable() : converted; + case FIXED: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + final LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) schema.getLogicalType(); + return DataTypes.DECIMAL(decimalType.getPrecision(), decimalType.getScale()).notNull(); + } + // convert fixed size binary data to primitive byte arrays + return DataTypes.VARBINARY(schema.getFixedSize()).notNull(); + case STRING: + // convert Avro's Utf8/CharSequence to String + return DataTypes.STRING().notNull(); + case BYTES: + // logical decimal type + if (schema.getLogicalType() instanceof LogicalTypes.Decimal) { + final LogicalTypes.Decimal decimalType = (LogicalTypes.Decimal) schema.getLogicalType(); + return DataTypes.DECIMAL(decimalType.getPrecision(), decimalType.getScale()).notNull(); + } + return DataTypes.BYTES().notNull(); + case INT: + // logical date and time type + final org.apache.avro.LogicalType logicalType = schema.getLogicalType(); + if (logicalType == LogicalTypes.date()) { + return DataTypes.DATE().notNull(); + } else if (logicalType == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } + return DataTypes.INT().notNull(); + case LONG: + if (legacyMapping) { + // Avro logical timestamp types to Flink SQL timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis()) { + return DataTypes.TIMESTAMP(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timestampMicros()) { + return DataTypes.TIMESTAMP(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP(9).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMicros()) { + return DataTypes.TIME(6).notNull(); + } + } else { + // Avro logical timestamp types to Flink SQL timestamp types + if (schema.getLogicalType() == LogicalTypes.timestampMillis()) { + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timestampMicros()) { + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(9).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMillis()) { + return DataTypes.TIME(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.timeMicros()) { + return DataTypes.TIME(6).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMillis()) { + return DataTypes.TIMESTAMP(3).notNull(); + } else if (schema.getLogicalType() == LogicalTypes.localTimestampMicros()) { + return DataTypes.TIMESTAMP(6).notNull(); + } else if (schema.getLogicalType() != null + && schema.getLogicalType().getName().equals("local-timestamp-nanos")) { + // Iceberg: Added support for custom nanosecond logical type (FLINK-39251) + return DataTypes.TIMESTAMP(9).notNull(); + } + } + + return DataTypes.BIGINT().notNull(); + case FLOAT: + return DataTypes.FLOAT().notNull(); + case DOUBLE: + return DataTypes.DOUBLE().notNull(); + case BOOLEAN: + return DataTypes.BOOLEAN().notNull(); + case NULL: + return DataTypes.NULL(); + } + throw new IllegalArgumentException("Unsupported Avro type '" + schema.getType() + "'."); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    Use "org.apache.flink.avro.generated.record" as the type name. + * + * @param schema the schema type, usually it should be the top level record type, e.g. not a + * nested type + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType schema) { + return convertToSchema(schema, true); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    Use "org.apache.flink.avro.generated.record" as the type name. + * + * @param schema the schema type, usually it should be the top level record type, e.g. not a + * nested type + * @param legacyTimestampMapping whether to use the legacy timestamp mapping + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType schema, boolean legacyTimestampMapping) { + return convertToSchema( + schema, "org.apache.flink.avro.generated.record", legacyTimestampMapping); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    The "{rowName}_" is used as the nested row type name prefix in order to generate the right + * schema. Nested record type that only differs with type name is still compatible. + * + * @param logicalType logical type + * @param rowName the record name + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema(LogicalType logicalType, String rowName) { + return convertToSchema(logicalType, rowName, true); + } + + /** + * Converts Flink SQL {@link LogicalType} (can be nested) into an Avro schema. + * + *

    The "{rowName}_" is used as the nested row type name prefix in order to generate the right + * schema. Nested record type that only differs with type name is still compatible. + * + * @param logicalType logical type + * @param rowName the record name + * @param legacyTimestampMapping whether to use legal timestamp mapping + * @return Avro's {@link Schema} matching this logical type. + */ + public static Schema convertToSchema( + LogicalType logicalType, String rowName, boolean legacyTimestampMapping) { + int precision; + boolean nullable = logicalType.isNullable(); + switch (logicalType.getTypeRoot()) { + case NULL: + return SchemaBuilder.builder().nullType(); + case BOOLEAN: + Schema bool = SchemaBuilder.builder().booleanType(); + return nullable ? nullableSchema(bool) : bool; + case TINYINT: + case SMALLINT: + case INTEGER: + Schema integer = SchemaBuilder.builder().intType(); + return nullable ? nullableSchema(integer) : integer; + case BIGINT: + Schema bigint = SchemaBuilder.builder().longType(); + return nullable ? nullableSchema(bigint) : bigint; + case FLOAT: + Schema floatSchema = SchemaBuilder.builder().floatType(); + return nullable ? nullableSchema(floatSchema) : floatSchema; + case DOUBLE: + Schema doubleSchema = SchemaBuilder.builder().doubleType(); + return nullable ? nullableSchema(doubleSchema) : doubleSchema; + case CHAR: + case VARCHAR: + Schema str = SchemaBuilder.builder().stringType(); + return nullable ? nullableSchema(str) : str; + case BINARY: + case VARBINARY: + Schema binary = SchemaBuilder.builder().bytesType(); + return nullable ? nullableSchema(binary) : binary; + case TIMESTAMP_WITHOUT_TIME_ZONE: + // use long to represents Timestamp + final TimestampType timestampType = (TimestampType) logicalType; + precision = timestampType.getPrecision(); + org.apache.avro.LogicalType avroLogicalType; + if (legacyTimestampMapping) { + if (precision <= 3) { + avroLogicalType = LogicalTypes.timestampMillis(); + } else { + throw new IllegalArgumentException( + "Avro does not support TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 3."); + } + } else { + if (precision <= 3) { + avroLogicalType = LogicalTypes.localTimestampMillis(); + } else if (precision <= 6) { + avroLogicalType = LogicalTypes.localTimestampMicros(); + } else { + throw new IllegalArgumentException( + "Avro does not support LOCAL TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 6."); + } + } + Schema timestamp = avroLogicalType.addToSchema(SchemaBuilder.builder().longType()); + return nullable ? nullableSchema(timestamp) : timestamp; + case TIMESTAMP_WITH_LOCAL_TIME_ZONE: + if (legacyTimestampMapping) { + throw new UnsupportedOperationException( + "Unsupported to derive Schema for type: " + logicalType); + } else { + final LocalZonedTimestampType localZonedTimestampType = + (LocalZonedTimestampType) logicalType; + precision = localZonedTimestampType.getPrecision(); + if (precision <= 3) { + avroLogicalType = LogicalTypes.timestampMillis(); + } else if (precision <= 6) { + avroLogicalType = LogicalTypes.timestampMicros(); + } else { + throw new IllegalArgumentException( + "Avro does not support TIMESTAMP type " + + "with precision: " + + precision + + ", it only supports precision less than 6."); + } + timestamp = avroLogicalType.addToSchema(SchemaBuilder.builder().longType()); + return nullable ? nullableSchema(timestamp) : timestamp; + } + case DATE: + // use int to represents Date + Schema date = LogicalTypes.date().addToSchema(SchemaBuilder.builder().intType()); + return nullable ? nullableSchema(date) : date; + case TIME_WITHOUT_TIME_ZONE: + precision = ((TimeType) logicalType).getPrecision(); + if (precision > 3) { + throw new IllegalArgumentException( + "Avro does not support TIME type with precision: " + + precision + + ", it only supports precision less than 3."); + } + // use int to represents Time, we only support millisecond when deserialization + Schema time = LogicalTypes.timeMillis().addToSchema(SchemaBuilder.builder().intType()); + return nullable ? nullableSchema(time) : time; + case DECIMAL: + DecimalType decimalType = (DecimalType) logicalType; + // store BigDecimal as byte[] + Schema decimal = + LogicalTypes.decimal(decimalType.getPrecision(), decimalType.getScale()) + .addToSchema(SchemaBuilder.builder().bytesType()); + return nullable ? nullableSchema(decimal) : decimal; + case ROW: + RowType rowType = (RowType) logicalType; + List fieldNames = rowType.getFieldNames(); + // we have to make sure the record name is different in a Schema + SchemaBuilder.FieldAssembler builder = + SchemaBuilder.builder().record(rowName).fields(); + for (int i = 0; i < rowType.getFieldCount(); i++) { + String fieldName = fieldNames.get(i); + LogicalType fieldType = rowType.getTypeAt(i); + SchemaBuilder.GenericDefault fieldBuilder = + builder + .name(fieldName) + .type( + convertToSchema( + fieldType, rowName + "_" + fieldName, legacyTimestampMapping)); + + if (fieldType.isNullable()) { + builder = fieldBuilder.withDefault(null); + } else { + builder = fieldBuilder.noDefault(); + } + } + Schema record = builder.endRecord(); + return nullable ? nullableSchema(record) : record; + case MULTISET: + case MAP: + Schema map = + SchemaBuilder.builder() + .map() + .values(convertToSchema(extractValueTypeToAvroMap(logicalType), rowName)); + return nullable ? nullableSchema(map) : map; + case ARRAY: + ArrayType arrayType = (ArrayType) logicalType; + Schema array = + SchemaBuilder.builder() + .array() + .items(convertToSchema(arrayType.getElementType(), rowName)); + return nullable ? nullableSchema(array) : array; + case RAW: + default: + throw new UnsupportedOperationException( + "Unsupported to derive Schema for type: " + logicalType); + } + } + + public static LogicalType extractValueTypeToAvroMap(LogicalType type) { + LogicalType keyType; + LogicalType valueType; + if (type instanceof MapType) { + MapType mapType = (MapType) type; + keyType = mapType.getKeyType(); + valueType = mapType.getValueType(); + } else { + MultisetType multisetType = (MultisetType) type; + keyType = multisetType.getElementType(); + valueType = new IntType(); + } + if (!keyType.is(LogicalTypeFamily.CHARACTER_STRING)) { + throw new UnsupportedOperationException( + "Avro format doesn't support non-string as key type of map. " + + "The key type is: " + + keyType.asSummaryString()); + } + return valueType; + } + + /** Returns schema with nullable true. */ + private static Schema nullableSchema(Schema schema) { + return schema.isNullable() + ? schema + : Schema.createUnion(SchemaBuilder.builder().nullType(), schema); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java index 2fce5e0b3e80..377413c3c3ab 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFiles.java @@ -53,6 +53,11 @@ public class DeleteOrphanFiles { ScanContext.builder().streaming(true).project(FILE_PATH_SCHEMA).build(); private static final Splitter COMMA_SPLITTER = Splitter.on(","); + static final Map DEFAULT_EQUAL_SCHEMES = + ImmutableMap.of( + "s3n", "s3", + "s3a", "s3"); + @Internal public static final OutputTag ERROR_STREAM = new OutputTag<>("error-stream", TypeInformation.of(Exception.class)); @@ -79,12 +84,8 @@ public static class Builder extends MaintenanceTaskBuilder equalSchemes = - Maps.newHashMap( - ImmutableMap.of( - "s3n", "s3", - "s3a", "s3")); + private boolean usePrefixListing = true; + private Map equalSchemes = Maps.newHashMap(DEFAULT_EQUAL_SCHEMES); private final Map equalAuthorities = Maps.newHashMap(); private PrefixMismatchMode prefixMismatchMode = PrefixMismatchMode.ERROR; @@ -189,78 +190,92 @@ public Builder deleteBatchSize(int newDeleteBatchSize) { return this; } + public Builder config(DeleteOrphanFilesConfig deleteOrphanFilesConfig) { + return this.scheduleOnInterval( + Duration.ofSeconds(deleteOrphanFilesConfig.scheduleOnIntervalSecond())) + .minAge(Duration.ofSeconds(deleteOrphanFilesConfig.minAgeSeconds())) + .deleteBatchSize(deleteOrphanFilesConfig.deleteBatchSize()) + .usePrefixListing(deleteOrphanFilesConfig.usePrefixListing()) + .prefixMismatchMode(deleteOrphanFilesConfig.prefixMismatchMode()) + .location(deleteOrphanFilesConfig.location()) + .planningWorkerPoolSize(deleteOrphanFilesConfig.planningWorkerPoolSize()) + .equalSchemes(deleteOrphanFilesConfig.equalSchemes()) + .equalAuthorities(deleteOrphanFilesConfig.equalAuthorities()); + } + @Override DataStream append(DataStream trigger) { tableLoader().open(); // Collect all data files SingleOutputStreamOperator splits = - trigger - .process( - new MetadataTablePlanner( - taskName(), - index(), - tableLoader(), - FILE_PATH_SCAN_CONTEXT, - MetadataTableType.ALL_FILES, - planningWorkerPoolSize)) - .name(operatorName(PLANNER_TASK_NAME)) - .uid(PLANNER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new MetadataTablePlanner( + taskName(), + index(), + tableLoader(), + FILE_PATH_SCAN_CONTEXT, + MetadataTableType.ALL_FILES, + planningWorkerPoolSize)) + .name(operatorName(PLANNER_TASK_NAME)) + .uid(PLANNER_TASK_NAME + uidSuffix()) + .forceNonParallel()); // Read the records and get all data files SingleOutputStreamOperator tableDataFiles = - splits - .rebalance() - .process( - new FileNameReader( - taskName(), - index(), - tableLoader(), - FILE_PATH_SCHEMA, - FILE_PATH_SCAN_CONTEXT, - MetadataTableType.ALL_FILES)) - .name(operatorName(READER_TASK_NAME)) - .uid(READER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + splits + .rebalance() + .process( + new FileNameReader( + taskName(), + index(), + tableLoader(), + FILE_PATH_SCHEMA, + FILE_PATH_SCAN_CONTEXT, + MetadataTableType.ALL_FILES)) + .name(operatorName(READER_TASK_NAME)) + .uid(READER_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); // Collect all meta data files SingleOutputStreamOperator tableMetadataFiles = - trigger - .process(new ListMetadataFiles(taskName(), index(), tableLoader())) - .name(operatorName(METADATA_FILES_TASK_NAME)) - .uid(METADATA_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process(new ListMetadataFiles(taskName(), index(), tableLoader())) + .name(operatorName(METADATA_FILES_TASK_NAME)) + .uid(METADATA_FILES_TASK_NAME + uidSuffix()) + .forceNonParallel()); // List the all file system files SingleOutputStreamOperator allFsFiles = - trigger - .process( - new ListFileSystemFiles( - taskName(), - index(), - tableLoader(), - location, - minAge.toMillis(), - usePrefixListing)) - .name(operatorName(FILESYSTEM_FILES_TASK_NAME)) - .uid(FILESYSTEM_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new ListFileSystemFiles( + taskName(), + index(), + tableLoader(), + location, + minAge.toMillis(), + usePrefixListing)) + .name(operatorName(FILESYSTEM_FILES_TASK_NAME)) + .uid(FILESYSTEM_FILES_TASK_NAME + uidSuffix()) + .forceNonParallel()); SingleOutputStreamOperator filesToDelete = - tableMetadataFiles - .union(tableDataFiles) - .keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities)) - .connect(allFsFiles.keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities))) - .process(new OrphanFilesDetector(prefixMismatchMode, equalSchemes, equalAuthorities)) - .slotSharingGroup(slotSharingGroup()) - .name(operatorName(FILTER_FILES_TASK_NAME)) - .uid(FILTER_FILES_TASK_NAME + uidSuffix()) - .setParallelism(parallelism()); + setSlotSharingGroup( + tableMetadataFiles + .union(tableDataFiles) + .keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities)) + .connect(allFsFiles.keyBy(new FileUriKeySelector(equalSchemes, equalAuthorities))) + .process( + new OrphanFilesDetector(prefixMismatchMode, equalSchemes, equalAuthorities)) + .name(operatorName(FILTER_FILES_TASK_NAME)) + .uid(FILTER_FILES_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); DataStream errorStream = tableMetadataFiles @@ -273,38 +288,38 @@ DataStream append(DataStream trigger) { // Stop deleting the files if there is an error SingleOutputStreamOperator filesOrSkip = - filesToDelete - .connect(errorStream) - .transform( - operatorName(SKIP_ON_ERROR_TASK_NAME), - TypeInformation.of(String.class), - new SkipOnError()) - .uid(SKIP_ON_ERROR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + filesToDelete + .connect(errorStream) + .transform( + operatorName(SKIP_ON_ERROR_TASK_NAME), + TypeInformation.of(String.class), + new SkipOnError()) + .uid(SKIP_ON_ERROR_TASK_NAME + uidSuffix()) + .forceNonParallel()); // delete the files - filesOrSkip - .rebalance() - .transform( - operatorName(DELETE_FILES_TASK_NAME), - TypeInformation.of(Void.class), - new DeleteFilesProcessor( - tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) - .uid(DELETE_FILES_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + filesOrSkip + .rebalance() + .transform( + operatorName(DELETE_FILES_TASK_NAME), + TypeInformation.of(Void.class), + new DeleteFilesProcessor( + tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) + .uid(DELETE_FILES_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); // Ignore the file deletion result and return the DataStream directly - return trigger - .connect(errorStream) - .transform( - operatorName(AGGREGATOR_TASK_NAME), - TypeInformation.of(TaskResult.class), - new TaskResultAggregator(tableName(), taskName(), index())) - .uid(AGGREGATOR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .connect(errorStream) + .transform( + operatorName(AGGREGATOR_TASK_NAME), + TypeInformation.of(TaskResult.class), + new TaskResultAggregator(tableName(), taskName(), index())) + .uid(AGGREGATOR_TASK_NAME + uidSuffix()) + .forceNonParallel()); } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java new file mode 100644 index 000000000000..af347357815c --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/DeleteOrphanFilesConfig.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import java.util.List; +import java.util.Map; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles.PrefixMismatchMode; +import org.apache.iceberg.flink.FlinkConfParser; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; + +public class DeleteOrphanFilesConfig { + public static final String PREFIX = FlinkMaintenanceConfig.PREFIX + "delete-orphan-files."; + + private static final Splitter COMMA_SPLITTER = Splitter.on(","); + private static final Splitter EQUALS_SPLITTER = Splitter.on("=").limit(2); + + public static final String SCHEDULE_ON_INTERVAL_SECOND = PREFIX + "schedule.interval-second"; + public static final ConfigOption SCHEDULE_ON_INTERVAL_SECOND_OPTION = + ConfigOptions.key(SCHEDULE_ON_INTERVAL_SECOND) + .longType() + .defaultValue(60 * 60L) // Default is 1 hour + .withDescription( + "The time interval (in seconds) between two consecutive delete orphan files operations."); + + public static final String MIN_AGE_SECONDS = PREFIX + "min-age-seconds"; + public static final ConfigOption MIN_AGE_SECONDS_OPTION = + ConfigOptions.key(MIN_AGE_SECONDS) + .longType() + .defaultValue(3L * 24 * 60 * 60) // Default is 3 days + .withDescription( + "The minimum age (in seconds) of files to be considered for deletion. " + + "Files newer than this will not be removed."); + + public static final String DELETE_BATCH_SIZE = PREFIX + "delete-batch-size"; + public static final ConfigOption DELETE_BATCH_SIZE_OPTION = + ConfigOptions.key(DELETE_BATCH_SIZE) + .intType() + .defaultValue(1000) + .withDescription("The batch size used for deleting orphan files."); + + public static final String LOCATION = PREFIX + "location"; + public static final ConfigOption LOCATION_OPTION = + ConfigOptions.key(LOCATION) + .stringType() + .noDefaultValue() + .withDescription( + "The location to start recursive listing of candidate files for removal. " + + "By default, the table location is used."); + + public static final String USE_PREFIX_LISTING = PREFIX + "use-prefix-listing"; + public static final ConfigOption USE_PREFIX_LISTING_OPTION = + ConfigOptions.key(USE_PREFIX_LISTING) + .booleanType() + .defaultValue(true) + .withDescription( + "Whether to use prefix listing when listing files from the file system."); + + public static final String PLANNING_WORKER_POOL_SIZE = PREFIX + "planning-worker-pool-size"; + public static final ConfigOption PLANNING_WORKER_POOL_SIZE_OPTION = + ConfigOptions.key(PLANNING_WORKER_POOL_SIZE) + .intType() + .noDefaultValue() + .withDescription( + "The worker pool size used for planning the scan of the ALL_FILES table. " + + "If not set, the shared worker pool is used."); + + public static final String EQUAL_SCHEMES = PREFIX + "equal-schemes"; + public static final ConfigOption EQUAL_SCHEMES_OPTION = + ConfigOptions.key(EQUAL_SCHEMES) + .stringType() + .noDefaultValue() + .withDescription( + "Schemes that should be considered equal, in the format 'scheme1=scheme2,scheme3=scheme4'."); + + public static final String EQUAL_AUTHORITIES = PREFIX + "equal-authorities"; + public static final ConfigOption EQUAL_AUTHORITIES_OPTION = + ConfigOptions.key(EQUAL_AUTHORITIES) + .stringType() + .noDefaultValue() + .withDescription( + "Authorities that should be considered equal, in the format 'auth1=auth2,auth3=auth4'."); + + public static final String PREFIX_MISMATCH_MODE = PREFIX + "prefix-mismatch-mode"; + public static final ConfigOption PREFIX_MISMATCH_MODE_OPTION = + ConfigOptions.key(PREFIX_MISMATCH_MODE) + .stringType() + .defaultValue(PrefixMismatchMode.ERROR.name()) + .withDescription( + "Action behavior when location prefixes (schemes/authorities) mismatch. " + + "Valid values: ERROR, IGNORE, DELETE."); + + private final FlinkConfParser confParser; + + public DeleteOrphanFilesConfig( + Table table, Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(table, writeOptions, readableConfig); + } + + public long scheduleOnIntervalSecond() { + return confParser + .longConf() + .option(SCHEDULE_ON_INTERVAL_SECOND) + .flinkConfig(SCHEDULE_ON_INTERVAL_SECOND_OPTION) + .defaultValue(SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()) + .parse(); + } + + public long minAgeSeconds() { + return confParser + .longConf() + .option(MIN_AGE_SECONDS) + .flinkConfig(MIN_AGE_SECONDS_OPTION) + .defaultValue(MIN_AGE_SECONDS_OPTION.defaultValue()) + .parse(); + } + + public int deleteBatchSize() { + return confParser + .intConf() + .option(DELETE_BATCH_SIZE) + .flinkConfig(DELETE_BATCH_SIZE_OPTION) + .defaultValue(DELETE_BATCH_SIZE_OPTION.defaultValue()) + .parse(); + } + + public String location() { + return confParser.stringConf().option(LOCATION).flinkConfig(LOCATION_OPTION).parseOptional(); + } + + public boolean usePrefixListing() { + return confParser + .booleanConf() + .option(USE_PREFIX_LISTING) + .flinkConfig(USE_PREFIX_LISTING_OPTION) + .defaultValue(USE_PREFIX_LISTING_OPTION.defaultValue()) + .parse(); + } + + public Integer planningWorkerPoolSize() { + return confParser + .intConf() + .option(PLANNING_WORKER_POOL_SIZE) + .flinkConfig(PLANNING_WORKER_POOL_SIZE_OPTION) + .defaultValue(ThreadPools.WORKER_THREAD_POOL_SIZE) + .parse(); + } + + public Map equalSchemes() { + String equalSchemes = + confParser + .stringConf() + .option(EQUAL_SCHEMES) + .flinkConfig(EQUAL_SCHEMES_OPTION) + .parseOptional(); + + return equalSchemes != null + ? parseKeyValuePairs(equalSchemes) + : Maps.newHashMap(DeleteOrphanFiles.DEFAULT_EQUAL_SCHEMES); + } + + public Map equalAuthorities() { + String equalAuthorities = + confParser + .stringConf() + .option(EQUAL_AUTHORITIES) + .flinkConfig(EQUAL_AUTHORITIES_OPTION) + .parseOptional(); + + return equalAuthorities != null ? parseKeyValuePairs(equalAuthorities) : Map.of(); + } + + public PrefixMismatchMode prefixMismatchMode() { + String value = + confParser + .stringConf() + .option(PREFIX_MISMATCH_MODE) + .flinkConfig(PREFIX_MISMATCH_MODE_OPTION) + .defaultValue(PREFIX_MISMATCH_MODE_OPTION.defaultValue()) + .parse(); + return PrefixMismatchMode.valueOf(value); + } + + private static Map parseKeyValuePairs(String value) { + Map result = Maps.newHashMap(); + for (String pair : COMMA_SPLITTER.split(value)) { + List parts = EQUALS_SPLITTER.splitToList(pair); + Preconditions.checkArgument(parts.size() == 2, "Invalid key-value pair: %s", pair); + result.put(parts.get(0).trim(), parts.get(1).trim()); + } + + return result; + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java index 628a91141474..7c524175c42b 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshots.java @@ -19,6 +19,7 @@ package org.apache.iceberg.flink.maintenance.api; import java.time.Duration; +import java.util.Optional; import org.apache.flink.api.common.typeinfo.TypeInformation; import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; @@ -107,35 +108,48 @@ public Builder cleanExpiredMetadata(boolean newCleanExpiredMetadata) { return this; } + public Builder config(ExpireSnapshotsConfig expireSnapshotsConfig) { + return this.scheduleOnCommitCount(expireSnapshotsConfig.scheduleOnCommitCount()) + .scheduleOnInterval(Duration.ofSeconds(expireSnapshotsConfig.scheduleOnIntervalSecond())) + .deleteBatchSize(expireSnapshotsConfig.deleteBatchSize()) + .maxSnapshotAge( + Optional.ofNullable(expireSnapshotsConfig.maxSnapshotAgeSeconds()) + .map(Duration::ofSeconds) + .orElse(null)) + .retainLast(expireSnapshotsConfig.retainLast()) + .cleanExpiredMetadata(expireSnapshotsConfig.cleanExpiredMetadata()) + .planningWorkerPoolSize(expireSnapshotsConfig.planningWorkerPoolSize()); + } + @Override DataStream append(DataStream trigger) { Preconditions.checkNotNull(tableLoader(), "TableLoader should not be null"); SingleOutputStreamOperator result = - trigger - .process( - new ExpireSnapshotsProcessor( - tableLoader(), - maxSnapshotAge == null ? null : maxSnapshotAge.toMillis(), - numSnapshots, - planningWorkerPoolSize, - cleanExpiredMetadata)) - .name(operatorName(EXECUTOR_OPERATOR_NAME)) - .uid(EXECUTOR_OPERATOR_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); - - result - .getSideOutput(ExpireSnapshotsProcessor.DELETE_STREAM) - .rebalance() - .transform( - operatorName(DELETE_FILES_OPERATOR_NAME), - TypeInformation.of(Void.class), - new DeleteFilesProcessor( - tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) - .uid(DELETE_FILES_OPERATOR_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + trigger + .process( + new ExpireSnapshotsProcessor( + tableLoader(), + maxSnapshotAge == null ? null : maxSnapshotAge.toMillis(), + numSnapshots, + planningWorkerPoolSize, + cleanExpiredMetadata)) + .name(operatorName(EXECUTOR_OPERATOR_NAME)) + .uid(EXECUTOR_OPERATOR_NAME + uidSuffix()) + .forceNonParallel()); + + setSlotSharingGroup( + result + .getSideOutput(ExpireSnapshotsProcessor.DELETE_STREAM) + .rebalance() + .transform( + operatorName(DELETE_FILES_OPERATOR_NAME), + TypeInformation.of(Void.class), + new DeleteFilesProcessor( + tableLoader().loadTable(), taskName(), index(), deleteBatchSize)) + .uid(DELETE_FILES_OPERATOR_NAME + uidSuffix()) + .setParallelism(parallelism())); // Ignore the file deletion result and return the DataStream directly return result; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java new file mode 100644 index 000000000000..13436975e182 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ExpireSnapshotsConfig.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import java.util.Map; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.FlinkConfParser; +import org.apache.iceberg.util.ThreadPools; + +public class ExpireSnapshotsConfig { + public static final String PREFIX = FlinkMaintenanceConfig.PREFIX + "expire-snapshots."; + + public static final String SCHEDULE_ON_COMMIT_COUNT = PREFIX + "schedule.commit-count"; + public static final ConfigOption SCHEDULE_ON_COMMIT_COUNT_OPTION = + ConfigOptions.key(SCHEDULE_ON_COMMIT_COUNT) + .intType() + .defaultValue(10) + .withDescription( + "The number of commits after which to trigger a new expire snapshots operation."); + + public static final String SCHEDULE_ON_INTERVAL_SECOND = PREFIX + "schedule.interval-second"; + public static final ConfigOption SCHEDULE_ON_INTERVAL_SECOND_OPTION = + ConfigOptions.key(SCHEDULE_ON_INTERVAL_SECOND) + .longType() + .defaultValue(60 * 60L) // Default is 1 hour + .withDescription( + "The time interval (in seconds) between two consecutive expire snapshots operations."); + + public static final String MAX_SNAPSHOT_AGE_SECONDS = PREFIX + "max-snapshot-age-seconds"; + public static final ConfigOption MAX_SNAPSHOT_AGE_SECONDS_OPTION = + ConfigOptions.key(MAX_SNAPSHOT_AGE_SECONDS) + .longType() + .noDefaultValue() + .withDescription( + "The maximum age (in seconds) of snapshots to retain. " + + "Snapshots older than this will be expired."); + + public static final String RETAIN_LAST = PREFIX + "retain-last"; + public static final ConfigOption RETAIN_LAST_OPTION = + ConfigOptions.key(RETAIN_LAST) + .intType() + .noDefaultValue() + .withDescription("The minimum number of snapshots to retain."); + + public static final String DELETE_BATCH_SIZE = PREFIX + "delete-batch-size"; + public static final ConfigOption DELETE_BATCH_SIZE_OPTION = + ConfigOptions.key(DELETE_BATCH_SIZE) + .intType() + .defaultValue(1000) + .withDescription("The batch size used for deleting expired files."); + + public static final String CLEAN_EXPIRED_METADATA = PREFIX + "clean-expired-metadata"; + public static final ConfigOption CLEAN_EXPIRED_METADATA_OPTION = + ConfigOptions.key(CLEAN_EXPIRED_METADATA) + .booleanType() + .defaultValue(true) + .withDescription( + "Whether to clean expired metadata such as partition specs and schemas."); + + public static final String PLANNING_WORKER_POOL_SIZE = PREFIX + "planning-worker-pool-size"; + public static final ConfigOption PLANNING_WORKER_POOL_SIZE_OPTION = + ConfigOptions.key(PLANNING_WORKER_POOL_SIZE) + .intType() + .noDefaultValue() + .withDescription( + "The worker pool size used to calculate the files to delete. " + + "If not set, the shared worker pool is used."); + + private final FlinkConfParser confParser; + + public ExpireSnapshotsConfig( + Table table, Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(table, writeOptions, readableConfig); + } + + public int scheduleOnCommitCount() { + return confParser + .intConf() + .option(SCHEDULE_ON_COMMIT_COUNT) + .flinkConfig(SCHEDULE_ON_COMMIT_COUNT_OPTION) + .defaultValue(SCHEDULE_ON_COMMIT_COUNT_OPTION.defaultValue()) + .parse(); + } + + public long scheduleOnIntervalSecond() { + return confParser + .longConf() + .option(SCHEDULE_ON_INTERVAL_SECOND) + .flinkConfig(SCHEDULE_ON_INTERVAL_SECOND_OPTION) + .defaultValue(SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()) + .parse(); + } + + public Long maxSnapshotAgeSeconds() { + return confParser + .longConf() + .option(MAX_SNAPSHOT_AGE_SECONDS) + .flinkConfig(MAX_SNAPSHOT_AGE_SECONDS_OPTION) + .parseOptional(); + } + + public Integer retainLast() { + return confParser.intConf().option(RETAIN_LAST).flinkConfig(RETAIN_LAST_OPTION).parseOptional(); + } + + public int deleteBatchSize() { + return confParser + .intConf() + .option(DELETE_BATCH_SIZE) + .flinkConfig(DELETE_BATCH_SIZE_OPTION) + .defaultValue(DELETE_BATCH_SIZE_OPTION.defaultValue()) + .parse(); + } + + public Boolean cleanExpiredMetadata() { + return confParser + .booleanConf() + .option(CLEAN_EXPIRED_METADATA) + .flinkConfig(CLEAN_EXPIRED_METADATA_OPTION) + .defaultValue(CLEAN_EXPIRED_METADATA_OPTION.defaultValue()) + .parse(); + } + + public Integer planningWorkerPoolSize() { + return confParser + .intConf() + .option(PLANNING_WORKER_POOL_SIZE) + .flinkConfig(PLANNING_WORKER_POOL_SIZE_OPTION) + .defaultValue(ThreadPools.WORKER_THREAD_POOL_SIZE) + .parse(); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java index 0c88abf82099..34d7330c5913 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/FlinkMaintenanceConfig.java @@ -23,7 +23,6 @@ import org.apache.flink.configuration.ConfigOption; import org.apache.flink.configuration.ConfigOptions; import org.apache.flink.configuration.ReadableConfig; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.iceberg.Table; import org.apache.iceberg.flink.FlinkConfParser; @@ -60,7 +59,7 @@ public class FlinkMaintenanceConfig { public static final ConfigOption SLOT_SHARING_GROUP_OPTION = ConfigOptions.key(SLOT_SHARING_GROUP) .stringType() - .defaultValue(StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP) + .noDefaultValue() .withDescription( "The slot sharing group for maintenance tasks. " + "Determines which operators can share slots in the Flink execution environment."); @@ -114,14 +113,21 @@ public String slotSharingGroup() { .stringConf() .option(SLOT_SHARING_GROUP) .flinkConfig(SLOT_SHARING_GROUP_OPTION) - .defaultValue(SLOT_SHARING_GROUP_OPTION.defaultValue()) - .parse(); + .parseOptional(); } public RewriteDataFilesConfig createRewriteDataFilesConfig() { return new RewriteDataFilesConfig(table, writeProperties, readableConfig); } + public ExpireSnapshotsConfig createExpireSnapshotsConfig() { + return new ExpireSnapshotsConfig(table, writeProperties, readableConfig); + } + + public DeleteOrphanFilesConfig createDeleteOrphanFilesConfig() { + return new DeleteOrphanFilesConfig(table, writeProperties, readableConfig); + } + public LockConfig createLockConfig() { return new LockConfig(table, writeProperties, readableConfig); } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java index f68605accc57..30e95b1edba0 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/JdbcLockFactory.java @@ -260,10 +260,6 @@ public void unlock() { this, instanceId, count); - } catch (SQLException e) { - // SQL exception happened when deleting lock information - throw new UncheckedSQLException( - e, "Failed to delete %s lock with instanceId %s", this, instanceId); } return null; @@ -298,9 +294,6 @@ private String instanceId() { return null; } } - } catch (SQLException e) { - // SQL exception happened when getting lock information - throw new UncheckedSQLException(e, "Failed to get lock information for %s", type); } }); } catch (InterruptedException e) { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java index 5d5f17b0a80e..04df6106bd4c 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/MaintenanceTaskBuilder.java @@ -226,4 +226,8 @@ DataStream append( return append(sourceStream); } + + SingleOutputStreamOperator setSlotSharingGroup(SingleOutputStreamOperator operator) { + return slotSharingGroup == null ? operator : operator.slotSharingGroup(slotSharingGroup); + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java index bedf70725a63..be77fda23d29 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/RewriteDataFiles.java @@ -24,6 +24,8 @@ import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.util.function.SerializableSupplier; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.actions.BinPackRewriteFilePlanner; import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; import org.apache.iceberg.expressions.Expression; @@ -58,7 +60,8 @@ public static class Builder extends MaintenanceTaskBuilder rewriteOptions = Maps.newHashMapWithExpectedSize(6); private long maxRewriteBytes = Long.MAX_VALUE; - private Expression filter = Expressions.alwaysTrue(); + private SerializableSupplier filterSupplier = Expressions::alwaysTrue; + private String branch = SnapshotRef.MAIN_BRANCH; @Override String maintenanceTaskName() { @@ -181,6 +184,19 @@ public Builder maxFileGroupSizeBytes(long maxFileGroupSizeBytes) { return this; } + /** + * Configures the max file count for rewriting. See {@link + * SizeBasedFileRewritePlanner#MAX_FILE_GROUP_INPUT_FILES} for more details. + * + * @param maxFileGroupInputFiles file count for rewrite + */ + public Builder maxFileGroupInputFiles(long maxFileGroupInputFiles) { + this.rewriteOptions.put( + SizeBasedFileRewritePlanner.MAX_FILE_GROUP_INPUT_FILES, + String.valueOf(maxFileGroupInputFiles)); + return this; + } + /** * Configures max files to rewrite. See {@link BinPackRewriteFilePlanner#MAX_FILES_TO_REWRITE} * for more details. @@ -199,9 +215,44 @@ public Builder maxFilesToRewrite(int maxFilesToRewrite) { * * @param newFilter the filter expression to apply * @return this for method chaining + * @deprecated will be removed in 1.12.0. Use {@link #filter(SerializableSupplier)} instead */ + @Deprecated public Builder filter(Expression newFilter) { - this.filter = newFilter; + this.filterSupplier = () -> newFilter; + return this; + } + + /** + * A user-provided supplier of a filter expression that determines which files are considered by + * the rewrite strategy. + * + *

    The supplier is evaluated by the planner on every compaction trigger, allowing a fresh + * filter to be produced for each compaction run. + * + *

    This is particularly useful for time-relative filters. For example, a supplier such as + * {@code () -> Expressions.greaterThanOrEqual("ts", + * LocalDateTime.now(ZoneOffset.UTC).minus(Duration.ofDays(3)).toString())} ensures that each + * compaction rewrites files from the last 3 days relative to the time the compaction is + * planned, rather than relative to when the job was started. + * + * @param newFilterSupplier the supplier providing the filter expression to apply + * @return this for method chaining + */ + public Builder filter(SerializableSupplier newFilterSupplier) { + this.filterSupplier = newFilterSupplier; + return this; + } + + /** + * Sets the branch to compact. When set, the planner reads from the branch's snapshot and + * commits are made to this branch. + * + * @param newBranch the branch name + * @return this for method chaining + */ + public Builder branch(String newBranch) { + this.branch = newBranch; return this; } @@ -239,56 +290,58 @@ public Builder config(RewriteDataFilesConfig rewriteDataFilesConfig) { @Override DataStream append(DataStream trigger) { SingleOutputStreamOperator planned = - trigger - .process( - new DataFileRewritePlanner( - tableName(), - taskName(), - index(), - tableLoader(), - partialProgressEnabled ? partialProgressMaxCommits : 1, - maxRewriteBytes, - rewriteOptions, - filter)) - .name(operatorName(PLANNER_TASK_NAME)) - .uid(PLANNER_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + trigger + .process( + new DataFileRewritePlanner( + tableName(), + taskName(), + index(), + tableLoader(), + partialProgressEnabled ? partialProgressMaxCommits : 1, + maxRewriteBytes, + rewriteOptions, + filterSupplier, + branch)) + .name(operatorName(PLANNER_TASK_NAME)) + .uid(PLANNER_TASK_NAME + uidSuffix()) + .forceNonParallel()); SingleOutputStreamOperator rewritten = - planned - .rebalance() - .process(new DataFileRewriteRunner(tableName(), taskName(), index())) - .name(operatorName(REWRITE_TASK_NAME)) - .uid(REWRITE_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .setParallelism(parallelism()); + setSlotSharingGroup( + planned + .rebalance() + .process(new DataFileRewriteRunner(tableName(), taskName(), index())) + .name(operatorName(REWRITE_TASK_NAME)) + .uid(REWRITE_TASK_NAME + uidSuffix()) + .setParallelism(parallelism())); SingleOutputStreamOperator updated = - rewritten - .transform( - operatorName(COMMIT_TASK_NAME), - TypeInformation.of(Trigger.class), - new DataFileRewriteCommitter(tableName(), taskName(), index(), tableLoader())) - .uid(COMMIT_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + setSlotSharingGroup( + rewritten + .transform( + operatorName(COMMIT_TASK_NAME), + TypeInformation.of(Trigger.class), + new DataFileRewriteCommitter( + tableName(), taskName(), index(), tableLoader(), branch)) + .uid(COMMIT_TASK_NAME + uidSuffix()) + .forceNonParallel()); - return trigger - .union(updated) - .connect( - planned - .getSideOutput(TaskResultAggregator.ERROR_STREAM) - .union( - rewritten.getSideOutput(TaskResultAggregator.ERROR_STREAM), - updated.getSideOutput(TaskResultAggregator.ERROR_STREAM))) - .transform( - operatorName(AGGREGATOR_TASK_NAME), - TypeInformation.of(TaskResult.class), - new TaskResultAggregator(tableName(), taskName(), index())) - .uid(AGGREGATOR_TASK_NAME + uidSuffix()) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .union(updated) + .connect( + planned + .getSideOutput(TaskResultAggregator.ERROR_STREAM) + .union( + rewritten.getSideOutput(TaskResultAggregator.ERROR_STREAM), + updated.getSideOutput(TaskResultAggregator.ERROR_STREAM))) + .transform( + operatorName(AGGREGATOR_TASK_NAME), + TypeInformation.of(TaskResult.class), + new TaskResultAggregator(tableName(), taskName(), index())) + .uid(AGGREGATOR_TASK_NAME + uidSuffix()) + .forceNonParallel()); } } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java index 1a2b0607dd1e..025a6d17c023 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/TableMaintenance.java @@ -20,9 +20,11 @@ import java.io.IOException; import java.time.Duration; +import java.util.Collection; import java.util.List; import java.util.Locale; import java.util.UUID; +import javax.annotation.Nullable; import org.apache.flink.annotation.Internal; import org.apache.flink.api.common.ExecutionConfig; import org.apache.flink.api.common.eventtime.TimestampAssigner; @@ -40,13 +42,14 @@ import org.apache.flink.streaming.api.datastream.DataStreamUtils; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.iceberg.flink.TableLoader; import org.apache.iceberg.flink.maintenance.operator.LockRemover; +import org.apache.iceberg.flink.maintenance.operator.LockRemoverOperatorFactory; import org.apache.iceberg.flink.maintenance.operator.MonitorSource; import org.apache.iceberg.flink.maintenance.operator.TableChange; import org.apache.iceberg.flink.maintenance.operator.TriggerEvaluator; import org.apache.iceberg.flink.maintenance.operator.TriggerManager; +import org.apache.iceberg.flink.maintenance.operator.TriggerManagerOperatorFactory; import org.apache.iceberg.flink.sink.IcebergSink; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -71,39 +74,77 @@ private TableMaintenance() {} * * @param changeStream the table changes * @param tableLoader used for accessing the table - * @param lockFactory used for preventing concurrent task runs + * @param lockFactory used for preventing concurrent task runs, if null, use coordination lock. * @return builder for the maintenance stream + * @deprecated since 1.12.0, will be removed in 2.0.0. Use {@link #forChangeStream(DataStream, + * TableLoader)} instead. */ + @Deprecated @Internal public static Builder forChangeStream( DataStream changeStream, TableLoader tableLoader, - TriggerLockFactory lockFactory) { + @Nullable TriggerLockFactory lockFactory) { Preconditions.checkNotNull(changeStream, "The change stream should not be null"); Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); - Preconditions.checkNotNull(lockFactory, "LockFactory should not be null"); return new Builder(null, changeStream, tableLoader, lockFactory); } + /** + * Use when the change stream is already provided, like in the {@link + * IcebergSink#addPostCommitTopology(DataStream)}. + * + * @param changeStream the table changes + * @param tableLoader used for accessing the table + * @return builder for the maintenance stream + */ + @Internal + public static Builder forChangeStream( + DataStream changeStream, TableLoader tableLoader) { + Preconditions.checkNotNull(changeStream, "The change stream should not be null"); + Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); + + return new Builder(null, changeStream, tableLoader, null); + } + /** * Use this for standalone maintenance job. It creates a monitor source that detect table changes * and build the maintenance pipelines afterwards. * * @param env used to register the monitor source * @param tableLoader used for accessing the table - * @param lockFactory used for preventing concurrent task runs + * @param lockFactory used for preventing concurrent task runs. If null, use coordination lock. * @return builder for the maintenance stream + * @deprecated since 1.12.0, will be removed in 2.0.0. Use {@link + * #forTable(StreamExecutionEnvironment, TableLoader)} instead. */ + @Deprecated public static Builder forTable( - StreamExecutionEnvironment env, TableLoader tableLoader, TriggerLockFactory lockFactory) { + StreamExecutionEnvironment env, + TableLoader tableLoader, + @Nullable TriggerLockFactory lockFactory) { Preconditions.checkNotNull(env, "StreamExecutionEnvironment should not be null"); Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); - Preconditions.checkNotNull(lockFactory, "LockFactory should not be null"); return new Builder(env, null, tableLoader, lockFactory); } + /** + * Use this for standalone maintenance job. It creates a monitor source that detect table changes + * and build the maintenance pipelines afterwards. But use coordination lock default. + * + * @param env used to register the monitor source + * @param tableLoader used for accessing the table + * @return builder for the maintenance stream + */ + public static Builder forTable(StreamExecutionEnvironment env, TableLoader tableLoader) { + Preconditions.checkNotNull(env, "StreamExecutionEnvironment should not be null"); + Preconditions.checkNotNull(tableLoader, "TableLoader should not be null"); + + return new Builder(env, null, tableLoader, null); + } + public static class Builder { private final StreamExecutionEnvironment env; private final DataStream inputStream; @@ -112,7 +153,7 @@ public static class Builder { private final TriggerLockFactory lockFactory; private String uidSuffix = "TableMaintenance-" + UUID.randomUUID(); - private String slotSharingGroup = StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP; + private String slotSharingGroup = null; private Duration rateLimit = Duration.ofSeconds(RATE_LIMIT_SECOND_DEFAULT); private Duration lockCheckDelay = Duration.ofSeconds(LOCK_CHECK_DELAY_SECOND_DEFAULT); private int parallelism = ExecutionConfig.PARALLELISM_DEFAULT; @@ -211,6 +252,17 @@ public Builder add(MaintenanceTaskBuilder task) { return this; } + /** + * Adds multiple tasks with the given schedules. + * + * @param tasks to add + */ + public Builder add(Collection> tasks) { + Preconditions.checkNotNull(tasks, "Tasks collection should not be null"); + taskBuilders.addAll(tasks); + return this; + } + /** Builds the task graph for the maintenance tasks. */ public void append() throws IOException { Preconditions.checkArgument(!taskBuilders.isEmpty(), "Provide at least one task"); @@ -226,38 +278,60 @@ public void append() throws IOException { try (TableLoader loader = tableLoader.clone()) { loader.open(); String tableName = loader.loadTable().name(); - DataStream triggers = - DataStreamUtils.reinterpretAsKeyedStream( - changeStream(tableName, loader), unused -> true) - .process( - new TriggerManager( - loader, - lockFactory, - taskNames, - evaluators, - rateLimit.toMillis(), - lockCheckDelay.toMillis())) - .name(TRIGGER_MANAGER_OPERATOR_NAME) - .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel() - .assignTimestampsAndWatermarks(new PunctuatedWatermarkStrategy()) - .name(WATERMARK_ASSIGNER_OPERATOR_NAME) - .uid(WATERMARK_ASSIGNER_OPERATOR_NAME + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel(); + DataStream triggers; + if (lockFactory == null) { + triggers = + setSlotSharingGroup( + DataStreamUtils.reinterpretAsKeyedStream( + changeStream(tableName, loader), unused -> true) + .transform( + TRIGGER_MANAGER_OPERATOR_NAME, + TypeInformation.of(Trigger.class), + new TriggerManagerOperatorFactory( + tableName, + taskNames, + evaluators, + rateLimit.toMillis(), + lockCheckDelay.toMillis())) + .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); + } else { + triggers = + setSlotSharingGroup( + DataStreamUtils.reinterpretAsKeyedStream( + changeStream(tableName, loader), unused -> true) + .process( + new TriggerManager( + loader, + lockFactory, + taskNames, + evaluators, + rateLimit.toMillis(), + lockCheckDelay.toMillis())) + .name(TRIGGER_MANAGER_OPERATOR_NAME) + .uid(TRIGGER_MANAGER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); + } + + triggers = + setSlotSharingGroup( + triggers + .assignTimestampsAndWatermarks(new PunctuatedWatermarkStrategy()) + .name(WATERMARK_ASSIGNER_OPERATOR_NAME) + .uid(WATERMARK_ASSIGNER_OPERATOR_NAME + uidSuffix) + .forceNonParallel()); // Add the specific tasks DataStream unioned = null; for (int i = 0; i < taskBuilders.size(); ++i) { int taskIndex = i; DataStream filtered = - triggers - .filter(t -> t.taskId() != null && t.taskId() == taskIndex) - .name(FILTER_OPERATOR_NAME_PREFIX + taskIndex) - .forceNonParallel() - .uid(FILTER_OPERATOR_NAME_PREFIX + taskIndex + "-" + uidSuffix) - .slotSharingGroup(slotSharingGroup); + setSlotSharingGroup( + triggers + .filter(t -> t.taskId() != null && t.taskId() == taskIndex) + .name(FILTER_OPERATOR_NAME_PREFIX + taskIndex) + .forceNonParallel() + .uid(FILTER_OPERATOR_NAME_PREFIX + taskIndex + "-" + uidSuffix)); MaintenanceTaskBuilder builder = taskBuilders.get(taskIndex); DataStream result = builder.append( @@ -277,14 +351,25 @@ public void append() throws IOException { } // Add the LockRemover to the end - unioned - .transform( - LOCK_REMOVER_OPERATOR_NAME, - TypeInformation.of(Void.class), - new LockRemover(tableName, lockFactory, taskNames)) - .forceNonParallel() - .uid("lock-remover-" + uidSuffix) - .slotSharingGroup(slotSharingGroup); + if (lockFactory == null) { + setSlotSharingGroup( + unioned + .transform( + LOCK_REMOVER_OPERATOR_NAME, + TypeInformation.of(Void.class), + new LockRemoverOperatorFactory(tableName, taskNames)) + .uid("lock-remover-" + uidSuffix) + .forceNonParallel()); + } else { + setSlotSharingGroup( + unioned + .transform( + LOCK_REMOVER_OPERATOR_NAME, + TypeInformation.of(Void.class), + new LockRemover(tableName, lockFactory, taskNames)) + .forceNonParallel() + .uid("lock-remover-" + uidSuffix)); + } } } @@ -294,11 +379,13 @@ private DataStream changeStream(String tableName, TableLoader loade MonitorSource source = new MonitorSource( loader, RateLimiterStrategy.perSecond(1.0 / rateLimit.getSeconds()), maxReadBack); - return env.fromSource( - source, WatermarkStrategy.noWatermarks(), SOURCE_OPERATOR_NAME_PREFIX + tableName) - .uid(SOURCE_OPERATOR_NAME_PREFIX + uidSuffix) - .slotSharingGroup(slotSharingGroup) - .forceNonParallel(); + return setSlotSharingGroup( + env.fromSource( + source, + WatermarkStrategy.noWatermarks(), + SOURCE_OPERATOR_NAME_PREFIX + tableName) + .uid(SOURCE_OPERATOR_NAME_PREFIX + uidSuffix) + .forceNonParallel()); } else { return inputStream.global(); } @@ -307,6 +394,11 @@ private DataStream changeStream(String tableName, TableLoader loade private static String nameFor(MaintenanceTaskBuilder streamBuilder, int taskIndex) { return String.format(Locale.ROOT, "%s [%d]", streamBuilder.maintenanceTaskName(), taskIndex); } + + private SingleOutputStreamOperator setSlotSharingGroup( + SingleOutputStreamOperator operator) { + return slotSharingGroup == null ? operator : operator.slotSharingGroup(slotSharingGroup); + } } @Internal diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java index 27c0a43a858a..7797da0caac3 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/api/ZkLockFactory.java @@ -20,7 +20,6 @@ import java.io.IOException; import java.util.concurrent.TimeUnit; -import org.apache.curator.shaded.com.google.common.annotations.VisibleForTesting; import org.apache.flink.shaded.curator5.org.apache.curator.RetryPolicy; import org.apache.flink.shaded.curator5.org.apache.curator.framework.CuratorFramework; import org.apache.flink.shaded.curator5.org.apache.curator.framework.CuratorFrameworkFactory; @@ -31,6 +30,7 @@ import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryNTimes; import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryOneTime; import org.apache.flink.shaded.curator5.org.apache.curator.retry.RetryUntilElapsed; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java new file mode 100644 index 000000000000..21e9ca43f304 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/BaseCoordinator.java @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import org.apache.flink.annotation.VisibleForTesting; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.util.FatalExitExceptionHandler; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base coordinator for table maintenance operators. Provides common functionality for thread + * management, subtask gateway management, and checkpoint handling. + */ +abstract class BaseCoordinator implements OperatorCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(BaseCoordinator.class); + private static final Map> LOCK_RELEASE_CONSUMERS = + Maps.newConcurrentMap(); + private static final List PENDING_RELEASE_EVENTS = Lists.newArrayList(); + + private final String operatorName; + private final Context context; + private final ExecutorService coordinatorExecutor; + private final CoordinatorExecutorThreadFactory coordinatorThreadFactory; + private final SubtaskGateways subtaskGateways; + + private boolean started; + + BaseCoordinator(String operatorName, Context context) { + this.operatorName = operatorName; + this.context = context; + + this.coordinatorThreadFactory = + new CoordinatorExecutorThreadFactory( + "Coordinator-" + operatorName, context.getUserCodeClassloader()); + this.coordinatorExecutor = Executors.newSingleThreadExecutor(coordinatorThreadFactory); + Preconditions.checkState( + context.currentParallelism() == 1, "Coordinator must run with parallelism 1"); + this.subtaskGateways = SubtaskGateways.create(operatorName); + LOG.info("Created coordinator: {}", operatorName); + } + + @SuppressWarnings("FutureReturnValueIgnored") + void registerLock(LockRegisterEvent lockRegisterEvent) { + LOCK_RELEASE_CONSUMERS.put( + lockRegisterEvent.lockId(), + lock -> { + LOG.info( + "Send release event for lock id {}, timestamp: {} to Operator {}", + lock.lockId(), + lock.timestamp(), + operatorName()); + subtaskGateways.subtaskGateway().sendEvent(lock); + }); + + synchronized (PENDING_RELEASE_EVENTS) { + if (!PENDING_RELEASE_EVENTS.isEmpty()) { + PENDING_RELEASE_EVENTS.forEach(this::handleReleaseLock); + PENDING_RELEASE_EVENTS.clear(); + } + } + } + + void handleReleaseLock(LockReleaseEvent lockReleaseEvent) { + synchronized (PENDING_RELEASE_EVENTS) { + if (LOCK_RELEASE_CONSUMERS.containsKey(lockReleaseEvent.lockId())) { + LOCK_RELEASE_CONSUMERS.get(lockReleaseEvent.lockId()).accept(lockReleaseEvent); + LOG.info( + "Send release event for lock id {}, timestamp: {}", + lockReleaseEvent.lockId(), + lockReleaseEvent.timestamp()); + } else { + PENDING_RELEASE_EVENTS.add(lockReleaseEvent); + LOG.info( + "No consumer for lock id {}, timestamp: {}", + lockReleaseEvent.lockId(), + lockReleaseEvent.timestamp()); + } + } + } + + @Override + public void start() throws Exception { + LOG.info("Starting coordinator: {}", operatorName); + this.started = true; + } + + @Override + public void close() throws Exception { + coordinatorExecutor.shutdown(); + this.started = false; + synchronized (PENDING_RELEASE_EVENTS) { + LOCK_RELEASE_CONSUMERS.clear(); + PENDING_RELEASE_EVENTS.clear(); + } + + LOG.info("Closed coordinator: {}", operatorName); + } + + @Override + public void checkpointCoordinator(long checkpointId, CompletableFuture resultFuture) { + runInCoordinatorThread( + () -> resultFuture.complete(new byte[0]), + String.format(Locale.ROOT, "taking checkpoint %d", checkpointId)); + } + + @Override + public void resetToCheckpoint(long checkpointId, byte[] checkpointData) { + Preconditions.checkState( + !started, "The coordinator %s can only be reset if it was not yet started", operatorName); + LOG.info("Reset to checkpoint {}", checkpointId); + synchronized (PENDING_RELEASE_EVENTS) { + LOCK_RELEASE_CONSUMERS.clear(); + PENDING_RELEASE_EVENTS.clear(); + } + } + + @Override + public void notifyCheckpointComplete(long checkpointId) {} + + @Override + public void subtaskReset(int subtask, long checkpointId) { + runInCoordinatorThread( + () -> { + LOG.info("Subtask {} is reset to checkpoint {}", subtask, checkpointId); + Preconditions.checkState(coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.reset(); + }, + String.format( + Locale.ROOT, "handling subtask %d recovery to checkpoint %d", subtask, checkpointId)); + } + + @Override + public void executionAttemptFailed(int subtask, int attemptNumber, Throwable reason) { + runInCoordinatorThread( + () -> { + LOG.info( + "Unregistering gateway after failure for subtask {} (#{}) of data statistics {}", + subtask, + attemptNumber, + operatorName); + Preconditions.checkState( + this.coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.unregisterSubtaskGateway(subtask, attemptNumber); + }, + String.format(Locale.ROOT, "handling subtask %d (#%d) failure", subtask, attemptNumber)); + } + + @Override + public void executionAttemptReady(int subtask, int attemptNumber, SubtaskGateway gateway) { + Preconditions.checkArgument(subtask == gateway.getSubtask()); + Preconditions.checkArgument(attemptNumber == gateway.getExecution().getAttemptNumber()); + runInCoordinatorThread( + () -> { + Preconditions.checkState( + this.coordinatorThreadFactory.isCurrentThreadCoordinatorThread()); + subtaskGateways.registerSubtaskGateway(gateway); + }, + String.format( + Locale.ROOT, + "making event gateway to subtask %d (#%d) available", + subtask, + attemptNumber)); + } + + String operatorName() { + return operatorName; + } + + void runInCoordinatorThread(Runnable runnable, String actionString) { + ensureStarted(); + coordinatorExecutor.execute( + () -> { + try { + runnable.run(); + } catch (Throwable t) { + LOG.error( + "Uncaught exception in coordinator while {}: {}", actionString, t.getMessage(), t); + context.failJob(t); + } + }); + } + + private void ensureStarted() { + Preconditions.checkState(started, "The coordinator has not started yet."); + } + + /** Inner class to manage subtask gateways. */ + private record SubtaskGateways(String operatorName, Map gateways) { + + private static SubtaskGateways create(String operatorName) { + return new SubtaskGateways(operatorName, Maps.newHashMap()); + } + + private void registerSubtaskGateway(SubtaskGateway gateway) { + int attemptNumber = gateway.getExecution().getAttemptNumber(); + Preconditions.checkState( + !gateways.containsKey(attemptNumber), + "Coordinator of %s already has a subtask gateway for (#%d)", + operatorName, + attemptNumber); + LOG.debug("Coordinator of {} registers gateway for attempt {}", operatorName, attemptNumber); + gateways.put(attemptNumber, gateway); + LOG.debug("Registered gateway for attempt {}", attemptNumber); + } + + private void unregisterSubtaskGateway(int subtaskIndex, int attemptNumber) { + gateways.remove(attemptNumber); + LOG.debug("Unregistered gateway for subtask {} attempt {}", subtaskIndex, attemptNumber); + } + + private SubtaskGateway subtaskGateway() { + Preconditions.checkState( + !gateways.isEmpty(), + "Coordinator of %s is not ready yet to receive events", + operatorName); + return Iterables.getOnlyElement(gateways.values()); + } + + private void reset() { + gateways.clear(); + } + } + + /** Custom thread factory for the coordinator executor. */ + private static class CoordinatorExecutorThreadFactory + implements ThreadFactory, Thread.UncaughtExceptionHandler { + + private final String coordinatorThreadName; + private final ClassLoader classLoader; + private final Thread.UncaughtExceptionHandler errorHandler; + + private Thread thread; + + private CoordinatorExecutorThreadFactory( + String coordinatorThreadName, ClassLoader contextClassLoader) { + this(coordinatorThreadName, contextClassLoader, FatalExitExceptionHandler.INSTANCE); + } + + private CoordinatorExecutorThreadFactory( + String coordinatorThreadName, + ClassLoader contextClassLoader, + Thread.UncaughtExceptionHandler errorHandler) { + this.coordinatorThreadName = coordinatorThreadName; + this.classLoader = contextClassLoader; + this.errorHandler = errorHandler; + } + + @Override + public synchronized Thread newThread(@Nonnull Runnable runnable) { + thread = new Thread(runnable, coordinatorThreadName); + thread.setContextClassLoader(classLoader); + thread.setUncaughtExceptionHandler(this); + return thread; + } + + @Override + public synchronized void uncaughtException(Thread t, Throwable e) { + errorHandler.uncaughtException(t, e); + } + + private boolean isCurrentThreadCoordinatorThread() { + return Thread.currentThread() == thread; + } + } + + @VisibleForTesting + List pendingReleaseEvents() { + return PENDING_RELEASE_EVENTS; + } + + @VisibleForTesting + ExecutorService coordinatorExecutor() { + return coordinatorExecutor; + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java index 135d3d9b42db..1125e5d9b6a5 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteCommitter.java @@ -29,12 +29,14 @@ import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; import org.apache.iceberg.DataFile; import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteDataFiles; import org.apache.iceberg.actions.RewriteDataFilesCommitManager; import org.apache.iceberg.actions.RewriteDataFilesCommitManager.CommitService; import org.apache.iceberg.actions.RewriteFileGroup; import org.apache.iceberg.flink.TableLoader; import org.apache.iceberg.flink.maintenance.api.Trigger; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,6 +54,7 @@ public class DataFileRewriteCommitter extends AbstractStreamOperator private final String taskName; private final int taskIndex; private final TableLoader tableLoader; + private final String branch; private transient Table table; private transient CommitService commitService; @@ -62,15 +65,17 @@ public class DataFileRewriteCommitter extends AbstractStreamOperator private transient Counter removedDataFileSizeCounter; public DataFileRewriteCommitter( - String tableName, String taskName, int taskIndex, TableLoader tableLoader) { + String tableName, String taskName, int taskIndex, TableLoader tableLoader, String branch) { Preconditions.checkNotNull(tableName, "Table name should no be null"); Preconditions.checkNotNull(taskName, "Task name should no be null"); Preconditions.checkNotNull(tableLoader, "Table loader should no be null"); + Preconditions.checkNotNull(branch, "Branch should not be null"); this.tableName = tableName; this.taskName = taskName; this.taskIndex = taskIndex; this.tableLoader = tableLoader; + this.branch = branch; } @Override @@ -103,7 +108,7 @@ public void processElement(StreamRecord str FlinkRewriteDataFilesCommitManager commitManager = new FlinkRewriteDataFilesCommitManager( - table, executedGroup.snapshotId(), streamRecord.getTimestamp()); + table, executedGroup.snapshotId(), streamRecord.getTimestamp(), branch); this.commitService = commitManager.service(executedGroup.groupsPerCommit()); commitService.start(); } @@ -164,8 +169,14 @@ public void close() throws IOException { private class FlinkRewriteDataFilesCommitManager extends RewriteDataFilesCommitManager { private final long timestamp; - FlinkRewriteDataFilesCommitManager(Table table, long startingSnapshotId, long timestamp) { - super(table, startingSnapshotId); + FlinkRewriteDataFilesCommitManager( + Table table, long startingSnapshotId, long timestamp, String branch) { + super( + table, + startingSnapshotId, + RewriteDataFiles.USE_STARTING_SEQUENCE_NUMBER_DEFAULT, + ImmutableMap.of(), + branch); this.timestamp = timestamp; } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java index c50060e16a6c..b78c602c647f 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewritePlanner.java @@ -26,9 +26,11 @@ import org.apache.flink.metrics.Counter; import org.apache.flink.streaming.api.functions.ProcessFunction; import org.apache.flink.util.Collector; +import org.apache.flink.util.function.SerializableSupplier; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.SerializableTable; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.actions.BinPackRewriteFilePlanner; import org.apache.iceberg.actions.FileRewritePlan; import org.apache.iceberg.actions.RewriteDataFiles; @@ -61,7 +63,8 @@ public class DataFileRewritePlanner private final long maxRewriteBytes; private final Map rewriterOptions; private transient Counter errorCounter; - private final Expression filter; + private final String branch; + private final SerializableSupplier filterSupplier; public DataFileRewritePlanner( String tableName, @@ -71,12 +74,14 @@ public DataFileRewritePlanner( int newPartialProgressMaxCommits, long maxRewriteBytes, Map rewriterOptions, - Expression filter) { + SerializableSupplier filterSupplier, + String branch) { Preconditions.checkNotNull(tableName, "Table name should no be null"); Preconditions.checkNotNull(taskName, "Task name should no be null"); Preconditions.checkNotNull(tableLoader, "Table loader should no be null"); Preconditions.checkNotNull(rewriterOptions, "Options map should no be null"); + Preconditions.checkNotNull(branch, "Branch should no be null"); this.tableName = tableName; this.taskName = taskName; @@ -85,7 +90,8 @@ public DataFileRewritePlanner( this.partialProgressMaxCommits = newPartialProgressMaxCommits; this.maxRewriteBytes = maxRewriteBytes; this.rewriterOptions = rewriterOptions; - this.filter = filter; + this.branch = branch; + this.filterSupplier = filterSupplier; } @Override @@ -108,7 +114,8 @@ public void processElement(Trigger value, Context ctx, Collector o try { SerializableTable table = (SerializableTable) SerializableTable.copyOf(tableLoader.loadTable()); - if (table.currentSnapshot() == null) { + Snapshot snapshot = table.snapshot(branch); + if (snapshot == null) { LOG.info( DataFileRewritePlanner.MESSAGE_PREFIX + "Nothing to plan for in an empty table", tableName, @@ -118,7 +125,8 @@ public void processElement(Trigger value, Context ctx, Collector o return; } - BinPackRewriteFilePlanner planner = new BinPackRewriteFilePlanner(table, filter); + BinPackRewriteFilePlanner planner = + new BinPackRewriteFilePlanner(table, filterSupplier.get(), snapshot.snapshotId(), false); planner.init(rewriterOptions); FileRewritePlan @@ -164,7 +172,7 @@ public void processElement(Trigger value, Context ctx, Collector o taskIndex, ctx.timestamp(), group); - out.collect(new PlannedGroup(table, groupsPerCommit, group)); + out.collect(new PlannedGroup(table, groupsPerCommit, group, branch)); } } catch (Exception e) { LOG.warn( @@ -189,11 +197,14 @@ public static class PlannedGroup { private final SerializableTable table; private final int groupsPerCommit; private final RewriteFileGroup group; + private final String branch; - private PlannedGroup(SerializableTable table, int groupsPerCommit, RewriteFileGroup group) { + private PlannedGroup( + SerializableTable table, int groupsPerCommit, RewriteFileGroup group, String branch) { this.table = table; this.groupsPerCommit = groupsPerCommit; this.group = group; + this.branch = branch; } SerializableTable table() { @@ -207,5 +218,9 @@ int groupsPerCommit() { RewriteFileGroup group() { return group; } + + String branch() { + return branch; + } } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java index 57b0e53d86e6..6fbfacf9f6c9 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/DataFileRewriteRunner.java @@ -124,7 +124,7 @@ public void processElement(PlannedGroup value, Context ctx, Collector o ctx.output(DELETE_STREAM, file); deleteFileCounter.incrementAndGet(); }) - .cleanExpiredFiles(true) + .cleanupLevel(ExpireSnapshots.CleanupLevel.ALL) .commit(); LOG.info( diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java index 3ae42c60831c..f9000511c11f 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/ListMetadataFiles.java @@ -66,6 +66,7 @@ public void open(OpenContext openContext) throws Exception { public void processElement(Trigger trigger, Context ctx, Collector collector) throws Exception { try { + table.refresh(); table .snapshots() .forEach( diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java new file mode 100644 index 000000000000..0dcd15a66559 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRegisterEvent.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +/** + * Event sent from TriggerManagerOperator to TriggerManagerCoordinator to register a lock release + * handler. This handler will be used to forward lock release events back to the operator when + * triggered by downstream operators. + */ +@Internal +public class LockRegisterEvent implements OperatorEvent { + + private final String lockId; + + public LockRegisterEvent(String lockId) { + this.lockId = lockId; + } + + public String lockId() { + return lockId; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("lockId", lockId).toString(); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java new file mode 100644 index 000000000000..8c6e71ca9761 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockReleaseEvent.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +/** + * Event sent from LockRemoverOperator to LockRemoverCoordinator to notify that a lock has been + * released. The LockRemoverCoordinator then forwards this event to the TriggerManagerOperator via + * the registered lock release handler. + */ +@Internal +public class LockReleaseEvent implements OperatorEvent { + + private final String lockId; + private final long timestamp; + + public LockReleaseEvent(String lockId, long timestamp) { + this.lockId = lockId; + this.timestamp = timestamp; + } + + public long timestamp() { + return timestamp; + } + + public String lockId() { + return lockId; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("lockId", lockId) + .add("timestamp", timestamp) + .toString(); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java new file mode 100644 index 000000000000..39020cd5d30a --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverCoordinator.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.Locale; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Coordinator for LockRemoverOperator. Handles lock release events from downstream operators. */ +class LockRemoverCoordinator extends BaseCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(LockRemoverCoordinator.class); + + LockRemoverCoordinator(String operatorName, Context context) { + super(operatorName, context); + LOG.info("Created LockRemoverCoordinator: {}", operatorName); + } + + @Override + public void handleEventFromOperator(int subtask, int attemptNumber, OperatorEvent event) { + runInCoordinatorThread( + () -> { + LOG.debug( + "Handling event from subtask {} (#{}) of {}: {}", + subtask, + attemptNumber, + operatorName(), + event); + if (event instanceof LockReleaseEvent) { + handleReleaseLock((LockReleaseEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + }, + String.format( + Locale.ROOT, + "handling operator event %s from subtask %d (#%d)", + event.getClass(), + subtask, + attemptNumber)); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java new file mode 100644 index 000000000000..d5c1be2e322d --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperator.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.io.Serial; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.flink.metrics.Counter; +import org.apache.flink.metrics.MetricGroup; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventHandler; +import org.apache.flink.streaming.api.operators.AbstractStreamOperator; +import org.apache.flink.streaming.api.operators.OneInputStreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.iceberg.flink.maintenance.api.TaskResult; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class LockRemoverOperator extends AbstractStreamOperator + implements OneInputStreamOperator, OperatorEventHandler { + + @Serial private static final long serialVersionUID = 1L; + private static final Logger LOG = LoggerFactory.getLogger(LockRemoverOperator.class); + + private final String tableName; + private final OperatorEventGateway operatorEventGateway; + private final List maintenanceTaskNames; + private transient List succeededTaskResultCounters; + private transient List failedTaskResultCounters; + private transient List taskLastRunDurationMs; + + LockRemoverOperator( + StreamOperatorParameters parameters, + OperatorEventGateway operatorEventGateway, + String tableName, + List maintenanceTaskNames) { + super(parameters); + this.tableName = tableName; + this.operatorEventGateway = operatorEventGateway; + this.maintenanceTaskNames = maintenanceTaskNames; + } + + @Override + public void open() throws Exception { + this.succeededTaskResultCounters = + Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + this.failedTaskResultCounters = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + this.taskLastRunDurationMs = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + for (int taskIndex = 0; taskIndex < maintenanceTaskNames.size(); ++taskIndex) { + MetricGroup taskMetricGroup = + TableMaintenanceMetrics.groupFor( + getRuntimeContext(), tableName, maintenanceTaskNames.get(taskIndex), taskIndex); + succeededTaskResultCounters.add( + taskMetricGroup.counter(TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER)); + failedTaskResultCounters.add( + taskMetricGroup.counter(TableMaintenanceMetrics.FAILED_TASK_COUNTER)); + AtomicLong duration = new AtomicLong(0); + taskLastRunDurationMs.add(duration); + taskMetricGroup.gauge(TableMaintenanceMetrics.LAST_RUN_DURATION_MS, duration::get); + } + } + + @Override + public void handleOperatorEvent(OperatorEvent event) { + // no incoming events + } + + @SuppressWarnings("FutureReturnValueIgnored") + @Override + public void processElement(StreamRecord streamRecord) { + TaskResult taskResult = streamRecord.getValue(); + LOG.info( + "Processing result {} for task {}", + taskResult, + maintenanceTaskNames.get(taskResult.taskIndex())); + long duration = System.currentTimeMillis() - taskResult.startEpoch(); + // Update the metrics + taskLastRunDurationMs.get(taskResult.taskIndex()).set(duration); + if (taskResult.success()) { + succeededTaskResultCounters.get(taskResult.taskIndex()).inc(); + } else { + failedTaskResultCounters.get(taskResult.taskIndex()).inc(); + } + } + + @Override + public void processWatermark(Watermark mark) throws Exception { + if (Watermark.MAX_WATERMARK.getTimestamp() != mark.getTimestamp()) { + operatorEventGateway.sendEventToCoordinator( + new LockReleaseEvent(tableName, mark.getTimestamp())); + } + + super.processWatermark(mark); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java new file mode 100644 index 000000000000..b43c3f15fcc3 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/LockRemoverOperatorFactory.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.RecreateOnResetOperatorCoordinator; +import org.apache.flink.streaming.api.operators.AbstractStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.CoordinatedOperatorFactory; +import org.apache.flink.streaming.api.operators.OneInputStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.StreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.iceberg.flink.maintenance.api.TaskResult; + +@Internal +public class LockRemoverOperatorFactory extends AbstractStreamOperatorFactory + implements CoordinatedOperatorFactory, OneInputStreamOperatorFactory { + private final String tableName; + private final List maintenanceTaskNames; + + public LockRemoverOperatorFactory(String tableName, List maintenanceTaskNames) { + this.tableName = tableName; + this.maintenanceTaskNames = maintenanceTaskNames; + } + + @Override + public OperatorCoordinator.Provider getCoordinatorProvider( + String operatorName, OperatorID operatorID) { + return new LockRemoverCoordinatorProvider(operatorName, operatorID); + } + + @SuppressWarnings("unchecked") + @Override + public > T createStreamOperator( + StreamOperatorParameters parameters) { + OperatorID operatorId = parameters.getStreamConfig().getOperatorID(); + OperatorEventGateway gateway = + parameters.getOperatorEventDispatcher().getOperatorEventGateway(operatorId); + LockRemoverOperator lockRemoverOperator = + new LockRemoverOperator(parameters, gateway, tableName, maintenanceTaskNames); + parameters.getOperatorEventDispatcher().registerEventHandler(operatorId, lockRemoverOperator); + + return (T) lockRemoverOperator; + } + + @SuppressWarnings("rawtypes") + @Override + public Class getStreamOperatorClass(ClassLoader classLoader) { + return LockRemoverOperator.class; + } + + private static class LockRemoverCoordinatorProvider + extends RecreateOnResetOperatorCoordinator.Provider { + + private final String operatorName; + + private LockRemoverCoordinatorProvider(String operatorName, OperatorID operatorID) { + super(operatorID); + this.operatorName = operatorName; + } + + @Override + public OperatorCoordinator getCoordinator(OperatorCoordinator.Context context) { + return new LockRemoverCoordinator(operatorName, context); + } + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java index d74b2349b1de..f468c2f6eb74 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/MonitorSource.java @@ -129,7 +129,7 @@ public TableChange next() { if (snapshot != null) { if (!DataOperations.REPLACE.equals(snapshot.operation())) { LOG.debug("Reading snapshot {}", snapshot.snapshotId()); - event.merge(new TableChange(snapshot, table.io())); + event.merge(new TableChange(snapshot, table)); } else { LOG.debug("Skipping replace snapshot {}", snapshot.snapshotId()); } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java index 87600c52304a..b363e3443d47 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TableChange.java @@ -23,7 +23,8 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.Snapshot; -import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; /** Event describing changes in an Iceberg table */ @@ -54,8 +55,12 @@ private TableChange( this.commitCount = commitCount; } - TableChange(Snapshot snapshot, FileIO io) { - this(snapshot.addedDataFiles(io), snapshot.addedDeleteFiles(io)); + TableChange(Snapshot snapshot, Table table) { + this(SnapshotChanges.builderFor(table).snapshot(snapshot).build()); + } + + private TableChange(SnapshotChanges changes) { + this(changes.addedDataFiles(), changes.addedDeleteFiles()); } public TableChange(Iterable dataFiles, Iterable deleteFiles) { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java index f1f2b51c0943..6d8326645d42 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManager.java @@ -226,7 +226,8 @@ private void checkAndFire(long current, TimerService timerService, Collector evaluators, - List changes, - List lastTriggerTimes, - long currentTime, - int startPos) { - int current = startPos; - do { - if (evaluators - .get(current) - .check(changes.get(current), lastTriggerTimes.get(current), currentTime)) { - return current; - } - - current = (current + 1) % evaluators.size(); - } while (current != startPos); - - return null; - } - private void init(Collector out, TimerService timerService) throws Exception { if (!inited) { long current = timerService.currentProcessingTime(); diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java new file mode 100644 index 000000000000..b5af41da279a --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerCoordinator.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.Locale; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class TriggerManagerCoordinator extends BaseCoordinator { + + private static final Logger LOG = LoggerFactory.getLogger(TriggerManagerCoordinator.class); + + TriggerManagerCoordinator(String operatorName, Context context) { + super(operatorName, context); + LOG.info("Created TriggerManagerCoordinator: {}", operatorName); + } + + @Override + public void handleEventFromOperator(int subtask, int attemptNumber, OperatorEvent event) { + runInCoordinatorThread( + () -> { + LOG.debug( + "Handling event from subtask {} (#{}) of {}: {}", + subtask, + attemptNumber, + operatorName(), + event); + if (event instanceof LockRegisterEvent) { + registerLock((LockRegisterEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + }, + String.format( + Locale.ROOT, + "handling operator event %s from subtask %d (#%d)", + event.getClass(), + subtask, + attemptNumber)); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java new file mode 100644 index 000000000000..f29ac9670b70 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperator.java @@ -0,0 +1,322 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.io.Serial; +import java.util.List; +import org.apache.flink.annotation.VisibleForTesting; +import org.apache.flink.api.common.operators.ProcessingTimeService.ProcessingTimeCallback; +import org.apache.flink.api.common.state.ListState; +import org.apache.flink.api.common.state.ListStateDescriptor; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.common.typeinfo.Types; +import org.apache.flink.metrics.Counter; +import org.apache.flink.metrics.MetricGroup; +import org.apache.flink.runtime.operators.coordination.OperatorEvent; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventHandler; +import org.apache.flink.runtime.state.StateInitializationContext; +import org.apache.flink.runtime.state.StateSnapshotContext; +import org.apache.flink.streaming.api.operators.AbstractStreamOperator; +import org.apache.flink.streaming.api.operators.OneInputStreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.flink.streaming.runtime.tasks.ProcessingTimeService; +import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The TriggerManagerOperator itself holds the lock and registers a callback method with the + * coordinator. When a task finishes, it sends a signal from downstream to the coordinator to + * trigger this callback, allowing the TriggerManagerOperator to release the lock. + */ +class TriggerManagerOperator extends AbstractStreamOperator + implements OneInputStreamOperator, + OperatorEventHandler, + ProcessingTimeCallback { + + @Serial private static final long serialVersionUID = 1L; + private static final Logger LOG = LoggerFactory.getLogger(TriggerManagerOperator.class); + + private final OperatorEventGateway operatorEventGateway; + private final List maintenanceTaskNames; + private final List evaluators; + private final long minFireDelayMs; + private final long lockCheckDelayMs; + private final String tableName; + + private transient Counter rateLimiterTriggeredCounter; + private transient Counter concurrentRunThrottledCounter; + private transient Counter nothingToTriggerCounter; + private transient List triggerCounters; + private transient ListState nextEvaluationTimeState; + private transient ListState accumulatedChangesState; + private transient ListState lastTriggerTimesState; + private transient Long nextEvaluationTime; + private transient List accumulatedChanges; + private transient List lastTriggerTimes; + // To keep the task scheduling fair we keep the last triggered task position in memory. + // If we find a task to trigger, then we run it, but after it is finished, we start from the given + // position to prevent "starvation" of the tasks. + // When there is nothing to trigger, we start from the beginning, as the order of the tasks might + // be important (RewriteDataFiles first, and then RewriteManifestFiles later) + private transient int startsFrom = 0; + private transient boolean triggered = false; + private transient Long lockTime; + private transient boolean shouldRestoreTasks = false; + + TriggerManagerOperator( + StreamOperatorParameters parameters, + OperatorEventGateway operatorEventGateway, + List maintenanceTaskNames, + List evaluators, + long minFireDelayMs, + long lockCheckDelayMs, + String tableName) { + super(parameters); + Preconditions.checkArgument( + maintenanceTaskNames != null && !maintenanceTaskNames.isEmpty(), + "Invalid maintenance task names: null or empty"); + Preconditions.checkArgument( + evaluators != null && !evaluators.isEmpty(), "Invalid evaluators: null or empty"); + Preconditions.checkArgument( + maintenanceTaskNames.size() == evaluators.size(), + "Provide a name and evaluator for all of the maintenance tasks"); + Preconditions.checkArgument(minFireDelayMs > 0, "Minimum fire delay should be at least 1."); + Preconditions.checkArgument( + lockCheckDelayMs > 0, "Minimum lock delay rate should be at least 1 ms."); + + this.maintenanceTaskNames = maintenanceTaskNames; + this.evaluators = evaluators; + this.minFireDelayMs = minFireDelayMs; + this.lockCheckDelayMs = lockCheckDelayMs; + this.tableName = tableName; + this.operatorEventGateway = operatorEventGateway; + } + + @Override + public void open() throws Exception { + super.open(); + MetricGroup mainGroup = TableMaintenanceMetrics.groupFor(getRuntimeContext(), tableName); + this.rateLimiterTriggeredCounter = + mainGroup.counter(TableMaintenanceMetrics.RATE_LIMITER_TRIGGERED); + this.concurrentRunThrottledCounter = + mainGroup.counter(TableMaintenanceMetrics.CONCURRENT_RUN_THROTTLED); + this.nothingToTriggerCounter = mainGroup.counter(TableMaintenanceMetrics.NOTHING_TO_TRIGGER); + this.triggerCounters = Lists.newArrayListWithExpectedSize(maintenanceTaskNames.size()); + for (int taskIndex = 0; taskIndex < maintenanceTaskNames.size(); ++taskIndex) { + triggerCounters.add( + TableMaintenanceMetrics.groupFor( + mainGroup, maintenanceTaskNames.get(taskIndex), taskIndex) + .counter(TableMaintenanceMetrics.TRIGGERED)); + } + } + + @Override + public void initializeState(StateInitializationContext context) throws Exception { + super.initializeState(context); + this.nextEvaluationTimeState = + context + .getOperatorStateStore() + .getListState(new ListStateDescriptor<>("triggerManagerNextTriggerTime", Types.LONG)); + + this.accumulatedChangesState = + context + .getOperatorStateStore() + .getListState( + new ListStateDescriptor<>( + "triggerManagerAccumulatedChange", TypeInformation.of(TableChange.class))); + + this.lastTriggerTimesState = + context + .getOperatorStateStore() + .getListState(new ListStateDescriptor<>("triggerManagerLastTriggerTime", Types.LONG)); + + long current = getProcessingTimeService().getCurrentProcessingTime(); + + // Initialize from state + if (!Iterables.isEmpty(nextEvaluationTimeState.get())) { + nextEvaluationTime = Iterables.getOnlyElement(nextEvaluationTimeState.get()); + } + + this.accumulatedChanges = Lists.newArrayList(accumulatedChangesState.get()); + this.lastTriggerTimes = Lists.newArrayList(lastTriggerTimesState.get()); + + // Initialize if the state was empty + if (accumulatedChanges.isEmpty()) { + for (int i = 0; i < evaluators.size(); ++i) { + accumulatedChanges.add(TableChange.empty()); + lastTriggerTimes.add(current); + } + } + + // register the lock register event + operatorEventGateway.sendEventToCoordinator(new LockRegisterEvent(tableName)); + + if (context.isRestored()) { + // When the job state is restored, there could be ongoing tasks. + // To prevent collision with the new triggers the following is done: + // - add a recovery lock + // This ensures that the tasks of the previous trigger are executed, and the lock is removed + // in the end. The result of the 'tryLock' is ignored as an already existing lock prevents + // collisions as well. + // register the recover lock + this.lockTime = current; + this.shouldRestoreTasks = true; + output.collect(new StreamRecord<>(Trigger.recovery(current), current)); + if (nextEvaluationTime == null) { + schedule(getProcessingTimeService(), current + minFireDelayMs); + } else { + schedule(getProcessingTimeService(), nextEvaluationTime); + } + } else { + this.lockTime = null; + } + } + + @Override + public void snapshotState(StateSnapshotContext context) throws Exception { + nextEvaluationTimeState.clear(); + if (nextEvaluationTime != null) { + nextEvaluationTimeState.add(nextEvaluationTime); + } + + accumulatedChangesState.update(accumulatedChanges); + lastTriggerTimesState.update(lastTriggerTimes); + LOG.info( + "Storing state: nextEvaluationTime {}, accumulatedChanges {}, lastTriggerTimes {}", + nextEvaluationTime, + accumulatedChanges, + lastTriggerTimes); + } + + @Override + public void handleOperatorEvent(OperatorEvent event) { + if (event instanceof LockReleaseEvent) { + LOG.info("Received lock released event: {}", event); + handleLockRelease((LockReleaseEvent) event); + } else { + throw new IllegalArgumentException( + "Invalid operator event type: " + event.getClass().getCanonicalName()); + } + } + + @Override + public void processElement(StreamRecord streamRecord) throws Exception { + TableChange change = streamRecord.getValue(); + accumulatedChanges.forEach(tableChange -> tableChange.merge(change)); + if (nextEvaluationTime == null) { + checkAndFire(getProcessingTimeService()); + } else { + LOG.info( + "Trigger manager rate limiter triggered current: {}, next: {}, accumulated changes: {},{}", + getProcessingTimeService().getCurrentProcessingTime(), + nextEvaluationTime, + accumulatedChanges, + maintenanceTaskNames); + rateLimiterTriggeredCounter.inc(); + } + } + + @Override + public void onProcessingTime(long l) { + this.nextEvaluationTime = null; + checkAndFire(getProcessingTimeService()); + } + + @Override + public void close() throws Exception { + super.close(); + this.lockTime = null; + } + + @VisibleForTesting + void handleLockRelease(LockReleaseEvent event) { + Preconditions.checkArgument(lockTime != null, "Lock time is null, Can't release lock"); + + if (event.timestamp() >= lockTime) { + this.lockTime = null; + this.shouldRestoreTasks = false; + } + } + + private void checkAndFire(ProcessingTimeService timerService) { + long current = timerService.getCurrentProcessingTime(); + if (shouldRestoreTasks) { + if (lockTime != null) { + // Recovered tasks in progress. Skip trigger check + LOG.info("The recovery lock is still held at {}", current); + schedule(timerService, current + lockCheckDelayMs); + return; + } + } + + Integer taskToStart = + TriggerUtil.nextTrigger( + evaluators, accumulatedChanges, lastTriggerTimes, current, startsFrom); + if (taskToStart == null) { + // Nothing to execute + if (!triggered) { + nothingToTriggerCounter.inc(); + LOG.debug("Nothing to execute at {} for collected: {}", current, accumulatedChanges); + } else { + LOG.debug("Execution check finished"); + } + + // Next time start from the beginning + startsFrom = 0; + triggered = false; + return; + } + + if (lockTime == null) { + this.lockTime = current; + TableChange change = accumulatedChanges.get(taskToStart); + output.collect(new StreamRecord<>(Trigger.create(current, taskToStart), current)); + LOG.debug("Fired event with time: {}, collected: {} for {}", current, change, tableName); + triggerCounters.get(taskToStart).inc(); + accumulatedChanges.set(taskToStart, TableChange.empty()); + lastTriggerTimes.set(taskToStart, current); + schedule(timerService, current + minFireDelayMs); + startsFrom = (taskToStart + 1) % evaluators.size(); + triggered = true; + } else { + // A task is already running, waiting for it to finish + LOG.info("Failed to acquire lock. Delaying task to {}", current + lockCheckDelayMs); + + startsFrom = taskToStart; + concurrentRunThrottledCounter.inc(); + schedule(timerService, current + lockCheckDelayMs); + } + } + + private void schedule(ProcessingTimeService timerService, long time) { + this.nextEvaluationTime = time; + timerService.registerTimer(time, this); + } + + @VisibleForTesting + Long lockTime() { + return lockTime; + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java new file mode 100644 index 000000000000..bace5e3afe2b --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerManagerOperatorFactory.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; +import org.apache.flink.annotation.Internal; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.OperatorCoordinator; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.RecreateOnResetOperatorCoordinator; +import org.apache.flink.streaming.api.operators.AbstractStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.CoordinatedOperatorFactory; +import org.apache.flink.streaming.api.operators.OneInputStreamOperatorFactory; +import org.apache.flink.streaming.api.operators.StreamOperator; +import org.apache.flink.streaming.api.operators.StreamOperatorParameters; +import org.apache.iceberg.flink.maintenance.api.Trigger; + +@Internal +public class TriggerManagerOperatorFactory extends AbstractStreamOperatorFactory + implements CoordinatedOperatorFactory, + OneInputStreamOperatorFactory { + + private final String lockId; + private final List maintenanceTaskNames; + private final List evaluators; + private final long minFireDelayMs; + private final long lockCheckDelayMs; + + public TriggerManagerOperatorFactory( + String lockId, + List maintenanceTaskNames, + List evaluators, + long minFireDelayMs, + long lockCheckDelayMs) { + this.lockId = lockId; + this.maintenanceTaskNames = maintenanceTaskNames; + this.evaluators = evaluators; + this.minFireDelayMs = minFireDelayMs; + this.lockCheckDelayMs = lockCheckDelayMs; + } + + @Override + public OperatorCoordinator.Provider getCoordinatorProvider( + String operatorName, OperatorID operatorID) { + return new TriggerManagerCoordinatorProvider(operatorName, operatorID); + } + + @SuppressWarnings("unchecked") + @Override + public > T createStreamOperator( + StreamOperatorParameters parameters) { + OperatorID operatorId = parameters.getStreamConfig().getOperatorID(); + OperatorEventGateway gateway = + parameters.getOperatorEventDispatcher().getOperatorEventGateway(operatorId); + + TriggerManagerOperator triggerManagerOperator = + new TriggerManagerOperator( + parameters, + gateway, + maintenanceTaskNames, + evaluators, + minFireDelayMs, + lockCheckDelayMs, + lockId); + + parameters + .getOperatorEventDispatcher() + .registerEventHandler(operatorId, triggerManagerOperator); + + return (T) triggerManagerOperator; + } + + @SuppressWarnings("rawtypes") + @Override + public Class getStreamOperatorClass(ClassLoader classLoader) { + return TriggerManagerOperator.class; + } + + private static class TriggerManagerCoordinatorProvider + extends RecreateOnResetOperatorCoordinator.Provider { + + private final String operatorName; + + private TriggerManagerCoordinatorProvider(String operatorName, OperatorID operatorID) { + super(operatorID); + this.operatorName = operatorName; + } + + @Override + public OperatorCoordinator getCoordinator(OperatorCoordinator.Context context) { + return new TriggerManagerCoordinator(operatorName, context); + } + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java new file mode 100644 index 000000000000..634e9a0d03c6 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/TriggerUtil.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import java.util.List; + +class TriggerUtil { + + private TriggerUtil() {} + + static Integer nextTrigger( + List evaluators, + List changes, + List lastTriggerTimes, + long currentTime, + int startPos) { + int current = startPos; + do { + if (evaluators + .get(current) + .check(changes.get(current), lastTriggerTimes.get(current), currentTime)) { + return current; + } + + current = (current + 1) % evaluators.size(); + } while (current != startPos); + + return null; + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java index f7e8e0c884cf..5f3494330cfc 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/AvroGenericRecordToRowDataMapper.java @@ -21,14 +21,14 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.api.common.functions.MapFunction; -import org.apache.flink.formats.avro.AvroToRowDataConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.LogicalType; import org.apache.flink.table.types.logical.RowType; import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; +import org.apache.iceberg.flink.formats.avro.AvroToRowDataConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; /** * This util class converts Avro GenericRecord to Flink RowData.
    diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java index 1b786e46452f..1da0f8564938 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/CommitSummary.java @@ -19,6 +19,7 @@ package org.apache.iceberg.flink.sink; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.NavigableMap; import java.util.concurrent.atomic.AtomicLong; @@ -44,7 +45,11 @@ public CommitSummary(NavigableMap pendingResults) { } public void addAll(NavigableMap> pendingResults) { - pendingResults.values().forEach(writeResults -> writeResults.forEach(this::addWriteResult)); + pendingResults.values().forEach(this::addAll); + } + + public void addAll(Collection pendingResults) { + pendingResults.forEach(this::addWriteResult); } private void addWriteResult(WriteResult writeResult) { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java index b3ada41737bc..d5247941d863 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkFileWriterFactory.java @@ -25,28 +25,19 @@ import java.io.Serializable; import java.util.Map; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.data.StringData; import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.FileFormat; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; -import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; import org.apache.iceberg.flink.FlinkSchemaUtil; -import org.apache.iceberg.flink.data.FlinkAvroWriter; -import org.apache.iceberg.flink.data.FlinkOrcWriter; -import org.apache.iceberg.flink.data.FlinkParquetWriters; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -public class FlinkFileWriterFactory extends BaseFileWriterFactory implements Serializable { - private RowType dataFlinkType; - private RowType equalityDeleteFlinkType; - - private FlinkFileWriterFactory( +public class FlinkFileWriterFactory extends RegistryBasedFileWriterFactory + implements Serializable { + FlinkFileWriterFactory( Table table, FileFormat dataFileFormat, Schema dataSchema, @@ -62,85 +53,30 @@ private FlinkFileWriterFactory( super( table, dataFileFormat, + RowData.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - writeProperties); - - this.dataFlinkType = dataFlinkType; - this.equalityDeleteFlinkType = equalityDeleteFlinkType; - } - - static Builder builderFor(Table table) { - return new Builder(table); - } - - @Override - protected void configureDataWrite(Avro.DataWriteBuilder builder) { - builder.createWriterFunc(ignore -> new FlinkAvroWriter(dataFlinkType())); - } - - @Override - protected void configureEqualityDelete(Avro.DeleteWriteBuilder builder) { - builder.createWriterFunc(ignored -> new FlinkAvroWriter(equalityDeleteFlinkType())); - } - - @Override - protected void configurePositionDelete(Avro.DeleteWriteBuilder builder) {} - - @Override - protected void configureDataWrite(Parquet.DataWriteBuilder builder) { - builder.createWriterFunc(msgType -> FlinkParquetWriters.buildWriter(dataFlinkType(), msgType)); - } - - @Override - protected void configureEqualityDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> FlinkParquetWriters.buildWriter(equalityDeleteFlinkType(), msgType)); - } - - @Override - protected void configurePositionDelete(Parquet.DeleteWriteBuilder builder) { - builder.transformPaths(path -> StringData.fromString(path.toString())); - } - - @Override - protected void configureDataWrite(ORC.DataWriteBuilder builder) { - builder.createWriterFunc( - (iSchema, typDesc) -> FlinkOrcWriter.buildWriter(dataFlinkType(), iSchema)); - } - - @Override - protected void configureEqualityDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc( - (iSchema, typDesc) -> FlinkOrcWriter.buildWriter(equalityDeleteFlinkType(), iSchema)); + writeProperties, + dataFlinkType == null ? FlinkSchemaUtil.convert(dataSchema) : dataFlinkType, + equalityDeleteInputSchema(equalityDeleteFlinkType, equalityDeleteRowSchema)); } - @Override - protected void configurePositionDelete(ORC.DeleteWriteBuilder builder) { - builder.transformPaths(path -> StringData.fromString(path.toString())); - } - - private RowType dataFlinkType() { - if (dataFlinkType == null) { - Preconditions.checkNotNull(dataSchema(), "Data schema must not be null"); - this.dataFlinkType = FlinkSchemaUtil.convert(dataSchema()); + private static RowType equalityDeleteInputSchema(RowType rowType, Schema rowSchema) { + if (rowType != null) { + return rowType; + } else if (rowSchema != null) { + return FlinkSchemaUtil.convert(rowSchema); + } else { + return null; } - - return dataFlinkType; } - private RowType equalityDeleteFlinkType() { - if (equalityDeleteFlinkType == null) { - Preconditions.checkNotNull( - equalityDeleteRowSchema(), "Equality delete schema must not be null"); - this.equalityDeleteFlinkType = FlinkSchemaUtil.convert(equalityDeleteRowSchema()); - } - - return equalityDeleteFlinkType; + static Builder builderFor(Table table) { + return new Builder(table); } public static class Builder { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java index d83a11d0f462..86194b407135 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/FlinkSink.java @@ -159,7 +159,11 @@ public static class Builder implements IcebergSinkBuilder { private Function> inputCreator = null; private TableLoader tableLoader; private Table table; - @Deprecated private TableSchema tableSchema; + + @SuppressWarnings("deprecation") + @Deprecated + private TableSchema tableSchema; + private ResolvedSchema resolvedSchema; private List equalityFieldColumns = null; private String uidPrefix = null; @@ -240,6 +244,7 @@ public Builder setAll(Map properties) { return this; } + @SuppressWarnings("deprecation") @Override public Builder tableSchema(TableSchema newTableSchema) { this.tableSchema = newTableSchema; @@ -727,9 +732,8 @@ private DataStream distributeDataStream( @Deprecated static RowType toFlinkRowType(Schema schema, TableSchema requestedSchema) { if (requestedSchema != null) { - // Convert the flink schema to iceberg schema firstly, then reassign ids to match the existing - // iceberg schema. - Schema writeSchema = TypeUtil.reassignIds(FlinkSchemaUtil.convert(requestedSchema), schema); + // Convert the flink schema to iceberg schema using the table schema as the reference. + Schema writeSchema = FlinkSchemaUtil.convert(schema, requestedSchema); TypeUtil.validateWriteSchema(schema, writeSchema, true, true); // We use this flink schema to read values from RowData. The flink's TINYINT and SMALLINT will @@ -744,9 +748,8 @@ static RowType toFlinkRowType(Schema schema, TableSchema requestedSchema) { static RowType toFlinkRowType(Schema schema, ResolvedSchema requestedSchema) { if (requestedSchema != null) { - // Convert the flink schema to iceberg schema firstly, then reassign ids to match the existing - // iceberg schema. - Schema writeSchema = TypeUtil.reassignIds(FlinkSchemaUtil.convert(requestedSchema), schema); + // Convert the flink schema to iceberg schema using the table schema as the reference. + Schema writeSchema = FlinkSchemaUtil.convert(schema, requestedSchema); TypeUtil.validateWriteSchema(schema, writeSchema, true, true); // We use this flink schema to read values from RowData. The flink's TINYINT and SMALLINT will diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java index c05e7d918093..8e45a2db30b2 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergCommitter.java @@ -78,7 +78,7 @@ class IcebergCommitter implements Committer { private int maxContinuousEmptyCommits; private ExecutorService workerPool; private int continuousEmptyCheckpoints = 0; - private boolean compactMode = false; + private final boolean tableMaintenanceEnabled; IcebergCommitter( TableLoader tableLoader, @@ -88,7 +88,7 @@ class IcebergCommitter implements Committer { int workerPoolSize, String sinkId, IcebergFilesCommitterMetrics committerMetrics, - boolean compactMode) { + boolean tableMaintenanceEnabled) { this.branch = branch; this.snapshotProperties = snapshotProperties; this.replacePartitions = replacePartitions; @@ -107,7 +107,7 @@ class IcebergCommitter implements Committer { ThreadPools.newFixedThreadPool( "iceberg-committer-pool-" + table.name() + "-" + sinkId, workerPoolSize); this.continuousEmptyCheckpoints = 0; - this.compactMode = compactMode; + this.tableMaintenanceEnabled = tableMaintenanceEnabled; } @Override @@ -177,7 +177,7 @@ private void commitPendingRequests( committerMetrics.updateCommitSummary(summary); } - if (!compactMode) { + if (!tableMaintenanceEnabled) { FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java index 593230262fde..440fdb278be8 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergSink.java @@ -29,6 +29,7 @@ import java.util.Set; import java.util.UUID; import java.util.function.Function; +import org.apache.commons.lang3.StringUtils; import org.apache.flink.annotation.Experimental; import org.apache.flink.api.common.SupportsConcurrentExecutionAttempts; import org.apache.flink.api.common.functions.FlatMapFunction; @@ -72,12 +73,16 @@ import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.TableLoader; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFiles; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshots; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; import org.apache.iceberg.flink.maintenance.api.FlinkMaintenanceConfig; import org.apache.iceberg.flink.maintenance.api.LockConfig; +import org.apache.iceberg.flink.maintenance.api.MaintenanceTaskBuilder; import org.apache.iceberg.flink.maintenance.api.RewriteDataFiles; import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; import org.apache.iceberg.flink.maintenance.api.TableMaintenance; -import org.apache.iceberg.flink.maintenance.api.TriggerLockFactory; import org.apache.iceberg.flink.maintenance.operator.LockFactoryBuilder; import org.apache.iceberg.flink.maintenance.operator.TableChange; import org.apache.iceberg.flink.sink.shuffle.DataStatisticsOperatorFactory; @@ -88,7 +93,9 @@ import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.SerializableSupplier; import org.slf4j.Logger; @@ -158,11 +165,14 @@ public class IcebergSink private final String branch; private final boolean overwriteMode; private final int workerPoolSize; - private final boolean compactMode; - private final transient FlinkMaintenanceConfig flinkMaintenanceConfig; - + private final boolean maintenanceEnabled; private final Table table; - private final Set equalityFieldColumns = null; + // This should only be used for logging/error messages. For any actual logic always use + // equalityFieldIds instead. + private final Set equalityFieldColumns; + + private final transient List> maintenanceTasks; + private final transient FlinkMaintenanceConfig flinkMaintenanceConfig; private IcebergSink( TableLoader tableLoader, @@ -176,7 +186,9 @@ private IcebergSink( Set equalityFieldIds, String branch, boolean overwriteMode, - FlinkMaintenanceConfig flinkMaintenanceConfig) { + List> maintenanceTasks, + FlinkMaintenanceConfig flinkMaintenanceConfig, + Set equalityFieldColumns) { this.tableLoader = tableLoader; this.snapshotProperties = snapshotProperties; this.uidSuffix = uidSuffix; @@ -196,8 +208,10 @@ private IcebergSink( // This is used to separate files generated by different sinks writing the same table. // Also used to generate the aggregator operator name this.sinkId = UUID.randomUUID().toString(); - this.compactMode = flinkWriteConf.compactMode(); + this.maintenanceEnabled = !maintenanceTasks.isEmpty(); + this.maintenanceTasks = maintenanceTasks; this.flinkMaintenanceConfig = flinkMaintenanceConfig; + this.equalityFieldColumns = equalityFieldColumns; } @Override @@ -233,7 +247,7 @@ public Committer createCommitter(CommitterInitContext contex workerPoolSize, sinkId, metrics, - compactMode); + maintenanceEnabled); } @Override @@ -245,7 +259,7 @@ public SimpleVersionedSerializer getCommittableSerializer() public void addPostCommitTopology( DataStream> committables) { - if (!compactMode) { + if (maintenanceTasks.isEmpty()) { return; } @@ -259,25 +273,31 @@ public void addPostCommitTopology( .uid(postCommitUid) .forceNonParallel(); try { - RewriteDataFilesConfig rewriteDataFilesConfig = - flinkMaintenanceConfig.createRewriteDataFilesConfig(); - RewriteDataFiles.Builder rewriteBuilder = - RewriteDataFiles.builder().config(rewriteDataFilesConfig); - LockConfig lockConfig = flinkMaintenanceConfig.createLockConfig(); - TriggerLockFactory triggerLockFactory = LockFactoryBuilder.build(lockConfig, table.name()); String tableMaintenanceUid = String.format("TableMaintenance : %s", suffix); - TableMaintenance.Builder builder = - TableMaintenance.forChangeStream(tableChangeStream, tableLoader, triggerLockFactory) - .uidSuffix(tableMaintenanceUid) - .add(rewriteBuilder); + + TableMaintenance.Builder builder; + if (StringUtils.isNotEmpty(lockConfig.lockType())) { + builder = + TableMaintenance.forChangeStream( + tableChangeStream, tableLoader, LockFactoryBuilder.build(lockConfig, table.name())); + } else { + builder = TableMaintenance.forChangeStream(tableChangeStream, tableLoader); + } builder + .uidSuffix(tableMaintenanceUid) + .add(maintenanceTasks) .rateLimit(Duration.ofSeconds(flinkMaintenanceConfig.rateLimit())) .lockCheckDelay(Duration.ofSeconds(flinkMaintenanceConfig.lockCheckDelay())) - .slotSharingGroup(flinkMaintenanceConfig.slotSharingGroup()) - .parallelism(flinkMaintenanceConfig.parallelism()) - .append(); + .parallelism(flinkMaintenanceConfig.parallelism()); + + String slotSharingGroup = flinkMaintenanceConfig.slotSharingGroup(); + if (slotSharingGroup != null) { + builder.slotSharingGroup(slotSharingGroup); + } + + builder.append(); } catch (IOException e) { throw new UncheckedIOException("Failed to create tableMaintenance ", e); } @@ -322,13 +342,18 @@ public SimpleVersionedSerializer getWriteResultSerializer() { public static class Builder implements IcebergSinkBuilder { private TableLoader tableLoader; private Function> inputCreator = null; - @Deprecated private TableSchema tableSchema; + + @SuppressWarnings("deprecation") + @Deprecated + private TableSchema tableSchema; + private ResolvedSchema resolvedSchema; private SerializableTable table; private final Map writeOptions = Maps.newHashMap(); private final Map snapshotSummary = Maps.newHashMap(); private ReadableConfig readableConfig = new Configuration(); private List equalityFieldColumns = null; + private final List> maintenanceTasks = Lists.newArrayList(); private Builder() {} @@ -437,6 +462,7 @@ public Builder setAll(Map properties) { return this; } + @SuppressWarnings("deprecation") @Override public Builder tableSchema(TableSchema newTableSchema) { this.tableSchema = newTableSchema; @@ -610,6 +636,85 @@ public Builder setSnapshotProperty(String property, String value) { return this; } + /** + * Enables or disables compaction (rewriting data files) as a post-commit maintenance task. + * + * @param enabled whether to enable compaction + * @see RewriteDataFilesConfig for the default config. + * @deprecated See {@code rewriteDatafiles(..)} + */ + @Deprecated + public Builder compaction(boolean enabled) { + writeOptions.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), Boolean.toString(enabled)); + return this; + } + + /** + * Enables rewriting data files (compaction) as a post-commit maintenance task. + * + * @see RewriteDataFilesConfig for the default config. + */ + public Builder rewriteDataFiles() { + writeOptions.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); + return this; + } + + /** + * Enables rewriting data files (compaction) as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link RewriteDataFilesConfig} for available + * keys + */ + public Builder rewriteDataFiles(Map config) { + rewriteDataFiles(); + writeOptions.putAll(config); + return this; + } + + /** + * Enables expire snapshots as a post-commit maintenance task. + * + * @see ExpireSnapshotsConfig for the default config. + */ + public Builder expireSnapshots() { + writeOptions.put(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key(), "true"); + return this; + } + + /** + * Enables or disables expire snapshots as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link ExpireSnapshotsConfig} for available + * keys + */ + public Builder expireSnapshots(Map config) { + expireSnapshots(); + writeOptions.putAll(config); + return this; + } + + /** + * Enables delete orphan files as a post-commit maintenance task. + * + * @see DeleteOrphanFilesConfig for the default config. + */ + public Builder deleteOrphanFiles() { + writeOptions.put(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key(), "true"); + return this; + } + + /** + * Enables delete orphan files as a post-commit maintenance task. + * + * @param config task-specific configuration, see {@link DeleteOrphanFilesConfig} for available + * keys. + */ + public Builder deleteOrphanFiles(Map config) { + deleteOrphanFiles(); + writeOptions.putAll(config); + return this; + } + @Override public Builder toBranch(String branch) { writeOptions.put(FlinkWriteOptions.BRANCH.key(), branch); @@ -666,6 +771,30 @@ IcebergSink build() { FlinkMaintenanceConfig flinkMaintenanceConfig = new FlinkMaintenanceConfig(table, writeOptions, readableConfig); + if (flinkWriteConf.compactMode()) { + RewriteDataFilesConfig rewriteDataFilesConfig = + flinkMaintenanceConfig.createRewriteDataFilesConfig(); + maintenanceTasks.add( + RewriteDataFiles.builder() + .branch(flinkWriteConf.branch()) + .config(rewriteDataFilesConfig)); + } + + if (flinkWriteConf.expireSnapshotsMode()) { + ExpireSnapshotsConfig expireSnapshotsConfig = + flinkMaintenanceConfig.createExpireSnapshotsConfig(); + maintenanceTasks.add(ExpireSnapshots.builder().config(expireSnapshotsConfig)); + } + + if (flinkWriteConf.deleteOrphanFilesMode()) { + DeleteOrphanFilesConfig deleteOrphanFilesConfig = + flinkMaintenanceConfig.createDeleteOrphanFilesConfig(); + maintenanceTasks.add(DeleteOrphanFiles.builder().config(deleteOrphanFilesConfig)); + } + + Set equalityFieldColumnsSet = + equalityFieldColumns != null ? Sets.newHashSet(equalityFieldColumns) : null; + return new IcebergSink( tableLoader, table, @@ -680,7 +809,9 @@ IcebergSink build() { equalityFieldIds, flinkWriteConf.branch(), overwriteMode, - flinkMaintenanceConfig); + maintenanceTasks, + flinkMaintenanceConfig, + equalityFieldColumnsSet); } /** diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java index 434f3969577f..6cf15ff713fb 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/IcebergStreamWriterMetrics.java @@ -18,23 +18,33 @@ */ package org.apache.iceberg.flink.sink; -import com.codahale.metrics.SlidingWindowReservoir; import java.util.Arrays; import java.util.concurrent.atomic.AtomicLong; import org.apache.flink.annotation.Internal; -import org.apache.flink.dropwizard.metrics.DropwizardHistogramWrapper; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.metrics.Counter; import org.apache.flink.metrics.Histogram; import org.apache.flink.metrics.MetricGroup; +import org.apache.iceberg.common.DynClasses; +import org.apache.iceberg.common.DynConstructors; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.util.ScanTaskUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Internal public class IcebergStreamWriterMetrics { + + private static final Logger LOG = LoggerFactory.getLogger(IcebergStreamWriterMetrics.class); + // 1,024 reservoir size should cost about 8KB, which is quite small. // It should also produce good accuracy for histogram distribution (like percentiles). private static final int HISTOGRAM_RESERVOIR_SIZE = 1024; + // Histogram metrics loaded through Flink's optional flink-metrics-dropwizard dependency. + // Will be null if not available. + private static final DropwizardCtors DROPWIZARD = loadDropwizardCtors(); + private final Counter flushedDataFiles; private final Counter flushedDeleteFiles; private final Counter flushedReferencedDataFiles; @@ -51,18 +61,8 @@ public IcebergStreamWriterMetrics(MetricGroup metrics, String fullTableName) { this.lastFlushDurationMs = new AtomicLong(); writerMetrics.gauge("lastFlushDurationMs", lastFlushDurationMs::get); - com.codahale.metrics.Histogram dropwizardDataFilesSizeHistogram = - new com.codahale.metrics.Histogram(new SlidingWindowReservoir(HISTOGRAM_RESERVOIR_SIZE)); - this.dataFilesSizeHistogram = - writerMetrics.histogram( - "dataFilesSizeHistogram", - new DropwizardHistogramWrapper(dropwizardDataFilesSizeHistogram)); - com.codahale.metrics.Histogram dropwizardDeleteFilesSizeHistogram = - new com.codahale.metrics.Histogram(new SlidingWindowReservoir(HISTOGRAM_RESERVOIR_SIZE)); - this.deleteFilesSizeHistogram = - writerMetrics.histogram( - "deleteFilesSizeHistogram", - new DropwizardHistogramWrapper(dropwizardDeleteFilesSizeHistogram)); + this.dataFilesSizeHistogram = registerHistogram(writerMetrics, "dataFilesSizeHistogram"); + this.deleteFilesSizeHistogram = registerHistogram(writerMetrics, "deleteFilesSizeHistogram"); } public void updateFlushResult(WriteResult result) { @@ -74,16 +74,21 @@ public void updateFlushResult(WriteResult result) { // This should works equally well and we avoided the overhead of tracking the list of file sizes // in the {@link CommitSummary}, which currently stores simple stats for counters and gauges // metrics. - Arrays.stream(result.dataFiles()) - .forEach( - dataFile -> { - dataFilesSizeHistogram.update(dataFile.fileSizeInBytes()); - }); - Arrays.stream(result.deleteFiles()) - .forEach( - deleteFile -> { - deleteFilesSizeHistogram.update(ScanTaskUtil.contentSizeInBytes(deleteFile)); - }); + if (dataFilesSizeHistogram != null) { + Arrays.stream(result.dataFiles()) + .forEach( + dataFile -> { + dataFilesSizeHistogram.update(dataFile.fileSizeInBytes()); + }); + } + + if (deleteFilesSizeHistogram != null) { + Arrays.stream(result.deleteFiles()) + .forEach( + deleteFile -> { + deleteFilesSizeHistogram.update(ScanTaskUtil.contentSizeInBytes(deleteFile)); + }); + } } public void flushDuration(long flushDurationMs) { @@ -97,4 +102,60 @@ public Counter getFlushedDataFiles() { public Counter getFlushedDeleteFiles() { return flushedDeleteFiles; } + + @VisibleForTesting + Histogram dataFilesSizeHistogram() { + return dataFilesSizeHistogram; + } + + @VisibleForTesting + Histogram deleteFilesSizeHistogram() { + return deleteFilesSizeHistogram; + } + + private static Histogram registerHistogram(MetricGroup group, String name) { + Histogram histogram = newDropwizardHistogram(); + return histogram != null ? group.histogram(name, histogram) : null; + } + + private static Histogram newDropwizardHistogram() { + if (DROPWIZARD == null) { + return null; + } + + Object reservoir = DROPWIZARD.reservoirCtor.newInstance(HISTOGRAM_RESERVOIR_SIZE); + Object codahaleHistogram = DROPWIZARD.histogramCtor.newInstance(reservoir); + return DROPWIZARD.wrapperCtor.newInstance(codahaleHistogram); + } + + private static DropwizardCtors loadDropwizardCtors() { + try { + Class reservoirInterface = + DynClasses.builder().impl("com.codahale.metrics.Reservoir").buildChecked(); + Class codahaleHistogramClass = + DynClasses.builder().impl("com.codahale.metrics.Histogram").buildChecked(); + return new DropwizardCtors( + DynConstructors.builder() + .impl("com.codahale.metrics.SlidingWindowReservoir", int.class) + .buildChecked(), + DynConstructors.builder() + .impl("com.codahale.metrics.Histogram", reservoirInterface) + .buildChecked(), + DynConstructors.builder(Histogram.class) + .impl( + "org.apache.flink.dropwizard.metrics.DropwizardHistogramWrapper", + codahaleHistogramClass) + .buildChecked()); + } catch (ClassNotFoundException | NoSuchMethodException e) { + LOG.warn( + "Cannot load Dropwizard metrics; is org.apache.flink:flink-metrics-dropwizard on the classpath?", + e); + return null; + } + } + + private record DropwizardCtors( + DynConstructors.Ctor reservoirCtor, + DynConstructors.Ctor histogramCtor, + DynConstructors.Ctor wrapperCtor) {} } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java index 41ffa609540b..cb4fc62c01b8 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/CompareSchemasVisitor.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.Map; +import javax.annotation.Nullable; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.iceberg.Schema; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.schema.SchemaWithPartnerVisitor; @@ -43,23 +45,31 @@ public class CompareSchemasVisitor extends SchemaWithPartnerVisitor { private final Schema tableSchema; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; - private CompareSchemasVisitor(Schema tableSchema) { + private CompareSchemasVisitor( + Schema tableSchema, boolean caseSensitive, boolean dropUnusedColumns) { this.tableSchema = tableSchema; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } - public static Result visit(Schema dataSchema, Schema tableSchema) { - return visit(dataSchema, tableSchema, true); - } - - public static Result visit(Schema dataSchema, Schema tableSchema, boolean caseSensitive) { + public static Result visit( + Schema dataSchema, Schema tableSchema, boolean caseSensitive, boolean dropUnusedColumns) { return visit( dataSchema, -1, - new CompareSchemasVisitor(tableSchema), + new CompareSchemasVisitor(tableSchema, caseSensitive, dropUnusedColumns), new PartnerIdByNameAccessors(tableSchema, caseSensitive)); } + @VisibleForTesting + @Deprecated + public static Result visit(Schema dataSchema, Schema tableSchema) { + return visit(dataSchema, tableSchema, true, false); + } + @Override public Result schema(Schema dataSchema, Integer tableSchemaId, Result downstream) { if (tableSchemaId == null) { @@ -70,6 +80,7 @@ public Result schema(Schema dataSchema, Integer tableSchemaId, Result downstream } @Override + @SuppressWarnings("CyclomaticComplexity") public Result struct(Types.StructType struct, Integer tableSchemaId, List fields) { if (tableSchemaId == null) { return Result.SCHEMA_UPDATE_NEEDED; @@ -88,10 +99,10 @@ public Result struct(Types.StructType struct, Integer tableSchemaId, List { private final Schema tableSchema; - private boolean caseSensitive = true; + private boolean caseSensitive; - PartnerIdByNameAccessors(Schema tableSchema) { + PartnerIdByNameAccessors(Schema tableSchema, boolean caseSensitive) { this.tableSchema = tableSchema; - } - - private PartnerIdByNameAccessors(Schema tableSchema, boolean caseSensitive) { - this(tableSchema); this.caseSensitive = caseSensitive; } @@ -207,8 +219,7 @@ public Integer fieldPartner(Integer tableSchemaFieldId, int fieldId, String name struct = tableSchema.findField(tableSchemaFieldId).type().asStructType(); } - Types.NestedField field = - caseSensitive ? struct.field(name) : struct.caseInsensitiveField(name); + Types.NestedField field = getFieldFromStruct(name, struct, caseSensitive); if (field != null) { return field.fieldId(); } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java index 33edefe71eb0..4f0b68573ff5 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittable.java @@ -26,7 +26,7 @@ /** * The aggregated results of a single checkpoint which should be committed. Containing the - * serialized {@link DeltaManifests} file - which contains the commit data, and the jobId, + * serialized {@link DeltaManifests} files - which contains the commit data, and the jobId, * operatorId, checkpointId triplet to identify the specific commit. * *

    {@link DynamicCommittableSerializer} is used to serialize {@link DynamicCommittable} between @@ -34,27 +34,27 @@ */ class DynamicCommittable implements Serializable { - private final WriteTarget key; - private final byte[] manifest; + private final TableKey key; + private final byte[][] manifests; private final String jobId; private final String operatorId; private final long checkpointId; DynamicCommittable( - WriteTarget key, byte[] manifest, String jobId, String operatorId, long checkpointId) { + TableKey key, byte[][] manifests, String jobId, String operatorId, long checkpointId) { this.key = key; - this.manifest = manifest; + this.manifests = manifests; this.jobId = jobId; this.operatorId = operatorId; this.checkpointId = checkpointId; } - WriteTarget key() { + TableKey key() { return key; } - byte[] manifest() { - return manifest; + byte[][] manifests() { + return manifests; } String jobId() { @@ -78,14 +78,14 @@ public boolean equals(Object o) { DynamicCommittable that = (DynamicCommittable) o; return checkpointId == that.checkpointId && Objects.equals(key, that.key) - && Objects.deepEquals(manifest, that.manifest) + && Arrays.deepEquals(manifests, that.manifests) && Objects.equals(jobId, that.jobId) && Objects.equals(operatorId, that.operatorId); } @Override public int hashCode() { - return Objects.hash(key, Arrays.hashCode(manifest), jobId, operatorId, checkpointId); + return Objects.hash(key, Arrays.deepHashCode(manifests), jobId, operatorId, checkpointId); } @Override @@ -97,8 +97,4 @@ public String toString() { .add("operatorId", operatorId) .toString(); } - - public WriteTarget writeTarget() { - return key; - } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java index 4aadcf1f3620..d599d29dba01 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommittableSerializer.java @@ -31,11 +31,12 @@ */ class DynamicCommittableSerializer implements SimpleVersionedSerializer { - private static final int VERSION = 1; + private static final int VERSION_1 = 1; + private static final int VERSION_2 = 2; @Override public int getVersion() { - return VERSION; + return VERSION_2; } @Override @@ -46,26 +47,60 @@ public byte[] serialize(DynamicCommittable committable) throws IOException { view.writeUTF(committable.jobId()); view.writeUTF(committable.operatorId()); view.writeLong(committable.checkpointId()); - view.writeInt(committable.manifest().length); - view.write(committable.manifest()); + + int numManifests = committable.manifests().length; + view.writeInt(numManifests); + for (int i = 0; i < numManifests; i++) { + byte[] manifest = committable.manifests()[i]; + view.writeInt(manifest.length); + view.write(manifest); + } + return out.toByteArray(); } @Override public DynamicCommittable deserialize(int version, byte[] serialized) throws IOException { - if (version == 1) { - DataInputDeserializer view = new DataInputDeserializer(serialized); - WriteTarget key = WriteTarget.deserializeFrom(view); - String jobId = view.readUTF(); - String operatorId = view.readUTF(); - long checkpointId = view.readLong(); - int manifestLen = view.readInt(); - byte[] manifestBuf; - manifestBuf = new byte[manifestLen]; - view.read(manifestBuf); - return new DynamicCommittable(key, manifestBuf, jobId, operatorId, checkpointId); + if (version == VERSION_1) { + return deserializeV1(serialized); + } else if (version == VERSION_2) { + return deserializeV2(serialized); } throw new IOException("Unrecognized version or corrupt state: " + version); } + + private DynamicCommittable deserializeV1(byte[] serialized) throws IOException { + DataInputDeserializer view = new DataInputDeserializer(serialized); + WriteTarget key = WriteTarget.deserializeFrom(view); + String jobId = view.readUTF(); + String operatorId = view.readUTF(); + long checkpointId = view.readLong(); + int manifestLen = view.readInt(); + byte[] manifestBuf = new byte[manifestLen]; + view.read(manifestBuf); + return new DynamicCommittable( + new TableKey(key.tableName(), key.branch()), + new byte[][] {manifestBuf}, + jobId, + operatorId, + checkpointId); + } + + private DynamicCommittable deserializeV2(byte[] serialized) throws IOException { + DataInputDeserializer view = new DataInputDeserializer(serialized); + TableKey key = TableKey.deserializeFrom(view); + String jobId = view.readUTF(); + String operatorId = view.readUTF(); + long checkpointId = view.readLong(); + + byte[][] manifestsBuf = new byte[view.readInt()][]; + for (int i = 0; i < manifestsBuf.length; i++) { + byte[] manifest = new byte[view.readInt()]; + view.read(manifest); + manifestsBuf[i] = manifest; + } + + return new DynamicCommittable(key, manifestsBuf, jobId, operatorId, checkpointId); + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java index 1cddc64d6016..5e824773f4bf 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicCommitter.java @@ -19,15 +19,15 @@ package org.apache.iceberg.flink.sink.dynamic; import java.io.IOException; -import java.io.Serializable; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.NavigableMap; -import java.util.Objects; +import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import org.apache.flink.annotation.Internal; import org.apache.flink.api.connector.sink2.Committer; import org.apache.flink.core.io.SimpleVersionedSerialization; @@ -51,12 +51,10 @@ import org.apache.iceberg.flink.sink.FlinkManifestUtil; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; -import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.ContentFileUtil; -import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.util.SnapshotUtil; import org.apache.iceberg.util.ThreadPools; import org.slf4j.Logger; @@ -79,26 +77,15 @@ class DynamicCommitter implements Committer { private static final String MAX_COMMITTED_CHECKPOINT_ID = "flink.max-committed-checkpoint-id"; private static final Logger LOG = LoggerFactory.getLogger(DynamicCommitter.class); - private static final byte[] EMPTY_MANIFEST_DATA = new byte[0]; - private static final WriteResult EMPTY_WRITE_RESULT = - WriteResult.builder() - .addDataFiles(Lists.newArrayList()) - .addDeleteFiles(Lists.newArrayList()) - .build(); private static final long INITIAL_CHECKPOINT_ID = -1L; - @VisibleForTesting - static final String MAX_CONTINUOUS_EMPTY_COMMITS = "flink.max-continuous-empty-commits"; - private static final String FLINK_JOB_ID = "flink.job-id"; private static final String OPERATOR_ID = "flink.operator-id"; private final Map snapshotProperties; private final boolean replacePartitions; private final DynamicCommitterMetrics committerMetrics; private final Catalog catalog; - private final Map maxContinuousEmptyCommitsMap; - private final Map continuousEmptyCheckpointsMap; private final ExecutorService workerPool; DynamicCommitter( @@ -112,9 +99,6 @@ class DynamicCommitter implements Committer { this.replacePartitions = replacePartitions; this.committerMetrics = committerMetrics; this.catalog = catalog; - this.maxContinuousEmptyCommitsMap = Maps.newHashMap(); - this.continuousEmptyCheckpointsMap = Maps.newHashMap(); - this.workerPool = ThreadPools.newFixedThreadPool("iceberg-committer-pool-" + sinkId, workerPoolSize); } @@ -126,9 +110,15 @@ public void commit(Collection> commitRequests) return; } - // For every table and every checkpoint, we store the list of to-be-committed - // DynamicCommittable. - // There may be DynamicCommittable from previous checkpoints which have not been committed yet. + /* + Each (table, branch, checkpoint) triplet must have only one commit request. + There may be commit requests from previous checkpoints which have not been committed yet. + + We currently keep a List of commit requests per checkpoint instead of a single CommitRequest + to process the Flink state from previous releases, which had multiple commit requests created by the upstream + DynamicWriteResultAggregator. Iceberg 1.12 will remove this, and users should upgrade to the 1.11 release first + to migrate their state to a single commit request per checkpoint. + */ Map>>> commitRequestMap = Maps.newHashMap(); for (CommitRequest request : commitRequests) { @@ -151,12 +141,16 @@ public void commit(Collection> commitRequests) : List.of(); long maxCommittedCheckpointId = getMaxCommittedCheckpointId(ancestors, last.jobId(), last.operatorId()); + + NavigableMap>> skippedCommitRequests = + entry.getValue().headMap(maxCommittedCheckpointId, true); + LOG.debug( + "Skipping {} commit requests: {}", skippedCommitRequests.size(), skippedCommitRequests); // Mark the already committed FilesCommittable(s) as finished - entry - .getValue() - .headMap(maxCommittedCheckpointId, true) + skippedCommitRequests .values() .forEach(list -> list.forEach(CommitRequest::signalAlreadyCommitted)); + NavigableMap>> uncommitted = entry.getValue().tailMap(maxCommittedCheckpointId, false); if (!uncommitted.isEmpty()) { @@ -210,89 +204,45 @@ private void commitPendingRequests( NavigableMap> pendingResults = Maps.newTreeMap(); for (Map.Entry>> e : commitRequestMap.entrySet()) { for (CommitRequest committable : e.getValue()) { - if (Arrays.equals(EMPTY_MANIFEST_DATA, committable.getCommittable().manifest())) { - pendingResults - .computeIfAbsent(e.getKey(), unused -> Lists.newArrayList()) - .add(EMPTY_WRITE_RESULT); - } else { + for (byte[] manifest : committable.getCommittable().manifests()) { DeltaManifests deltaManifests = SimpleVersionedSerialization.readVersionAndDeSerialize( - DeltaManifestsSerializer.INSTANCE, committable.getCommittable().manifest()); - - WriteResult writeResult = - FlinkManifestUtil.readCompletedFiles(deltaManifests, table.io(), table.specs()); - if (TableUtil.formatVersion(table) > 2) { - for (DeleteFile deleteFile : writeResult.deleteFiles()) { - if (deleteFile.content() == FileContent.POSITION_DELETES) { - Preconditions.checkArgument( - ContentFileUtil.isDV(deleteFile), - "Can't add position delete file to the %s table. Concurrent table upgrade to V3 is not supported.", - table.name()); - } - } - } - + DeltaManifestsSerializer.INSTANCE, manifest); pendingResults .computeIfAbsent(e.getKey(), unused -> Lists.newArrayList()) - .add(writeResult); + .add(FlinkManifestUtil.readCompletedFiles(deltaManifests, table.io(), table.specs())); manifests.addAll(deltaManifests.manifests()); } } } - CommitSummary summary = new CommitSummary(); - summary.addAll(pendingResults); - commitPendingResult(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - if (committerMetrics != null) { - committerMetrics.updateCommitSummary(table.name(), summary); + if (TableUtil.formatVersion(table) > 2) { + Optional positionalDelete = + pendingResults.values().stream() + .flatMap(List::stream) + .flatMap(writeResult -> Arrays.stream(writeResult.deleteFiles())) + .filter(deleteFile -> deleteFile.content() == FileContent.POSITION_DELETES) + .filter(Predicate.not(ContentFileUtil::isDV)) + .findAny(); + Preconditions.checkArgument( + positionalDelete.isEmpty(), + "Can't add position delete file to the %s table. Concurrent table upgrade to V3 is not supported.", + table.name()); } - FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); - } - - private void commitPendingResult( - Table table, - String branch, - NavigableMap> pendingResults, - CommitSummary summary, - String newFlinkJobId, - String operatorId) { - long totalFiles = summary.dataFilesCount() + summary.deleteFilesCount(); - TableKey key = new TableKey(table.name(), branch); - int continuousEmptyCheckpoints = - continuousEmptyCheckpointsMap.computeIfAbsent(key, unused -> 0); - int maxContinuousEmptyCommits = - maxContinuousEmptyCommitsMap.computeIfAbsent( - key, - unused -> { - int result = - PropertyUtil.propertyAsInt(table.properties(), MAX_CONTINUOUS_EMPTY_COMMITS, 10); - Preconditions.checkArgument( - result > 0, MAX_CONTINUOUS_EMPTY_COMMITS + " must be positive"); - return result; - }); - continuousEmptyCheckpoints = totalFiles == 0 ? continuousEmptyCheckpoints + 1 : 0; - if (totalFiles != 0 || continuousEmptyCheckpoints % maxContinuousEmptyCommits == 0) { - if (replacePartitions) { - replacePartitions(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - } else { - commitDeltaTxn(table, branch, pendingResults, summary, newFlinkJobId, operatorId); - } - - continuousEmptyCheckpoints = 0; + if (replacePartitions) { + replacePartitions(table, branch, pendingResults, newFlinkJobId, operatorId); } else { - long checkpointId = pendingResults.lastKey(); - LOG.info("Skip commit for checkpoint {} due to no data files or delete files.", checkpointId); + commitDeltaTxn(table, branch, pendingResults, newFlinkJobId, operatorId); } - continuousEmptyCheckpointsMap.put(key, continuousEmptyCheckpoints); + FlinkManifestUtil.deleteCommittedManifests(table, manifests, newFlinkJobId, checkpointId); } private void replacePartitions( Table table, String branch, NavigableMap> pendingResults, - CommitSummary summary, String newFlinkJobId, String operatorId) { // Iceberg tables are unsorted. So the order of the append data does not matter. @@ -305,6 +255,9 @@ private void replacePartitions( } } + CommitSummary summary = new CommitSummary(); + summary.addAll(pendingResults); + commitOperation( table, branch, @@ -320,7 +273,6 @@ private void commitDeltaTxn( Table table, String branch, NavigableMap> pendingResults, - CommitSummary summary, String newFlinkJobId, String operatorId) { for (Map.Entry> e : pendingResults.entrySet()) { @@ -340,6 +292,9 @@ private void commitDeltaTxn( Arrays.stream(result.deleteFiles()).forEach(rowDelta::addDeletes); } + CommitSummary summary = new CommitSummary(); + summary.addAll(writeResults); + // Every Flink checkpoint contains a set of independent changes which can be committed // together. While it is technically feasible to combine append-only data across checkpoints, // for the sake of simplicity, we do not implement this (premature) optimization. Multiple @@ -434,6 +389,7 @@ void commitOperation( durationMs); if (committerMetrics != null) { committerMetrics.commitDuration(table.name(), durationMs); + committerMetrics.updateCommitSummary(table.name(), summary); } } @@ -441,54 +397,4 @@ void commitOperation( public void close() throws IOException { workerPool.shutdown(); } - - private static class TableKey implements Serializable { - private String tableName; - private String branch; - - TableKey(String tableName, String branch) { - this.tableName = tableName; - this.branch = branch; - } - - TableKey(DynamicCommittable committable) { - this.tableName = committable.key().tableName(); - this.branch = committable.key().branch(); - } - - String tableName() { - return tableName; - } - - String branch() { - return branch; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - - if (other == null || getClass() != other.getClass()) { - return false; - } - - TableKey that = (TableKey) other; - return tableName.equals(that.tableName) && branch.equals(that.branch); - } - - @Override - public int hashCode() { - return Objects.hash(tableName, branch); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("tableName", tableName) - .add("branch", branch) - .toString(); - } - } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java index cbc50a5839e8..ad430cbf13f8 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicIcebergSink.java @@ -44,15 +44,14 @@ import org.apache.flink.streaming.api.datastream.DataStream; import org.apache.flink.streaming.api.datastream.DataStreamSink; import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator; +import org.apache.flink.streaming.runtime.operators.sink.SinkWriterOperatorFactory; import org.apache.flink.table.data.RowData; import org.apache.flink.util.OutputTag; -import org.apache.iceberg.FileFormat; import org.apache.iceberg.Table; import org.apache.iceberg.flink.CatalogLoader; import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.sink.IcebergSink; -import org.apache.iceberg.flink.sink.SinkUtil; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -78,43 +77,39 @@ public class DynamicIcebergSink private final String uidPrefix; private final String sinkId; private final Map writeProperties; - private final transient FlinkWriteConf flinkWriteConf; - private final FileFormat dataFileFormat; - private final long targetDataFileSize; - private final boolean overwriteMode; - private final int workerPoolSize; + private final Configuration flinkConfig; private final int cacheMaximumSize; + // Set by the builder before sinkTo() — forward writer results to union into pre-commit topology + private final transient DataStream> forwardWriteResults; + DynamicIcebergSink( CatalogLoader catalogLoader, Map snapshotProperties, String uidPrefix, Map writeProperties, - FlinkWriteConf flinkWriteConf, - int cacheMaximumSize) { + Configuration flinkConfig, + int cacheMaximumSize, + DataStream> forwardWriteResults) { this.catalogLoader = catalogLoader; this.snapshotProperties = snapshotProperties; this.uidPrefix = uidPrefix; this.writeProperties = writeProperties; - this.flinkWriteConf = flinkWriteConf; - this.dataFileFormat = flinkWriteConf.dataFileFormat(); - this.targetDataFileSize = flinkWriteConf.targetDataFileSize(); - this.overwriteMode = flinkWriteConf.overwriteMode(); - this.workerPoolSize = flinkWriteConf.workerPoolSize(); + this.flinkConfig = flinkConfig; this.cacheMaximumSize = cacheMaximumSize; // We generate a random UUID every time when a sink is created. // This is used to separate files generated by different sinks writing the same table. // Also used to generate the aggregator operator name this.sinkId = UUID.randomUUID().toString(); + this.forwardWriteResults = forwardWriteResults; } @Override public SinkWriter createWriter(WriterInitContext context) { return new DynamicWriter( catalogLoader.loadCatalog(), - dataFileFormat, - targetDataFileSize, writeProperties, + flinkConfig, cacheMaximumSize, new DynamicWriterMetrics(context.metricGroup()), context.getTaskInfo().getIndexOfThisSubtask(), @@ -123,12 +118,13 @@ public SinkWriter createWriter(WriterInitContext context) @Override public Committer createCommitter(CommitterInitContext context) { + FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeProperties, flinkConfig); DynamicCommitterMetrics metrics = new DynamicCommitterMetrics(context.metricGroup()); return new DynamicCommitter( catalogLoader.loadCatalog(), snapshotProperties, - overwriteMode, - workerPoolSize, + flinkWriteConf.overwriteMode(), + flinkWriteConf.workerPoolSize(), sinkId, metrics); } @@ -154,7 +150,11 @@ public DataStream> addPreCommitTopology( TypeInformation> typeInformation = CommittableMessageTypeInfo.of(this::getCommittableSerializer); - return writeResults + // Union forward writer results with the shuffle writer results + DataStream> allResults = + writeResults.union(forwardWriteResults); + + return allResults .keyBy( committable -> { if (committable instanceof CommittableSummary) { @@ -169,7 +169,7 @@ public DataStream> addPreCommitTopology( prefixIfNotNull(uidPrefix, sinkId + " Pre Commit"), typeInformation, new DynamicWriteResultAggregator(catalogLoader, cacheMaximumSize)) - .uid(prefixIfNotNull(uidPrefix, sinkId + "-pre-commit-topology")); + .uid(prefixIfNotNull(uidPrefix, "-pre-commit-topology")); } @Override @@ -177,6 +177,55 @@ public SimpleVersionedSerializer getWriteResultSerializer() return new DynamicWriteResultSerializer(); } + /** + * A lightweight Sink used with {@link SinkWriterOperatorFactory} for the forward write path. + * Implements {@link SupportsCommitter} so that {@code SinkWriterOperator} emits committables + * downstream. The committer is never called — committing is handled by the main sink. + */ + @VisibleForTesting + static class ForwardWriterSink + implements Sink, SupportsCommitter { + + private final CatalogLoader catalogLoader; + private final Map writeProperties; + private final Configuration flinkConfig; + private final int cacheMaximumSize; + + ForwardWriterSink( + CatalogLoader catalogLoader, + Map writeProperties, + Configuration flinkConfig, + int cacheMaximumSize) { + this.catalogLoader = catalogLoader; + this.writeProperties = writeProperties; + this.flinkConfig = flinkConfig; + this.cacheMaximumSize = cacheMaximumSize; + } + + @Override + public SinkWriter createWriter(WriterInitContext context) { + return new DynamicWriter( + catalogLoader.loadCatalog(), + writeProperties, + flinkConfig, + cacheMaximumSize, + new DynamicWriterMetrics(context.metricGroup()), + context.getTaskInfo().getIndexOfThisSubtask(), + context.getTaskInfo().getAttemptNumber()); + } + + @Override + public Committer createCommitter(CommitterInitContext context) { + throw new UnsupportedOperationException( + "WriterSink is used only for writing; committing is handled by the main sink"); + } + + @Override + public SimpleVersionedSerializer getCommittableSerializer() { + return new DynamicWriteResultSerializer(); + } + } + public static class Builder { private DataStream input; private DynamicRecordGenerator generator; @@ -186,10 +235,6 @@ public static class Builder { private final Map snapshotSummary = Maps.newHashMap(); private ReadableConfig readableConfig = new Configuration(); private TableCreator tableCreator = TableCreator.DEFAULT; - private boolean immediateUpdate = false; - private int cacheMaximumSize = 100; - private long cacheRefreshMs = 1_000; - private int inputSchemasPerTableCacheMaximumSize = 10; Builder() {} @@ -310,19 +355,39 @@ public Builder toBranch(String branch) { } public Builder immediateTableUpdate(boolean newImmediateUpdate) { - this.immediateUpdate = newImmediateUpdate; + writeOptions.put( + FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.key(), + Boolean.toString(newImmediateUpdate)); + return this; + } + + /** + * Dropping columns is disabled by default to prevent issues with late or out-of-order data, as + * removed fields cannot be easily restored without data loss. + * + *

    You can opt-in to allow dropping columns. Once a column has been dropped, it is + * technically still possible to write data to that column because Iceberg maintains all past + * table schemas. However, regular queries won't be able to reference the column. If the field + * was to re-appear as part of a new schema, an entirely new column would be added, which apart + * from the name, has nothing in common with the old column, i.e. queries for the new column + * will never return data of the old column. + */ + public Builder dropUnusedColumns(boolean newDropUnusedColumns) { + writeOptions.put( + FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key(), + Boolean.toString(newDropUnusedColumns)); return this; } /** Maximum size of the caches used in Dynamic Sink for table data and serializers. */ public Builder cacheMaxSize(int maxSize) { - this.cacheMaximumSize = maxSize; + writeOptions.put(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.key(), Integer.toString(maxSize)); return this; } /** Maximum interval for cache items renewals. */ public Builder cacheRefreshMs(long refreshMs) { - this.cacheRefreshMs = refreshMs; + writeOptions.put(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.key(), Long.toString(refreshMs)); return this; } @@ -332,7 +397,19 @@ public Builder cacheRefreshMs(long refreshMs) { * comparison results. */ public Builder inputSchemasPerTableCacheMaxSize(int inputSchemasPerTableCacheMaxSize) { - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaxSize; + writeOptions.put( + FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.key(), + Integer.toString(inputSchemasPerTableCacheMaxSize)); + return this; + } + + /** + * Set whether schema field name matching should be case-sensitive. The default is to match the + * field names case-sensitive. + */ + public Builder caseSensitive(boolean newCaseSensitive) { + writeOptions.put( + FlinkDynamicSinkOptions.CASE_SENSITIVE.key(), Boolean.toString(newCaseSensitive)); return this; } @@ -340,81 +417,134 @@ private String operatorName(String suffix) { return uidPrefix != null ? uidPrefix + "-" + suffix : suffix; } - private DynamicIcebergSink build() { + private DynamicIcebergSink build( + SingleOutputStreamOperator converted, + DynamicRecordInternalType sideOutputType) { Preconditions.checkArgument( generator != null, "Please use withGenerator() to convert the input DataStream."); Preconditions.checkNotNull(catalogLoader, "Catalog loader shouldn't be null"); - FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeOptions, readableConfig); - Map writeProperties = - SinkUtil.writeProperties(flinkWriteConf.dataFileFormat(), flinkWriteConf, null); - uidPrefix = Optional.ofNullable(uidPrefix).orElse(""); + Configuration flinkConfig = fromReadableConfig(); + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeOptions, flinkConfig); + + // Forward writer: chained with generator via forward edge, no data shuffle + ForwardWriterSink forwardWriterSink = + new ForwardWriterSink( + catalogLoader, writeOptions, flinkConfig, flinkDynamicSinkConf.cacheMaxSize()); + TypeInformation> writeResultTypeInfo = + CommittableMessageTypeInfo.of(DynamicWriteResultSerializer::new); - return instantiateSink(writeProperties, flinkWriteConf); + DataStream> forwardWriteResults = + converted + .getSideOutput( + new OutputTag<>(DynamicRecordProcessor.DYNAMIC_FORWARD_STREAM, sideOutputType)) + .transform( + operatorName("Forward-Writer"), + writeResultTypeInfo, + new SinkWriterOperatorFactory<>(forwardWriterSink)) + .setParallelism(converted.getParallelism()) + .uid(prefixIfNotNull(uidPrefix, "-forward-writer")); + + // Inject forward write results into sink — they'll be unioned in addPreCommitTopology + return instantiateSink(writeOptions, flinkConfig, forwardWriteResults); } @VisibleForTesting DynamicIcebergSink instantiateSink( - Map writeProperties, FlinkWriteConf flinkWriteConf) { + Map writeProperties, + Configuration flinkWriteConf, + DataStream> forwardWriteResults) { + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeProperties, flinkWriteConf); return new DynamicIcebergSink( catalogLoader, snapshotSummary, uidPrefix, writeProperties, flinkWriteConf, - cacheMaximumSize); + flinkDynamicSinkConf.cacheMaxSize(), + forwardWriteResults); } /** * Append the iceberg sink operators to write records to iceberg table. * + *

    The topology splits records by distribution mode: + * + *

      + *
    • Forward records ({@code null} distributionMode) go through a forward edge to a chained + * writer, avoiding any data shuffle. + *
    • Shuffle records (non-null distributionMode) go through the standard Sink2 pipeline with + * hash/round-robin distribution. + *
    + * + * Both writers feed into a single shared pre-commit aggregator and committer, ensuring atomic + * commits across both paths. + * * @return {@link DataStreamSink} for sink. */ public DataStreamSink append() { + uidPrefix = Optional.ofNullable(uidPrefix).orElse(""); + + FlinkDynamicSinkConf flinkDynamicSinkConf = + new FlinkDynamicSinkConf(writeOptions, readableConfig); + Configuration flinkConfig = fromReadableConfig(); + DynamicRecordInternalType type = - new DynamicRecordInternalType(catalogLoader, false, cacheMaximumSize); - DynamicIcebergSink sink = build(); + new DynamicRecordInternalType(catalogLoader, false, flinkDynamicSinkConf.cacheMaxSize()); + DynamicRecordInternalType sideOutputType = + new DynamicRecordInternalType(catalogLoader, true, flinkDynamicSinkConf.cacheMaxSize()); + SingleOutputStreamOperator converted = input .process( new DynamicRecordProcessor<>( generator, catalogLoader, - immediateUpdate, - cacheMaximumSize, - cacheRefreshMs, - inputSchemasPerTableCacheMaximumSize, - tableCreator)) + tableCreator, + flinkDynamicSinkConf, + writeOptions, + flinkConfig)) + .setParallelism(input.getParallelism()) .uid(prefixIfNotNull(uidPrefix, "-generator")) .name(operatorName("generator")) .returns(type); - DataStreamSink rowDataDataStreamSink = + DynamicIcebergSink sink = build(converted, sideOutputType); + + // Shuffle path: table update side output + main output → sinkTo() + DataStream shuffleInput = converted .getSideOutput( new OutputTag<>( - DynamicRecordProcessor.DYNAMIC_TABLE_UPDATE_STREAM, - new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize))) + DynamicRecordProcessor.DYNAMIC_TABLE_UPDATE_STREAM, sideOutputType)) .keyBy((KeySelector) DynamicRecordInternal::tableName) .map( - new DynamicTableUpdateOperator( - catalogLoader, - cacheMaximumSize, - cacheRefreshMs, - inputSchemasPerTableCacheMaximumSize, - tableCreator)) + new DynamicTableUpdateOperator(catalogLoader, tableCreator, flinkDynamicSinkConf)) .uid(prefixIfNotNull(uidPrefix, "-updater")) .name(operatorName("Updater")) .returns(type) - .union(converted) - .sinkTo(sink) + .union(converted); + + DataStreamSink result = + shuffleInput + .sinkTo(sink) // Forward write results are implicitly injected here .uid(prefixIfNotNull(uidPrefix, "-sink")); - if (sink.flinkWriteConf.writeParallelism() != null) { - rowDataDataStreamSink.setParallelism(sink.flinkWriteConf.writeParallelism()); + + FlinkWriteConf flinkWriteConf = new FlinkWriteConf(writeOptions, readableConfig); + if (flinkWriteConf.writeParallelism() != null) { + result.setParallelism(flinkWriteConf.writeParallelism()); } - return rowDataDataStreamSink; + return result; + } + + private Configuration fromReadableConfig() { + return readableConfig instanceof Configuration + ? (Configuration) readableConfig + : Configuration.fromMap(readableConfig.toMap()); } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java index 9f445766083e..6507a575c2af 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecord.java @@ -20,6 +20,7 @@ import java.util.Set; import javax.annotation.Nullable; +import org.apache.flink.annotation.Internal; import org.apache.flink.table.data.RowData; import org.apache.iceberg.DistributionMode; import org.apache.iceberg.PartitionSpec; @@ -34,20 +35,43 @@ public class DynamicRecord { private Schema schema; private RowData rowData; private PartitionSpec partitionSpec; - private DistributionMode distributionMode; + @Nullable private DistributionMode distributionMode; private int writeParallelism; private boolean upsertMode; @Nullable private Set equalityFields; + @Internal + DynamicRecord() {} + + /** + * Constructs a new DynamicRecord with forward (no shuffle) writes. + * + * @param tableIdentifier The target table identifier. + * @param branch The target table branch. + * @param schema The target table schema. + * @param rowData The data matching the provided schema. + * @param partitionSpec The target table {@link PartitionSpec}. + */ + public DynamicRecord( + TableIdentifier tableIdentifier, + String branch, + Schema schema, + RowData rowData, + PartitionSpec partitionSpec) { + this(tableIdentifier, branch, schema, rowData, partitionSpec, null, -1); + } + /** - * Constructs a new DynamicRecord. + * Constructs a new DynamicRecord. This record will be shuffled as specified by {@code + * distributionMode}. * * @param tableIdentifier The target table identifier. * @param branch The target table branch. * @param schema The target table schema. * @param rowData The data matching the provided schema. * @param partitionSpec The target table {@link PartitionSpec}. - * @param distributionMode The {@link DistributionMode}. + * @param distributionMode The {@link DistributionMode}. {@code null} indicates forward (no + * shuffle) writes. * @param writeParallelism The number of parallel writers. Can be set to any value {@literal > 0}, * but will always be automatically capped by the maximum write parallelism, which is the * parallelism of the sink. Set to Integer.MAX_VALUE for always using the maximum available diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java index 22b7742913e7..0d758ace1b19 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializer.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.Set; import org.apache.flink.annotation.Internal; +import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.common.typeutils.TypeSerializer; import org.apache.flink.api.common.typeutils.TypeSerializerSchemaCompatibility; import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot; @@ -34,6 +35,9 @@ import org.apache.iceberg.PartitionSpecParser; import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.flink.util.SerializerHelper; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @Internal @@ -43,18 +47,27 @@ class DynamicRecordInternalSerializer extends TypeSerializer duplicate() { return new DynamicRecordInternalSerializer( new TableSerializerCache(serializerCache.catalogLoader(), serializerCache.maximumSize()), - writeSchemaAndSpec); + writeSchemaAndSpec, + writeLongUTF); } @Override @@ -68,7 +81,12 @@ public void serialize(DynamicRecordInternal toSerialize, DataOutputView dataOutp dataOutputView.writeUTF(toSerialize.tableName()); dataOutputView.writeUTF(toSerialize.branch()); if (writeSchemaAndSpec) { - dataOutputView.writeUTF(SchemaParser.toJson(toSerialize.schema())); + if (writeLongUTF) { + SerializerHelper.writeLongUTF(dataOutputView, SchemaParser.toJson(toSerialize.schema())); + } else { + dataOutputView.writeUTF(SchemaParser.toJson(toSerialize.schema())); + } + dataOutputView.writeUTF(PartitionSpecParser.toJson(toSerialize.spec())); } else { dataOutputView.writeInt(toSerialize.schema().schemaId()); @@ -108,7 +126,12 @@ public DynamicRecordInternal deserialize(DataInputView dataInputView) throws IOE final PartitionSpec spec; final RowDataSerializer rowDataSerializer; if (writeSchemaAndSpec) { - schema = SchemaParser.fromJson(dataInputView.readUTF()); + if (writeLongUTF) { + schema = SchemaParser.fromJson(SerializerHelper.readLongUTF(dataInputView)); + } else { + schema = SchemaParser.fromJson(dataInputView.readUTF()); + } + spec = PartitionSpecParser.fromJson(schema, dataInputView.readUTF()); rowDataSerializer = serializerCache.serializer(tableName, schema, spec); } else { @@ -152,7 +175,12 @@ public DynamicRecordInternal deserialize(DynamicRecordInternal reuse, DataInputV final PartitionSpec spec; final RowDataSerializer rowDataSerializer; if (writeSchemaAndSpec) { - schema = SchemaParser.fromJson(dataInputView.readUTF()); + if (writeLongUTF) { + schema = SchemaParser.fromJson(SerializerHelper.readLongUTF(dataInputView)); + } else { + schema = SchemaParser.fromJson(dataInputView.readUTF()); + } + spec = PartitionSpecParser.fromJson(schema, dataInputView.readUTF()); reuse.setSchema(schema); reuse.setSpec(spec); @@ -245,25 +273,32 @@ public int getLength() { @Override public TypeSerializerSnapshot snapshotConfiguration() { - return new DynamicRecordInternalTypeSerializerSnapshot(writeSchemaAndSpec); + return new DynamicRecordInternalTypeSerializerSnapshot(writeSchemaAndSpec, serializerCache); } public static class DynamicRecordInternalTypeSerializerSnapshot implements TypeSerializerSnapshot { + private static final int MOST_RECENT_VERSION = 1; + private boolean writeSchemaAndSpec; + private int version; + private TableSerializerCache serializerCache; - // Zero args constructor is required to instantiate this class on restore + // Zero args constructor is required to instantiate this class on restore via readSnapshot(..) @SuppressWarnings({"unused", "checkstyle:RedundantModifier"}) public DynamicRecordInternalTypeSerializerSnapshot() {} - DynamicRecordInternalTypeSerializerSnapshot(boolean writeSchemaAndSpec) { + DynamicRecordInternalTypeSerializerSnapshot( + boolean writeSchemaAndSpec, TableSerializerCache serializerCache) { this.writeSchemaAndSpec = writeSchemaAndSpec; + this.serializerCache = serializerCache; + this.version = MOST_RECENT_VERSION; } @Override public int getCurrentVersion() { - return 0; + return version; } @Override @@ -274,22 +309,62 @@ public void writeSnapshot(DataOutputView out) throws IOException { @Override public void readSnapshot(int readVersion, DataInputView in, ClassLoader userCodeClassLoader) throws IOException { + this.version = readVersion; this.writeSchemaAndSpec = in.readBoolean(); } @Override public TypeSerializerSchemaCompatibility resolveSchemaCompatibility( TypeSerializerSnapshot oldSerializerSnapshot) { - return TypeSerializerSchemaCompatibility.compatibleAsIs(); + if (oldSerializerSnapshot.getCurrentVersion() == getCurrentVersion()) { + return TypeSerializerSchemaCompatibility.compatibleAsIs(); + } + + // Old TypeSerializerSnapshots do not contain the serializer cache, but the newest one does. + // This will also ensure that we always use the up-to-date cache alongside with its catalog + // configuration. + Preconditions.checkNotNull(serializerCache, "serializerCache should not be null"); + try { + DynMethods.builder("initializeSerializerCache") + .hiddenImpl( + DynamicRecordInternalTypeSerializerSnapshot.class, TableSerializerCache.class) + .build() + .invoke(oldSerializerSnapshot, serializerCache); + } catch (Exception e) { + throw new RuntimeException( + "Failed to initialize serializerCache for reading data with old serializer", e); + } + + // This will first read data with the old serializer, then switch to the most recent one. + return TypeSerializerSchemaCompatibility.compatibleAfterMigration(); } @Override public TypeSerializer restoreSerializer() { - // Note: We pass in a null serializer cache which would create issues if we tried to use this - // restored serializer, but since we are using {@code - // TypeSerializerSchemaCompatibility.compatibleAsIs()} above, this serializer will never be - // used. A new one will be created via {@code DynamicRecordInternalType}. - return new DynamicRecordInternalSerializer(null, writeSchemaAndSpec); + if (getCurrentVersion() < MOST_RECENT_VERSION) { + // If this serializer is not the most recent one, we need to read old data with the correct + // parameters. + return new DynamicRecordInternalSerializer(serializerCache, writeSchemaAndSpec, false); + } + + // In all other cases, we just use the newest serializer. + return new DynamicRecordInternalSerializer(serializerCache, writeSchemaAndSpec, true); + } + + /** + * We need to lazily initialize the cache from the up-to-date serializer which has the current + * CatalogLoader available. + * + *

    This method must not be removed! + */ + @SuppressWarnings("unused") + private void initializeSerializerCache(TableSerializerCache cache) { + this.serializerCache = cache; } } + + @VisibleForTesting + TableSerializerCache getSerializerCache() { + return serializerCache; + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java index bc3a25468d84..c752b8e9b8d9 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordProcessor.java @@ -18,10 +18,12 @@ */ package org.apache.iceberg.flink.sink.dynamic; +import java.util.Map; import org.apache.flink.annotation.Internal; import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.common.functions.OpenContext; import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.configuration.Configuration; import org.apache.flink.streaming.api.functions.ProcessFunction; import org.apache.flink.table.data.RowData; import org.apache.flink.util.Collector; @@ -30,6 +32,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.flink.CatalogLoader; +import org.apache.iceberg.flink.FlinkWriteConf; @Internal class DynamicRecordProcessor extends ProcessFunction @@ -37,36 +40,47 @@ class DynamicRecordProcessor extends ProcessFunction generator; private final CatalogLoader catalogLoader; + private final Map writeProperties; + private final Configuration flinkConfig; private final boolean immediateUpdate; + private final boolean dropUnusedColumns; private final int cacheMaximumSize; private final long cacheRefreshMs; private final int inputSchemasPerTableCacheMaximumSize; private final TableCreator tableCreator; + private final boolean caseSensitive; private transient TableMetadataCache tableCache; private transient HashKeyGenerator hashKeyGenerator; private transient TableUpdater updater; private transient OutputTag updateStream; + private transient OutputTag forwardStream; private transient Collector collector; + private transient DynamicRecordWithConfig dynamicRecordWithConfig; private transient Context context; DynamicRecordProcessor( DynamicRecordGenerator generator, CatalogLoader catalogLoader, - boolean immediateUpdate, - int cacheMaximumSize, - long cacheRefreshMs, - int inputSchemasPerTableCacheMaximumSize, - TableCreator tableCreator) { + TableCreator tableCreator, + FlinkDynamicSinkConf sinkConfig, + Map writeProperties, + Configuration flinkConfig) { this.generator = generator; this.catalogLoader = catalogLoader; - this.immediateUpdate = immediateUpdate; - this.cacheMaximumSize = cacheMaximumSize; - this.cacheRefreshMs = cacheRefreshMs; - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; + this.flinkConfig = flinkConfig; + this.writeProperties = writeProperties; + this.immediateUpdate = sinkConfig.immediateTableUpdate(); + this.cacheMaximumSize = sinkConfig.cacheMaxSize(); + this.cacheRefreshMs = sinkConfig.cacheRefreshMs(); + this.inputSchemasPerTableCacheMaximumSize = sinkConfig.inputSchemasPerTableCacheMaxSize(); this.tableCreator = tableCreator; + this.caseSensitive = sinkConfig.caseSensitive(); + this.dropUnusedColumns = sinkConfig.dropUnusedColumns(); } @Override @@ -75,19 +89,31 @@ public void open(OpenContext openContext) throws Exception { Catalog catalog = catalogLoader.loadCatalog(); this.tableCache = new TableMetadataCache( - catalog, cacheMaximumSize, cacheRefreshMs, inputSchemasPerTableCacheMaximumSize); + catalog, + cacheMaximumSize, + cacheRefreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns); this.hashKeyGenerator = new HashKeyGenerator( cacheMaximumSize, getRuntimeContext().getTaskInfo().getMaxNumberOfParallelSubtasks()); - if (immediateUpdate) { - updater = new TableUpdater(tableCache, catalog); - } else { + // Always create updater — needed for forced immediate updates on forward records + this.updater = new TableUpdater(tableCache, catalog, caseSensitive, dropUnusedColumns); + // Always create forward stream tag for forward (distributionMode == null) records + this.forwardStream = + new OutputTag<>( + DYNAMIC_FORWARD_STREAM, + new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize)) {}; + if (!immediateUpdate) { updateStream = new OutputTag<>( DYNAMIC_TABLE_UPDATE_STREAM, new DynamicRecordInternalType(catalogLoader, true, cacheMaximumSize)) {}; } + this.dynamicRecordWithConfig = + new DynamicRecordWithConfig(new FlinkWriteConf(writeProperties, flinkConfig)); generator.open(openContext); } @@ -100,7 +126,10 @@ public void processElement(T element, Context ctx, Collector newData = updater.update( data.tableIdentifier(), data.branch(), data.schema(), data.spec(), tableCreator); emit( - collector, data, newData.f0.resolvedTableSchema(), newData.f0.recordConverter(), - newData.f1); + newData.f1, + isForward); } else { + // Shuffled records with immediateUpdate=false go to the update side output int writerKey = hashKeyGenerator.generateKey( data, @@ -148,33 +182,38 @@ public void collect(DynamicRecord data) { } } else { emit( - collector, data, foundSchema.resolvedTableSchema(), foundSchema.recordConverter(), - foundSpec); + foundSpec, + isForward); } } private void emit( - Collector out, DynamicRecord data, Schema schema, DataConverter recordConverter, - PartitionSpec spec) { + PartitionSpec spec, + boolean forward) { RowData rowData = (RowData) recordConverter.convert(data.rowData()); - int writerKey = hashKeyGenerator.generateKey(data, schema, spec, rowData); - String tableName = data.tableIdentifier().toString(); - out.collect( + // writerKey is unused in the forward path. + int writerKey = forward ? -1 : hashKeyGenerator.generateKey(data, schema, spec, rowData); + DynamicRecordInternal record = new DynamicRecordInternal( - tableName, + data.tableIdentifier().toString(), data.branch(), schema, rowData, spec, writerKey, data.upsertMode(), - DynamicSinkUtil.getEqualityFieldIds(data.equalityFields(), schema))); + DynamicSinkUtil.getEqualityFieldIds(data.equalityFields(), schema)); + if (forward) { + context.output(forwardStream, record); + } else { + collector.collect(record); + } } @Override diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java new file mode 100644 index 000000000000..32716c3e4ac7 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordWithConfig.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.util.Set; +import org.apache.flink.table.data.RowData; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; + +class DynamicRecordWithConfig extends DynamicRecord { + private final String defaultBranch; + private final Integer defaultWriteParallelism; + + private DynamicRecord wrapped; + + DynamicRecordWithConfig(FlinkWriteConf flinkWriteConf) { + this.defaultBranch = flinkWriteConf.branch(); + this.defaultWriteParallelism = flinkWriteConf.writeParallelism(); + } + + DynamicRecordWithConfig wrap(DynamicRecord newWrapped) { + this.wrapped = newWrapped; + return this; + } + + @Override + public String branch() { + return wrapped.branch() != null ? wrapped.branch() : defaultBranch; + } + + @Override + public DistributionMode distributionMode() { + return wrapped.distributionMode(); + } + + @Override + public int writeParallelism() { + int originalParallelism = wrapped.writeParallelism(); + if (originalParallelism > 0 || defaultWriteParallelism == null) { + return originalParallelism; + } + + return defaultWriteParallelism; + } + + @Override + public TableIdentifier tableIdentifier() { + return wrapped.tableIdentifier(); + } + + @Override + public Schema schema() { + return wrapped.schema(); + } + + @Override + public PartitionSpec spec() { + return wrapped.spec(); + } + + @Override + public RowData rowData() { + return wrapped.rowData(); + } + + @Override + public boolean upsertMode() { + return wrapped.upsertMode(); + } + + @Override + public Set equalityFields() { + return wrapped.equalityFields(); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java new file mode 100644 index 000000000000..684da9bb54c7 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableRecordGenerator.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; + +/** + * Abstract base class for SQL-based dynamic record generators. Users will extend this class to + * create a DynamicRecord from RowData. + */ +public abstract class DynamicTableRecordGenerator implements DynamicRecordGenerator { + + private final RowType rowType; + + public DynamicTableRecordGenerator(RowType rowType) { + this.rowType = rowType; + } + + protected RowType rowType() { + return rowType; + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java index 586239b54bca..93c268ff86ad 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicTableUpdateOperator.java @@ -38,24 +38,25 @@ class DynamicTableUpdateOperator extends RichMapFunction { private final CatalogLoader catalogLoader; + private final boolean dropUnusedColumns; private final int cacheMaximumSize; private final long cacheRefreshMs; private final int inputSchemasPerTableCacheMaximumSize; private final TableCreator tableCreator; + private final boolean caseSensitive; private transient TableUpdater updater; DynamicTableUpdateOperator( - CatalogLoader catalogLoader, - int cacheMaximumSize, - long cacheRefreshMs, - int inputSchemasPerTableCacheMaximumSize, - TableCreator tableCreator) { + CatalogLoader catalogLoader, TableCreator tableCreator, FlinkDynamicSinkConf configuration) { this.catalogLoader = catalogLoader; - this.cacheMaximumSize = cacheMaximumSize; - this.cacheRefreshMs = cacheRefreshMs; - this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; this.tableCreator = tableCreator; + + this.cacheMaximumSize = configuration.cacheMaxSize(); + this.cacheRefreshMs = configuration.cacheRefreshMs(); + this.inputSchemasPerTableCacheMaximumSize = configuration.inputSchemasPerTableCacheMaxSize(); + this.caseSensitive = configuration.caseSensitive(); + this.dropUnusedColumns = configuration.dropUnusedColumns(); } @Override @@ -65,8 +66,15 @@ public void open(OpenContext openContext) throws Exception { this.updater = new TableUpdater( new TableMetadataCache( - catalog, cacheMaximumSize, cacheRefreshMs, inputSchemasPerTableCacheMaximumSize), - catalog); + catalog, + cacheMaximumSize, + cacheRefreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns), + catalog, + caseSensitive, + dropUnusedColumns); } @Override diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java index 85806f932ad5..d8d0ed6b573e 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResult.java @@ -19,22 +19,37 @@ package org.apache.iceberg.flink.sink.dynamic; import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; class DynamicWriteResult { - - private final WriteTarget key; + private final TableKey key; + private final int specId; private final WriteResult writeResult; - DynamicWriteResult(WriteTarget key, WriteResult writeResult) { + DynamicWriteResult(TableKey key, int specId, WriteResult writeResult) { this.key = key; + this.specId = specId; this.writeResult = writeResult; } - WriteTarget key() { + TableKey key() { return key; } + public int specId() { + return specId; + } + WriteResult writeResult() { return writeResult; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("key", key) + .add("specId", specId) + .add("writeResult", writeResult) + .toString(); + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java index 991f72bc4564..41f01c64757f 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultAggregator.java @@ -44,6 +44,7 @@ import org.apache.iceberg.flink.sink.FlinkManifestUtil; import org.apache.iceberg.flink.sink.ManifestOutputFileFactory; import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.slf4j.Logger; @@ -60,14 +61,13 @@ class DynamicWriteResultAggregator CommittableMessage, CommittableMessage> { private static final Logger LOG = LoggerFactory.getLogger(DynamicWriteResultAggregator.class); - private static final byte[] EMPTY_MANIFEST_DATA = new byte[0]; private final CatalogLoader catalogLoader; private final int cacheMaximumSize; private long lastCheckpointId = CheckpointIDCounter.INITIAL_CHECKPOINT_ID - 1; - private transient Map> results; + private transient Map>> resultsByTableKeyAndSpec; private transient Map> specs; private transient Map> outputFileFactoriesAndFormatVersions; @@ -95,7 +95,7 @@ public void open() throws Exception { this.operatorId = getOperatorID().toString(); this.subTaskId = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask(); this.attemptId = getRuntimeContext().getTaskInfo().getAttemptNumber(); - this.results = Maps.newHashMap(); + this.resultsByTableKeyAndSpec = Maps.newHashMap(); this.specs = new LRUCache<>(cacheMaximumSize); this.outputFileFactoriesAndFormatVersions = new LRUCache<>(cacheMaximumSize); this.catalog = catalogLoader.loadCatalog(); @@ -119,14 +119,15 @@ public void prepareSnapshotPreBarrier(long checkpointId) throws IOException { this.lastCheckpointId = checkpointId; Collection> committables = - Sets.newHashSetWithExpectedSize(results.size()); + Sets.newHashSetWithExpectedSize(resultsByTableKeyAndSpec.size()); int count = 0; - for (Map.Entry> entries : results.entrySet()) { + for (Map.Entry>> entries : + resultsByTableKeyAndSpec.entrySet()) { committables.add( new CommittableWithLineage<>( new DynamicCommittable( entries.getKey(), - writeToManifest(entries.getKey(), entries.getValue(), checkpointId), + writeToManifests(entries.getKey().tableName(), entries.getValue(), checkpointId), getContainingTask().getEnvironment().getJobID().toString(), getRuntimeContext().getOperatorUniqueID(), checkpointId), @@ -144,32 +145,42 @@ public void prepareSnapshotPreBarrier(long checkpointId) throws IOException { new StreamRecord<>( new CommittableWithLineage<>(c.getCommittable(), checkpointId, subTaskId)))); LOG.info("Emitted {} commit message to downstream committer operator", count); - results.clear(); + resultsByTableKeyAndSpec.clear(); } /** - * Write all the completed data files to a newly created manifest file and return the manifest's + * Write all the completed data files to a newly created manifest files and return the manifests' * avro serialized bytes. */ @VisibleForTesting - byte[] writeToManifest( - WriteTarget key, Collection writeResults, long checkpointId) + byte[][] writeToManifests( + String tableName, Map> writeResultsBySpec, long checkpointId) throws IOException { - if (writeResults.isEmpty()) { - return EMPTY_MANIFEST_DATA; + byte[][] deltaManifestsBySpec = new byte[writeResultsBySpec.size()][]; + int idx = 0; + for (Map.Entry> entry : writeResultsBySpec.entrySet()) { + deltaManifestsBySpec[idx] = + writeToManifest(tableName, entry.getKey(), entry.getValue(), checkpointId); + idx++; } + return deltaManifestsBySpec; + } + + private byte[] writeToManifest( + String tableName, int specId, Collection writeResults, long checkpointId) + throws IOException { WriteResult.Builder builder = WriteResult.builder(); - writeResults.forEach(w -> builder.add(w.writeResult())); + writeResults.forEach(builder::add); WriteResult result = builder.build(); Tuple2 outputFileFactoryAndVersion = - outputFileFactoryAndFormatVersion(key.tableName()); + outputFileFactoryAndFormatVersion(tableName); DeltaManifests deltaManifests = FlinkManifestUtil.writeCompletedFiles( result, () -> outputFileFactoryAndVersion.f0.create(checkpointId), - spec(key.tableName(), key.specId()), + spec(tableName, specId), outputFileFactoryAndVersion.f1); return SimpleVersionedSerialization.writeVersionAndSerialize( @@ -183,8 +194,16 @@ public void processElement(StreamRecord> if (element.isRecord() && element.getValue() instanceof CommittableWithLineage) { DynamicWriteResult result = ((CommittableWithLineage) element.getValue()).getCommittable(); - WriteTarget key = result.key(); - results.computeIfAbsent(key, unused -> Sets.newHashSet()).add(result); + Collection resultsPerTableKeyAndSpec = + resultsByTableKeyAndSpec + .computeIfAbsent(result.key(), unused -> Maps.newHashMap()) + .computeIfAbsent(result.specId(), unused -> Lists.newArrayList()); + resultsPerTableKeyAndSpec.add(result.writeResult()); + LOG.debug( + "Added {}, specId={}, totalResults={}", + result, + result.specId(), + resultsPerTableKeyAndSpec.size()); } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java index cf5f423fd7ff..5153ec6a49ee 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriteResultSerializer.java @@ -41,6 +41,7 @@ public byte[] serialize(DynamicWriteResult writeResult) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); writeResult.key().serializeTo(view); + view.writeInt(writeResult.specId()); byte[] result = WRITE_RESULT_SERIALIZER.serialize(writeResult.writeResult()); view.write(result); return out.toByteArray(); @@ -50,11 +51,12 @@ public byte[] serialize(DynamicWriteResult writeResult) throws IOException { public DynamicWriteResult deserialize(int version, byte[] serialized) throws IOException { if (version == 1) { DataInputDeserializer view = new DataInputDeserializer(serialized); - WriteTarget key = WriteTarget.deserializeFrom(view); + TableKey key = TableKey.deserializeFrom(view); + int specId = view.readInt(); byte[] resultBuf = new byte[view.available()]; view.read(resultBuf); WriteResult writeResult = WRITE_RESULT_SERIALIZER.deserialize(version, resultBuf); - return new DynamicWriteResult(key, writeResult); + return new DynamicWriteResult(key, specId, writeResult); } throw new IOException("Unrecognized version or corrupt state: " + version); diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java index c2a303285801..fcd0d082704a 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriter.java @@ -28,14 +28,16 @@ import org.apache.flink.annotation.VisibleForTesting; import org.apache.flink.api.connector.sink2.CommittingSinkWriter; import org.apache.flink.api.connector.sink2.SinkWriter; +import org.apache.flink.configuration.Configuration; import org.apache.flink.table.data.RowData; -import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionField; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.sink.SinkUtil; import org.apache.iceberg.io.TaskWriter; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; @@ -56,27 +58,24 @@ class DynamicWriter implements CommittingSinkWriter taskWriterFactories; private final Map> writers; + private final Configuration flinkConfig; + private final Map commonWriteProperties; private final DynamicWriterMetrics metrics; private final int subTaskId; private final int attemptId; private final Catalog catalog; - private final FileFormat dataFileFormat; - private final long targetDataFileSize; - private final Map commonWriteProperties; DynamicWriter( Catalog catalog, - FileFormat dataFileFormat, - long targetDataFileSize, Map commonWriteProperties, + Configuration flinkConfig, int cacheMaximumSize, DynamicWriterMetrics metrics, int subTaskId, int attemptId) { this.catalog = catalog; - this.dataFileFormat = dataFileFormat; - this.targetDataFileSize = targetDataFileSize; this.commonWriteProperties = commonWriteProperties; + this.flinkConfig = flinkConfig; this.metrics = metrics; this.subTaskId = subTaskId; this.attemptId = attemptId; @@ -106,10 +105,6 @@ public void write(DynamicRecordInternal element, Context context) Table table = catalog.loadTable(TableIdentifier.parse(factoryKey.tableName())); - Map tableWriteProperties = - Maps.newHashMap(table.properties()); - tableWriteProperties.putAll(commonWriteProperties); - Set equalityFieldIds = getEqualityFields(table, element.equalityFields()); if (element.upsertMode()) { @@ -128,12 +123,18 @@ public void write(DynamicRecordInternal element, Context context) } } + FlinkWriteConf flinkWriteConf = + new FlinkWriteConf(table, commonWriteProperties, flinkConfig); + Map tableWriteProperties = + SinkUtil.writeProperties( + flinkWriteConf.dataFileFormat(), flinkWriteConf, table); + LOG.debug("Creating new writer factory for table '{}'", table.name()); return new RowDataTaskWriterFactory( () -> table, FlinkSchemaUtil.convert(element.schema()), - targetDataFileSize, - dataFileFormat, + flinkWriteConf.targetDataFileSize(), + flinkWriteConf.dataFileFormat(), tableWriteProperties, Lists.newArrayList(equalityFieldIds), element.upsertMode(), @@ -145,6 +146,7 @@ public void write(DynamicRecordInternal element, Context context) return taskWriterFactory.create(); }) .write(element.rowData()); + metrics.mainMetricsGroup().getNumRecordsSendCounter().inc(); } @Override @@ -164,8 +166,6 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("subtaskId", subTaskId) .add("attemptId", attemptId) - .add("dataFileFormat", dataFileFormat) - .add("targetDataFileSize", targetDataFileSize) .add("writeProperties", commonWriteProperties) .toString(); } @@ -188,7 +188,11 @@ public Collection prepareCommit() throws IOException { writeResult.dataFiles().length, writeResult.deleteFiles().length); - result.add(new DynamicWriteResult(writeTarget, writeResult)); + result.add( + new DynamicWriteResult( + new TableKey(writeTarget.tableName(), writeTarget.branch()), + writeTarget.specId(), + writeResult)); } writers.clear(); diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java index 2e1f82df9d2d..d50a41512a15 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/DynamicWriterMetrics.java @@ -18,24 +18,37 @@ */ package org.apache.iceberg.flink.sink.dynamic; +import java.util.Arrays; import java.util.Map; -import org.apache.flink.metrics.MetricGroup; +import java.util.function.ToLongFunction; +import org.apache.flink.metrics.groups.SinkWriterMetricGroup; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; import org.apache.iceberg.flink.sink.IcebergStreamWriterMetrics; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ScanTaskUtil; class DynamicWriterMetrics { private final Map metrics; - private final MetricGroup mainMetricsGroup; + private final SinkWriterMetricGroup mainMetricsGroup; - DynamicWriterMetrics(MetricGroup mainMetricsGroup) { + DynamicWriterMetrics(SinkWriterMetricGroup mainMetricsGroup) { this.mainMetricsGroup = mainMetricsGroup; this.metrics = Maps.newHashMap(); } + SinkWriterMetricGroup mainMetricsGroup() { + return this.mainMetricsGroup; + } + public void updateFlushResult(String fullTableName, WriteResult result) { writerMetrics(fullTableName).updateFlushResult(result); + + long bytesOutTotal = sum(result.dataFiles()) + sum(result.deleteFiles()); + this.mainMetricsGroup.getNumBytesSendCounter().inc(bytesOutTotal); } public void flushDuration(String fullTableName, long flushDurationMs) { @@ -46,4 +59,16 @@ IcebergStreamWriterMetrics writerMetrics(String fullTableName) { return metrics.computeIfAbsent( fullTableName, tableName -> new IcebergStreamWriterMetrics(mainMetricsGroup, tableName)); } + + private static long sum(DataFile[] files) { + return sum(files, DataFile::fileSizeInBytes); + } + + private static long sum(DeleteFile[] files) { + return sum(files, ScanTaskUtil::contentSizeInBytes); + } + + private static > long sum(T[] files, ToLongFunction sizeExtractor) { + return Arrays.stream(files).mapToLong(sizeExtractor).sum(); + } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java index ee0549997178..d9747d201e3d 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/EvolveSchemaVisitor.java @@ -21,10 +21,13 @@ import java.util.List; import org.apache.iceberg.Schema; import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.schema.SchemaWithPartnerVisitor; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Visitor class that accumulates the set of changes needed to evolve an existing schema into the @@ -36,30 +39,42 @@ *

  • Adding new columns *
  • Widening the type of existing columsn *
  • Reordering columns + *
  • Dropping columns (when dropUnusedColumns is enabled) * * * We don't support: * *
      - *
    • Dropping columns *
    • Renaming columns *
    * - * The reason is that dropping columns would create issues with late / out of order data. Once we - * drop fields, we wouldn't be able to easily add them back later without losing the associated - * data. Renaming columns is not supported because we compare schemas by name, which doesn't allow - * for renaming without additional hints. + * By default, any columns present in the table but absent from the input schema are marked as + * optional to prevent issues caused by late or out-of-order data. If dropUnusedColumns is enabled, + * these columns are removed instead to ensure a strict one-to-one schema alignment. */ public class EvolveSchemaVisitor extends SchemaWithPartnerVisitor { + private static final Logger LOG = LoggerFactory.getLogger(EvolveSchemaVisitor.class); + private final TableIdentifier identifier; private final UpdateSchema api; private final Schema existingSchema; private final Schema targetSchema; - - private EvolveSchemaVisitor(UpdateSchema api, Schema existingSchema, Schema targetSchema) { - this.api = api; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; + + private EvolveSchemaVisitor( + TableIdentifier identifier, + UpdateSchema api, + Schema existingSchema, + Schema targetSchema, + boolean caseSensitive, + boolean dropUnusedColumns) { + this.identifier = identifier; + this.api = api.caseSensitive(caseSensitive); this.existingSchema = existingSchema; this.targetSchema = targetSchema; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } /** @@ -70,13 +85,22 @@ private EvolveSchemaVisitor(UpdateSchema api, Schema existingSchema, Schema targ * @param api an UpdateSchema for adding changes * @param existingSchema an existing schema * @param targetSchema a new schema to compare with the existing + * @param caseSensitive whether field name matching should be case-sensitive + * @param dropUnusedColumns whether to drop columns not present in target schema */ - public static void visit(UpdateSchema api, Schema existingSchema, Schema targetSchema) { + public static void visit( + TableIdentifier identifier, + UpdateSchema api, + Schema existingSchema, + Schema targetSchema, + boolean caseSensitive, + boolean dropUnusedColumns) { visit( targetSchema, -1, - new EvolveSchemaVisitor(api, existingSchema, targetSchema), - new CompareSchemasVisitor.PartnerIdByNameAccessors(existingSchema)); + new EvolveSchemaVisitor( + identifier, api, existingSchema, targetSchema, caseSensitive, dropUnusedColumns), + new CompareSchemasVisitor.PartnerIdByNameAccessors(existingSchema, caseSensitive)); } @Override @@ -89,25 +113,36 @@ public Boolean struct(Types.StructType struct, Integer partnerId, List Types.StructType partnerStruct = findFieldType(partnerId).asStructType(); String after = null; for (Types.NestedField targetField : struct.fields()) { - Types.NestedField nestedField = partnerStruct.field(targetField.name()); + Types.NestedField nestedField = + CompareSchemasVisitor.getFieldFromStruct( + targetField.name(), partnerStruct, caseSensitive); final String columnName; if (nestedField != null) { updateColumn(nestedField, targetField); columnName = this.existingSchema.findColumnName(nestedField.fieldId()); } else { addColumn(partnerId, targetField); - columnName = this.targetSchema.findColumnName(targetField.fieldId()); + columnName = targetSchema.findColumnName(targetField.fieldId()); } setPosition(columnName, after); after = columnName; } - // Ensure that unused fields are made optional for (Types.NestedField existingField : partnerStruct.fields()) { - if (struct.field(existingField.name()) == null) { - if (existingField.isRequired()) { - this.api.makeColumnOptional(this.existingSchema.findColumnName(existingField.fieldId())); + Types.NestedField targetField = + caseSensitive + ? struct.field(existingField.name()) + : struct.caseInsensitiveField(existingField.name()); + if (targetField == null) { + String columnName = this.existingSchema.findColumnName(existingField.fieldId()); + if (dropUnusedColumns) { + LOG.debug("{}: Dropping column: {}", identifier.name(), columnName); + this.api.deleteColumn(columnName); + } else { + if (existingField.isRequired()) { + this.api.makeColumnOptional(columnName); + } } } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java new file mode 100644 index 000000000000..75b169c4b533 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkConf.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.util.Map; +import org.apache.flink.configuration.ReadableConfig; +import org.apache.iceberg.flink.FlinkConfParser; + +/** + * A class for common Dynamic Iceberg sink configs for Flink writes. + * + *

    If a config is set at multiple levels, the following order of precedence is used (top to + * bottom): + * + *

      + *
    1. Write options + *
    2. Flink ReadableConfig + *
    3. Default values + *
    + * + * The most specific value is set in write options and takes precedence over all other configs. If + * no write option is provided, this class checks the flink configuration for any overrides. If no + * applicable value is found in the write options, this class uses the default values. + */ +class FlinkDynamicSinkConf { + + private final FlinkConfParser confParser; + + FlinkDynamicSinkConf(Map writeOptions, ReadableConfig readableConfig) { + this.confParser = new FlinkConfParser(writeOptions, readableConfig); + } + + int cacheMaxSize() { + return confParser + .intConf() + .option(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.key()) + .flinkConfig(FlinkDynamicSinkOptions.CACHE_MAX_SIZE) + .defaultValue(FlinkDynamicSinkOptions.CACHE_MAX_SIZE.defaultValue()) + .parse(); + } + + boolean immediateTableUpdate() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.key()) + .flinkConfig(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE) + .defaultValue(FlinkDynamicSinkOptions.IMMEDIATE_TABLE_UPDATE.defaultValue()) + .parse(); + } + + boolean dropUnusedColumns() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key()) + .flinkConfig(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS) + .defaultValue(FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.defaultValue()) + .parse(); + } + + long cacheRefreshMs() { + return confParser + .longConf() + .option(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.key()) + .flinkConfig(FlinkDynamicSinkOptions.CACHE_REFRESH_MS) + .defaultValue(FlinkDynamicSinkOptions.CACHE_REFRESH_MS.defaultValue()) + .parse(); + } + + int inputSchemasPerTableCacheMaxSize() { + return confParser + .intConf() + .option(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.key()) + .flinkConfig(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE) + .defaultValue(FlinkDynamicSinkOptions.INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE.defaultValue()) + .parse(); + } + + boolean caseSensitive() { + return confParser + .booleanConf() + .option(FlinkDynamicSinkOptions.CASE_SENSITIVE.key()) + .flinkConfig(FlinkDynamicSinkOptions.CASE_SENSITIVE) + .defaultValue(FlinkDynamicSinkOptions.CASE_SENSITIVE.defaultValue()) + .parse(); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java new file mode 100644 index 000000000000..7a4f038219d9 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/FlinkDynamicSinkOptions.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import org.apache.flink.annotation.Experimental; +import org.apache.flink.configuration.ConfigOption; +import org.apache.flink.configuration.ConfigOptions; + +@Experimental +public class FlinkDynamicSinkOptions { + + private FlinkDynamicSinkOptions() {} + + public static final ConfigOption CACHE_MAX_SIZE = + ConfigOptions.key("dynamic-sink.cache-max-size") + .intType() + .defaultValue(100) + .withDescription( + "Maximum size of the caches used in Dynamic Sink for table data and serializers."); + + public static final ConfigOption IMMEDIATE_TABLE_UPDATE = + ConfigOptions.key("dynamic-sink.immediate-table-update") + .booleanType() + .defaultValue(false) + .withDescription( + "Controls whether table schema and partition updates should be applied immediately in Dynamic Sink."); + + public static final ConfigOption DROP_UNUSED_COLUMNS = + ConfigOptions.key("dynamic-sink.drop-unused-columns") + .booleanType() + .defaultValue(false) + .withDescription( + "Allows dropping unused columns during schema evolution in Dynamic Sink."); + + public static final ConfigOption CACHE_REFRESH_MS = + ConfigOptions.key("dynamic-sink.cache-refresh-ms") + .longType() + .defaultValue(1_000L) + .withDescription( + "Cache refresh interval for dynamic table metadata in Dynamic Sink in milliseconds."); + + public static final ConfigOption INPUT_SCHEMAS_PER_TABLE_CACHE_MAX_SIZE = + ConfigOptions.key("dynamic-sink.input-schemas-per-table-cache-max-size") + .intType() + .defaultValue(10) + .withDescription( + "Maximum input schema objects to cache per each table in Dynamic Sink for performance."); + + public static final ConfigOption CASE_SENSITIVE = + ConfigOptions.key("dynamic-sink.case-sensitive") + .booleanType() + .defaultValue(true) + .withDescription( + "Controls whether schema field name matching should be case-sensitive in Dynamic Sink."); +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java index 1c611c46b90c..61a850212bf4 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/HashKeyGenerator.java @@ -40,6 +40,7 @@ import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,7 +87,9 @@ int generateKey( tableSpec != null ? tableSpec.specId() : null, dynamicRecord.schema(), dynamicRecord.spec(), - dynamicRecord.equalityFields()); + dynamicRecord.equalityFields(), + dynamicRecord.distributionMode(), + Math.min(dynamicRecord.writeParallelism(), maxWriteParallelism)); KeySelector keySelector = keySelectorCache.computeIfAbsent( cacheKey, @@ -95,8 +98,7 @@ int generateKey( tableIdent, MoreObjects.firstNonNull(tableSchema, dynamicRecord.schema()), MoreObjects.firstNonNull(tableSpec, dynamicRecord.spec()), - MoreObjects.firstNonNull( - dynamicRecord.distributionMode(), DistributionMode.NONE), + dynamicRecord.distributionMode(), MoreObjects.firstNonNull( dynamicRecord.equalityFields(), Collections.emptySet()), Math.min(dynamicRecord.writeParallelism(), maxWriteParallelism))); @@ -151,8 +153,9 @@ private KeySelector getKeySelector( tableName, schema, equalityFields, writeParallelism, maxWriteParallelism); } else { for (PartitionField partitionField : spec.fields()) { + Types.NestedField sourceField = schema.findField(partitionField.sourceId()); Preconditions.checkState( - equalityFields.contains(partitionField.name()), + sourceField != null && equalityFields.contains(sourceField.name()), "%s: In 'hash' distribution mode with equality fields set, partition field '%s' " + "should be included in equality fields: '%s'", tableName, @@ -320,6 +323,8 @@ static class SelectorKey { private final Schema schema; private final PartitionSpec spec; private final Set equalityFields; + private final DistributionMode distributionMode; + private final int writeParallelism; SelectorKey( String tableName, @@ -328,7 +333,9 @@ static class SelectorKey { @Nullable Integer tableSpecId, Schema schema, PartitionSpec spec, - Set equalityFields) { + Set equalityFields, + DistributionMode distributionMode, + int writeParallelism) { this.tableName = tableName; this.branch = branch; this.schemaId = tableSchemaId; @@ -336,6 +343,8 @@ static class SelectorKey { this.schema = tableSchemaId == null ? schema : null; this.spec = tableSpecId == null ? spec : null; this.equalityFields = equalityFields; + this.distributionMode = distributionMode; + this.writeParallelism = writeParallelism; } @Override @@ -355,12 +364,23 @@ public boolean equals(Object other) { && Objects.equals(specId, that.specId) && Objects.equals(schema, that.schema) && Objects.equals(spec, that.spec) - && Objects.equals(equalityFields, that.equalityFields); + && Objects.equals(equalityFields, that.equalityFields) + && distributionMode == that.distributionMode + && writeParallelism == that.writeParallelism; } @Override public int hashCode() { - return Objects.hash(tableName, branch, schemaId, specId, schema, spec, equalityFields); + return Objects.hash( + tableName, + branch, + schemaId, + specId, + schema, + spec, + equalityFields, + distributionMode, + writeParallelism); } @Override @@ -373,6 +393,8 @@ public String toString() { .add("schema", schema) .add("spec", spec) .add("equalityFields", equalityFields) + .add("distributionMode", distributionMode) + .add("writeParallelism", writeParallelism) .toString(); } } diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java new file mode 100644 index 000000000000..08b755fe14a8 --- /dev/null +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableKey.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import java.io.IOException; +import java.util.Objects; +import org.apache.flink.core.memory.DataInputView; +import org.apache.flink.core.memory.DataOutputView; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; + +class TableKey { + private final String tableName; + private final String branch; + + TableKey(String tableName, String branch) { + this.tableName = tableName; + this.branch = branch; + } + + TableKey(DynamicCommittable committable) { + this.tableName = committable.key().tableName(); + this.branch = committable.key().branch(); + } + + String tableName() { + return tableName; + } + + String branch() { + return branch; + } + + void serializeTo(DataOutputView view) throws IOException { + view.writeUTF(tableName); + view.writeUTF(branch); + } + + static TableKey deserializeFrom(DataInputView view) throws IOException { + return new TableKey(view.readUTF(), view.readUTF()); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other == null || getClass() != other.getClass()) { + return false; + } + + TableKey that = (TableKey) other; + return tableName.equals(that.tableName) && branch.equals(that.branch); + } + + @Override + public int hashCode() { + return Objects.hash(tableName, branch); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("tableName", tableName) + .add("branch", branch) + .toString(); + } +} diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java index 2c08a3486e7c..fdefc01402ac 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableMetadataCache.java @@ -29,6 +29,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.flink.FlinkSchemaUtil; import org.slf4j.Logger; @@ -54,10 +55,24 @@ class TableMetadataCache { private final Clock cacheRefreshClock; private final int inputSchemasPerTableCacheMaximumSize; private final Map tableCache; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; TableMetadataCache( - Catalog catalog, int maximumSize, long refreshMs, int inputSchemasPerTableCacheMaximumSize) { - this(catalog, maximumSize, refreshMs, inputSchemasPerTableCacheMaximumSize, Clock.systemUTC()); + Catalog catalog, + int maximumSize, + long refreshMs, + int inputSchemasPerTableCacheMaximumSize, + boolean caseSensitive, + boolean dropUnusedColumns) { + this( + catalog, + maximumSize, + refreshMs, + inputSchemasPerTableCacheMaximumSize, + caseSensitive, + dropUnusedColumns, + Clock.systemUTC()); } @VisibleForTesting @@ -66,19 +81,23 @@ class TableMetadataCache { int maximumSize, long refreshMs, int inputSchemasPerTableCacheMaximumSize, + boolean caseSensitive, + boolean dropUnusedColumns, Clock cacheRefreshClock) { this.catalog = catalog; this.refreshMs = refreshMs; - this.cacheRefreshClock = cacheRefreshClock; this.inputSchemasPerTableCacheMaximumSize = inputSchemasPerTableCacheMaximumSize; this.tableCache = new LRUCache<>(maximumSize); + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; + this.cacheRefreshClock = cacheRefreshClock; } Tuple2 exists(TableIdentifier identifier) { CacheItem cached = tableCache.get(identifier); if (cached != null && Boolean.TRUE.equals(cached.tableExists)) { return EXISTS; - } else if (needsRefresh(cached, true)) { + } else if (needsRefresh(identifier, cached, true)) { return refreshTable(identifier); } else { return NOT_EXISTS; @@ -115,7 +134,7 @@ private String branch(TableIdentifier identifier, String branch, boolean allowRe return branch; } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return branch(identifier, branch, false); } else { @@ -139,7 +158,8 @@ private ResolvedSchemaInfo schema( for (Map.Entry tableSchema : cached.tableSchemas.entrySet()) { CompareSchemasVisitor.Result result = - CompareSchemasVisitor.visit(input, tableSchema.getValue(), true); + CompareSchemasVisitor.visit( + input, tableSchema.getValue(), caseSensitive, dropUnusedColumns); if (result == CompareSchemasVisitor.Result.SAME) { ResolvedSchemaInfo newResult = new ResolvedSchemaInfo( @@ -155,7 +175,7 @@ private ResolvedSchemaInfo schema( } } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return schema(identifier, input, false); } else if (compatible != null) { @@ -185,7 +205,7 @@ private PartitionSpec spec(TableIdentifier identifier, PartitionSpec spec, boole } } - if (needsRefresh(cached, allowRefresh)) { + if (needsRefresh(identifier, cached, allowRefresh)) { refreshTable(identifier); return spec(identifier, spec, false); } else { @@ -198,18 +218,32 @@ private Tuple2 refreshTable(TableIdentifier identifier) { Table table = catalog.loadTable(identifier); update(identifier, table); return EXISTS; - } catch (NoSuchTableException e) { - LOG.debug("Table doesn't exist {}", identifier, e); + } catch (NoSuchTableException | NoSuchNamespaceException e) { + LOG.debug("Table or namespace doesn't exist {}", identifier, e); tableCache.put( identifier, new CacheItem(cacheRefreshClock.millis(), false, null, null, null, 1)); return Tuple2.of(false, e); } } - private boolean needsRefresh(CacheItem cacheItem, boolean allowRefresh) { - return allowRefresh - && (cacheItem == null - || cacheRefreshClock.millis() - cacheItem.createdTimestampMillis > refreshMs); + private boolean needsRefresh( + TableIdentifier identifier, CacheItem cacheItem, boolean allowRefresh) { + if (!allowRefresh) { + return false; + } + + if (cacheItem == null) { + return true; + } + + long nowMillis = cacheRefreshClock.millis(); + long timeElapsedMillis = nowMillis - cacheItem.createdTimestampMillis; + if (timeElapsedMillis > refreshMs) { + LOG.info("Refreshing table metadata for {} after {} millis", identifier, timeElapsedMillis); + return true; + } + + return false; } public void invalidate(TableIdentifier identifier) { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java index cadfe345980c..b0bdad8ed1e1 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/TableUpdater.java @@ -43,10 +43,15 @@ class TableUpdater { private static final Logger LOG = LoggerFactory.getLogger(TableUpdater.class); private final TableMetadataCache cache; private final Catalog catalog; + private final boolean caseSensitive; + private final boolean dropUnusedColumns; - TableUpdater(TableMetadataCache cache, Catalog catalog) { + TableUpdater( + TableMetadataCache cache, Catalog catalog, boolean caseSensitive, boolean dropUnusedColumns) { this.cache = cache; this.catalog = catalog; + this.caseSensitive = caseSensitive; + this.dropUnusedColumns = dropUnusedColumns; } /** @@ -124,7 +129,8 @@ private TableMetadataCache.ResolvedSchemaInfo findOrCreateSchema( } else { Table table = catalog.loadTable(identifier); Schema tableSchema = table.schema(); - CompareSchemasVisitor.Result result = CompareSchemasVisitor.visit(schema, tableSchema, true); + CompareSchemasVisitor.Result result = + CompareSchemasVisitor.visit(schema, tableSchema, caseSensitive, dropUnusedColumns); switch (result) { case SAME: cache.update(identifier, table); @@ -141,7 +147,8 @@ private TableMetadataCache.ResolvedSchemaInfo findOrCreateSchema( LOG.info( "Triggering schema update for table {} {} to {}", identifier, tableSchema, schema); UpdateSchema updateApi = table.updateSchema(); - EvolveSchemaVisitor.visit(updateApi, tableSchema, schema); + EvolveSchemaVisitor.visit( + identifier, updateApi, tableSchema, schema, caseSensitive, dropUnusedColumns); try { updateApi.commit(); diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java index a01b82539c5a..211f28c6518a 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/dynamic/WriteTarget.java @@ -23,7 +23,7 @@ import java.util.Objects; import java.util.Set; import org.apache.flink.core.memory.DataInputView; -import org.apache.flink.core.memory.DataOutputView; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -44,7 +44,7 @@ class WriteTarget implements Serializable { boolean upsertMode, Set equalityFields) { this.tableName = tableName; - this.branch = branch != null ? branch : "main"; + this.branch = branch != null ? branch : SnapshotRef.MAIN_BRANCH; this.schemaId = schemaId; this.specId = specId; this.upsertMode = upsertMode; @@ -75,18 +75,6 @@ Set equalityFields() { return equalityFields; } - void serializeTo(DataOutputView view) throws IOException { - view.writeUTF(tableName); - view.writeUTF(branch); - view.writeInt(schemaId); - view.writeInt(specId); - view.writeBoolean(upsertMode); - view.writeInt(equalityFields.size()); - for (Integer equalityField : equalityFields) { - view.writeInt(equalityField); - } - } - static WriteTarget deserializeFrom(DataInputView view) throws IOException { return new WriteTarget( view.readUTF(), diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java index 6f5bb6722771..ec099fbfe80a 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/sink/shuffle/SortKeySerializer.java @@ -159,6 +159,7 @@ public void serialize(SortKey record, DataOutputView target) throws IOException case LONG: case TIME: case TIMESTAMP: + case TIMESTAMP_NANO: target.writeLong(record.get(i, Long.class)); break; case FLOAT: @@ -237,6 +238,7 @@ public SortKey deserialize(SortKey reuse, DataInputView source) throws IOExcepti case LONG: case TIME: case TIMESTAMP: + case TIMESTAMP_NANO: reuse.set(i, source.readLong()); break; case FLOAT: diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java index ec7cb010b6be..a55402d2d0be 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergSource.java @@ -292,7 +292,10 @@ public static class Builder { private RowDataConverter converter; private ReadableConfig flinkConfig = new Configuration(); private final ScanContext.Builder contextBuilder = ScanContext.builder(); + + @SuppressWarnings("deprecation") private TableSchema projectedTableSchema; + private ResolvedSchema projectedFlinkSchema; private Boolean exposeLocality; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java index aeecd43e7f14..40df25b59fe6 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/IcebergTableSource.java @@ -121,6 +121,7 @@ public void applyProjection(int[][] projectFields, DataType producedDataType) { } } + @SuppressWarnings("deprecation") private DataStream createDataStream(StreamExecutionEnvironment execEnv) { return FlinkSource.forRowData() .env(execEnv) diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java index b8fb1ba32edf..ee4aaf4a3da1 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataFileScanTaskReader.java @@ -24,10 +24,8 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; import org.apache.iceberg.StructLike; -import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.encryption.InputFilesDecryptor; import org.apache.iceberg.expressions.Expression; @@ -35,19 +33,14 @@ import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.flink.FlinkSourceFilter; import org.apache.iceberg.flink.RowDataWrapper; -import org.apache.iceberg.flink.data.FlinkOrcReader; -import org.apache.iceberg.flink.data.FlinkParquetReaders; -import org.apache.iceberg.flink.data.FlinkPlannedAvroReader; import org.apache.iceberg.flink.data.RowDataProjection; import org.apache.iceberg.flink.data.RowDataUtil; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.mapping.NameMappingParser; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.PartitionUtil; @Internal @@ -73,8 +66,7 @@ public RowDataFileScanTaskReader( if (filters != null && !filters.isEmpty()) { Expression combinedExpression = filters.stream().reduce(Expressions.alwaysTrue(), Expressions::and); - this.rowFilter = - new FlinkSourceFilter(this.projectedSchema, combinedExpression, this.caseSensitive); + this.rowFilter = new FlinkSourceFilter(projectedSchema, combinedExpression, caseSensitive); } else { this.rowFilter = null; } @@ -112,23 +104,23 @@ private CloseableIterable newIterable( if (task.isDataTask()) { throw new UnsupportedOperationException("Cannot read data task."); } else { - switch (task.file().format()) { - case PARQUET: - iter = newParquetIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - case AVRO: - iter = newAvroIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - case ORC: - iter = newOrcIterable(task, schema, idToConstant, inputFilesDecryptor); - break; - - default: - throw new UnsupportedOperationException( - "Cannot read unknown format: " + task.file().format()); + ReadBuilder builder = + FormatModelRegistry.readBuilder( + task.file().format(), RowData.class, inputFilesDecryptor.getInputFile(task)); + + if (nameMapping != null) { + builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); } + + iter = + builder + .project(schema) + .idToConstant(idToConstant) + .split(task.start(), task.length()) + .caseSensitive(caseSensitive) + .filter(task.residual()) + .reuseContainers() + .build(); } if (rowFilter != null) { @@ -137,72 +129,6 @@ private CloseableIterable newIterable( return iter; } - private CloseableIterable newAvroIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Avro.ReadBuilder builder = - Avro.read(inputFilesDecryptor.getInputFile(task)) - .reuseContainers() - .project(schema) - .split(task.start(), task.length()) - .createReaderFunc(readSchema -> FlinkPlannedAvroReader.create(schema, idToConstant)); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - - private CloseableIterable newParquetIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Parquet.ReadBuilder builder = - Parquet.read(inputFilesDecryptor.getInputFile(task)) - .split(task.start(), task.length()) - .project(schema) - .createReaderFunc( - fileSchema -> FlinkParquetReaders.buildReader(schema, fileSchema, idToConstant)) - .filter(task.residual()) - .caseSensitive(caseSensitive) - .reuseContainers(); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - - private CloseableIterable newOrcIterable( - FileScanTask task, - Schema schema, - Map idToConstant, - InputFilesDecryptor inputFilesDecryptor) { - Schema readSchemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - schema, Sets.union(idToConstant.keySet(), MetadataColumns.metadataFieldIds())); - - ORC.ReadBuilder builder = - ORC.read(inputFilesDecryptor.getInputFile(task)) - .project(readSchemaWithoutConstantAndMetadataFields) - .split(task.start(), task.length()) - .createReaderFunc( - readOrcSchema -> new FlinkOrcReader(schema, readOrcSchema, idToConstant)) - .filter(task.residual()) - .caseSensitive(caseSensitive); - - if (nameMapping != null) { - builder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - return builder.build(); - } - private static class FlinkDeleteFilter extends DeleteFilter { private final RowType requiredRowType; private final RowDataWrapper asStructLike; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java index 8ef1f1fbb833..d74b8b9d620f 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/RowDataToAvroGenericRecordConverter.java @@ -23,8 +23,6 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.annotation.Internal; -import org.apache.flink.formats.avro.RowDataToAvroConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; import org.apache.flink.table.types.logical.LogicalType; @@ -32,6 +30,8 @@ import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.formats.avro.RowDataToAvroConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; /** * This is not serializable because Avro {@link Schema} is not actually serializable, even though it diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java index bac7c05bdfef..1c6644238c3d 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/ScanContext.java @@ -136,23 +136,26 @@ void validate() { if (startingStrategy == StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_ID) { Preconditions.checkArgument( startSnapshotId != null, - "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: null"); + "Invalid starting snapshot id for %s strategy: null", + startingStrategy); Preconditions.checkArgument( startSnapshotTimestamp == null, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot timestamp for %s strategy: not null", + startingStrategy); } if (startingStrategy == StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP) { Preconditions.checkArgument( startSnapshotTimestamp != null, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_TIMESTAMP strategy: null"); + "Invalid starting snapshot timestamp for %s strategy: null", + startingStrategy); Preconditions.checkArgument( startSnapshotId == null, - "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot id for %s strategy: not null", + startingStrategy); } Preconditions.checkArgument( - tag == null, - String.format("Cannot scan table using ref %s configured for streaming reader", tag)); + tag == null, "Cannot scan table using ref %s configured for streaming reader", tag); Preconditions.checkArgument( snapshotId == null, "Cannot set snapshot-id option for streaming reader"); Preconditions.checkArgument( diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java index 133859b657e5..c7db4ef933d9 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingMonitorFunction.java @@ -56,6 +56,7 @@ *

    The splits to be read are forwarded to the downstream {@link StreamingReaderOperator} which * can have parallelism greater than one. */ +@SuppressWarnings("deprecation") public class StreamingMonitorFunction extends RichSourceFunction implements CheckpointedFunction { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java index 6cc2ccd2c353..a8abd48a7175 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/StreamingReaderOperator.java @@ -67,6 +67,7 @@ public class StreamingReaderOperator extends AbstractStreamOperator private final MailboxExecutor executor; private FlinkInputFormat format; + @SuppressWarnings("deprecation") private transient SourceFunction.SourceContext sourceContext; private transient ListState inputSplitsState; @@ -93,6 +94,7 @@ private StreamingReaderOperator( Preconditions.checkNotNull(mailboxExecutor, "The mailboxExecutor should not be null."); } + @SuppressWarnings("deprecation") @Override public void initializeState(StateInitializationContext context) throws Exception { super.initializeState(context); @@ -213,6 +215,7 @@ private enum SplitState { RUNNING } + @SuppressWarnings("deprecation") private static class OperatorFactory extends AbstractStreamOperatorFactory implements YieldingOperatorFactory, OneInputStreamOperatorFactory { diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java index b158b0871a53..cfef780a4daa 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/AvroGenericRecordConverter.java @@ -21,8 +21,6 @@ import org.apache.avro.Schema; import org.apache.avro.generic.GenericRecord; import org.apache.flink.api.common.typeinfo.TypeInformation; -import org.apache.flink.formats.avro.RowDataToAvroConverters; -import org.apache.flink.formats.avro.typeutils.AvroSchemaConverter; import org.apache.flink.formats.avro.typeutils.GenericRecordAvroTypeInfo; import org.apache.flink.table.data.RowData; import org.apache.flink.table.types.DataType; @@ -31,6 +29,8 @@ import org.apache.flink.table.types.utils.TypeConversions; import org.apache.iceberg.avro.AvroSchemaUtil; import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.formats.avro.RowDataToAvroConverters; +import org.apache.iceberg.flink.formats.avro.typeutils.AvroSchemaConverter; public class AvroGenericRecordConverter implements RowDataConverter { private final Schema avroSchema; diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java index 4bb6f0a98c4c..34de689c6c5d 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/ColumnStatsWatermarkExtractor.java @@ -53,13 +53,24 @@ public ColumnStatsWatermarkExtractor( Types.NestedField field = schema.findField(eventTimeFieldName); TypeID typeID = field.type().typeId(); Preconditions.checkArgument( - typeID.equals(TypeID.LONG) || typeID.equals(TypeID.TIMESTAMP), - "Found %s, expected a LONG or TIMESTAMP column for watermark generation.", + typeID.equals(TypeID.LONG) + || typeID.equals(TypeID.TIMESTAMP) + || typeID.equals(TypeID.TIMESTAMP_NANO), + "Found %s, expected a LONG, TIMESTAMP, or TIMESTAMP_NANO column for watermark generation.", typeID); this.eventTimeFieldId = field.fieldId(); this.eventTimeFieldName = eventTimeFieldName; - // Use the timeUnit only for Long columns. - this.timeUnit = typeID.equals(TypeID.LONG) ? timeUnit : TimeUnit.MICROSECONDS; + // Use the timeUnit only for Long columns; timestamp columns store fixed-precision longs. + switch (typeID) { + case LONG: + this.timeUnit = timeUnit; + break; + case TIMESTAMP_NANO: + this.timeUnit = TimeUnit.NANOSECONDS; + break; + default: + this.timeUnit = TimeUnit.MICROSECONDS; + } } @VisibleForTesting diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java index 02ef57d344b1..3af9957875e8 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/reader/WatermarkExtractorRecordEmitter.java @@ -34,7 +34,7 @@ class WatermarkExtractorRecordEmitter implements SerializableRecordEmitter private static final Logger LOG = LoggerFactory.getLogger(WatermarkExtractorRecordEmitter.class); private final SplitWatermarkExtractor timeExtractor; private String lastSplitId = null; - private long watermark; + private long watermark = Long.MIN_VALUE; WatermarkExtractorRecordEmitter(SplitWatermarkExtractor timeExtractor) { this.timeExtractor = timeExtractor; @@ -44,7 +44,10 @@ class WatermarkExtractorRecordEmitter implements SerializableRecordEmitter public void emitRecord( RecordAndPosition element, SourceOutput output, IcebergSourceSplit split) { if (!split.splitId().equals(lastSplitId)) { - long newWatermark = timeExtractor.extractWatermark(split); + long extracted = timeExtractor.extractWatermark(split); + // Subtract 1 because watermark W means all records with eventTime <= W have arrived; + // records in this split have eventTime == extracted, so watermark must be extracted - 1. + long newWatermark = extracted > Long.MIN_VALUE ? extracted - 1 : Long.MIN_VALUE; if (newWatermark < watermark) { LOG.info( "Received a new split with lower watermark. Previous watermark = {}, current watermark = {}, previous split = {}, current split = {}", diff --git a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java index b6d6f60ef673..0cc1c633f906 100644 --- a/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/source/split/IcebergSourceSplit.java @@ -33,6 +33,7 @@ import org.apache.iceberg.CombinedScanTask; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.ScanTaskParser; +import org.apache.iceberg.flink.util.SerializerHelper; import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; diff --git a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java similarity index 92% rename from flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java rename to flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java index 841969666ee5..3a161ea2631f 100644 --- a/flink/v2.0/flink/src/main/java/org/apache/iceberg/flink/source/split/SerializerHelper.java +++ b/flink/v2.1/flink/src/main/java/org/apache/iceberg/flink/util/SerializerHelper.java @@ -16,20 +16,24 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.iceberg.flink.source.split; +package org.apache.iceberg.flink.util; import java.io.IOException; import java.io.Serializable; import java.io.UTFDataFormatException; +import org.apache.flink.annotation.Internal; import org.apache.flink.core.memory.DataInputDeserializer; +import org.apache.flink.core.memory.DataInputView; import org.apache.flink.core.memory.DataOutputSerializer; +import org.apache.flink.core.memory.DataOutputView; /** * Helper class to serialize and deserialize strings longer than 65K. The inspiration is mostly * taken from the class org.apache.flink.core.memory.DataInputSerializer.readUTF and * org.apache.flink.core.memory.DataOutputSerializer.writeUTF. */ -class SerializerHelper implements Serializable { +@Internal +public class SerializerHelper implements Serializable { private SerializerHelper() {} @@ -47,7 +51,7 @@ private SerializerHelper() {} * @param out the output stream to write the string to. * @param str the string value to be written. */ - public static void writeLongUTF(DataOutputSerializer out, String str) throws IOException { + public static void writeLongUTF(DataOutputView out, String str) throws IOException { int strlen = str.length(); long utflen = 0; int ch; @@ -85,7 +89,7 @@ public static void writeLongUTF(DataOutputSerializer out, String str) throws IOE * @return the string value read from the input stream. * @throws IOException if an I/O error occurs when reading from the input stream. */ - public static String readLongUTF(DataInputDeserializer in) throws IOException { + public static String readLongUTF(DataInputView in) throws IOException { int utflen = in.readInt(); byte[] bytearr = new byte[utflen]; char[] chararr = new char[utflen]; @@ -168,8 +172,7 @@ private static int getUTFBytesSize(int ch) { } } - private static void writeUTFBytes(DataOutputSerializer out, String str, int utflen) - throws IOException { + private static void writeUTFBytes(DataOutputView out, String str, int utflen) throws IOException { int strlen = str.length(); int ch; diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java index e2cd411d7069..795c4fa5a766 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/DataGenerators.java @@ -75,6 +75,11 @@ public static class Primitives implements DataGenerator { OffsetDateTime.of(2022, 1, 10, 0, 0, 0, 0, ZoneOffset.UTC); private static final LocalDateTime JAVA_LOCAL_DATE_TIME_20220110 = LocalDateTime.of(2022, 1, 10, 0, 0, 0); + private static final OffsetDateTime JAVA_OFFSET_DATE_TIME_MAX_NANO = + OffsetDateTime.of(2262, 4, 11, 23, 47, 16, 854_775_807, ZoneOffset.UTC); + private static final LocalDateTime JAVA_LOCAL_DATE_TIME_MAX_NANO = + LocalDateTime.of(2262, 4, 11, 23, 47, 16, 854_775_807); + private static final long ICEBERG_MAX_NANOS_EPOCH = 9223372036854775807L; private static final BigDecimal BIG_DECIMAL_NEGATIVE = new BigDecimal("-1.50"); private static final byte[] FIXED_BYTES = "012345689012345".getBytes(StandardCharsets.UTF_8); @@ -96,7 +101,11 @@ public static class Primitives implements DataGenerator { Types.NestedField.required(12, "uuid_field", Types.UUIDType.get()), Types.NestedField.required(13, "binary_field", Types.BinaryType.get()), Types.NestedField.required(14, "decimal_field", Types.DecimalType.of(9, 2)), - Types.NestedField.required(15, "fixed_field", Types.FixedType.ofLength(16))); + Types.NestedField.required(15, "fixed_field", Types.FixedType.ofLength(16)), + Types.NestedField.required( + 16, "ts_ns_with_zone_field", Types.TimestampNanoType.withZone()), + Types.NestedField.required( + 17, "ts_ns_without_zone_field", Types.TimestampNanoType.withoutZone())); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -171,6 +180,8 @@ public GenericRecord generateIcebergGenericRecord() { genericRecord.setField("time_field", JAVA_LOCAL_TIME_HOUR8); genericRecord.setField("ts_with_zone_field", JAVA_OFFSET_DATE_TIME_20220110); genericRecord.setField("ts_without_zone_field", JAVA_LOCAL_DATE_TIME_20220110); + genericRecord.setField("ts_ns_with_zone_field", JAVA_OFFSET_DATE_TIME_MAX_NANO); + genericRecord.setField("ts_ns_without_zone_field", JAVA_LOCAL_DATE_TIME_MAX_NANO); byte[] uuidBytes = new byte[16]; for (int i = 0; i < 16; ++i) { @@ -220,7 +231,11 @@ public GenericRowData generateFlinkRowData() { uuidBytes, binaryBytes, DecimalData.fromBigDecimal(BIG_DECIMAL_NEGATIVE, 9, 2), - FIXED_BYTES); + FIXED_BYTES, + TimestampData.fromEpochMillis( + ICEBERG_MAX_NANOS_EPOCH / 1_000_000, (int) (ICEBERG_MAX_NANOS_EPOCH % 1_000_000)), + TimestampData.fromEpochMillis( + ICEBERG_MAX_NANOS_EPOCH / 1_000_000, (int) (ICEBERG_MAX_NANOS_EPOCH % 1_000_000))); } @Override @@ -236,10 +251,12 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { genericRecord.put("date_field", DAYS_BTW_EPOC_AND_20220110); genericRecord.put("time_field", HOUR_8_IN_MILLI); - // Although Avro logical type for timestamp fields are in micro seconds, - // AvroToRowDataConverters only looks for long value in milliseconds. - genericRecord.put("ts_with_zone_field", JODA_DATETIME_20220110.getMillis()); - genericRecord.put("ts_without_zone_field", JODA_DATETIME_20220110.getMillis()); + // Now that AvroToRowDataConverters correctly supports microseconds, + // we must inject correct microsecond scale values into the Avro data. + genericRecord.put("ts_with_zone_field", JODA_DATETIME_20220110.getMillis() * 1000L); + genericRecord.put("ts_without_zone_field", JODA_DATETIME_20220110.getMillis() * 1000L); + genericRecord.put("ts_ns_with_zone_field", ICEBERG_MAX_NANOS_EPOCH); + genericRecord.put("ts_ns_without_zone_field", ICEBERG_MAX_NANOS_EPOCH); byte[] uuidBytes = new byte[16]; for (int i = 0; i < 16; ++i) { @@ -554,7 +571,11 @@ public static class ArrayOfPrimitive implements DataGenerator { new Schema( Types.NestedField.required(1, "row_id", Types.StringType.get()), Types.NestedField.required( - 2, "array_of_int", Types.ListType.ofOptional(101, Types.IntegerType.get()))); + 2, "array_of_int", Types.ListType.ofOptional(101, Types.IntegerType.get())), + Types.NestedField.optional( + 3, + "array_of_ts_ns", + Types.ListType.ofRequired(102, Types.TimestampNanoType.withoutZone()))); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -581,13 +602,33 @@ public GenericRecord generateIcebergGenericRecord() { GenericRecord genericRecord = GenericRecord.create(icebergSchema); genericRecord.setField("row_id", "row_id_value"); genericRecord.setField("array_of_int", Arrays.asList(1, 2, 3)); + + LocalDateTime posNanos = LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789); + LocalDateTime negNanos = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321); + genericRecord.setField("array_of_ts_ns", Arrays.asList(posNanos, negNanos)); return genericRecord; } @Override public GenericRowData generateFlinkRowData() { Integer[] arr = {1, 2, 3}; - return GenericRowData.of(StringData.fromString("row_id_value"), new GenericArrayData(arr)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + TimestampData[] tsArr = { + TimestampData.fromEpochMillis( + Math.floorDiv(posNanos, 1_000_000L), (int) Math.floorMod(posNanos, 1_000_000L)), + TimestampData.fromEpochMillis( + Math.floorDiv(negNanos, 1_000_000L), (int) Math.floorMod(negNanos, 1_000_000L)) + }; + return GenericRowData.of( + StringData.fromString("row_id_value"), + new GenericArrayData(arr), + new GenericArrayData(tsArr)); } @Override @@ -595,6 +636,14 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { org.apache.avro.generic.GenericRecord genericRecord = new GenericData.Record(avroSchema); genericRecord.put("row_id", "row_id_value"); genericRecord.put("array_of_int", Arrays.asList(1, 2, 3)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + genericRecord.put("array_of_ts_ns", Arrays.asList(posNanos, negNanos)); return genericRecord; } } @@ -808,7 +857,12 @@ public static class MapOfPrimitives implements DataGenerator { 2, "map_of_primitives", Types.MapType.ofRequired( - 101, 102, Types.StringType.get(), Types.IntegerType.get()))); + 101, 102, Types.StringType.get(), Types.IntegerType.get())), + Types.NestedField.optional( + 3, + "map_of_ts_ns", + Types.MapType.ofRequired( + 103, 104, Types.StringType.get(), Types.TimestampNanoType.withoutZone()))); private final RowType flinkRowType = FlinkSchemaUtil.convert(icebergSchema); @@ -835,15 +889,37 @@ public GenericRecord generateIcebergGenericRecord() { GenericRecord genericRecord = GenericRecord.create(icebergSchema); genericRecord.setField("row_id", "row_id_value"); genericRecord.setField("map_of_primitives", ImmutableMap.of("Jane", 1, "Joe", 2)); + + LocalDateTime posNanos = LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789); + LocalDateTime negNanos = LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321); + genericRecord.setField( + "map_of_ts_ns", ImmutableMap.of("positive", posNanos, "negative", negNanos)); return genericRecord; } @Override public GenericRowData generateFlinkRowData() { + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + return GenericRowData.of( StringData.fromString("row_id_value"), new GenericMapData( - ImmutableMap.of(StringData.fromString("Jane"), 1, StringData.fromString("Joe"), 2))); + ImmutableMap.of(StringData.fromString("Jane"), 1, StringData.fromString("Joe"), 2)), + new GenericMapData( + ImmutableMap.of( + StringData.fromString("positive"), + TimestampData.fromEpochMillis( + Math.floorDiv(posNanos, 1_000_000L), + (int) Math.floorMod(posNanos, 1_000_000L)), + StringData.fromString("negative"), + TimestampData.fromEpochMillis( + Math.floorDiv(negNanos, 1_000_000L), + (int) Math.floorMod(negNanos, 1_000_000L))))); } @Override @@ -851,6 +927,15 @@ public org.apache.avro.generic.GenericRecord generateAvroGenericRecord() { org.apache.avro.generic.GenericRecord genericRecord = new GenericData.Record(avroSchema); genericRecord.put("row_id", "row_id_value"); genericRecord.put("map_of_primitives", ImmutableMap.of("Jane", 1, "Joe", 2)); + + long posNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(2023, 1, 1, 12, 0, 0, 123456789)); + long negNanos = + org.apache.iceberg.util.DateTimeUtil.nanosFromTimestamp( + LocalDateTime.of(1969, 12, 31, 23, 59, 59, 987654321)); + genericRecord.put( + "map_of_ts_ns", ImmutableMap.of("positive", posNanos, "negative", negNanos)); return genericRecord; } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/RowDataConverter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/RowDataConverter.java index a79406b75cf2..a1b84f015d72 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/RowDataConverter.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/RowDataConverter.java @@ -20,6 +20,7 @@ import java.math.BigDecimal; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; @@ -39,11 +40,13 @@ import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.StringData; import org.apache.flink.table.data.TimestampData; +import org.apache.flink.types.variant.BinaryVariant; import org.apache.iceberg.Schema; import org.apache.iceberg.data.Record; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.apache.iceberg.variants.Variant; public class RowDataConverter { private static final OffsetDateTime EPOCH = Instant.ofEpochSecond(0).atOffset(ZoneOffset.UTC); @@ -126,6 +129,18 @@ private static Object convert(Type type, Object object) { convert(type.asMapType().valueType(), entry.getValue())); } return new GenericMapData(convertedMap); + case VARIANT: + Variant icebergVariant = (Variant) object; + + byte[] metadataBytes = new byte[icebergVariant.metadata().sizeInBytes()]; + ByteBuffer metadataBuffer = ByteBuffer.wrap(metadataBytes).order(ByteOrder.LITTLE_ENDIAN); + icebergVariant.metadata().writeTo(metadataBuffer, 0); + + byte[] valueBytes = new byte[icebergVariant.value().sizeInBytes()]; + ByteBuffer valueBuffer = ByteBuffer.wrap(valueBytes).order(ByteOrder.LITTLE_ENDIAN); + icebergVariant.value().writeTo(valueBuffer, 0); + + return new BinaryVariant(valueBytes, metadataBytes); default: throw new UnsupportedOperationException("Not a supported type: " + type); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java index 03d96ac2c573..e5e478fe4ad1 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkCompaction.java @@ -39,11 +39,13 @@ import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.ManifestReader; import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.maintenance.api.LockConfig; import org.apache.iceberg.flink.source.BoundedTableFactory; import org.apache.iceberg.flink.source.BoundedTestSource; import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; @@ -51,7 +53,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +@ExtendWith(ParameterizedTestExtension.class) public class TestFlinkTableSinkCompaction extends CatalogTestBase { private static final TypeInformation ROW_TYPE_INFO = @@ -75,15 +79,31 @@ public class TestFlinkTableSinkCompaction extends CatalogTestBase { + "'flink-maintenance.rewrite.rewrite-all'='true'," + "'flink-maintenance.rewrite.schedule.data-file-size'='1'," + "'flink-maintenance.lock-check-delay-seconds'='60'"; + private static final String TABLE_PROPERTIES_COORDINATOR = + "'flink-maintenance.rewrite.rewrite-all'='true'," + + "'flink-maintenance.rewrite.schedule.data-file-size'='1'," + + "'flink-maintenance.lock-check-delay-seconds'='60'"; @Parameter(index = 2) private boolean userSqlHint; - @Parameters(name = "catalogName={0}, baseNamespace={1}, userSqlHint={2}") + @Parameter(index = 3) + private String lockType; + + @Parameters(name = "catalogName={0}, baseNamespace={1}, userSqlHint={2}, lockType={3}") public static List parameters() { return Arrays.asList( - new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), true}, - new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), false}); + new Object[] { + "testhadoop_basenamespace", Namespace.of("l0", "l1"), true, LockConfig.JdbcLockConfig.JDBC + }, + new Object[] { + "testhadoop_basenamespace", + Namespace.of("l0", "l1"), + false, + LockConfig.JdbcLockConfig.JDBC + }, + new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), true, ""}, + new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1"), false, ""}); } @Override @@ -118,7 +138,13 @@ public void before() { if (userSqlHint) { sql("CREATE TABLE %s (id int, data varchar)", TABLE_NAME); } else { - sql("CREATE TABLE %s (id int, data varchar) with (%s)", TABLE_NAME, TABLE_PROPERTIES); + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + sql("CREATE TABLE %s (id int, data varchar) with (%s)", TABLE_NAME, TABLE_PROPERTIES); + } else { + sql( + "CREATE TABLE %s (id int, data varchar) with (%s)", + TABLE_NAME, TABLE_PROPERTIES_COORDINATOR); + } } icebergTable = validationCatalog.loadTable(TableIdentifier.of(icebergNamespace, TABLE_NAME)); @@ -144,9 +170,15 @@ public void testSQLCompactionE2e() throws Exception { // Redirect the records from source table to destination table. if (userSqlHint) { - sql( - "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", - TABLE_NAME, TABLE_PROPERTIES); + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + sql( + "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", + TABLE_NAME, TABLE_PROPERTIES); + } else { + sql( + "INSERT INTO %s /*+ OPTIONS(%s) */ SELECT id,data from sourceTable", + TABLE_NAME, TABLE_PROPERTIES_COORDINATOR); + } } else { sql("INSERT INTO %s SELECT id,data from sourceTable", TABLE_NAME); } @@ -174,7 +206,8 @@ public void testSQLCompactionE2e() throws Exception { private List getDataFiles(Snapshot snapshot, Table table) throws IOException { List dataFiles = Lists.newArrayList(); for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { - try (ManifestReader reader = ManifestFiles.read(dataManifest, table.io())) { + try (ManifestReader reader = + ManifestFiles.read(dataManifest, table.io(), table.specs())) { reader.iterator().forEachRemaining(dataFiles::add); } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java index 3afabf6e0795..2cc94bfe4977 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkTableSinkExtended.java @@ -50,6 +50,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.Namespace; @@ -356,7 +357,14 @@ public void testRangeDistributionPartitionColumn() { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -371,7 +379,12 @@ public void testRangeDistributionPartitionColumn() { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // range partition results in each partition only assigned to one writer task // maybe less than 26 partitions as BoundedSource doesn't always precisely // control the checkpoint boundary. diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java new file mode 100644 index 000000000000..aaf0ebda0e2b --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUnknownType.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink; + +import static org.apache.iceberg.flink.TestFixtures.DATABASE; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.util.List; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericAppenderHelper; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.sink.TaskWriterFactory; +import org.apache.iceberg.flink.source.DataIterator; +import org.apache.iceberg.flink.source.reader.ReaderUtil; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.TaskWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +class TestFlinkUnknownType { + private static final long TARGET_FILE_SIZE = 128 * 1024 * 1024; + private static final Schema SCHEMA_WITH_UNKNOWN_COL = + new Schema( + Lists.newArrayList( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "unknown_col", Types.UnknownType.get()), + Types.NestedField.optional(4, "data1", Types.StringType.get()))); + private static final List EXCEPTED_ROW_DATA = + Lists.newArrayList( + GenericRowData.of(1, StringData.fromString("data"), null, StringData.fromString("data1")), + GenericRowData.of( + 2, StringData.fromString("data"), null, StringData.fromString("data1"))); + private static final List EXPECTED_RECORDS = exceptedRecords(); + + @RegisterExtension + private static final HadoopCatalogExtension CATALOG_EXTENSION = + new HadoopCatalogExtension(DATABASE, TestFixtures.TABLE); + + @TempDir private Path warehouseDir; + + @Parameter private FileFormat fileFormat; + + private Table table; + + @Parameters(name = "fileFormat={0}") + public static Iterable parameters() { + return ImmutableList.of( + new Object[] {FileFormat.PARQUET}, + new Object[] {FileFormat.AVRO}, + new Object[] {FileFormat.ORC}); + } + + @BeforeEach + public void before() { + table = + CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SCHEMA_WITH_UNKNOWN_COL, + PartitionSpec.unpartitioned(), + null, + ImmutableMap.of("format-version", "3", "write.format.default", fileFormat.name())); + } + + @TestTemplate + void testV3TableUnknownTypeRead() throws Exception { + new GenericAppenderHelper(table, fileFormat, warehouseDir).appendToTable(EXPECTED_RECORDS); + table.refresh(); + + List genericRowData = Lists.newArrayList(); + CloseableIterable combinedScanTasks = table.newScan().planTasks(); + for (CombinedScanTask combinedScanTask : combinedScanTasks) { + DataIterator dataIterator = + ReaderUtil.createDataIterator(combinedScanTask, table.schema(), table.schema()); + while (dataIterator.hasNext()) { + GenericRowData rowData = (GenericRowData) dataIterator.next(); + genericRowData.add( + GenericRowData.of( + rowData.getInt(0), + rowData.getString(1), + rowData.getField(2), + rowData.getString(3))); + } + } + + assertThat(genericRowData).containsExactlyInAnyOrderElementsOf(EXCEPTED_ROW_DATA); + } + + @TestTemplate + void testV3TableUnknownTypeWrite() throws Exception { + try (TaskWriter taskWriter = createTaskWriter()) { + for (GenericRowData rowData : EXCEPTED_ROW_DATA) { + taskWriter.write(rowData); + } + + taskWriter.close(); + AppendFiles appendFiles = table.newAppend(); + for (DataFile dataFile : taskWriter.dataFiles()) { + appendFiles.appendFile(dataFile); + } + + appendFiles.commit(); + List records = SimpleDataUtil.tableRecords(table); + assertThat(records).containsExactlyInAnyOrderElementsOf(exceptedRecords()); + } + } + + private TaskWriter createTaskWriter() { + RowType flinkWriteType = FlinkSchemaUtil.convert(table.schema()); + TaskWriterFactory taskWriterFactory = + new RowDataTaskWriterFactory( + table, flinkWriteType, TARGET_FILE_SIZE, fileFormat, table.properties(), null, false); + taskWriterFactory.initialize(1, 1); + return taskWriterFactory.create(); + } + + private static List exceptedRecords() { + GenericRecord record = GenericRecord.create(SCHEMA_WITH_UNKNOWN_COL); + ImmutableList.Builder builder = ImmutableList.builder(); + EXCEPTED_ROW_DATA.forEach( + recordData -> { + GenericRecord copy = record.copy(); + for (int i = 0; i < recordData.getArity(); i++) { + Object field = recordData.getField(i); + if (field instanceof StringData) { + copy.set(i, recordData.getField(i).toString()); + } else { + copy.set(i, recordData.getField(i)); + } + } + + builder.add(copy); + }); + + return builder.build(); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java new file mode 100644 index 000000000000..cac258e7adcc --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkUuidType.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.GenericAppenderHelper; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.sink.RowDataTaskWriterFactory; +import org.apache.iceberg.flink.source.DataIterator; +import org.apache.iceberg.flink.source.reader.ReaderUtil; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.TaskWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.UUIDUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.io.TempDir; + +class TestFlinkUuidType extends CatalogTestBase { + private static final String TABLE_NAME = "test_table"; + private static final long TARGET_FILE_SIZE = 128 * 1024 * 1024; + private static final UUID EXPECTED_UUID = UUID.fromString("0f8fad5b-d9cb-469f-a165-70867728950e"); + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "uuid", Types.UUIDType.get())); + + private Table icebergTable; + @TempDir private Path warehouseDir; + + @Parameter(index = 2) + private FileFormat fileFormat; + + @Parameters(name = "catalogName={0}, baseNamespace={1}, fileFormat={2}") + protected static List parameters() { + return Arrays.asList( + new Object[] {"testhadoop", Namespace.empty(), FileFormat.PARQUET}, + new Object[] {"testhadoop", Namespace.empty(), FileFormat.AVRO}, + new Object[] {"testhadoop", Namespace.empty(), FileFormat.ORC}); + } + + @Override + @BeforeEach + public void before() { + super.before(); + sql("CREATE DATABASE %s", flinkDatabase); + sql("USE CATALOG %s", catalogName); + sql("USE %s", DATABASE); + } + + /** Writes UUID via Generic writer, reads via Flink. */ + @TestTemplate + void uuidWrittenByGenericWriter() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + Record record = + GenericRecord.create(icebergTable.schema()).copy("id", 1, "uuid", EXPECTED_UUID); + new GenericAppenderHelper(icebergTable, fileFormat, warehouseDir) + .appendToTable(ImmutableList.of(record)); + icebergTable.refresh(); + + List genericRowData = Lists.newArrayList(); + try (CloseableIterable combinedScanTasks = + icebergTable.newScan().planTasks()) { + for (CombinedScanTask combinedScanTask : combinedScanTasks) { + try (DataIterator dataIterator = + ReaderUtil.createDataIterator( + combinedScanTask, icebergTable.schema(), icebergTable.schema())) { + while (dataIterator.hasNext()) { + GenericRowData rowData = (GenericRowData) dataIterator.next(); + genericRowData.add(rowData); + } + } + } + } + + assertThat(genericRowData).hasSize(1); + assertThat(genericRowData.get(0).getField(1)).isInstanceOf(byte[].class); + byte[] bytes = (byte[]) genericRowData.get(0).getField(1); + assertThat(bytes).hasSize(16); + + ByteBuffer byteBuffer = ByteBuffer.wrap(bytes); + UUID actualUuid = new UUID(byteBuffer.getLong(), byteBuffer.getLong()); + assertThat(actualUuid).isEqualTo(EXPECTED_UUID); + } + + /** Writes UUID via Flink TaskWriter, reads via Generic reader. */ + @TestTemplate + void writeUuidViaFlinkWriter() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + RowType rowType = FlinkSchemaUtil.convert(SCHEMA); + RowDataTaskWriterFactory rowDataTaskWriterFactory = + new RowDataTaskWriterFactory( + icebergTable, + rowType, + TARGET_FILE_SIZE, + fileFormat, + icebergTable.properties(), + null, + false); + rowDataTaskWriterFactory.initialize(1, 1); + + byte[] uuidBytes = UUIDUtil.convert(EXPECTED_UUID); + GenericRowData genericRowData = GenericRowData.of(1, uuidBytes); + + try (TaskWriter writer = rowDataTaskWriterFactory.create()) { + writer.write(genericRowData); + writer.close(); + + AppendFiles append = icebergTable.newAppend(); + for (DataFile dataFile : writer.dataFiles()) { + append.appendFile(dataFile); + } + + append.commit(); + } + + List records = SimpleDataUtil.tableRecords(icebergTable); + assertThat(records).hasSize(1); + assertThat(records.get(0).getField("uuid")).isEqualTo(EXPECTED_UUID); + } + + /** Writes UUID via SQL INSERT, reads via Generic reader. */ + @TestTemplate + void sqlInsertUuid() throws Exception { + icebergTable = + validationCatalog.createTable( + TableIdentifier.of(icebergNamespace, TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(DEFAULT_FILE_FORMAT, fileFormat.name())); + + String uuidHex = EXPECTED_UUID.toString().replace("-", ""); + sql("INSERT INTO %s VALUES (1, CAST(X'%s' AS BINARY(16)))", TABLE_NAME, uuidHex); + + List records = SimpleDataUtil.tableRecords(icebergTable); + assertThat(records).hasSize(1); + assertThat(records.get(0).getField("uuid")).isEqualTo(EXPECTED_UUID); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkVariantType.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkVariantType.java new file mode 100644 index 000000000000..decdb12b46a6 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestFlinkVariantType.java @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.types.variant.BinaryVariant; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.GenericAppenderHelper; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.source.DataIterator; +import org.apache.iceberg.flink.source.reader.ReaderUtil; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.variants.ShreddedObject; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantPrimitive; +import org.apache.iceberg.variants.Variants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.io.TempDir; + +class TestFlinkVariantType extends CatalogTestBase { + + private static final String TABLE_NAME = "test_table"; + private Table icebergTable; + @TempDir private Path warehouseDir; + + @Parameters(name = "catalogName={0}, baseNamespace={1}") + protected static List parameters() { + return Arrays.asList( + // For now hive metadata is not supported variant, so we only test hadoop catalog + new Object[] {"testhadoop", Namespace.empty()}, + new Object[] {"testhadoop_basenamespace", Namespace.of("l0", "l1")}); + } + + @Override + @BeforeEach + public void before() { + super.before(); + sql("CREATE DATABASE %s", flinkDatabase); + sql("USE CATALOG %s", catalogName); + sql("USE %s", DATABASE); + sql( + "CREATE TABLE %s (id int, data variant) with ('write.format.default'='%s','format-version'='3' )", + TABLE_NAME, FileFormat.PARQUET.name()); + icebergTable = validationCatalog.loadTable(TableIdentifier.of(icebergNamespace, TABLE_NAME)); + } + + @TestTemplate + @SuppressWarnings("unchecked") + public void testInsertVariantFromFlink() throws Exception { + sql("INSERT INTO %s VALUES (1, PARSE_JSON('%s'))", TABLE_NAME, "{\"KeyA\":\"city\"}"); + List records = SimpleDataUtil.tableRecords(icebergTable); + assertThat(records).hasSize(1); + Object field = records.get(0).getField("data"); + assertThat(field).isInstanceOf(Variant.class); + Variant variant = (Variant) field; + assertThat(variant.metadata().get(0)).isEqualTo("KeyA"); + assertThat(((VariantPrimitive) variant.value().asObject().get("KeyA")).get()) + .isEqualTo("city"); + } + + @TestTemplate + public void testReadVariantFromFlink() throws Exception { + ImmutableList.Builder builder = ImmutableList.builder(); + VariantMetadata metadata = Variants.metadata("name", "age"); + ShreddedObject shreddedObject = Variants.object(metadata); + shreddedObject.put("name", Variants.of("John Doe")); + shreddedObject.put("age", Variants.of((byte) 30)); + builder.add( + GenericRecord.create(icebergTable.schema()) + .copy("id", 1, "data", Variant.of(metadata, shreddedObject))); + new GenericAppenderHelper(icebergTable, FileFormat.PARQUET, warehouseDir) + .appendToTable(builder.build()); + icebergTable.refresh(); + + List genericRowData = Lists.newArrayList(); + try (CloseableIterable combinedScanTasks = + icebergTable.newScan().planTasks()) { + for (CombinedScanTask combinedScanTask : combinedScanTasks) { + try (DataIterator dataIterator = + ReaderUtil.createDataIterator( + combinedScanTask, icebergTable.schema(), icebergTable.schema())) { + while (dataIterator.hasNext()) { + GenericRowData rowData = (GenericRowData) dataIterator.next(); + genericRowData.add(rowData); + } + } + } + } + + assertThat(genericRowData).hasSize(1); + assertThat(genericRowData.get(0).getField(1)).isInstanceOf(BinaryVariant.class); + BinaryVariant binaryVariant = (BinaryVariant) genericRowData.get(0).getField(1); + assertThat(binaryVariant.getField("name").getString()).isEqualTo("John Doe"); + assertThat(binaryVariant.getField("age").getByte()).isEqualTo((byte) 30); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestHelpers.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestHelpers.java index 5abbbfa9f49c..3ed289d36b34 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestHelpers.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestHelpers.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.math.BigDecimal; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -55,6 +56,8 @@ import org.apache.flink.table.types.logical.RowType; import org.apache.flink.table.types.utils.TypeConversions; import org.apache.flink.types.Row; +import org.apache.flink.types.variant.BinaryVariant; +import org.apache.flink.types.variant.Variant; import org.apache.iceberg.ContentFile; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.MetadataColumns; @@ -70,6 +73,8 @@ import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.DateTimeUtil; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; public class TestHelpers { private TestHelpers() {} @@ -378,11 +383,55 @@ private static void assertEquals( .isInstanceOf(byte[].class) .isEqualTo(expected); break; + case VARIANT: + assertThat(actual).as("Should expect a Variant").isInstanceOf(Variant.class); + assertThat(expected) + .as("Should expect a Variant") + .isInstanceOf(org.apache.iceberg.variants.Variant.class); + Variant flinkVariant = (Variant) actual; + org.apache.iceberg.variants.Variant icebergVariant = + (org.apache.iceberg.variants.Variant) expected; + compareVariants(icebergVariant, flinkVariant); + break; default: throw new IllegalArgumentException("Not a supported type: " + type); } } + private static void compareVariants( + org.apache.iceberg.variants.Variant icebergVariant, Variant flinkVariant) { + + VariantValue icebergValue = icebergVariant.value(); + VariantMetadata icebergMetadata = icebergVariant.metadata(); + + assertThat(flinkVariant) + .as("Flink variant should be a BinaryVariant") + .isInstanceOf(BinaryVariant.class); + + BinaryVariant binaryVariant = (BinaryVariant) flinkVariant; + + byte[] flinkValueBytes = binaryVariant.getValue(); + byte[] flinkMetadataBytes = binaryVariant.getMetadata(); + + assertThat(flinkValueBytes).as("Flink variant should contain value bytes").isNotNull(); + assertThat(flinkMetadataBytes).as("Flink variant should contain metadata bytes").isNotNull(); + + byte[] icebergMetadataBytes = new byte[icebergMetadata.sizeInBytes()]; + icebergMetadata.writeTo( + ByteBuffer.wrap(icebergMetadataBytes).order(ByteOrder.LITTLE_ENDIAN), 0); + + byte[] icebergValueBytes = new byte[icebergValue.sizeInBytes()]; + icebergValue.writeTo(ByteBuffer.wrap(icebergValueBytes).order(ByteOrder.LITTLE_ENDIAN), 0); + + // Compare metadata bytes + assertThat(flinkMetadataBytes) + .as("Metadata bytes should be equal") + .isEqualTo(icebergMetadataBytes); + + // Compare value bytes + assertThat(flinkValueBytes).as("Value bytes should be equal").isEqualTo(icebergValueBytes); + } + public static void assertEquals(Schema schema, List records, List rows) { Streams.forEachPair( records.stream(), rows.stream(), (record, row) -> assertEquals(schema, record, row)); @@ -529,6 +578,18 @@ private static void assertAvroEquals( .isInstanceOf(byte[].class) .isEqualTo(expected); break; + case VARIANT: + assertThat(actual).as("Should expect a Variant").isInstanceOf(Variant.class); + assertThat(expected) + .as("Should expect a Variant") + .isInstanceOf(org.apache.iceberg.variants.Variant.class); + + Variant flinkVariant = (Variant) actual; + org.apache.iceberg.variants.Variant icebergVariant = + (org.apache.iceberg.variants.Variant) expected; + + compareVariants(icebergVariant, flinkVariant); + break; default: throw new IllegalArgumentException("Not a supported type: " + type); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java index 0a6d5e44caa2..309b55c115c5 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/TestIcebergConnector.java @@ -25,23 +25,37 @@ import java.io.UncheckedIOException; import java.nio.file.Files; import java.util.Map; +import org.apache.flink.api.common.functions.OpenContext; import org.apache.flink.configuration.CoreOptions; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.table.api.EnvironmentSettings; import org.apache.flink.table.api.TableEnvironment; import org.apache.flink.table.api.bridge.java.StreamTableEnvironment; import org.apache.flink.table.catalog.Catalog; +import org.apache.flink.table.catalog.CatalogDatabaseImpl; import org.apache.flink.table.catalog.ObjectPath; +import org.apache.flink.table.catalog.exceptions.DatabaseAlreadyExistException; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.RowType; import org.apache.flink.types.Row; +import org.apache.flink.util.Collector; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.metastore.HiveMetaStoreClient; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.DistributionMode; import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.sink.dynamic.DynamicRecord; +import org.apache.iceberg.flink.sink.dynamic.DynamicTableRecordGenerator; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; import org.apache.thrift.TException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.TestTemplate; @@ -70,16 +84,25 @@ public static Iterable parameters() { new Object[] { "testhadoop", ImmutableMap.of( - "connector", "iceberg", - "catalog-type", "hadoop"), + "connector", + "iceberg", + "catalog-type", + "hadoop", + "catalog-database", + "test_database"), true }, new Object[] { "testhadoop", ImmutableMap.of( - "connector", "iceberg", - "catalog-type", "hadoop", - "catalog-table", "not_existing_table"), + "connector", + "iceberg", + "catalog-type", + "hadoop", + "catalog-database", + "test_database", + "catalog-table", + "not_existing_table"), true }, new Object[] { @@ -328,4 +351,126 @@ private String createWarehouse() { throw new UncheckedIOException(e); } } + + @TestTemplate + public void testCreateDynamicIcebergSink() throws DatabaseAlreadyExistException { + Map tableProps = createTableProps(); + Map dynamicTableProps = Maps.newHashMap(tableProps); + dynamicTableProps.put("use-dynamic-iceberg-sink", "true"); + dynamicTableProps.put( + "dynamic-record-generator-impl", SimpleRowDataTableRecordGenerator.class.getName()); + dynamicTableProps.put("table.props.key1", "val1"); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + FlinkCatalog flinkCatalog = + (FlinkCatalog) factory.createCatalog(catalogName, tableProps, new Configuration()); + flinkCatalog.createDatabase( + databaseName(), new CatalogDatabaseImpl(Maps.newHashMap(), null), true); + + // Create table with dynamic sink enabled + sql( + "CREATE TABLE %s (id BIGINT, data STRING, database_name STRING, table_name STRING) WITH %s", + TABLE_NAME + "_dynamic", toWithClause(dynamicTableProps)); + + // Insert data with database and table information + sql( + "INSERT INTO %s VALUES (1, 'AAA', '%s', '%s'), (2, 'BBB', '%s', '%s'), (3, 'CCC', '%s', '%s')", + TABLE_NAME + "_dynamic", + databaseName(), + tableName(), + databaseName(), + tableName(), + databaseName(), + tableName()); + + // Verify the table and data exists + ObjectPath objectPath = new ObjectPath(databaseName(), tableName()); + assertThat(flinkCatalog.tableExists(objectPath)).isTrue(); + Table table = + flinkCatalog + .getCatalogLoader() + .loadCatalog() + .loadTable(TableIdentifier.of(databaseName(), tableName())); + assertThat(table.properties()).containsEntry("key1", "val1"); + + tableProps.put("catalog-database", databaseName()); + sql("CREATE TABLE %s (id BIGINT, data STRING) WITH %s", tableName(), toWithClause(tableProps)); + assertThat(sql("SELECT * FROM %s", tableName())) + .containsExactlyInAnyOrder(Row.of(1L, "AAA"), Row.of(2L, "BBB"), Row.of(3L, "CCC")); + } + + @TestTemplate + public void testMissingDynamicRecordGeneratorImpl() throws DatabaseAlreadyExistException { + Map tableProps = createTableProps(); + tableProps.put("use-dynamic-iceberg-sink", "true"); + + FlinkCatalogFactory factory = new FlinkCatalogFactory(); + FlinkCatalog flinkCatalog = + (FlinkCatalog) factory.createCatalog(catalogName, tableProps, new Configuration()); + flinkCatalog.createDatabase( + databaseName(), new CatalogDatabaseImpl(Maps.newHashMap(), null), true); + + sql( + "CREATE TABLE %s (id BIGINT, data STRING, database_name STRING, table_name STRING) WITH %s", + TABLE_NAME + "_dynamic", toWithClause(tableProps)); + + assertThatThrownBy( + () -> + sql( + "INSERT INTO %s VALUES (1, 'AAA', '%s', '%s')", + TABLE_NAME + "_dynamic", databaseName(), tableName())) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Invalid dynamic record generator value: null. dynamic-record-generator-impl must be specified when use-dynamic-iceberg-sink is true."); + } + + public static class SimpleRowDataTableRecordGenerator extends DynamicTableRecordGenerator { + + private int databaseFieldIndex = -1; + private int tableFieldIndex = -1; + + public SimpleRowDataTableRecordGenerator(RowType rowType) { + super(rowType); + } + + @Override + public void open(OpenContext openContext) throws Exception { + String[] fieldNames = rowType().getFieldNames().toArray(new String[0]); + + for (int i = 0; i < fieldNames.length; i++) { + if ("database_name".equals(fieldNames[i])) { + databaseFieldIndex = i; + } else if ("table_name".equals(fieldNames[i])) { + tableFieldIndex = i; + } + } + } + + @Override + public void generate(RowData inputRecord, Collector out) throws Exception { + // Extract database and table names using the discovered field indexes + String databaseName = inputRecord.getString(databaseFieldIndex).toString(); + String tableName = inputRecord.getString(tableFieldIndex).toString(); + + // Create schema for the actual data fields (excluding metadata fields) + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.required(1, "data", Types.StringType.get())); + + TableIdentifier tableIdentifier = TableIdentifier.of(databaseName, tableName); + + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + "main", + schema, + inputRecord, + PartitionSpec.unpartitioned(), + DistributionMode.NONE, + 1); + out.collect(dynamicRecord); + } + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java new file mode 100644 index 000000000000..1f0fe70ac53b --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkFormatModel.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.data; + +import java.util.List; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseFormatModelTests; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.RowDataConverter; +import org.apache.iceberg.flink.TestHelpers; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; + +public class TestFlinkFormatModel extends BaseFormatModelTests { + + @Override + protected Class engineType() { + return RowData.class; + } + + @Override + protected Object engineSchema(Schema schema) { + return FlinkSchemaUtil.convert(schema); + } + + @Override + protected RowData convertToEngine(Record record, Schema schema) { + return RowDataConverter.convert(schema, record); + } + + @Override + protected void assertEquals(Schema schema, List expected, List actual) { + TestHelpers.assertRows(actual, expected, FlinkSchemaUtil.convert(schema)); + } + + @Override + protected Object convertConstantToEngine(Type type, Object value) { + if (value instanceof PartitionData partitionData) { + Types.StructType structType = type.asStructType(); + List fields = structType.fields(); + GenericRowData rowData = new GenericRowData(fields.size()); + int sourceSize = partitionData.size(); + for (int i = 0; i < fields.size(); i++) { + if (i < sourceSize) { + Object fieldValue = partitionData.get(i, Object.class); + rowData.setField(i, convertConstantToEngine(fields.get(i).type(), fieldValue)); + } else { + rowData.setField(i, null); + } + } + + return rowData; + } + + return RowDataUtil.convertConstant(type, value); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java index 4a70802f2a2e..b7b0a54156cc 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkOrcReaderWriter.java @@ -49,6 +49,11 @@ protected boolean allowsWritingNullValuesForRequiredFields() { return true; } + @Override + protected boolean supportsTimestampNanos() { + return true; + } + @Override protected void writeAndValidate(Schema schema) throws IOException { List expectedRecords = RandomGenericData.generate(schema, NUM_RECORDS, 1990L); diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java index d181d3351410..fa0fab9d3aae 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkParquetWriter.java @@ -25,9 +25,8 @@ import java.util.Iterator; import java.util.List; import org.apache.flink.table.data.RowData; -import org.apache.flink.table.data.binary.BinaryRowData; -import org.apache.flink.table.runtime.typeutils.RowDataSerializer; import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; import org.apache.iceberg.Schema; import org.apache.iceberg.data.DataTestBase; import org.apache.iceberg.data.RandomGenericData; @@ -59,63 +58,71 @@ protected boolean supportsTimestampNanos() { return true; } - private void writeAndValidate(Iterable iterable, Schema schema) throws IOException { - OutputFile outputFile = new InMemoryOutputFile(); + @Override + protected boolean supportsDefaultValues() { + return true; + } - LogicalType logicalType = FlinkSchemaUtil.convert(schema); + @Override + protected boolean supportsVariant() { + return true; + } + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema, List data) + throws IOException { + OutputFile outputFile = new InMemoryOutputFile(); + + LogicalType logicalType = FlinkSchemaUtil.convert(writeSchema); try (FileAppender writer = Parquet.write(outputFile) - .schema(schema) + .schema(writeSchema) .createWriterFunc(msgType -> FlinkParquetWriters.buildWriter(logicalType, msgType)) .build()) { - writer.addAll(iterable); + writer.addAll(RandomRowData.convert(writeSchema, data)); } try (CloseableIterable reader = Parquet.read(outputFile.toInputFile()) - .project(schema) - .createReaderFunc(msgType -> GenericParquetReaders.buildReader(schema, msgType)) + .project(expectedSchema) + .createReaderFunc( + fileSchema -> GenericParquetReaders.buildReader(expectedSchema, fileSchema)) .build()) { - Iterator expected = iterable.iterator(); Iterator actual = reader.iterator(); - LogicalType rowType = FlinkSchemaUtil.convert(schema); - for (int i = 0; i < NUM_RECORDS; i += 1) { + RowType rowType = FlinkSchemaUtil.convert(expectedSchema); + for (Record expected : data) { assertThat(actual).hasNext(); - TestHelpers.assertRowData(schema.asStruct(), rowType, actual.next(), expected.next()); + RowData actualRowData = RowDataConverter.convert(expectedSchema, actual.next()); + TestHelpers.assertRowData(expectedSchema.asStruct(), rowType, expected, actualRowData); } + assertThat(actual).isExhausted(); } } @Override protected void writeAndValidate(Schema schema) throws IOException { - writeAndValidate(RandomRowData.generate(schema, NUM_RECORDS, 19981), schema); + writeAndValidate(schema, RandomGenericData.generate(schema, NUM_RECORDS, 19981)); writeAndValidate( - RandomRowData.convert( - schema, - RandomGenericData.generateDictionaryEncodableRecords(schema, NUM_RECORDS, 21124)), - schema); + schema, + Lists.newArrayList( + RandomGenericData.generateDictionaryEncodableRecords(schema, NUM_RECORDS, 21124))); writeAndValidate( - RandomRowData.convert( - schema, + schema, + Lists.newArrayList( RandomGenericData.generateFallbackRecords( - schema, NUM_RECORDS, 21124, NUM_RECORDS / 20)), - schema); + schema, NUM_RECORDS, 21124, NUM_RECORDS / 20))); } @Override - protected void writeAndValidate(Schema schema, List expectedData) throws IOException { - RowDataSerializer rowDataSerializer = new RowDataSerializer(FlinkSchemaUtil.convert(schema)); - List binaryRowList = Lists.newArrayList(); - for (Record record : expectedData) { - RowData rowData = RowDataConverter.convert(schema, record); - BinaryRowData binaryRow = rowDataSerializer.toBinaryRow(rowData); - binaryRowList.add(binaryRow); - } + protected void writeAndValidate(Schema schema, List data) throws IOException { + writeAndValidate(schema, schema, data); + } - writeAndValidate(binaryRowList, schema); + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + List data = RandomGenericData.generate(writeSchema, NUM_RECORDS, 1991L); + writeAndValidate(writeSchema, expectedSchema, data); } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkVariants.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkVariants.java new file mode 100644 index 000000000000..93708731ac9f --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestFlinkVariants.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.types.logical.LogicalType; +import org.apache.flink.table.types.logical.RowType; +import org.apache.flink.table.types.logical.VariantType; +import org.apache.flink.types.variant.BinaryVariant; +import org.apache.flink.types.variant.VariantTypeException; +import org.apache.iceberg.Schema; +import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.inmemory.InMemoryOutputFile; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantPrimitive; +import org.apache.iceberg.variants.VariantTestHelper; +import org.apache.iceberg.variants.VariantTestUtil; +import org.apache.iceberg.variants.VariantValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +public class TestFlinkVariants { + + @Test + public void testIcebergVariantTypeToFlinkVariantType() { + // Test that Iceberg's VariantType converts to Flink's VariantType + Types.VariantType icebergVariantType = Types.VariantType.get(); + LogicalType flinkVariantType = FlinkSchemaUtil.convert(icebergVariantType); + + assertThat(flinkVariantType).isInstanceOf(VariantType.class); + } + + @Test + public void testFlinkVariantTypeToIcebergVariantType() { + VariantType flinkVariantType = new VariantType(false); + Type icebergType = FlinkSchemaUtil.convert(flinkVariantType); + + assertThat(icebergType).isInstanceOf(Types.VariantType.class); + assertThat(icebergType).isEqualTo(Types.VariantType.get()); + } + + @ParameterizedTest + @FieldSource("org.apache.iceberg.variants.VariantTestHelper#PRIMITIVES") + public void testVariantPrimitiveRoundTrip(VariantPrimitive primitive) { + VariantTestHelper.testVariantPrimitiveRoundTrip(this::testVariantRoundTrip, primitive); + } + + @Test + public void testVariantArrayRoundTrip() { + VariantTestHelper.testVariantArrayRoundTrip(this::testVariantRoundTrip); + } + + @Test + public void testVariantObjectRoundTrip() { + VariantTestHelper.testVariantObjectRoundTrip(this::testVariantRoundTrip); + } + + @Test + public void testVariantNestedStructures() { + VariantTestHelper.testVariantNestedStructures(this::testVariantRoundTrip); + } + + @ParameterizedTest + @FieldSource("org.apache.iceberg.variants.VariantTestHelper#UNSUPPORTED_PRIMITIVES") + public void testUnsupportedOperations(VariantPrimitive primitive) { + ByteBuffer valueBuffer = + ByteBuffer.allocate(primitive.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + primitive.writeTo(valueBuffer, 0); + + BinaryVariant flinkVariant = + new BinaryVariant(valueBuffer.array(), VariantTestUtil.emptyMetadata().array()); + + assertThatThrownBy(flinkVariant::toJson) + .as("Unsupported variant type in Flink") + .isInstanceOf(VariantTypeException.class) + .hasMessageContaining("UNKNOWN_PRIMITIVE_TYPE_IN_VARIANT"); + } + + @Test + public void testVariantWriteAndRead() { + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.VariantType.get())); + + LogicalType logicalType = FlinkSchemaUtil.convert(schema); + assertThat(logicalType).isNotNull(); + + assertThat(logicalType).isInstanceOf(RowType.class); + assertThat(logicalType.getChildren().get(1)).isInstanceOf(VariantType.class); + } + + @Test + public void testVariantBinaryVariantSupport() { + Schema schema = new Schema(Types.NestedField.required(1, "data", Types.VariantType.get())); + + LogicalType logicalType = FlinkSchemaUtil.convert(schema); + assertThat(logicalType).isNotNull(); + + assertThat(logicalType).isInstanceOf(RowType.class); + } + + @Test + public void testVariantSchemaVisitor() throws IOException { + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.VariantType.get())); + + LogicalType logicalType = FlinkSchemaUtil.convert(schema); + + assertThat(logicalType).isNotNull(); + + OutputFile outputFile = new InMemoryOutputFile(); + + FileAppender writer = + Parquet.write(outputFile) + .schema(schema) + .createWriterFunc(msgType -> FlinkParquetWriters.buildWriter(logicalType, msgType)) + .build(); + + writer.close(); + + assertThat(writer).isNotNull(); + } + + private void testVariantRoundTrip(VariantMetadata metadata, VariantValue value) { + Variant icebergVariant = Variant.of(metadata, value); + + ByteBuffer metadataBuffer = + ByteBuffer.allocate(metadata.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + metadata.writeTo(metadataBuffer, 0); + + ByteBuffer valueBuffer = + ByteBuffer.allocate(value.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + value.writeTo(valueBuffer, 0); + + byte[] metadataBytes = ByteBuffers.toByteArray(metadataBuffer); + byte[] valueBytes = ByteBuffers.toByteArray(valueBuffer); + BinaryVariant flinkVariant = new BinaryVariant(valueBytes, metadataBytes); + + assertThat(icebergVariant).isNotNull(); + assertThat(icebergVariant.value()).isNotNull(); + assertThat(icebergVariant.metadata()).isNotNull(); + + assertThat(flinkVariant).isNotNull(); + assertThat(flinkVariant.getValue()).isNotNull(); + assertThat(flinkVariant.getMetadata()).isNotNull(); + + assertThat(flinkVariant.getValue()).isEqualTo(valueBytes); + assertThat(flinkVariant.getMetadata()).isEqualTo(metadataBytes); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java index 4e5b38ffb026..a2411da1e344 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/data/TestRowDataProjection.java @@ -271,18 +271,19 @@ public void testMapOfPrimitivesProjection() { GenericRowData.of( StringData.fromString("row_id_value"), new GenericMapData( - ImmutableMap.of(StringData.fromString("foo"), 1, StringData.fromString("bar"), 2))); + ImmutableMap.of(StringData.fromString("foo"), 1, StringData.fromString("bar"), 2)), + null); testEqualsAndHashCode(schema, idOnly, rowData, copyRowData, otherRowData, true); testEqualsAndHashCode(schema, mapOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, schema, rowData, copyRowData, otherRowData); GenericRowData rowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("row_id_value"), null); + GenericRowData.of(StringData.fromString("row_id_value"), null, null); GenericRowData copyRowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("row_id_value"), null); + GenericRowData.of(StringData.fromString("row_id_value"), null, null); // modify the map field value GenericRowData otherRowDataNullOptionalFields = - GenericRowData.of(StringData.fromString("other_row_id_value"), null); + GenericRowData.of(StringData.fromString("other_row_id_value"), null, null); testEqualsAndHashCode( schema, idOnly, @@ -432,7 +433,8 @@ public void testArrayOfPrimitiveProjection() { GenericRowData otherRowData = GenericRowData.of( StringData.fromString("other_row_id_value"), - new GenericArrayData(new Integer[] {4, 5, 6})); + new GenericArrayData(new Integer[] {4, 5, 6}), + null); testEqualsAndHashCode(schema, idOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, arrayOnly, rowData, copyRowData, otherRowData); testEqualsAndHashCode(schema, schema, rowData, copyRowData, otherRowData); @@ -440,16 +442,19 @@ public void testArrayOfPrimitiveProjection() { GenericRowData rowDataNullOptionalFields = GenericRowData.of( StringData.fromString("row_id_value"), - new GenericArrayData(new Integer[] {1, null, 3})); + new GenericArrayData(new Integer[] {1, null, 3}), + null); GenericRowData copyRowDataNullOptionalFields = GenericRowData.of( StringData.fromString("row_id_value"), - new GenericArrayData(new Integer[] {1, null, 3})); + new GenericArrayData(new Integer[] {1, null, 3}), + null); // modify the map field value GenericRowData otherRowDataNullOptionalFields = GenericRowData.of( StringData.fromString("other_row_id_value"), - new GenericArrayData(new Integer[] {4, null, 6})); + new GenericArrayData(new Integer[] {4, null, 6}), + null); testEqualsAndHashCode( schema, idOnly, diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java new file mode 100644 index 000000000000..f26f608ffa61 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestDeleteOrphanFilesConfig.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.flink.configuration.Configuration; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles.PrefixMismatchMode; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestDeleteOrphanFilesConfig extends OperatorTestBase { + private Table table; + private Map input = Maps.newHashMap(); + + @BeforeEach + public void before() { + this.table = createTable(); + input.put(DeleteOrphanFilesConfig.SCHEDULE_ON_INTERVAL_SECOND, "60"); + input.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + input.put(DeleteOrphanFilesConfig.DELETE_BATCH_SIZE, "500"); + input.put(DeleteOrphanFilesConfig.LOCATION, "/tmp/test-location"); + input.put(DeleteOrphanFilesConfig.USE_PREFIX_LISTING, "true"); + input.put(DeleteOrphanFilesConfig.PLANNING_WORKER_POOL_SIZE, "4"); + input.put(DeleteOrphanFilesConfig.EQUAL_SCHEMES, "s3n=s3,s3a=s3"); + input.put(DeleteOrphanFilesConfig.EQUAL_AUTHORITIES, "auth1=auth2"); + input.put(DeleteOrphanFilesConfig.PREFIX_MISMATCH_MODE, "IGNORE"); + input.put("other.config", "should-be-ignored"); + } + + @AfterEach + public void after() { + input.clear(); + } + + @Test + void testConfigParsing() { + DeleteOrphanFilesConfig config = new DeleteOrphanFilesConfig(table, input, new Configuration()); + + assertThat(config.scheduleOnIntervalSecond()).isEqualTo(60); + assertThat(config.minAgeSeconds()).isEqualTo(86400L); + assertThat(config.deleteBatchSize()).isEqualTo(500); + assertThat(config.location()).isEqualTo("/tmp/test-location"); + assertThat(config.usePrefixListing()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(4); + assertThat(config.equalSchemes()).containsEntry("s3n", "s3").containsEntry("s3a", "s3"); + assertThat(config.equalAuthorities()).containsEntry("auth1", "auth2"); + assertThat(config.prefixMismatchMode()).isEqualTo(PrefixMismatchMode.IGNORE); + } + + @Test + void testConfigDefaults() { + DeleteOrphanFilesConfig config = + new DeleteOrphanFilesConfig(table, Maps.newHashMap(), new Configuration()); + + assertThat(config.scheduleOnIntervalSecond()) + .isEqualTo(DeleteOrphanFilesConfig.SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()); + assertThat(config.minAgeSeconds()) + .isEqualTo(DeleteOrphanFilesConfig.MIN_AGE_SECONDS_OPTION.defaultValue()); + assertThat(config.deleteBatchSize()) + .isEqualTo(DeleteOrphanFilesConfig.DELETE_BATCH_SIZE_OPTION.defaultValue()); + assertThat(config.location()).isNull(); + assertThat(config.usePrefixListing()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(ThreadPools.WORKER_THREAD_POOL_SIZE); + assertThat(config.equalSchemes()).containsEntry("s3n", "s3").containsEntry("s3a", "s3"); + assertThat(config.equalAuthorities()).isEqualTo(Map.of()); + assertThat(config.prefixMismatchMode()).isEqualTo(PrefixMismatchMode.ERROR); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java index b8aa259e2f17..a23850d82341 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshots.java @@ -164,7 +164,7 @@ void testUidAndSlotSharingGroupUnset() { 0, tableLoader(), UID_SUFFIX, - StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP, + null, 1) .sinkTo(infra.sink()); diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java new file mode 100644 index 000000000000..3bcec8114b0c --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestExpireSnapshotsConfig.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.flink.configuration.Configuration; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ThreadPools; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestExpireSnapshotsConfig extends OperatorTestBase { + private Table table; + private Map input = Maps.newHashMap(); + + @BeforeEach + public void before() { + this.table = createTable(); + input.put(ExpireSnapshotsConfig.SCHEDULE_ON_COMMIT_COUNT, "10"); + input.put(ExpireSnapshotsConfig.SCHEDULE_ON_INTERVAL_SECOND, "60"); + input.put(ExpireSnapshotsConfig.MAX_SNAPSHOT_AGE_SECONDS, "7200"); + input.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + input.put(ExpireSnapshotsConfig.DELETE_BATCH_SIZE, "500"); + input.put(ExpireSnapshotsConfig.CLEAN_EXPIRED_METADATA, "true"); + input.put(ExpireSnapshotsConfig.PLANNING_WORKER_POOL_SIZE, "4"); + input.put("other.config", "should-be-ignored"); + } + + @AfterEach + public void after() { + input.clear(); + } + + @Test + void testConfigParsing() { + ExpireSnapshotsConfig config = new ExpireSnapshotsConfig(table, input, new Configuration()); + + assertThat(config.scheduleOnCommitCount()).isEqualTo(10); + assertThat(config.scheduleOnIntervalSecond()).isEqualTo(60); + assertThat(config.maxSnapshotAgeSeconds()).isEqualTo(7200L); + assertThat(config.retainLast()).isEqualTo(5); + assertThat(config.deleteBatchSize()).isEqualTo(500); + assertThat(config.cleanExpiredMetadata()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(4); + } + + @Test + void testConfigDefaults() { + ExpireSnapshotsConfig config = + new ExpireSnapshotsConfig(table, Maps.newHashMap(), new Configuration()); + + assertThat(config.scheduleOnCommitCount()) + .isEqualTo(ExpireSnapshotsConfig.SCHEDULE_ON_COMMIT_COUNT_OPTION.defaultValue()); + assertThat(config.scheduleOnIntervalSecond()) + .isEqualTo(ExpireSnapshotsConfig.SCHEDULE_ON_INTERVAL_SECOND_OPTION.defaultValue()); + assertThat(config.maxSnapshotAgeSeconds()).isNull(); + assertThat(config.retainLast()).isNull(); + assertThat(config.deleteBatchSize()) + .isEqualTo(ExpireSnapshotsConfig.DELETE_BATCH_SIZE_OPTION.defaultValue()); + assertThat(config.cleanExpiredMetadata()).isTrue(); + assertThat(config.planningWorkerPoolSize()).isEqualTo(ThreadPools.WORKER_THREAD_POOL_SIZE); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java index 3cb18ffbb77e..4d35792e440e 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestJdbcLockFactory.java @@ -19,11 +19,18 @@ package org.apache.iceberg.flink.maintenance.api; import static org.apache.iceberg.flink.maintenance.api.JdbcLockFactory.INIT_LOCK_TABLES_PROPERTY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.sql.SQLTransientConnectionException; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.iceberg.jdbc.JdbcCatalog; +import org.apache.iceberg.jdbc.JdbcClientPool; +import org.apache.iceberg.jdbc.UncheckedSQLException; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.Test; class TestJdbcLockFactory extends TestLockFactoryBase { @Override @@ -38,4 +45,62 @@ TriggerLockFactory lockFactory(String tableName) { tableName, properties); } + + @Test + void testSQLExceptionEnablesRetryInClientPool() throws Exception { + // Regression test for #15759: verify that removing the inner try-catch allows + // ClientPoolImpl to retry on transient connection failures. + // + // Before the fix: inner catch converted SQLException -> UncheckedSQLException + // (RuntimeException) inside the lambda. ClientPoolImpl only catches the declared + // exception type (SQLException), so RuntimeException bypasses retry entirely. + // After the fix: SQLException propagates naturally, ClientPoolImpl catches it, + // and retries on transient connection exceptions. + Map props = Maps.newHashMap(); + props.put("username", "user"); + props.put("password", "password"); + String uri = "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", ""); + + try (JdbcClientPool pool = new JdbcClientPool(1, uri, props)) { + AtomicInteger attempts = new AtomicInteger(0); + + String result = + pool.run( + conn -> { + if (attempts.incrementAndGet() == 1) { + throw new SQLTransientConnectionException("transient failure"); + } + + return "success"; + }); + + assertThat(result).isEqualTo("success"); + assertThat(attempts.get()).isGreaterThan(1); + } + } + + @Test + void testUncheckedSQLExceptionBypassesRetry() throws Exception { + // Companion test: demonstrates that wrapping SQLException as UncheckedSQLException + // (the OLD behavior before the fix) prevents ClientPoolImpl from retrying. + Map props = Maps.newHashMap(); + props.put("username", "user"); + props.put("password", "password"); + String uri = "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", ""); + + try (JdbcClientPool pool = new JdbcClientPool(1, uri, props)) { + assertThatThrownBy( + () -> + pool.run( + conn -> { + try { + throw new SQLTransientConnectionException("transient failure"); + } catch (java.sql.SQLException e) { + throw new UncheckedSQLException(e, "wrapped"); + } + })) + .isInstanceOf(UncheckedSQLException.class) + .hasMessageContaining("wrapped"); + } + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java index 0a860fec4799..fe8457167a1f 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestMaintenanceE2E.java @@ -77,4 +77,43 @@ void testE2e() throws Exception { closeJobClient(jobClient); } } + + @Test + void testE2eUseCoordinator() throws Exception { + TableMaintenance.forTable(env, tableLoader()) + .uidSuffix("E2eTestUID") + .rateLimit(Duration.ofMinutes(10)) + .lockCheckDelay(Duration.ofSeconds(10)) + .add( + ExpireSnapshots.builder() + .scheduleOnCommitCount(10) + .maxSnapshotAge(Duration.ofMinutes(10)) + .retainLast(5) + .deleteBatchSize(5) + .parallelism(8)) + .add( + RewriteDataFiles.builder() + .scheduleOnDataFileCount(10) + .partialProgressEnabled(true) + .partialProgressMaxCommits(10) + .maxRewriteBytes(1000L) + .targetFileSizeBytes(1000L) + .minFileSizeBytes(1000L) + .maxFileSizeBytes(1000L) + .minInputFiles(10) + .deleteFileThreshold(10) + .rewriteAll(false) + .maxFileGroupSizeBytes(1000L)) + .append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Just make sure that we are able to instantiate the flow + assertThat(jobClient).isNotNull(); + } finally { + closeJobClient(jobClient); + } + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java index 707038c925d5..c27f6081af5a 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestRewriteDataFiles.java @@ -29,9 +29,11 @@ import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.REMOVED_DATA_FILE_SIZE_METRIC; import static org.assertj.core.api.Assertions.assertThat; +import java.time.Instant; import java.util.List; import java.util.stream.StreamSupport; import org.apache.flink.streaming.api.graph.StreamGraphGenerator; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; @@ -43,8 +45,14 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; class TestRewriteDataFiles extends MaintenanceTaskTestBase { + + private static final FileFormat[] FILE_FORMATS = + new FileFormat[] {FileFormat.AVRO, FileFormat.PARQUET, FileFormat.ORC}; + @Test void testRewriteUnpartitioned() throws Exception { Table table = createTable(); @@ -82,13 +90,14 @@ void testRewriteUnpartitioned() throws Exception { createRecord(4, "d"))); } - @Test - void testRewriteUnpartitionedPreserveLineage() throws Exception { - Table table = createTable(3); - insert(table, 1, "a"); - insert(table, 2, "b"); - insert(table, 3, "c"); - insert(table, 4, "d"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewriteUnpartitionedPreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createTable(3, fileFormat); + insert(table, 1, "a", fileFormat); + insert(table, 2, "b", fileFormat); + insert(table, 3, "c", fileFormat); + insert(table, 4, "d", fileFormat); assertFileNum(table, 4, 0); @@ -122,15 +131,17 @@ void testRewriteUnpartitionedPreserveLineage() throws Exception { schema); } - @Test - void testRewriteTheSameFilePreserveLineage() throws Exception { - Table table = createTable(3); - insert(table, 1, "a"); - insert(table, 2, "b"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewriteTheSameFilePreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createTable(3, fileFormat); + insert(table, 1, "a", fileFormat); + insert(table, 2, "b", fileFormat); // Create a file with two lines of data to verify that the rowid is read correctly. insert( table, - ImmutableList.of(SimpleDataUtil.createRecord(3, "c"), SimpleDataUtil.createRecord(4, "d"))); + ImmutableList.of(SimpleDataUtil.createRecord(3, "c"), SimpleDataUtil.createRecord(4, "d")), + fileFormat); assertFileNum(table, 3, 0); @@ -166,13 +177,14 @@ void testRewriteTheSameFilePreserveLineage() throws Exception { schema); } - @Test - void testRewritePartitionedPreserveLineage() throws Exception { - Table table = createPartitionedTable(3); - insertPartitioned(table, 1, "p1"); - insertPartitioned(table, 2, "p1"); - insertPartitioned(table, 3, "p2"); - insertPartitioned(table, 4, "p2"); + @ParameterizedTest + @FieldSource("FILE_FORMATS") + void testRewritePartitionedPreserveLineage(FileFormat fileFormat) throws Exception { + Table table = createPartitionedTable(3, fileFormat); + insertPartitioned(table, 1, "p1", fileFormat); + insertPartitioned(table, 2, "p1", fileFormat); + insertPartitioned(table, 3, "p2", fileFormat); + insertPartitioned(table, 4, "p2", fileFormat); assertFileNum(table, 4, 0); @@ -328,7 +340,7 @@ void testUidAndSlotSharingGroupUnset() { 0, tableLoader(), UID_SUFFIX, - StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP, + null, 1) .sinkTo(infra.sink()); @@ -529,6 +541,92 @@ void testRewriteWithFilter() throws Exception { createRecord(4, "d"))); } + /** + * By verifying that the creation time of the data content in the builder is later than the + * creation time of the filter condition — if the filter condition is actually created in the + * planner, then all files can be compacted; otherwise, not all files can be compacted — we can + * confirm whether the filter condition is actually created in the planner. + */ + @Test + void testRewriteWithFilterSupplier() throws Exception { + Table table = createTable(); + + appendRewriteDataFiles( + RewriteDataFiles.builder() + .parallelism(2) + .deleteFileThreshold(10) + .targetFileSizeBytes(1_000_000L) + .maxFileGroupSizeBytes(10_000_000L) + .maxFileSizeBytes(2_000_000L) + .minFileSizeBytes(500_000L) + .minInputFiles(2) + // Rewrite data files where id is less than current timestamp in planner + .filter(() -> Expressions.lessThan("id", (int) Instant.now().getEpochSecond())) + .partialProgressEnabled(true) + .partialProgressMaxCommits(1) + .maxRewriteBytes(100_000L) + .rewriteAll(false)); + + insert(table, 1, "a"); + insert(table, 2, "b"); + insert(table, 3, "c"); + + int epochSecond = (int) Instant.now().getEpochSecond(); + insert(table, epochSecond, "d"); + + assertFileNum(table, 4, 0); + + Thread.sleep(1_000L); + runAndWaitForSuccess(infra.env(), infra.source(), infra.sink()); + + // There is four files, only id is less than current timestamp will be rewritten. so expect 2 + // files. + assertFileNum(table, 1, 0); + + SimpleDataUtil.assertTableRecords( + table, + ImmutableList.of( + createRecord(1, "a"), + createRecord(2, "b"), + createRecord(3, "c"), + createRecord(epochSecond, "d"))); + } + + @Test + void testBranch() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + insert(table, 2, "b"); + + // Create branch based on above inserts + String branchName = "test-branch"; + table.manageSnapshots().createBranch(branchName).commit(); + + // Insert another file on main only (main has 3 files, branch stays at 2) + insert(table, 3, "c"); + + appendRewriteDataFiles(RewriteDataFiles.builder().rewriteAll(true).branch(branchName)); + + runAndWaitForSuccess(infra.env(), infra.source(), infra.sink()); + + table.refresh(); + + // Branch should be compacted from 2 files to 1 + assertThat( + table.snapshot(branchName).dataManifests(table.io()).stream() + .flatMap( + m -> + StreamSupport.stream( + ManifestFiles.read(m, table.io(), table.specs()).spliterator(), false)) + .count()) + .isEqualTo(1); + SimpleDataUtil.assertTableRecords( + table, ImmutableList.of(createRecord(1, "a"), createRecord(2, "b")), branchName); + + // Main should be untouched with 3 files + assertFileNum(table, 3, 0); + } + private void appendRewriteDataFiles() { appendRewriteDataFiles(RewriteDataFiles.builder().rewriteAll(true)); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java index eaa5b5e1b5b1..49219d5b4698 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenance.java @@ -424,12 +424,12 @@ String maintenanceTaskName() { @Override DataStream append(DataStream trigger) { String name = TASKS[id]; - return trigger - .map(new DummyMaintenanceTask(success)) - .name(name) - .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) - .slotSharingGroup(slotSharingGroup()) - .forceNonParallel(); + return setSlotSharingGroup( + trigger + .map(new DummyMaintenanceTask(success)) + .name(name) + .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) + .forceNonParallel()); } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java new file mode 100644 index 000000000000..9b7941c2d306 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestTableMaintenanceCoordinationLock.java @@ -0,0 +1,344 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.api; + +import static org.apache.iceberg.flink.SimpleDataUtil.createRowData; +import static org.apache.iceberg.flink.maintenance.api.TableMaintenance.SOURCE_OPERATOR_NAME_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import org.apache.flink.api.common.functions.MapFunction; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.api.dag.Transformation; +import org.apache.flink.api.java.tuple.Tuple2; +import org.apache.flink.api.java.typeutils.ResultTypeQueryable; +import org.apache.flink.configuration.CheckpointingOptions; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.transformations.SourceTransformation; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.runtime.typeutils.InternalTypeInfo; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.FlinkSchemaUtil; +import org.apache.iceberg.flink.TableLoader; +import org.apache.iceberg.flink.maintenance.operator.ManualSource; +import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase; +import org.apache.iceberg.flink.maintenance.operator.TableChange; +import org.apache.iceberg.flink.sink.FlinkSink; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TestTableMaintenanceCoordinationLock extends OperatorTestBase { + private static final String MAINTENANCE_TASK_NAME = "TestTableMaintenance"; + private static final String[] TASKS = + new String[] {MAINTENANCE_TASK_NAME + " [0]", MAINTENANCE_TASK_NAME + " [1]"}; + private static final TableChange DUMMY_CHANGE = TableChange.builder().commitCount(1).build(); + private static final List PROCESSED = + Collections.synchronizedList(Lists.newArrayListWithCapacity(1)); + + private StreamExecutionEnvironment env; + private Table table; + + @TempDir private File checkpointDir; + + @BeforeEach + void beforeEach() throws IOException { + Configuration config = new Configuration(); + config.set(CheckpointingOptions.CHECKPOINT_STORAGE, "filesystem"); + config.set(CheckpointingOptions.CHECKPOINTS_DIRECTORY, "file://" + checkpointDir.getPath()); + this.env = StreamExecutionEnvironment.getExecutionEnvironment(config); + this.table = createTable(); + insert(table, 1, "a"); + + PROCESSED.clear(); + MaintenanceTaskBuilderForTest.counter = 0; + } + + @Test + void testForChangeStream() throws Exception { + ManualSource schedulerSource = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + + TableMaintenance.Builder streamBuilder = + TableMaintenance.forChangeStream(schedulerSource.dataStream(), tableLoader()) + .rateLimit(Duration.ofMillis(2)) + .lockCheckDelay(Duration.ofSeconds(3)) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .scheduleOnDataFileCount(2) + .scheduleOnDataFileSize(3L) + .scheduleOnEqDeleteFileCount(4) + .scheduleOnEqDeleteRecordCount(5L) + .scheduleOnPosDeleteFileCount(6) + .scheduleOnPosDeleteRecordCount(7L) + .scheduleOnInterval(Duration.ofHours(1))); + + sendEvents(schedulerSource, streamBuilder, ImmutableList.of(Tuple2.of(DUMMY_CHANGE, 1))); + } + + @Test + void testForTable() throws Exception { + TableLoader tableLoader = tableLoader(); + + env.enableCheckpointing(10); + + TableMaintenance.forTable(env, tableLoader) + .rateLimit(Duration.ofMillis(2)) + .maxReadBack(2) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(2)) + .append(); + + // Creating a stream for inserting data into the table concurrently + ManualSource insertSource = + new ManualSource<>(env, InternalTypeInfo.of(FlinkSchemaUtil.convert(table.schema()))); + FlinkSink.forRowData(insertSource.dataStream()) + .tableLoader(tableLoader) + .uidPrefix(UID_SUFFIX + "-iceberg-sink") + .append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + insertSource.sendRecord(createRowData(2, "b")); + + Awaitility.await().until(() -> PROCESSED.size() == 1); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testUidAndSlotSharingGroup() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP)) + .append(); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + @Test + void testUidAndSlotSharingGroupUnset() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(1)) + .append(); + + checkUidsAreSet(env, null); + checkSlotSharingGroupsAreSet(env, null); + } + + @Test + void testUidAndSlotSharingGroupInherit() throws IOException { + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add(new MaintenanceTaskBuilderForTest(true).scheduleOnCommitCount(1)) + .append(); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + @Test + void testUidAndSlotSharingGroupOverWrite() throws IOException { + String anotherUid = "Another-UID"; + String anotherSlotSharingGroup = "Another-SlotSharingGroup"; + TableMaintenance.forChangeStream( + new ManualSource<>(env, TypeInformation.of(TableChange.class)).dataStream(), + tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(anotherUid) + .slotSharingGroup(anotherSlotSharingGroup)) + .append(); + + // Choose an operator from the scheduler part of the graph + Transformation schedulerTransformation = + env.getTransformations().stream() + .filter(t -> t.getName().equals("Trigger manager")) + .findFirst() + .orElseThrow(); + assertThat(schedulerTransformation.getUid()).contains(UID_SUFFIX); + assertThat(schedulerTransformation.getSlotSharingGroup()).isPresent(); + assertThat(schedulerTransformation.getSlotSharingGroup().get().getName()) + .isEqualTo(SLOT_SHARING_GROUP); + + // Choose an operator from the maintenance task part of the graph + Transformation scheduledTransformation = + env.getTransformations().stream() + .filter(t -> t.getName().startsWith(MAINTENANCE_TASK_NAME)) + .findFirst() + .orElseThrow(); + assertThat(scheduledTransformation.getUid()).contains(anotherUid); + assertThat(scheduledTransformation.getSlotSharingGroup()).isPresent(); + assertThat(scheduledTransformation.getSlotSharingGroup().get().getName()) + .isEqualTo(anotherSlotSharingGroup); + } + + @Test + void testUidAndSlotSharingGroupForMonitorSource() throws IOException { + TableMaintenance.forTable(env, tableLoader()) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP) + .add( + new MaintenanceTaskBuilderForTest(true) + .scheduleOnCommitCount(1) + .uidSuffix(UID_SUFFIX) + .slotSharingGroup(SLOT_SHARING_GROUP)) + .append(); + + Transformation source = monitorSource(); + assertThat(source).isNotNull(); + assertThat(source.getUid()).contains(UID_SUFFIX); + assertThat(source.getSlotSharingGroup()).isPresent(); + assertThat(source.getSlotSharingGroup().get().getName()).isEqualTo(SLOT_SHARING_GROUP); + + checkUidsAreSet(env, UID_SUFFIX); + checkSlotSharingGroupsAreSet(env, SLOT_SHARING_GROUP); + } + + /** + * Sends the events though the {@link ManualSource} provided, and waits until the given number of + * records are processed. + * + * @param schedulerSource used for sending the events + * @param streamBuilder used for generating the job + * @param eventsAndResultNumbers the pair of the event and the expected processed records + * @throws Exception if any + */ + private void sendEvents( + ManualSource schedulerSource, + TableMaintenance.Builder streamBuilder, + List> eventsAndResultNumbers) + throws Exception { + streamBuilder.append(); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + eventsAndResultNumbers.forEach( + eventsAndResultNumber -> { + int expectedSize = PROCESSED.size() + eventsAndResultNumber.f1; + schedulerSource.sendRecord(eventsAndResultNumber.f0); + Awaitility.await().until(() -> PROCESSED.size() == expectedSize); + }); + } finally { + closeJobClient(jobClient); + } + } + + /** + * Finds the {@link org.apache.iceberg.flink.maintenance.operator.MonitorSource} for testing + * purposes by parsing the transformation tree. + * + * @return The monitor source if we found it + */ + private Transformation monitorSource() { + assertThat(env.getTransformations()).isNotEmpty(); + assertThat(env.getTransformations().get(0).getInputs()).isNotEmpty(); + assertThat(env.getTransformations().get(0).getInputs().get(0).getInputs()).isNotEmpty(); + + Transformation result = + env.getTransformations().get(0).getInputs().get(0).getInputs().get(0); + + // Some checks to make sure this is the transformation we are looking for + assertThat(result).isInstanceOf(SourceTransformation.class); + assertThat(result.getName()).startsWith(SOURCE_OPERATOR_NAME_PREFIX); + + return result; + } + + private static class MaintenanceTaskBuilderForTest + extends MaintenanceTaskBuilder { + private final boolean success; + private final int id; + private static int counter = 0; + + MaintenanceTaskBuilderForTest(boolean success) { + this.success = success; + this.id = counter; + ++counter; + } + + @Override + String maintenanceTaskName() { + return MAINTENANCE_TASK_NAME; + } + + @Override + DataStream append(DataStream trigger) { + String name = TASKS[id]; + return setSlotSharingGroup( + trigger + .map(new DummyMaintenanceTask(success)) + .name(name) + .uid(uidSuffix() + "-test-mapper-" + name + "-" + id) + .forceNonParallel()); + } + } + + private record DummyMaintenanceTask(boolean success) + implements MapFunction, ResultTypeQueryable, Serializable { + + @Override + public TaskResult map(Trigger trigger) { + PROCESSED.add(trigger); + + return new TaskResult( + trigger.taskId(), + trigger.timestamp(), + success, + success ? Collections.emptyList() : Lists.newArrayList(new Exception("Testing error"))); + } + + @Override + public TypeInformation getProducedType() { + return TypeInformation.of(TaskResult.class); + } + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java new file mode 100644 index 000000000000..5bfc889f2720 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/CoordinatorTestBase.java @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 10) +class CoordinatorTestBase extends OperatorTestBase { + protected static final String OPERATOR_NAME = "TestCoordinator"; + protected static final String OPERATOR_NAME_1 = "TestCoordinator_1"; + protected static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + protected static final OperatorID TEST_OPERATOR_ID_1 = new OperatorID(1235L, 5679L); + protected static final int NUM_SUBTASKS = 1; + protected static final LockRegisterEvent LOCK_REGISTER_EVENT = + new LockRegisterEvent(DUMMY_TABLE_NAME); + protected static final LockReleaseEvent LOCK_RELEASE_EVENT = + new LockReleaseEvent(DUMMY_TABLE_NAME, 1L); + + protected static void setAllTasksReady( + BaseCoordinator baseCoordinator, EventReceivingTasks receivingTasks) { + for (int i = 0; i < NUM_SUBTASKS; i++) { + baseCoordinator.executionAttemptReady(i, 0, receivingTasks.createGatewayForSubtask(i, 0)); + } + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java index b9422a63d646..4f394be76f4f 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/OperatorTestBase.java @@ -24,7 +24,10 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; +import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; import org.apache.flink.configuration.Configuration; import org.apache.flink.configuration.MetricOptions; @@ -32,7 +35,6 @@ import org.apache.flink.core.execution.SavepointFormatType; import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraphGenerator; import org.apache.flink.streaming.api.transformations.SinkTransformation; import org.apache.flink.streaming.api.watermark.Watermark; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; @@ -79,6 +81,12 @@ public class OperatorTestBase { ImmutableMap.of(), ImmutableSet.of(SimpleDataUtil.SCHEMA.columns().get(0).fieldId())); + private static final Schema SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "ts", Types.TimestampType.withoutZone())); + protected static final String UID_SUFFIX = "UID-Dummy"; protected static final String SLOT_SHARING_GROUP = "SlotSharingGroup"; protected static final TriggerLockFactory LOCK_FACTORY = new MemoryLockFactory(); @@ -124,10 +132,14 @@ void after() throws IOException { } protected static Table createTable() { - return createTable(2); + return createTable(2, FileFormat.PARQUET); } protected static Table createTable(int formatVersion) { + return createPartitionedTable(formatVersion, FileFormat.PARQUET); + } + + protected static Table createTable(int formatVersion, FileFormat fileFormat) { return CATALOG_EXTENSION .catalog() .createTable( @@ -136,12 +148,29 @@ protected static Table createTable(int formatVersion) { PartitionSpec.unpartitioned(), null, ImmutableMap.of( + "write.format.default", + fileFormat.name(), TableProperties.FORMAT_VERSION, String.valueOf(formatVersion), "flink.max-continuous-empty-commits", "100000")); } + protected static Table createTableWithTimestampWithoutZone() { + return CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE, + PartitionSpec.builderFor(SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE).identity("ts").build(), + null, + ImmutableMap.of( + TableProperties.FORMAT_VERSION, + "2", + "flink.max-continuous-empty-commits", + "100000")); + } + protected static Table createTableWithDelete() { return createTableWithDelete(2); } @@ -158,7 +187,7 @@ protected static Table createTableWithDelete(int formatVersion) { "format-version", String.valueOf(formatVersion), "write.upsert.enabled", "true")); } - protected static Table createPartitionedTable(int formatVersion) { + protected static Table createPartitionedTable(int formatVersion, FileFormat fileFormat) { return CATALOG_EXTENSION .catalog() .createTable( @@ -167,6 +196,8 @@ protected static Table createPartitionedTable(int formatVersion) { PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).identity("data").build(), null, ImmutableMap.of( + "write.format.default", + fileFormat.name(), "format-version", String.valueOf(formatVersion), "flink.max-continuous-empty-commits", @@ -174,17 +205,27 @@ protected static Table createPartitionedTable(int formatVersion) { } protected static Table createPartitionedTable() { - return createPartitionedTable(2); + return createPartitionedTable(2, FileFormat.PARQUET); } protected void insert(Table table, Integer id, String data) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + insert(table, id, data, FileFormat.PARQUET); + } + + protected void insert(Table table, Integer id, String data, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir) .appendToTable(Lists.newArrayList(SimpleDataUtil.createRecord(id, data))); table.refresh(); } protected void insert(Table table, List records) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir).appendToTable(records); + insert(table, records, FileFormat.PARQUET); + } + + protected void insert(Table table, List records, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir).appendToTable(records); table.refresh(); } @@ -194,6 +235,20 @@ protected void insert(Table table, Integer id, String data, String extra) throws table.refresh(); } + protected void insertWithTimestampWithoutZone( + Table table, Integer id, String data, LocalDateTime ts) throws IOException { + GenericRecord record = GenericRecord.create(SCHEMA_WITH_TIMESTAMP_WITHOUT_ZONE); + record.setField("id", id); + record.setField("data", data); + record.setField("ts", ts); + long tsMicros = + TimeUnit.SECONDS.toMicros(ts.toEpochSecond(ZoneOffset.UTC)) + + TimeUnit.NANOSECONDS.toMicros(ts.getNano()); + new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + .appendToTable(TestHelpers.Row.of(tsMicros), Lists.newArrayList(record)); + table.refresh(); + } + /** * For the same identifier column id this methods simulate the following row operations: *

  • add an equality delete on oldData @@ -271,7 +326,12 @@ protected void update(Table table, Integer id, String oldData, String tempData, } protected void insertPartitioned(Table table, Integer id, String data) throws IOException { - new GenericAppenderHelper(table, FileFormat.PARQUET, warehouseDir) + insertPartitioned(table, id, data, FileFormat.PARQUET); + } + + protected void insertPartitioned(Table table, Integer id, String data, FileFormat fileFormat) + throws IOException { + new GenericAppenderHelper(table, fileFormat, warehouseDir) .appendToTable( TestHelpers.Row.of(data), Lists.newArrayList(SimpleDataUtil.createRecord(id, data))); table.refresh(); @@ -352,15 +412,17 @@ protected static void checkUidsAreSet(StreamExecutionEnvironment env, String uid } protected static void checkSlotSharingGroupsAreSet(StreamExecutionEnvironment env, String name) { - String nameToCheck = name != null ? name : StreamGraphGenerator.DEFAULT_SLOT_SHARING_GROUP; - env.getTransformations().stream() .filter( t -> !(t instanceof SinkTransformation) && !(t.getName().equals(IGNORED_OPERATOR_NAME))) .forEach( t -> { - assertThat(t.getSlotSharingGroup()).isPresent(); - assertThat(t.getSlotSharingGroup().get().getName()).isEqualTo(nameToCheck); + if (name == null) { + assertThat(t.getSlotSharingGroup()).isNotPresent(); + } else { + assertThat(t.getSlotSharingGroup()).isPresent(); + assertThat(t.getSlotSharingGroup().get().getName()).isEqualTo(name); + } }); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java index 68aaf29ac0d1..7b8f638b7e2f 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/RewriteUtil.java @@ -22,10 +22,13 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; +import java.util.Map; import java.util.Set; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses; import org.apache.iceberg.DataFile; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.flink.TableLoader; @@ -38,6 +41,11 @@ private RewriteUtil() {} static List planDataFileRewrite(TableLoader tableLoader) throws Exception { + return planDataFileRewrite(tableLoader, ImmutableMap.of(MIN_INPUT_FILES, "2")); + } + + static List planDataFileRewrite( + TableLoader tableLoader, Map rewriterOptions) throws Exception { try (OneInputStreamOperatorTestHarness testHarness = ProcessFunctionTestHarnesses.forProcessFunction( @@ -48,8 +56,9 @@ static List planDataFileRewrite(TableLoader tableLoader, 11, 10_000_000L, - ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + rewriterOptions, + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); @@ -80,6 +89,6 @@ static List executeRewrite( static Set newDataFiles(Table table) { table.refresh(); - return Sets.newHashSet(table.currentSnapshot().addedDataFiles(table.io())); + return Sets.newHashSet(SnapshotChanges.builderFor(table).build().addedDataFiles()); } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java index 9e8f2ec92162..ded958538ff0 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteCommitter.java @@ -31,6 +31,8 @@ import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.flink.maintenance.api.Trigger; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -225,7 +227,8 @@ private OneInputStreamOperatorTestHarness actualAdded = Sets.newHashSet(table.currentSnapshot().addedDataFiles(table.io())); - Set actualRemoved = - Sets.newHashSet(table.currentSnapshot().removedDataFiles(table.io())); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + Set actualAdded = Sets.newHashSet(changes.addedDataFiles()); + Set actualRemoved = Sets.newHashSet(changes.removedDataFiles()); assertThat(actualAdded.stream().map(DataFile::location).collect(Collectors.toSet())) .isEqualTo(expectedAdded.stream().map(DataFile::location).collect(Collectors.toSet())); assertThat(actualRemoved.stream().map(DataFile::location).collect(Collectors.toSet())) diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java index 9f4f96e1065b..8300df8c94eb 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewritePlanner.java @@ -18,11 +18,15 @@ */ package org.apache.iceberg.flink.maintenance.operator; +import static org.apache.iceberg.actions.SizeBasedFileRewritePlanner.MAX_FILE_GROUP_INPUT_FILES; import static org.apache.iceberg.actions.SizeBasedFileRewritePlanner.MIN_INPUT_FILES; import static org.apache.iceberg.flink.maintenance.operator.RewriteUtil.newDataFiles; import static org.apache.iceberg.flink.maintenance.operator.RewriteUtil.planDataFileRewrite; import static org.assertj.core.api.Assertions.assertThat; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -32,6 +36,7 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.FileContent; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.flink.maintenance.api.Trigger; @@ -105,7 +110,8 @@ void testError() throws Exception { 11, 1L, ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); // Cause an exception @@ -171,7 +177,8 @@ void testMaxRewriteBytes() throws Exception { 11, maxRewriteBytes, ImmutableMap.of(MIN_INPUT_FILES, "2"), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); @@ -182,6 +189,103 @@ void testMaxRewriteBytes() throws Exception { } } + @Test + void testMaxFileGroupCount() throws Exception { + Table table = createPartitionedTable(); + insertPartitioned(table, 1, "p1"); + insertPartitioned(table, 2, "p1"); + insertPartitioned(table, 3, "p2"); + insertPartitioned(table, 4, "p2"); + insertPartitioned(table, 5, "p2"); + insertPartitioned(table, 6, "p2"); + + List planWithNoLimit = planDataFileRewrite(tableLoader()); + assertThat(planWithNoLimit).hasSize(2); + + List planWithMaxFileGroupCount = + planDataFileRewrite( + tableLoader(), ImmutableMap.of(MIN_INPUT_FILES, "2", MAX_FILE_GROUP_INPUT_FILES, "2")); + assertThat(planWithMaxFileGroupCount).hasSize(3); + } + + @Test + void testBranch() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + insert(table, 2, "b"); + + String branchName = "test-branch"; + table.manageSnapshots().createBranch(branchName).commit(); + + // Insert more data on main only + insert(table, 3, "c"); + + try (OneInputStreamOperatorTestHarness + testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new DataFileRewritePlanner( + OperatorTestBase.DUMMY_TABLE_NAME, + OperatorTestBase.DUMMY_TABLE_NAME, + 0, + tableLoader(), + 11, + 10_000_000L, + ImmutableMap.of(MIN_INPUT_FILES, "2"), + Expressions::alwaysTrue, + branchName))) { + testHarness.open(); + + trigger(testHarness); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + List planned = testHarness.extractOutputValues(); + assertThat(planned).hasSize(1); + // Branch has 2 files, main has 3 + assertThat(planned.get(0).group().fileScanTasks()).hasSize(2); + assertThat(planned.get(0).branch()).isEqualTo(branchName); + } + } + + @Test + void testFilterSupplierWithTimestamp() throws Exception { + Table table = createTableWithTimestampWithoutZone(); + + LocalDateTime oldTs = LocalDateTime.now().minusDays(10); + insertWithTimestampWithoutZone(table, 1, "old_a", oldTs); + insertWithTimestampWithoutZone(table, 2, "old_b", oldTs); + + LocalDateTime recentTs = LocalDateTime.now().minusHours(1); + insertWithTimestampWithoutZone(table, 3, "new_a", recentTs); + insertWithTimestampWithoutZone(table, 4, "new_b", recentTs); + + try (OneInputStreamOperatorTestHarness + testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new DataFileRewritePlanner( + OperatorTestBase.DUMMY_TABLE_NAME, + OperatorTestBase.DUMMY_TABLE_NAME, + 0, + tableLoader(), + 11, + 10_000_000L, + ImmutableMap.of(MIN_INPUT_FILES, "2"), + () -> + Expressions.greaterThanOrEqual( + "ts", + LocalDateTime.now(ZoneOffset.UTC).minus(Duration.ofDays(3)).toString()), + SnapshotRef.MAIN_BRANCH))) { + testHarness.open(); + + trigger(testHarness); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + List planned = testHarness.extractOutputValues(); + + assertThat(planned).hasSize(1); + assertThat(planned.get(0).group().fileScanTasks()).hasSize(2); + } + } + void assertRewriteFileGroup( DataFileRewritePlanner.PlannedGroup plannedGroup, Table table, Set files) { assertThat(plannedGroup.table().currentSnapshot().snapshotId()) diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java index 4e21c7a956e4..62b29e7c017a 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestDataFileRewriteRunner.java @@ -38,6 +38,7 @@ import org.apache.iceberg.PartitionData; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.data.GenericAppenderHelper; @@ -308,7 +309,8 @@ void testSplitSize() throws Exception { "2", TARGET_FILE_SIZE_BYTES, String.valueOf(targetFileSize)), - Expressions.alwaysTrue()))) { + Expressions::alwaysTrue, + SnapshotRef.MAIN_BRANCH))) { testHarness.open(); OperatorTestBase.trigger(testHarness); diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java index bb8c74f3d5e9..8614c634f125 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestListMetadataFiles.java @@ -23,8 +23,10 @@ import java.util.List; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.flink.streaming.util.ProcessFunctionTestHarnesses; +import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.junit.jupiter.api.Test; class TestListMetadataFiles extends OperatorTestBase { @@ -87,4 +89,37 @@ void testMetadataFilesWithEmptyTable() throws Exception { assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); } } + + @Test + void testMetadataFilesIncludesSnapshotsAddedAfterOpen() throws Exception { + Table table = createTable(); + insert(table, 1, "a"); + + try (OneInputStreamOperatorTestHarness testHarness = + ProcessFunctionTestHarnesses.forProcessFunction( + new ListMetadataFiles(OperatorTestBase.DUMMY_TABLE_NAME, 0, tableLoader()))) { + testHarness.open(); + + // Add more snapshots AFTER the operator has been opened + insert(table, 2, "b"); + insert(table, 3, "c"); + + OperatorTestBase.trigger(testHarness); + + List tableMetadataFiles = testHarness.extractOutputValues(); + + // Verify that manifest lists from ALL 3 snapshots are present, not just the first one. + // Without table.refresh() in processElement, only snapshot 1's files would be emitted. + table.refresh(); + List snapshots = Lists.newArrayList(table.snapshots()); + assertThat(snapshots).hasSize(3); + for (Snapshot snapshot : snapshots) { + assertThat(tableMetadataFiles).contains(snapshot.manifestListLocation()); + } + // Verify total count matches what 3 snapshots should produce + assertThat(tableMetadataFiles).hasSize(24); + + assertThat(testHarness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull(); + } + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java new file mode 100644 index 000000000000..3427d2abe0ad --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoveCoordinator.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.ExecutionException; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Timeout(value = 10) +@Execution(ExecutionMode.SAME_THREAD) +class TestLockRemoveCoordinator extends CoordinatorTestBase { + + private EventReceivingTasks receivingTasks; + + @BeforeEach + void before() { + this.receivingTasks = EventReceivingTasks.createForRunningTasks(); + } + + @Test + void testEventHandling() throws Exception { + try (LockRemoverCoordinator lockRemoverCoordinator = createCoordinator()) { + + lockRemoverCoordinator.start(); + + setAllTasksReady(lockRemoverCoordinator, receivingTasks); + + lockRemoverCoordinator.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(lockRemoverCoordinator.pendingReleaseEvents()).hasSize(1); + } + } + + private LockRemoverCoordinator createCoordinator() { + return new LockRemoverCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)) { + @Override + void runInCoordinatorThread(Runnable runnable, String actionString) { + try { + coordinatorExecutor().submit(runnable).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(actionString, e); + } + } + }; + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java new file mode 100644 index 000000000000..a0b615e463d9 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestLockRemoverOperation.java @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.FAILED_TASK_COUNTER; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.LAST_RUN_DURATION_MS; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.apache.flink.runtime.operators.coordination.MockOperatorEventGateway; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.watermark.Watermark; +import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; +import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; +import org.apache.iceberg.flink.maintenance.api.TaskResult; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 10) +class TestLockRemoverOperation extends OperatorTestBase { + private static final String[] TASKS = new String[] {"task0", "task1", "task2"}; + private static final String OPERATOR_NAME = "TestCoordinator"; + private static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + + private LockRemoverCoordinator lockRemoverCoordinator; + + @BeforeEach + void before() { + MetricsReporterFactoryForTests.reset(); + this.lockRemoverCoordinator = createCoordinator(); + try { + lockRemoverCoordinator.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @AfterEach + void after() throws IOException { + super.after(); + try { + lockRemoverCoordinator.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void testProcess() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + LockRemoverOperator operator = + new LockRemoverOperator(null, mockGateway, DUMMY_TASK_NAME, Lists.newArrayList(TASKS[0])); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + testHarness.processElement( + new StreamRecord<>(new TaskResult(0, 0L, true, Lists.newArrayList()))); + assertThat(mockGateway.getEventsSent()).hasSize(0); + + testHarness.processWatermark(WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + } + } + + @Test + void testProcessMaxWaterMark() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + LockRemoverOperator operator = + new LockRemoverOperator(null, mockGateway, DUMMY_TASK_NAME, Lists.newArrayList(TASKS[0])); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + testHarness.processElement( + new StreamRecord<>(new TaskResult(0, 0L, true, Lists.newArrayList()))); + assertThat(mockGateway.getEventsSent()).hasSize(0); + + testHarness.processWatermark(WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + + testHarness.processWatermark(Watermark.MAX_WATERMARK); + assertThat(mockGateway.getEventsSent()).hasSize(1); + } + } + + @Test + void testMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = new ManualSource<>(env, TypeInformation.of(TaskResult.class)); + source + .dataStream() + .transform( + DUMMY_TASK_NAME, + TypeInformation.of(Void.class), + new LockRemoverOperatorFactory(DUMMY_TABLE_NAME, Lists.newArrayList(TASKS))) + .forceNonParallel(); + + JobClient jobClient = null; + long time = System.currentTimeMillis(); + try { + jobClient = env.executeAsync(); + // Start the 2 successful and one failed result trigger for task1, and 3 successful for task2 + processAndCheck(source, new TaskResult(0, time, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(0, time, false, Lists.newArrayList())); + processAndCheck(source, new TaskResult(0, time, true, Lists.newArrayList())); + processAndCheck(source, new TaskResult(1, 0L, true, Lists.newArrayList())); + + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of( + DUMMY_TASK_NAME, + DUMMY_TABLE_NAME, + TASKS[1], + "1", + SUCCEEDED_TASK_COUNTER)) + .equals(3L)); + + // Final check all the counters + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", SUCCEEDED_TASK_COUNTER), + 2L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", FAILED_TASK_COUNTER), + 1L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", SUCCEEDED_TASK_COUNTER), + 3L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", FAILED_TASK_COUNTER), + 0L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", SUCCEEDED_TASK_COUNTER), + 0L) + .put( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", FAILED_TASK_COUNTER), + 0L) + .build()); + + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[0], "0", LAST_RUN_DURATION_MS))) + .isPositive(); + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[1], "1", LAST_RUN_DURATION_MS))) + .isGreaterThan(time); + assertThat( + MetricsReporterFactoryForTests.gauge( + ImmutableList.of( + DUMMY_TASK_NAME, DUMMY_TABLE_NAME, TASKS[2], "2", LAST_RUN_DURATION_MS))) + .isZero(); + } finally { + closeJobClient(jobClient); + } + } + + private void processAndCheck(ManualSource source, TaskResult input) { + List counterKey = + ImmutableList.of( + DUMMY_TASK_NAME, + DUMMY_TABLE_NAME, + TASKS[input.taskIndex()], + String.valueOf(input.taskIndex()), + input.success() ? SUCCEEDED_TASK_COUNTER : FAILED_TASK_COUNTER); + Long counterValue = MetricsReporterFactoryForTests.counter(counterKey); + Long expected = counterValue != null ? counterValue + 1 : 1L; + + source.sendRecord(input); + source.sendWatermark(input.startEpoch()); + + Awaitility.await() + .until(() -> expected.equals(MetricsReporterFactoryForTests.counter(counterKey))); + } + + private OneInputStreamOperatorTestHarness createHarness( + LockRemoverOperator lockRemoverOperator) throws Exception { + OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(lockRemoverOperator); + harness.open(); + return harness; + } + + private static LockRemoverCoordinator createCoordinator() { + return new LockRemoverCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java index 9c1ea2f2295a..3dca6c421c76 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestMonitorSource.java @@ -46,6 +46,7 @@ import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.data.GenericAppenderHelper; import org.apache.iceberg.data.RandomGenericData; @@ -316,7 +317,7 @@ void testSkipReplace() throws IOException { // Create a DataOperations.REPLACE snapshot DataFile dataFile = - table.snapshots().iterator().next().addedDataFiles(table.io()).iterator().next(); + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); RewriteFiles rewrite = tableLoader.loadTable().newRewrite(); // Replace the file with itself for testing purposes rewrite.deleteFile(dataFile); @@ -328,14 +329,18 @@ void testSkipReplace() throws IOException { } private static long firstFileLength(Table table) { - return table.currentSnapshot().addedDataFiles(table.io()).iterator().next().fileSizeInBytes(); + return SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .iterator() + .next() + .fileSizeInBytes(); } private static TableChange tableChangeWithLastSnapshot(Table table, TableChange previous) { - List dataFiles = - Lists.newArrayList(table.currentSnapshot().addedDataFiles(table.io()).iterator()); - List deleteFiles = - Lists.newArrayList(table.currentSnapshot().addedDeleteFiles(table.io()).iterator()); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + List dataFiles = Lists.newArrayList(changes.addedDataFiles().iterator()); + List deleteFiles = Lists.newArrayList(changes.addedDeleteFiles().iterator()); long dataSize = dataFiles.stream().mapToLong(ContentFile::fileSizeInBytes).sum(); long deleteRecordCount = deleteFiles.stream().mapToLong(DeleteFile::recordCount).sum(); diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java new file mode 100644 index 000000000000..5fc6695f3e65 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerCoordinator.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.ExecutionException; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.EventReceivingTasks; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; + +@Timeout(value = 10) +@Execution(ExecutionMode.SAME_THREAD) +class TestTriggerManagerCoordinator extends CoordinatorTestBase { + + private EventReceivingTasks receivingTasks; + private EventReceivingTasks receivingTasks1; + + @BeforeEach + void before() { + this.receivingTasks = EventReceivingTasks.createForRunningTasks(); + this.receivingTasks1 = EventReceivingTasks.createForRunningTasks(); + } + + @Test + void testEventHandling() throws Exception { + try (TriggerManagerCoordinator triggerManagerCoordinator = + createCoordinator(OPERATOR_NAME, TEST_OPERATOR_ID); + TriggerManagerCoordinator triggerManagerCoordinator1 = + createCoordinator(OPERATOR_NAME_1, TEST_OPERATOR_ID_1)) { + + triggerManagerCoordinator.start(); + triggerManagerCoordinator1.start(); + + setAllTasksReady(triggerManagerCoordinator, receivingTasks); + setAllTasksReady(triggerManagerCoordinator1, receivingTasks1); + + triggerManagerCoordinator.handleEventFromOperator(0, 0, LOCK_REGISTER_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(0); + + // release lock from coordinator1 and get one event from coordinator + triggerManagerCoordinator1.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(1); + assertThat(receivingTasks1.getSentEventsForSubtask(0).size()).isEqualTo(0); + } + } + + @Test + void testEventArriveBeforeRegister() throws Exception { + try (TriggerManagerCoordinator triggerManagerCoordinator = + createCoordinator(OPERATOR_NAME, TEST_OPERATOR_ID)) { + + triggerManagerCoordinator.start(); + setAllTasksReady(triggerManagerCoordinator, receivingTasks); + + // release event arrive before register + triggerManagerCoordinator.handleReleaseLock(LOCK_RELEASE_EVENT); + assertThat(triggerManagerCoordinator.pendingReleaseEvents()).hasSize(1); + + triggerManagerCoordinator.handleEventFromOperator(0, 0, LOCK_REGISTER_EVENT); + assertThat(receivingTasks.getSentEventsForSubtask(0).size()).isEqualTo(1); + + assertThat(triggerManagerCoordinator.pendingReleaseEvents()).hasSize(0); + } + } + + private static TriggerManagerCoordinator createCoordinator( + String operatorName, OperatorID operatorID) { + return new TriggerManagerCoordinator( + operatorName, new MockOperatorCoordinatorContext(operatorID, 1)) { + @Override + void runInCoordinatorThread(Runnable runnable, String actionString) { + try { + coordinatorExecutor().submit(runnable).get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(actionString, e); + } + } + }; + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java new file mode 100644 index 000000000000..ea7d8b96253f --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestTriggerManagerOperator.java @@ -0,0 +1,668 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.maintenance.operator; + +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.CONCURRENT_RUN_THROTTLED; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.NOTHING_TO_TRIGGER; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.RATE_LIMITER_TRIGGERED; +import static org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.TRIGGERED; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import org.apache.flink.api.common.typeinfo.TypeInformation; +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.runtime.checkpoint.OperatorSubtaskState; +import org.apache.flink.runtime.jobgraph.OperatorID; +import org.apache.flink.runtime.operators.coordination.MockOperatorCoordinatorContext; +import org.apache.flink.runtime.operators.coordination.MockOperatorEventGateway; +import org.apache.flink.runtime.operators.coordination.OperatorEventGateway; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; +import org.apache.iceberg.Table; +import org.apache.iceberg.flink.maintenance.api.Trigger; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TestTriggerManagerOperator extends OperatorTestBase { + private static final long DELAY = 10L; + private static final String OPERATOR_NAME = "TestCoordinator"; + private static final OperatorID TEST_OPERATOR_ID = new OperatorID(1234L, 5678L); + private static final String[] TASKS = new String[] {"task0", "task1"}; + private long processingTime = 0L; + private String tableName; + private TriggerManagerCoordinator triggerManagerCoordinator; + private LockReleaseEvent lockReleaseEvent; + + @BeforeEach + void before() { + super.before(); + Table table = createTable(); + this.tableName = table.name(); + lockReleaseEvent = new LockReleaseEvent(tableName, 1L); + this.triggerManagerCoordinator = createCoordinator(); + try { + triggerManagerCoordinator.start(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @AfterEach + void after() throws IOException { + super.after(); + try { + triggerManagerCoordinator.close(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void testCommitCount() throws Exception { + MockOperatorEventGateway mockGateway = new MockOperatorEventGateway(); + TriggerManagerOperator operator = + createOperator(new TriggerEvaluator.Builder().commitCount(3).build(), mockGateway); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(1).build(), 4); + } + } + + @Test + void testDataFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().dataFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(1).build(), 0); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(5).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileCount(2).build(), 4); + } + } + + @Test + void testDataFileSizeInBytes() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().dataFileSizeInBytes(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().dataFileSizeInBytes(2L).build(), 3); + } + } + + @Test + void testPosDeleteFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().posDeleteFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteFileCount(1).build(), 4); + } + } + + @Test + void testPosDeleteRecordCount() throws Exception { + + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().posDeleteRecordCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().posDeleteRecordCount(2L).build(), 3); + } + } + + @Test + void testEqDeleteFileCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().eqDeleteFileCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(2).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(3).build(), 2); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(10).build(), 3); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 3); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 3); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteFileCount(1).build(), 4); + } + } + + @Test + void testEqDeleteRecordCount() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().eqDeleteRecordCount(3).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(1L).build(), 0); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(2L).build(), 1); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(5L).build(), 2); + + // No trigger in this case + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(1L).build(), 2); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().eqDeleteRecordCount(2L).build(), 3); + } + } + + @Test + void testTimeout() throws Exception { + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().timeout(Duration.ofSeconds(1)).build(), + new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + TableChange event = TableChange.builder().dataFileCount(1).commitCount(1).build(); + + // Wait for some time + testHarness.processElement(event, EVENT_TIME); + assertThat(testHarness.extractOutputValues()).isEmpty(); + + // Wait for the timeout to expire + long newTime = EVENT_TIME + Duration.ofSeconds(1).toMillis(); + testHarness.setProcessingTime(newTime); + testHarness.processElement(event, newTime); + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Remove the lock to allow the next trigger + operator.handleLockRelease(new LockReleaseEvent(tableName, newTime)); + + // Send a new event + testHarness.setProcessingTime(newTime + 1); + testHarness.processElement(event, newTime); + + // No trigger yet + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Send a new event + newTime += Duration.ofSeconds(1).toMillis(); + testHarness.setProcessingTime(newTime); + testHarness.processElement(event, newTime); + + // New trigger should arrive + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testStateRestore() throws Exception { + OperatorSubtaskState state; + TriggerManagerOperator operator = + createOperator( + new TriggerEvaluator.Builder().commitCount(2).build(), new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.processElement( + TableChange.builder().dataFileCount(1).commitCount(1).build(), EVENT_TIME); + + assertThat(testHarness.extractOutputValues()).isEmpty(); + + state = testHarness.snapshot(1, EVENT_TIME); + } + + // Restore the state, write some more data, create a checkpoint, check the data which is written + TriggerManagerOperator newOperator = + createOperator( + new TriggerEvaluator.Builder().commitCount(2).build(), new MockOperatorEventGateway()); + try (OneInputStreamOperatorTestHarness testHarness = + new OneInputStreamOperatorTestHarness<>(newOperator)) { + testHarness.initializeState(state); + testHarness.open(); + + // Mock a recovery trigger lock + assertTriggers( + testHarness.extractOutputValues(), + Lists.newArrayList(Trigger.recovery(testHarness.getProcessingTime()))); + + testHarness.processElement(TableChange.builder().commitCount(1).build(), EVENT_TIME_2); + + // Remove the lock to allow the next trigger + newOperator.handleOperatorEvent(lockReleaseEvent); + testHarness.setProcessingTime(EVENT_TIME_2); + + // At this point the output contains the recovery trigger and the real trigger + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testMinFireDelay() throws Exception { + TriggerManagerOperator operator = + createOperator( + tableName, + new TriggerEvaluator.Builder().commitCount(2).build(), + new MockOperatorEventGateway(), + DELAY, + 1); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.open(); + + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + long currentTime = testHarness.getProcessingTime(); + + // No new fire yet + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1); + + // Check that the trigger fired after the delay + testHarness.setProcessingTime(currentTime + DELAY); + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testLockCheckDelay() throws Exception { + TriggerManagerOperator operator = + createOperator( + tableName, + new TriggerEvaluator.Builder().commitCount(2).build(), + new MockOperatorEventGateway(), + 1, + DELAY); + try (OneInputStreamOperatorTestHarness testHarness = + createHarness(operator)) { + testHarness.open(); + + // Create a lock to prevent execution, and check that there is no result + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1, false); + addEventAndCheckResult( + operator, testHarness, TableChange.builder().commitCount(2).build(), 1, false); + long currentTime = testHarness.getProcessingTime(); + + // Remove the lock, and still no trigger + operator.handleOperatorEvent(lockReleaseEvent); + assertThat(testHarness.extractOutputValues()).hasSize(1); + + // Check that the trigger fired after the delay + testHarness.setProcessingTime(currentTime + DELAY); + assertThat(testHarness.extractOutputValues()).hasSize(2); + } + } + + @Test + void testTriggerMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + TriggerManagerOperatorFactory triggerManagerOperatorFactory = + new TriggerManagerOperatorFactory( + tableName, + Lists.newArrayList(TASKS), + Lists.newArrayList( + new TriggerEvaluator.Builder().commitCount(2).build(), + new TriggerEvaluator.Builder().commitCount(4).build()), + 1L, + 1L); + source + .dataStream() + .keyBy(unused -> true) + .transform( + DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), triggerManagerOperatorFactory) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // This one doesn't trigger - tests NOTHING_TO_TRIGGER + source.sendRecord(TableChange.builder().commitCount(1).build()); + + Awaitility.await() + .until( + () -> { + Long notingCounter = + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER)); + return notingCounter != null && notingCounter.equals(1L); + }); + + // Trigger one of the tasks - tests TRIGGERED + source.sendRecord(TableChange.builder().commitCount(1).build()); + // Wait until we receive the trigger + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED))) + .isEqualTo(1L); + + // manual unlock + triggerManagerCoordinator.handleReleaseLock(new LockReleaseEvent(tableName, Long.MAX_VALUE)); + // Trigger both of the tasks - tests TRIGGERED + source.sendRecord(TableChange.builder().commitCount(2).build()); + // Wait until we receive the trigger + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // manual unlock + triggerManagerCoordinator.handleReleaseLock(new LockReleaseEvent(tableName, Long.MAX_VALUE)); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED))) + .isEqualTo(2L); + assertThat( + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[1], "1", TRIGGERED))) + .isEqualTo(1L); + + // Final check all the counters + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED), -1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED), -1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED), 2L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[1], "1", TRIGGERED), 1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER), 1L) + .build()); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testRateLimiterMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + // High delay, so only triggered once + TriggerManagerOperatorFactory manager = manager(1_000_000L, 1L); + + source + .dataStream() + .keyBy(unused -> true) + .transform(DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), manager) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Start the first trigger + source.sendRecord(TableChange.builder().commitCount(2).build()); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // Remove the lock to allow the next trigger + triggerManagerCoordinator.handleReleaseLock(lockReleaseEvent); + + // The second trigger will be blocked + source.sendRecord(TableChange.builder().commitCount(2).build()); + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED)) + .equals(1L)); + + // Final check all the counters + assertCounters(1L, 0L); + } finally { + closeJobClient(jobClient); + } + } + + @Test + void testConcurrentRunMetrics() throws Exception { + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + ManualSource source = + new ManualSource<>(env, TypeInformation.of(TableChange.class)); + CollectingSink sink = new CollectingSink<>(); + + // High delay, so only triggered once + TriggerManagerOperatorFactory manager = manager(1L, 1_000_000L); + + source + .dataStream() + .keyBy(unused -> true) + .transform(DUMMY_TASK_NAME, TypeInformation.of(Trigger.class), manager) + .forceNonParallel() + .sinkTo(sink); + + JobClient jobClient = null; + try { + jobClient = env.executeAsync(); + + // Start the first trigger - notice that we do not remove the lock after the trigger + source.sendRecord(TableChange.builder().commitCount(2).build()); + assertThat(sink.poll(Duration.ofSeconds(5))).isNotNull(); + + // The second trigger will be blocked by the lock + source.sendRecord(TableChange.builder().commitCount(2).build()); + Awaitility.await() + .until( + () -> + MetricsReporterFactoryForTests.counter( + ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED)) + .equals(1L)); + + // Final check all the counters + assertCounters(0L, 1L); + } finally { + closeJobClient(jobClient); + } + } + + private void assertCounters(long rateLimiterTrigger, long concurrentRunTrigger) { + MetricsReporterFactoryForTests.assertCounters( + new ImmutableMap.Builder, Long>() + .put( + ImmutableList.of(DUMMY_TASK_NAME, tableName, RATE_LIMITER_TRIGGERED), + rateLimiterTrigger) + .put( + ImmutableList.of(DUMMY_TASK_NAME, tableName, CONCURRENT_RUN_THROTTLED), + concurrentRunTrigger) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, TASKS[0], "0", TRIGGERED), 1L) + .put(ImmutableList.of(DUMMY_TASK_NAME, tableName, NOTHING_TO_TRIGGER), 0L) + .build()); + } + + private void addEventAndCheckResult( + TriggerManagerOperator operator, + OneInputStreamOperatorTestHarness testHarness, + TableChange event, + int expectedSize) + throws Exception { + addEventAndCheckResult(operator, testHarness, event, expectedSize, true); + } + + private void addEventAndCheckResult( + TriggerManagerOperator operator, + OneInputStreamOperatorTestHarness testHarness, + TableChange event, + int expectedSize, + boolean removeLock) + throws Exception { + ++processingTime; + testHarness.setProcessingTime(processingTime); + testHarness.processElement(event, processingTime); + assertThat(testHarness.extractOutputValues()).hasSize(expectedSize); + if (removeLock && operator.lockTime() != null) { + // Remove the lock to allow the next trigger + operator.handleLockRelease(new LockReleaseEvent(tableName, processingTime)); + } + } + + private TriggerManagerOperatorFactory manager(long minFireDelayMs, long lockCheckDelayMs) { + return new TriggerManagerOperatorFactory( + tableName, + Lists.newArrayList(TASKS[0]), + Lists.newArrayList(new TriggerEvaluator.Builder().commitCount(2).build()), + minFireDelayMs, + lockCheckDelayMs); + } + + private static void assertTriggers(List expected, List actual) { + assertThat(actual).hasSize(expected.size()); + for (int i = 0; i < expected.size(); ++i) { + Trigger expectedTrigger = expected.get(i); + Trigger actualTrigger = actual.get(i); + assertThat(actualTrigger.timestamp()).isEqualTo(expectedTrigger.timestamp()); + assertThat(actualTrigger.taskId()).isEqualTo(expectedTrigger.taskId()); + assertThat(actualTrigger.isRecovery()).isEqualTo(expectedTrigger.isRecovery()); + } + } + + private static TriggerManagerCoordinator createCoordinator() { + return new TriggerManagerCoordinator( + OPERATOR_NAME, new MockOperatorCoordinatorContext(TEST_OPERATOR_ID, 1)); + } + + private TriggerManagerOperator createOperator( + TriggerEvaluator evaluator, OperatorEventGateway mockGateway) { + return createOperator(tableName, evaluator, mockGateway, 1, 1); + } + + private TriggerManagerOperator createOperator( + String lockId, + TriggerEvaluator evaluator, + OperatorEventGateway mockGateway, + long minFireDelayMs, + long lockCheckDelayMs) { + return new TriggerManagerOperator( + null, + mockGateway, + Lists.newArrayList(TASKS[0]), + Lists.newArrayList(evaluator), + minFireDelayMs, + lockCheckDelayMs, + lockId); + } + + private OneInputStreamOperatorTestHarness createHarness( + TriggerManagerOperator triggerManagerOperator) throws Exception { + OneInputStreamOperatorTestHarness harness = + new OneInputStreamOperatorTestHarness<>(triggerManagerOperator); + harness.open(); + return harness; + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java index da5b5f6c28f0..339cd0510efb 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestCompressionSettings.java @@ -35,7 +35,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.common.DynFields; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; import org.apache.iceberg.flink.FlinkWriteConf; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.SimpleDataUtil; @@ -238,21 +238,21 @@ private static Map appenderProperties( testHarness.processElement(SimpleDataUtil.createRowData(1, "hello"), 1); testHarness.prepareSnapshotPreBarrier(1L); - DynFields.BoundField operatorField = + DynFields.BoundField> operatorField = DynFields.builder() .hiddenImpl(testHarness.getOperatorFactory().getClass(), "operator") .build(testHarness.getOperatorFactory()); - DynFields.BoundField writerField = + DynFields.BoundField> writerField = DynFields.builder() .hiddenImpl(IcebergStreamWriter.class, "writer") .build(operatorField.get()); - DynFields.BoundField writerFactoryField = + DynFields.BoundField> writerFactoryField = DynFields.builder() .hiddenImpl(BaseTaskWriter.class, "writerFactory") .build(writerField.get()); DynFields.BoundField> propsField = DynFields.builder() - .hiddenImpl(BaseFileWriterFactory.class, "writerProperties") + .hiddenImpl(RegistryBasedFileWriterFactory.class, "writerProperties") .build(writerFactoryField.get()); return propsField.get(); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java index a77ddead3003..0497ba6c680f 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkBranch.java @@ -69,15 +69,15 @@ public class TestFlinkIcebergSinkBranch extends TestFlinkIcebergSinkBase { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {"1", "main", true}, + {"1", SnapshotRef.MAIN_BRANCH, true}, {"1", "testBranch", true}, - {"2", "main", true}, + {"2", SnapshotRef.MAIN_BRANCH, true}, {"2", "testBranch", true}, // Remove after the deprecation of TableSchema - END - {"1", "main", false}, + {"1", SnapshotRef.MAIN_BRANCH, false}, {"1", "testBranch", false}, - {"2", "main", false}, + {"2", SnapshotRef.MAIN_BRANCH, false}, {"2", "testBranch", false}, }; } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java index 04bc5da6a9be..d25e0d68391d 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkDistributionMode.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.HadoopCatalogExtension; @@ -279,7 +280,14 @@ public void testRangeDistributionWithoutSortOrderPartitioned() throws Exception // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -322,7 +330,14 @@ public void testRangeDistributionWithNullValue() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -367,7 +382,14 @@ public void testRangeDistributionWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -383,14 +405,24 @@ public void testRangeDistributionWithSortOrder() throws Exception { if (partitioned) { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // up to 26 partitions assertThat(addedDataFiles).hasSizeLessThanOrEqualTo(26); } } else { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -438,7 +470,14 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -456,7 +495,12 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -517,7 +561,14 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -535,7 +586,12 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column // sometimes assertThat(addedDataFiles).hasSize(writeParallelism); diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java index a5799288b5e3..8d425cb30aaf 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkRangeDistributionBucketing.java @@ -47,6 +47,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkSchemaUtil; @@ -202,7 +203,14 @@ private void testParallelism(int parallelism) throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Source rate limit per checkpoint cycle may not be super precise. @@ -217,7 +225,12 @@ private void testParallelism(int parallelism) throws Exception { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); assertThat(addedDataFiles) .hasSizeLessThanOrEqualTo(maxAddedDataFilesPerCheckpoint(parallelism)); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java index ffd40b6cdc95..fac1b8226d3c 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2.java @@ -35,6 +35,7 @@ import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.TableProperties; import org.apache.iceberg.data.Record; @@ -242,13 +243,14 @@ public void testDeleteStats() throws Exception { false, elementsPerCheckpoint, expectedRecords, - "main"); + SnapshotRef.MAIN_BRANCH); - DeleteFile deleteFile = table.currentSnapshot().addedDeleteFiles(table.io()).iterator().next(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + DeleteFile deleteFile = changes.addedDeleteFiles().iterator().next(); String fromStat = new String( deleteFile.lowerBounds().get(MetadataColumns.DELETE_FILE_PATH.fieldId()).array()); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = changes.addedDataFiles().iterator().next(); assumeThat(fromStat).isEqualTo(dataFile.location()); } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java index 8ce3e1886f40..9117534ed63b 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2Branch.java @@ -55,11 +55,25 @@ public class TestFlinkIcebergSinkV2Branch extends TestFlinkIcebergSinkV2Base { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, true, "main"}, + { + FileFormat.AVRO, + 1, + false, + TableProperties.WRITE_DISTRIBUTION_MODE_NONE, + true, + SnapshotRef.MAIN_BRANCH + }, {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, true, "testBranch"}, // Remove after the deprecation of TableSchema - END - {FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, false, "main"}, + { + FileFormat.AVRO, + 1, + false, + TableProperties.WRITE_DISTRIBUTION_MODE_NONE, + false, + SnapshotRef.MAIN_BRANCH + }, { FileFormat.AVRO, 1, false, TableProperties.WRITE_DISTRIBUTION_MODE_NONE, false, "testBranch" }, diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java index 0feb4cc282d2..9b208283c857 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestFlinkIcebergSinkV2DistributionMode.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.TableProperties; import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.HadoopCatalogExtension; @@ -289,7 +290,14 @@ public void testRangeDistributionWithoutSortOrderPartitioned() throws Exception // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -335,7 +343,14 @@ public void testRangeDistributionWithNullValue() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -381,7 +396,14 @@ public void testRangeDistributionWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -397,14 +419,24 @@ public void testRangeDistributionWithSortOrder() throws Exception { if (partitioned) { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // up to 26 partitions assertThat(addedDataFiles).hasSizeLessThanOrEqualTo(26); } } else { for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -453,7 +485,14 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -471,7 +510,12 @@ public void testRangeDistributionSketchWithSortOrder() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column assertThat(addedDataFiles).hasSize(writeParallelism); // verify there is no overlap in min-max stats range @@ -533,7 +577,14 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // only keep the snapshots with added data files snapshots = snapshots.stream() - .filter(snapshot -> snapshot.addedDataFiles(table.io()).iterator().hasNext()) + .filter( + snapshot -> + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator() + .hasNext()) .collect(Collectors.toList()); // Sometimes we will have more checkpoints than the bounded source if we pass the @@ -551,7 +602,12 @@ public void testRangeDistributionStatisticsMigration() throws Exception { // for partitioned or not for (Snapshot snapshot : rangePartitionedCycles) { List addedDataFiles = - Lists.newArrayList(snapshot.addedDataFiles(table.io()).iterator()); + Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(snapshot) + .build() + .addedDataFiles() + .iterator()); // each writer task should only write one file for non-partition sort column // sometimes assertThat(addedDataFiles).hasSize(writeParallelism); @@ -562,6 +618,39 @@ public void testRangeDistributionStatisticsMigration() throws Exception { } } + @TestTemplate + public void testHashDistributionWithPartitionNotInEqualityFields() { + assumeThat(partitioned).isTrue(); + + List rows = createRows(""); + DataStream dataStream = env.addSource(createBoundedSource(rows), ROW_TYPE_INFO); + + if (isTableSchema) { + IcebergSink.forRow(dataStream, SimpleDataUtil.FLINK_TABLE_SCHEMA) + .table(table) + .tableLoader(tableLoader) + .writeParallelism(writeParallelism) + .distributionMode(DistributionMode.HASH) + .upsert(false) + .equalityFieldColumns(ImmutableList.of("id")) + .append(); + } else { + IcebergSink.forRow(dataStream, SimpleDataUtil.FLINK_SCHEMA) + .table(table) + .tableLoader(tableLoader) + .writeParallelism(writeParallelism) + .distributionMode(DistributionMode.HASH) + .upsert(false) + .equalityFieldColumns(ImmutableList.of("id")) + .append(); + } + + assertThatThrownBy(env::execute) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "In 'hash' distribution mode with equality fields set, source column 'data' of partition field '1000: data: identity(2)' should be included in equality fields: '[id]'"); + } + private BoundedTestSource createRangeDistributionBoundedSource( List> rowsPerCheckpoint) { return new BoundedTestSource<>(rowsPerCheckpoint); diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java index 68621018be57..abe77e795b09 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergFilesCommitter.java @@ -63,6 +63,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionData; import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.StructLike; import org.apache.iceberg.TestBase; import org.apache.iceberg.flink.SimpleDataUtil; @@ -95,11 +96,11 @@ public class TestIcebergFilesCommitter extends TestBase { @Parameters(name = "formatVersion = {0}, fileFormat = {1}, branch = {2}") protected static List parameters() { return Arrays.asList( - new Object[] {1, FileFormat.AVRO, "main"}, + new Object[] {1, FileFormat.AVRO, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.AVRO, "test-branch"}, - new Object[] {1, FileFormat.PARQUET, "main"}, + new Object[] {1, FileFormat.PARQUET, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.PARQUET, "test-branch"}, - new Object[] {1, FileFormat.ORC, "main"}, + new Object[] {1, FileFormat.ORC, SnapshotRef.MAIN_BRANCH}, new Object[] {2, FileFormat.ORC, "test-branch"}); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java index ddcb57f6ca33..0789be5b03cd 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkBranch.java @@ -57,11 +57,11 @@ public class TestIcebergSinkBranch extends TestFlinkIcebergSinkBase { public static Object[][] parameters() { return new Object[][] { // Remove after the deprecation of TableSchema - BEGIN - {"main", true}, + {SnapshotRef.MAIN_BRANCH, true}, {"testBranch", true}, // Remove after the deprecation of TableSchema - END - {"main", false}, + {SnapshotRef.MAIN_BRANCH, false}, {"testBranch", false}, }; } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java deleted file mode 100644 index b84d21d020b3..000000000000 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkCompact.java +++ /dev/null @@ -1,149 +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. - */ -package org.apache.iceberg.flink.sink; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import org.apache.flink.runtime.jobgraph.JobVertex; -import org.apache.flink.streaming.api.datastream.DataStream; -import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; -import org.apache.flink.streaming.api.graph.StreamGraph; -import org.apache.flink.table.data.RowData; -import org.apache.flink.types.Row; -import org.apache.iceberg.DataFile; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.ManifestFiles; -import org.apache.iceberg.ManifestReader; -import org.apache.iceberg.PartitionSpec; -import org.apache.iceberg.Snapshot; -import org.apache.iceberg.Table; -import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; -import org.apache.iceberg.flink.FlinkWriteOptions; -import org.apache.iceberg.flink.MiniFlinkClusterExtension; -import org.apache.iceberg.flink.SimpleDataUtil; -import org.apache.iceberg.flink.TestFixtures; -import org.apache.iceberg.flink.maintenance.api.LockConfig; -import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; -import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; -import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class TestIcebergSinkCompact extends TestFlinkIcebergSinkBase { - - private Map flinkConf; - - @BeforeEach - void before() throws IOException { - this.flinkConf = Maps.newHashMap(); - flinkConf.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); - flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), LockConfig.JdbcLockConfig.JDBC); - flinkConf.put( - LockConfig.JdbcLockConfig.JDBC_URI_OPTION.key(), - "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", "")); - flinkConf.put(LockConfig.LOCK_ID_OPTION.key(), "test-lock-id"); - flinkConf.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); - - flinkConf.put(LockConfig.JdbcLockConfig.JDBC_INIT_LOCK_TABLE_OPTION.key(), "true"); - flinkConf.put(RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); - - table = - CATALOG_EXTENSION - .catalog() - .createTable( - TestFixtures.TABLE_IDENTIFIER, - SimpleDataUtil.SCHEMA, - PartitionSpec.unpartitioned(), - Maps.newHashMap()); - - env = - StreamExecutionEnvironment.getExecutionEnvironment( - MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG) - .enableCheckpointing(100); - - tableLoader = CATALOG_EXTENSION.tableLoader(); - } - - @Test - public void testCompactFileE2e() throws Exception { - List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); - DataStream dataStream = - env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) - .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); - - IcebergSink.forRowData(dataStream) - .setAll(flinkConf) - .table(table) - .tableLoader(tableLoader) - .append(); - - env.execute("Test Iceberg Compaction DataStream"); - - table.refresh(); - // check the data file count after compact - List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); - assertThat(afterCompactDataFiles).hasSize(1); - - // check the data file count before compact - List preCompactDataFiles = - getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); - assertThat(preCompactDataFiles).hasSize(3); - } - - private List getDataFiles(Snapshot snapshot, Table table) throws IOException { - List dataFiles = Lists.newArrayList(); - for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { - try (ManifestReader reader = ManifestFiles.read(dataManifest, table.io())) { - reader.iterator().forEachRemaining(dataFiles::add); - } - } - - return dataFiles; - } - - @Test - public void testTableMaintenanceOperatorAdded() { - List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); - DataStream dataStream = - env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) - .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); - - IcebergSink.forRowData(dataStream) - .table(table) - .tableLoader(tableLoader) - .setAll(flinkConf) - .append(); - - boolean containRewrite = false; - StreamGraph streamGraph = env.getStreamGraph(); - for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { - if (vertex.getName().contains("Rewrite")) { - containRewrite = true; - break; - } - } - - assertThat(containRewrite).isTrue(); - } -} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java new file mode 100644 index 000000000000..5c926d7c25d5 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkTableMaintenance.java @@ -0,0 +1,321 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.flink.runtime.jobgraph.JobVertex; +import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.graph.StreamGraph; +import org.apache.flink.table.data.RowData; +import org.apache.flink.types.Row; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.flink.MiniFlinkClusterExtension; +import org.apache.iceberg.flink.SimpleDataUtil; +import org.apache.iceberg.flink.TestFixtures; +import org.apache.iceberg.flink.maintenance.api.DeleteOrphanFilesConfig; +import org.apache.iceberg.flink.maintenance.api.ExpireSnapshotsConfig; +import org.apache.iceberg.flink.maintenance.api.LockConfig; +import org.apache.iceberg.flink.maintenance.api.RewriteDataFilesConfig; +import org.apache.iceberg.flink.util.FlinkCompatibilityUtil; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +class TestIcebergSinkTableMaintenance extends TestFlinkIcebergSinkBase { + private static final String[] LOCK_TYPES = new String[] {LockConfig.JdbcLockConfig.JDBC, ""}; + + private Map flinkConf; + + @BeforeEach + void before() throws IOException { + this.flinkConf = Maps.newHashMap(); + + table = + CATALOG_EXTENSION + .catalog() + .createTable( + TestFixtures.TABLE_IDENTIFIER, + SimpleDataUtil.SCHEMA, + PartitionSpec.unpartitioned(), + Maps.newHashMap()); + + env = + StreamExecutionEnvironment.getExecutionEnvironment( + MiniFlinkClusterExtension.DISABLE_CLASSLOADER_CHECK_CONFIG) + .enableCheckpointing(100); + + tableLoader = CATALOG_EXTENSION.tableLoader(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testCompactFileE2e(String lockType) throws Exception { + setupLockConfig(lockType); + setupCompactionConfig(); + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .setAll(flinkConf) + .table(table) + .tableLoader(tableLoader) + .append(); + + env.execute("Test Iceberg Compaction DataStream"); + + table.refresh(); + // check the data file count after compact + List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); + assertThat(afterCompactDataFiles).hasSize(1); + + // check the data file count before compact + List preCompactDataFiles = + getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); + assertThat(preCompactDataFiles).hasSize(3); + } + + private List getDataFiles(Snapshot snapshot, Table table) throws IOException { + List dataFiles = Lists.newArrayList(); + for (ManifestFile dataManifest : snapshot.dataManifests(table.io())) { + try (ManifestReader reader = + ManifestFiles.read(dataManifest, table.io(), table.specs())) { + reader.iterator().forEachRemaining(dataFiles::add); + } + } + + return dataFiles; + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testTableMaintenanceOperatorAdded(String lockType) { + setupLockConfig(lockType); + setupCompactionConfig(); + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + boolean containRewrite = false; + StreamGraph streamGraph = env.getStreamGraph(); + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Rewrite")) { + containRewrite = true; + break; + } + } + + assertThat(containRewrite).isTrue(); + } + + private void setupCompactionConfig() { + flinkConf.put(FlinkWriteOptions.COMPACTION_ENABLE.key(), "true"); + flinkConf.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); + flinkConf.put(RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); + } + + private void setupExpireSnapshotsConfig() { + flinkConf.put(FlinkWriteOptions.EXPIRE_SNAPSHOTS_ENABLE.key(), "true"); + flinkConf.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + } + + private void setupDeleteOrphanFilesConfig() { + flinkConf.put(FlinkWriteOptions.DELETE_ORPHAN_FILES_ENABLE.key(), "true"); + flinkConf.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + } + + private void setupLockConfig(String lockType) { + if (lockType.equals(LockConfig.JdbcLockConfig.JDBC)) { + flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), LockConfig.JdbcLockConfig.JDBC); + flinkConf.put( + LockConfig.JdbcLockConfig.JDBC_URI_OPTION.key(), + "jdbc:sqlite:file::memory:?ic" + UUID.randomUUID().toString().replace("-", "")); + flinkConf.put(LockConfig.LOCK_ID_OPTION.key(), "test-lock-id"); + flinkConf.put(LockConfig.JdbcLockConfig.JDBC_INIT_LOCK_TABLE_OPTION.key(), "true"); + } else { + flinkConf.put(LockConfig.LOCK_TYPE_OPTION.key(), ""); + } + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testExpireSnapshotsEnabled(String lockType) { + setupLockConfig(lockType); + setupExpireSnapshotsConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containExpire = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Expire")) { + containExpire = true; + break; + } + } + + assertThat(containExpire).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testDeleteOrphanFilesEnabled(String lockType) { + setupLockConfig(lockType); + setupDeleteOrphanFilesConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containOrphan = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Orphan")) { + containOrphan = true; + break; + } + } + + assertThat(containOrphan).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testAllMaintenanceTasksCombined(String lockType) { + setupLockConfig(lockType); + setupCompactionConfig(); + setupExpireSnapshotsConfig(); + setupDeleteOrphanFilesConfig(); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .table(table) + .tableLoader(tableLoader) + .setAll(flinkConf) + .append(); + + StreamGraph streamGraph = env.getStreamGraph(); + boolean containRewrite = false; + boolean containExpire = false; + boolean containOrphan = false; + for (JobVertex vertex : streamGraph.getJobGraph().getVertices()) { + if (vertex.getName().contains("Rewrite")) { + containRewrite = true; + } + + if (vertex.getName().contains("Expire")) { + containExpire = true; + } + + if (vertex.getName().contains("Orphan")) { + containOrphan = true; + } + } + + assertThat(containRewrite).isTrue(); + assertThat(containExpire).isTrue(); + assertThat(containOrphan).isTrue(); + } + + @ParameterizedTest(name = "lockType = {0}") + @FieldSource("LOCK_TYPES") + public void testAllMaintenanceE2e(String lockType) throws Exception { + setupLockConfig(lockType); + + Map compactionConfig = Maps.newHashMap(); + compactionConfig.put(RewriteDataFilesConfig.SCHEDULE_ON_DATA_FILE_SIZE, "1"); + compactionConfig.put( + RewriteDataFilesConfig.PREFIX + SizeBasedFileRewritePlanner.REWRITE_ALL, "true"); + + Map expireConfig = Maps.newHashMap(); + expireConfig.put(ExpireSnapshotsConfig.RETAIN_LAST, "5"); + + Map orphanConfig = Maps.newHashMap(); + orphanConfig.put(DeleteOrphanFilesConfig.MIN_AGE_SECONDS, "86400"); + + List rows = Lists.newArrayList(Row.of(1, "hello"), Row.of(2, "world"), Row.of(3, "foo")); + DataStream dataStream = + env.addSource(createBoundedSource(rows), ROW_TYPE_INFO) + .map(CONVERTER::toInternal, FlinkCompatibilityUtil.toTypeInfo(SimpleDataUtil.ROW_TYPE)); + + IcebergSink.forRowData(dataStream) + .setAll(flinkConf) + .table(table) + .tableLoader(tableLoader) + .rewriteDataFiles(compactionConfig) + .expireSnapshots(expireConfig) + .deleteOrphanFiles(orphanConfig) + .append(); + + env.execute("Test All Maintenance E2E"); + + table.refresh(); + // Compaction should have merged the 3 data files into 1 + List afterCompactDataFiles = getDataFiles(table.currentSnapshot(), table); + assertThat(afterCompactDataFiles).hasSize(1); + + List preCompactDataFiles = + getDataFiles(table.snapshot(table.currentSnapshot().parentId()), table); + assertThat(preCompactDataFiles).hasSize(3); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java index f873dcd99c06..71cec082a09e 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergSinkV2.java @@ -36,6 +36,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.TableProperties; import org.apache.iceberg.data.Record; @@ -225,13 +226,14 @@ public void testDeleteStats() throws Exception { false, elementsPerCheckpoint, expectedRecords, - "main"); + SnapshotRef.MAIN_BRANCH); - DeleteFile deleteFile = table.currentSnapshot().addedDeleteFiles(table.io()).iterator().next(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).build(); + DeleteFile deleteFile = changes.addedDeleteFiles().iterator().next(); String fromStat = new String( deleteFile.lowerBounds().get(MetadataColumns.DELETE_FILE_PATH.fieldId()).array()); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = changes.addedDataFiles().iterator().next(); assumeThat(fromStat).isEqualTo(dataFile.location()); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java new file mode 100644 index 000000000000..42bbfc0d3628 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestIcebergStreamWriterMetrics.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; +import org.apache.iceberg.io.WriteResult; +import org.junit.jupiter.api.Test; + +public class TestIcebergStreamWriterMetrics { + + @Test + void histogramsCreatedWhenDropwizardAvailable() { + IcebergStreamWriterMetrics metrics = + new IcebergStreamWriterMetrics( + UnregisteredMetricsGroup.createSinkWriterMetricGroup(), "db.table"); + + assertThat(metrics.dataFilesSizeHistogram()).isNotNull(); + assertThat(metrics.deleteFilesSizeHistogram()).isNotNull(); + + assertThatNoException() + .isThrownBy(() -> metrics.updateFlushResult(WriteResult.builder().build())); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java index 919fef579ab0..2e7e6db176b2 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/TestRowDataPartitionKey.java @@ -57,7 +57,11 @@ public class TestRowDataPartitionKey { Types.NestedField.required(12, "decimalType2", Types.DecimalType.of(10, 5)), Types.NestedField.required(13, "decimalType3", Types.DecimalType.of(38, 19)), Types.NestedField.required(14, "floatType", Types.FloatType.get()), - Types.NestedField.required(15, "doubleType", Types.DoubleType.get())); + Types.NestedField.required(15, "doubleType", Types.DoubleType.get()), + Types.NestedField.required( + 16, "timestampNanoWithoutZone", Types.TimestampNanoType.withoutZone()), + Types.NestedField.required( + 17, "timestampNanoWithZone", Types.TimestampNanoType.withZone())); private static final List SUPPORTED_PRIMITIVES = SCHEMA.asStruct().fields().stream().map(Types.NestedField::name).collect(Collectors.toList()); @@ -248,4 +252,47 @@ public void testNestedPartitionValues() { } } } + + @Test + public void testTimestampNanoPartitionTransforms() { + RowType rowType = FlinkSchemaUtil.convert(SCHEMA); + RowDataWrapper rowWrapper = new RowDataWrapper(rowType, SCHEMA.asStruct()); + InternalRecordWrapper recordWrapper = new InternalRecordWrapper(SCHEMA.asStruct()); + + List records = RandomGenericData.generate(SCHEMA, 10, 1995); + List rows = Lists.newArrayList(RandomRowData.convert(SCHEMA, records)); + + String[] columns = {"timestampNanoWithoutZone", "timestampNanoWithZone"}; + for (String column : columns) { + List specs = + Lists.newArrayList( + PartitionSpec.builderFor(SCHEMA).identity(column).build(), + PartitionSpec.builderFor(SCHEMA).year(column).build(), + PartitionSpec.builderFor(SCHEMA).month(column).build(), + PartitionSpec.builderFor(SCHEMA).day(column).build(), + PartitionSpec.builderFor(SCHEMA).hour(column).build(), + PartitionSpec.builderFor(SCHEMA).bucket(column, 16).build()); + + for (PartitionSpec spec : specs) { + Class[] javaClasses = spec.javaClasses(); + PartitionKey pk = new PartitionKey(spec, SCHEMA); + PartitionKey expectedPK = new PartitionKey(spec, SCHEMA); + + for (int j = 0; j < rows.size(); j++) { + pk.partition(rowWrapper.wrap(rows.get(j))); + expectedPK.partition(recordWrapper.wrap(records.get(j))); + + assertThat(pk.size()).isEqualTo(1); + assertThat(pk.get(0, javaClasses[0])) + .as( + "Partition with column " + + column + + " and spec " + + spec + + " should match Iceberg-side computation") + .isEqualTo(expectedPK.get(0, javaClasses[0])); + } + } + } + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java index 30782e8d4170..07096b891a95 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/DynamicRecordInternalSerializerTestBase.java @@ -55,15 +55,19 @@ abstract class DynamicRecordInternalSerializerTestBase static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).bucket("id", 10).build(); private boolean writeFullSchemaAndSpec; + private final boolean writeLongUTF; - DynamicRecordInternalSerializerTestBase(boolean writeFullSchemaAndSpec) { + DynamicRecordInternalSerializerTestBase(boolean writeFullSchemaAndSpec, boolean writeLongUTF) { this.writeFullSchemaAndSpec = writeFullSchemaAndSpec; + this.writeLongUTF = writeLongUTF; } @Override protected TypeSerializer createSerializer() { return new DynamicRecordInternalSerializer( - new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), writeFullSchemaAndSpec); + new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), + writeFullSchemaAndSpec, + writeLongUTF); } @BeforeEach diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java index 385a354889fb..9e4d600f9325 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestCompareSchemasVisitor.java @@ -33,6 +33,12 @@ class TestCompareSchemasVisitor { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + @Test void testSchema() { assertThat( @@ -44,7 +50,9 @@ void testSchema() { new Schema( optional(1, "id", IntegerType.get(), "comment"), optional(2, "data", StringType.get()), - optional(3, "extra", StringType.get())))) + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -59,7 +67,9 @@ void testSchemaDifferentId() { new Schema( optional(1, "id", IntegerType.get()), optional(2, "data", StringType.get()), - optional(3, "extra", StringType.get())))) + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -72,7 +82,9 @@ void testSchemaDifferent() { optional(1, "data", StringType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get())))) + optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -85,7 +97,9 @@ void testSchemaWithMoreColumns() { new Schema( optional(0, "id", IntegerType.get()), optional(1, "data", StringType.get()), - optional(2, "extra", StringType.get())))) + optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -96,7 +110,9 @@ void testDifferentType() { new Schema( optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())))) + optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -107,7 +123,9 @@ void testCompatibleType() { new Schema( optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())), new Schema( - optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())))) + optional(1, "id", LongType.get()), optional(2, "extra", StringType.get())), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -117,9 +135,11 @@ void testRequiredChangeForMatchingField() { new Schema(optional(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); Schema tableSchema = new Schema(required(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); - assertThat(CompareSchemasVisitor.visit(tableSchema, dataSchema)) + assertThat( + CompareSchemasVisitor.visit(tableSchema, dataSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -128,9 +148,11 @@ void testRequiredChangeForNonMatchingField() { Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); Schema tableSchema = new Schema(optional(1, "id", IntegerType.get()), required(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); - assertThat(CompareSchemasVisitor.visit(tableSchema, dataSchema)) + assertThat( + CompareSchemasVisitor.visit(tableSchema, dataSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -139,7 +161,8 @@ void testNoRequiredChangeForNonMatchingField() { Schema dataSchema = new Schema(required(1, "id", IntegerType.get())); Schema tableSchema = new Schema(required(1, "id", IntegerType.get()), optional(2, "extra", StringType.get())); - assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema)) + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); } @@ -152,8 +175,9 @@ void testStructDifferentId() { optional(2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))), new Schema( optional(0, "id", IntegerType.get()), - optional( - 1, "struct1", StructType.of(optional(2, "extra", IntegerType.get())))))) + optional(1, "struct1", StructType.of(optional(2, "extra", IntegerType.get())))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -166,8 +190,9 @@ void testStructChanged() { optional(1, "struct1", StructType.of(optional(2, "extra", LongType.get())))), new Schema( optional(1, "id", IntegerType.get()), - optional( - 2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))))) + optional(2, "struct1", StructType.of(optional(3, "extra", IntegerType.get())))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -182,7 +207,9 @@ void testMapDifferentId() { new Schema( optional(0, "id", IntegerType.get()), optional( - 1, "map1", MapType.ofOptional(2, 3, IntegerType.get(), StringType.get()))))) + 1, "map1", MapType.ofOptional(2, 3, IntegerType.get(), StringType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -197,7 +224,9 @@ void testMapChanged() { new Schema( optional(1, "id", IntegerType.get()), optional( - 2, "map1", MapType.ofOptional(3, 4, IntegerType.get(), StringType.get()))))) + 2, "map1", MapType.ofOptional(3, 4, IntegerType.get(), StringType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } @@ -210,7 +239,9 @@ void testListDifferentId() { optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))), new Schema( optional(0, "id", IntegerType.get()), - optional(1, "list1", ListType.ofOptional(2, IntegerType.get()))))) + optional(1, "list1", ListType.ofOptional(2, IntegerType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SAME); } @@ -223,7 +254,133 @@ void testListChanged() { optional(1, "list1", ListType.ofOptional(2, LongType.get()))), new Schema( optional(1, "id", IntegerType.get()), - optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))))) + optional(2, "list1", ListType.ofOptional(3, IntegerType.get()))), + CASE_SENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testCaseInsensitiveFieldMatching() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "Data", StringType.get()), + optional(3, "EXTRA", StringType.get())), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testCaseSensitiveFieldMatchingDefault() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "Data", StringType.get()), + optional(3, "EXTRA", StringType.get())), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())), + CASE_SENSITIVE, + DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testCaseInsensitiveNestedStruct() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(1, "ID", IntegerType.get()), + optional(2, "STRUCT1", StructType.of(optional(3, "NESTED", StringType.get())))), + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "struct1", StructType.of(optional(3, "nested", StringType.get())))), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testCaseInsensitiveWithMoreColumns() { + assertThat( + CompareSchemasVisitor.visit( + new Schema( + optional(0, "ID", IntegerType.get()), optional(1, "DATA", StringType.get())), + new Schema( + optional(0, "id", IntegerType.get()), + optional(1, "data", StringType.get()), + optional(2, "extra", StringType.get())), + CASE_INSENSITIVE, + PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); + } + + @Test + void testDropUnusedColumnsEnabled() { + Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); + Schema tableSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); } + + @Test + void testDropUnusedColumnsWithRequiredField() { + Schema dataSchema = new Schema(optional(1, "id", IntegerType.get())); + Schema tableSchema = + new Schema(optional(1, "id", IntegerType.get()), required(2, "data", StringType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testDropUnusedColumnsWhenInputHasMoreFields() { + Schema dataSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "data", StringType.get()), + optional(3, "extra", StringType.get())); + Schema tableSchema = new Schema(optional(1, "id", IntegerType.get())); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + } + + @Test + void testDropUnusedColumnsInNestedStruct() { + Schema dataSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional(2, "struct1", StructType.of(optional(3, "field1", StringType.get())))); + Schema tableSchema = + new Schema( + optional(1, "id", IntegerType.get()), + optional( + 2, + "struct1", + StructType.of( + optional(3, "field1", StringType.get()), + optional(4, "field2", IntegerType.get())))); + + assertThat(CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, DROP_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.SCHEMA_UPDATE_NEEDED); + + assertThat( + CompareSchemasVisitor.visit(dataSchema, tableSchema, CASE_SENSITIVE, PRESERVE_COLUMNS)) + .isEqualTo(CompareSchemasVisitor.Result.DATA_CONVERSION_NEEDED); + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java index 13a06d362717..16890d1f63d0 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommittableSerializer.java @@ -21,42 +21,94 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.Set; import org.apache.flink.api.common.JobID; +import org.apache.flink.core.memory.DataOutputViewStreamWrapper; import org.apache.flink.runtime.jobgraph.OperatorID; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.junit.jupiter.api.Test; class TestDynamicCommittableSerializer { + private static final DynamicCommittable COMMITTABLE = + new DynamicCommittable( + new TableKey("table", "branch"), + new byte[][] {{3, 4}, {5, 6}}, + JobID.generate().toHexString(), + new OperatorID().toHexString(), + 5); @Test - void testRoundtrip() throws IOException { - DynamicCommittable committable = + void testV1() throws IOException { + var committable = new DynamicCommittable( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - new byte[] {3, 4}, + new TableKey("table", "branch"), + new byte[][] {{3, 4}}, JobID.generate().toHexString(), new OperatorID().toHexString(), 5); - DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); - assertThat(serializer.deserialize(serializer.getVersion(), serializer.serialize(committable))) - .isEqualTo(committable); + assertThat(serializer.deserialize(1, serializeV1(committable))).isEqualTo(committable); } @Test - void testUnsupportedVersion() throws IOException { - DynamicCommittable committable = - new DynamicCommittable( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - new byte[] {3, 4}, - JobID.generate().toHexString(), - new OperatorID().toHexString(), - 5); + void testLatestVersion() throws IOException { + DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); + assertThat(serializer.deserialize(serializer.getVersion(), serializer.serialize(COMMITTABLE))) + .isEqualTo(COMMITTABLE); + } + @Test + void testUnsupportedVersion() { DynamicCommittableSerializer serializer = new DynamicCommittableSerializer(); - assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(committable))) + assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(COMMITTABLE))) .hasMessage("Unrecognized version or corrupt state: -1") .isInstanceOf(IOException.class); } + + byte[] serializeV1(DynamicCommittable committable) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); + + // Wrap TableKey into a testing WriteTarget to match the V1 format + WriteTarget writeTarget = + new WriteTarget( + committable.key().tableName(), + committable.key().branch(), + -1, + -1, + false, + Set.of(1, 2, 3)); + view.write(serializeV1(writeTarget)); + + view.writeUTF(committable.jobId()); + view.writeUTF(committable.operatorId()); + view.writeLong(committable.checkpointId()); + + Preconditions.checkArgument( + committable.manifests().length == 1, + "V1 serialization format must have only one manifest per committable."); + view.writeInt(committable.manifests()[0].length); + view.write(committable.manifests()[0]); + + return out.toByteArray(); + } + + byte[] serializeV1(WriteTarget writeTarget) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + DataOutputViewStreamWrapper view = new DataOutputViewStreamWrapper(out); + + view.writeUTF(writeTarget.tableName()); + view.writeUTF(writeTarget.branch()); + view.writeInt(writeTarget.schemaId()); + view.writeInt(writeTarget.specId()); + view.writeBoolean(writeTarget.upsertMode()); + view.writeInt(writeTarget.equalityFields().size()); + for (Integer equalityField : writeTarget.equalityFields()) { + view.writeInt(equalityField); + } + + return out.toByteArray(); + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java index 5f938d4e8827..4cc27151b094 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicCommitter.java @@ -54,6 +54,7 @@ import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; @@ -125,6 +126,15 @@ class TestDynamicCommitter { .ofPositionDeletes() .build(); + private static final Map> WRITE_RESULT_BY_SPEC = + Map.of( + DATA_FILE.specId(), + Lists.newArrayList(WriteResult.builder().addDataFiles(DATA_FILE).build())); + private static final Map> WRITE_RESULT_BY_SPEC_2 = + Map.of( + DATA_FILE_2.specId(), + Lists.newArrayList(WriteResult.builder().addDataFiles(DATA_FILE_2).build())); + @BeforeEach void before() { catalog = CATALOG_EXTENSION.catalog(); @@ -155,12 +165,9 @@ void testCommit() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget1 = - new WriteTarget(TABLE1, "branch", 42, 0, true, Sets.newHashSet(1, 2)); - WriteTarget writeTarget2 = - new WriteTarget(TABLE1, "branch2", 43, 0, true, Sets.newHashSet(1, 2)); - WriteTarget writeTarget3 = - new WriteTarget(TABLE2, "branch2", 43, 0, true, Sets.newHashSet(1, 2)); + TableKey tableKey1 = new TableKey(TABLE1, "branch"); + TableKey tableKey2 = new TableKey(TABLE1, "branch2"); + TableKey tableKey3 = new TableKey(TABLE2, "branch2"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -168,27 +175,12 @@ void testCommit() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget1, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget2, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget3, WriteResult.builder().addDataFiles(DATA_FILE).build())), - 0); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests2 = + aggregator.writeToManifests(tableKey2.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey3.tableName(), WRITE_RESULT_BY_SPEC, 0); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); @@ -196,15 +188,15 @@ void testCommit() throws Exception { CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey1, deltaManifests1, jobId, operatorId, checkpointId)); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey2, deltaManifests2, jobId, operatorId, checkpointId)); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey3, deltaManifests3, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest1, commitRequest2, commitRequest3)); @@ -267,7 +259,7 @@ void testCommit() throws Exception { } @Test - void testAlreadyCommitted() throws Exception { + void testSkipsCommitRequestsForPreviousCheckpoints() throws Exception { Table table1 = catalog.loadTable(TableIdentifier.of(TABLE1)); assertThat(table1.snapshots()).isEmpty(); @@ -285,8 +277,7 @@ void testAlreadyCommitted() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -298,24 +289,18 @@ void testAlreadyCommitted() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); CommitRequest oldCommitRequest = new MockCommitRequest<>( - new DynamicCommittable( - writeTarget, deltaManifest, jobId, operatorId, checkpointId - 1)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId - 1)); // Old commits requests shouldn't affect the result dynamicCommitter.commit(Sets.newHashSet(oldCommitRequest)); @@ -360,25 +345,23 @@ void testCommitDeleteInDifferentFormatVersion() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); - DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); OneInputStreamOperatorTestHarness aggregatorHarness = new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); + TableKey tableKey = new TableKey(TABLE1, "branch"); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, + byte[][] deltaManifests = + aggregator.writeToManifests( + tableKey.tableName(), + Map.of( + DATA_FILE.specId(), + Sets.newHashSet( WriteResult.builder() .addDataFiles(DATA_FILE) .addDeleteFiles(DELETE_FILE) @@ -387,7 +370,7 @@ void testCommitDeleteInDifferentFormatVersion() throws Exception { CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); // Upgrade the table version UpdateProperties updateApi = table1.updateProperties(); @@ -421,8 +404,7 @@ void testCommitOnlyDataInDifferentFormatVersion() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -434,17 +416,12 @@ void testCommitOnlyDataInDifferentFormatVersion() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); @@ -486,50 +463,34 @@ void testTableBranchAtomicCommitForAppendOnlyData() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - WriteTarget writeTarget1 = - new WriteTarget(TABLE1, "branch1", 42, 0, true, Sets.newHashSet(1, 2)); - // writeTarget2 has a different schema - WriteTarget writeTarget2 = new WriteTarget(TABLE1, "branch1", 23, 0, true, Sets.newHashSet()); - // Different branch for writeTarget3 - WriteTarget writeTarget3 = new WriteTarget(TABLE1, "branch2", 23, 0, true, Sets.newHashSet()); - - WriteResult writeResult1 = WriteResult.builder().addDataFiles(DATA_FILE).build(); - WriteResult writeResult2 = WriteResult.builder().addDataFiles(DATA_FILE_2).build(); + TableKey tableKey1 = new TableKey(TABLE1, "branch1"); + TableKey tableKey2 = new TableKey(TABLE1, "branch2"); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId1 = 1; final int checkpointId2 = 2; - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet(new DynamicWriteResult(writeTarget1, writeResult1)), - checkpointId1); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC, checkpointId1); CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId1)); + new DynamicCommittable(tableKey1, deltaManifests1, jobId, operatorId, checkpointId1)); - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet(new DynamicWriteResult(writeTarget2, writeResult2)), - checkpointId1); + byte[][] deltaManifests2 = + aggregator.writeToManifests(tableKey1.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId1); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId1)); + new DynamicCommittable(tableKey1, deltaManifests2, jobId, operatorId, checkpointId1)); - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet(new DynamicWriteResult(writeTarget3, writeResult2)), - checkpointId2); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey2.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId2); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId2)); + new DynamicCommittable(tableKey2, deltaManifests3, jobId, operatorId, checkpointId2)); boolean overwriteMode = false; int workerPoolSize = 1; @@ -601,49 +562,36 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - WriteTarget writeTarget1 = new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet()); - // writeTarget2 has a different schema - WriteTarget writeTarget2 = new WriteTarget(TABLE1, "branch", 23, 0, false, Sets.newHashSet()); - WriteTarget writeTarget3 = new WriteTarget(TABLE1, "branch", 23, 0, false, Sets.newHashSet()); + TableKey tableKey = new TableKey(TABLE1, "branch"); + + Map> writeResults = + Map.of( + DELETE_FILE.specId(), + Lists.newArrayList(WriteResult.builder().addDeleteFiles(DELETE_FILE).build())); - WriteResult writeResult1 = WriteResult.builder().addDataFiles(DATA_FILE).build(); - WriteResult writeResult2 = WriteResult.builder().addDeleteFiles(DELETE_FILE).build(); - WriteResult writeResult3 = WriteResult.builder().addDataFiles(DATA_FILE).build(); + byte[][] deltaManifests1 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); + byte[][] deltaManifests2 = aggregator.writeToManifests(tableKey.tableName(), writeResults, 0); + byte[][] deltaManifests3 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC_2, 0); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId1 = 1; final int checkpointId2 = 2; - - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet(new DynamicWriteResult(writeTarget1, writeResult1)), - checkpointId1); + final int checkpointId3 = 3; CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId1)); - - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet(new DynamicWriteResult(writeTarget2, writeResult2)), - checkpointId2); + new DynamicCommittable(tableKey, deltaManifests1, jobId, operatorId, checkpointId1)); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId2)); - - byte[] deltaManifest3 = - aggregator.writeToManifest( - writeTarget3, - Sets.newHashSet(new DynamicWriteResult(writeTarget3, writeResult3)), - checkpointId2); + new DynamicCommittable(tableKey, deltaManifests2, jobId, operatorId, checkpointId2)); CommitRequest commitRequest3 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget3, deltaManifest3, jobId, operatorId, checkpointId2)); + new DynamicCommittable(tableKey, deltaManifests3, jobId, operatorId, checkpointId3)); boolean overwriteMode = false; int workerPoolSize = 1; @@ -693,10 +641,7 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { } table.refresh(); - // Three committables, but only two snapshots! WriteResults from different checkpoints are not - // getting - // combined due to one writeResult2 containing a delete file. - assertThat(table.snapshots()).hasSize(2); + assertThat(table.snapshots()).hasSize(3); Snapshot snapshot1 = Iterables.getFirst(table.snapshots(), null); assertThat(snapshot1.summary()) @@ -720,18 +665,34 @@ void testTableBranchAtomicCommitWithFailures() throws Exception { assertThat(snapshot2.summary()) .containsAllEntriesOf( ImmutableMap.builder() - .put("added-data-files", "1") - .put("added-records", "42") .put("changed-partition-count", "1") .put("flink.job-id", jobId) .put("flink.max-committed-checkpoint-id", "" + checkpointId2) .put("flink.operator-id", operatorId) + .put("total-data-files", "1") + .put("total-delete-files", "1") + .put("total-equality-deletes", "0") + .put("total-files-size", "0") + .put("total-position-deletes", "24") + .put("total-records", "42") + .build()); + + Snapshot snapshot3 = Iterables.get(table.snapshots(), 2); + assertThat(snapshot3.summary()) + .containsAllEntriesOf( + ImmutableMap.builder() + .put("added-data-files", "1") + .put("added-records", "24") + .put("changed-partition-count", "1") + .put("flink.job-id", jobId) + .put("flink.max-committed-checkpoint-id", "" + checkpointId3) + .put("flink.operator-id", operatorId) .put("total-data-files", "2") .put("total-delete-files", "1") .put("total-equality-deletes", "0") .put("total-files-size", "0") .put("total-position-deletes", "24") - .put("total-records", "84") + .put("total-records", "66") .build()); } @@ -746,36 +707,24 @@ void testCommitDeltaTxnWithAppendFiles() throws Exception { new OneInputStreamOperatorTestHarness(aggregator); aggregatorHarness.open(); - WriteTarget writeTarget1 = - new WriteTarget(TABLE1, "branch1", 42, 0, true, Sets.newHashSet(1, 2)); - WriteTarget writeTarget2 = new WriteTarget(TABLE1, "branch1", 23, 0, true, Sets.newHashSet()); - - WriteResult writeResult1 = WriteResult.builder().addDataFiles(DATA_FILE).build(); - WriteResult writeResult2 = WriteResult.builder().addDataFiles(DATA_FILE_2).build(); - + TableKey tableKey = new TableKey(TABLE1, "branch1"); final String jobId = JobID.generate().toHexString(); final String operatorId = new OperatorID().toHexString(); final int checkpointId = 1; - byte[] deltaManifest1 = - aggregator.writeToManifest( - writeTarget1, - Sets.newHashSet(new DynamicWriteResult(writeTarget1, writeResult1)), - checkpointId); + byte[][] deltaManifest1 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget1, deltaManifest1, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifest1, jobId, operatorId, checkpointId)); - byte[] deltaManifest2 = - aggregator.writeToManifest( - writeTarget2, - Sets.newHashSet(new DynamicWriteResult(writeTarget2, writeResult2)), - checkpointId); + byte[][] deltaManifest2 = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC_2, checkpointId); CommitRequest commitRequest2 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget2, deltaManifest2, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifest2, jobId, operatorId, checkpointId)); boolean overwriteMode = false; int workerPoolSize = 1; @@ -820,8 +769,7 @@ void testReplacePartitions() throws Exception { sinkId, committerMetrics); - WriteTarget writeTarget = - new WriteTarget(TABLE1, "branch", 42, 0, false, Sets.newHashSet(1, 2)); + TableKey tableKey = new TableKey(TABLE1, "branch"); DynamicWriteResultAggregator aggregator = new DynamicWriteResultAggregator(CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize); @@ -833,32 +781,22 @@ void testReplacePartitions() throws Exception { final String operatorId = new OperatorID().toHexString(); final int checkpointId = 10; - byte[] deltaManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + byte[][] deltaManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest commitRequest = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, deltaManifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, deltaManifests, jobId, operatorId, checkpointId)); dynamicCommitter.commit(Sets.newHashSet(commitRequest)); - byte[] overwriteManifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId + 1); + byte[][] overwriteManifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, 0); CommitRequest overwriteRequest = new MockCommitRequest<>( new DynamicCommittable( - writeTarget, overwriteManifest, jobId, operatorId, checkpointId + 1)); + tableKey, overwriteManifests, jobId, operatorId, checkpointId + 1)); dynamicCommitter.commit(Sets.newHashSet(overwriteRequest)); @@ -901,18 +839,13 @@ void testThrowsValidationExceptionOnDuplicateCommit(boolean overwriteMode) throw final int checkpointId = 1; final String branch = SnapshotRef.MAIN_BRANCH; - WriteTarget writeTarget = new WriteTarget(TABLE1, branch, 42, 0, false, Sets.newHashSet(1, 2)); - byte[] manifest = - aggregator.writeToManifest( - writeTarget, - Sets.newHashSet( - new DynamicWriteResult( - writeTarget, WriteResult.builder().addDataFiles(DATA_FILE).build())), - checkpointId); + TableKey tableKey = new TableKey(TABLE1, branch); + byte[][] manifests = + aggregator.writeToManifests(tableKey.tableName(), WRITE_RESULT_BY_SPEC, checkpointId); CommitRequest commitRequest1 = new MockCommitRequest<>( - new DynamicCommittable(writeTarget, manifest, jobId, operatorId, checkpointId)); + new DynamicCommittable(tableKey, manifests, jobId, operatorId, checkpointId)); Collection> commitRequests = Sets.newHashSet(commitRequest1); int workerPoolSize = 1; diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java index b660d8e285d9..89befb9e8ea2 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSink.java @@ -29,8 +29,11 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import javax.annotation.Nullable; import org.apache.flink.api.common.typeinfo.TypeHint; import org.apache.flink.api.common.typeinfo.TypeInformation; @@ -40,9 +43,14 @@ import org.apache.flink.configuration.Configuration; import org.apache.flink.configuration.RestartStrategyOptions; import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; +import org.apache.flink.runtime.OperatorIDPair; import org.apache.flink.runtime.client.JobExecutionException; +import org.apache.flink.runtime.jobgraph.JobVertex; +import org.apache.flink.streaming.api.connector.sink2.CommittableMessage; import org.apache.flink.streaming.api.datastream.DataStream; +import org.apache.flink.streaming.api.datastream.DataStreamSource; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; +import org.apache.flink.streaming.api.graph.StreamNode; import org.apache.flink.table.catalog.ResolvedSchema; import org.apache.flink.table.data.RowData; import org.apache.flink.table.data.util.DataFormatConverters; @@ -58,6 +66,8 @@ import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.Catalog; @@ -68,6 +78,7 @@ import org.apache.iceberg.flink.CatalogLoader; import org.apache.iceberg.flink.FlinkSchemaUtil; import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.MiniFlinkClusterExtension; import org.apache.iceberg.flink.SimpleDataUtil; import org.apache.iceberg.flink.TestHelpers; @@ -76,8 +87,10 @@ import org.apache.iceberg.flink.sink.dynamic.TestDynamicCommitter.FailBeforeAndAfterCommit; import org.apache.iceberg.inmemory.InMemoryInputFile; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.relocated.com.google.common.collect.Sets; @@ -111,6 +124,7 @@ private static class DynamicIcebergDataImpl implements Serializable { PartitionSpec partitionSpec; boolean upsertMode; Set equalityFields; + int writeParallelism; private DynamicIcebergDataImpl( Schema schemaProvided, String tableName, String branch, PartitionSpec partitionSpec) { @@ -122,7 +136,8 @@ private DynamicIcebergDataImpl( partitionSpec, false, Collections.emptySet(), - false); + false, + 10); } private DynamicIcebergDataImpl( @@ -139,7 +154,8 @@ private DynamicIcebergDataImpl( partitionSpec, false, Collections.emptySet(), - false); + false, + 10); } private DynamicIcebergDataImpl( @@ -158,7 +174,8 @@ private DynamicIcebergDataImpl( partitionSpec, upsertMode, equalityFields, - isDuplicate); + isDuplicate, + 10); } private DynamicIcebergDataImpl( @@ -169,7 +186,8 @@ private DynamicIcebergDataImpl( PartitionSpec partitionSpec, boolean upsertMode, Set equalityFields, - boolean isDuplicate) { + boolean isDuplicate, + int writeParallelism) { this.rowProvided = randomRow(schemaProvided, isDuplicate ? seed : ++seed); this.rowExpected = isDuplicate ? null : rowProvided; this.schemaProvided = schemaProvided; @@ -179,6 +197,7 @@ private DynamicIcebergDataImpl( this.partitionSpec = partitionSpec; this.upsertMode = upsertMode; this.equalityFields = equalityFields; + this.writeParallelism = writeParallelism; } } @@ -198,6 +217,56 @@ public void generate(DynamicIcebergDataImpl row, Collector out) { converter(schema).toInternal(row.rowProvided), spec, spec.isPartitioned() ? DistributionMode.HASH : DistributionMode.NONE, + row.writeParallelism); + dynamicRecord.setUpsertMode(row.upsertMode); + dynamicRecord.setEqualityFields(row.equalityFields); + out.collect(dynamicRecord); + } + } + + /** Generator that always emits forward (null distributionMode) records. */ + private static class ForwardGenerator implements DynamicRecordGenerator { + @Override + public void generate(DynamicIcebergDataImpl row, Collector out) { + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, row.tableName); + Schema schema = row.schemaProvided; + PartitionSpec spec = row.partitionSpec; + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + row.branch, + schema, + converter(schema).toInternal(row.rowProvided), + spec); + dynamicRecord.setUpsertMode(row.upsertMode); + dynamicRecord.setEqualityFields(row.equalityFields); + out.collect(dynamicRecord); + } + } + + /** + * Generator that alternates between forward (null distributionMode) and shuffle records. Even + * indices go forward, odd indices go through shuffle. + */ + private static class MixedGenerator implements DynamicRecordGenerator { + private int count = 0; + + @Override + public void generate(DynamicIcebergDataImpl row, Collector out) { + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, row.tableName); + Schema schema = row.schemaProvided; + PartitionSpec spec = row.partitionSpec; + boolean forward = (count++ % 2 == 0); + DistributionMode mode = + forward ? null : (spec.isPartitioned() ? DistributionMode.HASH : DistributionMode.NONE); + DynamicRecord dynamicRecord = + new DynamicRecord( + tableIdentifier, + row.branch, + schema, + converter(schema).toInternal(row.rowProvided), + spec, + mode, 10); dynamicRecord.setUpsertMode(row.upsertMode); dynamicRecord.setEqualityFields(row.equalityFields); @@ -217,24 +286,133 @@ void testWrite() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } + @Test + void testNoShuffleTopology() throws Exception { + DataStream dataStream = + env.fromData( + Collections.emptyList(), TypeInformation.of(new TypeHint() {})); + DynamicIcebergSink.forInput(dataStream) + .generator(new ForwardGenerator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(false) + .overwrite(false) + .append(); + + boolean generatorAndSinkChained = false; + for (JobVertex vertex : env.getStreamGraph().getJobGraph().getVertices()) { + boolean generatorInThisVertex = false; + boolean sinkInThisVertex = false; + for (OperatorIDPair operatorID : vertex.getOperatorIDs()) { + String uid = operatorID.getUserDefinedOperatorUid(); + if (uid == null) { + continue; + } + + if (uid.endsWith("-forward-writer")) { + sinkInThisVertex = true; + } else if (uid.endsWith("-generator")) { + generatorInThisVertex = true; + } + } + + generatorAndSinkChained = generatorInThisVertex && sinkInThisVertex; + if (generatorAndSinkChained) { + break; + } + } + + assertThat(generatorAndSinkChained).isTrue(); + } + + @Test + void testForwardWrite() throws Exception { + runForwardWriteTest(new ForwardGenerator()); + } + + @Test + void testMixedForwardAndShuffleWrite() throws Exception { + runForwardWriteTest(new MixedGenerator()); + } + + private void runForwardWriteTest(DynamicRecordGenerator generator) + throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(1); + + DynamicIcebergSink.forInput(dataStream) + .generator(generator) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(1) + .immediateTableUpdate(true) + .append(); + + env.execute(); + + verifyResults(rows); + } + + @Test + void testWriteWithNullBranch() throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, "t1", null, PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, "t1", null, PartitionSpec.unpartitioned())); + + runTest( + rows, this.env, false, 1, ImmutableMap.of(FlinkWriteOptions.BRANCH.key(), "test-branch")); + } + @Test void testWritePartitioned() throws Exception { PartitionSpec spec = PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).bucket("id", 10).build(); List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec)); + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec)); runTest(rows); } @@ -257,11 +435,11 @@ void testWritePartitionedAdjustSchemaIdsInSpec() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema, "t1", "main", spec), - new DynamicIcebergDataImpl(schema2, "t1", "main", spec2), - new DynamicIcebergDataImpl(schema2, "t1", "main", spec2)); + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(schema2, "t1", SnapshotRef.MAIN_BRANCH, spec2), + new DynamicIcebergDataImpl(schema2, "t1", SnapshotRef.MAIN_BRANCH, spec2)); runTest(rows); } @@ -291,15 +469,35 @@ void testSchemaEvolutionFieldOrderChanges() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema, expectedSchema, "t1", "main", PartitionSpec.unpartitioned()), + schema, + expectedSchema, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema2, expectedSchema2, "t1", "main", PartitionSpec.unpartitioned()), + schema2, + expectedSchema2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - schema2, expectedSchema2, "t1", "main", PartitionSpec.unpartitioned())); + schema2, + expectedSchema2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); for (DynamicIcebergDataImpl row : rows) { if (row.schemaExpected == expectedSchema) { @@ -316,9 +514,15 @@ void testMultipleTables() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -329,8 +533,8 @@ void testMultipleTablesPartitioned() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t2", "main", spec)); + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t2", SnapshotRef.MAIN_BRANCH, spec)); runTest(rows); } @@ -340,9 +544,15 @@ void testSchemaEvolutionAddField() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); } @@ -352,9 +562,15 @@ void testRowEvolutionNullMissingOptionalField() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); } @@ -379,7 +595,7 @@ void testRowEvolutionMakeMissingRequiredFieldOptional() throws Exception { writeSchemaWithoutRequiredField, existingSchemaWithRequiredField, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); runTest(rows, this.env, 1); @@ -393,9 +609,10 @@ void testSchemaEvolutionNonBackwardsCompatible() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(initialSchema, "t1", "main", PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - erroringSchema, "t1", "main", PartitionSpec.unpartitioned())); + initialSchema, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + erroringSchema, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); try { runTest(rows, StreamExecutionEnvironment.getExecutionEnvironment(), 1); @@ -416,20 +633,45 @@ void testPartitionSpecEvolution() throws Exception { List rows = Lists.newArrayList( - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec1), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec1), - new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, "t1", "main", spec2)); + SimpleDataUtil.SCHEMA, "t1", SnapshotRef.MAIN_BRANCH, spec2)); runTest(rows); + + // Validate the table has expected partition specs + Table table = CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1")); + Map tableSpecs = table.specs(); + List expectedSpecs = List.of(spec1, spec2, PartitionSpec.unpartitioned()); + + assertThat(tableSpecs).hasSize(expectedSpecs.size()); + expectedSpecs.forEach( + expectedSpec -> + assertThat( + tableSpecs.values().stream() + .anyMatch( + spec -> PartitionSpecEvolution.checkCompatibility(spec, expectedSpec))) + .withFailMessage("Table spec not found: %s.", expectedSpec) + .isTrue()); } @Test @@ -439,7 +681,10 @@ void testMultipleBranches() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", "branch1", PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -449,23 +694,50 @@ void testWriteMultipleTablesWithSchemaChanges() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA2, "t2", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA2, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); runTest(rows); } @@ -478,7 +750,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -487,7 +759,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -495,7 +767,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -503,7 +775,7 @@ void testUpsert() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -547,7 +819,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -556,7 +828,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -564,7 +836,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -572,7 +844,7 @@ void testUpsertV3() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -625,7 +897,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -634,7 +906,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -642,7 +914,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -650,7 +922,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t1", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -662,7 +934,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -671,7 +943,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -679,7 +951,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -687,7 +959,7 @@ void testMultiFormatVersion() throws Exception { new DynamicIcebergDataImpl( SimpleDataUtil.SCHEMA, "t2", - "main", + SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned(), true, Sets.newHashSet("id"), @@ -745,9 +1017,15 @@ void testCommitFailedBeforeOrAfterCommit() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); final CommitHook commitHook = new FailBeforeAndAfterCommit(); assertThat(FailBeforeAndAfterCommit.failedBeforeCommit).isFalse(); @@ -769,9 +1047,15 @@ void testCommitConcurrency() throws Exception { List rows = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t1", "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, "t2", "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + "t2", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); TableIdentifier tableIdentifier = TableIdentifier.of("default", "t1"); Catalog catalog = CATALOG_EXTENSION.catalog(); @@ -789,9 +1073,15 @@ void testCommitsOnceWhenConcurrentDuplicateCommit(boolean overwriteMode) throws List records = Lists.newArrayList( new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, tableId.name(), "main", PartitionSpec.unpartitioned()), + SimpleDataUtil.SCHEMA, + tableId.name(), + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned()), new DynamicIcebergDataImpl( - SimpleDataUtil.SCHEMA, tableId.name(), "main", PartitionSpec.unpartitioned())); + SimpleDataUtil.SCHEMA, + tableId.name(), + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned())); CommitHook duplicateCommit = new DuplicateCommitHook( @@ -822,6 +1112,334 @@ void testCommitsOnceWhenConcurrentDuplicateCommit(boolean overwriteMode) throws assertThat(totalAddedRecords).isEqualTo(records.size()); } + @Test + void testCommitsOncePerTableBranchAndCheckpoint() throws Exception { + String tableName = "t1"; + String branch = SnapshotRef.MAIN_BRANCH; + PartitionSpec spec1 = PartitionSpec.unpartitioned(); + PartitionSpec spec2 = PartitionSpec.builderFor(SimpleDataUtil.SCHEMA).bucket("id", 10).build(); + Set equalityFields = Sets.newHashSet("id"); + + List inputRecords = + Lists.newArrayList( + // Two schemas + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA2, tableName, branch, spec1), + // Two specs + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec1), + new DynamicIcebergDataImpl(SimpleDataUtil.SCHEMA, tableName, branch, spec2), + // Some upserts + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, tableName, branch, spec1, true, equalityFields, false), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, tableName, branch, spec1, true, equalityFields, true)); + + executeDynamicSink(inputRecords, env, true, 1, null); + + List actualRecords; + try (CloseableIterable iterable = + IcebergGenerics.read( + CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of("default", "t1"))) + .build()) { + actualRecords = Lists.newArrayList(iterable); + } + + // Validate records + int expectedRecords = inputRecords.size() - 1; // 1 duplicate + assertThat(actualRecords).hasSize(expectedRecords); + + for (int i = 0; i < expectedRecords; i++) { + Record actual = actualRecords.get(0); + assertThat(inputRecords) + .anySatisfy( + inputRecord -> { + assertThat(actual.get(0)).isEqualTo(inputRecord.rowProvided.getField(0)); + assertThat(actual.get(1)).isEqualTo(inputRecord.rowProvided.getField(1)); + if (inputRecord.schemaProvided.equals(SimpleDataUtil.SCHEMA2)) { + assertThat(actual.get(2)).isEqualTo(inputRecord.rowProvided.getField(2)); + } + // There is an additional _pos field which gets added + }); + } + + TableIdentifier tableIdentifier = TableIdentifier.of("default", tableName); + Table table = CATALOG_EXTENSION.catalog().loadTable(tableIdentifier); + + Snapshot lastSnapshot = Iterables.getLast(table.snapshots()); + assertThat(lastSnapshot).isNotNull(); + assertThat(lastSnapshot.summary()) + .containsAllEntriesOf( + ImmutableMap.builder() + .put("total-delete-files", "2") + .put("total-records", "6") + .build()); + + // Count commits per checkpoint + Map commitsPerCheckpoint = + StreamSupport.stream(table.snapshots().spliterator(), false) + .map(snapshot -> snapshot.summary().get("flink.max-committed-checkpoint-id")) + .filter(Objects::nonNull) + .map(Long::parseLong) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + assertThat(commitsPerCheckpoint.values()).allMatch(count -> count == 1); + } + + @Test + void testOptInDropUnusedColumns() throws Exception { + Schema schema1 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "extra", Types.StringType.get())); + + Schema schema2 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.of(DATABASE, "t1"); + catalog.createTable(tableIdentifier, schema1); + + List rows = + Lists.newArrayList( + // Drop columns + new DynamicIcebergDataImpl( + schema2, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned()), + // Re-add columns + new DynamicIcebergDataImpl( + schema1, "t1", SnapshotRef.MAIN_BRANCH, PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(1); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .immediateTableUpdate(true) + .dropUnusedColumns(true) + .append(); + + env.execute("Test Drop Unused Columns"); + + Table table = catalog.loadTable(tableIdentifier); + table.refresh(); + + assertThat(table.schema().columns()).hasSize(2); + assertThat(table.schema().findField("id")).isNotNull(); + assertThat(table.schema().findField("data")).isNotNull(); + assertThat(table.schema().findField("extra")).isNull(); + + List records = Lists.newArrayList(IcebergGenerics.read(table).build()); + assertThat(records).hasSize(2); + } + + @Test + void testCaseInsensitiveSchemaMatching() throws Exception { + Schema lowerCaseSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema upperCaseSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + Schema mixedCaseSchema = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get())); + + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + lowerCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + upperCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + mixedCaseSchema, "t1", "main", PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(2); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(true) + .caseSensitive(false) + .append(); + + env.execute("Test Case Insensitive Iceberg DataStream"); + + verifyResults(rows); + } + + @Test + void testCaseSensitiveSchemaMatchingCreatesNewFields() throws Exception { + Schema lowerCaseSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Schema upperCaseSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + lowerCaseSchema, "t1", "main", PartitionSpec.unpartitioned()), + new DynamicIcebergDataImpl( + upperCaseSchema, "t1", "main", PartitionSpec.unpartitioned())); + + DataStream dataStream = + env.fromData(rows, TypeInformation.of(new TypeHint<>() {})); + env.setParallelism(2); + + DynamicIcebergSink.forInput(dataStream) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .writeParallelism(2) + .immediateTableUpdate(true) + .caseSensitive(true) + .append(); + + env.execute("Test Case Sensitive Iceberg DataStream"); + + Table table = CATALOG_EXTENSION.catalog().loadTable(TableIdentifier.of(DATABASE, "t1")); + Schema resultSchema = table.schema(); + assertThat(resultSchema.columns()).hasSize(4); + assertThat(resultSchema.findField("id")).isNotNull(); + assertThat(resultSchema.findField("ID")).isNotNull(); + assertThat(resultSchema.findField("data")).isNotNull(); + assertThat(resultSchema.findField("DATA")).isNotNull(); + } + + @Test + void testOperatorUidsAreDeterministic() { + assertThat(createSinkAndReturnUIds("test")).isEqualTo(createSinkAndReturnUIds("test")); + assertThat(createSinkAndReturnUIds("test")) + .doesNotContainAnyElementsOf(createSinkAndReturnUIds("test2")); + } + + @Test + void testOperatorUidsFormat() { + Set sinkUids = createSinkAndReturnUIds("test"); + // These look odd, but we need to keep the UIDs consistent. We had a bug where the UID of the + // pre commit topology was off, but since it is stateless, users will still be able to restore + // state, but we must keep the stateful operators UUIds like the committer consistent. + assertThat(sinkUids) + .containsOnly( + "test--sink", + "test--forward-writer", + "test--generator", + "test--updater", + "test--sink: test--pre-commit-topology", + "Sink Committer: test--sink"); + + sinkUids = createSinkAndReturnUIds(""); + assertThat(sinkUids) + .containsOnly( + "--sink", + "--forward-writer", + "--generator", + "--updater", + "--sink: --pre-commit-topology", + "Sink Committer: --sink"); + + sinkUids = createSinkAndReturnUIds(null); + assertThat(sinkUids) + .containsOnly( + "--sink", + "--forward-writer", + "--generator", + "--updater", + "--sink: --pre-commit-topology", + "Sink Committer: --sink"); + } + + @Test + void testGeneratorDefaultParallelism() { + StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + streamEnv.setParallelism(4); + + DataStreamSource source = + streamEnv.fromData(Collections.emptySet(), TypeInformation.of(new TypeHint<>() {})); + source.setParallelism(8); + + DynamicIcebergSink.forInput(source) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .uidPrefix("test") + .append(); + + // Since the generator parallelism is not directly accessible via the returned DataStreamSink, + // inspect the stream graph to verify the generator inherits the input source parallelism. + int generatorParallelism = + streamEnv.getStreamGraph().getStreamNodes().stream() + .filter(node -> "test--generator".equals(node.getTransformationUID())) + .findFirst() + .map(StreamNode::getParallelism) + .orElseThrow(() -> new AssertionError("Generator node not found")); + + assertThat(generatorParallelism).isEqualTo(source.getParallelism()); + } + + @Test + void testFallBackParallelismFromConfig() throws Exception { + List rows = + Lists.newArrayList( + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned(), + false, + Collections.emptySet(), + false, + -1), + new DynamicIcebergDataImpl( + SimpleDataUtil.SCHEMA, + SimpleDataUtil.SCHEMA, + "t1", + SnapshotRef.MAIN_BRANCH, + PartitionSpec.unpartitioned(), + false, + Collections.emptySet(), + false, + 0)); + + runTest( + rows, this.env, true, 2, ImmutableMap.of(FlinkWriteOptions.WRITE_PARALLELISM.key(), "1")); + } + + private Set createSinkAndReturnUIds(String uidPrefix) { + StreamExecutionEnvironment streamEnv = StreamExecutionEnvironment.getExecutionEnvironment(); + + DataStreamSource source = + streamEnv.fromData(Collections.emptySet(), TypeInformation.of(new TypeHint<>() {})); + source.uid("source"); + + DynamicIcebergSink.forInput(source) + .generator(new Generator()) + .catalogLoader(CATALOG_EXTENSION.catalogLoader()) + .uidPrefix(uidPrefix) + .append(); + + // Make sure to get the expanded graph with all sink nodes + return streamEnv.getStreamGraph().getStreamNodes().stream() + .map(StreamNode::getTransformationUID) + // We are not interested in the source + .filter(uid -> !uid.equals("source")) + .collect(Collectors.toSet()); + } + /** * Represents a concurrent duplicate commit during an ongoing commit operation, which can happen * in production scenarios when using REST catalog. @@ -909,6 +1527,18 @@ private void runTest( verifyResults(dynamicData); } + private void runTest( + List dynamicData, + StreamExecutionEnvironment env, + boolean immediateUpdate, + int parallelism, + Map writeProperties) + throws Exception { + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, null, false, writeProperties); + verifyResults(dynamicData, writeProperties); + } + private void executeDynamicSink( List dynamicData, StreamExecutionEnvironment env, @@ -916,7 +1546,8 @@ private void executeDynamicSink( int parallelism, @Nullable CommitHook commitHook) throws Exception { - executeDynamicSink(dynamicData, env, immediateUpdate, parallelism, commitHook, false); + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, commitHook, false, Maps.newHashMap()); } private void executeDynamicSink( @@ -927,6 +1558,19 @@ private void executeDynamicSink( @Nullable CommitHook commitHook, boolean overwrite) throws Exception { + executeDynamicSink( + dynamicData, env, immediateUpdate, parallelism, commitHook, overwrite, Maps.newHashMap()); + } + + private void executeDynamicSink( + List dynamicData, + StreamExecutionEnvironment env, + boolean immediateUpdate, + int parallelism, + @Nullable CommitHook commitHook, + boolean overwrite, + Map writeProperties) + throws Exception { DataStream dataStream = env.fromData(dynamicData, TypeInformation.of(new TypeHint<>() {})); env.setParallelism(parallelism); @@ -940,6 +1584,7 @@ private void executeDynamicSink( .immediateTableUpdate(immediateUpdate) .setSnapshotProperty("commit.retry.num-retries", "0") .overwrite(overwrite) + .setAll(writeProperties) .append(); } else { DynamicIcebergSink.forInput(dataStream) @@ -948,6 +1593,7 @@ private void executeDynamicSink( .writeParallelism(parallelism) .immediateTableUpdate(immediateUpdate) .overwrite(overwrite) + .setAll(writeProperties) .append(); } @@ -964,15 +1610,17 @@ static class CommitHookEnabledDynamicIcebergSink extends DynamicIcebergSink.B @Override DynamicIcebergSink instantiateSink( - Map writeProperties, FlinkWriteConf flinkWriteConf) { + Map writeProperties, + Configuration flinkConfig, + DataStream> forwardWriteResults) { return new CommitHookDynamicIcebergSink( commitHook, CATALOG_EXTENSION.catalogLoader(), Collections.emptyMap(), "uidPrefix", writeProperties, - flinkWriteConf, - 100); + flinkConfig, + forwardWriteResults); } } @@ -987,17 +1635,18 @@ static class CommitHookDynamicIcebergSink extends DynamicIcebergSink { Map snapshotProperties, String uidPrefix, Map writeProperties, - FlinkWriteConf flinkWriteConf, - int cacheMaximumSize) { + Configuration flinkConfig, + DataStream> forwardWritten) { super( catalogLoader, snapshotProperties, uidPrefix, writeProperties, - flinkWriteConf, - cacheMaximumSize); + flinkConfig, + 100, + forwardWritten); this.commitHook = commitHook; - this.overwriteMode = flinkWriteConf.overwriteMode(); + this.overwriteMode = new FlinkWriteConf(writeProperties, flinkConfig).overwriteMode(); } @Override @@ -1014,6 +1663,12 @@ public Committer createCommitter(CommitterInitContext contex } private void verifyResults(List dynamicData) throws IOException { + verifyResults(dynamicData, Maps.newHashMap()); + } + + private void verifyResults( + List dynamicData, Map writeProperties) + throws IOException { // Calculate the expected result Map, List> expectedData = Maps.newHashMap(); Map expectedSchema = Maps.newHashMap(); @@ -1027,9 +1682,12 @@ private void verifyResults(List dynamicData) throws IOEx dynamicData.forEach( r -> { + String branch = + MoreObjects.firstNonNull( + r.branch, writeProperties.get(FlinkWriteOptions.BRANCH.key())); List data = expectedData.computeIfAbsent( - Tuple2.of(r.tableName, r.branch), unused -> Lists.newArrayList()); + Tuple2.of(r.tableName, branch), unused -> Lists.newArrayList()); data.addAll( convertToRowData(expectedSchema.get(r.tableName), ImmutableList.of(r.rowExpected))); }); diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java index ae5b2f67120b..81f6a4fb803a 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicIcebergSinkPerf.java @@ -20,7 +20,6 @@ import static org.apache.iceberg.flink.TestFixtures.DATABASE; import static org.apache.iceberg.flink.TestFixtures.TABLE; -import static org.apache.iceberg.flink.sink.dynamic.DynamicCommitter.MAX_CONTINUOUS_EMPTY_COMMITS; import java.util.List; import java.util.function.Consumer; @@ -39,6 +38,8 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.data.RandomGenericData; @@ -122,12 +123,9 @@ void before() { CATALOG_EXTENSION .catalog() .createTable( - IDENTIFIERS[i], - SCHEMA, - PartitionSpec.unpartitioned(), - ImmutableMap.of(MAX_CONTINUOUS_EMPTY_COMMITS, "100000")); + IDENTIFIERS[i], SCHEMA, PartitionSpec.unpartitioned(), ImmutableMap.of()); - table.manageSnapshots().createBranch("main").commit(); + table.manageSnapshots().createBranch(SnapshotRef.MAIN_BRANCH).commit(); } List records = RandomGenericData.generate(SCHEMA, SAMPLE_SIZE, 1L); @@ -136,7 +134,7 @@ void before() { rows.add( new DynamicRecord( IDENTIFIERS[i % TABLE_NUM], - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA, RowDataConverter.convert(SCHEMA, records.get(i)), PartitionSpec.unpartitioned(), @@ -228,7 +226,8 @@ private void runTest(Consumer> sink) throws Exception { Table table = CATALOG_EXTENSION.catalog().loadTable(identifier); for (Snapshot snapshot : table.snapshots()) { long records = 0; - for (DataFile dataFile : snapshot.addedDataFiles(table.io())) { + for (DataFile dataFile : + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) { records += dataFile.recordCount(); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java new file mode 100644 index 000000000000..388ff303339c --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializer.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import org.apache.flink.api.common.typeutils.TypeSerializer; +import org.apache.flink.api.common.typeutils.TypeSerializerSnapshot; +import org.apache.flink.core.memory.DataInputDeserializer; +import org.apache.flink.core.memory.DataOutputSerializer; +import org.apache.iceberg.flink.HadoopCatalogExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class TestDynamicRecordInternalSerializer { + + @RegisterExtension + static final HadoopCatalogExtension CATALOG_EXTENSION = new HadoopCatalogExtension("db", "table"); + + @Test + void testCurrentTypeSerializerSnapshotVersion() { + TypeSerializer serializer = createSerializer(); + assertThat(serializer).isNotNull().isInstanceOf(DynamicRecordInternalSerializer.class); + TypeSerializerSnapshot snapshot = serializer.snapshotConfiguration(); + assertThat(snapshot.getCurrentVersion()).isEqualTo(1); + } + + @Test + void testCurrentTypeSerializerSnapshotCompatibility() { + TypeSerializer serializer = createSerializer(); + assertThat(serializer).isNotNull().isInstanceOf(DynamicRecordInternalSerializer.class); + TypeSerializerSnapshot snapshot = serializer.snapshotConfiguration(); + assertThat( + snapshot + .resolveSchemaCompatibility(serializer.snapshotConfiguration()) + .isCompatibleAsIs()) + .isTrue(); + } + + @Test + void testRestoreFromOldVersion() throws IOException { + // Create a serialized snapshot of the TypeSerializer + final int oldVersion = 0; + OldTypeSerializerSnapshot oldTypeSerializerSnapshot = new OldTypeSerializerSnapshot(oldVersion); + assertThat(oldTypeSerializerSnapshot.getCurrentVersion()).isEqualTo(oldVersion); + DataOutputSerializer dataOutputSerializer = new DataOutputSerializer(128); + oldTypeSerializerSnapshot.writeSnapshot(dataOutputSerializer); + + // Load the serialized state + DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot restoreSnapshot = + (DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot) + createSerializer().snapshotConfiguration(); + restoreSnapshot.readSnapshot( + oldVersion, + new DataInputDeserializer(dataOutputSerializer.getSharedBuffer()), + getClass().getClassLoader()); + // Check that it matches the original one + assertThat(restoreSnapshot.getCurrentVersion()).isEqualTo(oldVersion); + assertThat( + restoreSnapshot + .resolveSchemaCompatibility(oldTypeSerializerSnapshot) + .isCompatibleAsIs()) + .isTrue(); + TypeSerializer restoreSerializer = restoreSnapshot.restoreSerializer(); + assertThat(restoreSerializer).isInstanceOf(DynamicRecordInternalSerializer.class); + assertThat(((DynamicRecordInternalSerializer) restoreSerializer).getSerializerCache()) + .isNotNull(); + + // Compare against the latest version of a snapshot + TypeSerializerSnapshot latestVersion = + createSerializer().snapshotConfiguration(); + assertThat(latestVersion.getCurrentVersion()).isEqualTo(1); + assertThat( + latestVersion + .resolveSchemaCompatibility(oldTypeSerializerSnapshot) + .isCompatibleAfterMigration()) + .isTrue(); + assertThat( + latestVersion.resolveSchemaCompatibility(restoreSnapshot).isCompatibleAfterMigration()) + .isTrue(); + } + + private DynamicRecordInternalSerializer createSerializer() { + return new DynamicRecordInternalSerializer( + new TableSerializerCache(CATALOG_EXTENSION.catalogLoader(), 1), true); + } + + private static class OldTypeSerializerSnapshot + extends DynamicRecordInternalSerializer.DynamicRecordInternalTypeSerializerSnapshot { + + private final int version; + + OldTypeSerializerSnapshot(int version) { + this.version = version; + } + + @Override + public int getCurrentVersion() { + return version; + } + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java index ab8ce98c3594..e7aa2d24d9d0 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchema.java @@ -18,11 +18,11 @@ */ package org.apache.iceberg.flink.sink.dynamic; -/** Test writing DynamicRecord with the full schema */ +/** Test writing DynamicRecord with the full schema and standard UTF encoding */ class TestDynamicRecordInternalSerializerWriteSchema extends DynamicRecordInternalSerializerTestBase { TestDynamicRecordInternalSerializerWriteSchema() { - super(true); + super(true /* writeFullSchemaAndSpec */, false /* writeLongUTF */); } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java index 1d8890546214..bff0fd5c6aad 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaId.java @@ -18,11 +18,11 @@ */ package org.apache.iceberg.flink.sink.dynamic; -/** Test writing DynamicRecord with only the schema id. */ +/** Test writing DynamicRecord with only the schema id and standard UTF encoding */ class TestDynamicRecordInternalSerializerWriteSchemaId extends DynamicRecordInternalSerializerTestBase { TestDynamicRecordInternalSerializerWriteSchemaId() { - super(false); + super(false /* writeFullSchemaAndSpec */, false /* writeLongUTF */); } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java new file mode 100644 index 000000000000..7a1ae3df3806 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +/** Test writing DynamicRecord with only the schema id and long UTF encoding */ +class TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF + extends DynamicRecordInternalSerializerTestBase { + + TestDynamicRecordInternalSerializerWriteSchemaIdLongUTF() { + super(false /* writeFullSchemaAndSpec */, true /* writeLongUTF */); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java new file mode 100644 index 000000000000..faff8921db5d --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordInternalSerializerWriteSchemaLongUTF.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +/** Test writing DynamicRecord with the full schema and long UTF encoding */ +class TestDynamicRecordInternalSerializerWriteSchemaLongUTF + extends DynamicRecordInternalSerializerTestBase { + + TestDynamicRecordInternalSerializerWriteSchemaLongUTF() { + super(true /* writeFullSchemaAndSpec */, true /* writeLongUTF */); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java new file mode 100644 index 000000000000..de55621475ed --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicRecordWithConfig.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.sink.dynamic; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.Set; +import org.apache.flink.configuration.Configuration; +import org.apache.flink.table.data.GenericRowData; +import org.apache.flink.table.data.RowData; +import org.apache.flink.table.data.StringData; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +class TestDynamicRecordWithConfig { + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + + private static final TableIdentifier TABLE_IDENTIFIER = TableIdentifier.of("db", "table"); + private static final PartitionSpec UNPARTITIONED = PartitionSpec.unpartitioned(); + private static final RowData ROW_DATA = GenericRowData.of(1, StringData.fromString("test")); + + @Test + void testBranchFallBack() { + String defaultBranch = "default-branch"; + FlinkWriteConf conf = + new FlinkWriteConf( + ImmutableMap.of(FlinkWriteOptions.BRANCH.key(), defaultBranch), new Configuration()); + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(conf); + + DynamicRecord dynamicRecord = + new DynamicRecord(TABLE_IDENTIFIER, null, SCHEMA, ROW_DATA, UNPARTITIONED); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).branch()).isEqualTo(defaultBranch); + + String customBranch = "custom-branch"; + dynamicRecord.setBranch(customBranch); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).branch()).isEqualTo(customBranch); + } + + @Test + void testWriteParallelismFallBack() { + int defaultParallelism = 4; + FlinkWriteConf conf = + new FlinkWriteConf( + ImmutableMap.of( + FlinkWriteOptions.WRITE_PARALLELISM.key(), String.valueOf(defaultParallelism)), + new Configuration()); + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(conf); + + DynamicRecord dynamicRecord = + new DynamicRecord(TABLE_IDENTIFIER, null, SCHEMA, ROW_DATA, UNPARTITIONED, null, -1); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()) + .isEqualTo(defaultParallelism); + + dynamicRecord.writeParallelism(0); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()) + .isEqualTo(defaultParallelism); + + dynamicRecord.writeParallelism(8); + assertThat(dynamicRecordWithConfig.wrap(dynamicRecord).writeParallelism()).isEqualTo(8); + } + + @Test + void testDelegatesToWrappedRecord() { + FlinkWriteConf conf = new FlinkWriteConf(Collections.emptyMap(), new Configuration()); + PartitionSpec partitioned = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + Set equalityFields = ImmutableSet.of("id", "data"); + + DynamicRecord dynamicRecord = + new DynamicRecord( + TABLE_IDENTIFIER, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + ROW_DATA, + partitioned, + DistributionMode.HASH, + 2); + dynamicRecord.setUpsertMode(true); + dynamicRecord.setEqualityFields(equalityFields); + + DynamicRecordWithConfig record = new DynamicRecordWithConfig(conf).wrap(dynamicRecord); + + assertThat(record.tableIdentifier()).isEqualTo(TABLE_IDENTIFIER); + assertThat(record.schema()).isEqualTo(SCHEMA); + assertThat(record.spec()).isEqualTo(partitioned); + assertThat(record.rowData()).isSameAs(ROW_DATA); + assertThat(record.distributionMode()).isEqualTo(DistributionMode.HASH); + assertThat(record.upsertMode()).isTrue(); + assertThat(record.equalityFields()).isEqualTo(equalityFields); + } +} diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java index 22655ff99f86..f6b2b368c2be 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicTableUpdateOperator.java @@ -23,18 +23,28 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Collections; +import org.apache.flink.configuration.Configuration; import org.apache.flink.table.data.GenericRowData; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.HadoopCatalogExtension; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class TestDynamicTableUpdateOperator { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + @RegisterExtension private static final HadoopCatalogExtension CATALOG_EXTENSION = new HadoopCatalogExtension(DATABASE, TABLE); @@ -49,9 +59,6 @@ class TestDynamicTableUpdateOperator { @Test void testDynamicTableUpdateOperatorNewTable() throws Exception { - int cacheMaximumSize = 10; - int cacheRefreshMs = 1000; - int inputSchemaCacheMaximumSize = 10; Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier table = TableIdentifier.of(TABLE); @@ -59,10 +66,8 @@ void testDynamicTableUpdateOperatorNewTable() throws Exception { DynamicTableUpdateOperator operator = new DynamicTableUpdateOperator( CATALOG_EXTENSION.catalogLoader(), - cacheMaximumSize, - cacheRefreshMs, - inputSchemaCacheMaximumSize, - TableCreator.DEFAULT); + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); operator.open(null); DynamicRecordInternal input = @@ -83,19 +88,14 @@ void testDynamicTableUpdateOperatorNewTable() throws Exception { @Test void testDynamicTableUpdateOperatorSchemaChange() throws Exception { - int cacheMaximumSize = 10; - int cacheRefreshMs = 1000; - int inputSchemaCacheMaximumSize = 10; Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier table = TableIdentifier.of(TABLE); DynamicTableUpdateOperator operator = new DynamicTableUpdateOperator( CATALOG_EXTENSION.catalogLoader(), - cacheMaximumSize, - cacheRefreshMs, - inputSchemaCacheMaximumSize, - TableCreator.DEFAULT); + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); operator.open(null); catalog.createTable(table, SCHEMA1); @@ -119,4 +119,126 @@ void testDynamicTableUpdateOperatorSchemaChange() throws Exception { assertThat(output2).isEqualTo(output); assertThat(catalog.loadTable(table).schema().schemaId()).isEqualTo(output.schema().schemaId()); } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testCaseInSensitivity(boolean caseSensitive) throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + Schema initialSchema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + Schema caseSensitiveSchema = + new Schema(Types.NestedField.required(1, "Id", Types.IntegerType.get())); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(caseSensitive, PRESERVE_COLUMNS)); + operator.open(null); + + catalog.createTable(table, initialSchema); + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + caseSensitiveSchema, + GenericRowData.of(1, "test"), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + if (caseSensitive) { + // Schema changes due to case sensitivity + Schema expectedSchema = + new Schema( + Types.NestedField.optional(2, "Id", Types.IntegerType.get()), + Types.NestedField.optional(1, "id", Types.IntegerType.get())); + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.sameSchema(expectedSchema)).isTrue(); + assertThat(output.schema().sameSchema(expectedSchema)).isTrue(); + } else { + // No schema change due to case insensitivity + assertThat(catalog.loadTable(table).schema().sameSchema(initialSchema)).isTrue(); + assertThat(output.schema().sameSchema(initialSchema)).isTrue(); + } + } + + @Test + void testDynamicTableUpdateOperatorPreserveUnusedColumns() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_SENSITIVE, PRESERVE_COLUMNS)); + operator.open(null); + + catalog.createTable(table, SCHEMA2); + + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + SCHEMA1, + GenericRowData.of(1), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.columns()).hasSize(2); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNotNull(); + assertThat(tableSchema.findField("data").isOptional()).isTrue(); + assertThat(input).isEqualTo(output); + } + + @Test + void testDynamicTableUpdateOperatorDropUnusedColumns() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier table = TableIdentifier.of(TABLE); + + DynamicTableUpdateOperator operator = + new DynamicTableUpdateOperator( + CATALOG_EXTENSION.catalogLoader(), + TableCreator.DEFAULT, + flinkDynamicSinkConfiguration(CASE_INSENSITIVE, DROP_COLUMNS)); + operator.open(null); + + catalog.createTable(table, SCHEMA2); + + DynamicRecordInternal input = + new DynamicRecordInternal( + TABLE, + "branch", + SCHEMA1, + GenericRowData.of(1), + PartitionSpec.unpartitioned(), + 42, + false, + Collections.emptySet()); + DynamicRecordInternal output = operator.map(input); + + Schema tableSchema = catalog.loadTable(table).schema(); + assertThat(tableSchema.columns()).hasSize(1); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNull(); + assertThat(input).isEqualTo(output); + } + + private static FlinkDynamicSinkConf flinkDynamicSinkConfiguration( + boolean caseSensitive, boolean dropUnusedColumns) { + return new FlinkDynamicSinkConf( + ImmutableMap.of( + FlinkDynamicSinkOptions.CASE_SENSITIVE.key(), String.valueOf(caseSensitive), + FlinkDynamicSinkOptions.DROP_UNUSED_COLUMNS.key(), String.valueOf(dropUnusedColumns)), + new Configuration()); + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java index 17a4d98b2938..f68e8dae174e 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultAggregator.java @@ -27,6 +27,7 @@ import org.apache.flink.streaming.api.connector.sink2.CommittableMessage; import org.apache.flink.streaming.api.connector.sink2.CommittableSummary; import org.apache.flink.streaming.api.connector.sink2.CommittableWithLineage; +import org.apache.flink.streaming.api.connector.sink2.SinkV2Assertions; import org.apache.flink.streaming.runtime.streamrecord.StreamRecord; import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness; import org.apache.iceberg.DataFile; @@ -58,7 +59,7 @@ class TestDynamicWriteResultAggregator { .build(); @Test - void testAggregator() throws Exception { + void testAggregatesWriteResultsForTwoTables() throws Exception { CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table"), new Schema()); CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table2"), new Schema()); @@ -69,13 +70,12 @@ void testAggregator() throws Exception { testHarness = new OneInputStreamOperatorTestHarness<>(aggregator)) { testHarness.open(); - WriteTarget writeTarget1 = new WriteTarget("table", "branch", 42, 0, true, Sets.newHashSet()); + TableKey tableKey1 = new TableKey("table", "branch"); DynamicWriteResult dynamicWriteResult1 = - new DynamicWriteResult(writeTarget1, WriteResult.builder().build()); - WriteTarget writeTarget2 = - new WriteTarget("table2", "branch", 42, 0, true, Sets.newHashSet(1, 2)); + new DynamicWriteResult(tableKey1, -1, WriteResult.builder().build()); + TableKey tableKey2 = new TableKey("table2", "branch"); DynamicWriteResult dynamicWriteResult2 = - new DynamicWriteResult(writeTarget2, WriteResult.builder().build()); + new DynamicWriteResult(tableKey2, -1, WriteResult.builder().build()); CommittableWithLineage committable1 = new CommittableWithLineage<>(dynamicWriteResult1, 0, 0); @@ -113,18 +113,16 @@ void testPreventOutputFileFactoryCacheEvictionDuringFlush() throws Exception { testHarness = new OneInputStreamOperatorTestHarness<>(aggregator)) { testHarness.open(); - WriteTarget writeTarget1 = - new WriteTarget("table", "branch", 42, 0, false, Sets.newHashSet()); + TableKey tableKey1 = new TableKey("table", "branch"); DynamicWriteResult dynamicWriteResult1 = new DynamicWriteResult( - writeTarget1, WriteResult.builder().addDataFiles(DATA_FILE).build()); + tableKey1, DATA_FILE.specId(), WriteResult.builder().addDataFiles(DATA_FILE).build()); - // Different WriteTarget - WriteTarget writeTarget2 = - new WriteTarget("table", "branch2", 23, 0, true, Sets.newHashSet()); + // Different TableKey + TableKey tableKey2 = new TableKey("table", "branch2"); DynamicWriteResult dynamicWriteResult2 = new DynamicWriteResult( - writeTarget2, WriteResult.builder().addDataFiles(DATA_FILE).build()); + tableKey2, DATA_FILE.specId(), WriteResult.builder().addDataFiles(DATA_FILE).build()); CommittableWithLineage committable1 = new CommittableWithLineage<>(dynamicWriteResult1, 0, 0); @@ -151,6 +149,70 @@ void testPreventOutputFileFactoryCacheEvictionDuringFlush() throws Exception { } } + @Test + void testAggregatesWriteResultsForOneTable() throws Exception { + CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table"), new Schema()); + CATALOG_EXTENSION.catalog().createTable(TableIdentifier.of("table2"), new Schema()); + + long checkpointId = 1L; + + try (OneInputStreamOperatorTestHarness< + CommittableMessage, CommittableMessage> + testHarness = + new OneInputStreamOperatorTestHarness<>( + new DynamicWriteResultAggregator( + CATALOG_EXTENSION.catalogLoader(), cacheMaximumSize))) { + testHarness.open(); + + TableKey tableKey = new TableKey("table", "branch"); + DataFile dataFile1 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data-1.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + DataFile dataFile2 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/data-2.parquet") + .withFileSizeInBytes(20) + .withRecordCount(2) + .build(); + + testHarness.processElement( + createRecord(tableKey, checkpointId, dataFile1.specId(), dataFile1)); + testHarness.processElement( + createRecord(tableKey, checkpointId, dataFile2.specId(), dataFile2)); + + assertThat(testHarness.getOutput()).isEmpty(); + + testHarness.prepareSnapshotPreBarrier(checkpointId); + + List> outputValues = testHarness.extractOutputValues(); + // Contains a CommittableSummary + DynamicCommittable + assertThat(outputValues).hasSize(2); + + SinkV2Assertions.assertThat(extractAndAssertCommittableSummary(outputValues.get(0))) + .hasOverallCommittables(1) + .hasFailedCommittables(0) + .hasCheckpointId(checkpointId); + + CommittableWithLineage committable = + extractAndAssertCommittableWithLineage(outputValues.get(1)); + + SinkV2Assertions.assertThat(committable).hasCheckpointId(checkpointId); + + DynamicCommittable dynamicCommittable = committable.getCommittable(); + + assertThat(dynamicCommittable.manifests()).hasNumberOfRows(1); + assertThat(dynamicCommittable.key()).isEqualTo(tableKey); + assertThat(dynamicCommittable.checkpointId()).isEqualTo(checkpointId); + assertThat(dynamicCommittable.jobId()) + .isEqualTo(testHarness.getEnvironment().getJobID().toString()); + assertThat(dynamicCommittable.operatorId()) + .isEqualTo(testHarness.getOperator().getOperatorID().toString()); + } + } + private static Set getManifestPaths( List>> messages) throws IOException { Set manifestPaths = Sets.newHashSet(); @@ -158,15 +220,40 @@ private static Set getManifestPaths( for (StreamRecord> record : messages) { CommittableMessage message = record.getValue(); if (message instanceof CommittableWithLineage) { - DeltaManifests deltaManifests = - SimpleVersionedSerialization.readVersionAndDeSerialize( - DeltaManifestsSerializer.INSTANCE, - (((CommittableWithLineage) message).getCommittable()) - .manifest()); - deltaManifests.manifests().forEach(manifest -> manifestPaths.add(manifest.path())); + for (byte[] manifest : + (((CommittableWithLineage) message).getCommittable()).manifests()) { + DeltaManifests deltaManifests = + SimpleVersionedSerialization.readVersionAndDeSerialize( + DeltaManifestsSerializer.INSTANCE, manifest); + deltaManifests + .manifests() + .forEach(manifestFile -> manifestPaths.add(manifestFile.path())); + } } } return manifestPaths; } + + private static StreamRecord> createRecord( + TableKey tableKey, long checkpointId, int specId, DataFile... dataFiles) { + return new StreamRecord<>( + new CommittableWithLineage<>( + new DynamicWriteResult( + tableKey, specId, WriteResult.builder().addDataFiles(dataFiles).build()), + checkpointId, + 0)); + } + + static CommittableSummary extractAndAssertCommittableSummary( + CommittableMessage message) { + assertThat(message).isInstanceOf(CommittableSummary.class); + return (CommittableSummary) message; + } + + static CommittableWithLineage extractAndAssertCommittableWithLineage( + CommittableMessage message) { + assertThat(message).isInstanceOf(CommittableWithLineage.class); + return (CommittableWithLineage) message; + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java index b1bca6099d7a..35dc45d8fd41 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriteResultSerializer.java @@ -29,7 +29,6 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.io.WriteResult; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.junit.jupiter.api.Test; class TestDynamicWriteResultSerializer { @@ -48,13 +47,12 @@ class TestDynamicWriteResultSerializer { ImmutableMap.of(1, ByteBuffer.allocate(1)), ImmutableMap.of(1, ByteBuffer.allocate(1)))) .build(); + private static final TableKey TABLE_KEY = new TableKey("table", "branch"); @Test void testRoundtrip() throws IOException { DynamicWriteResult dynamicWriteResult = - new DynamicWriteResult( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - WriteResult.builder().addDataFiles(DATA_FILE).build()); + new DynamicWriteResult(TABLE_KEY, 1, WriteResult.builder().addDataFiles(DATA_FILE).build()); DynamicWriteResultSerializer serializer = new DynamicWriteResultSerializer(); DynamicWriteResult copy = @@ -68,11 +66,9 @@ void testRoundtrip() throws IOException { } @Test - void testUnsupportedVersion() throws IOException { + void testUnsupportedVersion() { DynamicWriteResult dynamicWriteResult = - new DynamicWriteResult( - new WriteTarget("table", "branch", 42, 23, false, Sets.newHashSet(1, 2)), - WriteResult.builder().addDataFiles(DATA_FILE).build()); + new DynamicWriteResult(TABLE_KEY, 1, WriteResult.builder().addDataFiles(DATA_FILE).build()); DynamicWriteResultSerializer serializer = new DynamicWriteResultSerializer(); assertThatThrownBy(() -> serializer.deserialize(-1, serializer.serialize(dynamicWriteResult))) diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java index 689fd20483c1..f604f639f217 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestDynamicWriter.java @@ -26,6 +26,7 @@ import java.util.Collection; import java.util.Map; import javax.annotation.Nonnull; +import org.apache.flink.configuration.Configuration; import org.apache.flink.metrics.groups.UnregisteredMetricsGroup; import org.apache.flink.table.data.RowData; import org.apache.iceberg.FileFormat; @@ -33,7 +34,8 @@ import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.common.DynFields; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; +import org.apache.iceberg.flink.FlinkWriteOptions; import org.apache.iceberg.flink.SimpleDataUtil; import org.apache.iceberg.flink.sink.TestFlinkIcebergSinkBase; import org.apache.iceberg.io.BaseTaskWriter; @@ -140,7 +142,7 @@ void testDynamicWriterPropertiesDefault() throws Exception { } @Test - void testDynamicWriterPropertiesPriority() throws Exception { + void testFlinkConfigOverridesTableProperties() throws Exception { Catalog catalog = CATALOG_EXTENSION.catalog(); Table table1 = catalog.createTable( @@ -149,11 +151,45 @@ void testDynamicWriterPropertiesPriority() throws Exception { null, ImmutableMap.of("write.parquet.compression-codec", "zstd")); + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.COMPRESSION_CODEC, "snappy"); + DynamicWriter dynamicWriter = - createDynamicWriter(catalog, ImmutableMap.of("write.parquet.compression-codec", "gzip")); + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); DynamicRecordInternal record1 = getDynamicRecordInternal(table1); - assertThat(getNumDataFiles(table1)).isEqualTo(0); + dynamicWriter.write(record1, null); + Map properties = properties(dynamicWriter); + assertThat(properties).containsEntry("write.parquet.compression-codec", "snappy"); + + dynamicWriter.close(); + } + + @Test + void testWritePropertiesOverrideFlinkConfig() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.COMPRESSION_CODEC, "snappy"); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + ImmutableMap.of("compression-codec", "gzip"), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); dynamicWriter.write(record1, null); Map properties = properties(dynamicWriter); @@ -162,6 +198,62 @@ void testDynamicWriterPropertiesPriority() throws Exception { dynamicWriter.close(); } + @Test + void testFlinkConfigFileFormat() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.WRITE_FORMAT, "orc"); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); + + dynamicWriter.write(record1, null); + dynamicWriter.prepareCommit(); + + File dataDir = new File(URI.create(table1.location()).getPath(), "data"); + File[] files = dataDir.listFiles((dir, name) -> name.endsWith(".orc")); + assertThat(files).isNotNull().hasSize(1); + + dynamicWriter.close(); + } + + @Test + void testFlinkConfigTargetFileSize() throws Exception { + Catalog catalog = CATALOG_EXTENSION.catalog(); + Table table1 = catalog.createTable(TABLE1, SimpleDataUtil.SCHEMA); + + Configuration flinkConfig = new Configuration(); + flinkConfig.set(FlinkWriteOptions.TARGET_FILE_SIZE_BYTES, 2048L); + + DynamicWriter dynamicWriter = + new DynamicWriter( + catalog, + Map.of(), + flinkConfig, + 100, + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), + 0, + 0); + DynamicRecordInternal record1 = getDynamicRecordInternal(table1); + + dynamicWriter.write(record1, null); + dynamicWriter.prepareCommit(); + + assertThat(getNumDataFiles(table1)).isEqualTo(1); + + dynamicWriter.close(); + } + @Test void testDynamicWriterUpsert() throws Exception { Catalog catalog = CATALOG_EXTENSION.catalog(); @@ -239,11 +331,10 @@ void testUniqueFileSuffixOnFactoryRecreation() throws Exception { DynamicWriter dynamicWriter = new DynamicWriter( catalog, - FileFormat.PARQUET, - 1024L, properties, + new Configuration(), 100, - new DynamicWriterMetrics(new UnregisteredMetricsGroup()), + new DynamicWriterMetrics(UnregisteredMetricsGroup.createSinkWriterMetricGroup()), 0, 0); return dynamicWriter; @@ -274,13 +365,13 @@ private Map properties(DynamicWriter dynamicWriter) { DynFields.BoundField>> writerField = DynFields.builder().hiddenImpl(dynamicWriter.getClass(), "writers").build(dynamicWriter); - DynFields.BoundField writerFactoryField = + DynFields.BoundField> writerFactoryField = DynFields.builder() .hiddenImpl(BaseTaskWriter.class, "writerFactory") .build(writerField.get().values().iterator().next()); DynFields.BoundField> propsField = DynFields.builder() - .hiddenImpl(BaseFileWriterFactory.class, "writerProperties") + .hiddenImpl(RegistryBasedFileWriterFactory.class, "writerProperties") .build(writerFactoryField.get()); return propsField.get(); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java index d416e7ec1fc6..d2da73c66973 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestEvolveSchemaVisitor.java @@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.apache.iceberg.Schema; import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Type.PrimitiveType; import org.apache.iceberg.types.Types; @@ -48,6 +49,14 @@ public class TestEvolveSchemaVisitor { + private static final TableIdentifier TABLE = TableIdentifier.of("table"); + + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + private static List primitiveTypes() { return Lists.newArrayList( StringType.get(), @@ -89,7 +98,8 @@ private static Types.NestedField[] primitiveFields( public void testAddTopLevelPrimitives() { Schema targetSchema = new Schema(primitiveFields(0, primitiveTypes())); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(targetSchema.asStruct()).isEqualTo(updateApi.apply().asStruct()); } @@ -99,12 +109,62 @@ public void testMakeTopLevelPrimitivesOptional() { assertThat(existingSchema.columns().stream().allMatch(Types.NestedField::isRequired)).isTrue(); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, new Schema()); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, new Schema(), CASE_SENSITIVE, PRESERVE_COLUMNS); Schema newSchema = updateApi.apply(); assertThat(newSchema.asStruct().fields()).hasSize(14); assertThat(newSchema.columns().stream().allMatch(Types.NestedField::isOptional)).isTrue(); } + @Test + public void testDropUnusedColumns() { + Schema existingSchema = + new Schema( + optional(1, "a", StringType.get()), + optional( + 2, + "b", + StructType.of( + optional(4, "nested1", StringType.get()), + optional(5, "nested2", StringType.get()))), + optional(3, "c", IntegerType.get())); + + Schema targetSchema = + new Schema( + optional(1, "a", StringType.get()), + optional(2, "b", StructType.of(optional(5, "nested2", StringType.get())))); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, DROP_COLUMNS); + + Schema newSchema = updateApi.apply(); + assertThat(newSchema.sameSchema(targetSchema)).isTrue(); + } + + @Test + public void testPreserveUnusedColumns() { + Schema existingSchema = + new Schema( + optional(1, "a", StringType.get()), + optional( + 2, + "b", + StructType.of( + optional(4, "nested1", StringType.get()), + optional(5, "nested2", StringType.get()))), + optional(3, "c", IntegerType.get())); + + Schema targetSchema = new Schema(optional(1, "a", StringType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); + + Schema newSchema = updateApi.apply(); + assertThat(newSchema.sameSchema(existingSchema)).isTrue(); + } + @Test public void testIdentifyFieldsByName() { Schema existingSchema = @@ -112,7 +172,8 @@ public void testIdentifyFieldsByName() { UpdateSchema updateApi = loadUpdateApi(existingSchema); Schema newSchema = new Schema(Arrays.asList(Types.NestedField.optional(-1, "myField", Types.LongType.get()))); - EvolveSchemaVisitor.visit(updateApi, existingSchema, newSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, newSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().sameSchema(existingSchema)).isTrue(); } @@ -125,7 +186,8 @@ public void testChangeOrderTopLevelPrimitives() { new Schema( Arrays.asList(optional(2, "b", StringType.get()), optional(1, "a", StringType.get()))); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -134,7 +196,8 @@ public void testAddTopLevelListOfPrimitives() { for (PrimitiveType primitiveType : primitiveTypes()) { Schema targetSchema = new Schema(optional(1, "aList", ListType.ofOptional(2, primitiveType))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -146,7 +209,8 @@ public void testMakeTopLevelListOfPrimitivesOptional() { new Schema(optional(1, "aList", ListType.ofRequired(2, primitiveType))); Schema targetSchema = new Schema(); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema expectedSchema = new Schema(optional(1, "aList", ListType.ofRequired(2, primitiveType))); assertThat(updateApi.apply().asStruct()).isEqualTo(expectedSchema.asStruct()); @@ -159,7 +223,8 @@ public void testAddTopLevelMapOfPrimitives() { Schema targetSchema = new Schema(optional(1, "aMap", MapType.ofOptional(2, 3, primitiveType, primitiveType))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -171,7 +236,8 @@ public void testAddTopLevelStructOfPrimitives() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), currentSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), currentSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(currentSchema.asStruct()); } } @@ -184,7 +250,8 @@ public void testAddNestedPrimitive() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -199,7 +266,8 @@ public void testMakeNestedPrimitiveOptional() { new Schema( optional(1, "aStruct", StructType.of(optional(2, "primitive", primitiveType)))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } } @@ -210,7 +278,8 @@ public void testAddNestedPrimitives() { Schema targetSchema = new Schema(optional(1, "aStruct", StructType.of(primitiveFields(1, primitiveTypes())))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -240,7 +309,8 @@ public void testAddNestedLists() { ListType.ofOptional( 10, DecimalType.of(11, 20)))))))))))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -279,7 +349,8 @@ public void testAddNestedStruct() { "aString", StringType.get())))))))))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -314,7 +385,8 @@ public void testAddNestedMaps() { 12, 13, StringType.get(), StringType.get())))))))); UpdateSchema updateApi = loadUpdateApi(new Schema()); - EvolveSchemaVisitor.visit(updateApi, new Schema(), targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, new Schema(), targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -326,7 +398,12 @@ public void testDetectInvalidTopLevelList() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aList.element: string -> long") .isInstanceOf(IllegalArgumentException.class); } @@ -343,7 +420,12 @@ public void testDetectInvalidTopLevelMapValue() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aMap.value: string -> long") .isInstanceOf(IllegalArgumentException.class); } @@ -358,7 +440,12 @@ public void testDetectInvalidTopLevelMapKey() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aMap.key: string -> uuid") .isInstanceOf(IllegalArgumentException.class); } @@ -370,7 +457,8 @@ public void testTypePromoteIntegerToLong() { Schema targetSchema = new Schema(required(1, "aCol", LongType.get())); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema applied = updateApi.apply(); assertThat(applied.asStruct().fields()).hasSize(1); assertThat(applied.asStruct().fields().get(0).type()).isEqualTo(LongType.get()); @@ -383,7 +471,8 @@ public void testTypePromoteFloatToDouble() { Schema targetSchema = new Schema(required(1, "aCol", DoubleType.get())); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema applied = updateApi.apply(); assertThat(applied.asStruct().fields()).hasSize(1); assertThat(applied.asStruct().fields().get(0).type()).isEqualTo(DoubleType.get()); @@ -396,7 +485,12 @@ public void testInvalidTypePromoteDoubleToFloat() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aCol: double -> float") .isInstanceOf(IllegalArgumentException.class); } @@ -409,7 +503,8 @@ public void testTypePromoteDecimalToFixedScaleWithWiderPrecision() { Schema targetSchema = new Schema(required(1, "aCol", DecimalType.of(22, 1))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -452,7 +547,8 @@ public void testAddPrimitiveToNestedStruct() { optional(6, "time", TimeType.get()))))))))); UpdateSchema updateApi = loadUpdateApi(existingSchema); - EvolveSchemaVisitor.visit(updateApi, existingSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -464,7 +560,12 @@ public void testReplaceListWithPrimitive() { assertThatThrownBy( () -> EvolveSchemaVisitor.visit( - loadUpdateApi(currentSchema), currentSchema, targetSchema)) + TABLE, + loadUpdateApi(currentSchema), + currentSchema, + targetSchema, + true, + PRESERVE_COLUMNS)) .hasMessage("Cannot change column type: aColumn: list -> string") .isInstanceOf(IllegalArgumentException.class); } @@ -501,7 +602,8 @@ public void addNewTopLevelStruct() { optional(7, "d1", StructType.of(optional(8, "d2", StringType.get())))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -553,7 +655,8 @@ public void testAppendNestedStruct() { StringType.get())))))))))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(updateApi.apply().asStruct()).isEqualTo(targetSchema.asStruct()); } @@ -573,7 +676,8 @@ public void testMakeNestedStructOptional() { optional( 3, "s3", StructType.of(optional(4, "s4", StringType.get())))))))); UpdateSchema updateApi = loadUpdateApi(currentSchema); - EvolveSchemaVisitor.visit(updateApi, currentSchema, targetSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, currentSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(getNestedSchemaWithOptionalModifier(true).asStruct()) .isEqualTo(updateApi.apply().asStruct()); } @@ -610,6 +714,82 @@ private static Schema getNestedSchemaWithOptionalModifier(boolean nestedIsOption 9, "s4", StringType.get())))))))))))))); } + @Test + public void testCaseInsensitiveAddField() { + Schema existingSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + Schema targetSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "NAME", Types.StringType.get()), + Types.NestedField.optional(3, "AGE", Types.IntegerType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.columns()).hasSize(3); + assertThat(result.findField("AGE")).isNotNull(); + } + + @Test + public void testCaseInsensitiveMakeFieldOptional() { + Schema existingSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "name", Types.StringType.get())); + Schema targetSchema = new Schema(Types.NestedField.optional(1, "ID", Types.IntegerType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.findField("name").isOptional()).isTrue(); + } + + @Test + public void testCaseInsensitiveNestedStructField() { + Schema existingSchema = + new Schema( + optional(1, "struct1", StructType.of(optional(2, "field1", Types.StringType.get())))); + Schema targetSchema = + new Schema( + optional( + 1, + "STRUCT1", + StructType.of( + optional(2, "FIELD1", Types.StringType.get()), + optional(3, "FIELD2", Types.IntegerType.get())))); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_INSENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + Types.StructType struct = result.findField("struct1").type().asStructType(); + assertThat(struct.fields()).hasSize(2); + assertThat(struct.field("FIELD2")).isNotNull(); + } + + @Test + public void testCaseSensitiveDoesNotMatch() { + Schema existingSchema = + new Schema(Types.NestedField.optional(1, "id", Types.IntegerType.get())); + Schema targetSchema = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "name", Types.StringType.get())); + + UpdateSchema updateApi = loadUpdateApi(existingSchema); + EvolveSchemaVisitor.visit( + TABLE, updateApi, existingSchema, targetSchema, CASE_SENSITIVE, PRESERVE_COLUMNS); + Schema result = updateApi.apply(); + assertThat(result.columns()).hasSize(3); + assertThat(result.findField("ID")).isNotNull(); + assertThat(result.findField("id")).isNotNull(); + } + private static UpdateSchema loadUpdateApi(Schema schema) { try { Constructor constructor = diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java index 04246bf03996..9a485fafaf47 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestHashKeyGenerator.java @@ -25,6 +25,7 @@ import java.util.Map; import java.util.Set; import org.apache.flink.api.java.functions.KeySelector; +import org.apache.flink.configuration.Configuration; import org.apache.flink.runtime.state.KeyGroupRangeAssignment; import org.apache.flink.table.data.GenericRowData; import org.apache.flink.table.data.RowData; @@ -32,7 +33,11 @@ import org.apache.iceberg.DistributionMode; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.flink.FlinkWriteConf; +import org.apache.iceberg.flink.FlinkWriteOptions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; @@ -44,7 +49,7 @@ class TestHashKeyGenerator { Types.NestedField.required(1, "id", Types.IntegerType.get()), Types.NestedField.required(2, "data", Types.StringType.get())); - private static final String BRANCH = "main"; + private static final String BRANCH = SnapshotRef.MAIN_BRANCH; private static final TableIdentifier TABLE_IDENTIFIER = TableIdentifier.of("default", "table"); @Test @@ -158,6 +163,48 @@ void testEqualityKeys() throws Exception { assertThat(getSubTaskId(writeKey3, writeParallelism, maxWriteParallelism)).isEqualTo(0); } + @Test + void testHashModeWithPartitionFieldAndEqualityField() throws Exception { + int writeParallelism = 2; + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("id", 4).build(); + + GenericRowData row1 = GenericRowData.of(1, StringData.fromString("foo")); + GenericRowData row2 = GenericRowData.of(1, StringData.fromString("bar")); + Set equalityColumns = Sets.newHashSet("id", "data"); + + int writeKey1 = + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row1); + int writeKey2 = + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row2); + + assertThat(writeKey1).isEqualTo(writeKey2); + } + + @Test + void testHashModeWithPartitionFieldNotInEqualityFieldsFails() { + int writeParallelism = 2; + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("id", 4).build(); + Set equalityColumns = Collections.singleton("data"); + + GenericRowData row = GenericRowData.of(1, StringData.fromString("foo")); + + assertThatThrownBy( + () -> + getWriteKey( + generator, spec, DistributionMode.HASH, writeParallelism, equalityColumns, row)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("partition field") + .hasMessageContaining(spec.fields().get(0).toString()) + .hasMessageContaining("should be included in equality fields"); + } + @Test void testFailOnNonPositiveWriteParallelism() { final int maxWriteParallelism = 5; @@ -186,6 +233,38 @@ void testFailOnNonPositiveWriteParallelism() { }); } + @Test + void testNonPositiveWriteParallelismConfigFallback() throws Exception { + int maxWriteParallelism = 5; + HashKeyGenerator generator = new HashKeyGenerator(16, maxWriteParallelism); + PartitionSpec unpartitioned = PartitionSpec.unpartitioned(); + FlinkWriteConf flinkWriteConf = + new FlinkWriteConf( + ImmutableMap.of(FlinkWriteOptions.WRITE_PARALLELISM.key(), "2"), new Configuration()); + + Set writeKeys = Sets.newHashSet(); + for (int i = 0; i < 20; i++) { + GenericRowData row = GenericRowData.of(i, StringData.fromString("z")); + writeKeys.add( + getWriteKey( + generator, + unpartitioned, + DistributionMode.NONE, + i % 2 == 0 ? 0 : -1, + Collections.emptySet(), + row, + flinkWriteConf)); + } + + assertThat(writeKeys).hasSize(2); + assertThat( + writeKeys.stream() + .map(key -> getSubTaskId(key, 2, maxWriteParallelism)) + .distinct() + .count()) + .isEqualTo(2); + } + @Test void testCapAtMaxWriteParallelism() throws Exception { int writeParallelism = 10; @@ -358,6 +437,74 @@ void testCaching() throws Exception { assertThat(writeKey1).isEqualTo(writeKey3); } + @Test + void testCacheMissOnWriteParallelismChange() throws Exception { + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(10, maxWriteParallelism); + Map> keySelectorCache = + generator.getKeySelectorCache(); + + PartitionSpec unpartitioned = PartitionSpec.unpartitioned(); + DynamicRecord record1 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + unpartitioned, + DistributionMode.NONE, + 2); + DynamicRecord record2 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + unpartitioned, + DistributionMode.NONE, + 4); + + generator.generateKey(record1); + assertThat(keySelectorCache).hasSize(1); + + generator.generateKey(record2); + assertThat(keySelectorCache).hasSize(2); + } + + @Test + void testCacheMissOnDistributionModeChange() throws Exception { + int maxWriteParallelism = 8; + HashKeyGenerator generator = new HashKeyGenerator(10, maxWriteParallelism); + Map> keySelectorCache = + generator.getKeySelectorCache(); + + PartitionSpec partitioned = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + DynamicRecord record1 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + partitioned, + DistributionMode.NONE, + 2); + DynamicRecord record2 = + new DynamicRecord( + TABLE_IDENTIFIER, + BRANCH, + SCHEMA, + GenericRowData.of(1, StringData.fromString("foo")), + partitioned, + DistributionMode.HASH, + 2); + + generator.generateKey(record1); + assertThat(keySelectorCache).hasSize(1); + + generator.generateKey(record2); + assertThat(keySelectorCache).hasSize(2); + } + private static int getWriteKey( HashKeyGenerator generator, PartitionSpec spec, @@ -366,10 +513,31 @@ private static int getWriteKey( Set equalityFields, GenericRowData row) throws Exception { - DynamicRecord record = + return getWriteKey( + generator, + spec, + mode, + writeParallelism, + equalityFields, + row, + new FlinkWriteConf(Collections.emptyMap(), new Configuration())); + } + + private static int getWriteKey( + HashKeyGenerator generator, + PartitionSpec spec, + DistributionMode mode, + int writeParallelism, + Set equalityFields, + GenericRowData row, + FlinkWriteConf flinkWriteConf) + throws Exception { + DynamicRecord inputRecord = new DynamicRecord(TABLE_IDENTIFIER, BRANCH, SCHEMA, row, spec, mode, writeParallelism); - record.setEqualityFields(equalityFields); - return generator.generateKey(record); + inputRecord.setEqualityFields(equalityFields); + + DynamicRecordWithConfig dynamicRecordWithConfig = new DynamicRecordWithConfig(flinkWriteConf); + return generator.generateKey(dynamicRecordWithConfig.wrap(inputRecord)); } private static int getSubTaskId(int writeKey1, int writeParallelism, int maxWriteParallelism) { diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java index bf5b9f562f9a..8a17c707f84a 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableMetadataCache.java @@ -26,6 +26,7 @@ import org.apache.commons.lang3.SerializationUtils; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; @@ -35,6 +36,12 @@ public class TestTableMetadataCache extends TestFlinkIcebergSinkBase { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + static final Schema SCHEMA = new Schema( Types.NestedField.optional(1, "id", Types.IntegerType.get()), @@ -46,12 +53,23 @@ public class TestTableMetadataCache extends TestFlinkIcebergSinkBase { Types.NestedField.optional(2, "data", Types.StringType.get()), Types.NestedField.optional(3, "extra", Types.StringType.get())); + static final Schema SCHEMA_UPPERCASE = + new Schema( + Types.NestedField.optional(1, "ID", Types.IntegerType.get()), + Types.NestedField.optional(2, "DATA", Types.StringType.get())); + + static final Schema SCHEMA_MIXEDCASE = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get())); + @Test void testCaching() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); assertThat(schema1.sameSchema(SCHEMA)).isTrue(); @@ -72,8 +90,9 @@ void testCacheInvalidationAfterSchemaChange() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); assertThat(schema1.sameSchema(SCHEMA)).isTrue(); @@ -81,7 +100,11 @@ void testCacheInvalidationAfterSchemaChange() { catalog.dropTable(tableIdentifier); catalog.createTable(tableIdentifier, SCHEMA2); tableUpdater.update( - tableIdentifier, "main", SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA2, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); Schema schema2 = cache.schema(tableIdentifier, SCHEMA2).resolvedTableSchema(); assertThat(schema2.sameSchema(SCHEMA2)).isTrue(); @@ -92,7 +115,8 @@ void testCachingDisabled() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 0, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 0, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); assertThat(cache.getInternalCache()).isEmpty(); } @@ -107,7 +131,13 @@ void testNoCacheRefreshingBeforeRefreshIntervalElapses() { // Init cache TableMetadataCache cache = new TableMetadataCache( - catalog, 10, 100L, 10, Clock.fixed(Instant.now(), ZoneId.systemDefault())); + catalog, + 10, + 100L, + 10, + CASE_INSENSITIVE, + PRESERVE_COLUMNS, + Clock.fixed(Instant.now(), ZoneId.systemDefault())); cache.update(tableIdentifier, table); // Cache schema @@ -125,4 +155,66 @@ void testNoCacheRefreshingBeforeRefreshIntervalElapses() { assertThat(cacheItem).isNotNull(); assertThat(cacheItem.inputSchemas()).containsKeys(SCHEMA, SCHEMA2); } + + @Test + void testNoSuchNamespaceExceptionHandling() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.of("nonexistent_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + TableMetadataCache.ResolvedSchemaInfo result = cache.schema(tableIdentifier, SCHEMA); + + assertThat(result).isEqualTo(TableMetadataCache.NOT_FOUND); + assertThat(cache.getInternalCache().get(tableIdentifier)).isNotNull(); + } + + @Test + void testNoSuchTableExceptionHandling() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.nonexistent_table"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + TableMetadataCache.ResolvedSchemaInfo result = cache.schema(tableIdentifier, SCHEMA); + + assertThat(result).isEqualTo(TableMetadataCache.NOT_FOUND); + assertThat(cache.getInternalCache().get(tableIdentifier)).isNotNull(); + } + + @Test + void testCaseInsensitiveCaching() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); + catalog.createTable(tableIdentifier, SCHEMA); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_INSENSITIVE, PRESERVE_COLUMNS); + + Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); + assertThat(schema1.sameSchema(SCHEMA)).isTrue(); + + Schema schemaUpperCase = cache.schema(tableIdentifier, SCHEMA_UPPERCASE).resolvedTableSchema(); + assertThat(schemaUpperCase).isEqualTo(schema1); + + Schema schemaMixedCase = cache.schema(tableIdentifier, SCHEMA_MIXEDCASE).resolvedTableSchema(); + assertThat(schemaMixedCase).isEqualTo(schema1); + } + + @Test + void testCaseSensitiveCachingDoesNotMatch() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); + catalog.createTable(tableIdentifier, SCHEMA); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + + Schema schema1 = cache.schema(tableIdentifier, SCHEMA).resolvedTableSchema(); + assertThat(schema1.sameSchema(SCHEMA)).isTrue(); + + assertThat(cache.schema(tableIdentifier, SCHEMA_UPPERCASE)) + .isEqualTo(TableMetadataCache.NOT_FOUND); + + assertThat(cache.schema(tableIdentifier, SCHEMA_MIXEDCASE)) + .isEqualTo(TableMetadataCache.NOT_FOUND); + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java index 1d4461698746..bdc825b44f2a 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/dynamic/TestTableUpdater.java @@ -25,18 +25,28 @@ import org.apache.flink.api.java.tuple.Tuple2; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.flink.sink.TestFlinkIcebergSinkBase; import org.apache.iceberg.inmemory.InMemoryCatalog; import org.apache.iceberg.types.Types; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class TestTableUpdater extends TestFlinkIcebergSinkBase { + private static final boolean CASE_SENSITIVE = true; + private static final boolean CASE_INSENSITIVE = false; + + private static final boolean DROP_COLUMNS = true; + private static final boolean PRESERVE_COLUMNS = false; + static final Schema SCHEMA = new Schema( Types.NestedField.optional(1, "id", Types.IntegerType.get()), @@ -55,8 +65,9 @@ void testTableCreation(@TempDir Path tempDir) { catalog.initialize("catalog", Map.of()); catalog.createNamespace(Namespace.of("myNamespace")); TableIdentifier tableIdentifier = TableIdentifier.parse("myNamespace.myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); String locationOverride = tempDir.toString() + "/custom-path"; Map tableProperties = Map.of("key", "value"); @@ -65,7 +76,11 @@ void testTableCreation(@TempDir Path tempDir) { catalog1.createTable(identifier, schema, spec, locationOverride, tableProperties); tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), tableCreator); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + tableCreator); assertThat(catalog.tableExists(tableIdentifier)).isTrue(); assertThat(catalog.loadTable(tableIdentifier).properties().get("key")).isEqualTo("value"); assertThat(catalog.loadTable(tableIdentifier).location()).isEqualTo(locationOverride); @@ -77,8 +92,9 @@ void testTableCreation(@TempDir Path tempDir) { void testTableAlreadyExists() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); // Make the table non-existent in cache cache.exists(tableIdentifier); @@ -87,7 +103,11 @@ void testTableAlreadyExists() { // Make sure that the cache is invalidated and the table refreshed without an error Tuple2 result = tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); assertThat(result.f1).isEqualTo(PartitionSpec.unpartitioned()); @@ -97,8 +117,9 @@ void testTableAlreadyExists() { void testBranchCreationAndCaching() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); catalog.createTable(tableIdentifier, SCHEMA); tableUpdater.update( @@ -115,11 +136,13 @@ void testBranchCreationAndCaching() { void testSpecCreation() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).bucket("data", 10).build(); - tableUpdater.update(tableIdentifier, "main", SCHEMA, spec, TableCreator.DEFAULT); + tableUpdater.update( + tableIdentifier, SnapshotRef.MAIN_BRANCH, SCHEMA, spec, TableCreator.DEFAULT); Table table = catalog.loadTable(tableIdentifier); assertThat(table).isNotNull(); @@ -131,15 +154,16 @@ void testInvalidateOldCacheEntryOnUpdate() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); cache.schema(tableIdentifier, SCHEMA); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); Schema updated = tableUpdater .update( tableIdentifier, - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT) @@ -155,12 +179,17 @@ void testLastResultInvalidation() { Catalog catalog = CATALOG_EXTENSION.catalog(); TableIdentifier tableIdentifier = TableIdentifier.parse("default.myTable"); catalog.createTable(tableIdentifier, SCHEMA); - TableMetadataCache cache = new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10); - TableUpdater tableUpdater = new TableUpdater(cache, catalog); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); // Initialize cache tableUpdater.update( - tableIdentifier, "main", SCHEMA, PartitionSpec.unpartitioned(), TableCreator.DEFAULT); + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); // Update table behind the scenes catalog.dropTable(tableIdentifier); @@ -174,7 +203,7 @@ void testLastResultInvalidation() { tableUpdater .update( tableIdentifier, - "main", + SnapshotRef.MAIN_BRANCH, SCHEMA2, PartitionSpec.unpartitioned(), TableCreator.DEFAULT) @@ -186,4 +215,132 @@ void testLastResultInvalidation() { assertThat(cache.getInternalCache().get(tableIdentifier).inputSchemas()) .doesNotContainKey(SCHEMA2); } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testCaseSensitivity(boolean caseSensitive) { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, caseSensitive, DROP_COLUMNS); + + TableUpdater tableUpdater = new TableUpdater(cache, catalog, caseSensitive, DROP_COLUMNS); + + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "extra", Types.StringType.get())); + + catalog.createTable(tableIdentifier, schema); + + Schema schemaWithUpperCase = + new Schema( + Types.NestedField.optional(1, "Id", Types.IntegerType.get()), + Types.NestedField.optional(2, "Data", Types.StringType.get()), + Types.NestedField.optional(3, "Extra", Types.StringType.get())); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + schemaWithUpperCase, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + + Schema tableSchema = catalog.loadTable(tableIdentifier).schema(); + if (caseSensitive) { + assertThat(tableSchema.columns()).hasSize(3); + assertThat(tableSchema.findField("Id")).isNotNull(); + assertThat(tableSchema.findField("Data")).isNotNull(); + assertThat(tableSchema.findField("Extra")).isNotNull(); + } else { + assertThat(tableSchema.sameSchema(schema)).isTrue(); + } + } + + @Test + void testDropUnusedColumns() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + TableIdentifier tableIdentifier = TableIdentifier.parse("myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, DROP_COLUMNS); + + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, DROP_COLUMNS); + + catalog.createTable(tableIdentifier, SCHEMA2); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + Schema tableSchema = catalog.loadTable(tableIdentifier).schema(); + assertThat(tableSchema.columns()).hasSize(2); + assertThat(tableSchema.findField("id")).isNotNull(); + assertThat(tableSchema.findField("data")).isNotNull(); + assertThat(tableSchema.findField("extra")).isNull(); + } + + @Test + void testNamespaceAndTableCreation() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + SupportsNamespaces namespaceCatalog = (SupportsNamespaces) catalog; + TableIdentifier tableIdentifier = TableIdentifier.of("new_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); + + assertThat(namespaceCatalog.namespaceExists(Namespace.of("new_namespace"))).isFalse(); + assertThat(catalog.tableExists(tableIdentifier)).isFalse(); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(namespaceCatalog.namespaceExists(Namespace.of("new_namespace"))).isTrue(); + + assertThat(catalog.tableExists(tableIdentifier)).isTrue(); + assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + } + + @Test + void testTableCreationWithExistingNamespace() { + Catalog catalog = CATALOG_EXTENSION.catalog(); + SupportsNamespaces namespaceCatalog = (SupportsNamespaces) catalog; + Namespace namespace = Namespace.of("existing_namespace"); + namespaceCatalog.createNamespace(namespace); + + TableIdentifier tableIdentifier = TableIdentifier.of("existing_namespace", "myTable"); + TableMetadataCache cache = + new TableMetadataCache(catalog, 10, Long.MAX_VALUE, 10, CASE_SENSITIVE, PRESERVE_COLUMNS); + TableUpdater tableUpdater = new TableUpdater(cache, catalog, CASE_SENSITIVE, PRESERVE_COLUMNS); + + assertThat(namespaceCatalog.namespaceExists(namespace)).isTrue(); + assertThat(catalog.tableExists(tableIdentifier)).isFalse(); + + Tuple2 result = + tableUpdater.update( + tableIdentifier, + SnapshotRef.MAIN_BRANCH, + SCHEMA, + PartitionSpec.unpartitioned(), + TableCreator.DEFAULT); + + assertThat(namespaceCatalog.namespaceExists(namespace)).isTrue(); + assertThat(catalog.tableExists(tableIdentifier)).isTrue(); + assertThat(result.f0.resolvedTableSchema().sameSchema(SCHEMA)).isTrue(); + assertThat(result.f0.compareResult()).isEqualTo(CompareSchemasVisitor.Result.SAME); + } } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java index ac2e2784e681..44791094de75 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/sink/shuffle/TestSortKeySerializerPrimitives.java @@ -54,6 +54,9 @@ protected SortOrder sortOrder() { .sortBy(Expressions.bucket("uuid_field", 16), SortDirection.ASC, NullOrder.NULLS_FIRST) .sortBy(Expressions.hour("ts_with_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) .sortBy(Expressions.day("ts_without_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) + .asc("ts_ns_with_zone_field") + .sortBy( + Expressions.hour("ts_ns_without_zone_field"), SortDirection.ASC, NullOrder.NULLS_FIRST) // can not test HeapByteBuffer due to equality test inside SerializerTestBase // .sortBy(Expressions.truncate("binary_field", 2), SortDirection.ASC, // NullOrder.NULLS_FIRST) diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java index 8352924d042a..6ae7bfb53a2e 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestFlinkMetaDataTable.java @@ -50,6 +50,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.avro.Avro; @@ -625,8 +626,9 @@ public void testMetadataLogEntries() { assertThat(metadataLog.getField("latest_snapshot_id")).isEqualTo(parentSnapshot.snapshotId()); metadataLog = metadataLogs.get(2); - assertThat(metadataLog.getField("timestamp")) - .isEqualTo(Instant.ofEpochMilli(currentSnapshot.timestampMillis())); + assertThat((Instant) metadataLog.getFieldAs("timestamp")) + .isAfterOrEqualTo(Instant.ofEpochMilli(currentSnapshot.timestampMillis())) + .isEqualTo(Instant.ofEpochMilli(tableMetadata.lastUpdatedMillis())); assertThat(metadataLog.getField("file")).isEqualTo(tableMetadata.metadataFileLocation()); assertThat(metadataLog.getField("latest_snapshot_id")).isEqualTo(currentSnapshot.snapshotId()); assertThat(metadataLog.getField("latest_schema_id")).isEqualTo(currentSnapshot.schemaId()); @@ -640,7 +642,7 @@ public void testMetadataLogEntries() { TABLE_NAME, currentSnapshotId); assertThat(metadataLogWithFilters).hasSize(1); metadataLog = metadataLogWithFilters.get(0); - assertThat(Instant.ofEpochMilli(tableMetadata.currentSnapshot().timestampMillis())) + assertThat(Instant.ofEpochMilli(tableMetadata.lastUpdatedMillis())) .isEqualTo(metadataLog.getField("timestamp")); assertThat(metadataLog.getField("file")).isEqualTo(tableMetadata.metadataFileLocation()); @@ -695,7 +697,7 @@ public void testSnapshotReferencesMetatable() { // Check branch entries in refs table List mainBranch = sql("SELECT * FROM %s$refs WHERE name='main' AND type='BRANCH'", TABLE_NAME); - assertThat((String) mainBranch.get(0).getFieldAs("name")).isEqualTo("main"); + assertThat((String) mainBranch.get(0).getFieldAs("name")).isEqualTo(SnapshotRef.MAIN_BRANCH); assertThat((String) mainBranch.get(0).getFieldAs("type")).isEqualTo("BRANCH"); assertThat((Long) mainBranch.get(0).getFieldAs("snapshot_id")).isEqualTo(currentSnapshotId); List testBranch = @@ -732,7 +734,8 @@ public void testSnapshotReferencesMetatable() { assertThat((String) testTagProjection.get(0).getFieldAs("min_snapshots_to_keep")).isNull(); List mainBranchProjection = sql("SELECT name, type FROM %s$refs WHERE name='main' AND type = 'BRANCH'", TABLE_NAME); - assertThat((String) mainBranchProjection.get(0).getFieldAs("name")).isEqualTo("main"); + assertThat((String) mainBranchProjection.get(0).getFieldAs("name")) + .isEqualTo(SnapshotRef.MAIN_BRANCH); assertThat((String) mainBranchProjection.get(0).getFieldAs("type")).isEqualTo("BRANCH"); List testBranchProjection = sql( diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java index f84cf7fb1aae..ec9333674d03 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestIcebergSourceWithWatermarkExtractor.java @@ -225,6 +225,68 @@ public void apply( 3))); } + /** + * Integration test verifying that records with eventTime equal to the minimum timestamp of their + * split are correctly included in windows. The {@link + * org.apache.iceberg.flink.source.reader.WatermarkExtractorRecordEmitter} emits the watermark as + * {@code minSplitTs - 1}, so records at exactly {@code minSplitTs} are on-time rather than late. + * + *

    The test writes 3 records at epoch (t=0). The split's column-stats lower-bound is 0, so the + * extracted watermark is 0ms and the emitted watermark is -1ms. Records at t=0 are strictly after + * that watermark and therefore belong to the [0, 5min) window. A later split is then appended to + * advance the watermark past the window boundary and trigger its evaluation. + */ + @Test + public void testWindowingWithRecordsAtSplitMinTimestamp() throws Exception { + GenericAppenderHelper dataAppender = appender(); + + // File 1: 3 records at exactly t=0 (epoch). Extracted watermark = 0ms, emitted = -1ms. + List batch = + ImmutableList.of( + generateRecord(0, "file_1-recordTs_0_a"), + generateRecord(0, "file_1-recordTs_0_b"), + generateRecord(0, "file_1-recordTs_0_c")); + dataAppender.appendToTable(batch); + + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + DataStream stream = + env.fromSource( + source(), + WatermarkStrategy.noWatermarks() + .withTimestampAssigner(new RowDataTimestampAssigner()), + SOURCE_NAME, + TypeInformation.of(RowData.class)); + + stream + .windowAll(TumblingEventTimeWindows.of(Duration.ofMinutes(5))) + .apply( + new AllWindowFunction() { + @Override + public void apply( + TimeWindow window, Iterable values, Collector out) { + AtomicInteger count = new AtomicInteger(0); + values.forEach(a -> count.incrementAndGet()); + out.collect(row(window.getStart(), count.get())); + WINDOWS.put(window.getStart(), count.get()); + } + }); + + WINDOWS.clear(); + env.executeAsync("Iceberg Source Min Timestamp Windowing Test"); + + // Append a file with much later timestamps to advance the watermark past [0, 5min) + dataAppender.appendToTable( + dataAppender.writeFile(ImmutableList.of(generateRecord(1500, "last-record")))); + + // The [0, 5min) window should fire with all 3 records written at epoch + Awaitility.await() + .pollInterval(Duration.ofMillis(10)) + .atMost(30, TimeUnit.SECONDS) + .until(() -> Integer.valueOf(3).equals(WINDOWS.get(0L))); + } + /** * This is an integration test for watermark handling and throttling. Integration testing the * following: diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java index 488969bab045..af3a79ff67b8 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestMetadataTableReadableMetrics.java @@ -38,6 +38,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; @@ -222,7 +223,8 @@ public void testPrimitiveColumns() throws Exception { // size of the column to increase. For example, with Parquet 1.14.x the // uncompressed size has been added to allow for better allocation of memory upfront. // Therefore, we look the sizes up, rather than hardcoding them - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); Map columnSizeStats = dataFile.columnSizes(); Row binaryCol = diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java index 5dd7de545e11..09639a8a9568 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/TestScanContext.java @@ -31,7 +31,7 @@ void testIncrementalFromSnapshotId() { .startingStrategy(StreamingStartingStrategy.INCREMENTAL_FROM_SNAPSHOT_ID) .build(); assertException( - context, "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: null"); + context, "Invalid starting snapshot id for INCREMENTAL_FROM_SNAPSHOT_ID strategy: null"); context = ScanContext.builder() @@ -42,7 +42,7 @@ void testIncrementalFromSnapshotId() { .build(); assertException( context, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + "Invalid starting snapshot timestamp for INCREMENTAL_FROM_SNAPSHOT_ID strategy: not null"); } @Test @@ -54,7 +54,7 @@ void testIncrementalFromSnapshotTimestamp() { .build(); assertException( context, - "Invalid starting snapshot timestamp for SPECIFIC_START_SNAPSHOT_TIMESTAMP strategy: null"); + "Invalid starting snapshot timestamp for INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP strategy: null"); context = ScanContext.builder() @@ -64,7 +64,8 @@ void testIncrementalFromSnapshotTimestamp() { .startSnapshotTimestamp(1L) .build(); assertException( - context, "Invalid starting snapshot id for SPECIFIC_START_SNAPSHOT_ID strategy: not null"); + context, + "Invalid starting snapshot id for INCREMENTAL_FROM_SNAPSHOT_TIMESTAMP strategy: not null"); } @Test diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java index 332244732928..3b094ba02298 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/ReaderUtil.java @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.List; import org.apache.flink.table.data.RowData; +import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.BaseCombinedScanTask; import org.apache.iceberg.BaseFileScanTask; import org.apache.iceberg.CombinedScanTask; @@ -78,11 +79,16 @@ public static FileScanTask createFileTask( } public static DataIterator createDataIterator(CombinedScanTask combinedTask) { + return createDataIterator(combinedTask, TestFixtures.SCHEMA, TestFixtures.SCHEMA); + } + + public static DataIterator createDataIterator( + CombinedScanTask combinedTask, Schema tableSchema, Schema projectSchema) { return new DataIterator<>( new RowDataFileScanTaskReader( - TestFixtures.SCHEMA, TestFixtures.SCHEMA, null, true, Collections.emptyList()), + tableSchema, projectSchema, null, true, Collections.emptyList()), combinedTask, - new HadoopFileIO(new org.apache.hadoop.conf.Configuration()), + new HadoopFileIO(new Configuration()), PlaintextEncryptionManager.instance()); } diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java index 761e5622277d..191dfe088cdf 100644 --- a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestColumnStatsWatermarkExtractor.java @@ -61,6 +61,13 @@ public class TestColumnStatsWatermarkExtractor { required(3, "long_column", Types.LongType.get()), required(4, "string_column", Types.StringType.get())); + // Separate schema for nanosecond columns: TIMESTAMP_NANO requires table format v3, which the + // HadoopTableExtension above does not provision. Tested via constructor preconditions only. + private static final Schema NANO_SCHEMA = + new Schema( + required(1, "timestamp_ns_column", Types.TimestampNanoType.withoutZone()), + required(2, "timestamptz_ns_column", Types.TimestampNanoType.withZone())); + private static final List> TEST_RECORDS = ImmutableList.of( RandomGenericData.generate(SCHEMA, 3, 2L), RandomGenericData.generate(SCHEMA, 3, 19L)); @@ -147,7 +154,17 @@ public void testWrongColumn() { assertThatThrownBy(() -> new ColumnStatsWatermarkExtractor(SCHEMA, columnName, null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining( - "Found STRING, expected a LONG or TIMESTAMP column for watermark generation."); + "Found STRING, expected a LONG, TIMESTAMP, or TIMESTAMP_NANO column for watermark generation."); + } + + @TestTemplate + public void testTimestampNanoAccepted() { + // Run the precondition check exactly once across the parameterized matrix. + assumeThat(columnName).isEqualTo("timestamp_column"); + + // Both flavours of TIMESTAMP_NANO must be accepted by the extractor's precondition check. + new ColumnStatsWatermarkExtractor(NANO_SCHEMA, "timestamp_ns_column", null); + new ColumnStatsWatermarkExtractor(NANO_SCHEMA, "timestamptz_ns_column", null); } @TestTemplate diff --git a/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java new file mode 100644 index 000000000000..1cb7be03c6a7 --- /dev/null +++ b/flink/v2.1/flink/src/test/java/org/apache/iceberg/flink/source/reader/TestWatermarkExtractorRecordEmitter.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.flink.source.reader; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.flink.api.common.eventtime.Watermark; +import org.apache.flink.api.connector.source.SourceOutput; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.flink.TestFixtures; +import org.apache.iceberg.flink.source.split.IcebergSourceSplit; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestWatermarkExtractorRecordEmitter { + @TempDir protected Path temporaryFolder; + + @Test + public void testWatermarkIsDecrementedByOne() throws IOException { + long extractedWatermark = 1000L; + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> extractedWatermark); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + emitter.emitRecord(new RecordAndPosition<>("record", 0, 0L), output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(extractedWatermark - 1); + } + + @Test + public void testWatermarkEmittedOnlyOncePerSplit() throws IOException { + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> 1000L); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split); + emitter.emitRecord(element, output, split); + emitter.emitRecord(element, output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.records).hasSize(3); + } + + @Test + public void testWatermarkNotEmittedWhenNewSplitHasLowerValue() throws IOException { + IcebergSourceSplit split1 = createSplit(1L); + IcebergSourceSplit split2 = createSplit(2L); + + Map watermarkMap = Maps.newHashMap(); + watermarkMap.put(split1.splitId(), 2000L); + watermarkMap.put(split2.splitId(), 1000L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> watermarkMap.get(s.splitId())); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split1); + emitter.emitRecord(element, output, split2); + + // Only split1's watermark is emitted; split2 has a lower value so it's skipped + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(1999L); + } + + @Test + public void testWatermarkEmittedForEachHigherSplit() throws IOException { + IcebergSourceSplit split1 = createSplit(1L); + IcebergSourceSplit split2 = createSplit(2L); + + Map watermarkMap = Maps.newHashMap(); + watermarkMap.put(split1.splitId(), 1000L); + watermarkMap.put(split2.splitId(), 2000L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> watermarkMap.get(s.splitId())); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + RecordAndPosition element = new RecordAndPosition<>("record", 0, 0L); + emitter.emitRecord(element, output, split1); + emitter.emitRecord(element, output, split2); + + assertThat(output.watermarks).hasSize(2); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(999L); + assertThat(output.watermarks.get(1).getTimestamp()).isEqualTo(1999L); + } + + @Test + public void testWatermarkAtLongMinValueDoesNotOverflow() throws IOException { + IcebergSourceSplit split = createSplit(1L); + + WatermarkExtractorRecordEmitter emitter = + new WatermarkExtractorRecordEmitter<>(s -> Long.MIN_VALUE); + + CapturingSourceOutput output = new CapturingSourceOutput<>(); + emitter.emitRecord(new RecordAndPosition<>("record", 0, 0L), output, split); + + assertThat(output.watermarks).hasSize(1); + assertThat(output.watermarks.get(0).getTimestamp()).isEqualTo(Long.MIN_VALUE); + } + + private IcebergSourceSplit createSplit(long seed) throws IOException { + List> recordBatchList = + ReaderUtil.createRecordBatchList(seed, TestFixtures.SCHEMA, 1, 1); + return IcebergSourceSplit.fromCombinedScanTask( + ReaderUtil.createCombinedScanTask( + recordBatchList, temporaryFolder, FileFormat.PARQUET, TestFixtures.SCHEMA)); + } + + private static class CapturingSourceOutput implements SourceOutput { + final List watermarks = Lists.newArrayList(); + final List records = Lists.newArrayList(); + + @Override + public void collect(T record) { + records.add(record); + } + + @Override + public void collect(T record, long timestamp) { + records.add(record); + } + + @Override + public void emitWatermark(Watermark watermark) { + watermarks.add(watermark); + } + + @Override + public void markIdle() {} + + @Override + public void markActive() {} + } +} diff --git a/format/gcm-stream-spec.md b/format/gcm-stream-spec.md index 4d241ca3ef24..8168780e118b 100644 --- a/format/gcm-stream-spec.md +++ b/format/gcm-stream-spec.md @@ -41,7 +41,7 @@ The output stream, produced by a metadata or data writer, is split into equal-si ## Encryption algorithm -AES GCM Stream uses the standard AEG GCM cipher, and supports all AES key sizes: 128, 192 and 256 bits. +AES GCM Stream uses the standard AES GCM cipher, and supports all AES key sizes: 128, 192 and 256 bits. AES GCM is an authenticated encryption. Besides data confidentiality (encryption), it supports two levels of integrity verification (authentication): of the data (default), and of the data combined with an optional AAD (“additional authenticated data”). An AAD is a free text to be authenticated, together with the data. The structure of AES GCM Stream AADs is described below. @@ -80,7 +80,7 @@ AES GCM Stream encrypts all blocks by the GCM cipher, without padding. The AES G ### Additional Authenticated Data -The AES GCM cipher protects against byte replacement inside a ciphertext block - but, without an AAD, it can't prevent replacement of one ciphertext block with another (encrypted with the same key). AES GCM Stream leverages AADs to protect against swapping ciphertext blocks inside a file or between files. AES GCM Stream can also protect against swapping full files - for example, replacement of a metadata file with an old version. AADs are built to reflects the identity of a file and of the blocks inside the file. +The AES GCM cipher protects against byte replacement inside a ciphertext block - but, without an AAD, it can't prevent replacement of one ciphertext block with another (encrypted with the same key). AES GCM Stream leverages AADs to protect against swapping ciphertext blocks inside a file or between files. AES GCM Stream can also protect against swapping full files - for example, replacement of a metadata file with an old version. AADs are built to reflect the identity of a file and of the blocks inside the file. AES GCM Stream constructs a block AAD from two components: an AAD prefix - a string provided by Iceberg for the file (with the file ID), and an AAD suffix - the block sequence number in the file, as an int in a 4-byte little-endian form. The block AAD is a direct concatenation of the prefix and suffix parts. diff --git a/format/puffin-spec.md b/format/puffin-spec.md index 06c7ad565dd7..8617ae6d8e96 100644 --- a/format/puffin-spec.md +++ b/format/puffin-spec.md @@ -188,7 +188,6 @@ codecs listed below. For maximal interoperability, other codecs are not supporte |------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | lz4 | Single [LZ4 compression frame](https://github.com/lz4/lz4/blob/77d1b93f72628af7bbde0243b4bba9205c3138d9/doc/lz4_Frame_format.md), with content size present | | zstd | Single [Zstandard compression frame](https://github.com/facebook/zstd/blob/8af64f41161f6c2e0ba842006fe238c664a6a437/doc/zstd_compression_format.md#zstandard-frames), with content size present | -__ ### Common properties diff --git a/format/spec.md b/format/spec.md index 57e8c7047e82..94651da0fa86 100644 --- a/format/spec.md +++ b/format/spec.md @@ -600,14 +600,15 @@ A manifest stores files for a single partition spec. When a table’s partition A manifest file must store the partition spec and other metadata as properties in the Avro file's key-value metadata: -| v1 | v2 | Key | Value | -|------------|------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| _required_ | _required_ | `schema` | JSON representation of the table schema at the time the manifest was written | -| _optional_ | _required_ | `schema-id` | ID of the schema used to write the manifest as a string | -| _required_ | _required_ | `partition-spec` | JSON representation of only the partition fields array of the partition spec used to write the manifest. See [Appendix C](#partition-specs) | -| _optional_ | _required_ | `partition-spec-id` | ID of the partition spec used to write the manifest as a string | -| _optional_ | _required_ | `format-version` | Table format version number of the manifest as a string | -| | _required_ | `content` | Type of content files tracked by the manifest: "data" or "deletes" | +=== "v1 - v3" + | v1 | v2 and v3 | Key | Value | + |------------|------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------| + | _required_ | _required_ | `schema` | JSON representation of the table schema at the time the manifest was written | + | _optional_ | _required_ | `schema-id` | ID of the schema used to write the manifest as a string | + | _required_ | _required_ | `partition-spec` | JSON representation of only the partition fields array of the partition spec used to write the manifest. See [Appendix C](#partition-specs) | + | _optional_ | _required_ | `partition-spec-id` | ID of the partition spec used to write the manifest as a string | + | _optional_ | _required_ | `format-version` | Table format version number of the manifest as a string | + | | _required_ | `content` | Type of content files tracked by the manifest: "data" or "deletes" | The schema of a manifest file is defined by the `manifest_entry` struct, described in the following section. @@ -615,13 +616,14 @@ The schema of a manifest file is defined by the `manifest_entry` struct, describ The `manifest_entry` struct consists of the following fields: -| v1 | v2 | Field id, name | Type | Description | -| ---------- | ---------- |-------------------------------|-----------------------------------------------------------|-------------| -| _required_ | _required_ | **`0 status`** | `int` with meaning: `0: EXISTING` `1: ADDED` `2: DELETED` | Used to track additions and deletions. Deletes are informational only and not used in scans. | -| _required_ | _optional_ | **`1 snapshot_id`** | `long` | Snapshot id where the file was added, or deleted if status is 2. Inherited when null. | -| | _optional_ | **`3 sequence_number`** | `long` | Data sequence number of the file. Inherited when null and status is 1 (added). | -| | _optional_ | **`4 file_sequence_number`** | `long` | File sequence number indicating when the file was added. Inherited when null and status is 1 (added). | -| _required_ | _required_ | **`2 data_file`** | `data_file` `struct` (see below) | File path, partition tuple, metrics, ... | +=== "v1 - v3" + | v1 | v2 and v3 | Field id, name | Type | Description | + | ---------- | ---------- |-------------------------------|-----------------------------------------------------------|-------------| + | _required_ | _required_ | **`0 status`** | `int` with meaning: `0: EXISTING` `1: ADDED` `2: DELETED` | Used to track additions and deletions. Deletes are informational only and not used in scans. | + | _required_ | _optional_ | **`1 snapshot_id`** | `long` | Snapshot id where the file was added, or deleted if status is 2. Inherited when null. | + | | _optional_ | **`3 sequence_number`** | `long` | Data sequence number of the file. Inherited when null and status is 1 (added). | + | | _optional_ | **`4 file_sequence_number`** | `long` | File sequence number indicating when the file was added. Inherited when null and status is 1 (added). | + | _required_ | _required_ | **`2 data_file`** | `data_file` `struct` (see below) | File path, partition tuple, metrics, ... | The manifest entry fields are used to keep track of the snapshot in which files were added or logically deleted. The `data_file` struct, defined below, is nested inside the manifest entry so that it can be easily passed to job planning without the manifest entry fields. @@ -643,32 +645,33 @@ Notes: The `data_file` struct consists of the following fields: -| v1 | v2 | v3 | Field id, name | Type | Description | -| ---------- |------------|------------|-----------------------------------|-----------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| | _required_ | _required_ | **`134 content`** | `int` with meaning: `0: DATA`, `1: POSITION DELETES`, `2: EQUALITY DELETES` | Type of content stored by the data file: data, equality deletes, or position deletes (all v1 files are data files) | -| _required_ | _required_ | _required_ | **`100 file_path`** | `string` | Full URI for the file with FS scheme | -| _required_ | _required_ | _required_ | **`101 file_format`** | `string` | String file format name, `avro`, `orc`, `parquet`, or `puffin` | -| _required_ | _required_ | _required_ | **`102 partition`** | `struct<...>` | Partition data tuple, schema based on the partition spec output using partition field ids for the struct field ids | -| _required_ | _required_ | _required_ | **`103 record_count`** | `long` | Number of records in this file, or the cardinality of a deletion vector | -| _required_ | _required_ | _required_ | **`104 file_size_in_bytes`** | `long` | Total file size in bytes | -| _required_ | | | ~~**`105 block_size_in_bytes`**~~ | `long` | **Deprecated. Always write a default in v1. Do not write in v2 or v3.** | -| _optional_ | | | ~~**`106 file_ordinal`**~~ | `int` | **Deprecated. Do not write.** | -| _optional_ | | | ~~**`107 sort_columns`**~~ | `list<112: int>` | **Deprecated. Do not write.** | -| _optional_ | _optional_ | _optional_ | **`108 column_sizes`** | `map<117: int, 118: long>` | Map from column id to the total size on disk of all regions that store the column. Does not include bytes necessary to read other columns, like footers. Leave null for row-oriented formats (Avro) | -| _optional_ | _optional_ | _optional_ | **`109 value_counts`** | `map<119: int, 120: long>` | Map from column id to number of values in the column (including null and NaN values) | -| _optional_ | _optional_ | _optional_ | **`110 null_value_counts`** | `map<121: int, 122: long>` | Map from column id to number of null values in the column | -| _optional_ | _optional_ | _optional_ | **`137 nan_value_counts`** | `map<138: int, 139: long>` | Map from column id to number of NaN values in the column | -| _optional_ | _optional_ | | ~~**`111 distinct_counts`**~~ | `map<123: int, 124: long>` | **Deprecated. Do not write.** | -| _optional_ | _optional_ | _optional_ | **`125 lower_bounds`** | `map<126: int, 127: binary>` | Map from column id to lower bound in the column serialized as binary [1]. Each value must be less than or equal to all non-null, non-NaN values in the column for the file [2] | -| _optional_ | _optional_ | _optional_ | **`128 upper_bounds`** | `map<129: int, 130: binary>` | Map from column id to upper bound in the column serialized as binary [1]. Each value must be greater than or equal to all non-null, non-Nan values in the column for the file [2] | -| _optional_ | _optional_ | _optional_ | **`131 key_metadata`** | `binary` | Implementation-specific key metadata for encryption | -| _optional_ | _optional_ | _optional_ | **`132 split_offsets`** | `list<133: long>` | Split offsets for the data file. For example, all row group offsets in a Parquet file. Must be sorted ascending | -| | _optional_ | _optional_ | **`135 equality_ids`** | `list<136: int>` | Field ids used to determine row equality in equality delete files. Required when `content=2` and should be null otherwise. Fields with ids listed in this column must be present in the delete file | -| _optional_ | _optional_ | _optional_ | **`140 sort_order_id`** | `int` | ID representing sort order for this file [3]. | -| | | _optional_ | **`142 first_row_id`** | `long` | The `_row_id` for the first row in the data file. See [First Row ID Inheritance](#first-row-id-inheritance) | -| | _optional_ | _optional_ | **`143 referenced_data_file`** | `string` | Fully qualified location (URI with FS scheme) of a data file that all deletes reference [4] | -| | | _optional_ | **`144 content_offset`** | `long` | The offset in the file where the content starts [5] | -| | | _optional_ | **`145 content_size_in_bytes`** | `long` | The length of a referenced content stored in the file; required if `content_offset` is present [5] | +=== "v1 - v3" + | v1 | v2 | v3 | Field id, name | Type | Description | + | ---------- |------------|------------|-----------------------------------|-----------------------------------------------------------------------------|-------------| + | | _required_ | _required_ | **`134 content`** | `int` with meaning: `0: DATA`, `1: POSITION DELETES`, `2: EQUALITY DELETES` | Type of content stored by the data file: data, equality deletes, or position deletes (all v1 files are data files) | + | _required_ | _required_ | _required_ | **`100 file_path`** | `string` | Full URI for the file with FS scheme | + | _required_ | _required_ | _required_ | **`101 file_format`** | `string` | String file format name, `avro`, `orc`, `parquet`, or `puffin` | + | _required_ | _required_ | _required_ | **`102 partition`** | `struct<...>` | Partition data tuple, schema based on the partition spec output using partition field ids for the struct field ids | + | _required_ | _required_ | _required_ | **`103 record_count`** | `long` | Number of records in this file, or the cardinality of a deletion vector | + | _required_ | _required_ | _required_ | **`104 file_size_in_bytes`** | `long` | Total file size in bytes | + | _required_ | | | ~~**`105 block_size_in_bytes`**~~ | `long` | **Deprecated. Always write a default in v1. Do not write in v2 or v3.** | + | _optional_ | | | ~~**`106 file_ordinal`**~~ | `int` | **Deprecated. Do not write.** | + | _optional_ | | | ~~**`107 sort_columns`**~~ | `list<112: int>` | **Deprecated. Do not write.** | + | _optional_ | _optional_ | _optional_ | **`108 column_sizes`** | `map<117: int, 118: long>` | Map from column id to the total size on disk of all regions that store the column. **Does not include bytes necessary to read other columns, like footers.** Leave null for row-oriented formats (Avro) | + | _optional_ | _optional_ | _optional_ | **`109 value_counts`** | `map<119: int, 120: long>` | Map from column id to number of values in the column (including null and NaN values) | + | _optional_ | _optional_ | _optional_ | **`110 null_value_counts`** | `map<121: int, 122: long>` | Map from column id to number of null values in the column | + | _optional_ | _optional_ | _optional_ | **`137 nan_value_counts`** | `map<138: int, 139: long>` | Map from column id to number of NaN values in the column | + | _optional_ | _optional_ | | ~~**`111 distinct_counts`**~~ | `map<123: int, 124: long>` | **Deprecated. Do not write.** | + | _optional_ | _optional_ | _optional_ | **`125 lower_bounds`** | `map<126: int, 127: binary>` | Map from column id to lower bound in the column serialized as binary [1]. Each value must be less than or equal to all non-null, non-NaN values in the column for the file [2] | + | _optional_ | _optional_ | _optional_ | **`128 upper_bounds`** | `map<129: int, 130: binary>` | Map from column id to upper bound in the column serialized as binary [1]. Each value must be greater than or equal to all non-null, non-Nan values in the column for the file [2] | + | _optional_ | _optional_ | _optional_ | **`131 key_metadata`** | `binary` | Implementation-specific key metadata for encryption | + | _optional_ | _optional_ | _optional_ | **`132 split_offsets`** | `list<133: long>` | Split offsets for the data file. For example, all row group offsets in a Parquet file. Must be sorted ascending | + | | _optional_ | _optional_ | **`135 equality_ids`** | `list<136: int>` | Field ids used to determine row equality in equality delete files. Required when `content=2` and should be null otherwise. Fields with ids listed in this column must be present in the delete file | + | _optional_ | _optional_ | _optional_ | **`140 sort_order_id`** | `int` | ID representing sort order for this file [3]. | + | | | _optional_ | **`142 first_row_id`** | `long` | The `_row_id` for the first row in the data file. See [First Row ID Inheritance](#first-row-id-inheritance) | + | | _optional_ | _optional_ | **`143 referenced_data_file`** | `string` | Fully qualified location (URI with FS scheme) of a data file that all deletes reference [4] | + | | | _optional_ | **`144 content_offset`** | `long` | The offset in the file where the content starts [5] | + | | | _optional_ | **`145 content_size_in_bytes`** | `long` | The length of a referenced content stored in the file; required if `content_offset` is present [5] | The `partition` struct stores the tuple of partition values for each file. Its type is derived from the partition fields of the partition spec used to write the manifest file. In v2, the partition struct's field ids must match the ids from the partition spec. @@ -733,20 +736,20 @@ Any null (unassigned) `first_row_id` must be assigned via inheritance, even if t ### Snapshots A snapshot consists of the following fields: - -| v1 | v2 | v3 | Field | Description | -| ---------- | ---------- |------------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------| -| _required_ | _required_ | _required_ | **`snapshot-id`** | A unique long ID | -| _optional_ | _optional_ | _optional_ | **`parent-snapshot-id`** | The snapshot ID of the snapshot's parent. Omitted for any snapshot with no parent | -| | _required_ | _required_ | **`sequence-number`** | A monotonically increasing long that tracks the order of changes to a table | -| _required_ | _required_ | _required_ | **`timestamp-ms`** | A timestamp when the snapshot was created, used for garbage collection and table inspection | -| _optional_ | _required_ | _required_ | **`manifest-list`** | The location of a manifest list for this snapshot that tracks manifest files with additional metadata | -| _optional_ | | | **`manifests`** | A list of manifest file locations. Must be omitted if `manifest-list` is present | -| _optional_ | _required_ | _required_ | **`summary`** | A string map that summarizes the snapshot changes, including `operation` as a _required_ field (see below) | -| _optional_ | _optional_ | _optional_ | **`schema-id`** | ID of the table's current schema when the snapshot was created | -| | | _required_ | **`first-row-id`** | The first `_row_id` assigned to the first row in the first data file in the first manifest, see [Row Lineage](#row-lineage) | -| | | _required_ | **`added-rows`** | The upper bound of the number of rows with assigned row IDs, see [Row Lineage](#row-lineage) | -| | | _optional_ | **`key-id`** | ID of the encryption key that encrypts the manifest list key metadata | +=== "v1 - v3" + | v1 | v2 | v3 | Field | Description | + | ---------- | ---------- |------------|------------------------------|-------------| + | _required_ | _required_ | _required_ | **`snapshot-id`** | A unique long ID | + | _optional_ | _optional_ | _optional_ | **`parent-snapshot-id`** | The snapshot ID of the snapshot's parent. Omitted for any snapshot with no parent | + | | _required_ | _required_ | **`sequence-number`** | A monotonically increasing long that tracks the order of changes to a table | + | _required_ | _required_ | _required_ | **`timestamp-ms`** | A timestamp when the snapshot was created, used for garbage collection and table inspection | + | _optional_ | _required_ | _required_ | **`manifest-list`** | The location of a manifest list for this snapshot that tracks manifest files with additional metadata | + | _optional_ | | | **`manifests`** | A list of manifest file locations. Must be omitted if `manifest-list` is present | + | _optional_ | _required_ | _required_ | **`summary`** | A string map that summarizes the snapshot changes, including `operation` as a _required_ field (see below) | + | _optional_ | _optional_ | _optional_ | **`schema-id`** | ID of the table's current schema when the snapshot was created | + | | | _required_ | **`first-row-id`** | The first `_row_id` assigned to the first row in the first data file in the first manifest, see [Row Lineage](#row-lineage) | + | | | _required_ | **`added-rows`** | The upper bound of the number of rows with assigned row IDs, see [Row Lineage](#row-lineage) | + | | | _optional_ | **`key-id`** | ID of the encryption key that encrypts the manifest list key metadata | The snapshot summary's `operation` field is used by some operations, like snapshot expiration, to skip processing certain snapshots. Possible `operation` values are: @@ -790,33 +793,34 @@ A manifest list is a valid Iceberg data file: files must use valid Iceberg forma Manifest list files store `manifest_file`, a struct with the following fields: -| v1 | v2 | v3 | Field id, name | Type | Description | -| ---------- | ---------- |------------|----------------------------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| -| _required_ | _required_ | _required_ | **`500 manifest_path`** | `string` | Location of the manifest file | -| _required_ | _required_ | _required_ | **`501 manifest_length`** | `long` | Length of the manifest file in bytes | -| _required_ | _required_ | _required_ | **`502 partition_spec_id`** | `int` | ID of a partition spec used to write the manifest; must be listed in table metadata `partition-specs` | -| | _required_ | _required_ | **`517 content`** | `int` with meaning: `0: data`, `1: deletes` | The type of files tracked by the manifest, either data or delete files; 0 for all v1 manifests | -| | _required_ | _required_ | **`515 sequence_number`** | `long` | The sequence number when the manifest was added to the table; use 0 when reading v1 manifest lists | -| | _required_ | _required_ | **`516 min_sequence_number`** | `long` | The minimum data sequence number of all live data or delete files in the manifest; use 0 when reading v1 manifest lists | -| _required_ | _required_ | _required_ | **`503 added_snapshot_id`** | `long` | ID of the snapshot where the manifest file was added | -| _optional_ | _required_ | _required_ | **`504 added_files_count`** | `int` | Number of entries in the manifest that have status `ADDED` (1), when `null` this is assumed to be non-zero | -| _optional_ | _required_ | _required_ | **`505 existing_files_count`** | `int` | Number of entries in the manifest that have status `EXISTING` (0), when `null` this is assumed to be non-zero | -| _optional_ | _required_ | _required_ | **`506 deleted_files_count`** | `int` | Number of entries in the manifest that have status `DELETED` (2), when `null` this is assumed to be non-zero | -| _optional_ | _required_ | _required_ | **`512 added_rows_count`** | `long` | Number of rows in all of files in the manifest that have status `ADDED`, when `null` this is assumed to be non-zero | -| _optional_ | _required_ | _required_ | **`513 existing_rows_count`** | `long` | Number of rows in all of files in the manifest that have status `EXISTING`, when `null` this is assumed to be non-zero | -| _optional_ | _required_ | _required_ | **`514 deleted_rows_count`** | `long` | Number of rows in all of files in the manifest that have status `DELETED`, when `null` this is assumed to be non-zero | -| _optional_ | _optional_ | _optional_ | **`507 partitions`** | `list<508: field_summary>` (see below) | A list of field summaries for each partition field in the spec. Each field in the list corresponds to a field in the manifest file’s partition spec. | -| _optional_ | _optional_ | _optional_ | **`519 key_metadata`** | `binary` | Implementation-specific key metadata for encryption | -| | | _optional_ | **`520 first_row_id`** | `long` | The starting `_row_id` to assign to rows added by `ADDED` data files [First Row ID Assignment](#first-row-id-assignment) | +=== "v1 - v3" + | v1 | v2 | v3 | Field id, name | Type | Description | + | ---------- | ---------- |------------|-------------------------------------|---------------------------------------------|-------------| + | _required_ | _required_ | _required_ | **`500 manifest_path`** | `string` | Location of the manifest file | + | _required_ | _required_ | _required_ | **`501 manifest_length`** | `long` | Length of the manifest file in bytes | + | _required_ | _required_ | _required_ | **`502 partition_spec_id`** | `int` | ID of a partition spec used to write the manifest; must be listed in table metadata `partition-specs` | + | | _required_ | _required_ | **`517 content`** | `int` with meaning: `0: data`, `1: deletes` | The type of files tracked by the manifest, either data or delete files; 0 for all v1 manifests | + | | _required_ | _required_ | **`515 sequence_number`** | `long` | The sequence number when the manifest was added to the table; use 0 when reading v1 manifest lists | + | | _required_ | _required_ | **`516 min_sequence_number`** | `long` | The minimum data sequence number of all live data or delete files in the manifest; use 0 when reading v1 manifest lists | + | _required_ | _required_ | _required_ | **`503 added_snapshot_id`** | `long` | ID of the snapshot where the manifest file was added | + | _optional_ | _required_ | _required_ | **`504 added_files_count`** | `int` | Number of entries in the manifest that have status `ADDED` (1), when `null` this is assumed to be non-zero | + | _optional_ | _required_ | _required_ | **`505 existing_files_count`** | `int` | Number of entries in the manifest that have status `EXISTING` (0), when `null` this is assumed to be non-zero | + | _optional_ | _required_ | _required_ | **`506 deleted_files_count`** | `int` | Number of entries in the manifest that have status `DELETED` (2), when `null` this is assumed to be non-zero | + | _optional_ | _required_ | _required_ | **`512 added_rows_count`** | `long` | Number of rows in all of files in the manifest that have status `ADDED`, when `null` this is assumed to be non-zero | + | _optional_ | _required_ | _required_ | **`513 existing_rows_count`** | `long` | Number of rows in all of files in the manifest that have status `EXISTING`, when `null` this is assumed to be non-zero | + | _optional_ | _required_ | _required_ | **`514 deleted_rows_count`** | `long` | Number of rows in all of files in the manifest that have status `DELETED`, when `null` this is assumed to be non-zero | + | _optional_ | _optional_ | _optional_ | **`507 partitions`** | `list<508: field_summary>` **(see below)** | A list of field summaries for each partition field in the spec. Each field in the list corresponds to a field in the manifest file’s partition spec. | + | _optional_ | _optional_ | _optional_ | **`519 key_metadata`** | `binary` | Implementation-specific key metadata for encryption | + | | | _optional_ | **`520 first_row_id`** | `long` | The starting `_row_id` to assign to rows added by `ADDED` data files [First Row ID Assignment](#first-row-id-assignment) | `field_summary` is a struct with the following fields: - -| v1 | v2 | Field id, name | Type | Description | -| ---------- | ---------- |-------------------------|---------------|-------------| -| _required_ | _required_ | **`509 contains_null`** | `boolean` | Whether the manifest contains at least one partition with a null value for the field | -| _optional_ | _optional_ | **`518 contains_nan`** | `boolean` | Whether the manifest contains at least one partition with a NaN value for the field | -| _optional_ | _optional_ | **`510 lower_bound`** | `bytes` [1] | Lower bound for the non-null, non-NaN values in the partition field, or null if all values are null or NaN [2] | -| _optional_ | _optional_ | **`511 upper_bound`** | `bytes` [1] | Upper bound for the non-null, non-NaN values in the partition field, or null if all values are null or NaN [2] | +=== "v1 - v3" + | v1 | v2 and v3 | Field id, name | Type | Description | + | ---------- | ---------- |-------------------------|---------------|-------------| + | _required_ | _required_ | **`509 contains_null`** | `boolean` | Whether the manifest contains at least one partition with a null value for the field | + | _optional_ | _optional_ | **`518 contains_nan`** | `boolean` | Whether the manifest contains at least one partition with a NaN value for the field | + | _optional_ | _optional_ | **`510 lower_bound`** | `bytes` [1] | Lower bound for the non-null, non-NaN values in the partition field, or null if all values are null or NaN [2] | + | _optional_ | _optional_ | **`511 upper_bound`** | `bytes` [1] | Upper bound for the non-null, non-NaN values in the partition field, or null if all values are null or NaN [2] | Notes: @@ -885,13 +889,14 @@ Tags are labels for individual snapshots. Branches are mutable named references The snapshot reference object records all the information of a reference including snapshot ID, reference type and [Snapshot Retention Policy](#snapshot-retention-policy). -| v1 | v2 | Field name | Type | Description | -| ---------- | ---------- | ---------------------------- | --------- | ----------- | -| _required_ | _required_ | **`snapshot-id`** | `long` | A reference's snapshot ID. The tagged snapshot or latest snapshot of a branch. | -| _required_ | _required_ | **`type`** | `string` | Type of the reference, `tag` or `branch` | -| _optional_ | _optional_ | **`min-snapshots-to-keep`** | `int` | For `branch` type only, a positive number for the minimum number of snapshots to keep in a branch while expiring snapshots. Defaults to table property `history.expire.min-snapshots-to-keep`. | -| _optional_ | _optional_ | **`max-snapshot-age-ms`** | `long` | For `branch` type only, a positive number for the max age of snapshots to keep when expiring, including the latest snapshot. Defaults to table property `history.expire.max-snapshot-age-ms`. | -| _optional_ | _optional_ | **`max-ref-age-ms`** | `long` | For snapshot references except the `main` branch, a positive number for the max age of the snapshot reference to keep while expiring snapshots. Defaults to table property `history.expire.max-ref-age-ms`. The `main` branch never expires. | +=== "v1 - v3" + | v1 | v2 and v3 | Field name | Type | Description | + | ---------- | ---------- | ---------------------------- | --------- | ----------- | + | _required_ | _required_ | **`snapshot-id`** | `long` | A reference's snapshot ID. The tagged snapshot or latest snapshot of a branch. | + | _required_ | _required_ | **`type`** | `string` | Type of the reference, `tag` or `branch` | + | _optional_ | _optional_ | **`min-snapshots-to-keep`** | `int` | For `branch` type only, a positive number for the minimum number of snapshots to keep in a branch while expiring snapshots. Defaults to table property `history.expire.min-snapshots-to-keep`. | + | _optional_ | _optional_ | **`max-snapshot-age-ms`** | `long` | For `branch` type only, a positive number for the max age of snapshots to keep when expiring, including the latest snapshot. Defaults to table property `history.expire.max-snapshot-age-ms`. | + | _optional_ | _optional_ | **`max-ref-age-ms`** | `long` | For snapshot references except the `main` branch, a positive number for the max age of the snapshot reference to keep while expiring snapshots. Defaults to table property `history.expire.max-ref-age-ms`. The `main` branch never expires. | Valid snapshot references are stored as the values of the `refs` map in table metadata. For serialization, see Appendix C. @@ -921,33 +926,34 @@ The atomic operation used to commit metadata depends on how tables are tracked a Table metadata consists of the following fields: -| v1 | v2 | v3 | Field | Description | -| ---------- | ---------- |------------|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| _required_ | _required_ | _required_ | **`format-version`** | An integer version number for the format. Implementations must throw an exception if a table's version is higher than the supported version. | -| _optional_ | _required_ | _required_ | **`table-uuid`** | A UUID that identifies the table, generated when the table is created. Implementations must throw an exception if a table's UUID does not match the expected UUID after refreshing metadata. | -| _required_ | _required_ | _required_ | **`location`** | The table's base location. This is used by writers to determine where to store data files, manifest files, and table metadata files. | -| | _required_ | _required_ | **`last-sequence-number`** | The table's highest assigned sequence number, a monotonically increasing long that tracks the order of snapshots in a table. | -| _required_ | _required_ | _required_ | **`last-updated-ms`** | Timestamp in milliseconds from the unix epoch when the table was last updated. Each table metadata file should update this field just before writing. | -| _required_ | _required_ | _required_ | **`last-column-id`** | An integer; the highest assigned column ID for the table. This is used to ensure columns are always assigned an unused ID when evolving schemas. | -| _required_ | | | **`schema`** | The table’s current schema. (**Deprecated**: use `schemas` and `current-schema-id` instead) | -| _optional_ | _required_ | _required_ | **`schemas`** | A list of schemas, stored as objects with `schema-id`. | -| _optional_ | _required_ | _required_ | **`current-schema-id`** | ID of the table's current schema. | -| _required_ | | | **`partition-spec`** | The table’s current partition spec, stored as only fields. Note that this is used by writers to partition data, but is not used when reading because reads use the specs stored in manifest files. (**Deprecated**: use `partition-specs` and `default-spec-id` instead) | -| _optional_ | _required_ | _required_ | **`partition-specs`** | A list of partition specs, stored as full partition spec objects. | -| _optional_ | _required_ | _required_ | **`default-spec-id`** | ID of the "current" spec that writers should use by default. | -| _optional_ | _required_ | _required_ | **`last-partition-id`** | An integer; the highest assigned partition field ID across all partition specs for the table. This is used to ensure partition fields are always assigned an unused ID when evolving specs. | -| _optional_ | _optional_ | _optional_ | **`properties`** | A string to string map of table properties. This is used to control settings that affect reading and writing and is not intended to be used for arbitrary metadata. For example, `commit.retry.num-retries` is used to control the number of commit retries. | -| _optional_ | _optional_ | _optional_ | **`current-snapshot-id`** | `long` ID of the current table snapshot; must be the same as the current ID of the `main` branch in `refs`. | -| _optional_ | _optional_ | _optional_ | **`snapshots`** | A list of valid snapshots. Valid snapshots are snapshots for which all data files exist in the file system. A data file must not be deleted from the file system until the last snapshot in which it was listed is garbage collected. | -| _optional_ | _optional_ | _optional_ | **`snapshot-log`** | A list (optional) of timestamp and snapshot ID pairs that encodes changes to the current snapshot for the table. Each time the current-snapshot-id is changed, a new entry should be added with the last-updated-ms and the new current-snapshot-id. When snapshots are expired from the list of valid snapshots, all entries before a snapshot that has expired should be removed. | -| _optional_ | _optional_ | _optional_ | **`metadata-log`** | A list (optional) of timestamp and metadata file location pairs that encodes changes to the previous metadata files for the table. Each time a new metadata file is created, a new entry of the previous metadata file location should be added to the list. Tables can be configured to remove oldest metadata log entries and keep a fixed-size log of the most recent entries after a commit. | -| _optional_ | _required_ | _required_ | **`sort-orders`** | A list of sort orders, stored as full sort order objects. | -| _optional_ | _required_ | _required_ | **`default-sort-order-id`** | Default sort order id of the table. Note that this could be used by writers, but is not used when reading because reads use the specs stored in manifest files. | -| | _optional_ | _optional_ | **`refs`** | A map of snapshot references. The map keys are the unique snapshot reference names in the table, and the map values are snapshot reference objects. There is always a `main` branch reference pointing to the `current-snapshot-id` even if the `refs` map is null. | -| _optional_ | _optional_ | _optional_ | **`statistics`** | A list (optional) of [table statistics](#table-statistics). | -| _optional_ | _optional_ | _optional_ | **`partition-statistics`** | A list (optional) of [partition statistics](#partition-statistics). | -| | | _required_ | **`next-row-id`** | A `long` higher than all assigned row IDs; the next snapshot's `first-row-id`. See [Row Lineage](#row-lineage). | -| | | _optional_ | **`encryption-keys`** | A list (optional) of [encryption keys](#encryption-keys) used for table encryption. | +=== "v1 - v3" + | v1 | v2 | v3 | Field | Description | + | ---------- | ---------- |------------|-----------------------------| ------------| + | _required_ | _required_ | _required_ | **`format-version`** | An integer version number for the format. Implementations must throw an exception if a table’s version is higher than the supported version. | + | _optional_ | _required_ | _required_ | **`table-uuid`** | A UUID that identifies the table, generated when the table is created. Implementations must throw an exception if a table’s UUID does not match the expected UUID after refreshing metadata. | + | _required_ | _required_ | _required_ | **`location`** | The table’s base location. This is used by writers to determine where to store data files, manifest files, and table metadata files. | + | | _required_ | _required_ | **`last-sequence-number`** | The table’s highest assigned sequence number, a monotonically increasing long that tracks the order of snapshots in a table. | + | _required_ | _required_ | _required_ | **`last-updated-ms`** | Timestamp in milliseconds from the unix epoch when the table was last updated. Each table metadata file should update this field just before writing. | + | _required_ | _required_ | _required_ | **`last-column-id`** | An integer; the highest assigned column ID for the table. This is used to ensure columns are always assigned an unused ID when evolving schemas. | + | _required_ | | | **`schema`** | The table’s current schema. (**Deprecated**: use `schemas` and `current-schema-id` instead) | + | _optional_ | _required_ | _required_ | **`schemas`** | A list of schemas, stored as objects with `schema-id`. | + | _optional_ | _required_ | _required_ | **`current-schema-id`** | ID of the table’s current schema. | + | _required_ | | | **`partition-spec`** | The table’s current partition spec, stored as only fields. (**Deprecated**: use `partition-specs` and `default-spec-id` instead) Note that this is used by writers to partition data, but is not used when reading because reads use the specs stored in manifest files. | + | _optional_ | _required_ | _required_ | **`partition-specs`** | A list of partition specs, stored as full partition spec objects. | + | _optional_ | _required_ | _required_ | **`default-spec-id`** | ID of the "current" spec that writers should use by default. | + | _optional_ | _required_ | _required_ | **`last-partition-id`** | An integer; the highest assigned partition field ID across all partition specs for the table. This is used to ensure partition fields are always assigned an unused ID when evolving specs. | + | _optional_ | _optional_ | _optional_ | **`properties`** | A string to string map of table properties. This is used to control settings that affect reading and writing and is not intended to be used for arbitrary metadata. For example, `commit.retry.num-retries` is used to control the number of commit retries. | + | _optional_ | _optional_ | _optional_ | **`current-snapshot-id`** | `long` ID of the current table snapshot; must be the same as the current ID of the `main` branch in `refs`. | + | _optional_ | _optional_ | _optional_ | **`snapshots`** | A list of valid snapshots. Valid snapshots are snapshots for which all data files exist in the file system. A data file must not be deleted from the file system until the last snapshot in which it was listed is garbage collected. | + | _optional_ | _optional_ | _optional_ | **`snapshot-log`** | A list (optional) of timestamp and snapshot ID pairs that encodes changes to the current snapshot for the table. Each time the current-snapshot-id is changed, a new entry should be added with the last-updated-ms and the new current-snapshot-id. When snapshots are expired from the list of valid snapshots, all entries before a snapshot that has expired should be removed. | + | _optional_ | _optional_ | _optional_ | **`metadata-log`** | A list (optional) of timestamp and metadata file location pairs that encodes changes to the previous metadata files for the table. Each time a new metadata file is created, a new entry of the previous metadata file location should be added to the list. Tables can be configured to remove oldest metadata log entries and keep a fixed-size log of the most recent entries after a commit. | + | _optional_ | _required_ | _required_ | **`sort-orders`** | A list of sort orders, stored as full sort order objects. | + | _optional_ | _required_ | _required_ | **`default-sort-order-id`** | Default sort order id of the table. Note that this could be used by writers, but is not used when reading because reads use the specs stored in manifest files. | + | | _optional_ | _optional_ | **`refs`** | A map of snapshot references. The map keys are the unique snapshot reference names in the table, and the map values are snapshot reference objects. There is always a `main` branch reference pointing to the `current-snapshot-id` even if the `refs` map is null. | + | _optional_ | _optional_ | _optional_ | **`statistics`** | A list (optional) of [table statistics](#table-statistics). | + | _optional_ | _optional_ | _optional_ | **`partition-statistics`** | A list (optional) of [partition statistics](#partition-statistics). | + | | | _required_ | **`next-row-id`** | A `long` higher than all assigned row IDs; the next snapshot’s `first-row-id`. See [Row Lineage](#row-lineage). | + | | | _optional_ | **`encryption-keys`** | A list (optional) of [encryption keys](#encryption-keys) used for table encryption. | For serialization details, see Appendix C. @@ -963,24 +969,26 @@ many statistics files associated with different table snapshots. Statistics files metadata within `statistics` table metadata field is a struct with the following fields: -| v1 | v2 | Field name | Type | Description | -|----|----|------------|------|-------------| -| _required_ | _required_ | **`snapshot-id`** | `long` | ID of the Iceberg table's snapshot the statistics file is associated with. | -| _required_ | _required_ | **`statistics-path`** | `string` | Path of the statistics file. See [Puffin file format](puffin-spec.md). | -| _required_ | _required_ | **`file-size-in-bytes`** | `long` | Size of the statistics file. | -| _required_ | _required_ | **`file-footer-size-in-bytes`** | `long` | Total size of the statistics file's footer (not the footer payload size). See [Puffin file format](puffin-spec.md) for footer definition. | -| _optional_ | _optional_ | **`key-metadata`** | Base64-encoded implementation-specific key metadata for encryption. | -| _required_ | _required_ | **`blob-metadata`** | `list` (see below) | A list of the blob metadata for statistics contained in the file with structure described below. | +=== "v1 - v3" + | v1 | v2 and v3 | Field name | Type | Description | + | ---------- | ---------- |---------------------------------|-----------------------|-------------| + | _required_ | _required_ | **`snapshot-id`** | `long` | ID of the Iceberg table's snapshot the statistics file is associated with. | + | _required_ | _required_ | **`statistics-path`** | `string` | Path of the statistics file. See [Puffin file format](puffin-spec.md). | + | _required_ | _required_ | **`file-size-in-bytes`** | `long` | Size of the statistics file. | + | _required_ | _required_ | **`file-footer-size-in-bytes`** | `long` | Total size of the statistics file's footer (not the footer payload size). See [Puffin file format](puffin-spec.md) for footer definition. | + | _optional_ | _optional_ | **`key-metadata`** | | Base64-encoded implementation-specific key metadata for encryption. | + | _required_ | _required_ | **`blob-metadata`** | `list` (see below) | A list of the blob metadata for statistics contained in the file with structure described below. | Blob metadata is a struct with the following fields: -| v1 | v2 | Field name | Type | Description | -|----|----|------------|------|-------------| -| _required_ | _required_ | **`type`** | `string` | Type of the blob. Matches Blob type in the Puffin file. | -| _required_ | _required_ | **`snapshot-id`** | `long` | ID of the Iceberg table's snapshot the blob was computed from. | -| _required_ | _required_ | **`sequence-number`** | `long` | Sequence number of the Iceberg table's snapshot the blob was computed from. | -| _required_ | _required_ | **`fields`** | `list` | Ordered list of fields, given by field ID, on which the statistic was calculated. | -| _optional_ | _optional_ | **`properties`** | `map` | Additional properties associated with the statistic. Subset of Blob properties in the Puffin file. | +=== "v1 - v3" + | v1 | v2 and v3 | Field name | Type | Description | + | ---------- | ---------- |-----------------------|-----------------------|-------------| + | _required_ | _required_ | **`type`** | `string` | Type of the blob. Matches Blob type in the Puffin file. | + | _required_ | _required_ | **`snapshot-id`** | `long` | ID of the Iceberg table's snapshot the blob was computed from. | + | _required_ | _required_ | **`sequence-number`** | `long` | Sequence number of the Iceberg table's snapshot the blob was computed from. | + | _required_ | _required_ | **`fields`** | `list` | Ordered list of fields, given by field ID, on which the statistic was calculated. | + | _optional_ | _optional_ | **`properties`** | `map` | Additional properties associated with the statistic. Subset of Blob properties in the Puffin file. | #### Partition Statistics @@ -992,11 +1000,12 @@ Partition statistics file must be registered in the table metadata file to be co `partition-statistics` field of table metadata is an optional list of structs with the following fields: -| v1 | v2 | v3 | Field name | Type | Description | -|----|----|----|------------|------|-------------| -| _required_ | _required_ | _required_ | **`snapshot-id`** | `long` | ID of the Iceberg table's snapshot the partition statistics file is associated with. | -| _required_ | _required_ | _required_ | **`statistics-path`** | `string` | Path of the partition statistics file. See [Partition statistics file](#partition-statistics-file). | -| _required_ | _required_ | _required_ | **`file-size-in-bytes`** | `long` | Size of the partition statistics file. | +=== "v1 - v3" + | v1 | v2 | v3 | Field name | Type | Description | + | ---------- | ---------- |------------|--------------------------|----------|-------------| + | _required_ | _required_ | _required_ | **`snapshot-id`** | `long` | ID of the Iceberg table's snapshot the partition statistics file is associated with. | + | _required_ | _required_ | _required_ | **`statistics-path`** | `string` | Path of the partition statistics file. See [Partition statistics file](#partition-statistics-file). | + | _required_ | _required_ | _required_ | **`file-size-in-bytes`** | `long` | Size of the partition statistics file. | ##### Partition Statistics File @@ -1005,21 +1014,22 @@ These rows must be sorted (in ascending manner with NULL FIRST) by `partition` f The schema of the partition statistics file is as follows: -| v1 | v2 | v3 | Field id, name | Type | Description | -|----|----|----|----------------|------|-------------| -| _required_ | _required_ | _required_ | **`1 partition`** | `struct<..>` | Partition data tuple, schema based on the unified partition type considering all specs in a table | -| _required_ | _required_ | _required_ | **`2 spec_id`** | `int` | Partition spec id | -| _required_ | _required_ | _required_ | **`3 data_record_count`** | `long` | Count of records in data files | -| _required_ | _required_ | _required_ | **`4 data_file_count`** | `int` | Count of data files | -| _required_ | _required_ | _required_ | **`5 total_data_file_size_in_bytes`** | `long` | Total size of data files in bytes | -| _optional_ | _optional_ | _required_ | **`6 position_delete_record_count`** | `long` | Count of position deletes across position delete files and deletion vectors | -| _optional_ | _optional_ | _required_ | **`7 position_delete_file_count`** | `int` | Count of position delete files ignoring deletion vectors | -| | | _required_ | **`13 dv_count`** | `int` | Count of deletion vectors | -| _optional_ | _optional_ | _required_ | **`8 equality_delete_record_count`** | `long` | Count of records in equality delete files | -| _optional_ | _optional_ | _required_ | **`9 equality_delete_file_count`** | `int` | Count of equality delete files | -| _optional_ | _optional_ | _optional_ | **`10 total_record_count`** | `long` | Accurate count of records in a partition after applying deletes if any | -| _optional_ | _optional_ | _optional_ | **`11 last_updated_at`** | `long` | Timestamp in milliseconds from the unix epoch when the partition was last updated | -| _optional_ | _optional_ | _optional_ | **`12 last_updated_snapshot_id`** | `long` | ID of snapshot that last updated this partition | +=== "v1 - v3" + | v1 | v2 | v3 | Field id, name | Type | Description | + | ---------- | ---------- |------------|------------------------------------------|--------------|-------------| + | _required_ | _required_ | _required_ | **`1 partition`** | `struct<..>` | Partition data tuple, schema based on the unified partition type considering all specs in a table | + | _required_ | _required_ | _required_ | **`2 spec_id`** | `int` | Partition spec id | + | _required_ | _required_ | _required_ | **`3 data_record_count`** | `long` | Count of records in data files | + | _required_ | _required_ | _required_ | **`4 data_file_count`** | `int` | Count of data files | + | _required_ | _required_ | _required_ | **`5 total_data_file_size_in_bytes`** | `long` | Total size of data files in bytes | + | _optional_ | _optional_ | _required_ | **`6 position_delete_record_count`** | `long` | Count of position deletes across position delete files and deletion vectors | + | _optional_ | _optional_ | _required_ | **`7 position_delete_file_count`** | `int` | Count of position delete files ignoring deletion vectors | + | | | _required_ | **`13 dv_count`** | `int` | Count of deletion vectors | + | _optional_ | _optional_ | _required_ | **`8 equality_delete_record_count`** | `long` | Count of records in equality delete files | + | _optional_ | _optional_ | _required_ | **`9 equality_delete_file_count`** | `int` | Count of equality delete files | + | _optional_ | _optional_ | _optional_ | **`10 total_record_count`** | `long` | Accurate count of records in a partition after applying deletes if any | + | _optional_ | _optional_ | _optional_ | **`11 last_updated_at`** | `long` | Timestamp in milliseconds from the unix epoch when the partition was last updated | + | _optional_ | _optional_ | _optional_ | **`12 last_updated_snapshot_id`** | `long` | ID of snapshot that last updated this partition | Note that partition data tuple's schema is based on the partition spec output using partition field ids for the struct field ids. The unified partition type is a struct containing all fields that have ever been a part of any spec in the table @@ -1044,13 +1054,13 @@ If a table has no deletes or only deletion vectors, implementations are encourag #### Encryption Keys Keys used for table encryption can be tracked in table metadata as a list named `encryption-keys`. The schema of each key is a struct with the following fields: - -| v1 | v2 | v3 | Field name | Type. | Description | -|----|----|------------|------------------------------|-----------------------|-------------| -| | | _required_ | **`key-id`** | `string` | ID of the encryption key | -| | | _required_ | **`encrypted-key-metadata`** | `string` | Encrypted key and metadata, base64 encoded [1] | -| | | _optional_ | **`encrypted-by-id`** | `string` | Optional ID of the key used to encrypt or wrap `key-metadata` | -| | | _optional_ | **`properties`** | `map` | A string to string map of additional metadata used by the table's encryption scheme | +=== "v1 - v3" + | v1 | v2 | v3 | Field name | Type | Description | + |----|----|------------|-------------------------------|-----------------------|-------------| + | | | _required_ | **`key-id`** | `string` | ID of the encryption key | + | | | _required_ | **`encrypted-key-metadata`** | `string` | Encrypted key and metadata, base64 encoded [1] | + | | | _optional_ | **`encrypted-by-id`** | `string` | Optional ID of the key used to encrypt or wrap `key-metadata` | + | | | _optional_ | **`properties`** | `map` | A string to string map of additional metadata used by the table's encryption scheme | Notes: @@ -1485,7 +1495,7 @@ Older versions of the reference implementation can read tables with transforms u ### Sort Orders -Sort orders are serialized as a list of JSON object, each of which contains the following fields: +Sort orders are serialized as a list of JSON objects, each of which contains the following fields: |Field|JSON representation|Example| |--- |--- |--- | @@ -1827,7 +1837,7 @@ Snapshot summary can include metrics fields to track numeric stats of the snapsh Writers should produce positive values for snapshot ids in a manner that minimizes the probability of id collisions and should verify the id does not conflict with existing snapshots. Producing snapshot ids based on timestamps alone is not recommended as it increases the potential for collisions. -The reference Java implementation uses a type 4 uuid and XORs the 4 most significant bytes with the 4 least significant bytes then ANDs with the maximum long value to arrive at a pseudo-random snapshot id with a low probability of collision. +The reference Java implementation uses a type 4 uuid and XORs the 8 most significant bytes with the 8 least significant bytes then ANDs with the maximum long value to arrive at a pseudo-random snapshot id with a low probability of collision. Java writes `-1` for "no current snapshot" with V1 and V2 tables and considers this equivalent to omitted or `null`. This has never been formalized in the spec, but for compatibility, other implementations can accept `-1` as `null`. Java will no longer write `-1` and will use `null` for "no current snapshot" for all tables with a version greater than or equal to V3. diff --git a/format/udf-spec.md b/format/udf-spec.md new file mode 100644 index 000000000000..a11f10ffd2fd --- /dev/null +++ b/format/udf-spec.md @@ -0,0 +1,358 @@ +--- +title: "SQL UDF Spec" +--- + + +# Iceberg UDF Spec + +## Background and Motivation + +A SQL user-defined function (UDF or UDTF) is a callable routine that accepts input parameters and executes a function body. +Depending on the function type, the result can be: + +- **Scalar function (UDF)** – returns a single value, which may be a primitive type (e.g., `int`, `string`) or a non-primitive type (e.g., `struct`, `list`). +- **Table function (UDTF)** – returns a table with zero or more rows of columns with a uniform schema. + +This specification introduces a standardized metadata format for UDFs in Iceberg. + +## Goals + +* Define a portable metadata format for both scalar and table SQL UDFs. The metadata is self-contained and can be moved across catalogs. +* Support function evolution through versioning and rollback. +* Provide consistent semantics for representing UDFs across engines. + +## Overview + +UDF metadata follows the same design principles as Iceberg table and view metadata: each function is represented by a +**self-contained metadata file**. Metadata captures definitions, parameters, return types, documentation, security, +properties, and engine-specific representations. + +* UDF metadata files are immutable. Any modification (new definition, updated representation, changed properties, etc.) + creates a new metadata file. Catalogs use an atomic swap to update the metadata file linked to a catalog identifier. +* Each metadata file includes recent definition versions, enabling rollbacks without external state. + +## Specification + +### UDF Metadata +The UDF metadata file has the following fields: + +| Requirement | Field name | Type | Description | +|-------------|------------------|------------------------|---------------------------------------------------------------------------| +| *required* | `function-uuid` | `string` | A UUID that identifies this UDF, generated once at creation. | +| *required* | `format-version` | `int` | UDF specification format version (must be `1`). | +| *required* | `definitions` | `list` | List of function [definition](#definition) entities. | +| *required* | `definition-log` | `list` | History of [versions within the function's definitions](#definition-log). | +| *optional* | `location` | `string` | The function's base location; used to create metadata file locations. | +| *optional* | `properties` | `map` | A string-to-string map of properties. | +| *optional* | `secure` | `boolean` | Whether it is a secure function. Default: `false`. | +| *optional* | `doc` | `string` | Documentation string. | + +Notes: + +1. When `secure` is set to `true`, engines should prevent leakage of sensitive information to end users. Each engine + may have its own security definition and mechanisms. It is the administrator's responsibility to ensure that + UDFs marked as secure are properly configured and protected in their environment. +2. Entries in `properties` are treated as hints, not strict rules. +3. UDF names are not stored in metadata. It's the catalog's responsibility to map UDF names to metadata file locations. + +### Definition + +Each `definition` represents one function signature (e.g., `add_one(int)` vs `add_one(float)`). A definition is uniquely +identified by its signature (the ordered list of parameter types). There can be only one definition for a given signature. +All versions within a definition must accept the same signature as specified in the definition's `parameters` field and +must produce values of the declared `return-type`. + +| Requirement | Field name | Type | Description | +|-------------|----------------------|--------------------------------|---------------------------------------------------------------------------------------------------| +| *required* | `definition-id` | `string` | An identifier derived from canonical parameter-type tuple (see [Definition ID](#definition-id)). | +| *required* | `parameters` | `list` | Ordered list of [function parameters](#parameter). Invocation order **must** match this list. | +| *required* | `return-type` | `string` or `object` | Declared return type using [Types](#types). | +| *optional* | `return-nullable` | `boolean` | A hint to indicate whether the return value is nullable or not. Default: `true`. | +| *required* | `versions` | `list` | [Versioned implementations](#definition-version) of this definition. | +| *required* | `current-version-id` | `int` | Identifier of the current version for this definition. | +| *required* | `function-type` | `string` (`"udf"` or `"udtf"`) | If `"udtf"`, `return-type` must be a `struct` (see [Types](#types)) describing the output schema. | +| *optional* | `doc` | `string` | Documentation string. | + +### Parameter +| Requirement | Field | Type | Description | +|-------------|--------|----------|--------------------------------------------| +| *required* | `type` | `string` | Parameter data type (see [Types](#types)). | +| *required* | `name` | `string` | Parameter name. | +| *optional* | `doc` | `string` | Parameter documentation. | + +Notes: + +1. Variadic (vararg) parameters are not supported. Each definition must declare a fixed number of parameters. +2. Parameterized signatures are not supported (e.g., ` array_agg(E)`). +3. All types, including parameters and return types, are considered nullable. + +#### Types +[iceberg-type-json]: https://iceberg.apache.org/spec/#appendix-c-json-serialization +Types are based on the [Iceberg Type](https://iceberg.apache.org/spec/#schemas-and-data-types). + +Primitive and semi-structured type strings are encoded based on [Iceberg Type JSON Representation][iceberg-type-json] +(e.g., `int`, `string`, `timestamp`, `decimal(9,2)`, `variant`). Type strings must contain no spaces or quote characters. + +Nested types (`struct`, `list`, `map`) use the [Iceberg Type JSON Representation][iceberg-type-json] with the +following fields required. Any other fields must be ignored. + +* `list` requires `type` and `element`, e.g., `{ "type": "list", "element": "string" }` +* `map` requires `type`, `key`, and `value`, e.g., `{ "type": "map", "key": "string", "value": "int" }` +* `struct` requires `type` and `fields`, where each field requires `name` and `type`, + e.g., `{ "type": "struct", "fields": [ { "name": "id", "type": "int" }, { "name": "name", "type": "string" } ] }` + +#### Definition ID +The `definition-id` is a canonical string derived from the parameter types, formatted as a comma-separated list with no +spaces. Each type uses the following string representation: + +* Primitives and semi-structured: the type name (e.g., `int`, `variant`) +* List: `list` (e.g., `list`) +* Map: `map` (e.g., `map`) +* Struct: `struct` with field names and types (e.g., `struct`) + +Examples of complete definition-id signatures: + +* `int` – single int parameter +* `int,string` – two parameters: int and string +* `int,list,struct` – three parameters: an int, a list and a struct + +### Definition Version + +Each definition can evolve over time by introducing new versions. +A `definition version` represents a specific implementation of that definition at a given point in time. + +| Requirement | Field name | Type | Description | +|-------------|-------------------|----------------------------------------------------------|----------------------------------------------------------------| +| *required* | `version-id` | `int` | Monotonically increasing identifier of the definition version. | +| *required* | `representations` | `list` | [UDF implementations](#representation). | +| *optional* | `deterministic` | `boolean` (default `false`) | Whether the function is deterministic. | +| *optional* | `on-null-input` | `string` (`"return-null"` or `"call"`, default `"call"`) | Defines how the UDF behaves when any input parameter is NULL. | +| *required* | `timestamp-ms` | `long` (unix epoch millis) | Creation timestamp of this version. | + +#### Null Input Handling +`on-null-input` provides an optimization hint for query engines: + +1. If set to `return-null`, the function always returns `NULL` if any input argument is `NULL`. This allows engines to + apply predicate pushdown or skip function evaluation for rows with `NULL` inputs. For a function `f(x, y) = x + y`, + the engine can safely rewrite `WHERE f(a,b) > 0` as `WHERE a IS NOT NULL AND b IS NOT NULL AND f(a,b) > 0`. +2. If set to `call`, the function may handle `NULL`s internally (e.g., `COALESCE`, `NVL`, `IFNULL`), so the engine must + execute the function even if some inputs are `NULL`. + +### Representation +Each representation is an object with at least one common field, `type`, that is one of the following: + +* `sql`: a SQL expression that defines the function body + +Representations further define metadata for each type. + +#### SQL Representation + +A definition version can have multiple SQL representations of different dialects, but only one SQL representation per dialect. +The SQL representation stores the function body as a SQL expression, with metadata such as the SQL dialect. + +| Requirement | Field name | Type | Description | +|-------------|------------|----------|------------------------------------------------------| +| *required* | `type` | `string` | Must be `"sql"` | +| *required* | `dialect` | `string` | SQL dialect identifier (e.g., `"spark"`, `"trino"`). | +| *required* | `sql` | `string` | SQL expression text. | + +Notes: + +1. The `sql` must reference parameters using the names declared in the definition's `parameters` field. + +### Definition Log +| Requirement | Field name | Type | Description | +|-------------|-----------------------|-----------------------------------------------------|-------------------------------------------------------------------------| +| *required* | `timestamp-ms` | `long` (unix epoch millis) | Timestamp when the function was updated to use the definition versions. | +| *required* | `definition-versions` | `list>` | Mapping of each definition to its selected version at this time. | + +## Function Call Convention and Resolution in Engines +Selecting the definition of a function to use is delegated to engines, which may apply their own casting rules. However, engines should: + +1. Prefer exact parameter matches over safe (widening) or unsafe casts. +2. Safely widen types as needed to avoid failing to find a matching definition. +3. Require explicit casts for unsafe or non-obvious conversions. +4. Use definitions with the same number of arguments as the input. +5. Pass positional arguments in the same position as the input. +6. Use definitions with the same set of field names as named input arguments. + +## Appendix A: Example – Overloaded Scalar Function + +SQL statement: +```sql +-- Trino SQL +CREATE FUNCTION add_one(x INT COMMENT 'Input integer') +COMMENT 'Add one to the input integer' +RETURNS INT +RETURN x + 1; + +-- Trino SQL +CREATE FUNCTION add_one(x FLOAT COMMENT 'Input float') +COMMENT 'Add one to the input float' +RETURNS FLOAT +RETURN x + 1.0; + +-- Spark SQL +CREATE OR REPLACE FUNCTION add_one(x FLOAT) +RETURNS FLOAT +RETURN x + 1.0; +``` + +```json +{ + "function-uuid": "42fd3f91-bc10-41c1-8a52-92b57dd0a9b2", + "format-version": 1, + "definitions": [ + { + "definition-id": "int", + "parameters": [ + { + "name": "x", "type": "int", "doc": "Input integer" + } + ], + "return-type": "int", + "function-type": "udf", + "doc": "Add one to the input integer", + "versions": [ + { + "version-id": 1, + "deterministic": true, + "representations": [ + { "type": "sql", "dialect": "trino", "sql": "x + 2" } + ], + "timestamp-ms": 1734507000123 + }, + { + "version-id": 2, + "deterministic": true, + "representations": [ + { "type": "sql", "dialect": "trino", "sql": "x + 1" }, + { "type": "sql", "dialect": "spark", "sql": "x + 1" } + ], + "timestamp-ms": 1735507000124 + } + ], + "current-version-id": 2 + }, + { + "definition-id": "float", + "parameters": [ + { + "name": "x", "type": "float", "doc": "Input float" + } + ], + "return-type": "float", + "function-type": "udf", + "doc": "Add one to the input float", + "versions": [ + { + "version-id": 1, + "deterministic": true, + "representations": [ + { "type": "sql", "dialect": "trino", "sql": "x + 1.0" } + ], + "timestamp-ms": 1734507001123 + } + ], + "current-version-id": 1 + } + ], + "definition-log": [ + { + "timestamp-ms": 1734507000123, + "definition-versions": [ + { "definition-id": "int", "version-id": 1 } + ] + }, + { + "timestamp-ms": 1734507001123, + "definition-versions": [ + { "definition-id": "int", "version-id": 1 }, + { "definition-id": "float", "version-id": 1 } + ] + }, + { + "timestamp-ms": 1735507000124, + "definition-versions": [ + { "definition-id": "int", "version-id": 2 }, + { "definition-id": "float", "version-id": 1 } + ] + } + ], + "doc": "Overloaded scalar UDF for integer and float inputs", + "secure": false +} +``` + +## Appendix B: UDTF Example +SQL statement: + +```sql +CREATE FUNCTION fruits_by_color(c VARCHAR COMMENT 'Color of fruits') + COMMENT 'Return fruits of specific color from fruits table' +RETURNS TABLE (name VARCHAR, color VARCHAR) +RETURN SELECT name, color FROM fruits WHERE color = c; +``` + +```json +{ + "function-uuid": "8a7fa39a-6d8f-4a2f-9d8d-3f3a8f3c2a10", + "format-version": 1, + "definitions": [ + { + "definition-id": "string", + "parameters": [ + { + "name": "c", "type": "string", "doc": "Color of fruits" + } + ], + "return-type": { + "type": "struct", + "fields": [ + { "name": "name", "type": "string" }, + { "name": "color", "type": "string" } + ] + }, + "function-type": "udtf", + "doc": "Return fruits of a specific color from the fruits table", + "versions": [ + { + "version-id": 1, + "deterministic": true, + "representations": [ + { "type": "sql", "dialect": "trino", "sql": "SELECT name, color FROM fruits WHERE color = c" }, + { "type": "sql", "dialect": "spark", "sql": "SELECT name, color FROM fruits WHERE color = c" } + ], + "timestamp-ms": 1734508000123 + } + ], + "current-version-id": 1 + } + ], + "definition-log": [ + { + "timestamp-ms": 1734508000123, + "definition-versions": [ + { "definition-id": "string", "version-id": 1 } + ] + } + ], + "doc": "UDTF returning (name, color) rows filtered by the given color", + "secure": false +} +``` diff --git a/format/view-spec.md b/format/view-spec.md index fa331aa31083..34a671454778 100644 --- a/format/view-spec.md +++ b/format/view-spec.md @@ -188,7 +188,7 @@ The path is intentionally similar to the path for Iceberg tables and uses a `met ``` s3://bucket/warehouse/default.db/event_agg/metadata/00001-(uuid).metadata.json ``` -``` +```json { "view-uuid": "fa6506c3-7681-40c8-86dc-e36561f83385", "format-version" : 1, @@ -257,7 +257,7 @@ Updating the view produces a new metadata file that completely replaces the old: ``` s3://bucket/warehouse/default.db/event_agg/metadata/00002-(uuid).metadata.json ``` -``` +```json { "view-uuid": "fa6506c3-7681-40c8-86dc-e36561f83385", "format-version" : 1, diff --git a/gcp-bundle/LICENSE b/gcp-bundle/LICENSE index 66d297673446..54822a830a72 100644 --- a/gcp-bundle/LICENSE +++ b/gcp-bundle/LICENSE @@ -203,1077 +203,1912 @@ -------------------------------------------------------------------------------- -This binary artifact contains: +This product bundles Jackson JSON Processor. --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.18.2 -Project URL: https://github.com/FasterXML/jackson-core -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.18.2 Project URL: https://github.com/FasterXML/jackson -License: The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.18.2 -Project URL: https://github.com/FasterXML/jackson -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles FastDoubleParser (via Jackson JSON Processor). --------------------------------------------------------------------------------- +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT -Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.18.2 -Project URL: https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310 -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles fast_float (bundled by FastDoubleParser). + +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT --------------------------------------------------------------------------------- +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles bigint (bundled by FastDoubleParser). + +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Android Annotations. -Group: com.google.android Name: annotations Version: 4.1.1.4 Project URL: http://source.android.com/ -License: Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api Name: api-common Version: 2.52.0 -License: BSD-3-Clause -Copyright 2016, Google Inc. -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +This product bundles Google API Common. --------------------------------------------------------------------------------- +Project URL: https://github.com/googleapis/api-common-java +License: BSD 3-Clause -Group: com.google.api Name: gax Version: 2.69.0 -Project URL: https://github.com/googleapis/gax-java -License: BSD-3-Clause -Copyright 2016, Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +| Copyright 2016, Google Inc. +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: com.google.api Name: gax-grpc Version: 2.69.0 -Project URL: https://github.com/googleapis/gax-java -License: BSD-3-Clause -Copyright 2016, Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +This product bundles Google GAX. --------------------------------------------------------------------------------- - -Group: com.google.api Name: gax-httpjson Version: 2.69.0 Project URL: https://github.com/googleapis/gax-java -License: BSD-3-Clause -Copyright 2016, Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - --------------------------------------------------------------------------------- +License: BSD 3-Clause + +| Copyright 2016, Google Inc. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Google API Client. -Group: com.google.api-client Name: google-api-client Version: 2.7.2 Project URL: https://developers.google.com/api-client-library/java/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: gapic-google-cloud-storage-v2 Version: 2.55.0 -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: grpc-google-cloud-storage-v2 Version: 2.55.0 -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-cloud-storage-v2 Version: 2.55.0 -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-common-protos Version: 2.60.0 -Project URL: https://github.com/googleapis/sdk-platform-java -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-iam-v1 Version: 1.55.0 -Project URL: https://github.com/googleapis/sdk-platform-java -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: grpc-google-cloud-bigquerystorage-v1 Version: 3.16.2 -Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: grpc-google-cloud-bigquerystorage-v1beta1 Version: 0.188.2 -Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: grpc-google-cloud-bigquerystorage-v1beta2 Version: 0.188.2 -Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api.grpc Name: proto-google-cloud-bigquerystorage-v1 Version: 3.16.2 -Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google API gRPC. --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-cloud-bigquerystorage-v1alpha Version: 3.16.2 Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api.grpc Name: proto-google-cloud-bigquerystorage-v1beta Version: 3.16.2 -Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google APIs. -Group: com.google.api.grpc Name: proto-google-cloud-bigquerystorage-v1beta1 Version: 0.188.2 -Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/googleapis/googleapis +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api.grpc Name: proto-google-cloud-bigquerystorage-v1beta2 Version: 0.188.2 -Project URL: https://github.com/googleapis/java-bigquerystorage/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google Cloud APIs for Java. --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-cloud-monitoring-v3 Version: 3.73.0 Project URL: https://github.com/googleapis/google-cloud-java -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-cloud-storage-v2 Version: 2.55.0 -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.apis Name: google-api-services-storage Version: v1-rev20250718-2.0.0 -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.apis Name: google-api-services-bigquery Version: v2-rev20250706-2.0.0 -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.auth Name: google-auth-library-credentials Version: 1.37.1 -License: BSD New license -Copyright 2014, Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.auth Name: google-auth-library-oauth2-http Version: 1.37.1 -License: BSD New license -Copyright 2014, Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +This product bundles Google Auth Library. + +Project URL: https://github.com/googleapis/google-auth-library-java +License: BSD 3-Clause + +| Copyright 2014, Google Inc. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Google Auto Value. --------------------------------------------------------------------------------- - -Group: com.google.auto.value Name: auto-value-annotations Version: 1.11.0 Project URL: https://github.com/google/auto/tree/master/value -License: Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.auto.value Name: auto-value Version: 1.11.0 -Project URL: https://github.com/google/auto/tree/main/value -License: Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.escapevelocity Name: escapevelocity Version: 0.9 -Project URL: https://github.com/google/escapevelocity -License: Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.squareup Name: javapoet Version: 1.13.0 -Project URL: https://github.com/square/javapoet -License: Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.ow2.asm Name: asm Version: 9.2 -Project URL: https://asm.ow2.io/ -License: BSD-3-Clause - -Copyright (c) 2000-2011 INRIA, France Telecom -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - -Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright -notice, this list of conditions and the following disclaimer in the -documentation and/or other materials provided with the distribution. - -Neither the name of the copyright holders nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF -THE POSSIBILITY OF SUCH DAMAGE. - --------------------------------------------------------------------------------- +This product bundles GCS Analytics. -Group: com.google.cloud Name: google-cloud-core Version: 2.59.0 -Project URL: https://github.com/googleapis/java-core -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/GoogleCloudPlatform/gcs-analytics-core +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.cloud Name: google-cloud-core-grpc Version: 2.59.0 -Project URL: https://github.com/googleapis/java-core -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google Cloud APIs. --------------------------------------------------------------------------------- - -Group: com.google.cloud Name: google-cloud-core-http Version: 2.59.0 Project URL: https://github.com/googleapis/java-core -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.cloud Name: google-cloud-storage Version: 2.55.0 -Project URL: https://github.com/googleapis/java-storage -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.cloud Name: google-cloud-bigquery Version: 2.54.1 -Project URL: https://github.com/googleapis/java-bigquery -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.cloud Name: google-cloud-bigquerystorage Version: 3.16.2 -Project URL: https://github.com/googleapis/java-bigquerystorage -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.cloud Name: google-cloud-monitoring Version: 3.73.0 -Project URL: https://github.com/googleapis/google-cloud-java -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.cloud.opentelemetry Name: detector-resources-support Version: 0.33.0 -Project URL: https://github.com/GoogleCloudPlatform/opentelemetry-operations-java -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Cloud Open-Telemetry Operations Exporters for Java. -Group: com.google.cloud.opentelemetry Name: exporter-metrics Version: 0.33.0 Project URL: https://github.com/GoogleCloudPlatform/opentelemetry-operations-java -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.cloud.opentelemetry Name: shared-resourcemapping Version: 0.33.0 -Project URL: https://github.com/GoogleCloudPlatform/opentelemetry-operations-java -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Gson. -Group: com.google.code.gson Name: gson Version: 2.12.1 Project URL: https://github.com/google/gson/ -License: Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.code.findbugs Name: jsr305 Version: 3.0.2 -Project URL: http://findbugs.sourceforge.net/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Error Prone Annotations. -Group: com.google.errorprone Name: error_prone_annotations Version: 2.38.0 Project URL: https://github.com/google/error-prone -License: Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.flatbuffers Name: flatbuffers-java Version: 24.3.25 +This product bundles Google flatbuffers. + Project URL: https://github.com/google/flatbuffers -License: Apache License V2.0 - https://raw.githubusercontent.com/google/flatbuffers/master/LICENSE +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.guava Name: failureaccess Version: 1.0.2 -Project URL: https://github.com/google/guava/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Guava. -Group: com.google.guava Name: guava Version: 33.4.0-jre Project URL: https://github.com/google/guava/ -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.guava Name: listenablefuture Version: 9999.0-empty-to-avoid-conflict-with-guava -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles the Mozilla Public Suffix List (via Google Guava). + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + +This product bundles Google Http Client. -Group: com.google.http-client Name: google-http-client Version: 1.47.1 Project URL: https://www.google.com/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.http-client Name: google-http-client-apache-v2 Version: 1.47.1 -Project URL: https://www.google.com/ -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.http-client Name: google-http-client-appengine Version: 1.47.1 -Project URL: https://github.com/googleapis/google-http-java-client -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.http-client Name: google-http-client-gson Version: 1.47.1 -Project URL: https://github.com/googleapis/google-http-java-client -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.http-client Name: google-http-client-jackson2 Version: 1.47.1 -Project URL: https://github.com/googleapis/google-http-java-client -License: The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google j2objc. --------------------------------------------------------------------------------- - -Group: com.google.j2objc Name: j2objc-annotations Version: 3.0.0 Project URL: https://github.com/google/j2objc/ -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.oauth-client Name: google-oauth-client Version: 1.39.0 +This product bundles Google OAuth Client. + Project URL: https://www.google.com/ -License: The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.protobuf Name: protobuf-java Version: 4.29.4 -Project URL: https://developers.google.com/protocol-buffers/ -License: BSD-3-Clause -Copyright 2008 Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Code generated by the Protocol Buffer compiler is owned by the owner -of the input file used when generating it. This code is not -standalone and requires a support library to be linked with it. This -support library is itself covered by the above license. +This product bundles Google protobuf. --------------------------------------------------------------------------------- - -Group: com.google.protobuf Name: protobuf-java-util Version: 4.29.4 Project URL: https://developers.google.com/protocol-buffers/ -License: BSD-3-Clause -Copyright 2008 Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Code generated by the Protocol Buffer compiler is owned by the owner -of the input file used when generating it. This code is not -standalone and requires a support library to be linked with it. This -support library is itself covered by the above license. +License: BSD 3-Clause + +| Copyright 2008 Google Inc. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +| +| Code generated by the Protocol Buffer compiler is owned by the owner +| of the input file used when generating it. This code is not +| standalone and requires a support library to be linked with it. This +| support library is itself covered by the above license. + +-------------------------------------------------------------------------------- + +This product bundles Google re2j. --------------------------------------------------------------------------------- - -Group: com.google.re2j Name: re2j Version: 1.8 Project URL: http://github.com/google/re2j License: Go License -This is a work derived from Russ Cox's RE2 in Go, whose license -http://golang.org/LICENSE is as follows: - -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in - the documentation and/or other materials provided with the - distribution. - - * Neither the name of Google Inc. nor the names of its contributors - may be used to endorse or promote products derived from this - software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --------------------------------------------------------------------------------- +| This is a work derived from Russ Cox's RE2 in Go, whose license +| http://golang.org/LICENSE is as follows: +| +| Copyright (c) 2009 The Go Authors. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in +| the documentation and/or other materials provided with the +| distribution. +| +| * Neither the name of Google Inc. nor the names of its contributors +| may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Commons Codec. -Group: commons-codec Name: commons-codec Version: 1.18.0 Project URL: https://commons.apache.org/proper/commons-codec/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-alts Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-api Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-auth Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-context Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-core Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-googleapis Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-grpclb Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-netty-shaded Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-protobuf Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-protobuf-lite Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-rls Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Apache Commons Lang. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-services Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://commons.apache.org/proper/commons-lang/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-stub Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles gRPC. -Group: io.grpc Name: grpc-xds Version: 1.71.0 Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-inprocess Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Netty. -Group: io.grpc Name: grpc-opentelemetry Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://netty.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-util Version: 1.71.0 -Project URL: https://github.com/grpc/grpc-java -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Tomcat Native (statically linked into netty-tcnative, bundled by gRPC-netty-shaded). -Group: io.netty Name: netty-buffer Version: 4.1.110.Final -Project URL: https://netty.io/ +Project URL: https://tomcat.apache.org/native-doc/ License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-common Version: 4.1.110.Final -Project URL: https://netty.io/ -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles BoringSSL (statically linked into netty-tcnative-boringssl-static, bundled by gRPC-netty-shaded). + +Project URL: https://boringssl.googlesource.com/boringssl/ +License: Apache License, Version 2.0 (with portions under the Go License - BSD 3-Clause) - https://boringssl.googlesource.com/boringssl/+/HEAD/LICENSE + +| +| Licenses for support code +| ------------------------- +| +| Parts of the TLS test suite are under the Go license. This code is not included +| in BoringSSL (i.e. libcrypto and libssl) when compiled, however, so +| distributing code linked against BoringSSL does not trigger this license: +| +| Copyright (c) 2009 The Go Authors. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles OpenCensus. --------------------------------------------------------------------------------- - -Group: io.opencensus Name: opencensus-api Version: 0.31.1 Project URL: https://github.com/census-instrumentation/opencensus-java -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opencensus Name: opencensus-contrib-http-util Version: 0.31.1 -Project URL: https://github.com/census-instrumentation/opencensus-java -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Perfmark. -Group: io.perfmark Name: perfmark-api Version: 0.27.0 Project URL: https://github.com/perfmark/perfmark -License: Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: javax.annotation Name: javax.annotation-api Version: 1.3.2 +This product bundles javax.annotation-api. + Project URL: https://javaee.github.io/glassfish Project URL: http://jcp.org/en/jsr/detail?id=250 -License: CDDL + GPLv2 with classpath exception - https://github.com/javaee/javax.annotation/blob/master/LICENSE - --------------------------------------------------------------------------------- +License: CDDL 1.1 - https://github.com/javaee/javax.annotation/blob/master/LICENSE + +| COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.1 +| +| 1. Definitions. +| +| 1.1. "Contributor" means each individual or entity that creates or +| contributes to the creation of Modifications. +| +| 1.2. "Contributor Version" means the combination of the Original +| Software, prior Modifications used by a Contributor (if any), and +| the Modifications made by that particular Contributor. +| +| 1.3. "Covered Software" means (a) the Original Software, or (b) +| Modifications, or (c) the combination of files containing Original +| Software with files containing Modifications, in each case including +| portions thereof. +| +| 1.4. "Executable" means the Covered Software in any form other than +| Source Code. +| +| 1.5. "Initial Developer" means the individual or entity that first +| makes Original Software available under this License. +| +| 1.6. "Larger Work" means a work which combines Covered Software or +| portions thereof with code not governed by the terms of this License. +| +| 1.7. "License" means this document. +| +| 1.8. "Licensable" means having the right to grant, to the maximum +| extent possible, whether at the time of the initial grant or +| subsequently acquired, any and all of the rights conveyed herein. +| +| 1.9. "Modifications" means the Source Code and Executable form of +| any of the following: +| +| A. Any file that results from an addition to, deletion from or +| modification of the contents of a file containing Original Software +| or previous Modifications; +| +| B. Any new file that contains any part of the Original Software or +| previous Modification; or +| +| C. Any new file that is contributed or otherwise made available +| under the terms of this License. +| +| 1.10. "Original Software" means the Source Code and Executable form +| of computer software code that is originally released under this +| License. +| +| 1.11. "Patent Claims" means any patent claim(s), now owned or +| hereafter acquired, including without limitation, method, process, +| and apparatus claims, in any patent Licensable by grantor. +| +| 1.12. "Source Code" means (a) the common form of computer software +| code in which modifications are made and (b) associated +| documentation included in or with such code. +| +| 1.13. "You" (or "Your") means an individual or a legal entity +| exercising rights under, and complying with all of the terms of, +| this License. For legal entities, "You" includes any entity which +| controls, is controlled by, or is under common control with You. +| For purposes of this definition, "control" means (a) the power, +| direct or indirect, to cause the direction or management of such +| entity, whether by contract or otherwise, or (b) ownership of more +| than fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants. +| +| 2.1. The Initial Developer Grant. +| +| Conditioned upon Your compliance with Section 3.1 below and subject +| to third party intellectual property claims, the Initial Developer +| hereby grants You a world-wide, royalty-free, non-exclusive license: +| +| (a) under intellectual property rights (other than patent or +| trademark) Licensable by Initial Developer, to use, reproduce, +| modify, display, perform, sublicense and distribute the Original +| Software (or portions thereof), with or without Modifications, +| and/or as part of a Larger Work; and +| +| (b) under Patent Claims infringed by the making, using or selling +| of Original Software, to make, have made, use, practice, sell, and +| offer for sale, and/or otherwise dispose of the Original Software +| (or portions thereof). +| +| (c) The licenses granted in Sections 2.1(a) and (b) are effective +| on the date Initial Developer first distributes or otherwise makes +| the Original Software available to a third party under the terms of +| this License. +| +| (d) Notwithstanding Section 2.1(b) above, no patent license is +| granted: (1) for code that You delete from the Original Software, +| or (2) for infringements caused by: (i) the modification of the +| Original Software, or (ii) the combination of the Original Software +| with other software or devices. +| +| 2.2. Contributor Grant. +| +| Conditioned upon Your compliance with Section 3.1 below and subject +| to third party intellectual property claims, each Contributor hereby +| grants You a world-wide, royalty-free, non-exclusive license: +| +| (a) under intellectual property rights (other than patent or +| trademark) Licensable by Contributor to use, reproduce, modify, +| display, perform, sublicense and distribute the Modifications +| created by such Contributor (or portions thereof), either on an +| unmodified basis, with other Modifications, as Covered Software +| and/or as part of a Larger Work; and +| +| (b) under Patent Claims infringed by the making, using, or selling +| of Modifications made by that Contributor either alone and/or in +| combination with its Contributor Version (or portions of such +| combination), to make, use, sell, offer for sale, have made, and/or +| otherwise dispose of: (1) Modifications made by that Contributor +| (or portions thereof); and (2) the combination of Modifications +| made by that Contributor with its Contributor Version (or portions +| of such combination). +| +| (c) The licenses granted in Sections 2.2(a) and 2.2(b) are +| effective on the date Contributor first makes Commercial Use of the +| Covered Software. +| +| (d) Notwithstanding Section 2.2(b) above, no patent license is +| granted: (1) for any code that Contributor has deleted from the +| Contributor Version; (2) for infringements caused by: (i) third +| party modifications of Contributor Version, or (ii) the combination +| of Modifications made by that Contributor with other software +| (except as part of the Contributor Version) or other devices; or +| (3) under Patent Claims infringed by Covered Software in the +| absence of Modifications made by that Contributor. +| +| 3. Distribution Obligations. +| +| 3.1. Availability of Source Code. +| +| Any Covered Software that You distribute or otherwise make available +| in Executable form must also be made available in Source Code form +| and that Source Code form must be distributed only under the terms +| of this License. You must include a copy of this License with every +| copy of the Source Code form of the Covered Software You distribute +| or otherwise make available. You must inform recipients of any such +| Covered Software in Executable form as to how they can obtain such +| Covered Software in Source Code form in a reasonable manner on or +| through a medium customarily used for software exchange. +| +| 3.2. Modifications. +| +| The Modifications that You create or to which You contribute are +| governed by the terms of this License. You represent that You +| believe Your Modifications are Your original creation(s) and/or You +| have sufficient rights to grant the rights conveyed by this License. +| +| 3.3. Required Notices. +| +| You must include a notice in each of Your Modifications that +| identifies You as the Contributor of the Modification. You may not +| remove or alter any copyright, patent or trademark notices contained +| within the Covered Software, or any notices of licensing or any +| descriptive text giving attribution to any Contributor or the +| Initial Developer. +| +| 3.4. Application of Additional Terms. +| +| You may not offer or impose any terms on any Covered Software in +| Source Code form that alters or restricts the applicable version of +| this License or the recipients' rights hereunder. You may choose to +| offer, and to charge a fee for, warranty, support, indemnity or +| liability obligations to one or more recipients of Covered Software. +| However, you may do so only on Your own behalf, and not on behalf of +| the Initial Developer or any Contributor. You must make it absolutely +| clear that any such warranty, support, indemnity or liability +| obligation is offered by You alone, and You hereby agree to indemnify +| the Initial Developer and every Contributor for any liability +| incurred by the Initial Developer or such Contributor as a result of +| warranty, support, indemnity or liability terms You offer. +| +| 3.5. Distribution of Executable Versions. +| +| You may distribute the Executable form of the Covered Software under +| the terms of this License or under the terms of a license of Your +| choice, which may contain terms different from this License, provided +| that You are in compliance with the terms of this License and that +| the license for the Executable form does not attempt to limit or +| alter the recipient's rights in the Source Code form from the rights +| set forth in this License. If You distribute the Covered Software in +| Executable form under a different license, You must make it +| absolutely clear that any terms which differ from this License are +| offered by You alone, not by the Initial Developer or Contributor. +| You hereby agree to indemnify the Initial Developer and every +| Contributor for any liability incurred by the Initial Developer or +| such Contributor as a result of any such terms You offer. +| +| 3.6. Larger Works. +| +| You may create a Larger Work by combining Covered Software with +| other code not governed by the terms of this License and distribute +| the Larger Work as a single product. In such a case, You must make +| sure the requirements of this License are fulfilled for the Covered +| Software. +| +| 4. Versions of the License. +| +| 4.1. New Versions. +| +| Oracle is the initial license steward and may publish revised and/or +| new versions of this License from time to time. Each version will be +| given a distinguishing version number. Except as provided in Section +| 4.3, no one other than the license steward has the right to modify +| this License. +| +| 4.2. Effect of New Versions. +| +| You may always continue to use, distribute or otherwise make the +| Covered Software available under the terms of the version of the +| License under which You originally received the Covered Software. If +| the Initial Developer includes a notice in the Original Software +| prohibiting it from being distributed or otherwise made available +| under any subsequent version of the License, You must distribute and +| make the Covered Software available under the terms of the version +| of the License under which You originally received the Covered +| Software. Otherwise, You may also choose to use, distribute or +| otherwise make the Covered Software available under the terms of any +| subsequent version of the License published by the license steward. +| +| 4.3. Modified Versions. +| +| When You are an Initial Developer and You want to create a new +| license for Your Original Software, You may create and use a +| modified version of this License if You: (a) rename the license and +| remove any references to the name of the license steward (except to +| note that the modified license differs from this License); and (b) +| otherwise make it clear that the license contains terms which differ +| from this License. +| +| 5. DISCLAIMER OF WARRANTY. +| +| COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS, +| WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, +| INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE COVERED SOFTWARE +| IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR +| NON-INFRINGING. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE +| OF THE COVERED SOFTWARE IS WITH YOU. SHOULD ANY COVERED SOFTWARE +| PROVE DEFECTIVE IN ANY RESPECT, YOU (NOT THE INITIAL DEVELOPER OR +| ANY OTHER CONTRIBUTOR) ASSUME THE COST OF ANY NECESSARY SERVICING, +| REPAIR OR CORRECTION. THIS DISCLAIMER OF WARRANTY CONSTITUTES AN +| ESSENTIAL PART OF THIS LICENSE. NO USE OF ANY COVERED SOFTWARE IS +| AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER. +| +| 6. TERMINATION. +| +| 6.1. This License and the rights granted hereunder will terminate +| automatically if You fail to comply with terms herein and fail to +| cure such breach within 30 days of becoming aware of the breach. +| Provisions which, by their nature, must remain in effect beyond the +| termination of this License shall survive. +| +| 6.2. If You assert a patent infringement claim (excluding +| declaratory judgment actions) against Initial Developer or a +| Contributor (the Initial Developer or Contributor against whom You +| assert such claim is referred to as "Participant") alleging that the +| Participant Software (meaning the Contributor Version where the +| Participant is a Contributor or the Original Software where the +| Participant is the Initial Developer) directly or indirectly +| infringes any patent, then any and all rights granted directly or +| indirectly to You by such Participant, the Initial Developer (if the +| Initial Developer is not the Participant) and all Contributors under +| Sections 2.1 and/or 2.2 of this License shall, upon 60 days notice +| from Participant terminate prospectively and automatically at the +| expiration of such 60 day notice period, unless if within such 60 +| day period You withdraw Your claim with respect to the Participant +| Software against such Participant either unilaterally or pursuant to +| a written agreement with Participant. +| +| 6.3. If You assert a patent infringement claim against Participant +| alleging that the Participant Software directly or indirectly +| infringes any patent where such claim is resolved (such as by +| license or settlement) prior to the initiation of patent +| infringement litigation, then the reasonable value of the licenses +| granted by such Participant under Sections 2.1 or 2.2 shall be +| taken into account in determining the amount or value of any payment +| or license. +| +| 6.4. In the event of termination under Sections 6.1 or 6.2 above, +| all end user licenses that have been validly granted by You or any +| distributor hereunder prior to termination (excluding licenses +| granted to You by any distributor) shall survive termination. +| +| 7. LIMITATION OF LIABILITY. +| +| UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT +| (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE +| INITIAL DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF +| COVERED SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE +| TO ANY PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR +| CONSEQUENTIAL DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT +| LIMITATION, DAMAGES FOR LOSS OF GOODWILL, WORK STOPPAGE, COMPUTER +| FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER COMMERCIAL DAMAGES OR +| LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN INFORMED OF THE +| POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF LIABILITY SHALL NOT +| APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY RESULTING FROM SUCH +| PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW PROHIBITS SUCH +| LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR +| LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THIS EXCLUSION +| AND LIMITATION MAY NOT APPLY TO YOU. +| +| 8. U.S. GOVERNMENT END USERS. +| +| The Covered Software is a "commercial item," as that term is defined +| in 48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer +| software" (as that term is defined at 48 C.F.R. 252.227-7014(a)(1)) +| and "commercial computer software documentation" as such terms are +| used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48 C.F.R. +| 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995), all +| U.S. Government End Users acquire Covered Software with only those +| rights set forth herein. This U.S. Government Rights clause is in +| lieu of, and supersedes, any other FAR, DFAR, or other clause or +| provision that addresses Government rights in computer software +| under this License. +| +| 9. MISCELLANEOUS. +| +| This License represents the complete agreement concerning subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. This License shall be governed by +| the law of the jurisdiction specified in a notice contained within +| the Original Software (except to the extent applicable law, if any, +| provides otherwise), excluding such jurisdiction's conflict-of-law +| provisions. Any litigation relating to this License shall be subject +| to the jurisdiction of the courts located in the jurisdiction and +| venue specified in a notice contained within the Original Software, +| with the losing party responsible for costs, including, without +| limitation, court costs and reasonable attorneys' fees and expenses. +| The application of the United Nations Convention on Contracts for +| the International Sale of Goods is expressly excluded. Any law or +| regulation which provides that the language of a contract shall be +| construed against the drafter shall not apply to this License. You +| agree that You alone are responsible for compliance with the United +| States export administration regulations (and the export control +| laws and regulation of any other countries) when You use, distribute +| or otherwise make available any Covered Software. +| +| 10. RESPONSIBILITY FOR CLAIMS. +| +| As between Initial Developer and the Contributors, each party is +| responsible for claims and damages arising, directly or indirectly, +| out of its utilization of rights under this License and You agree to +| work with Initial Developer and Contributors to distribute such +| responsibility on an equitable basis. Nothing herein is intended or +| shall be deemed to constitute any admission of liability. + +-------------------------------------------------------------------------------- + +This product bundles checkerframework checker-qual and checker-compat-qual. -Group: org.checkerframework Name: checker-qual Version: 3.33.0 Project URL: https://checkerframework.org/ -License: The MIT License - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: org.codehaus.mojo Name: animal-sniffer-annotations Version: 1.24 -License: MIT license -The MIT License - -Copyright (c) 2009 codehaus.org. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +License: MIT -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Animal Sniffer Annotations. + +Project URL: https://github.com/mojohaus/animal-sniffer +License: MIT --------------------------------------------------------------------------------- +| Copyright (c) 2009 codehaus.org. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Conscrypt (openjdk-uber). -Group: org.conscrypt Name: conscrypt-openjdk-uber Version: 2.5.2 Project URL: https://conscrypt.org/ -License: Apache 2 - https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.threeten Name: threetenbp Version: 1.7.0 -Project URL: https://www.threeten.org/threetenbp -License: BSD-3-Clause -Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. - -All rights reserved. +This product bundles ThreeTen BP. -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Project URL: https://www.threeten.org/threetenbp +License: BSD 3-Clause + +| Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| * Neither the name of JSR-310 nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles OpenTelemetry. -* Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. +Project URL: https://opentelemetry.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +-------------------------------------------------------------------------------- -* Neither the name of JSR-310 nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. +This product bundles Apache Arrow. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +Project URL: https://github.com/apache/arrow +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opentelemetry Name: opentelemetry-api Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Apache HttpComponents (core and client). -Group: io.opentelemetry Name: opentelemetry-context Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://hc.apache.org/httpcomponents-client-ga +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opentelemetry Name: opentelemetry-sdk Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles org.json. -Group: io.opentelemetry Name: opentelemetry-sdk-common Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/douglascrockford/JSON-java +License: Public Domain - https://github.com/stleary/JSON-java/blob/master/LICENSE -------------------------------------------------------------------------------- -Group: io.opentelemetry Name: opentelemetry-sdk-logs Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles ThreeTen Extra. + +Project URL: https://www.threeten.org/threeten-extra +License: BSD 3-Clause + +| Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. +| +| All rights reserved. +| +| * Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| * Neither the name of JSR-310 nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Envoy. + +Project URL: https://github.com/envoyproxy/envoy +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opentelemetry Name: opentelemetry-sdk-metrics Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles the Common Expression Language (CEL) specification (shaded by gRPC-xds). --------------------------------------------------------------------------------- - -Group: io.opentelemetry Name: opentelemetry-sdk-trace Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/cel-spec +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opentelemetry Name: opentelemetry-sdk-extension-autoconfigure-spi Version: 1.47.0 -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles xDS data plane API definitions (shaded by gRPC-xds). -Group: io.opentelemetry.contrib Name: opentelemetry-gcp-resources Version: 1.37.0-alpha -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/cncf/xds +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opentelemetry.semconv Name: opentelemetry-semconv Version: 1.27.0-alpha -Project URL: https://opentelemetry.io/ -License: The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles UDPA (Universal Data Plane API) definitions (shaded by gRPC-xds). -Group: org.apache.arrow Name: arrow-format Version: 17.0.0 -Project URL: https://github.com/apache/arrow -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/cncf/udpa +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.apache.arrow Name: arrow-memory-core Version: 17.0.0 -Project URL: https://github.com/apache/arrow -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles JSpecify. -Group: org.apache.arrow Name: arrow-memory-netty Version: 17.0.0 -Project URL: https://github.com/apache/arrow -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/jspecify/jspecify +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.apache.arrow Name: arrow-vector Version: 17.0.0 -Project URL: https://github.com/apache/arrow -License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Stax2 API. --------------------------------------------------------------------------------- +Project URL: http://github.com/FasterXML/stax2-api +License: BSD 2-Clause -Group: org.apache.httpcomponents Name: httpclient Version: 4.5.14 -Project URL: http://hc.apache.org/httpcomponents-client-ga -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +| Copyright (c) 2008+, FasterXML, LLC +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: org.apache.httpcomponents Name: httpcore Version: 4.4.16 -Project URL: http://hc.apache.org/httpcomponents-core-ga -License: Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Woodstox. -Group: org.checkerframework Name: checker-compat-qual Version: 2.5.6 -Project URL: https://checkerframework.org -License: The MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +Project URL: https://github.com/FasterXML/woodstox +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.checkerframework Name: checker-qual Version: 3.49.0 -Project URL: https://checkerframework.org/ -License: The MIT License -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - --------------------------------------------------------------------------------- +This product bundles JCTools (via Netty and OpenTelemetry). -Group: org.json Name: json Version: 20250107 -Project URL: https://github.com/douglascrockford/JSON-java -License: Public Domain - https://github.com/stleary/JSON-java/blob/master/LICENSE +Project URL: https://github.com/JCTools/JCTools +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.slf4j Name: slf4j-api Version: 2.0.17 -Project URL: http://www.slf4j.org -License: MIT -Copyright (c) 2004-2025 QOS.ch - All rights reserved. - - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files (the - "Software"), to deal in the Software without restriction, including - without limitation the rights to use, copy, modify, merge, publish, - distribute, sublicense, and/or sell copies of the Software, and to - permit persons to whom the Software is furnished to do so, subject to - the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE - LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +This product bundles WeakConcurrentMap (via OpenTelemetry). + +Copyright: 2014 Rafael Winterhalter +Project URL: https://github.com/raphw/weak-lock-free +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.threeten Name: threeten-extra Version: 1.8.0 -Project URL: https://www.threeten.org/threeten-extra -License: BSD 3-clause -Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. - -All rights reserved. - -* Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -* Neither the name of JSR-310 nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +This product bundles MSV xsdlib (bundled by Woodstox). + +Project URL: https://github.com/xmlark/msv +License: BSD 3-Clause + +| Copyright 2001-2013 Oracle and/or its affiliates. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| 1. Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| 3. Neither the name of the copyright holder nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles isorelax (bundled by Woodstox). + +Project URL: https://github.com/relaxng/jing-trang +License: CDDL 1.1 + +| COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.1 +| +| 1. Definitions. +| +| 1.1. "Contributor" means each individual or entity that creates or +| contributes to the creation of Modifications. +| +| 1.2. "Contributor Version" means the combination of the Original +| Software, prior Modifications used by a Contributor (if any), and +| the Modifications made by that particular Contributor. +| +| 1.3. "Covered Software" means (a) the Original Software, or (b) +| Modifications, or (c) the combination of files containing Original +| Software with files containing Modifications, in each case including +| portions thereof. +| +| 1.4. "Executable" means the Covered Software in any form other than +| Source Code. +| +| 1.5. "Initial Developer" means the individual or entity that first +| makes Original Software available under this License. +| +| 1.6. "Larger Work" means a work which combines Covered Software or +| portions thereof with code not governed by the terms of this License. +| +| 1.7. "License" means this document. +| +| 1.8. "Licensable" means having the right to grant, to the maximum +| extent possible, whether at the time of the initial grant or +| subsequently acquired, any and all of the rights conveyed herein. +| +| 1.9. "Modifications" means the Source Code and Executable form of +| any of the following: +| +| A. Any file that results from an addition to, deletion from or +| modification of the contents of a file containing Original Software +| or previous Modifications; +| +| B. Any new file that contains any part of the Original Software or +| previous Modification; or +| +| C. Any new file that is contributed or otherwise made available +| under the terms of this License. +| +| 1.10. "Original Software" means the Source Code and Executable form +| of computer software code that is originally released under this +| License. +| +| 1.11. "Patent Claims" means any patent claim(s), now owned or +| hereafter acquired, including without limitation, method, process, +| and apparatus claims, in any patent Licensable by grantor. +| +| 1.12. "Source Code" means (a) the common form of computer software +| code in which modifications are made and (b) associated +| documentation included in or with such code. +| +| 1.13. "You" (or "Your") means an individual or a legal entity +| exercising rights under, and complying with all of the terms of, +| this License. For legal entities, "You" includes any entity which +| controls, is controlled by, or is under common control with You. +| For purposes of this definition, "control" means (a) the power, +| direct or indirect, to cause the direction or management of such +| entity, whether by contract or otherwise, or (b) ownership of more +| than fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants. +| +| 2.1. The Initial Developer Grant. +| +| Conditioned upon Your compliance with Section 3.1 below and subject +| to third party intellectual property claims, the Initial Developer +| hereby grants You a world-wide, royalty-free, non-exclusive license: +| +| (a) under intellectual property rights (other than patent or +| trademark) Licensable by Initial Developer, to use, reproduce, +| modify, display, perform, sublicense and distribute the Original +| Software (or portions thereof), with or without Modifications, +| and/or as part of a Larger Work; and +| +| (b) under Patent Claims infringed by the making, using or selling +| of Original Software, to make, have made, use, practice, sell, and +| offer for sale, and/or otherwise dispose of the Original Software +| (or portions thereof). +| +| (c) The licenses granted in Sections 2.1(a) and (b) are effective +| on the date Initial Developer first distributes or otherwise makes +| the Original Software available to a third party under the terms of +| this License. +| +| (d) Notwithstanding Section 2.1(b) above, no patent license is +| granted: (1) for code that You delete from the Original Software, +| or (2) for infringements caused by: (i) the modification of the +| Original Software, or (ii) the combination of the Original Software +| with other software or devices. +| +| 2.2. Contributor Grant. +| +| Conditioned upon Your compliance with Section 3.1 below and subject +| to third party intellectual property claims, each Contributor hereby +| grants You a world-wide, royalty-free, non-exclusive license: +| +| (a) under intellectual property rights (other than patent or +| trademark) Licensable by Contributor to use, reproduce, modify, +| display, perform, sublicense and distribute the Modifications +| created by such Contributor (or portions thereof), either on an +| unmodified basis, with other Modifications, as Covered Software +| and/or as part of a Larger Work; and +| +| (b) under Patent Claims infringed by the making, using, or selling +| of Modifications made by that Contributor either alone and/or in +| combination with its Contributor Version (or portions of such +| combination), to make, use, sell, offer for sale, have made, and/or +| otherwise dispose of: (1) Modifications made by that Contributor +| (or portions thereof); and (2) the combination of Modifications +| made by that Contributor with its Contributor Version (or portions +| of such combination). +| +| (c) The licenses granted in Sections 2.2(a) and 2.2(b) are +| effective on the date Contributor first makes Commercial Use of the +| Covered Software. +| +| (d) Notwithstanding Section 2.2(b) above, no patent license is +| granted: (1) for any code that Contributor has deleted from the +| Contributor Version; (2) for infringements caused by: (i) third +| party modifications of Contributor Version, or (ii) the combination +| of Modifications made by that Contributor with other software +| (except as part of the Contributor Version) or other devices; or +| (3) under Patent Claims infringed by Covered Software in the +| absence of Modifications made by that Contributor. +| +| 3. Distribution Obligations. +| +| 3.1. Availability of Source Code. +| +| Any Covered Software that You distribute or otherwise make available +| in Executable form must also be made available in Source Code form +| and that Source Code form must be distributed only under the terms +| of this License. You must include a copy of this License with every +| copy of the Source Code form of the Covered Software You distribute +| or otherwise make available. You must inform recipients of any such +| Covered Software in Executable form as to how they can obtain such +| Covered Software in Source Code form in a reasonable manner on or +| through a medium customarily used for software exchange. +| +| 3.2. Modifications. +| +| The Modifications that You create or to which You contribute are +| governed by the terms of this License. You represent that You +| believe Your Modifications are Your original creation(s) and/or You +| have sufficient rights to grant the rights conveyed by this License. +| +| 3.3. Required Notices. +| +| You must include a notice in each of Your Modifications that +| identifies You as the Contributor of the Modification. You may not +| remove or alter any copyright, patent or trademark notices contained +| within the Covered Software, or any notices of licensing or any +| descriptive text giving attribution to any Contributor or the +| Initial Developer. +| +| 3.4. Application of Additional Terms. +| +| You may not offer or impose any terms on any Covered Software in +| Source Code form that alters or restricts the applicable version of +| this License or the recipients' rights hereunder. You may choose to +| offer, and to charge a fee for, warranty, support, indemnity or +| liability obligations to one or more recipients of Covered Software. +| However, you may do so only on Your own behalf, and not on behalf of +| the Initial Developer or any Contributor. You must make it absolutely +| clear that any such warranty, support, indemnity or liability +| obligation is offered by You alone, and You hereby agree to indemnify +| the Initial Developer and every Contributor for any liability +| incurred by the Initial Developer or such Contributor as a result of +| warranty, support, indemnity or liability terms You offer. +| +| 3.5. Distribution of Executable Versions. +| +| You may distribute the Executable form of the Covered Software under +| the terms of this License or under the terms of a license of Your +| choice, which may contain terms different from this License, provided +| that You are in compliance with the terms of this License and that +| the license for the Executable form does not attempt to limit or +| alter the recipient's rights in the Source Code form from the rights +| set forth in this License. If You distribute the Covered Software in +| Executable form under a different license, You must make it +| absolutely clear that any terms which differ from this License are +| offered by You alone, not by the Initial Developer or Contributor. +| You hereby agree to indemnify the Initial Developer and every +| Contributor for any liability incurred by the Initial Developer or +| such Contributor as a result of any such terms You offer. +| +| 3.6. Larger Works. +| +| You may create a Larger Work by combining Covered Software with +| other code not governed by the terms of this License and distribute +| the Larger Work as a single product. In such a case, You must make +| sure the requirements of this License are fulfilled for the Covered +| Software. +| +| 4. Versions of the License. +| +| 4.1. New Versions. +| +| Oracle is the initial license steward and may publish revised and/or +| new versions of this License from time to time. Each version will be +| given a distinguishing version number. Except as provided in Section +| 4.3, no one other than the license steward has the right to modify +| this License. +| +| 4.2. Effect of New Versions. +| +| You may always continue to use, distribute or otherwise make the +| Covered Software available under the terms of the version of the +| License under which You originally received the Covered Software. If +| the Initial Developer includes a notice in the Original Software +| prohibiting it from being distributed or otherwise made available +| under any subsequent version of the License, You must distribute and +| make the Covered Software available under the terms of the version +| of the License under which You originally received the Covered +| Software. Otherwise, You may also choose to use, distribute or +| otherwise make the Covered Software available under the terms of any +| subsequent version of the License published by the license steward. +| +| 4.3. Modified Versions. +| +| When You are an Initial Developer and You want to create a new +| license for Your Original Software, You may create and use a +| modified version of this License if You: (a) rename the license and +| remove any references to the name of the license steward (except to +| note that the modified license differs from this License); and (b) +| otherwise make it clear that the license contains terms which differ +| from this License. +| +| 5. DISCLAIMER OF WARRANTY. +| +| COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS, +| WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, +| INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE COVERED SOFTWARE +| IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR +| NON-INFRINGING. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE +| OF THE COVERED SOFTWARE IS WITH YOU. SHOULD ANY COVERED SOFTWARE +| PROVE DEFECTIVE IN ANY RESPECT, YOU (NOT THE INITIAL DEVELOPER OR +| ANY OTHER CONTRIBUTOR) ASSUME THE COST OF ANY NECESSARY SERVICING, +| REPAIR OR CORRECTION. THIS DISCLAIMER OF WARRANTY CONSTITUTES AN +| ESSENTIAL PART OF THIS LICENSE. NO USE OF ANY COVERED SOFTWARE IS +| AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER. +| +| 6. TERMINATION. +| +| 6.1. This License and the rights granted hereunder will terminate +| automatically if You fail to comply with terms herein and fail to +| cure such breach within 30 days of becoming aware of the breach. +| Provisions which, by their nature, must remain in effect beyond the +| termination of this License shall survive. +| +| 6.2. If You assert a patent infringement claim (excluding +| declaratory judgment actions) against Initial Developer or a +| Contributor (the Initial Developer or Contributor against whom You +| assert such claim is referred to as "Participant") alleging that the +| Participant Software (meaning the Contributor Version where the +| Participant is a Contributor or the Original Software where the +| Participant is the Initial Developer) directly or indirectly +| infringes any patent, then any and all rights granted directly or +| indirectly to You by such Participant, the Initial Developer (if the +| Initial Developer is not the Participant) and all Contributors under +| Sections 2.1 and/or 2.2 of this License shall, upon 60 days notice +| from Participant terminate prospectively and automatically at the +| expiration of such 60 day notice period, unless if within such 60 +| day period You withdraw Your claim with respect to the Participant +| Software against such Participant either unilaterally or pursuant to +| a written agreement with Participant. +| +| 6.3. If You assert a patent infringement claim against Participant +| alleging that the Participant Software directly or indirectly +| infringes any patent where such claim is resolved (such as by +| license or settlement) prior to the initiation of patent +| infringement litigation, then the reasonable value of the licenses +| granted by such Participant under Sections 2.1 or 2.2 shall be +| taken into account in determining the amount or value of any payment +| or license. +| +| 6.4. In the event of termination under Sections 6.1 or 6.2 above, +| all end user licenses that have been validly granted by You or any +| distributor hereunder prior to termination (excluding licenses +| granted to You by any distributor) shall survive termination. +| +| 7. LIMITATION OF LIABILITY. +| +| UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT +| (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE +| INITIAL DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF +| COVERED SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE +| TO ANY PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR +| CONSEQUENTIAL DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT +| LIMITATION, DAMAGES FOR LOSS OF GOODWILL, WORK STOPPAGE, COMPUTER +| FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER COMMERCIAL DAMAGES OR +| LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN INFORMED OF THE +| POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF LIABILITY SHALL NOT +| APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY RESULTING FROM SUCH +| PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW PROHIBITS SUCH +| LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR +| LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THIS EXCLUSION +| AND LIMITATION MAY NOT APPLY TO YOU. +| +| 8. U.S. GOVERNMENT END USERS. +| +| The Covered Software is a "commercial item," as that term is defined +| in 48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer +| software" (as that term is defined at 48 C.F.R. 252.227-7014(a)(1)) +| and "commercial computer software documentation" as such terms are +| used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48 C.F.R. +| 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995), all +| U.S. Government End Users acquire Covered Software with only those +| rights set forth herein. This U.S. Government Rights clause is in +| lieu of, and supersedes, any other FAR, DFAR, or other clause or +| provision that addresses Government rights in computer software +| under this License. +| +| 9. MISCELLANEOUS. +| +| This License represents the complete agreement concerning subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. This License shall be governed by +| the law of the jurisdiction specified in a notice contained within +| the Original Software (except to the extent applicable law, if any, +| provides otherwise), excluding such jurisdiction's conflict-of-law +| provisions. Any litigation relating to this License shall be subject +| to the jurisdiction of the courts located in the jurisdiction and +| venue specified in a notice contained within the Original Software, +| with the losing party responsible for costs, including, without +| limitation, court costs and reasonable attorneys' fees and expenses. +| The application of the United Nations Convention on Contracts for +| the International Sale of Goods is expressly excluded. Any law or +| regulation which provides that the language of a contract shall be +| construed against the drafter shall not apply to this License. You +| agree that You alone are responsible for compliance with the United +| States export administration regulations (and the export control +| laws and regulation of any other countries) when You use, distribute +| or otherwise make available any Covered Software. +| +| 10. RESPONSIBILITY FOR CLAIMS. +| +| As between Initial Developer and the Contributors, each party is +| responsible for claims and damages arising, directly or indirectly, +| out of its utilization of rights under this License and You agree to +| work with Initial Developer and Contributors to distribute such +| responsibility on an equitable basis. Nothing herein is intended or +| shall be deemed to constitute any admission of liability. + +-------------------------------------------------------------------------------- + +This product bundles RELAX NG Datatype API (bundled by Woodstox). + +Project URL: https://github.com/relaxng/relaxng-datatype-java +License: BSD 3-Clause + +| Copyright (c) 2018 Oracle and/or its affiliates. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| - Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| +| - Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in the +| documentation and/or other materials provided with the distribution. +| +| - Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived +| from this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +| IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +| THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +| PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/gcp-bundle/NOTICE b/gcp-bundle/NOTICE index 2532bf7c1529..98d13b8895d9 100644 --- a/gcp-bundle/NOTICE +++ b/gcp-bundle/NOTICE @@ -1,83 +1,97 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -NOTICE for Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.18.2 - -# Jackson JSON processor - -Jackson is a high-performance, Free/Open Source JSON processing library. -It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -been in development since 2007. -It is currently developed by a community of developers. - -## Licensing - -Jackson 2.x core and extension components are licensed under Apache License 2.0 -To find the details that apply to this artifact see the accompanying LICENSE file. - -## Credits - -A list of contributors may be found from CREDITS(-2.x) file, which is included -in some artifacts (usually source distributions); but is always available -from the source code management (SCM) system project uses. +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. -------------------------------------------------------------------------------- -NOTICE for Group: io.grpc Name: grpc-netty-shaded Version: 1.71.0 - +This product bundles Netty with the following in its NOTICE file: +| | The Netty Project | ================= -| +| | Please visit the Netty web site for more information: -| +| | * http://netty.io/ -| +| | Copyright 2016 The Netty Project -| +| | The Netty Project 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. -| +| | ------------------------------------------------------------------------------- | This product contains a forked and modified version of Tomcat Native -| +| | * LICENSE: | * license/LICENSE.tomcat-native.txt (Apache License 2.0) | * HOMEPAGE: | * http://tomcat.apache.org/native-doc/ | * https://svn.apache.org/repos/asf/tomcat/native/ -| +| | This product contains the Maven wrapper scripts from 'Maven Wrapper', that provides an easy way to ensure a user has everything necessary to run the Maven build. -| +| | * LICENSE: | * license/LICENSE.mvn-wrapper.txt (Apache License 2.0) | * HOMEPAGE: | * https://github.com/takari/maven-wrapper -| +| | This product contains small piece of code to support AIX, taken from netbsd. -| +| | * LICENSE: | * license/LICENSE.aix-netbsd.txt (OpenSSL License) | * HOMEPAGE: | * https://ftp.netbsd.org/pub/NetBSD/NetBSD-current/src/crypto/external/bsd/openssl/dist -| -| +| +| | This product contains code from boringssl. -| +| | * LICENSE (Combination ISC and OpenSSL license) | * license/LICENSE.boringssl.txt (Combination ISC and OpenSSL license) | * HOMEPAGE: @@ -85,51 +99,38 @@ NOTICE for Group: io.grpc Name: grpc-netty-shaded Version: 1.71.0 -------------------------------------------------------------------------------- -NOTICE for Group: io.grpc Name: grpc-alts Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-api Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-auth Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-context Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-core Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-googleapis Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-grpclb Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-protobuf Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-protobuf-lite Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-rls Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-services Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-stub Version: 1.71.0 -NOTICE for Group: io.grpc Name: grpc-xds Version: 1.71.0 - +This product bundles gRPC with the following in its NOTICE file: | Copyright 2014 The gRPC Authors -| +| | Licensed 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. -| +| | ----------------------------------------------------------------------- -| +| | This product contains a modified portion of 'OkHttp', an open source | HTTP & SPDY client for Android and Java applications, which can be obtained | at: -| +| | * LICENSE: | * okhttp/third_party/okhttp/LICENSE (Apache License 2.0) | * HOMEPAGE: | * https://github.com/square/okhttp | * LOCATION_IN_GRPC: | * okhttp/third_party/okhttp -| +| | This product contains a modified portion of 'Envoy', an open source | cloud-native high-performance edge/middle/service proxy, which can be | obtained at: -| +| | * LICENSE: | * xds/third_party/envoy/LICENSE (Apache License 2.0) | * NOTICE: @@ -138,11 +139,11 @@ NOTICE for Group: io.grpc Name: grpc-xds Version: 1.71.0 | * https://www.envoyproxy.io | * LOCATION_IN_GRPC: | * xds/third_party/envoy -| +| | This product contains a modified portion of 'protoc-gen-validate (PGV)', | an open source protoc plugin to generate polyglot message validators, | which can be obtained at: -| +| | * LICENSE: | * xds/third_party/protoc-gen-validate/LICENSE (Apache License 2.0) | * NOTICE: @@ -151,10 +152,10 @@ NOTICE for Group: io.grpc Name: grpc-xds Version: 1.71.0 | * https://github.com/envoyproxy/protoc-gen-validate | * LOCATION_IN_GRPC: | * xds/third_party/protoc-gen-validate -| +| | This product contains a modified portion of 'udpa', | an open source universal data plane API, which can be obtained at: -| +| | * LICENSE: | * xds/third_party/udpa/LICENSE (Apache License 2.0) | * HOMEPAGE: @@ -164,44 +165,43 @@ NOTICE for Group: io.grpc Name: grpc-xds Version: 1.71.0 -------------------------------------------------------------------------------- -NOTICE for Group: io.perfmark Name: perfmark-api Version: 0.27.0 - +This product bundles Perfmark with the following in its NOTICE file: | Copyright 2019 Google LLC -| +| | Licensed 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. -| +| | ----------------------------------------------------------------------- -| +| | This product contains a modified portion of 'Catapult', an open source -| Trace Event viewer for Chome, Linux, and Android applications, which can +| Trace Event viewer for Chome, Linux, and Android applications, which can | be obtained at: -| +| | * LICENSE: | * traceviewer/src/main/resources/io/perfmark/traceviewer/third_party/catapult/LICENSE (New BSD License) | * HOMEPAGE: | * https://github.com/catapult-project/catapult -| +| | This product contains a modified portion of 'Polymer', a library for Web | Components, which can be obtained at: | * LICENSE: | * traceviewer/src/main/resources/io/perfmark/traceviewer/third_party/polymer/LICENSE (New BSD License) | * HOMEPAGE: | * https://github.com/Polymer/polymer -| -| +| +| | This product contains a modified portion of 'ASM', an open source | Java Bytecode library, which can be obtained at: -| +| | * LICENSE: | * agent/src/main/resources/io/perfmark/agent/third_party/asm/LICENSE (BSD style License) | * HOMEPAGE: @@ -209,92 +209,41 @@ NOTICE for Group: io.perfmark Name: perfmark-api Version: 0.27.0 -------------------------------------------------------------------------------- -NOTICE for Group: org.conscrypt Name: conscrypt-openjdk-uber Version: 2.5.2 - +This product bundles Conscrypt (openjdk-uber) with the following in its NOTICE file: | Copyright 2016 The Android Open Source Project -| +| | Licensed 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. -| +| | ----------------------------------------------------------------------- | This product contains a modified portion of `Netty`, a configurable network | stack in Java, which can be obtained at: -| +| | * LICENSE: | * licenses/LICENSE.netty.txt (Apache License 2.0) | * HOMEPAGE: | * http://netty.io/ -| +| | This product contains a modified portion of `Apache Harmony`, modular Java runtime, | which can be obtained at: -| +| | * LICENSE: | * licenses/LICENSE.harmony.txt (Apache License 2.0) | * HOMEPAGE: | * https://harmony.apache.org/ -------------------------------------------------------------------------------- -NOTICE for Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.18.2 - -| # Jackson JSON processor -| -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers. -| ## Licensing -| -| Jackson components are licensed under Apache (Software) License, version 2.0, -| as per accompanying LICENSE file. -| ## Credits -| -| A list of contributors may be found from CREDITS file, which is included -| in some artifacts (usually source distributions); -| but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- - -NOTICE for Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.18.2 -NOTICE for Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.18.2 - -| # Jackson JSON processor -| -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers. -| ## Copyright -| -| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) -| -| ## Licensing -| -| Jackson 2.x core and extension components are licensed under Apache License 2.0 -| To find the details that apply to this artifact see the accompanying LICENSE file. -| ## Credits -| -| A list of contributors may be found from CREDITS(-2.x) file, which is included -| in some artifacts (usually source distributions); -| but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- - -NOTICE for Group: com.google.escapevelocity Name: escapevelocity Version: 0.9 - -| Apache Velocity -| -| Copyright (C) 2000-2007 The Apache Software Foundation +This product bundles Envoy with the following in its NOTICE file: +| Envoy +| Copyright The Envoy Project Authors | -| This product includes software developed at -| The Apache Software Foundation (http://www.apache.org/). \ No newline at end of file +| Licensed under Apache License 2.0. See LICENSE for terms. diff --git a/gcp-bundle/build.gradle b/gcp-bundle/build.gradle index 99e4359bd6d0..9c4907bcdaa5 100644 --- a/gcp-bundle/build.gradle +++ b/gcp-bundle/build.gradle @@ -23,12 +23,20 @@ project(":iceberg-gcp-bundle") { tasks.jar.dependsOn tasks.shadowJar + configurations { + implementation { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + exclude group: 'org.slf4j' + } + } + dependencies { implementation platform(libs.google.libraries.bom) implementation "com.google.cloud:google-cloud-storage" implementation "com.google.cloud:google-cloud-bigquery" implementation "com.google.cloud:google-cloud-core" implementation "com.google.cloud:google-cloud-kms" + implementation libs.gcs.analytics.core } shadowJar { @@ -41,10 +49,6 @@ project(":iceberg-gcp-bundle") { include 'NOTICE' } - dependencies { - exclude(dependency('org.slf4j:slf4j-api')) - } - // relocate GCP-specific versions relocate 'com.fasterxml.jackson', 'org.apache.iceberg.gcp.shaded.com.fasterxml.jackson' relocate 'com.google.common', 'org.apache.iceberg.gcp.shaded.com.google.common' @@ -58,4 +62,6 @@ project(":iceberg-gcp-bundle") { jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/gcp-bundle/runtime-deps.txt b/gcp-bundle/runtime-deps.txt new file mode 100644 index 000000000000..cb95135c07e1 --- /dev/null +++ b/gcp-bundle/runtime-deps.txt @@ -0,0 +1,112 @@ +com.fasterxml.jackson.core:jackson-annotations:2.18.3 +com.fasterxml.jackson.core:jackson-core:2.18.3 +com.fasterxml.jackson.core:jackson-databind:2.18.3 +com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.18.3 +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.3 +com.fasterxml.woodstox:woodstox-core:7.0.0 +com.google.android:annotations:4.1.1.4 +com.google.api-client:google-api-client:2.7.2 +com.google.api.grpc:gapic-google-cloud-storage-v2:2.67.0 +com.google.api.grpc:grpc-google-cloud-bigquerystorage-v1:3.27.0 +com.google.api.grpc:grpc-google-cloud-bigquerystorage-v1beta1:0.199.0 +com.google.api.grpc:grpc-google-cloud-bigquerystorage-v1beta2:0.199.0 +com.google.api.grpc:grpc-google-cloud-storage-v2:2.67.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1:3.27.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1alpha:3.27.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1beta1:0.199.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1beta2:0.199.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1beta:3.27.0 +com.google.api.grpc:proto-google-cloud-kms-v1:0.185.0 +com.google.api.grpc:proto-google-cloud-monitoring-v3:3.92.0 +com.google.api.grpc:proto-google-cloud-storage-v2:2.67.0 +com.google.api.grpc:proto-google-common-protos:2.70.0 +com.google.api.grpc:proto-google-iam-v1:1.65.0 +com.google.api:api-common:2.62.0 +com.google.api:gax-grpc:2.79.0 +com.google.api:gax-httpjson:2.79.0 +com.google.api:gax:2.79.0 +com.google.apis:google-api-services-bigquery:v2-rev20251012-2.0.0 +com.google.apis:google-api-services-storage:v1-rev20260204-2.0.0 +com.google.auth:google-auth-library-credentials:1.46.0 +com.google.auth:google-auth-library-oauth2-http:1.46.0 +com.google.auto.value:auto-value-annotations:1.11.1 +com.google.cloud.gcs.analytics:client:1.2.3 +com.google.cloud.gcs.analytics:gcs-analytics-core:1.2.3 +com.google.cloud.opentelemetry:detector-resources-support:0.33.0 +com.google.cloud.opentelemetry:exporter-metrics:0.33.0 +com.google.cloud.opentelemetry:shared-resourcemapping:0.33.0 +com.google.cloud:google-cloud-bigquery:2.65.0 +com.google.cloud:google-cloud-bigquerystorage:3.27.0 +com.google.cloud:google-cloud-core-grpc:2.69.0 +com.google.cloud:google-cloud-core-http:2.69.0 +com.google.cloud:google-cloud-core:2.69.0 +com.google.cloud:google-cloud-kms:2.94.0 +com.google.cloud:google-cloud-monitoring:3.92.0 +com.google.cloud:google-cloud-storage:2.67.0 +com.google.code.gson:gson:2.12.1 +com.google.errorprone:error_prone_annotations:2.45.0 +com.google.flatbuffers:flatbuffers-java:24.3.25 +com.google.guava:failureaccess:1.0.3 +com.google.guava:guava:33.5.0-jre +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava +com.google.http-client:google-http-client-apache-v2:2.1.0 +com.google.http-client:google-http-client-appengine:2.1.0 +com.google.http-client:google-http-client-gson:2.1.0 +com.google.http-client:google-http-client-jackson2:2.1.0 +com.google.http-client:google-http-client:2.1.0 +com.google.j2objc:j2objc-annotations:3.1 +com.google.oauth-client:google-oauth-client:1.39.0 +com.google.protobuf:protobuf-java-util:4.33.2 +com.google.protobuf:protobuf-java:4.33.2 +com.google.re2j:re2j:1.8 +commons-codec:commons-codec:1.18.0 +io.grpc:grpc-alts:1.80.0 +io.grpc:grpc-api:1.80.0 +io.grpc:grpc-auth:1.80.0 +io.grpc:grpc-context:1.80.0 +io.grpc:grpc-core:1.80.0 +io.grpc:grpc-googleapis:1.80.0 +io.grpc:grpc-grpclb:1.80.0 +io.grpc:grpc-inprocess:1.80.0 +io.grpc:grpc-netty-shaded:1.80.0 +io.grpc:grpc-opentelemetry:1.80.0 +io.grpc:grpc-protobuf-lite:1.80.0 +io.grpc:grpc-protobuf:1.80.0 +io.grpc:grpc-rls:1.80.0 +io.grpc:grpc-services:1.80.0 +io.grpc:grpc-stub:1.80.0 +io.grpc:grpc-util:1.80.0 +io.grpc:grpc-xds:1.80.0 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-common:4.2.13.Final +io.opencensus:opencensus-api:0.31.1 +io.opencensus:opencensus-contrib-http-util:0.31.1 +io.opentelemetry.contrib:opentelemetry-gcp-resources:1.37.0-alpha +io.opentelemetry.semconv:opentelemetry-semconv:1.29.0-alpha +io.opentelemetry:opentelemetry-api:1.51.0 +io.opentelemetry:opentelemetry-context:1.51.0 +io.opentelemetry:opentelemetry-sdk-common:1.51.0 +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.51.0 +io.opentelemetry:opentelemetry-sdk-logs:1.51.0 +io.opentelemetry:opentelemetry-sdk-metrics:1.51.0 +io.opentelemetry:opentelemetry-sdk-trace:1.51.0 +io.opentelemetry:opentelemetry-sdk:1.51.0 +io.perfmark:perfmark-api:0.27.0 +javax.annotation:javax.annotation-api:1.3.2 +org.apache.arrow:arrow-format:17.0.0 +org.apache.arrow:arrow-memory-core:17.0.0 +org.apache.arrow:arrow-memory-netty-buffer-patch:17.0.0 +org.apache.arrow:arrow-memory-netty:17.0.0 +org.apache.arrow:arrow-vector:17.0.0 +org.apache.commons:commons-lang3:3.20.0 +org.apache.httpcomponents:httpclient:4.5.14 +org.apache.httpcomponents:httpcore:4.4.16 +org.checkerframework:checker-compat-qual:2.5.6 +org.checkerframework:checker-qual:3.49.0 +org.codehaus.mojo:animal-sniffer-annotations:1.26 +org.codehaus.woodstox:stax2-api:4.2.2 +org.conscrypt:conscrypt-openjdk-uber:2.5.2 +org.json:json:20250517 +org.jspecify:jspecify:1.0.0 +org.threeten:threeten-extra:1.8.0 +org.threeten:threetenbp:1.7.0 diff --git a/gcp/src/integration/java/org/apache/iceberg/gcp/TestKeyManagementClient.java b/gcp/src/integration/java/org/apache/iceberg/gcp/TestKeyManagementClient.java index 1e02013b3a0e..f1d954ea62c0 100644 --- a/gcp/src/integration/java/org/apache/iceberg/gcp/TestKeyManagementClient.java +++ b/gcp/src/integration/java/org/apache/iceberg/gcp/TestKeyManagementClient.java @@ -35,9 +35,12 @@ import java.nio.ByteBuffer; import java.util.Map; import java.util.UUID; +import org.apache.iceberg.TestHelpers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; public abstract class TestKeyManagementClient { @@ -106,10 +109,30 @@ public void testKeyWrapping() { try (GcpKeyManagementClient keyManagementClient = new GcpKeyManagementClient(); ) { keyManagementClient.initialize(properties()); - ByteBuffer key = ByteBuffer.wrap(new String("super-secret-table-master-key").getBytes()); + ByteBuffer key = ByteBuffer.wrap("super-secret-table-master-key".getBytes()); ByteBuffer encryptedKey = keyManagementClient.wrapKey(key, keyname); assertThat(keyManagementClient.unwrapKey(encryptedKey, keyname)).isEqualTo(key); } } + + @ParameterizedTest + @MethodSource("org.apache.iceberg.TestHelpers#serializers") + public void testSerialization( + TestHelpers.RoundTripSerializer roundTripSerializer) + throws Exception { + String keyname = CryptoKeyName.of(projectId, LOCATION, KEY_RING_ID, keyId).toString(); + + try (GcpKeyManagementClient keyManagementClient = new GcpKeyManagementClient(); ) { + keyManagementClient.initialize(properties()); + + GcpKeyManagementClient result = roundTripSerializer.apply(keyManagementClient); + + ByteBuffer key = ByteBuffer.wrap("super-secret-table-master-key".getBytes()); + ByteBuffer encryptedKey = result.wrapKey(key, keyname); + + assertThat(keyManagementClient.unwrapKey(encryptedKey, keyname)).isEqualTo(key); + assertThat(result.unwrapKey(encryptedKey, keyname)).isEqualTo(key); + } + } } diff --git a/gcp/src/integration/java/org/apache/iceberg/gcp/gcs/TestGcsFileIO.java b/gcp/src/integration/java/org/apache/iceberg/gcp/gcs/TestGcsFileIO.java new file mode 100644 index 000000000000..1e956854b31b --- /dev/null +++ b/gcp/src/integration/java/org/apache/iceberg/gcp/gcs/TestGcsFileIO.java @@ -0,0 +1,273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.gcs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.github.dockerjava.api.model.ExposedPort; +import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Ports.Binding; +import com.google.cloud.NoCredentials; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.BucketInfo; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; +import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.gcp.GCPProperties; +import org.apache.iceberg.io.FileInfo; +import org.apache.iceberg.io.IOUtil; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.SeekableInputStream; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +public class TestGcsFileIO { + + private static final String BUCKET = "test-bucket"; + private static final String PROJECT_ID = "test-project"; + private static final int GCS_EMULATOR_PORT = 4443; + private static final Random RANDOM = new Random(1); + + @Container + private static final GenericContainer GCS_EMULATOR = + new GenericContainer<>("fsouza/fake-gcs-server:latest") + .withExposedPorts(GCS_EMULATOR_PORT) + .withCreateContainerCmdModifier( + cmd -> + cmd.withHostConfig( + new HostConfig() + .withPortBindings( + new PortBinding( + Binding.bindPort(GCS_EMULATOR_PORT), + new ExposedPort(GCS_EMULATOR_PORT))))) + .withCommand( + "-scheme", + "http", + "-external-url", + String.format("http://localhost:%d", GCS_EMULATOR_PORT)) + .waitingFor( + new HttpWaitStrategy() + .forPort(GCS_EMULATOR_PORT) + .forPath("/storage/v1/b") + .forStatusCode(200) + .withStartupTimeout(Duration.ofMinutes(2))); + + private GCSFileIO fileIO; + private static Storage storage; + + @BeforeAll + public static void beforeClass() { + String endpoint = String.format("http://localhost:%d", GCS_EMULATOR_PORT); + StorageOptions options = + StorageOptions.newBuilder() + .setProjectId(PROJECT_ID) + .setHost(endpoint) + .setCredentials(NoCredentials.getInstance()) + .build(); + storage = options.getService(); + storage.create(BucketInfo.of(BUCKET)); + } + + @AfterAll + public static void afterClass() { + if (storage != null) { + storage.delete(BUCKET); + } + } + + @BeforeEach + public void before() { + fileIO = new GCSFileIO(() -> storage); + fileIO.initialize(ImmutableMap.of()); + for (Blob blob : storage.list(BUCKET).iterateAll()) { + storage.delete(blob.getBlobId()); + } + } + + @AfterEach + public void after() { + for (Blob blob : storage.list(BUCKET).iterateAll()) { + storage.delete(blob.getBlobId()); + } + } + + @Test + public void newInputFileGcsAnalyticsCoreDisabled() throws IOException { + String location = String.format("gs://%s/path/to/file.txt", BUCKET); + byte[] expected = new byte[1024 * 1024]; + RANDOM.nextBytes(expected); + storage.create(BlobInfo.newBuilder(BlobId.fromGsUtilUri(location)).build(), expected); + InputFile in = fileIO.newInputFile(location); + byte[] actual = new byte[1024 * 1024]; + + try (InputStream is = in.newStream()) { + IOUtil.readFully(is, actual, 0, expected.length); + } + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void newInputFileGcsAnalyticsCoreEnabled() throws IOException { + String location = String.format("gs://%s/path/to/file.txt", BUCKET); + byte[] expected = new byte[1024 * 1024]; + RANDOM.nextBytes(expected); + storage.create(BlobInfo.newBuilder(BlobId.fromGsUtilUri(location)).build(), expected); + fileIO.initialize( + ImmutableMap.of( + GCPProperties.GCS_ANALYTICS_CORE_ENABLED, "true", + GCPProperties.GCS_NO_AUTH, "true", + GCPProperties.GCS_SERVICE_HOST, + String.format("http://localhost:%d", GCS_EMULATOR_PORT))); + InputFile in = fileIO.newInputFile(location); + byte[] actual = new byte[1024 * 1024]; + + InputStream inputStream = in.newStream(); + try (InputStream is = inputStream) { + IOUtil.readFully(is, actual, 0, expected.length); + } + + assertThat(inputStream).isNotInstanceOf(GCSInputStream.class); + assertThat(actual).isEqualTo(expected); + } + + @Test + public void deleteFiles() { + String prefix = "delete-files"; + List locations = Lists.newArrayList(); + for (int i = 0; i < 10; i++) { + String location = String.format("gs://%s/%s/file-%d.txt", BUCKET, prefix, i); + locations.add(location); + storage.create(BlobInfo.newBuilder(BlobId.fromGsUtilUri(location)).build(), new byte[] {1}); + } + + fileIO.deleteFiles(locations); + + for (String location : locations) { + assertThat(fileIO.newInputFile(location).exists()).isFalse(); + } + } + + @Test + public void listPrefix() { + String prefix = "list-prefix"; + String dir1 = String.format("gs://%s/%s/d1", BUCKET, prefix); + String dir2 = String.format("gs://%s/%s/d2", BUCKET, prefix); + storage.create( + BlobInfo.newBuilder(BlobId.fromGsUtilUri(dir1 + "/f1.txt")).build(), new byte[] {1}); + storage.create( + BlobInfo.newBuilder(BlobId.fromGsUtilUri(dir1 + "/f2.txt")).build(), new byte[] {1}); + storage.create( + BlobInfo.newBuilder(BlobId.fromGsUtilUri(dir2 + "/f3.txt")).build(), new byte[] {1}); + + List files = + Lists.newArrayList(fileIO.listPrefix(String.format("gs://%s/%s/", BUCKET, prefix))); + List paths = files.stream().map(FileInfo::location).collect(Collectors.toList()); + + assertThat(files).hasSize(3); + assertThat(paths).contains(dir1 + "/f1.txt", dir1 + "/f2.txt", dir2 + "/f3.txt"); + } + + @Test + public void deletePrefix() { + String prefixToDelete = String.format("gs://%s/delete-prefix/", BUCKET); + storage.create( + BlobInfo.newBuilder(BlobId.fromGsUtilUri(prefixToDelete + "f1.txt")).build(), + new byte[] {1}); + storage.create( + BlobInfo.newBuilder(BlobId.fromGsUtilUri(prefixToDelete + "f2.txt")).build(), + new byte[] {1}); + + int filesBeforeDelete = Lists.newArrayList(fileIO.listPrefix(prefixToDelete)).size(); + + fileIO.deletePrefix(prefixToDelete); + + assertThat(filesBeforeDelete).isEqualTo(2); + assertThat(Lists.newArrayList(fileIO.listPrefix(prefixToDelete))).isEmpty(); + } + + @Test + public void readMissingLocation() throws IOException { + String location = String.format("gs://%s/path/to/data.parquet", BUCKET); + InputFile input = fileIO.newInputFile(location); + + // Creating an input stream or changing the read position in it are local operations + try (SeekableInputStream in = input.newStream()) { + in.seek(1); + } + + try (SeekableInputStream in = input.newStream()) { + assertThatThrownBy(in::read) + .isInstanceOf(NotFoundException.class) + .hasCauseInstanceOf(IOException.class) + .hasMessage("Location does not exist: gs://test-bucket/path/to/data.parquet"); + } + } + + @Test + public void readMissingLocationGcsAnalyticsCoreEnabled() throws IOException { + String location = String.format("gs://%s/path/to/data.parquet", BUCKET); + fileIO.initialize( + ImmutableMap.of( + GCPProperties.GCS_ANALYTICS_CORE_ENABLED, + "true", + GCPProperties.GCS_NO_AUTH, + "true", + GCPProperties.GCS_SERVICE_HOST, + String.format("http://localhost:%d", GCS_EMULATOR_PORT))); + InputFile in = fileIO.newInputFile(location); + + assertThatThrownBy(() -> in.newStream().read()) + .isInstanceOf(NotFoundException.class) + .hasCauseInstanceOf(IOException.class) + .hasMessage("Location does not exist: gs://test-bucket/path/to/data.parquet"); + } + + @Test + public void deleteFile() { + String location = String.format("gs://%s/path/to/file.txt", BUCKET); + storage.create(BlobInfo.newBuilder(BlobId.fromGsUtilUri(location)).build(), new byte[] {1}); + InputFile in = fileIO.newInputFile(location); + assertThat(in.exists()).as("File should exist before delete").isTrue(); + + fileIO.deleteFile(in); + + assertThat(fileIO.newInputFile(location).exists()).isFalse(); + } +} diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java b/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java index d91601125c74..2702ce565d4e 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/GCPProperties.java @@ -19,11 +19,16 @@ package org.apache.iceberg.gcp; import java.io.Serializable; +import java.util.Arrays; import java.util.Date; +import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.RESTUtil; import org.apache.iceberg.util.PropertyUtil; @@ -49,6 +54,15 @@ public class GCPProperties implements Serializable { public static final String GCS_OAUTH2_REFRESH_CREDENTIALS_ENDPOINT = "gcs.oauth2.refresh-credentials-endpoint"; + // Impersonation properties + public static final String GCS_IMPERSONATE_SERVICE_ACCOUNT = "gcs.impersonate.service-account"; + public static final String GCS_IMPERSONATE_LIFETIME_SECONDS = "gcs.impersonate.lifetime-seconds"; + public static final String GCS_IMPERSONATE_DELEGATES = "gcs.impersonate.delegates"; + public static final String GCS_IMPERSONATE_SCOPES = "gcs.impersonate.scopes"; + public static final int GCS_IMPERSONATE_LIFETIME_SECONDS_DEFAULT = 3600; + private static final List GCS_IMPERSONATE_SCOPES_DEFAULT = + ImmutableList.of("https://www.googleapis.com/auth/cloud-platform"); + /** Controls whether vended credentials should be refreshed or not. Defaults to true. */ public static final String GCS_OAUTH2_REFRESH_CREDENTIALS_ENABLED = "gcs.oauth2.refresh-credentials-enabled"; @@ -56,6 +70,9 @@ public class GCPProperties implements Serializable { /** Configure the batch size used when deleting multiple files from a given GCS bucket */ public static final String GCS_DELETE_BATCH_SIZE = "gcs.delete.batch-size"; + /** Controls whether analytics core library is enabled or not. Defaults to false. */ + public static final String GCS_ANALYTICS_CORE_ENABLED = "gcs.analytics-core.enabled"; + /** * Max possible batch size for deletion. Currently, a max of 100 keys is advised, so we default to * a number below that. https://cloud.google.com/storage/docs/batch @@ -80,9 +97,47 @@ public class GCPProperties implements Serializable { private Date gcsOAuth2TokenExpiresAt; private String gcsOauth2RefreshCredentialsEndpoint; private boolean gcsOauth2RefreshCredentialsEnabled; + private boolean gcsAnalyticsCoreEnabled; + + private String gcsImpersonateServiceAccount; + private int gcsImpersonateLifetimeSeconds; + private List gcsImpersonateDelegates; + private List gcsImpersonateScopes; private int gcsDeleteBatchSize = GCS_DELETE_BATCH_SIZE_DEFAULT; + @VisibleForTesting + List parseCommaSeparatedList(String input, List defaultValue) { + if (input == null || input.trim().isEmpty()) { + return defaultValue; + } + return Arrays.stream(input.split(",")) + .map(String::trim) + .filter(str -> !str.isEmpty()) + .distinct() + .collect(Collectors.toList()); + } + + @VisibleForTesting + List expandScopes(List inputScopes) { + if (inputScopes == null || inputScopes.isEmpty()) { + return inputScopes; + } + return inputScopes.stream() + .map( + inputScope -> { + if (inputScope.startsWith("https://")) { + return inputScope; + } + if (inputScope.startsWith("http://")) { + return inputScope.replace("http://", "https://"); + } + + return "https://www.googleapis.com/auth/" + inputScope; + }) + .collect(Collectors.toList()); + } + public GCPProperties() { this.allProperties = ImmutableMap.of(); } @@ -128,6 +183,20 @@ public GCPProperties(Map properties) { gcsDeleteBatchSize = PropertyUtil.propertyAsInt( properties, GCS_DELETE_BATCH_SIZE, GCS_DELETE_BATCH_SIZE_DEFAULT); + + gcsImpersonateServiceAccount = properties.get(GCS_IMPERSONATE_SERVICE_ACCOUNT); + gcsImpersonateLifetimeSeconds = + PropertyUtil.propertyAsInt( + properties, GCS_IMPERSONATE_LIFETIME_SECONDS, GCS_IMPERSONATE_LIFETIME_SECONDS_DEFAULT); + gcsImpersonateDelegates = + parseCommaSeparatedList(properties.get(GCS_IMPERSONATE_DELEGATES), null); + List rawScopes = + parseCommaSeparatedList( + properties.get(GCS_IMPERSONATE_SCOPES), GCS_IMPERSONATE_SCOPES_DEFAULT); + gcsImpersonateScopes = expandScopes(rawScopes); + + gcsAnalyticsCoreEnabled = + PropertyUtil.propertyAsBoolean(properties, GCS_ANALYTICS_CORE_ENABLED, false); } public Optional channelReadChunkSize() { @@ -174,6 +243,22 @@ public Optional oauth2TokenExpiresAt() { return Optional.ofNullable(gcsOAuth2TokenExpiresAt); } + public Optional impersonateServiceAccount() { + return Optional.ofNullable(gcsImpersonateServiceAccount); + } + + public int impersonateLifetimeSeconds() { + return gcsImpersonateLifetimeSeconds; + } + + public List impersonateDelegates() { + return gcsImpersonateDelegates; + } + + public List impersonateScopes() { + return gcsImpersonateScopes; + } + public int deleteBatchSize() { return gcsDeleteBatchSize; } @@ -189,4 +274,8 @@ public boolean oauth2RefreshCredentialsEnabled() { public Map properties() { return allProperties; } + + public boolean isGcsAnalyticsCoreEnabled() { + return gcsAnalyticsCoreEnabled; + } } diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/GcpKeyManagementClient.java b/gcp/src/main/java/org/apache/iceberg/gcp/GcpKeyManagementClient.java index ff18d5234d2f..22161f9e0a64 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/GcpKeyManagementClient.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/GcpKeyManagementClient.java @@ -29,10 +29,13 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.iceberg.common.DynClasses; import org.apache.iceberg.common.DynMethods; import org.apache.iceberg.encryption.KeyManagementClient; +import org.apache.iceberg.exceptions.RuntimeIOException; import org.apache.iceberg.io.CloseableGroup; +import org.apache.iceberg.util.SerializableMap; /** * Key management client implementation that uses Google Cloud Key Management. To be used for @@ -45,31 +48,17 @@ */ public class GcpKeyManagementClient implements KeyManagementClient { - private KeyManagementServiceClient kmsClient; - private CloseableGroup closeableGroup = new CloseableGroup(); + private final AtomicBoolean isResourceClosed = new AtomicBoolean(false); + + private Map allProperties; + + private transient volatile KeyManagementServiceClient kmsClient; + private transient volatile CloseableGroup closeableGroup = new CloseableGroup(); @Override public void initialize(Map properties) { + this.allProperties = SerializableMap.copyOf(properties); this.closeableGroup = new CloseableGroup(); - closeableGroup.setSuppressCloseFailure(true); - - GCPProperties gcpProperties = new GCPProperties(properties); - - try { - KeyManagementServiceSettings.Builder kmsBuilder = KeyManagementServiceSettings.newBuilder(); - if (gcpProperties.oauth2Token().isPresent()) { - OAuth2Credentials oAuth2Credentials = - GCPAuthUtils.oauth2CredentialsFromGcpProperties(gcpProperties, closeableGroup); - kmsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(oAuth2Credentials)); - } - - // if not OAuth then defaults to GoogleCredentials.getApplicationDefault() - this.kmsClient = KeyManagementServiceClient.create(kmsBuilder.build()); - closeableGroup.addCloseable(kmsClient); - - } catch (IOException e) { - throw new RuntimeException("Failed to create GCP cloud KMS service client", e); - } } @Override @@ -78,7 +67,7 @@ public ByteBuffer wrapKey(ByteBuffer key, String wrappingKeyId) { requestBuilder = ByteStringShim.setPlainText(requestBuilder, key); EncryptRequest encryptRequest = requestBuilder.build(); - EncryptResponse encryptResponse = kmsClient.encrypt(encryptRequest); + EncryptResponse encryptResponse = kmsClient().encrypt(encryptRequest); // need ByteString.copyFrom() leaves the BB in an end position, need to reset key.position(0); @@ -91,7 +80,7 @@ public ByteBuffer unwrapKey(ByteBuffer wrappedKey, String wrappingKeyId) { requestBuilder = ByteStringShim.setCipherText(requestBuilder, wrappedKey); DecryptRequest decryptRequest = requestBuilder.build(); - DecryptResponse decryptResponse = kmsClient.decrypt(decryptRequest); + DecryptResponse decryptResponse = kmsClient().decrypt(decryptRequest); // need ByteString.copyFrom() leaves the BB in an end position, need to reset wrappedKey.position(0); @@ -100,11 +89,43 @@ public ByteBuffer unwrapKey(ByteBuffer wrappedKey, String wrappingKeyId) { @Override public void close() { - try { - closeableGroup.close(); - } catch (IOException ioe) { - // closure exceptions already suppressed and logged in closeableGroup + if (isResourceClosed.compareAndSet(false, true)) { + if (closeableGroup != null) { + closeableGroup.setSuppressCloseFailure(true); + try { + closeableGroup.close(); + } catch (IOException ioe) { + // closure exceptions already suppressed and logged in closeableGroup + } + } + } + } + + private KeyManagementServiceClient kmsClient() { + if (kmsClient == null) { + synchronized (this) { + if (kmsClient == null) { + GCPProperties gcpProperties = new GCPProperties(allProperties); + try { + KeyManagementServiceSettings.Builder kmsBuilder = + KeyManagementServiceSettings.newBuilder(); + if (gcpProperties.oauth2Token().isPresent()) { + OAuth2Credentials oAuth2Credentials = + GCPAuthUtils.oauth2CredentialsFromGcpProperties(gcpProperties, closeableGroup); + kmsBuilder.setCredentialsProvider(FixedCredentialsProvider.create(oAuth2Credentials)); + } + + // if not OAuth then defaults to GoogleCredentials.getApplicationDefault() + this.kmsClient = KeyManagementServiceClient.create(kmsBuilder.build()); + closeableGroup.addCloseable(kmsClient); + + } catch (IOException e) { + throw new RuntimeIOException(e, "Failed to create GCP cloud KMS service client"); + } + } + } } + return kmsClient; } private static final class ByteStringShim { diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/auth/GoogleAuthManager.java b/gcp/src/main/java/org/apache/iceberg/gcp/auth/GoogleAuthManager.java index a1d2b539ab16..65768dcbba12 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/auth/GoogleAuthManager.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/auth/GoogleAuthManager.java @@ -19,9 +19,12 @@ package org.apache.iceberg.gcp.auth; import com.google.auth.oauth2.GoogleCredentials; +import java.io.ByteArrayInputStream; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import org.apache.iceberg.catalog.SessionCatalog; @@ -43,8 +46,12 @@ *

    This manager can be configured with properties such as: * *

      - *
    • {@code gcp.auth.credentials-path}: Path to a service account JSON key file. If not set, - * Application Default Credentials will be used. + *
    • {@code gcp.auth.credentials-path}: Path to a service account JSON key file. If neither this + * property nor {@code gcp.auth.credentials-json} is set, Application Default Credentials will + * be used. + *
    • {@code gcp.auth.credentials-json}: JSON string of a service account credential. If neither + * this property nor {@code gcp.auth.credentials-path} is set, Application Default Credentials + * will be used. *
    • {@code gcp.auth.scopes}: Comma-separated list of OAuth scopes to request. Defaults to * "https://www.googleapis.com/auth/cloud-platform". *
    @@ -54,6 +61,7 @@ public class GoogleAuthManager implements AuthManager { private static final Splitter SPLITTER = Splitter.on(',').trimResults().omitEmptyStrings(); public static final String DEFAULT_SCOPES = "https://www.googleapis.com/auth/cloud-platform"; public static final String GCP_CREDENTIALS_PATH_PROPERTY = "gcp.auth.credentials-path"; + public static final String GCP_CREDENTIALS_JSON_PROPERTY = "gcp.auth.credentials-json"; public static final String GCP_SCOPES_PROPERTY = "gcp.auth.scopes"; private final String name; @@ -74,6 +82,16 @@ private void initialize(Map properties) { } String credentialsPath = properties.get(GCP_CREDENTIALS_PATH_PROPERTY); + String credentialsJson = properties.get(GCP_CREDENTIALS_JSON_PROPERTY); + boolean useCredentialsPath = credentialsPath != null && !credentialsPath.isEmpty(); + boolean useCredentialsJson = credentialsJson != null && !credentialsJson.isEmpty(); + if (useCredentialsPath && useCredentialsJson) { + throw new IllegalArgumentException( + String.format( + "Cannot specify both %s and %s", + GCP_CREDENTIALS_PATH_PROPERTY, GCP_CREDENTIALS_JSON_PROPERTY)); + } + String scopesString = properties.getOrDefault(GCP_SCOPES_PROPERTY, DEFAULT_SCOPES); List scopes = Strings.isNullOrEmpty(scopesString) @@ -81,11 +99,17 @@ private void initialize(Map properties) { : ImmutableList.copyOf(SPLITTER.splitToList(scopesString)); try { - if (credentialsPath != null && !credentialsPath.isEmpty()) { + if (useCredentialsPath) { LOG.info("Using Google credentials from path: {}", credentialsPath); try (FileInputStream credentialsStream = new FileInputStream(credentialsPath)) { this.credentials = GoogleCredentials.fromStream(credentialsStream).createScoped(scopes); } + } else if (useCredentialsJson) { + LOG.info("Using Google credentials from json"); + try (InputStream credentialsStream = + new ByteArrayInputStream(credentialsJson.getBytes(StandardCharsets.UTF_8))) { + this.credentials = GoogleCredentials.fromStream(credentialsStream).createScoped(scopes); + } } else { LOG.info("Using Application Default Credentials with scopes: {}", scopesString); this.credentials = GoogleCredentials.getApplicationDefault().createScoped(scopes); diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/AnalyticsCoreUtil.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/AnalyticsCoreUtil.java new file mode 100644 index 000000000000..4bc9659d77af --- /dev/null +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/AnalyticsCoreUtil.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.gcs; + +import com.google.auth.Credentials; +import com.google.cloud.gcs.analyticscore.client.GcsFileInfo; +import com.google.cloud.gcs.analyticscore.client.GcsFileSystem; +import com.google.cloud.gcs.analyticscore.client.GcsFileSystemImpl; +import com.google.cloud.gcs.analyticscore.client.GcsFileSystemOptions; +import com.google.cloud.gcs.analyticscore.client.GcsItemId; +import com.google.cloud.gcs.analyticscore.client.GcsItemInfo; +import com.google.cloud.gcs.analyticscore.client.GcsObjectRange; +import com.google.cloud.gcs.analyticscore.core.GcsAnalyticsCoreOptions; +import com.google.cloud.gcs.analyticscore.core.GoogleCloudStorageInputStream; +import com.google.cloud.storage.BlobId; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import java.util.function.IntFunction; +import java.util.stream.Collectors; +import org.apache.iceberg.gcp.GCPProperties; +import org.apache.iceberg.io.FileIOMetricsContext; +import org.apache.iceberg.io.FileRange; +import org.apache.iceberg.io.RangeReadable; +import org.apache.iceberg.io.SeekableInputStream; +import org.apache.iceberg.metrics.Counter; +import org.apache.iceberg.metrics.MetricsContext; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.util.PropertyUtil; + +/** + * Gateway to the optional {@code com.google.cloud.gcs.analyticscore.*} dependency. All references + * to analytics-core types are confined to this class so that it is loaded only when {@link + * org.apache.iceberg.gcp.GCPProperties#GCS_ANALYTICS_CORE_ENABLED} is true. + */ +class AnalyticsCoreUtil { + + private AnalyticsCoreUtil() {} + + static AutoCloseable createFileSystem(Map properties, Credentials credentials) { + Preconditions.checkState( + PropertyUtil.propertyAsBoolean(properties, GCPProperties.GCS_ANALYTICS_CORE_ENABLED, false), + "GCS analytics-core is disabled; %s must be set to true", + GCPProperties.GCS_ANALYTICS_CORE_ENABLED); + GcsAnalyticsCoreOptions options = new GcsAnalyticsCoreOptions("gcs.", properties); + GcsFileSystemOptions fileSystemOptions = options.getGcsFileSystemOptions(); + return credentials == null + ? new GcsFileSystemImpl(fileSystemOptions) + : new GcsFileSystemImpl(credentials, fileSystemOptions); + } + + static SeekableInputStream newStream( + AutoCloseable fileSystemHandle, BlobId blobId, Long blobSize, MetricsContext metrics) + throws IOException { + GcsFileSystem fileSystem = (GcsFileSystem) fileSystemHandle; + GcsItemId itemId = gcsItemId(blobId); + GoogleCloudStorageInputStream stream = + blobSize == null + ? GoogleCloudStorageInputStream.create(fileSystem, itemId) + : GoogleCloudStorageInputStream.create( + fileSystem, gcsFileInfo(blobId, itemId, blobSize)); + return new GcsInputStreamWrapper(stream, blobId, metrics); + } + + static void close(AutoCloseable fileSystemHandle) { + if (fileSystemHandle != null) { + ((GcsFileSystem) fileSystemHandle).close(); + } + } + + private static GcsItemId gcsItemId(BlobId blobId) { + GcsItemId.Builder builder = + GcsItemId.builder().setBucketName(blobId.getBucket()).setObjectName(blobId.getName()); + if (blobId.getGeneration() != null) { + builder.setContentGeneration(blobId.getGeneration()); + } + + return builder.build(); + } + + private static GcsFileInfo gcsFileInfo(BlobId blobId, GcsItemId itemId, long size) { + GcsItemInfo itemInfo = GcsItemInfo.builder().setItemId(itemId).setSize(size).build(); + return GcsFileInfo.builder() + .setItemInfo(itemInfo) + .setUri(URI.create(blobId.toGsUtilUri())) + .setAttributes(ImmutableMap.of()) + .build(); + } + + private static class GcsInputStreamWrapper extends SeekableInputStream implements RangeReadable { + private final Counter readBytes; + private final Counter readOperations; + private final GoogleCloudStorageInputStream stream; + private final BlobId blobId; + + GcsInputStreamWrapper( + GoogleCloudStorageInputStream stream, BlobId blobId, MetricsContext metrics) { + Preconditions.checkArgument(null != stream, "Invalid input stream : null"); + Preconditions.checkArgument(null != blobId, "Invalid blobId : null"); + this.stream = stream; + this.blobId = blobId; + this.readBytes = metrics.counter(FileIOMetricsContext.READ_BYTES, MetricsContext.Unit.BYTES); + this.readOperations = metrics.counter(FileIOMetricsContext.READ_OPERATIONS); + } + + @Override + public long getPos() throws IOException { + return stream.getPos(); + } + + @Override + public void seek(long newPos) throws IOException { + stream.seek(newPos); + } + + @Override + public int read() throws IOException { + int readByte; + try { + readByte = stream.read(); + } catch (IOException e) { + GCSExceptionUtil.throwNotFoundIfNotPresent(e, blobId); + throw e; + } + readBytes.increment(); + readOperations.increment(); + return readByte; + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int bytesRead; + try { + bytesRead = stream.read(b, off, len); + } catch (IOException e) { + GCSExceptionUtil.throwNotFoundIfNotPresent(e, blobId); + throw e; + } + if (bytesRead > 0) { + readBytes.increment(bytesRead); + } + readOperations.increment(); + return bytesRead; + } + + @Override + public void readFully(long position, byte[] buffer, int offset, int length) throws IOException { + try { + stream.readFully(position, buffer, offset, length); + } catch (IOException e) { + GCSExceptionUtil.throwNotFoundIfNotPresent(e, blobId); + throw e; + } + } + + @Override + public int readTail(byte[] buffer, int offset, int length) throws IOException { + try { + return stream.readTail(buffer, offset, length); + } catch (IOException e) { + GCSExceptionUtil.throwNotFoundIfNotPresent(e, blobId); + throw e; + } + } + + @Override + public void readVectored(List ranges, IntFunction allocate) + throws IOException { + List objectRanges = + ranges.stream() + .map( + fileRange -> + GcsObjectRange.builder() + .setOffset(fileRange.offset()) + .setLength(fileRange.length()) + .setByteBufferFuture(fileRange.byteBuffer()) + .build()) + .collect(Collectors.toList()); + try { + stream.readVectored(objectRanges, allocate); + } catch (IOException e) { + GCSExceptionUtil.throwNotFoundIfNotPresent(e, blobId); + throw e; + } + } + + @Override + public void close() throws IOException { + stream.close(); + } + } +} diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/BaseGCSFile.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/BaseGCSFile.java index d34ea63eee82..2a1440f3b48e 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/BaseGCSFile.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/BaseGCSFile.java @@ -27,13 +27,22 @@ abstract class BaseGCSFile { private final Storage storage; + // Using AutoCloseable avoids a runtime dependency on gcs-analytics-core. Cast via + // AnalyticsCoreUtil. + private final AutoCloseable gcsFileSystem; private final GCPProperties gcpProperties; private final BlobId blobId; private Blob metadata; private final MetricsContext metrics; - BaseGCSFile(Storage storage, BlobId blobId, GCPProperties gcpProperties, MetricsContext metrics) { + BaseGCSFile( + Storage storage, + AutoCloseable gcsFileSystem, + BlobId blobId, + GCPProperties gcpProperties, + MetricsContext metrics) { this.storage = storage; + this.gcsFileSystem = gcsFileSystem; this.blobId = blobId; this.gcpProperties = gcpProperties; this.metrics = metrics; @@ -47,6 +56,10 @@ Storage storage() { return storage; } + AutoCloseable gcsFileSystem() { + return gcsFileSystem; + } + URI uri() { return URI.create(blobId.toGsUtilUri()); } diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSExceptionUtil.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSExceptionUtil.java new file mode 100644 index 000000000000..681a2436e622 --- /dev/null +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSExceptionUtil.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.gcs; + +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.StorageException; +import java.io.IOException; +import org.apache.iceberg.exceptions.NotFoundException; + +final class GCSExceptionUtil { + private GCSExceptionUtil() {} + + static void throwNotFoundIfNotPresent(IOException ioException, BlobId blobId) { + if (ioException.getCause() instanceof StorageException storageException + && storageException.getCode() == 404) { + throw new NotFoundException(ioException, "Location does not exist: %s", blobId.toGsUtilUri()); + } + } +} diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSFileIO.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSFileIO.java index 188eddbfbb68..5e040943f703 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSFileIO.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSFileIO.java @@ -23,12 +23,21 @@ import com.google.cloud.storage.Blob; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.Storage; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.iceberg.common.DynConstructors; +import org.apache.iceberg.gcp.GCPProperties; import org.apache.iceberg.io.BulkDeletionFailureException; import org.apache.iceberg.io.DelegateFileIO; import org.apache.iceberg.io.FileInfo; @@ -44,6 +53,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Streams; import org.apache.iceberg.util.SerializableMap; import org.apache.iceberg.util.SerializableSupplier; +import org.apache.iceberg.util.ThreadPools; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,14 +74,16 @@ public class GCSFileIO implements DelegateFileIO, SupportsStorageCredentials { private static final String DEFAULT_METRICS_IMPL = "org.apache.iceberg.hadoop.HadoopMetricsContext"; private static final String ROOT_STORAGE_PREFIX = "gs"; + private static volatile ScheduledExecutorService executorService; private SerializableSupplier storageSupplier; private MetricsContext metrics = MetricsContext.nullMetrics(); private final AtomicBoolean isResourceClosed = new AtomicBoolean(false); private SerializableMap properties = null; // use modifiable collection for Kryo serde - private List storageCredentials = Lists.newArrayList(); + private volatile List storageCredentials = Lists.newArrayList(); private transient volatile Map storageByPrefix; + private transient volatile ScheduledFuture refreshFuture; /** * No-arg constructor to load the FileIO dynamically. @@ -199,6 +211,7 @@ private Map storageByPrefix() { storageSupplier)); }); this.storageByPrefix = localStorageByPrefix; + scheduleCredentialRefresh(); } } } @@ -206,6 +219,60 @@ private Map storageByPrefix() { return storageByPrefix; } + private void scheduleCredentialRefresh() { + storageCredentials.stream() + .map( + storageCredential -> + storageCredential.config().get(GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT)) + .filter(Objects::nonNull) + .map(expiresAtString -> Instant.ofEpochMilli(Long.parseLong(expiresAtString))) + .min(Comparator.naturalOrder()) + .ifPresent( + expiresAt -> { + Instant prefetchAt = expiresAt.minus(5, ChronoUnit.MINUTES); + long delay = Duration.between(Instant.now(), prefetchAt).toMillis(); + this.refreshFuture = + executorService() + .schedule(this::refreshStorageCredentials, delay, TimeUnit.MILLISECONDS); + }); + } + + private void refreshStorageCredentials() { + if (isResourceClosed.get()) { + return; + } + + try (OAuth2RefreshCredentialsHandler handler = + OAuth2RefreshCredentialsHandler.create(properties)) { + List refreshed = + handler.fetchCredentials().credentials().stream() + .filter(c -> c.prefix().startsWith(ROOT_STORAGE_PREFIX)) + .map(c -> StorageCredential.create(c.prefix(), c.config())) + .toList(); + + if (!refreshed.isEmpty() && !isResourceClosed.get()) { + this.storageCredentials = Lists.newArrayList(refreshed); + scheduleCredentialRefresh(); + } + } catch (Exception e) { + LOG.warn("Failed to refresh storage credentials", e); + } + } + + private ScheduledExecutorService executorService() { + if (executorService == null) { + synchronized (GCSFileIO.class) { + if (executorService == null) { + executorService = + ThreadPools.newExitingScheduledPool( + "iceberg-gcsfileio-tasks", 1, Duration.ofSeconds(10)); + } + } + } + + return executorService; + } + @Override public void close() { // handles concurrent calls to close() @@ -214,6 +281,10 @@ public void close() { storageByPrefix.values().forEach(PrefixedStorage::close); this.storageByPrefix = null; } + if (refreshFuture != null) { + refreshFuture.cancel(true); + refreshFuture = null; + } } } @@ -271,8 +342,21 @@ private void internalDeleteFiles(Stream blobIdsToDelete) { @Override public void setCredentials(List credentials) { Preconditions.checkArgument(credentials != null, "Invalid storage credentials: null"); + // stop any refresh that might be scheduled + if (refreshFuture != null) { + refreshFuture.cancel(true); + } + // copy credentials into a modifiable collection for Kryo serde this.storageCredentials = Lists.newArrayList(credentials); + + // if the clients are already initialized, we need to close and allow them to be recreated + synchronized (this) { + if (storageByPrefix != null) { + storageByPrefix.values().forEach(PrefixedStorage::close); + this.storageByPrefix = null; + } + } } @Override diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputFile.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputFile.java index 3022f1ecb451..9f1576f63dee 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputFile.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputFile.java @@ -20,12 +20,16 @@ import com.google.cloud.storage.BlobId; import com.google.cloud.storage.Storage; +import java.io.IOException; import org.apache.iceberg.gcp.GCPProperties; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.SeekableInputStream; import org.apache.iceberg.metrics.MetricsContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; class GCSInputFile extends BaseGCSFile implements InputFile { + private static final Logger LOG = LoggerFactory.getLogger(GCSInputFile.class); private Long blobSize; static GCSInputFile fromLocation( @@ -37,6 +41,7 @@ static GCSInputFile fromLocation( String location, long length, PrefixedStorage storage, MetricsContext metrics) { return new GCSInputFile( storage.storage(), + storage.gcsFileSystem(), BlobId.fromGsUtilUri(location), length > 0 ? length : null, storage.gcpProperties(), @@ -45,11 +50,14 @@ static GCSInputFile fromLocation( GCSInputFile( Storage storage, + // Using AutoCloseable avoids a runtime dependency on gcs-analytics-core. Cast via + // AnalyticsCoreUtil. + AutoCloseable gcsFileSystem, BlobId blobId, Long blobSize, GCPProperties gcpProperties, MetricsContext metrics) { - super(storage, blobId, gcpProperties, metrics); + super(storage, gcsFileSystem, blobId, gcpProperties, metrics); this.blobSize = blobSize; } @@ -64,6 +72,17 @@ public long getLength() { @Override public SeekableInputStream newStream() { + if (gcpProperties().isGcsAnalyticsCoreEnabled()) { + try { + return AnalyticsCoreUtil.newStream(gcsFileSystem(), blobId(), blobSize, metrics()); + } catch (IOException e) { + LOG.error( + "Failed to create GCS analytics core input stream for {}, falling back to default.", + uri(), + e); + } + } + return new GCSInputStream(storage(), blobId(), blobSize, gcpProperties(), metrics()); } } diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputStream.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputStream.java index 4483b0233669..910e97e0c178 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputStream.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSInputStream.java @@ -127,7 +127,12 @@ public int read() throws IOException { singleByteBuffer.position(0); pos += 1; - channel.read(singleByteBuffer); + try { + channel.read(singleByteBuffer); + } catch (IOException e) { + GCSExceptionUtil.throwNotFoundIfNotPresent(e, blobId); + throw e; + } readBytes.increment(); readOperations.increment(); @@ -174,7 +179,12 @@ private int read(ReadChannel readChannel, ByteBuffer buffer, int off, int len) throws IOException { buffer.position(off); buffer.limit(Math.min(off + len, buffer.capacity())); - return readChannel.read(buffer); + try { + return readChannel.read(buffer); + } catch (IOException e) { + GCSExceptionUtil.throwNotFoundIfNotPresent(e, blobId); + throw e; + } } @Override @@ -186,7 +196,7 @@ public void close() throws IOException { } } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputFile.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputFile.java index 34195aa71415..18bf53b791fb 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputFile.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputFile.java @@ -34,12 +34,22 @@ class GCSOutputFile extends BaseGCSFile implements OutputFile { static GCSOutputFile fromLocation( String location, PrefixedStorage storage, MetricsContext metrics) { return new GCSOutputFile( - storage.storage(), BlobId.fromGsUtilUri(location), storage.gcpProperties(), metrics); + storage.storage(), + storage.gcsFileSystem(), + BlobId.fromGsUtilUri(location), + storage.gcpProperties(), + metrics); } GCSOutputFile( - Storage storage, BlobId blobId, GCPProperties gcpProperties, MetricsContext metrics) { - super(storage, blobId, gcpProperties, metrics); + Storage storage, + // Using AutoCloseable avoids a runtime dependency on gcs-analytics-core. Cast via + // AnalyticsCoreUtil. + AutoCloseable gcsFileSystem, + BlobId blobId, + GCPProperties gcpProperties, + MetricsContext metrics) { + super(storage, gcsFileSystem, blobId, gcpProperties, metrics); } /** @@ -68,6 +78,6 @@ public PositionOutputStream createOrOverwrite() { @Override public InputFile toInputFile() { - return new GCSInputFile(storage(), blobId(), null, gcpProperties(), metrics()); + return new GCSInputFile(storage(), gcsFileSystem(), blobId(), null, gcpProperties(), metrics()); } } diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputStream.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputStream.java index e52a1638827a..3fb8aa4801ae 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputStream.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/GCSOutputStream.java @@ -130,7 +130,7 @@ public void close() throws IOException { stream.close(); } - @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize"}) + @SuppressWarnings({"checkstyle:NoFinalizer", "Finalize", "deprecation"}) @Override protected void finalize() throws Throwable { super.finalize(); diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/OAuth2RefreshCredentialsHandler.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/OAuth2RefreshCredentialsHandler.java index e46e254dd33e..c0ff1194d9e6 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/OAuth2RefreshCredentialsHandler.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/OAuth2RefreshCredentialsHandler.java @@ -32,7 +32,9 @@ import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.rest.ErrorHandlers; import org.apache.iceberg.rest.HTTPClient; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.RESTClient; +import org.apache.iceberg.rest.RESTUtil; import org.apache.iceberg.rest.auth.AuthManager; import org.apache.iceberg.rest.auth.AuthManagers; import org.apache.iceberg.rest.auth.AuthSession; @@ -43,6 +45,7 @@ public class OAuth2RefreshCredentialsHandler implements OAuth2CredentialsWithRefresh.OAuth2RefreshHandler, AutoCloseable { private final Map properties; private final String credentialsEndpoint; + private final String planId; // will be used to refresh the OAuth2 token private final String catalogEndpoint; private volatile HTTPClient client; @@ -59,19 +62,13 @@ private OAuth2RefreshCredentialsHandler(Map properties) { properties.get(GCPProperties.GCS_OAUTH2_REFRESH_CREDENTIALS_ENDPOINT); this.catalogEndpoint = properties.get(CatalogProperties.URI); this.properties = properties; + this.planId = properties.getOrDefault(RESTCatalogProperties.REST_SCAN_PLAN_ID, null); } @SuppressWarnings("JavaUtilDate") // GCP API uses java.util.Date @Override public AccessToken refreshAccessToken() { - LoadCredentialsResponse response = - httpClient() - .get( - credentialsEndpoint, - null, - LoadCredentialsResponse.class, - Map.of(), - ErrorHandlers.defaultErrorHandler()); + LoadCredentialsResponse response = fetchCredentials(); List gcsCredentials = response.credentials().stream() @@ -92,6 +89,16 @@ public AccessToken refreshAccessToken() { return new AccessToken(token, new Date(Long.parseLong(expiresAt))); } + LoadCredentialsResponse fetchCredentials() { + return httpClient() + .get( + credentialsEndpoint, + null != planId ? Map.of("planId", planId) : null, + LoadCredentialsResponse.class, + Map.of(), + ErrorHandlers.defaultErrorHandler()); + } + private void checkCredential(Credential gcsCredential, String gcsOauth2Token) { Preconditions.checkState( gcsCredential.config().containsKey(gcsOauth2Token), @@ -108,7 +115,11 @@ private RESTClient httpClient() { synchronized (this) { if (null == client) { authManager = AuthManagers.loadAuthManager("gcs-credentials-refresh", properties); - HTTPClient httpClient = HTTPClient.builder(properties).uri(catalogEndpoint).build(); + HTTPClient httpClient = + HTTPClient.builder(properties) + .uri(catalogEndpoint) + .withHeaders(RESTUtil.configHeaders(properties)) + .build(); authSession = authManager.catalogSession(httpClient, properties); client = httpClient.withAuthSession(authSession); } diff --git a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java index e9db60b149da..a442269c09cb 100644 --- a/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java +++ b/gcp/src/main/java/org/apache/iceberg/gcp/gcs/PrefixedStorage.java @@ -19,6 +19,9 @@ package org.apache.iceberg.gcp.gcs; import com.google.api.gax.rpc.FixedHeaderProvider; +import com.google.auth.Credentials; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ImpersonatedCredentials; import com.google.cloud.NoCredentials; import com.google.cloud.storage.Storage; import com.google.cloud.storage.StorageOptions; @@ -38,9 +41,11 @@ class PrefixedStorage implements AutoCloseable { private static final String GCS_FILE_IO_USER_AGENT = "gcsfileio/" + EnvironmentContext.get(); private final String storagePrefix; private final GCPProperties gcpProperties; + private final Map propertiesWithUserAgent; private SerializableSupplier storage; private CloseableGroup closeableGroup; private transient volatile Storage storageClient; + private transient volatile AutoCloseable gcsFileSystem; PrefixedStorage( String storagePrefix, Map properties, SerializableSupplier storage) { @@ -50,7 +55,12 @@ class PrefixedStorage implements AutoCloseable { this.storagePrefix = storagePrefix; this.storage = storage; this.gcpProperties = new GCPProperties(properties); - + this.propertiesWithUserAgent = + ImmutableMap.builder() + .putAll(properties) + .put("gcs.user-agent", GCS_FILE_IO_USER_AGENT) + .build(); + this.closeableGroup = new CloseableGroup(); if (null == storage) { this.storage = () -> { @@ -64,18 +74,9 @@ class PrefixedStorage implements AutoCloseable { gcpProperties.clientLibToken().ifPresent(builder::setClientLibToken); gcpProperties.serviceHost().ifPresent(builder::setHost); - // Google Cloud APIs default to automatically detect the credentials to use, which is - // in most cases the convenient way, especially in GCP. - // See javadoc of com.google.auth.oauth2.GoogleCredentials.getApplicationDefault() - if (gcpProperties.noAuth()) { - // Explicitly allow "no credentials" for testing purposes - builder.setCredentials(NoCredentials.getInstance()); - } - - if (gcpProperties.oauth2Token().isPresent()) { - this.closeableGroup = new CloseableGroup(); - builder.setCredentials( - GCPAuthUtils.oauth2CredentialsFromGcpProperties(gcpProperties, closeableGroup)); + Credentials credentials = credentials(gcpProperties); + if (credentials != null) { + builder.setCredentials(credentials); } return builder.build().getService(); @@ -105,17 +106,80 @@ public GCPProperties gcpProperties() { @Override public void close() { - if (null != closeableGroup) { + try { try { - closeableGroup.close(); + if (null != closeableGroup) { + closeableGroup.close(); + } } catch (IOException ioe) { throw new UncheckedIOException(ioe); + } finally { + if (null != gcsFileSystem) { + AnalyticsCoreUtil.close(gcsFileSystem); + gcsFileSystem = null; + } + } + } finally { + if (null != storage) { + // GCS Storage does not appear to be closable, so release the reference + storage = null; } } + } + + // Returns AutoCloseable to avoid a runtime dependency on gcs-analytics-core. Cast via + // AnalyticsCoreUtil. + AutoCloseable gcsFileSystem() { + if (!gcpProperties.isGcsAnalyticsCoreEnabled()) { + return null; + } + + if (gcsFileSystem == null) { + synchronized (this) { + if (gcsFileSystem == null) { + this.gcsFileSystem = + AnalyticsCoreUtil.createFileSystem( + propertiesWithUserAgent, credentials(gcpProperties)); + } + } + } + + return gcsFileSystem; + } + + private Credentials credentials(GCPProperties properties) { + // Google Cloud APIs default to automatically detect the credentials to use, which is + // in most cases the convenient way, especially in GCP. + // See javadoc of com.google.auth.oauth2.GoogleCredentials.getApplicationDefault() + if (properties.oauth2Token().isPresent()) { + return GCPAuthUtils.oauth2CredentialsFromGcpProperties(properties, closeableGroup); + } else if (properties.noAuth()) { + // Explicitly allow "no credentials" for testing purposes + return NoCredentials.getInstance(); + } else if (properties.impersonateServiceAccount().isPresent()) { + return buildImpersonatedCredentials(properties); + } else { + return null; + } + } + + private Credentials buildImpersonatedCredentials(GCPProperties properties) { + try { + GoogleCredentials sourceCredentials = GoogleCredentials.getApplicationDefault(); + + ImpersonatedCredentials impersonatedCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + properties.impersonateServiceAccount().get(), + properties.impersonateDelegates(), + properties.impersonateScopes(), + properties.impersonateLifetimeSeconds()); - if (null != storage) { - // GCS Storage does not appear to be closable, so release the reference - storage = null; + // Refresh to get initial token + impersonatedCredentials.refresh(); + return impersonatedCredentials; + } catch (IOException e) { + throw new UncheckedIOException("Failed to create impersonated credentials for GCS", e); } } } diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/auth/TestGoogleAuthManager.java b/gcp/src/test/java/org/apache/iceberg/gcp/auth/TestGoogleAuthManager.java index 63bc4cc7e77e..8c7bec27a126 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/auth/TestGoogleAuthManager.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/auth/TestGoogleAuthManager.java @@ -33,7 +33,9 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.InputStream; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.Collections; import java.util.Map; @@ -61,19 +63,22 @@ public class TestGoogleAuthManager { @Mock private RESTClient restClient; @Mock private GoogleCredentials credentials; @Mock private GoogleCredentials credentialsFromFile; + @Mock private GoogleCredentials credentialsFromJson; private GoogleAuthManager authManager; private MockedStatic mockedStaticCredentials; @TempDir File tempDir; private File credentialFile; + private String credentialJson; @BeforeEach public void beforeEach() throws IOException { authManager = new GoogleAuthManager(MANAGER_NAME); mockedStaticCredentials = Mockito.mockStatic(GoogleCredentials.class); credentialFile = new File(tempDir, "fake-creds.json"); - Files.write(credentialFile.toPath(), "{\"type\": \"service_account\"}".getBytes()); + credentialJson = "{\"type\": \"service_account\"}"; + Files.write(credentialFile.toPath(), credentialJson.getBytes(StandardCharsets.UTF_8)); } @AfterEach @@ -107,6 +112,27 @@ public void buildsCatalogSessionFromCredentialsFile() { verify(credentialsFromFile).createScoped(ImmutableList.of("scope1", "scope2")); } + @Test + public void buildsCatalogSessionFromCredentialsJson() { + String customScopes = "scope1,scope2"; + Map properties = + ImmutableMap.of( + GoogleAuthManager.GCP_CREDENTIALS_JSON_PROPERTY, + credentialJson, + GoogleAuthManager.GCP_SCOPES_PROPERTY, + customScopes); + + mockedStaticCredentials + .when(() -> GoogleCredentials.fromStream(any(InputStream.class))) + .thenReturn(credentialsFromJson); + when(credentialsFromJson.createScoped(anyList())).thenReturn(credentials); + + AuthSession session = authManager.catalogSession(restClient, properties); + + assertThat(session).isInstanceOf(GoogleAuthSession.class); + verify(credentialsFromJson).createScoped(ImmutableList.of("scope1", "scope2")); + } + @Test public void buildsCatalogSessionWithEmptyScopes() { Map properties = @@ -142,6 +168,20 @@ public void throwsUncheckedIOExceptionOnCredentialsFileError() { .hasMessageContaining("Failed to load Google credentials"); } + @Test + public void throwsIllegalArgumentExceptionOnMultipleCredentials() { + Map properties = + ImmutableMap.of( + GoogleAuthManager.GCP_CREDENTIALS_PATH_PROPERTY, + credentialFile.getAbsolutePath(), + GoogleAuthManager.GCP_CREDENTIALS_JSON_PROPERTY, + credentialJson); + + assertThatThrownBy(() -> authManager.catalogSession(restClient, properties)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot specify both gcp.auth.credentials-path and gcp.auth.credentials-json"); + } + @Test public void buildsCatalogSessionUsingADC() { mockedStaticCredentials diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestAnalyticsCoreUtil.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestAnalyticsCoreUtil.java new file mode 100644 index 000000000000..d91555345345 --- /dev/null +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestAnalyticsCoreUtil.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.gcs; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; + +import com.google.cloud.gcs.analyticscore.client.GcsFileSystem; +import com.google.cloud.gcs.analyticscore.client.GcsItemId; +import com.google.cloud.gcs.analyticscore.client.GcsObjectRange; +import com.google.cloud.gcs.analyticscore.core.GoogleCloudStorageInputStream; +import com.google.cloud.storage.BlobId; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.IntFunction; +import org.apache.iceberg.io.FileRange; +import org.apache.iceberg.io.RangeReadable; +import org.apache.iceberg.io.SeekableInputStream; +import org.apache.iceberg.metrics.MetricsContext; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +public class TestAnalyticsCoreUtil { + + @Test + public void readVectored() throws IOException { + GcsFileSystem fileSystem = mock(GcsFileSystem.class); + GoogleCloudStorageInputStream gcsInputStream = mock(GoogleCloudStorageInputStream.class); + BlobId blobId = BlobId.of("mockbucket", "mockname"); + + SeekableInputStream stream; + try (MockedStatic mocked = + mockStatic(GoogleCloudStorageInputStream.class)) { + mocked + .when(() -> GoogleCloudStorageInputStream.create(eq(fileSystem), any(GcsItemId.class))) + .thenReturn(gcsInputStream); + stream = AnalyticsCoreUtil.newStream(fileSystem, blobId, null, MetricsContext.nullMetrics()); + } + + CompletableFuture future1 = new CompletableFuture<>(); + CompletableFuture future2 = new CompletableFuture<>(); + List ranges = + List.of(new FileRange(future1, 10L, 100), new FileRange(future2, 0, 50)); + IntFunction allocate = ByteBuffer::allocate; + + ((RangeReadable) stream).readVectored(ranges, allocate); + + List objectRanges = + List.of( + GcsObjectRange.builder() + .setOffset(10) + .setLength(100) + .setByteBufferFuture(future1) + .build(), + GcsObjectRange.builder() + .setOffset(0) + .setLength(50) + .setByteBufferFuture(future2) + .build()); + verify(gcsInputStream).readVectored(objectRanges, allocate); + } +} diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSFileIO.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSFileIO.java index fde0038b278c..f6841664e0d3 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSFileIO.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSFileIO.java @@ -488,6 +488,45 @@ public void fileIOWithPrefixedStorageClientSerialization( .isEqualTo(new AccessToken("gcsTokenFromCredential", new Date(2000L))); } + @Test + public void setCredentialsRefreshesClients() { + StorageCredential initialCredential = + StorageCredential.create( + "gs://custom-uri", + ImmutableMap.of( + "gcs.oauth2.token", "initialToken", "gcs.oauth2.token-expires-at", "1000")); + + try (GCSFileIO fileIO = new GCSFileIO()) { + fileIO.setCredentials(ImmutableList.of(initialCredential)); + fileIO.initialize( + ImmutableMap.of( + GCS_OAUTH2_TOKEN, "gcsTokenFromProperties", GCS_OAUTH2_TOKEN_EXPIRES_AT, "500")); + + Storage initialClient = fileIO.client("gs://custom-uri/table1"); + assertThat(initialClient.getOptions().getCredentials()) + .isInstanceOf(OAuth2Credentials.class) + .extracting("value") + .extracting("temporaryAccess") + .isEqualTo(new AccessToken("initialToken", new Date(1000L))); + + StorageCredential refreshedCredential = + StorageCredential.create( + "gs://custom-uri", + ImmutableMap.of( + "gcs.oauth2.token", "refreshedToken", "gcs.oauth2.token-expires-at", "2000")); + + fileIO.setCredentials(ImmutableList.of(refreshedCredential)); + + Storage refreshedClient = fileIO.client("gs://custom-uri/table1"); + assertThat(refreshedClient).isNotSameAs(initialClient); + assertThat(refreshedClient.getOptions().getCredentials()) + .isInstanceOf(OAuth2Credentials.class) + .extracting("value") + .extracting("temporaryAccess") + .isEqualTo(new AccessToken("refreshedToken", new Date(2000L))); + } + } + @ParameterizedTest @MethodSource("org.apache.iceberg.TestHelpers#serializers") public void resolvingFileIOLoadWithoutStorageCredentials( diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSFileIOCredentialRefresh.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSFileIOCredentialRefresh.java new file mode 100644 index 000000000000..0b9bb37f5f90 --- /dev/null +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSFileIOCredentialRefresh.java @@ -0,0 +1,296 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.gcs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockserver.integration.ClientAndServer.startClientAndServer; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.gcp.GCPProperties; +import org.apache.iceberg.io.StorageCredential; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.HttpMethod; +import org.apache.iceberg.rest.credentials.ImmutableCredential; +import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse; +import org.apache.iceberg.rest.responses.LoadCredentialsResponse; +import org.apache.iceberg.rest.responses.LoadCredentialsResponseParser; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; +import org.mockserver.verify.VerificationTimes; + +class TestGCSFileIOCredentialRefresh { + + private static ClientAndServer mockServer; + private static String credentialsUri; + private static String catalogUri; + + @BeforeAll + static void beforeAll() { + mockServer = startClientAndServer(0); + int port = mockServer.getPort(); + credentialsUri = String.format("http://127.0.0.1:%d/v1/credentials", port); + catalogUri = String.format("http://127.0.0.1:%d/v1", port); + } + + @AfterAll + static void stopServer() { + mockServer.stop(); + } + + @BeforeEach + void before() { + mockServer.reset(); + } + + @Test + void credentialRefreshSchedulesNextRefresh() { + String nearExpiryMs = Long.toString(Instant.now().plus(3, ChronoUnit.MINUTES).toEpochMilli()); + + StorageCredential initialCredential = + StorageCredential.create( + "gs://bucket/path", + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "initialToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + nearExpiryMs)); + + // return credentials that also expire within 5 minutes so the next refresh fires immediately + String firstRefreshExpiryMs = + Long.toString(Instant.now().plus(2, ChronoUnit.MINUTES).toEpochMilli()); + String secondRefreshExpiryMs = + Long.toString(Instant.now().plus(1, ChronoUnit.HOURS).toEpochMilli()); + + LoadCredentialsResponse firstRefreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("gs://bucket/path") + .config( + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "firstRefreshedToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + firstRefreshExpiryMs)) + .build()) + .build(); + + LoadCredentialsResponse secondRefreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("gs://bucket/path") + .config( + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "secondRefreshedToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + secondRefreshExpiryMs)) + .build()) + .build(); + + HttpRequest mockRequest = request("/v1/credentials").withMethod(HttpMethod.GET.name()); + mockServer + .when(mockRequest, org.mockserver.matchers.Times.once()) + .respond( + response(LoadCredentialsResponseParser.toJson(firstRefreshResponse)) + .withStatusCode(200)); + mockServer + .when(mockRequest, org.mockserver.matchers.Times.unlimited()) + .respond( + response(LoadCredentialsResponseParser.toJson(secondRefreshResponse)) + .withStatusCode(200)); + + Map properties = + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_REFRESH_CREDENTIALS_ENDPOINT, + credentialsUri, + CatalogProperties.URI, + catalogUri); + + try (GCSFileIO fileIO = new GCSFileIO()) { + fileIO.initialize(properties); + fileIO.setCredentials(List.of(initialCredential)); + + fileIO.client(); + + // the first refresh returns near-expiry credentials, which should schedule a second refresh + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> mockServer.verify(mockRequest, VerificationTimes.atLeast(2))); + + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List credentials = fileIO.credentials(); + assertThat(credentials).hasSize(1); + assertThat(credentials.get(0).config()) + .containsEntry(GCPProperties.GCS_OAUTH2_TOKEN, "secondRefreshedToken") + .containsEntry( + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, secondRefreshExpiryMs); + }); + } + } + + @Test + void credentialRefreshWithinFiveMinuteWindow() { + String nearExpiryMs = Long.toString(Instant.now().plus(3, ChronoUnit.MINUTES).toEpochMilli()); + + StorageCredential initialCredential = + StorageCredential.create( + "gs://bucket/path", + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "initialToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + nearExpiryMs)); + + String refreshedExpiryMs = + Long.toString(Instant.now().plus(1, ChronoUnit.HOURS).toEpochMilli()); + LoadCredentialsResponse refreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("gs://bucket/path") + .config( + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "refreshedToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + refreshedExpiryMs)) + .build()) + .build(); + + HttpRequest mockRequest = request("/v1/credentials").withMethod(HttpMethod.GET.name()); + HttpResponse mockResponse = + response(LoadCredentialsResponseParser.toJson(refreshResponse)).withStatusCode(200); + mockServer.when(mockRequest).respond(mockResponse); + + Map properties = + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_REFRESH_CREDENTIALS_ENDPOINT, + credentialsUri, + CatalogProperties.URI, + catalogUri); + + try (GCSFileIO fileIO = new GCSFileIO()) { + fileIO.initialize(properties); + fileIO.setCredentials(List.of(initialCredential)); + + // trigger storageByPrefix() to build the client map and schedule the refresh + fileIO.client(); + + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> mockServer.verify(mockRequest, VerificationTimes.atLeast(1))); + + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List credentials = fileIO.credentials(); + assertThat(credentials).hasSize(1); + assertThat(credentials.get(0).config()) + .containsEntry(GCPProperties.GCS_OAUTH2_TOKEN, "refreshedToken") + .containsEntry(GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, refreshedExpiryMs); + }); + } + } + + @Test + void refreshedCredentialsAreKryoSerializable() throws Exception { + // Verify that a GCSFileIO whose credentials have been refreshed at runtime can still be + // round-tripped through Kryo. The internal storageCredentials list must be backed by a + // collection that Kryo can serialize and deserialize. + String nearExpiryMs = Long.toString(Instant.now().plus(3, ChronoUnit.MINUTES).toEpochMilli()); + + StorageCredential initialCredential = + StorageCredential.create( + "gs://bucket/path", + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "initialToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + nearExpiryMs)); + + String refreshedExpiryMs = + Long.toString(Instant.now().plus(1, ChronoUnit.HOURS).toEpochMilli()); + LoadCredentialsResponse refreshResponse = + ImmutableLoadCredentialsResponse.builder() + .addCredentials( + ImmutableCredential.builder() + .prefix("gs://bucket/path") + .config( + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "refreshedToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + refreshedExpiryMs)) + .build()) + .build(); + + HttpRequest mockRequest = request("/v1/credentials").withMethod(HttpMethod.GET.name()); + mockServer + .when(mockRequest) + .respond( + response(LoadCredentialsResponseParser.toJson(refreshResponse)).withStatusCode(200)); + + Map properties = + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_REFRESH_CREDENTIALS_ENDPOINT, + credentialsUri, + CatalogProperties.URI, + catalogUri); + + try (GCSFileIO fileIO = new GCSFileIO()) { + fileIO.initialize(properties); + fileIO.setCredentials(List.of(initialCredential)); + + fileIO.client(); + + // Wait for the refresh to update the in-memory credentials + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> + assertThat(fileIO.credentials().get(0).config()) + .containsEntry(GCPProperties.GCS_OAUTH2_TOKEN, "refreshedToken")); + + // Round-trip through Kryo and verify the credentials still match + try (GCSFileIO deserialized = TestHelpers.KryoHelpers.roundTripSerialize(fileIO)) { + assertThat(deserialized.credentials()).isEqualTo(fileIO.credentials()); + } + } + } +} diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSInputStream.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSInputStream.java index f367db94264a..8cc85fad72fd 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSInputStream.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGCSInputStream.java @@ -163,6 +163,9 @@ private void readAndCheckRanges( @Test public void testClose() throws Exception { BlobId blobId = BlobId.fromGsUtilUri("gs://bucket/path/to/closed.dat"); + byte[] data = randomData(1024 * 1024); + writeGCSData(blobId, data); + SeekableInputStream closed = new GCSInputStream(storage, blobId, null, gcpProperties, MetricsContext.nullMetrics()); closed.close(); diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsInputFile.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsInputFile.java new file mode 100644 index 000000000000..e3d295805f7e --- /dev/null +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestGcsInputFile.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.gcp.gcs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.google.cloud.gcs.analyticscore.client.GcsFileInfo; +import com.google.cloud.gcs.analyticscore.client.GcsFileSystem; +import com.google.cloud.gcs.analyticscore.client.GcsItemId; +import com.google.cloud.gcs.analyticscore.client.GcsItemInfo; +import com.google.cloud.gcs.analyticscore.core.GcsAnalyticsCoreOptions; +import com.google.cloud.gcs.analyticscore.core.GoogleCloudStorageInputStream; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.Storage; +import java.io.IOException; +import java.net.URI; +import java.util.Collections; +import org.apache.iceberg.gcp.GCPProperties; +import org.apache.iceberg.io.SeekableInputStream; +import org.apache.iceberg.metrics.MetricsContext; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +public class TestGcsInputFile { + + private static final String TEST_BUCKET = "TEST_BUCKET"; + private static final String KEY = "file/path/a.dat"; + private static final String LOCATION = "gs://" + TEST_BUCKET + "/" + KEY; + private static final long FILE_SIZE = 1024L; + + private Storage storage; + private GcsFileSystem gcsFileSystem; + private GcsAnalyticsCoreOptions gcsAnalyticsCoreOptions; + private PrefixedStorage prefixedStorage; + private GCPProperties gcpProperties; + private MetricsContext metricsContext; + private Blob blob; + + @BeforeEach + public void before() { + storage = mock(Storage.class); + gcsFileSystem = mock(GcsFileSystem.class); + prefixedStorage = mock(PrefixedStorage.class); + gcsAnalyticsCoreOptions = new GcsAnalyticsCoreOptions("", Collections.emptyMap()); + gcpProperties = new GCPProperties(); + metricsContext = MetricsContext.nullMetrics(); + blob = mock(Blob.class); + when(prefixedStorage.storage()).thenReturn(storage); + when(prefixedStorage.gcsFileSystem()).thenReturn(gcsFileSystem); + when(prefixedStorage.gcpProperties()).thenReturn(gcpProperties); + when(gcsFileSystem.getFileSystemOptions()) + .thenReturn(gcsAnalyticsCoreOptions.getGcsFileSystemOptions()); + when(storage.get(any(BlobId.class))).thenReturn(blob); + when(blob.getSize()).thenReturn(FILE_SIZE); + } + + @Test + public void fromLocation() { + GCSInputFile inputFile = GCSInputFile.fromLocation(LOCATION, prefixedStorage, metricsContext); + + assertThat(inputFile.blobId()).isEqualTo(BlobId.fromGsUtilUri(LOCATION)); + assertThat(inputFile.getLength()).isEqualTo(FILE_SIZE); + } + + @Test + public void fromLocationWithLength() { + GCSInputFile inputFile = + GCSInputFile.fromLocation(LOCATION, FILE_SIZE, prefixedStorage, metricsContext); + + assertThat(inputFile.blobId()).isEqualTo(BlobId.fromGsUtilUri(LOCATION)); + assertThat(inputFile.getLength()).isEqualTo(FILE_SIZE); + } + + @Test + public void getLength() { + when(blob.getSize()).thenReturn(FILE_SIZE); + GCSInputFile inputFile = + new GCSInputFile( + storage, + gcsFileSystem, + BlobId.fromGsUtilUri(LOCATION), + null, + gcpProperties, + metricsContext); + + assertThat(inputFile.getLength()).isEqualTo(FILE_SIZE); + } + + @Test + public void getLengthCached() { + GCSInputFile inputFile = + new GCSInputFile( + storage, + gcsFileSystem, + BlobId.fromGsUtilUri(LOCATION), + FILE_SIZE, + gcpProperties, + metricsContext); + + assertThat(inputFile.getLength()).isEqualTo(FILE_SIZE); + } + + @Test + public void newStreamGcsAnalyticsCoreEnabled() throws IOException { + GCPProperties enabledGcpProperties = + new GCPProperties(ImmutableMap.of(GCPProperties.GCS_ANALYTICS_CORE_ENABLED, "true")); + BlobId blobId = BlobId.fromGsUtilUri(LOCATION); + GcsItemId itemId = + GcsItemId.builder() + .setBucketName(blobId.getBucket()) + .setObjectName(blobId.getName()) + .build(); + GcsItemInfo itemInfo = GcsItemInfo.builder().setItemId(itemId).setSize(FILE_SIZE).build(); + GcsFileInfo gcsFileInfo = + GcsFileInfo.builder() + .setItemInfo(itemInfo) + .setUri(URI.create(LOCATION)) + .setAttributes(ImmutableMap.of()) + .build(); + try (MockedStatic mocked = + mockStatic(GoogleCloudStorageInputStream.class)) { + mocked + .when(() -> GoogleCloudStorageInputStream.create(gcsFileSystem, gcsFileInfo)) + .thenReturn(mock(GoogleCloudStorageInputStream.class)); + + GCSInputFile inputFile = + new GCSInputFile( + storage, + gcsFileSystem, + BlobId.fromGsUtilUri(LOCATION), + FILE_SIZE, + enabledGcpProperties, + metricsContext); + + try (SeekableInputStream stream = inputFile.newStream()) { + assertThat(stream).isNotInstanceOf(GCSInputStream.class); + } + mocked.verify(() -> GoogleCloudStorageInputStream.create(gcsFileSystem, gcsFileInfo)); + } + } + + @Test + public void newStreamGcsAnalyticsCoreEnabledObjectSizeNull() throws IOException { + GCPProperties enabledGcpProperties = + new GCPProperties(ImmutableMap.of(GCPProperties.GCS_ANALYTICS_CORE_ENABLED, "true")); + BlobId blobId = BlobId.fromGsUtilUri(LOCATION); + GcsItemId itemId = + GcsItemId.builder() + .setBucketName(blobId.getBucket()) + .setObjectName(blobId.getName()) + .build(); + try (MockedStatic mocked = + mockStatic(GoogleCloudStorageInputStream.class)) { + mocked + .when(() -> GoogleCloudStorageInputStream.create(gcsFileSystem, itemId)) + .thenReturn(mock(GoogleCloudStorageInputStream.class)); + + GCSInputFile inputFile = + new GCSInputFile( + storage, + gcsFileSystem, + BlobId.fromGsUtilUri(LOCATION), + null, + enabledGcpProperties, + metricsContext); + + try (SeekableInputStream stream = inputFile.newStream()) { + assertThat(stream).isNotInstanceOf(GCSInputStream.class); + } + mocked.verify(() -> GoogleCloudStorageInputStream.create(gcsFileSystem, itemId)); + } + } + + @Test + public void newStreamGcsAnalyticsCoreDisabled() throws IOException { + GCSInputFile inputFile = + new GCSInputFile( + storage, + gcsFileSystem, + BlobId.fromGsUtilUri(LOCATION), + FILE_SIZE, + gcpProperties, + metricsContext); + + try (MockedConstruction mocked = + mockConstruction( + GCSInputStream.class, + (mock, context) -> { + assertThat(context.arguments()).hasSize(5); + assertThat(context.arguments().get(0)).isEqualTo(storage); + assertThat(context.arguments().get(1)).isEqualTo(BlobId.fromGsUtilUri(LOCATION)); + assertThat(context.arguments().get(2)).isEqualTo(FILE_SIZE); + assertThat(context.arguments().get(3)).isEqualTo(gcpProperties); + assertThat(context.arguments().get(4)).isEqualTo(metricsContext); + })) { + try (SeekableInputStream stream = inputFile.newStream()) { + assertThat(stream).isInstanceOf(GCSInputStream.class); + assertThat(mocked.constructed()).hasSize(1); + } + } + } + + @Test + public void newStreamAnalyticsCoreInitializationFailed() throws IOException { + GCPProperties enabledGcpProperties = + new GCPProperties(ImmutableMap.of(GCPProperties.GCS_ANALYTICS_CORE_ENABLED, "true")); + BlobId blobId = BlobId.fromGsUtilUri(LOCATION); + GcsItemId itemId = + GcsItemId.builder() + .setBucketName(blobId.getBucket()) + .setObjectName(blobId.getName()) + .build(); + + try (MockedStatic mocked = + mockStatic(GoogleCloudStorageInputStream.class)) { + mocked + .when(() -> GoogleCloudStorageInputStream.create(gcsFileSystem, itemId)) + .thenThrow(new IOException("GCS connector failed")); + + GCSInputFile inputFile = + new GCSInputFile( + storage, + gcsFileSystem, + BlobId.fromGsUtilUri(LOCATION), + null, + enabledGcpProperties, + metricsContext); + try (MockedConstruction inputStreamMocked = + mockConstruction( + GCSInputStream.class, + (mock, context) -> { + assertThat(context.arguments()).hasSize(5); + assertThat(context.arguments().get(0)).isEqualTo(storage); + assertThat(context.arguments().get(1)).isEqualTo(BlobId.fromGsUtilUri(LOCATION)); + assertThat(context.arguments().get(2)).isEqualTo(null); + assertThat(context.arguments().get(3)).isEqualTo(enabledGcpProperties); + assertThat(context.arguments().get(4)).isEqualTo(metricsContext); + })) { + SeekableInputStream stream = inputFile.newStream(); + assertThat(stream).isInstanceOf(GCSInputStream.class); + assertThat(inputStreamMocked.constructed()).hasSize(1); + mocked.verify(() -> GoogleCloudStorageInputStream.create(gcsFileSystem, itemId)); + stream.close(); + } + } + } +} diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestOAuth2RefreshCredentialsHandler.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestOAuth2RefreshCredentialsHandler.java index 8e5e970a9a9c..0f29b9781895 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestOAuth2RefreshCredentialsHandler.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestOAuth2RefreshCredentialsHandler.java @@ -32,6 +32,7 @@ import org.apache.iceberg.gcp.GCPProperties; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.HttpMethod; +import org.apache.iceberg.rest.RESTCatalogProperties; import org.apache.iceberg.rest.credentials.Credential; import org.apache.iceberg.rest.credentials.ImmutableCredential; import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse; @@ -274,4 +275,45 @@ public void multipleGcsCredentials() { .isInstanceOf(IllegalStateException.class) .hasMessage("Invalid GCS Credentials: only one GCS credential should exist"); } + + @Test + public void planIdQueryParamIsSent() { + String planId = "randomPlanId"; + HttpRequest mockRequest = + HttpRequest.request("/v1/credentials") + .withMethod(HttpMethod.GET.name()) + .withQueryStringParameter("planId", planId); + + Credential credential = + ImmutableCredential.builder() + .prefix("gs") + .config( + ImmutableMap.of( + GCPProperties.GCS_OAUTH2_TOKEN, + "gcsToken", + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + Long.toString(Instant.now().plus(5, ChronoUnit.MINUTES).toEpochMilli()))) + .build(); + HttpResponse mockResponse = + HttpResponse.response( + LoadCredentialsResponseParser.toJson( + ImmutableLoadCredentialsResponse.builder().addCredentials(credential).build())) + .withStatusCode(200); + mockServer.when(mockRequest).respond(mockResponse); + + Map properties = + ImmutableMap.builder() + .putAll(PROPERTIES) + .put(RESTCatalogProperties.REST_SCAN_PLAN_ID, planId) + .build(); + OAuth2RefreshCredentialsHandler handler = OAuth2RefreshCredentialsHandler.create(properties); + + AccessToken accessToken = handler.refreshAccessToken(); + + // refresh always fetches a new token and sends the planId again + AccessToken refreshedToken = handler.refreshAccessToken(); + assertThat(refreshedToken).isNotSameAs(accessToken); + + mockServer.verify(mockRequest, VerificationTimes.exactly(2)); + } } diff --git a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java index 595f025ae9a0..0a06fcdd0c1f 100644 --- a/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java +++ b/gcp/src/test/java/org/apache/iceberg/gcp/gcs/TestPrefixedStorage.java @@ -21,6 +21,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.google.cloud.gcs.analyticscore.client.GcsClientOptions; +import com.google.cloud.gcs.analyticscore.client.GcsFileSystem; +import com.google.cloud.gcs.analyticscore.client.GcsFileSystemOptions; +import com.google.cloud.gcs.analyticscore.client.GcsReadOptions; import java.util.Map; import org.apache.iceberg.EnvironmentContext; import org.apache.iceberg.gcp.GCPProperties; @@ -69,4 +73,91 @@ public void userAgentPrefix() { assertThat(storage.storage().getOptions().getUserAgent()) .isEqualTo("gcsfileio/" + EnvironmentContext.get()); } + + @Test + public void impersonationPropertiesAreRead() { + Map properties = + ImmutableMap.of( + GCPProperties.GCS_PROJECT_ID, "myProject", + GCPProperties.GCS_IMPERSONATE_SERVICE_ACCOUNT, + "test-sa@project.iam.gserviceaccount.com", + GCPProperties.GCS_IMPERSONATE_DELEGATES, "delegate-sa@project.iam.gserviceaccount.com", + GCPProperties.GCS_IMPERSONATE_LIFETIME_SECONDS, "1800", + GCPProperties.GCS_IMPERSONATE_SCOPES, "bigquery,devstorage.read_only"); + + GCPProperties gcpProperties = new GCPProperties(properties); + + assertThat(gcpProperties.impersonateServiceAccount()) + .contains("test-sa@project.iam.gserviceaccount.com"); + assertThat(gcpProperties.impersonateDelegates()) + .contains("delegate-sa@project.iam.gserviceaccount.com"); + assertThat(gcpProperties.impersonateLifetimeSeconds()).isEqualTo(1800); + assertThat(gcpProperties.impersonateScopes()) + .containsExactly( + "https://www.googleapis.com/auth/bigquery", + "https://www.googleapis.com/auth/devstorage.read_only"); + } + + @Test + public void impersonationPropertiesWithDefaults() { + Map properties = + ImmutableMap.of( + GCPProperties.GCS_PROJECT_ID, "myProject", + GCPProperties.GCS_IMPERSONATE_SERVICE_ACCOUNT, + "test-sa@project.iam.gserviceaccount.com"); + + GCPProperties gcpProperties = new GCPProperties(properties); + + assertThat(gcpProperties.impersonateServiceAccount()) + .contains("test-sa@project.iam.gserviceaccount.com"); + assertThat(gcpProperties.impersonateDelegates()).isNull(); + assertThat(gcpProperties.impersonateLifetimeSeconds()) + .isEqualTo(GCPProperties.GCS_IMPERSONATE_LIFETIME_SECONDS_DEFAULT); + } + + @Test + public void gcsFileSystemDisabledByDefault() { + Map properties = ImmutableMap.of(GCPProperties.GCS_PROJECT_ID, "myProject"); + PrefixedStorage storage = new PrefixedStorage("gs://bucket", properties, null); + + assertThat(storage.gcsFileSystem()).isNull(); + } + + @Test + public void gcsFileSystem() { + Map properties = + ImmutableMap.builder() + .put(GCPProperties.GCS_ANALYTICS_CORE_ENABLED, "true") + .put(GCPProperties.GCS_PROJECT_ID, "myProject") + .put(GCPProperties.GCS_USER_PROJECT, "userProject") + .put(GCPProperties.GCS_CLIENT_LIB_TOKEN, "gccl") + .put(GCPProperties.GCS_SERVICE_HOST, "example.com") + .put(GCPProperties.GCS_DECRYPTION_KEY, "decryptionKey") + .put(GCPProperties.GCS_ENCRYPTION_KEY, "encryptionKey") + .put(GCPProperties.GCS_CHANNEL_READ_CHUNK_SIZE, "1024") + .build(); + PrefixedStorage storage = new PrefixedStorage("gs://bucket", properties, null); + GcsFileSystemOptions expectedOptions = + GcsFileSystemOptions.builder() + .setGcsClientOptions( + GcsClientOptions.builder() + .setProjectId("myProject") + .setClientLibToken("gccl") + .setServiceHost("example.com") + .setUserAgent("gcsfileio/" + EnvironmentContext.get()) + .setGcsReadOptions( + GcsReadOptions.builder() + .setChunkSize(1024) + .setDecryptionKey("decryptionKey") + .setUserProjectId("userProject") + .build()) + .build()) + .build(); + + GcsFileSystem fileSystem = (GcsFileSystem) storage.gcsFileSystem(); + + assertThat(fileSystem).isNotNull(); + assertThat(fileSystem.getGcsClient()).isNotNull(); + assertThat(fileSystem.getFileSystemOptions()).isEqualTo(expectedOptions); + } } diff --git a/gradle.properties b/gradle.properties index 0f70b49eb722..c0f283303ae0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,8 +18,8 @@ jmhJsonOutputPath=build/reports/jmh/results.json jmhIncludeRegex=.* systemProp.defaultFlinkVersions=2.1 systemProp.knownFlinkVersions=1.20,2.0,2.1 -systemProp.defaultSparkVersions=4.0 -systemProp.knownSparkVersions=3.4,3.5,4.0 +systemProp.defaultSparkVersions=4.1 +systemProp.knownSparkVersions=3.4,3.5,4.0,4.1 systemProp.defaultKafkaVersions=3 systemProp.knownKafkaVersions=3 systemProp.defaultScalaVersion=2.12 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a99206dba3f2..db659dc06b40 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,79 +21,85 @@ [versions] activation = "1.1.1" -aliyun-sdk-oss = "3.10.2" +aliyun-credentials-java = "0.3.12" +aliyun-sdk-oss = "3.18.5" +aliyun-tea = "1.4.1" analyticsaccelerator = "1.3.1" antlr = "4.9.3" antlr413 = "4.13.1" # For Spark 4.0 support -aircompressor = "0.27" +aircompressor = "2.0.3" apiguardian = "1.1.2" arrow = "15.0.2" avro = "1.12.1" -assertj-core = "3.27.6" +assertj-core = "3.27.7" awaitility = "4.3.0" -awssdk-bom = "2.40.3" -azuresdk-bom = "1.3.3" -awssdk-s3accessgrants = "2.3.0" +awssdk-bom = "2.44.4" +azuresdk-bom = "1.3.6" +awssdk-s3accessgrants = "2.4.1" +bouncycastle = "1.84" bson-ver = "4.11.5" caffeine = "2.9.3" calcite = "1.41.0" -comet = "0.10.1" datasketches = "6.2.0" delta-standalone = "3.3.2" delta-spark = "3.3.2" derby = "10.15.2.0" esotericsoftware-kryo = "4.0.3" -errorprone-annotations = "2.45.0" +errorprone-annotations = "2.49.0" failsafe = "3.3.2" findbugs-jsr305 = "3.0.2" flink120 = { strictly = "1.20.1"} flink20 = { strictly = "2.0.0"} flink21 = { strictly = "2.1.0"} -google-libraries-bom = "26.72.0" -guava = "33.5.0-jre" -hadoop3 = "3.4.2" -httpcomponents-httpclient5 = "5.5.1" +google-libraries-bom = "26.80.0" +gcs-analytics-core = "1.2.3" +guava = "33.6.0-jre" +hadoop3 = "3.4.3" +httpcomponents-httpclient5 = "5.6.1" hive2 = { strictly = "2.3.10"} # see rich version usage explanation above -immutables-value = "2.11.7" -jackson-annotations = "2.20" -jackson-bom = "2.20.1" -jackson211 = { strictly = "2.11.4"} # see rich version usage explanation above -jackson212 = { strictly = "2.12.3"} -jackson213 = { strictly = "2.13.4"} +immutables-value = "2.12.1" +jackson-annotations = "2.21" +jackson-bom = "2.21.3" jackson214 = { strictly = "2.14.2"} -jackson215 = { strictly = "2.15.2"} +jackson215 = { strictly = "2.15.2"} # see rich version usage explanation above jakarta-el-api = "3.0.3" jakarta-servlet-api = "6.1.0" jaxb-api = "2.3.1" jaxb-runtime = "2.3.9" -jetty = "11.0.26" -junit = "5.14.1" -junit-platform = "1.14.1" -kafka = "3.9.1" +jetty = "12.1.8" +joda = "2.14.2" +junit = "5.14.4" +junit-platform = "1.14.4" +junit-pioneer = "2.3.0" +kafka = "3.9.2" kryo-shaded = "4.0.3" +lz4Java = "1.11.0" microprofile-openapi-api = "3.1.2" mockito = "4.11.0" mockserver = "5.15.0" -nessie = "0.106.0" -netty-buffer = "4.2.7.Final" +nessie = "0.107.5" +netty-buffer = "4.2.13.Final" object-client-bundle = "3.3.2" -orc = "1.9.7" -parquet = "1.16.0" -roaringbitmap = "1.3.0" +orc = "1.9.8" +parquet = "1.17.1" +roaringbitmap = "1.6.14" scala-collection-compat = "2.14.0" slf4j = "2.0.17" -snowflake-jdbc = "3.27.1" +snowflake-jdbc = "3.28.0" spark34 = "3.4.4" -spark35 = "3.5.7" -spark40 = "4.0.1" -sqlite-jdbc = "3.51.1.0" -testcontainers = "2.0.2" +spark35 = "3.5.8" +spark40 = "4.0.2" +spark41 = "4.1.1" +sqlite-jdbc = "3.53.0.0" +testcontainers = "2.0.5" tez08 = { strictly = "0.8.4"} # see rich version usage explanation above [libraries] activation = { module = "javax.activation:activation", version.ref = "activation" } aircompressor = { module = "io.airlift:aircompressor", version.ref = "aircompressor" } +aliyun-credentials-java = { module = "com.aliyun:credentials-java", version.ref = "aliyun-credentials-java" } aliyun-sdk-oss = { module = "com.aliyun.oss:aliyun-sdk-oss", version.ref = "aliyun-sdk-oss" } +aliyun-tea = { module = "com.aliyun:tea", version.ref = "aliyun-tea" } analyticsaccelerator-s3 = { module = "software.amazon.s3.analyticsaccelerator:analyticsaccelerator-s3", version.ref = "analyticsaccelerator" } antlr-antlr4 = { module = "org.antlr:antlr4", version.ref = "antlr" } antlr-runtime = { module = "org.antlr:antlr4-runtime", version.ref = "antlr" } @@ -106,6 +112,9 @@ awssdk-bom = { module = "software.amazon.awssdk:bom", version.ref = "awssdk-bom" awssdk-s3accessgrants = { module = "software.amazon.s3.accessgrants:aws-s3-accessgrants-java-plugin", version.ref = "awssdk-s3accessgrants" } azuresdk-bom = { module = "com.azure:azure-sdk-bom", version.ref = "azuresdk-bom" } bson = { module = "org.mongodb:bson", version.ref = "bson-ver"} +bouncycastle-bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "bouncycastle" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } calcite-core = { module = "org.apache.calcite:calcite-core", version.ref = "calcite" } calcite-druid = { module = "org.apache.calcite:calcite-druid", version.ref = "calcite" } @@ -133,6 +142,7 @@ flink21-metrics-dropwizard = { module = "org.apache.flink:flink-metrics-dropwiza flink21-streaming-java = { module = "org.apache.flink:flink-streaming-java", version.ref = "flink21" } flink21-table-api-java-bridge = { module = "org.apache.flink:flink-table-api-java-bridge", version.ref = "flink21" } google-libraries-bom = { module = "com.google.cloud:libraries-bom", version.ref = "google-libraries-bom" } +gcs-analytics-core = { module = "com.google.cloud.gcs.analytics:gcs-analytics-core", version.ref = "gcs-analytics-core" } guava-guava = { module = "com.google.guava:guava", version.ref = "guava" } hadoop3-client = { module = "org.apache.hadoop:hadoop-client", version.ref = "hadoop3" } hadoop3-common = { module = "org.apache.hadoop:hadoop-common", version.ref = "hadoop3" } @@ -148,9 +158,6 @@ jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jac jackson-core = { module = "com.fasterxml.jackson.core:jackson-core", version.ref = "jackson-bom" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson-bom" } jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson-annotations" } -jackson211-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson211" } -jackson212-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson212" } -jackson213-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson213" } jackson214-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson214" } jackson215-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson215" } jaxb-api = { module = "javax.xml.bind:jaxb-api", version.ref = "jaxb-api" } @@ -159,6 +166,7 @@ kafka-clients = { module = "org.apache.kafka:kafka-clients", version.ref = "kafk kafka-connect-api = { module = "org.apache.kafka:connect-api", version.ref = "kafka" } kafka-connect-json = { module = "org.apache.kafka:connect-json", version.ref = "kafka" } kafka-connect-transforms = { module = "org.apache.kafka:connect-transforms", version.ref = "kafka" } +lz4Java = { module = "at.yawk.lz4:lz4-java", version.ref = "lz4Java" } microprofile-openapi-api = { module = "org.eclipse.microprofile.openapi:microprofile-openapi-api", version.ref = "microprofile-openapi-api" } nessie-client = { module = "org.projectnessie.nessie:nessie-client", version.ref = "nessie" } netty-buffer = { module = "io.netty:netty-buffer", version.ref = "netty-buffer" } @@ -199,10 +207,13 @@ flink21-test-utilsjunit = { module = "org.apache.flink:flink-test-utils-junit", guava-testlib = { module = "com.google.guava:guava-testlib", version.ref = "guava" } jakarta-el-api = { module = "jakarta.el:jakarta.el-api", version.ref = "jakarta-el-api" } jakarta-servlet = {module = "jakarta.servlet:jakarta.servlet-api", version.ref = "jakarta-servlet-api"} -jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" } -jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "jetty" } +jetty-compression-server = { module = "org.eclipse.jetty.compression:jetty-compression-server", version.ref = "jetty" } +jetty-compression-gzip = { module = "org.eclipse.jetty.compression:jetty-compression-gzip", version.ref = "jetty" } +jetty-servlet = { module = "org.eclipse.jetty.ee10:jetty-ee10-servlet", version.ref = "jetty" } +joda-time = { module = "joda-time:joda-time", version.ref = "joda" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } +junit-pioneer = { module = "org.junit-pioneer:junit-pioneer", version.ref = "junit-pioneer" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } junit-suite-api = { module = "org.junit.platform:junit-platform-suite-api", version.ref = "junit-platform" } junit-suite-engine = { module = "org.junit.platform:junit-platform-suite-engine", version.ref = "junit-platform" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 78cb6e16a49f..207c4600bb1b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionSha256Sum=f1771298a70f6db5a29daf62378c4e18a17fc33c9ba6b14362e0cdf40610380d +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f0ba46e93296..f506da8a822c 100755 --- a/gradlew +++ b/gradlew @@ -87,7 +87,7 @@ APP_BASE_NAME=${0##*/} APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit if [ ! -e $APP_HOME/gradle/wrapper/gradle-wrapper.jar ]; then - curl -o $APP_HOME/gradle/wrapper/gradle-wrapper.jar https://raw.githubusercontent.com/gradle/gradle/v8.14.3/gradle/wrapper/gradle-wrapper.jar + curl -o $APP_HOME/gradle/wrapper/gradle-wrapper.jar https://raw.githubusercontent.com/gradle/gradle/v8.14.4/gradle/wrapper/gradle-wrapper.jar fi # Use the maximum available, or set MAX_FD != -1 to use that value. diff --git a/hive-metastore/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java b/hive-metastore/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java index 0a177a7190d2..1895ecf831aa 100644 --- a/hive-metastore/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java +++ b/hive-metastore/src/main/java/org/apache/iceberg/hive/HMSTablePropertyHelper.java @@ -20,7 +20,13 @@ import static org.apache.iceberg.TableProperties.GC_ENABLED; +import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; import java.util.Locale; import java.util.Map; import java.util.Optional; @@ -36,10 +42,12 @@ import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.SortOrderParser; import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableMetadataParser; import org.apache.iceberg.TableProperties; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.HashWriter; import org.apache.iceberg.util.JsonUtil; import org.apache.iceberg.view.ViewMetadata; import org.slf4j.Logger; @@ -101,6 +109,7 @@ public static void updateHmsTableForIcebergTable( metadata.schema(), maxHiveTablePropertySize); setStorageHandler(parameters, hiveEngineEnabled); + setMetadataHash(metadata, parameters); // Set the basic statistics if (summary.get(SnapshotSummary.TOTAL_DATA_FILES_PROP) != null) { @@ -127,7 +136,8 @@ public static void updateHmsTableForIcebergView( ViewMetadata metadata, Set obsoleteProps, long maxHiveTablePropertySize, - String currentLocation) { + String currentLocation, + String sqlQuery) { Map parameters = Optional.ofNullable(tbl.getParameters()).orElseGet(Maps::newHashMap); @@ -145,6 +155,11 @@ public static void updateHmsTableForIcebergView( metadata.schema(), maxHiveTablePropertySize); tbl.setParameters(parameters); + + if (sqlQuery != null) { + tbl.setViewExpandedText(sqlQuery); + tbl.setViewOriginalText(sqlQuery); + } } private static void setCommonParameters( @@ -256,6 +271,41 @@ static void setSchema( } } + @VisibleForTesting + static void setMetadataHash(TableMetadata metadata, Map parameters) { + if (parameters.containsKey(TableProperties.ENCRYPTION_TABLE_KEY)) { + byte[] currentHashBytes = hashOf(metadata); + parameters.put( + BaseMetastoreTableOperations.METADATA_HASH_PROP, + Base64.getEncoder().encodeToString(currentHashBytes)); + } + } + + @VisibleForTesting + static void verifyMetadataHash(TableMetadata metadata, String metadataHashFromHMS) { + byte[] currentHashBytes = hashOf(metadata); + byte[] expectedHashBytes = Base64.getDecoder().decode(metadataHashFromHMS); + + if (!Arrays.equals(expectedHashBytes, currentHashBytes)) { + throw new RuntimeException( + String.format( + "The current metadata file %s might have been modified. Hash of metadata loaded from storage differs " + + "from HMS-stored metadata hash.", + metadata.metadataFileLocation())); + } + } + + private static byte[] hashOf(TableMetadata tableMetadata) { + try (HashWriter hashWriter = new HashWriter("SHA-256", StandardCharsets.UTF_8)) { + JsonGenerator generator = JsonUtil.factory().createGenerator(hashWriter); + TableMetadataParser.toJson(tableMetadata, generator); + generator.flush(); + return hashWriter.getHash(); + } catch (NoSuchAlgorithmException | IOException e) { + throw new RuntimeException("Unable to produce hash of table metadata", e); + } + } + private static void setField( Map parameters, String key, String value, long maxHiveTablePropertySize) { if (value.length() <= maxHiveTablePropertySize) { diff --git a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveCatalog.java b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveCatalog.java index 5e7a249af84e..4d881c515d48 100644 --- a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveCatalog.java +++ b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveCatalog.java @@ -28,7 +28,6 @@ import org.apache.hadoop.hive.conf.HiveConf; import org.apache.hadoop.hive.metastore.IMetaStoreClient; import org.apache.hadoop.hive.metastore.TableType; -import org.apache.hadoop.hive.metastore.api.AlreadyExistsException; import org.apache.hadoop.hive.metastore.api.Database; import org.apache.hadoop.hive.metastore.api.InvalidOperationException; import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; @@ -48,6 +47,7 @@ import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.encryption.EncryptionUtil; import org.apache.iceberg.encryption.KeyManagementClient; +import org.apache.iceberg.exceptions.AlreadyExistsException; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchIcebergViewException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; @@ -65,6 +65,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.LocationUtil; +import org.apache.iceberg.util.PropertyUtil; import org.apache.iceberg.view.BaseMetastoreViewCatalog; import org.apache.iceberg.view.View; import org.apache.iceberg.view.ViewBuilder; @@ -94,6 +95,7 @@ public class HiveCatalog extends BaseMetastoreViewCatalog private KeyManagementClient keyManagementClient; private ClientPool clients; private boolean listAllTables = false; + private boolean uniqueTableLocation; private Map catalogProperties; public HiveCatalog() {} @@ -126,10 +128,17 @@ public void initialize(String inputName, Map properties) { ? new HadoopFileIO(conf) : CatalogUtil.loadFileIO(fileIOImpl, properties, conf); - if (catalogProperties.containsKey(CatalogProperties.ENCRYPTION_KMS_IMPL)) { + if (catalogProperties.containsKey(CatalogProperties.ENCRYPTION_KMS_TYPE) + || catalogProperties.containsKey(CatalogProperties.ENCRYPTION_KMS_IMPL)) { this.keyManagementClient = EncryptionUtil.createKmsClient(properties); } + this.uniqueTableLocation = + PropertyUtil.propertyAsBoolean( + properties, + CatalogProperties.UNIQUE_TABLE_LOCATION, + CatalogProperties.UNIQUE_TABLE_LOCATION_DEFAULT); + this.clients = new CachedClientPool(conf, properties); } @@ -352,13 +361,11 @@ private void renameTableOrView( } if (tableExists(to)) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - "Cannot rename %s to %s. Table already exists", from, to); + throw new AlreadyExistsException("Cannot rename %s to %s. Table already exists", from, to); } if (viewExists(to)) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - "Cannot rename %s to %s. View already exists", from, to); + throw new AlreadyExistsException("Cannot rename %s to %s. View already exists", from, to); } String toDatabase = to.namespace().level(0); @@ -391,8 +398,7 @@ private void renameTableOrView( } catch (InvalidOperationException e) { if (e.getMessage() != null && e.getMessage().contains(String.format("new table %s already exists", to))) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - "Table already exists: %s", to); + throw new AlreadyExistsException("Table already exists: %s", to); } else { throw new RuntimeException("Failed to rename " + from + " to " + to, e); } @@ -499,9 +505,8 @@ public void createNamespace(Namespace namespace, Map meta) { LOG.info("Created namespace: {}", namespace); - } catch (AlreadyExistsException e) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - e, "Namespace already exists: %s", namespace); + } catch (org.apache.hadoop.hive.metastore.api.AlreadyExistsException e) { + throw new AlreadyExistsException(e, "Namespace already exists: %s", namespace); } catch (TException e) { throw new RuntimeException( @@ -711,13 +716,14 @@ protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { // - Create the metadata in HMS, and this way committing the changes // Create a new location based on the namespace / database if it is set on database level + String tableLocation = LocationUtil.tableLocation(tableIdentifier, uniqueTableLocation); try { Database databaseData = clients.run(client -> client.getDatabase(tableIdentifier.namespace().levels()[0])); if (databaseData.getLocationUri() != null) { // If the database location is set use it as a base. String databaseLocation = LocationUtil.stripTrailingSlash(databaseData.getLocationUri()); - return String.format("%s/%s", databaseLocation, tableIdentifier.name()); + return String.format("%s/%s", databaseLocation, tableLocation); } } catch (NoSuchObjectException e) { @@ -734,7 +740,7 @@ protected String defaultWarehouseLocation(TableIdentifier tableIdentifier) { // Otherwise, stick to the {WAREHOUSE_DIR}/{DB_NAME}.db/{TABLE_NAME} path String databaseLocation = databaseLocation(tableIdentifier.namespace().levels()[0]); - return String.format("%s/%s", databaseLocation, tableIdentifier.name()); + return String.format("%s/%s", databaseLocation, tableLocation); } private String databaseLocation(String databaseName) { @@ -862,8 +868,7 @@ private ViewAwareTableBuilder(TableIdentifier identifier, Schema schema) { @Override public Transaction createOrReplaceTransaction() { if (viewExists(identifier)) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - "View with same name already exists: %s", identifier); + throw new AlreadyExistsException("View with same name already exists: %s", identifier); } return super.createOrReplaceTransaction(); } @@ -871,8 +876,7 @@ public Transaction createOrReplaceTransaction() { @Override public org.apache.iceberg.Table create() { if (viewExists(identifier)) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - "View with same name already exists: %s", identifier); + throw new AlreadyExistsException("View with same name already exists: %s", identifier); } return super.create(); } @@ -896,8 +900,7 @@ private TableAwareViewBuilder(TableIdentifier identifier) { @Override public View createOrReplace() { if (tableExists(identifier)) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - "Table with same name already exists: %s", identifier); + throw new AlreadyExistsException("Table with same name already exists: %s", identifier); } return super.createOrReplace(); } @@ -905,10 +908,40 @@ public View createOrReplace() { @Override public View create() { if (tableExists(identifier)) { - throw new org.apache.iceberg.exceptions.AlreadyExistsException( - "Table with same name already exists: %s", identifier); + throw new AlreadyExistsException("Table with same name already exists: %s", identifier); } return super.create(); } } + + /** + * Register a table with the catalog if it does not exist. This is overridden in order to add view + * existence detection before registering a table. + * + * @param identifier a table identifier + * @param metadataFileLocation the location of a metadata file + * @return a Table instance + * @throws AlreadyExistsException if a table or view with the same identifier already exists in + * the catalog. + */ + @Override + public org.apache.iceberg.Table registerTable( + TableIdentifier identifier, String metadataFileLocation) { + Preconditions.checkArgument( + identifier != null && isValidIdentifier(identifier), "Invalid identifier: %s", identifier); + Preconditions.checkArgument( + metadataFileLocation != null && !metadataFileLocation.isEmpty(), + "Cannot register an empty metadata file location as a table"); + + // throw an exception in case the table identifier already exists as a table/view + if (tableExists(identifier)) { + throw new AlreadyExistsException("Table already exists: %s", identifier); + } + + if (viewExists(identifier)) { + throw new AlreadyExistsException("View with same name already exists: %s", identifier); + } + + return super.registerTable(identifier, metadataFileLocation); + } } diff --git a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveSchemaUtil.java b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveSchemaUtil.java index 20f9eb7f616e..d1ff5db66ad4 100644 --- a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveSchemaUtil.java +++ b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveSchemaUtil.java @@ -167,6 +167,8 @@ private static String convertToTypeString(Type type) { case FIXED: case BINARY: return "binary"; + case VARIANT: + return "unknown"; case DECIMAL: final Types.DecimalType decimalType = (Types.DecimalType) type; return String.format("decimal(%s,%s)", decimalType.precision(), decimalType.scale()); diff --git a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java index 4d382f8d388e..1038d907e718 100644 --- a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java +++ b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveTableOperations.java @@ -25,6 +25,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.common.StatsSetupConst; import org.apache.hadoop.hive.metastore.IMetaStoreClient; @@ -34,6 +35,7 @@ import org.apache.hadoop.hive.metastore.api.Table; import org.apache.iceberg.BaseMetastoreOperations; import org.apache.iceberg.BaseMetastoreTableOperations; +import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.ClientPool; import org.apache.iceberg.LocationProviders; import org.apache.iceberg.TableMetadata; @@ -55,8 +57,9 @@ import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.LocationProvider; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.PropertyUtil; import org.apache.thrift.TException; import org.slf4j.Logger; @@ -90,12 +93,7 @@ public class HiveTableOperations extends BaseMetastoreTableOperations private String tableKeyId; private int encryptionDekLength; - // keys loaded from the latest metadata - private Optional> encryptedKeysFromMetadata = Optional.empty(); - - // keys added to EM (e.g. as a result of a FileAppend) but not committed into the latest metadata - // yet - private Optional> encryptedKeysPending = Optional.empty(); + private List encryptedKeys = List.of(); protected HiveTableOperations( Configuration conf, @@ -146,22 +144,21 @@ public EncryptionManager encryption() { } if (tableKeyId != null) { - if (keyManagementClient == null) { - throw new RuntimeException( - "Cant create encryption manager, because key management client is not set"); - } - - Map encryptionProperties = Maps.newHashMap(); - encryptionProperties.put(TableProperties.ENCRYPTION_TABLE_KEY, tableKeyId); - encryptionProperties.put( - TableProperties.ENCRYPTION_DEK_LENGTH, String.valueOf(encryptionDekLength)); - - List keys = Lists.newLinkedList(); - encryptedKeysFromMetadata.ifPresent(keys::addAll); - encryptedKeysPending.ifPresent(keys::addAll); + Preconditions.checkArgument( + keyManagementClient != null, + "Cannot create encryption manager without a key management client. Consider setting the '%s' catalog property", + CatalogProperties.ENCRYPTION_KMS_IMPL); + + Map encryptionProperties = + ImmutableMap.of( + TableProperties.ENCRYPTION_TABLE_KEY, + tableKeyId, + TableProperties.ENCRYPTION_DEK_LENGTH, + String.valueOf(encryptionDekLength)); encryptionManager = - EncryptionUtil.createEncryptionManager(keys, encryptionProperties, keyManagementClient); + EncryptionUtil.createEncryptionManager( + encryptedKeys, encryptionProperties, keyManagementClient); } else { return PlaintextEncryptionManager.instance(); } @@ -174,6 +171,7 @@ protected void doRefresh() { String metadataLocation = null; String tableKeyIdFromHMS = null; String dekLengthFromHMS = null; + String metadataHashFromHMS = null; try { Table table = metaClients.run(client -> client.getTable(database, tableName)); @@ -189,6 +187,7 @@ the table key parameter (along with existing snapshots) in the file, making the produce unencrypted files. Table key ID is taken directly from HMS catalog */ tableKeyIdFromHMS = table.getParameters().get(TableProperties.ENCRYPTION_TABLE_KEY); dekLengthFromHMS = table.getParameters().get(TableProperties.ENCRYPTION_DEK_LENGTH); + metadataHashFromHMS = table.getParameters().get(METADATA_HASH_PROP); } catch (NoSuchObjectException e) { if (currentMetadataLocation() != null) { throw new NoSuchTableException("No such table: %s.%s", database, tableName); @@ -207,7 +206,7 @@ the table key parameter (along with existing snapshots) in the file, making the refreshFromMetadataLocation(metadataLocation, metadataRefreshMaxRetries); if (tableKeyIdFromHMS != null) { - checkEncryptionProperties(tableKeyIdFromHMS, dekLengthFromHMS); + checkIntegrityForEncryption(tableKeyIdFromHMS, dekLengthFromHMS, metadataHashFromHMS); tableKeyId = tableKeyIdFromHMS; encryptionDekLength = @@ -215,24 +214,20 @@ the table key parameter (along with existing snapshots) in the file, making the ? Integer.parseInt(dekLengthFromHMS) : TableProperties.ENCRYPTION_DEK_LENGTH_DEFAULT; - encryptedKeysFromMetadata = Optional.ofNullable(current().encryptionKeys()); + encryptedKeys = + Optional.ofNullable(current().encryptionKeys()) + .map(Lists::newLinkedList) + .orElseGet(Lists::newLinkedList); if (encryptionManager != null) { - encryptedKeysPending = Optional.of(Lists.newLinkedList()); - Set keyIdsFromMetadata = - encryptedKeysFromMetadata.orElseGet(Lists::newLinkedList).stream() - .map(EncryptedKey::keyId) - .collect(Collectors.toSet()); + encryptedKeys.stream().map(EncryptedKey::keyId).collect(Collectors.toSet()); for (EncryptedKey keyFromEM : EncryptionUtil.encryptionKeys(encryptionManager).values()) { if (!keyIdsFromMetadata.contains(keyFromEM.keyId())) { - encryptedKeysPending.get().add(keyFromEM); + encryptedKeys.add(keyFromEM); } } - - } else { - encryptedKeysPending = Optional.empty(); } // Force re-creation of encryption manager with updated keys @@ -245,6 +240,7 @@ the table key parameter (along with existing snapshots) in the file, making the @Override protected void doCommit(TableMetadata base, TableMetadata metadata) { boolean newTable = base == null; + final TableMetadata tableMetadata; encryptionPropsFromMetadata(metadata.properties()); String newMetadataLocation; @@ -257,19 +253,21 @@ protected void doCommit(TableMetadata base, TableMetadata metadata) { builder.addEncryptionKey(entry.getValue()); } - newMetadataLocation = writeNewMetadataIfRequired(newTable, builder.build()); + tableMetadata = builder.build(); } else { - newMetadataLocation = writeNewMetadataIfRequired(newTable, metadata); + tableMetadata = metadata; } - boolean hiveEngineEnabled = hiveEngineEnabled(metadata, conf); + newMetadataLocation = writeNewMetadataIfRequired(newTable, tableMetadata); + + boolean hiveEngineEnabled = hiveEngineEnabled(tableMetadata, conf); boolean keepHiveStats = conf.getBoolean(ConfigProperties.KEEP_HIVE_STATS, false); BaseMetastoreOperations.CommitStatus commitStatus = BaseMetastoreOperations.CommitStatus.FAILURE; boolean updateHiveTable = false; - HiveLock lock = lockObject(base != null ? base : metadata); + HiveLock lock = lockObject(base != null ? base : tableMetadata); try { lock.lock(); @@ -293,14 +291,14 @@ protected void doCommit(TableMetadata base, TableMetadata metadata) { } else { tbl = newHmsTable( - metadata.property(HiveCatalog.HMS_TABLE_OWNER, HiveHadoopUtil.currentUser())); + tableMetadata.property(HiveCatalog.HMS_TABLE_OWNER, HiveHadoopUtil.currentUser())); LOG.debug("Committing new table: {}", fullName); } tbl.setSd( HiveOperationsBase.storageDescriptor( - metadata.schema(), - metadata.location(), + tableMetadata.schema(), + tableMetadata.location(), hiveEngineEnabled)); // set to pickup any schema changes String metadataLocation = tbl.getParameters().get(METADATA_LOCATION_PROP); @@ -316,25 +314,24 @@ protected void doCommit(TableMetadata base, TableMetadata metadata) { if (base != null) { removedProps = base.properties().keySet().stream() - .filter(key -> !metadata.properties().containsKey(key)) + .filter(key -> !tableMetadata.properties().containsKey(key)) .collect(Collectors.toSet()); - } - if (removedProps.contains(TableProperties.ENCRYPTION_TABLE_KEY)) { - throw new IllegalArgumentException("Cannot remove key in encrypted table"); - } + Preconditions.checkArgument( + !removedProps.contains(TableProperties.ENCRYPTION_TABLE_KEY), + "Cannot remove key ID from an encrypted table"); - if (base != null - && !Objects.equals( - base.properties().get(TableProperties.ENCRYPTION_TABLE_KEY), - metadata.properties().get(TableProperties.ENCRYPTION_TABLE_KEY))) { - throw new IllegalArgumentException("Cannot modify key in encrypted table"); + Preconditions.checkArgument( + Objects.equals( + base.properties().get(TableProperties.ENCRYPTION_TABLE_KEY), + metadata.properties().get(TableProperties.ENCRYPTION_TABLE_KEY)), + "Cannot modify key ID of an encrypted table"); } HMSTablePropertyHelper.updateHmsTableForIcebergTable( newMetadataLocation, tbl, - metadata, + tableMetadata, removedProps, hiveEngineEnabled, maxHiveTablePropertySize, @@ -391,7 +388,7 @@ protected void doCommit(TableMetadata base, TableMetadata metadata) { // issue for example, and triggers this exception. So we need double-check to make sure // this is really a concurrent modification. Hitting this exception means no pending // requests, if any, can succeed later, so it's safe to check status in strict mode - commitStatus = checkCommitStatusStrict(newMetadataLocation, metadata); + commitStatus = checkCommitStatusStrict(newMetadataLocation, tableMetadata); if (commitStatus == BaseMetastoreOperations.CommitStatus.FAILURE) { throw new CommitFailedException( e, "The table %s.%s has been modified concurrently", database, tableName); @@ -402,7 +399,7 @@ protected void doCommit(TableMetadata base, TableMetadata metadata) { database, tableName, e); - commitStatus = checkCommitStatus(newMetadataLocation, metadata); + commitStatus = checkCommitStatus(newMetadataLocation, tableMetadata); } switch (commitStatus) { @@ -574,8 +571,20 @@ private void encryptionPropsFromMetadata(Map tableProperties) { } } - private void checkEncryptionProperties(String encryptionKeyIdFromHMS, String dekLengthFromHMS) { - Map propertiesFromMetadata = current().properties(); + private void checkIntegrityForEncryption( + String encryptionKeyIdFromHMS, String dekLengthFromHMS, String metadataHashFromHMS) { + TableMetadata metadata = current(); + if (StringUtils.isNotEmpty(metadataHashFromHMS)) { + HMSTablePropertyHelper.verifyMetadataHash(metadata, metadataHashFromHMS); + return; + } + + LOG.warn( + "Full metadata integrity check skipped because no metadata hash was recorded in HMS for table {}." + + " Falling back to encryption property based check.", + tableName); + + Map propertiesFromMetadata = metadata.properties(); String encryptionKeyIdFromMetadata = propertiesFromMetadata.get(TableProperties.ENCRYPTION_TABLE_KEY); diff --git a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveViewOperations.java b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveViewOperations.java index b58369b6bbaa..395a355455dd 100644 --- a/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveViewOperations.java +++ b/hive-metastore/src/main/java/org/apache/iceberg/hive/HiveViewOperations.java @@ -182,7 +182,8 @@ public void doCommit(ViewMetadata base, ViewMetadata metadata) { metadata, removedProps, maxHiveTablePropertySize, - currentMetadataLocation()); + currentMetadataLocation(), + sqlFor(metadata)); lock.ensureActive(); try { diff --git a/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveCatalog.java b/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveCatalog.java index 2bac6082854c..f212d307fe9d 100644 --- a/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveCatalog.java +++ b/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveCatalog.java @@ -26,6 +26,7 @@ import static org.apache.iceberg.TableProperties.CURRENT_SNAPSHOT_TIMESTAMP; import static org.apache.iceberg.TableProperties.DEFAULT_PARTITION_SPEC; import static org.apache.iceberg.TableProperties.DEFAULT_SORT_ORDER; +import static org.apache.iceberg.TableProperties.ENCRYPTION_TABLE_KEY; import static org.apache.iceberg.TableProperties.SNAPSHOT_COUNT; import static org.apache.iceberg.expressions.Expressions.bucket; import static org.apache.iceberg.types.Types.NestedField.required; @@ -47,6 +48,7 @@ import org.apache.hadoop.hive.metastore.api.Database; import org.apache.hadoop.hive.metastore.api.PrincipalType; import org.apache.hadoop.security.UserGroupInformation; +import org.apache.iceberg.BaseMetastoreTableOperations; import org.apache.iceberg.CachingCatalog; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.CatalogUtil; @@ -1239,4 +1241,29 @@ public void testTableLocationWithTrailingSlashInDatabaseLocation() throws TExcep HIVE_METASTORE_EXTENSION.metastoreClient().dropDatabase(dbName); } } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testMetadataHashing(boolean isTableEncrypted) { + Map hiveTblProperties = Maps.newHashMap(); + if (isTableEncrypted) { + hiveTblProperties.put(ENCRYPTION_TABLE_KEY, "key_id"); + } + + Schema schema = new Schema(Types.NestedField.required(1, "col1", Types.StringType.get())); + TableMetadata tableMetadata = + TableMetadata.newTableMetadata( + schema, PartitionSpec.unpartitioned(), null, ImmutableMap.of()); + + HMSTablePropertyHelper.setMetadataHash(tableMetadata, hiveTblProperties); + + String base64EncodedHash = + hiveTblProperties.get(BaseMetastoreTableOperations.METADATA_HASH_PROP); + if (isTableEncrypted) { + assertThat(base64EncodedHash).isBase64(); + HMSTablePropertyHelper.verifyMetadataHash(tableMetadata, base64EncodedHash); + } else { + assertThat(base64EncodedHash).isNull(); + } + } } diff --git a/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveSchemaUtil.java b/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveSchemaUtil.java index 1592a3461b40..59c19a5d095d 100644 --- a/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveSchemaUtil.java +++ b/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveSchemaUtil.java @@ -205,6 +205,13 @@ public void testConversionWithoutLastComment() { assertThat(schema.asStruct()).isEqualTo(expected.asStruct()); } + @Test + public void testVariantTypeConvertToHiveSchema() { + Schema schema = new Schema(optional(0, "variant_field", Types.VariantType.get())); + List hiveSchema = HiveSchemaUtil.convert(schema); + assertThat(hiveSchema).containsExactly(new FieldSchema("variant_field", "unknown", null)); + } + protected List getSupportedFieldSchemas() { List fields = Lists.newArrayListWithCapacity(10); fields.add(new FieldSchema("c_float", serdeConstants.FLOAT_TYPE_NAME, "float comment")); diff --git a/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveViewCommits.java b/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveViewCommits.java index ee0a88f46b95..738eac5b5adb 100644 --- a/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveViewCommits.java +++ b/hive-metastore/src/test/java/org/apache/iceberg/hive/TestHiveViewCommits.java @@ -37,6 +37,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hive.metastore.api.Table; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.CatalogUtil; import org.apache.iceberg.Schema; @@ -48,6 +49,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.types.Types; import org.apache.iceberg.view.BaseView; +import org.apache.iceberg.view.ImmutableSQLViewRepresentation; import org.apache.iceberg.view.View; import org.apache.iceberg.view.ViewMetadata; import org.apache.thrift.TException; @@ -70,6 +72,7 @@ public class TestHiveViewCommits { required(3, "id", Types.IntegerType.get(), "unique ID"), required(4, "data", Types.StringType.get())); private static final TableIdentifier VIEW_IDENTIFIER = TableIdentifier.of(NS, VIEW_NAME); + private static final String VIEW_QUERY = "select * from ns.tbl"; @RegisterExtension protected static final HiveMetastoreExtension HIVE_METASTORE_EXTENSION = @@ -100,7 +103,7 @@ public void createTestView() { .buildView(VIEW_IDENTIFIER) .withSchema(SCHEMA) .withDefaultNamespace(NS) - .withQuery("hive", "select * from ns.tbl") + .withQuery("hive", VIEW_QUERY) .create(); viewLocation = new Path(view.location()); } @@ -111,6 +114,35 @@ public void dropTestView() throws IOException { catalog.dropView(VIEW_IDENTIFIER); } + @Test + public void testViewQueryIsUpdatedOnCommit() throws Exception { + HiveViewOperations ops = (HiveViewOperations) ((BaseView) view).operations(); + assertThat(view.currentVersion().representations()) + .containsExactly( + ImmutableSQLViewRepresentation.builder().sql(VIEW_QUERY).dialect("hive").build()); + + Table hmsTable = ops.loadHmsTable(); + assertThat(hmsTable.getViewOriginalText()).isEqualTo(VIEW_QUERY); + assertThat(hmsTable.getViewExpandedText()).isEqualTo(VIEW_QUERY); + + String newQuery = "select * from ns.tbl2 limit 10"; + view = + catalog + .buildView(VIEW_IDENTIFIER) + .withSchema(SCHEMA) + .withDefaultNamespace(NS) + .withQuery("hive", newQuery) + .replace(); + + assertThat(view.currentVersion().representations()) + .containsExactly( + ImmutableSQLViewRepresentation.builder().sql(newQuery).dialect("hive").build()); + + Table updatedHmsTable = ops.loadHmsTable(); + assertThat(updatedHmsTable.getViewOriginalText()).isEqualTo(newQuery); + assertThat(updatedHmsTable.getViewExpandedText()).isEqualTo(newQuery); + } + @Test public void testSuppressUnlockExceptions() { HiveViewOperations ops = (HiveViewOperations) ((BaseView) view).operations(); diff --git a/jmh.gradle b/jmh.gradle index 57efb3821d8f..4df063f131a6 100644 --- a/jmh.gradle +++ b/jmh.gradle @@ -17,8 +17,8 @@ * under the License. */ -if (jdkVersion != '11' && jdkVersion != '17' && jdkVersion != '21') { - throw new GradleException("The JMH benchmarks must be run with JDK 11 or JDK 17 or JDK 21") +if (jdkVersion != '17' && jdkVersion != '21') { + throw new GradleException("The JMH benchmarks must be run with JDK 17 or JDK 21") } def flinkVersions = (System.getProperty("flinkVersions") != null ? System.getProperty("flinkVersions") : System.getProperty("defaultFlinkVersions")).split(",") @@ -53,6 +53,11 @@ if (sparkVersions.contains("4.0")) { jmhProjects.add(project(":iceberg-spark:iceberg-spark-extensions-4.0_2.13")) } +if (sparkVersions.contains("4.1")) { + jmhProjects.add(project(":iceberg-spark:iceberg-spark-4.1_2.13")) + jmhProjects.add(project(":iceberg-spark:iceberg-spark-extensions-4.1_2.13")) +} + configure(jmhProjects) { apply plugin: 'me.champeau.jmh' apply plugin: 'io.morethan.jmhreport' diff --git a/kafka-connect/build.gradle b/kafka-connect/build.gradle index 44d095e1c6fa..43eb245d93a3 100644 --- a/kafka-connect/build.gradle +++ b/kafka-connect/build.gradle @@ -17,6 +17,11 @@ * under the License. */ +// disable publishing from empty grouping project +afterEvaluate { + tasks.matching { it.group == 'publishing' }.each { it.enabled = false } +} + project(':iceberg-kafka-connect:iceberg-kafka-connect-events') { dependencies { api project(':iceberg-api') @@ -70,12 +75,13 @@ project(':iceberg-kafka-connect:iceberg-kafka-connect-runtime') { exclude group: 'org.jspecify', module: 'jspecify' // force upgrades for dependencies with known vulnerabilities... resolutionStrategy { - force 'org.codehaus.jettison:jettison:1.5.4' + force 'org.codehaus.jettison:jettison:1.5.5' force 'org.xerial.snappy:snappy-java:1.1.10.8' force 'org.apache.commons:commons-compress:1.28.0' force 'org.apache.hadoop.thirdparty:hadoop-shaded-guava:1.5.0' force 'com.fasterxml.woodstox:woodstox-core:6.7.0' force 'commons-beanutils:commons-beanutils:1.11.0' + force 'io.grpc:grpc-netty-shaded:1.81.0' } } } @@ -248,6 +254,8 @@ project(':iceberg-kafka-connect:iceberg-kafka-connect-runtime') { check.dependsOn integrationTest assemble.dependsOn distZip, hiveDistZip + + apply from: "${rootDir}/runtime-deps.gradle" } project(':iceberg-kafka-connect:iceberg-kafka-connect-transforms') { diff --git a/kafka-connect/kafka-connect-events/src/main/java/org/apache/iceberg/connect/events/TableReference.java b/kafka-connect/kafka-connect-events/src/main/java/org/apache/iceberg/connect/events/TableReference.java index f30eac892400..cd1341822c58 100644 --- a/kafka-connect/kafka-connect-events/src/main/java/org/apache/iceberg/connect/events/TableReference.java +++ b/kafka-connect/kafka-connect-events/src/main/java/org/apache/iceberg/connect/events/TableReference.java @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.stream.Collectors; import org.apache.avro.Schema; import org.apache.avro.generic.IndexedRecord; @@ -32,6 +33,7 @@ import org.apache.iceberg.types.Types.NestedField; import org.apache.iceberg.types.Types.StringType; import org.apache.iceberg.types.Types.StructType; +import org.apache.iceberg.types.Types.UUIDType; /** Element representing a table identifier, with namespace and name. */ public class TableReference implements IndexedRecord { @@ -39,23 +41,39 @@ public class TableReference implements IndexedRecord { private String catalog; private List namespace; private String name; + private UUID uuid; private final Schema avroSchema; static final int CATALOG = 10_600; static final int NAMESPACE = 10_601; static final int NAME = 10_603; + static final int TABLE_UUID = 10_604; public static final StructType ICEBERG_SCHEMA = StructType.of( NestedField.required(CATALOG, "catalog", StringType.get()), NestedField.required( NAMESPACE, "namespace", ListType.ofRequired(NAMESPACE + 1, StringType.get())), - NestedField.required(NAME, "name", StringType.get())); + NestedField.required(NAME, "name", StringType.get()), + NestedField.optional(TABLE_UUID, "table_uuid", UUIDType.get())); private static final Schema AVRO_SCHEMA = AvroUtil.convert(ICEBERG_SCHEMA, TableReference.class); + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link TableReference#of(String, + * TableIdentifier, UUID)} + */ + @Deprecated public static TableReference of(String catalog, TableIdentifier tableIdentifier) { return new TableReference( - catalog, Arrays.asList(tableIdentifier.namespace().levels()), tableIdentifier.name()); + catalog, Arrays.asList(tableIdentifier.namespace().levels()), tableIdentifier.name(), null); + } + + public static TableReference of(String catalog, TableIdentifier tableIdentifier, UUID tableUuid) { + return new TableReference( + catalog, + Arrays.asList(tableIdentifier.namespace().levels()), + tableIdentifier.name(), + tableUuid); } // Used by Avro reflection to instantiate this class when reading events @@ -63,6 +81,11 @@ public TableReference(Schema avroSchema) { this.avroSchema = avroSchema; } + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link TableReference#of(String, + * TableIdentifier, UUID)}. + */ + @Deprecated public TableReference(String catalog, List namespace, String name) { Preconditions.checkNotNull(catalog, "Catalog cannot be null"); Preconditions.checkNotNull(namespace, "Namespace cannot be null"); @@ -73,10 +96,25 @@ public TableReference(String catalog, List namespace, String name) { this.avroSchema = AVRO_SCHEMA; } + private TableReference(String catalog, List namespace, String name, UUID uuid) { + Preconditions.checkNotNull(catalog, "Catalog cannot be null"); + Preconditions.checkNotNull(namespace, "Namespace cannot be null"); + Preconditions.checkNotNull(name, "Name cannot be null"); + this.catalog = catalog; + this.namespace = namespace; + this.name = name; + this.uuid = uuid; + this.avroSchema = AVRO_SCHEMA; + } + public String catalog() { return catalog; } + public UUID uuid() { + return uuid; + } + public TableIdentifier identifier() { Namespace icebergNamespace = Namespace.of(namespace.toArray(new String[0])); return TableIdentifier.of(icebergNamespace, name); @@ -103,6 +141,9 @@ public void put(int i, Object v) { case NAME: this.name = v == null ? null : v.toString(); return; + case TABLE_UUID: + this.uuid = (UUID) v; + return; default: // ignore the object, it must be from a newer version of the format } @@ -117,6 +158,8 @@ public Object get(int i) { return namespace; case NAME: return name; + case TABLE_UUID: + return uuid; default: throw new UnsupportedOperationException("Unknown field ordinal: " + i); } @@ -133,11 +176,12 @@ public boolean equals(Object o) { TableReference that = (TableReference) o; return Objects.equals(catalog, that.catalog) && Objects.equals(namespace, that.namespace) - && Objects.equals(name, that.name); + && Objects.equals(name, that.name) + && Objects.equals(uuid, that.uuid); } @Override public int hashCode() { - return Objects.hash(catalog, namespace, name); + return Objects.hash(catalog, namespace, name, uuid); } } diff --git a/kafka-connect/kafka-connect-events/src/test/java/org/apache/iceberg/connect/events/TestEventSerialization.java b/kafka-connect/kafka-connect-events/src/test/java/org/apache/iceberg/connect/events/TestEventSerialization.java index 10054c66400d..eea2e6373019 100644 --- a/kafka-connect/kafka-connect-events/src/test/java/org/apache/iceberg/connect/events/TestEventSerialization.java +++ b/kafka-connect/kafka-connect-events/src/test/java/org/apache/iceberg/connect/events/TestEventSerialization.java @@ -21,8 +21,8 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Arrays; -import java.util.Collections; import java.util.UUID; +import org.apache.iceberg.catalog.TableIdentifier; import org.junit.jupiter.api.Test; public class TestEventSerialization { @@ -50,7 +50,7 @@ public void testDataWrittenSerialization() { new DataWritten( EventTestUtil.SPEC.partitionType(), commitId, - new TableReference("catalog", Collections.singletonList("db"), "tbl"), + TableReference.of("catalog", TableIdentifier.of("db", "tbl"), UUID.randomUUID()), Arrays.asList(EventTestUtil.createDataFile(), EventTestUtil.createDataFile()), Arrays.asList(EventTestUtil.createDeleteFile(), EventTestUtil.createDeleteFile()))); @@ -97,7 +97,7 @@ public void testCommitToTableSerialization() { "cg-connector", new CommitToTable( commitId, - new TableReference("catalog", Collections.singletonList("db"), "tbl"), + TableReference.of("catalog", TableIdentifier.of("db", "tbl"), UUID.randomUUID()), 1L, EventTestUtil.now())); diff --git a/kafka-connect/kafka-connect-runtime/hive/LICENSE b/kafka-connect/kafka-connect-runtime/hive/LICENSE index 1f18593b0fad..0d777ba54bba 100644 --- a/kafka-connect/kafka-connect-runtime/hive/LICENSE +++ b/kafka-connect/kafka-connect-runtime/hive/LICENSE @@ -203,1734 +203,1231 @@ -------------------------------------------------------------------------------- -This binary artifact contains code from the following projects: +This product bundles AOP Alliance. --------------------------------------------------------------------------------- - -Group: aopalliance Name: aopalliance Version: 1.0 -Project URL (from POM): http://aopalliance.sourceforge.net -License (from POM): Public Domain --------------------------------------------------------------------------------- - -Group: ch.qos.reload4j Name: reload4j Version: 1.2.22 -Project URL (from manifest): https://reload4j.qos.ch/ -Project URL (from POM): https://reload4j.qos.ch -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-core Version: 1.55.5 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-core-http-netty Version: 1.15.13 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-identity Version: 1.16.2 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-json Version: 1.5.0 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-blob Version: 12.31.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-common Version: 12.30.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-file-datalake Version: 12.24.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-storage-internal-avro Version: 12.16.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-xml Version: 1.2.0 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson -Project URL (from POM): https://github.com/FasterXML/jackson -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://aopalliance.sourceforge.net +License: Public Domain -------------------------------------------------------------------------------- -Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson-core -Project URL (from POM): https://github.com/FasterXML/jackson-core -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Reload4j. -Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson -Project URL (from POM): https://github.com/FasterXML/jackson -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://reload4j.qos.ch/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310 -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.jaxrs Name: jackson-jaxrs-base Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-base -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.jaxrs Name: jackson-jaxrs-json-provider Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-json-provider -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Azure SDK for Java. -Group: com.fasterxml.jackson.module Name: jackson-module-jaxb-annotations Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson-modules-base -Project URL (from POM): https://github.com/FasterXML/jackson-modules-base -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/Azure/azure-sdk-for-java +License: MIT +| The MIT License (MIT) +| +| Copyright (c) 2015 Microsoft +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -Group: com.fasterxml.woodstox Name: woodstox-core Version: 6.7.0 -Project URL (from manifest): https://github.com/FasterXML/woodstox -Project URL (from POM): https://github.com/FasterXML/woodstox -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.github.ben-manes.caffeine Name: caffeine Version: 2.9.3 -Project URL (from POM): https://github.com/ben-manes/caffeine -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.github.luben Name: zstd-jni Version: 1.5.7-3 -License URL (from manifest): https://opensource.org/licenses/BSD-2-Clause -Project URL (from POM): https://github.com/luben/zstd-jni -License (from POM): BSD 2-Clause License - https://opensource.org/licenses/BSD-2-Clause - --------------------------------------------------------------------------------- - -Group: com.github.pjfanning Name: jersey-json Version: 1.22.0 -Project URL (from POM): https://github.com/pjfanning/jersey-1.x -License (from POM): CDDL 1.1 - http://glassfish.java.net/public/CDDL+GPL_1_1.html -License (from POM): GPL2 w/ CPE - http://glassfish.java.net/public/CDDL+GPL_1_1.html - --------------------------------------------------------------------------------- - -Group: com.github.stephenc.jcip Name: jcip-annotations Version: 1.0-1 -Project URL (from POM): http://stephenc.github.com/jcip-annotations -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Jackson JSON Processor. -Group: com.google.android Name: annotations Version: 4.1.1.4 -Project URL (from POM): http://source.android.com/ -License (from POM): Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api Name: api-common Version: 2.52.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/api-common-java/blob/main/LICENSE +This product bundles Woodstox. --------------------------------------------------------------------------------- - -Group: com.google.api Name: gax Version: 2.69.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/gax-java/blob/master/LICENSE +Project URL: https://github.com/FasterXML/woodstox +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api Name: gax-grpc Version: 2.69.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/gax-java/blob/master/LICENSE - --------------------------------------------------------------------------------- +This product bundles Caffeine by Ben Manes. -Group: com.google.api Name: gax-httpjson Version: 2.69.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/gax-java/blob/master/LICENSE +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api-client Name: google-api-client Version: 2.7.2 -Project URL (from manifest): https://developers.google.com/api-client-library/java/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Luben Zstd. --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: gapic-google-cloud-storage-v2 Version: 2.55.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/luben/zstd-jni/ +License: BSD 2-Clause +| Zstd-jni: JNI bindings to Zstd Library +| +| Copyright (c) 2015-present, Luben Karavelov/ All rights reserved. +| +| BSD License +| +| Redistribution and use in source and binary forms, with or without modification, +| are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, this +| list of conditions and the following disclaimer in the documentation and/or +| other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +| ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +| (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +| LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +| ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: com.google.api.grpc Name: grpc-google-cloud-storage-v2 Version: 2.55.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Jersey Json. -Group: com.google.api.grpc Name: proto-google-cloud-storage-v2 Version: 2.55.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/pjfanning/jersey-1.x +License: CDDL 1.1 - http://glassfish.java.net/public/CDDL+GPL_1_1.html -------------------------------------------------------------------------------- -Group: com.google.api.grpc Name: proto-google-common-protos Version: 2.60.0 -Project URL (from POM): https://github.com/googleapis/sdk-platform-java -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles JCIP Annotations. --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-iam-v1 Version: 1.55.0 -Project URL (from POM): https://github.com/googleapis/sdk-platform-java -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://stephenc.github.com/jcip-annotations +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.apis Name: google-api-services-storage Version: v1-rev20250718-2.0.0 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Android Annotations. -Group: com.google.auth Name: google-auth-library-credentials Version: 1.37.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD New license - http://opensource.org/licenses/BSD-3-Clause +Project URL: http://source.android.com/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.auth Name: google-auth-library-oauth2-http Version: 1.37.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD New license - http://opensource.org/licenses/BSD-3-Clause +This product bundles Google API Common. --------------------------------------------------------------------------------- - -Group: com.google.auto.value Name: auto-value-annotations Version: 1.11.0 -Project URL (from POM): https://github.com/google/auto/tree/main/value -License (from POM): Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.cloud Name: google-cloud-core Version: 2.59.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google GAX. --------------------------------------------------------------------------------- - -Group: com.google.cloud Name: google-cloud-core-grpc Version: 2.59.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.cloud Name: google-cloud-core-http Version: 2.59.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google API Client. -Group: com.google.cloud Name: google-cloud-storage Version: 2.55.0 -Project URL (from POM): https://github.com/googleapis/java-storage -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://developers.google.com/api-client-library/java/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.code.findbugs Name: jsr305 Version: 3.0.2 -Project URL (from POM): http://findbugs.sourceforge.net/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google API gRPC. --------------------------------------------------------------------------------- - -Group: com.google.code.gson Name: gson Version: 2.12.1 -Project URL (from manifest): https://github.com/google/gson -Manifest License: "Apache-2.0";link="https://www.apache.org/licenses/LICENSE-2.0.txt" (Not packaged) -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.errorprone Name: error_prone_annotations Version: 2.38.0 -Project URL (from manifest): https://errorprone.info/error_prone_annotations -Manifest License: "Apache 2.0";link="http://www.apache.org/licenses/LICENSE-2.0.txt" (Not packaged) -License (from POM): Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google SDK platform for Java. -Group: com.google.guava Name: failureaccess Version: 1.0.2 -Project URL (from manifest): https://github.com/google/guava/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/googleapis/sdk-platform-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.guava Name: guava Version: 33.4.0-jre -Project URL (from manifest): https://github.com/google/guava/ -Project URL (from POM): https://github.com/google/guava -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google Auth Library. --------------------------------------------------------------------------------- - -Group: com.google.guava Name: listenablefuture Version: 9999.0-empty-to-avoid-conflict-with-guava -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.http-client Name: google-http-client Version: 1.47.1 -Project URL (from manifest): https://www.google.com/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Auto Value Annotations. -Group: com.google.http-client Name: google-http-client-apache-v2 Version: 1.47.1 -Project URL (from manifest): https://www.google.com/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/auto/tree/main/value +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.http-client Name: google-http-client-appengine Version: 1.47.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google Cloud Core. --------------------------------------------------------------------------------- - -Group: com.google.http-client Name: google-http-client-gson Version: 1.47.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.http-client Name: google-http-client-jackson2 Version: 1.47.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google findbugs jsr305. -Group: com.google.inject Name: guice Version: 4.2.3 -Project URL (from manifest): https://github.com/google/guice -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://findbugs.sourceforge.net/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.j2objc Name: j2objc-annotations Version: 3.0.0 -Project URL (from POM): https://github.com/google/j2objc/ -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Gson. -Group: com.google.oauth-client Name: google-oauth-client Version: 1.39.0 -Project URL (from manifest): https://www.google.com/ -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/gson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.protobuf Name: protobuf-java Version: 4.29.4 -Project URL (from manifest): https://developers.google.com/protocol-buffers/ -License (from POM): BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause - --------------------------------------------------------------------------------- +This product bundles Google Error Prone Annotations. -Group: com.google.protobuf Name: protobuf-java-util Version: 4.29.4 -Project URL (from manifest): https://developers.google.com/protocol-buffers/ -License (from POM): BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause +Project URL: https://errorprone.info/error_prone_annotations +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.re2j Name: re2j Version: 1.8 -Project URL (from POM): http://github.com/google/re2j -License (from POM): Go License - https://golang.org/LICENSE - --------------------------------------------------------------------------------- +This product bundles Google Guava. -Group: com.jcraft Name: jsch Version: 0.1.55 -Project URL (from POM): http://www.jcraft.com/jsch/ -License (from POM): Revised BSD - http://www.jcraft.com/jsch/LICENSE.txt +Project URL: https://github.com/google/guava/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.jolbox Name: bonecp Version: 0.8.0.RELEASE -Project URL (from manifest): http://jolbox.com -License (from POM): Apache v2 - http://www.apache.org/licenses/LICENSE-2.0.html +This product bundles Google Http Client. --------------------------------------------------------------------------------- +Project URL: https://www.google.com/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -Group: com.microsoft.azure Name: msal4j Version: 1.21.0 -Project URL (from manifest): https://github.com/AzureAD/microsoft-authentication-library-for-java -Manifest License: "MIT License" (Not packaged) -Project URL (from POM): https://github.com/AzureAD/microsoft-authentication-library-for-java -License (from POM): MIT License -------------------------------------------------------------------------------- -Group: com.microsoft.azure Name: msal4j-persistence-extension Version: 1.3.0 -Project URL (from POM): https://github.com/AzureAD/microsoft-authentication-library-for-java -License (from POM): MIT License --------------------------------------------------------------------------------- +This product bundles Google Guice. -Group: com.nimbusds Name: content-type Version: 2.3 -Project URL (from manifest): https://connect2id.com -Project URL (from POM): https://bitbucket.org/connect2id/nimbus-content-type -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/guice +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.nimbusds Name: lang-tag Version: 1.7 -Project URL (from manifest): https://connect2id.com/ -Project URL (from POM): https://bitbucket.org/connect2id/nimbus-language-tags -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google j2objc annotations. -Group: com.nimbusds Name: nimbus-jose-jwt Version: 10.0.1 -Project URL (from manifest): https://connect2id.com -Project URL (from POM): https://bitbucket.org/connect2id/nimbus-jose-jwt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/j2objc/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.nimbusds Name: oauth2-oidc-sdk Version: 11.23 -Project URL (from manifest): https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions -Manifest License: "Apache License, version 2.0";link="https://www.apache.org/licenses/LICENSE-2.0.html" (Not packaged) -Project URL (from POM): https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions -License (from POM): Apache License, version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.html +This product bundles Google OAuth Client. --------------------------------------------------------------------------------- - -Group: com.sun.xml.bind Name: jaxb-impl Version: 2.2.3-1 -Project URL (from POM): http://jaxb.java.net/ -License (from POM): CDDL 1.1 - https://glassfish.java.net/public/CDDL+GPL_1_1.html -License (from POM): GPL2 w/ CPE - https://glassfish.java.net/public/CDDL+GPL_1_1.html +Project URL: https://www.google.com/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: commons-beanutils Name: commons-beanutils Version: 1.11.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-beanutils/ -Project URL (from POM): https://commons.apache.org/proper/commons-beanutils/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google protobuf. -Group: commons-cli Name: commons-cli Version: 1.5.0 -Project URL (from manifest): http://commons.apache.org/cli/ -Project URL (from POM): http://commons.apache.org/cli/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://developers.google.com/protocol-buffers/ +License: BSD 3-Clause +| Copyright 2008 Google Inc. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +| +| Code generated by the Protocol Buffer compiler is owned by the owner +| of the input file used when generating it. This code is not +| standalone and requires a support library to be linked with it. This +| support library is itself covered by the above license. -------------------------------------------------------------------------------- -Group: commons-codec Name: commons-codec Version: 1.19.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-codec/ -Project URL (from POM): https://commons.apache.org/proper/commons-codec/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google re2j. --------------------------------------------------------------------------------- - -Group: commons-collections Name: commons-collections Version: 3.2.2 -Project URL (from manifest): http://commons.apache.org/collections/ -Project URL (from POM): http://commons.apache.org/collections/ -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://github.com/google/re2j +License: Go License +| This is a work derived from Russ Cox's RE2 in Go, whose license +| http://golang.org/LICENSE is as follows: +| +| Copyright (c) 2009 The Go Authors. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in +| the documentation and/or other materials provided with the +| distribution. +| +| * Neither the name of Google Inc. nor the names of its contributors +| may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: commons-dbcp Name: commons-dbcp Version: 1.4 -Project URL (from manifest): http://commons.apache.org/dbcp/ -Project URL (from POM): http://commons.apache.org/dbcp/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles JCraft JSch. -Group: commons-io Name: commons-io Version: 2.20.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-io/ -Project URL (from POM): https://commons.apache.org/proper/commons-io/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://www.jcraft.com/jsch/ +License: BSD 3-Clause +| Copyright (c) 2002-2015 Atsuhiko Yamanaka, JCraft,Inc. +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| 1. Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in +| the documentation and/or other materials provided with the distribution. +| +| 3. The names of the authors may not be used to endorse or promote products +| derived from this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, +| INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +| FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT, +| INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, +| INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +| OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +| EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: commons-lang Name: commons-lang Version: 2.6 -Project URL (from manifest): http://commons.apache.org/lang/ -Project URL (from POM): http://commons.apache.org/lang/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Jolbox BoneCP. --------------------------------------------------------------------------------- - -Group: commons-logging Name: commons-logging Version: 1.3.5 -Project URL (from manifest): http://commons.apache.org/proper/commons-logging/ -Project URL (from POM): http://commons.apache.org/proper/commons-logging/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://jolbox.com +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: commons-net Name: commons-net Version: 3.9.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-net/ -Project URL (from POM): https://commons.apache.org/proper/commons-net/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Microsoft Authentication Library for Java. -Group: commons-pool Name: commons-pool Version: 1.6 -Project URL (from manifest): http://commons.apache.org/pool/ -Project URL (from POM): http://commons.apache.org/pool/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/AzureAD/microsoft-authentication-library-for-java +License: MIT +| MIT License +| +| Copyright (c) Microsoft Corporation. All rights reserved. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE -------------------------------------------------------------------------------- -Group: dev.failsafe Name: failsafe Version: 3.3.2 -Project URL (from POM): https://github.com/failsafe-lib/failsafe -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Nimbus Content Type. -Group: dnsjava Name: dnsjava Version: 3.6.1 -Project URL (from POM): http://www.dnsjava.org -License (from POM): BSD 2-Clause license - http://opensource.org/licenses/BSD-2-Clause +Project URL: https://bitbucket.org/connect2id/nimbus-content-type +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.airlift Name: aircompressor Version: 2.0.2 -Project URL (from POM): https://github.com/airlift/aircompressor -License (from POM): Apache License 2.0 - https://www.apache.org/licenses/LICENSE-2.0.html +This product bundles Nimbus Lang Tag. --------------------------------------------------------------------------------- - -Group: io.dropwizard.metrics Name: metrics-core Version: 3.2.4 -License (from POM): Apache License 2.0 - http://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://bitbucket.org/connect2id/nimbus-language-tags +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.dropwizard.metrics Name: metrics-json Version: 3.1.0 -License (from POM): Apache License 2.0 - http://www.apache.org/licenses/LICENSE-2.0.html - --------------------------------------------------------------------------------- +This product bundles Nimbus Jose JWT. -Group: io.dropwizard.metrics Name: metrics-jvm Version: 3.1.0 -License (from POM): Apache License 2.0 - http://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://bitbucket.org/connect2id/nimbus-jose-jwt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-alts Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Nimbus OIDC SDK. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-api Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-auth Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles JAXB. -Group: io.grpc Name: grpc-context Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://jaxb.java.net/ +License: CDDL 1.1 - https://glassfish.java.net/public/CDDL+GPL_1_1.html -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-core Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Apache Commons Beanutils. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-googleapis Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://commons.apache.org/proper/commons-beanutils/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-grpclb Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons CLI. -Group: io.grpc Name: grpc-inprocess Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://commons.apache.org/cli/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-netty-shaded Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Apache Commons Codec. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-protobuf Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://commons.apache.org/proper/commons-codec/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-protobuf-lite Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Collections. -Group: io.grpc Name: grpc-rls Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://commons.apache.org/collections/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-services Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Apache Commons DBCP. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-stub Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://commons.apache.org/dbcp/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-util Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Apache Commons IO. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-xds Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://commons.apache.org/proper/commons-io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-buffer Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Lang. -Group: io.netty Name: netty-codec Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: http://commons.apache.org/lang/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-dns Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Apache Commons Logging. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-codec-haproxy Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: http://commons.apache.org/proper/commons-logging/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-http Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Net. -Group: io.netty Name: netty-codec-http2 Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://commons.apache.org/proper/commons-net/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-memcache Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Apache Commons Pool. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-codec-mqtt Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: http://commons.apache.org/pool/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-redis Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles failsafe. -Group: io.netty Name: netty-codec-smtp Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/failsafe-lib/failsafe +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-socks Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles DNS Java. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-codec-stomp Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: http://www.dnsjava.org +License: BSD 3-Clause +| Copyright (c) 1998-2019, Brian Wellington +| Copyright (c) 2005 VeriSign. All rights reserved. +| Copyright (c) 2019-2023, dnsjava authors +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| 3. Neither the name of the copyright holder nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-xml Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Airlift Aircompressor. -Group: io.netty Name: netty-common Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-handler Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Codahale Metrics. -Group: io.netty Name: netty-handler-proxy Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-handler-ssl-ocsp Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles gRPC. -Group: io.netty Name: netty-resolver Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/grpc/grpc-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-resolver-dns Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Netty. -Group: io.netty Name: netty-resolver-dns-classes-macos Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://netty.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-resolver-dns-native-macos Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Google FlatBuffers. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-tcnative-boringssl-static Version: 2.0.70.Final -Project URL (from manifest): https://netty.io/ -Project URL (from POM): https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://google.github.io/flatbuffers/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-tcnative-classes Version: 2.0.70.Final -Project URL (from manifest): https://netty.io/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles OpenCensus. -Group: io.netty Name: netty-transport Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/census-instrumentation/opencensus-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-classes-epoll Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Perfmark. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-transport-classes-kqueue Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/perfmark/perfmark +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-native-epoll Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles org.json. -Group: io.netty Name: netty-transport-native-kqueue Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/douglascrockford/JSON-java +License: Public Domain - https://github.com/stleary/JSON-java/blob/master/LICENSE -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-native-unix-common Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Reactor Core. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-transport-rxtx Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/reactor/reactor-core +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-sctp Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Reactor Netty. -Group: io.netty Name: netty-transport-udt Version: 4.1.100.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/reactor/reactor-netty +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opencensus Name: opencensus-api Version: 0.31.1 -Project URL (from POM): https://github.com/census-instrumentation/opencensus-java -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Jakarta Activation API. --------------------------------------------------------------------------------- - -Group: io.opencensus Name: opencensus-contrib-http-util Version: 0.31.1 -Project URL (from POM): https://github.com/census-instrumentation/opencensus-java -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://www.eclipse.org +License: Eclipse Public License v. 2.0 - https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt -------------------------------------------------------------------------------- -Group: io.perfmark Name: perfmark-api Version: 0.27.0 -Project URL (from POM): https://github.com/perfmark/perfmark -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Javax Annotation API. -Group: io.projectreactor Name: reactor-core Version: 3.4.41 -Project URL (from POM): https://github.com/reactor/reactor-core -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://javaee.github.io/glassfish +License: CDDL 1.1 - https://github.com/javaee/javax.annotation/blob/master/LICENSE -------------------------------------------------------------------------------- -Group: io.projectreactor.netty Name: reactor-netty-core Version: 1.0.48 -Project URL (from POM): https://github.com/reactor/reactor-netty -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Javax Servlet API. -Group: io.projectreactor.netty Name: reactor-netty-http Version: 1.0.48 -Project URL (from POM): https://github.com/reactor/reactor-netty -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://servlet-spec.java.net +License: CDDL 1.1 - https://glassfish.dev.java.net/nonav/public/CDDL+GPL.html -------------------------------------------------------------------------------- -Group: jakarta.activation Name: jakarta.activation-api Version: 1.2.2 -Project URL (from manifest): https://www.eclipse.org -License (from POM): EDL 1.0 - http://www.eclipse.org/org/documents/edl-v10.php -License (from POM): Eclipse Public License v. 2.0 - https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt -License (from POM): GNU General Public License, version 2 with the GNU Classpath Exception - https://www.gnu.org/software/classpath/license.html +This produt bundles JDO. --------------------------------------------------------------------------------- - -Group: javax.annotation Name: javax.annotation-api Version: 1.3.2 -Project URL (from manifest): https://javaee.github.io/glassfish -Project URL (from POM): http://jcp.org/en/jsr/detail?id=250 -License (from POM): CDDL + GPLv2 with classpath exception - https://github.com/javaee/javax.annotation/blob/master/LICENSE +Project URL: http://db.apache.org/jdo +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: javax.inject Name: javax.inject Version: 1 -Project URL (from POM): http://code.google.com/p/atinject/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Javax Servlet API. -Group: javax.jdo Name: jdo-api Version: 3.0.1 -Project URL (from POM): http://db.apache.org/jdo -License (from POM): Apache 2 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://servlet-spec.java.net +License: CDDL 1.1 - https://glassfish.dev.java.net/nonav/public/CDDL+GPL.html -------------------------------------------------------------------------------- -Group: javax.servlet Name: javax.servlet-api Version: 3.1.0 -Project URL (from manifest): https://glassfish.dev.java.net -Project URL (from POM): http://servlet-spec.java.net -License (from POM): CDDL + GPLv2 with classpath exception - https://glassfish.dev.java.net/nonav/public/CDDL+GPL.html +This product bundles Javax Servlet JSP. --------------------------------------------------------------------------------- - -Group: javax.servlet.jsp Name: jsp-api Version: 2.1 Project URL: https://github.com/javaee/javaee-jsp-api -License (from POM): CDDL + GPLv2 with classpath exception - https://github.com/javaee/javaee-jsp-api/blob/master/LICENSE - --------------------------------------------------------------------------------- - -Group: javax.transaction Name: jta Version: 1.1 -Project URL (from POM): http://java.sun.com/products/jta - --------------------------------------------------------------------------------- - -Group: javax.xml.bind Name: jaxb-api Version: 2.2.12 -Project URL (from manifest): http://www.oracle.com/ -Project URL (from POM): http://jaxb.java.net/ -License (from POM): CDDL 1.1 - https://glassfish.java.net/public/CDDL+GPL_1_1.html -License (from POM): GPL2 w/ CPE - https://glassfish.java.net/public/CDDL+GPL_1_1.html - --------------------------------------------------------------------------------- - -Group: javolution Name: javolution Version: 5.5.1 -Project URL (from manifest): http://javolution.org -Project URL (from POM): http://javolution.org -License (from POM): BSD License - http://javolution.org/LICENSE.txt - --------------------------------------------------------------------------------- - -Group: jline Name: jline Version: 2.12 -License (from POM): The BSD License - http://www.opensource.org/licenses/bsd-license.php - --------------------------------------------------------------------------------- - -Group: joda-time Name: joda-time Version: 2.8.1 -Project URL (from manifest): http://www.joda.org/joda-time/ -Manifest License: Apache 2.0 (Not packaged) -Project URL (from POM): http://www.joda.org/joda-time/ -License (from POM): Apache 2 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.java.dev.jna Name: jna Version: 5.13.0 -Project URL (from POM): https://github.com/java-native-access/jna -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): LGPL-2.1-or-later - https://www.gnu.org/licenses/old-licenses/lgpl-2.1 - --------------------------------------------------------------------------------- - -Group: net.java.dev.jna Name: jna-platform Version: 5.13.0 -Project URL (from POM): https://github.com/java-native-access/jna -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): LGPL-2.1-or-later - https://www.gnu.org/licenses/old-licenses/lgpl-2.1 - --------------------------------------------------------------------------------- - -Group: net.minidev Name: accessors-smart Version: 2.5.2 -Project URL (from manifest): https://urielch.github.io/ -Project URL (from POM): https://urielch.github.io/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.minidev Name: json-smart Version: 2.5.2 -Project URL (from manifest): https://urielch.github.io/ -Project URL (from POM): https://urielch.github.io/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.sf.opencsv Name: opencsv Version: 2.3 -Project URL (from POM): http://opencsv.sf.net -License (from POM): Apache 2 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.antlr Name: antlr-runtime Version: 3.5.2 -Project URL (from POM): http://www.antlr.org -License (from POM): BSD licence - http://antlr.org/license.html - --------------------------------------------------------------------------------- - -Group: org.apache.ant Name: ant Version: 1.9.1 -Project URL (from POM): http://ant.apache.org/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.ant Name: ant-launcher Version: 1.9.1 -Project URL (from POM): http://ant.apache.org/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.avro Name: avro Version: 1.12.0 -Project URL (from manifest): https://www.apache.org/ -Project URL (from POM): https://avro.apache.org -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-compress Version: 1.28.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-compress/ -Project URL (from POM): https://commons.apache.org/proper/commons-compress/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-configuration2 Version: 2.10.1 -Project URL (from manifest): https://commons.apache.org/proper/commons-configuration/ -Project URL (from POM): https://commons.apache.org/proper/commons-configuration/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-lang3 Version: 3.18.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-lang/ -Project URL (from POM): https://commons.apache.org/proper/commons-lang/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-math3 Version: 3.6.1 -Project URL (from manifest): http://commons.apache.org/math/ -Project URL (from POM): http://commons.apache.org/math/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +License: CDDL 1.1 - https://github.com/javaee/javaee-jsp-api/blob/master/LICENSE + +-------------------------------------------------------------------------------- + +This product bundles JTA. -Group: org.apache.commons Name: commons-text Version: 1.11.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-text -Project URL (from POM): https://commons.apache.org/proper/commons-text -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://java.sun.com/products/jta +License: CDDL 1.1 - https://glassfish.dev.java.net/nonav/public/CDDL+GPL.html -------------------------------------------------------------------------------- -Group: org.apache.curator Name: curator-client Version: 5.2.0 -Project URL (from manifest): http://www.apache.org/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles JAXB. --------------------------------------------------------------------------------- - -Group: org.apache.curator Name: curator-framework Version: 5.2.0 -Project URL (from manifest): http://www.apache.org/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.curator Name: curator-recipes Version: 5.2.0 -Project URL (from manifest): http://www.apache.org/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.derby Name: derby Version: 10.14.2.0 -License (from POM): Apache 2 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-annotations Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-auth Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-client Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-common Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-hdfs-client Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-mapreduce-client-common Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-mapreduce-client-core Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-mapreduce-client-jobclient Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-yarn-api Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-yarn-client Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-yarn-common Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop.thirdparty Name: hadoop-shaded-guava Version: 1.4.0 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop.thirdparty Name: hadoop-shaded-protobuf_3_25 Version: 1.3.0 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive Name: hive-common Version: 2.3.10 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive Name: hive-metastore Version: 2.3.10 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive Name: hive-serde Version: 2.3.10 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive Name: hive-shims Version: 2.3.10 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive Name: hive-storage-api Version: 2.4.0 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive.shims Name: hive-shims-0.23 Version: 2.3.10 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive.shims Name: hive-shims-common Version: 2.3.10 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hive.shims Name: hive-shims-scheduler Version: 2.3.10 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents Name: httpclient Version: 4.5.14 -Project URL (from POM): http://hc.apache.org/httpcomponents-client -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents Name: httpcore Version: 4.4.16 -Project URL (from POM): http://hc.apache.org/httpcomponents-core-ga -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents.client5 Name: httpclient5 Version: 5.5 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents.core5 Name: httpcore5 Version: 5.3.4 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents.core5 Name: httpcore5-h2 Version: 5.3.4 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.kerby Name: kerb-core Version: 2.0.3 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.kerby Name: kerb-crypto Version: 2.0.3 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.kerby Name: kerb-util Version: 2.0.3 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.kerby Name: kerby-asn1 Version: 2.0.3 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.kerby Name: kerby-config Version: 2.0.3 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.kerby Name: kerby-pkix Version: 2.0.3 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.kerby Name: kerby-util Version: 2.0.3 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.orc Name: orc-core Version: 1.9.7 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.orc Name: orc-shims Version: 1.9.7 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-avro Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://jaxb.java.net/ +License: CDDL 1.1 - https://glassfish.java.net/public/CDDL+GPL_1_1.html -------------------------------------------------------------------------------- -Group: org.apache.parquet Name: parquet-column Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Javolution. --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-common Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-encoding Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-format-structures Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-hadoop Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-jackson Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.thrift Name: libfb303 Version: 0.9.3 -Project URL (from POM): http://thrift.apache.org -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.thrift Name: libthrift Version: 0.14.1 -Project URL (from POM): http://thrift.apache.org -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.yetus Name: audience-annotations Version: 0.12.0 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.zookeeper Name: zookeeper Version: 3.8.4 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.zookeeper Name: zookeeper-jute Version: 3.8.4 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://javolution.org +License: BSD 2-Clause +| /* +| * Javolution - Java(tm) Solution for Real-Time and Embedded Systems +| * Copyright (c) 2012, Javolution (http://javolution.org/) +| * All rights reserved. +| * +| * Redistribution and use in source and binary forms, with or without +| * modification, are permitted provided that the following conditions are met: +| * +| * 1. Redistributions of source code must retain the above copyright +| * notice, this list of conditions and the following disclaimer. +| * +| * 2. Redistributions in binary form must reproduce the above copyright +| * notice, this list of conditions and the following disclaimer in the +| * documentation and/or other materials provided with the distribution. +| * +| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +| */ -------------------------------------------------------------------------------- -Group: org.checkerframework Name: checker-qual Version: 3.49.0 -Manifest License: MIT (Not packaged) -Project URL (from POM): https://checkerframework.org/ -License (from POM): The MIT License - http://opensource.org/licenses/MIT +This product bundles JLine. --------------------------------------------------------------------------------- - -Group: org.codehaus.jettison Name: jettison Version: 1.5.4 -Project URL (from POM): https://github.com/jettison-json/jettison -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- - -Group: org.codehaus.mojo Name: animal-sniffer-annotations Version: 1.24 -License (from POM): MIT license - https://spdx.org/licenses/MIT.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/jline/jline3 +License: BSD 3-Clause +| Copyright (c) 2002-2023, the original author or authors. +| All rights reserved. +| +| https://opensource.org/licenses/BSD-3-Clause +| +| Redistribution and use in source and binary forms, with or +| without modification, are permitted provided that the following +| conditions are met: +| +| Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| +| Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with +| the distribution. +| +| Neither the name of JLine nor the names of its contributors +| may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +| BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +| AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +| EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +| OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +| AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +| LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +| IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +| OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: org.codehaus.woodstox Name: stax2-api Version: 4.2.2 -Project URL (from manifest): http://github.com/FasterXML/stax2-api -Project URL (from POM): http://github.com/FasterXML/stax2-api -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The BSD 2-Clause License - http://www.opensource.org/licenses/bsd-license.php - --------------------------------------------------------------------------------- +This product bundles Joda Time. -Group: org.conscrypt Name: conscrypt-openjdk-uber Version: 2.5.2 -Project URL (from POM): https://conscrypt.org/ -License (from POM): Apache 2 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: http://www.joda.org/joda-time/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.datanucleus Name: datanucleus-api-jdo Version: 4.2.4 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.datanucleus Name: datanucleus-core Version: 4.1.17 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.datanucleus Name: datanucleus-rdbms Version: 4.1.19 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.datanucleus Name: javax.jdo Version: 3.2.0-m3 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.eclipse.jetty Name: jetty-client Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php - --------------------------------------------------------------------------------- - -Group: org.eclipse.jetty Name: jetty-http Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php - --------------------------------------------------------------------------------- - -Group: org.eclipse.jetty Name: jetty-io Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php - --------------------------------------------------------------------------------- - -Group: org.eclipse.jetty Name: jetty-security Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php - --------------------------------------------------------------------------------- +This product bundles JNA. -Group: org.eclipse.jetty Name: jetty-servlet Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php +Project URL: https://github.com/java-native-access/jna +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.eclipse.jetty Name: jetty-util Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php - --------------------------------------------------------------------------------- +This product bundles Accessors Smart. -Group: org.eclipse.jetty Name: jetty-util-ajax Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php +Project URL: https://urielch.github.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.eclipse.jetty Name: jetty-webapp Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php - --------------------------------------------------------------------------------- +This product bundles Json Smart. -Group: org.eclipse.jetty Name: jetty-xml Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php +Project URL: https://urielch.github.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.eclipse.jetty.websocket Name: websocket-api Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php - --------------------------------------------------------------------------------- +This product bundles OpenCSV. -Group: org.eclipse.jetty.websocket Name: websocket-client Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php +Project URL: http://opencsv.sf.net +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.eclipse.jetty.websocket Name: websocket-common Version: 9.4.53.v20231009 -Project URL (from manifest): https://eclipse.org/jetty -License (from POM): Apache Software License - Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 -License (from POM): Eclipse Public License - Version 1.0 - https://www.eclipse.org/org/documents/epl-v10.php +This product bundles ANTLR. --------------------------------------------------------------------------------- - -Group: org.jline Name: jline Version: 3.9.0 -License (from POM): The BSD License - http://www.opensource.org/licenses/bsd-license.php +Project URL: http://www.antlr.org +License: BSD 3-Clause +| Copyright (c) 2012-2022 The ANTLR Project. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in the +| documentation and/or other materials provided with the distribution. +| +| 3. Neither name of copyright holders nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: org.ow2.asm Name: asm Version: 9.7.1 -Project URL (from manifest): http://asm.ow2.org -Manifest License: BSD-3-Clause;link=https://asm.ow2.io/LICENSE.txt (Not packaged) -Project URL (from POM): http://asm.ow2.io/ -License (from POM): BSD-3-Clause - https://asm.ow2.io/license.html -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Apache Ant. -Group: org.reactivestreams Name: reactive-streams Version: 1.0.4 -Project URL (from manifest): http://reactive-streams.org -Project URL (from POM): http://www.reactive-streams.org/ -License (from POM): MIT-0 - https://spdx.org/licenses/MIT-0.html +Project URL: http://ant.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.roaringbitmap Name: RoaringBitmap Version: 1.3.0 -Project URL (from POM): https://github.com/RoaringBitmap/RoaringBitmap -License (from POM): Apache 2 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Apache Avro. --------------------------------------------------------------------------------- - -Group: org.slf4j Name: slf4j-api Version: 2.0.17 -Project URL (from manifest): http://www.slf4j.org -Project URL (from POM): http://www.slf4j.org -License (from POM): MIT License - http://www.opensource.org/licenses/mit-license.php +Project URL: https://avro.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.slf4j Name: slf4j-reload4j Version: 1.7.36 -Project URL (from POM): http://reload4j.qos.ch -License (from POM): MIT License - http://www.opensource.org/licenses/mit-license.php - --------------------------------------------------------------------------------- +This product bundles Apache Arrow. -Group: org.threeten Name: threeten-extra Version: 1.8.0 -Project URL (from manifest): https://www.threeten.org -Project URL (from POM): https://www.threeten.org/threeten-extra -License (from POM): BSD 3-clause - https://raw.githubusercontent.com/ThreeTen/threeten-extra/master/LICENSE.txt +Project URL: https://arrow.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.threeten Name: threetenbp Version: 1.7.0 -Project URL (from manifest): https://www.threeten.org -Project URL (from POM): https://www.threeten.org/threetenbp -License (from POM): BSD-3-Clause - https://raw.githubusercontent.com/ThreeTen/threetenbp/main/LICENSE.txt +This product bundles Apache Commons Compress. --------------------------------------------------------------------------------- - -Group: org.xerial.snappy Name: snappy-java Version: 1.1.10.8 -Project URL (from manifest): http://www.xerial.org/ -Project URL (from POM): https://github.com/xerial/snappy-java -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://commons.apache.org/proper/commons-compress/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: annotations Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Configuration. -Group: software.amazon.awssdk Name: apache-client Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://commons.apache.org/proper/commons-configuration/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: arns Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache Commons Lang. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: auth Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://commons.apache.org/proper/commons-lang/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: aws-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Math. -Group: software.amazon.awssdk Name: aws-json-protocol Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: http://commons.apache.org/math/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: aws-query-protocol Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Text. -Group: software.amazon.awssdk Name: aws-xml-protocol Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://commons.apache.org/proper/commons-text +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: checksums Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache Curator. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: checksums-spi Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://curator.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: crt-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Derby. -Group: software.amazon.awssdk Name: dynamodb Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://db.apache.org/derby +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: endpoints-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache Hadoop. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: glue Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://hadoop.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: http-auth Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Hive. -Group: software.amazon.awssdk Name: http-auth-aws Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://hive.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: http-auth-aws-crt Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache HttpComponents (core and client). --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-auth-aws-eventstream Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL (from POM): http://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: http-auth-spi Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Kerby. -Group: software.amazon.awssdk Name: http-client-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://directory.apache.org/kerby/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: iam Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache ORC. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: identity-spi Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://orc.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: json-utils Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Parquet. -Group: software.amazon.awssdk Name: kms Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://parquet.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: lakeformation Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache Thrift. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: metrics-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: http://thrift.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: netty-nio-client Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache Yetus. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: profiles Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://yetus.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: protocol-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Zookeeper. -Group: software.amazon.awssdk Name: regions Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://zookeeper.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: retries Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles checkerframework checker-qual. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: retries-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://checkerframework.org/ +License: MIT +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: s3 Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Jettison. -Group: software.amazon.awssdk Name: sdk-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/jettison-json/jettison +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: sso Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles JTS Topology Suite. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: sts Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/locationtech/jts +License: Eclipse Public License v. 2.0 - https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: third-party-jackson-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Animal Sniffer Annotations. -Group: software.amazon.awssdk Name: utils Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +License: MIT +| The MIT License +| +| Copyright (c) 2009 codehaus.org. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. -------------------------------------------------------------------------------- -Group: software.amazon.eventstream Name: eventstream Version: 1.0.1 -Project URL (from POM): https://github.com/awslabs/aws-eventstream-java -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Stax2 API. --------------------------------------------------------------------------------- - -Group: org.mongodb Name: bson Version: 4.11.5 -Project URL (from POM): https://github.com/mongodb/mongo-java-driver -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://github.com/FasterXML/stax2-api +License: BSD 2-Clause +| BSD 2-Clause License +| +| Copyright (c) 2008+, FasterXML, LLC +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Conscrypt (openjdk-uber). + +Project URL: https://conscrypt.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Datanucleaus. + +Project URL: https://www.datanucleus.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Jetty. + +Project URL: https://eclipse.org/jetty +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles ASM. + +Project URL: http://asm.ow2.org +License: BSD 3-Clause +| ASM: a very small and fast Java bytecode manipulation framework +| Copyright (c) 2000-2011 INRIA, France Telecom +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| 1. Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| 2. Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in the +| documentation and/or other materials provided with the distribution. +| 3. Neither the name of the copyright holders nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +| THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Reactive Streams. + +Project URL: http://reactive-streams.org +License: MIT +| MIT No Attribution +| +| Copyright 2014 Reactive Streams +| +| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles RoaringBitmap. + +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles SLF4J. + +Project URL: http://www.slf4j.org +License: MIT +| Copyright (c) 2004-2025 QOS.ch +| All rights reserved. +| +| Permission is hereby granted, free of charge, to any person obtaining +| a copy of this software and associated documentation files (the +| "Software"), to deal in the Software without restriction, including +| without limitation the rights to use, copy, modify, merge, publish, +| distribute, sublicense, and/or sell copies of the Software, and to +| permit persons to whom the Software is furnished to do so, subject to +| the following conditions: +| +| The above copyright notice and this permission notice shall be +| included in all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +| EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +| MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +| NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +| LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +| OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +| WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------------------------------- +-------------------------------------------------------------------------------- + +This product bundles ThreeTen Extra. + +Project URL: https://www.threeten.org/threeten-extra +License: BSD 3-Clause +| All rights reserved. +| +| * Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| * Neither the name of JSR-310 nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles ThreeTen BP. + +Project URL: https://www.threeten.org/threetenbp +License: BSD 3-Clause +| Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| * Neither the name of JSR-310 nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Snappy Java. + +Project URL: https://github.com/xerial/snappy-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles AWS SDK for Java. + +Project URL: https://aws.amazon.com/sdkforjava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles AWS EventStream for Java. + +Project URL: https://github.com/awslabs/aws-eventstream-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles MongoDB BSON. + +Project URL: https://github.com/mongodb/mongo-java-driver +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 diff --git a/kafka-connect/kafka-connect-runtime/hive/NOTICE b/kafka-connect/kafka-connect-runtime/hive/NOTICE index bdcaa9a7b64b..34052e8d51eb 100644 --- a/kafka-connect/kafka-connect-runtime/hive/NOTICE +++ b/kafka-connect/kafka-connect-runtime/hive/NOTICE @@ -1,6 +1,6 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). @@ -8,7 +8,7 @@ The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- This project includes code from Kite, developed at Cloudera, Inc. with -the following copyright notice: +the following in its NOTICE file: | Copyright 2013 Cloudera Inc. | @@ -26,13 +26,7 @@ the following copyright notice: -------------------------------------------------------------------------------- -This binary artifact contains code from the following projects: - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-netty-shaded Version: 1.71.0 - -Notice: +This product includes Netty (via gRPC) with the following in its NOTICE file: | The Netty Project | ================= | @@ -87,73 +81,7 @@ Notice: -------------------------------------------------------------------------------- -Group: jakarta.activation Name: jakarta.activation-api Version: 1.2.2 - -Notice: # Notices for Jakarta Activation - -| This content is produced and maintained by Jakarta Activation project. -| -| * Project home: https://projects.eclipse.org/projects/ee4j.jaf -| -| ## Copyright -| -| All content is the property of the respective authors or their employers. For -| more information regarding authorship of content, please consult the listed -| source code repository logs. -| -| ## Declared Project Licenses -| -| This program and the accompanying materials are made available under the terms -| of the Eclipse Distribution License v. 1.0, -| which is available at http://www.eclipse.org/org/documents/edl-v10.php. -| -| SPDX-License-Identifier: BSD-3-Clause -| -| ## Source Code -| -| The project maintains the following source code repositories: -| -| * https://github.com/eclipse-ee4j/jaf -| -| ## Third-party Content -| -| This project leverages the following third party content. -| -| JUnit (4.12) -| -| * License: Eclipse Public License - - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.module Name: jackson-module-jaxb-annotations Version: 2.19.2 - -Notice: # Jackson JSON processor - -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers, as well as supported -| commercially by FasterXML.com. -| -| ## Licensing -| -| Jackson core and extension components may licensed under different licenses. -| To find the details that apply to this artifact see the accompanying LICENSE file. -| For more information, including possible other licensing options, contact -| FasterXML.com (http://fasterxml.com). -| -| ## Credits -| -| A list of contributors may be found from CREDITS file, which is included -| in some artifacts (usually source distributions); but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- - -Group: org.datanucleus Name: datanucleus-core Version: 4.1.17 - -Notice: +This product bundles Datanucleus with the following in its NOTICE file: | ========================================================================= | == NOTICE file corresponding to section 4(d) of the Apache License, == | == Version 2.0, in this case for the DataNucleus distribution. == @@ -192,49 +120,9 @@ Notice: | (http://tjdo.sourceforge.net/). | =================================================================== - -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: annotations Version: 2.33.0 -Group: software.amazon.awssdk Name: apache-client Version: 2.33.0 -Group: software.amazon.awssdk Name: arns Version: 2.33.0 -Group: software.amazon.awssdk Name: auth Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-core Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-json-protocol Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-query-protocol Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-xml-protocol Version: 2.33.0 -Group: software.amazon.awssdk Name: checksums Version: 2.33.0 -Group: software.amazon.awssdk Name: checksums-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: crt-core Version: 2.33.0 -Group: software.amazon.awssdk Name: dynamodb Version: 2.33.0 -Group: software.amazon.awssdk Name: endpoints-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: glue Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-aws Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-aws-crt Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-aws-eventstream Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: http-client-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: iam Version: 2.33.0 -Group: software.amazon.awssdk Name: identity-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: json-utils Version: 2.33.0 -Group: software.amazon.awssdk Name: kms Version: 2.33.0 -Group: software.amazon.awssdk Name: lakeformation Version: 2.33.0 -Group: software.amazon.awssdk Name: metrics-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: netty-nio-client Version: 2.33.0 -Group: software.amazon.awssdk Name: profiles Version: 2.33.0 -Group: software.amazon.awssdk Name: protocol-core Version: 2.33.0 -Group: software.amazon.awssdk Name: regions Version: 2.33.0 -Group: software.amazon.awssdk Name: retries Version: 2.33.0 -Group: software.amazon.awssdk Name: retries-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: s3 Version: 2.33.0 -Group: software.amazon.awssdk Name: sdk-core Version: 2.33.0 -Group: software.amazon.awssdk Name: sso Version: 2.33.0 -Group: software.amazon.awssdk Name: sts Version: 2.33.0 -Group: software.amazon.awssdk Name: utils Version: 2.33.0 -Group: software.amazon.s3.analyticsaccelerator Name: analyticsaccelerator-s3 Version: 1.0.0 - -Notice: +This product bundles AWS SDK for Java with the following in its NOTICE file: | AWS SDK for Java 2.0 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | @@ -263,60 +151,16 @@ Notice: -------------------------------------------------------------------------------- -Group: jakarta.activation Name: jakarta.activation-api Version: 1.2.2 - -Notice: -| # Notices for Eclipse Project for JAF -| -| This content is produced and maintained by the Eclipse Project for JAF project. -| -| * Project home: https://projects.eclipse.org/projects/ee4j.jaf -| -| ## Copyright -| -| All content is the property of the respective authors or their employers. For -| more information regarding authorship of content, please consult the listed -| source code repository logs. -| -| ## Declared Project Licenses -| -| This program and the accompanying materials are made available under the terms -| of the Eclipse Distribution License v. 1.0, -| which is available at http://www.eclipse.org/org/documents/edl-v10.php. -| -| SPDX-License-Identifier: BSD-3-Clause -| -| ## Source Code -| -| The project maintains the following source code repositories: -| -| * https://github.com/eclipse-ee4j/jaf -| -| ## Third-party Content -| -| This project leverages the following third party content. -| -| JUnit (4.12) -| -| * License: Eclipse Public License - --------------------------------------------------------------------------------- - -Group: com.google.inject Name: guice Version: 4.2.3 - -Notice: +This product bundles Guice with the following in its NOTICE file: | Google Guice - Core Library | Copyright 2006-2015 Google, Inc. | | This product includes software developed at | The Apache Software Foundation (http://www.apache.org/). - -------------------------------------------------------------------------------- -Group: ch.qos.reload4j Name: reload4j Version: 1.2.22 - -Notice: +This product bundles reload4j with the following in its NOTICE file: | Apache log4j | Copyright 2007 The Apache Software Foundation | @@ -325,9 +169,7 @@ Notice: -------------------------------------------------------------------------------- -Group: joda-time Name: joda-time Version: 2.8.1 - -Notice: +This product bundles Joda Time with the following in its NOTICE file: | ============================================================================= | = NOTICE file corresponding to section 4d of the Apache License Version 2.0 = | ============================================================================= @@ -336,10 +178,7 @@ Notice: -------------------------------------------------------------------------------- -Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.19.2 -Group: software.amazon.awssdk Name: third-party-jackson-core Version: 2.33.0 - -Notice: +This product bundles Jackson JSON Processor with the following in its NOTICE file: | # Jackson JSON processor | | Jackson is a high-performance, Free/Open Source JSON processing library. @@ -375,176 +214,7 @@ Notice: -------------------------------------------------------------------------------- -Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.19.2 - -Notice: -| # Jackson JSON processor -| -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers. -| -| ## Licensing -| -| Jackson components are licensed under Apache (Software) License, version 2.0, -| as per accompanying LICENSE file. -| -| ## Credits -| -| A list of contributors may be found from CREDITS file, which is included -| in some artifacts (usually source distributions); but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- - -Group: org.datanucleus Name: datanucleus-api-jdo Version: 4.2.4 - -Notice: -| ========================================================================= -| == NOTICE file corresponding to section 4(d) of the Apache License, == -| == Version 2.0, in this case for the DataNucleus distribution. == -| ========================================================================= -| -| =================================================================== -| This product includes software developed by many individuals, -| including the following: -| =================================================================== -| Erik Bengtson -| Andy Jefferson -| -| -| =================================================================== -| This product has included contributions from some individuals, -| including the following: -| =================================================================== - --------------------------------------------------------------------------------- - -Group: org.datanucleus Name: datanucleus-rdbms Version: 4.1.19 - -Notice: -| ========================================================================= -| == NOTICE file corresponding to section 4(d) of the Apache License, == -| == Version 2.0, in this case for the DataNucleus distribution. == -| ========================================================================= -| -| =================================================================== -| This product includes software developed by many individuals, -| including the following: -| =================================================================== -| Andy Jefferson -| Erik Bengtson -| Joerg von Frantzius -| Marco Schulze -| -| -| =================================================================== -| This product has included contributions from some individuals, -| including the following: -| =================================================================== -| Barry Haddow -| Ralph Ullrich -| David Ezzio -| Brendan de Beer -| David Eaves -| Martin Taal -| Tony Lai -| Roland Szabo -| Anton Troshin (Timesten) -| -| -| =================================================================== -| This product also includes software developed by the TJDO project -| (http://tjdo.sourceforge.net/). -| =================================================================== -| -| =================================================================== -| This product also includes software developed by the Apache Commons project -| (http://commons.apache.org/). -| =================================================================== - --------------------------------------------------------------------------------- - -Group: javax.jdo Name: jdo-api Version: 3.0.1 - -Notice: -| Apache Java Data Objects (JDO) -| Copyright 2005-2006 The Apache Software Foundation -| -| This product includes software developed at -| The Apache Software Foundation (http://www.apache.org/). - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.19.2 -Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.19.2 - -Notice: -| # Jackson JSON processor -| -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers. -| -| ## Copyright -| -| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) -| -| ## Licensing -| -| Jackson 2.x core and extension components are licensed under Apache License 2.0 -| To find the details that apply to this artifact see the accompanying LICENSE file. -| -| ## Credits -| -| A list of contributors may be found from CREDITS(-2.x) file, which is included -| in some artifacts (usually source distributions); but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.jaxrs Name: jackson-jaxrs-json-provider Version: 2.19.2 - -Notice: -| # Jackson JSON processor -| -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers, as well as supported -| commercially by FasterXML.com. -| -| ## Licensing -| -| Jackson core and extension components may be licensed under different licenses. -| To find the details that apply to this artifact see the accompanying LICENSE file. -| For more information, including possible other licensing options, contact -| FasterXML.com (http://fasterxml.com). -| -| ## Credits -| -| A list of contributors may be found from CREDITS file, which is included -| in some artifacts (usually source distributions); but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- - -Group: org.eclipse.jetty Name: jetty-client Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-http Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-io Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-security Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-servlet Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-util Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-util-ajax Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-webapp Version: 9.4.53.v20231009 -Group: org.eclipse.jetty Name: jetty-xml Version: 9.4.53.v20231009 -Group: org.eclipse.jetty.websocket Name: websocket-api Version: 9.4.53.v20231009 -Group: org.eclipse.jetty.websocket Name: websocket-client Version: 9.4.53.v20231009 -Group: org.eclipse.jetty.websocket Name: websocket-common Version: 9.4.53.v20231009 - -Notice: +This product bundles Jetty with the following in its NOTICE file: | ============================================================== | Jetty Web Container | Copyright 1995-2018 Mort Bay Consulting Pty Ltd. @@ -663,6 +333,3 @@ Notice: | Permission to use, copy, modify and distribute UnixCrypt | for non-commercial or commercial purposes and without fee is | granted provided that the copyright notice appears in all copies. - - --------------------------------------------------------------------------------- diff --git a/kafka-connect/kafka-connect-runtime/main/LICENSE b/kafka-connect/kafka-connect-runtime/main/LICENSE index c577e143819d..2f93e2793f77 100644 --- a/kafka-connect/kafka-connect-runtime/main/LICENSE +++ b/kafka-connect/kafka-connect-runtime/main/LICENSE @@ -203,1248 +203,973 @@ -------------------------------------------------------------------------------- -This binary artifact contains code from the following projects: +This product bundles Azure SDK for Java. --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-core Version: 1.55.5 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT +Project URL: https://github.com/Azure/azure-sdk-for-java +License: MIT +| The MIT License (MIT) +| +| Copyright (c) 2015 Microsoft +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. -------------------------------------------------------------------------------- -Group: com.azure Name: azure-core-http-netty Version: 1.15.13 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT +This product bundles Jackson JSON Processor. --------------------------------------------------------------------------------- - -Group: com.azure Name: azure-identity Version: 1.16.2 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.azure Name: azure-json Version: 1.5.0 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- +This product bundles Woodstox. -Group: com.azure Name: azure-storage-blob Version: 12.31.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT +Project URL: https://github.com/FasterXML/woodstox +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.azure Name: azure-storage-common Version: 12.30.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- +This product bundles Caffeine by Ben Manes. -Group: com.azure Name: azure-storage-file-datalake Version: 12.24.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.azure Name: azure-storage-internal-avro Version: 12.16.1 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- +This product bundles Luben Zstd. -Group: com.azure Name: azure-xml Version: 1.2.0 -Project URL (from POM): https://github.com/Azure/azure-sdk-for-java -License (from POM): The MIT License (MIT) - http://opensource.org/licenses/MIT +Project URL: https://github.com/luben/zstd-jni/ +License: BSD 2-Clause +| Zstd-jni: JNI bindings to Zstd Library +| +| Copyright (c) 2015-present, Luben Karavelov/ All rights reserved. +| +| BSD License +| +| Redistribution and use in source and binary forms, with or without modification, +| are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, this +| list of conditions and the following disclaimer in the documentation and/or +| other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +| ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +| (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +| LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +| ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson -Project URL (from POM): https://github.com/FasterXML/jackson -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Jersey Json. --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson-core -Project URL (from POM): https://github.com/FasterXML/jackson-core -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/pjfanning/jersey-1.x +License: CDDL 1.1 - http://glassfish.java.net/public/CDDL+GPL_1_1.html -------------------------------------------------------------------------------- -Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson -Project URL (from POM): https://github.com/FasterXML/jackson -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles JCIP Annotations. --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.19.2 -Project URL (from manifest): https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310 -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://stephenc.github.com/jcip-annotations +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.fasterxml.woodstox Name: woodstox-core Version: 6.7.0 -Project URL (from manifest): https://github.com/FasterXML/woodstox -Project URL (from POM): https://github.com/FasterXML/woodstox -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Android Annotations. -Group: com.github.ben-manes.caffeine Name: caffeine Version: 2.9.3 -Project URL (from POM): https://github.com/ben-manes/caffeine -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://source.android.com/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.github.luben Name: zstd-jni Version: 1.5.7-3 -License URL (from manifest): https://opensource.org/licenses/BSD-2-Clause -Project URL (from POM): https://github.com/luben/zstd-jni -License (from POM): BSD 2-Clause License - https://opensource.org/licenses/BSD-2-Clause - --------------------------------------------------------------------------------- +This product bundles Google API Common. -Group: com.github.pjfanning Name: jersey-json Version: 1.22.0 -Project URL (from POM): https://github.com/pjfanning/jersey-1.x -License (from POM): CDDL 1.1 - http://glassfish.java.net/public/CDDL+GPL_1_1.html -License (from POM): GPL2 w/ CPE - http://glassfish.java.net/public/CDDL+GPL_1_1.html +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.github.stephenc.jcip Name: jcip-annotations Version: 1.0-1 -Project URL (from POM): http://stephenc.github.com/jcip-annotations -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google GAX. -Group: com.google.android Name: annotations Version: 4.1.1.4 -Project URL (from POM): http://source.android.com/ -License (from POM): Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api Name: api-common Version: 2.52.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/api-common-java/blob/main/LICENSE +This product bundles Google API Client. --------------------------------------------------------------------------------- - -Group: com.google.api Name: gax Version: 2.69.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/gax-java/blob/master/LICENSE - --------------------------------------------------------------------------------- - -Group: com.google.api Name: gax-grpc Version: 2.69.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/gax-java/blob/master/LICENSE +Project URL: https://developers.google.com/api-client-library/java/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api Name: gax-httpjson Version: 2.69.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD-3-Clause - https://github.com/googleapis/gax-java/blob/master/LICENSE - --------------------------------------------------------------------------------- - -Group: com.google.api-client Name: google-api-client Version: 2.7.2 -Project URL (from manifest): https://developers.google.com/api-client-library/java/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: gapic-google-cloud-storage-v2 Version: 2.55.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: grpc-google-cloud-storage-v2 Version: 2.55.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.api.grpc Name: proto-google-cloud-storage-v2 Version: 2.55.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google API gRPC. -Group: com.google.api.grpc Name: proto-google-common-protos Version: 2.60.0 -Project URL (from POM): https://github.com/googleapis/sdk-platform-java -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.api.grpc Name: proto-google-iam-v1 Version: 1.55.0 -Project URL (from POM): https://github.com/googleapis/sdk-platform-java -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google SDK platform for Java. --------------------------------------------------------------------------------- - -Group: com.google.apis Name: google-api-services-storage Version: v1-rev20250718-2.0.0 -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/googleapis/sdk-platform-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.auth Name: google-auth-library-credentials Version: 1.37.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD New license - http://opensource.org/licenses/BSD-3-Clause +This product bundles Google Auth Library. --------------------------------------------------------------------------------- - -Group: com.google.auth Name: google-auth-library-oauth2-http Version: 1.37.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): BSD New license - http://opensource.org/licenses/BSD-3-Clause +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.auto.value Name: auto-value-annotations Version: 1.11.0 -Project URL (from POM): https://github.com/google/auto/tree/main/value -License (from POM): Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Auto Value Annotations. -Group: com.google.cloud Name: google-cloud-core Version: 2.59.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/auto/tree/main/value +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.cloud Name: google-cloud-core-grpc Version: 2.59.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Cloud Core. -Group: com.google.cloud Name: google-cloud-core-http Version: 2.59.0 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.cloud Name: google-cloud-storage Version: 2.55.0 -Project URL (from POM): https://github.com/googleapis/java-storage -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google findbugs jsr305. -Group: com.google.code.findbugs Name: jsr305 Version: 3.0.2 Project URL (from POM): http://findbugs.sourceforge.net/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.code.gson Name: gson Version: 2.12.1 -Project URL (from manifest): https://github.com/google/gson -Manifest License: "Apache-2.0";link="https://www.apache.org/licenses/LICENSE-2.0.txt" (Not packaged) -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google Gson. --------------------------------------------------------------------------------- - -Group: com.google.errorprone Name: error_prone_annotations Version: 2.38.0 -Project URL (from manifest): https://errorprone.info/error_prone_annotations -Manifest License: "Apache 2.0";link="http://www.apache.org/licenses/LICENSE-2.0.txt" (Not packaged) -License (from POM): Apache 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/gson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.guava Name: failureaccess Version: 1.0.2 -Project URL (from manifest): https://github.com/google/guava/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Google Error Prone Annotations. --------------------------------------------------------------------------------- - -Group: com.google.guava Name: guava Version: 33.4.0-jre -Project URL (from manifest): https://github.com/google/guava/ -Project URL (from POM): https://github.com/google/guava -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://errorprone.info/error_prone_annotations +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.guava Name: listenablefuture Version: 9999.0-empty-to-avoid-conflict-with-guava -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Guava. -Group: com.google.http-client Name: google-http-client Version: 1.47.1 -Project URL (from manifest): https://www.google.com/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/google/guava/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.http-client Name: google-http-client-apache-v2 Version: 1.47.1 -Project URL (from manifest): https://www.google.com/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google Http Client. -Group: com.google.http-client Name: google-http-client-appengine Version: 1.47.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://www.google.com/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.google.http-client Name: google-http-client-gson Version: 1.47.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Google j2objc annotations. -Group: com.google.http-client Name: google-http-client-jackson2 Version: 1.47.1 -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.j2objc Name: j2objc-annotations Version: 3.0.0 Project URL (from POM): https://github.com/google/j2objc/ -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.oauth-client Name: google-oauth-client Version: 1.39.0 -Project URL (from manifest): https://www.google.com/ -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: com.google.protobuf Name: protobuf-java Version: 4.29.4 -Project URL (from manifest): https://developers.google.com/protocol-buffers/ -License (from POM): BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause - --------------------------------------------------------------------------------- - -Group: com.google.protobuf Name: protobuf-java-util Version: 4.29.4 -Project URL (from manifest): https://developers.google.com/protocol-buffers/ -License (from POM): BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause - --------------------------------------------------------------------------------- - -Group: com.google.re2j Name: re2j Version: 1.8 -Project URL (from POM): http://github.com/google/re2j -License (from POM): Go License - https://golang.org/LICENSE - --------------------------------------------------------------------------------- - -Group: com.jcraft Name: jsch Version: 0.1.55 -Project URL (from POM): http://www.jcraft.com/jsch/ -License (from POM): Revised BSD - http://www.jcraft.com/jsch/LICENSE.txt - --------------------------------------------------------------------------------- - -Group: com.microsoft.azure Name: msal4j Version: 1.21.0 -Project URL (from manifest): https://github.com/AzureAD/microsoft-authentication-library-for-java -Manifest License: "MIT License" (Not packaged) -Project URL (from POM): https://github.com/AzureAD/microsoft-authentication-library-for-java -License (from POM): MIT License +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Google protobuf. + +Project URL: https://developers.google.com/protocol-buffers/ +License: BSD 3-Clause +| Copyright 2008 Google Inc. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +| +| Code generated by the Protocol Buffer compiler is owned by the owner +| of the input file used when generating it. This code is not +| standalone and requires a support library to be linked with it. This +| support library is itself covered by the above license. + +-------------------------------------------------------------------------------- + +This product bundles Google re2j. + +Project URL: http://github.com/google/re2j +License: Go License +| This is a work derived from Russ Cox's RE2 in Go, whose license +| http://golang.org/LICENSE is as follows: +| +| Copyright (c) 2009 The Go Authors. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in +| the documentation and/or other materials provided with the +| distribution. +| +| * Neither the name of Google Inc. nor the names of its contributors +| may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles JCraft JSch. + +Project URL: http://www.jcraft.com/jsch/ +License: BSD 3-Clause +| Copyright (c) 2002-2015 Atsuhiko Yamanaka, JCraft,Inc. +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| 1. Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in +| the documentation and/or other materials provided with the distribution. +| +| 3. The names of the authors may not be used to endorse or promote products +| derived from this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, +| INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +| FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT, +| INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, +| INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +| OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +| EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: com.microsoft.azure Name: msal4j-persistence-extension Version: 1.3.0 -Project URL (from POM): https://github.com/AzureAD/microsoft-authentication-library-for-java -License (from POM): MIT License +This product bundles Microsoft Authentication Library for Java. --------------------------------------------------------------------------------- - -Group: com.nimbusds Name: content-type Version: 2.3 -Project URL (from manifest): https://connect2id.com -Project URL (from POM): https://bitbucket.org/connect2id/nimbus-content-type -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/AzureAD/microsoft-authentication-library-for-java +License: MIT +| MIT License +| +| Copyright (c) Microsoft Corporation. All rights reserved. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE -------------------------------------------------------------------------------- -Group: com.nimbusds Name: lang-tag Version: 1.7 -Project URL (from manifest): https://connect2id.com/ -Project URL (from POM): https://bitbucket.org/connect2id/nimbus-language-tags -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Nimbus Content Type. -Group: com.nimbusds Name: nimbus-jose-jwt Version: 10.0.1 -Project URL (from manifest): https://connect2id.com -Project URL (from POM): https://bitbucket.org/connect2id/nimbus-jose-jwt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://bitbucket.org/connect2id/nimbus-content-type +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: com.nimbusds Name: oauth2-oidc-sdk Version: 11.23 -Project URL (from manifest): https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions -Manifest License: "Apache License, version 2.0";link="https://www.apache.org/licenses/LICENSE-2.0.html" (Not packaged) -Project URL (from POM): https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions -License (from POM): Apache License, version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.html - --------------------------------------------------------------------------------- +This product bundles Nimbus Lang Tag. -Group: com.sun.xml.bind Name: jaxb-impl Version: 2.2.3-1 -Project URL (from POM): http://jaxb.java.net/ -License (from POM): CDDL 1.1 - https://glassfish.java.net/public/CDDL+GPL_1_1.html -License (from POM): GPL2 w/ CPE - https://glassfish.java.net/public/CDDL+GPL_1_1.html +Project URL: https://bitbucket.org/connect2id/nimbus-language-tags +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: commons-beanutils Name: commons-beanutils Version: 1.11.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-beanutils/ -Project URL (from POM): https://commons.apache.org/proper/commons-beanutils/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Nimbus Jose JWT. -Group: commons-cli Name: commons-cli Version: 1.5.0 -Project URL (from manifest): http://commons.apache.org/cli/ -Project URL (from POM): http://commons.apache.org/cli/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://bitbucket.org/connect2id/nimbus-jose-jwt +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: commons-codec Name: commons-codec Version: 1.19.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-codec/ -Project URL (from POM): https://commons.apache.org/proper/commons-codec/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Nimbus OAuth2 OIDC SDK. --------------------------------------------------------------------------------- - -Group: commons-collections Name: commons-collections Version: 3.2.2 -Project URL (from manifest): http://commons.apache.org/collections/ -Project URL (from POM): http://commons.apache.org/collections/ -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://bitbucket.org/connect2id/oauth-2.0-sdk-with-openid-connect-extensions +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: commons-io Name: commons-io Version: 2.20.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-io/ -Project URL (from POM): https://commons.apache.org/proper/commons-io/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles JAXB. --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-lang3 Version: 3.18.0 -Project URL (from manifest): http://commons.apache.org/lang/ -Project URL (from POM): http://commons.apache.org/lang/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://jaxb.java.net/ +License: CDDL 1.1 - https://glassfish.java.net/public/CDDL+GPL_1_1.html -------------------------------------------------------------------------------- -Group: commons-logging Name: commons-logging Version: 1.3.5 -Project URL (from manifest): http://commons.apache.org/proper/commons-logging/ -Project URL (from POM): http://commons.apache.org/proper/commons-logging/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Apache Commons Beanutils. -Group: commons-net Name: commons-net Version: 3.9.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-net/ -Project URL (from POM): https://commons.apache.org/proper/commons-net/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://commons.apache.org/proper/commons-beanutils/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: commons-pool Name: commons-pool Version: 1.6 -Project URL (from manifest): http://commons.apache.org/pool/ -Project URL (from POM): http://commons.apache.org/pool/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Apache Commons CLI. -Group: dev.failsafe Name: failsafe Version: 3.3.2 -Project URL (from POM): https://github.com/failsafe-lib/failsafe -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: http://commons.apache.org/cli/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: dnsjava Name: dnsjava Version: 3.6.1 -Project URL (from POM): http://www.dnsjava.org -License (from POM): BSD 2-Clause license - http://opensource.org/licenses/BSD-2-Clause - --------------------------------------------------------------------------------- +This product bundles Apache Commons Codec. -Group: io.airlift Name: aircompressor Version: 2.0.2 -Project URL (from POM): https://github.com/airlift/aircompressor -License (from POM): Apache License 2.0 - https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://commons.apache.org/proper/commons-codec/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.dropwizard.metrics Name: metrics-core Version: 3.2.4 -License (from POM): Apache License 2.0 - http://www.apache.org/licenses/LICENSE-2.0.html +This product bundles Apache Commons Collections. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-alts Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-api Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://commons.apache.org/collections/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-auth Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Apache Commons IO. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-context Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://commons.apache.org/proper/commons-io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-core Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Apache Commons Lang. --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-googleapis Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://commons.apache.org/lang/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-grpclb Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-inprocess Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-netty-shaded Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-protobuf Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Logging. -Group: io.grpc Name: grpc-protobuf-lite Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://commons.apache.org/proper/commons-logging/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-rls Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Net. -Group: io.grpc Name: grpc-services Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: https://commons.apache.org/proper/commons-net/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-stub Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Pool. -Group: io.grpc Name: grpc-util Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +Project URL: http://commons.apache.org/pool/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.grpc Name: grpc-xds Version: 1.71.0 -Project URL (from POM): https://github.com/grpc/grpc-java -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles failsafe. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-buffer Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/failsafe-lib/failsafe +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles DNS Java. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-codec-dns Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: http://www.dnsjava.org +License: BSD 3-Clause +| Copyright (c) 1998-2019, Brian Wellington +| Copyright (c) 2005 VeriSign. All rights reserved. +| Copyright (c) 2019-2023, dnsjava authors +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| 3. Neither the name of the copyright holder nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-http Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Airlift Aircompressor. -Group: io.netty Name: netty-codec-http2 Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-codec-socks Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Codahale Metrics. -Group: io.netty Name: netty-common Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-handler Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles gRPC. -Group: io.netty Name: netty-handler-proxy Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/grpc/grpc-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-resolver Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Netty. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-resolver-dns Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://netty.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-resolver-dns-classes-macos Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Google FlatBuffers. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-resolver-dns-native-macos Version: 4.1.112.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://google.github.io/flatbuffers/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-tcnative-boringssl-static Version: 2.0.70.Final -Project URL (from manifest): https://netty.io/ -Project URL (from POM): https://github.com/netty/netty-tcnative/netty-tcnative-boringssl-static/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles OpenCensus. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-tcnative-classes Version: 2.0.70.Final -Project URL (from manifest): https://netty.io/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/census-instrumentation/opencensus-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles Perfmark. -Group: io.netty Name: netty-transport-classes-epoll Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/perfmark/perfmark +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-classes-kqueue Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- +This product bundles org.json. -Group: io.netty Name: netty-transport-native-epoll Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/douglascrockford/JSON-java +License: Public Domain - https://github.com/stleary/JSON-java/blob/master/LICENSE -------------------------------------------------------------------------------- -Group: io.netty Name: netty-transport-native-kqueue Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +This product bundles Reactor Core. --------------------------------------------------------------------------------- - -Group: io.netty Name: netty-transport-native-unix-common Version: 4.1.124.Final -Project URL (from manifest): https://netty.io/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/reactor/reactor-core +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.opencensus Name: opencensus-api Version: 0.31.1 -Project URL (from POM): https://github.com/census-instrumentation/opencensus-java -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles Reactor Netty. --------------------------------------------------------------------------------- - -Group: io.opencensus Name: opencensus-contrib-http-util Version: 0.31.1 -Project URL (from POM): https://github.com/census-instrumentation/opencensus-java -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/reactor/reactor-netty +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: io.perfmark Name: perfmark-api Version: 0.27.0 -Project URL (from POM): https://github.com/perfmark/perfmark -License (from POM): Apache 2.0 - https://opensource.org/licenses/Apache-2.0 +This product bundles Jakarta Activation API. --------------------------------------------------------------------------------- - -Group: io.projectreactor Name: reactor-core Version: 3.4.41 -Project URL (from POM): https://github.com/reactor/reactor-core -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://www.eclipse.org +License: Eclipse Public License v. 2.0 - https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt -------------------------------------------------------------------------------- -Group: io.projectreactor.netty Name: reactor-netty-core Version: 1.0.48 -Project URL (from POM): https://github.com/reactor/reactor-netty -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- +This product bundles Javax Annotation API. -Group: io.projectreactor.netty Name: reactor-netty-http Version: 1.0.48 -Project URL (from POM): https://github.com/reactor/reactor-netty -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://javaee.github.io/glassfish +License: CDDL 1.1 - https://github.com/javaee/javax.annotation/blob/master/LICENSE -------------------------------------------------------------------------------- -Group: jakarta.activation Name: jakarta.activation-api Version: 1.2.1 -Project URL (from manifest): https://www.eclipse.org -License (from POM): EDL 1.0 - http://www.eclipse.org/org/documents/edl-v10.php -License (from POM): Eclipse Public License v. 2.0 - https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt -License (from POM): GNU General Public License, version 2 with the GNU Classpath Exception - https://www.gnu.org/software/classpath/license.html - --------------------------------------------------------------------------------- +This product bundles Javax Servlet API. -Group: javax.annotation Name: javax.annotation-api Version: 1.3.2 -Project URL (from manifest): https://javaee.github.io/glassfish -Project URL (from POM): http://jcp.org/en/jsr/detail?id=250 -License (from POM): CDDL + GPLv2 with classpath exception - https://github.com/javaee/javax.annotation/blob/master/LICENSE +Project URL: http://servlet-spec.java.net +License: CDDL 1.1 - https://glassfish.dev.java.net/nonav/public/CDDL+GPL.html -------------------------------------------------------------------------------- -Group: javax.servlet Name: javax.servlet-api Version: 3.1.0 -Project URL (from manifest): https://glassfish.dev.java.net -Project URL (from POM): http://servlet-spec.java.net -License (from POM): CDDL + GPLv2 with classpath exception - https://glassfish.dev.java.net/nonav/public/CDDL+GPL.html +This product bundles Javax Servlet JSP. --------------------------------------------------------------------------------- - -Group: javax.servlet.jsp Name: jsp-api Version: 2.1 Project URL: https://github.com/javaee/javaee-jsp-api -License (from POM): CDDL + GPLv2 with classpath exception - https://github.com/javaee/javaee-jsp-api/blob/master/LICENSE - --------------------------------------------------------------------------------- - -Group: javax.xml.bind Name: jaxb-api Version: 2.2.2 -Project URL (from POM): https://jaxb.dev.java.net/ -License (from POM): CDDL 1.1 - https://glassfish.dev.java.net/public/CDDL+GPL_1_1.html -License (from POM): GPL2 w/ CPE - https://glassfish.dev.java.net/public/CDDL+GPL_1_1.html - --------------------------------------------------------------------------------- - -Group: javax.xml.stream Name: stax-api Version: 1.0-2 -License (from POM): COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0 - http://www.sun.com/cddl/cddl.html -License (from POM): GNU General Public Library - http://www.gnu.org/licenses/gpl.txt - --------------------------------------------------------------------------------- - -Group: net.java.dev.jna Name: jna Version: 5.13.0 -Project URL (from POM): https://github.com/java-native-access/jna -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): LGPL-2.1-or-later - https://www.gnu.org/licenses/old-licenses/lgpl-2.1 - --------------------------------------------------------------------------------- - -Group: net.java.dev.jna Name: jna-platform Version: 5.13.0 -Project URL (from POM): https://github.com/java-native-access/jna -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): LGPL-2.1-or-later - https://www.gnu.org/licenses/old-licenses/lgpl-2.1 - --------------------------------------------------------------------------------- - -Group: net.minidev Name: accessors-smart Version: 2.5.2 -Project URL (from manifest): https://urielch.github.io/ -Project URL (from POM): https://urielch.github.io/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: net.minidev Name: json-smart Version: 2.5.2 -Project URL (from manifest): https://urielch.github.io/ -Project URL (from POM): https://urielch.github.io/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.avro Name: avro Version: 1.12.0 -Project URL (from manifest): https://www.apache.org/ -Project URL (from POM): https://avro.apache.org -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-compress Version: 1.28.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-compress/ -Project URL (from POM): https://commons.apache.org/proper/commons-compress/ -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-math3 Version: 3.6.1 -Project URL (from manifest): http://commons.apache.org/math/ -Project URL (from POM): http://commons.apache.org/math/ -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.commons Name: commons-text Version: 1.10.0 -Project URL (from manifest): https://commons.apache.org/proper/commons-text -Project URL (from POM): https://commons.apache.org/proper/commons-text -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-annotations Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop Name: hadoop-common Version: 3.4.1 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop.thirdparty Name: hadoop-shaded-guava Version: 1.4.0 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.hadoop.thirdparty Name: hadoop-shaded-protobuf_3_25 Version: 1.3.0 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents Name: httpclient Version: 4.5.14 -Project URL (from POM): http://hc.apache.org/httpcomponents-client -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents Name: httpcore Version: 4.4.16 -Project URL (from POM): http://hc.apache.org/httpcomponents-core-ga -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents.client5 Name: httpclient5 Version: 5.5 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents.core5 Name: httpcore5 Version: 5.3.4 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.httpcomponents.core5 Name: httpcore5-h2 Version: 5.3.4 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.orc Name: orc-core Version: 1.9.7 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.orc Name: orc-shims Version: 1.9.7 -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-avro Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-column Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-common Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-encoding Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-format-structures Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org/ -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-hadoop Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.apache.parquet Name: parquet-jackson Version: 1.16.0 -Project URL (from POM): https://parquet.apache.org -License (from POM): Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.checkerframework Name: checker-qual Version: 3.49.0 -Manifest License: MIT (Not packaged) -Project URL (from POM): https://checkerframework.org/ -License (from POM): The MIT License - http://opensource.org/licenses/MIT - --------------------------------------------------------------------------------- - -Group: org.codehaus.jettison Name: jettison Version: 1.5.4 -Project URL (from POM): https://github.com/jettison-json/jettison -License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0 +License: CDDL 1.1 - https://github.com/javaee/javaee-jsp-api/blob/master/LICENSE -------------------------------------------------------------------------------- -Group: org.codehaus.mojo Name: animal-sniffer-annotations Version: 1.24 -License (from POM): MIT license - https://spdx.org/licenses/MIT.txt -License (from POM): The Apache Software License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.codehaus.woodstox Name: stax2-api Version: 4.2.2 -Project URL (from manifest): http://github.com/FasterXML/stax2-api -Project URL (from POM): http://github.com/FasterXML/stax2-api -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -License (from POM): The BSD 2-Clause License - http://www.opensource.org/licenses/bsd-license.php - --------------------------------------------------------------------------------- - -Group: org.conscrypt Name: conscrypt-openjdk-uber Version: 2.5.2 -Project URL (from POM): https://conscrypt.org/ -License (from POM): Apache 2 - https://www.apache.org/licenses/LICENSE-2.0 - --------------------------------------------------------------------------------- - -Group: org.ow2.asm Name: asm Version: 9.7.1 -Project URL (from manifest): http://asm.ow2.org -Manifest License: BSD-3-Clause;link=https://asm.ow2.io/LICENSE.txt (Not packaged) -Project URL (from POM): http://asm.ow2.io/ -License (from POM): BSD-3-Clause - https://asm.ow2.io/license.html -License (from POM): The Apache Software License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.reactivestreams Name: reactive-streams Version: 1.0.4 -Project URL (from manifest): http://reactive-streams.org -Project URL (from POM): http://www.reactive-streams.org/ -License (from POM): MIT-0 - https://spdx.org/licenses/MIT-0.html - --------------------------------------------------------------------------------- - -Group: org.roaringbitmap Name: RoaringBitmap Version: 1.3.0 -Project URL (from POM): https://github.com/RoaringBitmap/RoaringBitmap -License (from POM): Apache 2 - http://www.apache.org/licenses/LICENSE-2.0.txt - --------------------------------------------------------------------------------- - -Group: org.slf4j Name: slf4j-api Version: 2.0.17 -Project URL (from manifest): http://www.slf4j.org -Project URL (from POM): http://www.slf4j.org -License (from POM): MIT License - http://www.opensource.org/licenses/mit-license.php - --------------------------------------------------------------------------------- - -Group: org.threeten Name: threeten-extra Version: 1.8.0 -Project URL (from manifest): https://www.threeten.org -Project URL (from POM): https://www.threeten.org/threeten-extra -License (from POM): BSD 3-clause - https://raw.githubusercontent.com/ThreeTen/threeten-extra/master/LICENSE.txt - --------------------------------------------------------------------------------- - -Group: org.threeten Name: threetenbp Version: 1.7.0 -Project URL (from manifest): https://www.threeten.org -Project URL (from POM): https://www.threeten.org/threetenbp -License (from POM): BSD-3-Clause - https://raw.githubusercontent.com/ThreeTen/threetenbp/main/LICENSE.txt - --------------------------------------------------------------------------------- - -Group: org.xerial.snappy Name: snappy-java Version: 1.1.10.8 -Project URL (from manifest): http://www.xerial.org/ -Project URL (from POM): https://github.com/xerial/snappy-java -License (from POM): Apache-2.0 - https://www.apache.org/licenses/LICENSE-2.0.html - --------------------------------------------------------------------------------- +This product bundles JAXB. -Group: software.amazon.awssdk Name: annotations Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://jaxb.dev.java.net/ +License: CDDL 1.1 - https://glassfish.dev.java.net/public/CDDL+GPL_1_1.html -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: apache-client Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Stax API. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: arns Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +License: CDDL 1.0 - http://www.sun.com/cddl/cddl.html -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: auth Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles JNA. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: aws-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/java-native-access/jna +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: aws-json-protocol Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Accessors Smart. -Group: software.amazon.awssdk Name: aws-query-protocol Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://urielch.github.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: aws-xml-protocol Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Json Smart. -Group: software.amazon.awssdk Name: checksums Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://urielch.github.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: checksums-spi Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Avro. -Group: software.amazon.awssdk Name: crt-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://avro.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: dynamodb Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache Arrow. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: endpoints-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://arrow.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: glue Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache Commons Compress. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: http-auth Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://commons.apache.org/proper/commons-compress/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: http-auth-aws Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Math. -Group: software.amazon.awssdk Name: http-auth-aws-crt Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: http://commons.apache.org/math/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: http-auth-aws-eventstream Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Commons Text. -Group: software.amazon.awssdk Name: http-auth-spi Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://commons.apache.org/proper/commons-text +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: http-client-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Hadoop (annotations and common). -Group: software.amazon.awssdk Name: iam Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://hadoop.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: identity-spi Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache HttpComponents (core and client). --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: json-utils Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: kms Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Apache ORC. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: lakeformation Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://orc.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: metrics-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Apache Parquet. -Group: software.amazon.awssdk Name: netty-nio-client Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://parquet.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: profiles Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles checkerframework checker-qual. -Group: software.amazon.awssdk Name: protocol-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://checkerframework.org/ +License: MIT +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: regions Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles Jettison. -Group: software.amazon.awssdk Name: retries Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/jettison-json/jettison +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: retries-spi Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles JTS Topology Suite. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: s3 Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/locationtech/jts +License: Eclipse Public License v. 2.0 - https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: sdk-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Animal Sniffer Annotations. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: sso Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +License: MIT +| The MIT License +| +| Copyright (c) 2009 codehaus.org. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: sts Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +This product bundles Stax2 API. --------------------------------------------------------------------------------- - -Group: software.amazon.awssdk Name: third-party-jackson-core Version: 2.33.0 -Project URL (from POM): https://aws.amazon.com/sdkforjava -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: http://github.com/FasterXML/stax2-api +License: BSD 2-Clause +| BSD 2-Clause License +| +| Copyright (c) 2008+, FasterXML, LLC +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Conscrypt (openjdk-uber). + +Project URL: https://conscrypt.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles ASM. + +Project URL: http://asm.ow2.org +License: BSD 3-Clause +| ASM: a very small and fast Java bytecode manipulation framework +| Copyright (c) 2000-2011 INRIA, France Telecom +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| 1. Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| 2. Redistributions in binary form must reproduce the above copyright +| notice, this list of conditions and the following disclaimer in the +| documentation and/or other materials provided with the distribution. +| 3. Neither the name of the copyright holders nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +| THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Reactive Streams. + +Project URL: http://reactive-streams.org +License: MIT +| MIT No Attribution +| +| Copyright 2014 Reactive Streams +| +| Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles RoaringBitmap. + +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles SLF4J. + +Project URL: http://www.slf4j.org +License: MIT +| Copyright (c) 2004-2025 QOS.ch +| All rights reserved. +| +| Permission is hereby granted, free of charge, to any person obtaining +| a copy of this software and associated documentation files (the +| "Software"), to deal in the Software without restriction, including +| without limitation the rights to use, copy, modify, merge, publish, +| distribute, sublicense, and/or sell copies of the Software, and to +| permit persons to whom the Software is furnished to do so, subject to +| the following conditions: +| +| The above copyright notice and this permission notice shall be +| included in all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +| EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +| MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +| NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +| LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +| OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +| WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles ThreeTen Extra. + +Project URL: https://www.threeten.org/threeten-extra +License: BSD 3-Clause +| All rights reserved. +| +| * Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| * Neither the name of JSR-310 nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles ThreeTen BP. + +Project URL: https://www.threeten.org/threetenbp +License: BSD 3-Clause +| Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| * Neither the name of JSR-310 nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Snappy Java. + +Project URL: https://github.com/xerial/snappy-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles AWS SDK for Java. + +Project URL: https://aws.amazon.com/sdkforjava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles AWS EventStream for Java. + +Project URL: https://github.com/awslabs/aws-eventstream-java +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: utils Version: 2.33.0 -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 - --------------------------------------------------------------------------------- +This product bundles MongoDB BSON. -Group: software.amazon.eventstream Name: eventstream Version: 1.0.1 -Project URL (from POM): https://github.com/awslabs/aws-eventstream-java -License (from POM): Apache License, Version 2.0 - https://aws.amazon.com/apache2.0 +Project URL: https://github.com/mongodb/mongo-java-driver +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -Group: org.mongodb Name: bson Version: 4.11.5 -Project URL (from POM): https://github.com/mongodb/mongo-java-driver -License (from POM): The Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt +This product bundles JCTools. --------------------------------------------------------------------------------- +Project URL: https://github.com/JCTools/JCTools +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 diff --git a/kafka-connect/kafka-connect-runtime/main/NOTICE b/kafka-connect/kafka-connect-runtime/main/NOTICE index f2be4788082c..c85873aa6497 100644 --- a/kafka-connect/kafka-connect-runtime/main/NOTICE +++ b/kafka-connect/kafka-connect-runtime/main/NOTICE @@ -1,14 +1,14 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -This project includes code from Kite, developed at Cloudera, Inc. with -the following copyright notice: +This product includes code from Kite, developed at Cloudera, Inc. with +the following in its NOTICE file: | Copyright 2013 Cloudera Inc. | @@ -26,13 +26,7 @@ the following copyright notice: -------------------------------------------------------------------------------- -This binary artifact contains code from the following projects: - --------------------------------------------------------------------------------- - -Group: io.grpc Name: grpc-netty-shaded Version: 1.71.0 - -Notice: +This product includes Netty (via gRPC) with the following in its NOTICE file: | The Netty Project | ================= | @@ -85,86 +79,10 @@ Notice: | * HOMEPAGE: | * https://boringssl.googlesource.com/boringssl/ --------------------------------------------------------------------------------- - -Group: jakarta.activation Name: jakarta.activation-api Version: 1.2.1 - -Notice: -| # Notices for Jakarta Activation -| -| This content is produced and maintained by Jakarta Activation project. -| -| * Project home: https://projects.eclipse.org/projects/ee4j.jaf -| -| ## Copyright -| -| All content is the property of the respective authors or their employers. For -| more information regarding authorship of content, please consult the listed -| source code repository logs. -| -| ## Declared Project Licenses -| -| This program and the accompanying materials are made available under the terms -| of the Eclipse Distribution License v. 1.0, -| which is available at http://www.eclipse.org/org/documents/edl-v10.php. -| -| SPDX-License-Identifier: BSD-3-Clause -| -| ## Source Code -| -| The project maintains the following source code repositories: -| -| * https://github.com/eclipse-ee4j/jaf -| -| ## Third-party Content -| -| This project leverages the following third party content. -| -| JUnit (4.12) -| -| * License: Eclipse Public License -------------------------------------------------------------------------------- -Group: software.amazon.awssdk Name: annotations Version: 2.33.0 -Group: software.amazon.awssdk Name: apache-client Version: 2.33.0 -Group: software.amazon.awssdk Name: arns Version: 2.33.0 -Group: software.amazon.awssdk Name: auth Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-core Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-json-protocol Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-query-protocol Version: 2.33.0 -Group: software.amazon.awssdk Name: aws-xml-protocol Version: 2.33.0 -Group: software.amazon.awssdk Name: checksums Version: 2.33.0 -Group: software.amazon.awssdk Name: checksums-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: crt-core Version: 2.33.0 -Group: software.amazon.awssdk Name: dynamodb Version: 2.33.0 -Group: software.amazon.awssdk Name: endpoints-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: glue Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-aws Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-aws-crt Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-aws-eventstream Version: 2.33.0 -Group: software.amazon.awssdk Name: http-auth-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: http-client-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: iam Version: 2.33.0 -Group: software.amazon.awssdk Name: identity-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: json-utils Version: 2.33.0 -Group: software.amazon.awssdk Name: kms Version: 2.33.0 -Group: software.amazon.awssdk Name: lakeformation Version: 2.33.0 -Group: software.amazon.awssdk Name: metrics-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: netty-nio-client Version: 2.33.0 -Group: software.amazon.awssdk Name: profiles Version: 2.33.0 -Group: software.amazon.awssdk Name: protocol-core Version: 2.33.0 -Group: software.amazon.awssdk Name: regions Version: 2.33.0 -Group: software.amazon.awssdk Name: retries Version: 2.33.0 -Group: software.amazon.awssdk Name: retries-spi Version: 2.33.0 -Group: software.amazon.awssdk Name: s3 Version: 2.33.0 -Group: software.amazon.awssdk Name: sdk-core Version: 2.33.0 -Group: software.amazon.awssdk Name: sso Version: 2.33.0 -Group: software.amazon.awssdk Name: sts Version: 2.33.0 -Group: software.amazon.awssdk Name: utils Version: 2.33.0 - -Notice: +This product bundles AWS SDK for Java with the following in its NOTICE file: | AWS SDK for Java 2.0 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | @@ -193,49 +111,7 @@ Notice: -------------------------------------------------------------------------------- -Group: jakarta.activation Name: jakarta.activation-api Version: 1.2.1 - -Notice: -| # Notices for Eclipse Project for JAF -| -| This content is produced and maintained by the Eclipse Project for JAF project. -| -| * Project home: https://projects.eclipse.org/projects/ee4j.jaf -| -| ## Copyright -| -| All content is the property of the respective authors or their employers. For -| more information regarding authorship of content, please consult the listed -| source code repository logs. -| -| ## Declared Project Licenses -| -| This program and the accompanying materials are made available under the terms -| of the Eclipse Distribution License v. 1.0, -| which is available at http://www.eclipse.org/org/documents/edl-v10.php. -| -| SPDX-License-Identifier: BSD-3-Clause -| -| ## Source Code -| -| The project maintains the following source code repositories: -| -| * https://github.com/eclipse-ee4j/jaf -| -| ## Third-party Content -| -| This project leverages the following third party content. -| -| JUnit (4.12) -| -| * License: Eclipse Public License - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-core Version: 2.19.2 -Group: software.amazon.awssdk Name: third-party-jackson-core Version: 2.33.0 - -Notice: +This product bundles Jackson JSON Processor with the following in its NOTICE file: | # Jackson JSON processor | | Jackson is a high-performance, Free/Open Source JSON processing library. @@ -268,56 +144,3 @@ Notice: | | See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser | and the licenses and copyrights that apply to that code. - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.datatype Name: jackson-datatype-jsr310 Version: 2.19.2 - -Notice: -| # Jackson JSON processor -| -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers. -| -| ## Licensing -| -| Jackson components are licensed under Apache (Software) License, version 2.0, -| as per accompanying LICENSE file. -| -| ## Credits -| -| A list of contributors may be found from CREDITS file, which is included -| in some artifacts (usually source distributions); but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- - -Group: com.fasterxml.jackson.core Name: jackson-annotations Version: 2.19.2 -Group: com.fasterxml.jackson.core Name: jackson-databind Version: 2.19.2 - -Notice: -| # Jackson JSON processor -| -| Jackson is a high-performance, Free/Open Source JSON processing library. -| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has -| been in development since 2007. -| It is currently developed by a community of developers. -| -| ## Copyright -| -| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) -| -| ## Licensing -| -| Jackson 2.x core and extension components are licensed under Apache License 2.0 -| To find the details that apply to this artifact see the accompanying LICENSE file. -| -| ## Credits -| -| A list of contributors may be found from CREDITS(-2.x) file, which is included -| in some artifacts (usually source distributions); but is always available -| from the source code management (SCM) system project uses. - --------------------------------------------------------------------------------- diff --git a/kafka-connect/kafka-connect-runtime/runtime-deps.txt b/kafka-connect/kafka-connect-runtime/runtime-deps.txt new file mode 100644 index 000000000000..259de75aafc3 --- /dev/null +++ b/kafka-connect/kafka-connect-runtime/runtime-deps.txt @@ -0,0 +1,237 @@ +com.azure:azure-core-http-netty:1.16.3 +com.azure:azure-core:1.57.1 +com.azure:azure-identity:1.18.2 +com.azure:azure-json:1.5.1 +com.azure:azure-storage-blob:12.33.3 +com.azure:azure-storage-common:12.32.2 +com.azure:azure-storage-file-datalake:12.26.3 +com.azure:azure-storage-internal-avro:12.18.2 +com.azure:azure-xml:1.2.1 +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.21.3 +com.fasterxml.jackson.core:jackson-databind:2.21.3 +com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.21.3 +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.3 +com.fasterxml.woodstox:woodstox-core:6.7.0 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.github.luben:zstd-jni:1.5.7-3 +com.github.pjfanning:jersey-json:1.22.0 +com.google.android:annotations:4.1.1.4 +com.google.api-client:google-api-client:2.7.2 +com.google.api.grpc:gapic-google-cloud-storage-v2:2.67.0 +com.google.api.grpc:grpc-google-cloud-bigquerystorage-v1:3.27.0 +com.google.api.grpc:grpc-google-cloud-bigquerystorage-v1beta1:0.199.0 +com.google.api.grpc:grpc-google-cloud-bigquerystorage-v1beta2:0.199.0 +com.google.api.grpc:grpc-google-cloud-storage-v2:2.67.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1:3.27.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1alpha:3.27.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1beta1:0.199.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1beta2:0.199.0 +com.google.api.grpc:proto-google-cloud-bigquerystorage-v1beta:3.27.0 +com.google.api.grpc:proto-google-cloud-monitoring-v3:3.92.0 +com.google.api.grpc:proto-google-cloud-storage-v2:2.67.0 +com.google.api.grpc:proto-google-common-protos:2.70.0 +com.google.api.grpc:proto-google-iam-v1:1.65.0 +com.google.api:api-common:2.62.0 +com.google.api:gax-grpc:2.79.0 +com.google.api:gax-httpjson:2.79.0 +com.google.api:gax:2.79.0 +com.google.apis:google-api-services-bigquery:v2-rev20251012-2.0.0 +com.google.apis:google-api-services-storage:v1-rev20260204-2.0.0 +com.google.auth:google-auth-library-credentials:1.46.0 +com.google.auth:google-auth-library-oauth2-http:1.46.0 +com.google.auto.value:auto-value-annotations:1.11.0 +com.google.cloud.opentelemetry:detector-resources-support:0.33.0 +com.google.cloud.opentelemetry:exporter-metrics:0.33.0 +com.google.cloud.opentelemetry:shared-resourcemapping:0.33.0 +com.google.cloud:google-cloud-bigquery:2.65.0 +com.google.cloud:google-cloud-bigquerystorage:3.27.0 +com.google.cloud:google-cloud-core-grpc:2.69.0 +com.google.cloud:google-cloud-core-http:2.69.0 +com.google.cloud:google-cloud-core:2.69.0 +com.google.cloud:google-cloud-monitoring:3.92.0 +com.google.cloud:google-cloud-storage:2.67.0 +com.google.code.findbugs:jsr305:3.0.2 +com.google.code.gson:gson:2.12.1 +com.google.errorprone:error_prone_annotations:2.45.0 +com.google.flatbuffers:flatbuffers-java:24.3.25 +com.google.guava:failureaccess:1.0.3 +com.google.guava:guava:33.5.0-jre +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava +com.google.http-client:google-http-client-apache-v2:2.1.0 +com.google.http-client:google-http-client-appengine:2.1.0 +com.google.http-client:google-http-client-gson:2.1.0 +com.google.http-client:google-http-client-jackson2:2.1.0 +com.google.http-client:google-http-client:2.1.0 +com.google.j2objc:j2objc-annotations:3.1 +com.google.oauth-client:google-oauth-client:1.39.0 +com.google.protobuf:protobuf-java-util:4.33.2 +com.google.protobuf:protobuf-java:4.33.2 +com.google.re2j:re2j:1.8 +com.jcraft:jsch:0.1.55 +com.microsoft.azure:msal4j-persistence-extension:1.3.0 +com.microsoft.azure:msal4j:1.23.1 +com.sun.xml.bind:jaxb-impl:2.2.3-1 +commons-cli:commons-cli:1.9.0 +commons-codec:commons-codec:1.19.0 +commons-io:commons-io:2.20.0 +commons-logging:commons-logging:1.2 +commons-net:commons-net:3.9.0 +commons-pool:commons-pool:1.6 +dev.failsafe:failsafe:3.3.2 +dnsjava:dnsjava:3.6.1 +io.airlift:aircompressor:2.0.3 +io.dropwizard.metrics:metrics-core:3.2.4 +io.grpc:grpc-alts:1.80.0 +io.grpc:grpc-api:1.80.0 +io.grpc:grpc-auth:1.80.0 +io.grpc:grpc-context:1.80.0 +io.grpc:grpc-core:1.80.0 +io.grpc:grpc-googleapis:1.80.0 +io.grpc:grpc-grpclb:1.80.0 +io.grpc:grpc-inprocess:1.80.0 +io.grpc:grpc-netty-shaded:1.81.0 +io.grpc:grpc-opentelemetry:1.80.0 +io.grpc:grpc-protobuf-lite:1.80.0 +io.grpc:grpc-protobuf:1.80.0 +io.grpc:grpc-rls:1.80.0 +io.grpc:grpc-services:1.80.0 +io.grpc:grpc-stub:1.80.0 +io.grpc:grpc-util:1.80.0 +io.grpc:grpc-xds:1.80.0 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-codec-base:4.2.13.Final +io.netty:netty-codec-compression:4.2.13.Final +io.netty:netty-codec-dns:4.2.13.Final +io.netty:netty-codec-http2:4.2.13.Final +io.netty:netty-codec-http:4.2.13.Final +io.netty:netty-codec-marshalling:4.2.13.Final +io.netty:netty-codec-protobuf:4.2.13.Final +io.netty:netty-codec-socks:4.2.13.Final +io.netty:netty-codec:4.2.13.Final +io.netty:netty-common:4.2.13.Final +io.netty:netty-handler-proxy:4.2.13.Final +io.netty:netty-handler:4.2.13.Final +io.netty:netty-resolver-dns-classes-macos:4.2.13.Final +io.netty:netty-resolver-dns-native-macos:4.2.13.Final +io.netty:netty-resolver-dns:4.2.13.Final +io.netty:netty-resolver:4.2.13.Final +io.netty:netty-tcnative-boringssl-static:2.0.74.Final +io.netty:netty-tcnative-classes:2.0.74.Final +io.netty:netty-transport-classes-epoll:4.2.13.Final +io.netty:netty-transport-classes-kqueue:4.2.13.Final +io.netty:netty-transport-native-epoll:4.2.13.Final +io.netty:netty-transport-native-kqueue:4.2.13.Final +io.netty:netty-transport-native-unix-common:4.2.13.Final +io.netty:netty-transport:4.2.13.Final +io.opencensus:opencensus-api:0.31.1 +io.opencensus:opencensus-contrib-http-util:0.31.1 +io.opentelemetry.contrib:opentelemetry-gcp-resources:1.37.0-alpha +io.opentelemetry.semconv:opentelemetry-semconv:1.29.0-alpha +io.opentelemetry:opentelemetry-api:1.51.0 +io.opentelemetry:opentelemetry-context:1.51.0 +io.opentelemetry:opentelemetry-sdk-common:1.51.0 +io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.51.0 +io.opentelemetry:opentelemetry-sdk-logs:1.51.0 +io.opentelemetry:opentelemetry-sdk-metrics:1.51.0 +io.opentelemetry:opentelemetry-sdk-trace:1.51.0 +io.opentelemetry:opentelemetry-sdk:1.51.0 +io.perfmark:perfmark-api:0.27.0 +io.projectreactor.netty:reactor-netty-core:1.2.13 +io.projectreactor.netty:reactor-netty-http:1.2.13 +io.projectreactor:reactor-core:3.7.14 +jakarta.activation:jakarta.activation-api:1.2.1 +javax.annotation:javax.annotation-api:1.3.2 +javax.servlet.jsp:jsp-api:2.1 +javax.servlet:javax.servlet-api:3.1.0 +javax.xml.bind:jaxb-api:2.2.2 +javax.xml.stream:stax-api:1.0-2 +net.java.dev.jna:jna-platform:5.17.0 +net.java.dev.jna:jna:5.17.0 +org.apache.arrow:arrow-format:17.0.0 +org.apache.arrow:arrow-memory-core:17.0.0 +org.apache.arrow:arrow-memory-netty-buffer-patch:17.0.0 +org.apache.arrow:arrow-memory-netty:17.0.0 +org.apache.arrow:arrow-vector:17.0.0 +org.apache.avro:avro:1.12.1 +org.apache.commons:commons-collections4:4.4 +org.apache.commons:commons-compress:1.28.0 +org.apache.commons:commons-lang3:3.18.0 +org.apache.commons:commons-math3:3.6.1 +org.apache.commons:commons-text:1.14.0 +org.apache.hadoop.thirdparty:hadoop-shaded-guava:1.5.0 +org.apache.hadoop.thirdparty:hadoop-shaded-protobuf_3_25:1.5.0 +org.apache.hadoop:hadoop-annotations:3.4.3 +org.apache.hadoop:hadoop-common:3.4.3 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.httpcomponents:httpclient:4.5.14 +org.apache.httpcomponents:httpcore:4.4.16 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.bouncycastle:bcprov-jdk18on:1.84 +org.checkerframework:checker-compat-qual:2.5.6 +org.checkerframework:checker-qual:3.49.0 +org.codehaus.jettison:jettison:1.5.5 +org.codehaus.mojo:animal-sniffer-annotations:1.26 +org.codehaus.woodstox:stax2-api:4.2.2 +org.conscrypt:conscrypt-openjdk-uber:2.5.2 +org.json:json:20250517 +org.locationtech.jts:jts-core:1.20.0 +org.mongodb:bson:4.11.5 +org.reactivestreams:reactive-streams:1.0.4 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.slf4j:slf4j-api:2.0.17 +org.threeten:threeten-extra:1.8.0 +org.threeten:threetenbp:1.7.0 +org.xerial.snappy:snappy-java:1.1.10.8 +software.amazon.awssdk.crt:aws-crt:0.45.1 +software.amazon.awssdk:annotations:2.44.4 +software.amazon.awssdk:apache-client:2.44.4 +software.amazon.awssdk:arns:2.44.4 +software.amazon.awssdk:auth:2.44.4 +software.amazon.awssdk:aws-core:2.44.4 +software.amazon.awssdk:aws-json-protocol:2.44.4 +software.amazon.awssdk:aws-query-protocol:2.44.4 +software.amazon.awssdk:aws-xml-protocol:2.44.4 +software.amazon.awssdk:checksums-spi:2.44.4 +software.amazon.awssdk:checksums:2.44.4 +software.amazon.awssdk:crt-core:2.44.4 +software.amazon.awssdk:dynamodb:2.44.4 +software.amazon.awssdk:endpoints-spi:2.44.4 +software.amazon.awssdk:glue:2.44.4 +software.amazon.awssdk:http-auth-aws-crt:2.44.4 +software.amazon.awssdk:http-auth-aws-eventstream:2.44.4 +software.amazon.awssdk:http-auth-aws:2.44.4 +software.amazon.awssdk:http-auth-spi:2.44.4 +software.amazon.awssdk:http-auth:2.44.4 +software.amazon.awssdk:http-client-spi:2.44.4 +software.amazon.awssdk:iam:2.44.4 +software.amazon.awssdk:identity-spi:2.44.4 +software.amazon.awssdk:json-utils:2.44.4 +software.amazon.awssdk:kms:2.44.4 +software.amazon.awssdk:lakeformation:2.44.4 +software.amazon.awssdk:metrics-spi:2.44.4 +software.amazon.awssdk:netty-nio-client:2.44.4 +software.amazon.awssdk:profiles:2.44.4 +software.amazon.awssdk:protocol-core:2.44.4 +software.amazon.awssdk:regions:2.44.4 +software.amazon.awssdk:retries-spi:2.44.4 +software.amazon.awssdk:retries:2.44.4 +software.amazon.awssdk:s3:2.44.4 +software.amazon.awssdk:sdk-core:2.44.4 +software.amazon.awssdk:sso:2.44.4 +software.amazon.awssdk:sts:2.44.4 +software.amazon.awssdk:third-party-jackson-core:2.44.4 +software.amazon.awssdk:utils-lite:2.44.4 +software.amazon.awssdk:utils:2.44.4 +software.amazon.eventstream:eventstream:1.0.1 diff --git a/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/IntegrationTestBase.java b/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/IntegrationTestBase.java index 8b5b1ddea34b..8c011ef537df 100644 --- a/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/IntegrationTestBase.java +++ b/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/IntegrationTestBase.java @@ -31,6 +31,7 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.Namespace; @@ -135,12 +136,20 @@ public boolean matches(String str) { protected List dataFiles(TableIdentifier tableIdentifier, String branch) { Table table = catalog.loadTable(tableIdentifier); - return Lists.newArrayList(latestSnapshot(table, branch).addedDataFiles(table.io())); + return Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(latestSnapshot(table, branch)) + .build() + .addedDataFiles()); } protected List deleteFiles(TableIdentifier tableIdentifier, String branch) { Table table = catalog.loadTable(tableIdentifier); - return Lists.newArrayList(latestSnapshot(table, branch).addedDeleteFiles(table.io())); + return Lists.newArrayList( + SnapshotChanges.builderFor(table) + .snapshot(latestSnapshot(table, branch)) + .build() + .addedDeleteFiles()); } private Snapshot latestSnapshot(Table table, String branch) { diff --git a/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/TestIntegrationDynamicTable.java b/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/TestIntegrationDynamicTable.java index 65bbcde9dfed..1d3d71a54152 100644 --- a/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/TestIntegrationDynamicTable.java +++ b/kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/TestIntegrationDynamicTable.java @@ -20,11 +20,14 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.Duration; import java.time.Instant; import java.util.List; import org.apache.iceberg.DataFile; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; @@ -59,6 +62,56 @@ public void testIcebergSink(String branch) { assertSnapshotProps(TABLE_IDENTIFIER2, branch); } + /** + * Verifies dynamic routing works when topic-rewriting SMTs (e.g. RegexRouter) change + * record.topic(). Before the fix, SinkWriter tracked offsets under the rewritten topic, causing a + * mismatch with context.assignment() and preventing proper offset commits. + */ + @Test + public void testDynamicRouteWithTopicRewritingSMT() { + String smtTable = "smttbl"; + TableIdentifier smtTableId = TableIdentifier.of(TEST_DB, smtTable); + catalog().createTable(smtTableId, TestEvent.TEST_SCHEMA); + + try { + // RegexRouter rewrites topic to "test.smttbl", then InsertField copies + // record.topic() (now "test.smttbl") into field "srcTopic", and dynamic + // routing uses "srcTopic" to pick the destination table. + KafkaConnectUtils.Config connectorConfig = + createCommonConfig(false) + .config("iceberg.tables.dynamic-enabled", true) + .config("iceberg.tables.route-field", "srcTopic") + .config("transforms", "rewriteTopic,insertTopic") + .config( + "transforms.rewriteTopic.type", "org.apache.kafka.connect.transforms.RegexRouter") + .config("transforms.rewriteTopic.regex", ".*") + .config("transforms.rewriteTopic.replacement", TEST_DB + "." + smtTable) + .config( + "transforms.insertTopic.type", + "org.apache.kafka.connect.transforms.InsertField$Value") + .config("transforms.insertTopic.topic.field", "srcTopic"); + + context().connectorCatalogProperties().forEach(connectorConfig::config); + context().startConnector(connectorConfig); + + send(testTopic(), new TestEvent(1, "type1", Instant.now(), "hello"), false); + send(testTopic(), new TestEvent(2, "type2", Instant.now(), "world"), false); + flush(); + + Awaitility.await() + .atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(1)) + .untilAsserted(() -> assertSnapshotAdded(List.of(smtTableId))); + + List files = dataFiles(smtTableId, null); + assertThat(files).hasSizeBetween(1, 2); + assertThat(files.stream().mapToLong(DataFile::recordCount).sum()).isEqualTo(2); + assertSnapshotProps(smtTableId, null); + } finally { + catalog().dropTable(smtTableId); + } + } + @Override protected KafkaConnectUtils.Config createConfig(boolean useSchema) { return createCommonConfig(useSchema) diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/IcebergSinkConfig.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/IcebergSinkConfig.java index 9650ce16270c..678bf3b0f2c0 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/IcebergSinkConfig.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/IcebergSinkConfig.java @@ -28,6 +28,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.iceberg.IcebergBuild; +import org.apache.iceberg.connect.data.ErrorTolerance; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.base.Splitter; @@ -79,6 +80,8 @@ public class IcebergSinkConfig extends AbstractConfig { "iceberg.tables.schema-force-optional"; private static final String TABLES_SCHEMA_CASE_INSENSITIVE_PROP = "iceberg.tables.schema-case-insensitive"; + private static final String ERROR_TOLERANCE = "errors.tolerance"; + private static final String ERROR_LOG_INCLUDE_MESSAGES = "errors.log.include.messages"; private static final String CONTROL_TOPIC_PROP = "iceberg.control.topic"; private static final String CONTROL_GROUP_ID_PREFIX_PROP = "iceberg.control.group-id-prefix"; private static final String COMMIT_INTERVAL_MS_PROP = "iceberg.control.commit.interval-ms"; @@ -95,6 +98,9 @@ public class IcebergSinkConfig extends AbstractConfig { private static final String TASK_ID = "task.id"; private static final String BOOTSTRAP_SERVERS_PROP = "bootstrap.servers"; + private static final String DEFAULT_ERROR_TOLERANCE = ErrorTolerance.NONE.toString(); + private static final String DEFAULT_ERROR_LOG_INCLUDE_MESSAGES = "false"; + private static final String DEFAULT_CATALOG_NAME = "iceberg"; private static final String DEFAULT_CONTROL_TOPIC = "control-iceberg"; public static final String DEFAULT_CONTROL_GROUP_PREFIX = "cg-control-"; @@ -181,6 +187,18 @@ private static ConfigDef newConfigDef() { DEFAULT_CATALOG_NAME, Importance.MEDIUM, "Iceberg catalog name"); + configDef.define( + ERROR_TOLERANCE, + ConfigDef.Type.STRING, + DEFAULT_ERROR_TOLERANCE, + Importance.MEDIUM, + "Behavior for tolerating errors during connector operation. 'none' is the default value and signals that any error will result in an immediate connector task failure; 'all' changes the behavior to skip over problematic records."); + configDef.define( + ERROR_LOG_INCLUDE_MESSAGES, + ConfigDef.Type.BOOLEAN, + DEFAULT_ERROR_LOG_INCLUDE_MESSAGES, + Importance.MEDIUM, + "If true, write each error and the details of the failed operation and problematic record to the Connect application log. This is 'false' by default, so that only errors that are not tolerated are reported."); configDef.define( CONTROL_TOPIC_PROP, ConfigDef.Type.STRING, @@ -452,6 +470,14 @@ public JsonConverter jsonConverter() { return jsonConverter; } + public String errorTolerance() { + return getString(ERROR_TOLERANCE); + } + + public boolean errorLogIncludeMessages() { + return getBoolean(ERROR_LOG_INCLUDE_MESSAGES); + } + @VisibleForTesting static boolean checkClassName(String className) { return (className.matches(".*\\.ConnectDistributed.*") diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java index 04602a66a5e1..61b99f5e89ca 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java @@ -30,7 +30,6 @@ import org.apache.kafka.clients.admin.ConsumerGroupDescription; import org.apache.kafka.clients.admin.MemberDescription; import org.apache.kafka.common.TopicPartition; -import org.apache.kafka.connect.errors.ConnectException; import org.apache.kafka.connect.sink.SinkRecord; import org.apache.kafka.connect.sink.SinkTaskContext; import org.slf4j.Logger; @@ -48,6 +47,7 @@ public class CommitterImpl implements Committer { private KafkaClientFactory clientFactory; private Collection membersWhenWorkerIsCoordinator; private final AtomicBoolean isInitialized = new AtomicBoolean(false); + private String taskId; private void initialize( Catalog icebergCatalog, @@ -58,6 +58,7 @@ private void initialize( this.config = icebergSinkConfig; this.context = sinkTaskContext; this.clientFactory = new KafkaClientFactory(config.kafkaProps()); + this.taskId = config.connectorName() + "-" + config.taskId(); } } @@ -92,16 +93,38 @@ boolean hasLeaderPartition(Collection currentAssignedPartitions) @VisibleForTesting boolean containsFirstPartition( Collection members, Collection partitions) { - // there should only be one task assigned partition 0 of the first topic, - // so elect that one the leader - TopicPartition firstTopicPartition = - members.stream() - .flatMap(member -> member.assignment().topicPartitions().stream()) - .min(new TopicPartitionComparator()) - .orElseThrow( - () -> new ConnectException("No partitions assigned, cannot determine leader")); - - return partitions.contains(firstTopicPartition); + // Determine the first partition across all members to elect the leader + TopicPartition firstTopicPartition = findFirstTopicPartition(members); + + if (firstTopicPartition == null) { + LOG.warn( + "Committer {} found no partitions assigned across all members, cannot determine leader", + taskId); + return false; + } + + boolean containsFirst = partitions.contains(firstTopicPartition); + if (containsFirst) { + LOG.info( + "Committer {} contains the first partition {}, this task is the leader", + taskId, + firstTopicPartition); + } else { + LOG.debug( + "Committer {} does not contain the first partition {}, not the leader", + taskId, + firstTopicPartition); + } + + return containsFirst; + } + + @VisibleForTesting + TopicPartition findFirstTopicPartition(Collection members) { + return members.stream() + .flatMap(member -> member.assignment().topicPartitions().stream()) + .min(new TopicPartitionComparator()) + .orElse(null); } @Override @@ -122,7 +145,7 @@ public void open( Collection addedPartitions) { initialize(icebergCatalog, icebergSinkConfig, sinkTaskContext); if (hasLeaderPartition(addedPartitions)) { - LOG.info("Committer received leader partition. Starting Coordinator."); + LOG.info("Committer {} received leader partition. Starting Coordinator.", taskId); startCoordinator(); } } @@ -141,31 +164,25 @@ public void close(Collection closedPartitions) { // Defensive: close called without prior initialization (should not happen). if (!isInitialized.get()) { - LOG.warn("Close unexpectedly called without partition assignment"); + LOG.warn("Close unexpectedly called on committer {} without partition assignment", taskId); return; } // Empty partitions → task was stopped explicitly. Stop coordinator if running. if (closedPartitions.isEmpty()) { - LOG.info("Task stopped. Closing coordinator."); + LOG.info("Committer {} stopped. Closing coordinator.", taskId); stopCoordinator(); return; } // Normal close: if leader partition is lost, stop coordinator. if (hasLeaderPartition(closedPartitions)) { - LOG.info( - "Committer {}-{} lost leader partition. Stopping coordinator.", - config.connectorName(), - config.taskId()); + LOG.info("Committer {} lost leader partition. Stopping coordinator.", taskId); stopCoordinator(); } // Reset offsets to last committed to avoid data loss. - LOG.info( - "Seeking to last committed offsets for worker {}-{}.", - config.connectorName(), - config.taskId()); + LOG.info("Seeking to last committed offsets for worker {}.", taskId); KafkaUtils.seekToLastCommittedOffsets(context); } @@ -181,9 +198,7 @@ public void save(Collection sinkRecords) { private void processControlEvents() { if (coordinatorThread != null && coordinatorThread.isTerminated()) { throw new NotRunningException( - String.format( - "Coordinator unexpectedly terminated on committer %s-%s", - config.connectorName(), config.taskId())); + String.format("Coordinator unexpectedly terminated on committer %s", taskId)); } if (worker != null) { worker.process(); @@ -194,6 +209,7 @@ private void startWorker() { if (null == this.worker) { LOG.info("Starting commit worker {}-{}", config.connectorName(), config.taskId()); SinkWriter sinkWriter = new SinkWriter(catalog, config); + sinkWriter.setReporter(context.errantRecordReporter()); worker = new Worker(config, clientFactory, sinkWriter, context); worker.start(); } @@ -201,10 +217,7 @@ private void startWorker() { private void startCoordinator() { if (null == this.coordinatorThread) { - LOG.info( - "Task {}-{} elected leader, starting commit coordinator", - config.connectorName(), - config.taskId()); + LOG.info("Task {} elected leader, starting commit coordinator", taskId); Coordinator coordinator = new Coordinator(catalog, config, membersWhenWorkerIsCoordinator, clientFactory, context); coordinatorThread = new CoordinatorThread(coordinator); diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Coordinator.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Coordinator.java index 1e0fa3286fe5..c986f8afc2eb 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Coordinator.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Coordinator.java @@ -81,6 +81,7 @@ class Coordinator extends Channel { private final ExecutorService exec; private final CommitState commitState; private volatile boolean terminated; + private final String taskId; Coordinator( Catalog catalog, @@ -110,6 +111,7 @@ class Coordinator extends Channel { .setNameFormat("iceberg-committer" + "-%d") .build()); this.commitState = new CommitState(config); + this.taskId = config.connectorName() + "-" + config.taskId(); } void process() { @@ -119,7 +121,7 @@ void process() { Event event = new Event(config.connectGroupId(), new StartCommit(commitState.currentCommitId())); send(event); - LOG.info("Commit {} initiated", commitState.currentCommitId()); + LOG.info("Coordinator {} initiated commit {}", taskId, commitState.currentCommitId()); } consumeAvailable(POLL_DURATION); @@ -149,7 +151,11 @@ private void commit(boolean partialCommit) { try { doCommit(partialCommit); } catch (Exception e) { - LOG.warn("Commit failed, will try again next cycle", e); + LOG.warn( + "Coordinator {} failed to commit for commit {}, will try again next cycle", + taskId, + commitState.currentCommitId(), + e); } finally { commitState.endCurrentCommit(); } @@ -163,10 +169,9 @@ private void doCommit(boolean partialCommit) { .executeWith(exec) .stopOnFailure() .run( - entry -> { - commitToTable( - entry.getKey(), entry.getValue(), controlTopicOffsets(), validThroughTs); - }); + entry -> + commitToTable( + entry.getKey(), entry.getValue(), controlTopicOffsets(), validThroughTs)); // we should only get here if all tables committed successfully... commitConsumerOffsets(); @@ -179,7 +184,8 @@ private void doCommit(boolean partialCommit) { send(event); LOG.info( - "Commit {} complete, committed to {} table(s), valid-through {}", + "Coordinator {} completed commit {}, committed to {} table(s), valid-through {}", + taskId, commitState.currentCommitId(), commitMap.size(), validThroughTs); @@ -193,6 +199,7 @@ private String offsetsToJson(Map offsets) { } } + @SuppressWarnings("checkstyle:CyclomaticComplexity") private void commitToTable( TableReference tableReference, List envelopeList, @@ -207,6 +214,15 @@ private void commitToTable( return; } + if (tableReference.uuid() != null && !tableReference.uuid().equals(table.uuid())) { + LOG.warn( + "Skipping commits to table {} due to target table mismatch. Expected: {} Received: {}", + tableIdentifier, + table.uuid(), + tableReference.uuid()); + return; + } + String branch = config.tableConfig(tableIdentifier.toString()).commitBranch(); // Control topic partition offsets may include a subset of partition ids if there were no @@ -246,13 +262,14 @@ private void commitToTable( .collect(Collectors.toList()); if (terminated) { - throw new ConnectException("Coordinator is terminated, commit aborted"); + throw new ConnectException( + String.format("Coordinator %s is terminated, commit aborted", taskId)); } if (dataFiles.isEmpty() && deleteFiles.isEmpty()) { - LOG.info("Nothing to commit to table {}, skipping", tableIdentifier); + LOG.info( + "Coordinator {} found nothing to commit to table {}, skipping", taskId, tableIdentifier); } else { - String taskId = String.format("%s-%s", config.connectorName(), config.taskId()); if (deleteFiles.isEmpty()) { AppendFiles appendOp = table.newAppend().validateWith(offsetValidator(tableIdentifier, committedOffsets)); @@ -293,7 +310,8 @@ private void commitToTable( send(event); LOG.info( - "Commit complete to table {}, snapshot {}, commit ID {}, valid-through {}", + "Coordinator {} completed commit to table {}, snapshot {}, commit ID {}, valid-through {}", + taskId, tableIdentifier, snapshotId, commitState.currentCommitId(), @@ -362,7 +380,7 @@ private Map parseOffsets(String value) { return Map.of(); } - TypeReference> typeRef = new TypeReference>() {}; + TypeReference> typeRef = new TypeReference<>() {}; try { return MAPPER.readValue(value, typeRef); } catch (IOException e) { diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Worker.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Worker.java index 27c5b9622fd3..903be7070370 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Worker.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Worker.java @@ -32,7 +32,6 @@ import org.apache.iceberg.connect.events.Event; import org.apache.iceberg.connect.events.PayloadType; import org.apache.iceberg.connect.events.StartCommit; -import org.apache.iceberg.connect.events.TableReference; import org.apache.iceberg.connect.events.TopicPartitionOffset; import org.apache.kafka.connect.sink.SinkRecord; import org.apache.kafka.connect.sink.SinkTaskContext; @@ -101,7 +100,7 @@ protected boolean receive(Envelope envelope) { new DataWritten( writeResult.partitionStruct(), commitId, - TableReference.of(config.catalogName(), writeResult.tableIdentifier()), + writeResult.tableReference(), writeResult.dataFiles(), writeResult.deleteFiles()))) .collect(Collectors.toList()); diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/ErrorTolerance.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/ErrorTolerance.java new file mode 100644 index 000000000000..cc4cc2599479 --- /dev/null +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/ErrorTolerance.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.connect.data; + +import java.util.Locale; + +public enum ErrorTolerance { + + /** Tolerate no errors. */ + NONE, + + /** Tolerate all errors. */ + ALL; + + public String value() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriter.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriter.java index b5be5b3a0047..e1ab8e8fa156 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriter.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriter.java @@ -24,8 +24,8 @@ import java.util.List; import java.util.Locale; import org.apache.iceberg.Table; -import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.connect.IcebergSinkConfig; +import org.apache.iceberg.connect.events.TableReference; import org.apache.iceberg.data.Record; import org.apache.iceberg.io.TaskWriter; import org.apache.iceberg.io.WriteResult; @@ -35,44 +35,56 @@ class IcebergWriter implements RecordWriter { private final Table table; - private final String tableName; + private final TableReference tableReference; private final IcebergSinkConfig config; private final List writerResults; private RecordConverter recordConverter; private TaskWriter writer; - IcebergWriter(Table table, String tableName, IcebergSinkConfig config) { + IcebergWriter(Table table, TableReference tableReference, IcebergSinkConfig config) { this.table = table; - this.tableName = tableName; + this.tableReference = tableReference; this.config = config; this.writerResults = Lists.newArrayList(); initNewWriter(); } private void initNewWriter() { - this.writer = RecordUtils.createTableWriter(table, tableName, config); + this.writer = RecordUtils.createTableWriter(table, tableReference, config); this.recordConverter = new RecordConverter(table, config); } @Override public void write(SinkRecord record) { + Record row = null; try { // ignore tombstones... if (record.value() != null) { - Record row = convertToRow(record); - writer.write(row); + row = convertToRow(record); } } catch (Exception e) { + String recordData = + this.config.errorLogIncludeMessages() + ? String.format(", record: %s", record.value().toString()) + : ""; throw new DataException( String.format( Locale.ROOT, - "An error occurred converting record, topic: %s, partition, %d, offset: %d", + "An error occurred converting record, topic: %s, partition, %d, offset: %d%s", record.topic(), record.kafkaPartition(), - record.kafkaOffset()), + record.kafkaOffset(), + recordData), e); } + if (row != null) { + try { + writer.write(row); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } } private Record convertToRow(SinkRecord record) { @@ -107,7 +119,7 @@ private void flush() { writerResults.add( new IcebergWriterResult( - TableIdentifier.parse(tableName), + tableReference, Arrays.asList(writeResult.dataFiles()), Arrays.asList(writeResult.deleteFiles()), table.spec().partitionType())); diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterFactory.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterFactory.java index 92f5af2d7a87..afb68f170136 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterFactory.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterFactory.java @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.List; +import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Table; @@ -28,6 +29,7 @@ import org.apache.iceberg.catalog.SupportsNamespaces; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.connect.IcebergSinkConfig; +import org.apache.iceberg.connect.events.TableReference; import org.apache.iceberg.exceptions.AlreadyExistsException; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.exceptions.NoSuchTableException; @@ -67,7 +69,15 @@ RecordWriter createWriter(String tableName, SinkRecord sample, boolean ignoreMis } } - return new IcebergWriter(table, tableName, config); + UUID tableUuid = table.uuid(); + if (tableUuid == null) { + LOG.warn( + "Table {} does not have a UUID, this may cause issues with commit coordination on table replace", + identifier); + } + TableReference tableReference = TableReference.of(catalog.name(), identifier, tableUuid); + + return new IcebergWriter(table, tableReference, config); } @VisibleForTesting diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterResult.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterResult.java index 58695a5572b5..5667399cd74e 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterResult.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/IcebergWriterResult.java @@ -22,28 +22,54 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.connect.events.TableReference; import org.apache.iceberg.types.Types.StructType; public class IcebergWriterResult { - private final TableIdentifier tableIdentifier; + private final TableReference tableReference; private final List dataFiles; private final List deleteFiles; private final StructType partitionStruct; + public IcebergWriterResult( + TableReference tableReference, + List dataFiles, + List deleteFiles, + StructType partitionStruct) { + this.tableReference = tableReference; + this.dataFiles = dataFiles; + this.deleteFiles = deleteFiles; + this.partitionStruct = partitionStruct; + } + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@link + * IcebergWriterResult#IcebergWriterResult(TableReference, List, List, StructType)} instead + */ + @Deprecated public IcebergWriterResult( TableIdentifier tableIdentifier, List dataFiles, List deleteFiles, StructType partitionStruct) { - this.tableIdentifier = tableIdentifier; + this.tableReference = TableReference.of("unknown", tableIdentifier); this.dataFiles = dataFiles; this.deleteFiles = deleteFiles; this.partitionStruct = partitionStruct; } + public TableReference tableReference() { + return tableReference; + } + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0; use {@code tableReference().identifier()} + * instead + */ + @Deprecated public TableIdentifier tableIdentifier() { - return tableIdentifier; + return tableReference.identifier(); } public List dataFiles() { diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordConverter.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordConverter.java index 1a57a6444870..ab3d5aa9bb43 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordConverter.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordConverter.java @@ -22,23 +22,29 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.math.BigDecimal; +import java.math.BigInteger; import java.math.RoundingMode; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.format.DateTimeParseException; import java.time.temporal.Temporal; import java.util.Base64; +import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.apache.iceberg.FileFormat; @@ -53,6 +59,7 @@ import org.apache.iceberg.mapping.NameMappingParser; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Type.PrimitiveType; import org.apache.iceberg.types.Types.DecimalType; @@ -64,6 +71,13 @@ import org.apache.iceberg.util.ByteBuffers; import org.apache.iceberg.util.DateTimeUtil; import org.apache.iceberg.util.UUIDUtil; +import org.apache.iceberg.variants.ShreddedObject; +import org.apache.iceberg.variants.ValueArray; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; +import org.apache.iceberg.variants.Variants; +import org.apache.kafka.connect.data.Field; import org.apache.kafka.connect.data.Struct; import org.apache.kafka.connect.errors.ConnectException; @@ -142,6 +156,8 @@ private Object convertValue( return convertTimeValue(value); case TIMESTAMP: return convertTimestampValue(value, (TimestampType) type); + case VARIANT: + return convertVariantValue(value); } throw new UnsupportedOperationException("Unsupported type: " + type.typeId()); } @@ -464,6 +480,234 @@ protected Temporal convertTimestampValue(Object value, TimestampType type) { return convertLocalDateTime(value); } + protected Variant convertVariantValue(Object value) { + if (value instanceof Variant variant) { + return variant; + } + + List sortedFieldNames = + collectFieldNames(value).stream().sorted().collect(Collectors.toList()); + VariantMetadata metadata = Variants.metadata(sortedFieldNames); + return Variant.of(metadata, objectToVariantValue(value, metadata, null)); + } + + /** + * Recursively collects field names from collections, maps, and structs. Returns an empty set for + * null, scalar values, and empty maps, lists, or structs. Map keys must be strings; non-string + * keys cause IllegalArgumentException. + */ + private static Set collectFieldNames(Object value) { + if (value == null) { + return Collections.emptySet(); + } + if (value instanceof Collection collection) { + if (collection.isEmpty()) { + return Collections.emptySet(); + } + Set names = Sets.newHashSet(); + collection.forEach(element -> names.addAll(collectFieldNames(element))); + return names; + } else if (value instanceof Map map) { + if (map.isEmpty()) { + return Collections.emptySet(); + } + Set names = Sets.newHashSet(); + map.forEach( + (key, val) -> { + if (key instanceof String keyStr) { + names.add(keyStr); + names.addAll(collectFieldNames(val)); + } else { + throw new IllegalArgumentException( + "Cannot convert map to variant: keys must be non-null strings, was: " + + (key == null ? "null" : key.getClass().getName())); + } + }); + return names; + } else if (value instanceof Struct struct) { + List fields = struct.schema().fields(); + if (fields.isEmpty()) { + return Collections.emptySet(); + } + Set names = Sets.newHashSet(); + fields.forEach( + field -> { + names.add(field.name()); + names.addAll(collectFieldNames(struct.get(field))); + }); + return names; + } + return Collections.emptySet(); + } + + /** + * Recursively converts a Java object to a VariantValue using the given shared metadata for all + * nested maps. Handles primitives, List (array), and Map (object); map keys become field names. + */ + private static VariantValue objectToVariantValue( + Object value, VariantMetadata metadata, org.apache.kafka.connect.data.Schema schema) { + if (value == null) { + return Variants.ofNull(); + } + VariantValue primitive = primitiveToVariantValue(value, schema); + if (primitive != null) { + return primitive; + } + if (value instanceof Collection collection) { + ValueArray array = Variants.array(); + org.apache.kafka.connect.data.Schema elementSchema = + schema != null ? schema.valueSchema() : null; + for (Object element : collection) { + array.add(objectToVariantValue(element, metadata, elementSchema)); + } + return array; + } + if (value instanceof Map map) { + return mapToVariantValue(map, metadata, schema); + } + if (value instanceof Struct struct) { + ShreddedObject object = Variants.object(metadata); + for (Field field : struct.schema().fields()) { + object.put(field.name(), objectToVariantValue(struct.get(field), metadata, field.schema())); + } + return object; + } + throw new IllegalArgumentException("Cannot convert to variant: " + value.getClass().getName()); + } + + /** Converts a Map to VariantValue; throw IllegalArgumentException if the key is not a string. */ + private static VariantValue mapToVariantValue( + Map map, VariantMetadata metadata, org.apache.kafka.connect.data.Schema schema) { + ShreddedObject object = Variants.object(metadata); + org.apache.kafka.connect.data.Schema mapValueSchema = + schema != null ? schema.valueSchema() : null; + map.forEach( + (key, val) -> { + if (key instanceof String keyStr) { + object.put(keyStr, objectToVariantValue(val, metadata, mapValueSchema)); + } else { + throw new IllegalArgumentException( + "Cannot convert map to variant: keys must be non-null strings, was: " + + (key == null ? "null" : key.getClass().getName())); + } + }); + return object; + } + + /** + * Converts a primitive or primitive-like value to VariantValue; returns null if not supported. + * The optional schema is used to disambiguate java.util.Date which Kafka Connect uses for Date, + * Time, and Timestamp logical types. + */ + private static VariantValue primitiveToVariantValue( + Object value, org.apache.kafka.connect.data.Schema schema) { + if (value instanceof Boolean booleanValue) { + return Variants.of(booleanValue); + } + VariantValue temporal = temporalObjectToVariantValue(value, schema); + if (temporal != null) { + return temporal; + } + if (value instanceof Number number) { + return numberToVariantValue(number); + } + if (value instanceof String stringValue) { + return Variants.of(stringValue); + } + if (value instanceof ByteBuffer byteBuffer) { + return Variants.of(byteBuffer); + } + if (value instanceof byte[] byteArray) { + return Variants.of(ByteBuffer.wrap(byteArray)); + } + if (value instanceof UUID uuid) { + return Variants.ofUUID(uuid); + } + return null; + } + + /** + * Converts java.time values and java.util.Date (with Connect logical type from the optional + * schema) to VariantValue; returns null if the value is not a supported temporal representation. + */ + private static VariantValue temporalObjectToVariantValue( + Object value, org.apache.kafka.connect.data.Schema schema) { + if (value instanceof Instant instant) { + return Variants.ofTimestamptz(DateTimeUtil.microsFromInstant(instant)); + } + if (value instanceof OffsetDateTime offsetDateTime) { + return Variants.ofTimestamptz(DateTimeUtil.microsFromTimestamptz(offsetDateTime)); + } + if (value instanceof ZonedDateTime zonedDateTime) { + return Variants.ofTimestamptz( + DateTimeUtil.microsFromTimestamptz(zonedDateTime.toOffsetDateTime())); + } + if (value instanceof LocalDateTime localDateTime) { + return Variants.ofTimestampntz(DateTimeUtil.microsFromTimestamp(localDateTime)); + } + if (value instanceof LocalDate localDate) { + return Variants.ofDate(DateTimeUtil.daysFromDate(localDate)); + } + if (value instanceof LocalTime localTime) { + return Variants.ofTime(DateTimeUtil.microsFromTime(localTime)); + } + if (value instanceof Date date) { + String logicalName = schema != null ? schema.name() : null; + // Connect represents Timestamp, Time, and Date logical types as java.util.Date at runtime; + // normalize to Instant once, then interpret using the schema logical type name. + Instant connectInstant = date.toInstant(); + if (org.apache.kafka.connect.data.Timestamp.LOGICAL_NAME.equals(logicalName)) { + return Variants.ofTimestamptz(DateTimeUtil.microsFromInstant(connectInstant)); + } + if (org.apache.kafka.connect.data.Time.LOGICAL_NAME.equals(logicalName)) { + LocalTime utcTime = connectInstant.atZone(ZoneOffset.UTC).toLocalTime(); + return Variants.ofTime(DateTimeUtil.microsFromTime(utcTime)); + } + if (org.apache.kafka.connect.data.Date.LOGICAL_NAME.equals(logicalName)) { + return Variants.ofDate(DateTimeUtil.daysFromInstant(connectInstant)); + } + throw new IllegalArgumentException( + "Cannot convert java.util.Date to variant without a recognized logical type schema" + + " (expected Timestamp, Time, or Date but got: " + + logicalName + + ")"); + } + return null; + } + + /** + * Converts a Number to VariantValue; throw IllegalArgumentException if the value is not a + * supported number representation. + */ + private static VariantValue numberToVariantValue(Number number) { + if (number instanceof BigDecimal bigDecimal) { + return Variants.of(bigDecimal); + } + if (number instanceof BigInteger bigInteger) { + return Variants.of(new BigDecimal(bigInteger)); + } + if (number instanceof Integer integer) { + return Variants.of(integer); + } + if (number instanceof Long longValue) { + return Variants.of(longValue); + } + if (number instanceof Float floatValue) { + return Variants.of(floatValue); + } + if (number instanceof Double doubleValue) { + return Variants.of(doubleValue); + } + if (number instanceof Byte byteValue) { + return Variants.of(byteValue); + } + if (number instanceof Short shortValue) { + return Variants.of(shortValue); + } + throw new IllegalArgumentException( + "Cannot convert Number to variant (unknown type): " + number.getClass().getName()); + } + @SuppressWarnings("JavaUtilDate") private OffsetDateTime convertOffsetDateTime(Object value) { if (value instanceof Number) { @@ -524,10 +768,19 @@ private String ensureTimestampFormat(String str) { if (result.charAt(10) == ' ') { result = result.substring(0, 10) + 'T' + result.substring(11); } - if (result.length() > 22 - && (result.charAt(19) == '+' || result.charAt(19) == '-') - && result.charAt(22) == ':') { - result = result.substring(0, 19) + result.substring(19).replace(":", ""); + // Search for the timezone offset sign starting after the seconds portion (index 19+). + // With fractional seconds (e.g. "...T03:17:37.260514+00:00") the sign appears later + // than index 19, so we must locate it dynamically rather than assuming a fixed position. + int signIdx = -1; + for (int i = 19; i < result.length(); i++) { + char ch = result.charAt(i); + if (ch == '+' || ch == '-') { + signIdx = i; + break; + } + } + if (signIdx != -1 && signIdx + 3 < result.length() && result.charAt(signIdx + 3) == ':') { + result = result.substring(0, signIdx + 3) + result.substring(signIdx + 4); } return result; } diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordUtils.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordUtils.java index d4bf4ce2a4d3..5ed820c8cbe1 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordUtils.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/RecordUtils.java @@ -27,6 +27,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.connect.IcebergSinkConfig; +import org.apache.iceberg.connect.events.TableReference; import org.apache.iceberg.data.GenericFileWriterFactory; import org.apache.iceberg.data.Record; import org.apache.iceberg.io.FileWriterFactory; @@ -95,7 +96,7 @@ private static Object valueFromMap(Map parent, List fields) { } public static TaskWriter createTableWriter( - Table table, String tableName, IcebergSinkConfig config) { + Table table, TableReference tableReference, IcebergSinkConfig config) { Map tableProps = Maps.newHashMap(table.properties()); tableProps.putAll(config.writeProps()); @@ -113,7 +114,7 @@ public static TaskWriter createTableWriter( Set identifierFieldIds = table.schema().identifierFieldIds(); // override the identifier fields if the config is set - List idCols = config.tableConfig(tableName).idColumns(); + List idCols = config.tableConfig(tableReference.identifier().name()).idColumns(); if (!idCols.isEmpty()) { identifierFieldIds = idCols.stream() diff --git a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/SinkWriter.java b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/SinkWriter.java index f81155e13777..f25ab208023c 100644 --- a/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/SinkWriter.java +++ b/kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/data/SinkWriter.java @@ -32,13 +32,21 @@ import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.connect.errors.DataException; +import org.apache.kafka.connect.sink.ErrantRecordReporter; import org.apache.kafka.connect.sink.SinkRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class SinkWriter { + + private static final Logger LOG = LoggerFactory.getLogger(SinkWriter.class); + private final IcebergSinkConfig config; private final IcebergWriterFactory writerFactory; private final Map writers; private final Map sourceOffsets; + private ErrantRecordReporter reporter; public SinkWriter(Catalog catalog, IcebergSinkConfig config) { this.config = config; @@ -47,6 +55,10 @@ public SinkWriter(Catalog catalog, IcebergSinkConfig config) { this.sourceOffsets = Maps.newHashMap(); } + public void setReporter(ErrantRecordReporter reporter) { + this.reporter = reporter; + } + public void close() { writers.values().forEach(RecordWriter::close); } @@ -65,7 +77,23 @@ public SinkWriterResult completeWrite() { } public void save(Collection sinkRecords) { - sinkRecords.forEach(this::save); + for (SinkRecord record : sinkRecords) { + try { + this.save(record); + } catch (DataException ex) { + if (this.reporter != null) { + this.reporter.report(record, ex); + } + if (this.config.errorTolerance().equalsIgnoreCase(ErrorTolerance.ALL.toString())) { + LOG.error( + "Data exception encountered while saving record but tolerated due to error tolerance settings. " + + "To change this behavior, set 'errors.tolerance' to 'none':", + ex); + } else { + throw ex; + } + } + } } private void save(SinkRecord record) { @@ -75,9 +103,12 @@ private void save(SinkRecord record) { record.timestamp() == null ? null : OffsetDateTime.ofInstant(Instant.ofEpochMilli(record.timestamp()), ZoneOffset.UTC); + // use the original topic and partition to track offsets, as SMTs may have changed + // record.topic() and record.kafkaPartition() (e.g. RegexRouter). The framework's + // context.assignment() and consumer offset management use the original values. sourceOffsets.put( - new TopicPartition(record.topic(), record.kafkaPartition()), - new Offset(record.kafkaOffset() + 1, timestamp)); + new TopicPartition(record.originalTopic(), record.originalKafkaPartition()), + new Offset(record.originalKafkaOffset() + 1, timestamp)); if (config.dynamicTablesEnabled()) { routeRecordDynamically(record); diff --git a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCoordinator.java b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCoordinator.java index 60a085781171..ed370fcdad35 100644 --- a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCoordinator.java +++ b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCoordinator.java @@ -32,7 +32,9 @@ import org.apache.iceberg.FileFormat; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.connect.events.AvroUtil; import org.apache.iceberg.connect.events.CommitComplete; import org.apache.iceberg.connect.events.CommitToTable; @@ -71,8 +73,9 @@ public void testCommitAppend() { Snapshot snapshot = snapshots.get(0); assertThat(snapshot.operation()).isEqualTo(DataOperations.APPEND); - assertThat(snapshot.addedDataFiles(table.io())).hasSize(1); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).hasSize(1); + assertThat(changes.addedDeleteFiles()).isEmpty(); assertThat(snapshot.summary()) .containsEntry(COMMIT_ID_SNAPSHOT_PROP, commitId.toString()) @@ -98,8 +101,9 @@ public void testCommitDelta() { Snapshot snapshot = snapshots.get(0); assertThat(snapshot.operation()).isEqualTo(DataOperations.OVERWRITE); - assertThat(snapshot.addedDataFiles(table.io())).hasSize(1); - assertThat(snapshot.addedDeleteFiles(table.io())).hasSize(1); + SnapshotChanges changes = SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).hasSize(1); + assertThat(changes.addedDeleteFiles()).hasSize(1); assertThat(snapshot.summary()) .containsEntry(COMMIT_ID_SNAPSHOT_PROP, commitId.toString()) @@ -189,7 +193,7 @@ private UUID coordinatorTest( new DataWritten( StructType.of(), commitId, - new TableReference("catalog", ImmutableList.of("db"), "tbl"), + TableReference.of("catalog", TableIdentifier.of("db", "tbl"), null), dataFiles, deleteFiles)); bytes = AvroUtil.encode(commitResponse); diff --git a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestRecordConverter.java b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestRecordConverter.java index 45d07f69591b..9b91ba61c167 100644 --- a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestRecordConverter.java +++ b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestRecordConverter.java @@ -32,6 +32,7 @@ import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.time.temporal.Temporal; import java.util.Base64; import java.util.Collection; @@ -74,7 +75,12 @@ import org.apache.iceberg.types.Types.TimeType; import org.apache.iceberg.types.Types.TimestampType; import org.apache.iceberg.types.Types.UUIDType; +import org.apache.iceberg.types.Types.VariantType; +import org.apache.iceberg.util.DateTimeUtil; import org.apache.iceberg.util.UUIDUtil; +import org.apache.iceberg.variants.PhysicalType; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantValue; import org.apache.kafka.connect.data.Decimal; import org.apache.kafka.connect.data.Schema; import org.apache.kafka.connect.data.SchemaBuilder; @@ -152,6 +158,9 @@ public class TestRecordConverter { NestedField.required( 100, "stma", MapType.ofRequired(101, 102, StringType.get(), ID_SCHEMA.asStruct()))); + private static final org.apache.iceberg.Schema VARIANT_SCHEMA = + new org.apache.iceberg.Schema(NestedField.required(1, "v", VariantType.get())); + private static final Schema CONNECT_SCHEMA = SchemaBuilder.struct() .field("i", Schema.INT32_SCHEMA) @@ -569,6 +578,22 @@ public void testTimestampWithZoneConversion() { assertTimestampConvert(expected, additionalInput, TimestampType.withZone()); } + @Test + public void testTimestampWithZoneAndFractionalSecondsConversion() { + // Timestamps with sub-second precision and a colon-separated UTC offset (e.g. +00:00) + // were previously mis-parsed because ensureTimestampFormat only checked for the timezone + // sign at the fixed index 19, which is only valid when there are no fractional seconds. + OffsetDateTime expected = OffsetDateTime.parse("2026-03-31T03:17:37.260514+00:00"); + List inputs = + ImmutableList.of( + "2026-03-31T03:17:37.260514+00:00", + "2026-03-31T03:17:37.260514+0000", + "2026-03-31T03:17:37.260514Z", + "2026-03-31 03:17:37.260514+00:00", + "2026-03-31 03:17:37.260514+0000"); + assertTimestampConvert(expected, inputs, TimestampType.withZone()); + } + @Test public void testTimestampWithoutZoneConversion() { LocalDateTime expected = LocalDateTime.parse("2023-05-18T11:22:33"); @@ -587,6 +612,23 @@ public void testTimestampWithoutZoneConversion() { assertTimestampConvert(expected, additionalInput, TimestampType.withoutZone()); } + @Test + public void testTimestampWithoutZoneAndFractionalSecondsConversion() { + // Fractional seconds with a colon-separated offset: timezone must be stripped and + // the colon in +HH:MM must be normalized before OFFSET_TIMESTAMP_FORMAT can parse it. + LocalDateTime expected = LocalDateTime.parse("2026-03-31T03:17:37.260514"); + List inputs = + ImmutableList.of( + "2026-03-31T03:17:37.260514", + "2026-03-31 03:17:37.260514", + "2026-03-31T03:17:37.260514+00:00", + "2026-03-31 03:17:37.260514+00:00", + "2026-03-31T03:17:37.260514+0000", + "2026-03-31 03:17:37.260514+0000", + "2026-03-31T03:17:37.260514Z"); + assertTimestampConvert(expected, inputs, TimestampType.withoutZone()); + } + private void assertTimestampConvert(Temporal expected, long expectedMillis, TimestampType type) { List inputList = Lists.newArrayList( @@ -881,6 +923,364 @@ public void testEvolveTypeDetectionStructNested() { assertThat(updateMap.get("st.ff").type()).isInstanceOf(DoubleType.class); } + private RecordConverter variantConverter() { + Table table = mock(Table.class); + when(table.schema()).thenReturn(VARIANT_SCHEMA); + return new RecordConverter(table, config); + } + + @Test + public void testConvertVariantValueFromNull() { + Variant variant = variantConverter().convertVariantValue(null); + assertThat(variant).isNotNull(); + assertThat(variant.value().type()).isEqualTo(PhysicalType.NULL); + } + + @Test + public void testConvertVariantValuePassThrough() { + Variant original = variantConverter().convertVariantValue("hello"); + assertThat(variantConverter().convertVariantValue(original)).isSameAs(original); + } + + @Test + public void testConvertVariantValueFromPrimitiveString() { + Variant variant = variantConverter().convertVariantValue("hello"); + assertThat(variant).isNotNull(); + assertThat(variant.metadata()).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.STRING); + assertThat(variant.value().asPrimitive().get()).isEqualTo("hello"); + } + + @Test + public void testConvertVariantValueFromPrimitiveNumber() { + Variant variant = variantConverter().convertVariantValue(123); + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.INT32); + assertThat(variant.value().asPrimitive().get()).isEqualTo(123); + } + + @Test + public void testConvertVariantValueFromBoolean() { + Variant variant = variantConverter().convertVariantValue(true); + assertThat(variant).isNotNull(); + assertThat(variant.value().type()).isEqualTo(PhysicalType.BOOLEAN_TRUE); + assertThat(variant.value().asPrimitive().get()).isEqualTo(true); + } + + @Test + public void testConvertVariantValueFromInstant() { + Instant instant = Instant.parse("2025-04-04T12:34:56.789Z"); + Variant variant = variantConverter().convertVariantValue(instant); + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromInstant(instant)); + } + + @Test + public void testConvertVariantValueFromOffsetDateTime() { + OffsetDateTime odt = OffsetDateTime.parse("2025-04-04T12:34:56.789+09:00"); + Variant variant = variantConverter().convertVariantValue(odt); + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamptz(odt)); + } + + @Test + public void testConvertVariantValueFromZonedDateTime() { + ZonedDateTime zdt = ZonedDateTime.parse("2025-04-04T12:34:56.789-05:00[America/New_York]"); + Variant variant = variantConverter().convertVariantValue(zdt); + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamptz(zdt.toOffsetDateTime())); + } + + @Test + public void testConvertVariantValueFromLocalDateTime() { + LocalDateTime ldt = LocalDateTime.parse("2025-04-04T12:34:56.789"); + Variant variant = variantConverter().convertVariantValue(ldt); + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.TIMESTAMPNTZ); + assertThat(variant.value().asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamp(ldt)); + } + + @Test + public void testConvertVariantValueFromLocalDate() { + LocalDate date = LocalDate.of(2025, 4, 4); + Variant variant = variantConverter().convertVariantValue(date); + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.DATE); + assertThat(variant.value().asPrimitive().get()).isEqualTo(DateTimeUtil.daysFromDate(date)); + } + + @Test + public void testConvertVariantValueFromLocalTime() { + LocalTime time = LocalTime.of(12, 34, 56, 789_000_000); + Variant variant = variantConverter().convertVariantValue(time); + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(0); + assertThat(variant.value().type()).isEqualTo(PhysicalType.TIME); + assertThat(variant.value().asPrimitive().get()).isEqualTo(DateTimeUtil.microsFromTime(time)); + } + + @Test + public void testConvertVariantValueFromList() { + // array with heterogeneous element types (string, int, boolean, double, null, nested array/map, + // java.time primitives). Note: java.util.Date is not supported without Connect logical schema. + Instant instant = Instant.parse("2025-04-04T12:34:56.789Z"); + OffsetDateTime offsetTs = OffsetDateTime.parse("2025-04-04T12:34:56.789+09:00"); + ZonedDateTime zonedTs = ZonedDateTime.parse("2025-04-04T12:34:56.789-05:00[America/New_York]"); + LocalDateTime localTs = LocalDateTime.parse("2025-04-04T12:34:56.789"); + LocalDate localDate = LocalDate.of(2025, 4, 4); + LocalTime localTime = LocalTime.of(12, 34, 56, 789_000_000); + + List input = + Lists.newArrayList( + "a", + 1, + true, + 2.5, + null, + ImmutableList.of("a", "b"), + ImmutableMap.of("key1", "value1", "key2", "value2"), + instant, + offsetTs, + zonedTs, + localTs, + localDate, + localTime); + Variant variant = variantConverter().convertVariantValue(input); + + assertThat(variant).isNotNull(); + assertThat(variant.value().type()).isEqualTo(PhysicalType.ARRAY); + assertThat(variant.value().asArray().numElements()).isEqualTo(13); + + assertThat(variant.value().asArray().get(0).type()).isEqualTo(PhysicalType.STRING); + assertThat(variant.value().asArray().get(0).asPrimitive().get()).isEqualTo("a"); + + assertThat(variant.value().asArray().get(1).type()).isEqualTo(PhysicalType.INT32); + assertThat(variant.value().asArray().get(1).asPrimitive().get()).isEqualTo(1); + + assertThat(variant.value().asArray().get(2).type()).isEqualTo(PhysicalType.BOOLEAN_TRUE); + assertThat(variant.value().asArray().get(2).asPrimitive().get()).isEqualTo(true); + + assertThat(variant.value().asArray().get(3).type()).isEqualTo(PhysicalType.DOUBLE); + assertThat(variant.value().asArray().get(3).asPrimitive().get()).isEqualTo(2.5); + + assertThat(variant.value().asArray().get(4).type()).isEqualTo(PhysicalType.NULL); + + assertThat(variant.value().asArray().get(5).type()).isEqualTo(PhysicalType.ARRAY); + assertThat(variant.value().asArray().get(5).asArray().numElements()).isEqualTo(2); + assertThat(variant.value().asArray().get(5).asArray().get(0).asPrimitive().get()) + .isEqualTo("a"); + assertThat(variant.value().asArray().get(5).asArray().get(1).asPrimitive().get()) + .isEqualTo("b"); + + assertThat(variant.value().asArray().get(6).type()).isEqualTo(PhysicalType.OBJECT); + assertThat(variant.value().asArray().get(6).asObject().numFields()).isEqualTo(2); + assertThat(variant.value().asArray().get(6).asObject().get("key1").asPrimitive().get()) + .isEqualTo("value1"); + assertThat(variant.value().asArray().get(6).asObject().get("key2").asPrimitive().get()) + .isEqualTo("value2"); + + assertThat(variant.value().asArray().get(7).type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asArray().get(7).asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromInstant(instant)); + + assertThat(variant.value().asArray().get(8).type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asArray().get(8).asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamptz(offsetTs)); + + assertThat(variant.value().asArray().get(9).type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asArray().get(9).asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamptz(zonedTs.toOffsetDateTime())); + + assertThat(variant.value().asArray().get(10).type()).isEqualTo(PhysicalType.TIMESTAMPNTZ); + assertThat(variant.value().asArray().get(10).asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamp(localTs)); + + assertThat(variant.value().asArray().get(11).type()).isEqualTo(PhysicalType.DATE); + assertThat(variant.value().asArray().get(11).asPrimitive().get()) + .isEqualTo(DateTimeUtil.daysFromDate(localDate)); + + assertThat(variant.value().asArray().get(12).type()).isEqualTo(PhysicalType.TIME); + assertThat(variant.value().asArray().get(12).asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTime(localTime)); + } + + @Test + public void testConvertVariantValueFromMap() { + // heterogeneous top-level values, nested map, java.time primitives; + // metadata shares one sorted dictionary for the whole tree + Instant instant = Instant.parse("2025-04-04T12:34:56.789Z"); + OffsetDateTime offsetTs = OffsetDateTime.parse("2025-04-04T12:34:56.789+09:00"); + ZonedDateTime zonedTs = ZonedDateTime.parse("2025-04-04T12:34:56.789-05:00[America/New_York]"); + LocalDateTime localTs = LocalDateTime.parse("2025-04-04T12:34:56.789"); + LocalDate localDate = LocalDate.of(2025, 4, 4); + LocalTime localTime = LocalTime.of(12, 34, 56, 789_000_000); + + Map input = Maps.newLinkedHashMap(); + input.put("s", "text"); + input.put("i", 1); + input.put("bool", true); + input.put("d", 2.5); + input.put("n", null); + input.put("hello", ImmutableMap.of("world", 1)); + input.put("tags", ImmutableList.of("a", "b")); + input.put("instant", instant); + input.put("odt", offsetTs); + input.put("zdt", zonedTs); + input.put("ldt", localTs); + input.put("ldate", localDate); + input.put("ltime", localTime); + + Variant variant = variantConverter().convertVariantValue(input); + + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(14); + assertThat(variant.metadata().get(0)).isEqualTo("bool"); + assertThat(variant.metadata().get(1)).isEqualTo("d"); + assertThat(variant.metadata().get(2)).isEqualTo("hello"); + assertThat(variant.metadata().get(3)).isEqualTo("i"); + assertThat(variant.metadata().get(4)).isEqualTo("instant"); + assertThat(variant.metadata().get(5)).isEqualTo("ldate"); + assertThat(variant.metadata().get(6)).isEqualTo("ldt"); + assertThat(variant.metadata().get(7)).isEqualTo("ltime"); + assertThat(variant.metadata().get(8)).isEqualTo("n"); + assertThat(variant.metadata().get(9)).isEqualTo("odt"); + assertThat(variant.metadata().get(10)).isEqualTo("s"); + assertThat(variant.metadata().get(11)).isEqualTo("tags"); + assertThat(variant.metadata().get(12)).isEqualTo("world"); + assertThat(variant.metadata().get(13)).isEqualTo("zdt"); + + assertThat(variant.value().type()).isEqualTo(PhysicalType.OBJECT); + assertThat(variant.value().asObject().numFields()).isEqualTo(13); + + assertThat(variant.value().asObject().get("bool").type()).isEqualTo(PhysicalType.BOOLEAN_TRUE); + assertThat(variant.value().asObject().get("bool").asPrimitive().get()).isEqualTo(true); + + assertThat(variant.value().asObject().get("d").type()).isEqualTo(PhysicalType.DOUBLE); + assertThat(variant.value().asObject().get("d").asPrimitive().get()).isEqualTo(2.5); + + assertThat(variant.value().asObject().get("i").type()).isEqualTo(PhysicalType.INT32); + assertThat(variant.value().asObject().get("i").asPrimitive().get()).isEqualTo(1); + + assertThat(variant.value().asObject().get("n").type()).isEqualTo(PhysicalType.NULL); + + assertThat(variant.value().asObject().get("s").type()).isEqualTo(PhysicalType.STRING); + assertThat(variant.value().asObject().get("s").asPrimitive().get()).isEqualTo("text"); + + VariantValue tags = variant.value().asObject().get("tags"); + assertThat(tags.type()).isEqualTo(PhysicalType.ARRAY); + assertThat(tags.asArray().numElements()).isEqualTo(2); + assertThat(tags.asArray().get(0).asPrimitive().get()).isEqualTo("a"); + assertThat(tags.asArray().get(1).asPrimitive().get()).isEqualTo("b"); + + assertThat(variant.value().asObject().get("instant").type()) + .isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asObject().get("instant").asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromInstant(instant)); + + assertThat(variant.value().asObject().get("odt").type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asObject().get("odt").asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamptz(offsetTs)); + + assertThat(variant.value().asObject().get("zdt").type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(variant.value().asObject().get("zdt").asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamptz(zonedTs.toOffsetDateTime())); + + assertThat(variant.value().asObject().get("ldt").type()).isEqualTo(PhysicalType.TIMESTAMPNTZ); + assertThat(variant.value().asObject().get("ldt").asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTimestamp(localTs)); + + assertThat(variant.value().asObject().get("ldate").type()).isEqualTo(PhysicalType.DATE); + assertThat(variant.value().asObject().get("ldate").asPrimitive().get()) + .isEqualTo(DateTimeUtil.daysFromDate(localDate)); + + assertThat(variant.value().asObject().get("ltime").type()).isEqualTo(PhysicalType.TIME); + assertThat(variant.value().asObject().get("ltime").asPrimitive().get()) + .isEqualTo(DateTimeUtil.microsFromTime(localTime)); + + VariantValue nested = variant.value().asObject().get("hello"); + assertThat(nested.type()).isEqualTo(PhysicalType.OBJECT); + assertThat(nested.asObject().get("world").asPrimitive().get()).isEqualTo(1); + } + + @Test + public void testConvertVariantValueFromStruct() { + // Nested Connect struct: primitives, array, and Timestamp / Time / Date (java.util.Date + + // logical types) + // 2025-04-04 12:34:56.789 UTC (aligned with java.time variant tests) + long tsMillis = 1743770096789L; + long timeMillis = 45296789L; + long dateMillis = 20182L * 86_400_000; + + Schema innerSchema = + SchemaBuilder.struct() + .field("i", Schema.INT32_SCHEMA) + .field("str", Schema.STRING_SCHEMA) + .field("tags", SchemaBuilder.array(Schema.STRING_SCHEMA).build()) + .field("ts", Timestamp.SCHEMA) + .field("t", Time.SCHEMA) + .field("d", org.apache.kafka.connect.data.Date.SCHEMA) + .build(); + Schema outerSchema = + SchemaBuilder.struct().field("inner", innerSchema).field("id", Schema.INT64_SCHEMA).build(); + Struct inner = + new Struct(innerSchema) + .put("i", 1) + .put("str", "world") + .put("tags", ImmutableList.of("a", "b")) + .put("ts", new Date(tsMillis)) + .put("t", new Date(timeMillis)) + .put("d", new Date(dateMillis)); + Struct outer = new Struct(outerSchema).put("inner", inner).put("id", 100L); + + Variant variant = variantConverter().convertVariantValue(outer); + + assertThat(variant).isNotNull(); + assertThat(variant.metadata().dictionarySize()).isEqualTo(8); + assertThat(variant.metadata().get(0)).isEqualTo("d"); + assertThat(variant.metadata().get(1)).isEqualTo("i"); + assertThat(variant.metadata().get(2)).isEqualTo("id"); + assertThat(variant.metadata().get(3)).isEqualTo("inner"); + assertThat(variant.metadata().get(4)).isEqualTo("str"); + assertThat(variant.metadata().get(5)).isEqualTo("t"); + assertThat(variant.metadata().get(6)).isEqualTo("tags"); + assertThat(variant.metadata().get(7)).isEqualTo("ts"); + + assertThat(variant.value().type()).isEqualTo(PhysicalType.OBJECT); + assertThat(variant.value().asObject().get("id").asPrimitive().get()).isEqualTo(100L); + + VariantValue innerVal = variant.value().asObject().get("inner"); + assertThat(innerVal.type()).isEqualTo(PhysicalType.OBJECT); + assertThat(innerVal.asObject().get("i").asPrimitive().get()).isEqualTo(1); + assertThat(innerVal.asObject().get("str").asPrimitive().get()).isEqualTo("world"); + assertThat(innerVal.asObject().get("tags").type()).isEqualTo(PhysicalType.ARRAY); + assertThat(innerVal.asObject().get("tags").asArray().numElements()).isEqualTo(2); + assertThat(innerVal.asObject().get("tags").asArray().get(0).asPrimitive().get()).isEqualTo("a"); + assertThat(innerVal.asObject().get("tags").asArray().get(1).asPrimitive().get()).isEqualTo("b"); + + assertThat(innerVal.asObject().get("ts").type()).isEqualTo(PhysicalType.TIMESTAMPTZ); + assertThat(innerVal.asObject().get("ts").asPrimitive().get()).isEqualTo(tsMillis * 1000); + + assertThat(innerVal.asObject().get("t").type()).isEqualTo(PhysicalType.TIME); + assertThat(innerVal.asObject().get("t").asPrimitive().get()).isEqualTo(timeMillis * 1000); + + assertThat(innerVal.asObject().get("d").type()).isEqualTo(PhysicalType.DATE); + assertThat(innerVal.asObject().get("d").asPrimitive().get()).isEqualTo(20182); + } + public static Map createMapData() { return ImmutableMap.builder() .put("i", 1) diff --git a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestSinkWriter.java b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestSinkWriter.java index a14ebcab7336..7d68d519e4dd 100644 --- a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestSinkWriter.java +++ b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/TestSinkWriter.java @@ -20,6 +20,7 @@ import static org.apache.iceberg.types.Types.NestedField.optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.Mockito.mock; @@ -44,10 +45,13 @@ import org.apache.iceberg.types.Types; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.record.TimestampType; +import org.apache.kafka.connect.errors.DataException; +import org.apache.kafka.connect.sink.ErrantRecordReporter; import org.apache.kafka.connect.sink.SinkRecord; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; public class TestSinkWriter { @@ -91,7 +95,7 @@ public void testDefaultRoute() { List writerResults = sinkWriterTest(value, config); assertThat(writerResults).hasSize(1); IcebergWriterResult writerResult = writerResults.get(0); - assertThat(writerResult.tableIdentifier()).isEqualTo(TABLE_IDENTIFIER); + assertThat(writerResult.tableReference().identifier()).isEqualTo(TABLE_IDENTIFIER); } @Test @@ -119,7 +123,7 @@ public void testStaticRoute() { List writerResults = sinkWriterTest(value, config); assertThat(writerResults).hasSize(1); IcebergWriterResult writerResult = writerResults.get(0); - assertThat(writerResult.tableIdentifier()).isEqualTo(TABLE_IDENTIFIER); + assertThat(writerResult.tableReference().identifier()).isEqualTo(TABLE_IDENTIFIER); } @Test @@ -150,7 +154,75 @@ public void testDynamicRoute() { List writerResults = sinkWriterTest(value, config); assertThat(writerResults).hasSize(1); IcebergWriterResult writerResult = writerResults.get(0); - assertThat(writerResult.tableIdentifier()).isEqualTo(TABLE_IDENTIFIER); + assertThat(writerResult.tableReference().identifier()).isEqualTo(TABLE_IDENTIFIER); + } + + @Test + public void testOffsetTrackedByOriginalTopicPartition() { + IcebergSinkConfig config = mock(IcebergSinkConfig.class); + when(config.tableConfig(any())).thenReturn(mock(TableSinkConfig.class)); + when(config.tables()).thenReturn(ImmutableList.of(TABLE_IDENTIFIER.toString())); + when(config.dynamicTablesEnabled()).thenReturn(true); + when(config.tablesRouteField()).thenReturn(ROUTE_FIELD); + + IcebergWriterResult writeResult = + new IcebergWriterResult( + TableIdentifier.parse(TABLE_NAME), + ImmutableList.of(mock(DataFile.class)), + ImmutableList.of(), + Types.StructType.of()); + IcebergWriter writer = mock(IcebergWriter.class); + when(writer.complete()).thenReturn(ImmutableList.of(writeResult)); + + IcebergWriterFactory writerFactory = mock(IcebergWriterFactory.class); + when(writerFactory.createWriter(any(), any(), anyBoolean())).thenReturn(writer); + + SinkWriter sinkWriter = new SinkWriter(catalog, config); + + // simulate a record that has been transformed by RegexRouter (topic changed) + String originalTopic = "orders"; + int originalPartition = 0; + long originalOffset = 42L; + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + + SinkRecord original = + new SinkRecord( + originalTopic, + originalPartition, + null, + "key", + null, + ImmutableMap.of(ROUTE_FIELD, TABLE_IDENTIFIER.toString()), + originalOffset, + now.toEpochMilli(), + TimestampType.LOG_APPEND_TIME); + + // RegexRouter changes the topic via newRecord + String transformedTopic = "tmp.dynamic_orders"; + SinkRecord transformed = + original.newRecord( + transformedTopic, + originalPartition, + original.keySchema(), + original.key(), + original.valueSchema(), + original.value(), + original.timestamp()); + + sinkWriter.save(ImmutableList.of(transformed)); + SinkWriterResult result = sinkWriter.completeWrite(); + + // offsets must be keyed by the ORIGINAL topic, not the transformed one + Offset offset = + result.sourceOffsets().get(new TopicPartition(originalTopic, originalPartition)); + assertThat(offset).isNotNull(); + assertThat(offset.offset()).isEqualTo(originalOffset + 1); + assertThat(offset.timestamp()).isEqualTo(now.atOffset(ZoneOffset.UTC)); + + // the transformed topic key should NOT be present + Offset wrongOffset = + result.sourceOffsets().get(new TopicPartition(transformedTopic, originalPartition)); + assertThat(wrongOffset).isNull(); } @Test @@ -169,6 +241,11 @@ public void testDynamicNoRoute() { private List sinkWriterTest( Map value, IcebergSinkConfig config) { + return sinkWriterTest(value, config, null); + } + + private List sinkWriterTest( + Map value, IcebergSinkConfig config, ErrantRecordReporter reporter) { IcebergWriterResult writeResult = new IcebergWriterResult( TableIdentifier.parse(TABLE_NAME), @@ -182,7 +259,7 @@ private List sinkWriterTest( when(writerFactory.createWriter(any(), any(), anyBoolean())).thenReturn(writer); SinkWriter sinkWriter = new SinkWriter(catalog, config); - + sinkWriter.setReporter(reporter); // save a record Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); SinkRecord rec = @@ -207,4 +284,69 @@ private List sinkWriterTest( return result.writerResults(); } + + @Test + public void testErrorToleranceAll() { + IcebergSinkConfig config = mock(IcebergSinkConfig.class); + when(config.tables()).thenReturn(ImmutableList.of(TABLE_IDENTIFIER.toString())); + when(config.tableConfig(any())).thenReturn(mock(TableSinkConfig.class)); + when(config.errorTolerance()).thenReturn(ErrorTolerance.ALL.toString()); + when(config.errorLogIncludeMessages()).thenReturn(true); + + Map badValue = ImmutableMap.of("id", "abc"); + List writerResults1 = sinkWriterTest(badValue, config); + assertThat(writerResults1.size()).isEqualTo(1); + } + + @Test + public void testErrorToleranceNone() { + IcebergSinkConfig config = mock(IcebergSinkConfig.class); + when(config.tables()).thenReturn(ImmutableList.of(TABLE_IDENTIFIER.toString())); + when(config.tableConfig(any())).thenReturn(mock(TableSinkConfig.class)); + when(config.errorTolerance()).thenReturn(ErrorTolerance.NONE.toString()); + + Map badValue = ImmutableMap.of("id", "abc"); + assertThatThrownBy(() -> sinkWriterTest(badValue, config)) + .isInstanceOf(DataException.class) + .hasMessage("An error occurred converting record, topic: topic, partition, 1, offset: 100"); + } + + @Test + public void testErrorToleranceNoneErrorLogIncludeMessages() { + IcebergSinkConfig config = mock(IcebergSinkConfig.class); + when(config.tables()).thenReturn(ImmutableList.of(TABLE_IDENTIFIER.toString())); + when(config.tableConfig(any())).thenReturn(mock(TableSinkConfig.class)); + when(config.errorTolerance()).thenReturn(ErrorTolerance.NONE.toString()); + when(config.errorLogIncludeMessages()).thenReturn(true); + + Map badValue = ImmutableMap.of("id", "abc"); + assertThatThrownBy(() -> sinkWriterTest(badValue, config)) + .isInstanceOf(DataException.class) + .hasStackTraceContaining( + "Caused by: java.lang.NumberFormatException: For input string: \"abc\"\n") + .hasMessage( + "An error occurred converting record, topic: topic, partition, 1, offset: 100, record: {id=abc}"); + } + + @Test + public void testErrantRecordReporter() { + IcebergSinkConfig config = mock(IcebergSinkConfig.class); + when(config.tables()).thenReturn(ImmutableList.of(TABLE_IDENTIFIER.toString())); + when(config.tableConfig(any())).thenReturn(mock(TableSinkConfig.class)); + when(config.errorTolerance()).thenReturn(ErrorTolerance.ALL.toString()); + + ErrantRecordReporter reporter = mock(ErrantRecordReporter.class); + when(reporter.report(any(), any())) + .then( + invocation -> { + return null; + }); + + Map badValue = ImmutableMap.of("id", "abc"); + List writerResults1 = sinkWriterTest(badValue, config, reporter); + assertThat(writerResults1.size()).isEqualTo(1); + + // Verify report function was called once + Mockito.verify(reporter, Mockito.times(1)).report(any(), any()); + } } diff --git a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/WriterTestBase.java b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/WriterTestBase.java index d25bfde85c0b..30b60fb3c542 100644 --- a/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/WriterTestBase.java +++ b/kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/data/WriterTestBase.java @@ -25,11 +25,14 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.util.List; +import java.util.UUID; import org.apache.iceberg.LocationProviders; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.connect.IcebergSinkConfig; +import org.apache.iceberg.connect.events.TableReference; import org.apache.iceberg.data.Record; import org.apache.iceberg.encryption.PlaintextEncryptionManager; import org.apache.iceberg.inmemory.InMemoryFileIO; @@ -73,7 +76,9 @@ public void before() { protected WriteResult writeTest( List rows, IcebergSinkConfig config, Class expectedWriterClass) { - try (TaskWriter writer = RecordUtils.createTableWriter(table, "name", config)) { + TableReference tableReference = + TableReference.of("test_catalog", TableIdentifier.of("name"), UUID.randomUUID()); + try (TaskWriter writer = RecordUtils.createTableWriter(table, tableReference, config)) { assertThat(writer.getClass()).isEqualTo(expectedWriterClass); rows.forEach( diff --git a/mr/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java b/mr/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java index 58966c666d5d..033b35985046 100644 --- a/mr/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java +++ b/mr/src/main/java/org/apache/iceberg/mr/mapreduce/IcebergInputFormat.java @@ -21,13 +21,9 @@ import java.io.IOException; import java.io.Serializable; import java.io.UncheckedIOException; -import java.util.Collections; import java.util.Iterator; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.concurrent.ExecutorService; -import java.util.function.BiFunction; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.mapreduce.InputFormat; import org.apache.hadoop.mapreduce.InputSplit; @@ -39,8 +35,6 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DataTableScan; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.MetadataColumns; -import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.SerializableTable; @@ -49,19 +43,17 @@ import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.TableScan; -import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.data.GenericDeleteFilter; -import org.apache.iceberg.data.IdentityPartitionConverters; import org.apache.iceberg.data.InternalRecordWrapper; -import org.apache.iceberg.data.avro.PlannedDataReader; -import org.apache.iceberg.data.orc.GenericOrcReader; -import org.apache.iceberg.data.parquet.GenericParquetReaders; +import org.apache.iceberg.data.Record; import org.apache.iceberg.encryption.EncryptedFiles; import org.apache.iceberg.encryption.EncryptionManager; import org.apache.iceberg.expressions.Evaluator; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.hadoop.HadoopConfigurable; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; @@ -70,13 +62,7 @@ import org.apache.iceberg.mapping.NameMappingParser; import org.apache.iceberg.mr.Catalogs; import org.apache.iceberg.mr.InputFormatConfig; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.types.Type; -import org.apache.iceberg.types.TypeUtil; -import org.apache.iceberg.util.PartitionUtil; import org.apache.iceberg.util.SerializationUtil; import org.apache.iceberg.util.ThreadPools; @@ -320,29 +306,34 @@ public void close() throws IOException { currentIterator.close(); } + @SuppressWarnings("unchecked") private CloseableIterable openTask(FileScanTask currentTask, Schema readSchema) { DataFile file = currentTask.file(); InputFile inputFile = encryptionManager.decrypt( EncryptedFiles.encryptedInput(io.newInputFile(file.location()), file.keyMetadata())); - CloseableIterable iterable; - switch (file.format()) { - case AVRO: - iterable = newAvroIterable(inputFile, currentTask, readSchema); - break; - case ORC: - iterable = newOrcIterable(inputFile, currentTask, readSchema); - break; - case PARQUET: - iterable = newParquetIterable(inputFile, currentTask, readSchema); - break; - default: - throw new UnsupportedOperationException( - String.format("Cannot read %s file: %s", file.format().name(), file.location())); + ReadBuilder readBuilder = + FormatModelRegistry.readBuilder(file.format(), Record.class, inputFile); + + if (reuseContainers) { + readBuilder = readBuilder.reuseContainers(); } - return iterable; + if (nameMapping != null) { + readBuilder = readBuilder.withNameMapping(NameMappingParser.fromJson(nameMapping)); + } + + return applyResidualFiltering( + (CloseableIterable) + readBuilder + .project(readSchema) + .split(currentTask.start(), currentTask.length()) + .caseSensitive(caseSensitive) + .filter(currentTask.residual()) + .build(), + currentTask.residual(), + readSchema); } @SuppressWarnings("unchecked") @@ -369,86 +360,6 @@ private CloseableIterable applyResidualFiltering( } } - private CloseableIterable newAvroIterable( - InputFile inputFile, FileScanTask task, Schema readSchema) { - Avro.ReadBuilder avroReadBuilder = - Avro.read(inputFile).project(readSchema).split(task.start(), task.length()); - if (reuseContainers) { - avroReadBuilder.reuseContainers(); - } - if (nameMapping != null) { - avroReadBuilder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - - avroReadBuilder.createResolvingReader( - schema -> - PlannedDataReader.create( - schema, constantsMap(task, IdentityPartitionConverters::convertConstant))); - return applyResidualFiltering(avroReadBuilder.build(), task.residual(), readSchema); - } - - private CloseableIterable newParquetIterable( - InputFile inputFile, FileScanTask task, Schema readSchema) { - Parquet.ReadBuilder parquetReadBuilder = - Parquet.read(inputFile) - .project(readSchema) - .filter(task.residual()) - .caseSensitive(caseSensitive) - .split(task.start(), task.length()); - if (reuseContainers) { - parquetReadBuilder.reuseContainers(); - } - if (nameMapping != null) { - parquetReadBuilder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - parquetReadBuilder.createReaderFunc( - fileSchema -> - GenericParquetReaders.buildReader( - readSchema, - fileSchema, - constantsMap(task, IdentityPartitionConverters::convertConstant))); - CloseableIterable parquetIterator = parquetReadBuilder.build(); - return applyResidualFiltering(parquetIterator, task.residual(), readSchema); - } - - private CloseableIterable newOrcIterable( - InputFile inputFile, FileScanTask task, Schema readSchema) { - Map idToConstant = - constantsMap(task, IdentityPartitionConverters::convertConstant); - Schema readSchemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - readSchema, Sets.union(idToConstant.keySet(), MetadataColumns.metadataFieldIds())); - - // ORC does not support reuse containers yet - ORC.ReadBuilder orcReadBuilder = - ORC.read(inputFile) - .project(readSchemaWithoutConstantAndMetadataFields) - .filter(task.residual()) - .caseSensitive(caseSensitive) - .split(task.start(), task.length()); - orcReadBuilder.createReaderFunc( - fileSchema -> GenericOrcReader.buildReader(readSchema, fileSchema, idToConstant)); - - if (nameMapping != null) { - orcReadBuilder.withNameMapping(NameMappingParser.fromJson(nameMapping)); - } - CloseableIterable orcIterator = orcReadBuilder.build(); - return applyResidualFiltering(orcIterator, task.residual(), readSchema); - } - - private Map constantsMap( - FileScanTask task, BiFunction converter) { - PartitionSpec spec = task.spec(); - Set idColumns = spec.identitySourceIds(); - Schema partitionSchema = TypeUtil.select(expectedSchema, idColumns); - boolean projectsIdentityPartitionColumns = !partitionSchema.columns().isEmpty(); - if (projectsIdentityPartitionColumns) { - return PartitionUtil.constantsMap(task, converter); - } else { - return Collections.emptyMap(); - } - } - private static Schema readSchema( Configuration conf, Schema tableSchema, boolean caseSensitive) { Schema readSchema = InputFormatConfig.readSchema(conf); diff --git a/nessie/src/main/java/org/apache/iceberg/nessie/NessieIcebergClient.java b/nessie/src/main/java/org/apache/iceberg/nessie/NessieIcebergClient.java index d901ffd5572f..f86e49c912ed 100644 --- a/nessie/src/main/java/org/apache/iceberg/nessie/NessieIcebergClient.java +++ b/nessie/src/main/java/org/apache/iceberg/nessie/NessieIcebergClient.java @@ -18,6 +18,7 @@ */ package org.apache.iceberg.nessie; +import java.io.UncheckedIOException; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; @@ -251,13 +252,13 @@ public void createNamespace(Namespace namespace, Map metadata) { String.format("Cannot create namespace '%s': %s", namespace, e.getMessage())); } } catch (NessieNotFoundException e) { - throw new RuntimeException( + throw new UncheckedIOException( String.format( "Cannot create namespace '%s': ref '%s' is no longer valid.", namespace, getRef().getName()), e); } catch (BaseNessieClientServerException e) { - throw new RuntimeException( + throw new UncheckedIOException( String.format("Cannot create namespace '%s': %s", namespace, e.getMessage()), e); } } @@ -348,8 +349,8 @@ public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyExcept throw new NamespaceNotEmptyException(e, "Namespace '%s' is not empty.", namespace); } } - throw new RuntimeException( - String.format("Cannot drop namespace '%s': %s", namespace, e.getMessage())); + throw new UncheckedIOException( + String.format("Cannot drop namespace '%s': %s", namespace, e.getMessage()), e); } } catch (NessieNotFoundException e) { LOG.error( @@ -358,7 +359,7 @@ public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyExcept getRef().getName(), e); } catch (BaseNessieClientServerException e) { - throw new RuntimeException( + throw new UncheckedIOException( String.format("Cannot drop namespace '%s': %s", namespace, e.getMessage()), e); } return false; @@ -382,7 +383,7 @@ public Map loadNamespaceMetadata(Namespace namespace) () -> new NoSuchNamespaceException("Namespace does not exist: %s", namespace)) .getProperties(); } catch (NessieNotFoundException e) { - throw new RuntimeException( + throw new UncheckedIOException( String.format( "Cannot load namespace '%s': ref '%s' is no longer valid.", namespace, getRef().getName()), @@ -430,19 +431,20 @@ private boolean updateProperties(Namespace namespace, Consumer handleBadRequestForCommit( } } } catch (NessieNotFoundException e) { - return Optional.of(new RuntimeException(e)); + return Optional.of(new UncheckedIOException(e)); } return Optional.empty(); diff --git a/nessie/src/main/java/org/apache/iceberg/nessie/NessieViewOperations.java b/nessie/src/main/java/org/apache/iceberg/nessie/NessieViewOperations.java index e1d46550358c..e8876e01e501 100644 --- a/nessie/src/main/java/org/apache/iceberg/nessie/NessieViewOperations.java +++ b/nessie/src/main/java/org/apache/iceberg/nessie/NessieViewOperations.java @@ -18,6 +18,7 @@ */ package org.apache.iceberg.nessie; +import java.io.UncheckedIOException; import org.apache.iceberg.exceptions.NoSuchViewException; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.view.BaseViewOperations; @@ -55,7 +56,7 @@ public void doRefresh() { try { client.refresh(); } catch (NessieNotFoundException e) { - throw new RuntimeException( + throw new UncheckedIOException( String.format( "Failed to refresh as ref '%s' is no longer valid.", client.getRef().getName()), e); @@ -87,7 +88,7 @@ public void doRefresh() { 2, location -> NessieUtil.loadViewMetadata( - ViewMetadataParser.read(io().newInputFile(location)), location, reference)); + ViewMetadataParser.read(io(), location), location, reference)); } @Override diff --git a/nessie/src/test/java/org/apache/iceberg/nessie/BaseTestIceberg.java b/nessie/src/test/java/org/apache/iceberg/nessie/BaseTestIceberg.java index 40e33759791f..dd33336cf800 100644 --- a/nessie/src/test/java/org/apache/iceberg/nessie/BaseTestIceberg.java +++ b/nessie/src/test/java/org/apache/iceberg/nessie/BaseTestIceberg.java @@ -39,6 +39,7 @@ import org.apache.iceberg.DataFiles; import org.apache.iceberg.Files; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadataParser; import org.apache.iceberg.TableOperations; @@ -250,7 +251,7 @@ void createBranch(String name) throws NessieNotFoundException, NessieConflictExc void createBranch(String name, String hash) throws NessieNotFoundException, NessieConflictException { - createBranch(name, hash, "main"); + createBranch(name, hash, SnapshotRef.MAIN_BRANCH); } void createBranch(String name, String hash, String sourceRef) diff --git a/nessie/src/test/java/org/apache/iceberg/nessie/TestBranchVisibility.java b/nessie/src/test/java/org/apache/iceberg/nessie/TestBranchVisibility.java index 4f1b87022f47..4d2cce4fa3e0 100644 --- a/nessie/src/test/java/org/apache/iceberg/nessie/TestBranchVisibility.java +++ b/nessie/src/test/java/org/apache/iceberg/nessie/TestBranchVisibility.java @@ -30,6 +30,7 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadataParser; import org.apache.iceberg.Transaction; @@ -64,7 +65,7 @@ public class TestBranchVisibility extends BaseTestIceberg { private int schemaCounter = 1; public TestBranchVisibility() { - super("main"); + super(SnapshotRef.MAIN_BRANCH); } @BeforeEach @@ -80,7 +81,7 @@ public void after() throws NessieNotFoundException, NessieConflictException { catalog.dropTable(tableIdentifier1); catalog.dropTable(tableIdentifier2); for (Reference reference : api.getAllReferences().get().getReferences()) { - if (!reference.getName().equals("main")) { + if (!reference.getName().equals(SnapshotRef.MAIN_BRANCH)) { api.deleteBranch().branch((Branch) reference).delete(); } } @@ -112,7 +113,7 @@ public void testCatalogOnReference() { testCatalogEquality(refCatalog, testCatalog, true, true, () -> {}); // catalog created with hash points to same catalog as above - NessieCatalog refHashCatalog = initCatalog("main"); + NessieCatalog refHashCatalog = initCatalog(SnapshotRef.MAIN_BRANCH); testCatalogEquality(refHashCatalog, catalog, true, true, () -> {}); } @@ -120,7 +121,7 @@ public void testCatalogOnReference() { public void testCatalogWithTableNames() { updateSchema(testCatalog, tableIdentifier2); - String mainName = "main"; + String mainName = SnapshotRef.MAIN_BRANCH; // asking for table@branch gives expected regardless of catalog assertThat(metadataLocation(catalog, TableIdentifier.of("test-ns", "table1@test"))) diff --git a/nessie/src/test/java/org/apache/iceberg/nessie/TestCustomNessieClient.java b/nessie/src/test/java/org/apache/iceberg/nessie/TestCustomNessieClient.java index 2e5be4670b70..02c68431b3c0 100644 --- a/nessie/src/test/java/org/apache/iceberg/nessie/TestCustomNessieClient.java +++ b/nessie/src/test/java/org/apache/iceberg/nessie/TestCustomNessieClient.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.TestCatalogUtil; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; @@ -32,7 +33,7 @@ public class TestCustomNessieClient extends BaseTestIceberg { public TestCustomNessieClient() { - super("main"); + super(SnapshotRef.MAIN_BRANCH); } @Test diff --git a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieCatalog.java b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieCatalog.java index 5123809a04a9..1ae33325a90c 100644 --- a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieCatalog.java +++ b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieCatalog.java @@ -27,6 +27,7 @@ import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.catalog.CatalogTests; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.util.LocationUtil; @@ -120,7 +121,7 @@ protected NessieCatalog initCatalog( "type", "nessie", "ref", - "main", + SnapshotRef.MAIN_BRANCH, CatalogProperties.URI, uri, CatalogProperties.WAREHOUSE_LOCATION, diff --git a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieIcebergClient.java b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieIcebergClient.java index de68656c89ab..c9fb97e0f9e9 100644 --- a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieIcebergClient.java +++ b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieIcebergClient.java @@ -32,6 +32,7 @@ import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.SortOrder; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.catalog.Namespace; @@ -65,7 +66,8 @@ public TestNessieIcebergClient() { @Test public void testWithNullRefLoadsMain() throws NessieNotFoundException { NessieIcebergClient client = new NessieIcebergClient(api, null, null, ImmutableMap.of()); - assertThat(client.getRef().getReference()).isEqualTo(api.getReference().refName("main").get()); + assertThat(client.getRef().getReference()) + .isEqualTo(api.getReference().refName(SnapshotRef.MAIN_BRANCH).get()); } @Test @@ -76,11 +78,15 @@ public void testWithNullHash() throws NessieNotFoundException { @Test public void testWithReference() throws NessieNotFoundException { - NessieIcebergClient client = new NessieIcebergClient(api, "main", null, ImmutableMap.of()); + NessieIcebergClient client = + new NessieIcebergClient(api, SnapshotRef.MAIN_BRANCH, null, ImmutableMap.of()); assertThat(client.withReference(null, null)).isEqualTo(client); - assertThat(client.withReference("main", null)).isNotEqualTo(client); - assertThat(client.withReference("main", api.getReference().refName("main").get().getHash())) + assertThat(client.withReference(SnapshotRef.MAIN_BRANCH, null)).isNotEqualTo(client); + assertThat( + client.withReference( + SnapshotRef.MAIN_BRANCH, + api.getReference().refName(SnapshotRef.MAIN_BRANCH).get().getHash())) .isEqualTo(client); assertThat(client.withReference(BRANCH, null)).isNotEqualTo(client); diff --git a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieTable.java b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieTable.java index 34de63fbef07..def5328d4297 100644 --- a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieTable.java +++ b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieTable.java @@ -43,6 +43,7 @@ import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableOperations; @@ -445,7 +446,10 @@ public void testRegisterTableWithGivenBranch() throws Exception { List metadataVersionFiles = metadataVersionFiles(tableLocation); assertThat(1).isEqualTo(metadataVersionFiles.size()); ImmutableTableReference tableReference = - ImmutableTableReference.builder().reference("main").name(TABLE_NAME).build(); + ImmutableTableReference.builder() + .reference(SnapshotRef.MAIN_BRANCH) + .name(TABLE_NAME) + .build(); TableIdentifier identifier = TableIdentifier.of(DB_NAME, tableReference.toString()); try { api.createNamespace().namespace(DB_NAME).refName(tableReference.getReference()).create(); @@ -517,7 +521,10 @@ public void testRegisterTableMoreThanOneBranch() throws Exception { List metadataVersionFiles = metadataVersionFiles(tableLocation); assertThat(1).isEqualTo(metadataVersionFiles.size()); ImmutableTableReference tableReference = - ImmutableTableReference.builder().reference("main").name(TABLE_NAME).build(); + ImmutableTableReference.builder() + .reference(SnapshotRef.MAIN_BRANCH) + .name(TABLE_NAME) + .build(); TableIdentifier identifier = TableIdentifier.of(DB_NAME, tableReference.toString()); try { api.createNamespace().namespace(DB_NAME).refName(tableReference.getReference()).create(); diff --git a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieViewCatalog.java b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieViewCatalog.java index 7bc24451c814..dc3d5dc0ed4c 100644 --- a/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieViewCatalog.java +++ b/nessie/src/test/java/org/apache/iceberg/nessie/TestNessieViewCatalog.java @@ -25,6 +25,7 @@ import java.nio.file.Path; import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; @@ -82,7 +83,7 @@ public void setUp(NessieClientFactory clientFactory, @NessieClientUri URI nessie initialHashOfDefaultBranch = api.getDefaultBranch().getHash(); uri = nessieUri.toASCIIString(); hadoopConfig = new Configuration(); - catalog = initNessieCatalog("main"); + catalog = initNessieCatalog(SnapshotRef.MAIN_BRANCH); } @AfterEach diff --git a/open-api/LICENSE b/open-api/LICENSE index 604b9c349b22..244ce3953515 100644 --- a/open-api/LICENSE +++ b/open-api/LICENSE @@ -337,11 +337,9 @@ License: https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from the following projects: +This product bundles Jackson JSON Processor. --------------------------------------------------------------------------------- -Group: com.fasterxml.jackson Name: jackson-bom Version: 2.18.0 -Project URL (from POM): https://github.com/FasterXML/jackson-bom +Project URL: https://github.com/FasterXML/jackson License (from POM): Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt -------------------------------------------------------------------------------- @@ -391,7 +389,7 @@ License (from POM): Apache License, Version 2.0 - http://apache.org/licenses/LIC -------------------------------------------------------------------------------- -Group: io.airlift Name: aircompressor Version: 0.27 +Group: io.airlift Name: aircompressor Version: 2.0.3 Project URL (from POM): https://github.com/airlift/aircompressor License (from POM): Apache License 2.0 - https://www.apache.org/licenses/LICENSE-2.0.html @@ -547,7 +545,7 @@ License (from POM): The Apache License, Version 2.0 - https://www.apache.org/lic -------------------------------------------------------------------------------- -Group: org.roaringbitmap Name: RoaringBitmap Version: 1.3.0 +Group: org.roaringbitmap Name: RoaringBitmap Version: 1.6.10 Project URL (from POM): https://github.com/RoaringBitmap/RoaringBitmap License (from POM): Apache 2 - http://www.apache.org/licenses/LICENSE-2.0.txt diff --git a/open-api/Makefile b/open-api/Makefile index 3c2c07936e41..797a2abd9293 100644 --- a/open-api/Makefile +++ b/open-api/Makefile @@ -21,10 +21,12 @@ install: validate-spec: uv run openapi-spec-validator --errors all rest-catalog-open-api.yaml + # TODO remove when s3-signer-open-api.yaml is removed uv run openapi-spec-validator --errors all ../aws/src/main/resources/s3-signer-open-api.yaml lint-spec: uv run yamllint --strict rest-catalog-open-api.yaml + # TODO remove when s3-signer-open-api.yaml is removed uv run yamllint --strict ../aws/src/main/resources/s3-signer-open-api.yaml lint: validate-spec lint-spec diff --git a/open-api/NOTICE b/open-api/NOTICE index 69b9f1e79b52..5db6ad5ea0e1 100644 --- a/open-api/NOTICE +++ b/open-api/NOTICE @@ -1,6 +1,6 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). diff --git a/open-api/requirements.txt b/open-api/requirements.txt index fcc9285b35bd..4e75e426b537 100644 --- a/open-api/requirements.txt +++ b/open-api/requirements.txt @@ -15,6 +15,6 @@ # specific language governing permissions and limitations # under the License. -openapi-spec-validator==0.7.2 -datamodel-code-generator==0.36.0 -yamllint==1.37.1 +openapi-spec-validator==0.8.5 +datamodel-code-generator==0.56.1 +yamllint==1.38.0 diff --git a/open-api/rest-catalog-open-api.py b/open-api/rest-catalog-open-api.py index 6627e4516bc8..f8b3f5bd3771 100644 --- a/open-api/rest-catalog-open-api.py +++ b/open-api/rest-catalog-open-api.py @@ -18,10 +18,10 @@ from __future__ import annotations from datetime import date, timedelta -from typing import Dict, List, Literal, Optional, Union +from typing import Literal from uuid import UUID -from pydantic import BaseModel, Extra, Field +from pydantic import Base64Str, BaseModel, ConfigDict, Field, RootModel class ErrorModel(BaseModel): @@ -33,12 +33,12 @@ class ErrorModel(BaseModel): type: str = Field( ..., description='Internal type definition of the error', - example='NoSuchNamespaceException', + examples=['NoSuchNamespaceException'], ) code: int = Field( - ..., description='HTTP response code', example=404, ge=400, le=600 + ..., description='HTTP response code', examples=[404], ge=400, le=600 ) - stack: Optional[List[str]] = None + stack: list[str] | None = None class CatalogConfig(BaseModel): @@ -46,56 +46,56 @@ class CatalogConfig(BaseModel): Server-provided configuration for the catalog. """ - overrides: Dict[str, str] = Field( + overrides: dict[str, str] = Field( ..., description='Properties that should be used to override client configuration; applied after defaults and client configuration.', ) - defaults: Dict[str, str] = Field( + defaults: dict[str, str] = Field( ..., description='Properties that should be used as default configuration; applied before client configuration.', ) - endpoints: Optional[List[str]] = Field( + endpoints: list[str] | None = Field( None, description='A list of endpoints that the server supports. The format of each endpoint must be " ". The HTTP verb and the resource path must be separated by a space character.', - example=[ - 'GET /v1/{prefix}/namespaces/{namespace}', - 'GET /v1/{prefix}/namespaces', - 'POST /v1/{prefix}/namespaces', - 'GET /v1/{prefix}/namespaces/{namespace}/tables/{table}', - 'GET /v1/{prefix}/namespaces/{namespace}/views/{view}', + examples=[ + [ + 'GET /v1/{prefix}/namespaces/{namespace}', + 'GET /v1/{prefix}/namespaces', + 'POST /v1/{prefix}/namespaces', + 'GET /v1/{prefix}/namespaces/{namespace}/tables/{table}', + 'GET /v1/{prefix}/namespaces/{namespace}/views/{view}', + ] ], ) - idempotency_key_lifetime: Optional[timedelta] = Field( + idempotency_key_lifetime: timedelta | None = Field( None, alias='idempotency-key-lifetime', description='Client reuse window for an Idempotency-Key (ISO-8601 duration, e.g., PT30M, PT24H). Interpreted as the maximum time from the first submission using a key to the last retry during which a client may reuse that key. Servers SHOULD accept retries for at least this duration and MAY include a grace period to account for delays/clock skew. Clients SHOULD NOT reuse an Idempotency-Key after this window elapses; they SHOULD generate a new key for any subsequent attempt. Presence of this field indicates the server supports Idempotency-Key semantics for mutation endpoints. If absent, clients MUST assume idempotency is not supported.', - example='PT30M', + examples=['PT30M'], ) class UpdateNamespacePropertiesRequest(BaseModel): - removals: Optional[List[str]] = Field( - None, example=['department', 'access_group'], unique_items=True - ) - updates: Optional[Dict[str, str]] = Field( - None, example={'owner': 'Hank Bendickson'} + removals: list[str] | None = Field(None, examples=[['department', 'access_group']]) + updates: dict[str, str] | None = Field( + None, examples=[{'owner': 'Hank Bendickson'}] ) -class Namespace(BaseModel): +class Namespace(RootModel[list[str]]): """ Reference to one or more levels of a namespace """ - __root__: List[str] = Field( + root: list[str] = Field( ..., description='Reference to one or more levels of a namespace', - example=['accounting', 'tax'], + examples=[['accounting', 'tax']], ) -class PageToken(BaseModel): - __root__: Optional[str] = Field( +class PageToken(RootModel[str | None]): + root: str | None = Field( None, description='An opaque token that allows clients to make use of pagination for list APIs (e.g. ListTables). Clients may initiate the first paginated request by sending an empty query parameter `pageToken` to the server.\nServers that support pagination should identify the `pageToken` parameter and return a `next-page-token` in the response if there are more results available. After the initial request, the value of `next-page-token` from each response must be used as the `pageToken` parameter value for the next request. The server must return `null` value for the `next-page-token` in the last response.\nServers that support pagination must return all results in a single response with the value of `next-page-token` set to `null` if the query parameter `pageToken` is not set in the request.\nServers that do not support pagination should ignore the `pageToken` parameter and return all results in a single response. The `next-page-token` must be omitted from the response.\nClients must interpret either `null` or missing response value of `next-page-token` as the end of the listing results.', ) @@ -106,86 +106,128 @@ class TableIdentifier(BaseModel): name: str -class PrimitiveType(BaseModel): - __root__: str = Field(..., example=['long', 'string', 'fixed[16]', 'decimal(10,2)']) +class PrimitiveType(RootModel[str]): + root: str = Field(..., examples=[['long', 'string', 'fixed[16]', 'decimal(10,2)']]) -class ExpressionType(BaseModel): - __root__: str = Field( +class ExpressionType(RootModel[str]): + root: str = Field( ..., - example=[ - 'true', - 'false', - 'eq', - 'and', - 'or', - 'not', - 'in', - 'not-in', - 'lt', - 'lt-eq', - 'gt', - 'gt-eq', - 'not-eq', - 'starts-with', - 'not-starts-with', - 'is-null', - 'not-null', - 'is-nan', - 'not-nan', + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] ], ) class TrueExpression(BaseModel): - type: ExpressionType = Field( - default_factory=lambda: ExpressionType.parse_obj('true'), const=True + type: Literal['true'] = Field( + ..., + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] + ], ) class FalseExpression(BaseModel): - type: ExpressionType = Field( - default_factory=lambda: ExpressionType.parse_obj('false'), const=True + type: Literal['false'] = Field( + ..., + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] + ], ) -class Reference(BaseModel): - __root__: str = Field(..., example=['column-name']) +class Reference(RootModel[str]): + root: str = Field(..., examples=[['column-name']]) -class Transform(BaseModel): - __root__: str = Field( +class Transform(RootModel[str]): + root: str = Field( ..., - example=[ - 'identity', - 'year', - 'month', - 'day', - 'hour', - 'bucket[256]', - 'truncate[16]', + examples=[ + ['identity', 'year', 'month', 'day', 'hour', 'bucket[256]', 'truncate[16]'] ], ) class PartitionField(BaseModel): - field_id: Optional[int] = Field(None, alias='field-id') + field_id: int | None = Field(None, alias='field-id') source_id: int = Field(..., alias='source-id') name: str transform: Transform class PartitionSpec(BaseModel): - spec_id: Optional[int] = Field(None, alias='spec-id') - fields: List[PartitionField] + spec_id: int | None = Field(None, alias='spec-id') + fields: list[PartitionField] -class SortDirection(BaseModel): - __root__: Literal['asc', 'desc'] +class SortDirection(RootModel[Literal['asc', 'desc']]): + root: Literal['asc', 'desc'] -class NullOrder(BaseModel): - __root__: Literal['nulls-first', 'nulls-last'] +class NullOrder(RootModel[Literal['nulls-first', 'nulls-last']]): + root: Literal['nulls-first', 'nulls-last'] class SortField(BaseModel): @@ -197,14 +239,14 @@ class SortField(BaseModel): class SortOrder(BaseModel): order_id: int = Field(..., alias='order-id') - fields: List[SortField] + fields: list[SortField] class EncryptedKey(BaseModel): key_id: str = Field(..., alias='key-id') - encrypted_key_metadata: str = Field(..., alias='encrypted-key-metadata') - encrypted_by_id: Optional[str] = Field(None, alias='encrypted-by-id') - properties: Optional[Dict[str, str]] = None + encrypted_key_metadata: Base64Str = Field(..., alias='encrypted-key-metadata') + encrypted_by_id: str | None = Field(None, alias='encrypted-by-id') + properties: dict[str, str] | None = None class Summary(BaseModel): @@ -213,38 +255,38 @@ class Summary(BaseModel): class Snapshot(BaseModel): snapshot_id: int = Field(..., alias='snapshot-id') - parent_snapshot_id: Optional[int] = Field(None, alias='parent-snapshot-id') - sequence_number: Optional[int] = Field(None, alias='sequence-number') + parent_snapshot_id: int | None = Field(None, alias='parent-snapshot-id') + sequence_number: int | None = Field(None, alias='sequence-number') timestamp_ms: int = Field(..., alias='timestamp-ms') manifest_list: str = Field( ..., alias='manifest-list', description="Location of the snapshot's manifest list file", ) - first_row_id: Optional[int] = Field( + first_row_id: int | None = Field( None, alias='first-row-id', description='The first _row_id assigned to the first row in the first data file in the first manifest', ) - added_rows: Optional[int] = Field( + added_rows: int | None = Field( None, alias='added-rows', description='The upper bound of the number of rows with assigned row IDs', ) summary: Summary - schema_id: Optional[int] = Field(None, alias='schema-id') + schema_id: int | None = Field(None, alias='schema-id') class SnapshotReference(BaseModel): type: Literal['tag', 'branch'] snapshot_id: int = Field(..., alias='snapshot-id') - max_ref_age_ms: Optional[int] = Field(None, alias='max-ref-age-ms') - max_snapshot_age_ms: Optional[int] = Field(None, alias='max-snapshot-age-ms') - min_snapshots_to_keep: Optional[int] = Field(None, alias='min-snapshots-to-keep') + max_ref_age_ms: int | None = Field(None, alias='max-ref-age-ms') + max_snapshot_age_ms: int | None = Field(None, alias='max-snapshot-age-ms') + min_snapshots_to_keep: int | None = Field(None, alias='min-snapshots-to-keep') -class SnapshotReferences(BaseModel): - __root__: Dict[str, SnapshotReference] +class SnapshotReferences(RootModel[dict[str, SnapshotReference]]): + root: dict[str, SnapshotReference] class SnapshotLogItem(BaseModel): @@ -252,8 +294,8 @@ class SnapshotLogItem(BaseModel): timestamp_ms: int = Field(..., alias='timestamp-ms') -class SnapshotLog(BaseModel): - __root__: List[SnapshotLogItem] +class SnapshotLog(RootModel[list[SnapshotLogItem]]): + root: list[SnapshotLogItem] class MetadataLogItem(BaseModel): @@ -261,8 +303,8 @@ class MetadataLogItem(BaseModel): timestamp_ms: int = Field(..., alias='timestamp-ms') -class MetadataLog(BaseModel): - __root__: List[MetadataLogItem] +class MetadataLog(RootModel[list[MetadataLogItem]]): + root: list[MetadataLogItem] class SQLViewRepresentation(BaseModel): @@ -271,8 +313,8 @@ class SQLViewRepresentation(BaseModel): dialect: str -class ViewRepresentation(BaseModel): - __root__: SQLViewRepresentation +class ViewRepresentation(RootModel[SQLViewRepresentation]): + root: SQLViewRepresentation class ViewHistoryEntry(BaseModel): @@ -288,9 +330,9 @@ class ViewVersion(BaseModel): alias='schema-id', description='Schema ID to set as current, or -1 to set last added schema', ) - summary: Dict[str, str] - representations: List[ViewRepresentation] - default_catalog: Optional[str] = Field(None, alias='default-catalog') + summary: dict[str, str] + representations: list[ViewRepresentation] + default_catalog: str | None = Field(None, alias='default-catalog') default_namespace: Namespace = Field(..., alias='default-namespace') @@ -303,17 +345,17 @@ class AssignUUIDUpdate(BaseUpdate): Assigning a UUID to a table/view should only be done when creating the table/view. It is not safe to re-assign the UUID if a table/view already has a UUID assigned """ - action: str = Field('assign-uuid', const=True) + action: Literal['assign-uuid'] = 'assign-uuid' uuid: str class UpgradeFormatVersionUpdate(BaseUpdate): - action: str = Field('upgrade-format-version', const=True) + action: Literal['upgrade-format-version'] = 'upgrade-format-version' format_version: int = Field(..., alias='format-version') class SetCurrentSchemaUpdate(BaseUpdate): - action: str = Field('set-current-schema', const=True) + action: Literal['set-current-schema'] = 'set-current-schema' schema_id: int = Field( ..., alias='schema-id', @@ -322,12 +364,12 @@ class SetCurrentSchemaUpdate(BaseUpdate): class AddPartitionSpecUpdate(BaseUpdate): - action: str = Field('add-spec', const=True) + action: Literal['add-spec'] = 'add-spec' spec: PartitionSpec class SetDefaultSpecUpdate(BaseUpdate): - action: str = Field('set-default-spec', const=True) + action: Literal['set-default-spec'] = 'set-default-spec' spec_id: int = Field( ..., alias='spec-id', @@ -336,12 +378,12 @@ class SetDefaultSpecUpdate(BaseUpdate): class AddSortOrderUpdate(BaseUpdate): - action: str = Field('add-sort-order', const=True) + action: Literal['add-sort-order'] = 'add-sort-order' sort_order: SortOrder = Field(..., alias='sort-order') class SetDefaultSortOrderUpdate(BaseUpdate): - action: str = Field('set-default-sort-order', const=True) + action: Literal['set-default-sort-order'] = 'set-default-sort-order' sort_order_id: int = Field( ..., alias='sort-order-id', @@ -350,47 +392,47 @@ class SetDefaultSortOrderUpdate(BaseUpdate): class AddSnapshotUpdate(BaseUpdate): - action: str = Field('add-snapshot', const=True) + action: Literal['add-snapshot'] = 'add-snapshot' snapshot: Snapshot class SetSnapshotRefUpdate(BaseUpdate, SnapshotReference): - action: str = Field('set-snapshot-ref', const=True) + action: Literal['set-snapshot-ref'] = 'set-snapshot-ref' ref_name: str = Field(..., alias='ref-name') class RemoveSnapshotsUpdate(BaseUpdate): - action: str = Field('remove-snapshots', const=True) - snapshot_ids: List[int] = Field(..., alias='snapshot-ids') + action: Literal['remove-snapshots'] = 'remove-snapshots' + snapshot_ids: list[int] = Field(..., alias='snapshot-ids') class RemoveSnapshotRefUpdate(BaseUpdate): - action: str = Field('remove-snapshot-ref', const=True) + action: Literal['remove-snapshot-ref'] = 'remove-snapshot-ref' ref_name: str = Field(..., alias='ref-name') class SetLocationUpdate(BaseUpdate): - action: str = Field('set-location', const=True) + action: Literal['set-location'] = 'set-location' location: str class SetPropertiesUpdate(BaseUpdate): - action: str = Field('set-properties', const=True) - updates: Dict[str, str] + action: Literal['set-properties'] = 'set-properties' + updates: dict[str, str] class RemovePropertiesUpdate(BaseUpdate): - action: str = Field('remove-properties', const=True) - removals: List[str] + action: Literal['remove-properties'] = 'remove-properties' + removals: list[str] class AddViewVersionUpdate(BaseUpdate): - action: str = Field('add-view-version', const=True) + action: Literal['add-view-version'] = 'add-view-version' view_version: ViewVersion = Field(..., alias='view-version') class SetCurrentViewVersionUpdate(BaseUpdate): - action: str = Field('set-current-view-version', const=True) + action: Literal['set-current-view-version'] = 'set-current-view-version' view_version_id: int = Field( ..., alias='view-version-id', @@ -399,32 +441,32 @@ class SetCurrentViewVersionUpdate(BaseUpdate): class RemoveStatisticsUpdate(BaseUpdate): - action: str = Field('remove-statistics', const=True) + action: Literal['remove-statistics'] = 'remove-statistics' snapshot_id: int = Field(..., alias='snapshot-id') class RemovePartitionStatisticsUpdate(BaseUpdate): - action: str = Field('remove-partition-statistics', const=True) + action: Literal['remove-partition-statistics'] = 'remove-partition-statistics' snapshot_id: int = Field(..., alias='snapshot-id') class RemovePartitionSpecsUpdate(BaseUpdate): - action: str = Field('remove-partition-specs', const=True) - spec_ids: List[int] = Field(..., alias='spec-ids') + action: Literal['remove-partition-specs'] = 'remove-partition-specs' + spec_ids: list[int] = Field(..., alias='spec-ids') class RemoveSchemasUpdate(BaseUpdate): - action: str = Field('remove-schemas', const=True) - schema_ids: List[int] = Field(..., alias='schema-ids') + action: Literal['remove-schemas'] = 'remove-schemas' + schema_ids: list[int] = Field(..., alias='schema-ids') class AddEncryptionKeyUpdate(BaseUpdate): - action: str = Field('add-encryption-key', const=True) + action: Literal['add-encryption-key'] = 'add-encryption-key' encryption_key: EncryptedKey = Field(..., alias='encryption-key') class RemoveEncryptionKeyUpdate(BaseUpdate): - action: str = Field('remove-encryption-key', const=True) + action: Literal['remove-encryption-key'] = 'remove-encryption-key' key_id: str = Field(..., alias='key-id') @@ -437,7 +479,7 @@ class AssertCreate(TableRequirement): The table must not already exist; used for create transactions """ - type: str = Field(..., const=True) + type: Literal['assert-create'] class AssertTableUUID(TableRequirement): @@ -445,7 +487,7 @@ class AssertTableUUID(TableRequirement): The table UUID must match the requirement's `uuid` """ - type: str = Field(..., const=True) + type: Literal['assert-table-uuid'] uuid: str @@ -457,7 +499,7 @@ class AssertRefSnapshotId(TableRequirement): """ - type: str = Field('assert-ref-snapshot-id', const=True) + type: Literal['assert-ref-snapshot-id'] = 'assert-ref-snapshot-id' ref: str snapshot_id: int = Field(..., alias='snapshot-id') @@ -467,7 +509,7 @@ class AssertLastAssignedFieldId(TableRequirement): The table's last assigned column id must match the requirement's `last-assigned-field-id` """ - type: str = Field('assert-last-assigned-field-id', const=True) + type: Literal['assert-last-assigned-field-id'] = 'assert-last-assigned-field-id' last_assigned_field_id: int = Field(..., alias='last-assigned-field-id') @@ -476,7 +518,7 @@ class AssertCurrentSchemaId(TableRequirement): The table's current schema id must match the requirement's `current-schema-id` """ - type: str = Field('assert-current-schema-id', const=True) + type: Literal['assert-current-schema-id'] = 'assert-current-schema-id' current_schema_id: int = Field(..., alias='current-schema-id') @@ -485,7 +527,9 @@ class AssertLastAssignedPartitionId(TableRequirement): The table's last assigned partition id must match the requirement's `last-assigned-partition-id` """ - type: str = Field('assert-last-assigned-partition-id', const=True) + type: Literal['assert-last-assigned-partition-id'] = ( + 'assert-last-assigned-partition-id' + ) last_assigned_partition_id: int = Field(..., alias='last-assigned-partition-id') @@ -494,7 +538,7 @@ class AssertDefaultSpecId(TableRequirement): The table's default spec id must match the requirement's `default-spec-id` """ - type: str = Field('assert-default-spec-id', const=True) + type: Literal['assert-default-spec-id'] = 'assert-default-spec-id' default_spec_id: int = Field(..., alias='default-spec-id') @@ -503,7 +547,7 @@ class AssertDefaultSortOrderId(TableRequirement): The table's default sort order id must match the requirement's `default-sort-order-id` """ - type: str = Field('assert-default-sort-order-id', const=True) + type: Literal['assert-default-sort-order-id'] = 'assert-default-sort-order-id' default_sort_order_id: int = Field(..., alias='default-sort-order-id') @@ -512,7 +556,7 @@ class AssertViewUUID(BaseModel): The view UUID must match the requirement's `uuid` """ - type: str = Field('assert-view-uuid', const=True) + type: Literal['assert-view-uuid'] uuid: str @@ -521,17 +565,36 @@ class StorageCredential(BaseModel): ..., description='Indicates a storage location prefix where the credential is relevant. Clients should choose the most specific prefix (by selecting the longest prefix) if several credentials of the same type are available.', ) - config: Dict[str, str] + config: dict[str, str] class LoadCredentialsResponse(BaseModel): - storage_credentials: List[StorageCredential] = Field( + storage_credentials: list[StorageCredential] = Field( ..., alias='storage-credentials' ) -class PlanStatus(BaseModel): - __root__: Literal['completed', 'submitted', 'cancelled', 'failed'] = Field( +class AsyncPlanningResult(BaseModel): + status: Literal['submitted'] = Field( + ..., description='Status of a server-side planning operation' + ) + plan_id: str = Field( + ..., alias='plan-id', description='ID used to track a planning request' + ) + + +class EmptyPlanningResult(BaseModel): + """ + Empty server-side planning result + """ + + status: Literal['cancelled'] = Field( + ..., description='Status of a server-side planning operation' + ) + + +class PlanStatus(RootModel[Literal['completed', 'submitted', 'cancelled', 'failed']]): + root: Literal['completed', 'submitted', 'cancelled', 'failed'] = Field( ..., description='Status of a server-side planning operation' ) @@ -539,14 +602,30 @@ class PlanStatus(BaseModel): class RegisterTableRequest(BaseModel): name: str metadata_location: str = Field(..., alias='metadata-location') - overwrite: Optional[bool] = Field( + overwrite: bool | None = Field( False, description='Whether to overwrite table metadata if the table already exists', ) -class TokenType(BaseModel): - __root__: Literal[ +class RegisterViewRequest(BaseModel): + name: str + metadata_location: str = Field(..., alias='metadata-location') + + +class TokenType( + RootModel[ + Literal[ + 'urn:ietf:params:oauth:token-type:access_token', + 'urn:ietf:params:oauth:token-type:refresh_token', + 'urn:ietf:params:oauth:token-type:id_token', + 'urn:ietf:params:oauth:token-type:saml1', + 'urn:ietf:params:oauth:token-type:saml2', + 'urn:ietf:params:oauth:token-type:jwt', + ] + ] +): + root: Literal[ 'urn:ietf:params:oauth:token-type:access_token', 'urn:ietf:params:oauth:token-type:refresh_token', 'urn:ietf:params:oauth:token-type:id_token', @@ -569,7 +648,7 @@ class OAuthClientCredentialsRequest(BaseModel): """ grant_type: Literal['client_credentials'] - scope: Optional[str] = None + scope: str | None = None client_id: str = Field( ..., description='Client ID\n\nThis can be sent in the request body, but OAuth2 recommends sending it in a Basic Authorization header.', @@ -590,21 +669,24 @@ class OAuthTokenExchangeRequest(BaseModel): """ grant_type: Literal['urn:ietf:params:oauth:grant-type:token-exchange'] - scope: Optional[str] = None - requested_token_type: Optional[TokenType] = None + scope: str | None = None + requested_token_type: TokenType | None = None subject_token: str = Field( ..., description='Subject token for token exchange request' ) subject_token_type: TokenType - actor_token: Optional[str] = Field( + actor_token: str | None = Field( None, description='Actor token for token exchange request' ) - actor_token_type: Optional[TokenType] = None + actor_token_type: TokenType | None = None -class OAuthTokenRequest(BaseModel): - __root__: Union[OAuthClientCredentialsRequest, OAuthTokenExchangeRequest] = Field( +class OAuthTokenRequest( + RootModel[OAuthClientCredentialsRequest | OAuthTokenExchangeRequest] +): + root: OAuthClientCredentialsRequest | OAuthTokenExchangeRequest = Field( ..., + deprecated=True, description='The `oauth/tokens` endpoint and related schemas are **DEPRECATED for REMOVAL** from this spec, see description of the endpoint.', ) @@ -620,12 +702,12 @@ class TimerResult(BaseModel): total_duration: int = Field(..., alias='total-duration') -class MetricResult(BaseModel): - __root__: Union[CounterResult, TimerResult] +class MetricResult(RootModel[CounterResult | TimerResult]): + root: CounterResult | TimerResult -class Metrics(BaseModel): - __root__: Dict[str, MetricResult] +class Metrics(RootModel[dict[str, MetricResult]]): + root: dict[str, MetricResult] class CommitReport(BaseModel): @@ -634,7 +716,7 @@ class CommitReport(BaseModel): sequence_number: int = Field(..., alias='sequence-number') operation: str metrics: Metrics - metadata: Optional[Dict[str, str]] = None + metadata: dict[str, str] | None = None class OAuthError(BaseModel): @@ -650,8 +732,8 @@ class OAuthError(BaseModel): 'unsupported_grant_type', 'invalid_scope', ] - error_description: Optional[str] = None - error_uri: Optional[str] = None + error_description: str | None = None + error_uri: str | None = None class OAuthTokenResponse(BaseModel): @@ -666,15 +748,15 @@ class OAuthTokenResponse(BaseModel): ..., description='Access token type for client credentials or token exchange\n\nSee https://datatracker.ietf.org/doc/html/rfc6749#section-7.1', ) - expires_in: Optional[int] = Field( + expires_in: int | None = Field( None, description='Lifetime of the access token in seconds for client credentials or token exchange', ) - issued_token_type: Optional[TokenType] = None - refresh_token: Optional[str] = Field( + issued_token_type: TokenType | None = None + refresh_token: str | None = Field( None, description='Refresh token for client credentials or token exchange' ) - scope: Optional[str] = Field( + scope: str | None = Field( None, description='Authorization scope for client credentials or token exchange' ) @@ -684,48 +766,46 @@ class IcebergErrorResponse(BaseModel): JSON wrapper for all error responses (non-2xx) """ - class Config: - extra = Extra.forbid - + model_config = ConfigDict( + extra='forbid', + ) error: ErrorModel class CreateNamespaceResponse(BaseModel): namespace: Namespace - properties: Optional[Dict[str, str]] = Field( + properties: dict[str, str] | None = Field( {}, description='Properties stored on the namespace, if supported by the server.', - example={'owner': 'Ralph', 'created_at': '1452120468'}, + examples=[{'owner': 'Ralph', 'created_at': '1452120468'}], ) class GetNamespaceResponse(BaseModel): namespace: Namespace - properties: Optional[Dict[str, str]] = Field( + properties: dict[str, str] | None = Field( {}, description='Properties stored on the namespace, if supported by the server. If the server does not support namespace properties, it should return null for this field. If namespace properties are supported, but none are set, it should return an empty object.', - example={'owner': 'Ralph', 'transient_lastDdlTime': '1452120468'}, + examples=[{'owner': 'Ralph', 'transient_lastDdlTime': '1452120468'}], ) class ListTablesResponse(BaseModel): - next_page_token: Optional[PageToken] = Field(None, alias='next-page-token') - identifiers: Optional[List[TableIdentifier]] = Field(None, unique_items=True) + next_page_token: PageToken | None = Field(None, alias='next-page-token') + identifiers: list[TableIdentifier] | None = None class ListNamespacesResponse(BaseModel): - next_page_token: Optional[PageToken] = Field(None, alias='next-page-token') - namespaces: Optional[List[Namespace]] = Field(None, unique_items=True) + next_page_token: PageToken | None = Field(None, alias='next-page-token') + namespaces: list[Namespace] | None = None class UpdateNamespacePropertiesResponse(BaseModel): - updated: List[str] = Field( - ..., - description='List of property keys that were added or updated', - unique_items=True, + updated: list[str] = Field( + ..., description='List of property keys that were added or updated' ) - removed: List[str] = Field(..., description='List of properties that were removed') - missing: Optional[List[str]] = Field( + removed: list[str] = Field(..., description='List of properties that were removed') + missing: list[str] | None = Field( None, description="List of properties requested for removal that were not found in the namespace's properties. Represents a partial success response. Server's do not need to implement this.", ) @@ -735,8 +815,8 @@ class BlobMetadata(BaseModel): type: str snapshot_id: int = Field(..., alias='snapshot-id') sequence_number: int = Field(..., alias='sequence-number') - fields: List[int] - properties: Optional[Dict[str, str]] = None + fields: list[int] + properties: dict[str, str] | None = None class PartitionStatisticsFile(BaseModel): @@ -745,145 +825,164 @@ class PartitionStatisticsFile(BaseModel): file_size_in_bytes: int = Field(..., alias='file-size-in-bytes') -class BooleanTypeValue(BaseModel): - __root__: bool = Field(..., example=True) +class BooleanTypeValue(RootModel[bool]): + root: bool = Field(..., examples=[True]) -class IntegerTypeValue(BaseModel): - __root__: int = Field(..., example=42) +class IntegerTypeValue(RootModel[int]): + root: int = Field(..., examples=[42]) -class LongTypeValue(BaseModel): - __root__: int = Field(..., example=9223372036854775807) +class LongTypeValue(RootModel[int]): + root: int = Field(..., examples=[9223372036854775807]) -class FloatTypeValue(BaseModel): - __root__: float = Field(..., example=3.14) +class FloatTypeValue(RootModel[float]): + root: float = Field(..., examples=[3.14]) -class DoubleTypeValue(BaseModel): - __root__: float = Field(..., example=123.456) +class DoubleTypeValue(RootModel[float]): + root: float = Field(..., examples=[123.456]) -class DecimalTypeValue(BaseModel): - __root__: str = Field( +class DecimalTypeValue(RootModel[str]): + root: str = Field( ..., description="Decimal type values are serialized as strings. Decimals with a positive scale serialize as numeric plain text, while decimals with a negative scale use scientific notation and the exponent will be equal to the negated scale. For instance, a decimal with a positive scale is '123.4500', with zero scale is '2', and with a negative scale is '2E+20'", - example='123.4500', + examples=['123.4500'], ) -class StringTypeValue(BaseModel): - __root__: str = Field(..., example='hello') +class StringTypeValue(RootModel[str]): + root: str = Field(..., examples=['hello']) -class UUIDTypeValue(BaseModel): - __root__: UUID = Field( +class UUIDTypeValue(RootModel[UUID]): + root: UUID = Field( ..., description='UUID type values are serialized as a 36-character lowercase string in standard UUID format as specified by RFC-4122', - example='eb26bdb1-a1d8-4aa6-990e-da940875492c', + examples=['eb26bdb1-a1d8-4aa6-990e-da940875492c'], max_length=36, min_length=36, - regex='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', ) -class DateTypeValue(BaseModel): - __root__: date = Field( +class DateTypeValue(RootModel[date]): + root: date = Field( ..., description="Date type values follow the 'YYYY-MM-DD' ISO-8601 standard date format", - example='2007-12-03', + examples=['2007-12-03'], ) -class TimeTypeValue(BaseModel): - __root__: str = Field( +class TimeTypeValue(RootModel[str]): + root: str = Field( ..., description="Time type values follow the 'HH:MM:SS.ssssss' ISO-8601 format with microsecond precision", - example='22:31:08.123456', + examples=['22:31:08.123456'], ) -class TimestampTypeValue(BaseModel): - __root__: str = Field( +class TimestampTypeValue(RootModel[str]): + root: str = Field( ..., description="Timestamp type values follow the 'YYYY-MM-DDTHH:MM:SS.ssssss' ISO-8601 format with microsecond precision", - example='2007-12-03T10:15:30.123456', + examples=['2007-12-03T10:15:30.123456'], ) -class TimestampTzTypeValue(BaseModel): - __root__: str = Field( +class TimestampTzTypeValue(RootModel[str]): + root: str = Field( ..., description="TimestampTz type values follow the 'YYYY-MM-DDTHH:MM:SS.ssssss+00:00' ISO-8601 format with microsecond precision, and a timezone offset (+00:00 for UTC)", - example='2007-12-03T10:15:30.123456+00:00', + examples=['2007-12-03T10:15:30.123456+00:00'], ) -class TimestampNanoTypeValue(BaseModel): - __root__: str = Field( +class TimestampNanoTypeValue(RootModel[str]): + root: str = Field( ..., description="Timestamp_ns type values follow the 'YYYY-MM-DDTHH:MM:SS.sssssssss' ISO-8601 format with nanosecond precision", - example='2007-12-03T10:15:30.123456789', + examples=['2007-12-03T10:15:30.123456789'], ) -class TimestampTzNanoTypeValue(BaseModel): - __root__: str = Field( +class TimestampTzNanoTypeValue(RootModel[str]): + root: str = Field( ..., description="Timestamp_ns type values follow the 'YYYY-MM-DDTHH:MM:SS.sssssssss+00:00' ISO-8601 format with nanosecond precision, and a timezone offset (+00:00 for UTC)", - example='2007-12-03T10:15:30.123456789+00:00', + examples=['2007-12-03T10:15:30.123456789+00:00'], ) -class FixedTypeValue(BaseModel): - __root__: str = Field( +class FixedTypeValue(RootModel[str]): + root: str = Field( ..., description='Fixed length type values are stored and serialized as an uppercase hexadecimal string preserving the fixed length', - example='78797A', + examples=['78797A'], ) -class BinaryTypeValue(BaseModel): - __root__: str = Field( +class BinaryTypeValue(RootModel[str]): + root: str = Field( ..., description='Binary type values are stored and serialized as an uppercase hexadecimal string', - example='78797A', + examples=['78797A'], ) class CountMap(BaseModel): - keys: Optional[List[IntegerTypeValue]] = Field( + keys: list[IntegerTypeValue] | None = Field( None, description='List of integer column ids for each corresponding value' ) - values: Optional[List[LongTypeValue]] = Field( + values: list[LongTypeValue] | None = Field( None, description="List of Long values, matched to 'keys' by index" ) -class PrimitiveTypeValue(BaseModel): - __root__: Union[ - BooleanTypeValue, - IntegerTypeValue, - LongTypeValue, - FloatTypeValue, - DoubleTypeValue, - DecimalTypeValue, - StringTypeValue, - UUIDTypeValue, - DateTypeValue, - TimeTypeValue, - TimestampTypeValue, - TimestampTzTypeValue, - TimestampNanoTypeValue, - TimestampTzNanoTypeValue, - FixedTypeValue, - BinaryTypeValue, +class PrimitiveTypeValue( + RootModel[ + BooleanTypeValue + | IntegerTypeValue + | LongTypeValue + | FloatTypeValue + | DoubleTypeValue + | DecimalTypeValue + | StringTypeValue + | UUIDTypeValue + | DateTypeValue + | TimeTypeValue + | TimestampTypeValue + | TimestampTzTypeValue + | TimestampNanoTypeValue + | TimestampTzNanoTypeValue + | FixedTypeValue + | BinaryTypeValue ] - - -class FileFormat(BaseModel): - __root__: Literal['avro', 'orc', 'parquet', 'puffin'] +): + root: ( + BooleanTypeValue + | IntegerTypeValue + | LongTypeValue + | FloatTypeValue + | DoubleTypeValue + | DecimalTypeValue + | StringTypeValue + | UUIDTypeValue + | DateTypeValue + | TimeTypeValue + | TimestampTypeValue + | TimestampTzTypeValue + | TimestampNanoTypeValue + | TimestampTzNanoTypeValue + | FixedTypeValue + | BinaryTypeValue + ) + + +class FileFormat(RootModel[Literal['avro', 'orc', 'parquet', 'puffin']]): + root: Literal['avro', 'orc', 'parquet', 'puffin'] class ContentFile(BaseModel): @@ -891,10 +990,10 @@ class ContentFile(BaseModel): file_path: str = Field(..., alias='file-path') file_format: FileFormat = Field(..., alias='file-format') spec_id: int = Field(..., alias='spec-id') - partition: List[PrimitiveTypeValue] = Field( + partition: list[PrimitiveTypeValue] = Field( ..., description='A list of partition field values ordered based on the fields of the partition spec specified by the `spec-id`', - example=[1, 'bar'], + examples=[[1, 'bar']], ) file_size_in_bytes: int = Field( ..., alias='file-size-in-bytes', description='Total file size in bytes' @@ -902,23 +1001,23 @@ class ContentFile(BaseModel): record_count: int = Field( ..., alias='record-count', description='Number of records in the file' ) - key_metadata: Optional[BinaryTypeValue] = Field( + key_metadata: BinaryTypeValue | None = Field( None, alias='key-metadata', description='Encryption key metadata blob' ) - split_offsets: Optional[List[int]] = Field( + split_offsets: list[int] | None = Field( None, alias='split-offsets', description='List of splittable offsets' ) - sort_order_id: Optional[int] = Field(None, alias='sort-order-id') + sort_order_id: int | None = Field(None, alias='sort-order-id') class PositionDeleteFile(ContentFile): - content: Literal['position-deletes'] = Field(..., const=True) - content_offset: Optional[int] = Field( + content: Literal['position-deletes'] + content_offset: int | None = Field( None, alias='content-offset', description='Offset within the delete file of delete content', ) - content_size_in_bytes: Optional[int] = Field( + content_size_in_bytes: int | None = Field( None, alias='content-size-in-bytes', description='Length, in bytes, of the delete content; required if content-offset is present', @@ -926,32 +1025,69 @@ class PositionDeleteFile(ContentFile): class EqualityDeleteFile(ContentFile): - content: Literal['equality-deletes'] = Field(..., const=True) - equality_ids: Optional[List[int]] = Field( + content: Literal['equality-deletes'] + equality_ids: list[int] | None = Field( None, alias='equality-ids', description='List of equality field IDs' ) -class FieldName(BaseModel): - __root__: str = Field( +class FieldName(RootModel[str]): + root: str = Field( ..., description='A full field name (including parent field names), such as those passed in APIs like Java `Schema#findField(String name)`.\nThe nested field name follows these rules - Nested struct fields are named by concatenating field names at each struct level using dot (`.`) delimiter, e.g. employer.contact_info.address.zip_code - Nested fields in a map key are named using the keyword `key`, e.g. employee_address_map.key.first_name - Nested fields in a map value are named using the keyword `value`, e.g. employee_address_map.value.zip_code - Nested fields in a list are named using the keyword `element`, e.g. employees.element.first_name', ) -class PlanTask(BaseModel): - __root__: str = Field( +class PlanTask(RootModel[str]): + root: str = Field( ..., description='An opaque string provided by the REST server that represents a unit of work to produce file scan tasks for scan planning. This allows clients to fetch tasks across multiple requests to accommodate large result sets.', ) +class MultiValuedMap(RootModel[dict[str, list[str]]]): + """ + A map of string keys where each key can map to multiple string values. + """ + + root: dict[str, list[str]] + + +class RemoteSignRequest(BaseModel): + """ + The request to be signed remotely. + """ + + region: str + uri: str + method: Literal['PUT', 'GET', 'HEAD', 'POST', 'DELETE', 'PATCH', 'OPTIONS'] + headers: MultiValuedMap + properties: dict[str, str] | None = None + body: str | None = Field( + None, + description='Optional body of the request to send to the signing API. This should only be populated for requests where the body of the message contains content which must be validated before a request is signed, such as the S3 DeleteObjects call.', + ) + provider: str | None = Field( + None, + description='The storage provider for which the request is to be signed. The provider should correspond to the scheme used for a storage native URI. For example `s3` for AWS S3 paths. For backwards compatibility, if this is not specified, the provider is assumed to be `s3`.', + ) + + +class RemoteSignResult(BaseModel): + """ + The result of a remote request signing operation. + """ + + uri: str + headers: MultiValuedMap + + class CreateNamespaceRequest(BaseModel): namespace: Namespace - properties: Optional[Dict[str, str]] = Field( + properties: dict[str, str] | None = Field( {}, description='Configured string to string map of properties for the namespace', - example={'owner': 'Hank Bendickson'}, + examples=[{'owner': 'Hank Bendickson'}], ) @@ -961,20 +1097,20 @@ class RenameTableRequest(BaseModel): class TransformTerm(BaseModel): - type: str = Field('transform', const=True) + type: Literal['transform'] transform: Transform term: Reference class SetPartitionStatisticsUpdate(BaseUpdate): - action: str = Field('set-partition-statistics', const=True) + action: Literal['set-partition-statistics'] = 'set-partition-statistics' partition_statistics: PartitionStatisticsFile = Field( ..., alias='partition-statistics' ) -class ViewRequirement(BaseModel): - __root__: AssertViewUUID = Field(..., discriminator='type') +class ViewRequirement(RootModel[AssertViewUUID]): + root: AssertViewUUID = Field(..., discriminator='type') class FailedPlanningResult(IcebergErrorResponse): @@ -982,24 +1118,11 @@ class FailedPlanningResult(IcebergErrorResponse): Failed server-side planning result """ - status: Literal['failed'] = Field(..., const=True) - - -class AsyncPlanningResult(BaseModel): - status: Literal['submitted'] = Field(..., const=True) - plan_id: str = Field( - ..., alias='plan-id', description='ID used to track a planning request' + status: Literal['failed'] = Field( + ..., description='Status of a server-side planning operation' ) -class EmptyPlanningResult(BaseModel): - """ - Empty server-side planning result - """ - - status: Literal['cancelled'] - - class ReportMetricsRequest2(CommitReport): report_type: str = Field(..., alias='report-type') @@ -1009,94 +1132,170 @@ class StatisticsFile(BaseModel): statistics_path: str = Field(..., alias='statistics-path') file_size_in_bytes: int = Field(..., alias='file-size-in-bytes') file_footer_size_in_bytes: int = Field(..., alias='file-footer-size-in-bytes') - blob_metadata: List[BlobMetadata] = Field(..., alias='blob-metadata') + blob_metadata: list[BlobMetadata] = Field(..., alias='blob-metadata') class ValueMap(BaseModel): - keys: Optional[List[IntegerTypeValue]] = Field( + keys: list[IntegerTypeValue] | None = Field( None, description='List of integer column ids for each corresponding value' ) - values: Optional[List[PrimitiveTypeValue]] = Field( + values: list[PrimitiveTypeValue] | None = Field( None, description="List of primitive type values, matched to 'keys' by index" ) class DataFile(ContentFile): - content: str = Field(..., const=True) - first_row_id: Optional[int] = Field( + content: Literal['data'] + first_row_id: int | None = Field( None, alias='first-row-id', description='The first row ID assigned to the first row in the data file', ) - column_sizes: Optional[CountMap] = Field( + column_sizes: CountMap | None = Field( None, alias='column-sizes', description='Map of column id to total count, including null and NaN', ) - value_counts: Optional[CountMap] = Field( + value_counts: CountMap | None = Field( None, alias='value-counts', description='Map of column id to null value count' ) - null_value_counts: Optional[CountMap] = Field( + null_value_counts: CountMap | None = Field( None, alias='null-value-counts', description='Map of column id to null value count', ) - nan_value_counts: Optional[CountMap] = Field( + nan_value_counts: CountMap | None = Field( None, alias='nan-value-counts', description='Map of column id to number of NaN values in the column', ) - lower_bounds: Optional[ValueMap] = Field( + lower_bounds: ValueMap | None = Field( None, alias='lower-bounds', description='Map of column id to lower bound primitive type values', ) - upper_bounds: Optional[ValueMap] = Field( + upper_bounds: ValueMap | None = Field( None, alias='upper-bounds', description='Map of column id to upper bound primitive type values', ) -class DeleteFile(BaseModel): - __root__: Union[PositionDeleteFile, EqualityDeleteFile] = Field( - ..., discriminator='content' - ) +class DeleteFile(RootModel[PositionDeleteFile | EqualityDeleteFile]): + root: PositionDeleteFile | EqualityDeleteFile = Field(..., discriminator='content') class FetchScanTasksRequest(BaseModel): plan_task: PlanTask = Field(..., alias='plan-task') -class Term(BaseModel): - __root__: Union[Reference, TransformTerm] +class Term(RootModel[Reference | TransformTerm]): + root: Reference | TransformTerm class SetStatisticsUpdate(BaseUpdate): - action: str = Field('set-statistics', const=True) - snapshot_id: Optional[int] = Field( + action: Literal['set-statistics'] = 'set-statistics' + snapshot_id: int | None = Field( None, alias='snapshot-id', + deprecated=True, description='This optional field is **DEPRECATED for REMOVAL** since it contains redundant information. Clients should use the `statistics.snapshot-id` field instead.', ) statistics: StatisticsFile class UnaryExpression(BaseModel): - type: ExpressionType + type: Literal['is-null', 'not-null', 'is-nan', 'not-nan'] = Field( + ..., + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] + ], + ) term: Term class LiteralExpression(BaseModel): - type: ExpressionType + type: Literal[ + 'lt', 'lt-eq', 'gt', 'gt-eq', 'eq', 'not-eq', 'starts-with', 'not-starts-with' + ] = Field( + ..., + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] + ], + ) term: Term value: PrimitiveTypeValue class SetExpression(BaseModel): - type: ExpressionType + type: Literal['in', 'not-in'] = Field( + ..., + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] + ], + ) term: Term - values: List[PrimitiveTypeValue] + values: list[PrimitiveTypeValue] class StructField(BaseModel): @@ -1104,25 +1303,25 @@ class StructField(BaseModel): name: str type: Type required: bool - doc: Optional[str] = None - initial_default: Optional[PrimitiveTypeValue] = Field(None, alias='initial-default') - write_default: Optional[PrimitiveTypeValue] = Field(None, alias='write-default') + doc: str | None = None + initial_default: PrimitiveTypeValue | None = Field(None, alias='initial-default') + write_default: PrimitiveTypeValue | None = Field(None, alias='write-default') class StructType(BaseModel): - type: str = Field('struct', const=True) - fields: List[StructField] + type: Literal['struct'] + fields: list[StructField] class ListType(BaseModel): - type: str = Field('list', const=True) + type: Literal['list'] element_id: int = Field(..., alias='element-id') element: Type element_required: bool = Field(..., alias='element-required') class MapType(BaseModel): - type: str = Field('map', const=True) + type: Literal['map'] key_id: int = Field(..., alias='key-id') key: Type value_id: int = Field(..., alias='value-id') @@ -1130,31 +1329,63 @@ class MapType(BaseModel): value_required: bool = Field(..., alias='value-required') -class Type(BaseModel): - __root__: Union[PrimitiveType, StructType, ListType, MapType] - - -class Expression(BaseModel): - __root__: Union[ - TrueExpression, - FalseExpression, - AndOrExpression, - NotExpression, - SetExpression, - LiteralExpression, - UnaryExpression, - ] - - class AndOrExpression(BaseModel): - type: ExpressionType + type: Literal['and', 'or'] = Field( + ..., + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] + ], + ) left: Expression right: Expression class NotExpression(BaseModel): - type: ExpressionType = Field( - default_factory=lambda: ExpressionType.parse_obj('not'), const=True + type: Literal['not'] = Field( + ..., + examples=[ + [ + 'true', + 'false', + 'eq', + 'and', + 'or', + 'not', + 'in', + 'not-in', + 'lt', + 'lt-eq', + 'gt', + 'gt-eq', + 'not-eq', + 'starts-with', + 'not-starts-with', + 'is-null', + 'not-null', + 'is-nan', + 'not-nan', + ] + ], ) child: Expression @@ -1162,33 +1393,31 @@ class NotExpression(BaseModel): class TableMetadata(BaseModel): format_version: int = Field(..., alias='format-version', ge=1, le=3) table_uuid: str = Field(..., alias='table-uuid') - location: Optional[str] = None - last_updated_ms: Optional[int] = Field(None, alias='last-updated-ms') - next_row_id: Optional[int] = Field( + location: str | None = None + last_updated_ms: int | None = Field(None, alias='last-updated-ms') + next_row_id: int | None = Field( None, alias='next-row-id', description="A long higher than all assigned row IDs; the next snapshot's first-row-id.", ) - properties: Optional[Dict[str, str]] = None - schemas: Optional[List[Schema]] = None - current_schema_id: Optional[int] = Field(None, alias='current-schema-id') - last_column_id: Optional[int] = Field(None, alias='last-column-id') - partition_specs: Optional[List[PartitionSpec]] = Field( - None, alias='partition-specs' - ) - default_spec_id: Optional[int] = Field(None, alias='default-spec-id') - last_partition_id: Optional[int] = Field(None, alias='last-partition-id') - sort_orders: Optional[List[SortOrder]] = Field(None, alias='sort-orders') - default_sort_order_id: Optional[int] = Field(None, alias='default-sort-order-id') - encryption_keys: Optional[List[EncryptedKey]] = Field(None, alias='encryption-keys') - snapshots: Optional[List[Snapshot]] = None - refs: Optional[SnapshotReferences] = None - current_snapshot_id: Optional[int] = Field(None, alias='current-snapshot-id') - last_sequence_number: Optional[int] = Field(None, alias='last-sequence-number') - snapshot_log: Optional[SnapshotLog] = Field(None, alias='snapshot-log') - metadata_log: Optional[MetadataLog] = Field(None, alias='metadata-log') - statistics: Optional[List[StatisticsFile]] = None - partition_statistics: Optional[List[PartitionStatisticsFile]] = Field( + properties: dict[str, str] | None = None + schemas: list[Schema] | None = None + current_schema_id: int | None = Field(None, alias='current-schema-id') + last_column_id: int | None = Field(None, alias='last-column-id') + partition_specs: list[PartitionSpec] | None = Field(None, alias='partition-specs') + default_spec_id: int | None = Field(None, alias='default-spec-id') + last_partition_id: int | None = Field(None, alias='last-partition-id') + sort_orders: list[SortOrder] | None = Field(None, alias='sort-orders') + default_sort_order_id: int | None = Field(None, alias='default-sort-order-id') + encryption_keys: list[EncryptedKey] | None = Field(None, alias='encryption-keys') + snapshots: list[Snapshot] | None = None + refs: SnapshotReferences | None = None + current_snapshot_id: int | None = Field(None, alias='current-snapshot-id') + last_sequence_number: int | None = Field(None, alias='last-sequence-number') + snapshot_log: SnapshotLog | None = Field(None, alias='snapshot-log') + metadata_log: MetadataLog | None = Field(None, alias='metadata-log') + statistics: list[StatisticsFile] | None = None + partition_statistics: list[PartitionStatisticsFile] | None = Field( None, alias='partition-statistics' ) @@ -1198,61 +1427,23 @@ class ViewMetadata(BaseModel): format_version: int = Field(..., alias='format-version', ge=1, le=1) location: str current_version_id: int = Field(..., alias='current-version-id') - versions: List[ViewVersion] - version_log: List[ViewHistoryEntry] = Field(..., alias='version-log') - schemas: List[Schema] - properties: Optional[Dict[str, str]] = None + versions: list[ViewVersion] + version_log: list[ViewHistoryEntry] = Field(..., alias='version-log') + schemas: list[Schema] + properties: dict[str, str] | None = None class AddSchemaUpdate(BaseUpdate): - action: str = Field('add-schema', const=True) + action: Literal['add-schema'] = 'add-schema' schema_: Schema = Field(..., alias='schema') - last_column_id: Optional[int] = Field( + last_column_id: int | None = Field( None, alias='last-column-id', + deprecated=True, description="This optional field is **DEPRECATED for REMOVAL** since it more safe to handle this internally, and shouldn't be exposed to the clients.\nThe highest assigned column ID for the table. This is used to ensure columns are always assigned an unused ID when evolving schemas. When omitted, it will be computed on the server side.", ) -class TableUpdate(BaseModel): - __root__: Union[ - AssignUUIDUpdate, - UpgradeFormatVersionUpdate, - AddSchemaUpdate, - SetCurrentSchemaUpdate, - AddPartitionSpecUpdate, - SetDefaultSpecUpdate, - AddSortOrderUpdate, - SetDefaultSortOrderUpdate, - AddSnapshotUpdate, - SetSnapshotRefUpdate, - RemoveSnapshotsUpdate, - RemoveSnapshotRefUpdate, - SetLocationUpdate, - SetPropertiesUpdate, - RemovePropertiesUpdate, - SetStatisticsUpdate, - RemoveStatisticsUpdate, - RemovePartitionSpecsUpdate, - RemoveSchemasUpdate, - AddEncryptionKeyUpdate, - RemoveEncryptionKeyUpdate, - ] - - -class ViewUpdate(BaseModel): - __root__: Union[ - AssignUUIDUpdate, - UpgradeFormatVersionUpdate, - AddSchemaUpdate, - SetLocationUpdate, - SetPropertiesUpdate, - RemovePropertiesUpdate, - AddViewVersionUpdate, - SetCurrentViewVersionUpdate, - ] - - class LoadTableResult(BaseModel): """ Result used when a table is successfully loaded. @@ -1270,6 +1461,9 @@ class LoadTableResult(BaseModel): ## General Configurations - `token`: Authorization bearer token to use for table requests if OAuth2 security is enabled + - `scan-planning-mode`: Communicates to clients the supported planning mode. Clients should use this value to fail fast if the supported scanning mode is not available on the client. Valid values: + - `client`: Clients MUST use client-side scan planning + - `server`: Clients MUST use server-side scan planning via the `planTableScan` endpoint ## AWS Configurations @@ -1278,7 +1472,7 @@ class LoadTableResult(BaseModel): - `s3.access-key-id`: id for credentials that provide access to the data in S3 - `s3.secret-access-key`: secret for credentials that provide access to data in S3 - `s3.session-token`: if present, this value should be used for as the session token - - `s3.remote-signing-enabled`: if `true` remote signing should be performed as described in the `s3-signer-open-api.yaml` specification + - `s3.remote-signing-enabled`: if `true` remote signing should be performed as described in the `RemoteSignRequest` schema section of this spec document. - `s3.cross-region-access-enabled`: if `true`, S3 Cross-Region bucket access is enabled ## Storage Credentials @@ -1286,16 +1480,22 @@ class LoadTableResult(BaseModel): Credentials for ADLS / GCS / S3 / ... are provided through the `storage-credentials` field. Clients must first check whether the respective credentials exist in the `storage-credentials` field before checking the `config` for credentials. + ## Remote Signing + + If remote signing for a specific storage provider is enabled, clients must respect the following configurations when creating a remote signer client: + - `signer.endpoint`: the remote signer endpoint. Required. Can either be a relative path (to be resolved against `signer.uri`) or an absolute URI. + - `signer.uri`: the base URI to resolve `signer.endpoint` against. Optional. Only meaningful if `signer.endpoint` is a relative path. Defaults to the catalog's base URI if not set. + """ - metadata_location: Optional[str] = Field( + metadata_location: str | None = Field( None, alias='metadata-location', description='May be null if the table is staged as part of a transaction', ) metadata: TableMetadata - config: Optional[Dict[str, str]] = None - storage_credentials: Optional[List[StorageCredential]] = Field( + config: dict[str, str] | None = None + storage_credentials: list[StorageCredential] | None = Field( None, alias='storage-credentials' ) @@ -1314,79 +1514,65 @@ class ScanTasks(BaseModel): """ - delete_files: Optional[List[DeleteFile]] = Field( + delete_files: list[DeleteFile] | None = Field( None, alias='delete-files', description='Delete files referenced by file scan tasks', ) - file_scan_tasks: Optional[List[FileScanTask]] = Field(None, alias='file-scan-tasks') - plan_tasks: Optional[List[PlanTask]] = Field(None, alias='plan-tasks') - - -class FetchPlanningResult(BaseModel): - __root__: Union[ - CompletedPlanningResult, FailedPlanningResult, EmptyPlanningResult - ] = Field( - ..., - description='Result of server-side scan planning for fetchPlanningResult', - discriminator='status', - ) - - -class PlanTableScanResult(BaseModel): - __root__: Union[ - CompletedPlanningWithIDResult, - FailedPlanningResult, - AsyncPlanningResult, - EmptyPlanningResult, - ] = Field( - ..., - description='Result of server-side scan planning for planTableScan', - discriminator='status', - ) + file_scan_tasks: list[FileScanTask] | None = Field(None, alias='file-scan-tasks') + plan_tasks: list[PlanTask] | None = Field(None, alias='plan-tasks') class CommitTableRequest(BaseModel): - identifier: Optional[TableIdentifier] = Field( + identifier: TableIdentifier | None = Field( None, description='Table identifier to update; must be present for CommitTransactionRequest', ) - requirements: List[TableRequirement] - updates: List[TableUpdate] + requirements: list[ + AssertCreate + | AssertTableUUID + | AssertRefSnapshotId + | AssertLastAssignedFieldId + | AssertCurrentSchemaId + | AssertLastAssignedPartitionId + | AssertDefaultSpecId + | AssertDefaultSortOrderId + ] + updates: list[TableUpdate] class CommitViewRequest(BaseModel): - identifier: Optional[TableIdentifier] = Field( + identifier: TableIdentifier | None = Field( None, description='View identifier to update' ) - requirements: Optional[List[ViewRequirement]] = None - updates: List[ViewUpdate] + requirements: list[ViewRequirement] | None = None + updates: list[ViewUpdate] class CommitTransactionRequest(BaseModel): - table_changes: List[CommitTableRequest] = Field(..., alias='table-changes') + table_changes: list[CommitTableRequest] = Field(..., alias='table-changes') class CreateTableRequest(BaseModel): name: str - location: Optional[str] = None + location: str | None = None schema_: Schema = Field(..., alias='schema') - partition_spec: Optional[PartitionSpec] = Field(None, alias='partition-spec') - write_order: Optional[SortOrder] = Field(None, alias='write-order') - stage_create: Optional[bool] = Field(None, alias='stage-create') - properties: Optional[Dict[str, str]] = None + partition_spec: PartitionSpec | None = Field(None, alias='partition-spec') + write_order: SortOrder | None = Field(None, alias='write-order') + stage_create: bool | None = Field(None, alias='stage-create') + properties: dict[str, str] | None = None class CreateViewRequest(BaseModel): name: str - location: Optional[str] = None + location: str | None = None schema_: Schema = Field(..., alias='schema') view_version: ViewVersion = Field( ..., alias='view-version', description='The view version to create, will replace the schema-id sent within the view-version with the id assigned to the provided schema', ) - properties: Dict[str, str] + properties: dict[str, str] class LoadViewResult(BaseModel): @@ -1409,11 +1595,7 @@ class LoadViewResult(BaseModel): metadata_location: str = Field(..., alias='metadata-location') metadata: ViewMetadata - config: Optional[Dict[str, str]] = None - - -class ReportMetricsRequest(BaseModel): - __root__: Union[ReportMetricsRequest1, ReportMetricsRequest2] + config: dict[str, str] | None = None class ScanReport(BaseModel): @@ -1421,10 +1603,10 @@ class ScanReport(BaseModel): snapshot_id: int = Field(..., alias='snapshot-id') filter: Expression schema_id: int = Field(..., alias='schema-id') - projected_field_ids: List[int] = Field(..., alias='projected-field-ids') - projected_field_names: List[str] = Field(..., alias='projected-field-names') + projected_field_ids: list[int] = Field(..., alias='projected-field-ids') + projected_field_names: list[str] = Field(..., alias='projected-field-names') metrics: Metrics - metadata: Optional[Dict[str, str]] = None + metadata: dict[str, str] | None = None class CommitTableResponse(BaseModel): @@ -1433,43 +1615,43 @@ class CommitTableResponse(BaseModel): class PlanTableScanRequest(BaseModel): - snapshot_id: Optional[int] = Field( + snapshot_id: int | None = Field( None, alias='snapshot-id', description='Identifier for the snapshot to scan in a point-in-time scan', ) - select: Optional[List[FieldName]] = Field( + select: list[FieldName] | None = Field( None, description='List of selected schema fields' ) - filter: Optional[Expression] = Field( + filter: Expression | None = Field( None, description='Expression used to filter the table data' ) - min_rows_requested: Optional[int] = Field( + min_rows_requested: int | None = Field( None, alias='min-rows-requested', description='The minimum number of rows requested for the scan. This is used as a hint to the server to not have to return more rows than necessary. It is not required for the server to return that many rows since the scan may not produce that many rows. The server can also return more rows than requested.', ) - case_sensitive: Optional[bool] = Field( + case_sensitive: bool | None = Field( True, alias='case-sensitive', description='Enables case sensitive field matching for filter and select', ) - use_snapshot_schema: Optional[bool] = Field( + use_snapshot_schema: bool | None = Field( False, alias='use-snapshot-schema', description='Whether to use the schema at the time the snapshot was written.\nWhen time travelling, the snapshot schema should be used (true). When scanning a branch, the table schema should be used (false).', ) - start_snapshot_id: Optional[int] = Field( + start_snapshot_id: int | None = Field( None, alias='start-snapshot-id', description='Starting snapshot ID for an incremental scan (exclusive)', ) - end_snapshot_id: Optional[int] = Field( + end_snapshot_id: int | None = Field( None, alias='end-snapshot-id', description='Ending snapshot ID for an incremental scan (inclusive).\nRequired when start-snapshot-id is specified.', ) - stats_fields: Optional[List[FieldName]] = Field( + stats_fields: list[FieldName] | None = Field( None, alias='stats-fields', description='List of fields for which the service should send column stats.', @@ -1478,12 +1660,12 @@ class PlanTableScanRequest(BaseModel): class FileScanTask(BaseModel): data_file: DataFile = Field(..., alias='data-file') - delete_file_references: Optional[List[int]] = Field( + delete_file_references: list[int] | None = Field( None, alias='delete-file-references', description='A list of indices in the delete files array (0-based)', ) - residual_filter: Optional[Expression] = Field( + residual_filter: Expression | None = Field( None, alias='residual-filter', description='An optional filter to be applied to rows in this file scan task.\nIf the residual is not present, the client must produce the residual or use the original filter.', @@ -1491,9 +1673,111 @@ class FileScanTask(BaseModel): class Schema(StructType): - schema_id: Optional[int] = Field(None, alias='schema-id') - identifier_field_ids: Optional[List[int]] = Field( - None, alias='identifier-field-ids' + schema_id: int | None = Field(None, alias='schema-id') + identifier_field_ids: list[int] | None = Field(None, alias='identifier-field-ids') + + +class Type(RootModel[PrimitiveType | StructType | ListType | MapType]): + root: PrimitiveType | StructType | ListType | MapType + + +class Expression( + RootModel[ + TrueExpression + | FalseExpression + | AndOrExpression + | NotExpression + | SetExpression + | LiteralExpression + | UnaryExpression + ] +): + root: ( + TrueExpression + | FalseExpression + | AndOrExpression + | NotExpression + | SetExpression + | LiteralExpression + | UnaryExpression + ) + + +class TableUpdate( + RootModel[ + AssignUUIDUpdate + | UpgradeFormatVersionUpdate + | AddSchemaUpdate + | SetCurrentSchemaUpdate + | AddPartitionSpecUpdate + | SetDefaultSpecUpdate + | AddSortOrderUpdate + | SetDefaultSortOrderUpdate + | AddSnapshotUpdate + | SetSnapshotRefUpdate + | RemoveSnapshotsUpdate + | RemoveSnapshotRefUpdate + | SetLocationUpdate + | SetPropertiesUpdate + | RemovePropertiesUpdate + | SetStatisticsUpdate + | RemoveStatisticsUpdate + | SetPartitionStatisticsUpdate + | RemovePartitionStatisticsUpdate + | RemovePartitionSpecsUpdate + | RemoveSchemasUpdate + | AddEncryptionKeyUpdate + | RemoveEncryptionKeyUpdate + ] +): + root: ( + AssignUUIDUpdate + | UpgradeFormatVersionUpdate + | AddSchemaUpdate + | SetCurrentSchemaUpdate + | AddPartitionSpecUpdate + | SetDefaultSpecUpdate + | AddSortOrderUpdate + | SetDefaultSortOrderUpdate + | AddSnapshotUpdate + | SetSnapshotRefUpdate + | RemoveSnapshotsUpdate + | RemoveSnapshotRefUpdate + | SetLocationUpdate + | SetPropertiesUpdate + | RemovePropertiesUpdate + | SetStatisticsUpdate + | RemoveStatisticsUpdate + | SetPartitionStatisticsUpdate + | RemovePartitionStatisticsUpdate + | RemovePartitionSpecsUpdate + | RemoveSchemasUpdate + | AddEncryptionKeyUpdate + | RemoveEncryptionKeyUpdate + ) + + +class ViewUpdate( + RootModel[ + AssignUUIDUpdate + | UpgradeFormatVersionUpdate + | AddSchemaUpdate + | SetLocationUpdate + | SetPropertiesUpdate + | RemovePropertiesUpdate + | AddViewVersionUpdate + | SetCurrentViewVersionUpdate + ] +): + root: ( + AssignUUIDUpdate + | UpgradeFormatVersionUpdate + | AddSchemaUpdate + | SetLocationUpdate + | SetPropertiesUpdate + | RemovePropertiesUpdate + | AddViewVersionUpdate + | SetCurrentViewVersionUpdate ) @@ -1502,8 +1786,10 @@ class CompletedPlanningResult(ScanTasks): Completed server-side planning result """ - status: Literal['completed'] = Field(..., const=True) - storage_credentials: Optional[List[StorageCredential]] = Field( + status: Literal['completed'] = Field( + ..., description='Status of a server-side planning operation' + ) + storage_credentials: list[StorageCredential] | None = Field( None, alias='storage-credentials', description='Storage credentials for accessing the files returned in the scan result.\nIf the server returns storage credentials as part of the completed scan planning response, the expectation is for the client to use these credentials to read the files returned in the FileScanTasks as part of the scan result.', @@ -1527,19 +1813,57 @@ class CompletedPlanningWithIDResult(CompletedPlanningResult): status: Literal['completed'] -StructField.update_forward_refs() -ListType.update_forward_refs() -MapType.update_forward_refs() -Expression.update_forward_refs() -TableMetadata.update_forward_refs() -ViewMetadata.update_forward_refs() -AddSchemaUpdate.update_forward_refs() -ScanTasks.update_forward_refs() -FetchPlanningResult.update_forward_refs() -PlanTableScanResult.update_forward_refs() -CreateTableRequest.update_forward_refs() -CreateViewRequest.update_forward_refs() -ReportMetricsRequest.update_forward_refs() -CompletedPlanningResult.update_forward_refs() -FetchScanTasksResult.update_forward_refs() -CompletedPlanningWithIDResult.update_forward_refs() +class FetchPlanningResult( + RootModel[CompletedPlanningResult | FailedPlanningResult | EmptyPlanningResult] +): + root: CompletedPlanningResult | FailedPlanningResult | EmptyPlanningResult = Field( + ..., + description='Result of server-side scan planning for fetchPlanningResult', + discriminator='status', + ) + + +class ReportMetricsRequest(RootModel[ReportMetricsRequest1 | ReportMetricsRequest2]): + root: ReportMetricsRequest1 | ReportMetricsRequest2 + + +class PlanTableScanResult( + RootModel[ + CompletedPlanningWithIDResult + | FailedPlanningResult + | AsyncPlanningResult + | EmptyPlanningResult + ] +): + root: ( + CompletedPlanningWithIDResult + | FailedPlanningResult + | AsyncPlanningResult + | EmptyPlanningResult + ) = Field( + ..., + description='Result of server-side scan planning for planTableScan', + discriminator='status', + ) + + +StructField.model_rebuild() +ListType.model_rebuild() +MapType.model_rebuild() +AndOrExpression.model_rebuild() +NotExpression.model_rebuild() +TableMetadata.model_rebuild() +ViewMetadata.model_rebuild() +AddSchemaUpdate.model_rebuild() +ScanTasks.model_rebuild() +CommitTableRequest.model_rebuild() +CommitViewRequest.model_rebuild() +CreateTableRequest.model_rebuild() +CreateViewRequest.model_rebuild() +ScanReport.model_rebuild() +PlanTableScanRequest.model_rebuild() +FileScanTask.model_rebuild() +CompletedPlanningResult.model_rebuild() +FetchScanTasksResult.model_rebuild() +ReportMetricsRequest1.model_rebuild() +CompletedPlanningWithIDResult.model_rebuild() diff --git a/open-api/rest-catalog-open-api.yaml b/open-api/rest-catalog-open-api.yaml index c977f9b9afb8..06d13ec133d9 100644 --- a/open-api/rest-catalog-open-api.yaml +++ b/open-api/rest-catalog-open-api.yaml @@ -27,7 +27,7 @@ info: version: 0.0.1 description: Defines the specification for the first version of the REST Catalog API. - Implementations should ideally support both Iceberg table specs v1 and v2, with priority given to v2. + Implementations should ideally support all Iceberg table spec versions. servers: - url: "{scheme}://{host}/{basePath}" description: Server URL when the port can be inferred from the scheme @@ -162,6 +162,15 @@ paths: $ref: '#/components/responses/UnauthorizedResponse' 403: $ref: '#/components/responses/ForbiddenResponse' + 404: + description: Not Found - Warehouse provided in the `warehouse` query parameter is not found. + content: + application/json: + schema: + $ref: '#/components/schemas/IcebergErrorResponse' + examples: + NoSuchWarehouseExample: + $ref: '#/components/examples/NoSuchWarehouseError' 419: $ref: '#/components/responses/AuthenticationTimeoutResponse' 503: @@ -263,7 +272,10 @@ paths: An optional namespace, underneath which to list namespaces. If not provided, all top-level namespaces should be listed. For backward compatibility, empty string is treated as absent for now. - If parent is a multipart namespace, the parts must be separated by the unit separator (`0x1F`) byte. + If parent is a multipart namespace, the parts must be separated by the namespace separator as + indicated via the /config override `namespace-separator`, which defaults to the unit separator `0x1F` byte (url encoded `%1F`). + To be compatible with older clients, servers must use both the advertised separator and `0x1F` as valid separators when decoding namespaces. + The `namespace-separator` should be provided in a url encoded form. required: false schema: type: string @@ -595,7 +607,7 @@ paths: NamespaceNotFound: $ref: '#/components/examples/NoSuchNamespaceError' 409: - description: Conflict - The table already exists + description: Conflict - The identifier already exists as a table or view content: application/json: schema: @@ -615,6 +627,7 @@ paths: - $ref: '#/components/parameters/prefix' - $ref: '#/components/parameters/namespace' - $ref: '#/components/parameters/table' + - $ref: '#/components/parameters/data-access' post: tags: - Catalog API @@ -659,6 +672,8 @@ paths: needed by calling cancelPlanning. Cancellation is not necessary after fetchScanTasks has been used to fetch scan tasks for each plan task. operationId: planTableScan + parameters: + - $ref: '#/components/parameters/idempotency-key' requestBody: content: application/json: @@ -702,6 +717,7 @@ paths: - $ref: '#/components/parameters/namespace' - $ref: '#/components/parameters/table' - $ref: '#/components/parameters/plan-id' + - $ref: '#/components/parameters/data-access' get: tags: @@ -766,6 +782,8 @@ paths: - Catalog API summary: Cancels scan planning for a plan-id operationId: cancelPlanning + parameters: + - $ref: '#/components/parameters/idempotency-key' description: > Cancels scan planning for a plan-id. @@ -794,6 +812,7 @@ paths: 404: description: Not Found + - NoSuchPlanIdException, the plan-id does not exist - NoSuchTableException, the table does not exist - NoSuchNamespaceException, the namespace does not exist content: @@ -801,6 +820,8 @@ paths: schema: $ref: '#/components/schemas/IcebergErrorResponse' examples: + PlanIdDoesNotExist: + $ref: '#/components/examples/NoSuchPlanIdError' TableDoesNotExist: $ref: '#/components/examples/NoSuchTableError' NamespaceDoesNotExist: @@ -824,6 +845,8 @@ paths: - Catalog API summary: Fetches result tasks for a plan task operationId: fetchScanTasks + parameters: + - $ref: '#/components/parameters/idempotency-key' description: Fetches result tasks for a plan task. requestBody: content: @@ -873,6 +896,7 @@ paths: - Catalog API summary: Register a table in the given namespace using given metadata file location parameters: + - $ref: '#/components/parameters/data-access' - $ref: '#/components/parameters/idempotency-key' description: Register a table using given metadata file location. @@ -903,7 +927,7 @@ paths: NamespaceNotFound: $ref: '#/components/examples/NoSuchNamespaceError' 409: - description: Conflict - The table already exists + description: Conflict - The identifier already exists as a table or view content: application/json: schema: @@ -951,23 +975,24 @@ paths: in: header description: An optional header that allows the server to return 304 (Not Modified) if the metadata - is current. The content is the value of the ETag received in a CreateTableResponse or - LoadTableResponse. + is current. The content is the value of the ETag received in a CreateTableResponse, + LoadTableResponse or CommitTableResponse. required: false schema: type: string - in: query name: snapshots description: - The snapshots to return in the body of the metadata. Setting the value to `all` would - return the full set of snapshots currently valid for the table. Setting the value to - `refs` would load all snapshots referenced by branches or tags. + The snapshots to return in the body of the metadata via the `snapshots` field. Setting + the value to `all` would return the full set of snapshots currently valid for the table. + Setting the value to `refs` would load all snapshots referenced by branches or tags. Default if no param is provided is `all`. required: false schema: type: string enum: [ all, refs ] + - $ref: '#/components/parameters/referenced-by' responses: 200: $ref: '#/components/responses/LoadTableResponse' @@ -1210,6 +1235,7 @@ paths: schema: type: string description: The plan ID that has been used for server-side scan planning + - $ref: '#/components/parameters/referenced-by' description: Load vended credentials for a table from the catalog. responses: 200: @@ -1237,6 +1263,40 @@ paths: 5XX: $ref: '#/components/responses/ServerErrorResponse' + /v1/{prefix}/namespaces/{namespace}/tables/{table}/sign: + parameters: + - $ref: '#/components/parameters/prefix' + - $ref: '#/components/parameters/namespace' + - $ref: '#/components/parameters/table' + + post: + tags: + - Catalog API + summary: Remotely signs requests to object storage + operationId: signRequest + requestBody: + description: The request to be signed + content: + application/json: + schema: + $ref: '#/components/schemas/RemoteSignRequest' + required: true + responses: + 200: + $ref: '#/components/responses/RemoteSignResponse' + 400: + $ref: '#/components/responses/BadRequestErrorResponse' + 401: + $ref: '#/components/responses/UnauthorizedResponse' + 403: + $ref: '#/components/responses/ForbiddenResponse' + 419: + $ref: '#/components/responses/AuthenticationTimeoutResponse' + 503: + $ref: '#/components/responses/ServiceUnavailableResponse' + 5XX: + $ref: '#/components/responses/ServerErrorResponse' + /v1/{prefix}/tables/rename: parameters: - $ref: '#/components/parameters/prefix' @@ -1533,7 +1593,7 @@ paths: NamespaceNotFound: $ref: '#/components/examples/NoSuchNamespaceError' 409: - description: Conflict - The view already exists + description: Conflict - The identifier already exists as a table or view content: application/json: schema: @@ -1574,6 +1634,8 @@ paths: view. The configuration key "token" is used to pass an access token to be used as a bearer token for view requests. Otherwise, a token may be passed using a RFC 8693 token type as a configuration key. For example, "urn:ietf:params:oauth:token-type:jwt=". + parameters: + - $ref: '#/components/parameters/referenced-by' responses: 200: $ref: '#/components/responses/LoadViewResponse' @@ -1823,6 +1885,60 @@ paths: 5XX: $ref: '#/components/responses/ServerErrorResponse' + /v1/{prefix}/namespaces/{namespace}/register-view: + parameters: + - $ref: '#/components/parameters/prefix' + - $ref: '#/components/parameters/namespace' + + post: + tags: + - Catalog API + summary: Register a view in the catalog + parameters: + - $ref: '#/components/parameters/idempotency-key' + description: + Register a view in the given namespace using given metadata file location. + + operationId: registerView + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterViewRequest' + responses: + 200: + $ref: '#/components/responses/LoadViewResponse' + 400: + $ref: '#/components/responses/BadRequestErrorResponse' + 401: + $ref: '#/components/responses/UnauthorizedResponse' + 403: + $ref: '#/components/responses/ForbiddenResponse' + 404: + description: Not Found - The namespace specified does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/IcebergErrorResponse' + examples: + NamespaceNotFound: + $ref: '#/components/examples/NoSuchNamespaceError' + 409: + description: Conflict - The identifier already exists as a table or view + content: + application/json: + schema: + $ref: '#/components/schemas/IcebergErrorResponse' + examples: + ViewAlreadyExists: + $ref: '#/components/examples/ViewAlreadyExistsError' + 419: + $ref: '#/components/responses/AuthenticationTimeoutResponse' + 503: + $ref: '#/components/responses/ServiceUnavailableResponse' + 5XX: + $ref: '#/components/responses/ServerErrorResponse' components: ####################################################### @@ -1835,7 +1951,10 @@ components: required: true description: A namespace identifier as a single string. - Multipart namespace parts should be separated by the unit separator (`0x1F`) byte. + Multipart namespace parts must be separated by the namespace separator as + indicated via the /config override `namespace-separator`, which defaults to the unit separator `0x1F` byte (url encoded `%1F`). + To be compatible with older clients, servers must use both the advertised separator and `0x1F` as valid separators when decoding namespaces. + The `namespace-separator` should be provided in a url encoded form. schema: type: string examples: @@ -1887,12 +2006,8 @@ components: to supply access via any or none of the requested mechanisms. - Specific properties and handling for `vended-credentials` is documented - in the `LoadTableResult` schema section of this spec document. - - - The protocol and specification for `remote-signing` is documented in - the `s3-signer-open-api.yaml` OpenApi spec in the `aws` module. + Specific properties and handling for `vended-credentials` and `remote-signing` + are documented in the `LoadTableResult` schema section of this spec document. required: false schema: @@ -1925,7 +2040,38 @@ components: etag: name: ETag in: header - description: Identifies a unique version of the table metadata. + description: + Identifies a unique version of the table metadata. + + Implementations that support ETags should produce unique tags for responses that return + different metadata content but represent the same version of table metadata. For example, + the `snapshots` query parameter may result in different metadata representations depending + on whether `refs` or `all` is provided, therefore should have distinct ETags. + required: false + schema: + type: string + + referenced-by: + name: referenced-by + in: query + description: + A comma-separated list of fully qualified view names (namespace and view name) representing the view + reference chain when an entity (table or view) is loaded via a view. The list should be ordered with the outermost view + first, followed by any intermediate views it references, down to the view that directly references the entity. + For a simple case where a view directly references the entity, the list contains a single view identifier. + For nested views (a view referencing another view which references the entity), the list contains multiple + view identifiers representing the complete dependency chain. + + The view identifier is a composite string of the format {namespace}{separator}{viewName}. + The namespace-separator (defined in /config) acts as the delimiter. + When parsing, the last occurrence of this separator identifies the boundary between the namespace and the view name. + Multipart namespaces must follow the encoding rules of the parent parameter in the list namespaces endpoint. + + Multiple view identifiers are separated by commas. Servers should split the parameter value on comma characters + to parse individual view identifiers. If view names contain commas, they must be url-encoded as %2C. + + Example with multiple views (where prod%1Fanalytics is a nested namespace which has a quarterly_view which references to monthly_view + which then references the entity being loaded) - prod%1Fanalytics%1Fquarterly_view,prod%1Fanalytics%1Fmonthly_view required: false schema: type: string @@ -3144,6 +3290,8 @@ components: - $ref: '#/components/schemas/RemovePropertiesUpdate' - $ref: '#/components/schemas/SetStatisticsUpdate' - $ref: '#/components/schemas/RemoveStatisticsUpdate' + - $ref: '#/components/schemas/SetPartitionStatisticsUpdate' + - $ref: '#/components/schemas/RemovePartitionStatisticsUpdate' - $ref: '#/components/schemas/RemovePartitionSpecsUpdate' - $ref: '#/components/schemas/RemoveSchemasUpdate' - $ref: '#/components/schemas/AddEncryptionKeyUpdate' @@ -3359,6 +3507,9 @@ components: ## General Configurations - `token`: Authorization bearer token to use for table requests if OAuth2 security is enabled + - `scan-planning-mode`: Communicates to clients the supported planning mode. Clients should use this value to fail fast if the supported scanning mode is not available on the client. Valid values: + - `client`: Clients MUST use client-side scan planning + - `server`: Clients MUST use server-side scan planning via the `planTableScan` endpoint ## AWS Configurations @@ -3367,13 +3518,19 @@ components: - `s3.access-key-id`: id for credentials that provide access to the data in S3 - `s3.secret-access-key`: secret for credentials that provide access to data in S3 - `s3.session-token`: if present, this value should be used for as the session token - - `s3.remote-signing-enabled`: if `true` remote signing should be performed as described in the `s3-signer-open-api.yaml` specification + - `s3.remote-signing-enabled`: if `true` remote signing should be performed as described in the `RemoteSignRequest` schema section of this spec document. - `s3.cross-region-access-enabled`: if `true`, S3 Cross-Region bucket access is enabled ## Storage Credentials Credentials for ADLS / GCS / S3 / ... are provided through the `storage-credentials` field. Clients must first check whether the respective credentials exist in the `storage-credentials` field before checking the `config` for credentials. + + ## Remote Signing + + If remote signing for a specific storage provider is enabled, clients must respect the following configurations when creating a remote signer client: + - `signer.endpoint`: the remote signer endpoint. Required. Can either be a relative path (to be resolved against `signer.uri`) or an absolute URI. + - `signer.uri`: the base URI to resolve `signer.endpoint` against. Optional. Only meaningful if `signer.endpoint` is a relative path. Defaults to the catalog's base URI if not set. type: object required: - metadata @@ -3675,6 +3832,17 @@ components: additionalProperties: type: string + RegisterViewRequest: + type: object + required: + - name + - metadata-location + properties: + name: + type: string + metadata-location: + type: string + TokenType: type: string enum: @@ -4573,6 +4741,59 @@ components: allOf: - $ref: '#/components/schemas/Expression' + MultiValuedMap: + description: A map of string keys where each key can map to multiple string values. + type: object + additionalProperties: + type: array + items: + type: string + + RemoteSignRequest: + description: The request to be signed remotely. + type: object + required: + - region + - uri + - method + - headers + properties: + region: + type: string + uri: + type: string + method: + type: string + enum: ["PUT", "GET", "HEAD", "POST", "DELETE", "PATCH", "OPTIONS"] + headers: + $ref: '#/components/schemas/MultiValuedMap' + properties: + type: object + additionalProperties: + type: string + body: + type: string + description: Optional body of the request to send to the signing API. This should only be populated + for requests where the body of the message contains content which must be validated before a request is + signed, such as the S3 DeleteObjects call. + provider: + type: string + description: The storage provider for which the request is to be signed. The provider should correspond to + the scheme used for a storage native URI. For example `s3` for AWS S3 paths. For backwards compatibility, + if this is not specified, the provider is assumed to be `s3`. + + RemoteSignResult: + description: The result of a remote request signing operation. + type: object + required: + - uri + - headers + properties: + uri: + type: string + headers: + $ref: '#/components/schemas/MultiValuedMap' + ############################# # Reusable Response Objects # ############################# @@ -4843,6 +5064,9 @@ components: application/json: schema: $ref: '#/components/schemas/CommitTableResponse' + headers: + etag: + $ref: '#/components/parameters/etag' LoadCredentialsResponse: description: Table credentials result when loading credentials for a table @@ -4851,6 +5075,15 @@ components: schema: $ref: '#/components/schemas/LoadCredentialsResponse' + RemoteSignResponse: + description: The response containing signed & unsigned headers. The server will also send + a Cache-Control header, indicating whether the response can be cached (Cache-Control = ["private"]) + or not (Cache-Control = ["no-cache"]). + content: + application/json: + schema: + $ref: '#/components/schemas/RemoteSignResult' + ####################################### # Common examples of different values # ####################################### @@ -4944,6 +5177,16 @@ components: } } + NoSuchWarehouseError: + summary: The requested warehouse does not exist + value: { + "error": { + "message": "The given warehouse does not exist", + "type": "NoSuchWarehouseException", + "code": 404 + } + } + NoSuchNamespaceError: summary: The requested namespace does not exist value: { @@ -4982,7 +5225,7 @@ components: summary: The requested table identifier already exists value: { "error": { - "message": "The given table already exists", + "message": "The requested table identifier already exists", "type": "AlreadyExistsException", "code": 409 } @@ -4992,7 +5235,7 @@ components: summary: The requested view identifier already exists value: { "error": { - "message": "The given view already exists", + "message": "The requested view identifier already exists", "type": "AlreadyExistsException", "code": 409 } diff --git a/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java b/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java index 87ec90663db2..cbf752e484bd 100644 --- a/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java +++ b/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java @@ -26,6 +26,8 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -97,4 +99,19 @@ protected boolean supportsNamesWithDot() { return PropertyUtil.propertyAsBoolean( restCatalog.properties(), RESTCompatibilityKitSuite.RCK_SUPPORTS_NAMES_WITH_DOT, false); } + + @Override + protected boolean supportsNamesWithSlashes() { + // names with slashes are rejected and considered as suspicious characters after upgrading Jetty + // and the Servlet API. See also + // https://jakarta.ee/specifications/servlet/6.0/jakarta-servlet-spec-6.0.html#uri-path-canonicalization + // for additional details + return false; + } + + @Disabled("RESTServerExtension isn’t configurable per test") + @Test + public void createTableInUniqueLocation() { + super.createTableInUniqueLocation(); + } } diff --git a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RCKUtils.java b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RCKUtils.java index 4bd060d788a7..bfdcfc8a4bd9 100644 --- a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RCKUtils.java +++ b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RCKUtils.java @@ -37,7 +37,13 @@ class RCKUtils { static final String RCK_LOCAL = "rck.local"; static final String RCK_PURGE_TEST_NAMESPACES = "rck.purge-test-namespaces"; - static final List TEST_NAMESPACES = List.of(Namespace.of("ns"), Namespace.of("newdb")); + static final List TEST_NAMESPACES = + List.of( + Namespace.of("ns"), + Namespace.of("newdb"), + Namespace.of("ns1"), + Namespace.of("ns2"), + Namespace.of("other_ns")); private RCKUtils() {} diff --git a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTCatalogServer.java b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTCatalogServer.java index 5f0f89d92646..2e4541b50b33 100644 --- a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTCatalogServer.java +++ b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTCatalogServer.java @@ -28,12 +28,13 @@ import org.apache.iceberg.jdbc.JdbcCatalog; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.util.PropertyUtil; +import org.eclipse.jetty.compression.gzip.GzipCompression; +import org.eclipse.jetty.compression.server.CompressionHandler; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.server.handler.gzip.GzipHandler; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -115,7 +116,9 @@ public void start(boolean join) throws Exception { ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); ServletHolder servletHolder = new ServletHolder(servlet); context.addServlet(servletHolder, "/*"); - context.insertHandler(new GzipHandler()); + CompressionHandler compressionHandler = new CompressionHandler(); + compressionHandler.putCompression(new GzipCompression()); + context.insertHandler(compressionHandler); this.httpServer = new Server( diff --git a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerCatalogAdapter.java b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerCatalogAdapter.java index 8c7b6416e39f..fde23d4b930a 100644 --- a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerCatalogAdapter.java +++ b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerCatalogAdapter.java @@ -18,13 +18,19 @@ */ package org.apache.iceberg.rest; +import java.util.List; import java.util.Map; import java.util.function.Consumer; import org.apache.iceberg.aws.s3.S3FileIOProperties; import org.apache.iceberg.azure.AzureProperties; import org.apache.iceberg.gcp.GCPProperties; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.rest.RESTCatalogServer.CatalogContext; +import org.apache.iceberg.rest.credentials.Credential; +import org.apache.iceberg.rest.credentials.ImmutableCredential; +import org.apache.iceberg.rest.responses.FetchPlanningResultResponse; import org.apache.iceberg.rest.responses.LoadTableResponse; +import org.apache.iceberg.rest.responses.PlanTableScanResponse; import org.apache.iceberg.util.PropertyUtil; class RESTServerCatalogAdapter extends RESTCatalogAdapter { @@ -37,6 +43,7 @@ class RESTServerCatalogAdapter extends RESTCatalogAdapter { this.catalogContext = catalogContext; } + @SuppressWarnings("unchecked") @Override public T handleRequest( Route route, @@ -46,11 +53,30 @@ public T handleRequest( Consumer> responseHeaders) { T restResponse = super.handleRequest(route, vars, httpRequest, responseType, responseHeaders); - if (restResponse instanceof LoadTableResponse) { - if (PropertyUtil.propertyAsBoolean( - catalogContext.configuration(), INCLUDE_CREDENTIALS, false)) { - applyCredentials( - catalogContext.configuration(), ((LoadTableResponse) restResponse).config()); + if (PropertyUtil.propertyAsBoolean( + catalogContext.configuration(), INCLUDE_CREDENTIALS, false)) { + if (restResponse instanceof LoadTableResponse response) { + applyCredentials(catalogContext.configuration(), response.config()); + } else if (restResponse instanceof PlanTableScanResponse response + && PlanStatus.COMPLETED == response.planStatus()) { + return (T) + PlanTableScanResponse.builder() + .withPlanStatus(response.planStatus()) + .withPlanId(response.planId()) + .withFileScanTasks(response.fileScanTasks()) + .withSpecsById(response.specsById()) + .withCredentials(createStorageCredentials(catalogContext.configuration())) + .build(); + } else if (restResponse instanceof FetchPlanningResultResponse response + && PlanStatus.COMPLETED == response.planStatus()) { + return (T) + FetchPlanningResultResponse.builder() + .withPlanStatus(response.planStatus()) + .withFileScanTasks(response.fileScanTasks()) + .withPlanTasks(response.planTasks()) + .withSpecsById(response.specsById()) + .withCredentials(createStorageCredentials(catalogContext.configuration())) + .build(); } } @@ -87,4 +113,66 @@ private void applyCredentials( || entry.getKey().startsWith(AzureProperties.ADLS_CONNECTION_STRING_PREFIX)) .forEach(entry -> tableConfig.put(entry.getKey(), entry.getValue())); } + + private List createStorageCredentials(Map catalogConfig) { + List storageCredentials = Lists.newArrayList(); + + if (catalogConfig.containsKey(S3FileIOProperties.ACCESS_KEY_ID) + && catalogConfig.containsKey(S3FileIOProperties.SECRET_ACCESS_KEY) + && catalogConfig.containsKey(S3FileIOProperties.SESSION_TOKEN)) { + ImmutableCredential.Builder s3 = + ImmutableCredential.builder() + .prefix("s3") + .putConfig( + S3FileIOProperties.ACCESS_KEY_ID, + catalogConfig.get(S3FileIOProperties.ACCESS_KEY_ID)) + .putConfig( + S3FileIOProperties.SECRET_ACCESS_KEY, + catalogConfig.get(S3FileIOProperties.SECRET_ACCESS_KEY)) + .putConfig( + S3FileIOProperties.SESSION_TOKEN, + catalogConfig.get(S3FileIOProperties.SESSION_TOKEN)); + if (catalogConfig.containsKey(S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS)) { + s3.putConfig( + S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS, + catalogConfig.get(S3FileIOProperties.SESSION_TOKEN_EXPIRES_AT_MS)); + } + + storageCredentials.add(s3.build()); + } + + if (catalogConfig.containsKey(GCPProperties.GCS_OAUTH2_TOKEN)) { + ImmutableCredential.Builder gcs = + ImmutableCredential.builder() + .prefix("gcp") + .putConfig( + GCPProperties.GCS_OAUTH2_TOKEN, + catalogConfig.get(GCPProperties.GCS_OAUTH2_TOKEN)); + + if (catalogConfig.containsKey(GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT)) { + gcs.putConfig( + GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT, + catalogConfig.get(GCPProperties.GCS_OAUTH2_TOKEN_EXPIRES_AT)); + } + + storageCredentials.add(gcs.build()); + } + + if (catalogConfig.entrySet().stream() + .anyMatch(entry -> entry.getKey().startsWith(AzureProperties.ADLS_SAS_TOKEN_PREFIX))) { + ImmutableCredential.Builder adls = ImmutableCredential.builder().prefix("adls"); + catalogConfig.entrySet().stream() + .filter( + entry -> + entry.getKey().startsWith(AzureProperties.ADLS_SAS_TOKEN_PREFIX) + || entry.getKey().startsWith(AzureProperties.ADLS_CONNECTION_STRING_PREFIX) + || entry + .getKey() + .startsWith(AzureProperties.ADLS_SAS_TOKEN_EXPIRES_AT_MS_PREFIX)) + .forEach(entry -> adls.putConfig(entry.getKey(), entry.getValue())); + storageCredentials.add(adls.build()); + } + + return storageCredentials; + } } diff --git a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerExtension.java b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerExtension.java index 9b79f7e73e4e..41627d45805e 100644 --- a/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerExtension.java +++ b/open-api/src/testFixtures/java/org/apache/iceberg/rest/RESTServerExtension.java @@ -18,6 +18,7 @@ */ package org.apache.iceberg.rest; +import java.io.UncheckedIOException; import java.net.BindException; import java.util.Map; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -71,7 +72,7 @@ public void beforeAll(ExtensionContext extensionContext) throws Exception { break; } catch (BindException e) { if (!findFreePort || i == maxAttempts - 1) { - throw new RuntimeException("Failed to start REST server", e); + throw new UncheckedIOException("Failed to start REST server", e); } } catch (Exception e) { throw new RuntimeException("Failed to start REST server", e); diff --git a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java index 74c1303dfeda..3bd8bfbfd6b7 100644 --- a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java +++ b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java @@ -76,7 +76,7 @@ public OrcValueReader record( TypeDescription record, List names, List> fields) { - return GenericOrcReaders.struct(fields, expected, idToConstant); + return GenericOrcReaders.struct(record, fields, expected, idToConstant); } @Override diff --git a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java index ba8cbbb749a7..faa62f770e4d 100644 --- a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java +++ b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java @@ -43,6 +43,7 @@ import org.apache.iceberg.variants.Variant; import org.apache.iceberg.variants.VariantMetadata; import org.apache.iceberg.variants.VariantValue; +import org.apache.orc.TypeDescription; import org.apache.orc.storage.ql.exec.vector.BytesColumnVector; import org.apache.orc.storage.ql.exec.vector.ColumnVector; import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector; @@ -56,11 +57,25 @@ public class GenericOrcReaders { private GenericOrcReaders() {} + /** + * @deprecated Use {@link #struct(TypeDescription, List, Types.StructType, Map)} instead. This + * method uses position-based binding which may cause field misalignment in MOR and lineage + * scenarios. + */ + @Deprecated public static OrcValueReader struct( List> readers, Types.StructType struct, Map idToConstant) { return new StructReader(readers, struct, idToConstant); } + public static OrcValueReader struct( + TypeDescription orcType, + List> readers, + Types.StructType struct, + Map idToConstant) { + return new StructReader(orcType, readers, struct, idToConstant); + } + public static OrcValueReader> array(OrcValueReader elementReader) { return new ListReader(elementReader); } @@ -231,6 +246,12 @@ public Variant nonNullRead(ColumnVector vector, int row) { private static class StructReader extends OrcValueReaders.StructReader { private final GenericRecord template; + /** + * @deprecated Use {@link #StructReader(TypeDescription, List, Types.StructType, Map)} instead. + * This constructor uses position-based binding which may cause field misalignment in MOR + * and lineage scenarios. + */ + @Deprecated protected StructReader( List> readers, Types.StructType structType, @@ -239,6 +260,15 @@ protected StructReader( this.template = GenericRecord.create(structType); } + protected StructReader( + TypeDescription orcType, + List> readers, + Types.StructType structType, + Map idToConstant) { + super(orcType, readers, structType, idToConstant); + this.template = GenericRecord.create(structType); + } + @Override protected Record create() { // GenericRecord.copy() is more performant then GenericRecord.create(StructType) since diff --git a/orc/src/main/java/org/apache/iceberg/orc/IdToOrcName.java b/orc/src/main/java/org/apache/iceberg/orc/IdToOrcName.java index 1fb11d7c9e61..d3b189c00326 100644 --- a/orc/src/main/java/org/apache/iceberg/orc/IdToOrcName.java +++ b/orc/src/main/java/org/apache/iceberg/orc/IdToOrcName.java @@ -33,7 +33,7 @@ /** * Generates mapping from field IDs to ORC qualified names. * - *

    This visitor also enclose column names in backticks i.e. ` so that ORC can correctly parse + *

    This visitor also encloses column names in backticks i.e. ` so that ORC can correctly parse * column names with special characters. A comparison of ORC convention with Iceberg convention is * provided below * diff --git a/orc/src/main/java/org/apache/iceberg/orc/ORC.java b/orc/src/main/java/org/apache/iceberg/orc/ORC.java index 451c670fcd54..9fb805246962 100644 --- a/orc/src/main/java/org/apache/iceberg/orc/ORC.java +++ b/orc/src/main/java/org/apache/iceberg/orc/ORC.java @@ -45,6 +45,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; import java.util.stream.Collectors; @@ -52,6 +53,7 @@ import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.MetricsConfig; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; @@ -79,7 +81,10 @@ import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.ArrayUtil; import org.apache.iceberg.util.PropertyUtil; import org.apache.orc.CompressionKind; @@ -180,8 +185,7 @@ public WriteBuilder metricsConfig(MetricsConfig newMetricsConfig) { } // supposed to always be a private method used strictly by data and delete write builders - private WriteBuilder createContextFunc( - Function, Context> newCreateContextFunc) { + WriteBuilder createContextFunc(Function, Context> newCreateContextFunc) { this.createContextFunc = newCreateContextFunc; return this; } @@ -219,7 +223,7 @@ public FileAppender build() { metricsConfig); } - private static class Context { + static class Context { private final long stripeSize; private final long blockSize; private final int vectorizedRowBatchSize; @@ -699,6 +703,7 @@ public static class ReadBuilder { private Function> readerFunc; private Function> batchedReaderFunc; private int recordsPerBatch = VectorizedRowBatch.DEFAULT_SIZE; + private Set constantFieldIds = ImmutableSet.of(); private ReadBuilder(InputFile file) { Preconditions.checkNotNull(file, "Input file cannot be null"); @@ -775,12 +780,24 @@ public ReadBuilder withNameMapping(NameMapping newNameMapping) { return this; } + ReadBuilder constantFieldIds(Set newConstantFieldIds) { + this.constantFieldIds = newConstantFieldIds; + return this; + } + public CloseableIterable build() { Preconditions.checkNotNull(schema, "Schema is required"); + Set idsToExclude = + Sets.difference( + Sets.union(constantFieldIds, MetadataColumns.metadataFieldIds()), + ImmutableSet.of( + MetadataColumns.ROW_ID.fieldId(), + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId())); + return new OrcIterable<>( file, conf, - schema, + TypeUtil.selectNot(schema, idsToExclude), nameMapping, start, length, diff --git a/orc/src/main/java/org/apache/iceberg/orc/ORCFormatModel.java b/orc/src/main/java/org/apache/iceberg/orc/ORCFormatModel.java new file mode 100644 index 000000000000..e9d83cb27bac --- /dev/null +++ b/orc/src/main/java/org/apache/iceberg/orc/ORCFormatModel.java @@ -0,0 +1,316 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.orc; + +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.function.Function; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.orc.GenericOrcWriter; +import org.apache.iceberg.data.orc.GenericOrcWriters; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.BaseFormatModel; +import org.apache.iceberg.formats.ModelWriteBuilder; +import org.apache.iceberg.formats.ReadBuilder; +import org.apache.iceberg.io.DeleteSchemaUtil; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.orc.TypeDescription; + +public class ORCFormatModel + extends BaseFormatModel, R, TypeDescription> { + private final boolean isBatchReader; + + public static ORCFormatModel, Void, Object> forPositionDeletes() { + return new ORCFormatModel<>(PositionDelete.deleteClass(), Void.class, null, null, false); + } + + public static ORCFormatModel> create( + Class type, + Class schemaType, + WriterFunction, S, TypeDescription> writerFunction, + ReaderFunction, S, TypeDescription> readerFunction) { + return new ORCFormatModel<>(type, schemaType, writerFunction, readerFunction, false); + } + + public static ORCFormatModel> create( + Class type, + Class schemaType, + ReaderFunction, S, TypeDescription> batchReaderFunction) { + return new ORCFormatModel<>(type, schemaType, null, batchReaderFunction, true); + } + + private ORCFormatModel( + Class type, + Class schemaType, + WriterFunction, S, TypeDescription> writerFunction, + ReaderFunction readerFunction, + boolean isBatchReader) { + super(type, schemaType, writerFunction, readerFunction); + this.isBatchReader = isBatchReader; + } + + @Override + public FileFormat format() { + return FileFormat.ORC; + } + + @Override + public ModelWriteBuilder writeBuilder(EncryptedOutputFile outputFile) { + return new WriteBuilderWrapper<>(outputFile, writerFunction()); + } + + @Override + public ReadBuilder readBuilder(InputFile inputFile) { + return new ReadBuilderWrapper<>(inputFile, readerFunction(), isBatchReader); + } + + private static class WriteBuilderWrapper implements ModelWriteBuilder { + private final ORC.WriteBuilder internal; + private final WriterFunction, S, TypeDescription> writerFunction; + private Schema schema; + private S engineSchema; + + private FileContent content; + + private WriteBuilderWrapper( + EncryptedOutputFile outputFile, + WriterFunction, S, TypeDescription> writerFunction) { + this.internal = ORC.write(outputFile); + this.writerFunction = writerFunction; + } + + @Override + public ModelWriteBuilder schema(Schema newSchema) { + this.schema = newSchema; + internal.schema(newSchema); + return this; + } + + @Override + public ModelWriteBuilder engineSchema(S newSchema) { + this.engineSchema = newSchema; + return this; + } + + @Override + public ModelWriteBuilder set(String property, String value) { + internal.set(property, value); + return this; + } + + @Override + public ModelWriteBuilder setAll(Map properties) { + internal.setAll(properties); + return this; + } + + @Override + public ModelWriteBuilder meta(String property, String value) { + internal.metadata(property, value); + return this; + } + + @Override + public ModelWriteBuilder content(FileContent newContent) { + this.content = newContent; + return this; + } + + @Override + public ModelWriteBuilder metricsConfig(MetricsConfig metricsConfig) { + internal.metricsConfig(metricsConfig); + return this; + } + + @Override + public ModelWriteBuilder overwrite() { + internal.overwrite(); + return this; + } + + @Override + public ModelWriteBuilder withFileEncryptionKey(ByteBuffer encryptionKey) { + // ORC doesn't support file encryption + throw new UnsupportedOperationException("ORC does not support file encryption keys"); + } + + @Override + public ModelWriteBuilder withAADPrefix(ByteBuffer aadPrefix) { + // ORC doesn't support file encryption + throw new UnsupportedOperationException("ORC does not support AAD prefix"); + } + + @Override + public FileAppender build() { + switch (content) { + case DATA: + internal.createContextFunc(ORC.WriteBuilder.Context::dataContext); + internal.createWriterFunc( + (icebergSchema, typeDescription) -> + writerFunction.write(icebergSchema, typeDescription, engineSchema)); + break; + case EQUALITY_DELETES: + internal.createContextFunc(ORC.WriteBuilder.Context::deleteContext); + internal.createWriterFunc( + (icebergSchema, typeDescription) -> + writerFunction.write(icebergSchema, typeDescription, engineSchema)); + break; + case POSITION_DELETES: + Preconditions.checkState( + schema == null, + "Invalid schema: %s. Position deletes with schema are not supported by the API.", + schema); + Preconditions.checkState( + engineSchema == null, + "Invalid engineSchema: %s. Position deletes with schema are not supported by the API.", + engineSchema); + + internal.createContextFunc(ORC.WriteBuilder.Context::deleteContext); + internal.createWriterFunc( + (icebergSchema, typeDescription) -> + GenericOrcWriters.positionDelete( + GenericOrcWriter.buildWriter(icebergSchema, typeDescription), + Function.identity())); + internal.schema(DeleteSchemaUtil.pathPosSchema()); + break; + default: + throw new IllegalArgumentException("Unknown file content: " + content); + } + + return internal.build(); + } + } + + private static class ReadBuilderWrapper implements ReadBuilder { + private final ORC.ReadBuilder internal; + private final ReaderFunction readerFunction; + private final boolean isBatchReader; + private S engineSchema; + private boolean reuseContainers = false; + private Schema icebergSchema; + private Map idToConstant = ImmutableMap.of(); + + private ReadBuilderWrapper( + InputFile inputFile, + ReaderFunction readerFunction, + boolean isBatchReader) { + this.internal = ORC.read(inputFile); + this.readerFunction = readerFunction; + this.isBatchReader = isBatchReader; + } + + @Override + public ReadBuilder split(long newStart, long newLength) { + internal.split(newStart, newLength); + return this; + } + + @Override + public ReadBuilder project(Schema schema) { + this.icebergSchema = schema; + internal.project(schema); + return this; + } + + @Override + public ReadBuilder engineProjection(S schema) { + this.engineSchema = schema; + return this; + } + + @Override + public ReadBuilder caseSensitive(boolean caseSensitive) { + internal.caseSensitive(caseSensitive); + return this; + } + + @Override + public ReadBuilder filter(Expression filter) { + internal.filter(filter); + return this; + } + + @Override + public ReadBuilder set(String key, String value) { + internal.config(key, value); + return this; + } + + @Override + public ReadBuilder reuseContainers() { + this.reuseContainers = true; + return this; + } + + @Override + public ReadBuilder recordsPerBatch(int numRowsPerBatch) { + if (!isBatchReader) { + throw new UnsupportedOperationException( + "Batch reading is not supported in non-vectorized reader"); + } + + internal.recordsPerBatch(numRowsPerBatch); + return this; + } + + @Override + public ReadBuilder idToConstant(Map newIdToConstant) { + internal.constantFieldIds(newIdToConstant.keySet()); + this.idToConstant = newIdToConstant; + return this; + } + + @Override + public ReadBuilder withNameMapping(NameMapping nameMapping) { + internal.withNameMapping(nameMapping); + return this; + } + + @Override + public org.apache.iceberg.io.CloseableIterable build() { + Preconditions.checkNotNull(reuseContainers, "Reuse containers is required for ORC read"); + if (isBatchReader) { + return internal + .createBatchedReaderFunc( + typeDescription -> + (OrcBatchReader) + readerFunction.read( + icebergSchema, typeDescription, engineSchema, idToConstant)) + .build(); + } else { + return internal + .createReaderFunc( + typeDescription -> + (OrcRowReader) + readerFunction.read( + icebergSchema, typeDescription, engineSchema, idToConstant)) + .build(); + } + } + } +} diff --git a/orc/src/main/java/org/apache/iceberg/orc/ORCSchemaUtil.java b/orc/src/main/java/org/apache/iceberg/orc/ORCSchemaUtil.java index 9fd59324668c..7dd7f0e3e42b 100644 --- a/orc/src/main/java/org/apache/iceberg/orc/ORCSchemaUtil.java +++ b/orc/src/main/java/org/apache/iceberg/orc/ORCSchemaUtil.java @@ -268,7 +268,7 @@ private static TypeDescription convert(Integer fieldId, Type type, boolean isReq } /** - * Convert an ORC schema to an Iceberg schema. This method handles the convertion from the + * Convert an ORC schema to an Iceberg schema. This method handles the conversion from the * original Iceberg column mapping IDs if present in the ORC column attributes, otherwise, ORC * columns with no Iceberg IDs will be ignored and skipped in the conversion. * diff --git a/orc/src/main/java/org/apache/iceberg/orc/OrcFileAppender.java b/orc/src/main/java/org/apache/iceberg/orc/OrcFileAppender.java index 842bd99f4680..48b0e837f1a9 100644 --- a/orc/src/main/java/org/apache/iceberg/orc/OrcFileAppender.java +++ b/orc/src/main/java/org/apache/iceberg/orc/OrcFileAppender.java @@ -138,7 +138,7 @@ public long length() { e); } - // This value is estimated, not actual. + // This value is an estimate, not the actual length. return (long) Math.ceil(dataLength + (estimateMemory + (long) batch.size * avgRowByteSize) * 0.2); } diff --git a/orc/src/main/java/org/apache/iceberg/orc/OrcIterable.java b/orc/src/main/java/org/apache/iceberg/orc/OrcIterable.java index 119b3c54f278..0f65f1b65d9c 100644 --- a/orc/src/main/java/org/apache/iceberg/orc/OrcIterable.java +++ b/orc/src/main/java/org/apache/iceberg/orc/OrcIterable.java @@ -103,6 +103,8 @@ public CloseableIterator iterator() { VectorizedRowBatchIterator rowBatchIterator = newOrcIterator(file, readOrcSchema, start, length, orcFileReader, sarg, recordsPerBatch); + addCloseable(rowBatchIterator); + if (batchReaderFunction != null) { OrcBatchReader batchReader = (OrcBatchReader) batchReaderFunction.apply(readOrcSchema); return CloseableIterator.transform( diff --git a/orc/src/main/java/org/apache/iceberg/orc/OrcMetrics.java b/orc/src/main/java/org/apache/iceberg/orc/OrcMetrics.java index 45415ad513be..867828820d2b 100644 --- a/orc/src/main/java/org/apache/iceberg/orc/OrcMetrics.java +++ b/orc/src/main/java/org/apache/iceberg/orc/OrcMetrics.java @@ -172,7 +172,7 @@ private static Metrics buildOrcMetrics( if (statsColumns.contains(fieldId)) { // Since ORC does not track null values nor repeated ones, the value count for columns in // containers (maps, list) may be larger than what it actually is, however these are not - // used in expressions right now. For such cases, we use the value number of values + // used in expressions right now. For such cases, we use the number of values // directly stored in ORC. if (colStat.hasNull()) { nullCounts.put(fieldId, numOfRows - colStat.getNumberOfValues()); diff --git a/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java b/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java index b6d40a3d7d00..c1fba3f15add 100644 --- a/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java +++ b/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java @@ -22,8 +22,11 @@ import java.util.List; import java.util.Map; import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.apache.orc.TypeDescription; import org.apache.orc.storage.ql.exec.vector.BytesColumnVector; import org.apache.orc.storage.ql.exec.vector.ColumnVector; import org.apache.orc.storage.ql.exec.vector.DoubleColumnVector; @@ -135,12 +138,27 @@ public byte[] nonNullRead(ColumnVector vector, int row) { public abstract static class StructReader implements OrcValueReader { private final OrcValueReader[] readers; private final boolean[] isConstantOrMetadataField; - + // Maps each projected struct field position to the matching child index in the ORC schema. + // This allows fields to be read by Iceberg field ID when the projected struct order differs + // from the file schema. + private final int[] orcFieldIndex; + + /** + * @param readers readers for each field + * @param struct struct type + * @param idToConstant constant values by field id + * @deprecated Use {@link #StructReader(TypeDescription, List, Types.StructType, Map)} instead. + * This constructor uses position-based binding which may cause field misalignment in MOR + * scenarios. This doesn't work lineage scenarios. + */ + @Deprecated protected StructReader( List> readers, Types.StructType struct, Map idToConstant) { List fields = struct.fields(); this.readers = new OrcValueReader[fields.size()]; this.isConstantOrMetadataField = new boolean[fields.size()]; + this.orcFieldIndex = null; + for (int pos = 0, readerIndex = 0; pos < fields.size(); pos += 1) { Types.NestedField field = fields.get(pos); if (idToConstant.containsKey(field.fieldId())) { @@ -154,7 +172,6 @@ protected StructReader( this.readers[pos] = constants(false); } else if (MetadataColumns.isMetadataColumn(field.name()) || field.type().typeId() == Type.TypeID.UNKNOWN) { - // in case of any other metadata field, fill with nulls this.isConstantOrMetadataField[pos] = true; this.readers[pos] = constants(null); } else { @@ -163,6 +180,122 @@ protected StructReader( } } + protected StructReader( + TypeDescription orcType, + List> readers, + Types.StructType struct, + Map idToConstant) { + List fields = struct.fields(); + this.readers = new OrcValueReader[fields.size()]; + this.isConstantOrMetadataField = new boolean[fields.size()]; + this.orcFieldIndex = new int[fields.size()]; + + Map> readersById = readersByFieldId(orcType, readers); + Map fieldIdToOrcIndex = buildFieldIdToOrcIndex(orcType); + + for (int pos = 0; pos < fields.size(); pos += 1) { + Types.NestedField field = fields.get(pos); + OrcValueReader fileReader = readersById.get(field.fieldId()); + int orcIndex = fieldIdToOrcIndex.getOrDefault(field.fieldId(), -1); + + if (field.equals(MetadataColumns.ROW_ID)) { + handleRowIdField(pos, field, fileReader, idToConstant, orcIndex); + } else if (field.equals(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER)) { + handleLastUpdatedSeqField(pos, field, fileReader, idToConstant, orcIndex); + } else if (idToConstant.containsKey(field.fieldId())) { + this.isConstantOrMetadataField[pos] = true; + this.readers[pos] = constants(idToConstant.get(field.fieldId())); + } else if (field.equals(MetadataColumns.ROW_POSITION)) { + this.isConstantOrMetadataField[pos] = true; + this.readers[pos] = new RowPositionReader(); + } else if (field.equals(MetadataColumns.IS_DELETED)) { + this.isConstantOrMetadataField[pos] = true; + this.readers[pos] = constants(false); + } else if (fileReader != null) { + this.isConstantOrMetadataField[pos] = false; + this.orcFieldIndex[pos] = fieldIdToOrcIndex.getOrDefault(field.fieldId(), -1); + this.readers[pos] = fileReader; + } else if (MetadataColumns.isMetadataColumn(field.name()) + || field.type().typeId() == Type.TypeID.UNKNOWN) { + this.isConstantOrMetadataField[pos] = true; + this.readers[pos] = constants(null); + } else { + throw new IllegalArgumentException( + String.format("Missing ORC reader for field %s (%s)", field.name(), field.fieldId())); + } + } + } + + private Map buildFieldIdToOrcIndex(TypeDescription orcType) { + List children = orcType.getChildren(); + Map mapping = Maps.newHashMap(); + for (int i = 0; i < children.size(); i++) { + mapping.put(ORCSchemaUtil.fieldId(children.get(i)), i); + } + + return mapping; + } + + private Map> readersByFieldId( + TypeDescription orcType, List> readerList) { + List children = orcType.getChildren(); + Preconditions.checkState( + children.size() == readerList.size(), + "Invalid ORC reader binding: children=%s readers=%s", + children.size(), + readerList.size()); + + Map> readersById = Maps.newHashMap(); + for (int i = 0; i < children.size(); i += 1) { + readersById.put(ORCSchemaUtil.fieldId(children.get(i)), readerList.get(i)); + } + + return readersById; + } + + @SuppressWarnings("unchecked") + private void handleRowIdField( + int pos, + Types.NestedField field, + OrcValueReader fileReader, + Map idToConstant, + int orcIndex) { + Long firstRowId = (Long) idToConstant.get(field.fieldId()); + if (firstRowId != null) { + OrcValueReader fileIdReader = (OrcValueReader) fileReader; + this.readers[pos] = new RowIdReader(firstRowId, fileIdReader); + this.isConstantOrMetadataField[pos] = fileIdReader == null; + if (fileIdReader != null) { + this.orcFieldIndex[pos] = orcIndex; + } + } else { + this.isConstantOrMetadataField[pos] = true; + this.readers[pos] = constants(null); + } + } + + @SuppressWarnings("unchecked") + private void handleLastUpdatedSeqField( + int pos, + Types.NestedField field, + OrcValueReader fileReader, + Map idToConstant, + int orcIndex) { + Long fileLastUpdated = (Long) idToConstant.get(field.fieldId()); + Long firstRowId = (Long) idToConstant.get(MetadataColumns.ROW_ID.fieldId()); + if (fileLastUpdated != null && firstRowId != null) { + OrcValueReader fileSeqReader = (OrcValueReader) fileReader; + this.readers[pos] = new LastUpdatedSeqReader(fileLastUpdated, fileSeqReader); + this.isConstantOrMetadataField[pos] = fileSeqReader == null; + if (fileSeqReader != null) { + this.orcFieldIndex[pos] = orcIndex; + } + } else { + this.isConstantOrMetadataField[pos] = true; + this.readers[pos] = constants(null); + } + } + protected abstract T create(); protected abstract void set(T struct, int pos, Object value); @@ -178,14 +311,17 @@ public T nonNullRead(ColumnVector vector, int row) { } private T readInternal(T struct, ColumnVector[] columnVectors, int row) { - for (int c = 0, vectorIndex = 0; c < readers.length; ++c) { + int vectorIndex = 0; + for (int c = 0; c < readers.length; ++c) { ColumnVector vector; if (isConstantOrMetadataField[c]) { vector = null; + } else if (orcFieldIndex != null) { + vector = columnVectors[orcFieldIndex[c]]; } else { - vector = columnVectors[vectorIndex]; - vectorIndex++; + vector = columnVectors[vectorIndex++]; } + set(struct, c, reader(c).read(vector, row)); } return struct; @@ -235,4 +371,76 @@ public void setBatchContext(long newBatchOffsetInFile) { this.batchOffsetInFile = newBatchOffsetInFile; } } + + private static class RowIdReader implements OrcValueReader { + private final long firstRowId; + private final OrcValueReader fileIdReader; + private final RowPositionReader posReader; + + RowIdReader(long firstRowId, OrcValueReader fileIdReader) { + this.firstRowId = firstRowId; + this.fileIdReader = fileIdReader; + this.posReader = new RowPositionReader(); + } + + @Override + public Long read(ColumnVector vector, int row) { + if (fileIdReader != null) { + Long idFromFile = fileIdReader.read(vector, row); + if (idFromFile != null) { + return idFromFile; + } + } + + long pos = posReader.read(null, row); + return firstRowId + pos; + } + + @Override + public Long nonNullRead(ColumnVector vector, int row) { + return read(vector, row); + } + + @Override + public void setBatchContext(long batchOffsetInFile) { + posReader.setBatchContext(batchOffsetInFile); + if (fileIdReader != null) { + fileIdReader.setBatchContext(batchOffsetInFile); + } + } + } + + private static class LastUpdatedSeqReader implements OrcValueReader { + private final long fileLastUpdated; + private final OrcValueReader fileSeqReader; + + LastUpdatedSeqReader(long fileLastUpdated, OrcValueReader fileSeqReader) { + this.fileLastUpdated = fileLastUpdated; + this.fileSeqReader = fileSeqReader; + } + + @Override + public Long read(ColumnVector vector, int row) { + if (fileSeqReader != null) { + Long seqFromFile = fileSeqReader.read(vector, row); + if (seqFromFile != null) { + return seqFromFile; + } + } + + return fileLastUpdated; + } + + @Override + public Long nonNullRead(ColumnVector vector, int row) { + return read(vector, row); + } + + @Override + public void setBatchContext(long batchOffsetInFile) { + if (fileSeqReader != null) { + fileSeqReader.setBatchContext(batchOffsetInFile); + } + } + } } diff --git a/orc/src/test/java/org/apache/iceberg/orc/OrcWritingTestUtils.java b/orc/src/test/java/org/apache/iceberg/orc/OrcWritingTestUtils.java new file mode 100644 index 000000000000..72ed03ce2c80 --- /dev/null +++ b/orc/src/test/java/org/apache/iceberg/orc/OrcWritingTestUtils.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.orc; + +import org.apache.hadoop.fs.FileSystem; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; + +public class OrcWritingTestUtils { + private OrcWritingTestUtils() {} + + public static FileSystem outputFileSystem(OutputFile file) { + return new FileIOFSUtil.OutputFileSystem(file); + } + + public static FileSystem inputFileSystem(InputFile file) { + return new FileIOFSUtil.InputFileSystem(file); + } +} diff --git a/orc/src/test/java/org/apache/iceberg/orc/TestORCSchemaUtil.java b/orc/src/test/java/org/apache/iceberg/orc/TestORCSchemaUtil.java index c19e36be3ac1..e331ca94a211 100644 --- a/orc/src/test/java/org/apache/iceberg/orc/TestORCSchemaUtil.java +++ b/orc/src/test/java/org/apache/iceberg/orc/TestORCSchemaUtil.java @@ -560,4 +560,12 @@ private static boolean equalsWithIds(TypeDescription first, TypeDescription seco return true; } + + public static TypeDescription removeIds(TypeDescription type) { + return ORCSchemaUtil.removeIds(type); + } + + public static boolean hasIds(TypeDescription orcSchema) { + return ORCSchemaUtil.hasIds(orcSchema); + } } diff --git a/orc/src/test/java/org/apache/iceberg/orc/TestOrcIterableResourceCleanup.java b/orc/src/test/java/org/apache/iceberg/orc/TestOrcIterableResourceCleanup.java new file mode 100644 index 000000000000..6e819af5574b --- /dev/null +++ b/orc/src/test/java/org/apache/iceberg/orc/TestOrcIterableResourceCleanup.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.orc; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.orc.GenericOrcReader; +import org.apache.iceberg.data.orc.GenericOrcWriter; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.io.SeekableInputStream; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +public class TestOrcIterableResourceCleanup { + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + @TempDir private File temp; + + @Test + public void testClosingIterableClosesAllStreams() throws IOException { + List inputStreams = Lists.newArrayList(); + InputFile inputFile = spyOnStreams(writeTestOrcFile(), inputStreams); + + try (CloseableIterable iterable = newOrcIterable(inputFile)) { + try (CloseableIterator iterator = iterable.iterator()) { + drain(iterator); + } + } + + verifyAllStreamsClosed(inputStreams); + } + + @Test + public void testClosingIterableClosesIteratorResources() throws IOException { + List inputStreams = Lists.newArrayList(); + InputFile inputFile = spyOnStreams(writeTestOrcFile(), inputStreams); + + // Without addCloseable(rowBatchIterator) in OrcIterable, the VectorizedRowBatchIterator + // and its RecordReader are never closed, leaking ORC input streams / file handles. + for (int round = 0; round < 5; round++) { + try (CloseableIterable iterable = newOrcIterable(inputFile)) { + drain(iterable.iterator()); + } + } + + verifyAllStreamsClosed(inputStreams); + } + + private static void drain(CloseableIterator iterator) { + while (iterator.hasNext()) { + iterator.next(); + } + } + + private InputFile writeTestOrcFile() throws IOException { + OutputFile outputFile = Files.localOutput(File.createTempFile("test", ".orc", temp)); + try (DataWriter writer = + ORC.writeData(outputFile) + .schema(SCHEMA) + .createWriterFunc(GenericOrcWriter::buildWriter) + .overwrite() + .withSpec(PartitionSpec.unpartitioned()) + .build()) { + GenericRecord record = GenericRecord.create(SCHEMA); + for (int i = 0; i < 10; i++) { + writer.write(record.copy(ImmutableMap.of("id", (long) i, "data", "val" + i))); + } + } + + return outputFile.toInputFile(); + } + + private static CloseableIterable newOrcIterable(InputFile input) { + return ORC.read(input) + .project(SCHEMA) + .createReaderFunc(fileSchema -> GenericOrcReader.buildReader(SCHEMA, fileSchema)) + .build(); + } + + private static void verifyAllStreamsClosed(List streams) throws IOException { + for (SeekableInputStream stream : streams) { + Mockito.verify(stream, Mockito.times(1)).close(); + } + } + + private static InputFile spyOnStreams(InputFile delegate, List streams) { + InputFile inputFile = Mockito.spy(delegate); + Mockito.doAnswer( + invocation -> { + SeekableInputStream real = (SeekableInputStream) invocation.callRealMethod(); + SeekableInputStream inputStream = Mockito.spy(real); + streams.add(inputStream); + return inputStream; + }) + .when(inputFile) + .newStream(); + return inputFile; + } +} diff --git a/orc/src/test/java/org/apache/iceberg/orc/TestOrcPartitionStatisticsScan.java b/orc/src/test/java/org/apache/iceberg/orc/TestOrcPartitionStatisticsScan.java new file mode 100644 index 000000000000..2040f046ee5d --- /dev/null +++ b/orc/src/test/java/org/apache/iceberg/orc/TestOrcPartitionStatisticsScan.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.orc; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionStatisticsScanTestBase; + +public class TestOrcPartitionStatisticsScan extends PartitionStatisticsScanTestBase { + @Override + public FileFormat format() { + return FileFormat.ORC; + } + + @Override + public void testScanPartitionStatsForCurrentSnapshot() throws Exception { + assertThatThrownBy(super::testScanPartitionStatsForCurrentSnapshot) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Cannot write using unregistered internal data format: ORC"); + } + + @Override + public void testScanPartitionStatsForOlderSnapshot() throws Exception { + assertThatThrownBy(super::testScanPartitionStatsForOlderSnapshot) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Cannot write using unregistered internal data format: ORC"); + } + + @Override + public void testReadingStatsWithInvalidSchema() throws Exception { + assertThatThrownBy(super::testReadingStatsWithInvalidSchema) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Cannot write using unregistered internal data format: ORC"); + } + + @Override + public void testV2toV3SchemaEvolution() throws Exception { + assertThatThrownBy(super::testV2toV3SchemaEvolution) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Cannot write using unregistered internal data format: ORC"); + } +} diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java b/parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java index 2b2e460ee994..f02974d6e79c 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/Parquet.java @@ -23,6 +23,7 @@ import static org.apache.iceberg.TableProperties.DELETE_PARQUET_DICT_SIZE_BYTES; import static org.apache.iceberg.TableProperties.DELETE_PARQUET_PAGE_ROW_LIMIT; import static org.apache.iceberg.TableProperties.DELETE_PARQUET_PAGE_SIZE_BYTES; +import static org.apache.iceberg.TableProperties.DELETE_PARQUET_PAGE_VERSION; import static org.apache.iceberg.TableProperties.DELETE_PARQUET_ROW_GROUP_CHECK_MAX_RECORD_COUNT; import static org.apache.iceberg.TableProperties.DELETE_PARQUET_ROW_GROUP_CHECK_MIN_RECORD_COUNT; import static org.apache.iceberg.TableProperties.DELETE_PARQUET_ROW_GROUP_SIZE_BYTES; @@ -42,6 +43,8 @@ import static org.apache.iceberg.TableProperties.PARQUET_PAGE_ROW_LIMIT_DEFAULT; import static org.apache.iceberg.TableProperties.PARQUET_PAGE_SIZE_BYTES; import static org.apache.iceberg.TableProperties.PARQUET_PAGE_SIZE_BYTES_DEFAULT; +import static org.apache.iceberg.TableProperties.PARQUET_PAGE_VERSION; +import static org.apache.iceberg.TableProperties.PARQUET_PAGE_VERSION_DEFAULT; import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_CHECK_MAX_RECORD_COUNT; import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_CHECK_MAX_RECORD_COUNT_DEFAULT; import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_CHECK_MIN_RECORD_COUNT; @@ -95,7 +98,6 @@ import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.parquet.ParquetValueWriters.PositionDeleteStructWriter; import org.apache.iceberg.parquet.ParquetValueWriters.StructWriter; -import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Maps; @@ -168,7 +170,6 @@ public static class WriteBuilder implements InternalData.WriteBuilder { private BiFunction> createWriterFunc = null; private MetricsConfig metricsConfig = MetricsConfig.getDefault(); private ParquetFileWriter.Mode writeMode = ParquetFileWriter.Mode.CREATE; - private WriterVersion writerVersion = WriterVersion.PARQUET_1_0; private Function, Context> createContextFunc = Context::dataContext; private ByteBuffer fileEncryptionKey = null; private ByteBuffer fileAADPrefix = null; @@ -266,7 +267,12 @@ public WriteBuilder overwrite(boolean enabled) { } public WriteBuilder writerVersion(WriterVersion version) { - this.writerVersion = version; + Preconditions.checkNotNull(version, "Writer version cannot be null"); + Preconditions.checkArgument( + version == WriterVersion.PARQUET_1_0 || version == WriterVersion.PARQUET_2_0, + "Unsupported writer version: %s", + version); + config.put(PARQUET_PAGE_VERSION, version.name()); return this; } @@ -292,18 +298,8 @@ private WriteSupport getWriteSupport(MessageType type) { } } - /* - * Sets the writer version. Default value is PARQUET_1_0 (v1). - */ - @VisibleForTesting - WriteBuilder withWriterVersion(WriterVersion version) { - this.writerVersion = version; - return this; - } - // supposed to always be a private method used strictly by data and delete write builders - private WriteBuilder createContextFunc( - Function, Context> newCreateContextFunc) { + WriteBuilder createContextFunc(Function, Context> newCreateContextFunc) { this.createContextFunc = newCreateContextFunc; return this; } @@ -434,7 +430,7 @@ public FileAppender build() throws IOException { ParquetProperties.Builder propsBuilder = ParquetProperties.builder() - .withWriterVersion(writerVersion) + .withWriterVersion(context.writerVersion()) .withPageSize(pageSize) .withPageRowCountLimit(pageRowLimit) .withDictionaryEncoding(dictionaryEnabled) @@ -470,14 +466,14 @@ public FileAppender build() throws IOException { } else { ParquetWriteBuilder parquetWriteBuilder = new ParquetWriteBuilder(ParquetIO.file(file)) - .withWriterVersion(writerVersion) + .withWriterVersion(context.writerVersion()) .setType(type) .setConfig(config) .setKeyValueMetadata(metadata) .setWriteSupport(getWriteSupport(type)) .withCompressionCodec(codec) .withWriteMode(writeMode) - .withRowGroupSize(rowGroupSize) + .withRowGroupSize((long) rowGroupSize) .withPageSize(pageSize) .withPageRowCountLimit(pageRowLimit) .withDictionaryEncoding(dictionaryEnabled) @@ -498,11 +494,12 @@ public FileAppender build() throws IOException { } } - private static class Context { + static class Context { private final int rowGroupSize; private final int pageSize; private final int pageRowLimit; private final int dictionaryPageSize; + private final WriterVersion writerVersion; private final CompressionCodecName codec; private final String compressionLevel; private final int rowGroupCheckMinRecordCount; @@ -519,6 +516,7 @@ private Context( int pageSize, int pageRowLimit, int dictionaryPageSize, + WriterVersion writerVersion, CompressionCodecName codec, String compressionLevel, int rowGroupCheckMinRecordCount, @@ -533,6 +531,7 @@ private Context( this.pageSize = pageSize; this.pageRowLimit = pageRowLimit; this.dictionaryPageSize = dictionaryPageSize; + this.writerVersion = writerVersion; this.codec = codec; this.compressionLevel = compressionLevel; this.rowGroupCheckMinRecordCount = rowGroupCheckMinRecordCount; @@ -566,6 +565,10 @@ static Context dataContext(Map config) { config, PARQUET_DICT_SIZE_BYTES, PARQUET_DICT_SIZE_BYTES_DEFAULT); Preconditions.checkArgument(dictionaryPageSize > 0, "Dictionary page size must be > 0"); + WriterVersion writerVersion = + toWriterVersion( + config.getOrDefault(PARQUET_PAGE_VERSION, PARQUET_PAGE_VERSION_DEFAULT)); + String codecAsString = config.getOrDefault(PARQUET_COMPRESSION, PARQUET_COMPRESSION_DEFAULT); CompressionCodecName codec = toCodec(codecAsString); @@ -617,6 +620,7 @@ static Context dataContext(Map config) { pageSize, pageRowLimit, dictionaryPageSize, + writerVersion, codec, compressionLevel, rowGroupCheckMinRecordCount, @@ -653,6 +657,12 @@ static Context deleteContext(Map config) { config, DELETE_PARQUET_DICT_SIZE_BYTES, dataContext.dictionaryPageSize()); Preconditions.checkArgument(dictionaryPageSize > 0, "Dictionary page size must be > 0"); + String deletePageVersion = config.get(DELETE_PARQUET_PAGE_VERSION); + WriterVersion writerVersion = + deletePageVersion != null + ? toWriterVersion(deletePageVersion) + : dataContext.writerVersion(); + String codecAsString = config.get(DELETE_PARQUET_COMPRESSION); CompressionCodecName codec = codecAsString != null ? toCodec(codecAsString) : dataContext.codec(); @@ -687,6 +697,7 @@ static Context deleteContext(Map config) { pageSize, pageRowLimit, dictionaryPageSize, + writerVersion, codec, compressionLevel, rowGroupCheckMinRecordCount, @@ -707,6 +718,15 @@ private static CompressionCodecName toCodec(String codecAsString) { } } + private static WriterVersion toWriterVersion(String pageVersion) { + try { + return WriterVersion.fromString(pageVersion); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Unsupported Parquet page version: " + pageVersion + " (must be v1 or v2)"); + } + } + int rowGroupSize() { return rowGroupSize; } @@ -723,6 +743,10 @@ int dictionaryPageSize() { return dictionaryPageSize; } + WriterVersion writerVersion() { + return writerVersion; + } + CompressionCodecName codec() { return codec; } @@ -1176,6 +1200,7 @@ public static class ReadBuilder implements InternalData.ReadBuilder { private Expression filter = null; private ReadSupport readSupport = null; private Function> batchedReaderFunc = null; + private BiFunction> batchedReaderFuncWithSchema = null; private ReaderFunction readerFunction = null; private boolean filterRecords = true; private boolean caseSensitive = true; @@ -1298,6 +1323,9 @@ public ReadBuilder createReaderFunc( Preconditions.checkArgument( this.batchedReaderFunc == null, "Cannot set reader function: batched reader function already set"); + Preconditions.checkArgument( + this.batchedReaderFuncWithSchema == null, + "Cannot set reader function: batched reader function with schema already set"); Preconditions.checkArgument( this.readerFunction == null, "Cannot set reader function: reader function already set"); this.readerFunction = new UnaryReaderFunction(newReaderFunction); @@ -1309,6 +1337,9 @@ public ReadBuilder createReaderFunc( Preconditions.checkArgument( this.batchedReaderFunc == null, "Cannot set reader function: batched reader function already set"); + Preconditions.checkArgument( + this.batchedReaderFuncWithSchema == null, + "Cannot set reader function: batched reader function with schema already set"); Preconditions.checkArgument( this.readerFunction == null, "Cannot set reader function: reader function already set"); this.readerFunction = new BinaryReaderFunction(newReaderFunction); @@ -1319,6 +1350,9 @@ public ReadBuilder createBatchedReaderFunc(Function> func) { + Preconditions.checkArgument( + this.batchedReaderFunc == null, + "Cannot set batched reader function: batched reader function already set"); + Preconditions.checkArgument( + this.batchedReaderFuncWithSchema == null, + "Cannot set reader function: batched reader function with schema already set"); + Preconditions.checkArgument( + this.readerFunction == null, + "Cannot set batched reader function: ReaderFunction already set"); + this.batchedReaderFuncWithSchema = func; + return this; + } + public ReadBuilder createReaderFunc(ReaderFunction reader) { Preconditions.checkArgument( this.batchedReaderFunc == null, "Cannot set reader function: batched reader function already set"); + Preconditions.checkArgument( + this.batchedReaderFuncWithSchema == null, + "Cannot set reader function: batched reader function with schema already set"); Preconditions.checkArgument( this.readerFunction == null, "Cannot set reader function: reader function already set"); this.readerFunction = reader; @@ -1389,7 +1441,7 @@ public ReadBuilder withAADPrefix(ByteBuffer aadPrefix) { } @Override - @SuppressWarnings({"unchecked", "checkstyle:CyclomaticComplexity"}) + @SuppressWarnings({"unchecked", "checkstyle:CyclomaticComplexity", "checkstyle:MethodLength"}) public CloseableIterable build() { FileDecryptionProperties fileDecryptionProperties = null; if (fileEncryptionKey != null) { @@ -1404,7 +1456,9 @@ public CloseableIterable build() { Preconditions.checkState(fileAADPrefix == null, "AAD prefix set with null encryption key"); } - if (batchedReaderFunc != null || readerFunction != null) { + if (batchedReaderFunc != null + || batchedReaderFuncWithSchema != null + || readerFunction != null) { ParquetReadOptions.Builder optionsBuilder; if (file instanceof HadoopInputFile) { // remove read properties already set that may conflict with this read @@ -1441,12 +1495,16 @@ public CloseableIterable build() { mapping = NameMapping.empty(); } - if (batchedReaderFunc != null) { + Function> batchedFunc = + batchedReaderFuncWithSchema != null + ? messageType -> batchedReaderFuncWithSchema.apply(schema, messageType) + : batchedReaderFunc; + if (batchedFunc != null) { return new VectorizedParquetReader<>( file, schema, options, - batchedReaderFunc, + batchedFunc, mapping, filter, reuseContainers, diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetAvroWriter.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetAvroWriter.java index 114e5fe27545..75b3a6604084 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetAvroWriter.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetAvroWriter.java @@ -103,6 +103,11 @@ public ParquetValueWriter map( ParquetValueWriters.option(valueType, valueD, valueWriter)); } + @Override + public ParquetValueWriter variant(GroupType variant) { + throw new UnsupportedOperationException("Avro writer does not support variant types"); + } + @Override public ParquetValueWriter primitive(PrimitiveType primitive) { ColumnDescriptor desc = type.getColumnDescription(currentPath()); diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetCodecFactory.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetCodecFactory.java index bfcece6259a6..bfe87f8e5017 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetCodecFactory.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetCodecFactory.java @@ -39,6 +39,7 @@ public ParquetCodecFactory(Configuration configuration, int pageSize) { /** * This is copied from {@link CodecFactory} and modified to include the level in the cache key. */ + @SuppressWarnings("deprecation") @Override protected CompressionCodec getCodec(CompressionCodecName codecName) { String codecClassName = codecName.getHadoopCompressionCodecClassName(); @@ -67,6 +68,7 @@ protected CompressionCodec getCodec(CompressionCodecName codecName) { } } + @SuppressWarnings("deprecation") private String cacheKey(CompressionCodecName codecName) { String level = null; switch (codecName) { diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetConversions.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetConversions.java index 1e5ed1fb9b87..7e8e6d77de5c 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetConversions.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetConversions.java @@ -23,7 +23,6 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; -import java.util.UUID; import java.util.function.Function; import org.apache.iceberg.types.Type; import org.apache.iceberg.util.UUIDUtil; diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetFormatModel.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetFormatModel.java new file mode 100644 index 000000000000..90dd6e117ba8 --- /dev/null +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetFormatModel.java @@ -0,0 +1,395 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.parquet; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.BaseFormatModel; +import org.apache.iceberg.formats.ModelWriteBuilder; +import org.apache.iceberg.formats.ReadBuilder; +import org.apache.iceberg.io.BufferedFileAppender; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.DeleteSchemaUtil; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.Type; + +public class ParquetFormatModel + extends BaseFormatModel, R, MessageType> { + private final boolean isBatchReader; + private final VariantShreddingAnalyzer variantAnalyzer; + private final UnaryOperator copyFunc; + + public static ParquetFormatModel, Void, Object> forPositionDeletes() { + return new ParquetFormatModel<>( + PositionDelete.deleteClass(), Void.class, null, null, false, null, null); + } + + public static ParquetFormatModel> create( + Class type, + Class schemaType, + WriterFunction, S, MessageType> writerFunction, + ReaderFunction, S, MessageType> readerFunction) { + return new ParquetFormatModel<>( + type, schemaType, writerFunction, readerFunction, false, null, null); + } + + public static ParquetFormatModel> create( + Class type, + Class schemaType, + WriterFunction, S, MessageType> writerFunction, + ReaderFunction, S, MessageType> readerFunction, + VariantShreddingAnalyzer variantAnalyzer, + UnaryOperator copyFunc) { + return new ParquetFormatModel<>( + type, schemaType, writerFunction, readerFunction, false, variantAnalyzer, copyFunc); + } + + public static ParquetFormatModel> create( + Class type, + Class schemaType, + ReaderFunction, S, MessageType> batchReaderFunction) { + return new ParquetFormatModel<>(type, schemaType, null, batchReaderFunction, true, null, null); + } + + private ParquetFormatModel( + Class type, + Class schemaType, + WriterFunction, S, MessageType> writerFunction, + ReaderFunction readerFunction, + boolean isBatchReader, + VariantShreddingAnalyzer variantAnalyzer, + UnaryOperator copyFunc) { + super(type, schemaType, writerFunction, readerFunction); + this.isBatchReader = isBatchReader; + this.variantAnalyzer = variantAnalyzer; + this.copyFunc = copyFunc; + } + + @Override + public FileFormat format() { + return FileFormat.PARQUET; + } + + @Override + public ModelWriteBuilder writeBuilder(EncryptedOutputFile outputFile) { + return new WriteBuilderWrapper<>(outputFile, writerFunction(), variantAnalyzer, copyFunc); + } + + @Override + public ReadBuilder readBuilder(InputFile inputFile) { + return new ReadBuilderWrapper<>(inputFile, readerFunction(), isBatchReader); + } + + private static class WriteBuilderWrapper implements ModelWriteBuilder { + private final Parquet.WriteBuilder internal; + private final WriterFunction, S, MessageType> writerFunction; + private final VariantShreddingAnalyzer variantAnalyzer; + private final UnaryOperator copyFunc; + private Schema schema; + private S engineSchema; + private FileContent content; + private boolean shreddingEnabled = false; + private int bufferSize = TableProperties.PARQUET_VARIANT_BUFFER_SIZE_DEFAULT; + + private WriteBuilderWrapper( + EncryptedOutputFile outputFile, + WriterFunction, S, MessageType> writerFunction, + VariantShreddingAnalyzer variantAnalyzer, + UnaryOperator copyFunc) { + this.internal = Parquet.write(outputFile); + this.writerFunction = writerFunction; + this.variantAnalyzer = variantAnalyzer; + this.copyFunc = copyFunc; + } + + @Override + public ModelWriteBuilder schema(Schema newSchema) { + this.schema = newSchema; + internal.schema(newSchema); + return this; + } + + @Override + public ModelWriteBuilder engineSchema(S newSchema) { + this.engineSchema = newSchema; + return this; + } + + @Override + public ModelWriteBuilder set(String property, String value) { + if (TableProperties.PARQUET_SHRED_VARIANTS.equals(property)) { + shreddingEnabled = Boolean.parseBoolean(value); + } + + if (TableProperties.PARQUET_VARIANT_BUFFER_SIZE.equals(property)) { + bufferSize = Integer.parseInt(value); + } + + internal.set(property, value); + return this; + } + + @Override + public ModelWriteBuilder setAll(Map properties) { + properties.forEach(this::set); + return this; + } + + @Override + public ModelWriteBuilder meta(String property, String value) { + internal.meta(property, value); + return this; + } + + @Override + public ModelWriteBuilder meta(Map properties) { + internal.meta(properties); + return this; + } + + @Override + public ModelWriteBuilder content(FileContent newContent) { + this.content = newContent; + return this; + } + + @Override + public ModelWriteBuilder metricsConfig(MetricsConfig metricsConfig) { + internal.metricsConfig(metricsConfig); + return this; + } + + @Override + public ModelWriteBuilder overwrite() { + internal.overwrite(); + return this; + } + + @Override + public ModelWriteBuilder withFileEncryptionKey(ByteBuffer encryptionKey) { + internal.withFileEncryptionKey(encryptionKey); + return this; + } + + @Override + public ModelWriteBuilder withAADPrefix(ByteBuffer aadPrefix) { + internal.withAADPrefix(aadPrefix); + return this; + } + + @Override + public FileAppender build() throws IOException { + boolean shredVariants = false; + switch (content) { + case DATA: + internal.createContextFunc(Parquet.WriteBuilder.Context::dataContext); + internal.createWriterFunc( + (icebergSchema, messageType) -> + writerFunction.write(icebergSchema, messageType, engineSchema)); + shredVariants = shreddingEnabled && variantAnalyzer != null && hasVariantColumns(schema); + break; + case EQUALITY_DELETES: + internal.createContextFunc(Parquet.WriteBuilder.Context::deleteContext); + internal.createWriterFunc( + (icebergSchema, messageType) -> + writerFunction.write(icebergSchema, messageType, engineSchema)); + break; + case POSITION_DELETES: + Preconditions.checkState( + schema == null, + "Invalid schema: %s. Position deletes with schema are not supported by the API.", + schema); + Preconditions.checkState( + engineSchema == null, + "Invalid engineSchema: %s. Position deletes with schema are not supported by the API.", + engineSchema); + + internal.createContextFunc(Parquet.WriteBuilder.Context::deleteContext); + internal.createWriterFunc( + (icebergSchema, messageType) -> + new ParquetValueWriters.PositionDeleteStructWriter( + (ParquetValueWriters.StructWriter) + GenericParquetWriter.create(icebergSchema, messageType), + Function.identity())); + internal.schema(DeleteSchemaUtil.pathPosSchema()); + break; + default: + throw new IllegalArgumentException("Unknown file content: " + content); + } + + if (shredVariants) { + return buildShreddedAppender(); + } + + return internal.build(); + } + + /** + * Creates a {@link BufferedFileAppender} that buffers the first N rows, runs variant shredding + * analysis on them, then creates the real Parquet appender with a shredded schema. + * + *

    Only top-level variant columns are shredded. Nested variants (inside structs/lists/maps) + * fall through to unshredded 2-field layout because column index resolution only applies to + * top-level fields. + */ + private FileAppender buildShreddedAppender() { + return new BufferedFileAppender<>( + bufferSize, + bufferedRows -> { + Map shreddedTypes = + variantAnalyzer.analyzeVariantColumns(bufferedRows, schema, engineSchema); + + if (!shreddedTypes.isEmpty()) { + internal.variantShreddingFunc((fieldId, name) -> shreddedTypes.get(fieldId)); + } + + try { + return internal.build(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create shredded variant writer", e); + } + }, + copyFunc); + } + + private static boolean hasVariantColumns(Schema schema) { + return schema != null + && schema.columns().stream().anyMatch(field -> field.type().isVariantType()); + } + } + + private static class ReadBuilderWrapper implements ReadBuilder { + private final Parquet.ReadBuilder internal; + private final ReaderFunction readerFunction; + private final boolean isBatchReader; + private S engineSchema; + private Map idToConstant = ImmutableMap.of(); + + private ReadBuilderWrapper( + InputFile inputFile, + ReaderFunction readerFunction, + boolean isBatchReader) { + this.internal = Parquet.read(inputFile); + this.readerFunction = readerFunction; + this.isBatchReader = isBatchReader; + } + + @Override + public ReadBuilder split(long newStart, long newLength) { + internal.split(newStart, newLength); + return this; + } + + @Override + public ReadBuilder project(Schema schema) { + internal.project(schema); + return this; + } + + @Override + public ReadBuilder engineProjection(S schema) { + this.engineSchema = schema; + return this; + } + + @Override + public ReadBuilder caseSensitive(boolean caseSensitive) { + internal.caseSensitive(caseSensitive); + return this; + } + + @Override + public ReadBuilder filter(Expression filter) { + internal.filter(filter); + return this; + } + + @Override + public ReadBuilder set(String key, String value) { + internal.set(key, value); + return this; + } + + @Override + public ReadBuilder reuseContainers() { + internal.reuseContainers(); + return this; + } + + @Override + public ReadBuilder recordsPerBatch(int numRowsPerBatch) { + if (!isBatchReader) { + throw new UnsupportedOperationException( + "Batch reading is not supported in non-vectorized reader"); + } + + internal.recordsPerBatch(numRowsPerBatch); + return this; + } + + @Override + public ReadBuilder idToConstant(Map newIdToConstant) { + this.idToConstant = newIdToConstant; + return this; + } + + @Override + public ReadBuilder withNameMapping(NameMapping nameMapping) { + internal.withNameMapping(nameMapping); + return this; + } + + @Override + public CloseableIterable build() { + if (isBatchReader) { + return internal + .createBatchedReaderFunc( + (icebergSchema, messageType) -> + (VectorizedReader) + readerFunction.read(icebergSchema, messageType, engineSchema, idToConstant)) + .build(); + } else { + return internal + .createReaderFunc( + (icebergSchema, messageType) -> + (ParquetValueReader) + readerFunction.read(icebergSchema, messageType, engineSchema, idToConstant)) + .build(); + } + } + } +} diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetIO.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetIO.java index 7e0c7f5f3fb5..ecca4eb0d65a 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetIO.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetIO.java @@ -18,7 +18,6 @@ */ package org.apache.iceberg.parquet; -import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -187,18 +186,10 @@ private static List convertRanges(List ranges) { parquetFileRange -> { CompletableFuture future = new CompletableFuture<>(); parquetFileRange.setDataReadFuture(future); - try { - return new FileRange( - parquetFileRange.getDataReadFuture(), - parquetFileRange.getOffset(), - parquetFileRange.getLength()); - } catch (EOFException e) { - throw new RuntimeIOException( - e, - "Failed to create range file for offset: %s and length: %s", - parquetFileRange.getOffset(), - parquetFileRange.getLength()); - } + return new FileRange( + parquetFileRange.getDataReadFuture(), + parquetFileRange.getOffset(), + parquetFileRange.getLength()); }) .collect(Collectors.toList()); } diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetReadSupport.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetReadSupport.java index b1172147f80a..4f314f8356b6 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetReadSupport.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetReadSupport.java @@ -104,6 +104,7 @@ public ReadContext init( projection, context != null ? context.getReadSupportMetadata() : ImmutableMap.of()); } + @SuppressWarnings("deprecation") @Override public RecordMaterializer prepareForRead( Configuration configuration, diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetVariantWriters.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetVariantWriters.java index 9e94b1bbd6cd..e5c56da166f4 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetVariantWriters.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetVariantWriters.java @@ -18,6 +18,8 @@ */ package org.apache.iceberg.parquet; +import java.math.BigDecimal; +import java.math.RoundingMode; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.Arrays; @@ -99,6 +101,16 @@ static ParquetValueWriter objects( builder.build()); } + @SuppressWarnings("unchecked") + static ParquetValueWriter decimal( + ParquetValueWriter writer, int precision, int scale, PhysicalType... types) { + return new DecimalPrimitiveWriter( + (ParquetValueWriter) writer, + Sets.immutableEnumSet(Arrays.asList(types)), + precision, + scale); + } + @SuppressWarnings("unchecked") public static ParquetValueWriter array( int repeatedDefinitionLevel, @@ -220,6 +232,10 @@ protected int writeTo(ByteBuffer buffer, int offset, VariantValue value) { private interface TypedWriter extends ParquetValueWriter { Set types(); + + default boolean canWrite(VariantValue value) { + return true; + } } private static class PrimitiveWriter implements TypedWriter { @@ -274,7 +290,7 @@ private ShreddedVariantWriter( @Override public void write(int repetitionLevel, VariantValue value) { - if (typedWriter.types().contains(value.type())) { + if (typedWriter.types().contains(value.type()) && typedWriter.canWrite(value)) { typedWriter.write(repetitionLevel, value); writeNull(valueWriter, repetitionLevel, valueDefinitionLevel); } else { @@ -372,6 +388,49 @@ public void setColumnStore(ColumnWriteStore columnStore) { } } + private static class DecimalPrimitiveWriter implements TypedWriter { + private final Set types; + private final ParquetValueWriter writer; + private final int precision; + private final int scale; + + private DecimalPrimitiveWriter( + ParquetValueWriter writer, Set types, int precision, int scale) { + this.types = types; + this.writer = writer; + this.precision = precision; + this.scale = scale; + } + + @Override + public Set types() { + return types; + } + + @Override + public boolean canWrite(VariantValue value) { + BigDecimal decimal = (BigDecimal) value.asPrimitive().get(); + int integerDigits = decimal.precision() - decimal.scale(); + return decimal.scale() <= scale && integerDigits + scale <= precision; + } + + @Override + public void write(int repetitionLevel, VariantValue value) { + BigDecimal decimal = (BigDecimal) value.asPrimitive().get(); + writer.write(repetitionLevel, decimal.setScale(scale, RoundingMode.UNNECESSARY)); + } + + @Override + public List> columns() { + return writer.columns(); + } + + @Override + public void setColumnStore(ColumnWriteStore columnStore) { + writer.setColumnStore(columnStore); + } + } + private static class ArrayWriter implements TypedWriter { private final int definitionLevel; private final int repetitionLevel; diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriteSupport.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriteSupport.java index 985d0c51c609..d490584f7940 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriteSupport.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriteSupport.java @@ -37,6 +37,7 @@ class ParquetWriteSupport extends WriteSupport { this.wrapped = writeSupport; } + @SuppressWarnings("deprecation") @Override public WriteContext init(Configuration configuration) { WriteContext wrappedContext = wrapped.init(configuration); diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriter.java b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriter.java index e31df97c2bad..2334e75532be 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriter.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/ParquetWriter.java @@ -34,9 +34,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.parquet.column.ColumnWriteStore; import org.apache.parquet.column.ParquetProperties; +import org.apache.parquet.compression.CompressionCodecFactory; import org.apache.parquet.crypto.FileEncryptionProperties; import org.apache.parquet.crypto.InternalFileEncryptor; -import org.apache.parquet.hadoop.CodecFactory; import org.apache.parquet.hadoop.ColumnChunkPageWriteStore; import org.apache.parquet.hadoop.ParquetFileWriter; import org.apache.parquet.hadoop.metadata.CompressionCodecName; @@ -50,7 +50,7 @@ class ParquetWriter implements FileAppender, Closeable { private final long targetRowGroupSize; private final Map metadata; private final ParquetProperties props; - private final CodecFactory.BytesCompressor compressor; + private final CompressionCodecFactory.BytesInputCompressor compressor; private final MessageType parquetSchema; private final ParquetValueWriter model; private final MetricsConfig metricsConfig; diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/VariantShreddingAnalyzer.java b/parquet/src/main/java/org/apache/iceberg/parquet/VariantShreddingAnalyzer.java new file mode 100644 index 000000000000..d2a058c1128a --- /dev/null +++ b/parquet/src/main/java/org/apache/iceberg/parquet/VariantShreddingAnalyzer.java @@ -0,0 +1,532 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.parquet; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.iceberg.Schema; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.variants.PhysicalType; +import org.apache.iceberg.variants.VariantArray; +import org.apache.iceberg.variants.VariantObject; +import org.apache.iceberg.variants.VariantPrimitive; +import org.apache.iceberg.variants.VariantValue; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.parquet.schema.Types; + +/** + * Analyzes variant data across buffered rows to determine an optimal shredding schema. + * + *

    Determinism contract: for a given set of variant values (regardless of row arrival order), + * this analyzer produces the same shredded schema. When the number of distinct fields at any level + * exceeds {@code MAX_INTERMEDIATE_FIELDS}, field tracking becomes insertion-order dependent and + * determinism is not guaranteed. + * + *

      + *
    • Object fields use a TreeMap, so field ordering is alphabetical and deterministic. + *
    • Type selection picks the most common type with explicit tie-break priority (see + * TIE_BREAK_PRIORITY), not enum ordinal. + *
    • Integer types (INT8/16/32/64) and decimal types (DECIMAL4/8/16) are each promoted to the + * widest observed before competing with other types. + *
    • Fields below {@code MIN_FIELD_FREQUENCY} are pruned. Above {@code MAX_SHREDDED_FIELDS}, the + * most frequent are kept with alphabetical tie-breaking. + *
    • Recursion into nested objects/arrays stops at {@code MAX_SHREDDING_DEPTH} (default 50). + *
    • New struct fields are not tracked once a node reaches {@code MAX_INTERMEDIATE_FIELDS} + * (default 1000) to bound memory during inference. + *
    + * + *

    This contract holds within a single batch. Different batches with different distributions may + * produce different layouts; cross-batch stability requires schema pinning (not yet implemented). + * + *

    Subclasses implement {@link #extractVariantValues} to convert engine-specific row types into + * {@link VariantValue} instances. + * + * @param the engine-specific row type (e.g., Spark InternalRow, Flink RowData) + * @param the engine-specific schema type (e.g., Spark StructType, Flink RowType) + */ +public abstract class VariantShreddingAnalyzer { + private static final String TYPED_VALUE = "typed_value"; + private static final String VALUE = "value"; + private static final String ELEMENT = "element"; + private static final double MIN_FIELD_FREQUENCY = 0.10; + private static final int MAX_SHREDDED_FIELDS = 300; + private static final int MAX_SHREDDING_DEPTH = 50; + private static final int MAX_INTERMEDIATE_FIELDS = 1000; + + protected VariantShreddingAnalyzer() {} + + /** + * Analyzes buffered variant values to determine the optimal shredding schema. + * + * @param bufferedRows the buffered rows to analyze + * @param variantFieldIndex the index of the variant field in the rows + * @return the shredded schema type, or null if no shredding should be performed + */ + public Type analyzeAndCreateSchema(List bufferedRows, int variantFieldIndex) { + List variantValues = extractVariantValues(bufferedRows, variantFieldIndex); + if (variantValues.isEmpty()) { + return null; + } + + PathNode root = buildPathTree(variantValues); + PhysicalType rootType = root.info.getMostCommonType(); + if (rootType == null) { + return null; + } + + pruneInfrequentFields(root, root.info.observationCount); + + return buildTypedValue(root, rootType); + } + + protected abstract List extractVariantValues( + List bufferedRows, int variantFieldIndex); + + /** + * Resolves a column name to its index in the engine-specific schema. Returns -1 if the column is + * not found. + */ + protected abstract int resolveColumnIndex(S engineSchema, String columnName); + + /** + * Analyzes all variant columns in the schema, resolving column indices via the engine-specific + * {@link #resolveColumnIndex} method. + * + * @param bufferedRows the buffered rows to analyze + * @param icebergSchema the Iceberg table schema + * @param engineSchema the engine-specific schema used to resolve column indices + * @return a map from Iceberg field ID to the shredded Parquet type for each variant column + */ + public Map analyzeVariantColumns( + List bufferedRows, Schema icebergSchema, S engineSchema) { + Map shreddedTypes = Maps.newHashMap(); + for (NestedField col : icebergSchema.columns()) { + if (col.type().isVariantType()) { + int rowIndex = resolveColumnIndex(engineSchema, col.name()); + if (rowIndex >= 0) { + Type typed = analyzeAndCreateSchema(bufferedRows, rowIndex); + if (typed != null) { + shreddedTypes.put(col.fieldId(), typed); + } + } + } + } + + return shreddedTypes; + } + + private static PathNode buildPathTree(List variantValues) { + PathNode root = new PathNode(null); + root.info = new FieldInfo(); + + for (VariantValue value : variantValues) { + traverse(root, value, 0); + } + + return root; + } + + private static void pruneInfrequentFields(PathNode node, int totalRows) { + if (node.objectChildren.isEmpty() && node.arrayElement == null) { + return; + } + + // Remove fields below frequency threshold + node.objectChildren + .entrySet() + .removeIf( + entry -> { + FieldInfo info = entry.getValue().info; + return info != null + && ((double) info.observationCount / totalRows) < MIN_FIELD_FREQUENCY; + }); + + // Cap at MAX_SHREDDED_FIELDS, keep the most frequently observed + if (node.objectChildren.size() > MAX_SHREDDED_FIELDS) { + List> sorted = Lists.newArrayList(node.objectChildren.entrySet()); + sorted.sort( + (a, b) -> { + int cmp = + Integer.compare( + b.getValue().info.observationCount, a.getValue().info.observationCount); + return cmp != 0 ? cmp : a.getKey().compareTo(b.getKey()); + }); + Set keep = Sets.newHashSet(); + for (int i = 0; i < MAX_SHREDDED_FIELDS; i++) { + keep.add(sorted.get(i).getKey()); + } + node.objectChildren.entrySet().removeIf(entry -> !keep.contains(entry.getKey())); + } + + // Recurse into remaining object children + for (PathNode child : node.objectChildren.values()) { + pruneInfrequentFields(child, totalRows); + } + + // Recurse into array elements (arrays of objects need pruning too) + if (node.arrayElement != null) { + pruneInfrequentFields(node.arrayElement, totalRows); + } + } + + private static void traverse(PathNode node, VariantValue value, int depth) { + if (value == null || value.type() == PhysicalType.NULL) { + return; + } + + node.info.observe(value); + + if (value.type() == PhysicalType.OBJECT && depth < MAX_SHREDDING_DEPTH) { + traverseObject(node, value.asObject(), depth); + } else if (value.type() == PhysicalType.ARRAY && depth < MAX_SHREDDING_DEPTH) { + traverseArray(node, value.asArray(), depth); + } + } + + private static void traverseObject(PathNode node, VariantObject obj, int depth) { + for (String fieldName : obj.fieldNames()) { + VariantValue fieldValue = obj.get(fieldName); + if (fieldValue != null) { + PathNode childNode = node.objectChildren.get(fieldName); + if (childNode == null) { + if (node.objectChildren.size() >= MAX_INTERMEDIATE_FIELDS) { + continue; + } + childNode = new PathNode(fieldName); + childNode.info = new FieldInfo(); + node.objectChildren.put(fieldName, childNode); + } + traverse(childNode, fieldValue, depth + 1); + } + } + } + + // observationCount inside arrays counts per-element, not per-row, so fields in long arrays + // have inflated frequency and resist pruning. + private static void traverseArray(PathNode node, VariantArray array, int depth) { + int numElements = array.numElements(); + if (node.arrayElement == null) { + node.arrayElement = new PathNode(null); + node.arrayElement.info = new FieldInfo(); + } + for (int i = 0; i < numElements; i++) { + VariantValue element = array.get(i); + if (element != null) { + traverse(node.arrayElement, element, depth + 1); + } + } + } + + private static Type buildFieldGroup(PathNode node) { + PhysicalType commonType = node.info.getMostCommonType(); + if (commonType == null) { + return null; + } + + Type typedValue = buildTypedValue(node, commonType); + if (typedValue == null) { + return null; + } + + return Types.buildGroup(Type.Repetition.REQUIRED) + .optional(PrimitiveType.PrimitiveTypeName.BINARY) + .named(VALUE) + .addField(typedValue) + .named(node.fieldName); + } + + private static Type buildTypedValue(PathNode node, PhysicalType physicalType) { + return switch (physicalType) { + case ARRAY -> createArrayTypedValue(node); + case OBJECT -> createObjectTypedValue(node); + default -> createPrimitiveTypedValue(node.info, physicalType); + }; + } + + private static Type createObjectTypedValue(PathNode node) { + if (node.objectChildren.isEmpty()) { + return null; + } + + Types.GroupBuilder builder = Types.buildGroup(Type.Repetition.OPTIONAL); + boolean hasFields = false; + for (PathNode child : node.objectChildren.values()) { + Type fieldType = buildFieldGroup(child); + if (fieldType != null) { + builder.addField(fieldType); + hasFields = true; + } + } + + return hasFields ? builder.named(TYPED_VALUE) : null; + } + + private static Type createArrayTypedValue(PathNode node) { + PathNode elementNode = node.arrayElement; + if (elementNode == null) { + return null; + } + PhysicalType elementType = elementNode.info.getMostCommonType(); + if (elementType == null) { + return null; + } + Type elementTypedValue = buildTypedValue(elementNode, elementType); + if (elementTypedValue == null) { + return null; + } + + GroupType elementGroup = + Types.buildGroup(Type.Repetition.REQUIRED) + .optional(PrimitiveType.PrimitiveTypeName.BINARY) + .named(VALUE) + .addField(elementTypedValue) + .named(ELEMENT); + + return Types.optionalList().element(elementGroup).named(TYPED_VALUE); + } + + private static class PathNode { + private final String fieldName; + private final Map objectChildren = Maps.newTreeMap(); + private PathNode arrayElement = null; + private FieldInfo info = null; + + private PathNode(String fieldName) { + this.fieldName = fieldName; + } + } + + /** Use DECIMAL with maximum precision and scale as the shredding type */ + private static Type createDecimalTypedValue(FieldInfo info) { + int maxPrecision = Math.min(info.maxDecimalIntegerDigits + info.maxDecimalScale, 38); + int maxScale = Math.min(info.maxDecimalScale, Math.max(0, 38 - info.maxDecimalIntegerDigits)); + + if (maxPrecision <= 9) { + return Types.optional(PrimitiveType.PrimitiveTypeName.INT32) + .as(LogicalTypeAnnotation.decimalType(maxScale, maxPrecision)) + .named(TYPED_VALUE); + } else if (maxPrecision <= 18) { + return Types.optional(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.decimalType(maxScale, maxPrecision)) + .named(TYPED_VALUE); + } else { + return Types.optional(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) + .length(16) + .as(LogicalTypeAnnotation.decimalType(maxScale, maxPrecision)) + .named(TYPED_VALUE); + } + } + + private static Type createPrimitiveTypedValue(FieldInfo info, PhysicalType primitiveType) { + return switch (primitiveType) { + case BOOLEAN_TRUE, BOOLEAN_FALSE -> + Types.optional(PrimitiveType.PrimitiveTypeName.BOOLEAN).named(TYPED_VALUE); + case INT8 -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT32) + .as(LogicalTypeAnnotation.intType(8, true)) + .named(TYPED_VALUE); + case INT16 -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT32) + .as(LogicalTypeAnnotation.intType(16, true)) + .named(TYPED_VALUE); + case INT32 -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT32) + .as(LogicalTypeAnnotation.intType(32, true)) + .named(TYPED_VALUE); + case INT64 -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.intType(64, true)) + .named(TYPED_VALUE); + case FLOAT -> Types.optional(PrimitiveType.PrimitiveTypeName.FLOAT).named(TYPED_VALUE); + case DOUBLE -> Types.optional(PrimitiveType.PrimitiveTypeName.DOUBLE).named(TYPED_VALUE); + case STRING -> + Types.optional(PrimitiveType.PrimitiveTypeName.BINARY) + .as(LogicalTypeAnnotation.stringType()) + .named(TYPED_VALUE); + case BINARY -> Types.optional(PrimitiveType.PrimitiveTypeName.BINARY).named(TYPED_VALUE); + case TIME -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timeType(false, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named(TYPED_VALUE); + case DATE -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT32) + .as(LogicalTypeAnnotation.dateType()) + .named(TYPED_VALUE); + case TIMESTAMPTZ -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named(TYPED_VALUE); + case TIMESTAMPNTZ -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(false, LogicalTypeAnnotation.TimeUnit.MICROS)) + .named(TYPED_VALUE); + case TIMESTAMPTZ_NANOS -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(true, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named(TYPED_VALUE); + case TIMESTAMPNTZ_NANOS -> + Types.optional(PrimitiveType.PrimitiveTypeName.INT64) + .as(LogicalTypeAnnotation.timestampType(false, LogicalTypeAnnotation.TimeUnit.NANOS)) + .named(TYPED_VALUE); + case DECIMAL4, DECIMAL8, DECIMAL16 -> createDecimalTypedValue(info); + case UUID -> + Types.optional(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) + .length(16) + .as(LogicalTypeAnnotation.uuidType()) + .named(TYPED_VALUE); + default -> + throw new UnsupportedOperationException( + "Unknown primitive physical type: " + primitiveType); + }; + } + + /** Tracks occurrence count and types for a single field. */ + private static class FieldInfo { + private final Map typeCounts = Maps.newHashMap(); + private int maxDecimalScale = 0; + private int maxDecimalIntegerDigits = 0; + private int observationCount = 0; + + private static final Map INTEGER_PRIORITY = + ImmutableMap.of( + PhysicalType.INT8, 0, + PhysicalType.INT16, 1, + PhysicalType.INT32, 2, + PhysicalType.INT64, 3); + + private static final Map DECIMAL_PRIORITY = + ImmutableMap.of( + PhysicalType.DECIMAL4, 0, + PhysicalType.DECIMAL8, 1, + PhysicalType.DECIMAL16, 2); + + private static final Map TIE_BREAK_PRIORITY = + ImmutableMap.builder() + .put(PhysicalType.BOOLEAN_TRUE, 0) + .put(PhysicalType.INT8, 1) + .put(PhysicalType.INT16, 2) + .put(PhysicalType.INT32, 3) + .put(PhysicalType.INT64, 4) + .put(PhysicalType.FLOAT, 5) + .put(PhysicalType.DOUBLE, 6) + .put(PhysicalType.DECIMAL4, 7) + .put(PhysicalType.DECIMAL8, 8) + .put(PhysicalType.DECIMAL16, 9) + .put(PhysicalType.DATE, 10) + .put(PhysicalType.TIME, 11) + .put(PhysicalType.TIMESTAMPTZ, 12) + .put(PhysicalType.TIMESTAMPNTZ, 13) + .put(PhysicalType.BINARY, 14) + .put(PhysicalType.STRING, 15) + .put(PhysicalType.TIMESTAMPTZ_NANOS, 16) + .put(PhysicalType.TIMESTAMPNTZ_NANOS, 17) + .put(PhysicalType.UUID, 18) + .buildOrThrow(); + + void observe(VariantValue value) { + observationCount++; + // Use BOOLEAN_TRUE for both TRUE/FALSE values + PhysicalType type = + value.type() == PhysicalType.BOOLEAN_FALSE ? PhysicalType.BOOLEAN_TRUE : value.type(); + + typeCounts.compute(type, (k, v) -> (v == null) ? 1 : v + 1); + + // Track max precision and scale for decimal types + if (type == PhysicalType.DECIMAL4 + || type == PhysicalType.DECIMAL8 + || type == PhysicalType.DECIMAL16) { + VariantPrimitive primitive = value.asPrimitive(); + Object decimalValue = primitive.get(); + if (decimalValue instanceof BigDecimal bd) { + maxDecimalIntegerDigits = Math.max(maxDecimalIntegerDigits, bd.precision() - bd.scale()); + maxDecimalScale = Math.max(maxDecimalScale, bd.scale()); + } + } + } + + PhysicalType getMostCommonType() { + Map combinedCounts = Maps.newHashMap(); + + int integerTotalCount = 0; + PhysicalType mostCapableInteger = null; + + int decimalTotalCount = 0; + PhysicalType mostCapableDecimal = null; + + for (Map.Entry entry : typeCounts.entrySet()) { + PhysicalType type = entry.getKey(); + int count = entry.getValue(); + + if (isIntegerType(type)) { + integerTotalCount += count; + if (mostCapableInteger == null + || INTEGER_PRIORITY.get(type) > INTEGER_PRIORITY.get(mostCapableInteger)) { + mostCapableInteger = type; + } + } else if (isDecimalType(type)) { + decimalTotalCount += count; + if (mostCapableDecimal == null + || DECIMAL_PRIORITY.get(type) > DECIMAL_PRIORITY.get(mostCapableDecimal)) { + mostCapableDecimal = type; + } + } else { + combinedCounts.put(type, count); + } + } + + if (mostCapableInteger != null) { + combinedCounts.put(mostCapableInteger, integerTotalCount); + } + + if (mostCapableDecimal != null) { + combinedCounts.put(mostCapableDecimal, decimalTotalCount); + } + + // Pick the most common type with tie-breaking + return combinedCounts.entrySet().stream() + .max( + Map.Entry.comparingByValue() + .thenComparingInt(entry -> TIE_BREAK_PRIORITY.getOrDefault(entry.getKey(), -1))) + .map(Map.Entry::getKey) + .orElse(null); + } + + private static boolean isIntegerType(PhysicalType type) { + return type == PhysicalType.INT8 + || type == PhysicalType.INT16 + || type == PhysicalType.INT32 + || type == PhysicalType.INT64; + } + + private static boolean isDecimalType(PhysicalType type) { + return type == PhysicalType.DECIMAL4 + || type == PhysicalType.DECIMAL8 + || type == PhysicalType.DECIMAL16; + } + } +} diff --git a/parquet/src/main/java/org/apache/iceberg/parquet/VariantWriterBuilder.java b/parquet/src/main/java/org/apache/iceberg/parquet/VariantWriterBuilder.java index a447a102690a..da409c92f113 100644 --- a/parquet/src/main/java/org/apache/iceberg/parquet/VariantWriterBuilder.java +++ b/parquet/src/main/java/org/apache/iceberg/parquet/VariantWriterBuilder.java @@ -202,23 +202,29 @@ public Optional> visit(DecimalLogicalTypeAnnotation decima case FIXED_LEN_BYTE_ARRAY: case BINARY: writer = - ParquetVariantWriters.primitive( + ParquetVariantWriters.decimal( ParquetValueWriters.decimalAsFixed( desc, decimal.getPrecision(), decimal.getScale()), + decimal.getPrecision(), + decimal.getScale(), PhysicalType.DECIMAL16); return Optional.of(writer); case INT64: writer = - ParquetVariantWriters.primitive( + ParquetVariantWriters.decimal( ParquetValueWriters.decimalAsLong( desc, decimal.getPrecision(), decimal.getScale()), + decimal.getPrecision(), + decimal.getScale(), PhysicalType.DECIMAL8); return Optional.of(writer); case INT32: writer = - ParquetVariantWriters.primitive( + ParquetVariantWriters.decimal( ParquetValueWriters.decimalAsInteger( desc, decimal.getPrecision(), decimal.getScale()), + decimal.getPrecision(), + decimal.getScale(), PhysicalType.DECIMAL4); return Optional.of(writer); } diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/ParquetWritingTestUtils.java b/parquet/src/test/java/org/apache/iceberg/parquet/ParquetWritingTestUtils.java index b8cd38f56dfe..441073d34a4e 100644 --- a/parquet/src/test/java/org/apache/iceberg/parquet/ParquetWritingTestUtils.java +++ b/parquet/src/test/java/org/apache/iceberg/parquet/ParquetWritingTestUtils.java @@ -35,7 +35,7 @@ import org.apache.parquet.schema.MessageType; /** Utilities for tests that need to write Parquet files. */ -class ParquetWritingTestUtils { +public class ParquetWritingTestUtils { private ParquetWritingTestUtils() {} diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/TestDictionaryRowGroupFilter.java b/parquet/src/test/java/org/apache/iceberg/parquet/TestDictionaryRowGroupFilter.java index 48cc2b0992a5..22f8068c0fa3 100644 --- a/parquet/src/test/java/org/apache/iceberg/parquet/TestDictionaryRowGroupFilter.java +++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestDictionaryRowGroupFilter.java @@ -179,7 +179,7 @@ public void createInputFile() throws IOException { OutputFile outFile = Files.localOutput(parquetFile); try (FileAppender appender = - Parquet.write(outFile).schema(FILE_SCHEMA).withWriterVersion(writerVersion).build()) { + Parquet.write(outFile).schema(FILE_SCHEMA).writerVersion(writerVersion).build()) { GenericRecordBuilder builder = new GenericRecordBuilder(convert(FILE_SCHEMA, "table")); // create 20 copies of each record to ensure dictionary-encoding for (int copy = 0; copy < 20; copy += 1) { @@ -608,7 +608,7 @@ public void testIntegerLt() { shouldRead = new ParquetDictionaryRowGroupFilter(SCHEMA, lessThan("id", INT_MAX_VALUE)) .shouldRead(parquetSchema, rowGroupMetadata, dictionaryStore); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -656,7 +656,7 @@ public void testIntegerGt() { shouldRead = new ParquetDictionaryRowGroupFilter(SCHEMA, greaterThan("id", INT_MAX_VALUE - 4)) .shouldRead(parquetSchema, rowGroupMetadata, dictionaryStore); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -679,7 +679,7 @@ public void testIntegerGtEq() { shouldRead = new ParquetDictionaryRowGroupFilter(SCHEMA, greaterThanOrEqual("id", INT_MAX_VALUE - 4)) .shouldRead(parquetSchema, rowGroupMetadata, dictionaryStore); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -835,7 +835,7 @@ SCHEMA, lessThan("struct_not_null.int_field", INT_MIN_VALUE + 1)) new ParquetDictionaryRowGroupFilter( SCHEMA, lessThan("struct_not_null.int_field", INT_MAX_VALUE)) .shouldRead(parquetSchema, rowGroupMetadata, dictionaryStore); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -891,7 +891,7 @@ SCHEMA, greaterThan("struct_not_null.int_field", INT_MAX_VALUE - 1)) new ParquetDictionaryRowGroupFilter( SCHEMA, greaterThan("struct_not_null.int_field", INT_MAX_VALUE - 4)) .shouldRead(parquetSchema, rowGroupMetadata, dictionaryStore); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate @@ -918,7 +918,7 @@ SCHEMA, greaterThanOrEqual("struct_not_null.int_field", INT_MAX_VALUE)) new ParquetDictionaryRowGroupFilter( SCHEMA, greaterThanOrEqual("struct_not_null.int_field", INT_MAX_VALUE - 4)) .shouldRead(parquetSchema, rowGroupMetadata, dictionaryStore); - assertThat(shouldRead).as("Should read: may possible ids").isTrue(); + assertThat(shouldRead).as("Should read: many possible ids").isTrue(); } @TestTemplate diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/TestParquet.java b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquet.java index 58850ec7c9f4..5f1e0c83cc0f 100644 --- a/parquet/src/test/java/org/apache/iceberg/parquet/TestParquet.java +++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquet.java @@ -30,6 +30,7 @@ import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.io.IOException; @@ -57,6 +58,7 @@ import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.IntegerType; import org.apache.iceberg.util.Pair; +import org.apache.iceberg.variants.Variant; import org.apache.parquet.avro.AvroParquetWriter; import org.apache.parquet.column.statistics.Statistics; import org.apache.parquet.hadoop.ParquetFileReader; @@ -64,7 +66,9 @@ import org.apache.parquet.hadoop.metadata.BlockMetaData; import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData; import org.apache.parquet.io.LocalOutputFile; +import org.apache.parquet.schema.LogicalTypeAnnotation; import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -314,6 +318,26 @@ public void testFooterMetricsWithNameMappingForFileWithoutIds() throws IOExcepti } } + @Test + public void testAvroWriterRejectsVariantType() { + MessageType schema = + org.apache.parquet.schema.Types.buildMessage() + .optional(PrimitiveTypeName.INT32) + .named("id") + .optionalGroup() + .as(LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION)) + .required(PrimitiveTypeName.BINARY) + .named("metadata") + .required(PrimitiveTypeName.BINARY) + .named("value") + .named("v") + .named("table"); + + assertThatThrownBy(() -> ParquetAvroWriter.buildWriter(schema)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Avro writer does not support variant types"); + } + private Pair generateFile( Function> createWriterFunc, int desiredRecordCount, diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetDataWriter.java b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetDataWriter.java index 3918fdc63084..36e254628a6a 100644 --- a/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetDataWriter.java +++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetDataWriter.java @@ -42,8 +42,11 @@ import org.apache.iceberg.data.Record; import org.apache.iceberg.data.parquet.GenericParquetReaders; import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.io.BufferedFileAppender; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; @@ -52,8 +55,12 @@ import org.apache.iceberg.variants.Variant; import org.apache.iceberg.variants.VariantMetadata; import org.apache.iceberg.variants.VariantTestUtil; +import org.apache.iceberg.variants.VariantValue; import org.apache.iceberg.variants.Variants; +import org.apache.parquet.example.data.Group; import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.ParquetReader; +import org.apache.parquet.hadoop.example.GroupReadSupport; import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.MessageType; import org.junit.jupiter.api.BeforeEach; @@ -331,4 +338,207 @@ public void testDataWriterWithVariantShredding() throws IOException { testDataWriter( variantSchema, (id, name) -> ParquetVariantUtil.toParquetSchema(variant.value())); } + + @Test + public void testShreddingWriteReturnsBufferedAppender() throws IOException { + Schema variantSchema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "v", Types.VariantType.get())); + + VariantShreddingAnalyzer testAnalyzer = + new VariantShreddingAnalyzer() { + @Override + protected List extractVariantValues(List rows, int idx) { + return java.util.Collections.emptyList(); + } + + @Override + protected int resolveColumnIndex(Void engineSchema, String columnName) { + return -1; + } + }; + + OutputFile outputFile = Files.localOutput(createTempFile(temp)); + + ParquetFormatModel> model = + ParquetFormatModel.create( + Record.class, + Void.class, + (icebergSchema, messageType, engineSchema) -> + GenericParquetWriter.create(icebergSchema, messageType), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + GenericParquetReaders.buildReader(icebergSchema, fileSchema), + testAnalyzer, + record -> record); + + try (FileAppender appender = + model + .writeBuilder(EncryptedFiles.plainAsEncryptedOutput(outputFile)) + .schema(variantSchema) + .setAll(ImmutableMap.of(TableProperties.PARQUET_SHRED_VARIANTS, "true")) + .content(FileContent.DATA) + .build()) { + assertThat(appender).isInstanceOf(BufferedFileAppender.class); + } + } + + @Test + public void testWriteBuilderReturnsDirectAppenderWithNullAnalyzer() throws IOException { + Schema variantSchema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "v", Types.VariantType.get())); + + OutputFile outputFile = Files.localOutput(createTempFile(temp)); + + ParquetFormatModel> model = + ParquetFormatModel.create( + Record.class, + Void.class, + (icebergSchema, messageType, engineSchema) -> + GenericParquetWriter.create(icebergSchema, messageType), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + GenericParquetReaders.buildReader(icebergSchema, fileSchema), + null, + null); + + try (FileAppender appender = + model + .writeBuilder(EncryptedFiles.plainAsEncryptedOutput(outputFile)) + .schema(variantSchema) + .setAll(ImmutableMap.of(TableProperties.PARQUET_SHRED_VARIANTS, "true")) + .content(FileContent.DATA) + .build()) { + // Even with shredding property set, null variantAnalyzer means no BufferedFileAppender + assertThat(appender).isNotInstanceOf(BufferedFileAppender.class); + } + } + + @Test + public void testFormatModelVariantShreddingRoundTrip() throws IOException { + Schema variantSchema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "v", Types.VariantType.get())); + + VariantShreddingAnalyzer analyzer = + new VariantShreddingAnalyzer() { + @Override + protected List extractVariantValues(List rows, int idx) { + List values = Lists.newArrayList(); + for (Record row : rows) { + Object obj = row.get(idx); + if (obj instanceof Variant) { + values.add(((Variant) obj).value()); + } + } + return values; + } + + @Override + protected int resolveColumnIndex(Void engineSchema, String columnName) { + // GenericRecord uses schema column order + return variantSchema.columns().indexOf(variantSchema.findField(columnName)); + } + }; + + ByteBuffer metadataBuffer = VariantTestUtil.createMetadata(ImmutableList.of("a", "b"), true); + VariantMetadata metadata = Variants.metadata(metadataBuffer); + ByteBuffer objectBuffer = + VariantTestUtil.createObject( + metadataBuffer, + ImmutableMap.of( + "a", Variants.of(42), + "b", Variants.of("hello"))); + Variant variant = Variant.of(metadata, Variants.value(metadata, objectBuffer)); + + GenericRecord record = GenericRecord.create(variantSchema); + List variantRecords = + ImmutableList.of( + record.copy(ImmutableMap.of("id", 1L, "v", variant)), + record.copy(ImmutableMap.of("id", 2L, "v", variant)), + record.copy(ImmutableMap.of("id", 3L, "v", variant))); + + OutputFile outputFile = Files.localOutput(createTempFile(temp)); + + ParquetFormatModel> model = + ParquetFormatModel.create( + Record.class, + Void.class, + (icebergSchema, messageType, engineSchema) -> + GenericParquetWriter.create(icebergSchema, messageType), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + GenericParquetReaders.buildReader(icebergSchema, fileSchema), + analyzer, + record1 -> record1); + + try (FileAppender appender = + model + .writeBuilder(EncryptedFiles.plainAsEncryptedOutput(outputFile)) + .schema(variantSchema) + .setAll( + ImmutableMap.of( + TableProperties.PARQUET_SHRED_VARIANTS, "true", + TableProperties.PARQUET_VARIANT_BUFFER_SIZE, "2")) + .content(FileContent.DATA) + .build()) { + assertThat(appender).isInstanceOf(BufferedFileAppender.class); + for (Record rec : variantRecords) { + appender.add(rec); + } + } + + // Verify shredded Parquet schema + try (ParquetFileReader reader = + ParquetFileReader.open(ParquetIO.file(outputFile.toInputFile()))) { + MessageType parquetSchema = reader.getFooter().getFileMetaData().getSchema(); + GroupType variantGroup = parquetSchema.getType("v").asGroupType(); + assertThat(variantGroup.containsField("metadata")).isTrue(); + assertThat(variantGroup.containsField("value")).isTrue(); + assertThat(variantGroup.containsField("typed_value")).isTrue(); + + GroupType typedValue = variantGroup.getType("typed_value").asGroupType(); + assertThat(typedValue.containsField("a")).isTrue(); + assertThat(typedValue.containsField("b")).isTrue(); + } + + // Verify data is in typed columns by reading raw Parquet groups + try (ParquetReader rawReader = + ParquetReader.builder( + new GroupReadSupport(), new org.apache.hadoop.fs.Path(outputFile.location())) + .build()) { + Group row = rawReader.read(); + Group variantData = row.getGroup("v", 0); + + assertThat(variantData.getFieldRepetitionCount("value")) + .as("value should be absent when fully shredded") + .isEqualTo(0); + + Group typedValue = variantData.getGroup("typed_value", 0); + assertThat(typedValue.getGroup("a", 0).getInteger("typed_value", 0)) + .as("typed_value.a should contain 42") + .isEqualTo(42); + assertThat(typedValue.getGroup("b", 0).getString("typed_value", 0)) + .as("typed_value.b should contain hello") + .isEqualTo("hello"); + } + + // Verify data round-trips + List writtenRecords; + try (CloseableIterable reader = + Parquet.read(outputFile.toInputFile()) + .project(variantSchema) + .createReaderFunc( + fileSchema -> GenericParquetReaders.buildReader(variantSchema, fileSchema)) + .build()) { + writtenRecords = Lists.newArrayList(reader); + } + + assertThat(writtenRecords).hasSameSizeAs(variantRecords); + for (int i = 0; i < variantRecords.size(); i++) { + InternalTestHelpers.assertEquals( + variantSchema.asStruct(), variantRecords.get(i), writtenRecords.get(i)); + } + } } diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetPageVersion.java b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetPageVersion.java new file mode 100644 index 000000000000..3be1dce4d9ea --- /dev/null +++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetPageVersion.java @@ -0,0 +1,251 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.parquet; + +import static org.apache.iceberg.parquet.ParquetWritingTestUtils.createTempFile; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.deletes.EqualityDeleteWriter; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.types.Types; +import org.apache.parquet.column.ParquetProperties.WriterVersion; +import org.apache.parquet.column.page.DataPage; +import org.apache.parquet.column.page.DataPageV1; +import org.apache.parquet.column.page.DataPageV2; +import org.apache.parquet.column.page.PageReadStore; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TestParquetPageVersion { + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + private List records; + + @TempDir private Path temp; + + @BeforeEach + void createRecords() { + GenericRecord record = GenericRecord.create(SCHEMA); + + this.records = + ImmutableList.of( + record.copy(ImmutableMap.of("id", 1L, "data", "a")), + record.copy(ImmutableMap.of("id", 2L, "data", "b")), + record.copy(ImmutableMap.of("id", 3L, "data", "c")), + record.copy(ImmutableMap.of("id", 4L, "data", "d")), + record.copy(ImmutableMap.of("id", 5L, "data", "e"))); + } + + @Test + void testWriterDefaultsToPageVersion1() throws IOException { + OutputFile outputFile = newOutputFile(); + + try (FileAppender writer = + Parquet.write(outputFile) + .schema(SCHEMA) + .createWriterFunc(GenericParquetWriter::create) + .build()) { + writer.addAll(records); + } + + assertThat(firstDataPage(outputFile)).isInstanceOf(DataPageV1.class); + } + + @Test + void testWriterUsesConfiguredPageVersion() throws IOException { + OutputFile outputFile = newOutputFile(); + + try (FileAppender writer = + Parquet.write(outputFile) + .schema(SCHEMA) + .set(TableProperties.PARQUET_PAGE_VERSION, "v2") + .createWriterFunc(GenericParquetWriter::create) + .build()) { + writer.addAll(records); + } + + assertThat(firstDataPage(outputFile)).isInstanceOf(DataPageV2.class); + } + + @Test + void testDeleteWriterUsesConfiguredPageVersion() throws IOException { + OutputFile outputFile = newOutputFile(); + + EqualityDeleteWriter deleteWriter = + Parquet.writeDeletes(outputFile) + .createWriterFunc(GenericParquetWriter::create) + .set(TableProperties.PARQUET_PAGE_VERSION, "v2") + .overwrite() + .rowSchema(SCHEMA) + .withSpec(PartitionSpec.unpartitioned()) + .equalityFieldIds(1) + .buildEqualityWriter(); + + try (EqualityDeleteWriter writer = deleteWriter) { + writer.write(records); + } + + assertThat(firstDataPage(outputFile)).isInstanceOf(DataPageV2.class); + } + + @Test + void testDeleteWriterUsesDeleteSpecificPageVersion() throws IOException { + OutputFile outputFile = newOutputFile(); + + EqualityDeleteWriter deleteWriter = + Parquet.writeDeletes(outputFile) + .createWriterFunc(GenericParquetWriter::create) + .set(TableProperties.PARQUET_PAGE_VERSION, "v1") + .set(TableProperties.DELETE_PARQUET_PAGE_VERSION, "v2") + .overwrite() + .rowSchema(SCHEMA) + .withSpec(PartitionSpec.unpartitioned()) + .equalityFieldIds(1) + .buildEqualityWriter(); + + try (EqualityDeleteWriter writer = deleteWriter) { + writer.write(records); + } + + assertThat(firstDataPage(outputFile)).isInstanceOf(DataPageV2.class); + } + + @Test + void testExplicitWriterVersion2OverridesPageVersionProperty() throws IOException { + OutputFile outputFile = newOutputFile(); + + try (FileAppender writer = + Parquet.write(outputFile) + .schema(SCHEMA) + .set(TableProperties.PARQUET_PAGE_VERSION, "v1") + .writerVersion(WriterVersion.PARQUET_2_0) + .createWriterFunc(GenericParquetWriter::create) + .build()) { + writer.addAll(records); + } + + assertThat(firstDataPage(outputFile)).isInstanceOf(DataPageV2.class); + } + + @Test + void testExplicitWriterVersion1OverridesPageVersionProperty() throws IOException { + OutputFile outputFile = newOutputFile(); + + try (FileAppender writer = + Parquet.write(outputFile) + .schema(SCHEMA) + .set(TableProperties.PARQUET_PAGE_VERSION, "v2") + .writerVersion(WriterVersion.PARQUET_1_0) + .createWriterFunc(GenericParquetWriter::create) + .build()) { + writer.addAll(records); + } + + assertThat(firstDataPage(outputFile)).isInstanceOf(DataPageV1.class); + } + + @Test + void testPageVersionPropertyAfterWriterVersionSetsVersion() throws IOException { + OutputFile outputFile = newOutputFile(); + + try (FileAppender writer = + Parquet.write(outputFile) + .schema(SCHEMA) + .writerVersion(WriterVersion.PARQUET_1_0) + .set(TableProperties.PARQUET_PAGE_VERSION, "v2") + .createWriterFunc(GenericParquetWriter::create) + .build()) { + writer.addAll(records); + } + + assertThat(firstDataPage(outputFile)).isInstanceOf(DataPageV2.class); + } + + @Test + void testInvalidPageVersionFails() throws IOException { + OutputFile outputFile = newOutputFile(); + + assertThatThrownBy( + () -> + Parquet.write(outputFile) + .schema(SCHEMA) + .set(TableProperties.PARQUET_PAGE_VERSION, "3") + .createWriterFunc(GenericParquetWriter::create) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported Parquet page version: 3 (must be v1 or v2)"); + } + + @Test + void testInvalidDeletePageVersionFails() throws IOException { + OutputFile outputFile = newOutputFile(); + + assertThatThrownBy( + () -> + Parquet.writeDeletes(outputFile) + .createWriterFunc(GenericParquetWriter::create) + .set(TableProperties.DELETE_PARQUET_PAGE_VERSION, "3") + .overwrite() + .rowSchema(SCHEMA) + .withSpec(PartitionSpec.unpartitioned()) + .equalityFieldIds(1) + .buildEqualityWriter()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported Parquet page version: 3 (must be v1 or v2)"); + } + + private OutputFile newOutputFile() throws IOException { + return Files.localOutput(createTempFile(temp)); + } + + private DataPage firstDataPage(OutputFile outputFile) throws IOException { + try (ParquetFileReader reader = + ParquetFileReader.open(ParquetIO.file(outputFile.toInputFile()))) { + PageReadStore rowGroup = reader.readNextRowGroup(); + assertThat(rowGroup).isNotNull(); + + DataPage dataPage = + rowGroup + .getPageReader( + reader.getFileMetaData().getSchema().getColumnDescription(new String[] {"id"})) + .readPage(); + assertThat(dataPage).isNotNull(); + return dataPage; + } + } +} diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetPartitionStatisticsScan.java b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetPartitionStatisticsScan.java new file mode 100644 index 000000000000..5152e31b28e5 --- /dev/null +++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestParquetPartitionStatisticsScan.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.parquet; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionStatisticsScanTestBase; + +public class TestParquetPartitionStatisticsScan extends PartitionStatisticsScanTestBase { + + @Override + public FileFormat format() { + return FileFormat.PARQUET; + } +} diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantShreddingAnalyzer.java b/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantShreddingAnalyzer.java new file mode 100644 index 000000000000..5ac10f74cc51 --- /dev/null +++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantShreddingAnalyzer.java @@ -0,0 +1,475 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.parquet; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.variants.ShreddedObject; +import org.apache.iceberg.variants.ValueArray; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; +import org.apache.iceberg.variants.Variants; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.junit.jupiter.api.Test; + +public class TestVariantShreddingAnalyzer { + + private static class DirectAnalyzer extends VariantShreddingAnalyzer { + @Override + protected List extractVariantValues(List rows, int idx) { + return rows; + } + + @Override + protected int resolveColumnIndex(Void engineSchema, String columnName) { + throw new UnsupportedOperationException("Not used in direct tests"); + } + } + + @Test + public void testDepthLimitStopsObjectRecursion() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // Each level has {"a": , "x": 1} so objects always have a shreddable primitive + VariantMetadata meta = Variants.metadata("a", "x"); + ShreddedObject innermost = Variants.object(meta); + innermost.put("a", Variants.of(42)); + innermost.put("x", Variants.of(1)); + + for (int i = 0; i < 54; i++) { + ShreddedObject wrapper = Variants.object(meta); + wrapper.put("a", innermost); + wrapper.put("x", Variants.of(1)); + innermost = wrapper; + } + + Type schema = analyzer.analyzeAndCreateSchema(List.of(innermost), 0); + assertThat(schema).isNotNull(); + assertThat(schema.getName()).isEqualTo("typed_value"); + + int shreddedDepth = countObjectDepth(schema); + assertThat(shreddedDepth).isLessThanOrEqualTo(50).isGreaterThan(0); + } + + @Test + public void testDepthLimitStopsArrayRecursion() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // 55-level nested arrays with a primitive only at the very bottom. + // Depth limit (50) prevents reaching the leaf, so schema is null (graceful degradation). + VariantValue innermost = Variants.of(42); + for (int i = 0; i < 55; i++) { + ValueArray wrapper = Variants.array(); + wrapper.add(innermost); + innermost = wrapper; + } + + Type schema = analyzer.analyzeAndCreateSchema(List.of(innermost), 0); + assertThat(schema).isNull(); + } + + @Test + public void testArrayWithinDepthLimit() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // 5-level nested arrays + VariantValue innermost = Variants.of(42); + for (int i = 0; i < 5; i++) { + ValueArray wrapper = Variants.array(); + wrapper.add(innermost); + innermost = wrapper; + } + + Type schema = analyzer.analyzeAndCreateSchema(List.of(innermost), 0); + assertThat(schema).isNotNull(); + assertThat(schema.getName()).isEqualTo("typed_value"); + + int arrayDepth = countArrayDepth(schema); + assertThat(arrayDepth).isEqualTo(5); + } + + @Test + public void testIntermediateFieldCapLimitsTrackedFields() { + int numFields = 1500; + String[] fieldNames = new String[numFields]; + for (int i = 0; i < numFields; i++) { + fieldNames[i] = String.format(Locale.ROOT, "field_%04d", i); + } + + VariantMetadata meta = Variants.metadata(fieldNames); + ShreddedObject obj = Variants.object(meta); + for (String name : fieldNames) { + obj.put(name, Variants.of(42)); + } + + DirectAnalyzer analyzer = new DirectAnalyzer(); + Type schema = analyzer.analyzeAndCreateSchema(List.of(obj), 0); + + assertThat(schema).isNotNull(); + assertThat(schema).isInstanceOf(GroupType.class); + GroupType typedValue = (GroupType) schema; + assertThat(typedValue.getFieldCount()).isLessThanOrEqualTo(300).isGreaterThan(0); + } + + @Test + public void testFieldCapAllowsExistingFieldUpdates() { + int numFields = 1500; + String[] fieldNames = new String[numFields]; + for (int i = 0; i < numFields; i++) { + fieldNames[i] = String.format(Locale.ROOT, "field_%04d", i); + } + + VariantMetadata meta = Variants.metadata(fieldNames); + + ShreddedObject row1 = Variants.object(meta); + for (String name : fieldNames) { + row1.put(name, Variants.of(42)); + } + + ShreddedObject row2 = Variants.object(meta); + for (int i = 0; i < 10; i++) { + row2.put(fieldNames[i], Variants.of("text")); + } + + ShreddedObject row3 = Variants.object(meta); + for (int i = 0; i < 10; i++) { + row3.put(fieldNames[i], Variants.of(99)); + } + + DirectAnalyzer analyzer = new DirectAnalyzer(); + Type schema = analyzer.analyzeAndCreateSchema(List.of(row1, row2, row3), 0); + + assertThat(schema).isNotNull(); + assertThat(schema).isInstanceOf(GroupType.class); + GroupType typedValue = (GroupType) schema; + assertThat(typedValue.getFieldCount()).isGreaterThan(0).isLessThanOrEqualTo(300); + } + + @Test + public void testNestedObjectsWithinDepthLimit() { + VariantMetadata cityMeta = Variants.metadata("city"); + ShreddedObject city = Variants.object(cityMeta); + city.put("city", Variants.of("NYC")); + + VariantMetadata addrMeta = Variants.metadata("address"); + ShreddedObject addr = Variants.object(addrMeta); + addr.put("address", city); + + VariantMetadata rootMeta = Variants.metadata("user"); + ShreddedObject root = Variants.object(rootMeta); + root.put("user", addr); + + DirectAnalyzer analyzer = new DirectAnalyzer(); + Type schema = analyzer.analyzeAndCreateSchema(List.of(root), 0); + + assertThat(schema).isNotNull(); + GroupType rootTv = schema.asGroupType(); + assertThat(rootTv.getName()).isEqualTo("typed_value"); + + // user -> typed_value -> address -> typed_value -> city -> typed_value (STRING) + GroupType userGroup = rootTv.getType("user").asGroupType(); + assertThat(userGroup.containsField("value")).isTrue(); + assertThat(userGroup.containsField("typed_value")).isTrue(); + + GroupType addrTv = userGroup.getType("typed_value").asGroupType(); + GroupType addrGroup = addrTv.getType("address").asGroupType(); + assertThat(addrGroup.containsField("typed_value")).isTrue(); + + GroupType cityTv = addrGroup.getType("typed_value").asGroupType(); + GroupType cityGroup = cityTv.getType("city").asGroupType(); + assertThat(cityGroup.containsField("typed_value")).isTrue(); + + PrimitiveType cityPrimitive = cityGroup.getType("typed_value").asPrimitiveType(); + assertThat(cityPrimitive.getPrimitiveTypeName()) + .isEqualTo(PrimitiveType.PrimitiveTypeName.BINARY); + assertThat(cityPrimitive.getLogicalTypeAnnotation()) + .isEqualTo(LogicalTypeAnnotation.stringType()); + } + + @Test + public void testDecimalForExceedingPrecision() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + // Value 1: 30 integer digits, 0 fractional -> precision=30, scale=0, intDigits=30 + // Value 2: 1 integer digit, 20 fractional -> precision=21, scale=20, intDigits=1 + // Combined: maxIntDigits=30, maxScale=20, raw sum=50 -> capped to precision=38, + // scale=min(20, 38-30)=8 (integer digits get priority) + VariantMetadata meta = Variants.metadata("val"); + ShreddedObject row1 = Variants.object(meta); + row1.put("val", Variants.of(new BigDecimal("123456789012345678901234567890"))); + + ShreddedObject row2 = Variants.object(meta); + row2.put("val", Variants.of(new BigDecimal("1.23456789012345678901"))); + + Type schema = analyzer.analyzeAndCreateSchema(List.of(row1, row2), 0); + assertThat(schema).isNotNull(); + + GroupType typedValue = schema.asGroupType(); + GroupType valGroup = typedValue.getType("val").asGroupType(); + PrimitiveType valPrimitive = valGroup.getType("typed_value").asPrimitiveType(); + + LogicalTypeAnnotation.DecimalLogicalTypeAnnotation decimal = + (LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) + valPrimitive.getLogicalTypeAnnotation(); + assertThat(decimal).isNotNull(); + assertThat(decimal.getPrecision()).isEqualTo(38); + // With 30 integer digits, scale is capped to 38 - 30 = 8 (integer digits get priority) + assertThat(decimal.getScale()).isEqualTo(8); + assertThat(decimal.getScale()).isLessThanOrEqualTo(decimal.getPrecision()); + + // Physical type should be FIXED_LEN_BYTE_ARRAY since precision > 18 + assertThat(valPrimitive.getPrimitiveTypeName()) + .isEqualTo(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY); + } + + @Test + public void testDecimalForExactPrecision() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // Value with exactly precision=38: 20 integer digits + 18 scale = 38 + VariantMetadata meta = Variants.metadata("val"); + ShreddedObject row = Variants.object(meta); + row.put("val", Variants.of(new BigDecimal("12345678901234567890.123456789012345678"))); + + Type schema = analyzer.analyzeAndCreateSchema(List.of(row), 0); + assertThat(schema).isNotNull(); + + GroupType typedValue = schema.asGroupType(); + GroupType valGroup = typedValue.getType("val").asGroupType(); + PrimitiveType valPrimitive = valGroup.getType("typed_value").asPrimitiveType(); + + LogicalTypeAnnotation.DecimalLogicalTypeAnnotation decimal = + (LogicalTypeAnnotation.DecimalLogicalTypeAnnotation) + valPrimitive.getLogicalTypeAnnotation(); + assertThat(decimal.getPrecision()).isEqualTo(38); + assertThat(decimal.getScale()).isEqualTo(18); + } + + @Test + public void testInfrequentFieldsArePruned() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // 100 rows: "common" in all, "rare" in only 5 (below MIN_FIELD_FREQUENCY = 0.10) + List rows = buildPruningTestRows(5, obj -> obj); + + Type schema = analyzer.analyzeAndCreateSchema(rows, 0); + assertThat(schema).isNotNull(); + + GroupType group = schema.asGroupType(); + assertThat(group.containsField("common")).isTrue(); + assertThat(group.containsField("rare")).isFalse(); + } + + @Test + public void testEmptyArrayReturnsNull() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // All rows are empty arrays, no element type to infer + List rows = List.of(Variants.array(), Variants.array(), Variants.array()); + + Type schema = analyzer.analyzeAndCreateSchema(rows, 0); + assertThat(schema).isNull(); + } + + @Test + public void testRootPrimitiveProducesTypedValue() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // root type is primitive + List rows = List.of(Variants.of("hello"), Variants.of("world"), Variants.of("x")); + + Type schema = analyzer.analyzeAndCreateSchema(rows, 0); + assertThat(schema).isNotNull(); + assertThat(schema.getName()).isEqualTo("typed_value"); + assertThat(schema.isPrimitive()).isTrue(); + assertThat(schema.asPrimitiveType().getLogicalTypeAnnotation()) + .isEqualTo(LogicalTypeAnnotation.stringType()); + } + + @Test + public void testRootArrayOfObjectsPrunesInfrequentFields() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + // 100 arrays: "common" in all, "rare" in only 3 (below MIN_FIELD_FREQUENCY = 0.10) + List rows = + buildPruningTestRows( + 3, + obj -> { + ValueArray arr = Variants.array(); + arr.add(obj); + return arr; + }); + + Type schema = analyzer.analyzeAndCreateSchema(rows, 0); + assertThat(schema).isNotNull(); + + GroupType listType = schema.asGroupType(); + assertThat(listType.getLogicalTypeAnnotation()) + .isInstanceOf(LogicalTypeAnnotation.ListLogicalTypeAnnotation.class); + GroupType repeatedGroup = listType.getType(0).asGroupType(); + GroupType elementGroup = repeatedGroup.getType(0).asGroupType(); + assertThat(elementGroup.containsField("typed_value")).isTrue(); + GroupType objectFields = elementGroup.getType("typed_value").asGroupType(); + assertThat(objectFields.containsField("common")).isTrue(); + assertThat(objectFields.containsField("rare")).isFalse(); + } + + @Test + public void testObjectWithArrayChildPrunesNestedFields() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + VariantMetadata itemMeta = Variants.metadata("name", "rare"); + VariantMetadata rootMeta = Variants.metadata("items"); + + // 100 rows, "rare" appears in only 3 rows (below MIN_FIELD_FREQUENCY = 0.10) + List rows = Lists.newArrayList(); + for (int i = 0; i < 100; i++) { + ShreddedObject item = Variants.object(itemMeta); + item.put("name", Variants.of("item_" + i)); + if (i < 3) { + item.put("rare", Variants.of(1)); + } + ValueArray arr = Variants.array(); + arr.add(item); + ShreddedObject root = Variants.object(rootMeta); + root.put("items", arr); + rows.add(root); + } + + Type schema = analyzer.analyzeAndCreateSchema(rows, 0); + assertThat(schema).isNotNull(); + + GroupType rootTv = schema.asGroupType(); + GroupType itemsGroup = rootTv.getType("items").asGroupType(); + assertThat(itemsGroup.containsField("typed_value")).isTrue(); + GroupType listType = itemsGroup.getType("typed_value").asGroupType(); + GroupType repeatedGroup = listType.getType(0).asGroupType(); + GroupType elementGroup = repeatedGroup.getType(0).asGroupType(); + assertThat(elementGroup.containsField("typed_value")).isTrue(); + GroupType elementFields = elementGroup.getType("typed_value").asGroupType(); + assertThat(elementFields.containsField("name")).isTrue(); + assertThat(elementFields.containsField("rare")).isFalse(); + } + + @Test + public void testLongArrayInFewRowsSurvivesPruning() { + DirectAnalyzer analyzer = new DirectAnalyzer(); + + VariantMetadata itemMeta = Variants.metadata("key"); + + // 2 of 100 rows have 500-element arrays with {"key": N}. Per-element counting gives + // observationCount=1000, so key survives the 10% pruning threshold. + List rows = Lists.newArrayList(); + for (int i = 0; i < 100; i++) { + ValueArray arr = Variants.array(); + if (i < 2) { + for (int j = 0; j < 500; j++) { + ShreddedObject item = Variants.object(itemMeta); + item.put("key", Variants.of(j)); + arr.add(item); + } + } else { + arr.add(Variants.of("no_key")); + } + rows.add(arr); + } + + Type schema = analyzer.analyzeAndCreateSchema(rows, 0); + assertThat(schema).isNotNull(); + + GroupType listType = schema.asGroupType(); + GroupType repeatedGroup = listType.getType(0).asGroupType(); + GroupType elementGroup = repeatedGroup.getType(0).asGroupType(); + assertThat(elementGroup.containsField("typed_value")).isTrue(); + GroupType elementFields = elementGroup.getType("typed_value").asGroupType(); + assertThat(elementFields.containsField("key")).isTrue(); + } + + /** + * Builds 100 variant rows where "common" appears in every row and "rare" appears in only {@code + * rareCount} rows (below MIN_FIELD_FREQUENCY = 0.10 when rareCount < 10). + */ + private static List buildPruningTestRows( + int rareCount, Function wrap) { + VariantMetadata meta = Variants.metadata("common", "rare"); + List rows = Lists.newArrayList(); + for (int i = 0; i < 100; i++) { + ShreddedObject obj = Variants.object(meta); + obj.put("common", Variants.of(i)); + if (i < rareCount) { + obj.put("rare", Variants.of("text")); + } + rows.add(wrap.apply(obj)); + } + return rows; + } + + /** Count typed_value group nesting depth along field "a". */ + private static int countObjectDepth(Type type) { + int depth = 0; + Type current = type; + while (current != null && "typed_value".equals(current.getName()) && !current.isPrimitive()) { + depth++; + GroupType group = current.asGroupType(); + if (group.containsField("a")) { + GroupType fieldGroup = group.getType("a").asGroupType(); + if (fieldGroup.containsField("typed_value")) { + current = fieldGroup.getType("typed_value"); + } else { + break; + } + } else { + break; + } + } + return depth; + } + + /** Count nested array (LIST) levels in the schema. */ + private static int countArrayDepth(Type type) { + int depth = 0; + Type current = type; + while (current != null && !current.isPrimitive()) { + if (!"typed_value".equals(current.getName())) { + break; + } + GroupType group = current.asGroupType(); + if (!(group.getLogicalTypeAnnotation() + instanceof LogicalTypeAnnotation.ListLogicalTypeAnnotation)) { + break; + } + depth++; + GroupType listGroup = group.getType(0).asGroupType(); + GroupType elementGroup = listGroup.getType(0).asGroupType(); + if (elementGroup.containsField("typed_value")) { + current = elementGroup.getType("typed_value"); + } else { + break; + } + } + return depth; + } +} diff --git a/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantWriters.java b/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantWriters.java index 2c83406f9fee..13521ec02982 100644 --- a/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantWriters.java +++ b/parquet/src/test/java/org/apache/iceberg/parquet/TestVariantWriters.java @@ -41,6 +41,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.types.Types; +import org.apache.iceberg.variants.ShreddedObject; import org.apache.iceberg.variants.ValueArray; import org.apache.iceberg.variants.Variant; import org.apache.iceberg.variants.VariantArray; @@ -52,6 +53,7 @@ import org.apache.parquet.hadoop.ParquetFileReader; import org.apache.parquet.schema.LogicalTypeAnnotation; import org.apache.parquet.schema.MessageType; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.FieldSource; @@ -280,4 +282,53 @@ private static ValueArray array(VariantValue... values) { return arr; } + + @Test + public void testPartialShreddingWithShreddedObject() throws IOException { + // Test for issue #15086: partial shredding with ShreddedObject created using put() + // Create a ShreddedObject with multiple fields, then partially shred it + VariantMetadata metadata = Variants.metadata("id", "name", "city"); + + // Create objects using ShreddedObject.put() instead of serialized buffers + List records = Lists.newArrayList(); + for (int i = 0; i < 3; i++) { + ShreddedObject obj = Variants.object(metadata); + obj.put("id", Variants.of(1000L + i)); + obj.put("name", Variants.of("user_" + i)); + obj.put("city", Variants.of("city_" + i)); + + Variant variant = Variant.of(metadata, obj); + Record record = RECORD.copy("id", i, "var", variant); + records.add(record); + } + + // Shredding function that only shreds the "id" field + VariantShreddingFunction partialShredding = + (id, name) -> { + VariantMetadata shreddedMetadata = Variants.metadata("id"); + ShreddedObject shreddedObject = Variants.object(shreddedMetadata); + shreddedObject.put("id", Variants.of(1234L)); + return ParquetVariantUtil.toParquetSchema(shreddedObject); + }; + + // Write and read back + List actual = writeAndRead(partialShredding, records); + + // Verify all records match + assertThat(actual).hasSameSizeAs(records); + for (int i = 0; i < records.size(); i++) { + Record expected = records.get(i); + Record read = actual.get(i); + + InternalTestHelpers.assertEquals(SCHEMA.asStruct(), expected, read); + + // Also verify the variant object has all fields intact + Variant readVariant = (Variant) read.getField("var"); + VariantObject readObj = readVariant.value().asObject(); + assertThat(readObj.numFields()).isEqualTo(3); + assertThat(readObj.get("id").asPrimitive().get()).isEqualTo(1000L + i); + assertThat(readObj.get("name").asPrimitive().get()).isEqualTo("user_" + i); + assertThat(readObj.get("city").asPrimitive().get()).isEqualTo("city_" + i); + } + } } diff --git a/parquet/src/testFixtures/java/org/apache/iceberg/parquet/ParquetFileTestUtils.java b/parquet/src/testFixtures/java/org/apache/iceberg/parquet/ParquetFileTestUtils.java new file mode 100644 index 000000000000..a6055424c0a6 --- /dev/null +++ b/parquet/src/testFixtures/java/org/apache/iceberg/parquet/ParquetFileTestUtils.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.parquet; + +import org.apache.parquet.io.InputFile; +import org.apache.parquet.io.OutputFile; + +/** Utilities for tests that need to write Parquet files. */ +public class ParquetFileTestUtils { + + private ParquetFileTestUtils() {} + + public static OutputFile file(org.apache.iceberg.io.OutputFile file) { + return ParquetIO.file(file); + } + + public static InputFile file(org.apache.iceberg.io.InputFile file) { + return ParquetIO.file(file); + } +} diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/double.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/double.parquet new file mode 100644 index 000000000000..3e0edd2627ab Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/double.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/double_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/double_with_nulls.parquet new file mode 100644 index 000000000000..d23c1e6b7387 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/double_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/float.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/float.parquet new file mode 100644 index 000000000000..8ba32a302f08 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/float.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/float_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/float_with_nulls.parquet new file mode 100644 index 000000000000..c16e10e6804e Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/float_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int32.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int32.parquet new file mode 100644 index 000000000000..46132a924dc8 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int32.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int32_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int32_with_nulls.parquet new file mode 100644 index 000000000000..174f7e52337a Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int32_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int64.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int64.parquet new file mode 100644 index 000000000000..83576717e84b Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int64.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int64_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int64_with_nulls.parquet new file mode 100644 index 000000000000..e5e91eb0ab44 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/int64_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/uuid.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/uuid.parquet new file mode 100644 index 000000000000..df52946dc27b Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/uuid.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/uuid_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/uuid_with_nulls.parquet new file mode 100644 index 000000000000..577bb592bced Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/BYTE_STREAM_SPLIT/uuid_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/binary.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/binary.parquet new file mode 100644 index 000000000000..6d01839cd4aa Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/binary.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/binary_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/binary_with_nulls.parquet new file mode 100644 index 000000000000..b252f642ed6e Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/binary_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/string.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/string.parquet new file mode 100644 index 000000000000..204719bc072b Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/string.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/string_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/string_with_nulls.parquet new file mode 100644 index 000000000000..599e72bac021 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_BYTE_ARRAY/string_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/binary.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/binary.parquet new file mode 100644 index 000000000000..926d02835f77 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/binary.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/binary_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/binary_with_nulls.parquet new file mode 100644 index 000000000000..82697f40aafa Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/binary_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/string.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/string.parquet new file mode 100644 index 000000000000..686bf91ff076 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/string.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/string_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/string_with_nulls.parquet new file mode 100644 index 000000000000..b33d5602c823 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/DELTA_LENGTH_BYTE_ARRAY/string_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/binary_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/binary_with_nulls.parquet new file mode 100644 index 000000000000..80ce3ee71ecc Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/binary_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/boolean_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/boolean_with_nulls.parquet new file mode 100644 index 000000000000..4678e31da179 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/boolean_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/double.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/double.parquet new file mode 100644 index 000000000000..edd614c66af6 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/double.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/double_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/double_with_nulls.parquet new file mode 100644 index 000000000000..3d4f64baa722 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/double_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/float_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/float_with_nulls.parquet new file mode 100644 index 000000000000..c9abb9b98def Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/float_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/int32_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/int32_with_nulls.parquet new file mode 100644 index 000000000000..3e1dc514c725 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/int32_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/int64_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/int64_with_nulls.parquet new file mode 100644 index 000000000000..1e6e36e54195 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/int64_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/string_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/string_with_nulls.parquet new file mode 100644 index 000000000000..9beedb2be5d0 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/string_with_nulls.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/uuid.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/uuid.parquet new file mode 100644 index 000000000000..3182d22757c8 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/uuid.parquet differ diff --git a/parquet/src/testFixtures/resources/encodings/PLAIN/uuid_with_nulls.parquet b/parquet/src/testFixtures/resources/encodings/PLAIN/uuid_with_nulls.parquet new file mode 100644 index 000000000000..79803f74a466 Binary files /dev/null and b/parquet/src/testFixtures/resources/encodings/PLAIN/uuid_with_nulls.parquet differ diff --git a/runtime-deps.gradle b/runtime-deps.gradle new file mode 100644 index 000000000000..e08fce3fe47a --- /dev/null +++ b/runtime-deps.gradle @@ -0,0 +1,130 @@ +/* + * 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. + */ + +// Guards the runtime dependency surface for shadow JAR modules. +// +// Prevents accidental transitive dependency growth in shipped shadow JARs. +// Without this guard, adding a single catalog module as 'implementation' +// instead of 'compileOnly' can silently leak dozens of transitive artifacts +// into the runtime JAR, inflating its size and introducing unlicensed code. +// +// Apply this script in any project that ships a bundled artifact: Spark and +// Flink runtime shadow JARs, cloud bundles (aws, azure, gcp), and Kafka +// Connect runtime distribution. +// +// It adds two tasks: +// +// generateRuntimeDeps - resolves runtimeClasspath and writes a sorted +// baseline of group:artifact:version coordinates +// to runtime-deps.txt in the project directory. +// +// checkRuntimeDeps - compares the resolved dependencies against the +// checked-in baseline and fails with a diff if +// they don't match. Patch-level version changes are +// ignored so that routine Dependabot bumps don't +// require a baseline update. Wired into the 'check' +// lifecycle. +// +// Workflow: +// 1. ./gradlew check -- fails if deps changed +// 2. ./gradlew generateRuntimeDeps -- auto-updates all baselines +// 3. Update LICENSE and NOTICE if dependency licenses changed -- This is a Manual Step +// 4. Commit + +def depsFile = file("${projectDir}/runtime-deps.txt") + +def resolveRuntimeDeps = { + configurations.runtimeClasspath.resolvedConfiguration + .resolvedArtifacts + .collect { "${it.moduleVersion.id.group}:${it.moduleVersion.id.name}:${it.moduleVersion.id.version}" } + .findAll { !it.startsWith('org.apache.iceberg:') } + .toSorted() + .toUnique() +} + +tasks.register('generateRuntimeDeps') { + group = 'verification' + description = 'Regenerate the runtime dependency baseline after intentional dependency changes' + outputs.file(depsFile) + doLast { + def deps = resolveRuntimeDeps() + depsFile.text = deps.join('\n') + '\n' + logger.lifecycle("Wrote ${deps.size()} dependencies to ${depsFile}") + logger.lifecycle("Review the diff, then update LICENSE and NOTICE if licenses changed.") + } +} + +tasks.register('checkRuntimeDeps') { + group = 'verification' + description = 'Verify runtime dependencies match the checked-in baseline' + inputs.files(configurations.runtimeClasspath) + outputs.file(depsFile) + doLast { + if (!depsFile.exists()) { + logger.warn("WARNING: Missing ${depsFile.name} in ${projectDir}. " + + "Run: ./gradlew ${project.path}:generateRuntimeDeps") + return + } + + def actual = resolveRuntimeDeps() + def expected = depsFile.readLines().findAll { it.trim() }.toSorted() + + def groupArtifact = { coord -> coord.substring(0, coord.lastIndexOf(':')) } + def majorMinor = { coord -> + def ver = coord.substring(coord.lastIndexOf(':') + 1) + def parts = ver.split('\\.') + parts.length >= 2 ? "${parts[0]}.${parts[1]}" : ver + } + + def actualByModule = actual.collectEntries { [(groupArtifact(it)): it] } + def expectedByModule = expected.collectEntries { [(groupArtifact(it)): it] } + + def added = actualByModule.keySet() - expectedByModule.keySet() + def removed = expectedByModule.keySet() - actualByModule.keySet() + def shared = actualByModule.keySet().intersect(expectedByModule.keySet()) + def versionChanged = shared.findAll { + majorMinor(actualByModule[it]) != majorMinor(expectedByModule[it]) + } + + if (added || removed || versionChanged) { + def msg = new StringBuilder() + msg.append("Runtime dependency baseline mismatch for ${project.name}!\n") + if (versionChanged) { + msg.append("\n Version changed (${versionChanged.size()}):\n") + versionChanged.toSorted().each { module -> + msg.append(" ~ ${expectedByModule[module]} -> ${actualByModule[module]}\n") + } + } + if (added) { + msg.append("\n Added (${added.size()}):\n") + added.toSorted().each { module -> msg.append(" + ${actualByModule[module]}\n") } + } + if (removed) { + msg.append("\n Removed (${removed.size()}):\n") + removed.toSorted().each { module -> msg.append(" - ${expectedByModule[module]}\n") } + } + msg.append("\nTo update the baseline run:\n") + msg.append(" ./gradlew ${project.path}:generateRuntimeDeps\n") + msg.append("\nThen update LICENSE and NOTICE to reflect the dependency changes.") + throw new GradleException(msg.toString()) + } + } +} + +check.dependsOn checkRuntimeDeps diff --git a/settings.gradle b/settings.gradle index de342dda1476..70f9343a252b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -175,6 +175,18 @@ if (sparkVersions.contains("4.0")) { project(":iceberg-spark:spark-runtime-4.0_2.13").name = "iceberg-spark-runtime-4.0_2.13" } +if (sparkVersions.contains("4.1")) { + include ":iceberg-spark:spark-4.1_2.13" + include ":iceberg-spark:spark-extensions-4.1_2.13" + include ":iceberg-spark:spark-runtime-4.1_2.13" + project(":iceberg-spark:spark-4.1_2.13").projectDir = file('spark/v4.1/spark') + project(":iceberg-spark:spark-4.1_2.13").name = "iceberg-spark-4.1_2.13" + project(":iceberg-spark:spark-extensions-4.1_2.13").projectDir = file('spark/v4.1/spark-extensions') + project(":iceberg-spark:spark-extensions-4.1_2.13").name = "iceberg-spark-extensions-4.1_2.13" + project(":iceberg-spark:spark-runtime-4.1_2.13").projectDir = file('spark/v4.1/spark-runtime') + project(":iceberg-spark:spark-runtime-4.1_2.13").name = "iceberg-spark-runtime-4.1_2.13" +} + if (kafkaVersions.contains("3")) { include 'kafka-connect' project(':kafka-connect').name = 'iceberg-kafka-connect' diff --git a/site/README.md b/site/README.md index c0fd351adcee..212b0a706fd3 100644 --- a/site/README.md +++ b/site/README.md @@ -141,7 +141,6 @@ make serve-dev This development mode: - **Only builds `nightly` and `latest` versions** - Skips all historical versions - **Significantly reduces build time** - Typically 5-10x faster than building all versions -- **Uses the `--dirty` flag** - Only rebuilds changed files for even faster iteration - **Perfect for iterative development** - Great for working on documentation content The development mode sets the `ICEBERG_DEV_MODE=true` environment variable and uses a simplified mkdocs configuration (`mkdocs-dev.yml`) that only includes the most recent versions. diff --git a/site/dev/common.sh b/site/dev/common.sh index 147c17d225f8..6556a4df7637 100755 --- a/site/dev/common.sh +++ b/site/dev/common.sh @@ -19,7 +19,7 @@ set -e export REMOTE="iceberg_docs" -export VENV_DIR=".venv" +export VENV_DIR="${VENV_DIR:-.venv}" # Ensures the presence of a specified remote repository for documentation. # If the remote doesn't exist, it adds it using the provided URL. @@ -48,7 +48,8 @@ install_deps () { echo " --> install deps" # Use pip from venv to install or upgrade dependencies from the 'requirements.txt' file quietly - "${VENV_DIR}/bin/pip3" -q install -r requirements.txt --upgrade + local PIP="${PIP:-${VENV_DIR}/bin/pip3}" + ${PIP} -q install -r requirements.txt --upgrade } # Checks if a provided argument is not empty. If empty, displays an error message and exits with a status code 1. diff --git a/site/dev/serve-dev.sh b/site/dev/serve-dev.sh index 8e3ef3d1ac75..d578d8b097cc 100755 --- a/site/dev/serve-dev.sh +++ b/site/dev/serve-dev.sh @@ -32,6 +32,4 @@ echo "" ./dev/setup_env.sh -# Using mkdocs serve with --dirty flag for even faster rebuilds -# The --dirty flag means only changed files are rebuilt -"${VENV_DIR}/bin/python3" -m mkdocs serve --dirty --watch . -f mkdocs-dev.yml +"${VENV_DIR}/bin/python3" -m mkdocs serve -f mkdocs-dev.yml --livereload diff --git a/site/dev/serve.sh b/site/dev/serve.sh index 2be2e5f62661..68b87b434e15 100755 --- a/site/dev/serve.sh +++ b/site/dev/serve.sh @@ -19,8 +19,19 @@ source dev/common.sh set -e +echo "" +echo "**********************************************" +echo "** **" +echo "** 💡 TIP: Use 'make serve-dev' instead! **" +echo "** **" +echo "** It's FASTER for local development **" +echo "** (only builds nightly and latest) **" +echo "** **" +echo "**********************************************" +echo "" + ./dev/setup_env.sh ./dev/lint.sh -"${VENV_DIR}/bin/python3" -m mkdocs serve --dirty --watch . +"${VENV_DIR}/bin/python3" -m mkdocs serve --livereload diff --git a/site/docs/assets/images/2026-02-20-file-format-api-registry-rw.png b/site/docs/assets/images/2026-02-20-file-format-api-registry-rw.png new file mode 100644 index 000000000000..d940f8625b21 Binary files /dev/null and b/site/docs/assets/images/2026-02-20-file-format-api-registry-rw.png differ diff --git a/site/docs/assets/images/2026-02-20-file-format-api-registry.png b/site/docs/assets/images/2026-02-20-file-format-api-registry.png new file mode 100644 index 000000000000..f64d0b06d634 Binary files /dev/null and b/site/docs/assets/images/2026-02-20-file-format-api-registry.png differ diff --git a/site/docs/assets/images/2026-02-20-file-format-api-spark-model.png b/site/docs/assets/images/2026-02-20-file-format-api-spark-model.png new file mode 100644 index 000000000000..d46060e17d96 Binary files /dev/null and b/site/docs/assets/images/2026-02-20-file-format-api-spark-model.png differ diff --git a/site/docs/assets/images/flink-quickstart.excalidraw.png b/site/docs/assets/images/flink-quickstart.excalidraw.png new file mode 100644 index 000000000000..033b4901c074 Binary files /dev/null and b/site/docs/assets/images/flink-quickstart.excalidraw.png differ diff --git a/site/docs/benchmarks.md b/site/docs/benchmarks.md index 1b43652f9a73..e3118228b8ae 100644 --- a/site/docs/benchmarks.md +++ b/site/docs/benchmarks.md @@ -28,6 +28,8 @@ Also note that JMH benchmarks run within the same JVM as the system-under-test, It is possible to run one or more Benchmarks via the **JMH Benchmarks** GH action on your own fork of the Iceberg repo. This GH action takes the following inputs: * The repository name where those benchmarks should be run against, such as `apache/iceberg` or `/iceberg` * The branch name to run benchmarks against, such as `main` or `my-cool-feature-branch` +* The Spark version to use, such as `4.1` (default: `4.1`) +* The Scala version to use, such as `2.13` (default: `2.13`) * A list of comma-separated double-quoted Benchmark names, such as `"IcebergSourceFlatParquetDataReadBenchmark", "IcebergSourceFlatParquetDataFilterBenchmark", "IcebergSourceNestedListParquetDataWriteBenchmark"` Benchmark results will be uploaded once **all** benchmarks are done. @@ -42,89 +44,89 @@ Below are the existing benchmarks shown with the actual commands on how to run t ### IcebergSourceNestedListParquetDataWriteBenchmark A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceNestedListParquetDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-list-parquet-data-write-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceNestedListParquetDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-list-parquet-data-write-benchmark-result.txt` ### SparkParquetReadersNestedDataBenchmark A benchmark that evaluates the performance of reading nested Parquet data using Iceberg and Spark Parquet readers. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=SparkParquetReadersNestedDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-readers-nested-data-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=SparkParquetReadersNestedDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-readers-nested-data-benchmark-result.txt` ### SparkParquetWritersFlatDataBenchmark A benchmark that evaluates the performance of writing Parquet data with a flat schema using Iceberg and Spark Parquet writers. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=SparkParquetWritersFlatDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-writers-flat-data-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=SparkParquetWritersFlatDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-writers-flat-data-benchmark-result.txt` ### IcebergSourceFlatORCDataReadBenchmark A benchmark that evaluates the performance of reading ORC data with a flat schema using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceFlatORCDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-orc-data-read-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceFlatORCDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-orc-data-read-benchmark-result.txt` ### SparkParquetReadersFlatDataBenchmark A benchmark that evaluates the performance of reading Parquet data with a flat schema using Iceberg and Spark Parquet readers. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=SparkParquetReadersFlatDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-readers-flat-data-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=SparkParquetReadersFlatDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-readers-flat-data-benchmark-result.txt` ### VectorizedReadDictionaryEncodedFlatParquetDataBenchmark A benchmark to compare performance of reading Parquet dictionary encoded data with a flat schema using vectorized Iceberg read path and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=VectorizedReadDictionaryEncodedFlatParquetDataBenchmark -PjmhOutputPath=benchmark/vectorized-read-dict-encoded-flat-parquet-data-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=VectorizedReadDictionaryEncodedFlatParquetDataBenchmark -PjmhOutputPath=benchmark/vectorized-read-dict-encoded-flat-parquet-data-result.txt` ### IcebergSourceNestedListORCDataWriteBenchmark A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceNestedListORCDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-list-orc-data-write-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceNestedListORCDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-list-orc-data-write-benchmark-result.txt` ### VectorizedReadFlatParquetDataBenchmark A benchmark to compare performance of reading Parquet data with a flat schema using vectorized Iceberg read path and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=VectorizedReadFlatParquetDataBenchmark -PjmhOutputPath=benchmark/vectorized-read-flat-parquet-data-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=VectorizedReadFlatParquetDataBenchmark -PjmhOutputPath=benchmark/vectorized-read-flat-parquet-data-result.txt` ### IcebergSourceFlatParquetDataWriteBenchmark A benchmark that evaluates the performance of writing Parquet data with a flat schema using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceFlatParquetDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-write-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceFlatParquetDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-write-benchmark-result.txt` ### IcebergSourceNestedAvroDataReadBenchmark A benchmark that evaluates the performance of reading Avro data with a flat schema using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceNestedAvroDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-avro-data-read-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceNestedAvroDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-avro-data-read-benchmark-result.txt` ### IcebergSourceFlatAvroDataReadBenchmark A benchmark that evaluates the performance of reading Avro data with a flat schema using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceFlatAvroDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-avro-data-read-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceFlatAvroDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-avro-data-read-benchmark-result.txt` ### IcebergSourceNestedParquetDataWriteBenchmark A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceNestedParquetDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-write-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceNestedParquetDataWriteBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-write-benchmark-result.txt` ### IcebergSourceNestedParquetDataReadBenchmark * A benchmark that evaluates the performance of reading nested Parquet data using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceNestedParquetDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-read-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceNestedParquetDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-read-benchmark-result.txt` ### IcebergSourceNestedORCDataReadBenchmark A benchmark that evaluates the performance of reading ORC data with a flat schema using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceNestedORCDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-orc-data-read-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceNestedORCDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-orc-data-read-benchmark-result.txt` ### IcebergSourceFlatParquetDataReadBenchmark A benchmark that evaluates the performance of reading Parquet data with a flat schema using Iceberg and the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceFlatParquetDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-read-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceFlatParquetDataReadBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-read-benchmark-result.txt` ### IcebergSourceFlatParquetDataFilterBenchmark A benchmark that evaluates the file skipping capabilities in the Spark data source for Iceberg. This class uses a dataset with a flat schema, where the records are clustered according to the column used in the filter predicate. The performance is compared to the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceFlatParquetDataFilterBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-filter-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceFlatParquetDataFilterBenchmark -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-filter-benchmark-result.txt` ### IcebergSourceNestedParquetDataFilterBenchmark A benchmark that evaluates the file skipping capabilities in the Spark data source for Iceberg. This class uses a dataset with nested data, where the records are clustered according to the column used in the filter predicate. The performance is compared to the built-in file source in Spark. To run this benchmark for either spark-2 or spark-3: -`./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=IcebergSourceNestedParquetDataFilterBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-filter-benchmark-result.txt` +`./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=IcebergSourceNestedParquetDataFilterBenchmark -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-filter-benchmark-result.txt` ### SparkParquetWritersNestedDataBenchmark * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and Spark Parquet writers. To run this benchmark for either spark-2 or spark-3: - `./gradlew :iceberg-spark:iceberg-spark-3.5_2.12:jmh -PjmhIncludeRegex=SparkParquetWritersNestedDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-writers-nested-data-benchmark-result.txt` + `./gradlew -DsparkVersions=4.1 -DscalaVersion=2.13 :iceberg-spark:iceberg-spark-4.1_2.13:jmh -PjmhIncludeRegex=SparkParquetWritersNestedDataBenchmark -PjmhOutputPath=benchmark/spark-parquet-writers-nested-data-benchmark-result.txt` diff --git a/site/docs/blog/.authors.yml b/site/docs/blog/.authors.yml new file mode 100644 index 000000000000..fc3b66a3896c --- /dev/null +++ b/site/docs/blog/.authors.yml @@ -0,0 +1,20 @@ +# 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. + +authors: + iceberg-pmc: + name: Apache Iceberg PMC + description: Apache Iceberg PMC + avatar: assets/images/iceberg-logo-icon.png diff --git a/site/docs/blog/index.md b/site/docs/blog/index.md new file mode 100644 index 000000000000..24ce88f00062 --- /dev/null +++ b/site/docs/blog/index.md @@ -0,0 +1,18 @@ + + +# Project News and Blog diff --git a/site/docs/blog/posts/2026-01-10-iceberg-summit.md b/site/docs/blog/posts/2026-01-10-iceberg-summit.md new file mode 100644 index 000000000000..3bca0d791d04 --- /dev/null +++ b/site/docs/blog/posts/2026-01-10-iceberg-summit.md @@ -0,0 +1,95 @@ +--- +date: 2026-01-10 +title: Announcing Iceberg Summit 2026 +slug: announcing-iceberg-summit-2026 # this is the url +authors: + - iceberg-pmc +categories: + - announcement +--- + + + +**Iceberg Summit is officially coming back in 2026!** + +Mark your calendars! On **April 8 and 9, 2026**, the Apache Iceberg community will gather once again in **San Francisco** at the Marriott Marquis for two full days dedicated to all things Iceberg. + +**[Register now](https://www.icebergsummit.org/#register)** to reserve your spot! + + + +## Two Full Days of Apache Iceberg + +Following the tremendous success of the first in-person Iceberg Summit in 2025, we're excited to expand the event to **TWO full in-person days** for 2026. The community showed up in incredible ways last year, and we're ready to make this year even bigger and better. + +This third edition of Iceberg Summit is sanctioned by the Apache Software Foundation (ASF) with oversight from the Apache Iceberg™ PMC. The event is dedicated to sharing practical knowledge about using Apache Iceberg as the foundation of a modern data stack, bringing together practitioners, data engineers, and developers—from the highly experienced to the "Iceberg-curious." + +## Call for Papers is OPEN! + +If you've been working on something exciting in the Iceberg ecosystem, now's the perfect time to start crafting your submission for a chance to share your work with the entire community. Whether you're: + +- **Building on top of Iceberg** in production +- **Implementing new features** or exploring what's next +- **Compiling best practices** from real-world deployments +- **Working on ecosystem integrations** with other technologies +- **Sharing thought leadership** about the future of data architecture + +**We want to hear from YOU!** + +[Submit Now!](https://sessionize.com/iceberg-summit-2026/) The Call for Papers is still open! + +!!! info "Important Dates" + - **Call for Papers Closes**: January 18, 2026, 11:59 PM PT + - **Speaker Announcements**: February 6, 2026 + - **Event Dates**: April 8-9, 2026 + +### Session Formats + +We're accepting several types of submissions: + +- **Breakout Sessions** (30 minutes) - The standard conference talk format +- **Lightning Talks** (15 minutes) - Quick, digestible insights +- **Keynotes** (30 minutes) - Forward-thinking and thought leadership +- **Panels** (45 minutes) - Multi-perspective discussions with 4-6 panelists +- **Workshops/Labs** (60-180 minutes) - Hands-on, interactive sessions + +### Topics We're Looking For + +- **Iceberg in Production**: Real-world case studies, implementation journeys, lessons learned +- **Iceberg Technology and Features**: Deep dives into Iceberg, PyIceberg, Puffin, and upcoming enhancements +- **Data Architecture**: Stack options, integration patterns, and decision criteria +- **Best Practices**: Tips, tricks, and techniques for specific use cases +- **Thought Leadership**: Your vision for data architecture in the age of big data, cloud, and AI + +### Submit Your Talk + +What are you waiting for? Head over to [Sessionize](https://sessionize.com/iceberg-summit-2026/) to submit your proposal. + +**Need help crafting your submission?** Join us on the [Iceberg Community Slack](https://join.slack.com/t/apache-iceberg/shared_invite/zt-3tkrk9gpf-1eFZ8ozS2In0~zM_BeZiRQ) #abstracts channel for feedback and advice. + +- ~~Session 1: Wednesday, December 17, 2025 @ 8:00 AM PT~~ +- ~~Session 2: Wednesday, January 7, 2026 @ 8:00 AM PT~~ +- Session 3: Wednesday, January 14, 2026 @ 8:00 AM PT + +## Spread the Word + +Last year, we were blown away by the volume and quality of submissions from across the community. Let's make Iceberg Summit 2026 the best one yet! If you know someone working with Iceberg, encourage them to submit a talk and [register](https://www.icebergsummit.org/) to join us this April. + +Can't make it in person? Be sure to register for a _free_ streaming pass to catch the keynotes and one track of content live. + +Hope to see you there! diff --git a/site/docs/blog/posts/2026-01-26-iceberg-cpp-0.2.0-release.md b/site/docs/blog/posts/2026-01-26-iceberg-cpp-0.2.0-release.md new file mode 100644 index 000000000000..6c81cb4ea4c2 --- /dev/null +++ b/site/docs/blog/posts/2026-01-26-iceberg-cpp-0.2.0-release.md @@ -0,0 +1,102 @@ +--- +date: 2026-01-26 +title: Apache Iceberg C++ 0.2.0 Release +slug: apache-iceberg-cpp-0.2.0-release # this is the blog url +authors: + - iceberg-pmc +categories: + - release +--- + + + +The Apache Iceberg community is pleased to announce the 0.2.0 release of Apache Iceberg C++. This release includes [over 200 merged pull requests](https://github.com/apache/iceberg-cpp/compare/v0.1.0...v0.2.0) from 23 distinct contributors. + +The release notes below are not exhaustive and only expose selected highlights of the release. Many other bugfixes and improvements have been made: we refer you to the [complete changelog](https://github.com/apache/iceberg-cpp/releases/tag/v0.2.0) for details. + + + +## Release Highlights + +### Table Scan and Data Access +- Support for v2 deletes and metadata column reads +- Enhanced ManifestReader with projection and filtering +- Implemented file scan task reader with Arrow C Stream integration + +### Table Operations +- Schema evolution: add, delete, update, and move columns +- Table updates: properties, sort order, partition spec, location, and statistics +- Transaction API with snapshot management (fast append) + +### REST Catalog +- Full REST Catalog client with namespace operations and table CRUD operations +- Support for create, load, drop, list, update, and stage-create table operations +- Integration test coverage + +### Expression System +- Complete expression framework with literal expressions, type casting, and binary serialization +- Inclusive/strict metrics evaluators, manifest evaluator, and residual evaluator +- Aggregate expressions and projection evaluators + +### Performance and I/O +- Optimized Avro reader/writer with direct encoding and multi-block support +- Configurable Avro and Parquet readers/writers + +### Catalog and Metadata +- InMemoryCatalog implementation with table management +- Location provider and partition path generation +- Schema selection, projection, and table metadata builder + +### Miscellaneous +- Meson build system support +- Initial documentation website and devcontainer +- Improved code organization and type safety with validation + +## Contributors + +``` +$ git shortlog --perl-regexp --author='^((?!dependabot\[bot\]).*)$' -sn v0.1.0..v0.2.0 + 47 Feiyang Li + 47 Junwang Zhao + 41 Gang Wu + 17 Zhuo Wang + 15 Xinli Shang + 14 Zehua Zou + 9 Guotao Yu + 7 Xiao Dong + 6 Chao Liu + 3 Jiajia Li + 3 Shuxu Li + 2 Kevin Liu + 2 William Ayd + 2 Zhiyuan Liang + 1 Dipanshu Pandey + 1 Subham + 1 liuxiaoyu + 1 slfan1989 +``` + +We thank all contributors for their efforts in making this release possible! + +## Roadmap for 0.3.0 + +The community is actively working on the next release, see [#523](https://github.com/apache/iceberg-cpp/issues/523). + +## Getting Involved + +We welcome questions and contributions from all interested. Issues can be filed on [GitHub](https://github.com/apache/iceberg-cpp/issues), and questions can be directed to GitHub or the [Iceberg dev mailing list](https://iceberg.apache.org/community/#mailing-lists). diff --git a/site/docs/blog/posts/2026-02-02-iceberg-rust-0.8.0-release.md b/site/docs/blog/posts/2026-02-02-iceberg-rust-0.8.0-release.md new file mode 100644 index 000000000000..347e85ab0a4a --- /dev/null +++ b/site/docs/blog/posts/2026-02-02-iceberg-rust-0.8.0-release.md @@ -0,0 +1,141 @@ +--- +date: 2026-02-02 +title: Apache Iceberg Rust 0.8.0 Release +slug: apache-iceberg-rust-0.8.0-release # this is the blog url +authors: + - iceberg-pmc +categories: + - release +--- + + + +The Apache Iceberg community is pleased to announce version 0.8.0 of [iceberg-rust](https://github.com/apache/iceberg-rust). + +This release covers development work from late November 2025 through early January 2026 and is the result of merging **144 PRs** from **37 contributors**. See the [changelog] for the complete list of changes. + +[changelog]: https://github.com/apache/iceberg-rust/blob/main/CHANGELOG.md#v080---2026-01-06 + +`iceberg-rust` is a native Rust implementation of the Apache Iceberg specification, providing high-performance libraries for reading, writing, and managing Iceberg tables in Rust applications and through Python bindings (`pyiceberg-core`). + + + +## Release Highlights + +### V3 Metadata Support + +This release introduces [support for Iceberg V3 metadata format](https://github.com/apache/iceberg-rust/pull/1682), enabling `iceberg-rust` to work with the latest version of the Iceberg table specification. This includes support for V3 manifests with delete file content and enhanced metadata handling. + +### Enhanced DataFusion Integration + +The DataFusion integration received significant improvements: + +- **INSERT INTO partitioned tables**: [Native support](https://github.com/apache/iceberg-rust/pull/1827) for inserting data into partitioned Iceberg tables, with [partition column projection](https://github.com/apache/iceberg-rust/pull/1602) and [repartitioning operator](https://github.com/apache/iceberg-rust/pull/1620) +- **Partition-aware sorting**: [New `sort_by_partition` operator](https://github.com/apache/iceberg-rust/pull/1618) to sort input data by partition values +- **SQLLogicTest integration**: [Added comprehensive testing framework](https://github.com/apache/iceberg-rust/pull/1764) for DataFusion operations +- **Parallel writing**: [Implemented parallel writer interface](https://github.com/apache/iceberg-rust/pull/1769) for DataFusion execution + +### Advanced Delete File Handling + +`iceberg-rust` now has substantially improved support for Iceberg's delete files: + +- **Shared delete file caching**: [Optimized performance](https://github.com/apache/iceberg-rust/pull/1941) by caching and sharing delete files across multiple readers +- **Mixed delete types**: [Support for both position and equality deletes](https://github.com/apache/iceberg-rust/pull/1778) on the same FileScanTask +- **Binary type support**: [Added binary type support](https://github.com/apache/iceberg-rust/pull/1848) in equality delete processing +- **Performance fixes**: Fixed [stack overflow with large deletes](https://github.com/apache/iceberg-rust/pull/1915) and [scan deadlocks](https://github.com/apache/iceberg-rust/pull/1937) +- **Row group filtering**: [Improved filtering](https://github.com/apache/iceberg-rust/pull/1779) when FileScanTask contains byte ranges +- **Case-sensitive deletes**: [Added support](https://github.com/apache/iceberg-rust/pull/1930) for case-sensitive equality delete matching + +### Enhanced Reader Capabilities + +The Arrow reader received several important enhancements: + +- **Position-based projection**: [Support for Parquet files without field IDs](https://github.com/apache/iceberg-rust/pull/1777), enabling reads from migrated tables +- **PartitionSpec support**: [Added partition awareness](https://github.com/apache/iceberg-rust/pull/1821) to FileScanTask and RecordBatchTransformer +- **Schema evolution**: [Fixed handling](https://github.com/apache/iceberg-rust/pull/1750) of Parquet files with schema changes +- **Compressed metadata**: [Support for reading](https://github.com/apache/iceberg-rust/pull/1802) compressed table metadata +- **Date32 support**: Improved handling of [Date32 values](https://github.com/apache/iceberg-rust/pull/1792) and [struct defaults](https://github.com/apache/iceberg-rust/pull/1847) +- **Binary deserialization**: [Added support](https://github.com/apache/iceberg-rust/pull/1820) for deserializing bytes values +- **`_file` metadata column**: [Support for Iceberg's `_file` column](https://github.com/apache/iceberg-rust/pull/1824) to expose source file information + +### Advanced Writers + +New writer implementations provide flexible approaches to data organization: + +- **Clustered and Fanout writers**: [Implemented writers](https://github.com/apache/iceberg-rust/pull/1735) for different data distribution strategies +- **Configurable FanoutWriter**: [Made writer configuration](https://github.com/apache/iceberg-rust/pull/1962) more flexible +- **Non-consuming builders**: [Refactored writer builders](https://github.com/apache/iceberg-rust/pull/1889) to not consume self, allowing reuse + +### Catalog Improvements + +Multiple catalog implementations received updates: + +- **SqlCatalog**: Added [update_table support](https://github.com/apache/iceberg-rust/pull/1911), [register_table](https://github.com/apache/iceberg-rust/pull/1724), and a [builder pattern](https://github.com/apache/iceberg-rust/pull/1666) +- **S3TablesCatalog**: [Implemented update_table](https://github.com/apache/iceberg-rust/pull/1594) for AWS S3 Tables +- **Glue**: [Improved concurrency error handling](https://github.com/apache/iceberg-rust/pull/1875) for Glue catalog +- **MemoryCatalog**: [Fixed namespace handling](https://github.com/apache/iceberg-rust/pull/1970) to return absolute NamespaceIdents +- **REST catalog**: [Improved authentication](https://github.com/apache/iceberg-rust/pull/1712) and [made types public](https://github.com/apache/iceberg-rust/pull/1901) with documentation + +### Schema Conversion + +[New capability](https://github.com/apache/iceberg-rust/pull/1928) to convert Arrow schemas to Iceberg schemas with auto-assigned field IDs, simplifying integration with Arrow-based systems. + +### PyIceberg-core Improvements + +The Python bindings received multiple enhancements: + +- **Smaller artifacts**: Multiple optimizations ([#1841](https://github.com/apache/iceberg-rust/pull/1841), [#1844](https://github.com/apache/iceberg-rust/pull/1844)) reduced package size +- **ABI3 support**: [Using pyo3 abi3-py310](https://github.com/apache/iceberg-rust/pull/1843) for better Python version compatibility +- **RecordBatchTransformerBuilder**: [Updated API](https://github.com/apache/iceberg-rust/pull/1857) for more ergonomic usage + +## Breaking Changes + +This release includes several breaking changes: + +- **Runtime**: [Dropped smol runtime support](https://github.com/apache/iceberg-rust/pull/1900) in favor of tokio +- **MSRV**: [Bumped to Rust 1.88](https://github.com/apache/iceberg-rust/pull/1902) +- **API cleanup**: [Removed deprecated `remove_all`](https://github.com/apache/iceberg-rust/pull/1863) from FileIO +- **SnapshotProducer**: [Simplified validation methods](https://github.com/apache/iceberg-rust/pull/1853) by removing redundant parameters +- **Enums**: [Removed wildcard patterns](https://github.com/apache/iceberg-rust/pull/1925) from exhaustive enums + +## Dependency Updates + +- [Upgraded to DataFusion 51](https://github.com/apache/iceberg-rust/pull/1899) from DataFusion 48 +- [Upgraded to Arrow 57](https://github.com/apache/iceberg-rust/pull/1899) from Arrow 53 +- [Upgraded to apache-avro 0.21.0](https://github.com/apache/iceberg-rust/pull/1881) +- [Upgraded to OpenDAL v0.55](https://github.com/apache/iceberg-rust/pull/1895) +- [Migrated from tera to minijinja](https://github.com/apache/iceberg-rust/pull/1798) for template rendering + +## Infrastructure Improvements + +- **Parallel testing**: [CI now runs unit tests](https://github.com/apache/iceberg-rust/pull/1833) in parallel using a matrix strategy +- **Python tooling**: [Migrated to uv](https://github.com/apache/iceberg-rust/pull/1796) for faster Python dependency management +- **License checking**: [Configured to ignore target/](https://github.com/apache/iceberg-rust/pull/1954) directory +- **GitHub workflow**: [Added failure notifications](https://github.com/apache/iceberg-rust/pull/1870) + +## Getting Involved + +The `iceberg-rust` project welcomes contributions. We use GitHub [issues](https://github.com/apache/iceberg-rust/issues) for tracking work and the [Apache Iceberg Community Slack](https://iceberg.apache.org/community/#slack) for discussions. + +The easiest way to get started is to: + +1. Try `iceberg-rust` with your workloads and report any issues you encounter +2. Review the [contributor guide](https://github.com/apache/iceberg-rust/blob/main/CONTRIBUTING.md) +3. Look for [good first issues](https://github.com/apache/iceberg-rust/contribute) + +For more information, visit the [iceberg-rust repository](https://github.com/apache/iceberg-rust). diff --git a/site/docs/blog/posts/2026-02-10-iceberg-python-0.11.0-release.md b/site/docs/blog/posts/2026-02-10-iceberg-python-0.11.0-release.md new file mode 100644 index 000000000000..986947f8d8df --- /dev/null +++ b/site/docs/blog/posts/2026-02-10-iceberg-python-0.11.0-release.md @@ -0,0 +1,157 @@ +--- +date: 2026-02-10 +title: Apache Iceberg Python 0.11.0 Release +slug: apache-iceberg-python-0.11.0-release +authors: + - iceberg-pmc +categories: + - release +--- + + + +The Apache Iceberg community is pleased to announce the 0.11.0 release of Apache [Iceberg Python](https://github.com/apache/iceberg-python). This release includes over [380 pull requests](https://github.com/apache/iceberg-python/compare/pyiceberg-0.10.0...pyiceberg-0.11.0) from 50+ contributors, including 28 first-time contributors. + +These notes cover the highlights. For the full list, see the [changelog](https://github.com/apache/iceberg-python/releases/tag/pyiceberg-0.11.0). + + + +## Release Highlights + +### Reads and Writes + +- [DeleteFileIndex](https://github.com/apache/iceberg-python/pull/2918) for faster delete file lookups +- [Generator-based writes](https://github.com/apache/iceberg-python/pull/2671) to reduce memory pressure +- [Relaxed `field-id` constraint](https://github.com/apache/iceberg-python/pull/2662) on `add_files` +- [Connection reuse](https://github.com/apache/iceberg-python/pull/2543) for remote S3 signing +- [Multi-process safe ExecutorFactory](https://github.com/apache/iceberg-python/pull/2546) +- [Fixed O(N²) manifest cache growth](https://github.com/apache/iceberg-python/pull/2951) + +### Snapshot Management + +- [Roll back to a specific snapshot ID](https://github.com/apache/iceberg-python/pull/2878) +- [Roll back to a point in time](https://github.com/apache/iceberg-python/pull/2879) +- [Set the current snapshot directly](https://github.com/apache/iceberg-python/pull/2871) + +### Catalog Improvements + +- [Entra ID (Azure AD) auth manager](https://github.com/apache/iceberg-python/pull/2974) +- [Configurable namespace separator](https://github.com/apache/iceberg-python/pull/2826) for REST catalogs and a new [`namespace_exists` method](https://github.com/apache/iceberg-python/pull/2972) +- [`rename_table`](https://github.com/apache/iceberg-python/pull/2588) now validates source and destination namespaces exist +- [X-Client-Version header](https://github.com/apache/iceberg-python/pull/2910) on REST requests +- [AWS profile support](https://github.com/apache/iceberg-python/pull/2948) for Glue and fsspec S3 FileIO +- [`anon` property](https://github.com/apache/iceberg-python/pull/2661) for fsspec ADLS FileIO and [S3 `addressing_style`](https://github.com/apache/iceberg-python/pull/2517) support + +### ORC Read Support + +[Full ORC read support](https://github.com/apache/iceberg-python/pull/2432) was added to the PyArrow I/O layer. + +### Sort Order Evolution + +[Sort order can now be updated](https://github.com/apache/iceberg-python/pull/2552) on existing tables without recreating them. + +### Supported Endpoints in ConfigResponse + +REST catalogs can now [declare which endpoints they support](https://github.com/apache/iceberg-python/pull/2848) via the `ConfigResponse`. The client checks operations against that set, falling back to defaults for older servers that don't return the field. If you call something the server doesn't support, you get a clear error instead of a cryptic failure. + +### REST Scan Planning + +Table scans can now be [planned server-side](https://github.com/apache/iceberg-python/pull/2864) by REST catalogs. This release adds synchronous planning where the client sends a scan request and the server returns file scan tasks to be read. Async planning (submit, poll, cancel) is planned for a future release. + +## Breaking Changes + +- [Dropped Python 3.9](https://github.com/apache/iceberg-python/pull/2554) +- [Removed methods deprecated for 0.11.0](https://github.com/apache/iceberg-python/pull/2983) + +## Infrastructure Improvements + +- [Python 3.13 support](https://github.com/apache/iceberg-python/pull/2863) +- [aarch64 wheel builds](https://github.com/apache/iceberg-python/pull/2973) +- [Migrated to UV](https://github.com/apache/iceberg-python/pull/2601) for dependency management +- [pyiceberg-core 0.8.0](https://github.com/apache/iceberg-python/pull/2928) with DataFusion 51 + +## Contributors + +``` +$ git shortlog --perl-regexp --author='^((?!dependabot\[bot\]).*)$' -sn pyiceberg-0.10.0..pyiceberg-0.11.0 + 44 Kevin Liu + 30 Drew Gallardo + 19 Alex Stephen + 16 Fokko Driesprong + 6 Gabriel Igliozzi + 4 Aniket + 4 Neelesh Salian + 4 Soham + 4 Somasundaram Sekar + 3 Brad Cannon + 3 Jaime Fernández + 3 Manu Zhang + 2 Luke Fitzgerald + 2 Yujiang Zhong + 1 Alex + 1 Anton-Tarazi + 1 Bharath Krishna + 1 Ehsan Totoni + 1 Elton SV + 1 FANNG + 1 Gowthami B + 1 Grégoire Clément + 1 Hanzhi Wang + 1 Honah (Jonas) J. + 1 Ian Atkinson + 1 Jayce Slesar + 1 Jiajia Li + 1 Kevin Jiao + 1 Kyle Heath + 1 Leandro Martelli + 1 Luiz Otavio Vilas Boas Oliveira + 1 NNSatyaKarthik + 1 NikitaMatskevich + 1 Quentin FLEURENT NAMBOT + 1 Ragnar Dahlén + 1 Raúl Cumplido + 1 Stanley Law + 1 Stats + 1 Thomas Powell + 1 Tiansu + 1 Tom + 1 Victorien + 1 Yftach Zur + 1 Yuya Ebihara + 1 chao qian + 1 fcfangcc + 1 gmweaver + 1 jeroko + 1 jtuglu1 + 1 nathanbijleveld + 1 vinjai +``` + +We thank all contributors for their efforts in making this release possible! + +## Getting Involved + +The PyIceberg project welcomes contributions. We use GitHub [issues](https://github.com/apache/iceberg-python/issues) for tracking work and the [Apache Iceberg Community Slack](https://iceberg.apache.org/community/#slack) for discussions. + +The easiest way to get started is to: + +1. Try PyIceberg with your workloads and report any issues you encounter +2. Review the [contributor guide](https://py.iceberg.apache.org/contributing/#getting-started) +3. Look for [good first issues](https://github.com/apache/iceberg-python/contribute) + +For more information, visit the [PyIceberg repository](https://github.com/apache/iceberg-python) or the [documentation](https://py.iceberg.apache.org/). diff --git a/site/docs/blog/posts/2026-02-20-file-format-api.md b/site/docs/blog/posts/2026-02-20-file-format-api.md new file mode 100644 index 000000000000..e396a2bcd55a --- /dev/null +++ b/site/docs/blog/posts/2026-02-20-file-format-api.md @@ -0,0 +1,139 @@ +--- +date: 2026-02-20 +title: Introducing the Apache Iceberg File Format API +slug: apache-iceberg-file-format-api +authors: + - iceberg-pmc +categories: + - announcement +--- + + + +The Apache Iceberg community is excited to announce the finalization of the File Format API, a major architectural milestone that makes file formats **pluggable**, **consistent**, and **engine‑agnostic** across the Iceberg Java codebase. + + + +For years, Iceberg has delivered high‑quality support for **Parquet**, **Avro**, and **ORC**, but the data landscape has evolved dramatically. New formats now emphasize extremely fast random access, GPU‑native encodings, flexible file layouts, and built‑in indexing structures. Opening up the possibility of integrating such formats required a new foundation. + +The File Format API introduces a unified, extensible layer that engines can rely on when reading and writing Iceberg data files in any supported format. It will ship in the upcoming Apache Iceberg 1.11.0 release, making it available to all engines that use the Iceberg Java readers and writers. + +## Why a New File Format API Was Needed + +Iceberg’s original format‑handling code grew organically as support for Parquet, Avro, and ORC matured. Over time, this approach revealed several limitations. + +### Fragmented and duplicated logic +Each engine Apache Spark, Apache Flink, and the generic Java implementation—maintained its own format‑specific readers, writers, and feature handling. Trying out a new format required deep modifications across multiple layers. + +### Large branching code paths +Support for multiple formats was implemented through large switch statements or branching logic, making it difficult to extend and easy to introduce inconsistencies. + +### Uneven feature support +Basic capabilities such as projection, filtering, and delete file handling needed custom work for each format/engine combination, slowing feature development, leaving features unavailable for some formats, and increasing maintenance cost. + +### Accelerating innovation in the ecosystem +New formats have emerged with capabilities such as: + +- Adaptive encodings for strings, numerics, or complex types +- Integrated indexes for fast point/range lookups +- CPU‑ and GPU‑optimized layouts +- File structures that do not match traditional row‑group‑based designs + +Enabling possible support for these formats cleanly required a more flexible architectural contract. + +## What the File Format API Provides + +The File Format API introduces a well‑defined, pluggable interface for integrating new formats into Iceberg. It allows engines to interact with formats through a standardized set of builders and metadata structures. + +### Core concepts include: + +#### **FormatModel** +A format implementation provides a FormatModel describing: + +- The name/identifier of the file format +- Reader construction +- Writer construction +- Format‑specific configuration or capabilities + +![Registry Diagram](../../assets/images/2026-02-20-file-format-api-spark-model.png) + +#### **FormatModelRegistry** +A registry stores the available FormatModels. This decouples engines from specific formats and allows new formats to be added without modifying engine code. + +``` +FormatModelRegistry.register(FormatModel) +``` + + +![Registry Diagram](../../assets/images/2026-02-20-file-format-api-registry.png) + +#### **Read and Write Builders** +Instead of hard‑coded file‑format logic in engines, all operations now go through the Registry, which provides builders for read/write operations. These builder classes are fetched from the registry like this: + +``` +FormatModelRegistry.readBuilder(fileFormat, clazz, inputFile) +FormatModelRegistry.dataWriteBuilder(fileFormat, clazz, outputFile) +FormatModelRegistry.equalityDeleteWriteBuilder(fileFormat, clazz, outputFile) +FormatModelRegistry.positionDeleteWriteBuilder(fileFormat, clazz, outputFile) +``` + +![Registry Diagram](../../assets/images/2026-02-20-file-format-api-registry-rw.png) + +## What the New API Unlocks + +### **1. Integration of New File Formats** +The new architecture allows us to try out formats such as Vortex and Lance to integrate cleanly using predictable APIs. This would allow us to leverage their unique capabilities, such as GPU‑native encodings and index structures, without complex engine‑specific code. + +### **2. Column Families** +The API enables vertically split storage layouts—column families—which support: + +- Partial updates without rewriting entire files +- Higher parallelism +- Smaller metadata footers +- More efficient selective reads + +## Current State + +### ✔ API Finalized +### ✔ Generic Model Implemented +### ✔ Engine Integrations Merged +### ✖ TCK In Progress + +The Technology Compatibility Kit (TCK) is one of the most important next steps. It will validate correctness, semantics, feature completeness, type support, and compatibility for new format implementations. + +## Next Steps + +### **1. Comet format model** +The Spark Comet reader could be migrated to the new Format Model API to cleanly separate out the [Comet](https://github.com/apache/iceberg/pull/13786) related classes. After this the code could be moved to the comet repository, and the Comet format could be registered in the FormatModelRegistry. + +### **2. Vortex Integration** +A full [Vortex](https://github.com/apache/iceberg/issues/15416) integration will demonstrate the power of the new File Format API. + +### **3. Completing the TCK** +Providing a [Technology Compatibility Kit](https://github.com/apache/iceberg/issues/15415) is essential for ensuring stable, long‑term compatibility. + +### **4. Column Families** +We expect that implementing [Column Families](https://docs.google.com/document/d/1OHuZ6RyzZvCOQ6UQoV84GzwVp3UPiu_cfXClsOi03ww) / [Efficient Column Updates](https://docs.google.com/document/u/1/d/1Bd7JVzgajA8-DozzeEE24mID_GLuz6iwj0g4TlcVJcs/edit) proposal will illustrate how vertically split layouts can be implemented cleanly using the new API. + +## Getting Involved + +The community welcomes all contributors. You can help by testing integrations, participating in TCK development, or experimenting with new formats. + +## Conclusion + +Finalizing the File Format API marks a major milestone for Apache Iceberg. It enables clean extensibility, encourages innovation across file formats, and prepares Iceberg for the next generation of analytics and AI workloads. Work on the [TCK](https://github.com/apache/iceberg/issues/15415) and [Vortex](https://github.com/apache/iceberg/issues/15416) integration is already underway, and we are excited to see what the community builds next. For a deeper dive into the design and motivations behind the API, you can also watch [our talk on YouTube](https://www.youtube.com/watch?v=aERM4rjBDhY). diff --git a/site/docs/blog/posts/2026-03-05-iceberg-go-0.5.0-release.md b/site/docs/blog/posts/2026-03-05-iceberg-go-0.5.0-release.md new file mode 100644 index 000000000000..09e532963d8e --- /dev/null +++ b/site/docs/blog/posts/2026-03-05-iceberg-go-0.5.0-release.md @@ -0,0 +1,143 @@ +--- +date: 2026-03-05 +title: Apache Iceberg Go 0.5.0 Release +slug: apache-iceberg-go-0.5.0-release +authors: + - iceberg-pmc +categories: + - release +--- + + + +The Apache Iceberg community is pleased to announce version 0.5.0 of [iceberg-go](https://github.com/apache/iceberg-go). + +This release covers approximately five months of development since the 0.4.0 release in October 2025 and is the result of merging over **110 PRs** from **31 contributors**, including **18 first-time contributors**. See the [full changelog](https://github.com/apache/iceberg-go/compare/v0.4.0...v0.5.0) for the complete list of changes. + +`iceberg-go` is a native Go implementation of the Apache Iceberg table format, providing libraries for reading, writing, and managing Iceberg tables in Go applications. + + + +## Release Highlights + +### Iceberg V3 Table Spec Support + +This release significantly advances `iceberg-go`'s implementation of the Iceberg V3 table specification: + +- **Table Metadata V3**: [Full V3 metadata format support](https://github.com/apache/iceberg-go/pull/585) is now implemented +- **Unknown Type**: [Support for the `Unknown` type](https://github.com/apache/iceberg-go/pull/605) introduced in the V3 spec +- **TimestampNano and TimestampNanoTz**: [New timestamp nanosecond types](https://github.com/apache/iceberg-go/pull/594) required by V3 are now supported +- **Row Lineage Tracking**: [Row lineage metadata](https://github.com/apache/iceberg-go/pull/659) is now tracked and the [snapshot producer wires V3 row-lineage state](https://github.com/apache/iceberg-go/pull/728) correctly, with a fix for [first-row-id assignment in V3 manifest lists](https://github.com/apache/iceberg-go/pull/741) +- **Source IDs**: [Support for `source-ids` metadata](https://github.com/apache/iceberg-go/pull/651) was added + +### View Support + +`iceberg-go` now has substantial support for Iceberg views: + +- **View Spec**: [Initial implementation of the View specification](https://github.com/apache/iceberg-go/pull/600) including serialization, deserialization, and JSON validation +- **View Metadata Builder**: [Full view metadata builder](https://github.com/apache/iceberg-go/pull/629) and `UpdateView` method in the REST catalog +- **Load View**: [Load existing views](https://github.com/apache/iceberg-go/pull/640) from catalogs +- **Hive Catalog View Operations**: [View create, load, drop, and list support](https://github.com/apache/iceberg-go/pull/742) in the Hive catalog +- **RCK Compatibility**: [Field ID preservation and error handling](https://github.com/apache/iceberg-go/pull/647) for REST Catalog Kit compatibility + +### Schema Evolution + +- **Schema evolution**: [Full schema evolution support](https://github.com/apache/iceberg-go/pull/596) was added, enabling `AddColumn`, `RenameColumn`, `UpdateColumnType`, and related operations +- **HighestFieldID fixes**: Corrected `HighestFieldID` to [include the List element ID](https://github.com/apache/iceberg-go/pull/609) and [fixed a related bug](https://github.com/apache/iceberg-go/pull/611) + +### Delete File Support + +`iceberg-go` now supports both strategies for deleting rows from Iceberg tables: + +- **Copy-on-Write deletes**: [Implemented `copy-on-write` delete mode](https://github.com/apache/iceberg-go/pull/718) for rewriting data files on delete +- **Merge-on-Read deletes**: [Implemented `merge-on-read` delete mode](https://github.com/apache/iceberg-go/pull/721) for writing position and equality delete files +- **Table overwrite**: [General table overwrite functionality](https://github.com/apache/iceberg-go/pull/674) with a [clean options-based API](https://github.com/apache/iceberg-go/pull/717) +- **Add/replace data files**: [Functions to directly add or replace DataFiles](https://github.com/apache/iceberg-go/pull/723) in a table + +### Hive Catalog + +- **Initial Hive Catalog support**: [Full Hive metastore catalog](https://github.com/apache/iceberg-go/pull/678) was added, supporting table creation, listing, loading, and dropping +- **View operations in Hive**: [View support](https://github.com/apache/iceberg-go/pull/742) was added alongside the table operations + +### Puffin File Format + +A [Puffin reader and writer](https://github.com/apache/iceberg-go/pull/676) was implemented, enabling `iceberg-go` to read and write Puffin statistics files used for table statistics and NDV sketches. + +### Catalog Improvements + +- **AuthManager support**: [Pluggable authentication manager](https://github.com/apache/iceberg-go/pull/579) for the REST catalog, enabling custom credential flows +- **WithHeaders**: [A `WithHeaders` catalog option](https://github.com/apache/iceberg-go/pull/652) allows injecting custom HTTP headers into REST catalog requests +- **Staged table creation**: [Support for staged table creation](https://github.com/apache/iceberg-go/pull/754) in the REST catalog +- **ListNamespaces pagination**: [Pagination support](https://github.com/apache/iceberg-go/pull/694) was added to `ListNamespaces` +- **RegisterTable fix**: [Corrected the `RegisterTable` REST endpoint](https://github.com/apache/iceberg-go/pull/624) +- **renameTable 204 handling**: [Properly handle `204 No Content`](https://github.com/apache/iceberg-go/pull/633) responses on `renameTable` + +### Write Performance + +- **Partitioned write throughput**: [Optimized partitioned write throughput](https://github.com/apache/iceberg-go/pull/622) by reducing allocations in the hot path +- **Gzip metadata**: Support for both [reading](https://github.com/apache/iceberg-go/pull/610) and [writing](https://github.com/apache/iceberg-go/pull/612) gzip-compressed metadata JSON files + +### IO Improvements + +- **IO registry pattern**: [Refactored IO scheme registration](https://github.com/apache/iceberg-go/pull/709) to use a registry pattern, making it easier to add custom IO backends +- **S3 signing**: [Allow `s3.signer.uri` property](https://github.com/apache/iceberg-go/pull/744) and only reject explicit `s3.remote-signing-enabled=true`; also [set `x-amz-content-sha256` before signing](https://github.com/apache/iceberg-go/pull/637) +- **Azure SAS tokens**: [Updated SAS token behaviour](https://github.com/apache/iceberg-go/pull/746) to match the Java implementation + +### Bug Fixes + +Notable bug fixes in this release include: + +- [Fix truncate transform for negative integers](https://github.com/apache/iceberg-go/pull/608) +- [Fix data race in `yieldDataFiles`](https://github.com/apache/iceberg-go/pull/650) +- [Remove partition type cache causing concurrent map write panic](https://github.com/apache/iceberg-go/pull/627) +- [Preserve snapshot log history](https://github.com/apache/iceberg-go/pull/638) +- [Handle missing parent snapshots in `ExpireSnapshots`](https://github.com/apache/iceberg-go/pull/671) +- [Add `AssertRefSnapshotID` requirements to `ExpireSnapshots`](https://github.com/apache/iceberg-go/pull/672) +- [Support INT32/INT64 physical types for decimal columns](https://github.com/apache/iceberg-go/pull/686) +- [Fix decimal literal `MarshalBinary`](https://github.com/apache/iceberg-go/pull/745) +- [Fix index out of range in `buildManifestEvaluator`](https://github.com/apache/iceberg-go/pull/692) +- [Fix response body leaks in the REST catalog](https://github.com/apache/iceberg-go/pull/655) +- [Close writers on error for every exit path](https://github.com/apache/iceberg-go/pull/667) +- [Fix `findMinSeq` from manifest list](https://github.com/apache/iceberg-go/pull/693) + +### Breaking Changes + +Some of these changes are breaking changes that need to be called out: + +- You now need to add `import _ "github.com/apache/iceberg-go/io/gocloud"` to register the storage implementations for S3, GCS, Azure and in-memory blobs. They are no longer registered by default, only the local filesystem implementation exists by default. +- `io.ParseAWSConfig` is now in the `gocloud` package, though it wasn't expected that this would necessarily be used by many consumers anyways. +- The `table.Metadata` interface has two new methods `PartitionSpecByID` and `NextRowID`, any packages that implemented their own Metadata object to meet the interface would need to add these methods. Though it is not expected that consumers would do this. +- The `rest.Catalog.CreateView` function signature has changed ([#629](https://github.com/apache/iceberg-go/pull/629)) +- The fields of `catalog.CreateTableCfg` have been moved into an embedded `commonCreateCfg` struct ([#754](https://github.com/apache/iceberg-go/pull/754)). Field access should still work via the inherited properties, but you should use `NewCreateTableCfg()` and the `With*` methods rather than attempting to construct this directly. + +## New Contributors + +Welcome to all 18 first-time contributors: +@hamilton-earthscope, @itaigilo, @ankur-anand, @0xMALVEE, @el10savio, @rambleraptor, @vjanelle, @hdnpth, @dhananjaykrutika, @hackintoshrao, @csumisha, @ErenDursun, @dontirun, @alexandre-normand, @agaddis02, @mkuznets, @lovromazgon, @zhjwpku + +## Getting Involved + +The `iceberg-go` project welcomes contributions. We use GitHub [issues](https://github.com/apache/iceberg-go/issues) for tracking work and the [Apache Iceberg Community Slack](https://iceberg.apache.org/community/#slack) for discussions. + +The easiest way to get started is to: + +1. Try `iceberg-go` with your workloads and report any issues you encounter +2. Review the [contributor guide](https://github.com/apache/iceberg-go/blob/main/CONTRIBUTING.md) +3. Look for [good first issues](https://github.com/apache/iceberg-go/contribute) + +For more information, visit the [iceberg-go repository](https://github.com/apache/iceberg-go). diff --git a/site/docs/blog/posts/2026-03-10-iceberg-rust-0.9.0-release.md b/site/docs/blog/posts/2026-03-10-iceberg-rust-0.9.0-release.md new file mode 100644 index 000000000000..488c9329b958 --- /dev/null +++ b/site/docs/blog/posts/2026-03-10-iceberg-rust-0.9.0-release.md @@ -0,0 +1,122 @@ +--- +date: 2026-03-10 +title: Apache Iceberg Rust 0.9.0 Release +slug: apache-iceberg-rust-0.9.0-release # this is the blog url +authors: + - iceberg-pmc +categories: + - release +--- + + + +The Apache Iceberg community is pleased to announce version 0.9.0 of [iceberg-rust](https://github.com/apache/iceberg-rust). + +This release covers development work from early January through early March 2026 and is the result of merging **109 PRs** from **28 contributors**, including **8 new contributors**. See the [changelog] for the complete list of changes. + +[changelog]: https://github.com/apache/iceberg-rust/blob/main/CHANGELOG.md#v090---2026-03-10 + +`iceberg-rust` is a native Rust implementation of the Apache Iceberg specification, providing high-performance libraries for reading, writing, and managing Iceberg tables in Rust applications and through Python bindings (`pyiceberg-core`). + + + +## Release Highlights + +### Trait-Based Storage Architecture + +The most significant change in this release is a new trait-based storage architecture that decouples `iceberg-rust` from any single storage backend. The new [`Storage` trait](https://github.com/apache/iceberg-rust/pull/2061) provides a clean abstraction for file I/O, with [integration into FileIO](https://github.com/apache/iceberg-rust/pull/2116) to make it the primary extension point for adding new storage backends. + +This release includes two native storage implementations built on this trait: + +- **[LocalFsStorage](https://github.com/apache/iceberg-rust/pull/2094)**: A native local filesystem backend +- **[MemoryStorage](https://github.com/apache/iceberg-rust/pull/2097)**: An in-memory backend useful for testing + +The existing OpenDAL-based storage was [moved to its own crate](https://github.com/apache/iceberg-rust/pull/2207) (`iceberg-storage-opendal`), keeping the core library lightweight while preserving full OpenDAL support as an optional dependency. See the [Storage trait RFC](https://github.com/apache/iceberg-rust/pull/1885) for the design rationale. + +### Enhanced DataFusion Integration + +DataFusion integration continues to expand with DDL and query optimization improvements: + +- **CREATE TABLE**: [Native support](https://github.com/apache/iceberg-rust/pull/1972) for creating Iceberg tables through DataFusion SQL, with [schema validation for partition projection](https://github.com/apache/iceberg-rust/pull/2008) +- **DROP TABLE**: [Support for dropping tables](https://github.com/apache/iceberg-rust/pull/2033) through DataFusion SQL +- **LIMIT pushdown**: [Pushes row limits](https://github.com/apache/iceberg-rust/pull/2006) into Iceberg scans to avoid reading unnecessary data +- **Predicate pushdown**: Added support for [Boolean](https://github.com/apache/iceberg-rust/pull/2082), [IsNaN](https://github.com/apache/iceberg-rust/pull/2142), [Timestamp](https://github.com/apache/iceberg-rust/pull/2069), [Binary](https://github.com/apache/iceberg-rust/pull/2048), and [LIKE/StartsWith](https://github.com/apache/iceberg-rust/pull/2014) predicates +- **INSERT INTO**: [Support for inserting data](https://github.com/apache/iceberg-rust/pull/2005) into Iceberg tables through DataFusion SQL, with automatic sort-based clustering for partitioned writes + +### Reader Performance Improvements + +The Arrow reader received several performance optimizations as part of an [ongoing effort](https://github.com/apache/iceberg-rust/issues/2172) to reduce I/O overhead during reads: + +- **Byte range coalescing**: [Implemented `get_byte_ranges`](https://github.com/apache/iceberg-rust/pull/2181) on `AsyncFileReader` to coalesce small reads into fewer I/O operations +- **Fast-path single-threaded reads**: [Optimized `ArrowReader::read`](https://github.com/apache/iceberg-rust/pull/2020) for single-concurrency scenarios by avoiding unnecessary async overhead +- **Metadata size hints**: [Added Parquet metadata size hint option](https://github.com/apache/iceberg-rust/pull/2173) to `ArrowReaderBuilder`, eliminating unnecessary `stat()` calls when the metadata size is known +- **File size propagation**: [Pass data file and delete file sizes](https://github.com/apache/iceberg-rust/pull/2175) to the reader to avoid redundant metadata lookups +- **Reduced builder overhead**: [Avoided redundant](https://github.com/apache/iceberg-rust/pull/2176) `create_parquet_record_batch_stream_builder()` calls +- **Timestamp support**: [Added timestamp type support](https://github.com/apache/iceberg-rust/pull/2180) in column creation + +### 38-Digit Decimal Precision + +The `rust_decimal` crate was [replaced with `fastnum`](https://github.com/apache/iceberg-rust/pull/2063), enabling full 38-digit decimal precision as required by the Iceberg specification. This also includes a [fix for decimal byte representation](https://github.com/apache/iceberg-rust/pull/1998) used in hashing. + +## Bug Fixes + +- **Table upgrades**: [Fixed v2 to v3 table upgrades](https://github.com/apache/iceberg-rust/pull/2010) that previously failed +- **Sort order validation**: [Reserved sort order IDs](https://github.com/apache/iceberg-rust/pull/1978) can no longer contain fields +- **Partition field IDs**: [Fixed reuse of partition field IDs](https://github.com/apache/iceberg-rust/pull/2011) in `AddSpec` +- **REST catalog security**: [Filtered sensitive headers](https://github.com/apache/iceberg-rust/pull/2117) from error logs to prevent credential leakage +- **SqlCatalog**: [Fixed `SqlCatalogBuilder`](https://github.com/apache/iceberg-rust/pull/2079) not persisting the supplied catalog name +- **S3Tables**: [Correctly interpret warehouse](https://github.com/apache/iceberg-rust/pull/2115) as `table_location` for S3Tables + +## Breaking Changes + +This release includes several breaking changes: + +- **Storage architecture**: The [Storage trait](https://github.com/apache/iceberg-rust/pull/2116) is now integrated with FileIO, and [OpenDAL storage moved](https://github.com/apache/iceberg-rust/pull/2207) to `iceberg-storage-opendal` +- **MSRV**: [Bumped to Rust 1.92.0](https://github.com/apache/iceberg-rust/pull/2224) +- **Decimal**: [Replaced `rust_decimal` with `fastnum`](https://github.com/apache/iceberg-rust/pull/2063) for 38-digit precision support + +## Dependency Updates + +- [Upgraded to DataFusion 52.2](https://github.com/apache/iceberg-rust/pull/1997) +- Various updates to Arrow, Parquet, and other dependencies + +## Infrastructure Improvements + +- **Security scanning**: [Added CodeQL workflow](https://github.com/apache/iceberg-rust/pull/2151) for GitHub Actions security analysis +- **Workflow permissions**: [Added explicit least-privilege permissions](https://github.com/apache/iceberg-rust/pull/2163) to workflows +- **Faster tests**: [Adopted nextest](https://github.com/apache/iceberg-rust/pull/2078) for faster test execution with [parallel integration tests](https://github.com/apache/iceberg-rust/pull/2076) +- **Python tooling**: [Switched to uv](https://github.com/apache/iceberg-rust/pull/2129) for Python dependency management + +## What's Next + +Work is already underway on several features for upcoming releases: + +- **Storage trait expansion**: With the new trait-based storage architecture landed in 0.9.0, the community is looking to [integrate additional storage backends](https://github.com/apache/iceberg-rust/issues/2208), such as `object_store`, making it easy for users to opt into alternative storage implementations. +- **Table encryption**: [AES-GCM encryption support](https://github.com/apache/iceberg-rust/pull/2026) is in progress, bringing Iceberg's table-level encryption spec to Rust with Java-compatible ciphertext formats. See the [table encryption RFC](https://github.com/apache/iceberg-rust/pull/2183) for the full design, including envelope encryption with a chained key hierarchy and KMS integration. + +## Getting Involved + +The `iceberg-rust` project welcomes contributions. We use GitHub [issues](https://github.com/apache/iceberg-rust/issues) for tracking work and the [Apache Iceberg Community Slack](https://iceberg.apache.org/community/#slack) for discussions. + +The easiest way to get started is to: + +1. Try `iceberg-rust` with your workloads and report any issues you encounter +2. Review the [contributor guide](https://github.com/apache/iceberg-rust/blob/main/CONTRIBUTING.md) +3. Look for [good first issues](https://github.com/apache/iceberg-rust/contribute) + +For more information, visit the [iceberg-rust repository](https://github.com/apache/iceberg-rust). diff --git a/site/docs/community.md b/site/docs/community.md index d8611fc6b231..48c61229d6a9 100644 --- a/site/docs/community.md +++ b/site/docs/community.md @@ -28,29 +28,43 @@ Community discussions happen across various mailing lists, on the apache-iceberg ### Mailing Lists -Iceberg has four mailing lists: +Apache Iceberg mailing lists: -* **Developers**: -- used for community discussions +* **Developers**: `dev@iceberg.apache.org` -- Iceberg community discussions + - [Archive](https://lists.apache.org/list.html?dev@iceberg.apache.org) - [Subscribe](mailto:dev-subscribe@iceberg.apache.org) - [Unsubscribe](mailto:dev-unsubscribe@iceberg.apache.org) - - [Archive](https://lists.apache.org/list.html?dev@iceberg.apache.org) -* **Commits**: -- distributes commit notifications +* **Commits**: `commits@iceberg.apache.org` -- GitHub commit notifications + - [Archive](https://lists.apache.org/list.html?commits@iceberg.apache.org) - [Subscribe](mailto:commits-subscribe@iceberg.apache.org) - [Unsubscribe](mailto:commits-unsubscribe@iceberg.apache.org) - - [Archive](https://lists.apache.org/list.html?commits@iceberg.apache.org) -* **Issues**: -- Github issue tracking +* **Issues**: `issues@iceberg.apache.org` -- GitHub issue tracking + - [Archive](https://lists.apache.org/list.html?issues@iceberg.apache.org) - [Subscribe](mailto:issues-subscribe@iceberg.apache.org) - [Unsubscribe](mailto:issues-unsubscribe@iceberg.apache.org) - - [Archive](https://lists.apache.org/list.html?issues@iceberg.apache.org) -* **Private**: -- private list for the PMC to discuss sensitive issues related to the health of the project - - [Archive](https://lists.apache.org/list.html?private@iceberg.apache.org) +* **CI Jobs**: `ci-jobs@iceberg.apache.org` -- GitHub Actions notifications + - [Archive](https://lists.apache.org/list.html?ci-jobs@iceberg.apache.org) + - [Subscribe](mailto:ci-jobs-subscribe@iceberg.apache.org) + - [Unsubscribe](mailto:ci-jobs-unsubscribe@iceberg.apache.org) +* **Private**: `private@iceberg.apache.org` -- private mailing list for the PMC to discuss sensitive issues related to the health of the project + - [Archive](https://lists.apache.org/list.html?private@iceberg.apache.org) (PMC-only access) ### Slack -We use the [Apache Iceberg workspace](https://apache-iceberg.slack.com/) on Slack. To be invited, follow [this invite link](https://join.slack.com/t/apache-iceberg/shared_invite/zt-3kclosz6r-3heAW3d~_PHefmN2A_~cAg). +We use the [Apache Iceberg workspace](https://apache-iceberg.slack.com/) on Slack. To be invited, follow [this invite link](https://join.slack.com/t/apache-iceberg/shared_invite/zt-3tkrk9gpf-1eFZ8ozS2In0~zM_BeZiRQ). Please note that this link may occasionally break when Slack does an upgrade. If you encounter problems using it, please let us know by sending an email to . +#### Requesting Slack Integrations + +To request a new app or integration for the Apache Iceberg Slack workspace, send an email to the [dev mailing list](#mailing-lists) with the following information: + +* **What you want to add** — The name and description of the app or integration +* **Why you want to add it** — The benefit it provides to the community +* **What permissions does it need** — The access levels and permissions required by the app + +This allows the community to do a quick consensus check before the app is installed. + ### Issues Apache Iceberg tracks issues in GitHub and prefers to receive contributions as pull requests. @@ -88,6 +102,15 @@ Hosts are required to ensure that: 4. Planned meetups ought to be brought to the attention of the [dev list](#mailing-lists) 5. All [Community Guidelines](#community-guidelines) must be respected +Meetups *must* be small events under the [ASF branding guidelines](https://www.apache.org/foundation/marks/events.html#dates) and are typically small, informal gatherings. If you're unsure whether an event is a meetup, meetups usually: + +* Rely on a curated selection of talks, where organizers work with the community to include a diverse representation of speakers, topics, and companies. Due to the small number of submissions, meetups might use, but don't require, a CfP. +* Are single-tracked and accommodate only a handful of sessions. +* Have at most 2-3 hours of content, plus networking time. +* Are sponsored by 1-3 companies providing food, drinks, or meeting space; not by selling booth space or marketing opportunities. They don't require substantial financial support. + +If you don't know whether an event qualifies as a meetup, please ask the PMC through the private mailing list! (Be sure to do this *before* using the Apache Iceberg brand or trademark.) + ## Community Guidelines ### Apache Iceberg Community Guidelines diff --git a/site/docs/contribute.md b/site/docs/contribute.md index 54aa076639ce..27dd31ff4bf5 100644 --- a/site/docs/contribute.md +++ b/site/docs/contribute.md @@ -57,6 +57,17 @@ There are several exceptions to a single committer being able to merge a PR: * Behavioral and functional changes to a specification must go through the [Iceberg improvement proposal](#apache-iceberg-improvement-proposals) before any code can be merged. * Changes to files under the `format` directory and `open-api/rest-catalog*` are considered specification changes. Unless already covered under an Iceberg improvement proposal, specification changes require their own vote (e.g. bug fixes or specification clarifications). The vote follows the ASF [code modification](https://www.apache.org/foundation/voting.html#votes-on-code-modification) model and no lazy consensus modifier. Grammar, spelling and minor formatting fixes are exempted from this rule. Draft specifications (new independent specifications that are going through the Iceberg improvement process) do not require a vote but authors should provide notice on the developer mailing list about substantive changes (the final draft will be subject to a vote). +### Guidelines for AI-assisted Contributions + +The Iceberg community welcomes contributions that are created or assisted by AI tools (e.g., large language models or code assistants). Contributors using such tools are expected to adhere to the following principles: + +* The PR author should **understand the core ideas** behind the implementation **end-to-end**, and be able to justify the design and code during review. +* PR titles, commit messages, and code must align with the style, tone, and level of detail used in the existing Iceberg codebase. +* Use AI for acceleration, then verify. Treat AI output as a draft for code, tests, or docs; run linters/tests and review the logic yourself. +* Be transparent about AI usage and uncertainties. Disclose if AI was used (e.g., draft, refactoring, test scaffolding) and call out any areas of uncertainty or assumptions, especially regarding complex logic, concurrency, or internal APIs, so reviewers can focus on them. +* **Respect ASF policy**. Ensure generated content does not introduce incompatible licenses or undisclosed third-party code; review the [ASF Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html) and licensing rules when in doubt. +* If you are filing a security report based on AI assisted scanning, please verify that the issue is reproducible and clearly document the steps to reproduce it before submitting. Security reports can be time-consuming for maintainers to examine and triage, so thorough, well-documented reports help ensure they are addressed efficiently. + ## Apache Iceberg Improvement Proposals ### What is an improvement proposal? @@ -95,12 +106,12 @@ settle disagreements or to force a decision. ## Building the Project Locally -Iceberg is built using Gradle with Java 11, 17, or 21. +Iceberg is built using Gradle with Java 17 or 21. * To invoke a build and run tests: `./gradlew build` * To skip tests: `./gradlew build -x test -x integrationTest` * To fix code style: `./gradlew spotlessApply` -* To build particular Spark/Flink Versions: `./gradlew build -DsparkVersions=3.4,3.5 -DflinkVersions=1.14` +* To build particular Spark/Flink Versions: `./gradlew build -DsparkVersions=3.5,4.0 -DflinkVersions=1.20,2.0` Iceberg table support is organized in library modules: @@ -179,7 +190,7 @@ being difficult to orchestrate, it is up to the committers to decide if deprecat All interfaces, classes, and methods targeted for deprecation must include the following: 1. `@Deprecated` annotation on the appropriate element -2. `@depreceted` javadoc comment including: the version for removal, the appropriate alternative for usage +2. `@deprecated` javadoc comment including: the version for removal, the appropriate alternative for usage 3. Replacement of existing code paths that use the deprecated behavior Example: @@ -268,7 +279,7 @@ Execution failed for task ':iceberg-api:revapi'. ### Adding a default implementation -To avoid breaking the API, add a default implementation that throws an `UnsupportedOperationException`:` +To avoid breaking the API, add a default implementation that throws an `UnsupportedOperationException`: ```java public interface ManageSnapshots extends PendingUpdate { @@ -418,7 +429,7 @@ Use `this` when assigning values to instance variables, making it clear when the #### Config naming 1. Use `-` to link words in one concept - * For example, preferred convection `access-key-id` rather than `access.key.id` + * For example, preferred convention `access-key-id` rather than `access.key.id` 2. Use `.` to create a hierarchy of config groups * For example, `s3` in `s3.access-key-id`, `s3.secret-access-key` diff --git a/site/docs/flink-quickstart.md b/site/docs/flink-quickstart.md new file mode 100644 index 000000000000..657d13d80681 --- /dev/null +++ b/site/docs/flink-quickstart.md @@ -0,0 +1,174 @@ +--- +title: "Flink and Iceberg Quickstart" +--- + + +This guide will get you up and running with Apache Iceberg™ using Apache Flink™, including sample code to +highlight some powerful features. You can learn more about Iceberg's Flink runtime by checking out the [Flink](docs/latest/flink.md) section. + +## Quickstart environment + +The fastest way to get started is to use Docker Compose with the [Iceberg Flink Quickstart](https://github.com/apache/iceberg/tree/main/docker/iceberg-flink-quickstart) image. + +To use this, you'll need to install the [Docker CLI](https://docs.docker.com/get-docker/). + +The quickstart includes: + +* A local Flink cluster (Job Manager and Task Manager) +* Iceberg REST Catalog +* MinIO (local S3 storage) + +![An overview of the Flink quickstart containers](/assets/images/flink-quickstart.excalidraw.png) + +Clone the Iceberg repository and start up the Docker containers: + +```sh +git clone https://github.com/apache/iceberg.git +cd iceberg +docker compose -f docker/iceberg-flink-quickstart/docker-compose.yml up -d --build +``` + +Launch a Flink SQL client session: + +```sh +docker exec -it jobmanager ./bin/sql-client.sh +``` + +## Creating an Iceberg Catalog in Flink + +Iceberg has several catalog back-ends that can be used to track tables, like JDBC, Hive MetaStore and Glue. +In this guide we use a REST catalog, backed by S3. +To learn more, check out the [Catalog](docs/latest/flink-configuration.md#catalog-configuration) page in the Flink section. + +First up, we need to define a Flink catalog. +Tables within this catalog will be stored on S3 blob store: + +```sql +CREATE CATALOG iceberg_catalog WITH ( + 'type' = 'iceberg', + 'catalog-impl' = 'org.apache.iceberg.rest.RESTCatalog', + 'uri' = 'http://iceberg-rest:8181', + 'warehouse' = 's3://warehouse/', + 'io-impl' = 'org.apache.iceberg.aws.s3.S3FileIO', + 's3.endpoint' = 'http://minio:9000', + 's3.access-key-id' = 'admin', + 's3.secret-access-key' = 'password', + 's3.path-style-access' = 'true' +); +``` + +Create a database in the catalog: + +```sql +CREATE DATABASE IF NOT EXISTS iceberg_catalog.nyc; +``` + +## Creating a Table + +To create your first Iceberg table in Flink, run a [`CREATE TABLE`](docs/latest/flink-ddl.md#create-table) command. +Let's create a table using `iceberg_catalog.nyc.taxis` where `iceberg_catalog` is the catalog name, `nyc` is the database name, and `taxis` is the table name. + +```sql +CREATE TABLE iceberg_catalog.nyc.taxis +( + vendor_id BIGINT, + trip_id BIGINT, + trip_distance FLOAT, + fare_amount DOUBLE, + store_and_fwd_flag STRING +); +``` + +Iceberg catalogs support the full range of Flink SQL DDL commands, including: + +* [`CREATE TABLE ... PARTITIONED BY`](docs/latest/flink-ddl.md#partitioned-by) +* [`ALTER TABLE`](docs/latest/flink-ddl.md#alter-table) +* [`DROP TABLE`](docs/latest/flink-ddl.md#drop-table) + +## Writing Data to a Table + +Once your table is created, you can insert records. + +Flink uses checkpoints to ensure data durability and exactly-once semantics. +Without checkpointing, Iceberg data and metadata may not be fully committed to storage. + +```sql +SET 'execution.checkpointing.interval' = '10s'; +``` + +Then you can write some data: + +```sql +INSERT INTO iceberg_catalog.nyc.taxis +VALUES (1, 1000371, 1.8, 15.32, 'N'), (2, 1000372, 2.5, 22.15, 'N'), (2, 1000373, 0.9, 9.01, 'N'), (1, 1000374, 8.4, 42.13, 'Y'); +``` + +## Reading Data from a Table + +To read a table, use the Iceberg table's name: + +```sql +SELECT * FROM iceberg_catalog.nyc.taxis; +``` + +## Creating a Table with Inline Configuration + +Creating a Flink catalog as shown above, backed by an Iceberg REST Catalog, is one way to use Iceberg in Flink. +Another way is to use the [Flink connector](docs/latest/flink-connector.md) and specify the catalog connection details directly in the table definition. This still connects to the same external Iceberg REST Catalog - the difference is just that you don't need a separate `CREATE CATALOG` statement. + +Create a table using inline configuration: + +!!! note + The Flink table definition here is registered in Flink's default in-memory catalog (`default_catalog`), but the connector properties tell Flink to store the Iceberg table and its data in the same REST Catalog and S3 storage as before. + +```sql +CREATE TABLE taxis_inline_config ( + vendor_id BIGINT, + trip_id BIGINT, + trip_distance FLOAT, + fare_amount DOUBLE, + store_and_fwd_flag STRING +) WITH ( + 'connector' = 'iceberg', + 'catalog-name' = 'foo', -- Required by Flink connector but value doesn't matter for inline config + 'catalog-type' = 'rest', + 'uri' = 'http://iceberg-rest:8181', + 'warehouse' = 's3://warehouse/', + 'io-impl' = 'org.apache.iceberg.aws.s3.S3FileIO', + 's3.endpoint' = 'http://minio:9000', + 's3.access-key-id' = 'admin', + 's3.secret-access-key' = 'password', + 's3.path-style-access' = 'true' +); +``` + +## Shutting down the quickstart environment + +Once you've finished with the quickstart, shut down the Docker containers by running the following: + +```sh +docker compose -f docker/iceberg-flink-quickstart/docker-compose.yml down +``` + +## Learn More + +!!! note + If you want to include Iceberg in your Flink installation, add the Iceberg Flink runtime to Flink's `jars` folder. + You can download the runtime from the [Releases](releases.md) page. + +Now that you're up and running with Iceberg and Flink, check out the [Iceberg Flink docs](docs/latest/flink.md) to learn more! diff --git a/site/docs/hive-quickstart.md b/site/docs/hive-quickstart.md index 988664d9007a..fbf9dec53689 100644 --- a/site/docs/hive-quickstart.md +++ b/site/docs/hive-quickstart.md @@ -36,13 +36,12 @@ Take a look at the Tags tab in [Apache Hive docker images](https://hub.docker.co Set the version variable. ```sh -export HIVE_VERSION=4.0.0 +export HIVE_VERSION=4.2.0 ``` -To accommodate both Intel-based (x86_64) and Apple Silicon (M1, M2, M3) Macs when running your Docker container, you can use the --platform flag to specify the desired architecture. Apple Silicon Macs use the arm64 architecture, while Intel Macs use the amd64 architecture. -Start the container, using the option `--platform linux/arm64` for a Mac with an M-Series chip: +Start the HiveServer2 container: ```sh -docker run -d --platform linux/arm64 -p 10000:10000 -p 10002:10002 --env SERVICE_NAME=hiveserver2 --name hive4 apache/hive:${HIVE_VERSION} +docker run -d -p 10000:10000 -p 10002:10002 --env SERVICE_NAME=hiveserver2 --name hive4 apache/hive:${HIVE_VERSION} ``` The docker run command above configures Hive to use the embedded derby database for Hive Metastore. Hive Metastore functions as the Iceberg catalog to locate Iceberg files, which can be anywhere. @@ -106,8 +105,8 @@ SELECT * FROM nyc.taxis; #### Adding Iceberg to Hive -If you already have a Hive 4.0.0 or later environment, it comes with the Iceberg 1.4.3 included. No additional downloads or jars are needed. If you have a Hive 2.3.x or Hive 3.1.x environment see [Enabling Iceberg support in Hive](docs/latest/hive.md#hive-23x-hive-31x). +If you already have a Hive 4.0.0 or later environment, it comes with the Iceberg included. No additional downloads or jars are needed. If you have a Hive 2.3.x or Hive 3.1.x environment see [Enabling Iceberg support in Hive](docs/latest/hive.md#hive-23x-hive-31x). #### Learn More -To learn more about setting up a database other than Derby, see [Apache Hive Quick Start](https://hive.apache.org/developement/quickstart/). You can also [set up a standalone metastore, HS2 and Postgres](https://github.com/apache/hive/blob/master/packaging/src/docker/docker-compose.yml). Now that you're up and running with Iceberg and Hive, check out the [Iceberg-Hive docs](docs/latest/hive.md) to learn more! +To learn more about setting up a database other than Derby, see [Apache Hive Quick Start](https://hive.apache.org/developement/quickstart/). You can also [set up a standalone metastore, HS2 and Postgres](https://github.com/apache/hive/blob/master/packaging/src/docker/docker-compose.yml) or [use Hive Metastore as Iceberg REST Catalog](https://hive.apache.org/docs/latest/admin/iceberg-rest-catalog/). Now that you're up and running with Iceberg and Hive, check out the [Iceberg-Hive docs](docs/latest/hive.md) to learn more! diff --git a/site/docs/multi-engine-support.md b/site/docs/multi-engine-support.md index 6ff49d3ea7d3..d63964614197 100644 --- a/site/docs/multi-engine-support.md +++ b/site/docs/multi-engine-support.md @@ -72,7 +72,7 @@ Each engine version undergoes the following lifecycle stages: | 3.1 | End of Life | 0.12.0 | 1.3.1 | [iceberg-spark-runtime-3.1_2.12](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.1_2.12/1.3.1/iceberg-spark-runtime-3.1_2.12-1.3.1.jar) [1] | | 3.2 | End of Life | 0.13.0 | 1.4.3 | [iceberg-spark-runtime-3.2_2.12](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.2_2.12/1.4.3/iceberg-spark-runtime-3.2_2.12-1.4.3.jar), [iceberg-spark-runtime-3.2_2.13](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.2_2.13/1.4.3/iceberg-spark-runtime-3.2_2.13-1.4.3.jar) | | 3.3 | End of Life | 0.14.0 | 1.8.1 | [iceberg-spark-runtime-3.3_2.12](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.3_2.12/1.8.1/iceberg-spark-runtime-3.3_2.12-1.8.1.jar), [iceberg-spark-runtime-3.3_2.13](https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-3.3_2.13/1.8.1/iceberg-spark-runtime-3.3_2.13-1.8.1.jar) | -| 3.4 | Deprecated | 1.3.0 | 1.10.0 | [iceberg-spark-runtime-3.4_2.12](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.4_2.12/1.10.0/iceberg-spark-runtime-3.4_2.12-1.10.0.jar), [iceberg-spark-runtime-3.4_2.13](https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-3.4_2.13/1.10.0/iceberg-spark-runtime-3.4_2.13-1.10.0.jar) | +| 3.4 | Deprecated | 1.3.0 | 1.10.1 | [iceberg-spark-runtime-3.4_2.12](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.4_2.12/1.10.1/iceberg-spark-runtime-3.4_2.12-1.10.1.jar), [iceberg-spark-runtime-3.4_2.13](https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-3.4_2.13/1.10.1/iceberg-spark-runtime-3.4_2.13-1.10.1.jar) | | 3.5 | Maintained | 1.4.0 | {{ icebergVersion }} | [iceberg-spark-runtime-3.5_2.12](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.5_2.12/{{ icebergVersion }}/iceberg-spark-runtime-3.5_2.12-{{ icebergVersion }}.jar), [iceberg-spark-runtime-3.5_2.13](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-3.5_2.13/{{ icebergVersion }}/iceberg-spark-runtime-3.5_2.13-{{ icebergVersion }}.jar) | | 4.0 | Maintained | 1.10.0 | {{ icebergVersion }} | [iceberg-spark-runtime-4.0_2.13](https://search.maven.org/remotecontent?filepath=org/apache/iceberg/iceberg-spark-runtime-4.0_2.13/{{ icebergVersion }}/iceberg-spark-runtime-4.0_2.13-{{ icebergVersion }}.jar) | diff --git a/site/docs/releases.md b/site/docs/releases.md index 04d55e4287d6..bc8957942fc2 100644 --- a/site/docs/releases.md +++ b/site/docs/releases.md @@ -45,7 +45,7 @@ To add a dependency on Iceberg in Gradle, add the following to `build.gradle`: ``` dependencies { - compile 'org.apache.iceberg:iceberg-core:{{ icebergVersion }}' + implementation 'org.apache.iceberg:iceberg-core:{{ icebergVersion }}' } ``` @@ -67,6 +67,55 @@ To add a dependency on Iceberg in Maven, add the following to your `pom.xml`: ``` +### 1.10.1 release + +Apache Iceberg 1.10.1 was released on Dec 22, 2025. + +The 1.10.1 release contains bug fixes. For full release notes visit [Github](https://github.com/apache/iceberg/releases/tag/apache-iceberg-1.10.1) + +* API + - API: required nested fields within optional structs can produce null ([\#13804](https://github.com/apache/iceberg/pull/13804)) + - API: Detect whether required fields nested within optionals can produce nulls ([\#14270](https://github.com/apache/iceberg/pull/14270)) +* Core + - Prevent dropping a namespace when it contains views ([\#14456](https://github.com/apache/iceberg/pull/14456)) + - Fix overflow due to default value on timestamp nanos ([\#14359](https://github.com/apache/iceberg/pull/14359)) + - Fix the unpartitioned check in replace partitions ([\#14186](https://github.com/apache/iceberg/pull/14186)) + - Fix for respecting custom location providers in SerializableTable and actions ([\#14280](https://github.com/apache/iceberg/pull/14280)) + - Fix `NAN_VALUE_COUNTS` serialization for ContentFile ([\#14721](https://github.com/apache/iceberg/pull/14721)) + - Handle unknown type during deletes ([\#14356](https://github.com/apache/iceberg/pull/14356)) + - REST: Fix validation of PlanTableScanRequest ([\#14561](https://github.com/apache/iceberg/pull/14561)) + - REST: Fix validation of PlanTableScanResponse ([\#14562](https://github.com/apache/iceberg/pull/14562)) + - REST: Fix RESTFileScanTaskParser to handle empty delete file references list ([\#14568](https://github.com/apache/iceberg/pull/14568)) + - REST: Fix serde of tasks with multiple deletes ([\#14573](https://github.com/apache/iceberg/pull/14573)) + - REST: Align PlanTableScanRequest filter with the OpenAPI spec ([\#14658](https://github.com/apache/iceberg/pull/14658)) + - REST: Make plan status consistent with the REST spec ([\#14643](https://github.com/apache/iceberg/pull/14643)) +* Parquet + - Fix NameMapping loss in `ParquetUtil.footerMetrics` ([\#14617](https://github.com/apache/iceberg/pull/14617)) + - Add variant type support to `ParquetTypeVisitor` ([\#14588](https://github.com/apache/iceberg/pull/14588)) + - Fix variant type filtering in `ParquetMetricsRowGroupFilter` ([\#14081](https://github.com/apache/iceberg/pull/14081)) + - handle NPE for `VariantLogicalType` in `TypeWithSchemaVisitor` ([\#14261](https://github.com/apache/iceberg/pull/14261)) + - Fix `UUID` `ClassCastException` when reading Parquet files with UUIDs ([\#14027](https://github.com/apache/iceberg/pull/14027)) +* Flink + - Ensure `DynamicCommitter` idempotence in the presence of failures ([\#14182](https://github.com/apache/iceberg/pull/14182)) + - Backport Ensure `DynamicCommitter` idempotence in the presence of failures ([\#14213](https://github.com/apache/iceberg/pull/14213)) + - Prevent recreation of `ManifestOutputFileFactory` during flushing ([\#14358](https://github.com/apache/iceberg/pull/14358)) + - Backport Prevent recreation of `ManifestOutputFileFactory` during flushing ([\#14385](https://github.com/apache/iceberg/pull/14385)) + - Fix cache refreshing in `DynamicIcebergSink` ([\#14406](https://github.com/apache/iceberg/pull/14406)) + - Port fix cache refreshing in `DynamicIcebergSink` ([\#14765](https://github.com/apache/iceberg/pull/14765)) +* Spark + - 3.4: Pass format-version when creating a snapshot ([\#14170](https://github.com/apache/iceberg/pull/14170)) + - 3.5: Pass format-version when creating a snapshot ([\#14169](https://github.com/apache/iceberg/pull/14169)) + - 4.0: Pass format-version when creating a snapshot in table migration actions ([\#14163](https://github.com/apache/iceberg/pull/14163)) + - Fix Z-order UDF to correctly handle `DateType` ([\#14108](https://github.com/apache/iceberg/pull/14108)) +* Kafka Connect + - Merge control topic and last persisted offsets ([\#14525](https://github.com/apache/iceberg/pull/14525)) +* Vendor integrations + - AWS: Configure builder for reuse of HTTP connection pool in SDK v2 ([\#14161](https://github.com/apache/iceberg/pull/14161)) + - AWS: Fix leaked credentials when contacting multiple catalogs ([\#14178](https://github.com/apache/iceberg/pull/14178)) + - AWS: Exclude logging dependencies from the bundle ([\#14225](https://github.com/apache/iceberg/pull/14225)) + +## Past releases + ### 1.10.0 release Apache Iceberg 1.10.0 was released on September 11, 2025. @@ -196,8 +245,6 @@ The 1.10.0 release contains bug fixes and new features. For full release notes v - Comet: 0.5.0 -> 0.8.1 - Apache httpclient: 5.4.3 -> 5.4.4 -## Past releases - ### 1.9.2 release Apache Iceberg 1.9.2 was released on Jul 16, 2025. diff --git a/site/docs/spark-quickstart.md b/site/docs/spark-quickstart.md index 299b69367823..38149ad35038 100644 --- a/site/docs/spark-quickstart.md +++ b/site/docs/spark-quickstart.md @@ -146,6 +146,28 @@ You can then run any of the following commands to start a Spark session. To create your first Iceberg table in Spark, run a [`CREATE TABLE`](docs/latest/spark-ddl.md#create-table) command. Let's create a table using `demo.nyc.taxis` where `demo` is the catalog name, `nyc` is the database name, and `taxis` is the table name. +First, create the database if it doesn't already exist: + +=== "SparkSQL" + + ```sql + CREATE DATABASE IF NOT EXISTS demo.nyc; + ``` + +=== "Spark-Shell" + + ```scala + spark.sql("CREATE DATABASE IF NOT EXISTS demo.nyc") + ``` + +=== "PySpark" + + ```py + spark.sql("CREATE DATABASE IF NOT EXISTS demo.nyc") + ``` + +Then create the table: + === "SparkSQL" ```sql @@ -326,8 +348,8 @@ If you already have a Spark environment, you can add Iceberg, using the `--packa !!! note If you want to include Iceberg in your Spark installation, add the Iceberg Spark runtime to Spark's `jars` folder. - You can download the runtime by visiting to the [Releases](releases.md) page. + You can download the runtime from the [Releases](releases.md) page. #### Learn More -Now that you're up an running with Iceberg and Spark, check out the [Iceberg-Spark docs](docs/latest/spark-ddl.md) to learn more! +Now that you're up and running with Iceberg and Spark, check out the [Iceberg-Spark docs](docs/latest/spark-ddl.md) to learn more! diff --git a/site/docs/status.md b/site/docs/status.md index beb129d9a5f2..51d2f7271561 100644 --- a/site/docs/status.md +++ b/site/docs/status.md @@ -31,141 +31,145 @@ This section lists the libraries that implement the Apache Iceberg specification - [PyIceberg](https://pypi.org/project/pyiceberg/) - [Rust](https://crates.io/crates/iceberg) - [Go](https://pkg.go.dev/github.com/apache/iceberg-go) +- [C++](https://github.com/apache/iceberg-cpp/releases) ## Data Types -| Data Type | Java | PyIceberg | Rust | Go | -|----------------|------|-----------|------|----| -| boolean | Y | Y | Y | Y | -| int | Y | Y | Y | Y | -| long | Y | Y | Y | Y | -| float | Y | Y | Y | Y | -| double | Y | Y | Y | Y | -| decimal | Y | Y | Y | Y | -| date | Y | Y | Y | Y | -| time | Y | Y | Y | Y | -| timestamp | Y | Y | Y | Y | -| timestamptz | Y | Y | Y | Y | -| timestamp_ns | Y | Y | Y | Y | -| timestamptz_ns | Y | Y | Y | Y | -| string | Y | Y | Y | Y | -| uuid | Y | Y | Y | Y | -| fixed | Y | Y | Y | Y | -| binary | Y | Y | Y | Y | -| variant | Y | Y | Y | Y | -| list | Y | Y | Y | Y | -| map | Y | Y | Y | Y | -| struct | Y | Y | Y | Y | +| Data Type | Java | PyIceberg | Rust | Go | C++ | +|----------------|------|-----------|------|----|-----| +| boolean | Y | Y | Y | Y | Y | +| int | Y | Y | Y | Y | Y | +| long | Y | Y | Y | Y | Y | +| float | Y | Y | Y | Y | Y | +| double | Y | Y | Y | Y | Y | +| decimal | Y | Y | Y | Y | Y | +| date | Y | Y | Y | Y | Y | +| time | Y | Y | Y | Y | Y | +| timestamp | Y | Y | Y | Y | Y | +| timestamptz | Y | Y | Y | Y | Y | +| timestamp_ns | Y | Y | Y | Y | N | +| timestamptz_ns | Y | Y | Y | Y | N | +| unknown | Y | Y | N | Y | N | +| string | Y | Y | Y | Y | Y | +| uuid | Y | Y | Y | Y | N | +| fixed | Y | Y | Y | Y | Y | +| binary | Y | Y | Y | Y | Y | +| variant | Y | Y | Y | Y | N | +| geometry | Y | N | N | N | N | +| geography | Y | N | N | N | N | +| list | Y | Y | Y | Y | Y | +| map | Y | Y | Y | Y | Y | +| struct | Y | Y | Y | Y | Y | ## Data File Formats -| Format | Java | PyIceberg | Rust | Go | -|---------|------|-----------|------|----| -| Parquet | Y | Y | Y | Y | -| ORC | Y | N | N | N | -| Puffin | Y | N | N | N | -| Avro | Y | N | N | N | +| Format | Java | PyIceberg | Rust | Go | C++ | +|---------|------|-----------|------|----|-----| +| Parquet | Y | Y | Y | Y | Y | +| ORC | Y | N | N | N | N | +| Puffin | Y | N | N | N | N | +| Avro | Y | N | N | N | Y | ## File IO -| Storage | Java | PyIceberg | Rust | Go | -|-------------------|------|-----------|------|----| -| Local Filesystem | Y | Y | Y | Y | -| Hadoop Filesystem | Y | Y | Y | Y | -| S3 Compatible | Y | Y | Y | Y | -| GCS Compatible | Y | Y | Y | Y | -| ADLS Compatible | Y | Y | Y | Y | +| Storage | Java | PyIceberg | Rust | Go | C++ | +|-------------------|------|-----------|------|----|------| +| Local Filesystem | Y | Y | Y | Y | Y | +| Hadoop Filesystem | Y | Y | Y | Y | N | +| S3 Compatible | Y | Y | Y | Y | N | +| GCS Compatible | Y | Y | Y | Y | N | +| ADLS Compatible | Y | Y | Y | Y | N | ## Table Maintenance Operations ### Table Spec V1 -| Operation | Java | PyIceberg | Rust | Go | -|-----------------------------|------|-----------|------|----| -| Update schema | Y | Y | Y | N | -| Update partition spec | Y | Y | Y | N | -| Update table properties | Y | Y | Y | Y | -| Replace sort order | Y | N | N | N | -| Update table location | Y | Y | N | N | -| Update statistics | Y | Y | N | N | -| Update partition statistics | Y | N | N | N | -| Expire snapshots | Y | N | N | N | -| Manage snapshots | Y | N | N | N | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------------------|------|-----------|------|----|-----| +| Update schema | Y | Y | Y | Y | Y | +| Update partition spec | Y | Y | Y | Y | Y | +| Update table properties | Y | Y | Y | Y | Y | +| Replace sort order | Y | N | Y | Y | Y | +| Update table location | Y | Y | Y | Y | Y | +| Update statistics | Y | Y | Y | Y | Y | +| Update partition statistics | Y | N | N | N | N | +| Expire snapshots | Y | N | N | Y | N | +| Manage snapshots | Y | N | N | Y | N | ### Table Spec V2 -| Operation | Java | PyIceberg | Rust | Go | -|-----------------------------|------|-----------|------|----| -| Update schema | Y | Y | N | N | -| Update partition spec | Y | Y | N | N | -| Update table properties | Y | Y | Y | Y | -| Replace sort order | Y | N | N | N | -| Update table location | Y | Y | N | N | -| Update statistics | Y | Y | N | N | -| Update partition statistics | Y | N | N | N | -| Expire snapshots | Y | N | N | N | -| Manage snapshots | Y | N | N | N | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------------------|------|-----------|------|----|-----| +| Update schema | Y | Y | N | Y | Y | +| Update partition spec | Y | Y | N | Y | Y | +| Update table properties | Y | Y | Y | Y | Y | +| Replace sort order | Y | N | Y | Y | Y | +| Update table location | Y | Y | Y | Y | Y | +| Update statistics | Y | Y | Y | Y | Y | +| Update partition statistics | Y | N | N | N | N | +| Expire snapshots | Y | N | N | Y | N | +| Manage snapshots | Y | N | N | Y | N | ## Table Update Operations ### Table Spec V1 -| Operation | Java | PyIceberg | Rust | Go | -|-------------------|------|-----------|------|----| -| Append data files | Y | Y | N | Y | -| Rewrite files | Y | Y | N | N | -| Rewrite manifests | Y | Y | N | Y | -| Overwrite files | Y | Y | N | N | -| Delete files | Y | Y | N | N | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|-------------------|------|-----------|------|----|-----| +| Append data files | Y | Y | Y | Y | Y | +| Rewrite files | Y | Y | N | Y | N | +| Rewrite manifests | Y | Y | N | Y | N | +| Overwrite files | Y | Y | N | Y | N | +| Delete files | Y | Y | N | Y | N | ### Table Spec V2 -| Operation | Java | PyIceberg | Rust | Go | -|-------------------|------|-----------|------|----| -| Append data files | Y | Y | N | Y | -| Rewrite files | Y | Y | N | N | -| Rewrite manifests | Y | Y | N | Y | -| Overwrite files | Y | Y | N | N | -| Row delta | Y | N | N | N | -| Delete files | Y | Y | N | N | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|-------------------|------|-----------|------|----|-----| +| Append data files | Y | Y | Y | Y | Y | +| Rewrite files | Y | Y | N | Y | N | +| Rewrite manifests | Y | Y | N | Y | N | +| Overwrite files | Y | Y | N | Y | N | +| Row delta | Y | N | N | Y | N | +| Delete files | Y | Y | N | Y | N | ## Table Read Operations ### Table Spec V1 -| Operation | Java | PyIceberg | Rust | Go | -|-----------------------------|------|-----------|------|----| -| Plan with data file | Y | Y | Y | Y | -| Plan with puffin statistics | Y | Y | Y | Y | -| Read data file | Y | N | Y | Y | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------------------|------|-----------|------|----|-----| +| Plan with data file | Y | Y | Y | Y | Y | +| Plan with puffin statistics | Y | Y | Y | Y | N | +| Read data file | Y | N | Y | Y | Y | ### Table Spec V2 -| Operation | Java | PyIceberg | Rust | Go | -|-----------------------------|------|-----------|------|----| -| Plan with data file | Y | Y | Y | Y | -| Plan with position deletes | Y | Y | N | Y | -| Plan with equality deletes | Y | Y | N | N | -| Plan with puffin statistics | Y | N | N | N | -| Read data file | Y | Y | Y | Y | -| Read with position deletes | Y | Y | N | Y | -| Read with equality deletes | Y | N | N | N | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------------------|------|-----------|------|----|-----| +| Plan with data file | Y | Y | Y | Y | Y | +| Plan with position deletes | Y | Y | Y | Y | Y | +| Plan with equality deletes | Y | Y | Y | Y | Y | +| Plan with puffin statistics | Y | N | N | N | N | +| Read data file | Y | Y | Y | Y | Y | +| Read with position deletes | Y | Y | Y | Y | N | +| Read with equality deletes | Y | N | Y | Y | N | ## Table Write Operations ### Table Spec V1 -| Operation | Java | PyIceberg | Rust | Go | -|-------------|------|-----------|------|----| -| Append data | Y | Y | Y | Y | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|-------------|------|-----------|------|----|-----| +| Append data | Y | Y | Y | Y | N | ### Table Spec V2 -| Operation | Java | PyIceberg | Rust | Go | -|------------------------|------|-----------|------|----| -| Append data | Y | Y | Y | Y | -| Write position deletes | Y | N | N | N | -| Write equality deletes | Y | N | N | N | +| Operation | Java | PyIceberg | Rust | Go | C++ | +|------------------------|------|-----------|------|----|-----| +| Append data | Y | Y | Y | Y | N | +| Write position deletes | Y | N | N | Y | N | +| Write equality deletes | Y | N | Y | Y | N | ## Catalogs @@ -173,198 +177,198 @@ This section lists the libraries that implement the Apache Iceberg specification #### Table Spec V1 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | Y | +| createTable | Y | Y | Y | Y | Y | +| dropTable | Y | Y | Y | Y | Y | +| loadTable | Y | Y | Y | Y | Y | +| updateTable | Y | Y | Y | Y | Y | +| renameTable | Y | Y | Y | Y | Y | +| tableExists | Y | Y | Y | Y | Y | #### Table Spec V2 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | Y | +| createTable | Y | Y | Y | Y | Y | +| dropTable | Y | Y | Y | Y | Y | +| loadTable | Y | Y | Y | Y | Y | +| updateTable | Y | Y | Y | Y | Y | +| renameTable | Y | Y | Y | Y | Y | +| tableExists | Y | Y | Y | Y | Y | #### View Spec V1 -| View Operation | Java | PyIceberg | Rust | Go | -|----------------|------|-----------|------|----| -| createView | Y | N | N | N | -| dropView | Y | Y | N | N | -| listView | Y | Y | N | N | -| viewExists | Y | Y | N | N | -| replaceView | Y | N | N | N | -| renameView | Y | N | N | N | +| View Operation | Java | PyIceberg | Rust | Go | C++ | +|----------------|------|-----------|------|----|-----| +| createView | Y | N | N | Y | N | +| dropView | Y | Y | N | Y | N | +| listView | Y | Y | N | Y | N | +| viewExists | Y | Y | N | Y | N | +| replaceView | Y | N | N | N | N | +| renameView | Y | N | N | N | N | #### Namespace Operations -| Namespace Operation | Java | PyIceberg | Rust | Go | -|---------------------------|------|-----------|------|----| -| listNamespaces | Y | Y | Y | Y | -| createNamespace | Y | Y | Y | Y | -| dropNamespace | Y | Y | Y | Y | -| namespaceExists | Y | Y | Y | Y | -| updateNamespaceProperties | Y | Y | Y | Y | -| loadNamespaceMetadata | Y | Y | Y | Y | +| Namespace Operation | Java | PyIceberg | Rust | Go | C++ | +|---------------------------|------|-----------|------|----|-----| +| listNamespaces | Y | Y | Y | Y | Y | +| createNamespace | Y | Y | Y | Y | Y | +| dropNamespace | Y | Y | Y | Y | Y | +| namespaceExists | Y | Y | Y | Y | Y | +| updateNamespaceProperties | Y | Y | Y | Y | Y | +| loadNamespaceMetadata | Y | Y | Y | Y | Y | ### Sql Catalog The sql catalog is a catalog backed by a sql database, which is called jdbc catalog in java. -| Database | Java | PyIceberg | Rust | Go | -|----------|------|-----------|------|----| -| Postgres | Y | Y | Y | Y | -| MySQL | Y | Y | Y | Y | -| SQLite | Y | Y | Y | Y | +| Database | Java | PyIceberg | Rust | Go | C++ | +|----------|------|-----------|------|----|-----| +| Postgres | Y | Y | Y | Y | N | +| MySQL | Y | Y | Y | Y | N | +| SQLite | Y | Y | Y | Y | N | #### Table Spec V1 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | N | +| createTable | Y | Y | Y | Y | N | +| dropTable | Y | Y | Y | Y | N | +| loadTable | Y | Y | Y | Y | N | +| updateTable | Y | Y | Y | Y | N | +| renameTable | Y | Y | Y | Y | N | +| tableExists | Y | Y | Y | Y | N | #### Table Spec V2 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | N | +| createTable | Y | Y | Y | Y | N | +| dropTable | Y | Y | Y | Y | N | +| loadTable | Y | Y | Y | Y | N | +| updateTable | Y | Y | Y | Y | N | +| renameTable | Y | Y | Y | Y | N | +| tableExists | Y | Y | Y | Y | N | #### View Spec V1 -| View Operation | Java | PyIceberg | Rust | Go | -|----------------|------|-----------|------|----| -| createView | Y | N | N | N | -| dropView | Y | N | N | N | -| listView | Y | N | N | N | -| viewExists | Y | N | N | N | -| replaceView | Y | N | N | N | -| renameView | Y | N | N | N | +| View Operation | Java | PyIceberg | Rust | Go | C++ | +|----------------|------|-----------|------|----|-----| +| createView | Y | N | N | Y | N | +| dropView | Y | N | N | Y | N | +| listView | Y | N | N | Y | N | +| viewExists | Y | N | N | Y | N | +| replaceView | Y | N | N | N | N | +| renameView | Y | N | N | N | N | #### Namespace Operations -| Namespace Operation | Java | PyIceberg | Rust | Go | -|---------------------------|------|-----------|------|----| -| listNamespaces | Y | Y | N | Y | -| createNamespace | Y | Y | N | Y | -| dropNamespace | Y | Y | Y | Y | -| namespaceExists | Y | N | N | Y | -| updateNamespaceProperties | Y | Y | Y | Y | -| loadNamespaceMetadata | Y | Y | N | Y | +| Namespace Operation | Java | PyIceberg | Rust | Go | C++ | +|---------------------------|------|-----------|------|----|-----| +| listNamespaces | Y | Y | Y | Y | N | +| createNamespace | Y | Y | Y | Y | N | +| dropNamespace | Y | Y | Y | Y | N | +| namespaceExists | Y | N | Y | Y | N | +| updateNamespaceProperties | Y | Y | Y | Y | N | +| loadNamespaceMetadata | Y | Y | Y | Y | N | ### Glue Catalog #### Table Spec V1 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | N | +| createTable | Y | Y | Y | Y | N | +| dropTable | Y | Y | Y | Y | N | +| loadTable | Y | Y | Y | Y | N | +| updateTable | Y | Y | Y | Y | N | +| renameTable | Y | Y | Y | Y | N | +| tableExists | Y | Y | Y | Y | N | #### Table Spec V2 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | N | +| createTable | Y | Y | Y | Y | N | +| dropTable | Y | Y | Y | Y | N | +| loadTable | Y | Y | Y | Y | N | +| updateTable | Y | Y | Y | Y | N | +| renameTable | Y | Y | Y | Y | N | +| tableExists | Y | Y | Y | Y | N | #### View Spec V1 -| View Operation | Java | PyIceberg | Rust | Go | -|----------------|------|-----------|------|----| -| createView | Y | N | N | N | -| dropView | Y | N | N | N | -| listView | Y | N | N | N | -| viewExists | Y | N | N | N | -| replaceView | Y | N | N | N | -| renameView | Y | N | N | N | +| View Operation | Java | PyIceberg | Rust | Go | C++ | +|----------------|------|-----------|------|----|-----| +| createView | Y | N | N | N | N | +| dropView | Y | N | N | N | N | +| listView | Y | N | N | N | N | +| viewExists | Y | N | N | N | N | +| replaceView | Y | N | N | N | N | +| renameView | Y | N | N | N | N | #### Namespace Operations -| Namespace Operation | Java | PyIceberg | Rust | Go | -|---------------------------|------|-----------|------|----| -| listNamespaces | Y | Y | N | Y | -| createNamespace | Y | Y | N | Y | -| dropNamespace | Y | Y | N | Y | -| namespaceExists | Y | N | N | Y | -| updateNamespaceProperties | Y | Y | Y | Y | -| loadNamespaceMetadata | Y | Y | N | Y | +| Namespace Operation | Java | PyIceberg | Rust | Go | C++ | +|---------------------------|------|-----------|------|----|-----| +| listNamespaces | Y | Y | Y | Y | N | +| createNamespace | Y | Y | Y | Y | N | +| dropNamespace | Y | Y | Y | Y | N | +| namespaceExists | Y | N | Y | Y | N | +| updateNamespaceProperties | Y | Y | Y | Y | N | +| loadNamespaceMetadata | Y | Y | Y | Y | N | ### Hive Metastore Catalog #### Table Spec V1 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | N | +| createTable | Y | Y | Y | Y | N | +| dropTable | Y | Y | Y | Y | N | +| loadTable | Y | Y | Y | Y | N | +| updateTable | Y | Y | Y | Y | N | +| renameTable | Y | Y | Y | Y | N | +| tableExists | Y | Y | Y | Y | N | #### Table Spec V2 -| Table Operation | Java | PyIceberg | Rust | Go | -|-----------------|------|-----------|------|----| -| listTable | Y | Y | Y | Y | -| createTable | Y | Y | Y | Y | -| dropTable | Y | Y | Y | Y | -| loadTable | Y | Y | Y | Y | -| updateTable | Y | Y | Y | Y | -| renameTable | Y | Y | Y | Y | -| tableExists | Y | Y | Y | Y | +| Table Operation | Java | PyIceberg | Rust | Go | C++ | +|-----------------|------|-----------|------|----|-----| +| listTable | Y | Y | Y | Y | N | +| createTable | Y | Y | Y | Y | N | +| dropTable | Y | Y | Y | Y | N | +| loadTable | Y | Y | Y | Y | N | +| updateTable | Y | Y | Y | Y | N | +| renameTable | Y | Y | Y | Y | N | +| tableExists | Y | Y | Y | Y | N | #### View Spec V1 -| View Operation | Java | PyIceberg | Rust | Go | -|----------------|------|-----------|------|----| -| createView | Y | N | N | N | -| dropView | Y | N | N | N | -| listView | Y | N | N | N | -| viewExists | Y | N | N | N | -| replaceView | Y | N | N | N | -| renameView | Y | N | N | N | +| View Operation | Java | PyIceberg | Rust | Go | C++ | +|----------------|------|-----------|------|----|-----| +| createView | Y | N | N | Y | N | +| dropView | Y | N | N | Y | N | +| listView | Y | N | N | Y | N | +| viewExists | Y | N | N | Y | N | +| replaceView | Y | N | N | N | N | +| renameView | Y | N | N | N | N | #### Namespace Operations -| Namespace Operation | Java | PyIceberg | Rust | Go | -|---------------------------|------|-----------|------|----| -| listNamespaces | Y | Y | N | N | -| createNamespace | Y | Y | N | N | -| dropNamespace | Y | Y | N | N | -| namespaceExists | Y | N | N | N | -| updateNamespaceProperties | Y | Y | Y | Y | -| loadNamespaceMetadata | Y | Y | N | N | +| Namespace Operation | Java | PyIceberg | Rust | Go | C++ | +|---------------------------|------|-----------|------|----|-----| +| listNamespaces | Y | Y | Y | Y | N | +| createNamespace | Y | Y | Y | Y | N | +| dropNamespace | Y | Y | Y | Y | N | +| namespaceExists | Y | N | Y | Y | N | +| updateNamespaceProperties | Y | Y | Y | Y | N | +| loadNamespaceMetadata | Y | Y | Y | Y | N | diff --git a/site/docs/udf-spec.md b/site/docs/udf-spec.md new file mode 120000 index 000000000000..59ca37097896 --- /dev/null +++ b/site/docs/udf-spec.md @@ -0,0 +1 @@ +../../format/udf-spec.md \ No newline at end of file diff --git a/site/docs/vendors.md b/site/docs/vendors.md index a40b0fa2ab9a..8ea8021a4d13 100644 --- a/site/docs/vendors.md +++ b/site/docs/vendors.md @@ -24,7 +24,7 @@ This page contains some of the vendors who are shipping and supporting Apache Ic ### [Amazon Web Services (AWS)](https://aws.com) -AWS provides a [comprehensive suite of services](https://aws.amazon.com/what-is/apache-iceberg/#seo-faq-pairs#what-aws-services-support-iceberg) to support Apache Iceberg as part of its modern data architecture. [Amazon Athena](https://aws.amazon.com/athena/) offers a serverless, interactive query engine with native support for Iceberg, enabling fast and cost-efficient querying of large-scale datasets. [Amazon EMR](https://aws.amazon.com/emr/) integrates Iceberg with Apache Spark, Apache Flink, Apache Hive, Presto and Trino, making it easy to process and analyze data at scale. [AWS Glue](https://aws.amazon.com/glue/) provides fully managed data integration capabilities, including schema evolution, maintenance, optimizations and partition management for Iceberg tables. With [Amazon S3](https://aws.amazon.com/s3/) as the underlying storage layer, AWS enables a high-performance and cost-effective data lakehouse solution powered by Iceberg. +AWS provides a [comprehensive suite of services](https://aws.amazon.com/what-is/apache-iceberg/#seo-faq-pairs#what-aws-services-support-iceberg) to support Apache Iceberg as part of its modern data architecture. [Amazon S3](https://aws.amazon.com/s3/) offers virtually unlimited, highly durable storage for data lakes, while [Amazon S3 Tables](https://aws.amazon.com/s3/features/tables/) deliver fully managed Iceberg tables with automated maintenance, optimization, and cost management. [Amazon Athena](https://aws.amazon.com/athena/) offers a serverless, interactive query engine with native Iceberg support, enabling fast and cost-efficient querying of large-scale datasets. [Amazon EMR](https://aws.amazon.com/emr/) integrates Iceberg with Apache Spark, Apache Flink, Apache Hive, Presto, and Trino, making it easy to process and analyze data at scale. [AWS Glue](https://aws.amazon.com/glue/) provides fully managed data integration capabilities, including schema evolution, maintenance, optimizations, and partition management for Iceberg tables. Together, these AWS services enable a high-performance and cost-effective data lakehouse solution powered by Iceberg. ### [BladePipe](https://bladepipe.com) @@ -141,6 +141,10 @@ IOMETE is a fully-managed ready to use, batteries included Data Platform. IOMETE ### [Microsoft OneLake](https://learn.microsoft.com/en-us/fabric/onelake/) [Microsoft OneLake](https://learn.microsoft.com/en-us/fabric/onelake/) is a single unified data lake that brings together your entire data estate into an open, secure foundation for analytics across the organization. Built into Microsoft Fabric, OneLake delivers two powerful APIs: the Tables API and the Files API. The [OneLake Tables API](https://aka.ms/onelakeircdocs) supports the Apache Iceberg REST Catalog (IRC) specification, making it simple to create, manage, and integrate Iceberg tables with existing tools and workflows. The OneLake Files API offers full Azure Data Lake Storage (ADLS) compatibility, enabling seamless file operations and interoperability with familiar ADLS tools. Together, these APIs make OneLake a truly open and interoperable data lake, delivering flexibility and connectivity for modern analytics and AI-driven pipelines. +### [Oracle](https://oracle.com/) + +As a fully-managed Oracle AI Database service, Oracle [Autonomous AI Lakehouse](https://www.oracle.com/autonomous-database/autonomous-ai-lakehouse/) combines the openness of Apache Iceberg with the performance, automation, and security of Oracle Autonomous Database and Oracle Exadata. Available across Oracle Cloud Infrastructure (OCI), Microsoft Azure, Google Cloud, AWS, and on-premises, Oracle AI Database provides a multicloud and hybrid open lakehouse architecture with high-performance access to Iceberg tables through integration with existing catalogs and support for the Apache Iceberg REST Catalog specification. Oracle enables interoperability across engines such as Apache Spark, Trino, and Apache Flink while minimizing data movement and preserving vendor independence. Built-in AI, vector search, graph analytics, and JSON-relational capabilities allow organizations to run advanced analytics and AI workloads directly on Iceberg data with enterprise-grade governance, availability, and serverless scalability. + ### [PuppyGraph](https://puppygraph.com) PuppyGraph is a cloud-native graph analytics engine that enables users to query one or more relational data stores as a unified graph model. This eliminates the overhead of deploying and maintaining a siloed graph database system, with no ETL required. [PuppyGraph’s native Apache Iceberg integration](https://docs.puppygraph.com/user-manual/getting-started/iceberg) adds native graph capabilities to your existing data lake in an easy and performant way. @@ -157,6 +161,10 @@ Redpanda is both a cloud-native and self-hosted streaming platform whose [Iceber [Ryft](https://ryft.io/) is a fully automated Iceberg management platform. Ryft helps data teams create an open, automated and cost-effective Iceberg lakehouse, by maintaining and optimizing Iceberg tables in real time, based on actual usage patterns. The Ryft engine runs compaction intelligently, adapting to different use cases like streaming, batch jobs, CDC, and more. Ryft also automates compliance, disaster recovery and data lifecycle management for Iceberg tables, to ensure your lakehouse stays secure and compliant. It directly integrates with your existing catalog, storage and query engines, allowing for a very simple deployment. +### [Sail](https://lakesail.com/) + +[Sail](https://github.com/lakehq/sail) is an open-source multimodal distributed compute framework, built in Rust, unifying batch, streaming, and AI workloads. For seamless adoption, Sail offers a drop-in replacement for the Spark SQL and DataFrame APIs in both single-host and distributed settings. Learn more about using Sail with Iceberg in the [Sail Iceberg guide](https://docs.lakesail.com/sail/latest/guide/sources/iceberg). + ### [SingleStore](https://singlestore.com/) SingleStore is a high‑performance, scalable, distributed SQL platform that makes real‑time analytics and transactional processing available at scale. Its native Apache Iceberg integration removes costly ETL steps and powers intelligent, millisecond‑response applications. @@ -167,7 +175,7 @@ By directly reading and [managing](https://docs.singlestore.com/cloud/load-data/ ### [Snowflake](https://snowflake.com/) -[Snowflake](https://www.snowflake.com/) is a single, cross-cloud platform that enables every organization to mobilize their data with Snowflake’s Data Cloud. Snowflake supports Apache Iceberg by offering [Snowflake-managed Iceberg Tables](https://docs.snowflake.com/en/user-guide/tables-iceberg#use-snowflake-as-the-iceberg-catalog) for full DML as well as [externally managed Iceberg Tables with catalog integrations](https://docs.snowflake.com/en/user-guide/tables-iceberg#use-a-catalog-integration) for read-only access. +[Snowflake](https://www.snowflake.com/) is a single, cross-cloud platform that enables every organization to mobilize their data with Snowflake’s Data Cloud. [Snowflake Horizon Catalog](https://docs.snowflake.com/en/user-guide/snowflake-horizon) is the universal catalog built into every Snowflake account, providing governance, metadata, and interoperability for Apache Iceberg across engines and clouds. Snowflake supports [Snowflake-managed Iceberg Tables](https://docs.snowflake.com/en/user-guide/tables-iceberg#use-snowflake-as-the-iceberg-catalog) with full DDL and DML support and an [Iceberg REST API endpoint](https://docs.snowflake.com/en/user-guide/tables-iceberg-access-using-external-query-engine-snowflake-horizon) that lets external query engines such as Apache Spark, Trino, and Apache Flink read and write those tables directly. For federation, [catalog-linked databases](https://docs.snowflake.com/en/user-guide/tables-iceberg-catalog-linked-database) provide automatic table discovery and synchronization with remote Iceberg REST catalogs, and read and write access through [catalog integrations for externally managed Iceberg Tables](https://docs.snowflake.com/en/user-guide/tables-iceberg#use-an-external-catalog). ### [Stackable](https://stackable.tech) diff --git a/site/mkdocs-dev.yml b/site/mkdocs-dev.yml index ce02dd99357a..b4c68aacc3fc 100644 --- a/site/mkdocs-dev.yml +++ b/site/mkdocs-dev.yml @@ -25,16 +25,17 @@ nav: - Home: index.md - Quickstart: - Spark: spark-quickstart.md + - Flink: flink-quickstart.md - Hive: hive-quickstart.md - Docs: - Java: - Nightly: '!include docs/docs/nightly/mkdocs.yml' - - Latest (1.10.0): '!include docs/docs/latest/mkdocs.yml' + - Latest (1.10.1): '!include docs/docs/latest/mkdocs.yml' - Other Implementations: - Python: https://py.iceberg.apache.org/ - Rust: https://rust.iceberg.apache.org/ - Go: https://go.iceberg.apache.org/ - - C++: https://github.com/apache/iceberg-cpp/ + - C++: https://cpp.iceberg.apache.org/ - Third-party: - Catalogs: - Apache Gravitino: https://gravitino.apache.org/ @@ -51,6 +52,7 @@ nav: - Apache Amoro: integrations/amoro.md - Apache Doris: https://doris.apache.org/docs/dev/lakehouse/catalogs/iceberg-catalog - Apache Druid: https://druid.apache.org/docs/latest/development/extensions-contrib/iceberg/ + - Apache Fluss: https://fluss.apache.org/docs/next/streaming-lakehouse/integrate-data-lakes/iceberg/ - BladePipe: https://www.bladepipe.com/docs/dataMigrationAndSync/datasource_func/Iceberg/props_for_iceberg_ds - ClickHouse: https://clickhouse.com/docs/en/engines/table-engines/integrations/iceberg - Daft: integrations/daft.md @@ -62,11 +64,14 @@ nav: - Google BigQuery: https://cloud.google.com/bigquery/docs/iceberg-tables - Impala: https://impala.apache.org/docs/build/html/topics/impala_iceberg.html - Memiiso Debezium: https://memiiso.github.io/debezium-server-iceberg/ + - Microsoft OneLake: https://aka.ms/onelakeircdocs - Nimtable: https://github.com/nimtable/nimtable - OLake: https://olake.io/docs - Presto: https://prestodb.io/docs/current/connector/iceberg.html - Redpanda: https://docs.redpanda.com/current/manage/iceberg/about-iceberg-topics - RisingWave: integrations/risingwave.md + - Ryft: https://docs.ryft.io/platform + - Sail: https://docs.lakesail.com/sail/latest/guide/sources/iceberg - Snowflake: https://docs.snowflake.com/en/user-guide/tables-iceberg - Starburst: https://docs.starburst.io/latest/connector/iceberg.html - Starrocks: https://docs.starrocks.io/en-us/latest/data_source/catalog/iceberg_catalog @@ -90,6 +95,7 @@ nav: - Community: community.md - Talks: talks.md - Vendors: vendors.md + - Blog: blog/ - Specification: - Terms: terms.md - REST Catalog Spec: rest-catalog-spec.md @@ -97,6 +103,7 @@ nav: - View spec: view-spec.md - Puffin spec: puffin-spec.md - AES GCM Stream spec: gcm-stream-spec.md + - UDF spec: udf-spec.md - Implementation status: status.md exclude_docs: | diff --git a/site/mkdocs.yml b/site/mkdocs.yml index 6ea9ceb3636b..436ca963c57b 100644 --- a/site/mkdocs.yml +++ b/site/mkdocs.yml @@ -18,6 +18,7 @@ INHERIT: ./nav.yml site_name: "Apache Iceberg™" +site_url: "https://iceberg.apache.org" theme: custom_dir: overrides @@ -48,9 +49,26 @@ theme: plugins: - search + - blog: + blog_dir: blog + blog_toc: true + post_date_format: long + post_url_format: "{slug}" + - rss: + feed_description: "Blogs for Apache Iceberg" + match_path: "blog/posts/.*" + image: "https://iceberg.apache.org/assets/images/Iceberg-logo.svg" + date_from_meta: + as_creation: date + categories: + - categories + feeds_filenames: + rss_created: rss.xml - macros - monorepo - - privacy + - privacy: + assets_exclude: + - '*bladepipe.com/*' - offline: enabled: !ENV [OFFLINE, false] - redirects: @@ -84,7 +102,7 @@ markdown_extensions: permalink: 🔗 extra: - icebergVersion: '1.10.0' + icebergVersion: '1.10.1' nessieVersion: '0.104.5' flinkVersion: '2.0.0' flinkVersionMajor: '2.0' @@ -100,7 +118,7 @@ extra: link: 'https://www.youtube.com/@ApacheIceberg' title: youtube - icon: fontawesome/brands/slack - link: 'https://join.slack.com/t/apache-iceberg/shared_invite/zt-3kclosz6r-3heAW3d~_PHefmN2A_~cAg' + link: 'https://join.slack.com/t/apache-iceberg/shared_invite/zt-3tkrk9gpf-1eFZ8ozS2In0~zM_BeZiRQ' title: slack exclude_docs: | diff --git a/site/nav.yml b/site/nav.yml index df8073e55cca..dd2b0dce474f 100644 --- a/site/nav.yml +++ b/site/nav.yml @@ -19,12 +19,14 @@ nav: - Home: index.md - Quickstart: - Spark: spark-quickstart.md + - Flink: flink-quickstart.md - Hive: hive-quickstart.md - Docs: - Java: - Nightly: '!include docs/docs/nightly/mkdocs.yml' - - Latest (1.10.0): '!include docs/docs/latest/mkdocs.yml' + - Latest (1.10.1): '!include docs/docs/latest/mkdocs.yml' - Previous: + - 1.10.0: '!include docs/docs/1.10.0/mkdocs.yml' - 1.9.2: '!include docs/docs/1.9.2/mkdocs.yml' - 1.9.1: '!include docs/docs/1.9.1/mkdocs.yml' - 1.9.0: '!include docs/docs/1.9.0/mkdocs.yml' @@ -47,7 +49,7 @@ nav: - Python: https://py.iceberg.apache.org/ - Rust: https://rust.iceberg.apache.org/ - Go: https://go.iceberg.apache.org/ - - C++: https://github.com/apache/iceberg-cpp/ + - C++: https://cpp.iceberg.apache.org/ - Third-party: - Catalogs: - Apache Gravitino: https://gravitino.apache.org/ @@ -64,6 +66,7 @@ nav: - Apache Amoro: integrations/amoro.md - Apache Doris: https://doris.apache.org/docs/dev/lakehouse/catalogs/iceberg-catalog - Apache Druid: https://druid.apache.org/docs/latest/development/extensions-contrib/iceberg/ + - Apache Fluss: https://fluss.apache.org/docs/next/streaming-lakehouse/integrate-data-lakes/iceberg/ - BladePipe: https://www.bladepipe.com/docs/dataMigrationAndSync/datasource_func/Iceberg/props_for_iceberg_ds - ClickHouse: https://clickhouse.com/docs/en/engines/table-engines/integrations/iceberg - Daft: integrations/daft.md @@ -81,6 +84,8 @@ nav: - Presto: https://prestodb.io/docs/current/connector/iceberg.html - Redpanda: https://docs.redpanda.com/current/manage/iceberg/about-iceberg-topics - RisingWave: integrations/risingwave.md + - Ryft: https://docs.ryft.io/platform + - Sail: https://docs.lakesail.com/sail/latest/guide/sources/iceberg - Snowflake: https://docs.snowflake.com/en/user-guide/tables-iceberg - Starburst: https://docs.starburst.io/latest/connector/iceberg.html - Starrocks: https://docs.starrocks.io/en-us/latest/data_source/catalog/iceberg_catalog @@ -104,7 +109,7 @@ nav: - Community: community.md - Talks: talks.md - Vendors: vendors.md - + - Blog: blog/ - Specification: - Terms: terms.md - REST Catalog Spec: rest-catalog-spec.md @@ -112,4 +117,5 @@ nav: - View spec: view-spec.md - Puffin spec: puffin-spec.md - AES GCM Stream spec: gcm-stream-spec.md + - UDF spec: udf-spec.md - Implementation status: status.md diff --git a/site/overrides/partials/footer.html b/site/overrides/partials/footer.html index 85a07bd4b29c..46c0323841f0 100644 --- a/site/overrides/partials/footer.html +++ b/site/overrides/partials/footer.html @@ -52,6 +52,7 @@

    Get Started

  • Hive Quickstart
  • Open Table Spec
  • Docs
  • +
  • Blog
  • Talks

  • diff --git a/site/requirements.txt b/site/requirements.txt index ac9f68f38f58..e21e4c8f1b5c 100644 --- a/site/requirements.txt +++ b/site/requirements.txt @@ -17,8 +17,9 @@ mkdocs-awesome-pages-plugin==2.10.1 mkdocs-macros-plugin==1.5.0 -mkdocs-material==9.6.23 +mkdocs-material==9.7.5 mkdocs-material-extensions==1.3.1 mkdocs-monorepo-plugin @ git+https://github.com/bitsondatadev/mkdocs-monorepo-plugin@url-fix -mkdocs-redirects==1.2.2 -pymarkdownlnt==0.9.33 +mkdocs-redirects==1.2.3 +mkdocs-rss-plugin==1.19.0 +pymarkdownlnt==0.9.36 diff --git a/spark/build.gradle b/spark/build.gradle index 75d3f899e5d6..4d4a84fd390a 100644 --- a/spark/build.gradle +++ b/spark/build.gradle @@ -31,3 +31,7 @@ if (sparkVersions.contains("3.5")) { if (sparkVersions.contains("4.0")) { apply from: file("$projectDir/v4.0/build.gradle") } + +if (sparkVersions.contains("4.1")) { + apply from: file("$projectDir/v4.1/build.gradle") +} diff --git a/spark/v3.4/build.gradle b/spark/v3.4/build.gradle index 69baf216c3cc..57e485317762 100644 --- a/spark/v3.4/build.gradle +++ b/spark/v3.4/build.gradle @@ -75,8 +75,6 @@ project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") { exclude group: 'org.roaringbitmap' } - compileOnly "org.apache.datafusion:comet-spark-spark${sparkMajorVersion}_${scalaVersion}:${libs.versions.comet.get()}" - implementation libs.parquet.column implementation libs.parquet.hadoop @@ -107,13 +105,10 @@ project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") { testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') - testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - testImplementation libs.sqlite.jdbc + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) testImplementation libs.awaitility - // runtime dependencies for running REST Catalog based integration test - testRuntimeOnly libs.jetty.servlet + testImplementation(testFixtures(project(':iceberg-parquet'))) } test { @@ -174,17 +169,10 @@ project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVer testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-hive-metastore', configuration: 'testArtifacts') testImplementation project(path: ":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}", configuration: 'testArtifacts') - testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - // runtime dependencies for running REST Catalog based integration test - testRuntimeOnly libs.jetty.servlet - testRuntimeOnly libs.sqlite.jdbc - + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) testImplementation libs.avro.avro testImplementation libs.parquet.hadoop testImplementation libs.awaitility - testImplementation "org.apache.datafusion:comet-spark-spark${sparkMajorVersion}_${scalaVersion}:${libs.versions.comet.get()}" // Required because we remove antlr plugin dependencies from the compile configuration, see note above runtimeOnly libs.antlr.runtime @@ -256,7 +244,9 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio } integrationImplementation "org.scala-lang.modules:scala-collection-compat_${scalaVersion}:${libs.versions.scala.collection.compat.get()}" - integrationImplementation "org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark34.get()}" + integrationImplementation("org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark34.get()}") { + exclude group: 'org.roaringbitmap' + } integrationImplementation libs.junit.jupiter integrationImplementation libs.junit.platform.launcher integrationImplementation libs.slf4j.simple @@ -270,11 +260,7 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio integrationRuntimeOnly project(':iceberg-hive-metastore') // runtime dependencies for running REST Catalog based integration test integrationRuntimeOnly project(path: ':iceberg-core', configuration: 'testArtifacts') - integrationRuntimeOnly (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - integrationRuntimeOnly libs.jetty.servlet - integrationRuntimeOnly libs.sqlite.jdbc + integrationRuntimeOnly (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) // Not allowed on our classpath, only the runtime jar is allowed integrationCompileOnly project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}") @@ -334,5 +320,7 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java index f48e39e500c0..86f3f19de937 100644 --- a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java +++ b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java @@ -31,6 +31,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; import org.apache.iceberg.util.ThreadPools; import org.apache.spark.sql.SparkSession; @@ -157,7 +158,7 @@ private void initDataAndDeletes() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) diff --git a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java index dc625d240769..97e6b86dabce 100644 --- a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java +++ b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java @@ -155,7 +155,7 @@ private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java index 2b189c242867..52884bf10308 100644 --- a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java +++ b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java @@ -39,6 +39,7 @@ import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.ScanTask; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.expressions.Expression; @@ -156,7 +157,7 @@ public void localPlanningWithoutFilterWithStats(Blackhole blackhole) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) @@ -222,14 +223,14 @@ private void dropTable() { private DataFile loadAddedDataFile() { table.refresh(); - Iterable dataFiles = table.currentSnapshot().addedDataFiles(table.io()); + Iterable dataFiles = SnapshotChanges.builderFor(table).build().addedDataFiles(); return Iterables.getOnlyElement(dataFiles); } private DeleteFile loadAddedDeleteFile() { table.refresh(); - Iterable deleteFiles = table.currentSnapshot().addedDeleteFiles(table.io()); + Iterable deleteFiles = SnapshotChanges.builderFor(table).build().addedDeleteFiles(); return Iterables.getOnlyElement(deleteFiles); } diff --git a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java index 42b367af521d..5e39596f6ac6 100644 --- a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java +++ b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java @@ -39,6 +39,7 @@ import org.apache.iceberg.ScanTask; import org.apache.iceberg.ScanTaskGroup; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.io.CloseableIterable; @@ -161,14 +162,15 @@ private void loadFileTasks() { private DataFile loadAddedDataFile() { table.refresh(); - Iterable addedDataFiles = table.currentSnapshot().addedDataFiles(table.io()); + Iterable addedDataFiles = SnapshotChanges.builderFor(table).build().addedDataFiles(); return Iterables.getOnlyElement(addedDataFiles); } private DeleteFile loadAddedDeleteFile() { table.refresh(); - Iterable addedDeleteFiles = table.currentSnapshot().addedDeleteFiles(table.io()); + Iterable addedDeleteFiles = + SnapshotChanges.builderFor(table).build().addedDeleteFiles(); return Iterables.getOnlyElement(addedDeleteFiles); } @@ -240,7 +242,7 @@ private Dataset randomDataDF(Schema schema, int numRows) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) diff --git a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java index d917eae5eb0f..caa23625fc44 100644 --- a/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java +++ b/spark/v3.4/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java @@ -138,7 +138,7 @@ private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelCommandDynamicPruning.scala b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelCommandDynamicPruning.scala index f8acef9fe355..6766ad338b9f 100644 --- a/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelCommandDynamicPruning.scala +++ b/spark/v3.4/spark-extensions/src/main/scala/org/apache/spark/sql/execution/dynamicpruning/RowLevelCommandDynamicPruning.scala @@ -45,7 +45,7 @@ import org.apache.spark.sql.catalyst.plans.logical.UpdateIcebergTable import org.apache.spark.sql.catalyst.rules.Rule import org.apache.spark.sql.catalyst.trees.TreePattern.PLAN_EXPRESSION import org.apache.spark.sql.catalyst.trees.TreePattern.SORT -import org.apache.spark.sql.connector.read.SupportsRuntimeFiltering +import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation import org.apache.spark.sql.execution.datasources.v2.ExtendedDataSourceV2Implicits @@ -67,7 +67,7 @@ case class RowLevelCommandDynamicPruning(spark: SparkSession) // apply special dynamic filtering only for plans that don't support deltas case RewrittenRowLevelCommand( command: RowLevelCommand, - DataSourceV2ScanRelation(_, scan: SupportsRuntimeFiltering, _, _, _), + DataSourceV2ScanRelation(_, scan: SupportsRuntimeV2Filtering, _, _, _), rewritePlan: ReplaceIcebergData) if conf.dynamicPartitionPruningEnabled && isCandidate(command) => diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java new file mode 100644 index 000000000000..ef4f0090292c --- /dev/null +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.expressions.Term; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.parser.AbstractSqlParser; +import org.apache.spark.sql.catalyst.parser.AstBuilder; +import org.apache.spark.sql.catalyst.parser.ParserInterface; +import org.apache.spark.sql.catalyst.parser.extensions.IcebergSparkSqlExtensionsParser; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestExtendedParser { + + private static SparkSession spark; + private static final String SQL_PARSER_FIELD = "sqlParser"; + private ParserInterface originalParser; + + @BeforeAll + public static void before() { + spark = + SparkSession.builder() + .master("local") + .appName("TestExtendedParser") + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void after() { + if (spark != null) { + spark.stop(); + } + } + + @BeforeEach + public void saveOriginalParser() throws Exception { + Class clazz = spark.sessionState().getClass(); + Field parserField = null; + while (clazz != null && parserField == null) { + try { + parserField = clazz.getDeclaredField(SQL_PARSER_FIELD); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + parserField.setAccessible(true); + originalParser = (ParserInterface) parserField.get(spark.sessionState()); + } + + @AfterEach + public void restoreOriginalParser() throws Exception { + setSessionStateParser(spark.sessionState(), originalParser); + } + + /** + * Tests that the Iceberg extended SQL parser can correctly parse a sort order string and return + * the expected RawOrderField. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderWithRealIcebergExtendedParser() throws Exception { + ParserInterface origParser = null; + Class clazz = spark.sessionState().getClass(); + while (clazz != null && origParser == null) { + try { + Field parserField = clazz.getDeclaredField(SQL_PARSER_FIELD); + parserField.setAccessible(true); + origParser = (ParserInterface) parserField.get(spark.sessionState()); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + assertThat(origParser).isNotNull(); + + IcebergSparkSqlExtensionsParser icebergParser = new IcebergSparkSqlExtensionsParser(origParser); + + setSessionStateParser(spark.sessionState(), icebergParser); + + List fields = + ExtendedParser.parseSortOrder(spark, "id ASC NULLS FIRST"); + + assertThat(fields).isNotEmpty(); + ExtendedParser.RawOrderField first = fields.get(0); + assertThat(first.direction()).isEqualTo(SortDirection.ASC); + assertThat(first.nullOrder()).isEqualTo(NullOrder.NULLS_FIRST); + } + + /** + * Tests that parseSortOrder can find and use an ExtendedParser that is wrapped inside another + * ParserInterface implementation. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderFindsNestedExtendedParser() throws Exception { + ExtendedParser icebergParser = mock(ExtendedParser.class); + + ExtendedParser.RawOrderField field = + new ExtendedParser.RawOrderField( + mock(Term.class), SortDirection.ASC, NullOrder.NULLS_FIRST); + List expected = Collections.singletonList(field); + + when(icebergParser.parseSortOrder("id ASC NULLS FIRST")).thenReturn(expected); + + ParserInterface wrapper = new WrapperParser(icebergParser); + + setSessionStateParser(spark.sessionState(), wrapper); + + List result = + ExtendedParser.parseSortOrder(spark, "id ASC NULLS FIRST"); + assertThat(result).isSameAs(expected); + + verify(icebergParser).parseSortOrder("id ASC NULLS FIRST"); + } + + /** + * Tests that parseSortOrder throws an exception if no ExtendedParser instance can be found in the + * parser chain. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderThrowsWhenNoExtendedParserFound() throws Exception { + ParserInterface dummy = mock(ParserInterface.class); + setSessionStateParser(spark.sessionState(), dummy); + + assertThatThrownBy(() -> ExtendedParser.parseSortOrder(spark, "id ASC")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Iceberg ExtendedParser"); + } + + /** + * Tests that parseSortOrder can find an ExtendedParser in a parent class field of the parser. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderFindsExtendedParserInParentClassField() throws Exception { + ExtendedParser icebergParser = mock(ExtendedParser.class); + ExtendedParser.RawOrderField field = + new ExtendedParser.RawOrderField( + mock(Term.class), SortDirection.ASC, NullOrder.NULLS_FIRST); + List expected = Collections.singletonList(field); + when(icebergParser.parseSortOrder("id ASC NULLS FIRST")).thenReturn(expected); + ParserInterface parser = new GrandChildParser(icebergParser); + setSessionStateParser(spark.sessionState(), parser); + + List result = + ExtendedParser.parseSortOrder(spark, "id ASC NULLS FIRST"); + assertThat(result).isSameAs(expected); + verify(icebergParser).parseSortOrder("id ASC NULLS FIRST"); + } + + private static void setSessionStateParser(Object sessionState, ParserInterface parser) + throws Exception { + Class clazz = sessionState.getClass(); + Field targetField = null; + while (clazz != null && targetField == null) { + try { + targetField = clazz.getDeclaredField(SQL_PARSER_FIELD); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (targetField == null) { + throw new IllegalStateException( + "No suitable sqlParser field found in sessionState class hierarchy!"); + } + targetField.setAccessible(true); + targetField.set(sessionState, parser); + } + + private static class WrapperParser extends AbstractSqlParser { + private final ParserInterface delegate; + private String name; + + WrapperParser(ParserInterface delegate) { + this.delegate = delegate; + this.name = "delegate"; + } + + public ParserInterface getDelegate() { + return delegate; + } + + @Override + public AstBuilder astBuilder() { + return null; + } + } + + private static class ChildParser extends WrapperParser { + ChildParser(ParserInterface parent) { + super(parent); + } + } + + private static class GrandChildParser extends ChildParser { + GrandChildParser(ParserInterface parent) { + super(parent); + } + } +} diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java index 4c1a5095916c..834640e24328 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java @@ -59,6 +59,7 @@ public static void startMetastoreAndSpark() { .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") .config( SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), String.valueOf(RANDOM.nextBoolean())) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java index f70f2d819013..54b3c4b11b98 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java @@ -26,6 +26,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -109,10 +110,12 @@ public void testCreateBranchOnEmptyTable() { Snapshot snapshot = table.snapshot(ref.snapshotId()); assertThat(snapshot.parentId()).isNull(); - assertThat(snapshot.addedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); }); } @@ -370,10 +373,12 @@ public void testCreateOrReplaceBranchOnEmptyTable() { Snapshot snapshot = table.snapshot(ref.snapshotId()); assertThat(snapshot.parentId()).isNull(); - assertThat(snapshot.addedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); }); } diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java index f7ded0c4d7d2..d39dff060c9a 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java @@ -162,6 +162,25 @@ public synchronized void testDeleteWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteDeleteSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("DELETE FROM %s WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithPreservedDataGrouping() throws NoSuchTableException { createAndInitPartitionedTable(); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java index 8d509c2952a8..03d5b4ca5bdf 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java @@ -147,6 +147,34 @@ public synchronized void testMergeWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteMergeSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("source", Collections.singletonList(1), Encoders.INT()); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'changed' " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'new')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithReportedPartitioning() { createAndInitTable("id INT, dep STRING"); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java index 21d1377b2b98..b547218acbd4 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java @@ -149,6 +149,25 @@ public synchronized void testUpdateWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteUpdateSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET dep = 'changed' WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithReportedPartitioning() { createAndInitTable("id INT, dep STRING"); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java index 1dd6db48f7d8..b106e8fc38f3 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java @@ -1395,6 +1395,62 @@ public void testDeleteToCustomWapBranchWithoutWhereClause() throws NoSuchTableEx }); } + @TestTemplate + public void testDeleteToWapBranchCanDeleteWhereScansWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + + sql("DELETE FROM %s WHERE id = 1", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("DELETE should remove the matching rows from the WAP branch") + .containsExactly(row(0, "hr"), row(2, "hr")); + assertThat(sql("SELECT id, dep FROM %s.branch_main", tableName)) + .as("Main branch must not be modified by a WAP-targeted DELETE") + .containsExactly(row(1, "hr")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + + @TestTemplate + public void testMetadataDeleteToWapBranchCommitsToWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr"), new Employee(5, "eng")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(2, "eng")); + + sql("DELETE FROM %s WHERE dep = 'hr'", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("Metadata delete should remove the hr partition on the WAP branch") + .containsExactly(row(2, "eng"), row(5, "eng")); + assertThat(sql("SELECT id, dep FROM %s.branch_main ORDER BY id", tableName)) + .as("Metadata delete must not commit to main when WAP is set") + .containsExactly(row(1, "hr"), row(5, "eng")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + @TestTemplate public void testDeleteWithFilterOnNestedColumn() { createAndInitNestedColumnsTable(); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java index 80eafb15e651..87b9f34e3d68 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java @@ -52,6 +52,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -352,7 +353,7 @@ public void testMergeWithStaticPredicatePushDown() { // remove the data file from the 'hr' partition to ensure it is not scanned withUnavailableFiles( - snapshot.addedDataFiles(table.io()), + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(), () -> { // disable dynamic pruning and rely only on static predicate pushdown withSQLConf( diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java index 0d9be093c96c..c78bb14fc72b 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java @@ -36,6 +36,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.deletes.DeleteGranularity; @@ -140,7 +141,8 @@ public void testPositionDeletesAreMaintainedDuringDelete() throws NoSuchTableExc Table table = validationCatalog.loadTable(tableIdent); Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); - assertThat(latest.removedDeleteFiles(table.io())).hasSize(1); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); assertEquals( "Should have expected rows", ImmutableList.of(row(1, "b"), row(2, "e")), @@ -181,7 +183,8 @@ public void testUnpartitionedPositionDeletesAreMaintainedDuringDelete() Table table = validationCatalog.loadTable(tableIdent); Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); - assertThat(latest.removedDeleteFiles(table.io())).hasSize(1); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); assertEquals( "Should have expected rows", ImmutableList.of(row(1, "b"), row(2, "e")), diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java index 361faade7e37..dcd9b9575786 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java @@ -26,6 +26,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.ParameterizedTestExtension; @@ -136,6 +137,34 @@ public void testMergeWithDVAndHistoricalPositionDeletes() { assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); } + @TestTemplate + public void testMergeOnReadMergeSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + createOrReplaceView("source", ImmutableList.of(1, 3), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET id = id + 10 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'hr')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + private void checkMergeDeleteGranularity(DeleteGranularity deleteGranularity) { createTableWithDeleteGranularity( "id INT, dep STRING", "PARTITIONED BY (dep)", deleteGranularity); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java index 7fa9ffcd5f89..599f39c8a247 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java @@ -25,11 +25,13 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.deletes.DeleteGranularity; @@ -74,7 +76,12 @@ public void testPositionDeletesAreMaintainedDuringUpdate() { String expectedDeleteFilesCount = "2"; validateMergeOnRead(currentSnapshot, "2", expectedDeleteFilesCount, "2"); - assertThat(currentSnapshot.removedDeleteFiles(table.io())).hasSize(2); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); assertEquals( "Should have expected rows", ImmutableList.of( @@ -122,7 +129,12 @@ public void testUnpartitionedPositionDeletesAreMaintainedDuringUpdate() { expectedDeleteFilesCount = "2"; validateMergeOnRead(currentSnapshot, "1", expectedDeleteFilesCount, "1"); - assertThat(currentSnapshot.removedDeleteFiles(table.io())).hasSize(2); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); assertEquals( "Should have expected rows", ImmutableList.of( @@ -186,6 +198,25 @@ public void testUpdateWithDVAndHistoricalPositionDeletes() { assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); } + @TestTemplate + public void testMergeOnReadUpdateSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET id = id + 10 WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + private void checkUpdateFileGranularity(DeleteGranularity deleteGranularity) { initTable("PARTITIONED BY (dep)", deleteGranularity); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java index 82a065073d94..04947592214d 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java @@ -44,6 +44,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.avro.Avro; @@ -578,7 +579,7 @@ public void testMetadataLogEntries() throws Exception { parentSnapshot.schemaId(), parentSnapshot.sequenceNumber()), row( - DateTimeUtils.toJavaTimestamp(currentSnapshot.timestampMillis() * 1000), + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), tableMetadata.metadataFileLocation(), currentSnapshot.snapshotId(), currentSnapshot.schemaId(), @@ -597,8 +598,7 @@ public void testMetadataLogEntries() throws Exception { "Result should match the latest snapshot entry", ImmutableList.of( row( - DateTimeUtils.toJavaTimestamp( - tableMetadata.currentSnapshot().timestampMillis() * 1000), + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), tableMetadata.metadataFileLocation(), tableMetadata.currentSnapshot().snapshotId(), tableMetadata.currentSnapshot().schemaId(), @@ -749,7 +749,9 @@ public void testSnapshotReferencesMetatable() throws Exception { .collectAsList(); assertThat(mainBranch) .hasSize(1) - .containsExactly(RowFactory.create("main", "BRANCH", currentSnapshotId, null, null, null)); + .containsExactly( + RowFactory.create( + SnapshotRef.MAIN_BRANCH, "BRANCH", currentSnapshotId, null, null, null)); assertThat(mainBranch.get(0).schema().fieldNames()) .containsExactly( "name", @@ -817,7 +819,7 @@ public void testSnapshotReferencesMetatable() throws Exception { .collectAsList(); assertThat(mainBranchProjection) .hasSize(1) - .containsExactly(RowFactory.create("main", "BRANCH")); + .containsExactly(RowFactory.create(SnapshotRef.MAIN_BRANCH, "BRANCH")); assertThat(mainBranchProjection.get(0).schema().fieldNames()).containsExactly("name", "type"); List testBranchProjection = diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java index a9e309ddeacc..d26e4f35bc4d 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java @@ -288,4 +288,21 @@ public void testMigratePartitionedWithParallelism() throws IOException { ImmutableList.of(row("a", 1L), row("b", 2L)), sql("SELECT * FROM %s ORDER BY id", tableName)); } + + @TestTemplate + public void testMigrateBucketedTable() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet " + + "CLUSTERED BY (id) INTO 4 BUCKETS LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertThatThrownBy(() -> sql("CALL %s.system.migrate('%s')", catalogName, tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot create an Iceberg table from a bucketed source table: " + + "4 buckets, bucket columns: [id]"); + } } diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java index 08f44c8f01f2..d9319801d154 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java @@ -161,6 +161,26 @@ public void testApplyInvalidWapId() { .hasMessage("Cannot apply unknown WAP ID 'not_valid'"); } + @TestTemplate + public void testApplyDuplicateWapId() { + + String wapId = "wap_id_1"; + + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", wapId); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.publish_changes('%s', '%s')", catalogName, tableIdent, wapId)) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID 'wap_id_1'"); + } + @TestTemplate public void testInvalidApplyWapChangesCases() { assertThatThrownBy( diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java index 7ba93935349d..a2a693c56b21 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java @@ -33,6 +33,7 @@ import org.apache.iceberg.EnvironmentContext; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ValidationException; @@ -45,6 +46,7 @@ import org.apache.iceberg.spark.SparkCatalogConfig; import org.apache.iceberg.spark.SparkTableCache; import org.apache.iceberg.spark.SystemFunctionPushDownHelper; +import org.apache.iceberg.spark.actions.SparkActions; import org.apache.iceberg.spark.source.ThreeColumnRecord; import org.apache.spark.sql.AnalysisException; import org.apache.spark.sql.Dataset; @@ -926,6 +928,7 @@ public void testRewriteDataFilesSummary() { Map summary = snapshotSummary(); assertThat(summary) .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") .hasEntrySatisfying( EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("3.4")); @@ -1055,4 +1058,209 @@ private List currentData() { private List currentData(String table) { return rowsToJava(spark.sql("SELECT * FROM " + table + " order by c1, c2, c3").collectAsList()); } + + @TestTemplate + public void testRewriteDataFilesOnBranch() { + createTable(); + insertData(10); + + String branchName = "testBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testRewriteDataFilesToNullBranchFails() { + createTable(); + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThatThrownBy(() -> SparkActions.get(spark).rewriteDataFiles(table).toBranch(null)) + .as("Invalid branch") + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid branch name: null"); + } + + @TestTemplate + public void testRewriteDataFilesOnBranchWithFilter() { + createPartitionTable(); + insertData(10); + + String branchName = "filteredBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch with filter (select only partition c2 = 'bar') + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s', where => 'c2 = \"bar\"')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 5 data files from single matching partition" + + "(containing c2 = bar) and add 1 data file", + row(5, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed after rewrite + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testBranchCompactionDoesNotAffectMain() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + // Create branch from current main state + String branchName = "compactionBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + // Add more data to MAIN to make it diverge from branch + insertData(tableName, 10); + + // Refresh to get new main snapshot after divergence + table.refresh(); + long mainSnapshotAfterDivergence = table.currentSnapshot().snapshotId(); + List expectedMainRecords = currentData(); + + // Get branch data before compaction + List expectedBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + long branchSnapshotBeforeCompaction = table.refs().get(branchName).snapshotId(); + + // Verify that branch and main have diverged + assertThat(branchSnapshotBeforeCompaction) + .as("Branch and main should have different snapshots") + .isNotEqualTo(mainSnapshotAfterDivergence); + + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + table.refresh(); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot ID must remain unchanged after branch compaction") + .isEqualTo(mainSnapshotAfterDivergence); + + // Verify main data unchanged + List actualMainRecords = currentData(); + assertEquals( + "Main data after compaction should not change", expectedMainRecords, actualMainRecords); + + // Verify branch data unchanged + List actualBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals( + "Branch data after compaction should not change", + expectedBranchRecords, + actualBranchRecords); + + // Verify branch snapshot changed + long branchSnapshotAfterCompaction = table.refs().get(branchName).snapshotId(); + assertThat(branchSnapshotAfterCompaction) + .as("Branch snapshot must be updated after compaction") + .isNotEqualTo(branchSnapshotBeforeCompaction); + + // Verify the new branch snapshot is a child of the previous branch snapshot + assertThat(table.snapshot(branchSnapshotAfterCompaction).parentId()) + .as("New branch snapshot must be a child of the previous branch snapshot") + .isEqualTo(branchSnapshotBeforeCompaction); + } } diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java index bec5e06b37a1..e5daaf65d73e 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java @@ -239,11 +239,74 @@ public void testRewriteSummary() throws Exception { Map summary = snapshotSummary(); assertThat(summary) .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") .hasEntrySatisfying( EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("3.4")); } + @TestTemplate + public void testRewritePositionDeletesWithArrayColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, items ARRAY>) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', array(named_struct('value', cast(10 as bigint), 'count', 1))), " + + "(2, 'b', array(named_struct('value', cast(20 as bigint), 'count', 2))), " + + "(3, 'c', array(named_struct('value', cast(30 as bigint), 'count', 3))), " + + "(4, 'd', array(named_struct('value', cast(40 as bigint), 'count', 4))), " + + "(5, 'e', array(named_struct('value', cast(50 as bigint), 'count', 5))), " + + "(6, 'f', array(named_struct('value', cast(60 as bigint), 'count', 6)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + + @TestTemplate + public void testRewritePositionDeletesWithMapColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, props MAP) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', map('x', cast(10 as bigint))), " + + "(2, 'b', map('y', cast(20 as bigint))), " + + "(3, 'c', map('z', cast(30 as bigint))), " + + "(4, 'd', map('w', cast(40 as bigint))), " + + "(5, 'e', map('v', cast(50 as bigint))), " + + "(6, 'f', map('u', cast(60 as bigint)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + private Map snapshotSummary() { return validationCatalog.loadTable(tableIdent).currentSnapshot().summary(); } diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java index d1db48b0bf9e..ceb3077c5670 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java @@ -30,6 +30,7 @@ import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RewriteTablePathUtil; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableUtil; import org.apache.iceberg.data.FileHelpers; @@ -211,7 +212,12 @@ public void testRewriteTablePathWithManifestAndDeleteCounts() throws IOException List> rowsToDelete = Lists.newArrayList( Pair.of( - table.currentSnapshot().addedDataFiles(table.io()).iterator().next().location(), + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .iterator() + .next() + .location(), 0L)); File file = new File(removePrefix(table.location()) + "/data/deletes.parquet"); diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java index d955364378a9..85853100621e 100644 --- a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java @@ -54,6 +54,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -1289,7 +1290,9 @@ public void testUpdateWithStaticPredicatePushdown() { assertThat(dataFilesCount).as("Must have 2 files before UPDATE").isEqualTo("2"); // remove the data file from the 'hr' partition to ensure it is not scanned - DataFile dataFile = Iterables.getOnlyElement(snapshot.addedDataFiles(table.io())); + DataFile dataFile = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()); table.io().deleteFile(dataFile.location()); // disable dynamic pruning and rely only on static predicate pushdown diff --git a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java new file mode 100644 index 000000000000..0152c8e0e6f0 --- /dev/null +++ b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.RESTCatalog; +import org.apache.iceberg.rest.RESTCatalogProperties; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.sql.TestSelect; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRemoteScanPlanning extends TestSelect { + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, binaryTableName = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build(), + SparkCatalogConfig.REST.catalogName() + ".default.binary_table" + } + }; + } + + @TestTemplate + public void fileIOIsPropagated() { + RESTCatalog catalog = new RESTCatalog(); + catalog.setConf(new Configuration()); + catalog.initialize( + "test", + ImmutableMap.builder() + .putAll(restCatalog.properties()) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build()); + Table table = catalog.loadTable(tableIdent); + + SparkScanBuilder builder = new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + verifyFileIOHasPlanId(builder.build().toBatch(), table); + verifyFileIOHasPlanId(builder.buildCopyOnWriteScan().toBatch(), table); + } + + private void verifyFileIOHasPlanId(Batch batch, Table table) { + FileIO fileIOForScan = + (FileIO) + assertThat(batch) + .extracting("fileIO") + .isInstanceOf(Supplier.class) + .asInstanceOf(InstanceOfAssertFactories.type(Supplier.class)) + .actual() + .get(); + assertThat(fileIOForScan.properties()).containsKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + assertThat(table.io().properties()).doesNotContainKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + } +} diff --git a/spark/v3.4/spark-runtime/LICENSE b/spark/v3.4/spark-runtime/LICENSE index e36e0b684501..3aceb9b01aa9 100644 --- a/spark/v3.4/spark-runtime/LICENSE +++ b/spark/v3.4/spark-runtime/LICENSE @@ -203,77 +203,170 @@ -------------------------------------------------------------------------------- -This binary artifact contains Apache Avro. +This product bundles Apache Avro. Copyright: Copyright 2010-2019 The Apache Software Foundation -Home page: https://avro.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://avro.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains the Jackson JSON processor. +This product bundles Jackson JSON processor. Copyright: 2007-2019 Tatu Saloranta and other contributors -Home page: http://jackson.codehaus.org/ -License: http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Parquet. +This product bundles FastDoubleParser (via Jackson JSON Processor). + +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles fast_float (bundled by FastDoubleParser). + +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles bigint (bundled by FastDoubleParser). + +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Parquet. Copyright: 2014-2024 The Apache Software Foundation -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://parquet.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Thrift. +This product bundles Apache Thrift (bundled by Parquet). Copyright: 2006-2017 The Apache Software Foundation. -Home page: https://thrift.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://thrift.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Daniel Lemire's JavaFastPFOR project. +This product includes code from Daniel Lemire's JavaFastPFOR project (bundled by Parquet). Copyright: 2013 Daniel Lemire -Home page: https://github.com/lemire/JavaFastPFOR -License: Apache License Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/lemire/JavaFastPFOR +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains fastutil. +This product bundles fastutil (bundled by Parquet). Copyright: 2002-2014 Sebastiano Vigna -Home page: http://fastutil.di.unimi.it/ -License: http://www.apache.org/licenses/LICENSE-2.0.html +Project URL: http://fastutil.di.unimi.it/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache ORC. +This product bundles Zero-Allocation Hashing (bundled by Parquet). + +Project URL: https://github.com/OpenHFT/Zero-Allocation-Hashing +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Apache ORC. Copyright: 2013 and onwards The Apache Software Foundation. -Home page: https://orc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://orc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Hive's storage API via ORC. +This product bundles Apache Hive's Storage API (bundled by ORC). Copyright: 2008-2020 The Apache Software Foundation -Home page: https://hive.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://hive.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google protobuf via ORC. +This product bundles Google protobuf (bundled by ORC). Copyright: 2008 Google Inc. -Home page: https://developers.google.com/protocol-buffers -License: https://github.com/protocolbuffers/protobuf/blob/master/LICENSE (BSD) - -License text: +Project URL: https://developers.google.com/protocol-buffers +License: BSD 3-Clause | Copyright 2008 Google Inc. All rights reserved. | @@ -310,53 +403,52 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Airlift Aircompressor. +This product bundles Airlift Aircompressor. Copyright: 2011-2019 Aircompressor authors. -Home page: https://github.com/airlift/aircompressor -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Cloudera Kite. +This product includes code from Cloudera Kite. Copyright: 2013-2017 Cloudera Inc. -Home page: https://kitesdk.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://kitesdk.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Presto. +This product includes code from Presto. Copyright: 2016 Facebook and contributors -Home page: https://prestodb.io/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://prestodb.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Guava. +This product bundles Google Guava. Copyright: 2006-2019 The Guava Authors -Home page: https://github.com/google/guava -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Error Prone Annotations. +This product bundles Google Error Prone Annotations. Copyright: Copyright 2011-2019 The Error Prone Authors -Home page: https://github.com/google/error-prone -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains checkerframework checker-qual Annotations. +This product bundles checkerframework checker-qual. Copyright: 2004-2019 the Checker Framework developers -Home page: https://github.com/typetools/checker-framework -License: https://github.com/typetools/checker-framework/blob/master/LICENSE.txt (MIT license) +Project URL: https://github.com/typetools/checker-framework +License: MIT license -License text: | The annotations are licensed under the MIT License. (The text of this | license appears below.) More specifically, all the parts of the Checker | Framework that you might want to include with your own program use the @@ -387,45 +479,50 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Caffeine by Ben Manes. +This product bundles Caffeine by Ben Manes. Copyright: 2014-2019 Ben Manes and contributors -Home page: https://github.com/ben-manes/caffeine -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Arrow. +This product bundles Apache Arrow. Copyright: 2016-2019 The Apache Software Foundation. -Home page: https://arrow.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://arrow.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Netty's buffer library. +This product bundles Netty. Copyright: 2014-2020 The Netty Project -Home page: https://netty.io/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://netty.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JCTools (via Netty). + +Project URL: https://github.com/JCTools/JCTools +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google FlatBuffers. +This product bundles Google FlatBuffers. Copyright: 2013-2020 Google Inc. -Home page: https://google.github.io/flatbuffers/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://google.github.io/flatbuffers/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains ThreeTen. +This product bundles ThreeTen Extra. Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. -Home page: https://www.threeten.org/threeten-extra/ -License: https://github.com/ThreeTen/threeten-extra/blob/master/LICENSE.txt (BSD 3-clause) - -License text: +Project URL: https://www.threeten.org/threeten-extra/ +License: BSD 3-Clause | All rights reserved. | @@ -457,15 +554,15 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains code from Project Nessie. +This product bundles Project Nessie. Copyright: 2015-2025 Dremio Corporation. -Home page: https://projectnessie.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://projectnessie.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache Spark. +This product bundles and includes code from Apache Spark. * vectorized reading of definition levels in BaseVectorizedParquetValuesReader.java * portions of the extensions parser @@ -473,8 +570,8 @@ This product includes code from Apache Spark. * implementation of SetAccumulator. Copyright: 2011-2018 The Apache Software Foundation -Home page: https://spark.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://spark.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -483,48 +580,509 @@ This product includes code from Delta Lake. * AssignmentAlignmentSupport is an independent development but UpdateExpressionsSupport in Delta was used as a reference. Copyright: 2020 The Delta Lake Project Authors. -Home page: https://delta.io/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://delta.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary includes code from Apache Commons. +This product includes code from Apache Commons. * Core ArrayUtil. Copyright: 2020 The Apache Software Foundation -Home page: https://commons.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://commons.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache HttpComponents Client. +This product bundles and includes code from Apache HttpComponents (core/client). + +* retry and error handling logic in ExponentialHttpRequestRetryStrategy.java -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 1999-2022 The Apache Software Foundation +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache HttpComponents Client. +This product bundles the Mozilla Public Suffix List, distributed by Apache HttpComponents. -* retry and error handling logic in ExponentialHttpRequestRetryStrategy.java +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. -------------------------------------------------------------------------------- -This binary artifact contains failsafe. +This product bundles failsafe. Copyright: Jonathan Halterman and friends -Home page: https://failsafe.dev/ -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- +This product bundles Eclipse MicroProfile OpenAPI. + +Project URL: https://github.com/microprofile/microprofile-open-api +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Eclipse Collections. + +Project URL: https://github.com/eclipse-collections/eclipse-collections +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Datasketches. + +Project URL: https://datasketches.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JTS Topology Suite. + +Project URL: https://github.com/locationtech/jts +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -This binary artifact contains Eclipse MicroProfile OpenAPI. +This product bundles RoaringBitmap. -Home page: https://github.com/microprofile/microprofile-open-api -License: https://www.apache.org/licenses/LICENSE-2.0 +Copyright: (c) 2013-... the RoaringBitmap authors +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 diff --git a/spark/v3.4/spark-runtime/NOTICE b/spark/v3.4/spark-runtime/NOTICE index b5d1c8d1be4b..c038e853af77 100644 --- a/spark/v3.4/spark-runtime/NOTICE +++ b/spark/v3.4/spark-runtime/NOTICE @@ -1,15 +1,14 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -This binary artifact contains code from Kite, developed at Cloudera, Inc. with -the following copyright notice: - +This product includes code from Kite, developed at Cloudera, Inc. with +the following in its NOTICE file: | Copyright 2013 Cloudera Inc. | | Licensed under the Apache License, Version 2.0 (the "License"); @@ -26,9 +25,7 @@ the following copyright notice: -------------------------------------------------------------------------------- -This binary artifact includes Airlift Aircompressor with the following in its -NOTICE file: - +This product bundles Airlift Aircompressor with the following in its NOTICE file: | Snappy Copyright Notices | ========================= | @@ -69,47 +66,7 @@ NOTICE file: -------------------------------------------------------------------------------- -This binary artifact includes Google Protobuf with the following copyright -notice: - -| Copyright 2008 Google Inc. All rights reserved. -| -| Redistribution and use in source and binary forms, with or without -| modification, are permitted provided that the following conditions are -| met: -| -| * Redistributions of source code must retain the above copyright -| notice, this list of conditions and the following disclaimer. -| * Redistributions in binary form must reproduce the above -| copyright notice, this list of conditions and the following disclaimer -| in the documentation and/or other materials provided with the -| distribution. -| * Neither the name of Google Inc. nor the names of its -| contributors may be used to endorse or promote products derived from -| this software without specific prior written permission. -| -| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -| -| Code generated by the Protocol Buffer compiler is owned by the owner -| of the input file used when generating it. This code is not -| standalone and requires a support library to be linked with it. This -| support library is itself covered by the above license. - --------------------------------------------------------------------------------- - -This binary artifact includes Netty buffers with the following in its NOTICE -file: - +This product bundles Netty with the following in its NOTICE file: | The Netty Project | ================= | @@ -360,9 +317,7 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Project Nessie with the following in its NOTICE -file: - +This product bundles Project Nessie with the following in its NOTICE file: | Nessie | Copyright 2015-2025 Dremio Corporation | @@ -380,8 +335,43 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Eclipse MicroProfile OpenAPI with the following in its NOTICE file: +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. + +-------------------------------------------------------------------------------- +This product bundles Eclipse MicroProfile OpenAPI with the following in its NOTICE file: | ========================================================================= | == NOTICE file corresponding to section 4(d) of the Apache License, == | == Version 2.0, in this case for MicroProfile OpenAPI == diff --git a/spark/v3.4/spark-runtime/runtime-deps.txt b/spark/v3.4/spark-runtime/runtime-deps.txt new file mode 100644 index 000000000000..858171371ec2 --- /dev/null +++ b/spark/v3.4/spark-runtime/runtime-deps.txt @@ -0,0 +1,40 @@ +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.14.2 +com.fasterxml.jackson.core:jackson-databind:2.14.2 +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.3 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.google.errorprone:error_prone_annotations:2.10.0 +com.google.flatbuffers:flatbuffers-java:23.5.26 +dev.failsafe:failsafe:3.3.2 +io.airlift:aircompressor:2.0.3 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-common:4.2.13.Final +org.apache.arrow:arrow-format:15.0.2 +org.apache.arrow:arrow-memory-core:15.0.2 +org.apache.arrow:arrow-memory-netty:15.0.2 +org.apache.arrow:arrow-vector:15.0.2 +org.apache.avro:avro:1.12.1 +org.apache.datasketches:datasketches-java:6.2.0 +org.apache.datasketches:datasketches-memory:3.0.2 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.checkerframework:checker-qual:3.19.0 +org.eclipse.collections:eclipse-collections-api:11.1.0 +org.eclipse.collections:eclipse-collections:11.1.0 +org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1 +org.locationtech.jts:jts-core:1.20.0 +org.projectnessie.nessie:nessie-client:0.107.5 +org.projectnessie.nessie:nessie-model:0.107.5 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.threeten:threeten-extra:1.7.1 diff --git a/spark/v3.4/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java b/spark/v3.4/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java index 29f725615a21..709f626b08c1 100644 --- a/spark/v3.4/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java +++ b/spark/v3.4/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java @@ -38,8 +38,6 @@ public void dropTable() { } // Run through our Doc's Getting Started Example - // TODO Update doc example so that it can actually be run, modifications were required for this - // test suite to run @TestTemplate public void testGettingStarted() throws IOException { // Creating a table diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java index b6ade2bff3e1..317bd96e7df1 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java @@ -20,6 +20,9 @@ import static org.apache.spark.sql.functions.lit; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.sql.Timestamp; import java.util.List; import java.util.Locale; @@ -32,9 +35,9 @@ import org.apache.iceberg.Table; import org.apache.iceberg.actions.DeleteOrphanFiles; import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.io.Files; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.actions.SparkActions; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; @@ -57,8 +60,8 @@ /** * A benchmark that evaluates the performance of remove orphan files action in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=DeleteOrphanFilesBenchmark * -PjmhOutputPath=benchmark/delete-orphan-files-benchmark-results.txt * @@ -161,7 +164,14 @@ private Table table() { } private String catalogWarehouse() { - return Files.createTempDir().getAbsolutePath() + "/" + UUID.randomUUID() + "/"; + try { + return Files.createTempDirectory("benchmark-").toAbsolutePath() + + "/" + + UUID.randomUUID() + + "/"; + } catch (IOException e) { + throw new UncheckedIOException(e); + } } private void setupSpark() { @@ -170,6 +180,7 @@ private void setupSpark() { .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") .config("spark.sql.catalog.spark_catalog.warehouse", catalogWarehouse()) + .config(TestBase.DISABLE_UI) .master("local"); spark = builder.getOrCreate(); } diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java index 7cd41bd7e705..073e8c9327df 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java @@ -26,6 +26,8 @@ import static org.apache.spark.sql.functions.expr; import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -36,10 +38,10 @@ import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; -import org.apache.iceberg.relocated.com.google.common.io.Files; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.actions.SparkActions; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; @@ -371,8 +373,14 @@ protected final SparkSession spark() { } protected String getCatalogWarehouse() { - String location = Files.createTempDir().getAbsolutePath() + "/" + UUID.randomUUID() + "/"; - return location; + try { + return Files.createTempDirectory("benchmark-").toAbsolutePath() + + "/" + + UUID.randomUUID() + + "/"; + } catch (IOException e) { + throw new UncheckedIOException(e); + } } protected void cleanupFiles() throws IOException { @@ -386,6 +394,7 @@ protected void setupSpark() { "spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog") .config("spark.sql.catalog.spark_catalog.type", "hadoop") .config("spark.sql.catalog.spark_catalog.warehouse", getCatalogWarehouse()) + .config(TestBase.DISABLE_UI) .master("local[*]"); spark = builder.getOrCreate(); Configuration sparkHadoopConf = spark.sessionState().newHadoopConf(); diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java index e65e2fb576b0..bbc89d76bdd0 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java @@ -25,9 +25,11 @@ import java.io.IOException; import java.util.List; import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; import org.apache.iceberg.Schema; import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; @@ -58,8 +60,8 @@ * A benchmark that evaluates the performance of reading Parquet data with a flat schema using * Iceberg and Spark Parquet readers. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=SparkParquetReadersFlatDataBenchmark * -PjmhOutputPath=benchmark/spark-parquet-readers-flat-data-benchmark-result.txt * @@ -113,9 +115,9 @@ public void tearDownBenchmark() { @Threads(1) public void readUsingIcebergReader(Blackhole blackHole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) .build()) { for (InternalRow row : rows) { @@ -171,9 +173,9 @@ public void readUsingSparkReader(Blackhole blackhole) throws IOException { @Threads(1) public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(PROJECTED_SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) .build()) { for (InternalRow row : rows) { diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java index 3520c9b47489..8ff677e04b3d 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java @@ -25,9 +25,11 @@ import java.io.IOException; import java.util.List; import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; import org.apache.iceberg.Schema; import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; @@ -58,8 +60,8 @@ * A benchmark that evaluates the performance of reading nested Parquet data using Iceberg and Spark * Parquet readers. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=SparkParquetReadersNestedDataBenchmark * -PjmhOutputPath=benchmark/spark-parquet-readers-nested-data-benchmark-result.txt * @@ -111,9 +113,9 @@ public void tearDownBenchmark() { @Threads(1) public void readUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) .build()) { for (InternalRow row : rows) { @@ -169,9 +171,9 @@ public void readUsingSparkReader(Blackhole blackhole) throws IOException { @Threads(1) public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(PROJECTED_SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) .build()) { for (InternalRow row : rows) { diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java index f104b8b88b36..b049df816c24 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java @@ -23,13 +23,17 @@ import java.io.File; import java.io.IOException; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.RandomData; -import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; @@ -51,8 +55,8 @@ * A benchmark that evaluates the performance of writing Parquet data with a flat schema using * Iceberg and Spark Parquet writers. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=SparkParquetWritersFlatDataBenchmark * -PjmhOutputPath=benchmark/spark-parquet-writers-flat-data-benchmark-result.txt * @@ -95,15 +99,16 @@ public void tearDownBenchmark() { @Benchmark @Threads(1) public void writeUsingIcebergWriter() throws IOException { - try (FileAppender writer = - Parquet.write(Files.localOutput(dataFile)) - .createWriterFunc( - msgType -> - SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) .build()) { - writer.addAll(rows); + writer.write(rows); } } @@ -121,6 +126,7 @@ public void writeUsingSparkWriter() throws IOException { .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") .set("spark.sql.caseSensitive", "false") .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") .schema(SCHEMA) .build()) { diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java index e375d1c56a6f..90005af6da9a 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java @@ -23,13 +23,17 @@ import java.io.File; import java.io.IOException; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.RandomData; -import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; @@ -51,8 +55,8 @@ * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and Spark * Parquet writers. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=SparkParquetWritersNestedDataBenchmark * -PjmhOutputPath=benchmark/spark-parquet-writers-nested-data-benchmark-result.txt * @@ -95,15 +99,16 @@ public void tearDownBenchmark() { @Benchmark @Threads(1) public void writeUsingIcebergWriter() throws IOException { - try (FileAppender writer = - Parquet.write(Files.localOutput(dataFile)) - .createWriterFunc( - msgType -> - SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) .build()) { - writer.addAll(rows); + writer.write(rows); } } @@ -121,6 +126,7 @@ public void writeUsingSparkWriter() throws IOException { .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") .set("spark.sql.caseSensitive", "false") .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") .schema(SCHEMA) .build()) { diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java index 68c537e34a4a..debe37866ff7 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java @@ -30,6 +30,7 @@ import org.apache.iceberg.UpdateProperties; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.apache.spark.sql.SaveMode; @@ -94,7 +95,7 @@ protected void cleanupFiles() throws IOException { } protected void setupSpark(boolean enableDictionaryEncoding) { - SparkSession.Builder builder = SparkSession.builder().config("spark.ui.enabled", false); + SparkSession.Builder builder = SparkSession.builder().config(TestBase.DISABLE_UI); if (!enableDictionaryEncoding) { builder .config("parquet.dictionary.page.size", "1") diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/AvroWritersBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/AvroWritersBenchmark.java index 5220f65dfa6c..7729000e08f3 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/AvroWritersBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/AvroWritersBenchmark.java @@ -24,8 +24,8 @@ /** * A benchmark that evaluates the performance of various Iceberg writers for Avro data. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=AvroWritersBenchmark * -PjmhOutputPath=benchmark/avro-writers-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceFlatAvroDataReadBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceFlatAvroDataReadBenchmark.java index 4eb1ee9d92bb..a9bbc0220e06 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceFlatAvroDataReadBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceFlatAvroDataReadBenchmark.java @@ -40,8 +40,8 @@ * A benchmark that evaluates the performance of reading Avro data with a flat schema using Iceberg * and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceFlatAvroDataReadBenchmark * -PjmhOutputPath=benchmark/iceberg-source-flat-avro-data-read-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceNestedAvroDataReadBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceNestedAvroDataReadBenchmark.java index 2e792b6d35e3..752c5dd58d0a 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceNestedAvroDataReadBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceNestedAvroDataReadBenchmark.java @@ -40,8 +40,8 @@ * A benchmark that evaluates the performance of reading Avro data with a flat schema using Iceberg * and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceNestedAvroDataReadBenchmark * -PjmhOutputPath=benchmark/iceberg-source-nested-avro-data-read-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataReadBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataReadBenchmark.java index 8ee467b509e0..773b8c89560c 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataReadBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataReadBenchmark.java @@ -40,8 +40,8 @@ * A benchmark that evaluates the performance of reading ORC data with a flat schema using Iceberg * and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceFlatORCDataReadBenchmark * -PjmhOutputPath=benchmark/iceberg-source-flat-orc-data-read-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedListORCDataWriteBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedListORCDataWriteBenchmark.java index 15486113493a..2ae7ea208638 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedListORCDataWriteBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedListORCDataWriteBenchmark.java @@ -39,8 +39,8 @@ * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the * built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceNestedListORCDataWriteBenchmark * -PjmhOutputPath=benchmark/iceberg-source-nested-list-orc-data-write-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedORCDataReadBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedORCDataReadBenchmark.java index c651f9eea8c7..b43c58843bc3 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedORCDataReadBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedORCDataReadBenchmark.java @@ -41,8 +41,8 @@ * A benchmark that evaluates the performance of reading ORC data with a flat schema using Iceberg * and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceNestedORCDataReadBenchmark * -PjmhOutputPath=benchmark/iceberg-source-nested-orc-data-read-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataFilterBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataFilterBenchmark.java index 1633709f4cd2..649d963b052e 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataFilterBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataFilterBenchmark.java @@ -43,8 +43,8 @@ * *

    The performance is compared to the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceFlatParquetDataFilterBenchmark * -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-filter-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataReadBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataReadBenchmark.java index 1babed8c5c79..d72387d86142 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataReadBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataReadBenchmark.java @@ -39,8 +39,8 @@ * A benchmark that evaluates the performance of reading Parquet data with a flat schema using * Iceberg and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceFlatParquetDataReadBenchmark * -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-read-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataWriteBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataWriteBenchmark.java index 0bab9c401935..8c180260eef1 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataWriteBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataWriteBenchmark.java @@ -37,8 +37,8 @@ * A benchmark that evaluates the performance of writing Parquet data with a flat schema using * Iceberg and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceFlatParquetDataWriteBenchmark * -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-write-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedListParquetDataWriteBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedListParquetDataWriteBenchmark.java index 47d866f1b803..d52318454e09 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedListParquetDataWriteBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedListParquetDataWriteBenchmark.java @@ -40,8 +40,8 @@ * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the * built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceNestedListParquetDataWriteBenchmark * -PjmhOutputPath=benchmark/iceberg-source-nested-list-parquet-data-write-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataFilterBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataFilterBenchmark.java index 7da6499c14a3..59e80cd57cca 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataFilterBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataFilterBenchmark.java @@ -43,8 +43,8 @@ * *

    The performance is compared to the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceNestedParquetDataFilterBenchmark * -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-filter-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataReadBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataReadBenchmark.java index e55717fdc442..8a83b86f959b 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataReadBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataReadBenchmark.java @@ -39,8 +39,8 @@ * A benchmark that evaluates the performance of reading nested Parquet data using Iceberg and the * built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceNestedParquetDataReadBenchmark * -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-read-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataWriteBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataWriteBenchmark.java index 981107dc651b..92ac4ea22082 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataWriteBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataWriteBenchmark.java @@ -38,8 +38,8 @@ * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the * built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh * -PjmhIncludeRegex=IcebergSourceNestedParquetDataWriteBenchmark * -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-write-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetEqDeleteBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetEqDeleteBenchmark.java index f1e5956dbdc4..2238cb6e6679 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetEqDeleteBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetEqDeleteBenchmark.java @@ -27,8 +27,8 @@ * A benchmark that evaluates the non-vectorized read and vectorized read with equality delete in * the Spark data source for Iceberg. * - *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3:jmh + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4:jmh * -PjmhIncludeRegex=IcebergSourceParquetEqDeleteBenchmark * -PjmhOutputPath=benchmark/iceberg-source-parquet-eq-delete-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java index 60430339d392..b61636d9b93e 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -28,8 +29,8 @@ * A benchmark that evaluates the non-vectorized read and vectorized read with pos-delete in the * Spark data source for Iceberg. * - *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3:jmh \ + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4:jmh \ * -PjmhIncludeRegex=IcebergSourceParquetMultiDeleteFileBenchmark \ * -PjmhOutputPath=benchmark/iceberg-source-parquet-multi-delete-file-benchmark-result.txt * @@ -44,7 +45,7 @@ protected void appendData() throws IOException { writeData(fileNum); table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletes(file.location(), NUM_ROWS, 0.25, numDeleteFile); } } diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java index 72fffaa50b8e..82b1c850b832 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -28,8 +29,8 @@ * A benchmark that evaluates the non-vectorized read and vectorized read with pos-delete in the * Spark data source for Iceberg. * - *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3:jmh + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4:jmh * -PjmhIncludeRegex=IcebergSourceParquetPosDeleteBenchmark * -PjmhOutputPath=benchmark/iceberg-source-parquet-pos-delete-benchmark-result.txt * @@ -46,7 +47,7 @@ protected void appendData() throws IOException { if (percentDeleteRow > 0) { // add pos-deletes table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletes(file.location(), NUM_ROWS, percentDeleteRow); } } diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java index 892e2c5db19f..8d45e9ecaf56 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -28,8 +29,8 @@ * A benchmark that evaluates the non-vectorized read and vectorized read with pos-delete in the * Spark data source for Iceberg. * - *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3:jmh + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4:jmh * -PjmhIncludeRegex=IcebergSourceParquetWithUnrelatedDeleteBenchmark * -PjmhOutputPath=benchmark/iceberg-source-parquet-with-unrelated-delete-benchmark-result.txt * @@ -46,7 +47,7 @@ protected void appendData() throws IOException { writeData(fileNum); table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletesWithNoise( file.location(), NUM_ROWS, diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/ParquetWritersBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/ParquetWritersBenchmark.java index 3857aabf5655..c0ab3fdc07cf 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/ParquetWritersBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/ParquetWritersBenchmark.java @@ -24,8 +24,8 @@ /** * A benchmark that evaluates the performance of various Iceberg writers for Parquet data. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh \ + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh \ * -PjmhIncludeRegex=ParquetWritersBenchmark \ * -PjmhOutputPath=benchmark/parquet-writers-benchmark-result.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadDictionaryEncodedFlatParquetDataBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadDictionaryEncodedFlatParquetDataBenchmark.java index 0a30639d1c79..ec4a199cc23f 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadDictionaryEncodedFlatParquetDataBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadDictionaryEncodedFlatParquetDataBenchmark.java @@ -39,8 +39,8 @@ * Benchmark to compare performance of reading Parquet dictionary encoded data with a flat schema * using vectorized Iceberg read path and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh \ + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh \ * -PjmhIncludeRegex=VectorizedReadDictionaryEncodedFlatParquetDataBenchmark \ * -PjmhOutputPath=benchmark/results.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadFlatParquetDataBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadFlatParquetDataBenchmark.java index abfc5d950a0d..1e05dbd02fdb 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadFlatParquetDataBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadFlatParquetDataBenchmark.java @@ -50,8 +50,8 @@ * Benchmark to compare performance of reading Parquet data with a flat schema using vectorized * Iceberg read path and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh \ + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh \ * -PjmhIncludeRegex=VectorizedReadFlatParquetDataBenchmark \ * -PjmhOutputPath=benchmark/results.txt * diff --git a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadParquetDecimalBenchmark.java b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadParquetDecimalBenchmark.java index e915da9d3c91..c9d2612784a6 100644 --- a/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadParquetDecimalBenchmark.java +++ b/spark/v3.4/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadParquetDecimalBenchmark.java @@ -48,8 +48,8 @@ * Benchmark to compare performance of reading Parquet decimal data using vectorized Iceberg read * path and the built-in file source in Spark. * - *

    To run this benchmark for spark-3.3: - * ./gradlew -DsparkVersions=3.3 :iceberg-spark:iceberg-spark-3.3_2.12:jmh \ + *

    To run this benchmark for spark-3.4: + * ./gradlew -DsparkVersions=3.4 :iceberg-spark:iceberg-spark-3.4_2.12:jmh \ * -PjmhIncludeRegex=VectorizedReadParquetDecimalBenchmark \ * -PjmhOutputPath=benchmark/results.txt * diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java index ea400a779235..cb9da3edc678 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java @@ -56,7 +56,7 @@ public static R withCommitProperties( ExceptionUtil.castAndThrow(e, exClass); return null; } finally { - COMMIT_PROPERTIES.set(ImmutableMap.of()); + COMMIT_PROPERTIES.remove(); } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ExtendedParser.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ExtendedParser.java index 19b3dd8f49be..5bfefbb97409 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ExtendedParser.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ExtendedParser.java @@ -18,6 +18,7 @@ */ package org.apache.iceberg.spark; +import java.lang.reflect.Field; import java.util.List; import org.apache.iceberg.NullOrder; import org.apache.iceberg.SortDirection; @@ -25,6 +26,8 @@ import org.apache.spark.sql.AnalysisException; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.parser.ParserInterface; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public interface ExtendedParser extends ParserInterface { class RawOrderField { @@ -52,10 +55,10 @@ public NullOrder nullOrder() { } static List parseSortOrder(SparkSession spark, String orderString) { - if (spark.sessionState().sqlParser() instanceof ExtendedParser) { - ExtendedParser parser = (ExtendedParser) spark.sessionState().sqlParser(); + ExtendedParser extParser = findParser(spark.sessionState().sqlParser(), ExtendedParser.class); + if (extParser != null) { try { - return parser.parseSortOrder(orderString); + return extParser.parseSortOrder(orderString); } catch (AnalysisException e) { throw new IllegalArgumentException( String.format("Unable to parse sortOrder: %s", orderString), e); @@ -66,5 +69,42 @@ static List parseSortOrder(SparkSession spark, String orderString } } + private static T findParser(ParserInterface parser, Class clazz) { + ParserInterface current = parser; + while (current != null) { + if (clazz.isInstance(current)) { + return clazz.cast(current); + } + + current = getNextDelegateParser(current); + } + + return null; + } + + private static ParserInterface getNextDelegateParser(ParserInterface parser) { + try { + Class clazz = parser.getClass(); + while (clazz != null) { + for (Field field : clazz.getDeclaredFields()) { + field.setAccessible(true); + Object value = field.get(parser); + if (value instanceof ParserInterface && value != parser) { + return (ParserInterface) value; + } + } + clazz = clazz.getSuperclass(); + } + } catch (Exception e) { + log().warn("Failed to scan delegate parser in {}: ", parser.getClass().getName(), e); + } + + return null; + } + + private static Logger log() { + return LoggerFactory.getLogger(ExtendedParser.class); + } + List parseSortOrder(String orderString) throws AnalysisException; } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java index 442d728d4d69..b7cd441c9642 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java @@ -24,6 +24,4 @@ @Value.Immutable public interface ParquetBatchReadConf extends Serializable { int batchSize(); - - ParquetReaderType readerType(); } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java deleted file mode 100644 index d9742c048251..000000000000 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java +++ /dev/null @@ -1,47 +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. - */ -package org.apache.iceberg.spark; - -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; - -/** Enumerates the types of Parquet readers. */ -public enum ParquetReaderType { - /** ICEBERG type utilizes the built-in Parquet reader. */ - ICEBERG, - - /** - * COMET type changes the Parquet reader to the Apache DataFusion Comet Parquet reader. Comet - * Parquet reader performs I/O and decompression in the JVM but decodes in native to improve - * performance. Additionally, Comet will convert Spark's physical plan into a native physical plan - * and execute this plan natively. - * - *

    TODO: Implement {@link org.apache.comet.parquet.SupportsComet} in SparkScan to convert Spark - * physical plan to native physical plan for native execution. - */ - COMET; - - public static ParquetReaderType fromString(String typeAsString) { - Preconditions.checkArgument(typeAsString != null, "Parquet reader type is null"); - try { - return ParquetReaderType.valueOf(typeAsString.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Unknown parquet reader type: " + typeAsString); - } - } -} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java index fbd21f737450..fec413ca079a 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java @@ -41,6 +41,7 @@ import org.apache.spark.sql.types.IntegerType$; import org.apache.spark.sql.types.LongType$; import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType$; import org.apache.spark.sql.types.StringType$; import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; @@ -238,5 +239,6 @@ public Type primitive(Type.PrimitiveType primitive) { .put(TypeID.STRING, ImmutableSet.of(StringType$.class)) .put(TypeID.FIXED, ImmutableSet.of(BinaryType$.class)) .put(TypeID.BINARY, ImmutableSet.of(BinaryType$.class)) + .put(TypeID.UNKNOWN, ImmutableSet.of(NullType$.class)) .buildOrThrow(); } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java index bc8a966488ee..f1709277525a 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java @@ -24,17 +24,17 @@ import java.util.function.Function; import org.apache.spark.sql.connector.catalog.Identifier; import org.apache.spark.sql.connector.catalog.StagedTable; -import org.apache.spark.sql.connector.catalog.SupportsDelete; +import org.apache.spark.sql.connector.catalog.SupportsDeleteV2; import org.apache.spark.sql.connector.catalog.SupportsRead; import org.apache.spark.sql.connector.catalog.SupportsWrite; import org.apache.spark.sql.connector.catalog.Table; import org.apache.spark.sql.connector.catalog.TableCapability; import org.apache.spark.sql.connector.catalog.TableCatalog; import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.ScanBuilder; import org.apache.spark.sql.connector.write.LogicalWriteInfo; import org.apache.spark.sql.connector.write.WriteBuilder; -import org.apache.spark.sql.sources.Filter; import org.apache.spark.sql.types.StructType; import org.apache.spark.sql.util.CaseInsensitiveStringMap; @@ -58,7 +58,7 @@ * #capabilities()}. */ public class RollbackStagedTable - implements StagedTable, SupportsRead, SupportsWrite, SupportsDelete { + implements StagedTable, SupportsRead, SupportsWrite, SupportsDeleteV2 { private final TableCatalog catalog; private final Identifier ident; private final Table table; @@ -106,8 +106,8 @@ public Set capabilities() { } @Override - public void deleteWhere(Filter[] filters) { - call(SupportsDelete.class, t -> t.deleteWhere(filters)); + public void deleteWhere(Predicate[] predicates) { + call(SupportsDeleteV2.class, t -> t.deleteWhere(predicates)); } @Override diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java index 0f49c54544f8..c07508568dbf 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java @@ -797,6 +797,7 @@ public final void initialize(String name, CaseInsensitiveStringMap options) { EnvironmentContext.put( EnvironmentContext.ENGINE_VERSION, sparkSession.sparkContext().version()); EnvironmentContext.put(CatalogProperties.APP_ID, sparkSession.sparkContext().applicationId()); + EnvironmentContext.put(CatalogProperties.APP_NAME, sparkSession.sparkContext().appName()); } @Override diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java index bad31d8d85f4..7b44d93e53b5 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java @@ -35,8 +35,6 @@ public abstract class SparkContentFile implements ContentFile { - private static final FileContent[] FILE_CONTENT_VALUES = FileContent.values(); - private final int fileContentPosition; private final int filePathPosition; private final int fileFormatPosition; @@ -54,6 +52,7 @@ public abstract class SparkContentFile implements ContentFile { private final int sortOrderIdPosition; private final int fileSpecIdPosition; private final int equalityIdsPosition; + private final int firstRowIdPosition; private final int referencedDataFilePosition; private final int contentOffsetPosition; private final int contentSizePosition; @@ -106,6 +105,7 @@ public abstract class SparkContentFile implements ContentFile { this.sortOrderIdPosition = positions.get(DataFile.SORT_ORDER_ID.name()); this.fileSpecIdPosition = positions.get(DataFile.SPEC_ID.name()); this.equalityIdsPosition = positions.get(DataFile.EQUALITY_IDS.name()); + this.firstRowIdPosition = positions.get(DataFile.FIRST_ROW_ID.name()); this.referencedDataFilePosition = positions.get(DataFile.REFERENCED_DATA_FILE.name()); this.contentOffsetPosition = positions.get(DataFile.CONTENT_OFFSET.name()); this.contentSizePosition = positions.get(DataFile.CONTENT_SIZE.name()); @@ -139,7 +139,7 @@ public FileContent content() { if (wrapped.isNullAt(fileContentPosition)) { return null; } - return FILE_CONTENT_VALUES[wrapped.getInt(fileContentPosition)]; + return FileContent.fromId(wrapped.getInt(fileContentPosition)); } @Override @@ -258,6 +258,15 @@ public Long contentSizeInBytes() { return wrapped.getLong(contentSizePosition); } + @Override + public Long firstRowId() { + if (wrapped.isNullAt(firstRowIdPosition)) { + return null; + } + + return wrapped.getLong(firstRowIdPosition); + } + private int fieldPosition(String name, StructType sparkType) { try { return sparkType.fieldIndex(name); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java index dd7e2c20c1b9..5b86efd34374 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java @@ -22,10 +22,12 @@ import java.util.Map; import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.SupportsDistributedScanPlanning; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.hadoop.Util; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.PropertyUtil; import org.apache.spark.SparkConf; import org.apache.spark.sql.SparkSession; @@ -264,6 +266,39 @@ public int maxRecordsPerMicroBatch() { .parse(); } + public boolean asyncMicroBatchPlanningEnabled() { + return confParser + .booleanConf() + .option(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .sessionConf(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .defaultValue(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT) + .parse(); + } + + public long streamingSnapshotPollingIntervalMs() { + return confParser + .longConf() + .option(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS) + .defaultValue(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadFileLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadRowLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT) + .parse(); + } + public boolean preserveDataGrouping() { return confParser .booleanConf() @@ -284,6 +319,7 @@ public boolean aggregatePushDownEnabled() { public boolean adaptiveSplitSizeEnabled() { return confParser .booleanConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_ENABLED) .tableProperty(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED) .defaultValue(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED_DEFAULT) .parse(); @@ -295,8 +331,21 @@ public int parallelism() { return Math.max(defaultParallelism, numShufflePartitions); } + public int splitParallelism() { + int parallelism = + confParser + .intConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM) + .defaultValue(parallelism()) + .parse(); + Preconditions.checkArgument(parallelism > 0, "Split parallelism must be > 0: %s", parallelism); + return parallelism; + } + public boolean distributedPlanningEnabled() { - return dataPlanningMode() != LOCAL || deletePlanningMode() != LOCAL; + return table instanceof SupportsDistributedScanPlanning distributed + && distributed.allowDistributedPlanning() + && (dataPlanningMode() != LOCAL || deletePlanningMode() != LOCAL); } public PlanningMode dataPlanningMode() { @@ -371,12 +420,4 @@ public boolean reportColumnStats() { .defaultValue(SparkSQLProperties.REPORT_COLUMN_STATS_DEFAULT) .parse(); } - - public ParquetReaderType parquetReaderType() { - return confParser - .enumConf(ParquetReaderType::fromString) - .sessionConf(SparkSQLProperties.PARQUET_READER_TYPE) - .defaultValue(SparkSQLProperties.PARQUET_READER_TYPE_DEFAULT) - .parse(); - } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java index c31a7e55543d..a6e6479d661d 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java @@ -92,6 +92,21 @@ private SparkReadOptions() {} public static final String STREAMING_MAX_ROWS_PER_MICRO_BATCH = "streaming-max-rows-per-micro-batch"; + // Enable async micro batch planning + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "async-micro-batch-planning-enabled"; + + // Polling interval for async planner to refresh table metadata (ms) + public static final String STREAMING_SNAPSHOT_POLLING_INTERVAL_MS = + "streaming-snapshot-polling-interval-ms"; + public static final long STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT = 30000L; + + // Initial queue preload limits for async micro batch planner + public static final String ASYNC_QUEUE_PRELOAD_FILE_LIMIT = "async-queue-preload-file-limit"; + public static final long ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT = 100L; + public static final String ASYNC_QUEUE_PRELOAD_ROW_LIMIT = "async-queue-preload-row-limit"; + public static final long ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT = 100000L; + // Table path public static final String PATH = "path"; diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java index fa7d4a4b185a..6b09e325e683 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java @@ -27,10 +27,6 @@ private SparkSQLProperties() {} // Controls whether vectorized reads are enabled public static final String VECTORIZATION_ENABLED = "spark.sql.iceberg.vectorization.enabled"; - // Controls which Parquet reader implementation to use - public static final String PARQUET_READER_TYPE = "spark.sql.iceberg.parquet.reader-type"; - public static final ParquetReaderType PARQUET_READER_TYPE_DEFAULT = ParquetReaderType.ICEBERG; - // Controls whether reading/writing timestamps without timezones is allowed @Deprecated public static final String HANDLE_TIMESTAMP_WITHOUT_TIMEZONE = @@ -110,4 +106,18 @@ private SparkSQLProperties() {} // Controls whether to report available column statistics to Spark for query optimization. public static final String REPORT_COLUMN_STATS = "spark.sql.iceberg.report-column-stats"; public static final boolean REPORT_COLUMN_STATS_DEFAULT = true; + + // Controls whether to enable async micro batch planning for session + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "spark.sql.iceberg.async-micro-batch-planning-enabled"; + public static final boolean ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT = false; + + // Controls whether adaptive split sizing is enabled + public static final String READ_ADAPTIVE_SPLIT_SIZE_ENABLED = + "spark.sql.iceberg.read.adaptive-split-size.enabled"; + + // Overrides the parallelism used for adaptive split sizing. When unset, the parallelism + // defaults to max(spark.default.parallelism, spark.sql.shuffle.partitions). + public static final String READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM = + "spark.sql.iceberg.read.adaptive-split-size.parallelism"; } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java index 20032c898a48..04ca7112c91b 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java @@ -44,6 +44,7 @@ import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CatalogUtil; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; @@ -66,7 +67,6 @@ import org.apache.iceberg.hadoop.Util; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.OutputFile; -import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.mapping.NameMappingParser; import org.apache.iceberg.relocated.com.google.common.base.Joiner; @@ -80,8 +80,6 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.source.SparkTable; import org.apache.iceberg.util.PropertyUtil; -import org.apache.iceberg.util.Tasks; -import org.apache.iceberg.util.ThreadPools; import org.apache.spark.TaskContext; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; @@ -951,15 +949,7 @@ public static List filterPartitions( } private static void deleteManifests(FileIO io, List manifests) { - if (io instanceof SupportsBulkOperations) { - ((SupportsBulkOperations) io).deleteFiles(Lists.transform(manifests, ManifestFile::path)); - } else { - Tasks.foreach(manifests) - .executeWith(ThreadPools.getWorkerPool()) - .noRetry() - .suppressFailureWhenFinished() - .run(item -> io.deleteFile(item.path())); - } + CatalogUtil.deleteFiles(io, Lists.transform(manifests, ManifestFile::path), "manifests"); } public static Dataset loadTable(SparkSession spark, Table table, long snapshotId) { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java index 8beaefc5cc8f..b7ed31c274d7 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java @@ -35,6 +35,7 @@ import org.apache.spark.sql.types.IntegerType; import org.apache.spark.sql.types.LongType; import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType; import org.apache.spark.sql.types.ShortType; import org.apache.spark.sql.types.StringType; import org.apache.spark.sql.types.StructField; @@ -155,6 +156,8 @@ public Type atomic(DataType atomic) { ((DecimalType) atomic).precision(), ((DecimalType) atomic).scale()); } else if (atomic instanceof BinaryType) { return Types.BinaryType.get(); + } else if (atomic instanceof NullType) { + return Types.UnknownType.get(); } throw new UnsupportedOperationException("Not a supported type: " + atomic.catalogString()); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java index a13cff6e99a5..6f2a4781c519 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java @@ -41,6 +41,7 @@ import org.apache.iceberg.FileFormat; import org.apache.iceberg.IsolationLevel; import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.TableUtil; @@ -157,6 +158,25 @@ public int outputSpecId() { return outputSpecId; } + public int outputSortOrderId(SparkWriteRequirements writeRequirements) { + Integer explicitId = + confParser.intConf().option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID).parseOptional(); + + if (explicitId != null) { + Preconditions.checkArgument( + table.sortOrders().containsKey(explicitId), + "Cannot use output sort order id %s because the table does not contain a sort order with that id", + explicitId); + return explicitId; + } + + if (writeRequirements.hasOrdering()) { + return table.sortOrder().orderId(); + } + + return SortOrder.unsorted().orderId(); + } + public FileFormat dataFileFormat() { String valueAsString = confParser diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java index 391cb6bae3bf..1daecb523bc7 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java @@ -59,6 +59,7 @@ private SparkWriteOptions() {} "handle-timestamp-without-timezone"; public static final String OUTPUT_SPEC_ID = "output-spec-id"; + public static final String OUTPUT_SORT_ORDER_ID = "output-sort-order-id"; public static final String OVERWRITE_MODE = "overwrite-mode"; diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java index dfb9b30be603..d33632bbbd54 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java @@ -38,6 +38,7 @@ import org.apache.spark.sql.types.MapType$; import org.apache.spark.sql.types.Metadata; import org.apache.spark.sql.types.MetadataBuilder; +import org.apache.spark.sql.types.NullType$; import org.apache.spark.sql.types.StringType$; import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType$; @@ -124,9 +125,11 @@ public DataType primitive(Type.PrimitiveType primitive) { case DECIMAL: Types.DecimalType decimal = (Types.DecimalType) primitive; return DecimalType$.MODULE$.apply(decimal.precision(), decimal.scale()); + case UNKNOWN: + return NullType$.MODULE$; default: throw new UnsupportedOperationException( - "Cannot convert unknown type to Spark: " + primitive); + "Cannot convert unsupported type to Spark: " + primitive); } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java index 65c605519d3d..6a977c0c0a2a 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java @@ -162,7 +162,8 @@ protected Dataset contentFileDS(Table table, Set snapshotIds) { "length", "0 as sequenceNumber", "partition_spec_id as partitionSpecId", - "added_snapshot_id as addedSnapshotId") + "added_snapshot_id as addedSnapshotId", + "key_metadata as keyMetadata") .dropDuplicates("path") .repartition(numShufflePartitions) // avoid adaptive execution combining tasks .as(ManifestFileBean.ENCODER); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java index 0ca73bef4d7e..37e1ec4ce788 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java @@ -134,6 +134,10 @@ private void validateSourceTable() { Preconditions.checkArgument( !sourceCatalogTable.storage().locationUri().isEmpty(), "Cannot create an Iceberg table from a source without an explicit location"); + Preconditions.checkArgument( + sourceCatalogTable.bucketSpec().isEmpty(), + "Cannot create an Iceberg table from a bucketed source table: %s", + (Object) sourceCatalogTable.bucketSpec().getOrElse(() -> null)); } protected StagingTableCatalog checkDestinationCatalog(CatalogPlugin catalog) { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java index 78662159b0bb..92bfc880ad7f 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java @@ -300,7 +300,10 @@ private DeleteOrphanFiles.Result deleteFiles(Dataset orphanFileDS) { LOG.info("Deleted {} orphan files", filesCount); - return ImmutableDeleteOrphanFiles.Result.builder().orphanFileLocations(orphanFileList).build(); + return ImmutableDeleteOrphanFiles.Result.builder() + .orphanFileLocations(orphanFileList) + .orphanFilesCount(filesCount) + .build(); } private void collectPathsForOutput( diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java index 5f4d4ec15184..e49e7326736f 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java @@ -27,6 +27,7 @@ import java.util.concurrent.ExecutorService; import java.util.function.Consumer; import java.util.stream.Collectors; +import org.apache.iceberg.ExpireSnapshots.CleanupLevel; import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; @@ -169,7 +170,7 @@ public Dataset expireFiles() { expireSnapshots.cleanExpiredMetadata(cleanExpiredMetadata); } - expireSnapshots.cleanExpiredFiles(false).commit(); + expireSnapshots.cleanupLevel(CleanupLevel.NONE).commit(); // fetch valid files after expiration TableMetadata updatedMetadata = ops.refresh(); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java index fd4639897743..599e27b71c45 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java @@ -37,6 +37,7 @@ public class ManifestFileBean implements ManifestFile, Serializable { private Integer content = null; private Long sequenceNumber = null; private Long firstRowId = null; + private byte[] keyMetadata = null; public static ManifestFileBean fromManifest(ManifestFile manifest) { ManifestFileBean bean = new ManifestFileBean(); @@ -48,6 +49,7 @@ public static ManifestFileBean fromManifest(ManifestFile manifest) { bean.setContent(manifest.content().id()); bean.setSequenceNumber(manifest.sequenceNumber()); bean.setFirstRowId(manifest.firstRowId()); + bean.setKeyMetadata(manifest.keyMetadata() == null ? null : manifest.keyMetadata().array()); return bean; } @@ -104,6 +106,14 @@ public void setFirstRowId(Long firstRowId) { this.firstRowId = firstRowId; } + public byte[] getKeyMetadata() { + return keyMetadata; + } + + public void setKeyMetadata(byte[] keyMetadata) { + this.keyMetadata = keyMetadata; + } + @Override public String path() { return path; @@ -176,7 +186,7 @@ public List partitions() { @Override public ByteBuffer keyMetadata() { - return null; + return keyMetadata == null ? null : ByteBuffer.wrap(keyMetadata); } @Override diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java index a42ce0ecf7af..2f5cc25074ea 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java @@ -31,6 +31,7 @@ import java.util.stream.Collectors; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.SortOrder; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; @@ -96,6 +97,7 @@ public class RewriteDataFilesSparkAction private boolean removeDanglingDeletes; private boolean useStartingSequenceNumber; private boolean caseSensitive; + private String branch = SnapshotRef.MAIN_BRANCH; private BinPackRewriteFilePlanner planner = null; private FileRewriteRunner runner = null; @@ -157,13 +159,24 @@ public RewriteDataFilesSparkAction filter(Expression expression) { return this; } + public RewriteDataFilesSparkAction toBranch(String targetBranch) { + Preconditions.checkArgument(targetBranch != null, "Invalid branch name: null"); + this.branch = targetBranch; + return this; + } + @Override public RewriteDataFiles.Result execute() { if (table.currentSnapshot() == null) { return EMPTY_RESULT; } - long startingSnapshotId = table.currentSnapshot().snapshotId(); + Preconditions.checkArgument( + table.snapshot(branch) != null, + "Cannot rewrite data files for branch %s: branch does not exist", + branch); + + long startingSnapshotId = table.snapshot(branch).snapshotId(); init(startingSnapshotId); @@ -230,7 +243,7 @@ private ExecutorService rewriteService() { @VisibleForTesting RewriteDataFilesCommitManager commitManager(long startingSnapshotId) { return new RewriteDataFilesCommitManager( - table, startingSnapshotId, useStartingSequenceNumber, commitSummary()); + table, startingSnapshotId, useStartingSequenceNumber, commitSummary(), branch); } private Builder doExecute( diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java index ca9d562194d2..77aadc1d5da8 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java @@ -304,7 +304,7 @@ private Result doExecuteWithPartialProgress( } private void validateAndInitOptions() { - Set validOptions = Sets.newHashSet(planner.validOptions()); + Set validOptions = Sets.newHashSet(runner.validOptions()); validOptions.addAll(VALID_OPTIONS); validOptions.addAll(planner.validOptions()); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java index d6a13bcd515d..aedb25e4a4a6 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutorService; import java.util.stream.Collectors; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; @@ -52,13 +53,12 @@ import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.Record; import org.apache.iceberg.data.avro.DataWriter; -import org.apache.iceberg.data.avro.PlannedDataReader; -import org.apache.iceberg.data.orc.GenericOrcReader; import org.apache.iceberg.data.orc.GenericOrcWriter; -import org.apache.iceberg.data.parquet.GenericParquetReaders; import org.apache.iceberg.data.parquet.GenericParquetWriter; import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedFiles; import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.DeleteSchemaUtil; import org.apache.iceberg.io.FileIO; @@ -72,7 +72,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.JobGroupInfo; import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.Tasks; import org.apache.spark.api.java.function.ForeachFunction; import org.apache.spark.api.java.function.MapFunction; import org.apache.spark.api.java.function.ReduceFunction; @@ -99,6 +101,7 @@ public class RewriteTablePathSparkAction extends BaseSparkAction tableBroadcast = null; @@ -158,6 +161,12 @@ public RewriteTablePath createFileList(boolean createFileListFlag) { return this; } + @Override + public RewriteTablePath executeWith(ExecutorService service) { + this.executorService = service; + return this; + } + @Override public Result execute() { validateInputs(); @@ -286,22 +295,30 @@ private Result rebuildMetadata() { Sets.difference(snapshotSet(endMetadata), snapshotSet(startMetadata)); // rebuild manifest-list files - RewriteResult rewriteManifestListResult = - validSnapshots.stream() - .map(snapshot -> rewriteManifestList(snapshot, endMetadata, manifestsToRewrite)) - .reduce(new RewriteResult<>(), RewriteResult::append); + Set> manifestListResults = Sets.newConcurrentHashSet(); + Tasks.foreach(validSnapshots) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + snapshot -> + manifestListResults.add( + rewriteManifestList(snapshot, endMetadata, manifestsToRewrite))); + + RewriteResult rewriteManifestListResult = new RewriteResult<>(); + manifestListResults.forEach(rewriteManifestListResult::append); // rebuild manifest files Set metaFiles = rewriteManifestListResult.toRewrite(); RewriteContentFileResult rewriteManifestResult = - rewriteManifests(deltaSnapshots, endMetadata, rewriteManifestListResult.toRewrite()); + rewriteManifests(deltaSnapshots, endMetadata, metaFiles); // rebuild position delete files Set deleteFiles = rewriteManifestResult.toRewrite().stream() .filter(e -> e instanceof DeleteFile) .map(e -> (DeleteFile) e) - .collect(Collectors.toSet()); + .collect(Collectors.toCollection(DeleteFileSet::create)); rewritePositionDeletes(deleteFiles); ImmutableRewriteTablePath.Result.Builder builder = @@ -362,6 +379,7 @@ private RewriteResult rewriteVersionFiles(TableMetadata endMetadata) { result.copyPlan().addAll(rewriteVersionFile(endMetadata, endVersionName)); List versions = endMetadata.previousFiles(); + List versionFilePaths = Lists.newArrayList(); for (int i = versions.size() - 1; i >= 0; i--) { String versionFilePath = versions.get(i).file(); if (versionFilePath.equals(startVersionName)) { @@ -371,13 +389,26 @@ private RewriteResult rewriteVersionFiles(TableMetadata endMetadata) { Preconditions.checkArgument( fileExist(versionFilePath), String.format("Version file %s doesn't exist", versionFilePath)); - TableMetadata tableMetadata = - new StaticTableOperations(versionFilePath, table.io()).current(); - - result.toRewrite().addAll(tableMetadata.snapshots()); - result.copyPlan().addAll(rewriteVersionFile(tableMetadata, versionFilePath)); + versionFilePaths.add(versionFilePath); } + Set allSnapshots = Sets.newConcurrentHashSet(); + Set> allCopyPlan = Sets.newConcurrentHashSet(); + Tasks.foreach(versionFilePaths) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + versionFilePath -> { + TableMetadata tableMetadata = + new StaticTableOperations(versionFilePath, table.io()).current(); + allSnapshots.addAll(tableMetadata.snapshots()); + allCopyPlan.addAll(rewriteVersionFile(tableMetadata, versionFilePath)); + }); + + result.toRewrite().addAll(allSnapshots); + result.copyPlan().addAll(allCopyPlan); + return result; } @@ -719,32 +750,10 @@ private ForeachFunction rewritePositionDelete( private static CloseableIterable positionDeletesReader( InputFile inputFile, FileFormat format, PartitionSpec spec) { - Schema deleteSchema = DeleteSchemaUtil.posDeleteReadSchema(spec.schema()); - switch (format) { - case AVRO: - return Avro.read(inputFile) - .project(deleteSchema) - .reuseContainers() - .createReaderFunc(fileSchema -> PlannedDataReader.create(deleteSchema)) - .build(); - - case PARQUET: - return Parquet.read(inputFile) - .project(deleteSchema) - .reuseContainers() - .createReaderFunc( - fileSchema -> GenericParquetReaders.buildReader(deleteSchema, fileSchema)) - .build(); - - case ORC: - return ORC.read(inputFile) - .project(deleteSchema) - .createReaderFunc(fileSchema -> GenericOrcReader.buildReader(deleteSchema, fileSchema)) - .build(); - - default: - throw new UnsupportedOperationException("Unsupported file format: " + format); - } + return FormatModelRegistry.readBuilder(format, Record.class, inputFile) + .project(DeleteSchemaUtil.posDeleteReadSchema(spec.schema())) + .reuseContainers() + .build(); } private static PositionDeleteWriter positionDeletesWriter( @@ -754,30 +763,37 @@ private static PositionDeleteWriter positionDeletesWriter( StructLike partition, Schema rowSchema) throws IOException { - switch (format) { - case AVRO: - return Avro.writeDeletes(outputFile) - .createWriterFunc(DataWriter::create) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - case PARQUET: - return Parquet.writeDeletes(outputFile) - .createWriterFunc(GenericParquetWriter::create) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - case ORC: - return ORC.writeDeletes(outputFile) - .createWriterFunc(GenericOrcWriter::buildWriter) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - default: - throw new UnsupportedOperationException("Unsupported file format: " + format); + if (rowSchema == null) { + return FormatModelRegistry.positionDeleteWriteBuilder( + format, EncryptedFiles.plainAsEncryptedOutput(outputFile)) + .partition(partition) + .spec(spec) + .build(); + } else { + return switch (format) { + case AVRO -> + Avro.writeDeletes(outputFile) + .createWriterFunc(DataWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(outputFile) + .createWriterFunc(GenericParquetWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(outputFile) + .createWriterFunc(GenericOrcWriter::buildWriter) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + default -> throw new UnsupportedOperationException("Unsupported file format: " + format); + }; } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java index 5f7f408cb099..043b63870ca9 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java @@ -124,7 +124,16 @@ private SnapshotTable.Result doExecute() { StagedSparkTable stagedTable = stageDestTable(); Table icebergTable = stagedTable.table(); - // TODO: Check the dest table location does not overlap with the source table location + String sourceTableLocation = sourceTableLocation(); + String stagedTableLocation = icebergTable.location(); + Preconditions.checkArgument( + !sourceTableLocation.equals(stagedTableLocation) + && !stagedTableLocation.startsWith(sourceTableLocation + "/") + && !sourceTableLocation.startsWith(stagedTableLocation + "/"), + "Cannot create a snapshot at location %s because it would overlap with source table location %s. " + + "Overlapping snapshot and source would mix table files.", + stagedTableLocation, + sourceTableLocation); boolean threw = true; try { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java index b1c5a5c0901a..346abaee5e63 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java @@ -47,10 +47,14 @@ import org.apache.spark.sql.connector.expressions.SortOrder; import org.apache.spark.sql.connector.write.RequiresDistributionAndOrdering; import org.apache.spark.sql.execution.datasources.v2.DistributionAndOrderingUtils$; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import scala.Option; abstract class SparkShufflingFileRewriteRunner extends SparkDataFileRewriteRunner { + private static final Logger LOG = LoggerFactory.getLogger(SparkShufflingFileRewriteRunner.class); + /** * The number of shuffle partitions to use for each output file. By default, this file rewriter * assumes each shuffle partition would become a separate output file. Attempting to generate @@ -119,6 +123,17 @@ public void doRewrite(String groupId, RewriteFileGroup fileGroup) { spec(fileGroup.outputSpecId()), fileGroup.expectedOutputFiles())); + org.apache.iceberg.SortOrder sortOrderInJobSpec = sortOrder(); + + org.apache.iceberg.SortOrder maybeMatchingTableSortOrder = + SortOrderUtil.findTableSortOrder(table(), sortOrder()); + + if (sortOrderInJobSpec.isSorted() && maybeMatchingTableSortOrder.isUnsorted()) { + LOG.warn( + "Sort order specified for job {} doesn't match any table sort orders, rewritten files will not be marked as sorted in the manifest files", + Spark3Util.describe(sortOrderInJobSpec)); + } + sortedDF .write() .format("iceberg") @@ -126,6 +141,7 @@ public void doRewrite(String groupId, RewriteFileGroup fileGroup) { .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, fileGroup.maxOutputFileSize()) .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") .option(SparkWriteOptions.OUTPUT_SPEC_ID, fileGroup.outputSpecId()) + .option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, maybeMatchingTableSortOrder.orderId()) .mode("append") .save(groupId); } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java index 9a6811d1a46e..fa8d765541da 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java @@ -176,6 +176,13 @@ private List validZOrderColNames( Set identityPartitionFieldIds = table.spec().identitySourceIds(); boolean caseSensitive = SparkUtil.caseSensitive(spark); + Preconditions.checkArgument( + caseSensitive + ? schema.findField(Z_COLUMN) == null + : schema.caseInsensitiveFindField(Z_COLUMN) == null, + "Cannot zorder because the table has a column named '%s', which conflicts with Iceberg's internal Z-order column name", + Z_COLUMN); + List validZOrderColNames = Lists.newArrayList(); for (String colName : inputZOrderColNames) { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java index db359fdd62fc..bf80dcb10b30 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java @@ -24,6 +24,8 @@ import java.nio.ByteBuffer; import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import org.apache.iceberg.util.DateTimeUtil; import org.apache.iceberg.util.ZOrderByteUtils; import org.apache.spark.sql.Column; import org.apache.spark.sql.expressions.UserDefinedFunction; @@ -40,6 +42,7 @@ import org.apache.spark.sql.types.LongType; import org.apache.spark.sql.types.ShortType; import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.TimestampNTZType; import org.apache.spark.sql.types.TimestampType; import scala.collection.JavaConverters; import scala.collection.Seq; @@ -180,6 +183,29 @@ value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) return udf; } + private UserDefinedFunction timestampNtzToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (LocalDateTime value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + long micros = DateTimeUtil.microsFromTimestamp(value); + return ZOrderByteUtils.longToOrderedBytes( + micros, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("TIMESTAMP_NTZ_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + private UserDefinedFunction floatToOrderedBytesUDF() { int position = inputCol; UserDefinedFunction udf = @@ -309,6 +335,8 @@ Column sortedLexicographically(Column column, DataType type) { return booleanToOrderedBytesUDF().apply(column); } else if (type instanceof TimestampType) { return longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType)); + } else if (type instanceof TimestampNTZType) { + return timestampNtzToOrderedBytesUDF().apply(column); } else if (type instanceof DateType) { return longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType)); } else { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java index d74a76f94e87..2a2eef198b76 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java @@ -31,6 +31,7 @@ import org.apache.parquet.schema.Type.Repetition; import org.apache.spark.sql.types.ArrayType; import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; import org.apache.spark.sql.types.MapType; import org.apache.spark.sql.types.Metadata; import org.apache.spark.sql.types.StructField; @@ -181,21 +182,27 @@ private static T visitField( private static List visitFields( StructType struct, GroupType group, ParquetWithSparkSchemaVisitor visitor) { - StructField[] sFields = struct.fields(); - Preconditions.checkArgument( - sFields.length == group.getFieldCount(), "Structs do not match: %s and %s", struct, group); List results = Lists.newArrayListWithExpectedSize(group.getFieldCount()); - for (int i = 0; i < sFields.length; i += 1) { - Type field = group.getFields().get(i); - StructField sField = sFields[i]; - Preconditions.checkArgument( - field.getName().equals(AvroSchemaUtil.makeCompatibleName(sField.name())), - "Structs do not match: field %s != %s", - field.getName(), - sField.name()); - results.add(visitField(sField, field, visitor)); + + int fieldIndex = 0; + for (StructField sField : struct.fields()) { + if (sField.dataType() != DataTypes.NullType) { + Type field = group.getFields().get(fieldIndex); + Preconditions.checkArgument( + field.getName().equals(AvroSchemaUtil.makeCompatibleName(sField.name())), + "Structs do not match: field %s != %s", + field.getName(), + sField.name()); + results.add(visitField(sField, field, visitor)); + + fieldIndex += 1; + } } + // All the group fields should have been visited + Preconditions.checkArgument( + fieldIndex == group.getFieldCount(), "Structs do not match: %s and %s", struct, group); + return results; } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java index 04dfd46a1891..186439c58c06 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java @@ -32,6 +32,7 @@ import org.apache.iceberg.avro.ValueWriter; import org.apache.iceberg.avro.ValueWriters; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.types.ByteType; import org.apache.spark.sql.types.DataType; @@ -46,6 +47,10 @@ public SparkAvroWriter(StructType dsSchema) { this.dsSchema = dsSchema; } + public SparkAvroWriter(org.apache.iceberg.Schema icebergSchema, StructType dsSchema) { + this(dsSchema != null ? dsSchema : SparkSchemaUtil.convert(icebergSchema)); + } + @Override @SuppressWarnings("unchecked") public void setSchema(Schema schema) { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java index 6b799e677bf4..6fc8849c82b2 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java @@ -20,6 +20,8 @@ import java.io.Serializable; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; import org.apache.iceberg.FieldMetrics; @@ -77,7 +79,7 @@ public OrcValueWriter record( TypeDescription record, List names, List> fields) { - return new InternalRowWriter(fields, record.getChildren()); + return new InternalRowWriter(fields, iStruct, record.getChildren()); } @Override @@ -133,12 +135,16 @@ public OrcValueWriter primitive(Type.PrimitiveType iPrimitive, TypeDescriptio private static class InternalRowWriter extends GenericOrcWriters.StructWriter { private final List> fieldGetters; - InternalRowWriter(List> writers, List orcTypes) { - super(writers); + InternalRowWriter( + List> writers, Types.StructType iStruct, List orcTypes) { + super(iStruct, writers); this.fieldGetters = Lists.newArrayListWithExpectedSize(orcTypes.size()); - for (TypeDescription orcType : orcTypes) { - fieldGetters.add(createFieldGetter(orcType)); + Map idToType = + orcTypes.stream().collect(Collectors.toMap(ORCSchemaUtil::fieldId, s -> s)); + + for (Types.NestedField iField : iStruct.fields()) { + fieldGetters.add(createFieldGetter(idToType.get(iField.fieldId()))); } } @@ -149,6 +155,11 @@ protected Object get(InternalRow struct, int index) { } static FieldGetter createFieldGetter(TypeDescription fieldType) { + // In the case of an UnknownType + if (fieldType == null) { + return (row, ordinal) -> null; + } + final FieldGetter fieldGetter; switch (fieldType.getCategory()) { case BOOLEAN: diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java index ffda57be2bc3..a1ed6c66f337 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java @@ -26,6 +26,8 @@ import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; +import java.util.stream.IntStream; +import org.apache.iceberg.Schema; import org.apache.iceberg.parquet.ParquetValueReaders.ReusableEntry; import org.apache.iceberg.parquet.ParquetValueWriter; import org.apache.iceberg.parquet.ParquetValueWriters; @@ -34,6 +36,7 @@ import org.apache.iceberg.parquet.ParquetValueWriters.RepeatedWriter; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.DecimalUtil; import org.apache.iceberg.util.UUIDUtil; @@ -53,6 +56,7 @@ import org.apache.spark.sql.types.DataType; import org.apache.spark.sql.types.Decimal; import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType; import org.apache.spark.sql.types.ShortType; import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; @@ -61,10 +65,18 @@ public class SparkParquetWriters { private SparkParquetWriters() {} - @SuppressWarnings("unchecked") public static ParquetValueWriter buildWriter(StructType dfSchema, MessageType type) { + return buildWriter(null, type, dfSchema); + } + + @SuppressWarnings("unchecked") + public static ParquetValueWriter buildWriter( + Schema icebergSchema, MessageType type, StructType dfSchema) { return (ParquetValueWriter) - ParquetWithSparkSchemaVisitor.visit(dfSchema, type, new WriteBuilder(type)); + ParquetWithSparkSchemaVisitor.visit( + dfSchema != null ? dfSchema : SparkSchemaUtil.convert(icebergSchema), + type, + new WriteBuilder(type)); } private static class WriteBuilder extends ParquetWithSparkSchemaVisitor> { @@ -84,15 +96,18 @@ public ParquetValueWriter message( public ParquetValueWriter struct( StructType sStruct, GroupType struct, List> fieldWriters) { List fields = struct.getFields(); - StructField[] sparkFields = sStruct.fields(); List> writers = Lists.newArrayListWithExpectedSize(fieldWriters.size()); - List sparkTypes = Lists.newArrayList(); for (int i = 0; i < fields.size(); i += 1) { writers.add(newOption(struct.getType(i), fieldWriters.get(i))); - sparkTypes.add(sparkFields[i].dataType()); } - return new InternalRowWriter(writers, sparkTypes); + StructField[] sFields = sStruct.fields(); + DataType[] types = new DataType[sFields.length]; + for (int i = 0; i < sFields.length; i += 1) { + types[i] = sFields[i].dataType(); + } + + return new InternalRowWriter(writers, types); } @Override @@ -556,14 +571,33 @@ public Map.Entry next() { private static class InternalRowWriter extends ParquetValueWriters.StructWriter { private final DataType[] types; - private InternalRowWriter(List> writers, List types) { - super(writers); - this.types = types.toArray(new DataType[0]); + private InternalRowWriter(List> writers, DataType[] types) { + super(writerToFieldIndex(types, writers.size()), writers); + this.types = types; } @Override protected Object get(InternalRow struct, int index) { return struct.get(index, types[index]); } + + /** Returns a mapping from writer index to field index, skipping Unknown columns. */ + private static int[] writerToFieldIndex(DataType[] types, int numWriters) { + if (null == types) { + return IntStream.rangeClosed(0, numWriters).toArray(); + } + + // value writer index to record field index + int[] indexes = new int[numWriters]; + int writerIndex = 0; + for (int pos = 0; pos < types.length; pos += 1) { + if (!(types[pos] instanceof NullType)) { + indexes[writerIndex] = pos; + writerIndex += 1; + } + } + + return indexes; + } } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java deleted file mode 100644 index 81b7d83a7077..000000000000 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java +++ /dev/null @@ -1,140 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.io.IOException; -import org.apache.comet.CometSchemaImporter; -import org.apache.comet.parquet.AbstractColumnReader; -import org.apache.comet.parquet.ColumnReader; -import org.apache.comet.parquet.TypeUtil; -import org.apache.comet.parquet.Utils; -import org.apache.comet.shaded.arrow.memory.RootAllocator; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.spark.sql.types.DataType; -import org.apache.spark.sql.types.Metadata; -import org.apache.spark.sql.types.StructField; -import org.apache.spark.sql.vectorized.ColumnVector; - -class CometColumnReader implements VectorizedReader { - // use the Comet default batch size - public static final int DEFAULT_BATCH_SIZE = 8192; - - private final ColumnDescriptor descriptor; - private final DataType sparkType; - - // The delegated ColumnReader from Comet side - private AbstractColumnReader delegate; - private boolean initialized = false; - private int batchSize = DEFAULT_BATCH_SIZE; - private CometSchemaImporter importer; - - CometColumnReader(DataType sparkType, ColumnDescriptor descriptor) { - this.sparkType = sparkType; - this.descriptor = descriptor; - } - - CometColumnReader(Types.NestedField field) { - DataType dataType = SparkSchemaUtil.convert(field.type()); - StructField structField = new StructField(field.name(), dataType, false, Metadata.empty()); - this.sparkType = dataType; - this.descriptor = TypeUtil.convertToParquet(structField); - } - - public AbstractColumnReader delegate() { - return delegate; - } - - void setDelegate(AbstractColumnReader delegate) { - this.delegate = delegate; - } - - void setInitialized(boolean initialized) { - this.initialized = initialized; - } - - public int batchSize() { - return batchSize; - } - - /** - * This method is to initialized/reset the CometColumnReader. This needs to be called for each row - * group after readNextRowGroup, so a new dictionary encoding can be set for each of the new row - * groups. - */ - public void reset() { - if (importer != null) { - importer.close(); - } - - if (delegate != null) { - delegate.close(); - } - - this.importer = new CometSchemaImporter(new RootAllocator()); - this.delegate = Utils.getColumnReader(sparkType, descriptor, importer, batchSize, false, false); - this.initialized = true; - } - - public ColumnDescriptor descriptor() { - return descriptor; - } - - /** Returns the Spark data type for this column. */ - public DataType sparkType() { - return sparkType; - } - - /** - * Set the page reader to be 'pageReader'. - * - *

    NOTE: this should be called before reading a new Parquet column chunk, and after {@link - * CometColumnReader#reset} is called. - */ - public void setPageReader(PageReader pageReader) throws IOException { - Preconditions.checkState(initialized, "Invalid state: 'reset' should be called first"); - ((ColumnReader) delegate).setPageReader(pageReader); - } - - @Override - public void close() { - // close resources on native side - if (importer != null) { - importer.close(); - } - - if (delegate != null) { - delegate.close(); - } - } - - @Override - public void setBatchSize(int size) { - this.batchSize = size; - } - - @Override - public ColumnVector read(ColumnVector reuse, int numRowsToRead) { - throw new UnsupportedOperationException("Not supported"); - } -} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java deleted file mode 100644 index 3d3e9aca24de..000000000000 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java +++ /dev/null @@ -1,137 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.util.List; -import java.util.Map; -import org.apache.comet.parquet.AbstractColumnReader; -import org.apache.comet.parquet.BatchReader; -import org.apache.iceberg.Schema; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.parquet.column.page.PageReadStore; -import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData; -import org.apache.parquet.hadoop.metadata.ColumnPath; -import org.apache.spark.sql.vectorized.ColumnVector; -import org.apache.spark.sql.vectorized.ColumnarBatch; - -/** - * {@link VectorizedReader} that returns Spark's {@link ColumnarBatch} to support Spark's vectorized - * read path. The {@link ColumnarBatch} returned is created by passing in the Arrow vectors - * populated via delegated read calls to {@link CometColumnReader VectorReader(s)}. - */ -@SuppressWarnings("checkstyle:VisibilityModifier") -class CometColumnarBatchReader implements VectorizedReader { - - private final CometColumnReader[] readers; - - // The delegated BatchReader on the Comet side does the real work of loading a batch of rows. - // The Comet BatchReader contains an array of ColumnReader. There is no need to explicitly call - // ColumnReader.readBatch; instead, BatchReader.nextBatch will be called, which underneath calls - // ColumnReader.readBatch. The only exception is DeleteColumnReader, because at the time of - // calling BatchReader.nextBatch, the isDeleted value is not yet available, so - // DeleteColumnReader.readBatch must be called explicitly later, after the isDeleted value is - // available. - private final BatchReader delegate; - - CometColumnarBatchReader(List> readers, Schema schema) { - this.readers = - readers.stream().map(CometColumnReader.class::cast).toArray(CometColumnReader[]::new); - - AbstractColumnReader[] abstractColumnReaders = new AbstractColumnReader[readers.size()]; - this.delegate = new BatchReader(abstractColumnReaders); - delegate.setSparkSchema(SparkSchemaUtil.convert(schema)); - } - - @Override - public void setRowGroupInfo( - PageReadStore pageStore, Map metaData) { - for (int i = 0; i < readers.length; i++) { - try { - if (!(readers[i] instanceof CometConstantColumnReader) - && !(readers[i] instanceof CometPositionColumnReader) - && !(readers[i] instanceof CometDeleteColumnReader)) { - readers[i].reset(); - readers[i].setPageReader(pageStore.getPageReader(readers[i].descriptor())); - } - } catch (IOException e) { - throw new UncheckedIOException("Failed to setRowGroupInfo for Comet vectorization", e); - } - } - - for (int i = 0; i < readers.length; i++) { - delegate.getColumnReaders()[i] = this.readers[i].delegate(); - } - } - - @Override - public final ColumnarBatch read(ColumnarBatch reuse, int numRowsToRead) { - return new ColumnBatchLoader(numRowsToRead).loadDataToColumnBatch(); - } - - @Override - public void setBatchSize(int batchSize) { - for (CometColumnReader reader : readers) { - if (reader != null) { - reader.setBatchSize(batchSize); - } - } - } - - @Override - public void close() { - for (CometColumnReader reader : readers) { - if (reader != null) { - reader.close(); - } - } - } - - private class ColumnBatchLoader { - private final int batchSize; - - ColumnBatchLoader(int numRowsToRead) { - Preconditions.checkArgument( - numRowsToRead > 0, "Invalid number of rows to read: %s", numRowsToRead); - this.batchSize = numRowsToRead; - } - - ColumnarBatch loadDataToColumnBatch() { - ColumnVector[] vectors = readDataToColumnVectors(); - - ColumnarBatch batch = new ColumnarBatch(vectors); - batch.setNumRows(batchSize); - return batch; - } - - ColumnVector[] readDataToColumnVectors() { - ColumnVector[] columnVectors = new ColumnVector[readers.length]; - // Fetch rows for all readers in the delegate - delegate.nextBatch(batchSize); - for (int i = 0; i < readers.length; i++) { - columnVectors[i] = readers[i].delegate().currentBatch(); - } - - return columnVectors; - } - } -} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java deleted file mode 100644 index c665002e8f66..000000000000 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java +++ /dev/null @@ -1,65 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.math.BigDecimal; -import java.nio.ByteBuffer; -import org.apache.comet.parquet.ConstantColumnReader; -import org.apache.iceberg.types.Types; -import org.apache.spark.sql.types.DataType; -import org.apache.spark.sql.types.DataTypes; -import org.apache.spark.sql.types.Decimal; -import org.apache.spark.sql.types.DecimalType; -import org.apache.spark.unsafe.types.UTF8String; - -class CometConstantColumnReader extends CometColumnReader { - - CometConstantColumnReader(T value, Types.NestedField field) { - super(field); - // use delegate to set constant value on the native side to be consumed by native execution. - setDelegate( - new ConstantColumnReader(sparkType(), descriptor(), convertToSparkValue(value), false)); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private Object convertToSparkValue(T value) { - DataType dataType = sparkType(); - // Match the value to Spark internal type if necessary - if (dataType == DataTypes.StringType && value instanceof String) { - // the internal type for StringType is UTF8String - return UTF8String.fromString((String) value); - } else if (dataType instanceof DecimalType && value instanceof BigDecimal) { - // the internal type for DecimalType is Decimal - return Decimal.apply((BigDecimal) value); - } else if (dataType == DataTypes.BinaryType && value instanceof ByteBuffer) { - // the internal type for DecimalType is byte[] - // Iceberg default value should always use HeapBufferBuffer, so calling ByteBuffer.array() - // should be safe. - return ((java.nio.ByteBuffer) value).array(); - } else { - return value; - } - } -} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java deleted file mode 100644 index 26219014f777..000000000000 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java +++ /dev/null @@ -1,74 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import org.apache.comet.parquet.MetadataColumnReader; -import org.apache.comet.parquet.Native; -import org.apache.comet.parquet.TypeUtil; -import org.apache.comet.vector.CometVector; -import org.apache.iceberg.types.Types; -import org.apache.spark.sql.types.DataTypes; -import org.apache.spark.sql.types.Metadata; -import org.apache.spark.sql.types.StructField; - -class CometDeleteColumnReader extends CometColumnReader { - CometDeleteColumnReader(Types.NestedField field) { - super(field); - setDelegate(new DeleteColumnReader()); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private static class DeleteColumnReader extends MetadataColumnReader { - private final CometDeletedColumnVector deletedVector; - - DeleteColumnReader() { - this(new boolean[0]); - } - - DeleteColumnReader(boolean[] isDeleted) { - super( - DataTypes.BooleanType, - TypeUtil.convertToParquet( - new StructField("_deleted", DataTypes.BooleanType, false, Metadata.empty())), - false /* useDecimal128 = false */, - false /* isConstant = false */); - this.deletedVector = new CometDeletedColumnVector(isDeleted); - } - - @Override - public void readBatch(int total) { - Native.resetBatch(nativeHandle); - // set isDeleted on the native side to be consumed by native execution - Native.setIsDeleted(nativeHandle, deletedVector.isDeleted()); - - super.readBatch(total); - } - - @Override - public CometVector currentBatch() { - return deletedVector; - } - } -} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java deleted file mode 100644 index 1949a717982a..000000000000 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java +++ /dev/null @@ -1,62 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import org.apache.comet.parquet.MetadataColumnReader; -import org.apache.comet.parquet.Native; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.spark.sql.types.DataTypes; - -class CometPositionColumnReader extends CometColumnReader { - CometPositionColumnReader(Types.NestedField field) { - super(field); - setDelegate(new PositionColumnReader(descriptor())); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private static class PositionColumnReader extends MetadataColumnReader { - /** The current position value of the column that are used to initialize this column reader. */ - private long position; - - PositionColumnReader(ColumnDescriptor descriptor) { - super( - DataTypes.LongType, - descriptor, - false /* useDecimal128 = false */, - false /* isConstant */); - } - - @Override - public void readBatch(int total) { - Native.resetBatch(nativeHandle); - // set position on the native side to be consumed by native execution - Native.setPosition(nativeHandle, position, total); - position += total; - - super.readBatch(total); - } - } -} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java deleted file mode 100644 index 779dc240d4f6..000000000000 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java +++ /dev/null @@ -1,138 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.IntStream; -import org.apache.iceberg.MetadataColumns; -import org.apache.iceberg.Schema; -import org.apache.iceberg.parquet.TypeWithSchemaVisitor; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; -import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.schema.GroupType; -import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -class CometVectorizedReaderBuilder extends TypeWithSchemaVisitor> { - - private final MessageType parquetSchema; - private final Schema icebergSchema; - private final Map idToConstant; - private final Function>, VectorizedReader> readerFactory; - - CometVectorizedReaderBuilder( - Schema expectedSchema, - MessageType parquetSchema, - Map idToConstant, - Function>, VectorizedReader> readerFactory) { - this.parquetSchema = parquetSchema; - this.icebergSchema = expectedSchema; - this.idToConstant = idToConstant; - this.readerFactory = readerFactory; - } - - @Override - public VectorizedReader message( - Types.StructType expected, MessageType message, List> fieldReaders) { - GroupType groupType = message.asGroupType(); - Map> readersById = Maps.newHashMap(); - List fields = groupType.getFields(); - - IntStream.range(0, fields.size()) - .filter(pos -> fields.get(pos).getId() != null) - .forEach(pos -> readersById.put(fields.get(pos).getId().intValue(), fieldReaders.get(pos))); - - List icebergFields = - expected != null ? expected.fields() : ImmutableList.of(); - - List> reorderedFields = - Lists.newArrayListWithExpectedSize(icebergFields.size()); - - for (Types.NestedField field : icebergFields) { - int id = field.fieldId(); - VectorizedReader reader = readersById.get(id); - if (idToConstant.containsKey(id)) { - CometConstantColumnReader constantReader = - new CometConstantColumnReader<>(idToConstant.get(id), field); - reorderedFields.add(constantReader); - } else if (id == MetadataColumns.ROW_POSITION.fieldId()) { - reorderedFields.add(new CometPositionColumnReader(field)); - } else if (id == MetadataColumns.IS_DELETED.fieldId()) { - CometColumnReader deleteReader = new CometDeleteColumnReader<>(field); - reorderedFields.add(deleteReader); - } else if (reader != null) { - reorderedFields.add(reader); - } else if (field.initialDefault() != null) { - CometColumnReader constantReader = - new CometConstantColumnReader<>(field.initialDefault(), field); - reorderedFields.add(constantReader); - } else if (field.isOptional()) { - CometColumnReader constantReader = new CometConstantColumnReader<>(null, field); - reorderedFields.add(constantReader); - } else { - throw new IllegalArgumentException( - String.format("Missing required field: %s", field.name())); - } - } - return vectorizedReader(reorderedFields); - } - - protected VectorizedReader vectorizedReader(List> reorderedFields) { - return readerFactory.apply(reorderedFields); - } - - @Override - public VectorizedReader struct( - Types.StructType expected, GroupType groupType, List> fieldReaders) { - if (expected != null) { - throw new UnsupportedOperationException( - "Vectorized reads are not supported yet for struct fields"); - } - return null; - } - - @Override - public VectorizedReader primitive( - org.apache.iceberg.types.Type.PrimitiveType expected, PrimitiveType primitive) { - - if (primitive.getId() == null) { - return null; - } - int parquetFieldId = primitive.getId().intValue(); - ColumnDescriptor desc = parquetSchema.getColumnDescription(currentPath()); - // Nested types not yet supported for vectorized reads - if (desc.getMaxRepetitionLevel() > 0) { - return null; - } - Types.NestedField icebergField = icebergSchema.findField(parquetFieldId); - if (icebergField == null) { - return null; - } - - return new CometColumnReader(SparkSchemaUtil.convert(icebergField.type()), desc); - } -} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java index 76e0ee381143..4f324239881e 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java @@ -462,7 +462,11 @@ public ColumnVector convert( } else if (field.equals(MetadataColumns.ROW_POSITION)) { fieldVectors.add(new RowPositionColumnVector(batchOffsetInFile)); } else if (field.equals(MetadataColumns.IS_DELETED)) { - fieldVectors.add(new ConstantColumnVector(field.type(), batchSize, false)); + DeletedColumnVector deletedVector = new DeletedColumnVector(field.type()); + deletedVector.setValue(new boolean[batchSize]); + fieldVectors.add(deletedVector); + } else if (field.type().equals(Types.UnknownType.get())) { + fieldVectors.add(new ConstantColumnVector(field.type(), batchSize, null)); } else { fieldVectors.add( fieldConverters diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java index 8e25e81a05b2..a80249ee7c36 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java @@ -75,19 +75,6 @@ public static ColumnarBatchReader buildReader( return buildReader(expectedSchema, fileSchema, idToConstant, ArrowAllocation.rootAllocator()); } - public static CometColumnarBatchReader buildCometReader( - Schema expectedSchema, MessageType fileSchema, Map idToConstant) { - return (CometColumnarBatchReader) - TypeWithSchemaVisitor.visit( - expectedSchema.asStruct(), - fileSchema, - new CometVectorizedReaderBuilder( - expectedSchema, - fileSchema, - idToConstant, - readers -> new CometColumnarBatchReader(readers, expectedSchema))); - } - // enables unsafe memory access to avoid costly checks to see if index is within bounds // as long as it is not configured explicitly (see BoundsChecking in Arrow) private static void enableUnsafeMemoryAccess() { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java index 2c3ce7418e08..a47e75415336 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java @@ -18,10 +18,8 @@ */ package org.apache.iceberg.spark.procedures; -import java.util.Optional; import org.apache.iceberg.Snapshot; import org.apache.iceberg.exceptions.ValidationException; -import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; import org.apache.iceberg.util.WapUtil; import org.apache.spark.sql.catalyst.InternalRow; @@ -92,21 +90,26 @@ public InternalRow[] call(InternalRow args) { return modifyIcebergTable( tableIdent, table -> { - Optional wapSnapshot = - Optional.ofNullable( - Iterables.find( - table.snapshots(), - snapshot -> wapId.equals(WapUtil.stagedWapId(snapshot)), - null)); - if (!wapSnapshot.isPresent()) { + Snapshot matchingSnap = null; + for (Snapshot snap : table.snapshots()) { + if (wapId.equals(WapUtil.stagedWapId(snap))) { + if (matchingSnap != null) { + throw new ValidationException( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID '%s'", + wapId); + } else { + matchingSnap = snap; + } + } + } + + if (matchingSnap == null) { throw new ValidationException("Cannot apply unknown WAP ID '%s'", wapId); } - long wapSnapshotId = wapSnapshot.get().snapshotId(); + long wapSnapshotId = matchingSnap.snapshotId(); table.manageSnapshots().cherrypick(wapSnapshotId).commit(); - Snapshot currentSnapshot = table.currentSnapshot(); - InternalRow outputRow = newInternalRow(wapSnapshotId, currentSnapshot.snapshotId()); return new InternalRow[] {outputRow}; }); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java index f1958a5a172f..60ed7b84d45e 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.actions.RewriteDataFiles; @@ -30,6 +31,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.ExtendedParser; +import org.apache.iceberg.spark.actions.RewriteDataFilesSparkAction; import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.catalog.Identifier; @@ -57,10 +59,12 @@ class RewriteDataFilesProcedure extends BaseProcedure { ProcedureParameter.optional("options", STRING_MAP); private static final ProcedureParameter WHERE_PARAM = ProcedureParameter.optional("where", DataTypes.StringType); + private static final ProcedureParameter BRANCH_PARAM = + ProcedureParameter.optional("branch", DataTypes.StringType); private static final ProcedureParameter[] PARAMETERS = new ProcedureParameter[] { - TABLE_PARAM, STRATEGY_PARAM, SORT_ORDER_PARAM, OPTIONS_PARAM, WHERE_PARAM + TABLE_PARAM, STRATEGY_PARAM, SORT_ORDER_PARAM, OPTIONS_PARAM, WHERE_PARAM, BRANCH_PARAM }; // counts are not nullable since the action result is never null @@ -109,17 +113,29 @@ public InternalRow[] call(InternalRow args) { String sortOrderString = input.asString(SORT_ORDER_PARAM, null); Map options = input.asStringMap(OPTIONS_PARAM, ImmutableMap.of()); String where = input.asString(WHERE_PARAM, null); + String branchParam = input.asString(BRANCH_PARAM, null); + if (branchParam == null) { + branchParam = loadSparkTable(tableIdent).branch(); + if (branchParam == null) { + branchParam = SnapshotRef.MAIN_BRANCH; + } + } + String branch = branchParam; return modifyIcebergTable( tableIdent, table -> { - RewriteDataFiles action = actions().rewriteDataFiles(table).options(options); + RewriteDataFilesSparkAction action = + (RewriteDataFilesSparkAction) + actions().rewriteDataFiles(table).options(options).toBranch(branch); if (strategy != null || sortOrderString != null) { - action = checkAndApplyStrategy(action, strategy, sortOrderString, table.schema()); + action = + (RewriteDataFilesSparkAction) + checkAndApplyStrategy(action, strategy, sortOrderString, table.schema()); } - action = checkAndApplyFilter(action, where, tableIdent); + action = (RewriteDataFilesSparkAction) checkAndApplyFilter(action, where, tableIdent); RewriteDataFiles.Result result = action.execute(); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..3e442f9917d4 --- /dev/null +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java @@ -0,0 +1,543 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.connector.read.streaming.ReadAllAvailable; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class AsyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(AsyncSparkMicroBatchPlanner.class); + private static final int PLAN_FILES_CACHE_MAX_SIZE = 10; + private static final long QUEUE_POLL_TIMEOUT_MS = 100L; // 100 ms + + private final long minQueuedFiles; + private final long minQueuedRows; + + // Cache for planFiles results to handle duplicate calls + private final Cache, List> planFilesCache; + + // Queue to buffer pre-fetched file scan tasks + private final LinkedBlockingDeque> queue; + + // Background executor for async operations + private final ScheduledExecutorService executor; + + // Error tracking + private volatile Throwable refreshFailedThrowable; + private volatile Throwable fillQueueFailedThrowable; + + // Tracking queue state + private final AtomicLong queuedFileCount = new AtomicLong(0); + private final AtomicLong queuedRowCount = new AtomicLong(0); + private Snapshot lastQueuedSnapshot; + private boolean stopped; + + // Cap for Trigger.AvailableNow - don't process beyond this offset + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + /** + * This class manages a queue of FileScanTask + StreamingOffset. On creation, it starts up an + * asynchronous polling process which populates the queue when a new snapshot arrives or the + * minimum amount of queued data is too low. + * + *

    Note: this will capture the state of the table when snapshots are added to the queue. If a + * snapshot is expired after being added to the queue, the job will still process it. + */ + AsyncSparkMicroBatchPlanner( + Table table, + SparkReadConf readConf, + StreamingOffset initialOffset, + StreamingOffset maybeEndOffset, + StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.minQueuedFiles = readConf().maxFilesPerMicroBatch(); + this.minQueuedRows = readConf().maxRecordsPerMicroBatch(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + this.planFilesCache = Caffeine.newBuilder().maximumSize(PLAN_FILES_CACHE_MAX_SIZE).build(); + this.queue = new LinkedBlockingDeque<>(); + + table().refresh(); + + // Synchronously add data to the queue to meet our initial constraints. + // For Trigger.AvailableNow, constructor-time preload is normally initialized from + // latestOffset(...) with no explicit end offset, so bounded preload must stop at + // Trigger.AvailableNow snapshot. + fillQueue(initialOffset, maybeEndOffset); + + this.executor = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread thread = new Thread(r, "iceberg-async-planner-" + table().name()); + thread.setDaemon(true); + return thread; + }); + // Schedule table refresh at configured interval + long pollingIntervalMs = readConf().streamingSnapshotPollingIntervalMs(); + this.executor.scheduleWithFixedDelay( + this::refreshAndTrapException, pollingIntervalMs, pollingIntervalMs, TimeUnit.MILLISECONDS); + // Schedule queue fill to run frequently (use polling interval for tests, cap at 100ms for + // production) + long queueFillIntervalMs = Math.min(QUEUE_POLL_TIMEOUT_MS, pollingIntervalMs); + executor.scheduleWithFixedDelay( + () -> fillQueueAndTrapException(lastQueuedSnapshot), + 0, + queueFillIntervalMs, + TimeUnit.MILLISECONDS); + + LOG.info( + "Started AsyncSparkMicroBatchPlanner for {} from initialOffset: {}", + table().name(), + initialOffset); + } + + @Override + public synchronized void stop() { + Preconditions.checkArgument( + !stopped, "AsyncSparkMicroBatchPlanner for {} was already stopped", table().name()); + stopped = true; + LOG.info("Stopping AsyncSparkMicroBatchPlanner for table: {}", table().name()); + executor.shutdownNow(); + boolean terminated = false; + try { + terminated = + executor.awaitTermination( + readConf().streamingSnapshotPollingIntervalMs() * 2, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + // Restore interrupt status + Thread.currentThread().interrupt(); + } + LOG.info("AsyncSparkMicroBatchPlanner for table: {}, stopped: {}", table().name(), terminated); + } + + @Override + public void close() { + stop(); + } + + /** + * Spark can call this multiple times; it should produce the same answer every time. + * + * @param startOffset the starting offset of this microbatch, position is inclusive + * @param endOffset the end offset of this microbatch, position is exclusive + * @return the list of files to scan between these offsets + */ + @Override + public synchronized List planFiles( + StreamingOffset startOffset, StreamingOffset endOffset) { + return planFilesCache.get( + Pair.of(startOffset, endOffset), + key -> { + LOG.info( + "running planFiles for {}, startOffset: {}, endOffset: {}", + table().name(), + startOffset, + endOffset); + List result = new LinkedList<>(); + Pair elem; + StreamingOffset currentOffset; + boolean shouldTerminate = false; + long filesInPlan = 0; + long rowsInPlan = 0; + + do { + try { + elem = queue.pollFirst(QUEUE_POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while polling queue", e); + } + + if (elem != null) { + currentOffset = elem.first(); + LOG.debug("planFiles consumed: {}", currentOffset); + FileScanTask currentTask = elem.second(); + filesInPlan += 1; + long elemRows = currentTask.file().recordCount(); + rowsInPlan += elemRows; + queuedFileCount.decrementAndGet(); + queuedRowCount.addAndGet(-elemRows); + result.add(currentTask); + + // try to peek at the next entry of the queue and see if we should stop + Pair nextElem = queue.peekFirst(); + boolean endOffsetPeek = false; + if (nextElem != null) { + endOffsetPeek = endOffset.equals(nextElem.first()); + } + // end offset may be synthetic and not exist in the queue + boolean endOffsetSynthetic = + currentOffset.snapshotId() == endOffset.snapshotId() + && (currentOffset.position() + 1) == endOffset.position(); + shouldTerminate = endOffsetPeek || endOffsetSynthetic; + } else { + LOG.trace("planFiles hasn't reached {}, waiting", endOffset); + } + } while (!shouldTerminate + && refreshFailedThrowable == null + && fillQueueFailedThrowable == null); + + if (refreshFailedThrowable != null) { + throw new RuntimeException("Table refresh failed", refreshFailedThrowable); + } + + if (fillQueueFailedThrowable != null) { + throw new RuntimeException("Queue filling failed", fillQueueFailedThrowable); + } + + LOG.info( + "completed planFiles for {}, startOffset: {}, endOffset: {}, files: {}, rows: {}", + table().name(), + startOffset, + endOffset, + filesInPlan, + rowsInPlan); + return result; + }); + } + + /** + * This needs to be non destructive on the queue as spark could call this multiple times. Each + * time, depending on the table state it could return something different + * + * @param startOffset the starting offset of the next microbatch + * @param limit a limit for how many files/bytes/rows the next microbatch should include + * @return The end offset to use for the next microbatch, null signals that no data is available + */ + @Override + public synchronized StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + LOG.info( + "running latestOffset for {}, startOffset: {}, limit: {}", + table().name(), + startOffset, + limit); + + if (table().currentSnapshot() == null) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() is null"); + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < readConf().streamFromTimestamp()) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() < fromTimestamp"); + return StreamingOffset.START_OFFSET; + } + + // if any exceptions were encountered in the background process, raise them here + if (refreshFailedThrowable != null) { + throw new RuntimeException(refreshFailedThrowable); + } + if (fillQueueFailedThrowable != null) { + throw new RuntimeException(fillQueueFailedThrowable); + } + + // if we want to read all available we don't need to scan files, just snapshots + if (limit instanceof ReadAllAvailable) { + // If Trigger.AvailableNow cap is set, return it directly + if (this.lastOffsetForTriggerAvailableNow != null) { + return this.lastOffsetForTriggerAvailableNow; + } + Snapshot lastValidSnapshot = table().snapshot(startOffset.snapshotId()); + Snapshot nextValidSnapshot; + do { + nextValidSnapshot = nextValidSnapshot(lastValidSnapshot); + if (nextValidSnapshot != null) { + lastValidSnapshot = nextValidSnapshot; + } + } while (nextValidSnapshot != null); + return new StreamingOffset( + lastValidSnapshot.snapshotId(), + MicroBatchUtils.addedFilesCount(table(), lastValidSnapshot), + false); + } + + return computeLimitedOffset(limit); + } + + private StreamingOffset computeLimitedOffset(ReadLimit limit) { + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long rowsSeen = 0; + long filesSeen = 0; + LOG.debug( + "latestOffset queue status, queuedFiles: {}, queuedRows: {}", + queuedFileCount.get(), + queuedRowCount.get()); + + List> queueSnapshot = Lists.newArrayList(queue); + Pair queueTail = + queueSnapshot.isEmpty() ? null : queueSnapshot.get(queueSnapshot.size() - 1); + + for (int i = 0; i < queueSnapshot.size(); i++) { + Pair elem = queueSnapshot.get(i); + long fileRows = elem.second().file().recordCount(); + + // Hard limit on files - stop BEFORE exceeding + if (filesSeen + 1 > unpackedLimits.getMaxFiles()) { + if (filesSeen == 0) { + return null; + } + LOG.debug( + "latestOffset hit file limit at {}, rows: {}, files: {}", + elem.first(), + rowsSeen, + filesSeen); + return elem.first(); + } + + // Soft limit on rows - include file FIRST, then check + rowsSeen += fileRows; + filesSeen += 1; + + // Check if we've hit the row limit after including this file + if (rowsSeen >= unpackedLimits.getMaxRows()) { + if (filesSeen == 1 && rowsSeen > unpackedLimits.getMaxRows()) { + LOG.warn( + "File {} at offset {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + elem.second().file().location(), + elem.first(), + fileRows, + unpackedLimits.getMaxRows()); + } + // Return the offset of the NEXT element (or synthesize tail+1) + if (i + 1 < queueSnapshot.size()) { + LOG.debug( + "latestOffset hit row limit at {}, rows: {}, files: {}", + queueSnapshot.get(i + 1).first(), + rowsSeen, + filesSeen); + return queueSnapshot.get(i + 1).first(); + } else { + // This is the last element - return tail+1 + StreamingOffset current = elem.first(); + StreamingOffset result = + new StreamingOffset( + current.snapshotId(), current.position() + 1, current.shouldScanAllFiles()); + LOG.debug( + "latestOffset hit row limit at tail {}, rows: {}, files: {}", + result, + rowsSeen, + filesSeen); + return result; + } + } + } + + // if we got here there aren't enough files to exceed our limits + if (queueTail != null) { + StreamingOffset tailOffset = queueTail.first(); + // we have to increment the position by 1 since we want to include the tail in the read and + // position is non-inclusive + StreamingOffset latestOffset = + new StreamingOffset( + tailOffset.snapshotId(), tailOffset.position() + 1, tailOffset.shouldScanAllFiles()); + LOG.debug("latestOffset returning all queued data {}", latestOffset); + return latestOffset; + } + + // if we got here the queue is empty + LOG.debug("latestOffset no data, returning null"); + return null; + } + + // Background task wrapper that traps exceptions + private void refreshAndTrapException() { + try { + table().refresh(); + } catch (Throwable t) { + LOG.error("Failed to refresh table {}", table().name(), t); + refreshFailedThrowable = t; + } + } + + // Background task wrapper that traps exceptions + private void fillQueueAndTrapException(Snapshot snapshot) { + try { + fillQueue(snapshot); + } catch (Throwable t) { + LOG.error("Failed to fill queue for table {}", table().name(), t); + fillQueueFailedThrowable = t; + } + } + + /** Generate a MicroBatch based on input parameters and add to the queue */ + private void addMicroBatchToQueue( + Snapshot snapshot, long startFileIndex, long endFileIndex, boolean shouldScanAllFile) { + LOG.info("Adding MicroBatch for snapshot: {} to the queue", snapshot.snapshotId()); + MicroBatches.MicroBatch microBatch = + MicroBatches.from(snapshot, table().io()) + .caseSensitive(readConf().caseSensitive()) + .specsById(table().specs()) + .generate(startFileIndex, endFileIndex, Long.MAX_VALUE, shouldScanAllFile); + + long position = startFileIndex; + for (FileScanTask task : microBatch.tasks()) { + Pair elem = + Pair.of(new StreamingOffset(microBatch.snapshotId(), position, shouldScanAllFile), task); + queuedFileCount.incrementAndGet(); + queuedRowCount.addAndGet(task.file().recordCount()); + queue.addLast(elem); + position += 1; + } + if (LOG.isDebugEnabled()) { + StringBuilder sb = new StringBuilder("\n"); + for (Pair elem : queue) { + sb.append(elem.first()).append("\n"); + } + LOG.debug(sb.toString()); + } + lastQueuedSnapshot = snapshot; + } + + private void fillQueue(StreamingOffset fromOffset, StreamingOffset toOffset) { + LOG.debug("filling queue from {}, to: {}", fromOffset, toOffset); + Snapshot currentSnapshot = table().snapshot(fromOffset.snapshotId()); + // this could be a partial snapshot so add it outside the loop + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + fromOffset.position(), + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + fromOffset.shouldScanAllFiles()); + } + if (toOffset != null) { + if (currentSnapshot != null) { + while (currentSnapshot.snapshotId() != toOffset.snapshotId()) { + currentSnapshot = nextValidSnapshot(currentSnapshot); + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + false); + } else { + break; + } + } + } + // toOffset snapshot already added in loop when currentSnapshot == toOffset + } else { + fillQueueInitialBuffer(currentSnapshot); + } + } + + private void fillQueueInitialBuffer(Snapshot startSnapshot) { + // toOffset is null - fill initial buffer to prevent queue starvation before background + // thread starts. Use configured limits to avoid loading all snapshots + // (which could cause OOM on tables with thousands of snapshots). + long targetRows = readConf().asyncQueuePreloadRowLimit(); + long targetFiles = readConf().asyncQueuePreloadFileLimit(); + + Snapshot preloadEndSnapshot = initialPreloadEndSnapshot(); + if (preloadEndSnapshot == null) { + return; // Empty table + } + + // START_OFFSET case: initialize using nextValidSnapshot which respects timestamp filtering + Snapshot current = startSnapshot; + if (current == null) { + current = nextValidSnapshot(null); + if (current != null) { + addMicroBatchToQueue(current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } + } + + // Continue loading more snapshots within safety limits + if (current != null) { + while ((queuedRowCount.get() < targetRows || queuedFileCount.get() < targetFiles) + && current.snapshotId() != preloadEndSnapshot.snapshotId()) { + current = nextValidSnapshot(current); + if (current != null) { + addMicroBatchToQueue( + current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } else { + break; + } + } + } + } + + private Snapshot initialPreloadEndSnapshot() { + if (lastOffsetForTriggerAvailableNow != null) { + return table().snapshot(lastOffsetForTriggerAvailableNow.snapshotId()); + } + + return table().currentSnapshot(); + } + + @VisibleForTesting + static boolean reachedAvailableNowCap( + Snapshot readFrom, StreamingOffset lastOffsetForTriggerAvailableNow) { + return lastOffsetForTriggerAvailableNow != null + && readFrom != null + && readFrom.snapshotId() == lastOffsetForTriggerAvailableNow.snapshotId(); + } + + /** Try to populate the queue with data from unread snapshots */ + private void fillQueue(Snapshot readFrom) { + // Don't add beyond cap for Trigger.AvailableNow + if (reachedAvailableNowCap(readFrom, lastOffsetForTriggerAvailableNow)) { + LOG.debug( + "Reached cap snapshot {}, not adding more", + this.lastOffsetForTriggerAvailableNow.snapshotId()); + return; + } + + if ((queuedRowCount.get() > minQueuedRows) || (queuedFileCount.get() > minQueuedFiles)) { + // we have enough data buffered, check back shortly + LOG.debug( + "Buffer is full, {} > {} or {} > {}", + queuedRowCount.get(), + minQueuedRows, + queuedFileCount.get(), + minQueuedFiles); + } else { + // add an entire snapshot to the queue + Snapshot nextValidSnapshot = nextValidSnapshot(readFrom); + if (nextValidSnapshot != null) { + addMicroBatchToQueue( + nextValidSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), nextValidSnapshot), + false); + } else { + LOG.debug("No snapshots ready to be read"); + } + } + } +} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java index ff30f29aeae6..c1b2a5873730 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java @@ -19,7 +19,6 @@ package org.apache.iceberg.spark.source; import java.util.Map; -import java.util.Set; import javax.annotation.Nonnull; import org.apache.iceberg.FileFormat; import org.apache.iceberg.MetadataColumns; @@ -29,21 +28,17 @@ import org.apache.iceberg.Table; import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.OrcBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.data.vectorized.ColumnVectorWithFilter; import org.apache.iceberg.spark.data.vectorized.ColumnarBatchUtil; import org.apache.iceberg.spark.data.vectorized.UpdatableDeletedColumnVector; -import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; -import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; -import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.Pair; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.vectorized.ColumnVector; @@ -55,6 +50,7 @@ abstract class BaseBatchReader extends BaseReader taskGroup, Schema tableSchema, Schema expectedSchema, @@ -63,7 +59,13 @@ abstract class BaseBatchReader extends BaseReader newBatchIterable( Expression residual, Map idToConstant, @Nonnull SparkDeleteFilter deleteFilter) { - CloseableIterable iterable; - switch (format) { - case PARQUET: - iterable = - newParquetIterable( - inputFile, start, length, residual, idToConstant, deleteFilter.requiredSchema()); - break; - case ORC: - iterable = newOrcIterable(inputFile, start, length, residual, idToConstant); - break; - default: - throw new UnsupportedOperationException( - "Format: " + format + " not supported for batched reads"); + ReadBuilder readBuilder = + FormatModelRegistry.readBuilder(format, ColumnarBatch.class, inputFile); + + if (parquetConf != null) { + readBuilder = readBuilder.recordsPerBatch(parquetConf.batchSize()); + } else if (orcConf != null) { + readBuilder = readBuilder.recordsPerBatch(orcConf.batchSize()); } - return CloseableIterable.transform(iterable, new BatchDeleteFilter(deleteFilter)::filterBatch); - } + CloseableIterable iterable = + readBuilder + .project(deleteFilter.requiredSchema()) + .idToConstant(idToConstant) + .split(start, length) + .filter(residual) + .caseSensitive(caseSensitive()) + // Spark eagerly consumes the batches. So the underlying memory allocated could be + // reused without worrying about subsequent reads clobbering over each other. This + // improves read performance as every batch read doesn't have to pay the cost of + // allocating memory. + .reuseContainers() + .withNameMapping(nameMapping()) + .build(); - private CloseableIterable newParquetIterable( - InputFile inputFile, - long start, - long length, - Expression residual, - Map idToConstant, - Schema requiredSchema) { - return Parquet.read(inputFile) - .project(requiredSchema) - .split(start, length) - .createBatchedReaderFunc( - fileSchema -> { - if (parquetConf.readerType() == ParquetReaderType.COMET) { - return VectorizedSparkParquetReaders.buildCometReader( - requiredSchema, fileSchema, idToConstant); - } else { - return VectorizedSparkParquetReaders.buildReader( - requiredSchema, fileSchema, idToConstant); - } - }) - .recordsPerBatch(parquetConf.batchSize()) - .filter(residual) - .caseSensitive(caseSensitive()) - // Spark eagerly consumes the batches. So the underlying memory allocated could be reused - // without worrying about subsequent reads clobbering over each other. This improves - // read performance as every batch read doesn't have to pay the cost of allocating memory. - .reuseContainers() - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newOrcIterable( - InputFile inputFile, - long start, - long length, - Expression residual, - Map idToConstant) { - Set constantFieldIds = idToConstant.keySet(); - Set metadataFieldIds = MetadataColumns.metadataFieldIds(); - Sets.SetView constantAndMetadataFieldIds = - Sets.union(constantFieldIds, metadataFieldIds); - Schema schemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot(expectedSchema(), constantAndMetadataFieldIds); - - return ORC.read(inputFile) - .project(schemaWithoutConstantAndMetadataFields) - .split(start, length) - .createBatchedReaderFunc( - fileSchema -> - VectorizedSparkOrcReaders.buildReader(expectedSchema(), fileSchema, idToConstant)) - .recordsPerBatch(orcConf.batchSize()) - .filter(residual) - .caseSensitive(caseSensitive()) - .withNameMapping(nameMapping()) - .build(); + return CloseableIterable.transform(iterable, new BatchDeleteFilter(deleteFilter)::filterBatch); } @VisibleForTesting diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java index 150d4bf28174..0333f1e45dd4 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java @@ -42,14 +42,12 @@ import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.data.DeleteLoader; import org.apache.iceberg.deletes.DeleteCounter; -import org.apache.iceberg.encryption.EncryptedFiles; -import org.apache.iceberg.encryption.EncryptedInputFile; +import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.mapping.NameMappingParser; -import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkExecutorCache; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkUtil; @@ -69,6 +67,7 @@ abstract class BaseReader implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(BaseReader.class); private final Table table; + private final EncryptingFileIO fileIO; private final Schema tableSchema; private final Schema expectedSchema; private final boolean caseSensitive; @@ -85,12 +84,14 @@ abstract class BaseReader implements Closeable { BaseReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { this.table = table; + this.fileIO = EncryptingFileIO.combine(fileIO, table().encryption()); this.taskGroup = taskGroup; this.tasks = taskGroup.tasks().iterator(); this.currentIterator = CloseableIterator.empty(); @@ -182,25 +183,14 @@ protected InputFile getInputFile(String location) { private Map inputFiles() { if (lazyInputFiles == null) { - Stream encryptedFiles = - taskGroup.tasks().stream().flatMap(this::referencedFiles).map(this::toEncryptedInputFile); - - // decrypt with the batch call to avoid multiple RPCs to a key server, if possible - Iterable decryptedFiles = table.encryption().decrypt(encryptedFiles::iterator); - - Map files = Maps.newHashMapWithExpectedSize(taskGroup.tasks().size()); - decryptedFiles.forEach(decrypted -> files.putIfAbsent(decrypted.location(), decrypted)); - this.lazyInputFiles = ImmutableMap.copyOf(files); + this.lazyInputFiles = + fileIO.bulkDecrypt( + () -> taskGroup.tasks().stream().flatMap(this::referencedFiles).iterator()); } return lazyInputFiles; } - private EncryptedInputFile toEncryptedInputFile(ContentFile file) { - InputFile inputFile = table.io().newInputFile(file.location()); - return EncryptedFiles.encryptedInput(inputFile, file.keyMetadata()); - } - protected Map constantsMap(ContentScanTask task, Schema readSchema) { if (readSchema.findField(MetadataColumns.PARTITION_COLUMN_ID) != null) { StructType partitionType = Partitioning.partitionType(table); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java index c12931e786b1..a7016e3b09b7 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java @@ -20,34 +20,35 @@ import java.util.Map; import org.apache.iceberg.FileFormat; -import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.ScanTask; import org.apache.iceberg.ScanTaskGroup; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; -import org.apache.iceberg.avro.Avro; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.spark.data.SparkOrcReader; -import org.apache.iceberg.spark.data.SparkParquetReaders; -import org.apache.iceberg.spark.data.SparkPlannedAvroReader; -import org.apache.iceberg.types.TypeUtil; import org.apache.spark.sql.catalyst.InternalRow; abstract class BaseRowReader extends BaseReader { BaseRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); } protected CloseableIterable newIterable( @@ -58,69 +59,15 @@ protected CloseableIterable newIterable( Expression residual, Schema projection, Map idToConstant) { - switch (format) { - case PARQUET: - return newParquetIterable(file, start, length, residual, projection, idToConstant); - - case AVRO: - return newAvroIterable(file, start, length, projection, idToConstant); - - case ORC: - return newOrcIterable(file, start, length, residual, projection, idToConstant); - - default: - throw new UnsupportedOperationException("Cannot read unknown format: " + format); - } - } - - private CloseableIterable newAvroIterable( - InputFile file, long start, long length, Schema projection, Map idToConstant) { - return Avro.read(file) - .reuseContainers() + ReadBuilder reader = + FormatModelRegistry.readBuilder(format, InternalRow.class, file); + return reader .project(projection) - .split(start, length) - .createResolvingReader(schema -> SparkPlannedAvroReader.create(schema, idToConstant)) - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newParquetIterable( - InputFile file, - long start, - long length, - Expression residual, - Schema readSchema, - Map idToConstant) { - return Parquet.read(file) + .idToConstant(idToConstant) .reuseContainers() .split(start, length) - .project(readSchema) - .createReaderFunc( - fileSchema -> SparkParquetReaders.buildReader(readSchema, fileSchema, idToConstant)) - .filter(residual) .caseSensitive(caseSensitive()) - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newOrcIterable( - InputFile file, - long start, - long length, - Expression residual, - Schema readSchema, - Map idToConstant) { - Schema readSchemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - readSchema, Sets.union(idToConstant.keySet(), MetadataColumns.metadataFieldIds())); - - return ORC.read(file) - .project(readSchemaWithoutConstantAndMetadataFields) - .split(start, length) - .createReaderFunc( - readOrcSchema -> new SparkOrcReader(readSchema, readOrcSchema, idToConstant)) .filter(residual) - .caseSensitive(caseSensitive()) .withNameMapping(nameMapping()) .build(); } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..9298c2bbdfcc --- /dev/null +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Locale; +import org.apache.iceberg.DataOperations; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.CompositeReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadMaxFiles; +import org.apache.spark.sql.connector.read.streaming.ReadMaxRows; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class BaseSparkMicroBatchPlanner implements SparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(BaseSparkMicroBatchPlanner.class); + private final Table table; + private final SparkReadConf readConf; + + BaseSparkMicroBatchPlanner(Table table, SparkReadConf readConf) { + this.table = table; + this.readConf = readConf; + } + + protected Table table() { + return table; + } + + protected SparkReadConf readConf() { + return readConf; + } + + protected boolean shouldProcess(Snapshot snapshot) { + String op = snapshot.operation(); + switch (op) { + case DataOperations.APPEND: + return true; + case DataOperations.REPLACE: + return false; + case DataOperations.DELETE: + Preconditions.checkState( + readConf.streamingSkipDeleteSnapshots(), + "Cannot process delete snapshot: %s, to ignore deletes, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS); + return false; + case DataOperations.OVERWRITE: + Preconditions.checkState( + readConf.streamingSkipOverwriteSnapshots(), + "Cannot process overwrite snapshot: %s, to ignore overwrites, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS); + return false; + default: + throw new IllegalStateException( + String.format( + "Cannot process unknown snapshot operation: %s (snapshot id %s)", + op.toLowerCase(Locale.ROOT), snapshot.snapshotId())); + } + } + + /** + * Get the next snapshot skipping over rewrite and delete snapshots. Async must handle nulls. + * + * @param curSnapshot the current snapshot + * @return the next valid snapshot (not a rewrite or delete snapshot), returns null if all + * remaining snapshots should be skipped. + */ + protected Snapshot nextValidSnapshot(Snapshot curSnapshot) { + Snapshot nextSnapshot; + // if there were no valid snapshots, check for an initialOffset again + if (curSnapshot == null) { + StreamingOffset startingOffset = + MicroBatchUtils.determineStartingOffset(table, readConf.streamFromTimestamp()); + LOG.debug("determineStartingOffset picked startingOffset: {}", startingOffset); + if (StreamingOffset.START_OFFSET.equals(startingOffset)) { + return null; + } + nextSnapshot = table.snapshot(startingOffset.snapshotId()); + } else { + if (curSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, curSnapshot.snapshotId()); + } + // skip over rewrite and delete snapshots + while (!shouldProcess(nextSnapshot)) { + LOG.debug("Skipping snapshot: {}", nextSnapshot); + // if the currentSnapShot was also the mostRecentSnapshot then break + // avoids snapshotAfter throwing exception since there are no more snapshots to process + if (nextSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, nextSnapshot.snapshotId()); + } + return nextSnapshot; + } + + static class UnpackedLimits { + private long maxRows = Integer.MAX_VALUE; + private long maxFiles = Integer.MAX_VALUE; + + UnpackedLimits(ReadLimit limit) { + if (limit instanceof CompositeReadLimit) { + ReadLimit[] compositeLimits = ((CompositeReadLimit) limit).getReadLimits(); + for (ReadLimit individualLimit : compositeLimits) { + if (individualLimit instanceof ReadMaxRows) { + ReadMaxRows readMaxRows = (ReadMaxRows) individualLimit; + this.maxRows = Math.min(this.maxRows, readMaxRows.maxRows()); + } else if (individualLimit instanceof ReadMaxFiles) { + ReadMaxFiles readMaxFiles = (ReadMaxFiles) individualLimit; + this.maxFiles = Math.min(this.maxFiles, readMaxFiles.maxFiles()); + } + } + } else if (limit instanceof ReadMaxRows) { + this.maxRows = ((ReadMaxRows) limit).maxRows(); + } else if (limit instanceof ReadMaxFiles) { + this.maxFiles = ((ReadMaxFiles) limit).maxFiles(); + } + } + + public long getMaxRows() { + return maxRows; + } + + public long getMaxFiles() { + return maxFiles; + } + } +} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java index 9ec0f885775f..3dcfb604ea46 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java @@ -26,6 +26,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.OrcBatchReadConf; @@ -53,6 +54,7 @@ class BatchDataReader extends BaseBatchReader OrcBatchReadConf orcBatchReadConf) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -64,6 +66,7 @@ class BatchDataReader extends BaseBatchReader BatchDataReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -73,6 +76,7 @@ class BatchDataReader extends BaseBatchReader boolean cacheDeleteFilesOnExecutors) { super( table, + fileIO, taskGroup, tableSchema, expectedSchema, diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java index b8fa129f6a44..365747565165 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java @@ -35,6 +35,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.SnapshotUtil; @@ -51,6 +52,7 @@ class ChangelogRowReader extends BaseRowReader ChangelogRowReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -60,6 +62,7 @@ class ChangelogRowReader extends BaseRowReader ChangelogRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -67,6 +70,7 @@ class ChangelogRowReader extends BaseRowReader boolean cacheDeleteFilesOnExecutors) { super( table, + fileIO, taskGroup, tableSchema, ChangelogUtil.dropChangelogMetadata(expectedSchema), diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java index e1292647b797..96dd99ea64e3 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java @@ -25,6 +25,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.spark.rdd.InputFileBlockHolder; import org.apache.spark.sql.catalyst.InternalRow; @@ -32,11 +33,19 @@ public class EqualityDeleteRowReader extends RowDataReader { public EqualityDeleteRowReader( CombinedScanTask task, Table table, + FileIO fileIO, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { - super(table, task, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + super( + table, + fileIO, + task, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); } @Override diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java index 1afde6bb5180..869e80bbd32b 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java @@ -116,13 +116,13 @@ public Table getTable(StructType schema, Transform[] partitioning, Map PositionDeletesRowReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -58,14 +57,20 @@ class PositionDeletesRowReader extends BaseRowReader PositionDeletesRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { - super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); int numSplits = taskGroup.tasks().size(); LOG.debug("Reading {} position delete file split(s) for table {}", numSplits, table.name()); @@ -88,12 +93,16 @@ protected CloseableIterator open(PositionDeletesScanTask task) { InputFile inputFile = getInputFile(task.file().location()); Preconditions.checkNotNull(inputFile, "Could not find InputFile associated with %s", task); - // select out constant fields when pushing down filter to row reader + // Retain predicates on non-constant fields for row reader filter Map idToConstant = constantsMap(task, expectedSchema()); - Set nonConstantFieldIds = nonConstantFieldIds(idToConstant); + int[] nonConstantFieldIds = + expectedSchema().idToName().keySet().stream() + .filter(id -> !idToConstant.containsKey(id)) + .mapToInt(Integer::intValue) + .toArray(); Expression residualWithoutConstants = ExpressionUtil.extractByIdInclusive( - task.residual(), expectedSchema(), caseSensitive(), Ints.toArray(nonConstantFieldIds)); + task.residual(), expectedSchema(), caseSensitive(), nonConstantFieldIds); if (ContentFileUtil.isDV(task.file())) { return new DVIterator(inputFile, task.file(), expectedSchema(), idToConstant); @@ -109,12 +118,4 @@ protected CloseableIterator open(PositionDeletesScanTask task) { idToConstant) .iterator(); } - - private Set nonConstantFieldIds(Map idToConstant) { - Set fields = expectedSchema().idToName().keySet(); - return fields.stream() - .filter(id -> expectedSchema().findField(id).type().isPrimitiveType()) - .filter(id -> !idToConstant.containsKey(id)) - .collect(Collectors.toSet()); - } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java index b2b3c7856389..08aa44f71041 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java @@ -28,6 +28,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.source.metrics.TaskNumDeletes; @@ -48,6 +49,7 @@ class RowDataReader extends BaseRowReader implements PartitionRead RowDataReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -57,6 +59,7 @@ class RowDataReader extends BaseRowReader implements PartitionRead RowDataReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -64,7 +67,13 @@ class RowDataReader extends BaseRowReader implements PartitionRead boolean cacheDeleteFilesOnExecutors) { super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); numSplits = taskGroup.tasks().size(); LOG.debug("Reading {} file split(s) for table {}", numSplits, table.name()); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java new file mode 100644 index 000000000000..2a359110c810 --- /dev/null +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java @@ -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. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.function.Function; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.hadoop.HadoopConfigurable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.util.SerializableSupplier; +import org.apache.spark.util.KnownSizeEstimation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class provides a serializable {@link FileIO} with a known size estimate. Spark calls its + * {@link org.apache.spark.util.SizeEstimator} class when broadcasting variables and this can be an + * expensive operation, so providing a known size estimate allows that operation to be skipped. + * + *

    This class also implements {@link AutoCloseable} to avoid leaking resources upon broadcasting. + * Broadcast variables are destroyed and cleaned up on the driver and executors once they are + * garbage collected on the driver. The implementation ensures only resources used by copies of the + * main {@link FileIO} are released. + */ +class SerializableFileIOWithSize + implements FileIO, HadoopConfigurable, KnownSizeEstimation, AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(SerializableFileIOWithSize.class); + private static final long SIZE_ESTIMATE = 32_768L; + private final transient Object serializationMarker; + private final FileIO fileIO; + + private SerializableFileIOWithSize(FileIO fileIO) { + this.fileIO = fileIO; + this.serializationMarker = new Object(); + } + + @Override + public long estimatedSize() { + return SIZE_ESTIMATE; + } + + public static FileIO wrap(FileIO fileIO) { + return new SerializableFileIOWithSize(fileIO); + } + + @Override + public void close() { + if (null == serializationMarker) { + LOG.debug("Closing FileIO"); + fileIO.close(); + } + } + + @Override + public InputFile newInputFile(String path) { + return fileIO.newInputFile(path); + } + + @Override + public InputFile newInputFile(String path, long length) { + return fileIO.newInputFile(path, length); + } + + @Override + public OutputFile newOutputFile(String path) { + return fileIO.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + fileIO.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + fileIO.initialize(properties); + } + + @Override + public Map properties() { + return fileIO.properties(); + } + + @Override + public void serializeConfWith( + Function> confSerializer) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.serializeConfWith(confSerializer); + } + } + + @Override + public void setConf(Configuration conf) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.setConf(conf); + } + } + + @Override + public Configuration getConf() { + if (fileIO instanceof HadoopConfigurable hadoopConfigurable) { + return hadoopConfigurable.getConf(); + } + + return null; + } +} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java index 0626d0b43985..2109936c96b9 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Objects; +import java.util.function.Supplier; import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.MetadataColumns; @@ -28,14 +29,13 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.spark.ImmutableOrcBatchReadConf; import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; import org.apache.iceberg.spark.OrcBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.SparkReadConf; import org.apache.iceberg.spark.SparkUtil; -import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; @@ -47,6 +47,7 @@ class SparkBatch implements Batch { private final JavaSparkContext sparkContext; private final Table table; + private final Supplier fileIO; private final String branch; private final SparkReadConf readConf; private final Types.StructType groupingKeyType; @@ -61,6 +62,7 @@ class SparkBatch implements Batch { SparkBatch( JavaSparkContext sparkContext, Table table, + Supplier fileIO, SparkReadConf readConf, Types.StructType groupingKeyType, List> taskGroups, @@ -68,6 +70,7 @@ class SparkBatch implements Batch { int scanHashCode) { this.sparkContext = sparkContext; this.table = table; + this.fileIO = fileIO; this.branch = readConf.branch(); this.readConf = readConf; this.groupingKeyType = groupingKeyType; @@ -85,6 +88,8 @@ public InputPartition[] planInputPartitions() { // broadcast the table metadata as input partitions will be sent to executors Broadcast tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + Broadcast fileIOBroadcast = + sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); String expectedSchemaString = SchemaParser.toJson(expectedSchema); String[][] locations = computePreferredLocations(); @@ -96,6 +101,7 @@ public InputPartition[] planInputPartitions() { groupingKeyType, taskGroups.get(index), tableBroadcast, + fileIOBroadcast, branch, expectedSchemaString, caseSensitive, @@ -108,7 +114,7 @@ public InputPartition[] planInputPartitions() { private String[][] computePreferredLocations() { if (localityEnabled) { - return SparkPlanningUtil.fetchBlockLocations(table.io(), taskGroups); + return SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups); } else if (executorCacheLocalityEnabled) { List executorLocations = SparkUtil.executorLocations(); @@ -122,11 +128,8 @@ private String[][] computePreferredLocations() { @Override public PartitionReaderFactory createReaderFactory() { - if (useCometBatchReads()) { - return new SparkColumnarReaderFactory(parquetBatchReadConf(ParquetReaderType.COMET)); - - } else if (useParquetBatchReads()) { - return new SparkColumnarReaderFactory(parquetBatchReadConf(ParquetReaderType.ICEBERG)); + if (useParquetBatchReads()) { + return new SparkColumnarReaderFactory(parquetBatchReadConf()); } else if (useOrcBatchReads()) { return new SparkColumnarReaderFactory(orcBatchReadConf()); @@ -136,11 +139,8 @@ public PartitionReaderFactory createReaderFactory() { } } - private ParquetBatchReadConf parquetBatchReadConf(ParquetReaderType readerType) { - return ImmutableParquetBatchReadConf.builder() - .batchSize(readConf.parquetBatchSize()) - .readerType(readerType) - .build(); + private ParquetBatchReadConf parquetBatchReadConf() { + return ImmutableParquetBatchReadConf.builder().batchSize(readConf.parquetBatchSize()).build(); } private OrcBatchReadConf orcBatchReadConf() { @@ -175,20 +175,6 @@ private boolean supportsParquetBatchReads(Types.NestedField field) { return field.type().isPrimitiveType() || MetadataColumns.isMetadataColumn(field.fieldId()); } - private boolean useCometBatchReads() { - return readConf.parquetVectorizationEnabled() - && readConf.parquetReaderType() == ParquetReaderType.COMET - && expectedSchema.columns().stream().allMatch(this::supportsCometBatchReads) - && taskGroups.stream().allMatch(this::supportsParquetBatchReads); - } - - private boolean supportsCometBatchReads(Types.NestedField field) { - return field.type().isPrimitiveType() - && !field.type().typeId().equals(Type.TypeID.UUID) - && field.fieldId() != MetadataColumns.ROW_ID.fieldId() - && field.fieldId() != MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(); - } - // conditions for using ORC batch reads: // - ORC vectorization is enabled // - all tasks are of type FileScanTask and read only ORC files with no delete files diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java index 55ea137ca1b0..eba0431e3adf 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java @@ -106,6 +106,7 @@ public Batch toBatch() { return new SparkBatch( sparkContext, table, + null != scan ? scan.fileIO() : table::io, readConf, EMPTY_GROUPING_KEY_TYPE, taskGroups(), diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java index 93b27c566c9f..8d5972ac7acf 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java @@ -21,9 +21,9 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.CatalogUtil; import org.apache.iceberg.ContentFile; import org.apache.iceberg.exceptions.NotFoundException; -import org.apache.iceberg.io.BulkDeletionFailureException; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -84,34 +84,13 @@ private static String taskInfo() { */ public static void deleteFiles(String context, FileIO io, List> files) { List paths = Lists.transform(files, ContentFile::location); - deletePaths(context, io, paths); - } - - private static void deletePaths(String context, FileIO io, List paths) { if (io instanceof SupportsBulkOperations) { - SupportsBulkOperations bulkIO = (SupportsBulkOperations) io; - bulkDelete(context, bulkIO, paths); + CatalogUtil.deleteFiles(io, paths, ""); } else { delete(context, io, paths); } } - private static void bulkDelete(String context, SupportsBulkOperations io, List paths) { - try { - io.deleteFiles(paths); - LOG.info("Deleted {} file(s) using bulk deletes ({})", paths.size(), context); - - } catch (BulkDeletionFailureException e) { - int deletedFilesCount = paths.size() - e.numberFailedObjects(); - LOG.warn( - "Deleted only {} of {} file(s) using bulk deletes ({})", - deletedFilesCount, - paths.size(), - context, - e); - } - } - private static void delete(String context, FileIO io, List paths) { AtomicInteger deletedFilesCount = new AtomicInteger(0); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java index dbf5d455b948..9674a7333fa8 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java @@ -39,11 +39,11 @@ import org.apache.iceberg.util.SnapshotUtil; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.Literal; import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.Statistics; -import org.apache.spark.sql.connector.read.SupportsRuntimeFiltering; -import org.apache.spark.sql.sources.Filter; -import org.apache.spark.sql.sources.In; +import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering; import org.apache.spark.sql.types.Metadata; import org.apache.spark.sql.types.MetadataBuilder; import org.apache.spark.sql.types.StructField; @@ -52,7 +52,7 @@ import org.slf4j.LoggerFactory; class SparkCopyOnWriteScan extends SparkPartitioningAwareScan - implements SupportsRuntimeFiltering { + implements SupportsRuntimeV2Filtering { private static final Logger LOG = LoggerFactory.getLogger(SparkCopyOnWriteScan.class); @@ -118,7 +118,7 @@ public NamedReference[] filterAttributes() { } @Override - public void filter(Filter[] filters) { + public void filter(Predicate[] predicates) { Preconditions.checkState( Objects.equals(snapshotId(), currentSnapshotId()), "Runtime file filtering is not possible: the table has been concurrently modified. " @@ -128,16 +128,10 @@ public void filter(Filter[] filters) { snapshotId(), currentSnapshotId()); - for (Filter filter : filters) { - // Spark can only pass In filters at the moment - if (filter instanceof In - && ((In) filter).attribute().equalsIgnoreCase(MetadataColumns.FILE_PATH.name())) { - In in = (In) filter; - - Set fileLocations = Sets.newHashSet(); - for (Object value : in.values()) { - fileLocations.add((String) value); - } + for (Predicate predicate : predicates) { + // Spark can only pass IN predicates at the moment + if (isFilePathInPredicate(predicate)) { + Set fileLocations = extractStringLiterals(predicate); // Spark may call this multiple times for UPDATEs with subqueries // as such cases are rewritten using UNION and the same scan on both sides @@ -159,7 +153,7 @@ public void filter(Filter[] filters) { resetTasks(filteredTasks); } } else { - LOG.warn("Unsupported runtime filter {}", filter); + LOG.warn("Unsupported runtime filter {}", predicate); } } } @@ -228,4 +222,32 @@ private boolean isRowLineageField(StructField field) { || field.name().equals(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()); return hasLineageFieldName && field.metadata().contains("__metadata_col"); } + + private static boolean isFilePathInPredicate(Predicate predicate) { + if (!"IN".equals(predicate.name()) || predicate.children().length < 1) { + return false; + } + + if (!(predicate.children()[0] instanceof NamedReference)) { + return false; + } + + String[] fieldNames = ((NamedReference) predicate.children()[0]).fieldNames(); + + return fieldNames.length == 1 + && fieldNames[0].equalsIgnoreCase(MetadataColumns.FILE_PATH.name()); + } + + private static Set extractStringLiterals(Predicate predicate) { + Set values = Sets.newHashSet(); + for (int i = 1; i < predicate.children().length; i++) { + if (predicate.children()[i] instanceof Literal) { + Object value = ((Literal) predicate.children()[i]).value(); + // V2 string literals come through as UTF8String; toString() materializes the Java String + values.add(value.toString()); + } + } + + return values; + } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java index a93db17e4a0f..39110f0b0597 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java @@ -23,13 +23,20 @@ import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; import static org.apache.iceberg.TableProperties.DELETE_DEFAULT_FILE_FORMAT; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.Map; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; import org.apache.iceberg.io.DeleteSchemaUtil; import org.apache.iceberg.orc.ORC; import org.apache.iceberg.parquet.Parquet; @@ -40,14 +47,20 @@ import org.apache.iceberg.spark.data.SparkOrcWriter; import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.spark.sql.catalyst.InternalRow; -import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; import org.apache.spark.unsafe.types.UTF8String; - -class SparkFileWriterFactory extends BaseFileWriterFactory { - private StructType dataSparkType; - private StructType equalityDeleteSparkType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SparkFileWriterFactory extends RegistryBasedFileWriterFactory { + private static final Logger LOG = LoggerFactory.getLogger(SparkFileWriterFactory.class); + // We need to use old writers to write position deletes with row data, which is a deprecated + // feature. + private final boolean useDeprecatedPositionDeleteWriter; private StructType positionDeleteSparkType; + private final Schema positionDeleteRowSchema; + private final Table table; + private final FileFormat deleteFormat; private final Map writeProperties; /** @@ -75,18 +88,26 @@ class SparkFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + InternalRow.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - positionDeleteRowSchema); + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); - this.dataSparkType = dataSparkType; - this.equalityDeleteSparkType = equalityDeleteSparkType; - this.positionDeleteSparkType = positionDeleteSparkType; + this.table = table; + this.deleteFormat = deleteFileFormat; this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = positionDeleteRowSchema; + this.positionDeleteSparkType = positionDeleteSparkType; + this.useDeprecatedPositionDeleteWriter = + positionDeleteRowSchema != null + || (positionDeleteSparkType != null + && positionDeleteSparkType.getFieldIndex(DELETE_FILE_ROW_FIELD_NAME).isDefined()); } SparkFileWriterFactory( @@ -105,119 +126,109 @@ class SparkFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + InternalRow.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - ImmutableMap.of()); + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); - this.dataSparkType = dataSparkType; - this.equalityDeleteSparkType = equalityDeleteSparkType; - this.positionDeleteSparkType = null; + this.table = table; + this.deleteFormat = deleteFileFormat; this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = null; + this.useDeprecatedPositionDeleteWriter = false; } static Builder builderFor(Table table) { return new Builder(table); } - @Override - protected void configureDataWrite(Avro.DataWriteBuilder builder) { - builder.createWriterFunc(ignored -> new SparkAvroWriter(dataSparkType())); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(Avro.DeleteWriteBuilder builder) { - builder.createWriterFunc(ignored -> new SparkAvroWriter(equalityDeleteSparkType())); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(Avro.DeleteWriteBuilder builder) { - boolean withRow = - positionDeleteSparkType().getFieldIndex(DELETE_FILE_ROW_FIELD_NAME).isDefined(); - if (withRow) { - // SparkAvroWriter accepts just the Spark type of the row ignoring the path and pos - StructField rowField = positionDeleteSparkType().apply(DELETE_FILE_ROW_FIELD_NAME); - StructType positionDeleteRowSparkType = (StructType) rowField.dataType(); - builder.createWriterFunc(ignored -> new SparkAvroWriter(positionDeleteRowSparkType)); - } - - builder.setAll(writeProperties); - } - - @Override - protected void configureDataWrite(Parquet.DataWriteBuilder builder) { - builder.createWriterFunc(msgType -> SparkParquetWriters.buildWriter(dataSparkType(), msgType)); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> SparkParquetWriters.buildWriter(equalityDeleteSparkType(), msgType)); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> SparkParquetWriters.buildWriter(positionDeleteSparkType(), msgType)); - builder.transformPaths(path -> UTF8String.fromString(path.toString())); - builder.setAll(writeProperties); - } - - @Override - protected void configureDataWrite(ORC.DataWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.transformPaths(path -> UTF8String.fromString(path.toString())); - builder.setAll(writeProperties); - } - - private StructType dataSparkType() { - if (dataSparkType == null) { - Preconditions.checkNotNull(dataSchema(), "Data schema must not be null"); - this.dataSparkType = SparkSchemaUtil.convert(dataSchema()); - } - - return dataSparkType; - } - - private StructType equalityDeleteSparkType() { - if (equalityDeleteSparkType == null) { - Preconditions.checkNotNull( - equalityDeleteRowSchema(), "Equality delete schema must not be null"); - this.equalityDeleteSparkType = SparkSchemaUtil.convert(equalityDeleteRowSchema()); - } - - return equalityDeleteSparkType; - } - private StructType positionDeleteSparkType() { if (positionDeleteSparkType == null) { // wrap the optional row schema into the position delete schema containing path and position - Schema positionDeleteSchema = DeleteSchemaUtil.posDeleteSchema(positionDeleteRowSchema()); + Schema positionDeleteSchema = DeleteSchemaUtil.posDeleteSchema(positionDeleteRowSchema); this.positionDeleteSparkType = SparkSchemaUtil.convert(positionDeleteSchema); } return positionDeleteSparkType; } + @Override + public PositionDeleteWriter newPositionDeleteWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + if (!useDeprecatedPositionDeleteWriter) { + return super.newPositionDeleteWriter(file, spec, partition); + } else { + LOG.warn("Position deletes with deleted rows are deprecated and will be removed in 1.12.0."); + Map properties = table == null ? ImmutableMap.of() : table.properties(); + MetricsConfig metricsConfig = + table == null + ? MetricsConfig.forPositionDelete() + : MetricsConfig.forPositionDelete(table); + + try { + return switch (deleteFormat) { + case AVRO -> + Avro.writeDeletes(file) + .createWriterFunc( + ignored -> + new SparkAvroWriter( + (StructType) + positionDeleteSparkType() + .apply(DELETE_FILE_ROW_FIELD_NAME) + .dataType())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(file) + .createWriterFunc(SparkOrcWriter::new) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(file) + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter(positionDeleteSparkType(), msgType)) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + default -> + throw new UnsupportedOperationException( + "Cannot write pos-deletes for unsupported file format: " + deleteFormat); + }; + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new position delete writer", e); + } + } + } + static class Builder { private final Table table; private FileFormat dataFileFormat; @@ -340,4 +351,14 @@ SparkFileWriterFactory build() { writeProperties); } } + + private static StructType useOrConvert(StructType sparkType, Schema schema) { + if (sparkType != null) { + return sparkType; + } else if (schema != null) { + return SparkSchemaUtil.convert(schema); + } else { + return null; + } + } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java new file mode 100644 index 000000000000..23fbe54a4be3 --- /dev/null +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; +import org.apache.iceberg.spark.data.SparkAvroWriter; +import org.apache.iceberg.spark.data.SparkOrcReader; +import org.apache.iceberg.spark.data.SparkOrcWriter; +import org.apache.iceberg.spark.data.SparkParquetReaders; +import org.apache.iceberg.spark.data.SparkParquetWriters; +import org.apache.iceberg.spark.data.SparkPlannedAvroReader; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +public class SparkFormatModels { + public static void register() { + FormatModelRegistry.register( + AvroFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkAvroWriter(icebergSchema, engineSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkPlannedAvroReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register( + ParquetFormatModel.create( + InternalRow.class, + StructType.class, + SparkParquetWriters::buildWriter, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ParquetFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkParquetReaders.buildReader( + icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkOrcWriter(icebergSchema, fileSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + new SparkOrcReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkOrcReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + } + + private SparkFormatModels() {} +} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java index 99b1d78a86b0..a93031780255 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java @@ -24,6 +24,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.types.Types; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; @@ -34,6 +35,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab private final Types.StructType groupingKeyType; private final ScanTaskGroup taskGroup; private final Broadcast
    tableBroadcast; + private final Broadcast fileIOBroadcast; private final String branch; private final String expectedSchemaString; private final boolean caseSensitive; @@ -46,6 +48,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab Types.StructType groupingKeyType, ScanTaskGroup taskGroup, Broadcast
    tableBroadcast, + Broadcast fileIOBroadcast, String branch, String expectedSchemaString, boolean caseSensitive, @@ -54,6 +57,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab this.groupingKeyType = groupingKeyType; this.taskGroup = taskGroup; this.tableBroadcast = tableBroadcast; + this.fileIOBroadcast = fileIOBroadcast; this.branch = branch; this.expectedSchemaString = expectedSchemaString; this.caseSensitive = caseSensitive; @@ -84,6 +88,10 @@ public Table table() { return tableBroadcast.value(); } + public FileIO io() { + return fileIOBroadcast.value(); + } + public String branch() { return branch; } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java new file mode 100644 index 000000000000..1986ddac5d8e --- /dev/null +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.FileScanTask; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; + +interface SparkMicroBatchPlanner { + /** + * Return the {@link FileScanTask}s for data added between the start and end offsets. + * + * @param startOffset the offset to start planning from + * @param endOffset the offset to plan up to + * @return file scan tasks for data in the offset range + */ + List planFiles(StreamingOffset startOffset, StreamingOffset endOffset); + + /** + * Return the latest offset the stream can advance to from {@code startOffset}, respecting the + * given {@link ReadLimit}. + * + * @param startOffset the current offset of the stream + * @param limit the read limit bounding how far ahead to advance + * @return the latest available offset, or {@code null} if no new data is available + */ + StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit); + + /** Stop the planner and release any resources. */ + void stop(); +} diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java index 4d8bb1772355..a1ff767fe2a0 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java @@ -26,44 +26,32 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.Locale; +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.CombinedScanTask; -import org.apache.iceberg.DataOperations; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.MicroBatches; -import org.apache.iceberg.MicroBatches.MicroBatch; import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; +import org.apache.iceberg.hadoop.HadoopFileIO; import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.relocated.com.google.common.base.Joiner; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadConf; -import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.Pair; -import org.apache.iceberg.util.PropertyUtil; -import org.apache.iceberg.util.SnapshotUtil; import org.apache.iceberg.util.TableScanUtil; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.connector.read.InputPartition; import org.apache.spark.sql.connector.read.PartitionReaderFactory; -import org.apache.spark.sql.connector.read.streaming.CompositeReadLimit; import org.apache.spark.sql.connector.read.streaming.MicroBatchStream; import org.apache.spark.sql.connector.read.streaming.Offset; import org.apache.spark.sql.connector.read.streaming.ReadLimit; -import org.apache.spark.sql.connector.read.streaming.ReadMaxFiles; -import org.apache.spark.sql.connector.read.streaming.ReadMaxRows; import org.apache.spark.sql.connector.read.streaming.SupportsTriggerAvailableNow; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,35 +62,41 @@ public class SparkMicroBatchStream implements MicroBatchStream, SupportsTriggerA private static final Types.StructType EMPTY_GROUPING_KEY_TYPE = Types.StructType.of(); private final Table table; + private final Supplier fileIO; + private final SparkReadConf readConf; private final String branch; private final boolean caseSensitive; private final String expectedSchema; private final Broadcast
    tableBroadcast; + private final Broadcast fileIOBroadcast; private final long splitSize; private final int splitLookback; private final long splitOpenFileCost; private final boolean localityPreferred; private final StreamingOffset initialOffset; - private final boolean skipDelete; - private final boolean skipOverwrite; private final long fromTimestamp; private final int maxFilesPerMicroBatch; private final int maxRecordsPerMicroBatch; private final boolean cacheDeleteFilesOnExecutors; + private SparkMicroBatchPlanner planner; private StreamingOffset lastOffsetForTriggerAvailableNow; SparkMicroBatchStream( JavaSparkContext sparkContext, Table table, + Supplier fileIO, SparkReadConf readConf, Schema expectedSchema, String checkpointLocation) { this.table = table; + this.fileIO = fileIO; + this.readConf = readConf; this.branch = readConf.branch(); this.caseSensitive = readConf.caseSensitive(); this.expectedSchema = SchemaParser.toJson(expectedSchema); this.localityPreferred = readConf.localityEnabled(); this.tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + this.fileIOBroadcast = sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); this.splitSize = readConf.splitSize(); this.splitLookback = readConf.splitLookback(); this.splitOpenFileCost = readConf.splitOpenFileCost(); @@ -112,11 +106,9 @@ public class SparkMicroBatchStream implements MicroBatchStream, SupportsTriggerA this.cacheDeleteFilesOnExecutors = readConf.cacheDeleteFilesOnExecutors(); InitialOffsetStore initialOffsetStore = - new InitialOffsetStore(table, checkpointLocation, fromTimestamp); + new InitialOffsetStore( + table, checkpointLocation, fromTimestamp, sparkContext.hadoopConfiguration()); this.initialOffset = initialOffsetStore.initialOffset(); - - this.skipDelete = readConf.streamingSkipDeleteSnapshots(); - this.skipOverwrite = readConf.streamingSkipOverwriteSnapshots(); } @Override @@ -131,8 +123,8 @@ public Offset latestOffset() { } Snapshot latestSnapshot = table.currentSnapshot(); - - return new StreamingOffset(latestSnapshot.snapshotId(), addedFilesCount(latestSnapshot), false); + return new StreamingOffset( + latestSnapshot.snapshotId(), MicroBatchUtils.addedFilesCount(table, latestSnapshot), false); } @Override @@ -151,7 +143,11 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { StreamingOffset endOffset = (StreamingOffset) end; StreamingOffset startOffset = (StreamingOffset) start; - List fileScanTasks = planFiles(startOffset, endOffset); + if (planner == null) { + initializePlanner(startOffset, endOffset); + } + + List fileScanTasks = planner.planFiles(startOffset, endOffset); CloseableIterable splitTasks = TableScanUtil.splitFiles(CloseableIterable.withNoopClose(fileScanTasks), splitSize); @@ -161,13 +157,13 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { String[][] locations = computePreferredLocations(combinedScanTasks); InputPartition[] partitions = new InputPartition[combinedScanTasks.size()]; - for (int index = 0; index < combinedScanTasks.size(); index++) { partitions[index] = new SparkInputPartition( EMPTY_GROUPING_KEY_TYPE, combinedScanTasks.get(index), tableBroadcast, + fileIOBroadcast, branch, expectedSchema, caseSensitive, @@ -179,7 +175,9 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { } private String[][] computePreferredLocations(List taskGroups) { - return localityPreferred ? SparkPlanningUtil.fetchBlockLocations(table.io(), taskGroups) : null; + return localityPreferred + ? SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups) + : null; } @Override @@ -201,317 +199,35 @@ public Offset deserializeOffset(String json) { public void commit(Offset end) {} @Override - public void stop() {} - - private List planFiles(StreamingOffset startOffset, StreamingOffset endOffset) { - List fileScanTasks = Lists.newArrayList(); - StreamingOffset batchStartOffset = - StreamingOffset.START_OFFSET.equals(startOffset) - ? determineStartingOffset(table, fromTimestamp) - : startOffset; - - StreamingOffset currentOffset = null; - - // [(startOffset : startFileIndex), (endOffset : endFileIndex) ) - do { - long endFileIndex; - if (currentOffset == null) { - currentOffset = batchStartOffset; - } else { - Snapshot snapshotAfter = SnapshotUtil.snapshotAfter(table, currentOffset.snapshotId()); - // it may happen that we need to read this snapshot partially in case it's equal to - // endOffset. - if (currentOffset.snapshotId() != endOffset.snapshotId()) { - currentOffset = new StreamingOffset(snapshotAfter.snapshotId(), 0L, false); - } else { - currentOffset = endOffset; - } - } - - Snapshot snapshot = table.snapshot(currentOffset.snapshotId()); - - validateCurrentSnapshotExists(snapshot, currentOffset); - - if (!shouldProcess(snapshot)) { - LOG.debug("Skipping snapshot: {} of table {}", currentOffset.snapshotId(), table.name()); - continue; - } - - Snapshot currentSnapshot = table.snapshot(currentOffset.snapshotId()); - if (currentOffset.snapshotId() == endOffset.snapshotId()) { - endFileIndex = endOffset.position(); - } else { - endFileIndex = addedFilesCount(currentSnapshot); - } - - MicroBatch latestMicroBatch = - MicroBatches.from(currentSnapshot, table.io()) - .caseSensitive(caseSensitive) - .specsById(table.specs()) - .generate( - currentOffset.position(), - endFileIndex, - Long.MAX_VALUE, - currentOffset.shouldScanAllFiles()); - - fileScanTasks.addAll(latestMicroBatch.tasks()); - } while (currentOffset.snapshotId() != endOffset.snapshotId()); - - return fileScanTasks; - } - - private boolean shouldProcess(Snapshot snapshot) { - String op = snapshot.operation(); - switch (op) { - case DataOperations.APPEND: - return true; - case DataOperations.REPLACE: - return false; - case DataOperations.DELETE: - Preconditions.checkState( - skipDelete, - "Cannot process delete snapshot: %s, to ignore deletes, set %s=true", - snapshot.snapshotId(), - SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS); - return false; - case DataOperations.OVERWRITE: - Preconditions.checkState( - skipOverwrite, - "Cannot process overwrite snapshot: %s, to ignore overwrites, set %s=true", - snapshot.snapshotId(), - SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS); - return false; - default: - throw new IllegalStateException( - String.format( - "Cannot process unknown snapshot operation: %s (snapshot id %s)", - op.toLowerCase(Locale.ROOT), snapshot.snapshotId())); - } - } - - private static StreamingOffset determineStartingOffset(Table table, Long fromTimestamp) { - if (table.currentSnapshot() == null) { - return StreamingOffset.START_OFFSET; - } - - if (fromTimestamp == null) { - // match existing behavior and start from the oldest snapshot - return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); - } - - if (table.currentSnapshot().timestampMillis() < fromTimestamp) { - return StreamingOffset.START_OFFSET; - } - - try { - Snapshot snapshot = SnapshotUtil.oldestAncestorAfter(table, fromTimestamp); - if (snapshot != null) { - return new StreamingOffset(snapshot.snapshotId(), 0, false); - } else { - return StreamingOffset.START_OFFSET; - } - } catch (IllegalStateException e) { - // could not determine the first snapshot after the timestamp. use the oldest ancestor instead - return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); + public void stop() { + if (planner != null) { + planner.stop(); } } - private static int getMaxFiles(ReadLimit readLimit) { - if (readLimit instanceof ReadMaxFiles) { - return ((ReadMaxFiles) readLimit).maxFiles(); - } - - if (readLimit instanceof CompositeReadLimit) { - // We do not expect a CompositeReadLimit to contain a nested CompositeReadLimit. - // In fact, it should only be a composite of two or more of ReadMinRows, ReadMaxRows and - // ReadMaxFiles, with no more than one of each. - ReadLimit[] limits = ((CompositeReadLimit) readLimit).getReadLimits(); - for (ReadLimit limit : limits) { - if (limit instanceof ReadMaxFiles) { - return ((ReadMaxFiles) limit).maxFiles(); - } - } - } - - // there is no ReadMaxFiles, so return the default - return Integer.MAX_VALUE; - } - - private static int getMaxRows(ReadLimit readLimit) { - if (readLimit instanceof ReadMaxRows) { - long maxRows = ((ReadMaxRows) readLimit).maxRows(); - return Math.toIntExact(maxRows); - } - - if (readLimit instanceof CompositeReadLimit) { - ReadLimit[] limits = ((CompositeReadLimit) readLimit).getReadLimits(); - for (ReadLimit limit : limits) { - if (limit instanceof ReadMaxRows) { - long maxRows = ((ReadMaxRows) limit).maxRows(); - return Math.toIntExact(maxRows); - } - } + private void initializePlanner(StreamingOffset startOffset, StreamingOffset endOffset) { + if (readConf.asyncMicroBatchPlanningEnabled()) { + this.planner = + new AsyncSparkMicroBatchPlanner( + table, readConf, startOffset, endOffset, lastOffsetForTriggerAvailableNow); + } else { + this.planner = + new SyncSparkMicroBatchPlanner(table, readConf, lastOffsetForTriggerAvailableNow); } - - // There is no ReadMaxRows, so return the default - return Integer.MAX_VALUE; } @Override - @SuppressWarnings("checkstyle:CyclomaticComplexity") public Offset latestOffset(Offset startOffset, ReadLimit limit) { - // calculate end offset get snapshotId from the startOffset Preconditions.checkArgument( startOffset instanceof StreamingOffset, "Invalid start offset: %s is not a StreamingOffset", startOffset); - table.refresh(); - if (table.currentSnapshot() == null) { - return StreamingOffset.START_OFFSET; - } - - if (table.currentSnapshot().timestampMillis() < fromTimestamp) { - return StreamingOffset.START_OFFSET; + if (planner == null) { + initializePlanner((StreamingOffset) startOffset, null); } - // end offset can expand to multiple snapshots - StreamingOffset startingOffset = (StreamingOffset) startOffset; - - if (startOffset.equals(StreamingOffset.START_OFFSET)) { - startingOffset = determineStartingOffset(table, fromTimestamp); - } - - Snapshot curSnapshot = table.snapshot(startingOffset.snapshotId()); - validateCurrentSnapshotExists(curSnapshot, startingOffset); - - // Use the pre-computed snapshotId when Trigger.AvailableNow is enabled. - long latestSnapshotId = - lastOffsetForTriggerAvailableNow != null - ? lastOffsetForTriggerAvailableNow.snapshotId() - : table.currentSnapshot().snapshotId(); - - int startPosOfSnapOffset = (int) startingOffset.position(); - - boolean scanAllFiles = startingOffset.shouldScanAllFiles(); - - boolean shouldContinueReading = true; - int curFilesAdded = 0; - long curRecordCount = 0; - long curPos = 0; - - // Note : we produce nextOffset with pos as non-inclusive - while (shouldContinueReading) { - // generate manifest index for the curSnapshot - List> indexedManifests = - MicroBatches.skippedManifestIndexesFromSnapshot( - table.io(), curSnapshot, startPosOfSnapOffset, scanAllFiles); - // this is under assumption we will be able to add at-least 1 file in the new offset - for (int idx = 0; idx < indexedManifests.size() && shouldContinueReading; idx++) { - // be rest assured curPos >= startFileIndex - curPos = indexedManifests.get(idx).second(); - try (CloseableIterable taskIterable = - MicroBatches.openManifestFile( - table.io(), - table.specs(), - caseSensitive, - curSnapshot, - indexedManifests.get(idx).first(), - scanAllFiles); - CloseableIterator taskIter = taskIterable.iterator()) { - while (taskIter.hasNext()) { - FileScanTask task = taskIter.next(); - if (curPos >= startPosOfSnapOffset) { - if ((curFilesAdded + 1) > getMaxFiles(limit)) { - // On including the file it might happen that we might exceed, the configured - // soft limit on the number of records, since this is a soft limit its acceptable. - shouldContinueReading = false; - break; - } - - curFilesAdded += 1; - curRecordCount += task.file().recordCount(); - - if (curRecordCount >= getMaxRows(limit)) { - // we included the file, so increment the number of files - // read in the current snapshot. - ++curPos; - shouldContinueReading = false; - break; - } - } - ++curPos; - } - } catch (IOException ioe) { - LOG.warn("Failed to close task iterable", ioe); - } - } - // if the currentSnapShot was also the latestSnapshot then break - if (curSnapshot.snapshotId() == latestSnapshotId) { - break; - } - - // if everything was OK and we consumed complete snapshot then move to next snapshot - if (shouldContinueReading) { - Snapshot nextValid = nextValidSnapshot(curSnapshot); - if (nextValid == null) { - // nextValid implies all the remaining snapshots should be skipped. - break; - } - // we found the next available snapshot, continue from there. - curSnapshot = nextValid; - startPosOfSnapOffset = -1; - // if anyhow we are moving to next snapshot we should only scan addedFiles - scanAllFiles = false; - } - } - - StreamingOffset latestStreamingOffset = - new StreamingOffset(curSnapshot.snapshotId(), curPos, scanAllFiles); - - // if no new data arrived, then return null. - return latestStreamingOffset.equals(startingOffset) ? null : latestStreamingOffset; - } - - /** - * Get the next snapshot skiping over rewrite and delete snapshots. - * - * @param curSnapshot the current snapshot - * @return the next valid snapshot (not a rewrite or delete snapshot), returns null if all - * remaining snapshots should be skipped. - */ - private Snapshot nextValidSnapshot(Snapshot curSnapshot) { - Snapshot nextSnapshot = SnapshotUtil.snapshotAfter(table, curSnapshot.snapshotId()); - // skip over rewrite and delete snapshots - while (!shouldProcess(nextSnapshot)) { - LOG.debug("Skipping snapshot: {} of table {}", nextSnapshot.snapshotId(), table.name()); - // if the currentSnapShot was also the mostRecentSnapshot then break - if (nextSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { - return null; - } - nextSnapshot = SnapshotUtil.snapshotAfter(table, nextSnapshot.snapshotId()); - } - return nextSnapshot; - } - - private long addedFilesCount(Snapshot snapshot) { - long addedFilesCount = - PropertyUtil.propertyAsLong(snapshot.summary(), SnapshotSummary.ADDED_FILES_PROP, -1); - // If snapshotSummary doesn't have SnapshotSummary.ADDED_FILES_PROP, - // iterate through addedFiles iterator to find addedFilesCount. - return addedFilesCount == -1 - ? Iterables.size(snapshot.addedDataFiles(table.io())) - : addedFilesCount; - } - - private void validateCurrentSnapshotExists(Snapshot snapshot, StreamingOffset currentOffset) { - if (snapshot == null) { - throw new IllegalStateException( - String.format( - Locale.ROOT, - "Cannot load current offset at snapshot %d, the snapshot was expired or removed", - currentOffset.snapshotId())); - } + return planner.latestOffset((StreamingOffset) startOffset, limit); } @Override @@ -539,17 +255,23 @@ public void prepareForTriggerAvailableNow() { (StreamingOffset) latestOffset(initialOffset, ReadLimit.allAvailable()); LOG.info("lastOffset for Trigger.AvailableNow is {}", lastOffsetForTriggerAvailableNow.json()); + + if (planner != null) { + planner.stop(); + planner = null; + } } private static class InitialOffsetStore { private final Table table; private final FileIO io; private final String initialOffsetLocation; - private final Long fromTimestamp; + private final long fromTimestamp; - InitialOffsetStore(Table table, String checkpointLocation, Long fromTimestamp) { + InitialOffsetStore( + Table table, String checkpointLocation, long fromTimestamp, Configuration conf) { this.table = table; - this.io = table.io(); + this.io = new HadoopFileIO(conf); this.initialOffsetLocation = SLASH.join(checkpointLocation, "offsets/0"); this.fromTimestamp = fromTimestamp; } @@ -561,7 +283,7 @@ public StreamingOffset initialOffset() { } table.refresh(); - StreamingOffset offset = determineStartingOffset(table, fromTimestamp); + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, fromTimestamp); OutputFile outputFile = io.newOutputFile(initialOffsetLocation); writeOffset(offset, outputFile); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java index c9726518ee4e..4d9fb7556b4e 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java @@ -78,7 +78,14 @@ abstract class SparkPartitioningAwareScan extends S Schema expectedSchema, List filters, Supplier scanReportSupplier) { - super(spark, table, readConf, expectedSchema, filters, scanReportSupplier); + super( + spark, + table, + null != scan ? scan.fileIO() : table::io, + readConf, + expectedSchema, + filters, + scanReportSupplier); this.scan = scan; this.preserveDataGrouping = readConf.preserveDataGrouping(); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java index f1fd7b7ff972..885b67c6aec9 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java @@ -110,6 +110,7 @@ class SparkPositionDeltaWrite implements DeltaWrite, RequiresDistributionAndOrde private final String branch; private final Map extraSnapshotMetadata; private final SparkWriteRequirements writeRequirements; + private final int sortOrderId; private final Context context; private final Map writeProperties; @@ -135,6 +136,7 @@ class SparkPositionDeltaWrite implements DeltaWrite, RequiresDistributionAndOrde this.branch = writeConf.branch(); this.extraSnapshotMetadata = writeConf.extraSnapshotMetadata(); this.writeRequirements = writeConf.positionDeltaRequirements(command); + this.sortOrderId = writeConf.outputSortOrderId(writeRequirements); this.context = new Context(dataSchema, writeConf, info, writeRequirements); this.writeProperties = writeConf.writeProperties(); } @@ -169,7 +171,8 @@ public DeltaWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) { broadcastRewritableDeletes(), command, context, - writeProperties); + writeProperties, + sortOrderId); } private Broadcast> broadcastRewritableDeletes() { @@ -379,18 +382,21 @@ private static class PositionDeltaWriteFactory implements DeltaWriterFactory { private final Command command; private final Context context; private final Map writeProperties; + private final int sortOrderId; PositionDeltaWriteFactory( Broadcast
    tableBroadcast, Broadcast> rewritableDeletesBroadcast, Command command, Context context, - Map writeProperties) { + Map writeProperties, + int sortOrderId) { this.tableBroadcast = tableBroadcast; this.rewritableDeletesBroadcast = rewritableDeletesBroadcast; this.command = command; this.context = context; this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; } @Override @@ -417,6 +423,7 @@ public DeltaWriter createWriter(int partitionId, long taskId) { .deleteFileFormat(context.deleteFileFormat()) .positionDeleteSparkType(context.deleteSparkType()) .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) .build(); if (command == DELETE) { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java index 106b296de098..6cf12f6e7667 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java @@ -33,6 +33,7 @@ import org.apache.iceberg.StatisticsFile; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.metrics.ScanReport; import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -101,6 +102,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { private final JavaSparkContext sparkContext; private final Table table; + private final Supplier fileIO; private final SparkSession spark; private final SparkReadConf readConf; private final boolean caseSensitive; @@ -115,6 +117,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { SparkScan( SparkSession spark, Table table, + Supplier fileIO, SparkReadConf readConf, Schema expectedSchema, List filters, @@ -125,6 +128,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { this.spark = spark; this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); this.table = table; + this.fileIO = fileIO; this.readConf = readConf; this.caseSensitive = readConf.caseSensitive(); this.expectedSchema = expectedSchema; @@ -162,13 +166,20 @@ protected Types.StructType groupingKeyType() { @Override public Batch toBatch() { return new SparkBatch( - sparkContext, table, readConf, groupingKeyType(), taskGroups(), expectedSchema, hashCode()); + sparkContext, + table, + fileIO, + readConf, + groupingKeyType(), + taskGroups(), + expectedSchema, + hashCode()); } @Override public MicroBatchStream toMicroBatchStream(String checkpointLocation) { return new SparkMicroBatchStream( - sparkContext, table, readConf, expectedSchema, checkpointLocation); + sparkContext, table, fileIO, readConf, expectedSchema, checkpointLocation); } @Override @@ -345,8 +356,17 @@ public CustomMetric[] supportedCustomMetrics() { protected long adjustSplitSize(List tasks, long splitSize) { if (readConf.splitSizeOption() == null && readConf.adaptiveSplitSizeEnabled()) { long scanSize = tasks.stream().mapToLong(ScanTask::sizeBytes).sum(); - int parallelism = readConf.parallelism(); - return TableScanUtil.adjustSplitSize(scanSize, parallelism, splitSize); + int parallelism = readConf.splitParallelism(); + long adjustedSplitSize = TableScanUtil.adjustSplitSize(scanSize, parallelism, splitSize); + if (adjustedSplitSize != splitSize) { + LOG.debug( + "Adjusted split size from {} to {} for table {} with parallelism {}", + splitSize, + adjustedSplitSize, + table().name(), + parallelism); + } + return adjustedSplitSize; } else { return splitSize; } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java index 11b0ba58af51..8b75906a628f 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java @@ -307,16 +307,16 @@ private boolean metricsModeSupportsAggregatePushDown(List> colName); return false; } - } else if (mode instanceof MetricsModes.Truncate) { - // lower_bounds and upper_bounds may be truncated, so disable push down - if (aggregate.type().typeId() == Type.TypeID.STRING) { - if (aggregate.op() == Expression.Operation.MAX - || aggregate.op() == Expression.Operation.MIN) { - LOG.info( - "Skipping aggregate pushdown: Cannot produce min or max from truncated values for column {}", - colName); - return false; - } + } else if (aggregate.type().typeId() == Type.TypeID.STRING + || aggregate.type().typeId() == Type.TypeID.BINARY) { + // lower_bounds and upper_bounds may have been truncated before, so disable push down + // regardless of the current mode + if (aggregate.op() == Expression.Operation.MAX + || aggregate.op() == Expression.Operation.MIN) { + LOG.info( + "Skipping aggregate pushdown: Cannot produce min or max from truncated values for column {}", + colName); + return false; } } } @@ -760,7 +760,7 @@ public StructType readSchema() { } private BatchScan newBatchScan() { - if (table instanceof BaseTable && readConf.distributedPlanningEnabled()) { + if (readConf.distributedPlanningEnabled()) { return new SparkDistributedDataScan(spark, table, readConf); } else { return table.newBatchScan(); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java index d2eb4e5a56e9..99e0deabb0fb 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java @@ -41,7 +41,7 @@ class SparkStagedScan extends SparkScan { private List> taskGroups = null; // lazy cache of tasks SparkStagedScan(SparkSession spark, Table table, Schema expectedSchema, SparkReadConf readConf) { - super(spark, table, readConf, expectedSchema, ImmutableList.of(), null); + super(spark, table, table::io, readConf, expectedSchema, ImmutableList.of(), null); this.taskSetId = readConf.scanTaskSetId(); this.splitSize = readConf.splitSize(); this.splitLookback = readConf.splitLookback(); diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java index 353566eb7f34..1348afff6475 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.util.Map; +import java.util.Objects; import java.util.Set; import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.BaseTable; @@ -56,6 +57,7 @@ import org.apache.iceberg.spark.CommitMetadata; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSQLProperties; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkTableUtil; import org.apache.iceberg.spark.SparkUtil; @@ -333,11 +335,31 @@ public boolean canDeleteWhere(Predicate[] predicates) { } } - return canDeleteUsingMetadata(deleteExpr); + return canDeleteUsingMetadata(deleteExpr, scanBranchForDelete()); + } + + // Resolves the branch to scan during canDeleteWhere so it matches the branch deleteWhere + // will commit to. Falls back to main when WAP is configured but the WAP branch does not + // exist yet, since this is a read scan. + private String scanBranchForDelete() { + if (branch != null) { + return branch; + } + + if (!SparkTableUtil.wapEnabled(table())) { + return null; + } + + String wapBranch = sparkSession().conf().get(SparkSQLProperties.WAP_BRANCH, null); + if (wapBranch != null && table().refs().containsKey(wapBranch)) { + return wapBranch; + } + + return null; } // a metadata delete is possible iff matching files can be deleted entirely - private boolean canDeleteUsingMetadata(Expression deleteExpr) { + private boolean canDeleteUsingMetadata(Expression deleteExpr, String scanBranch) { boolean caseSensitive = SparkUtil.caseSensitive(sparkSession()); if (ExpressionUtil.selectsPartitions(deleteExpr, table(), caseSensitive)) { @@ -352,14 +374,14 @@ private boolean canDeleteUsingMetadata(Expression deleteExpr) { .includeColumnStats() .ignoreResiduals(); - if (branch != null) { - scan = scan.useRef(branch); + if (scanBranch != null) { + scan = scan.useRef(scanBranch); } try (CloseableIterable tasks = scan.planFiles()) { Map evaluators = Maps.newHashMap(); StrictMetricsEvaluator metricsEvaluator = - new StrictMetricsEvaluator(SnapshotUtil.schemaFor(table(), branch), deleteExpr); + new StrictMetricsEvaluator(SnapshotUtil.schemaFor(table(), scanBranch), deleteExpr); return Iterables.all( tasks, @@ -396,12 +418,13 @@ public void deleteWhere(Predicate[] predicates) { .set("spark.app.id", sparkSession().sparkContext().applicationId()) .deleteFromRowFilter(deleteExpr); + String writeBranch = branch; if (SparkTableUtil.wapEnabled(table())) { - branch = SparkTableUtil.determineWriteBranch(sparkSession(), branch); + writeBranch = SparkTableUtil.determineWriteBranch(sparkSession(), branch); } - if (branch != null) { - deleteFiles.toBranch(branch); + if (writeBranch != null) { + deleteFiles.toBranch(writeBranch); } if (!CommitMetadata.commitProperties().isEmpty()) { @@ -424,15 +447,16 @@ public boolean equals(Object other) { return false; } - // use only name in order to correctly invalidate Spark cache SparkTable that = (SparkTable) other; - return icebergTable.name().equals(that.icebergTable.name()); + return icebergTable.name().equals(that.icebergTable.name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && Objects.equals(snapshotId, that.snapshotId) + && Objects.equals(branch, that.branch); } @Override public int hashCode() { - // use only name in order to correctly invalidate Spark cache - return icebergTable.name().hashCode(); + return Objects.hash(icebergTable.name(), table().uuid(), snapshotId, branch); } private static CaseInsensitiveStringMap addSnapshotId( diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java index e890680cf780..f1bbd949d5da 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java @@ -182,6 +182,7 @@ private WriterFactory createWriterFactory() { // broadcast the table metadata as the writer factory will be sent to executors Broadcast
    tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + int sortOrderId = writeConf.outputSortOrderId(writeRequirements); return new WriterFactory( tableBroadcast, queryId, @@ -191,7 +192,8 @@ private WriterFactory createWriterFactory() { writeSchema, dsSchema, partitionedFanoutEnabled, - writeProperties); + writeProperties, + sortOrderId); } private void commitOperation(SnapshotUpdate operation, String description) { @@ -656,6 +658,7 @@ private static class WriterFactory implements DataWriterFactory, StreamingDataWr private final boolean partitionedFanoutEnabled; private final String queryId; private final Map writeProperties; + private final int sortOrderId; protected WriterFactory( Broadcast
    tableBroadcast, @@ -666,7 +669,8 @@ protected WriterFactory( Schema writeSchema, StructType dsSchema, boolean partitionedFanoutEnabled, - Map writeProperties) { + Map writeProperties, + int sortOrderId) { this.tableBroadcast = tableBroadcast; this.format = format; this.outputSpecId = outputSpecId; @@ -676,6 +680,7 @@ protected WriterFactory( this.partitionedFanoutEnabled = partitionedFanoutEnabled; this.queryId = queryId; this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; } @Override @@ -700,6 +705,7 @@ public DataWriter createWriter(int partitionId, long taskId, long e .dataSchema(writeSchema) .dataSparkType(dsSchema) .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) .build(); if (spec.isUnpartitioned()) { diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java index e608a40b72ad..df4566da0c90 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java @@ -27,27 +27,27 @@ import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkFilters; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.spark.SparkV2Filters; import org.apache.iceberg.spark.SparkWriteConf; import org.apache.iceberg.spark.SparkWriteRequirements; import org.apache.iceberg.types.TypeUtil; import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.Scan; import org.apache.spark.sql.connector.write.BatchWrite; import org.apache.spark.sql.connector.write.LogicalWriteInfo; import org.apache.spark.sql.connector.write.RowLevelOperation.Command; import org.apache.spark.sql.connector.write.SupportsDynamicOverwrite; -import org.apache.spark.sql.connector.write.SupportsOverwrite; +import org.apache.spark.sql.connector.write.SupportsOverwriteV2; import org.apache.spark.sql.connector.write.Write; import org.apache.spark.sql.connector.write.WriteBuilder; import org.apache.spark.sql.connector.write.streaming.StreamingWrite; -import org.apache.spark.sql.sources.Filter; import org.apache.spark.sql.types.LongType$; import org.apache.spark.sql.types.StructType; -class SparkWriteBuilder implements WriteBuilder, SupportsDynamicOverwrite, SupportsOverwrite { +class SparkWriteBuilder implements WriteBuilder, SupportsDynamicOverwrite, SupportsOverwriteV2 { private final SparkSession spark; private final Table table; private final SparkWriteConf writeConf; @@ -100,12 +100,12 @@ public WriteBuilder overwriteDynamicPartitions() { } @Override - public WriteBuilder overwrite(Filter[] filters) { + public WriteBuilder overwrite(Predicate[] predicates) { Preconditions.checkState( !overwriteFiles, "Cannot overwrite individual files and using filters"); Preconditions.checkState(rewrittenFileSetId == null, "Cannot overwrite and rewrite"); - this.overwriteExpr = SparkFilters.convert(filters); + this.overwriteExpr = SparkV2Filters.convert(predicates); if (overwriteExpr == Expressions.alwaysTrue() && "dynamic".equals(overwriteMode)) { // use the write option to override truncating the table. use dynamic overwrite instead. this.overwriteDynamic = true; diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..f1b0029c5432 --- /dev/null +++ b/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.MicroBatches.MicroBatch; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(SyncSparkMicroBatchPlanner.class); + + private final boolean caseSensitive; + private final long fromTimestamp; + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + SyncSparkMicroBatchPlanner( + Table table, SparkReadConf readConf, StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.caseSensitive = readConf().caseSensitive(); + this.fromTimestamp = readConf().streamFromTimestamp(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + } + + @Override + public List planFiles(StreamingOffset startOffset, StreamingOffset endOffset) { + List fileScanTasks = Lists.newArrayList(); + StreamingOffset batchStartOffset = + StreamingOffset.START_OFFSET.equals(startOffset) + ? MicroBatchUtils.determineStartingOffset(table(), fromTimestamp) + : startOffset; + + StreamingOffset currentOffset = null; + + // [(startOffset : startFileIndex), (endOffset : endFileIndex) ) + do { + long endFileIndex; + if (currentOffset == null) { + currentOffset = batchStartOffset; + } else { + Snapshot snapshotAfter = SnapshotUtil.snapshotAfter(table(), currentOffset.snapshotId()); + // it may happen that we need to read this snapshot partially in case it's equal to + // endOffset. + if (currentOffset.snapshotId() != endOffset.snapshotId()) { + currentOffset = new StreamingOffset(snapshotAfter.snapshotId(), 0L, false); + } else { + currentOffset = endOffset; + } + } + + Snapshot snapshot = table().snapshot(currentOffset.snapshotId()); + + validateCurrentSnapshotExists(snapshot, currentOffset); + + if (!shouldProcess(snapshot)) { + LOG.debug("Skipping snapshot: {} of table {}", currentOffset.snapshotId(), table().name()); + continue; + } + + Snapshot currentSnapshot = table().snapshot(currentOffset.snapshotId()); + if (currentOffset.snapshotId() == endOffset.snapshotId()) { + endFileIndex = endOffset.position(); + } else { + endFileIndex = MicroBatchUtils.addedFilesCount(table(), currentSnapshot); + } + + MicroBatch latestMicroBatch = + MicroBatches.from(currentSnapshot, table().io()) + .caseSensitive(caseSensitive) + .specsById(table().specs()) + .generate( + currentOffset.position(), + endFileIndex, + Long.MAX_VALUE, + currentOffset.shouldScanAllFiles()); + + fileScanTasks.addAll(latestMicroBatch.tasks()); + } while (currentOffset.snapshotId() != endOffset.snapshotId()); + + return fileScanTasks; + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + table().refresh(); + if (table().currentSnapshot() == null) { + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < fromTimestamp) { + return StreamingOffset.START_OFFSET; + } + + // end offset can expand to multiple snapshots + StreamingOffset startingOffset = startOffset; + + if (startOffset.equals(StreamingOffset.START_OFFSET)) { + startingOffset = MicroBatchUtils.determineStartingOffset(table(), fromTimestamp); + } + + Snapshot curSnapshot = table().snapshot(startingOffset.snapshotId()); + validateCurrentSnapshotExists(curSnapshot, startingOffset); + + // Use the pre-computed snapshotId when Trigger.AvailableNow is enabled. + long latestSnapshotId = + lastOffsetForTriggerAvailableNow != null + ? lastOffsetForTriggerAvailableNow.snapshotId() + : table().currentSnapshot().snapshotId(); + + int startPosOfSnapOffset = (int) startingOffset.position(); + + boolean scanAllFiles = startingOffset.shouldScanAllFiles(); + + boolean shouldContinueReading = true; + int curFilesAdded = 0; + long curRecordCount = 0; + int curPos = 0; + + // Extract limits once to avoid repeated calls in tight loop + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long maxFiles = unpackedLimits.getMaxFiles(); + long maxRows = unpackedLimits.getMaxRows(); + + // Note : we produce nextOffset with pos as non-inclusive + while (shouldContinueReading) { + // generate manifest index for the curSnapshot + List> indexedManifests = + MicroBatches.skippedManifestIndexesFromSnapshot( + table().io(), curSnapshot, startPosOfSnapOffset, scanAllFiles); + // this is under assumption we will be able to add at-least 1 file in the new offset + for (int idx = 0; idx < indexedManifests.size() && shouldContinueReading; idx++) { + // be rest assured curPos >= startFileIndex + curPos = indexedManifests.get(idx).second(); + try (CloseableIterable taskIterable = + MicroBatches.openManifestFile( + table().io(), + table().specs(), + caseSensitive, + curSnapshot, + indexedManifests.get(idx).first(), + scanAllFiles); + CloseableIterator taskIter = taskIterable.iterator()) { + while (taskIter.hasNext()) { + FileScanTask task = taskIter.next(); + if (curPos >= startPosOfSnapOffset) { + if ((curFilesAdded + 1) > maxFiles) { + // On including the file it might happen that we might exceed, the configured + // soft limit on the number of records, since this is a soft limit its acceptable. + shouldContinueReading = false; + break; + } + + curFilesAdded += 1; + curRecordCount += task.file().recordCount(); + + if (curRecordCount >= maxRows) { + // we included the file, so increment the number of files + // read in the current snapshot. + if (curFilesAdded == 1 && curRecordCount > maxRows) { + LOG.warn( + "File {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + task.file().location(), + task.file().recordCount(), + maxRows); + } + ++curPos; + shouldContinueReading = false; + break; + } + } + ++curPos; + } + } catch (IOException ioe) { + LOG.warn("Failed to close task iterable", ioe); + } + } + // if the currentSnapShot was also the latestSnapshot then break + if (curSnapshot.snapshotId() == latestSnapshotId) { + break; + } + + // if everything was OK and we consumed complete snapshot then move to next snapshot + if (shouldContinueReading) { + Snapshot nextValid = nextValidSnapshot(curSnapshot); + if (nextValid == null) { + // nextValid implies all the remaining snapshots should be skipped. + break; + } + // we found the next available snapshot, continue from there. + curSnapshot = nextValid; + startPosOfSnapOffset = -1; + // if anyhow we are moving to next snapshot we should only scan addedFiles + scanAllFiles = false; + } + } + + StreamingOffset latestStreamingOffset = + new StreamingOffset(curSnapshot.snapshotId(), curPos, scanAllFiles); + + // if no new data arrived, then return null. + return latestStreamingOffset.equals(startingOffset) ? null : latestStreamingOffset; + } + + @Override + public void stop() {} + + private void validateCurrentSnapshotExists(Snapshot snapshot, StreamingOffset currentOffset) { + if (snapshot == null) { + throw new IllegalStateException( + String.format( + Locale.ROOT, + "Cannot load current offset at snapshot %d, the snapshot was expired or removed", + currentOffset.snapshotId())); + } + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java index 404ba7284606..9b08d6f7ab1e 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.BeforeEach; @@ -90,6 +91,7 @@ protected static SparkSession initSpark(String serializer) { .master("local[2]") .config("spark.serializer", serializer) .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java index 9361c63176e0..8eeb55171dbe 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -69,6 +70,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java index a218f965ea65..eae640528f9e 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java @@ -23,6 +23,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -62,6 +63,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java index acd4688440d1..6ad0907fffed 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -59,6 +60,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java index dac09fea7562..1090245326fb 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java @@ -58,7 +58,8 @@ public static void validateDataManifest( List actualSnapshotIds = Lists.newArrayList(); List actualFiles = Lists.newArrayList(); - for (ManifestEntry entry : ManifestFiles.read(manifest, table.io()).entries()) { + for (ManifestEntry entry : + ManifestFiles.read(manifest, table.io(), table.specs()).entries()) { actualDataSeqs.add(entry.dataSequenceNumber()); actualFileSeqs.add(entry.fileSequenceNumber()); actualSnapshotIds.add(entry.snapshotId()); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java new file mode 100644 index 000000000000..ee1f29e56fb3 --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import com.codahale.metrics.MetricRegistry; +import java.util.Properties; +import org.apache.spark.SparkConf; +import org.apache.spark.metrics.sink.MetricsServlet; +import org.sparkproject.jetty.servlet.ServletContextHandler; + +/** + * A dummy implementation of {@link MetricsServlet} that does not start a server or report metrics. + * This is used in tests to avoid conflicts with Spark's jetty dependencies. + */ +public class DummyMetricsServlet extends MetricsServlet { + + /** + * Constructor required by Spark's reflection-based instantiation. + * + * @param properties Metrics properties + * @param registry Metric registry + */ + public DummyMetricsServlet(Properties properties, MetricRegistry registry) { + super(properties, registry); + } + + @Override + public ServletContextHandler[] getHandlers(SparkConf conf) { + return new ServletContextHandler[] {}; + } + + @Override + public void start() { + // No-op for tests + } + + @Override + public void stop() { + // No-op for tests + } + + @Override + public void report() { + // No-op for tests + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBase.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBase.java index 3c32b4693684..b89109174d90 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBase.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBase.java @@ -65,6 +65,13 @@ public abstract class TestBase extends SparkTestHelperBase { protected static SparkSession spark = null; protected static JavaSparkContext sparkContext = null; protected static HiveCatalog catalog = null; + // disable Spark UI and use dummy servlet to avoid dependency conflicts with Spark's Jetty version + public static final Map DISABLE_UI = + ImmutableMap.of( + "spark.ui.enabled", + "false", + "spark.metrics.conf.*.sink.servlet.class", + "org.apache.iceberg.spark.DummyMetricsServlet"); @BeforeAll public static void startMetastoreAndSpark() { @@ -79,6 +86,7 @@ public static void startMetastoreAndSpark() { .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config(DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java index 3fcd7fa24a3e..38dcea259c99 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java @@ -69,7 +69,11 @@ public abstract class TestBaseWithCatalog extends TestBase { // status even belonging to the same catalog. Reference: // https://www.sqlite.org/inmemorydb.html CatalogProperties.CLIENT_POOL_SIZE, - "1")); + "1", + "include-credentials", + "true", + "gcs.oauth2.token", + "dummyToken")); protected static RESTCatalog restCatalog; diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java new file mode 100644 index 000000000000..c3fc69c8b25c --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReadConf extends TestBaseWithCatalog { + + @BeforeEach + public void before() { + super.before(); + sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName); + } + + @AfterEach + public void after() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSplitParallelismDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(conf.parallelism()); + } + + @TestTemplate + public void testSplitParallelismSessionConf() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "999", + SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, + "42"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(42); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsZero() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "0"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsNegative() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "-5"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java index 9b5b207a5b6b..0846cf6f1161 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java @@ -24,9 +24,11 @@ import java.util.List; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; +import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.expressions.AttributeReference; import org.apache.spark.sql.catalyst.expressions.MetadataAttribute; +import org.apache.spark.sql.types.DataTypes; import org.apache.spark.sql.types.StructType; import org.junit.jupiter.api.Test; @@ -79,4 +81,18 @@ public void testSchemaConversionWithMetaDataColumnSchema() { } } } + + @Test + public void testUnknownTypeToSpark() { + Schema schema = new Schema(optional(1, "col", Types.UnknownType.get())); + StructType sparkType = SparkSchemaUtil.convert(schema); + assertThat(sparkType.fields()[0].dataType()).isEqualTo(DataTypes.NullType); + } + + @Test + public void testNullTypeToIceberg() { + StructType sparkType = new StructType().add("col", DataTypes.NullType, true); + Type icebergType = SparkSchemaUtil.convert(sparkType).findField("col").type(); + assertThat(icebergType).isEqualTo(Types.UnknownType.get()); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java index ae7ed3af651e..4b261aaf8386 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java @@ -45,6 +45,7 @@ import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE; import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Duration; @@ -489,6 +490,51 @@ public void testDeleteFileWriteConf() { } } + @TestTemplate + public void testSortOrderWriteConf() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConf = + new SparkWriteConf( + spark, table, ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "1")); + + assertThat(writeConf.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .isEqualTo(table.sortOrder().orderId()); + } + + @TestTemplate + public void testSortOrderWriteConfWithInvalidId() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfForUnknownSortOrder = + new SparkWriteConf( + spark, table, ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "999")); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> writeConfForUnknownSortOrder.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .withMessage( + "Cannot use output sort order id 999 because the table does not contain a sort order with that id"); + } + + @TestTemplate + public void testSortOrderWriteConfWithNoOption() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfNoOption = new SparkWriteConf(spark, table, ImmutableMap.of()); + + assertThat(writeConfNoOption.outputSortOrderId(writeConfNoOption.writeRequirements())) + .isEqualTo(table.sortOrder().orderId()); + + assertThat(writeConfNoOption.outputSortOrderId(SparkWriteRequirements.EMPTY)).isEqualTo(0); + } + private void testWriteProperties(List> propertiesSuite) { withSQLConf( propertiesSuite.get(0), diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java index 303411eb7ddd..cb405886cb55 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java @@ -23,9 +23,8 @@ import java.io.IOException; import java.util.List; -import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionStatistics; import org.apache.iceberg.PartitionStatisticsFile; -import org.apache.iceberg.PartitionStats; import org.apache.iceberg.PartitionStatsHandler; import org.apache.iceberg.Partitioning; import org.apache.iceberg.Schema; @@ -120,10 +119,9 @@ public void partitionStatsComputeOnLatestSnapshot() throws IOException { assertThat(table.partitionStatisticsFiles()).containsExactly(statisticsFile); Types.StructType partitionType = Partitioning.partitionType(table); - Schema dataSchema = PartitionStatsHandler.schema(partitionType, 2); validatePartitionStats( - statisticsFile, - dataSchema, + table, + statisticsFile.snapshotId(), Tuple.tuple( partitionRecord(partitionType, "foo", "A"), DEFAULT_SPEC_ID, @@ -212,8 +210,8 @@ public void partitionStatsComputeOnSnapshot() throws IOException { Schema dataSchema = PartitionStatsHandler.schema(partitionType, 2); // should contain stats for only partitions of snapshot1 (no entry for partition bar, A) validatePartitionStats( - statisticsFile, - dataSchema, + table, + statisticsFile.snapshotId(), Tuple.tuple( partitionRecord(partitionType, "foo", "A"), DEFAULT_SPEC_ID, @@ -272,31 +270,28 @@ private void createPartitionedTableV1() { tableName); } - private void validatePartitionStats( - PartitionStatisticsFile result, Schema recordSchema, Tuple... expectedValues) + private void validatePartitionStats(Table table, long snapshotId, Tuple... expectedValues) throws IOException { - // read the partition entries from the stats file - List partitionStats; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - recordSchema, Files.localInput(result.path()))) { + List partitionStats; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()) { partitionStats = Lists.newArrayList(recordIterator); } assertThat(partitionStats) .extracting( - PartitionStats::partition, - PartitionStats::specId, - PartitionStats::dataRecordCount, - PartitionStats::dataFileCount, - PartitionStats::totalDataFileSizeInBytes, - PartitionStats::positionDeleteRecordCount, - PartitionStats::positionDeleteFileCount, - PartitionStats::equalityDeleteRecordCount, - PartitionStats::equalityDeleteFileCount, - PartitionStats::totalRecords, - PartitionStats::lastUpdatedAt, - PartitionStats::lastUpdatedSnapshotId) + PartitionStatistics::partition, + PartitionStatistics::specId, + PartitionStatistics::dataRecordCount, + PartitionStatistics::dataFileCount, + PartitionStatistics::totalDataFileSizeInBytes, + PartitionStatistics::positionDeleteRecordCount, + PartitionStatistics::positionDeleteFileCount, + PartitionStatistics::equalityDeleteRecordCount, + PartitionStatistics::equalityDeleteFileCount, + PartitionStatistics::totalRecords, + PartitionStatistics::lastUpdatedAt, + PartitionStatistics::lastUpdatedSnapshotId) .containsExactlyInAnyOrder(expectedValues); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java index dd751499df30..9b0fecdaae41 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java @@ -667,7 +667,7 @@ public void schemaEvolutionTestWithSparkSQL() throws Exception { "CAST(id AS FLOAT) col1", "CAST(id AS STRING) col2", "CAST(id AS INT) col3") - .registerTempTable("tempdata"); + .createOrReplaceTempView("tempdata"); sql("INSERT INTO TABLE %s SELECT * FROM tempdata", tblName); List expectedBeforeAddColumn = sql("SELECT * FROM %s ORDER BY col0", tblName); List expectedAfterAddColumn = diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java index afbe747093ea..d6416e1dd262 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java @@ -47,6 +47,7 @@ import org.apache.iceberg.ReachableFileUtil; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableProperties; @@ -628,8 +629,10 @@ public void testWithExpiringDanglingStageCommit() { expectedDeletes.add(snapshotA.manifestListLocation()); // Files should be deleted of dangling staged snapshot - snapshotB - .addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(snapshotB) + .build() + .addedDataFiles() .forEach( i -> { expectedDeletes.add(i.location()); @@ -699,7 +702,10 @@ public void testWithCherryPickTableSnapshot() { Lists.newArrayList(snapshotB, snapshotC, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -748,7 +754,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -768,7 +777,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java index 40505b856737..0d2a5c0a4daf 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java @@ -184,6 +184,9 @@ public void testDryRun() throws IOException { assertThat(result1.orphanFileLocations()) .as("Default olderThan interval should be safe") .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); DeleteOrphanFiles.Result result2 = actions @@ -195,6 +198,9 @@ public void testDryRun() throws IOException { assertThat(result2.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFiles); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -210,6 +216,9 @@ public void testDryRun() throws IOException { assertThat(result3.orphanFileLocations()) .as("Streaming dry run should find 1 file") .isEqualTo(invalidFiles); + assertThat(result3.orphanFilesCount()) + .as("Streaming dry run should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present after streaming dry run") .isTrue(); @@ -223,6 +232,9 @@ public void testDryRun() throws IOException { assertThat(result4.orphanFileLocations()) .as("Action should delete 1 file") .isEqualTo(invalidFiles); + assertThat(result4.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should not be present") .isFalse(); @@ -286,6 +298,7 @@ public void testAllValidFilesAreKept() throws IOException { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 4 files").hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); Path dataPath = new Path(tableLocation + "/data"); FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); @@ -366,6 +379,7 @@ public void orphanedFileRemovedWithParallelTasks() { .containsExactlyInAnyOrder( "remove-orphan-0", "remove-orphan-1", "remove-orphan-2", "remove-orphan-3"); assertThat(deletedFiles).hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); } @TestTemplate @@ -410,6 +424,7 @@ public void testWapFilesAreKept() { actions.deleteOrphanFiles(table).olderThan(System.currentTimeMillis()).execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); } @TestTemplate @@ -440,6 +455,7 @@ public void testMetadataFolderIsIntact() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); List actualRecords = @@ -478,6 +494,7 @@ public void testOlderThanTimestamp() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete only 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete only 2 files").isEqualTo(2L); } @TestTemplate @@ -509,6 +526,7 @@ public void testRemoveUnreachableMetadataVersionFiles() { assertThat(result.orphanFileLocations()) .containsExactly(tableLocation + "metadata/v1.metadata.json"); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); List expectedRecords = Lists.newArrayList(); expectedRecords.addAll(records); @@ -545,6 +563,7 @@ public void testManyTopLevelPartitions() { .execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); assertThat(resultDF.count()).as("Rows count must match").isEqualTo(records.size()); @@ -575,6 +594,7 @@ public void testManyLeafPartitions() { .execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); assertThat(resultDF.count()).as("Row count must match").isEqualTo(records.size()); @@ -615,6 +635,7 @@ public void testHiddenPartitionPaths() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); } @TestTemplate @@ -655,6 +676,7 @@ public void testHiddenPartitionPathsWithPartitionEvolution() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); } @TestTemplate @@ -694,6 +716,7 @@ public void testHiddenPathsStartingWithPartitionNamesAreIgnored() throws IOExcep .execute(); assertThat(result.orphanFileLocations()).as("Should delete 0 files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should delete 0 files").isEqualTo(0L); assertThat(fs.exists(pathToFileInHiddenFolder)).isTrue(); } @@ -766,6 +789,9 @@ public void testRemoveOrphanFilesWithRelativeFilePath() throws IOException { assertThat(result.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFiles); + assertThat(result.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -803,6 +829,7 @@ public void testRemoveOrphanFilesWithHadoopCatalog() throws InterruptedException .execute(); assertThat(result.orphanFileLocations()).as("Should delete only 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete only 1 file").isEqualTo(1L); Dataset resultDF = spark.read().format("iceberg").load(table.location()); List actualRecords = @@ -838,6 +865,7 @@ public void testHiveCatalogTable() throws IOException { assertThat(result.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + "/data/trashfile"); + assertThat(result.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -932,6 +960,9 @@ public void testCompareToFileList() throws IOException { assertThat(result1.orphanFileLocations()) .as("Default olderThan interval should be safe") .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); DeleteOrphanFiles.Result result2 = actions @@ -943,6 +974,9 @@ public void testCompareToFileList() throws IOException { assertThat(result2.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFilePaths); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFilePaths.size()); assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -956,6 +990,9 @@ public void testCompareToFileList() throws IOException { assertThat(result3.orphanFileLocations()) .as("Action should delete 1 file") .isEqualTo(invalidFilePaths); + assertThat(result3.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFilePaths.size()); assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) .as("Invalid file should not be present") .isFalse(); @@ -985,6 +1022,7 @@ public void testCompareToFileList() throws IOException { .deleteWith(s -> {}) .execute(); assertThat(result4.orphanFileLocations()).as("Action should find nothing").isEmpty(); + assertThat(result4.orphanFilesCount()).as("Action should find nothing").isEqualTo(0L); } protected long waitUntilAfter(long timestampMillis) { @@ -1064,6 +1102,7 @@ public void testRemoveOrphanFilesWithStatisticFiles() throws Exception { .execute(); Iterable orphanFileLocations = result.orphanFileLocations(); assertThat(orphanFileLocations).hasSize(1).containsExactly(statsLocation.toURI().toString()); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); assertThat(statsLocation).as("stats file should be deleted").doesNotExist(); } @@ -1282,6 +1321,9 @@ public void testStreamResultsDeletion() throws IOException { .as("Non-streaming dry-run should return all 10 orphan files") .hasSize(10) .containsExactlyInAnyOrderElementsOf(invalidFiles); + assertThat(nonStreamingResult.orphanFilesCount()) + .as("Non-streaming dry-run should return all 10 orphan files") + .isEqualTo((long) invalidFiles.size()); DeleteOrphanFiles.Result streamingResult = SparkActions.get() @@ -1295,6 +1337,9 @@ public void testStreamResultsDeletion() throws IOException { assertThat(streamingResult.orphanFileLocations()) .as("Streaming with sample size 5 should return only 5 orphan files") .hasSize(5); + assertThat(streamingResult.orphanFilesCount()) + .as("Deleted 10 files") + .isEqualTo((long) invalidFiles.size()); for (String invalidFile : invalidFiles) { assertThat(fs.exists(new Path(invalidFile))).as("Orphan file should be deleted").isFalse(); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java index 646e5f8e70d4..88ac800b158f 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.io.File; +import java.util.stream.StreamSupport; import org.apache.iceberg.actions.DeleteOrphanFiles; import org.apache.iceberg.spark.SparkCatalog; import org.apache.iceberg.spark.SparkSchemaUtil; @@ -59,6 +60,7 @@ public void testSparkCatalogTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -88,6 +90,7 @@ public void testSparkCatalogNamedHadoopTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -114,9 +117,11 @@ public void testSparkCatalogNamedHiveTable() throws Exception { .deleteOrphanFiles(table.table()) .olderThan(System.currentTimeMillis() + 1000) .execute(); - assertThat(results.orphanFileLocations()) + + assertThat(StreamSupport.stream(results.orphanFileLocations().spliterator(), false)) .as("trash file should be removed") - .contains("file:" + location + trashFile); + .anyMatch(file -> file.contains("file:" + location + trashFile)); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -149,6 +154,7 @@ public void testSparkSessionCatalogHadoopTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -181,6 +187,7 @@ public void testSparkSessionCatalogHiveTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @AfterEach diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java index 2b4a2a211ec0..d74d8a29f994 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java @@ -22,6 +22,7 @@ import static org.apache.iceberg.data.FileHelpers.encrypt; import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.spark.sql.functions.col; import static org.apache.spark.sql.functions.current_date; import static org.apache.spark.sql.functions.date_add; import static org.apache.spark.sql.functions.expr; @@ -71,6 +72,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.SortOrder; import org.apache.iceberg.StructLike; @@ -126,6 +128,7 @@ import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.DataTypes; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; @@ -173,7 +176,6 @@ public void setupTableLocation() { private RewriteDataFilesSparkAction basicRewrite(Table table) { // Always compact regardless of input files - table.refresh(); return actions() .rewriteDataFiles(table) .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1"); @@ -289,6 +291,7 @@ public void testBinPackAfterPartitionChange() { Table table = createTable(); writeRecords(20, SCALE, 20); + table.refresh(); shouldHaveFiles(table, 20); table.updateSpec().addField(Expressions.ref("c1")).commit(); @@ -362,7 +365,6 @@ public void testDataFilesRewrittenWithMaxDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).isEmpty(); @@ -412,7 +414,6 @@ public void testDataFilesRewrittenWithHighDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).hasSize(1); @@ -462,7 +463,6 @@ public void testDataFilesNotRewrittenWithLowDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(0); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).hasSameSizeAs(dataFiles); @@ -475,7 +475,6 @@ public void testBinPackWithV2PositionDeletes() throws IOException { assumeThat(formatVersion).isEqualTo(2); Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); @@ -492,7 +491,6 @@ public void testBinPackWithV2PositionDeletes() throws IOException { } rowDelta.commit(); - table.refresh(); List expectedRecords = currentData(); long dataSizeBefore = testDataSize(table); Result result = @@ -519,7 +517,6 @@ public void testBinPackWithDVs() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(3); Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); - table.refresh(); List initialRecords = currentDataWithLineage(); Set rowIds = initialRecords.stream().map(record -> (Long) record[0]).collect(Collectors.toSet()); @@ -551,7 +548,6 @@ public void testBinPackWithDVs() throws IOException { } rowDelta.commit(); - table.refresh(); List recordsWithLineageAfterDelete = currentDataWithLineage(); rowIds.removeAll(rowIdsBeingRemoved); assertThat(rowIds) @@ -630,7 +626,6 @@ public void removeDanglingDVsFromDeleteManifest() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); assertThat(result.removedDeleteFilesCount()).isEqualTo(numDataFiles); - table.refresh(); assertThat(TestHelpers.dataFiles(table)).hasSize(1); assertThat(TestHelpers.deleteFiles(table)).isEmpty(); @@ -681,7 +676,6 @@ public void testRemoveDangledEqualityDeletesPartitionEvolution() { .hasSize(1); // partition evolution - table.refresh(); table.updateSpec().addField(Expressions.ref("c3")).commit(); // data seq = 4, write 2 new data files in both partitions for evolved spec @@ -779,7 +773,6 @@ public void testBinPackWithDeleteAllData() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(2); Table table = createTablePartitioned(1, 1, 1); shouldHaveFiles(table, 1); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); @@ -795,7 +788,6 @@ public void testBinPackWithDeleteAllData() throws IOException { } rowDelta.commit(); - table.refresh(); List expectedRecords = currentData(); long dataSizeBefore = testDataSize(table); @@ -832,7 +824,6 @@ public void testBinPackWithStartingSequenceNumber() { Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); List expectedRecords = currentData(); - table.refresh(); long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); long dataSizeBefore = testDataSize(table); @@ -848,7 +839,6 @@ public void testBinPackWithStartingSequenceNumber() { List actualRecords = currentData(); assertEquals("Rows must match", expectedRecords, actualRecords); - table.refresh(); assertThat(table.currentSnapshot().sequenceNumber()) .as("Table sequence number should be incremented") .isGreaterThan(oldSequenceNumber); @@ -869,7 +859,6 @@ public void testBinPackWithStartingSequenceNumberV1Compatibility() { Table table = createTablePartitioned(4, 2, SCALE, properties); shouldHaveFiles(table, 8); List expectedRecords = currentData(); - table.refresh(); long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); assertThat(oldSequenceNumber).as("Table sequence number should be 0").isZero(); long dataSizeBefore = testDataSize(table); @@ -886,7 +875,6 @@ public void testBinPackWithStartingSequenceNumberV1Compatibility() { List actualRecords = currentData(); assertEquals("Rows must match", expectedRecords, actualRecords); - table.refresh(); assertThat(table.currentSnapshot().sequenceNumber()) .as("Table sequence number should still be 0") .isEqualTo(oldSequenceNumber); @@ -980,6 +968,7 @@ public void testBinPackCombineMixedFiles() { // Add one more small file, and one large file writeRecords(1, SCALE); writeRecords(1, SCALE * 3); + table.refresh(); shouldHaveFiles(table, 3); List expectedRecords = currentData(); @@ -1069,8 +1058,6 @@ public void testPartialProgressEnabled() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - shouldHaveSnapshots(table, 11); shouldHaveACleanCache(table); @@ -1097,8 +1084,6 @@ public void testMultipleGroups() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1126,8 +1111,6 @@ public void testPartialProgressMaxCommits() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1159,8 +1142,6 @@ public void testSingleCommitWithRewriteFailure() { .isInstanceOf(RuntimeException.class) .hasMessage("Rewrite Failed"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1193,8 +1174,6 @@ public void testSingleCommitWithCommitFailure() { .isInstanceOf(RuntimeException.class) .hasMessageContaining("Cannot commit rewrite"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1227,8 +1206,6 @@ public void testCommitFailsWithUncleanableFailure() { .isInstanceOf(RuntimeException.class) .hasMessageContaining("Arbitrary Failure"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1262,8 +1239,6 @@ public void testParallelSingleCommitWithRewriteFailure() { .isInstanceOf(CommitFailedException.class) .hasMessage("Rewrite Failed"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1302,8 +1277,6 @@ public void testPartialProgressWithRewriteFailure() { assertThat(result.failedDataFilesCount()).isEqualTo(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1345,8 +1318,6 @@ public void testParallelPartialProgressWithRewriteFailure() { assertThat(result.failedDataFilesCount()).isEqualTo(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1391,8 +1362,6 @@ public void testParallelPartialProgressWithCommitFailure() { assertThat(result.rewriteResults()).as("Should have 6 fileGroups").hasSize(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1431,8 +1400,6 @@ public void testParallelPartialProgressWithMaxFailedCommits() { .hasMessageContaining( "1 rewrite commits failed. This is more than the maximum allowed failures of 0"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1464,11 +1431,8 @@ public void testParallelPartialProgressWithMaxCommitsLargerThanTotalGroupCount() .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_FAILED_COMMITS, "1"); rewrite.execute(); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); - table.refresh(); assertThat(table.snapshots()) .as("Table did not have the expected number of snapshots") // To tolerate 1 random commit failure @@ -1544,8 +1508,6 @@ public void testSortMultipleGroups() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1554,7 +1516,7 @@ public void testSortMultipleGroups() { } @TestTemplate - public void testSimpleSort() { + public void testSimpleSort() throws IOException { Table table = createTable(20); shouldHaveFiles(table, 20); table.replaceSortOrder().asc("c2").commit(); @@ -1575,8 +1537,6 @@ public void testSimpleSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1584,10 +1544,11 @@ public void testSimpleSort() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); } @TestTemplate - public void testSortAfterPartitionChange() { + public void testSortAfterPartitionChange() throws IOException { Table table = createTable(20); shouldHaveFiles(table, 20); table.updateSpec().addField(Expressions.bucket("c1", 4)).commit(); @@ -1611,8 +1572,6 @@ public void testSortAfterPartitionChange() { .hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1620,10 +1579,11 @@ public void testSortAfterPartitionChange() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); } @TestTemplate - public void testSortCustomSortOrder() { + public void testSortCustomSortOrder() throws IOException { Table table = createTable(20); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveFiles(table, 20); @@ -1642,8 +1602,6 @@ public void testSortCustomSortOrder() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1651,13 +1609,15 @@ public void testSortCustomSortOrder() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate - public void testSortCustomSortOrderRequiresRepartition() { + public void testSortCustomSortOrderRequiresRepartition() throws IOException { int partitions = 4; Table table = createTable(); writeRecords(20, SCALE, partitions); + table.refresh(); shouldHaveLastCommitUnsorted(table, "c3"); // Add a partition column so this requires repartitioning @@ -1681,8 +1641,6 @@ public void testSortCustomSortOrderRequiresRepartition() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1691,10 +1649,40 @@ public void testSortCustomSortOrderRequiresRepartition() { shouldHaveMultipleFiles(table); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveLastCommitSorted(table, "c3"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate - public void testAutoSortShuffleOutput() { + public void testSortPastTableSortOrderGetsAppliedToFiles() throws IOException { + Table table = createTable(1); + + table.replaceSortOrder().asc("c3").commit(); + SortOrder c3SortOrder = table.sortOrder(); + + table.replaceSortOrder().asc("c2").commit(); + + List originalData = currentData(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c3").build()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + + table.refresh(); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + dataFilesShouldHaveSortOrderIdMatching(table, c3SortOrder); + } + + @TestTemplate + public void testAutoSortShuffleOutput() throws IOException { Table table = createTable(20); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveFiles(table, 20); @@ -1718,12 +1706,10 @@ public void testAutoSortShuffleOutput() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 40+ files") .hasSizeGreaterThanOrEqualTo(40); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1731,6 +1717,7 @@ public void testAutoSortShuffleOutput() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate @@ -1765,6 +1752,26 @@ public void testCommitStateUnknownException() { shouldHaveSnapshots(table, 2); // Commit actually Succeeded } + @TestTemplate + public void testZOrderWithZColumnCollision() { + Schema schema = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "ICEZVALUE", Types.StringType.get())); + + Table table = + TABLES.create( + schema, + PartitionSpec.unpartitioned(), + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + assertThatThrownBy(() -> basicRewrite(table).zOrder("c1", "c2").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot zorder because the table has a column named 'ICEZVALUE'"); + } + @TestTemplate public void testZOrderSort() { int originalFiles = 20; @@ -1797,12 +1804,10 @@ public void testZOrderSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 40+ files") .hasSizeGreaterThanOrEqualTo(40); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1862,12 +1867,10 @@ public void testZOrderAllTypesSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 1 file") .hasSize(1); - table.refresh(); - List postRaw = spark .read() @@ -2144,7 +2147,6 @@ protected long testDataSize(Table table) { } protected void shouldHaveMultipleFiles(Table table) { - table.refresh(); int numFiles = Iterables.size(table.newScan().planFiles()); assertThat(numFiles) .as(String.format("Should have multiple files, had %d", numFiles)) @@ -2152,7 +2154,6 @@ protected void shouldHaveMultipleFiles(Table table) { } protected void shouldHaveFiles(Table table, int numExpected) { - table.refresh(); int numFiles = Iterables.size(table.newScan().planFiles()); assertThat(numFiles).as("Did not have the expected number of files").isEqualTo(numExpected); } @@ -2173,7 +2174,6 @@ protected long shouldHaveMinSequenceNumberInPartition( } protected void shouldHaveSnapshots(Table table, int expectedSnapshots) { - table.refresh(); assertThat(table.snapshots()) .as("Table did not have the expected number of snapshots") .hasSize(expectedSnapshots); @@ -2226,13 +2226,13 @@ private Pair boundsOf(DataFile file, NestedField field, Class javaC private List, Pair>> checkForOverlappingFiles( Table table, String column) { - table.refresh(); NestedField field = table.schema().caseInsensitiveFindField(column); Class javaClass = (Class) field.type().typeId().javaClass(); Snapshot snapshot = table.currentSnapshot(); Map> filesByPartition = - Streams.stream(snapshot.addedDataFiles(table.io())) + Streams.stream( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) .collect(Collectors.groupingBy(DataFile::partition)); Stream, Pair>> overlaps = @@ -2307,6 +2307,7 @@ protected Table createTable() { protected Table createTable(int files) { Table table = createTable(); writeRecords(files, SCALE); + table.refresh(); return table; } @@ -2317,6 +2318,7 @@ protected Table createTablePartitioned( assertThat(table.currentSnapshot()).as("Table must be empty").isNull(); writeRecords(files, numRecords, partitions); + table.refresh(); return table; } @@ -2369,11 +2371,11 @@ private Table createTypeTestTable() { .mode("append") .save(tableLocation); + table.refresh(); return table; } protected int averageFileSize(Table table) { - table.refresh(); return (int) Streams.stream(table.newScan().planFiles()) .mapToLong(FileScanTask::length) @@ -2607,6 +2609,23 @@ public void testExecutorCacheForDeleteFilesDisabled() { .isFalse(); } + @TestTemplate + public void testZOrderUDFWithTimestampNTZType() { + SparkZOrderUDF zorderUDF = new SparkZOrderUDF(1, 16, 1024); + Dataset result = + spark + .sql("SELECT timestamp_ntz '2025-01-01 12:00:00' as test_col") + .withColumn( + "zorder_result", + zorderUDF.sortedLexicographically(col("test_col"), DataTypes.TimestampNTZType)); + + assertThat(result.schema().apply("zorder_result").dataType()).isEqualTo(DataTypes.BinaryType); + List rows = result.collectAsList(); + Row row = rows.get(0); + byte[] zorderBytes = row.getAs("zorder_result"); + assertThat(zorderBytes).isNotNull().isNotEmpty(); + } + private double percentFilesRequired(Table table, String col, String value) { return percentFilesRequired(table, new String[] {col}, new String[] {value}); } @@ -2634,4 +2653,17 @@ public boolean matches(RewriteFileGroup argument) { return groupIDs.contains(argument.info().globalIndex()); } } + + private void dataFilesSortOrderShouldMatchTableSortOrder(Table table) throws IOException { + dataFilesShouldHaveSortOrderIdMatching(table, table.sortOrder()); + } + + private void dataFilesShouldHaveSortOrderIdMatching(Table table, SortOrder sortOrder) + throws IOException { + try (CloseableIterable files = table.newScan().planFiles()) { + assertThat(files) + .extracting(fileScanTask -> fileScanTask.file().sortOrderId()) + .containsOnly(sortOrder.orderId()); + } + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java index 4f95416878e2..0120d4b5fe78 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java @@ -51,6 +51,7 @@ import org.apache.iceberg.ManifestFile; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.ManifestWriter; +import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; @@ -58,6 +59,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -195,6 +197,119 @@ public void testRewriteManifestsPreservesOptionalFields() throws IOException { } } + @TestTemplate + public void testRewriteV3ManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, null, "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteV3PartitionedManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, "AAAA", "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "BBBB", "BBBB"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteManifestsAfterV2ToV3Upgrade() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, spec, ImmutableMap.of(TableProperties.FORMAT_VERSION, "2"), tableLocation); + + ThreeColumnRecord record1 = new ThreeColumnRecord(1, null, "AAAA"); + ThreeColumnRecord record2 = new ThreeColumnRecord(2, "CCCC", "CCCC"); + writeRecords(Lists.newArrayList(record1)); + writeRecords(Lists.newArrayList(record2)); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + table + .updateProperties() + .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)) + .commit(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull() + .doesNotHaveDuplicates(); + assertThat(rowsAfter) + .extracting(r -> new ThreeColumnRecord(r.getAs("c1"), r.getAs("c2"), r.getAs("c3"))) + .containsExactlyInAnyOrder(record1, record2); + } + @TestTemplate public void testRewriteManifestsEmptyTable() throws IOException { PartitionSpec spec = PartitionSpec.unpartitioned(); @@ -728,7 +843,9 @@ public void testRewriteSmallManifestsNonPartitionedV2Table() { table.refresh(); Snapshot snapshot1 = table.currentSnapshot(); - DataFile file1 = Iterables.getOnlyElement(snapshot1.addedDataFiles(table.io())); + DataFile file1 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot1).build().addedDataFiles()); List records2 = Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC")); writeRecords(records2); @@ -736,7 +853,9 @@ public void testRewriteSmallManifestsNonPartitionedV2Table() { table.refresh(); Snapshot snapshot2 = table.currentSnapshot(); - DataFile file2 = Iterables.getOnlyElement(snapshot2.addedDataFiles(table.io())); + DataFile file2 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot2).build().addedDataFiles()); List manifests = table.currentSnapshot().allManifests(table.io()); assertThat(manifests).as("Should have 2 manifests before rewrite").hasSize(2); @@ -1134,6 +1253,17 @@ private List actualRecords() { .collectAsList(); } + private List recordsWithLineage() { + return spark + .read() + .format("iceberg") + .load(tableLocation) + .selectExpr( + MetadataColumns.ROW_ID.name(), MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), "*") + .orderBy(MetadataColumns.ROW_ID.name()) + .collectAsList(); + } + private void writeRecords(List records) { Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); writeDF(df); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java index 2d90c80da246..7734fee2b69e 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java @@ -251,7 +251,6 @@ public void testRewriteAll() throws Exception { @TestTemplate public void testRewriteFilter() throws Exception { Table table = createTablePartitioned(4, 2, SCALE); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); @@ -260,7 +259,6 @@ public void testRewriteFilter() throws Exception { List deleteFiles = deleteFiles(table); assertThat(deleteFiles).hasSize(8); - table.refresh(); List expectedRecords = records(table); List expectedDeletes = deleteRecords(table); assertThat(expectedRecords).hasSize(12000); @@ -536,7 +534,6 @@ public void testSomePartitionsDanglingDeletes() throws Exception { @TestTemplate public void testRewriteFilterRemoveDangling() throws Exception { Table table = createTablePartitioned(4, 2, SCALE); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles, true); @@ -545,7 +542,6 @@ public void testRewriteFilterRemoveDangling() throws Exception { List deleteFiles = deleteFiles(table); assertThat(deleteFiles).hasSize(8); - table.refresh(); List expectedRecords = records(table); List expectedDeletes = deleteRecords(table); assertThat(expectedRecords).hasSize(12000); // 16000 data - 4000 delete rows @@ -705,7 +701,6 @@ public void testSchemaEvolution() throws Exception { List newSchemaDeleteFiles = except(deleteFiles(table), deleteFiles); assertThat(newSchemaDeleteFiles).hasSize(4); - table.refresh(); List expectedDeletes = deleteRecords(table); List expectedRecords = records(table); assertThat(expectedDeletes).hasSize(4000); // 4 files * 1000 per file diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java index 0bcaf0af6581..dae721b1d73d 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java @@ -30,6 +30,8 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -46,6 +48,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StaticTableOperations; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; @@ -433,9 +436,9 @@ private void runPositionDeletesTest(String fileFormat) throws Exception { List> deletes = Lists.newArrayList( Pair.of( - tableWithPosDeletes - .currentSnapshot() - .addedDataFiles(tableWithPosDeletes.io()) + SnapshotChanges.builderFor(tableWithPosDeletes) + .build() + .addedDataFiles() .iterator() .next() .location(), @@ -480,7 +483,7 @@ private void runPositionDeletesTest(String fileFormat) throws Exception { @TestTemplate public void testPositionDeleteWithRow() throws Exception { String dataFileLocation = - table.currentSnapshot().addedDataFiles(table.io()).iterator().next().location(); + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next().location(); List> deletes = Lists.newArrayList(); OutputFile deleteFile = table @@ -530,7 +533,15 @@ public void testPositionDeletesAcrossFiles() throws Exception { .isEqualTo(2); Stream allFiles = StreamSupport.stream(table.snapshots().spliterator(), false) - .flatMap(s -> StreamSupport.stream(s.addedDataFiles(table.io()).spliterator(), false)); + .flatMap( + s -> + StreamSupport.stream( + SnapshotChanges.builderFor(table) + .snapshot(s) + .build() + .addedDataFiles() + .spliterator(), + false)); List> deletes = allFiles.map(f -> Pair.of((CharSequence) f.location(), 0L)).collect(Collectors.toList()); @@ -565,6 +576,79 @@ public void testPositionDeletesAcrossFiles() throws Exception { .isEmpty(); } + /** + * Test for https://github.com/apache/iceberg/issues/14814 + * + *

    This test verifies that rewrite_table_path correctly deduplicates delete files when the same + * delete file appears in multiple manifests. Without the DeleteFileSet fix, this test would fail + * with AlreadyExistsException because DeleteFile objects don't override equals() and the same + * file would be processed multiple times. + * + *

    The test creates a scenario where the same delete file is added to multiple snapshots, + * causing it to appear in multiple manifest entries. When these manifests are processed, the same + * delete file is returned as different object instances which need proper deduplication. + */ + @TestTemplate + public void testPositionDeletesDeduplication() throws Exception { + // Format versions 3 and 4 use Deletion Vectors stored in Puffin files, which have different + // validation rules that prevent adding the same delete file multiple times + assumeThat(formatVersion) + .as("Format versions 3+ use DVs with different validation rules") + .isEqualTo(2); + + Table tableWithPosDeletes = + createTableWithSnapshots( + tableDir.toFile().toURI().toString().concat("tableWithDuplicateDeletes"), + 2, + Map.of(TableProperties.DELETE_DEFAULT_FILE_FORMAT, "parquet")); + + // Get a data file to create position deletes for + DataFile dataFile = + tableWithPosDeletes + .currentSnapshot() + .addedDataFiles(tableWithPosDeletes.io()) + .iterator() + .next(); + + // Create a position delete file + List> deletes = Lists.newArrayList(Pair.of(dataFile.location(), 0L)); + File deleteFile = + new File( + removePrefix(tableWithPosDeletes.location() + "/data/deeply/nested/deletes.parquet")); + DeleteFile positionDeletes = + FileHelpers.writeDeleteFile( + tableWithPosDeletes, + tableWithPosDeletes.io().newOutputFile(deleteFile.toURI().toString()), + deletes, + formatVersion) + .first(); + + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // Add the SAME delete file AGAIN in a second snapshot - this creates a duplicate entry + // in a new manifest, which will cause duplicate DeleteFile objects when processing + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // This should NOT throw AlreadyExistsException - the fix uses DeleteFileSet to deduplicate + // Without the fix (using Collectors.toSet()), this would fail because: + // 1. Both manifests contain entries for the same delete file + // 2. Processing returns two different DeleteFile objects for the same file + // 3. HashSet doesn't deduplicate them (DeleteFile doesn't override equals()) + // 4. rewritePositionDeletes tries to write the same file twice -> AlreadyExistsException + RewriteTablePath.Result result = + actions() + .rewriteTablePath(tableWithPosDeletes) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(tableWithPosDeletes.location(), targetTableLocation()) + .execute(); + + // Verify the rewrite completed successfully - should have rewritten exactly 1 delete file + // (the duplicate should be deduplicated by DeleteFileSet) + assertThat(result.rewrittenDeleteFilePathsCount()) + .as("Should have rewritten exactly 1 delete file after deduplication") + .isEqualTo(1); + } + @TestTemplate public void testEqualityDeletes() throws Exception { Table sourceTable = createTableWithSnapshots(newTableLocation(), 1); @@ -704,7 +788,10 @@ public void testRewritePathWithNonLiveEntry() throws Exception { Snapshot oldest = SnapshotUtil.oldestAncestor(tableWith3Snaps); String oldestDataFilePath = Iterables.getOnlyElement( - tableWith3Snaps.snapshot(oldest.snapshotId()).addedDataFiles(tableWith3Snaps.io())) + SnapshotChanges.builderFor(tableWith3Snaps) + .snapshot(tableWith3Snaps.snapshot(oldest.snapshotId())) + .build() + .addedDataFiles()) .location(); String deletedDataFilePathInTargetLocation = String.format("%sdata/%s", targetTableLocation(), fileName(oldestDataFilePath)); @@ -1228,27 +1315,14 @@ public void testNestedDirectoryStructurePreservation() throws Exception { // Create position delete files with same names in different nested directories // This simulates the scenario tested in // TestRewriteTablePathUtil.testStagingPathPreservesDirectoryStructure + SnapshotChanges sourceChanges = SnapshotChanges.builderFor(sourceTable).build(); List> deletes1 = Lists.newArrayList( - Pair.of( - sourceTable - .currentSnapshot() - .addedDataFiles(sourceTable.io()) - .iterator() - .next() - .location(), - 0L)); + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); List> deletes2 = Lists.newArrayList( - Pair.of( - sourceTable - .currentSnapshot() - .addedDataFiles(sourceTable.io()) - .iterator() - .next() - .location(), - 0L)); + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); // Create delete files with same name in different nested paths (hash1/ and hash2/) File file1 = @@ -1331,6 +1405,28 @@ public void testRewritePathWithoutCreateFileList() throws Exception { .isEqualTo(NOT_APPLICABLE); } + @TestTemplate + public void testRewritePathWithExecutorService() throws Exception { + String sourceLocation = newTableLocation(); + Table sourceTable = createTableWithSnapshots(sourceLocation, 50); + + ExecutorService service = Executors.newFixedThreadPool(4); + try { + sourceTable.refresh(); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceLocation, targetTableLocation()) + .startVersion("v1.metadata.json") + .executeWith(service) + .execute(); + + checkFileNum(50, 50, 50, 200, result); + } finally { + service.shutdown(); + } + } + protected void checkFileNum( int versionFileCount, int manifestListCount, diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java index d9c42a07b853..9fac633e75ac 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java @@ -18,7 +18,11 @@ */ package org.apache.iceberg.spark.actions; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_HADOOP; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.IOException; import java.nio.file.Files; @@ -33,6 +37,7 @@ @ExtendWith(ParameterizedTestExtension.class) public class TestSnapshotTableAction extends CatalogTestBase { private static final String SOURCE_NAME = "spark_catalog.default.source"; + private static final String SOURCE = "source"; @AfterEach public void removeTables() { @@ -65,4 +70,92 @@ public void testSnapshotWithParallelTasks() throws IOException { .execute(); assertThat(snapshotThreadsIndex.get()).isEqualTo(2); } + + @TestTemplate + public void testSnapshotWithOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(actualSourceLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "The snapshot table location cannot be same as the source table location."); + + String destAsSubdirectory = actualSourceLocation + "/nested"; + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(destAsSubdirectory) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + + String parentLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - ("/" + SOURCE).length()); + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(parentLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + } + + @TestTemplate + public void testSnapshotWithNonOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + String validDestLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - SOURCE.length()) + + "newDestination"; + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(validDestLocation) + .execute(); + assertThat(sql("SELECT * FROM %s", tableName)).hasSize(2); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java index 0db6a65fd394..45053c1a4f1f 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java @@ -32,6 +32,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; @@ -108,8 +109,8 @@ protected boolean supportsRowLineage() { required(114, "dec_9_0", Types.DecimalType.of(9, 0)), // int encoded required(115, "dec_11_2", Types.DecimalType.of(11, 2)), // long encoded required(116, "dec_20_5", Types.DecimalType.of(20, 5)), // requires padding - required(117, "dec_38_10", Types.DecimalType.of(38, 10)) // Spark's maximum precision - ); + required(117, "dec_38_10", Types.DecimalType.of(38, 10)), // Spark's maximum precision + optional(118, "unk", Types.UnknownType.get())); @TempDir protected Path temp; @@ -120,10 +121,13 @@ public void testSimpleStruct() throws IOException { @Test public void testStructWithRequiredFields() throws IOException { + List supportedPrimitives = + SUPPORTED_PRIMITIVES.fields().stream() + .filter(f -> f.type().typeId() != Type.TypeID.UNKNOWN) + .collect(Collectors.toList()); writeAndValidate( TypeUtil.assignIncreasingFreshIds( - new Schema( - Lists.transform(SUPPORTED_PRIMITIVES.fields(), Types.NestedField::asRequired)))); + new Schema(Lists.transform(supportedPrimitives, Types.NestedField::asRequired)))); } @Test @@ -603,4 +607,48 @@ public void testRowLineage() throws Exception { record.copy(Map.of("id", 4L, "data", "d", "_row_id", 1_001L)), record.copy(Map.of("id", 5L, "data", "e")))); } + + @Test + public void testUnknownNestedLevel() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(1, "id", LongType.get()), + optional( + 2, + "nested", + Types.StructType.of( + required(20, "int", Types.IntegerType.get()), + optional(21, "unk", Types.UnknownType.get())))); + + writeAndValidate(schema); + } + + @Test + public void testUnknownListType() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional(1, "data", ListType.ofOptional(2, Types.UnknownType.get()))); + + writeAndValidate(schema); + } + + @Test + public void testUnknownMapType() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional( + 1, + "data", + MapType.ofOptional(2, 3, Types.StringType.get(), Types.UnknownType.get()))); + + writeAndValidate(schema); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java new file mode 100644 index 000000000000..b7ba7a34309a --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; + +/** Converts Iceberg Record to Spark InternalRow for testing. */ +public class InternalRowConverter { + private static final OffsetDateTime EPOCH = Instant.ofEpochSecond(0).atOffset(ZoneOffset.UTC); + private static final LocalDate EPOCH_DAY = EPOCH.toLocalDate(); + + private InternalRowConverter() {} + + public static InternalRow convert(Schema schema, Record record) { + return convert(schema.asStruct(), record); + } + + private static InternalRow convert(Types.StructType struct, Record record) { + GenericInternalRow internalRow = new GenericInternalRow(struct.fields().size()); + List fields = struct.fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + + Type fieldType = field.type(); + internalRow.update(i, convert(fieldType, record.get(i))); + } + + return internalRow; + } + + private static Object convert(Type type, Object value) { + if (value == null) { + return null; + } + + return switch (type.typeId()) { + case BOOLEAN, INTEGER, LONG, FLOAT, DOUBLE -> value; + case DATE -> (int) ChronoUnit.DAYS.between(EPOCH_DAY, (LocalDate) value); + case TIMESTAMP -> + ((Types.TimestampType) type).shouldAdjustToUTC() + ? ChronoUnit.MICROS.between(EPOCH, (OffsetDateTime) value) + : ChronoUnit.MICROS.between(EPOCH, ((LocalDateTime) value).atZone(ZoneId.of("UTC"))); + case STRING -> UTF8String.fromString((String) value); + case UUID -> UTF8String.fromString(value.toString()); + case FIXED, BINARY -> { + ByteBuffer buffer = (ByteBuffer) value; + yield Arrays.copyOfRange( + buffer.array(), + buffer.arrayOffset() + buffer.position(), + buffer.arrayOffset() + buffer.remaining()); + } + case DECIMAL -> Decimal.apply((BigDecimal) value); + case STRUCT -> convert((Types.StructType) type, (Record) value); + case LIST -> + new GenericArrayData( + ((List) value) + .stream() + .map(element -> convert(type.asListType().elementType(), element)) + .toArray()); + case MAP -> + new ArrayBasedMapData( + new GenericArrayData( + ((Map) value) + .keySet().stream() + .map(o -> convert(type.asMapType().keyType(), o)) + .toArray()), + new GenericArrayData( + ((Map) value) + .values().stream() + .map(o -> convert(type.asMapType().valueType(), o)) + .toArray())); + // TIME is not supported by Spark, VARIANT not yet implemented + default -> + throw new UnsupportedOperationException( + "Unsupported type for conversion to InternalRow: " + type); + }; + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java index 120d6eeb1730..72f9a3660965 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java @@ -52,10 +52,16 @@ import org.apache.iceberg.ManifestFile; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableScan; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.deletes.DeleteCounter; +import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.relocated.com.google.common.collect.Streams; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.types.Type; @@ -895,4 +901,69 @@ public static Dataset selectNonDerived(Dataset metadataTable) { public static Types.StructType nonDerivedSchema(Dataset metadataTable) { return SparkSchemaUtil.convert(TestHelpers.selectNonDerived(metadataTable).schema()).asStruct(); } + + public static class CustomizedDeleteFilter extends DeleteFilter { + private final boolean hasDeletes; + + protected CustomizedDeleteFilter( + boolean hasDeletes, Schema tableSchema, Schema projectedSchema) { + super("", List.of(), tableSchema, projectedSchema, new DeleteCounter(), true); + this.hasDeletes = hasDeletes; + } + + @Override + protected StructLike asStructLike(InternalRow record) { + return null; + } + + @Override + protected InputFile getInputFile(String location) { + return null; + } + + @Override + public boolean hasPosDeletes() { + return hasDeletes; + } + + @Override + public PositionDeleteIndex deletedRowPositions() { + PositionDeleteIndex deletedRowPos = new CustomizedPositionDeleteIndex(); + if (hasDeletes) { + deletedRowPos.delete(98, 103); + } + + return deletedRowPos; + } + } + + public static class CustomizedPositionDeleteIndex implements PositionDeleteIndex { + private final Set deleteIndex; + + private CustomizedPositionDeleteIndex() { + deleteIndex = Sets.newHashSet(); + } + + @Override + public void delete(long position) { + deleteIndex.add(position); + } + + @Override + public void delete(long posStart, long posEnd) { + for (long l = posStart; l < posEnd; l++) { + delete(l); + } + } + + @Override + public boolean isDeleted(long position) { + return deleteIndex.contains(position); + } + + @Override + public boolean isEmpty() { + return deleteIndex.isEmpty(); + } + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java new file mode 100644 index 000000000000..291bb2bca4f5 --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseFormatModelTests; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Type; +import org.apache.spark.sql.catalyst.InternalRow; + +public class TestSparkFormatModel extends BaseFormatModelTests { + + @Override + protected Class engineType() { + return InternalRow.class; + } + + @Override + protected Object engineSchema(Schema schema) { + return SparkSchemaUtil.convert(schema); + } + + @Override + protected InternalRow convertToEngine(Record record, Schema schema) { + return InternalRowConverter.convert(schema, record); + } + + @Override + protected void assertEquals(Schema schema, List expected, List actual) { + assertThat(actual).hasSameSizeAs(expected); + for (int i = 0; i < expected.size(); i++) { + TestHelpers.assertEquals(schema, expected.get(i), actual.get(i)); + } + } + + @Override + protected Object convertConstantToEngine(Type type, Object value) { + return SparkUtil.internalToSpark(type, value); + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java index 9d725250d3d2..13acaa1e3a7b 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java @@ -20,6 +20,7 @@ import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.File; import java.io.IOException; @@ -36,6 +37,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; +import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.exceptions.RuntimeIOException; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; @@ -46,6 +48,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.spark.source.BatchReaderUtil; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; import org.apache.orc.OrcConf; @@ -74,7 +77,11 @@ public class TestSparkOrcReadMetadataColumns { MetadataColumns.ROW_POSITION, MetadataColumns.IS_DELETED); + private static final DeleteFilter NO_DELETES_FILTER = + new TestHelpers.CustomizedDeleteFilter(false, DATA_SCHEMA, PROJECTION_SCHEMA); + private static final int NUM_ROWS = 1000; + private static final int RECORDS_PER_BATCH = 10; private static final List DATA_ROWS; private static final List EXPECTED_ROWS; @@ -128,13 +135,35 @@ public void writeFile() throws IOException { @TestTemplate public void testReadRowNumbers() throws IOException { - readAndValidate(null, null, null, EXPECTED_ROWS); + readAndValidate(null, null, null, EXPECTED_ROWS, NO_DELETES_FILTER); + } + + @TestTemplate + public void testReadRowNumbersWithDelete() throws IOException { + assumeThat(vectorized).isTrue(); + + List expectedRowsAfterDelete = Lists.newArrayList(); + EXPECTED_ROWS.forEach(row -> expectedRowsAfterDelete.add(row.copy())); + // remove row at position 98, 99, 100, 101, 102, this crosses two row groups [0, 100) and [100, + // 200) + for (int i = 98; i <= 102; i++) { + expectedRowsAfterDelete.get(i).update(3, true); + } + + DeleteFilter deleteFilter = + new TestHelpers.CustomizedDeleteFilter(true, DATA_SCHEMA, PROJECTION_SCHEMA); + + readAndValidate(null, null, null, expectedRowsAfterDelete, deleteFilter); } @TestTemplate public void testReadRowNumbersWithFilter() throws IOException { readAndValidate( - Expressions.greaterThanOrEqual("id", 500), null, null, EXPECTED_ROWS.subList(500, 1000)); + Expressions.greaterThanOrEqual("id", 500), + null, + null, + EXPECTED_ROWS.subList(500, 1000), + NO_DELETES_FILTER); } @TestTemplate @@ -157,12 +186,17 @@ public void testReadRowNumbersWithSplits() throws IOException { null, splitOffsets.get(i), splitLengths.get(i), - EXPECTED_ROWS.subList(i * 100, (i + 1) * 100)); + EXPECTED_ROWS.subList(i * 100, (i + 1) * 100), + NO_DELETES_FILTER); } } private void readAndValidate( - Expression filter, Long splitStart, Long splitLength, List expected) + Expression filter, + Long splitStart, + Long splitLength, + List expected, + DeleteFilter deleteFilter) throws IOException { Schema projectionWithoutMetadataFields = TypeUtil.selectNot(PROJECTION_SCHEMA, MetadataColumns.metadataFieldIds()); @@ -173,10 +207,12 @@ private void readAndValidate( if (vectorized) { builder = - builder.createBatchedReaderFunc( - readOrcSchema -> - VectorizedSparkOrcReaders.buildReader( - PROJECTION_SCHEMA, readOrcSchema, ImmutableMap.of())); + builder + .recordsPerBatch(RECORDS_PER_BATCH) + .createBatchedReaderFunc( + readOrcSchema -> + VectorizedSparkOrcReaders.buildReader( + PROJECTION_SCHEMA, readOrcSchema, ImmutableMap.of())); } else { builder = builder.createReaderFunc( @@ -192,7 +228,7 @@ private void readAndValidate( } if (vectorized) { - reader = batchesToRows(builder.build()); + reader = batchesToRows(BatchReaderUtil.applyDeleteFilter(builder.build(), deleteFilter)); } else { reader = builder.build(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java index 546a44fc77bb..a1f71848b14e 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java @@ -21,6 +21,7 @@ import static org.apache.iceberg.spark.data.TestHelpers.assertEquals; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.io.IOException; @@ -106,4 +107,20 @@ private void writeAndValidateRecords(Schema schema, Iterable expect private Iterator batchesToRows(Iterator batches) { return Iterators.concat(Iterators.transform(batches, ColumnarBatch::rowIterator)); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java index ccd783915c62..e2e5a98ccb8b 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java @@ -26,7 +26,6 @@ import java.io.IOException; import java.util.Iterator; import java.util.List; -import java.util.Set; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.iceberg.Files; @@ -35,21 +34,16 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; -import org.apache.iceberg.StructLike; import org.apache.iceberg.data.DeleteFilter; -import org.apache.iceberg.deletes.DeleteCounter; -import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.parquet.ParquetSchemaUtil; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; import org.apache.iceberg.spark.source.BatchReaderUtil; @@ -183,7 +177,8 @@ public void testReadRowNumbersWithDelete() throws IOException { Parquet.ReadBuilder builder = Parquet.read(Files.localInput(testFile)).project(PROJECTION_SCHEMA); - DeleteFilter deleteFilter = new TestDeleteFilter(true); + DeleteFilter deleteFilter = + new TestHelpers.CustomizedDeleteFilter(true, DATA_SCHEMA, PROJECTION_SCHEMA); builder.createBatchedReaderFunc( fileSchema -> @@ -194,70 +189,6 @@ public void testReadRowNumbersWithDelete() throws IOException { validate(expectedRowsAfterDelete, builder, deleteFilter); } - private static class TestDeleteFilter extends DeleteFilter { - private final boolean hasDeletes; - - protected TestDeleteFilter(boolean hasDeletes) { - super("", List.of(), DATA_SCHEMA, PROJECTION_SCHEMA, new DeleteCounter(), true); - this.hasDeletes = hasDeletes; - } - - @Override - protected StructLike asStructLike(InternalRow record) { - return null; - } - - @Override - protected InputFile getInputFile(String location) { - return null; - } - - @Override - public boolean hasPosDeletes() { - return hasDeletes; - } - - @Override - public PositionDeleteIndex deletedRowPositions() { - PositionDeleteIndex deletedRowPos = new CustomizedPositionDeleteIndex(); - if (hasDeletes) { - deletedRowPos.delete(98, 103); - } - - return deletedRowPos; - } - } - - private static class CustomizedPositionDeleteIndex implements PositionDeleteIndex { - private final Set deleteIndex; - - private CustomizedPositionDeleteIndex() { - deleteIndex = Sets.newHashSet(); - } - - @Override - public void delete(long position) { - deleteIndex.add(position); - } - - @Override - public void delete(long posStart, long posEnd) { - for (long l = posStart; l < posEnd; l++) { - delete(l); - } - } - - @Override - public boolean isDeleted(long position) { - return deleteIndex.contains(position); - } - - @Override - public boolean isEmpty() { - return deleteIndex.isEmpty(); - } - } - @TestTemplate public void testReadRowNumbersWithFilter() throws IOException { // current iceberg supports row group filter. @@ -314,7 +245,10 @@ private void readAndValidate( builder = builder.split(splitStart, splitLength); } - validate(expected, builder, new TestDeleteFilter(false)); + validate( + expected, + builder, + new TestHelpers.CustomizedDeleteFilter(false, DATA_SCHEMA, PROJECTION_SCHEMA)); } private void validate( diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java index 9ae8b8cbe530..993dc868bba8 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java @@ -249,4 +249,20 @@ public void testMissingRequiredWithoutDefault() { .isInstanceOf(IllegalArgumentException.class) .hasMessage("Missing required field: missing_str"); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java index 8e1f860085c6..3c88db139e47 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java @@ -20,6 +20,7 @@ import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.io.IOException; @@ -152,4 +153,20 @@ private static void assertEqualsUnsafe( .isFalse(); assertThat(actualIter.hasNext()).as("Actual iterator should not have any extra rows").isFalse(); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/parquet/vectorized/TestParquetVectorizedReads.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/parquet/vectorized/TestParquetVectorizedReads.java index 6bff9010eb4f..1f9e46ddbfe6 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/parquet/vectorized/TestParquetVectorizedReads.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/data/parquet/vectorized/TestParquetVectorizedReads.java @@ -293,14 +293,18 @@ public void testReadsForTypePromotedColumns() throws Exception { @Test public void testSupportedReadsForParquetV2() throws Exception { - // Float and double column types are written using plain encoding with Parquet V2, - // also Parquet V2 will dictionary encode decimals that use fixed length binary - // (i.e. decimals > 8 bytes) + // Parquet V2 uses PLAIN for float/double, DELTA_BINARY_PACKED for int/long, + // DELTA_LENGTH_BYTE_ARRAY for string/binary, and dictionary encoding for decimals + // that use fixed length binary (i.e. decimals > 8 bytes). Schema schema = new Schema( optional(102, "float_data", Types.FloatType.get()), optional(103, "double_data", Types.DoubleType.get()), - optional(104, "decimal_data", Types.DecimalType.of(25, 5))); + optional(104, "decimal_data", Types.DecimalType.of(25, 5)), + optional(105, "int_data", Types.IntegerType.get()), + optional(106, "long_data", Types.LongType.get()), + optional(107, "string_data", Types.StringType.get()), + optional(108, "binary_data", Types.BinaryType.get())); OutputFile outputFile = new InMemoryOutputFile(); Iterable data = diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java index 05dec42a031a..80cc3556b3c4 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java @@ -32,6 +32,7 @@ import org.apache.iceberg.Files; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.Tables; @@ -125,9 +126,9 @@ public void testAlternateLocation() throws IOException { writeRecords(table, RandomData.generateList(table.schema(), 100, 87112L)); - table - .currentSnapshot() - .addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() .forEach( dataFile -> assertThat(dataFile.location()) diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java index 6647a1b483e0..91d07e3647c9 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java @@ -37,6 +37,7 @@ import org.apache.iceberg.TableProperties; import org.apache.iceberg.hadoop.HadoopTables; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.AvroDataTestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; @@ -62,6 +63,7 @@ public static void startSpark() { SparkSession.builder() .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) .master("local[2]") + .config(TestBase.DISABLE_UI) .getOrCreate(); ScanTestBase.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); } @@ -93,14 +95,11 @@ protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throw HadoopTables tables = new HadoopTables(CONF); // If V3 spec features are used, set the format version to 3 - Map tableProperties = - writeSchema.columns().stream() - .anyMatch(f -> f.initialDefaultLiteral() != null || f.writeDefaultLiteral() != null) - ? ImmutableMap.of(TableProperties.FORMAT_VERSION, "3") - : ImmutableMap.of(); + Map tableProperties = ImmutableMap.of(TableProperties.FORMAT_VERSION, "3"); Table table = tables.create( writeSchema, PartitionSpec.unpartitioned(), tableProperties, location.toString()); + configureTable(table); // Important: use the table's schema for the rest of the test // When tables are created, the column ids are reassigned. diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..b6017e2001e7 --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.iceberg.Snapshot; +import org.junit.jupiter.api.Test; + +class TestAsyncSparkMicroBatchPlanner { + + @Test + void reachedAvailableNowCapReturnsTrueOnlyForExactCapSnapshot() { + Snapshot capSnapshot = mockSnapshot(10L); + Snapshot laterSnapshotWithHigherId = mockSnapshot(20L); + Snapshot laterSnapshotWithLowerId = mockSnapshot(5L); + StreamingOffset capOffset = new StreamingOffset(10L, 3L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(capSnapshot, capOffset)).isTrue(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap( + laterSnapshotWithHigherId, capOffset)) + .isFalse(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(laterSnapshotWithLowerId, capOffset)) + .isFalse(); + } + + @Test + void reachedAvailableNowCapReturnsFalseWhenCapOrSnapshotIsMissing() { + Snapshot readFrom = mockSnapshot(10L); + StreamingOffset capOffset = new StreamingOffset(10L, 1L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(readFrom, null)).isFalse(); + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(null, capOffset)).isFalse(); + } + + private Snapshot mockSnapshot(long snapshotId) { + Snapshot snapshot = mock(Snapshot.class); + when(snapshot.snapshotId()).thenReturn(snapshotId); + return snapshot; + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java index 1e53710a0f7f..8e26a2f42646 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java @@ -90,7 +90,7 @@ private static class ClosureTrackingReader extends BaseReader tracker = Maps.newHashMap(); ClosureTrackingReader(Table table, List tasks) { - super(table, new BaseCombinedScanTask(tasks), null, null, false, true); + super(table, table.io(), new BaseCombinedScanTask(tasks), null, null, false, true); } @Override diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java index b88f0233e203..be4391aab668 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java @@ -105,7 +105,8 @@ public void testInsert() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -136,7 +137,8 @@ public void testDelete() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -170,7 +172,8 @@ public void testDataFileRewrite() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -197,7 +200,8 @@ public void testMixDeleteAndInsert() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java index 24a14bb64d86..678d23de85ca 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java @@ -207,7 +207,8 @@ public void testWriteDataWithDifferentSetting() throws Exception { .append(); Table table = catalog.loadTable(TableIdentifier.of("default", TABLE_NAME)); List manifestFiles = table.currentSnapshot().dataManifests(table.io()); - try (ManifestReader reader = ManifestFiles.read(manifestFiles.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifestFiles.get(0), table.io(), table.specs())) { DataFile file = reader.iterator().next(); InputFile inputFile = table.io().newInputFile(file.location()); assertThat(getCompressionType(inputFile)) diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java index 99d3f38ee7eb..a7e8d76969b6 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java @@ -35,6 +35,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -206,7 +207,7 @@ public void testSplitOptionsOverridesTableProperties() throws IOException { .save(tableLocation); List files = - Lists.newArrayList(icebergTable.currentSnapshot().addedDataFiles(icebergTable.io())); + Lists.newArrayList(SnapshotChanges.builderFor(icebergTable).build().addedDataFiles()); assertThat(files).as("Should have written 1 file").hasSize(1); long fileSize = files.get(0).fileSizeInBytes(); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java index 308b1bd2c646..cfc38ed66fac 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java @@ -58,6 +58,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.GenericsHelpers; import org.apache.iceberg.transforms.Transforms; import org.apache.iceberg.types.Types; @@ -125,6 +126,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); // define UDFs used by partition tests diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java index c03f7b94eca9..dcd9c2897e08 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java @@ -46,6 +46,7 @@ import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.types.Types; @@ -99,6 +100,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java index 09219963f7a3..b07c41783dfb 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java @@ -46,10 +46,12 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.Files; +import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -256,7 +258,7 @@ public void testEntriesTableDataFilePrune() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List singleActual = rowsToJava( @@ -289,7 +291,7 @@ public void testEntriesTableDataFilePruneMulti() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List multiActual = rowsToJava( @@ -327,7 +329,7 @@ public void testFilesSelectMap() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List multiActual = rowsToJava( @@ -633,7 +635,7 @@ public void testFilesUnpartitionedTable() throws Exception { table.refresh(); DataFile toDelete = - Iterables.getOnlyElement(table.currentSnapshot().addedDataFiles(table.io())); + Iterables.getOnlyElement(SnapshotChanges.builderFor(table).build().addedDataFiles()); // add a second file df2.select("id", "data") @@ -820,6 +822,10 @@ public void testHistoryTable() { // rollback the table state to the first snapshot table.manageSnapshots().rollbackTo(firstSnapshotId).commit(); long rollbackTimestamp = Iterables.getLast(table.history()).timestampMillis(); + assertThat(rollbackTimestamp) + .as("Rollback history timestamp should be greater than first snapshot timestamp") + .isEqualTo(((HasTableOperations) table).operations().current().lastUpdatedMillis()) + .isGreaterThan(firstSnapshotTimestamp); inputDf .select("id", "data") @@ -1128,19 +1134,16 @@ public void testPruneManifestsTable() { .mode("append") .save(loadLocation(tableIdentifier)); - if (!spark.version().startsWith("2")) { - // Spark 2 isn't able to actually push down nested struct projections so this will not break - assertThatThrownBy( - () -> - spark - .read() - .format("iceberg") - .load(loadLocation(tableIdentifier, "manifests")) - .select("partition_spec_id", "path", "partition_summaries.contains_null") - .collectAsList()) - .isInstanceOf(SparkException.class) - .hasMessageContaining("Cannot project a partial list element struct"); - } + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "manifests")) + .select("partition_spec_id", "path", "partition_summaries.contains_null") + .collectAsList()) + .isInstanceOf(SparkException.class) + .hasMessageContaining("Cannot project a partial list element struct"); Dataset actualDf = spark @@ -1317,7 +1320,7 @@ public void testUnpartitionedPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.currentSnapshot().addedDataFiles(table.io()))) + totalSizeInBytes(SnapshotChanges.builderFor(table).build().addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1386,7 +1389,11 @@ public void testPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1402,7 +1409,11 @@ public void testPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(secondCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(secondCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1666,7 +1677,11 @@ public void testPartitionsTableDeleteStats() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1682,7 +1697,11 @@ public void testPartitionsTableDeleteStats() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 2L) // should be incremented now .set("position_delete_file_count", 2) // should be incremented now .set("equality_delete_record_count", 0L) @@ -2410,7 +2429,7 @@ private DeleteFile writePosDeleteFile(Table table) { private DeleteFile writePosDeleteFile(Table table, long pos) { DataFile dataFile = - Iterables.getFirst(table.currentSnapshot().addedDataFiles(table.io()), null); + Iterables.getFirst(SnapshotChanges.builderFor(table).build().addedDataFiles(), null); PartitionSpec dataFileSpec = table.specs().get(dataFile.specId()); StructLike dataFilePartition = dataFile.partition(); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java index f4f57157e479..a637b975fe2b 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java @@ -28,6 +28,7 @@ import java.sql.Timestamp; import java.util.List; import org.apache.iceberg.spark.IcebergSpark; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.transforms.Transforms; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Row; @@ -51,6 +52,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java index 05e1cae51666..63fef0f2e37a 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java @@ -47,6 +47,18 @@ public void testTime() { // Spark does not support time fields. } + @Disabled + @Override + public void testTimestampNanoWithoutZone() { + // Spark does not support nanosecond timestamp without zone. + } + + @Disabled + @Override + public void testTimestampNanoWithZone() { + // Spark does not support nanosecond timestamp with zone. + } + @Override protected void generateAndValidate(Schema schema, AssertMethod assertMethod) { int numRecords = 100; diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java index c21ccd0100db..f74446b2f416 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java @@ -33,6 +33,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; @@ -200,7 +201,8 @@ private GenericRecord createNestedRecord(Long longCol, Double doubleCol) { @TestTemplate public void testPrimitiveColumns() throws Exception { Table table = createPrimitiveTable(); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); Map columnSizeStats = dataFile.columnSizes(); Object[] binaryCol = diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java new file mode 100644 index 000000000000..a9ce340fd4ec --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMicroBatchPlanningUtils extends CatalogTestBase { + + private Table table; + + @BeforeEach + public void setupTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql( + "CREATE TABLE %s " + + "(id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(3, id))", + tableName); + this.table = validationCatalog.loadTable(tableIdent); + } + + @AfterEach + public void dropTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testUnpackedLimitsCompositeChoosesMinimum() { + ReadLimit[] limits = + new ReadLimit[] { + ReadLimit.maxRows(10), ReadLimit.maxRows(4), ReadLimit.maxFiles(8), ReadLimit.maxFiles(2) + }; + + ReadLimit composite = ReadLimit.compositeLimit(limits); + + BaseSparkMicroBatchPlanner.UnpackedLimits unpacked = + new BaseSparkMicroBatchPlanner.UnpackedLimits(composite); + + assertThat(unpacked.getMaxRows()).isEqualTo(4); + assertThat(unpacked.getMaxFiles()).isEqualTo(2); + } + + @TestTemplate + public void testDetermineStartingOffsetWithTimestampBetweenSnapshots() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + long snapshot1Time = table.currentSnapshot().timestampMillis(); + + sql("INSERT INTO %s VALUES (2, 'two')", tableName); + table.refresh(); + long snapshot2Id = table.currentSnapshot().snapshotId(); + + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, snapshot1Time + 1); + + assertThat(offset.snapshotId()).isEqualTo(snapshot2Id); + assertThat(offset.position()).isEqualTo(0L); + assertThat(offset.shouldScanAllFiles()).isFalse(); + } + + @TestTemplate + public void testAddedFilesCountUsesSummaryWhenPresent() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + + long expectedAddedFiles = + Long.parseLong(table.currentSnapshot().summary().get(SnapshotSummary.ADDED_FILES_PROP)); + + long actual = MicroBatchUtils.addedFilesCount(table, table.currentSnapshot()); + + assertThat(actual).isEqualTo(expectedAddedFiles); + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java index 35be6423ee23..892e260f66f0 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java @@ -18,9 +18,13 @@ */ package org.apache.iceberg.spark.source; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import org.apache.iceberg.FileFormat; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; +import org.apache.spark.SparkException; +import org.junit.jupiter.api.Test; public class TestORCDataFrameWrite extends DataFrameWriteTestBase { @Override @@ -30,4 +34,24 @@ protected void configureTable(Table table) { .set(TableProperties.DEFAULT_FILE_FORMAT, FileFormat.ORC.toString()) .commit(); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java index 90a9ac48a486..c24d92ef30af 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java @@ -18,9 +18,13 @@ */ package org.apache.iceberg.spark.source; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import org.apache.iceberg.FileFormat; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; +import org.apache.spark.SparkException; +import org.junit.jupiter.api.Test; public class TestParquetDataFrameWrite extends DataFrameWriteTestBase { @Override @@ -30,4 +34,24 @@ protected void configureTable(Table table) { .set(TableProperties.DEFAULT_FILE_FORMAT, FileFormat.PARQUET.toString()) .commit(); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java index 6b9ec85b7f0b..6056f1a7929d 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java @@ -19,6 +19,7 @@ package org.apache.iceberg.spark.source; import static org.apache.iceberg.Files.localOutput; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assumptions.assumeThat; import java.io.File; @@ -37,6 +38,7 @@ import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; public class TestParquetScan extends ScanTestBase { protected boolean vectorized() { @@ -84,4 +86,20 @@ protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throw super.writeAndValidate(writeSchema, expectedSchema); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java index e1402396fa7f..cf3097ebdb30 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java @@ -59,6 +59,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.transforms.Transforms; import org.apache.iceberg.types.Types; import org.apache.spark.api.java.JavaRDD; @@ -118,6 +119,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestPartitionPruning.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); @@ -241,14 +243,7 @@ public void testPartitionPruningTruncatedStringComparingValueShorterThanPartitio @TestTemplate public void testPartitionPruningHourlyPartition() { - String filterCond; - if (spark.version().startsWith("2")) { - // Looks like from Spark 2 we need to compare timestamp with timestamp to push down the - // filter. - filterCond = "timestamp >= to_timestamp('2020-02-03T01:00:00')"; - } else { - filterCond = "timestamp >= '2020-02-03T01:00:00'"; - } + String filterCond = "timestamp >= '2020-02-03T01:00:00'"; Predicate partCondition = (Row r) -> { int hourValue = r.getInt(4); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java index b0ad930487b1..82575a720236 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java @@ -46,6 +46,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.types.Types; @@ -112,6 +113,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java index 764e1c6c9370..92aace3dfd5c 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java @@ -180,6 +180,7 @@ public void readPositionDeletesTableWithMultipleDeleteFiles() throws IOException try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), positionDeletesTable.schema(), projectedSchema, @@ -220,6 +221,7 @@ public void readPositionDeletesTableWithMultipleDeleteFiles() throws IOException try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask2)), positionDeletesTable.schema(), projectedSchema, @@ -292,6 +294,7 @@ public void readPositionDeletesTableWithDifferentColumnOrdering() throws IOExcep try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), positionDeletesTable.schema(), projectedSchema, diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java index c30a730917ae..0a5e064aced2 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java @@ -209,6 +209,60 @@ public void testPartitionedTable() throws IOException { dropTable(tableName); } + @TestTemplate + public void testArrayColumnFilter() throws IOException { + assumeThat(formatVersion) + .as("Row content in position_deletes is required for array column filter test") + .isEqualTo(2); + String tableName = "array_column_filter"; + Schema schemaWithArray = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional( + 3, "arr_col", Types.ListType.ofOptional(4, Types.IntegerType.get()))); + Table tab = createTable(tableName, schemaWithArray, PartitionSpec.unpartitioned()); + + GenericRecord record1 = GenericRecord.create(schemaWithArray); + record1.set(0, 1); + record1.set(1, "a"); + record1.set(2, ImmutableList.of(1, 2)); + GenericRecord record2 = GenericRecord.create(schemaWithArray); + record2.set(0, 2); + record2.set(1, "b"); + record2.set(2, ImmutableList.of(3, 4)); + List dataRecords = ImmutableList.of(record1, record2); + DataFile dFile = + FileHelpers.writeDataFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + dataRecords); + tab.newAppend().appendFile(dFile).commit(); + + List> deletes = + ImmutableList.of( + positionDelete(schemaWithArray, dFile.location(), 0L, 1, "a", ImmutableList.of(1, 2)), + positionDelete(schemaWithArray, dFile.location(), 1L, 2, "b", ImmutableList.of(3, 4))); + DeleteFile posDeletes = + FileHelpers.writePosDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + deletes, + formatVersion); + tab.newRowDelta().addDeletes(posDeletes).commit(); + + // Filter directly on array column: row.arr_col = array(1, 2) + StructLikeSet actual = actual(tableName, tab, "row.arr_col = array(1, 2)"); + StructLikeSet expected = expected(tab, ImmutableList.of(deletes.get(0)), null, posDeletes); + + assertThat(actual) + .as("Filtering position_deletes by row.arr_col = array(1, 2) should return matching row") + .isEqualTo(expected); + dropTable(tableName); + } + @TestTemplate public void testSelect() throws IOException { assumeThat(formatVersion).as("DVs don't have row info in PositionDeletesTable").isEqualTo(2); @@ -409,7 +463,7 @@ public void testPartitionFilter() throws IOException { // Add position deletes for both partitions Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); - Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileA, "b"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); @@ -455,7 +509,7 @@ public void testPartitionTransformFilter() throws IOException { Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, new Object[] {"aa"}, new Object[] {"a"}); Pair>, DeleteFile> deletesB = - deleteFile(tab, dataFileA, new Object[] {"bb"}, new Object[] {"b"}); + deleteFile(tab, dataFileB, new Object[] {"bb"}, new Object[] {"b"}); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); // Prepare expected values @@ -496,7 +550,7 @@ public void testPartitionEvolutionReplace() throws Exception { DataFile dataFileB = dataFile(tab, "b"); tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); - Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileA, "b"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); // Switch partition spec from (data) to (id) @@ -508,7 +562,7 @@ public void testPartitionEvolutionReplace() throws Exception { tab.newAppend().appendFile(dataFile10).appendFile(dataFile99).commit(); Pair>, DeleteFile> deletes10 = deleteFile(tab, dataFile10, 10); - Pair>, DeleteFile> deletes99 = deleteFile(tab, dataFile10, 99); + Pair>, DeleteFile> deletes99 = deleteFile(tab, dataFile99, 99); tab.newRowDelta().addDeletes(deletes10.second()).addDeletes(deletes99.second()).commit(); // Query partition of old spec diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java new file mode 100644 index 000000000000..e025114f4fd9 --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.apache.iceberg.io.FileIO; +import org.junit.jupiter.api.Test; + +class TestSerializableFileIOWithSize { + + @Test + void newInputFileWithLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + long length = 1024L; + + serializableFileIO.newInputFile(path, length); + + verify(mockFileIO).newInputFile(path, length); + } + + @Test + void newInputFileWithoutLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + + serializableFileIO.newInputFile(path); + + verify(mockFileIO).newInputFile(path); + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java index 11865db7fce5..fe754f4a02ba 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java @@ -42,6 +42,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; @@ -91,6 +92,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java index 833a0b6b5eb0..f56333649261 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java @@ -59,6 +59,7 @@ import org.apache.iceberg.spark.SparkDataFile; import org.apache.iceberg.spark.SparkDeleteFile; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.types.Conversions; import org.apache.iceberg.types.Types; @@ -125,6 +126,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestSparkDataFile.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); } @@ -183,7 +185,8 @@ private void checkSparkContentFiles(Table table) throws IOException { assertThat(manifests).hasSize(1); List dataFiles = Lists.newArrayList(); - try (ManifestReader reader = ManifestFiles.read(manifests.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { for (DataFile dataFile : reader) { checkDataFile(dataFile.copy(), DataFiles.builder(dataFilesSpec).copy(dataFile).build()); dataFiles.add(dataFile.copy()); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java index 8884654fe0b3..bf667956ec5c 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java @@ -22,12 +22,12 @@ import static org.apache.iceberg.types.Types.NestedField.optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assumptions.assumeThat; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.io.File; +import java.io.IOException; import java.net.InetAddress; import java.nio.file.Path; import java.util.List; @@ -36,6 +36,7 @@ import org.apache.iceberg.AppendFiles; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.Parameter; @@ -44,14 +45,17 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.exceptions.CommitStateUnknownException; import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.SnapshotUtil; import org.apache.spark.sql.Dataset; @@ -87,7 +91,7 @@ public class TestSparkDataWrite { public static Object[][] parameters() { return new Object[][] { new Object[] {FileFormat.PARQUET, null}, - new Object[] {FileFormat.PARQUET, "main"}, + new Object[] {FileFormat.PARQUET, SnapshotRef.MAIN_BRANCH}, new Object[] {FileFormat.PARQUET, "testBranch"}, new Object[] {FileFormat.AVRO, null}, new Object[] {FileFormat.ORC, "testBranch"} @@ -100,6 +104,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } @@ -148,12 +153,13 @@ public void testBasicWrite() { assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { // TODO: avro not support split if (!format.equals(FileFormat.AVRO)) { assertThat(file.splitOffsets()).as("Split offsets not present").isNotNull(); } assertThat(file.recordCount()).as("Should have reported record count as 1").isEqualTo(1); + assertThat(file.sortOrderId()).isEqualTo(SortOrder.unsorted().orderId()); // TODO: append more metric info if (format.equals(FileFormat.PARQUET)) { assertThat(file.columnSizes()).as("Column sizes metric not present").isNotNull(); @@ -397,7 +403,7 @@ public void testUnpartitionedCreateWithTargetFileSizeViaTableProperties() { List files = Lists.newArrayList(); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { files.add(file); } } @@ -427,100 +433,127 @@ public void testPartitionedFanoutCreateWithTargetFileSizeViaOption2() { } @TestTemplate - public void testWriteProjection() { - assumeThat(spark.version()) - .as("Not supported in Spark 3; analysis requires all columns are present") - .startsWith("2"); - + public void testViewsReturnRecentResults() { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); - PartitionSpec spec = PartitionSpec.unpartitioned(); - Table table = tables.create(SCHEMA, spec, location.toString()); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + tables.create(SCHEMA, spec, location.toString()); - List expected = + List records = Lists.newArrayList( - new SimpleRecord(1, null), new SimpleRecord(2, null), new SimpleRecord(3, null)); + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); - Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); - df.select("id") - .write() // select only id column + df.select("id", "data") + .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) .save(location.toString()); + Table table = tables.load(location.toString()); createBranch(table); - table.refresh(); - Dataset result = spark.read().format("iceberg").load(targetLocation); + Dataset query = spark.read().format("iceberg").load(targetLocation).where("id = 1"); + query.createOrReplaceTempView("tmp"); + + List actual1 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected1 = Lists.newArrayList(new SimpleRecord(1, "a")); + assertThat(actual1).hasSameSizeAs(expected1).isEqualTo(expected1); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(targetLocation); + + List actual2 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected2 = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "a")); + assertThat(actual2).hasSameSizeAs(expected2).isEqualTo(expected2); + } + + @TestTemplate + public void testWriteDataFilesInTableSortOrder() throws IOException { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + List expected = Lists.newArrayListWithCapacity(10); + for (int i = 0; i < 10; i++) { + expected.add(new SimpleRecord(i, "a")); + } + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + Dataset result = spark.read().format("iceberg").load(location.toString()); List actual = result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + assertThat(fileScanTasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } } @TestTemplate - public void testWriteProjectionWithMiddle() { - assumeThat(spark.version()) - .as("Not supported in Spark 3; analysis requires all columns are present") - .startsWith("2"); - + public void testWriteDataFilesUnsortedTable() throws IOException { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); - String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); PartitionSpec spec = PartitionSpec.unpartitioned(); - Schema schema = - new Schema( - optional(1, "c1", Types.IntegerType.get()), - optional(2, "c2", Types.StringType.get()), - optional(3, "c3", Types.StringType.get())); - Table table = tables.create(schema, spec, location.toString()); - - List expected = - Lists.newArrayList( - new ThreeColumnRecord(1, null, "hello"), - new ThreeColumnRecord(2, null, "world"), - new ThreeColumnRecord(3, null, null)); + Table table = tables.create(SCHEMA, spec, location.toString()); - Dataset df = spark.createDataFrame(expected, ThreeColumnRecord.class); + List expected = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); - df.select("c1", "c3") + df.select("id", "data") .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) .save(location.toString()); - createBranch(table); - table.refresh(); - - Dataset result = spark.read().format("iceberg").load(targetLocation); - - List actual = - result.orderBy("c1").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); - assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles should have unsorted sort order id") + .containsOnly(SortOrder.unsorted().orderId()); + } } @TestTemplate - public void testViewsReturnRecentResults() { + public void testWriteDataFilesAfterSortOrderChange() throws IOException { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); - String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); - PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); - tables.create(SCHEMA, spec, location.toString()); - - List records = - Lists.newArrayList( - new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + List records = Lists.newArrayList(new SimpleRecord(1, "a")); Dataset df = spark.createDataFrame(records, SimpleRecord.class); df.select("id", "data") @@ -530,29 +563,31 @@ public void testViewsReturnRecentResults() { .mode(SaveMode.Append) .save(location.toString()); - Table table = tables.load(location.toString()); - createBranch(table); + table.refresh(); + int unsortedId = SortOrder.unsorted().orderId(); - Dataset query = spark.read().format("iceberg").load(targetLocation).where("id = 1"); - query.createOrReplaceTempView("tmp"); + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks).extracting(task -> task.file().sortOrderId()).containsOnly(unsortedId); + } - List actual1 = - spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); - List expected1 = Lists.newArrayList(new SimpleRecord(1, "a")); - assertThat(actual1).hasSameSizeAs(expected1).isEqualTo(expected1); + table.replaceSortOrder().asc("id").commit(); + int sortedId = table.sortOrder().orderId(); df.select("id", "data") .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) - .save(targetLocation); + .save(location.toString()); - List actual2 = - spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); - List expected2 = - Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "a")); - assertThat(actual2).hasSameSizeAs(expected2).isEqualTo(expected2); + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("Should contain both unsorted and sorted files") + .containsOnly(unsortedId, sortedId); + } } public void partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType option) { @@ -624,7 +659,7 @@ public void partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType opti List files = Lists.newArrayList(); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { files.add(file); } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java index 8ccea303d0c1..de6a5e59029c 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java @@ -50,6 +50,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; @@ -88,6 +89,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); ImmutableMap config = ImmutableMap.of( diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java index 64dbcbf5d0f3..42ddefe47f8b 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java @@ -21,11 +21,13 @@ import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; import static org.apache.iceberg.spark.source.SparkSQLExecutionHelper.lastExecutedMetricValue; import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.spark.sql.types.DataTypes.IntegerType; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assumptions.assumeThat; import java.io.File; import java.io.IOException; +import java.time.LocalDate; import java.util.List; import java.util.Set; import javax.annotation.Nonnull; @@ -69,8 +71,11 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; +import org.apache.iceberg.spark.ParquetBatchReadConf; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkStructLike; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.spark.source.metrics.NumDeletes; @@ -87,6 +92,7 @@ import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.internal.SQLConf; import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -132,6 +138,7 @@ public static void startMetastoreAndSpark() { .config("spark.ui.liveUpdate.period", 0) .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); @@ -324,7 +331,8 @@ public void testReadEqualityDeleteRows() throws IOException { for (CombinedScanTask task : tasks) { try (EqualityDeleteRowReader reader = - new EqualityDeleteRowReader(task, table, null, table.schema(), false, true)) { + new EqualityDeleteRowReader( + task, table, table.io(), table.schema(), table.schema(), false, true)) { while (reader.next()) { actualRowSet.add( new InternalRowWrapper( @@ -633,6 +641,61 @@ public void testPosDeletesOnParquetFileWithMultipleRowGroups() throws IOExceptio assertThat(rowSet(tblName, tbl, "*")).hasSize(193); } + @TestTemplate + public void testEqualityDeleteWithDifferentScanAndDeleteColumns() throws IOException { + assumeThat(format).isEqualTo(FileFormat.PARQUET); + initDateTable(); + + Schema deleteRowSchema = dateTable.schema().select("dt"); + Record dataDelete = GenericRecord.create(deleteRowSchema); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("dt", LocalDate.parse("2021-09-01")), + dataDelete.copy("dt", LocalDate.parse("2021-09-02")), + dataDelete.copy("dt", LocalDate.parse("2021-09-03"))); + + DeleteFile eqDeletes = + FileHelpers.writeDeleteFile( + dateTable, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + dataDeletes.subList(0, 3), + deleteRowSchema); + + dateTable.newRowDelta().addDeletes(eqDeletes).commit(); + + CloseableIterable tasks = + TableScanUtil.planTasks( + dateTable.newScan().planFiles(), + TableProperties.METADATA_SPLIT_SIZE_DEFAULT, + TableProperties.SPLIT_LOOKBACK_DEFAULT, + TableProperties.SPLIT_OPEN_FILE_COST_DEFAULT); + + ParquetBatchReadConf conf = ImmutableParquetBatchReadConf.builder().batchSize(7).build(); + + for (CombinedScanTask task : tasks) { + try (BatchDataReader reader = + new BatchDataReader( + // expected column is id, while the equality filter column is dt + dateTable, + dateTable.io(), + task, + dateTable.schema(), + dateTable.schema().select("id"), + false, + conf, + null, + true)) { + while (reader.next()) { + ColumnarBatch columnarBatch = reader.get(); + int numOfCols = columnarBatch.numCols(); + assertThat(numOfCols).as("Number of columns").isEqualTo(1); + assertThat(columnarBatch.column(0).dataType()).as("Column type").isEqualTo(IntegerType); + } + } + } + } + private static final Schema PROJECTION_SCHEMA = new Schema( required(1, "id", Types.IntegerType.get()), diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java index d22ecb02d483..cb2f866fab10 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java @@ -64,6 +64,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PropertyUtil; import org.apache.spark.sql.Dataset; @@ -182,6 +183,7 @@ public static void startMetastoreAndSpark() { SparkSession.builder() .master("local[2]") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java index a248d4e12827..0a3c09dffa00 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java @@ -461,8 +461,7 @@ public void testUnpartitionedYears() throws Exception { pushFilters(builder, predicate); scan = builder.build().toBatch(); - // notEq can't be answered using column bounds because they are not exact - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); } @TestTemplate @@ -771,7 +770,7 @@ public void testUnpartitionedTruncateString() throws Exception { pushFilters(builder, predicate); Batch scan = builder.build().toBatch(); - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); // NOT NotEqual builder = scanBuilder(); @@ -990,7 +989,7 @@ public void testUnpartitionedOr() throws Exception { pushFilters(builder, predicate); scan = builder.build().toBatch(); - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); } @TestTemplate diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java index d14b1a52cf82..e3934faa60ce 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java @@ -20,7 +20,9 @@ import static org.assertj.core.api.Assertions.assertThat; +import org.apache.iceberg.HistoryEntry; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; import org.apache.iceberg.spark.CatalogTestBase; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; import org.apache.spark.sql.connector.catalog.CatalogManager; @@ -56,4 +58,58 @@ public void testTableEquality() throws NoSuchTableException { assertThat(table1).as("References must be different").isNotSameAs(table2); assertThat(table1).as("Tables must be equivalent").isEqualTo(table2); } + + @TestTemplate + public void testTableInequalityWithDifferentSnapshots() throws NoSuchTableException { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + SparkTable table = (SparkTable) catalog.loadTable(identifier); + + Table icebergTable = validationCatalog.loadTable(tableIdent); + long[] snapshotIds = + icebergTable.history().stream().mapToLong(HistoryEntry::snapshotId).toArray(); + + SparkTable tableAtSnapshot1 = table.copyWithSnapshotId(snapshotIds[0]); + SparkTable tableAtSnapshot2 = table.copyWithSnapshotId(snapshotIds[1]); + + assertThat(tableAtSnapshot1) + .as("Tables at different snapshots must not be equal") + .isNotEqualTo(tableAtSnapshot2); + assertThat(tableAtSnapshot1.hashCode()) + .as("Hash codes should differ for different snapshots") + .isNotEqualTo(tableAtSnapshot2.hashCode()); + } + + @TestTemplate + public void testTableInequalityWithDifferentBranches() throws NoSuchTableException { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + + Table icebergTable = validationCatalog.loadTable(tableIdent); + icebergTable + .manageSnapshots() + .createBranch("testBranch", icebergTable.currentSnapshot().snapshotId()) + .commit(); + + // reload after branch creation so the table sees the new ref + SparkTable table = (SparkTable) catalog.loadTable(identifier); + table.table().refresh(); + + SparkTable tableOnMain = table.copyWithBranch("main"); + SparkTable tableOnBranch = table.copyWithBranch("testBranch"); + + assertThat(tableOnMain) + .as("Tables on different branches must not be equal") + .isNotEqualTo(tableOnBranch); + assertThat(tableOnMain.hashCode()) + .as("Hash codes should differ for different branches") + .isNotEqualTo(tableOnBranch.hashCode()); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java new file mode 100644 index 000000000000..6428689aaa19 --- /dev/null +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.streaming.StreamingQuery; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests to verify that streaming checkpoints always use HadoopFileIO and never use the table's + * FileIO implementation. + */ +@ExtendWith(ParameterizedTestExtension.class) +public class TestStreamingCheckpointHadoopIO extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + "testtableio", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hadoop", + CatalogProperties.FILE_IO_IMPL, + TrackingFileIO.class.getName(), + "cache-enabled", + "false") + } + }; + } + + @AfterEach + public void stopStreams() throws TimeoutException { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + TrackingFileIO.reset(); + } + + @TestTemplate + public void testCheckpointsUseHadoopIONotTableIO() throws Exception { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b')", tableName); + + // Use nested checkpoint path to verify parent directory creation + File checkpointDir = new File(temp.toFile(), "nested/checkpoint"); + TrackingFileIO.reset(); + + // Run streaming query with checkpoints + StreamingQuery query = + spark + .readStream() + .format("iceberg") + .load(tableName) + .writeStream() + .format("console") + .option("checkpointLocation", checkpointDir.getAbsolutePath()) + .start(); + + query.processAllAvailable(); + query.stop(); + + // Verify TrackingFileIO (table's FileIO) was NOT used for checkpoint operations + assertThat(TrackingFileIO.wasUsed()) + .as("HadoopFileIO should be used for checkpoints, not table's FileIO") + .isFalse(); + + // Verify checkpoint files were actually created using HadoopFileIO + assertThat(new File(checkpointDir, "offsets/0")).exists().isFile(); + } + + /** + * A FileIO that tracks whether it was used for checkpoint operations. This allows us to verify + * that the table's FileIO is NOT being used for checkpoints. + */ + public static class TrackingFileIO implements FileIO { + private static final String CHECKPOINT_OFFSETS_PATH = "/offsets/"; + private static final AtomicBoolean USED = new AtomicBoolean(false); + private FileIO delegate; + + public static void reset() { + USED.set(false); + } + + public static boolean wasUsed() { + return USED.get(); + } + + @Override + public InputFile newInputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newInputFile(path); + } + + @Override + public OutputFile newOutputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + delegate.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + this.delegate = new HadoopFileIO(); + this.delegate.initialize(properties); + } + + @Override + public void close() { + if (delegate != null) { + delegate.close(); + } + } + } +} diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java index dc4fc7e187fb..fcc8acd56a71 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java @@ -29,11 +29,16 @@ import java.nio.file.Paths; import java.util.List; import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoder; @@ -69,6 +74,7 @@ public static void startSpark() { .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) .config("spark.sql.shuffle.partitions", 4) + .config(TestBase.DISABLE_UI) .getOrCreate(); } @@ -260,6 +266,50 @@ public void testStreamingWriteCompleteModeWithProjection() throws Exception { } } + @Test + public void testStreamingWriteDataFilesInTableSortOrder() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + MemoryStream inputStream = newMemoryStream(1, spark.sqlContext(), Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .selectExpr("value AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("append") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + query.processAllAvailable(); + query.stop(); + + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + @Test public void testStreamingWriteUpdateMode() throws Exception { File parent = temp.resolve("parquet").toFile(); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java index a1ccb69aef97..2b2be95c5c09 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java @@ -31,16 +31,21 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.IntStream; import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.DataFile; import org.apache.iceberg.DataFiles; import org.apache.iceberg.DataOperations; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.Files; +import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; import org.apache.iceberg.RewriteFiles; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableOperations; @@ -49,15 +54,22 @@ import org.apache.iceberg.data.GenericRecord; import org.apache.iceberg.data.Record; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadConf; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.api.java.function.VoidFunction2; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; import org.apache.spark.sql.Row; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.streaming.Offset; import org.apache.spark.sql.internal.SQLConf; import org.apache.spark.sql.streaming.DataStreamWriter; import org.apache.spark.sql.streaming.OutputMode; @@ -72,10 +84,73 @@ @ExtendWith(ParameterizedTestExtension.class) public final class TestStructuredStreamingRead3 extends CatalogTestBase { + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, async = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + false + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + true + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + false + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + true + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + false + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + true + }, + { + SparkCatalogConfig.SPARK.catalogName(), + SparkCatalogConfig.SPARK.implementation(), + SparkCatalogConfig.SPARK.properties(), + false + }, + { + SparkCatalogConfig.SPARK.catalogName(), + SparkCatalogConfig.SPARK.implementation(), + SparkCatalogConfig.SPARK.properties(), + true + } + }; + } + private Table table; private final AtomicInteger microBatches = new AtomicInteger(); + @Parameter(index = 3) + private Boolean async; + /** * test data to be used by multiple writes each write creates a snapshot and writes a list of * records @@ -157,7 +232,6 @@ public void testReadStreamOnIcebergTableWithMultipleSnapshots() throws Exception @TestTemplate public void testReadStreamWithMaxFiles1() throws Exception { appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); - assertMicroBatchRecordSizes( ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), List.of(1L, 2L, 1L, 1L, 1L, 1L)); @@ -171,7 +245,6 @@ public void testReadStreamWithMaxFiles1() throws Exception { @TestTemplate public void testReadStreamWithMaxFiles2() throws Exception { appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); - assertMicroBatchRecordSizes( ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "2"), List.of(3L, 2L, 2L)); @@ -205,7 +278,6 @@ public void testReadStreamWithMaxRows1() throws Exception { @TestTemplate public void testReadStreamWithMaxRows2() throws Exception { appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); - assertMicroBatchRecordSizes( ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "2"), List.of(3L, 2L, 2L)); @@ -226,7 +298,6 @@ public void testReadStreamWithMaxRows2() throws Exception { @TestTemplate public void testReadStreamWithMaxRows4() throws Exception { appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); - assertMicroBatchRecordSizes( ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "4"), List.of(4L, 3L)); @@ -239,13 +310,10 @@ public void testReadStreamWithMaxRows4() throws Exception { @TestTemplate public void testReadStreamWithCompositeReadLimit() throws Exception { appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); - assertMicroBatchRecordSizes( ImmutableMap.of( - SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, - "4", - SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, - "1"), + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1", + SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "2"), List.of(1L, 2L, 1L, 1L, 1L, 1L)); assertMicroBatchRecordSizes( @@ -256,15 +324,41 @@ public void testReadStreamWithCompositeReadLimit() throws Exception { Trigger.AvailableNow()); } + @TestTemplate + public void testReadStreamWithLowAsyncQueuePreload() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + // Set low preload limits to test async queue behavior - background thread should load + // remaining data + + StreamingQuery query = + startStream( + ImmutableMap.of( + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "5", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "5")); + + List actual = rowsAvailable(query); + assertThat(actual) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(TEST_DATA_MULTIPLE_SNAPSHOTS)); + } + @TestTemplate public void testAvailableNowStreamReadShouldNotHangOrReprocessData() throws Exception { File writerCheckpointFolder = temp.resolve("writer-checkpoint-folder").toFile(); File writerCheckpoint = new File(writerCheckpointFolder, "writer-checkpoint"); File output = temp.resolve("junit").toFile(); + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + DataStreamWriter querySource = spark .readStream() + .options(options) .format("iceberg") .load(tableName) .writeStream() @@ -319,10 +413,17 @@ public void testTriggerAvailableNowDoesNotProcessNewDataWhileRunning() throws Ex long expectedSnapshotId = table.currentSnapshot().snapshotId(); String sinkTable = "availablenow_sink"; + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + StreamingQuery query = spark .readStream() - .option(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1") + .options(options) .format("iceberg") .load(tableName) .writeStream() @@ -364,6 +465,142 @@ public void testTriggerAvailableNowDoesNotProcessNewDataWhileRunning() throws Ex assertThat(actualResults).containsExactlyInAnyOrderElementsOf(Iterables.concat(expectedData)); } + @TestTemplate + public void testTriggerAvailableNowCapsAsyncPreloadAfterPrepare() { + List> initialData = + List.of(List.of(new SimpleRecord(1, "one")), List.of(new SimpleRecord(2, "two"))); + appendDataAsMultipleSnapshots(initialData); + + table.refresh(); + long expectedCapSnapshotId = table.currentSnapshot().snapshotId(); + + SparkMicroBatchStream stream = + new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf( + spark, + table, + ImmutableMap.of( + SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, + async.toString(), + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, + "1", + SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, + "1", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "10", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "10")), + table.schema(), + temp.resolve("available-now-cap-checkpoint").toString()); + + try { + stream.prepareForTriggerAvailableNow(); + + appendData(List.of(new SimpleRecord(3, "three"))); + + Offset startOffset = stream.initialOffset(); + Offset firstEndOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + assertThat(firstEndOffset).isNotNull(); + stream.planInputPartitions(startOffset, firstEndOffset); + + Offset secondEndOffset = stream.latestOffset(firstEndOffset, stream.getDefaultReadLimit()); + assertThat(secondEndOffset).isNotNull(); + stream.planInputPartitions(firstEndOffset, secondEndOffset); + + assertThat(stream.latestOffset(secondEndOffset, stream.getDefaultReadLimit())).isNull(); + assertThat(((StreamingOffset) secondEndOffset).snapshotId()).isEqualTo(expectedCapSnapshotId); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testLatestOffsetReturnsNullAfterFinalBatchIsConsumed() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + table.refresh(); + int expectedBatchCount; + try (CloseableIterable tasks = table.newScan().planFiles()) { + expectedBatchCount = Iterables.size(tasks); + } + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "drain-to-null-checkpoint"); + + try { + int plannedBatchCount = 0; + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + InputPartition[] partitions = stream.planInputPartitions(startOffset, endOffset); + assertThat(partitions).isNotEmpty(); + plannedBatchCount += 1; + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + assertThat(plannedBatchCount).isEqualTo(expectedBatchCount); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testPlanInputPartitionsIsIdempotentForSameOffsets() { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "idempotent-plan-files-checkpoint"); + + try { + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + + assertThat(endOffset).isNotNull(); + + InputPartition[] firstPartitions = stream.planInputPartitions(startOffset, endOffset); + InputPartition[] secondPartitions = stream.planInputPartitions(startOffset, endOffset); + + List firstFileLocations = Lists.newArrayList(); + for (InputPartition partition : firstPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + firstFileLocations.add(task.file().location()); + } + } + + List secondFileLocations = Lists.newArrayList(); + for (InputPartition partition : secondPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + secondFileLocations.add(task.file().location()); + } + } + + assertThat(firstFileLocations).containsExactlyInAnyOrderElementsOf(secondFileLocations); + + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + assertThat(stream.planInputPartitions(startOffset, endOffset)).isNotEmpty(); + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + } finally { + stream.stop(); + } + } + @TestTemplate public void testReadStreamOnIcebergThenAddData() throws Exception { List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; @@ -393,7 +630,7 @@ public void testReadingStreamFromTimestamp() throws Exception { startStream(SparkReadOptions.STREAM_FROM_TIMESTAMP, Long.toString(streamStartTimestamp)); List empty = rowsAvailable(query); - assertThat(empty.isEmpty()).isTrue(); + assertThat(empty).isEmpty(); List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; appendDataAsMultipleSnapshots(expected); @@ -411,7 +648,7 @@ public void testReadingStreamFromFutureTimetsamp() throws Exception { startStream(SparkReadOptions.STREAM_FROM_TIMESTAMP, Long.toString(futureTimestamp)); List actual = rowsAvailable(query); - assertThat(actual.isEmpty()).isTrue(); + assertThat(actual).isEmpty(); List data = Lists.newArrayList( @@ -424,13 +661,15 @@ public void testReadingStreamFromFutureTimetsamp() throws Exception { .forEach( x -> { appendData(data); - assertThat(rowsAvailable(query).isEmpty()).isTrue(); + assertThat(rowsAvailable(query)).isEmpty(); }); waitUntilAfter(futureTimestamp); // Data appended after the timestamp should appear appendData(data); + // Allow async background thread to refresh, else test sometimes fails + Thread.sleep(50); actual = rowsAvailable(query); assertThat(actual).containsExactlyInAnyOrderElementsOf(data); } @@ -673,7 +912,6 @@ public void testReadStreamWithSnapshotTypeRewriteDataFilesIgnoresReplace() throw appendDataAsMultipleSnapshots(expected); makeRewriteDataFiles(); - assertMicroBatchRecordSizes( ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), List.of(1L, 2L, 1L, 1L, 1L, 1L)); @@ -687,10 +925,8 @@ public void testReadStreamWithSnapshotTypeRewriteDataFilesIgnoresReplaceMaxRows( appendDataAsMultipleSnapshots(expected); makeRewriteDataFiles(); - assertMicroBatchRecordSizes( - ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "4"), - List.of(5L, 2L)); + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "4"), List.of(4L, 3L)); } @TestTemplate @@ -718,7 +954,6 @@ public void testReadStreamWithSnapshotType2RewriteDataFilesIgnoresReplace() thro makeRewriteDataFiles(); makeRewriteDataFiles(); - assertMicroBatchRecordSizes( ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), List.of(1L, 2L, 1L, 1L, 1L, 1L)); @@ -734,7 +969,6 @@ public void testReadStreamWithSnapshotTypeRewriteDataFilesIgnoresReplaceFollowed makeRewriteDataFiles(); appendDataAsMultipleSnapshots(expected); - assertMicroBatchRecordSizes( ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), List.of(1L, 2L, 1L, 1L, 1L, 1L, 1L, 2L, 1L, 1L, 1L, 1L)); @@ -845,7 +1079,8 @@ public void makeRewriteDataFiles() { Iterable it = table.snapshots(); for (Snapshot snapshot : it) { if (snapshot.operation().equals(DataOperations.APPEND)) { - Iterable datafiles = snapshot.addedDataFiles(table.io()); + Iterable datafiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(); for (DataFile datafile : datafiles) { rewrite.addFile(datafile); rewrite.deleteFile(datafile); @@ -882,13 +1117,18 @@ private void appendData(List data, String format) { private static final String MEMORY_TABLE = "_stream_view_mem"; private StreamingQuery startStream(Map options) throws TimeoutException { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } return spark .readStream() - .options(options) + .options(allOptions) .format("iceberg") .load(tableName) .writeStream() - .options(options) + .options(allOptions) .format("memory") .queryName(MEMORY_TABLE) .outputMode(OutputMode.Append()) @@ -913,11 +1153,17 @@ private void assertMicroBatchRecordSizes( private void assertMicroBatchRecordSizes( Map options, List expectedMicroBatchRecordSize, Trigger trigger) throws TimeoutException { - Dataset ds = spark.readStream().options(options).format("iceberg").load(tableName); - List syncList = Collections.synchronizedList(Lists.newArrayList()); + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + Dataset ds = spark.readStream().options(allOptions).format("iceberg").load(tableName); + List syncList = Collections.synchronizedList(Lists.newArrayList()); ds.writeStream() - .options(options) + .options(allOptions) .trigger(trigger) .foreachBatch( (VoidFunction2, Long>) @@ -939,4 +1185,21 @@ private List rowsAvailable(StreamingQuery query) { .as(Encoders.bean(SimpleRecord.class)) .collectAsList(); } + + private SparkMicroBatchStream newMicroBatchStream( + Map options, String checkpointDirName) { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + return new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf(spark, table, allOptions), + table.schema(), + temp.resolve(checkpointDirName).toString()); + } } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java index c3fac70dd3fc..45ff9184566b 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java @@ -41,6 +41,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.ByteBuffers; @@ -84,6 +85,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestWriteMetricsConfig.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); } diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java index 5ce56b4feca7..646e96eb541a 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java @@ -63,6 +63,7 @@ public static void startMetastoreAndSpark() { SparkSession.builder() .master("local[2]") .config("spark.sql.iceberg.aggregate_pushdown", "true") + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); @@ -123,8 +124,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { + "max(boolean_data), min(boolean_data), count(boolean_data), " + "max(float_data), min(float_data), count(float_data), " + "max(double_data), min(double_data), count(double_data), " - + "max(decimal_data), min(decimal_data), count(decimal_data), " - + "max(binary_data), min(binary_data), count(binary_data) FROM %s"; + + "max(decimal_data), min(decimal_data), count(decimal_data) FROM %s"; List explain = sql("EXPLAIN " + select, tableName); String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); @@ -147,10 +147,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { && explainString.contains("count(double_data)") && explainString.contains("max(decimal_data)") && explainString.contains("min(decimal_data)") - && explainString.contains("count(decimal_data)") - && explainString.contains("max(binary_data)") - && explainString.contains("min(binary_data)") - && explainString.contains("count(binary_data)")) { + && explainString.contains("count(decimal_data)")) { explainContainsPushDownAggregates = true; } @@ -180,10 +177,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { 5L, new BigDecimal("66.66"), new BigDecimal("11.11"), - 6L, - new byte[] {85, 85}, - new byte[] {17, 17}, - 5L + 6L }); assertEquals("min/max/count push down", expected, actual); } @@ -333,14 +327,14 @@ public void testAggregatePushDownWithMetricsMode() { @TestTemplate public void testAggregateNotPushDownForStringType() { sql("CREATE TABLE %s (id LONG, data STRING) USING iceberg", tableName); - sql( - "INSERT INTO TABLE %s VALUES (1, '1111'), (1, '2222'), (2, '3333'), (2, '4444'), (3, '5555'), (3, '6666') ", - tableName); sql( "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", - tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(16)"); + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, '111111'), (1, '2222'), (2, '3333'), (2, '4444'), (3, '5555'), (3, '666666') ", + tableName); - String select1 = "SELECT MAX(id), MAX(data) FROM %s"; + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; List explain1 = sql("EXPLAIN " + select1, tableName); String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); @@ -355,7 +349,7 @@ public void testAggregateNotPushDownForStringType() { List actual1 = sql(select1, tableName); List expected1 = Lists.newArrayList(); - expected1.add(new Object[] {3L, "6666"}); + expected1.add(new Object[] {3L, "666666", "111111"}); assertEquals("expected and actual should equal", expected1, actual1); String select2 = "SELECT COUNT(data) FROM %s"; @@ -378,20 +372,93 @@ public void testAggregateNotPushDownForStringType() { sql( "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); - String select3 = "SELECT count(data), max(data) FROM %s"; + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; List explain3 = sql("EXPLAIN " + select3, tableName); String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { explainContainsPushDownAggregates = true; } + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual3 = sql(select3, tableName); + List expected3 = Lists.newArrayList(); + expected3.add(new Object[] {6L, "666666", "111111"}); + assertEquals("expected and actual should equal", expected3, actual3); + } + + @TestTemplate + public void testAggregateNotPushDownForBinaryType() { + sql("CREATE TABLE %s (id LONG, data BINARY) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, X'111111111111'), (1, X'2222')," + + " (2, X'3333'), (2, X'4444')," + + " (3, X'5555'), (3, X'555555555555') ", + tableName); + + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; + + List explain1 = sql("EXPLAIN " + select1, tableName); + String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString1.contains("max(id)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual1 = sql(select1, tableName); + List expected1 = Lists.newArrayList(); + expected1.add( + new Object[] { + 3L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); + assertEquals("expected and actual should equal", expected1, actual1); + + String select2 = "SELECT COUNT(data) FROM %s"; + List explain2 = sql("EXPLAIN " + select2, tableName); + String explainString2 = explain2.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString2.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + assertThat(explainContainsPushDownAggregates) .as("explain should contain the pushed down aggregates") .isTrue(); + List actual2 = sql(select2, tableName); + List expected2 = Lists.newArrayList(); + expected2.add(new Object[] {6L}); + assertEquals("expected and actual should equal", expected2, actual2); + + explainContainsPushDownAggregates = false; + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; + List explain3 = sql("EXPLAIN " + select3, tableName); + String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + List actual3 = sql(select3, tableName); List expected3 = Lists.newArrayList(); - expected3.add(new Object[] {6L, "6666"}); + expected3.add( + new Object[] { + 6L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); assertEquals("expected and actual should equal", expected3, actual3); } @@ -766,6 +833,51 @@ public void testNaN() { assertEquals("expected and actual should equal", expected, actual); } + @TestTemplate + public void testNanWithLowerAndUpperBoundMetrics() { + sql("CREATE TABLE %s (id int, data float) USING iceberg PARTITIONED BY (id)", tableName); + sql( + "INSERT INTO %s VALUES (1, float('nan'))," + + "(1, float('nan')), " + + "(1, 10.0), " + + "(2, 2), " + + "(2, float('nan')), " + + "(3, float('nan')), " + + "(3, 1)", + tableName); + + // Validate all files has upper bound, lower bound and nan count + String countsQuery = + "select readable_metrics.data.nan_value_count > 0, " + + "isnull(readable_metrics.data.lower_bound), " + + "isnull(readable_metrics.data.upper_bound) " + + "from %s.files"; + + Object[] expectedResult = new Object[] {true, false, false}; + assertThat(sql(countsQuery, tableName)) + .as("Data files should contain nan count, lower bound and upper bound.") + .allMatch(row -> Arrays.equals(row, expectedResult)); + + // Check aggregates are not pushed down + String select = "SELECT count(*), max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = + (explainString.contains("max(data)") + || explainString.contains("min(data)") + || explainString.contains("count(data)")); + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {7L, Float.NaN, 1.0F, 7L}); + assertEquals("expected and actual should equal", expected, actual); + } + @TestTemplate public void testInfinity() { sql( diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java index 7fb1fda3364f..afb4e9964842 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java @@ -40,7 +40,6 @@ import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.NestedField; import org.apache.iceberg.types.Types.StructType; -import org.apache.spark.sql.connector.catalog.TableCatalog; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; @@ -273,7 +272,7 @@ public void testCreateTableComment() { assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); assertThat(table.properties()) .doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT) - .containsEntry(TableCatalog.PROP_COMMENT, "Table doc"); + .containsEntry(TableProperties.COMMENT, "Table doc"); } @TestTemplate diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java index 426c68447724..8b1edf9c7075 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java @@ -22,6 +22,7 @@ import java.util.Set; import org.apache.iceberg.DataFile; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.spark.CatalogTestBase; @@ -65,7 +66,7 @@ public void testRefreshCommand() { // Modify table outside of spark, it should be cached so Spark should see the same value after // mutation Table table = validationCatalog.loadTable(tableIdent); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); table.newDelete().deleteFile(file).commit(); List cachedActual = sql("SELECT * FROM %s", tableName); diff --git a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java index d4187a266b94..497a22420074 100644 --- a/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java +++ b/spark/v3.4/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java @@ -30,7 +30,10 @@ import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.events.Listeners; import org.apache.iceberg.events.ScanEvent; import org.apache.iceberg.exceptions.ValidationException; @@ -40,6 +43,7 @@ import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkCatalogConfig; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.junit.jupiter.api.AfterEach; @@ -107,7 +111,10 @@ public void testSelect() { List expected = ImmutableList.of(row(1L, "a", 1.0F), row(2L, "b", 2.0F), row(3L, "c", Float.NaN)); - assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); } @TestTemplate @@ -118,7 +125,10 @@ public void testSelectWithSpecifiedTargetSplitSize() { Table table = validationCatalog.loadTable(tableIdent); table.updateProperties().set("read.split.target-size", "1024").commit(); spark.sql("REFRESH TABLE " + tableName); - assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); // Query failed when `SPLIT_SIZE` < 0 table.updateProperties().set(SPLIT_SIZE, "-1").commit(); @@ -158,16 +168,21 @@ public void selectWithLimit() { // verify that LIMIT is properly applied in case SupportsPushDownLimit.isPartiallyPushed() is // ever overridden in SparkScanBuilder - assertThat(sql("SELECT * FROM %s LIMIT 1", tableName)).containsExactly(first); - assertThat(sql("SELECT * FROM %s LIMIT 2", tableName)).containsExactly(first, second); - assertThat(sql("SELECT * FROM %s LIMIT 3", tableName)).containsExactly(first, second, third); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 1", tableName)).containsExactly(first); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 2", tableName)) + .containsExactly(first, second); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 3", tableName)) + .containsExactly(first, second, third); } @TestTemplate public void testProjection() { List expected = ImmutableList.of(row(1L), row(2L), row(3L)); - assertEquals("Should return all expected rows", expected, sql("SELECT id FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id FROM %s ORDER BY id", tableName)); assertThat(scanEventCount).as("Should create only one scan").isEqualTo(1); assertThat(lastScanEvent.filter()) @@ -209,14 +224,14 @@ public void testMetadataTables() { public void testSnapshotInTableName() { // get the snapshot ID of the last write and get the current row set as expected long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); String prefix = "snapshot_id_"; // read the table at the snapshot - List actual = sql("SELECT * FROM %s.%s", tableName, prefix + snapshotId); + List actual = sql("SELECT * FROM %s.%s ORDER BY id", tableName, prefix + snapshotId); assertEquals("Snapshot at specific ID, prefix " + prefix, expected, actual); // read the table using DataFrameReader option @@ -225,7 +240,8 @@ public void testSnapshotInTableName() { .read() .format("iceberg") .option(SparkReadOptions.SNAPSHOT_ID, snapshotId) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); } @@ -235,14 +251,14 @@ public void testTimestampInTableName() { // get a timestamp just after the last write and get the current row set as expected long snapshotTs = validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis(); long timestamp = waitUntilAfter(snapshotTs + 2); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); String prefix = "at_timestamp_"; // read the table at the snapshot - List actual = sql("SELECT * FROM %s.%s", tableName, prefix + timestamp); + List actual = sql("SELECT * FROM %s.%s ORDER BY id", tableName, prefix + timestamp); assertEquals("Snapshot at timestamp, prefix " + prefix, expected, actual); // read the table using DataFrameReader option @@ -251,7 +267,8 @@ public void testTimestampInTableName() { .read() .format("iceberg") .option(SparkReadOptions.AS_OF_TIMESTAMP, timestamp) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); } @@ -260,19 +277,20 @@ public void testTimestampInTableName() { public void testVersionAsOf() { // get the snapshot ID of the last write and get the current row set as expected long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); // read the table at the snapshot - List actual1 = sql("SELECT * FROM %s VERSION AS OF %s", tableName, snapshotId); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, snapshotId); assertEquals("Snapshot at specific ID", expected, actual1); // read the table at the snapshot // HIVE time travel syntax List actual2 = - sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF %s", tableName, snapshotId); + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF %s ORDER BY id", tableName, snapshotId); assertEquals("Snapshot at specific ID", expected, actual2); // read the table using DataFrameReader option: versionAsOf @@ -281,7 +299,8 @@ public void testVersionAsOf() { .read() .format("iceberg") .option(SparkReadOptions.VERSION_AS_OF, snapshotId) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); } @@ -291,28 +310,35 @@ public void testTagReference() { Table table = validationCatalog.loadTable(tableIdent); long snapshotId = table.currentSnapshot().snapshotId(); table.manageSnapshots().createTag("test_tag", snapshotId).commit(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot, read the table at the tag sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); - List actual1 = sql("SELECT * FROM %s VERSION AS OF 'test_tag'", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_tag' ORDER BY id", tableName); assertEquals("Snapshot at specific tag reference name", expected, actual1); // read the table at the tag // HIVE time travel syntax - List actual2 = sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_tag'", tableName); + List actual2 = + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_tag' ORDER BY id", tableName); assertEquals("Snapshot at specific tag reference name", expected, actual2); // Spark session catalog does not support extended table names if (!"spark_catalog".equals(catalogName)) { // read the table using the "tag_" prefix in the table name - List actual3 = sql("SELECT * FROM %s.tag_test_tag", tableName); + List actual3 = sql("SELECT * FROM %s.tag_test_tag ORDER BY id", tableName); assertEquals("Snapshot at specific tag reference name, prefix", expected, actual3); } // read the table using DataFrameReader option: tag Dataset df = - spark.read().format("iceberg").option(SparkReadOptions.TAG, "test_tag").load(tableName); + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TAG, "test_tag") + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific tag reference name", expected, fromDF); } @@ -323,7 +349,7 @@ public void testUseSnapshotIdForTagReferenceAsOf() { long snapshotId1 = table.currentSnapshot().snapshotId(); // create a second snapshot, read the table at the snapshot - List actual = sql("SELECT * FROM %s", tableName); + List actual = sql("SELECT * FROM %s ORDER BY id", tableName); sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); table.refresh(); @@ -334,11 +360,11 @@ public void testUseSnapshotIdForTagReferenceAsOf() { // this means if a tag name matches a snapshot ID, it will always choose snapshotID to travel // to. List travelWithStringResult = - sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, snapshotId1); + sql("SELECT * FROM %s VERSION AS OF '%s' ORDER BY id", tableName, snapshotId1); assertEquals("Snapshot at specific tag reference name", actual, travelWithStringResult); List travelWithLongResult = - sql("SELECT * FROM %s VERSION AS OF %s", tableName, snapshotId1); + sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, snapshotId1); assertEquals("Snapshot at specific tag reference name", actual, travelWithLongResult); } @@ -350,7 +376,7 @@ public void readAndWriteWithBranchAfterSchemaChange() { List expected = Arrays.asList(row(1L, "a", 1.0f), row(2L, "b", 2.0f), row(3L, "c", Float.NaN)); - assertThat(sql("SELECT * FROM %s", tableName)).containsExactlyElementsOf(expected); + assertThat(sql("SELECT * FROM %s", tableName)).containsExactlyInAnyOrderElementsOf(expected); // change schema on the table and add more data sql("ALTER TABLE %s DROP COLUMN float", tableName); @@ -361,16 +387,16 @@ public void readAndWriteWithBranchAfterSchemaChange() { // time-travel query using snapshot id should return the snapshot's schema long branchSnapshotId = table.refs().get(branchName).snapshotId(); - assertThat(sql("SELECT * FROM %s VERSION AS OF %s", tableName, branchSnapshotId)) + assertThat(sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, branchSnapshotId)) .containsExactlyElementsOf(expected); // querying the head of the branch should return the table's schema - assertThat(sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, branchName)) + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER BY id", tableName, branchName)) .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); if (!"spark_catalog".equals(catalogName)) { // querying the head of the branch using 'branch_' should return the table's schema - assertThat(sql("SELECT * FROM %s.branch_%s", tableName, branchName)) + assertThat(sql("SELECT * FROM %s.branch_%s ORDER BY id", tableName, branchName)) .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); } @@ -380,7 +406,7 @@ public void readAndWriteWithBranchAfterSchemaChange() { tableName, branchName); // querying the head of the branch returns the table's schema - assertThat(sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, branchName)) + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER BY id", tableName, branchName)) .containsExactlyInAnyOrder( row(1L, "a", null), row(2L, "b", null), @@ -405,23 +431,24 @@ public void testBranchReference() { Table table = validationCatalog.loadTable(tableIdent); long snapshotId = table.currentSnapshot().snapshotId(); table.manageSnapshots().createBranch("test_branch", snapshotId).commit(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot, read the table at the branch sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); - List actual1 = sql("SELECT * FROM %s VERSION AS OF 'test_branch'", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_branch' ORDER BY id", tableName); assertEquals("Snapshot at specific branch reference name", expected, actual1); // read the table at the branch // HIVE time travel syntax List actual2 = - sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_branch'", tableName); + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_branch' ORDER BY id", tableName); assertEquals("Snapshot at specific branch reference name", expected, actual2); // Spark session catalog does not support extended table names if (!"spark_catalog".equals(catalogName)) { // read the table using the "branch_" prefix in the table name - List actual3 = sql("SELECT * FROM %s.branch_test_branch", tableName); + List actual3 = sql("SELECT * FROM %s.branch_test_branch ORDER BY id", tableName); assertEquals("Snapshot at specific branch reference name, prefix", expected, actual3); } @@ -431,7 +458,8 @@ public void testBranchReference() { .read() .format("iceberg") .option(SparkReadOptions.BRANCH, "test_branch") - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific branch reference name", expected, fromDF); } @@ -453,30 +481,30 @@ public void testTimestampAsOf() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String formattedDate = sdf.format(new Date(timestamp)); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); // read the table at the timestamp in long format i.e 1656507980463. List actualWithLongFormat = - sql("SELECT * FROM %s TIMESTAMP AS OF %s", tableName, timestampInSeconds); + sql("SELECT * FROM %s TIMESTAMP AS OF %s ORDER BY id", tableName, timestampInSeconds); assertEquals("Snapshot at timestamp", expected, actualWithLongFormat); // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 List actualWithDateFormat = - sql("SELECT * FROM %s TIMESTAMP AS OF '%s'", tableName, formattedDate); + sql("SELECT * FROM %s TIMESTAMP AS OF '%s' ORDER BY id", tableName, formattedDate); assertEquals("Snapshot at timestamp", expected, actualWithDateFormat); // HIVE time travel syntax // read the table at the timestamp in long format i.e 1656507980463. List actualWithLongFormatInHiveSyntax = - sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF %s", tableName, timestampInSeconds); + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF %s ORDER BY id", tableName, timestampInSeconds); assertEquals("Snapshot at specific ID", expected, actualWithLongFormatInHiveSyntax); // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 List actualWithDateFormatInHiveSyntax = - sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF '%s'", tableName, formattedDate); + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF '%s' ORDER BY id", tableName, formattedDate); assertEquals("Snapshot at specific ID", expected, actualWithDateFormatInHiveSyntax); // read the table using DataFrameReader option @@ -485,7 +513,8 @@ public void testTimestampAsOf() { .read() .format("iceberg") .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedDate) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); } @@ -605,6 +634,29 @@ public void testBinaryInFilter() { sql("SELECT id, binary FROM %s where binary > X'11'", binaryTableName)); } + @TestTemplate + public void testFixedInFilter() { + // Create table programmatically with fixed type since Spark SQL DDL doesn't support it + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "fixed", Types.FixedType.ofLength(2))); + + TableIdentifier fixedTableIdent = TableIdentifier.of(tableIdent.namespace(), "fixed_table"); + validationCatalog.createTable(fixedTableIdent, schema, PartitionSpec.unpartitioned()); + + String fixedTableName = tableName("fixed_table"); + sql("INSERT INTO %s VALUES (1, X'0000'), (2, X'1111'), (3, X'0011')", fixedTableName); + List expected = ImmutableList.of(row(2L, new byte[] {0x11, 0x11})); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id, fixed FROM %s WHERE fixed > X'0011'", fixedTableName)); + + sql("DROP TABLE IF EXISTS %s", fixedTableName); + } + @TestTemplate public void testComplexTypeFilter() { String complexTypeTableName = tableName("complex_table"); diff --git a/spark/v3.5/build.gradle b/spark/v3.5/build.gradle index f31383a5582d..68bdb1c21a98 100644 --- a/spark/v3.5/build.gradle +++ b/spark/v3.5/build.gradle @@ -75,8 +75,6 @@ project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") { exclude group: 'org.roaringbitmap' } - compileOnly "org.apache.datafusion:comet-spark-spark${sparkMajorVersion}_${scalaVersion}:${libs.versions.comet.get()}" - implementation libs.parquet.column implementation libs.parquet.hadoop @@ -107,14 +105,10 @@ project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") { testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') - testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - testImplementation libs.sqlite.jdbc + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) testImplementation libs.awaitility testImplementation(testFixtures(project(':iceberg-parquet'))) - // runtime dependencies for running REST Catalog based integration test - testRuntimeOnly libs.jetty.servlet } test { @@ -175,17 +169,10 @@ project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVer testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-hive-metastore', configuration: 'testArtifacts') testImplementation project(path: ":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}", configuration: 'testArtifacts') - testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - // runtime dependencies for running REST Catalog based integration test - testRuntimeOnly libs.jetty.servlet - testRuntimeOnly libs.sqlite.jdbc - + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) testImplementation libs.avro.avro testImplementation libs.parquet.hadoop testImplementation libs.awaitility - testImplementation "org.apache.datafusion:comet-spark-spark${sparkMajorVersion}_${scalaVersion}:${libs.versions.comet.get()}" testImplementation(testFixtures(project(':iceberg-parquet'))) // Required because we remove antlr plugin dependencies from the compile configuration, see note above @@ -258,7 +245,9 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio } integrationImplementation "org.scala-lang.modules:scala-collection-compat_${scalaVersion}:${libs.versions.scala.collection.compat.get()}" - integrationImplementation "org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark35.get()}" + integrationImplementation("org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark35.get()}") { + exclude group: 'org.roaringbitmap' + } integrationImplementation libs.junit.jupiter integrationImplementation libs.junit.platform.launcher integrationImplementation libs.slf4j.simple @@ -272,11 +261,7 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio integrationRuntimeOnly project(':iceberg-hive-metastore') // runtime dependencies for running REST Catalog based integration test integrationRuntimeOnly project(path: ':iceberg-core', configuration: 'testArtifacts') - integrationRuntimeOnly (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - integrationRuntimeOnly libs.jetty.servlet - integrationRuntimeOnly libs.sqlite.jdbc + integrationRuntimeOnly (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) // Not allowed on our classpath, only the runtime jar is allowed integrationCompileOnly project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}") @@ -336,5 +321,7 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java index 8b0b05911f66..242ef7439a39 100644 --- a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java +++ b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java @@ -31,6 +31,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; import org.apache.iceberg.util.ThreadPools; import org.apache.spark.sql.SparkSession; @@ -205,7 +206,7 @@ private void initDataAndDVs() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) diff --git a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java index d7f285288004..03e0410c0adc 100644 --- a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java +++ b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java @@ -155,7 +155,7 @@ private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java index 1d51350487c4..5cd8143f17bf 100644 --- a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java +++ b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java @@ -215,7 +215,7 @@ public void localPlanningViaDistributedScanWithoutFilterWithStats(Blackhole blac private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.driver.maxResultSize", "8G") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) diff --git a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java index ad78205ce98c..a77c130ee17a 100644 --- a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java +++ b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java @@ -199,7 +199,7 @@ private void initDataAndDeletes() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) diff --git a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java index d917eae5eb0f..caa23625fc44 100644 --- a/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java +++ b/spark/v3.5/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java @@ -138,7 +138,7 @@ private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java index bfcb5af235d3..ef4f0090292c 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java @@ -49,7 +49,12 @@ public class TestExtendedParser { @BeforeAll public static void before() { - spark = SparkSession.builder().master("local").appName("TestExtendedParser").getOrCreate(); + spark = + SparkSession.builder() + .master("local") + .appName("TestExtendedParser") + .config(TestBase.DISABLE_UI) + .getOrCreate(); } @AfterAll diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java index 4c1a5095916c..834640e24328 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java @@ -59,6 +59,7 @@ public static void startMetastoreAndSpark() { .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") .config( SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), String.valueOf(RANDOM.nextBoolean())) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java index c49def204f7e..26dde2059966 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java @@ -26,6 +26,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -109,10 +110,12 @@ public void testCreateBranchOnEmptyTable() { Snapshot snapshot = table.snapshot(ref.snapshotId()); assertThat(snapshot.parentId()).isNull(); - assertThat(snapshot.addedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); }); } @@ -370,10 +373,12 @@ public void testCreateOrReplaceBranchOnEmptyTable() { Snapshot snapshot = table.snapshot(ref.snapshotId()); assertThat(snapshot.parentId()).isNull(); - assertThat(snapshot.addedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); }); } diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java index f7ded0c4d7d2..d39dff060c9a 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java @@ -162,6 +162,25 @@ public synchronized void testDeleteWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteDeleteSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("DELETE FROM %s WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithPreservedDataGrouping() throws NoSuchTableException { createAndInitPartitionedTable(); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java index fef8b28c689a..394dbbda1a3d 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java @@ -151,6 +151,34 @@ public synchronized void testMergeWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteMergeSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("source", Collections.singletonList(1), Encoders.INT()); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'changed' " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'new')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithReportedPartitioning() { createAndInitTable("id INT, dep STRING"); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java index 21d1377b2b98..b547218acbd4 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java @@ -149,6 +149,25 @@ public synchronized void testUpdateWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteUpdateSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET dep = 'changed' WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithReportedPartitioning() { createAndInitTable("id INT, dep STRING"); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java index fbf6ce3559a7..79d6bea12f67 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java @@ -1422,6 +1422,62 @@ public void testDeleteToCustomWapBranchWithoutWhereClause() throws NoSuchTableEx }); } + @TestTemplate + public void testDeleteToWapBranchCanDeleteWhereScansWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + + sql("DELETE FROM %s WHERE id = 1", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("DELETE should remove the matching rows from the WAP branch") + .containsExactly(row(0, "hr"), row(2, "hr")); + assertThat(sql("SELECT id, dep FROM %s.branch_main", tableName)) + .as("Main branch must not be modified by a WAP-targeted DELETE") + .containsExactly(row(1, "hr")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + + @TestTemplate + public void testMetadataDeleteToWapBranchCommitsToWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr"), new Employee(5, "eng")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(2, "eng")); + + sql("DELETE FROM %s WHERE dep = 'hr'", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("Metadata delete should remove the hr partition on the WAP branch") + .containsExactly(row(2, "eng"), row(5, "eng")); + assertThat(sql("SELECT id, dep FROM %s.branch_main ORDER BY id", tableName)) + .as("Metadata delete must not commit to main when WAP is set") + .containsExactly(row(1, "hr"), row(5, "eng")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + @TestTemplate public void testDeleteWithFilterOnNestedColumn() { createAndInitNestedColumnsTable(); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java index cfa77250bb9a..cac853dae746 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java @@ -55,6 +55,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -474,7 +475,7 @@ public void testMergeWithStaticPredicatePushDown() { // remove the data file from the 'hr' partition to ensure it is not scanned withUnavailableFiles( - snapshot.addedDataFiles(table.io()), + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(), () -> { // disable dynamic pruning and rely only on static predicate pushdown withSQLConf( diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java index bf9c53f82dbd..11643c495400 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java @@ -36,6 +36,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.deletes.DeleteGranularity; @@ -140,7 +141,8 @@ public void testPositionDeletesAreMaintainedDuringDelete() throws NoSuchTableExc Table table = validationCatalog.loadTable(tableIdent); Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); - assertThat(latest.removedDeleteFiles(table.io())).hasSize(1); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); assertEquals( "Should have expected rows", ImmutableList.of(row(1, "b"), row(2, "e")), @@ -181,7 +183,8 @@ public void testUnpartitionedPositionDeletesAreMaintainedDuringDelete() Table table = validationCatalog.loadTable(tableIdent); Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); - assertThat(latest.removedDeleteFiles(table.io())).hasSize(1); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); assertEquals( "Should have expected rows", ImmutableList.of(row(1, "b"), row(2, "e")), diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java index 737f19e86a95..9a42b58e3434 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java @@ -26,6 +26,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.ParameterizedTestExtension; @@ -136,6 +137,34 @@ public void testMergeWithDVAndHistoricalPositionDeletes() { assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); } + @TestTemplate + public void testMergeOnReadMergeSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + createOrReplaceView("source", ImmutableList.of(1, 3), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET id = id + 10 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'hr')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + private void checkMergeDeleteGranularity(DeleteGranularity deleteGranularity) { createTableWithDeleteGranularity( "id INT, dep STRING", "PARTITIONED BY (dep)", deleteGranularity); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java index 1bec21b9b68d..d1c336d5ddeb 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java @@ -25,11 +25,13 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.deletes.DeleteGranularity; @@ -75,7 +77,12 @@ public void testPositionDeletesAreMaintainedDuringUpdate() { String expectedDeleteFilesCount = "2"; validateMergeOnRead(currentSnapshot, "2", expectedDeleteFilesCount, "2"); - assertThat(currentSnapshot.removedDeleteFiles(table.io())).hasSize(2); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); assertEquals( "Should have expected rows", ImmutableList.of( @@ -123,7 +130,12 @@ public void testUnpartitionedPositionDeletesAreMaintainedDuringUpdate() { expectedDeleteFilesCount = "2"; validateMergeOnRead(currentSnapshot, "1", expectedDeleteFilesCount, "1"); - assertThat(currentSnapshot.removedDeleteFiles(table.io())).hasSize(2); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); assertEquals( "Should have expected rows", ImmutableList.of( @@ -213,6 +225,25 @@ public void testUpdateWithDVAndHistoricalPositionDeletes() { assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); } + @TestTemplate + public void testMergeOnReadUpdateSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET id = id + 10 WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + private void initTable(String partitionedBy, DeleteGranularity deleteGranularity) { createTableWithDeleteGranularity("id INT, dep STRING", partitionedBy, deleteGranularity); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java index ac528d1c470e..9ca29635f060 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java @@ -44,6 +44,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.avro.Avro; @@ -578,7 +579,7 @@ public void testMetadataLogEntries() throws Exception { parentSnapshot.schemaId(), parentSnapshot.sequenceNumber()), row( - DateTimeUtils.toJavaTimestamp(currentSnapshot.timestampMillis() * 1000), + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), tableMetadata.metadataFileLocation(), currentSnapshot.snapshotId(), currentSnapshot.schemaId(), @@ -597,8 +598,7 @@ public void testMetadataLogEntries() throws Exception { "Result should match the latest snapshot entry", ImmutableList.of( row( - DateTimeUtils.toJavaTimestamp( - tableMetadata.currentSnapshot().timestampMillis() * 1000), + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), tableMetadata.metadataFileLocation(), tableMetadata.currentSnapshot().snapshotId(), tableMetadata.currentSnapshot().schemaId(), @@ -749,7 +749,9 @@ public void testSnapshotReferencesMetatable() throws Exception { .collectAsList(); assertThat(mainBranch) .hasSize(1) - .containsExactly(RowFactory.create("main", "BRANCH", currentSnapshotId, null, null, null)); + .containsExactly( + RowFactory.create( + SnapshotRef.MAIN_BRANCH, "BRANCH", currentSnapshotId, null, null, null)); assertThat(mainBranch.get(0).schema().fieldNames()) .containsExactly( "name", @@ -817,7 +819,7 @@ public void testSnapshotReferencesMetatable() throws Exception { .collectAsList(); assertThat(mainBranchProjection) .hasSize(1) - .containsExactly(RowFactory.create("main", "BRANCH")); + .containsExactly(RowFactory.create(SnapshotRef.MAIN_BRANCH, "BRANCH")); assertThat(mainBranchProjection.get(0).schema().fieldNames()).containsExactly("name", "type"); List testBranchProjection = diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java index c189311a2042..8c22aaeabac3 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java @@ -286,4 +286,21 @@ public void testMigratePartitionedWithParallelism() throws IOException { ImmutableList.of(row("a", 1L), row("b", 2L)), sql("SELECT * FROM %s ORDER BY id", tableName)); } + + @TestTemplate + public void testMigrateBucketedTable() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet " + + "CLUSTERED BY (id) INTO 4 BUCKETS LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertThatThrownBy(() -> sql("CALL %s.system.migrate('%s')", catalogName, tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot create an Iceberg table from a bucketed source table: " + + "4 buckets, bucket columns: [id]"); + } } diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java index 08f44c8f01f2..d9319801d154 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java @@ -161,6 +161,26 @@ public void testApplyInvalidWapId() { .hasMessage("Cannot apply unknown WAP ID 'not_valid'"); } + @TestTemplate + public void testApplyDuplicateWapId() { + + String wapId = "wap_id_1"; + + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", wapId); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.publish_changes('%s', '%s')", catalogName, tableIdent, wapId)) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID 'wap_id_1'"); + } + @TestTemplate public void testInvalidApplyWapChangesCases() { assertThatThrownBy( diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java index ae88e04e447e..ee727bd13900 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java @@ -32,13 +32,9 @@ import java.util.stream.LongStream; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.EnvironmentContext; -import org.apache.iceberg.Files; import org.apache.iceberg.ParameterizedTestExtension; -import org.apache.iceberg.PartitionStatisticsFile; -import org.apache.iceberg.PartitionStats; +import org.apache.iceberg.PartitionStatistics; import org.apache.iceberg.PartitionStatsHandler; -import org.apache.iceberg.Partitioning; -import org.apache.iceberg.Schema; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -54,6 +50,7 @@ import org.apache.iceberg.spark.SparkCatalogConfig; import org.apache.iceberg.spark.SparkTableCache; import org.apache.iceberg.spark.SystemFunctionPushDownHelper; +import org.apache.iceberg.spark.actions.SparkActions; import org.apache.iceberg.spark.source.ThreeColumnRecord; import org.apache.spark.sql.AnalysisException; import org.apache.spark.sql.Dataset; @@ -155,33 +152,36 @@ public void testPartitionStatsIncrementalCompute() throws IOException { insertData(10); Table table = validationCatalog.loadTable(tableIdent); - PartitionStatisticsFile statisticsFile = PartitionStatsHandler.computeAndWriteStatsFile(table); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - - Schema dataSchema = PartitionStatsHandler.schema(Partitioning.partitionType(table), 2); - List statsBeforeCompaction; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsBeforeCompaction; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsBeforeCompaction = Lists.newArrayList(recordIterator); } sql("CALL %s.system.rewrite_data_files(table => '%s')", catalogName, tableIdent); table.refresh(); - statisticsFile = - PartitionStatsHandler.computeAndWriteStatsFile(table, table.currentSnapshot().snapshotId()); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - List statsAfterCompaction; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsAfterCompaction; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsAfterCompaction = Lists.newArrayList(recordIterator); } for (int index = 0; index < statsBeforeCompaction.size(); index++) { - PartitionStats statsAfter = statsAfterCompaction.get(index); - PartitionStats statsBefore = statsBeforeCompaction.get(index); + PartitionStatistics statsAfter = statsAfterCompaction.get(index); + PartitionStatistics statsBefore = statsBeforeCompaction.get(index); assertThat(statsAfter.partition()).isEqualTo(statsBefore.partition()); // data count should match after compaction @@ -978,6 +978,7 @@ public void testRewriteDataFilesSummary() { Map summary = snapshotSummary(); assertThat(summary) .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") .hasEntrySatisfying( EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("3.5")); @@ -1107,4 +1108,209 @@ private List currentData() { private List currentData(String table) { return rowsToJava(spark.sql("SELECT * FROM " + table + " order by c1, c2, c3").collectAsList()); } + + @TestTemplate + public void testRewriteDataFilesOnBranch() { + createTable(); + insertData(10); + + String branchName = "testBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testRewriteDataFilesToNullBranchFails() { + createTable(); + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThatThrownBy(() -> SparkActions.get(spark).rewriteDataFiles(table).toBranch(null)) + .as("Invalid branch") + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid branch name: null"); + } + + @TestTemplate + public void testRewriteDataFilesOnBranchWithFilter() { + createPartitionTable(); + insertData(10); + + String branchName = "filteredBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch with filter (select only partition c2 = 'bar') + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s', where => 'c2 = \"bar\"')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 5 data files from single matching partition" + + "(containing c2 = bar) and add 1 data file", + row(5, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed after rewrite + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testBranchCompactionDoesNotAffectMain() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + // Create branch from current main state + String branchName = "compactionBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + // Add more data to MAIN to make it diverge from branch + insertData(tableName, 10); + + // Refresh to get new main snapshot after divergence + table.refresh(); + long mainSnapshotAfterDivergence = table.currentSnapshot().snapshotId(); + List expectedMainRecords = currentData(); + + // Get branch data before compaction + List expectedBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + long branchSnapshotBeforeCompaction = table.refs().get(branchName).snapshotId(); + + // Verify that branch and main have diverged + assertThat(branchSnapshotBeforeCompaction) + .as("Branch and main should have different snapshots") + .isNotEqualTo(mainSnapshotAfterDivergence); + + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + table.refresh(); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot ID must remain unchanged after branch compaction") + .isEqualTo(mainSnapshotAfterDivergence); + + // Verify main data unchanged + List actualMainRecords = currentData(); + assertEquals( + "Main data after compaction should not change", expectedMainRecords, actualMainRecords); + + // Verify branch data unchanged + List actualBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals( + "Branch data after compaction should not change", + expectedBranchRecords, + actualBranchRecords); + + // Verify branch snapshot changed + long branchSnapshotAfterCompaction = table.refs().get(branchName).snapshotId(); + assertThat(branchSnapshotAfterCompaction) + .as("Branch snapshot must be updated after compaction") + .isNotEqualTo(branchSnapshotBeforeCompaction); + + // Verify the new branch snapshot is a child of the previous branch snapshot + assertThat(table.snapshot(branchSnapshotAfterCompaction).parentId()) + .as("New branch snapshot must be a child of the previous branch snapshot") + .isEqualTo(branchSnapshotBeforeCompaction); + } } diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java index fcebb8b04057..90fa85501070 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java @@ -26,13 +26,9 @@ import java.sql.Date; import java.sql.Timestamp; import java.util.List; -import org.apache.iceberg.Files; import org.apache.iceberg.ParameterizedTestExtension; -import org.apache.iceberg.PartitionStatisticsFile; -import org.apache.iceberg.PartitionStats; +import org.apache.iceberg.PartitionStatistics; import org.apache.iceberg.PartitionStatsHandler; -import org.apache.iceberg.Partitioning; -import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -408,14 +404,15 @@ public void testPartitionStatsIncrementalCompute() throws IOException { sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); Table table = validationCatalog.loadTable(tableIdent); - PartitionStatisticsFile statisticsFile = PartitionStatsHandler.computeAndWriteStatsFile(table); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - - Schema dataSchema = PartitionStatsHandler.schema(Partitioning.partitionType(table), 2); - List statsBeforeRewrite; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsBeforeRewrite; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsBeforeRewrite = Lists.newArrayList(recordIterator); } @@ -424,19 +421,21 @@ public void testPartitionStatsIncrementalCompute() throws IOException { catalogName, tableIdent); table.refresh(); - statisticsFile = - PartitionStatsHandler.computeAndWriteStatsFile(table, table.currentSnapshot().snapshotId()); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - List statsAfterRewrite; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsAfterRewrite; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsAfterRewrite = Lists.newArrayList(recordIterator); } for (int index = 0; index < statsBeforeRewrite.size(); index++) { - PartitionStats statsAfter = statsAfterRewrite.get(index); - PartitionStats statsBefore = statsBeforeRewrite.get(index); + PartitionStatistics statsAfter = statsAfterRewrite.get(index); + PartitionStatistics statsBefore = statsBeforeRewrite.get(index); assertThat(statsAfter.partition()).isEqualTo(statsBefore.partition()); // data count should match diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java index eae9208022a1..184693e2111b 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java @@ -239,11 +239,74 @@ public void testRewriteSummary() throws Exception { Map summary = snapshotSummary(); assertThat(summary) .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") .hasEntrySatisfying( EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("3.5")); } + @TestTemplate + public void testRewritePositionDeletesWithArrayColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, items ARRAY>) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', array(named_struct('value', cast(10 as bigint), 'count', 1))), " + + "(2, 'b', array(named_struct('value', cast(20 as bigint), 'count', 2))), " + + "(3, 'c', array(named_struct('value', cast(30 as bigint), 'count', 3))), " + + "(4, 'd', array(named_struct('value', cast(40 as bigint), 'count', 4))), " + + "(5, 'e', array(named_struct('value', cast(50 as bigint), 'count', 5))), " + + "(6, 'f', array(named_struct('value', cast(60 as bigint), 'count', 6)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + + @TestTemplate + public void testRewritePositionDeletesWithMapColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, props MAP) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', map('x', cast(10 as bigint))), " + + "(2, 'b', map('y', cast(20 as bigint))), " + + "(3, 'c', map('z', cast(30 as bigint))), " + + "(4, 'd', map('w', cast(40 as bigint))), " + + "(5, 'e', map('v', cast(50 as bigint))), " + + "(6, 'f', map('u', cast(60 as bigint)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + private Map snapshotSummary() { return validationCatalog.loadTable(tableIdent).currentSnapshot().summary(); } diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java index d1db48b0bf9e..ceb3077c5670 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java @@ -30,6 +30,7 @@ import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RewriteTablePathUtil; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableUtil; import org.apache.iceberg.data.FileHelpers; @@ -211,7 +212,12 @@ public void testRewriteTablePathWithManifestAndDeleteCounts() throws IOException List> rowsToDelete = Lists.newArrayList( Pair.of( - table.currentSnapshot().addedDataFiles(table.io()).iterator().next().location(), + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .iterator() + .next() + .location(), 0L)); File file = new File(removePrefix(table.location()) + "/data/deletes.parquet"); diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java index b28ea0e286e9..20cd0f239179 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java @@ -54,6 +54,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -1306,7 +1307,9 @@ public void testUpdateWithStaticPredicatePushdown() { assertThat(dataFilesCount).as("Must have 2 files before UPDATE").isEqualTo("2"); // remove the data file from the 'hr' partition to ensure it is not scanned - DataFile dataFile = Iterables.getOnlyElement(snapshot.addedDataFiles(table.io())); + DataFile dataFile = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()); table.io().deleteFile(dataFile.location()); // disable dynamic pruning and rely only on static predicate pushdown diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java index 6144b249305c..153f580e5ba6 100644 --- a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java @@ -25,7 +25,6 @@ import java.nio.file.Paths; import java.util.List; -import java.util.Locale; import java.util.Random; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -108,7 +107,12 @@ public static Object[][] parameters() { .putAll(SparkCatalogConfig.SPARK_SESSION_WITH_VIEWS.properties()) .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) .build() - } + }, + { + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.catalogName(), + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.implementation(), + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.properties() + }, }; } @@ -215,9 +219,7 @@ public void readFromViewUsingNonExistingTable() throws NoSuchTableException { assertThatThrownBy(() -> sql("SELECT * FROM %s", viewName)) .isInstanceOf(AnalysisException.class) .hasMessageContaining( - String.format( - "The table or view `%s`.`%s`.`non_existing` cannot be found", - catalogName, NAMESPACE)); + "The table or view `%s`.`%s`.`non_existing` cannot be found", catalogName, NAMESPACE); } @TestTemplate @@ -876,8 +878,7 @@ public void renameViewTargetAlreadyExistsAsView() { assertThatThrownBy(() -> sql("ALTER VIEW %s RENAME TO %s", viewName, target)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view default.%s because it already exists", target)); + .hasMessageContaining("Cannot create view default.%s because it already exists", target); } @TestTemplate @@ -901,8 +902,7 @@ public void renameViewTargetAlreadyExistsAsTable() { catalogName, NAMESPACE, target, catalogName.equals(SPARK_CATALOG) ? " USING iceberg" : ""); assertThatThrownBy(() -> sql("ALTER VIEW %s RENAME TO %s", viewName, target)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view default.%s because it already exists", target)); + .hasMessageContaining("Cannot create view default.%s because it already exists", target); } @TestTemplate @@ -996,8 +996,7 @@ public void createViewIfNotExists() { assertThatThrownBy(() -> sql("CREATE VIEW %s AS SELECT id FROM %s", viewName, tableName)) .isInstanceOf(AnalysisException.class) .hasMessageContaining( - String.format( - "Cannot create view %s.%s because it already exists", NAMESPACE, viewName)); + "Cannot create view %s.%s because it already exists", NAMESPACE, viewName); // using IF NOT EXISTS should work assertThatNoException() @@ -1041,8 +1040,7 @@ public void createViewReferencingTempView() throws NoSuchTableException { () -> sql("CREATE VIEW %s AS SELECT id FROM %s", viewReferencingTempView, tempView)) .isInstanceOf(AnalysisException.class) .hasMessageContaining( - String.format( - "Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewReferencingTempView)) + "Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewReferencingTempView) .hasMessageContaining("that references temporary view:") .hasMessageContaining(tempView); } @@ -1065,8 +1063,7 @@ public void createViewReferencingGlobalTempView() throws NoSuchTableException { viewReferencingTempView, globalTempView)) .isInstanceOf(AnalysisException.class) .hasMessageContaining( - String.format( - "Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewReferencingTempView)) + "Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewReferencingTempView) .hasMessageContaining("that references temporary view:") .hasMessageContaining("%s.%s", "global_temp", globalTempView); } @@ -1084,8 +1081,7 @@ public void createViewReferencingTempFunction() { assertThatThrownBy( () -> sql("CREATE VIEW %s AS SELECT %s(id) FROM %s", viewName, functionName, tableName)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("that references temporary function:") .hasMessageContaining(functionName); } @@ -1136,8 +1132,7 @@ public void createViewWithMismatchedColumnCounts() { assertThatThrownBy( () -> sql("CREATE VIEW %s (id, data) AS SELECT id FROM %s", viewName, tableName)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("not enough data columns") .hasMessageContaining("View columns: id, data") .hasMessageContaining("Data columns: id"); @@ -1145,8 +1140,7 @@ public void createViewWithMismatchedColumnCounts() { assertThatThrownBy( () -> sql("CREATE VIEW %s (id) AS SELECT id, data FROM %s", viewName, tableName)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("too many data columns") .hasMessageContaining("View columns: id") .hasMessageContaining("Data columns: id, data"); @@ -1265,8 +1259,7 @@ public void createViewWithCTEReferencingTempView() { assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("that references temporary view:") .hasMessageContaining(tempViewInCTE); } @@ -1287,8 +1280,7 @@ public void createViewWithCTEReferencingTempFunction() { assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("that references temporary function:") .hasMessageContaining(functionName); } @@ -1316,8 +1308,7 @@ public void createViewWithSubqueryExpressionUsingTempView() { assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("that references temporary view:") .hasMessageContaining(tempView); } @@ -1337,8 +1328,7 @@ public void createViewWithSubqueryExpressionUsingGlobalTempView() { assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("that references temporary view:") .hasMessageContaining("%s.%s", "global_temp", globalTempView); } @@ -1358,8 +1348,7 @@ public void createViewWithSubqueryExpressionUsingTempFunction() { assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) .isInstanceOf(AnalysisException.class) - .hasMessageContaining( - String.format("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) .hasMessageContaining("that references temporary function:") .hasMessageContaining(functionName); } @@ -1367,6 +1356,10 @@ public void createViewWithSubqueryExpressionUsingTempFunction() { @TestTemplate public void createViewWithSubqueryExpressionInFilterThatIsRewritten() throws NoSuchTableException { + assumeThat(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE)) + .as( + "Executing subquery expression with Hive fails due to an exception when instantiating the FileInputFormat") + .isNotEqualTo("hive"); insertRows(5); String viewName = viewName("viewWithSubqueryExpression"); String sql = @@ -1393,6 +1386,10 @@ public void createViewWithSubqueryExpressionInFilterThatIsRewritten() @TestTemplate public void createViewWithSubqueryExpressionInQueryThatIsRewritten() throws NoSuchTableException { + assumeThat(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE)) + .as( + "Executing subquery expression with Hive fails due to an exception when instantiating the FileInputFormat") + .isNotEqualTo("hive"); insertRows(3); String viewName = viewName("viewWithSubqueryExpression"); String sql = @@ -1550,25 +1547,26 @@ public void showViews() throws NoSuchTableException { insertRows(6); String sql = String.format("SELECT * from %s", tableName); String v1 = viewName("v1"); - String prefixV2 = viewName("prefixV2"); - String prefixV3 = viewName("prefixV3"); - String globalViewForListing = viewName("globalViewForListing"); - String tempViewForListing = viewName("tempViewForListing"); + String prefixV2 = viewName("prefix_v2"); + String prefixV3 = viewName("prefix_v3"); + String globalViewForListing = viewName("global_view_for_listing"); + String tempViewForListing = viewName("temp_view_for_listing"); sql("CREATE VIEW %s AS %s", v1, sql); sql("CREATE VIEW %s AS %s", prefixV2, sql); sql("CREATE VIEW %s AS %s", prefixV3, sql); sql("CREATE GLOBAL TEMPORARY VIEW %s AS %s", globalViewForListing, sql); sql("CREATE TEMPORARY VIEW %s AS %s", tempViewForListing, sql); - // spark stores temp views case-insensitive by default - Object[] tempView = row("", tempViewForListing.toLowerCase(Locale.ROOT), true); + Object[] globalView = row("global_temp", globalViewForListing, true); + Object[] tempView = row("", tempViewForListing, true); Object[] v1Row = row(NAMESPACE.toString(), v1, false); Object[] v2Row = row(NAMESPACE.toString(), prefixV2, false); Object[] v3Row = row(NAMESPACE.toString(), prefixV3, false); assertThat(sql("SHOW VIEWS")).contains(v2Row, v3Row, v1Row, tempView); - if (!"rest".equals(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE))) { - // REST catalog requires a namespace + String catalogType = catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE); + if (!"rest".equals(catalogType) && !"hive".equals(catalogType)) { + // REST & Hive catalog require a namespace assertThat(sql("SHOW VIEWS IN %s", catalogName)) .contains(tempView) .doesNotContain(v1Row, v2Row, v3Row); @@ -1583,7 +1581,7 @@ public void showViews() throws NoSuchTableException { assertThat(sql("SHOW VIEWS LIKE 'non-existing'")).isEmpty(); - if (!catalogName.equals(SPARK_CATALOG)) { + if (catalogName.equals(SparkCatalogConfig.SPARK_WITH_VIEWS.catalogName())) { sql("CREATE NAMESPACE IF NOT EXISTS spark_catalog.%s", NAMESPACE); assertThat(sql("SHOW VIEWS IN spark_catalog.%s", NAMESPACE)) .contains(tempView) @@ -1591,9 +1589,7 @@ public void showViews() throws NoSuchTableException { } assertThat(sql("SHOW VIEWS IN global_temp")) - .contains( - // spark stores temp views case-insensitive by default - row("global_temp", globalViewForListing.toLowerCase(Locale.ROOT), true), tempView) + .contains(globalView, tempView) .doesNotContain(v1Row, v2Row, v3Row); sql("USE spark_catalog"); @@ -1606,8 +1602,8 @@ public void showViews() throws NoSuchTableException { public void showViewsWithCurrentNamespace() { String namespaceOne = "show_views_ns1"; String namespaceTwo = "show_views_ns2"; - String viewOne = viewName("viewOne"); - String viewTwo = viewName("viewTwo"); + String viewOne = viewName("view_one"); + String viewTwo = viewName("view_two"); sql("CREATE NAMESPACE IF NOT EXISTS %s", namespaceOne); sql("CREATE NAMESPACE IF NOT EXISTS %s", namespaceTwo); @@ -1623,14 +1619,14 @@ public void showViewsWithCurrentNamespace() { .doesNotContain(v2); sql("USE %s", namespaceOne); assertThat(sql("SHOW VIEWS")).contains(v1).doesNotContain(v2); - assertThat(sql("SHOW VIEWS LIKE 'viewOne*'")).contains(v1).doesNotContain(v2); + assertThat(sql("SHOW VIEWS LIKE 'view_one*'")).contains(v1).doesNotContain(v2); assertThat(sql("SHOW VIEWS IN %s.%s", catalogName, namespaceTwo)) .contains(v2) .doesNotContain(v1); sql("USE %s", namespaceTwo); assertThat(sql("SHOW VIEWS")).contains(v2).doesNotContain(v1); - assertThat(sql("SHOW VIEWS LIKE 'viewTwo*'")).contains(v2).doesNotContain(v1); + assertThat(sql("SHOW VIEWS LIKE 'view_two*'")).contains(v2).doesNotContain(v1); } @TestTemplate @@ -2021,8 +2017,7 @@ public void createViewWithRecursiveCycle() { String cycle = String.format("%s -> %s -> %s", view1, view2, view1); assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS SELECT * FROM %s", viewOne, view2)) .isInstanceOf(AnalysisException.class) - .hasMessageStartingWith( - String.format("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle)); + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); } @TestTemplate @@ -2043,8 +2038,7 @@ public void createViewWithRecursiveCycleToV1View() { String cycle = String.format("%s -> %s -> %s", view1, view2, view1); assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS SELECT * FROM %s", viewOne, view2)) .isInstanceOf(AnalysisException.class) - .hasMessageStartingWith( - String.format("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle)); + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); } @TestTemplate @@ -2068,8 +2062,7 @@ public void createViewWithRecursiveCycleInCTE() { String cycle = String.format("%s -> %s -> %s", view1, viewTwo, view1); assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS %s", viewOne, sql)) .isInstanceOf(AnalysisException.class) - .hasMessageStartingWith( - String.format("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle)); + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); } @TestTemplate @@ -2090,8 +2083,7 @@ public void createViewWithRecursiveCycleInSubqueryExpression() { String cycle = String.format("%s -> %s -> %s", view1, viewTwo, view1); assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS %s", viewOne, sql)) .isInstanceOf(AnalysisException.class) - .hasMessageStartingWith( - String.format("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle)); + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); } @TestTemplate diff --git a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java new file mode 100644 index 000000000000..0152c8e0e6f0 --- /dev/null +++ b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.RESTCatalog; +import org.apache.iceberg.rest.RESTCatalogProperties; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.sql.TestSelect; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRemoteScanPlanning extends TestSelect { + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, binaryTableName = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build(), + SparkCatalogConfig.REST.catalogName() + ".default.binary_table" + } + }; + } + + @TestTemplate + public void fileIOIsPropagated() { + RESTCatalog catalog = new RESTCatalog(); + catalog.setConf(new Configuration()); + catalog.initialize( + "test", + ImmutableMap.builder() + .putAll(restCatalog.properties()) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build()); + Table table = catalog.loadTable(tableIdent); + + SparkScanBuilder builder = new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + verifyFileIOHasPlanId(builder.build().toBatch(), table); + verifyFileIOHasPlanId(builder.buildCopyOnWriteScan().toBatch(), table); + } + + private void verifyFileIOHasPlanId(Batch batch, Table table) { + FileIO fileIOForScan = + (FileIO) + assertThat(batch) + .extracting("fileIO") + .isInstanceOf(Supplier.class) + .asInstanceOf(InstanceOfAssertFactories.type(Supplier.class)) + .actual() + .get(); + assertThat(fileIOForScan.properties()).containsKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + assertThat(table.io().properties()).doesNotContainKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + } +} diff --git a/spark/v3.5/spark-runtime/LICENSE b/spark/v3.5/spark-runtime/LICENSE index e36e0b684501..50c91faf8edb 100644 --- a/spark/v3.5/spark-runtime/LICENSE +++ b/spark/v3.5/spark-runtime/LICENSE @@ -203,77 +203,170 @@ -------------------------------------------------------------------------------- -This binary artifact contains Apache Avro. +This product bundles Apache Avro. Copyright: Copyright 2010-2019 The Apache Software Foundation -Home page: https://avro.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://avro.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains the Jackson JSON processor. +This product bundles Jackson JSON processor. Copyright: 2007-2019 Tatu Saloranta and other contributors -Home page: http://jackson.codehaus.org/ -License: http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Parquet. +This product bundles FastDoubleParser (via Jackson JSON Processor). + +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles fast_float (bundled by FastDoubleParser). + +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles bigint (bundled by FastDoubleParser). + +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Parquet. Copyright: 2014-2024 The Apache Software Foundation -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://parquet.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Thrift. +This product bundles Apache Thrift (bundled by Parquet). Copyright: 2006-2017 The Apache Software Foundation. -Home page: https://thrift.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://thrift.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Daniel Lemire's JavaFastPFOR project. +This product includes code from Daniel Lemire's JavaFastPFOR project (bundled by Parquet). Copyright: 2013 Daniel Lemire -Home page: https://github.com/lemire/JavaFastPFOR -License: Apache License Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/lemire/JavaFastPFOR +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains fastutil. +This product bundles fastutil (bundled by Parquet). Copyright: 2002-2014 Sebastiano Vigna -Home page: http://fastutil.di.unimi.it/ -License: http://www.apache.org/licenses/LICENSE-2.0.html +Project URL: http://fastutil.di.unimi.it/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache ORC. +This product bundles Zero-Allocation Hashing (bundled by Parquet). + +Project URL: https://github.com/OpenHFT/Zero-Allocation-Hashing +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Apache ORC. Copyright: 2013 and onwards The Apache Software Foundation. -Home page: https://orc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://orc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Hive's storage API via ORC. +This product bundles Apache Hive's Storage API (bundled by ORC). Copyright: 2008-2020 The Apache Software Foundation -Home page: https://hive.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://hive.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google protobuf via ORC. +This product bundles Google protobuf (bundled by ORC). Copyright: 2008 Google Inc. -Home page: https://developers.google.com/protocol-buffers -License: https://github.com/protocolbuffers/protobuf/blob/master/LICENSE (BSD) - -License text: +Project URL: https://developers.google.com/protocol-buffers +License: BSD 3-Clause | Copyright 2008 Google Inc. All rights reserved. | @@ -310,53 +403,433 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Airlift Aircompressor. +This product bundles Airlift Aircompressor. Copyright: 2011-2019 Aircompressor authors. -Home page: https://github.com/airlift/aircompressor -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Cloudera Kite. +This product includes code from Cloudera Kite. Copyright: 2013-2017 Cloudera Inc. -Home page: https://kitesdk.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://kitesdk.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Presto. +This product includes code from Presto. Copyright: 2016 Facebook and contributors -Home page: https://prestodb.io/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://prestodb.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Guava. +This product bundles Google Guava. Copyright: 2006-2019 The Guava Authors -Home page: https://github.com/google/guava -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Error Prone Annotations. +This product bundles the Mozilla Public Suffix List, distributed by Apache HttpComponents. + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + +This product bundles Google Error Prone Annotations. Copyright: Copyright 2011-2019 The Error Prone Authors -Home page: https://github.com/google/error-prone -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains checkerframework checker-qual Annotations. +This product bundles checkerframework checker-qual. Copyright: 2004-2019 the Checker Framework developers -Home page: https://github.com/typetools/checker-framework -License: https://github.com/typetools/checker-framework/blob/master/LICENSE.txt (MIT license) +Project URL: https://github.com/typetools/checker-framework +License: MIT license -License text: | The annotations are licensed under the MIT License. (The text of this | license appears below.) More specifically, all the parts of the Checker | Framework that you might want to include with your own program use the @@ -387,45 +860,50 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Caffeine by Ben Manes. +This product bundles Caffeine by Ben Manes. Copyright: 2014-2019 Ben Manes and contributors -Home page: https://github.com/ben-manes/caffeine -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Arrow. +This product bundles Apache Arrow. Copyright: 2016-2019 The Apache Software Foundation. -Home page: https://arrow.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://arrow.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Netty's buffer library. +This product bundles Netty. Copyright: 2014-2020 The Netty Project -Home page: https://netty.io/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://netty.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JCTools (via Netty). + +Project URL: https://github.com/JCTools/JCTools +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google FlatBuffers. +This product bundles Google FlatBuffers. Copyright: 2013-2020 Google Inc. -Home page: https://google.github.io/flatbuffers/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://google.github.io/flatbuffers/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains ThreeTen. +This product bundles ThreeTen Extra. Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. -Home page: https://www.threeten.org/threeten-extra/ -License: https://github.com/ThreeTen/threeten-extra/blob/master/LICENSE.txt (BSD 3-clause) - -License text: +Project URL: https://www.threeten.org/threeten-extra/ +License: BSD 3-Clause | All rights reserved. | @@ -457,15 +935,15 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains code from Project Nessie. +This product bundles Project Nessie. Copyright: 2015-2025 Dremio Corporation. -Home page: https://projectnessie.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://projectnessie.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache Spark. +This product bundles and includes code from Apache Spark. * vectorized reading of definition levels in BaseVectorizedParquetValuesReader.java * portions of the extensions parser @@ -473,8 +951,8 @@ This product includes code from Apache Spark. * implementation of SetAccumulator. Copyright: 2011-2018 The Apache Software Foundation -Home page: https://spark.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://spark.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -483,48 +961,128 @@ This product includes code from Delta Lake. * AssignmentAlignmentSupport is an independent development but UpdateExpressionsSupport in Delta was used as a reference. Copyright: 2020 The Delta Lake Project Authors. -Home page: https://delta.io/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://delta.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary includes code from Apache Commons. +This product includes code from Apache Commons. * Core ArrayUtil. Copyright: 2020 The Apache Software Foundation -Home page: https://commons.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://commons.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache HttpComponents Client. +This product bundles and includes code from Apache HttpComponents (core/client). + +* retry and error handling logic in ExponentialHttpRequestRetryStrategy.java -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 1999-2022 The Apache Software Foundation +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache HttpComponents Client. +This product bundles failsafe. -* retry and error handling logic in ExponentialHttpRequestRetryStrategy.java +Copyright: Jonathan Halterman and friends +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +-------------------------------------------------------------------------------- + +This product bundles Eclipse MicroProfile OpenAPI. + +Project URL: https://github.com/microprofile/microprofile-open-api +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains failsafe. +This product bundles Eclipse Collections. -Copyright: Jonathan Halterman and friends -Home page: https://failsafe.dev/ -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/eclipse-collections/eclipse-collections +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Datasketches. + +Project URL: https://datasketches.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JTS Topology Suite. + +Project URL: https://github.com/locationtech/jts +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -This binary artifact contains Eclipse MicroProfile OpenAPI. +This product bundles RoaringBitmap. -Home page: https://github.com/microprofile/microprofile-open-api -License: https://www.apache.org/licenses/LICENSE-2.0 +Copyright: (c) 2013-... the RoaringBitmap authors +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 diff --git a/spark/v3.5/spark-runtime/NOTICE b/spark/v3.5/spark-runtime/NOTICE index b5d1c8d1be4b..c038e853af77 100644 --- a/spark/v3.5/spark-runtime/NOTICE +++ b/spark/v3.5/spark-runtime/NOTICE @@ -1,15 +1,14 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -This binary artifact contains code from Kite, developed at Cloudera, Inc. with -the following copyright notice: - +This product includes code from Kite, developed at Cloudera, Inc. with +the following in its NOTICE file: | Copyright 2013 Cloudera Inc. | | Licensed under the Apache License, Version 2.0 (the "License"); @@ -26,9 +25,7 @@ the following copyright notice: -------------------------------------------------------------------------------- -This binary artifact includes Airlift Aircompressor with the following in its -NOTICE file: - +This product bundles Airlift Aircompressor with the following in its NOTICE file: | Snappy Copyright Notices | ========================= | @@ -69,47 +66,7 @@ NOTICE file: -------------------------------------------------------------------------------- -This binary artifact includes Google Protobuf with the following copyright -notice: - -| Copyright 2008 Google Inc. All rights reserved. -| -| Redistribution and use in source and binary forms, with or without -| modification, are permitted provided that the following conditions are -| met: -| -| * Redistributions of source code must retain the above copyright -| notice, this list of conditions and the following disclaimer. -| * Redistributions in binary form must reproduce the above -| copyright notice, this list of conditions and the following disclaimer -| in the documentation and/or other materials provided with the -| distribution. -| * Neither the name of Google Inc. nor the names of its -| contributors may be used to endorse or promote products derived from -| this software without specific prior written permission. -| -| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -| -| Code generated by the Protocol Buffer compiler is owned by the owner -| of the input file used when generating it. This code is not -| standalone and requires a support library to be linked with it. This -| support library is itself covered by the above license. - --------------------------------------------------------------------------------- - -This binary artifact includes Netty buffers with the following in its NOTICE -file: - +This product bundles Netty with the following in its NOTICE file: | The Netty Project | ================= | @@ -360,9 +317,7 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Project Nessie with the following in its NOTICE -file: - +This product bundles Project Nessie with the following in its NOTICE file: | Nessie | Copyright 2015-2025 Dremio Corporation | @@ -380,8 +335,43 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Eclipse MicroProfile OpenAPI with the following in its NOTICE file: +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. + +-------------------------------------------------------------------------------- +This product bundles Eclipse MicroProfile OpenAPI with the following in its NOTICE file: | ========================================================================= | == NOTICE file corresponding to section 4(d) of the Apache License, == | == Version 2.0, in this case for MicroProfile OpenAPI == diff --git a/spark/v3.5/spark-runtime/runtime-deps.txt b/spark/v3.5/spark-runtime/runtime-deps.txt new file mode 100644 index 000000000000..dc3fbeb7e9d5 --- /dev/null +++ b/spark/v3.5/spark-runtime/runtime-deps.txt @@ -0,0 +1,40 @@ +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.15.2 +com.fasterxml.jackson.core:jackson-databind:2.15.2 +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.3 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.google.errorprone:error_prone_annotations:2.10.0 +com.google.flatbuffers:flatbuffers-java:23.5.26 +dev.failsafe:failsafe:3.3.2 +io.airlift:aircompressor:2.0.3 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-common:4.2.13.Final +org.apache.arrow:arrow-format:15.0.2 +org.apache.arrow:arrow-memory-core:15.0.2 +org.apache.arrow:arrow-memory-netty:15.0.2 +org.apache.arrow:arrow-vector:15.0.2 +org.apache.avro:avro:1.12.1 +org.apache.datasketches:datasketches-java:6.2.0 +org.apache.datasketches:datasketches-memory:3.0.2 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.checkerframework:checker-qual:3.19.0 +org.eclipse.collections:eclipse-collections-api:11.1.0 +org.eclipse.collections:eclipse-collections:11.1.0 +org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1 +org.locationtech.jts:jts-core:1.20.0 +org.projectnessie.nessie:nessie-client:0.107.5 +org.projectnessie.nessie:nessie-model:0.107.5 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.threeten:threeten-extra:1.7.1 diff --git a/spark/v3.5/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java b/spark/v3.5/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java index 29f725615a21..709f626b08c1 100644 --- a/spark/v3.5/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java +++ b/spark/v3.5/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java @@ -38,8 +38,6 @@ public void dropTable() { } // Run through our Doc's Getting Started Example - // TODO Update doc example so that it can actually be run, modifications were required for this - // test suite to run @TestTemplate public void testGettingStarted() throws IOException { // Creating a table diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java index 64edb1002e99..47fe46558d7e 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java @@ -37,6 +37,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.actions.SparkActions; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; @@ -179,6 +180,7 @@ private void setupSpark() { .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") .config("spark.sql.catalog.spark_catalog.warehouse", catalogWarehouse()) + .config(TestBase.DISABLE_UI) .master("local"); spark = builder.getOrCreate(); } diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java index 77b79384a6d8..683f6bb46d05 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java @@ -26,6 +26,7 @@ import static org.apache.spark.sql.functions.expr; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.util.Collections; import java.util.UUID; @@ -40,6 +41,7 @@ import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.actions.SparkActions; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; @@ -373,11 +375,12 @@ protected final SparkSession spark() { protected String getCatalogWarehouse() { try { - String location = - Files.createTempDirectory("benchmark-").toAbsolutePath() + "/" + UUID.randomUUID() + "/"; - return location; + return Files.createTempDirectory("benchmark-").toAbsolutePath() + + "/" + + UUID.randomUUID() + + "/"; } catch (IOException e) { - throw new RuntimeException(e); + throw new UncheckedIOException(e); } } @@ -392,6 +395,7 @@ protected void setupSpark() { "spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog") .config("spark.sql.catalog.spark_catalog.type", "hadoop") .config("spark.sql.catalog.spark_catalog.warehouse", getCatalogWarehouse()) + .config(TestBase.DISABLE_UI) .master("local[*]"); spark = builder.getOrCreate(); Configuration sparkHadoopConf = spark.sessionState().newHadoopConf(); diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java index f2f9488b6ec2..1c7505eab471 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java @@ -25,9 +25,11 @@ import java.io.IOException; import java.util.List; import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; import org.apache.iceberg.Schema; import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; @@ -113,9 +115,9 @@ public void tearDownBenchmark() { @Threads(1) public void readUsingIcebergReader(Blackhole blackHole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) .build()) { for (InternalRow row : rows) { @@ -171,9 +173,9 @@ public void readUsingSparkReader(Blackhole blackhole) throws IOException { @Threads(1) public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(PROJECTED_SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) .build()) { for (InternalRow row : rows) { diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java index b19ab683d634..42454c10ac66 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java @@ -25,9 +25,11 @@ import java.io.IOException; import java.util.List; import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; import org.apache.iceberg.Schema; import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; @@ -111,9 +113,9 @@ public void tearDownBenchmark() { @Threads(1) public void readUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) .build()) { for (InternalRow row : rows) { @@ -169,9 +171,9 @@ public void readUsingSparkReader(Blackhole blackhole) throws IOException { @Threads(1) public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(PROJECTED_SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) .build()) { for (InternalRow row : rows) { diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java index 87289b3cba31..f77454c4b2a3 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java @@ -23,13 +23,17 @@ import java.io.File; import java.io.IOException; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.RandomData; -import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; @@ -95,15 +99,16 @@ public void tearDownBenchmark() { @Benchmark @Threads(1) public void writeUsingIcebergWriter() throws IOException { - try (FileAppender writer = - Parquet.write(Files.localOutput(dataFile)) - .createWriterFunc( - msgType -> - SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) .build()) { - writer.addAll(rows); + writer.write(rows); } } @@ -121,6 +126,7 @@ public void writeUsingSparkWriter() throws IOException { .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") .set("spark.sql.caseSensitive", "false") .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") .schema(SCHEMA) .build()) { diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java index dd913a27561b..a732d36526c0 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java @@ -23,13 +23,17 @@ import java.io.File; import java.io.IOException; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.RandomData; -import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; @@ -95,15 +99,16 @@ public void tearDownBenchmark() { @Benchmark @Threads(1) public void writeUsingIcebergWriter() throws IOException { - try (FileAppender writer = - Parquet.write(Files.localOutput(dataFile)) - .createWriterFunc( - msgType -> - SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) .build()) { - writer.addAll(rows); + writer.write(rows); } } @@ -121,6 +126,7 @@ public void writeUsingSparkWriter() throws IOException { .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") .set("spark.sql.caseSensitive", "false") .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") .schema(SCHEMA) .build()) { diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java index c6794e43c636..3f242ce228ca 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java @@ -49,6 +49,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.util.ContentFileUtil; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.InternalRow; @@ -234,7 +235,7 @@ private String generateDataFilePath() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java index ac74fb5a109c..db5789724056 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java @@ -43,6 +43,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; @@ -218,7 +219,7 @@ private String generateDataFilePath() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java index 68c537e34a4a..debe37866ff7 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java @@ -30,6 +30,7 @@ import org.apache.iceberg.UpdateProperties; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.apache.spark.sql.SaveMode; @@ -94,7 +95,7 @@ protected void cleanupFiles() throws IOException { } protected void setupSpark(boolean enableDictionaryEncoding) { - SparkSession.Builder builder = SparkSession.builder().config("spark.ui.enabled", false); + SparkSession.Builder builder = SparkSession.builder().config(TestBase.DISABLE_UI); if (!enableDictionaryEncoding) { builder .config("parquet.dictionary.page.size", "1") diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java index f410fcc905fe..4427d58b6477 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -44,7 +45,7 @@ protected void appendData() throws IOException { writeData(fileNum); table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletes(file.location(), NUM_ROWS, 0.25, numDeleteFile); } } diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java index b145a8178898..03e2ebc06309 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -46,7 +47,7 @@ protected void appendData() throws IOException { if (percentDeleteRow > 0) { // add pos-deletes table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletes(file.location(), NUM_ROWS, percentDeleteRow); } } diff --git a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java index b0439fe07ee8..dd35dde1fd65 100644 --- a/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java +++ b/spark/v3.5/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -46,7 +47,7 @@ protected void appendData() throws IOException { writeData(fileNum); table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletesWithNoise( file.location(), NUM_ROWS, diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java index ea400a779235..cb9da3edc678 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java @@ -56,7 +56,7 @@ public static R withCommitProperties( ExceptionUtil.castAndThrow(e, exClass); return null; } finally { - COMMIT_PROPERTIES.set(ImmutableMap.of()); + COMMIT_PROPERTIES.remove(); } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java index 442d728d4d69..b7cd441c9642 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java @@ -24,6 +24,4 @@ @Value.Immutable public interface ParquetBatchReadConf extends Serializable { int batchSize(); - - ParquetReaderType readerType(); } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java deleted file mode 100644 index d9742c048251..000000000000 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java +++ /dev/null @@ -1,47 +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. - */ -package org.apache.iceberg.spark; - -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; - -/** Enumerates the types of Parquet readers. */ -public enum ParquetReaderType { - /** ICEBERG type utilizes the built-in Parquet reader. */ - ICEBERG, - - /** - * COMET type changes the Parquet reader to the Apache DataFusion Comet Parquet reader. Comet - * Parquet reader performs I/O and decompression in the JVM but decodes in native to improve - * performance. Additionally, Comet will convert Spark's physical plan into a native physical plan - * and execute this plan natively. - * - *

    TODO: Implement {@link org.apache.comet.parquet.SupportsComet} in SparkScan to convert Spark - * physical plan to native physical plan for native execution. - */ - COMET; - - public static ParquetReaderType fromString(String typeAsString) { - Preconditions.checkArgument(typeAsString != null, "Parquet reader type is null"); - try { - return ParquetReaderType.valueOf(typeAsString.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Unknown parquet reader type: " + typeAsString); - } - } -} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java index fbd21f737450..fec413ca079a 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java @@ -41,6 +41,7 @@ import org.apache.spark.sql.types.IntegerType$; import org.apache.spark.sql.types.LongType$; import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType$; import org.apache.spark.sql.types.StringType$; import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; @@ -238,5 +239,6 @@ public Type primitive(Type.PrimitiveType primitive) { .put(TypeID.STRING, ImmutableSet.of(StringType$.class)) .put(TypeID.FIXED, ImmutableSet.of(BinaryType$.class)) .put(TypeID.BINARY, ImmutableSet.of(BinaryType$.class)) + .put(TypeID.UNKNOWN, ImmutableSet.of(NullType$.class)) .buildOrThrow(); } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java index bc8a966488ee..f1709277525a 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java @@ -24,17 +24,17 @@ import java.util.function.Function; import org.apache.spark.sql.connector.catalog.Identifier; import org.apache.spark.sql.connector.catalog.StagedTable; -import org.apache.spark.sql.connector.catalog.SupportsDelete; +import org.apache.spark.sql.connector.catalog.SupportsDeleteV2; import org.apache.spark.sql.connector.catalog.SupportsRead; import org.apache.spark.sql.connector.catalog.SupportsWrite; import org.apache.spark.sql.connector.catalog.Table; import org.apache.spark.sql.connector.catalog.TableCapability; import org.apache.spark.sql.connector.catalog.TableCatalog; import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.ScanBuilder; import org.apache.spark.sql.connector.write.LogicalWriteInfo; import org.apache.spark.sql.connector.write.WriteBuilder; -import org.apache.spark.sql.sources.Filter; import org.apache.spark.sql.types.StructType; import org.apache.spark.sql.util.CaseInsensitiveStringMap; @@ -58,7 +58,7 @@ * #capabilities()}. */ public class RollbackStagedTable - implements StagedTable, SupportsRead, SupportsWrite, SupportsDelete { + implements StagedTable, SupportsRead, SupportsWrite, SupportsDeleteV2 { private final TableCatalog catalog; private final Identifier ident; private final Table table; @@ -106,8 +106,8 @@ public Set capabilities() { } @Override - public void deleteWhere(Filter[] filters) { - call(SupportsDelete.class, t -> t.deleteWhere(filters)); + public void deleteWhere(Predicate[] predicates) { + call(SupportsDeleteV2.class, t -> t.deleteWhere(predicates)); } @Override diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java index 5e1cd977954b..913fe25e6dd1 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java @@ -797,6 +797,7 @@ public final void initialize(String name, CaseInsensitiveStringMap options) { EnvironmentContext.put( EnvironmentContext.ENGINE_VERSION, sparkSession.sparkContext().version()); EnvironmentContext.put(CatalogProperties.APP_ID, sparkSession.sparkContext().applicationId()); + EnvironmentContext.put(CatalogProperties.APP_NAME, sparkSession.sparkContext().appName()); } @Override diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java index bad31d8d85f4..7b44d93e53b5 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java @@ -35,8 +35,6 @@ public abstract class SparkContentFile implements ContentFile { - private static final FileContent[] FILE_CONTENT_VALUES = FileContent.values(); - private final int fileContentPosition; private final int filePathPosition; private final int fileFormatPosition; @@ -54,6 +52,7 @@ public abstract class SparkContentFile implements ContentFile { private final int sortOrderIdPosition; private final int fileSpecIdPosition; private final int equalityIdsPosition; + private final int firstRowIdPosition; private final int referencedDataFilePosition; private final int contentOffsetPosition; private final int contentSizePosition; @@ -106,6 +105,7 @@ public abstract class SparkContentFile implements ContentFile { this.sortOrderIdPosition = positions.get(DataFile.SORT_ORDER_ID.name()); this.fileSpecIdPosition = positions.get(DataFile.SPEC_ID.name()); this.equalityIdsPosition = positions.get(DataFile.EQUALITY_IDS.name()); + this.firstRowIdPosition = positions.get(DataFile.FIRST_ROW_ID.name()); this.referencedDataFilePosition = positions.get(DataFile.REFERENCED_DATA_FILE.name()); this.contentOffsetPosition = positions.get(DataFile.CONTENT_OFFSET.name()); this.contentSizePosition = positions.get(DataFile.CONTENT_SIZE.name()); @@ -139,7 +139,7 @@ public FileContent content() { if (wrapped.isNullAt(fileContentPosition)) { return null; } - return FILE_CONTENT_VALUES[wrapped.getInt(fileContentPosition)]; + return FileContent.fromId(wrapped.getInt(fileContentPosition)); } @Override @@ -258,6 +258,15 @@ public Long contentSizeInBytes() { return wrapped.getLong(contentSizePosition); } + @Override + public Long firstRowId() { + if (wrapped.isNullAt(firstRowIdPosition)) { + return null; + } + + return wrapped.getLong(firstRowIdPosition); + } + private int fieldPosition(String name, StructType sparkType) { try { return sparkType.fieldIndex(name); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java index 9ea08c316919..a5de652f8047 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java @@ -22,10 +22,12 @@ import java.util.Map; import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.SupportsDistributedScanPlanning; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.hadoop.Util; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.PropertyUtil; import org.apache.spark.SparkConf; import org.apache.spark.sql.SparkSession; @@ -260,6 +262,39 @@ public int maxRecordsPerMicroBatch() { .parse(); } + public boolean asyncMicroBatchPlanningEnabled() { + return confParser + .booleanConf() + .option(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .sessionConf(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .defaultValue(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT) + .parse(); + } + + public long streamingSnapshotPollingIntervalMs() { + return confParser + .longConf() + .option(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS) + .defaultValue(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadFileLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadRowLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT) + .parse(); + } + public boolean preserveDataGrouping() { return confParser .booleanConf() @@ -280,6 +315,7 @@ public boolean aggregatePushDownEnabled() { public boolean adaptiveSplitSizeEnabled() { return confParser .booleanConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_ENABLED) .tableProperty(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED) .defaultValue(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED_DEFAULT) .parse(); @@ -291,8 +327,21 @@ public int parallelism() { return Math.max(defaultParallelism, numShufflePartitions); } + public int splitParallelism() { + int parallelism = + confParser + .intConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM) + .defaultValue(parallelism()) + .parse(); + Preconditions.checkArgument(parallelism > 0, "Split parallelism must be > 0: %s", parallelism); + return parallelism; + } + public boolean distributedPlanningEnabled() { - return dataPlanningMode() != LOCAL || deletePlanningMode() != LOCAL; + return table instanceof SupportsDistributedScanPlanning distributed + && distributed.allowDistributedPlanning() + && (dataPlanningMode() != LOCAL || deletePlanningMode() != LOCAL); } public PlanningMode dataPlanningMode() { @@ -367,12 +416,4 @@ public boolean reportColumnStats() { .defaultValue(SparkSQLProperties.REPORT_COLUMN_STATS_DEFAULT) .parse(); } - - public ParquetReaderType parquetReaderType() { - return confParser - .enumConf(ParquetReaderType::fromString) - .sessionConf(SparkSQLProperties.PARQUET_READER_TYPE) - .defaultValue(SparkSQLProperties.PARQUET_READER_TYPE_DEFAULT) - .parse(); - } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java index 17f2bfee69b8..5262310e2c5e 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java @@ -87,6 +87,21 @@ private SparkReadOptions() {} public static final String STREAMING_MAX_ROWS_PER_MICRO_BATCH = "streaming-max-rows-per-micro-batch"; + // Enable async micro batch planning + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "async-micro-batch-planning-enabled"; + + // Polling interval for async planner to refresh table metadata (ms) + public static final String STREAMING_SNAPSHOT_POLLING_INTERVAL_MS = + "streaming-snapshot-polling-interval-ms"; + public static final long STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT = 30000L; + + // Initial queue preload limits for async micro batch planner + public static final String ASYNC_QUEUE_PRELOAD_FILE_LIMIT = "async-queue-preload-file-limit"; + public static final long ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT = 100L; + public static final String ASYNC_QUEUE_PRELOAD_ROW_LIMIT = "async-queue-preload-row-limit"; + public static final long ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT = 100000L; + // Table path public static final String PATH = "path"; diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java index 5e76123cab42..3d3287e471e4 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java @@ -27,9 +27,6 @@ private SparkSQLProperties() {} // Controls whether vectorized reads are enabled public static final String VECTORIZATION_ENABLED = "spark.sql.iceberg.vectorization.enabled"; - // Controls which Parquet reader implementation to use - public static final String PARQUET_READER_TYPE = "spark.sql.iceberg.parquet.reader-type"; - public static final ParquetReaderType PARQUET_READER_TYPE_DEFAULT = ParquetReaderType.ICEBERG; // Controls whether to perform the nullability check during writes public static final String CHECK_NULLABILITY = "spark.sql.iceberg.check-nullability"; public static final boolean CHECK_NULLABILITY_DEFAULT = true; @@ -106,4 +103,18 @@ private SparkSQLProperties() {} // Controls whether to report available column statistics to Spark for query optimization. public static final String REPORT_COLUMN_STATS = "spark.sql.iceberg.report-column-stats"; public static final boolean REPORT_COLUMN_STATS_DEFAULT = true; + + // Controls whether to enable async micro batch planning for session + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "spark.sql.iceberg.async-micro-batch-planning-enabled"; + public static final boolean ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT = false; + + // Controls whether adaptive split sizing is enabled + public static final String READ_ADAPTIVE_SPLIT_SIZE_ENABLED = + "spark.sql.iceberg.read.adaptive-split-size.enabled"; + + // Overrides the parallelism used for adaptive split sizing. When unset, the parallelism + // defaults to max(spark.default.parallelism, spark.sql.shuffle.partitions). + public static final String READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM = + "spark.sql.iceberg.read.adaptive-split-size.parallelism"; } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java index 4e9573d55520..ce509d92741b 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java @@ -44,6 +44,7 @@ import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CatalogUtil; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; @@ -66,7 +67,6 @@ import org.apache.iceberg.hadoop.Util; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.OutputFile; -import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.mapping.NameMappingParser; import org.apache.iceberg.relocated.com.google.common.base.Joiner; @@ -80,8 +80,6 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.source.SparkTable; import org.apache.iceberg.util.PropertyUtil; -import org.apache.iceberg.util.Tasks; -import org.apache.iceberg.util.ThreadPools; import org.apache.spark.TaskContext; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; @@ -951,15 +949,7 @@ public static List filterPartitions( } private static void deleteManifests(FileIO io, List manifests) { - if (io instanceof SupportsBulkOperations) { - ((SupportsBulkOperations) io).deleteFiles(Lists.transform(manifests, ManifestFile::path)); - } else { - Tasks.foreach(manifests) - .executeWith(ThreadPools.getWorkerPool()) - .noRetry() - .suppressFailureWhenFinished() - .run(item -> io.deleteFile(item.path())); - } + CatalogUtil.deleteFiles(io, Lists.transform(manifests, ManifestFile::path), "manifests"); } public static Dataset loadTable(SparkSession spark, Table table, long snapshotId) { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java index 8beaefc5cc8f..b7ed31c274d7 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java @@ -35,6 +35,7 @@ import org.apache.spark.sql.types.IntegerType; import org.apache.spark.sql.types.LongType; import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType; import org.apache.spark.sql.types.ShortType; import org.apache.spark.sql.types.StringType; import org.apache.spark.sql.types.StructField; @@ -155,6 +156,8 @@ public Type atomic(DataType atomic) { ((DecimalType) atomic).precision(), ((DecimalType) atomic).scale()); } else if (atomic instanceof BinaryType) { return Types.BinaryType.get(); + } else if (atomic instanceof NullType) { + return Types.UnknownType.get(); } throw new UnsupportedOperationException("Not a supported type: " + atomic.catalogString()); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java index b3e8af5fe056..9da48ae51e5c 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java @@ -42,6 +42,7 @@ import org.apache.iceberg.FileFormat; import org.apache.iceberg.IsolationLevel; import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.TableUtil; @@ -162,6 +163,25 @@ public int outputSpecId() { return outputSpecId; } + public int outputSortOrderId(SparkWriteRequirements writeRequirements) { + Integer explicitId = + confParser.intConf().option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID).parseOptional(); + + if (explicitId != null) { + Preconditions.checkArgument( + table.sortOrders().containsKey(explicitId), + "Cannot use output sort order id %s because the table does not contain a sort order with that id", + explicitId); + return explicitId; + } + + if (writeRequirements.hasOrdering()) { + return table.sortOrder().orderId(); + } + + return SortOrder.unsorted().orderId(); + } + public FileFormat dataFileFormat() { String valueAsString = confParser diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java index 33db70bae587..1be02feaf0c0 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java @@ -54,6 +54,7 @@ private SparkWriteOptions() {} public static final String REWRITTEN_FILE_SCAN_TASK_SET_ID = "rewritten-file-scan-task-set-id"; public static final String OUTPUT_SPEC_ID = "output-spec-id"; + public static final String OUTPUT_SORT_ORDER_ID = "output-sort-order-id"; public static final String OVERWRITE_MODE = "overwrite-mode"; diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java index 833e0e44e391..072727ac4bb0 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java @@ -26,8 +26,10 @@ /** A set of requirements such as distribution and ordering reported to Spark during writes. */ public class SparkWriteRequirements { + private static final long NO_ADVISORY_PARTITION_SIZE = 0; public static final SparkWriteRequirements EMPTY = - new SparkWriteRequirements(Distributions.unspecified(), new SortOrder[0], 0); + new SparkWriteRequirements( + Distributions.unspecified(), new SortOrder[0], NO_ADVISORY_PARTITION_SIZE); private final Distribution distribution; private final SortOrder[] ordering; @@ -37,7 +39,11 @@ public class SparkWriteRequirements { Distribution distribution, SortOrder[] ordering, long advisoryPartitionSize) { this.distribution = distribution; this.ordering = ordering; - this.advisoryPartitionSize = advisoryPartitionSize; + // Spark prohibits requesting a particular advisory partition size without distribution + this.advisoryPartitionSize = + distribution instanceof UnspecifiedDistribution + ? NO_ADVISORY_PARTITION_SIZE + : advisoryPartitionSize; } public Distribution distribution() { @@ -53,7 +59,6 @@ public boolean hasOrdering() { } public long advisoryPartitionSize() { - // Spark prohibits requesting a particular advisory partition size without distribution - return distribution instanceof UnspecifiedDistribution ? 0 : advisoryPartitionSize; + return advisoryPartitionSize; } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java index dfb9b30be603..d33632bbbd54 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java @@ -38,6 +38,7 @@ import org.apache.spark.sql.types.MapType$; import org.apache.spark.sql.types.Metadata; import org.apache.spark.sql.types.MetadataBuilder; +import org.apache.spark.sql.types.NullType$; import org.apache.spark.sql.types.StringType$; import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType$; @@ -124,9 +125,11 @@ public DataType primitive(Type.PrimitiveType primitive) { case DECIMAL: Types.DecimalType decimal = (Types.DecimalType) primitive; return DecimalType$.MODULE$.apply(decimal.precision(), decimal.scale()); + case UNKNOWN: + return NullType$.MODULE$; default: throw new UnsupportedOperationException( - "Cannot convert unknown type to Spark: " + primitive); + "Cannot convert unsupported type to Spark: " + primitive); } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java index a8e82d101fbf..f53859ef97d6 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java @@ -161,7 +161,8 @@ protected Dataset contentFileDS(Table table, Set snapshotIds) { "length", "0 as sequenceNumber", "partition_spec_id as partitionSpecId", - "added_snapshot_id as addedSnapshotId") + "added_snapshot_id as addedSnapshotId", + "key_metadata as keyMetadata") .dropDuplicates("path") .repartition(numShufflePartitions) // avoid adaptive execution combining tasks .as(ManifestFileBean.ENCODER); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java index 0ca73bef4d7e..37e1ec4ce788 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java @@ -134,6 +134,10 @@ private void validateSourceTable() { Preconditions.checkArgument( !sourceCatalogTable.storage().locationUri().isEmpty(), "Cannot create an Iceberg table from a source without an explicit location"); + Preconditions.checkArgument( + sourceCatalogTable.bucketSpec().isEmpty(), + "Cannot create an Iceberg table from a bucketed source table: %s", + (Object) sourceCatalogTable.bucketSpec().getOrElse(() -> null)); } protected StagingTableCatalog checkDestinationCatalog(CatalogPlugin catalog) { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java index 78662159b0bb..92bfc880ad7f 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java @@ -300,7 +300,10 @@ private DeleteOrphanFiles.Result deleteFiles(Dataset orphanFileDS) { LOG.info("Deleted {} orphan files", filesCount); - return ImmutableDeleteOrphanFiles.Result.builder().orphanFileLocations(orphanFileList).build(); + return ImmutableDeleteOrphanFiles.Result.builder() + .orphanFileLocations(orphanFileList) + .orphanFilesCount(filesCount) + .build(); } private void collectPathsForOutput( diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java index 5f4d4ec15184..e49e7326736f 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java @@ -27,6 +27,7 @@ import java.util.concurrent.ExecutorService; import java.util.function.Consumer; import java.util.stream.Collectors; +import org.apache.iceberg.ExpireSnapshots.CleanupLevel; import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.Snapshot; import org.apache.iceberg.Table; @@ -169,7 +170,7 @@ public Dataset expireFiles() { expireSnapshots.cleanExpiredMetadata(cleanExpiredMetadata); } - expireSnapshots.cleanExpiredFiles(false).commit(); + expireSnapshots.cleanupLevel(CleanupLevel.NONE).commit(); // fetch valid files after expiration TableMetadata updatedMetadata = ops.refresh(); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java index fd4639897743..599e27b71c45 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java @@ -37,6 +37,7 @@ public class ManifestFileBean implements ManifestFile, Serializable { private Integer content = null; private Long sequenceNumber = null; private Long firstRowId = null; + private byte[] keyMetadata = null; public static ManifestFileBean fromManifest(ManifestFile manifest) { ManifestFileBean bean = new ManifestFileBean(); @@ -48,6 +49,7 @@ public static ManifestFileBean fromManifest(ManifestFile manifest) { bean.setContent(manifest.content().id()); bean.setSequenceNumber(manifest.sequenceNumber()); bean.setFirstRowId(manifest.firstRowId()); + bean.setKeyMetadata(manifest.keyMetadata() == null ? null : manifest.keyMetadata().array()); return bean; } @@ -104,6 +106,14 @@ public void setFirstRowId(Long firstRowId) { this.firstRowId = firstRowId; } + public byte[] getKeyMetadata() { + return keyMetadata; + } + + public void setKeyMetadata(byte[] keyMetadata) { + this.keyMetadata = keyMetadata; + } + @Override public String path() { return path; @@ -176,7 +186,7 @@ public List partitions() { @Override public ByteBuffer keyMetadata() { - return null; + return keyMetadata == null ? null : ByteBuffer.wrap(keyMetadata); } @Override diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java index a42ce0ecf7af..2f5cc25074ea 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java @@ -31,6 +31,7 @@ import java.util.stream.Collectors; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.SortOrder; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; @@ -96,6 +97,7 @@ public class RewriteDataFilesSparkAction private boolean removeDanglingDeletes; private boolean useStartingSequenceNumber; private boolean caseSensitive; + private String branch = SnapshotRef.MAIN_BRANCH; private BinPackRewriteFilePlanner planner = null; private FileRewriteRunner runner = null; @@ -157,13 +159,24 @@ public RewriteDataFilesSparkAction filter(Expression expression) { return this; } + public RewriteDataFilesSparkAction toBranch(String targetBranch) { + Preconditions.checkArgument(targetBranch != null, "Invalid branch name: null"); + this.branch = targetBranch; + return this; + } + @Override public RewriteDataFiles.Result execute() { if (table.currentSnapshot() == null) { return EMPTY_RESULT; } - long startingSnapshotId = table.currentSnapshot().snapshotId(); + Preconditions.checkArgument( + table.snapshot(branch) != null, + "Cannot rewrite data files for branch %s: branch does not exist", + branch); + + long startingSnapshotId = table.snapshot(branch).snapshotId(); init(startingSnapshotId); @@ -230,7 +243,7 @@ private ExecutorService rewriteService() { @VisibleForTesting RewriteDataFilesCommitManager commitManager(long startingSnapshotId) { return new RewriteDataFilesCommitManager( - table, startingSnapshotId, useStartingSequenceNumber, commitSummary()); + table, startingSnapshotId, useStartingSequenceNumber, commitSummary(), branch); } private Builder doExecute( diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java index ca9d562194d2..77aadc1d5da8 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java @@ -304,7 +304,7 @@ private Result doExecuteWithPartialProgress( } private void validateAndInitOptions() { - Set validOptions = Sets.newHashSet(planner.validOptions()); + Set validOptions = Sets.newHashSet(runner.validOptions()); validOptions.addAll(VALID_OPTIONS); validOptions.addAll(planner.validOptions()); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java index d6a13bcd515d..aedb25e4a4a6 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutorService; import java.util.stream.Collectors; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; @@ -52,13 +53,12 @@ import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.Record; import org.apache.iceberg.data.avro.DataWriter; -import org.apache.iceberg.data.avro.PlannedDataReader; -import org.apache.iceberg.data.orc.GenericOrcReader; import org.apache.iceberg.data.orc.GenericOrcWriter; -import org.apache.iceberg.data.parquet.GenericParquetReaders; import org.apache.iceberg.data.parquet.GenericParquetWriter; import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedFiles; import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.DeleteSchemaUtil; import org.apache.iceberg.io.FileIO; @@ -72,7 +72,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.JobGroupInfo; import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.Tasks; import org.apache.spark.api.java.function.ForeachFunction; import org.apache.spark.api.java.function.MapFunction; import org.apache.spark.api.java.function.ReduceFunction; @@ -99,6 +101,7 @@ public class RewriteTablePathSparkAction extends BaseSparkAction tableBroadcast = null; @@ -158,6 +161,12 @@ public RewriteTablePath createFileList(boolean createFileListFlag) { return this; } + @Override + public RewriteTablePath executeWith(ExecutorService service) { + this.executorService = service; + return this; + } + @Override public Result execute() { validateInputs(); @@ -286,22 +295,30 @@ private Result rebuildMetadata() { Sets.difference(snapshotSet(endMetadata), snapshotSet(startMetadata)); // rebuild manifest-list files - RewriteResult rewriteManifestListResult = - validSnapshots.stream() - .map(snapshot -> rewriteManifestList(snapshot, endMetadata, manifestsToRewrite)) - .reduce(new RewriteResult<>(), RewriteResult::append); + Set> manifestListResults = Sets.newConcurrentHashSet(); + Tasks.foreach(validSnapshots) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + snapshot -> + manifestListResults.add( + rewriteManifestList(snapshot, endMetadata, manifestsToRewrite))); + + RewriteResult rewriteManifestListResult = new RewriteResult<>(); + manifestListResults.forEach(rewriteManifestListResult::append); // rebuild manifest files Set metaFiles = rewriteManifestListResult.toRewrite(); RewriteContentFileResult rewriteManifestResult = - rewriteManifests(deltaSnapshots, endMetadata, rewriteManifestListResult.toRewrite()); + rewriteManifests(deltaSnapshots, endMetadata, metaFiles); // rebuild position delete files Set deleteFiles = rewriteManifestResult.toRewrite().stream() .filter(e -> e instanceof DeleteFile) .map(e -> (DeleteFile) e) - .collect(Collectors.toSet()); + .collect(Collectors.toCollection(DeleteFileSet::create)); rewritePositionDeletes(deleteFiles); ImmutableRewriteTablePath.Result.Builder builder = @@ -362,6 +379,7 @@ private RewriteResult rewriteVersionFiles(TableMetadata endMetadata) { result.copyPlan().addAll(rewriteVersionFile(endMetadata, endVersionName)); List versions = endMetadata.previousFiles(); + List versionFilePaths = Lists.newArrayList(); for (int i = versions.size() - 1; i >= 0; i--) { String versionFilePath = versions.get(i).file(); if (versionFilePath.equals(startVersionName)) { @@ -371,13 +389,26 @@ private RewriteResult rewriteVersionFiles(TableMetadata endMetadata) { Preconditions.checkArgument( fileExist(versionFilePath), String.format("Version file %s doesn't exist", versionFilePath)); - TableMetadata tableMetadata = - new StaticTableOperations(versionFilePath, table.io()).current(); - - result.toRewrite().addAll(tableMetadata.snapshots()); - result.copyPlan().addAll(rewriteVersionFile(tableMetadata, versionFilePath)); + versionFilePaths.add(versionFilePath); } + Set allSnapshots = Sets.newConcurrentHashSet(); + Set> allCopyPlan = Sets.newConcurrentHashSet(); + Tasks.foreach(versionFilePaths) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + versionFilePath -> { + TableMetadata tableMetadata = + new StaticTableOperations(versionFilePath, table.io()).current(); + allSnapshots.addAll(tableMetadata.snapshots()); + allCopyPlan.addAll(rewriteVersionFile(tableMetadata, versionFilePath)); + }); + + result.toRewrite().addAll(allSnapshots); + result.copyPlan().addAll(allCopyPlan); + return result; } @@ -719,32 +750,10 @@ private ForeachFunction rewritePositionDelete( private static CloseableIterable positionDeletesReader( InputFile inputFile, FileFormat format, PartitionSpec spec) { - Schema deleteSchema = DeleteSchemaUtil.posDeleteReadSchema(spec.schema()); - switch (format) { - case AVRO: - return Avro.read(inputFile) - .project(deleteSchema) - .reuseContainers() - .createReaderFunc(fileSchema -> PlannedDataReader.create(deleteSchema)) - .build(); - - case PARQUET: - return Parquet.read(inputFile) - .project(deleteSchema) - .reuseContainers() - .createReaderFunc( - fileSchema -> GenericParquetReaders.buildReader(deleteSchema, fileSchema)) - .build(); - - case ORC: - return ORC.read(inputFile) - .project(deleteSchema) - .createReaderFunc(fileSchema -> GenericOrcReader.buildReader(deleteSchema, fileSchema)) - .build(); - - default: - throw new UnsupportedOperationException("Unsupported file format: " + format); - } + return FormatModelRegistry.readBuilder(format, Record.class, inputFile) + .project(DeleteSchemaUtil.posDeleteReadSchema(spec.schema())) + .reuseContainers() + .build(); } private static PositionDeleteWriter positionDeletesWriter( @@ -754,30 +763,37 @@ private static PositionDeleteWriter positionDeletesWriter( StructLike partition, Schema rowSchema) throws IOException { - switch (format) { - case AVRO: - return Avro.writeDeletes(outputFile) - .createWriterFunc(DataWriter::create) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - case PARQUET: - return Parquet.writeDeletes(outputFile) - .createWriterFunc(GenericParquetWriter::create) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - case ORC: - return ORC.writeDeletes(outputFile) - .createWriterFunc(GenericOrcWriter::buildWriter) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - default: - throw new UnsupportedOperationException("Unsupported file format: " + format); + if (rowSchema == null) { + return FormatModelRegistry.positionDeleteWriteBuilder( + format, EncryptedFiles.plainAsEncryptedOutput(outputFile)) + .partition(partition) + .spec(spec) + .build(); + } else { + return switch (format) { + case AVRO -> + Avro.writeDeletes(outputFile) + .createWriterFunc(DataWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(outputFile) + .createWriterFunc(GenericParquetWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(outputFile) + .createWriterFunc(GenericOrcWriter::buildWriter) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + default -> throw new UnsupportedOperationException("Unsupported file format: " + format); + }; } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java index 5f7f408cb099..043b63870ca9 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java @@ -124,7 +124,16 @@ private SnapshotTable.Result doExecute() { StagedSparkTable stagedTable = stageDestTable(); Table icebergTable = stagedTable.table(); - // TODO: Check the dest table location does not overlap with the source table location + String sourceTableLocation = sourceTableLocation(); + String stagedTableLocation = icebergTable.location(); + Preconditions.checkArgument( + !sourceTableLocation.equals(stagedTableLocation) + && !stagedTableLocation.startsWith(sourceTableLocation + "/") + && !sourceTableLocation.startsWith(stagedTableLocation + "/"), + "Cannot create a snapshot at location %s because it would overlap with source table location %s. " + + "Overlapping snapshot and source would mix table files.", + stagedTableLocation, + sourceTableLocation); boolean threw = true; try { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java index b1c5a5c0901a..346abaee5e63 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java @@ -47,10 +47,14 @@ import org.apache.spark.sql.connector.expressions.SortOrder; import org.apache.spark.sql.connector.write.RequiresDistributionAndOrdering; import org.apache.spark.sql.execution.datasources.v2.DistributionAndOrderingUtils$; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import scala.Option; abstract class SparkShufflingFileRewriteRunner extends SparkDataFileRewriteRunner { + private static final Logger LOG = LoggerFactory.getLogger(SparkShufflingFileRewriteRunner.class); + /** * The number of shuffle partitions to use for each output file. By default, this file rewriter * assumes each shuffle partition would become a separate output file. Attempting to generate @@ -119,6 +123,17 @@ public void doRewrite(String groupId, RewriteFileGroup fileGroup) { spec(fileGroup.outputSpecId()), fileGroup.expectedOutputFiles())); + org.apache.iceberg.SortOrder sortOrderInJobSpec = sortOrder(); + + org.apache.iceberg.SortOrder maybeMatchingTableSortOrder = + SortOrderUtil.findTableSortOrder(table(), sortOrder()); + + if (sortOrderInJobSpec.isSorted() && maybeMatchingTableSortOrder.isUnsorted()) { + LOG.warn( + "Sort order specified for job {} doesn't match any table sort orders, rewritten files will not be marked as sorted in the manifest files", + Spark3Util.describe(sortOrderInJobSpec)); + } + sortedDF .write() .format("iceberg") @@ -126,6 +141,7 @@ public void doRewrite(String groupId, RewriteFileGroup fileGroup) { .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, fileGroup.maxOutputFileSize()) .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") .option(SparkWriteOptions.OUTPUT_SPEC_ID, fileGroup.outputSpecId()) + .option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, maybeMatchingTableSortOrder.orderId()) .mode("append") .save(groupId); } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java index 9a6811d1a46e..fa8d765541da 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java @@ -176,6 +176,13 @@ private List validZOrderColNames( Set identityPartitionFieldIds = table.spec().identitySourceIds(); boolean caseSensitive = SparkUtil.caseSensitive(spark); + Preconditions.checkArgument( + caseSensitive + ? schema.findField(Z_COLUMN) == null + : schema.caseInsensitiveFindField(Z_COLUMN) == null, + "Cannot zorder because the table has a column named '%s', which conflicts with Iceberg's internal Z-order column name", + Z_COLUMN); + List validZOrderColNames = Lists.newArrayList(); for (String colName : inputZOrderColNames) { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java index db359fdd62fc..bf80dcb10b30 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java @@ -24,6 +24,8 @@ import java.nio.ByteBuffer; import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import org.apache.iceberg.util.DateTimeUtil; import org.apache.iceberg.util.ZOrderByteUtils; import org.apache.spark.sql.Column; import org.apache.spark.sql.expressions.UserDefinedFunction; @@ -40,6 +42,7 @@ import org.apache.spark.sql.types.LongType; import org.apache.spark.sql.types.ShortType; import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.TimestampNTZType; import org.apache.spark.sql.types.TimestampType; import scala.collection.JavaConverters; import scala.collection.Seq; @@ -180,6 +183,29 @@ value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) return udf; } + private UserDefinedFunction timestampNtzToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (LocalDateTime value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + long micros = DateTimeUtil.microsFromTimestamp(value); + return ZOrderByteUtils.longToOrderedBytes( + micros, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("TIMESTAMP_NTZ_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + private UserDefinedFunction floatToOrderedBytesUDF() { int position = inputCol; UserDefinedFunction udf = @@ -309,6 +335,8 @@ Column sortedLexicographically(Column column, DataType type) { return booleanToOrderedBytesUDF().apply(column); } else if (type instanceof TimestampType) { return longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType)); + } else if (type instanceof TimestampNTZType) { + return timestampNtzToOrderedBytesUDF().apply(column); } else if (type instanceof DateType) { return longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType)); } else { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java index 9480385d5452..e11a85d538a6 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java @@ -31,6 +31,7 @@ import org.apache.parquet.schema.Type.Repetition; import org.apache.spark.sql.types.ArrayType; import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; import org.apache.spark.sql.types.MapType; import org.apache.spark.sql.types.Metadata; import org.apache.spark.sql.types.StructField; @@ -173,21 +174,27 @@ private static T visitField( private static List visitFields( StructType struct, GroupType group, ParquetWithSparkSchemaVisitor visitor) { - StructField[] sFields = struct.fields(); - Preconditions.checkArgument( - sFields.length == group.getFieldCount(), "Structs do not match: %s and %s", struct, group); List results = Lists.newArrayListWithExpectedSize(group.getFieldCount()); - for (int i = 0; i < sFields.length; i += 1) { - Type field = group.getFields().get(i); - StructField sField = sFields[i]; - Preconditions.checkArgument( - field.getName().equals(AvroSchemaUtil.makeCompatibleName(sField.name())), - "Structs do not match: field %s != %s", - field.getName(), - sField.name()); - results.add(visitField(sField, field, visitor)); + + int fieldIndex = 0; + for (StructField sField : struct.fields()) { + if (sField.dataType() != DataTypes.NullType) { + Type field = group.getFields().get(fieldIndex); + Preconditions.checkArgument( + field.getName().equals(AvroSchemaUtil.makeCompatibleName(sField.name())), + "Structs do not match: field %s != %s", + field.getName(), + sField.name()); + results.add(visitField(sField, field, visitor)); + + fieldIndex += 1; + } } + // All the group fields should have been visited + Preconditions.checkArgument( + fieldIndex == group.getFieldCount(), "Structs do not match: %s and %s", struct, group); + return results; } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java index 04dfd46a1891..186439c58c06 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java @@ -32,6 +32,7 @@ import org.apache.iceberg.avro.ValueWriter; import org.apache.iceberg.avro.ValueWriters; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.types.ByteType; import org.apache.spark.sql.types.DataType; @@ -46,6 +47,10 @@ public SparkAvroWriter(StructType dsSchema) { this.dsSchema = dsSchema; } + public SparkAvroWriter(org.apache.iceberg.Schema icebergSchema, StructType dsSchema) { + this(dsSchema != null ? dsSchema : SparkSchemaUtil.convert(icebergSchema)); + } + @Override @SuppressWarnings("unchecked") public void setSchema(Schema schema) { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java index 6b799e677bf4..6fc8849c82b2 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java @@ -20,6 +20,8 @@ import java.io.Serializable; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import java.util.stream.Stream; import javax.annotation.Nullable; import org.apache.iceberg.FieldMetrics; @@ -77,7 +79,7 @@ public OrcValueWriter record( TypeDescription record, List names, List> fields) { - return new InternalRowWriter(fields, record.getChildren()); + return new InternalRowWriter(fields, iStruct, record.getChildren()); } @Override @@ -133,12 +135,16 @@ public OrcValueWriter primitive(Type.PrimitiveType iPrimitive, TypeDescriptio private static class InternalRowWriter extends GenericOrcWriters.StructWriter { private final List> fieldGetters; - InternalRowWriter(List> writers, List orcTypes) { - super(writers); + InternalRowWriter( + List> writers, Types.StructType iStruct, List orcTypes) { + super(iStruct, writers); this.fieldGetters = Lists.newArrayListWithExpectedSize(orcTypes.size()); - for (TypeDescription orcType : orcTypes) { - fieldGetters.add(createFieldGetter(orcType)); + Map idToType = + orcTypes.stream().collect(Collectors.toMap(ORCSchemaUtil::fieldId, s -> s)); + + for (Types.NestedField iField : iStruct.fields()) { + fieldGetters.add(createFieldGetter(idToType.get(iField.fieldId()))); } } @@ -149,6 +155,11 @@ protected Object get(InternalRow struct, int index) { } static FieldGetter createFieldGetter(TypeDescription fieldType) { + // In the case of an UnknownType + if (fieldType == null) { + return (row, ordinal) -> null; + } + final FieldGetter fieldGetter; switch (fieldType.getCategory()) { case BOOLEAN: diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java index 8ffe26dc33f6..a1ed6c66f337 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java @@ -26,6 +26,8 @@ import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; +import java.util.stream.IntStream; +import org.apache.iceberg.Schema; import org.apache.iceberg.parquet.ParquetValueReaders.ReusableEntry; import org.apache.iceberg.parquet.ParquetValueWriter; import org.apache.iceberg.parquet.ParquetValueWriters; @@ -34,6 +36,7 @@ import org.apache.iceberg.parquet.ParquetValueWriters.RepeatedWriter; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.DecimalUtil; import org.apache.iceberg.util.UUIDUtil; @@ -53,6 +56,7 @@ import org.apache.spark.sql.types.DataType; import org.apache.spark.sql.types.Decimal; import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType; import org.apache.spark.sql.types.ShortType; import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; @@ -61,10 +65,18 @@ public class SparkParquetWriters { private SparkParquetWriters() {} - @SuppressWarnings("unchecked") public static ParquetValueWriter buildWriter(StructType dfSchema, MessageType type) { + return buildWriter(null, type, dfSchema); + } + + @SuppressWarnings("unchecked") + public static ParquetValueWriter buildWriter( + Schema icebergSchema, MessageType type, StructType dfSchema) { return (ParquetValueWriter) - ParquetWithSparkSchemaVisitor.visit(dfSchema, type, new WriteBuilder(type)); + ParquetWithSparkSchemaVisitor.visit( + dfSchema != null ? dfSchema : SparkSchemaUtil.convert(icebergSchema), + type, + new WriteBuilder(type)); } private static class WriteBuilder extends ParquetWithSparkSchemaVisitor> { @@ -84,14 +96,18 @@ public ParquetValueWriter message( public ParquetValueWriter struct( StructType sStruct, GroupType struct, List> fieldWriters) { List fields = struct.getFields(); - StructField[] sparkFields = sStruct.fields(); List> writers = Lists.newArrayListWithExpectedSize(fieldWriters.size()); - List sparkTypes = Lists.newArrayList(); for (int i = 0; i < fields.size(); i += 1) { writers.add(newOption(struct.getType(i), fieldWriters.get(i))); - sparkTypes.add(sparkFields[i].dataType()); } - return new InternalRowWriter(writers, sparkTypes); + + StructField[] sFields = sStruct.fields(); + DataType[] types = new DataType[sFields.length]; + for (int i = 0; i < sFields.length; i += 1) { + types[i] = sFields[i].dataType(); + } + + return new InternalRowWriter(writers, types); } @Override @@ -555,14 +571,33 @@ public Map.Entry next() { private static class InternalRowWriter extends ParquetValueWriters.StructWriter { private final DataType[] types; - private InternalRowWriter(List> writers, List types) { - super(writers); - this.types = types.toArray(new DataType[0]); + private InternalRowWriter(List> writers, DataType[] types) { + super(writerToFieldIndex(types, writers.size()), writers); + this.types = types; } @Override protected Object get(InternalRow struct, int index) { return struct.get(index, types[index]); } + + /** Returns a mapping from writer index to field index, skipping Unknown columns. */ + private static int[] writerToFieldIndex(DataType[] types, int numWriters) { + if (null == types) { + return IntStream.rangeClosed(0, numWriters).toArray(); + } + + // value writer index to record field index + int[] indexes = new int[numWriters]; + int writerIndex = 0; + for (int pos = 0; pos < types.length; pos += 1) { + if (!(types[pos] instanceof NullType)) { + indexes[writerIndex] = pos; + writerIndex += 1; + } + } + + return indexes; + } } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java deleted file mode 100644 index 81b7d83a7077..000000000000 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java +++ /dev/null @@ -1,140 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.io.IOException; -import org.apache.comet.CometSchemaImporter; -import org.apache.comet.parquet.AbstractColumnReader; -import org.apache.comet.parquet.ColumnReader; -import org.apache.comet.parquet.TypeUtil; -import org.apache.comet.parquet.Utils; -import org.apache.comet.shaded.arrow.memory.RootAllocator; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.spark.sql.types.DataType; -import org.apache.spark.sql.types.Metadata; -import org.apache.spark.sql.types.StructField; -import org.apache.spark.sql.vectorized.ColumnVector; - -class CometColumnReader implements VectorizedReader { - // use the Comet default batch size - public static final int DEFAULT_BATCH_SIZE = 8192; - - private final ColumnDescriptor descriptor; - private final DataType sparkType; - - // The delegated ColumnReader from Comet side - private AbstractColumnReader delegate; - private boolean initialized = false; - private int batchSize = DEFAULT_BATCH_SIZE; - private CometSchemaImporter importer; - - CometColumnReader(DataType sparkType, ColumnDescriptor descriptor) { - this.sparkType = sparkType; - this.descriptor = descriptor; - } - - CometColumnReader(Types.NestedField field) { - DataType dataType = SparkSchemaUtil.convert(field.type()); - StructField structField = new StructField(field.name(), dataType, false, Metadata.empty()); - this.sparkType = dataType; - this.descriptor = TypeUtil.convertToParquet(structField); - } - - public AbstractColumnReader delegate() { - return delegate; - } - - void setDelegate(AbstractColumnReader delegate) { - this.delegate = delegate; - } - - void setInitialized(boolean initialized) { - this.initialized = initialized; - } - - public int batchSize() { - return batchSize; - } - - /** - * This method is to initialized/reset the CometColumnReader. This needs to be called for each row - * group after readNextRowGroup, so a new dictionary encoding can be set for each of the new row - * groups. - */ - public void reset() { - if (importer != null) { - importer.close(); - } - - if (delegate != null) { - delegate.close(); - } - - this.importer = new CometSchemaImporter(new RootAllocator()); - this.delegate = Utils.getColumnReader(sparkType, descriptor, importer, batchSize, false, false); - this.initialized = true; - } - - public ColumnDescriptor descriptor() { - return descriptor; - } - - /** Returns the Spark data type for this column. */ - public DataType sparkType() { - return sparkType; - } - - /** - * Set the page reader to be 'pageReader'. - * - *

    NOTE: this should be called before reading a new Parquet column chunk, and after {@link - * CometColumnReader#reset} is called. - */ - public void setPageReader(PageReader pageReader) throws IOException { - Preconditions.checkState(initialized, "Invalid state: 'reset' should be called first"); - ((ColumnReader) delegate).setPageReader(pageReader); - } - - @Override - public void close() { - // close resources on native side - if (importer != null) { - importer.close(); - } - - if (delegate != null) { - delegate.close(); - } - } - - @Override - public void setBatchSize(int size) { - this.batchSize = size; - } - - @Override - public ColumnVector read(ColumnVector reuse, int numRowsToRead) { - throw new UnsupportedOperationException("Not supported"); - } -} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java deleted file mode 100644 index 3d3e9aca24de..000000000000 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java +++ /dev/null @@ -1,137 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.util.List; -import java.util.Map; -import org.apache.comet.parquet.AbstractColumnReader; -import org.apache.comet.parquet.BatchReader; -import org.apache.iceberg.Schema; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.parquet.column.page.PageReadStore; -import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData; -import org.apache.parquet.hadoop.metadata.ColumnPath; -import org.apache.spark.sql.vectorized.ColumnVector; -import org.apache.spark.sql.vectorized.ColumnarBatch; - -/** - * {@link VectorizedReader} that returns Spark's {@link ColumnarBatch} to support Spark's vectorized - * read path. The {@link ColumnarBatch} returned is created by passing in the Arrow vectors - * populated via delegated read calls to {@link CometColumnReader VectorReader(s)}. - */ -@SuppressWarnings("checkstyle:VisibilityModifier") -class CometColumnarBatchReader implements VectorizedReader { - - private final CometColumnReader[] readers; - - // The delegated BatchReader on the Comet side does the real work of loading a batch of rows. - // The Comet BatchReader contains an array of ColumnReader. There is no need to explicitly call - // ColumnReader.readBatch; instead, BatchReader.nextBatch will be called, which underneath calls - // ColumnReader.readBatch. The only exception is DeleteColumnReader, because at the time of - // calling BatchReader.nextBatch, the isDeleted value is not yet available, so - // DeleteColumnReader.readBatch must be called explicitly later, after the isDeleted value is - // available. - private final BatchReader delegate; - - CometColumnarBatchReader(List> readers, Schema schema) { - this.readers = - readers.stream().map(CometColumnReader.class::cast).toArray(CometColumnReader[]::new); - - AbstractColumnReader[] abstractColumnReaders = new AbstractColumnReader[readers.size()]; - this.delegate = new BatchReader(abstractColumnReaders); - delegate.setSparkSchema(SparkSchemaUtil.convert(schema)); - } - - @Override - public void setRowGroupInfo( - PageReadStore pageStore, Map metaData) { - for (int i = 0; i < readers.length; i++) { - try { - if (!(readers[i] instanceof CometConstantColumnReader) - && !(readers[i] instanceof CometPositionColumnReader) - && !(readers[i] instanceof CometDeleteColumnReader)) { - readers[i].reset(); - readers[i].setPageReader(pageStore.getPageReader(readers[i].descriptor())); - } - } catch (IOException e) { - throw new UncheckedIOException("Failed to setRowGroupInfo for Comet vectorization", e); - } - } - - for (int i = 0; i < readers.length; i++) { - delegate.getColumnReaders()[i] = this.readers[i].delegate(); - } - } - - @Override - public final ColumnarBatch read(ColumnarBatch reuse, int numRowsToRead) { - return new ColumnBatchLoader(numRowsToRead).loadDataToColumnBatch(); - } - - @Override - public void setBatchSize(int batchSize) { - for (CometColumnReader reader : readers) { - if (reader != null) { - reader.setBatchSize(batchSize); - } - } - } - - @Override - public void close() { - for (CometColumnReader reader : readers) { - if (reader != null) { - reader.close(); - } - } - } - - private class ColumnBatchLoader { - private final int batchSize; - - ColumnBatchLoader(int numRowsToRead) { - Preconditions.checkArgument( - numRowsToRead > 0, "Invalid number of rows to read: %s", numRowsToRead); - this.batchSize = numRowsToRead; - } - - ColumnarBatch loadDataToColumnBatch() { - ColumnVector[] vectors = readDataToColumnVectors(); - - ColumnarBatch batch = new ColumnarBatch(vectors); - batch.setNumRows(batchSize); - return batch; - } - - ColumnVector[] readDataToColumnVectors() { - ColumnVector[] columnVectors = new ColumnVector[readers.length]; - // Fetch rows for all readers in the delegate - delegate.nextBatch(batchSize); - for (int i = 0; i < readers.length; i++) { - columnVectors[i] = readers[i].delegate().currentBatch(); - } - - return columnVectors; - } - } -} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java deleted file mode 100644 index 047c96314b13..000000000000 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java +++ /dev/null @@ -1,65 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.math.BigDecimal; -import java.nio.ByteBuffer; -import org.apache.comet.parquet.ConstantColumnReader; -import org.apache.iceberg.types.Types; -import org.apache.spark.sql.types.DataType; -import org.apache.spark.sql.types.DataTypes; -import org.apache.spark.sql.types.Decimal; -import org.apache.spark.sql.types.DecimalType; -import org.apache.spark.unsafe.types.UTF8String; - -class CometConstantColumnReader extends CometColumnReader { - - CometConstantColumnReader(T value, Types.NestedField field) { - super(field); - // use delegate to set constant value on the native side to be consumed by native execution. - setDelegate( - new ConstantColumnReader(sparkType(), descriptor(), convertToSparkValue(value), false)); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private Object convertToSparkValue(T value) { - DataType dataType = sparkType(); - // Match the value to Spark internal type if necessary - if (dataType == DataTypes.StringType && value instanceof String) { - // the internal type for StringType is UTF8String - return UTF8String.fromString((String) value); - } else if (dataType instanceof DecimalType && value instanceof BigDecimal) { - // the internal type for DecimalType is Decimal - return Decimal.apply((BigDecimal) value); - } else if (dataType == DataTypes.BinaryType && value instanceof ByteBuffer) { - // the internal type for DecimalType is byte[] - // Iceberg default value should always use HeapBufferBuffer, so calling ByteBuffer.array() - // should be safe. - return ((ByteBuffer) value).array(); - } else { - return value; - } - } -} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java deleted file mode 100644 index 26219014f777..000000000000 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java +++ /dev/null @@ -1,74 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import org.apache.comet.parquet.MetadataColumnReader; -import org.apache.comet.parquet.Native; -import org.apache.comet.parquet.TypeUtil; -import org.apache.comet.vector.CometVector; -import org.apache.iceberg.types.Types; -import org.apache.spark.sql.types.DataTypes; -import org.apache.spark.sql.types.Metadata; -import org.apache.spark.sql.types.StructField; - -class CometDeleteColumnReader extends CometColumnReader { - CometDeleteColumnReader(Types.NestedField field) { - super(field); - setDelegate(new DeleteColumnReader()); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private static class DeleteColumnReader extends MetadataColumnReader { - private final CometDeletedColumnVector deletedVector; - - DeleteColumnReader() { - this(new boolean[0]); - } - - DeleteColumnReader(boolean[] isDeleted) { - super( - DataTypes.BooleanType, - TypeUtil.convertToParquet( - new StructField("_deleted", DataTypes.BooleanType, false, Metadata.empty())), - false /* useDecimal128 = false */, - false /* isConstant = false */); - this.deletedVector = new CometDeletedColumnVector(isDeleted); - } - - @Override - public void readBatch(int total) { - Native.resetBatch(nativeHandle); - // set isDeleted on the native side to be consumed by native execution - Native.setIsDeleted(nativeHandle, deletedVector.isDeleted()); - - super.readBatch(total); - } - - @Override - public CometVector currentBatch() { - return deletedVector; - } - } -} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java deleted file mode 100644 index bcc0e514c28d..000000000000 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java +++ /dev/null @@ -1,62 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import org.apache.comet.parquet.MetadataColumnReader; -import org.apache.comet.parquet.Native; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.spark.sql.types.DataTypes; - -class CometPositionColumnReader extends CometColumnReader { - CometPositionColumnReader(Types.NestedField field) { - super(field); - setDelegate(new PositionColumnReader(descriptor())); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private static class PositionColumnReader extends MetadataColumnReader { - /** The current position value of the column that are used to initialize this column reader. */ - private long position; - - PositionColumnReader(ColumnDescriptor descriptor) { - super( - DataTypes.LongType, - descriptor, - false /* useDecimal128 = false */, - false /* isConstant = false */); - } - - @Override - public void readBatch(int total) { - Native.resetBatch(nativeHandle); - // set position on the native side to be consumed by native execution - Native.setPosition(nativeHandle, position, total); - position += total; - - super.readBatch(total); - } - } -} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java deleted file mode 100644 index 779dc240d4f6..000000000000 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java +++ /dev/null @@ -1,138 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.IntStream; -import org.apache.iceberg.MetadataColumns; -import org.apache.iceberg.Schema; -import org.apache.iceberg.parquet.TypeWithSchemaVisitor; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; -import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.schema.GroupType; -import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -class CometVectorizedReaderBuilder extends TypeWithSchemaVisitor> { - - private final MessageType parquetSchema; - private final Schema icebergSchema; - private final Map idToConstant; - private final Function>, VectorizedReader> readerFactory; - - CometVectorizedReaderBuilder( - Schema expectedSchema, - MessageType parquetSchema, - Map idToConstant, - Function>, VectorizedReader> readerFactory) { - this.parquetSchema = parquetSchema; - this.icebergSchema = expectedSchema; - this.idToConstant = idToConstant; - this.readerFactory = readerFactory; - } - - @Override - public VectorizedReader message( - Types.StructType expected, MessageType message, List> fieldReaders) { - GroupType groupType = message.asGroupType(); - Map> readersById = Maps.newHashMap(); - List fields = groupType.getFields(); - - IntStream.range(0, fields.size()) - .filter(pos -> fields.get(pos).getId() != null) - .forEach(pos -> readersById.put(fields.get(pos).getId().intValue(), fieldReaders.get(pos))); - - List icebergFields = - expected != null ? expected.fields() : ImmutableList.of(); - - List> reorderedFields = - Lists.newArrayListWithExpectedSize(icebergFields.size()); - - for (Types.NestedField field : icebergFields) { - int id = field.fieldId(); - VectorizedReader reader = readersById.get(id); - if (idToConstant.containsKey(id)) { - CometConstantColumnReader constantReader = - new CometConstantColumnReader<>(idToConstant.get(id), field); - reorderedFields.add(constantReader); - } else if (id == MetadataColumns.ROW_POSITION.fieldId()) { - reorderedFields.add(new CometPositionColumnReader(field)); - } else if (id == MetadataColumns.IS_DELETED.fieldId()) { - CometColumnReader deleteReader = new CometDeleteColumnReader<>(field); - reorderedFields.add(deleteReader); - } else if (reader != null) { - reorderedFields.add(reader); - } else if (field.initialDefault() != null) { - CometColumnReader constantReader = - new CometConstantColumnReader<>(field.initialDefault(), field); - reorderedFields.add(constantReader); - } else if (field.isOptional()) { - CometColumnReader constantReader = new CometConstantColumnReader<>(null, field); - reorderedFields.add(constantReader); - } else { - throw new IllegalArgumentException( - String.format("Missing required field: %s", field.name())); - } - } - return vectorizedReader(reorderedFields); - } - - protected VectorizedReader vectorizedReader(List> reorderedFields) { - return readerFactory.apply(reorderedFields); - } - - @Override - public VectorizedReader struct( - Types.StructType expected, GroupType groupType, List> fieldReaders) { - if (expected != null) { - throw new UnsupportedOperationException( - "Vectorized reads are not supported yet for struct fields"); - } - return null; - } - - @Override - public VectorizedReader primitive( - org.apache.iceberg.types.Type.PrimitiveType expected, PrimitiveType primitive) { - - if (primitive.getId() == null) { - return null; - } - int parquetFieldId = primitive.getId().intValue(); - ColumnDescriptor desc = parquetSchema.getColumnDescription(currentPath()); - // Nested types not yet supported for vectorized reads - if (desc.getMaxRepetitionLevel() > 0) { - return null; - } - Types.NestedField icebergField = icebergSchema.findField(parquetFieldId); - if (icebergField == null) { - return null; - } - - return new CometColumnReader(SparkSchemaUtil.convert(icebergField.type()), desc); - } -} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java index 5f68c233f60d..4f324239881e 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java @@ -462,7 +462,9 @@ public ColumnVector convert( } else if (field.equals(MetadataColumns.ROW_POSITION)) { fieldVectors.add(new RowPositionColumnVector(batchOffsetInFile)); } else if (field.equals(MetadataColumns.IS_DELETED)) { - fieldVectors.add(new ConstantColumnVector(field.type(), batchSize, false)); + DeletedColumnVector deletedVector = new DeletedColumnVector(field.type()); + deletedVector.setValue(new boolean[batchSize]); + fieldVectors.add(deletedVector); } else if (field.type().equals(Types.UnknownType.get())) { fieldVectors.add(new ConstantColumnVector(field.type(), batchSize, null)); } else { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java index 8e25e81a05b2..a80249ee7c36 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java @@ -75,19 +75,6 @@ public static ColumnarBatchReader buildReader( return buildReader(expectedSchema, fileSchema, idToConstant, ArrowAllocation.rootAllocator()); } - public static CometColumnarBatchReader buildCometReader( - Schema expectedSchema, MessageType fileSchema, Map idToConstant) { - return (CometColumnarBatchReader) - TypeWithSchemaVisitor.visit( - expectedSchema.asStruct(), - fileSchema, - new CometVectorizedReaderBuilder( - expectedSchema, - fileSchema, - idToConstant, - readers -> new CometColumnarBatchReader(readers, expectedSchema))); - } - // enables unsafe memory access to avoid costly checks to see if index is within bounds // as long as it is not configured explicitly (see BoundsChecking in Arrow) private static void enableUnsafeMemoryAccess() { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java index 2c3ce7418e08..a47e75415336 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java @@ -18,10 +18,8 @@ */ package org.apache.iceberg.spark.procedures; -import java.util.Optional; import org.apache.iceberg.Snapshot; import org.apache.iceberg.exceptions.ValidationException; -import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; import org.apache.iceberg.util.WapUtil; import org.apache.spark.sql.catalyst.InternalRow; @@ -92,21 +90,26 @@ public InternalRow[] call(InternalRow args) { return modifyIcebergTable( tableIdent, table -> { - Optional wapSnapshot = - Optional.ofNullable( - Iterables.find( - table.snapshots(), - snapshot -> wapId.equals(WapUtil.stagedWapId(snapshot)), - null)); - if (!wapSnapshot.isPresent()) { + Snapshot matchingSnap = null; + for (Snapshot snap : table.snapshots()) { + if (wapId.equals(WapUtil.stagedWapId(snap))) { + if (matchingSnap != null) { + throw new ValidationException( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID '%s'", + wapId); + } else { + matchingSnap = snap; + } + } + } + + if (matchingSnap == null) { throw new ValidationException("Cannot apply unknown WAP ID '%s'", wapId); } - long wapSnapshotId = wapSnapshot.get().snapshotId(); + long wapSnapshotId = matchingSnap.snapshotId(); table.manageSnapshots().cherrypick(wapSnapshotId).commit(); - Snapshot currentSnapshot = table.currentSnapshot(); - InternalRow outputRow = newInternalRow(wapSnapshotId, currentSnapshot.snapshotId()); return new InternalRow[] {outputRow}; }); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java index f1958a5a172f..60ed7b84d45e 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.actions.RewriteDataFiles; @@ -30,6 +31,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.ExtendedParser; +import org.apache.iceberg.spark.actions.RewriteDataFilesSparkAction; import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.catalog.Identifier; @@ -57,10 +59,12 @@ class RewriteDataFilesProcedure extends BaseProcedure { ProcedureParameter.optional("options", STRING_MAP); private static final ProcedureParameter WHERE_PARAM = ProcedureParameter.optional("where", DataTypes.StringType); + private static final ProcedureParameter BRANCH_PARAM = + ProcedureParameter.optional("branch", DataTypes.StringType); private static final ProcedureParameter[] PARAMETERS = new ProcedureParameter[] { - TABLE_PARAM, STRATEGY_PARAM, SORT_ORDER_PARAM, OPTIONS_PARAM, WHERE_PARAM + TABLE_PARAM, STRATEGY_PARAM, SORT_ORDER_PARAM, OPTIONS_PARAM, WHERE_PARAM, BRANCH_PARAM }; // counts are not nullable since the action result is never null @@ -109,17 +113,29 @@ public InternalRow[] call(InternalRow args) { String sortOrderString = input.asString(SORT_ORDER_PARAM, null); Map options = input.asStringMap(OPTIONS_PARAM, ImmutableMap.of()); String where = input.asString(WHERE_PARAM, null); + String branchParam = input.asString(BRANCH_PARAM, null); + if (branchParam == null) { + branchParam = loadSparkTable(tableIdent).branch(); + if (branchParam == null) { + branchParam = SnapshotRef.MAIN_BRANCH; + } + } + String branch = branchParam; return modifyIcebergTable( tableIdent, table -> { - RewriteDataFiles action = actions().rewriteDataFiles(table).options(options); + RewriteDataFilesSparkAction action = + (RewriteDataFilesSparkAction) + actions().rewriteDataFiles(table).options(options).toBranch(branch); if (strategy != null || sortOrderString != null) { - action = checkAndApplyStrategy(action, strategy, sortOrderString, table.schema()); + action = + (RewriteDataFilesSparkAction) + checkAndApplyStrategy(action, strategy, sortOrderString, table.schema()); } - action = checkAndApplyFilter(action, where, tableIdent); + action = (RewriteDataFilesSparkAction) checkAndApplyFilter(action, where, tableIdent); RewriteDataFiles.Result result = action.execute(); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..3e442f9917d4 --- /dev/null +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java @@ -0,0 +1,543 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.connector.read.streaming.ReadAllAvailable; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class AsyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(AsyncSparkMicroBatchPlanner.class); + private static final int PLAN_FILES_CACHE_MAX_SIZE = 10; + private static final long QUEUE_POLL_TIMEOUT_MS = 100L; // 100 ms + + private final long minQueuedFiles; + private final long minQueuedRows; + + // Cache for planFiles results to handle duplicate calls + private final Cache, List> planFilesCache; + + // Queue to buffer pre-fetched file scan tasks + private final LinkedBlockingDeque> queue; + + // Background executor for async operations + private final ScheduledExecutorService executor; + + // Error tracking + private volatile Throwable refreshFailedThrowable; + private volatile Throwable fillQueueFailedThrowable; + + // Tracking queue state + private final AtomicLong queuedFileCount = new AtomicLong(0); + private final AtomicLong queuedRowCount = new AtomicLong(0); + private Snapshot lastQueuedSnapshot; + private boolean stopped; + + // Cap for Trigger.AvailableNow - don't process beyond this offset + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + /** + * This class manages a queue of FileScanTask + StreamingOffset. On creation, it starts up an + * asynchronous polling process which populates the queue when a new snapshot arrives or the + * minimum amount of queued data is too low. + * + *

    Note: this will capture the state of the table when snapshots are added to the queue. If a + * snapshot is expired after being added to the queue, the job will still process it. + */ + AsyncSparkMicroBatchPlanner( + Table table, + SparkReadConf readConf, + StreamingOffset initialOffset, + StreamingOffset maybeEndOffset, + StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.minQueuedFiles = readConf().maxFilesPerMicroBatch(); + this.minQueuedRows = readConf().maxRecordsPerMicroBatch(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + this.planFilesCache = Caffeine.newBuilder().maximumSize(PLAN_FILES_CACHE_MAX_SIZE).build(); + this.queue = new LinkedBlockingDeque<>(); + + table().refresh(); + + // Synchronously add data to the queue to meet our initial constraints. + // For Trigger.AvailableNow, constructor-time preload is normally initialized from + // latestOffset(...) with no explicit end offset, so bounded preload must stop at + // Trigger.AvailableNow snapshot. + fillQueue(initialOffset, maybeEndOffset); + + this.executor = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread thread = new Thread(r, "iceberg-async-planner-" + table().name()); + thread.setDaemon(true); + return thread; + }); + // Schedule table refresh at configured interval + long pollingIntervalMs = readConf().streamingSnapshotPollingIntervalMs(); + this.executor.scheduleWithFixedDelay( + this::refreshAndTrapException, pollingIntervalMs, pollingIntervalMs, TimeUnit.MILLISECONDS); + // Schedule queue fill to run frequently (use polling interval for tests, cap at 100ms for + // production) + long queueFillIntervalMs = Math.min(QUEUE_POLL_TIMEOUT_MS, pollingIntervalMs); + executor.scheduleWithFixedDelay( + () -> fillQueueAndTrapException(lastQueuedSnapshot), + 0, + queueFillIntervalMs, + TimeUnit.MILLISECONDS); + + LOG.info( + "Started AsyncSparkMicroBatchPlanner for {} from initialOffset: {}", + table().name(), + initialOffset); + } + + @Override + public synchronized void stop() { + Preconditions.checkArgument( + !stopped, "AsyncSparkMicroBatchPlanner for {} was already stopped", table().name()); + stopped = true; + LOG.info("Stopping AsyncSparkMicroBatchPlanner for table: {}", table().name()); + executor.shutdownNow(); + boolean terminated = false; + try { + terminated = + executor.awaitTermination( + readConf().streamingSnapshotPollingIntervalMs() * 2, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + // Restore interrupt status + Thread.currentThread().interrupt(); + } + LOG.info("AsyncSparkMicroBatchPlanner for table: {}, stopped: {}", table().name(), terminated); + } + + @Override + public void close() { + stop(); + } + + /** + * Spark can call this multiple times; it should produce the same answer every time. + * + * @param startOffset the starting offset of this microbatch, position is inclusive + * @param endOffset the end offset of this microbatch, position is exclusive + * @return the list of files to scan between these offsets + */ + @Override + public synchronized List planFiles( + StreamingOffset startOffset, StreamingOffset endOffset) { + return planFilesCache.get( + Pair.of(startOffset, endOffset), + key -> { + LOG.info( + "running planFiles for {}, startOffset: {}, endOffset: {}", + table().name(), + startOffset, + endOffset); + List result = new LinkedList<>(); + Pair elem; + StreamingOffset currentOffset; + boolean shouldTerminate = false; + long filesInPlan = 0; + long rowsInPlan = 0; + + do { + try { + elem = queue.pollFirst(QUEUE_POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while polling queue", e); + } + + if (elem != null) { + currentOffset = elem.first(); + LOG.debug("planFiles consumed: {}", currentOffset); + FileScanTask currentTask = elem.second(); + filesInPlan += 1; + long elemRows = currentTask.file().recordCount(); + rowsInPlan += elemRows; + queuedFileCount.decrementAndGet(); + queuedRowCount.addAndGet(-elemRows); + result.add(currentTask); + + // try to peek at the next entry of the queue and see if we should stop + Pair nextElem = queue.peekFirst(); + boolean endOffsetPeek = false; + if (nextElem != null) { + endOffsetPeek = endOffset.equals(nextElem.first()); + } + // end offset may be synthetic and not exist in the queue + boolean endOffsetSynthetic = + currentOffset.snapshotId() == endOffset.snapshotId() + && (currentOffset.position() + 1) == endOffset.position(); + shouldTerminate = endOffsetPeek || endOffsetSynthetic; + } else { + LOG.trace("planFiles hasn't reached {}, waiting", endOffset); + } + } while (!shouldTerminate + && refreshFailedThrowable == null + && fillQueueFailedThrowable == null); + + if (refreshFailedThrowable != null) { + throw new RuntimeException("Table refresh failed", refreshFailedThrowable); + } + + if (fillQueueFailedThrowable != null) { + throw new RuntimeException("Queue filling failed", fillQueueFailedThrowable); + } + + LOG.info( + "completed planFiles for {}, startOffset: {}, endOffset: {}, files: {}, rows: {}", + table().name(), + startOffset, + endOffset, + filesInPlan, + rowsInPlan); + return result; + }); + } + + /** + * This needs to be non destructive on the queue as spark could call this multiple times. Each + * time, depending on the table state it could return something different + * + * @param startOffset the starting offset of the next microbatch + * @param limit a limit for how many files/bytes/rows the next microbatch should include + * @return The end offset to use for the next microbatch, null signals that no data is available + */ + @Override + public synchronized StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + LOG.info( + "running latestOffset for {}, startOffset: {}, limit: {}", + table().name(), + startOffset, + limit); + + if (table().currentSnapshot() == null) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() is null"); + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < readConf().streamFromTimestamp()) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() < fromTimestamp"); + return StreamingOffset.START_OFFSET; + } + + // if any exceptions were encountered in the background process, raise them here + if (refreshFailedThrowable != null) { + throw new RuntimeException(refreshFailedThrowable); + } + if (fillQueueFailedThrowable != null) { + throw new RuntimeException(fillQueueFailedThrowable); + } + + // if we want to read all available we don't need to scan files, just snapshots + if (limit instanceof ReadAllAvailable) { + // If Trigger.AvailableNow cap is set, return it directly + if (this.lastOffsetForTriggerAvailableNow != null) { + return this.lastOffsetForTriggerAvailableNow; + } + Snapshot lastValidSnapshot = table().snapshot(startOffset.snapshotId()); + Snapshot nextValidSnapshot; + do { + nextValidSnapshot = nextValidSnapshot(lastValidSnapshot); + if (nextValidSnapshot != null) { + lastValidSnapshot = nextValidSnapshot; + } + } while (nextValidSnapshot != null); + return new StreamingOffset( + lastValidSnapshot.snapshotId(), + MicroBatchUtils.addedFilesCount(table(), lastValidSnapshot), + false); + } + + return computeLimitedOffset(limit); + } + + private StreamingOffset computeLimitedOffset(ReadLimit limit) { + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long rowsSeen = 0; + long filesSeen = 0; + LOG.debug( + "latestOffset queue status, queuedFiles: {}, queuedRows: {}", + queuedFileCount.get(), + queuedRowCount.get()); + + List> queueSnapshot = Lists.newArrayList(queue); + Pair queueTail = + queueSnapshot.isEmpty() ? null : queueSnapshot.get(queueSnapshot.size() - 1); + + for (int i = 0; i < queueSnapshot.size(); i++) { + Pair elem = queueSnapshot.get(i); + long fileRows = elem.second().file().recordCount(); + + // Hard limit on files - stop BEFORE exceeding + if (filesSeen + 1 > unpackedLimits.getMaxFiles()) { + if (filesSeen == 0) { + return null; + } + LOG.debug( + "latestOffset hit file limit at {}, rows: {}, files: {}", + elem.first(), + rowsSeen, + filesSeen); + return elem.first(); + } + + // Soft limit on rows - include file FIRST, then check + rowsSeen += fileRows; + filesSeen += 1; + + // Check if we've hit the row limit after including this file + if (rowsSeen >= unpackedLimits.getMaxRows()) { + if (filesSeen == 1 && rowsSeen > unpackedLimits.getMaxRows()) { + LOG.warn( + "File {} at offset {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + elem.second().file().location(), + elem.first(), + fileRows, + unpackedLimits.getMaxRows()); + } + // Return the offset of the NEXT element (or synthesize tail+1) + if (i + 1 < queueSnapshot.size()) { + LOG.debug( + "latestOffset hit row limit at {}, rows: {}, files: {}", + queueSnapshot.get(i + 1).first(), + rowsSeen, + filesSeen); + return queueSnapshot.get(i + 1).first(); + } else { + // This is the last element - return tail+1 + StreamingOffset current = elem.first(); + StreamingOffset result = + new StreamingOffset( + current.snapshotId(), current.position() + 1, current.shouldScanAllFiles()); + LOG.debug( + "latestOffset hit row limit at tail {}, rows: {}, files: {}", + result, + rowsSeen, + filesSeen); + return result; + } + } + } + + // if we got here there aren't enough files to exceed our limits + if (queueTail != null) { + StreamingOffset tailOffset = queueTail.first(); + // we have to increment the position by 1 since we want to include the tail in the read and + // position is non-inclusive + StreamingOffset latestOffset = + new StreamingOffset( + tailOffset.snapshotId(), tailOffset.position() + 1, tailOffset.shouldScanAllFiles()); + LOG.debug("latestOffset returning all queued data {}", latestOffset); + return latestOffset; + } + + // if we got here the queue is empty + LOG.debug("latestOffset no data, returning null"); + return null; + } + + // Background task wrapper that traps exceptions + private void refreshAndTrapException() { + try { + table().refresh(); + } catch (Throwable t) { + LOG.error("Failed to refresh table {}", table().name(), t); + refreshFailedThrowable = t; + } + } + + // Background task wrapper that traps exceptions + private void fillQueueAndTrapException(Snapshot snapshot) { + try { + fillQueue(snapshot); + } catch (Throwable t) { + LOG.error("Failed to fill queue for table {}", table().name(), t); + fillQueueFailedThrowable = t; + } + } + + /** Generate a MicroBatch based on input parameters and add to the queue */ + private void addMicroBatchToQueue( + Snapshot snapshot, long startFileIndex, long endFileIndex, boolean shouldScanAllFile) { + LOG.info("Adding MicroBatch for snapshot: {} to the queue", snapshot.snapshotId()); + MicroBatches.MicroBatch microBatch = + MicroBatches.from(snapshot, table().io()) + .caseSensitive(readConf().caseSensitive()) + .specsById(table().specs()) + .generate(startFileIndex, endFileIndex, Long.MAX_VALUE, shouldScanAllFile); + + long position = startFileIndex; + for (FileScanTask task : microBatch.tasks()) { + Pair elem = + Pair.of(new StreamingOffset(microBatch.snapshotId(), position, shouldScanAllFile), task); + queuedFileCount.incrementAndGet(); + queuedRowCount.addAndGet(task.file().recordCount()); + queue.addLast(elem); + position += 1; + } + if (LOG.isDebugEnabled()) { + StringBuilder sb = new StringBuilder("\n"); + for (Pair elem : queue) { + sb.append(elem.first()).append("\n"); + } + LOG.debug(sb.toString()); + } + lastQueuedSnapshot = snapshot; + } + + private void fillQueue(StreamingOffset fromOffset, StreamingOffset toOffset) { + LOG.debug("filling queue from {}, to: {}", fromOffset, toOffset); + Snapshot currentSnapshot = table().snapshot(fromOffset.snapshotId()); + // this could be a partial snapshot so add it outside the loop + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + fromOffset.position(), + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + fromOffset.shouldScanAllFiles()); + } + if (toOffset != null) { + if (currentSnapshot != null) { + while (currentSnapshot.snapshotId() != toOffset.snapshotId()) { + currentSnapshot = nextValidSnapshot(currentSnapshot); + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + false); + } else { + break; + } + } + } + // toOffset snapshot already added in loop when currentSnapshot == toOffset + } else { + fillQueueInitialBuffer(currentSnapshot); + } + } + + private void fillQueueInitialBuffer(Snapshot startSnapshot) { + // toOffset is null - fill initial buffer to prevent queue starvation before background + // thread starts. Use configured limits to avoid loading all snapshots + // (which could cause OOM on tables with thousands of snapshots). + long targetRows = readConf().asyncQueuePreloadRowLimit(); + long targetFiles = readConf().asyncQueuePreloadFileLimit(); + + Snapshot preloadEndSnapshot = initialPreloadEndSnapshot(); + if (preloadEndSnapshot == null) { + return; // Empty table + } + + // START_OFFSET case: initialize using nextValidSnapshot which respects timestamp filtering + Snapshot current = startSnapshot; + if (current == null) { + current = nextValidSnapshot(null); + if (current != null) { + addMicroBatchToQueue(current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } + } + + // Continue loading more snapshots within safety limits + if (current != null) { + while ((queuedRowCount.get() < targetRows || queuedFileCount.get() < targetFiles) + && current.snapshotId() != preloadEndSnapshot.snapshotId()) { + current = nextValidSnapshot(current); + if (current != null) { + addMicroBatchToQueue( + current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } else { + break; + } + } + } + } + + private Snapshot initialPreloadEndSnapshot() { + if (lastOffsetForTriggerAvailableNow != null) { + return table().snapshot(lastOffsetForTriggerAvailableNow.snapshotId()); + } + + return table().currentSnapshot(); + } + + @VisibleForTesting + static boolean reachedAvailableNowCap( + Snapshot readFrom, StreamingOffset lastOffsetForTriggerAvailableNow) { + return lastOffsetForTriggerAvailableNow != null + && readFrom != null + && readFrom.snapshotId() == lastOffsetForTriggerAvailableNow.snapshotId(); + } + + /** Try to populate the queue with data from unread snapshots */ + private void fillQueue(Snapshot readFrom) { + // Don't add beyond cap for Trigger.AvailableNow + if (reachedAvailableNowCap(readFrom, lastOffsetForTriggerAvailableNow)) { + LOG.debug( + "Reached cap snapshot {}, not adding more", + this.lastOffsetForTriggerAvailableNow.snapshotId()); + return; + } + + if ((queuedRowCount.get() > minQueuedRows) || (queuedFileCount.get() > minQueuedFiles)) { + // we have enough data buffered, check back shortly + LOG.debug( + "Buffer is full, {} > {} or {} > {}", + queuedRowCount.get(), + minQueuedRows, + queuedFileCount.get(), + minQueuedFiles); + } else { + // add an entire snapshot to the queue + Snapshot nextValidSnapshot = nextValidSnapshot(readFrom); + if (nextValidSnapshot != null) { + addMicroBatchToQueue( + nextValidSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), nextValidSnapshot), + false); + } else { + LOG.debug("No snapshots ready to be read"); + } + } + } +} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java index ff30f29aeae6..c1b2a5873730 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java @@ -19,7 +19,6 @@ package org.apache.iceberg.spark.source; import java.util.Map; -import java.util.Set; import javax.annotation.Nonnull; import org.apache.iceberg.FileFormat; import org.apache.iceberg.MetadataColumns; @@ -29,21 +28,17 @@ import org.apache.iceberg.Table; import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.OrcBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.data.vectorized.ColumnVectorWithFilter; import org.apache.iceberg.spark.data.vectorized.ColumnarBatchUtil; import org.apache.iceberg.spark.data.vectorized.UpdatableDeletedColumnVector; -import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; -import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; -import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.Pair; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.vectorized.ColumnVector; @@ -55,6 +50,7 @@ abstract class BaseBatchReader extends BaseReader taskGroup, Schema tableSchema, Schema expectedSchema, @@ -63,7 +59,13 @@ abstract class BaseBatchReader extends BaseReader newBatchIterable( Expression residual, Map idToConstant, @Nonnull SparkDeleteFilter deleteFilter) { - CloseableIterable iterable; - switch (format) { - case PARQUET: - iterable = - newParquetIterable( - inputFile, start, length, residual, idToConstant, deleteFilter.requiredSchema()); - break; - case ORC: - iterable = newOrcIterable(inputFile, start, length, residual, idToConstant); - break; - default: - throw new UnsupportedOperationException( - "Format: " + format + " not supported for batched reads"); + ReadBuilder readBuilder = + FormatModelRegistry.readBuilder(format, ColumnarBatch.class, inputFile); + + if (parquetConf != null) { + readBuilder = readBuilder.recordsPerBatch(parquetConf.batchSize()); + } else if (orcConf != null) { + readBuilder = readBuilder.recordsPerBatch(orcConf.batchSize()); } - return CloseableIterable.transform(iterable, new BatchDeleteFilter(deleteFilter)::filterBatch); - } + CloseableIterable iterable = + readBuilder + .project(deleteFilter.requiredSchema()) + .idToConstant(idToConstant) + .split(start, length) + .filter(residual) + .caseSensitive(caseSensitive()) + // Spark eagerly consumes the batches. So the underlying memory allocated could be + // reused without worrying about subsequent reads clobbering over each other. This + // improves read performance as every batch read doesn't have to pay the cost of + // allocating memory. + .reuseContainers() + .withNameMapping(nameMapping()) + .build(); - private CloseableIterable newParquetIterable( - InputFile inputFile, - long start, - long length, - Expression residual, - Map idToConstant, - Schema requiredSchema) { - return Parquet.read(inputFile) - .project(requiredSchema) - .split(start, length) - .createBatchedReaderFunc( - fileSchema -> { - if (parquetConf.readerType() == ParquetReaderType.COMET) { - return VectorizedSparkParquetReaders.buildCometReader( - requiredSchema, fileSchema, idToConstant); - } else { - return VectorizedSparkParquetReaders.buildReader( - requiredSchema, fileSchema, idToConstant); - } - }) - .recordsPerBatch(parquetConf.batchSize()) - .filter(residual) - .caseSensitive(caseSensitive()) - // Spark eagerly consumes the batches. So the underlying memory allocated could be reused - // without worrying about subsequent reads clobbering over each other. This improves - // read performance as every batch read doesn't have to pay the cost of allocating memory. - .reuseContainers() - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newOrcIterable( - InputFile inputFile, - long start, - long length, - Expression residual, - Map idToConstant) { - Set constantFieldIds = idToConstant.keySet(); - Set metadataFieldIds = MetadataColumns.metadataFieldIds(); - Sets.SetView constantAndMetadataFieldIds = - Sets.union(constantFieldIds, metadataFieldIds); - Schema schemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot(expectedSchema(), constantAndMetadataFieldIds); - - return ORC.read(inputFile) - .project(schemaWithoutConstantAndMetadataFields) - .split(start, length) - .createBatchedReaderFunc( - fileSchema -> - VectorizedSparkOrcReaders.buildReader(expectedSchema(), fileSchema, idToConstant)) - .recordsPerBatch(orcConf.batchSize()) - .filter(residual) - .caseSensitive(caseSensitive()) - .withNameMapping(nameMapping()) - .build(); + return CloseableIterable.transform(iterable, new BatchDeleteFilter(deleteFilter)::filterBatch); } @VisibleForTesting diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java index bf1622617185..0333f1e45dd4 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java @@ -44,6 +44,7 @@ import org.apache.iceberg.deletes.DeleteCounter; import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.mapping.NameMappingParser; @@ -66,6 +67,7 @@ abstract class BaseReader implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(BaseReader.class); private final Table table; + private final EncryptingFileIO fileIO; private final Schema tableSchema; private final Schema expectedSchema; private final boolean caseSensitive; @@ -82,12 +84,14 @@ abstract class BaseReader implements Closeable { BaseReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { this.table = table; + this.fileIO = EncryptingFileIO.combine(fileIO, table().encryption()); this.taskGroup = taskGroup; this.tasks = taskGroup.tasks().iterator(); this.currentIterator = CloseableIterator.empty(); @@ -180,9 +184,8 @@ protected InputFile getInputFile(String location) { private Map inputFiles() { if (lazyInputFiles == null) { this.lazyInputFiles = - EncryptingFileIO.combine(table().io(), table().encryption()) - .bulkDecrypt( - () -> taskGroup.tasks().stream().flatMap(this::referencedFiles).iterator()); + fileIO.bulkDecrypt( + () -> taskGroup.tasks().stream().flatMap(this::referencedFiles).iterator()); } return lazyInputFiles; diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java index c12931e786b1..a7016e3b09b7 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java @@ -20,34 +20,35 @@ import java.util.Map; import org.apache.iceberg.FileFormat; -import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.ScanTask; import org.apache.iceberg.ScanTaskGroup; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; -import org.apache.iceberg.avro.Avro; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.spark.data.SparkOrcReader; -import org.apache.iceberg.spark.data.SparkParquetReaders; -import org.apache.iceberg.spark.data.SparkPlannedAvroReader; -import org.apache.iceberg.types.TypeUtil; import org.apache.spark.sql.catalyst.InternalRow; abstract class BaseRowReader extends BaseReader { BaseRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); } protected CloseableIterable newIterable( @@ -58,69 +59,15 @@ protected CloseableIterable newIterable( Expression residual, Schema projection, Map idToConstant) { - switch (format) { - case PARQUET: - return newParquetIterable(file, start, length, residual, projection, idToConstant); - - case AVRO: - return newAvroIterable(file, start, length, projection, idToConstant); - - case ORC: - return newOrcIterable(file, start, length, residual, projection, idToConstant); - - default: - throw new UnsupportedOperationException("Cannot read unknown format: " + format); - } - } - - private CloseableIterable newAvroIterable( - InputFile file, long start, long length, Schema projection, Map idToConstant) { - return Avro.read(file) - .reuseContainers() + ReadBuilder reader = + FormatModelRegistry.readBuilder(format, InternalRow.class, file); + return reader .project(projection) - .split(start, length) - .createResolvingReader(schema -> SparkPlannedAvroReader.create(schema, idToConstant)) - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newParquetIterable( - InputFile file, - long start, - long length, - Expression residual, - Schema readSchema, - Map idToConstant) { - return Parquet.read(file) + .idToConstant(idToConstant) .reuseContainers() .split(start, length) - .project(readSchema) - .createReaderFunc( - fileSchema -> SparkParquetReaders.buildReader(readSchema, fileSchema, idToConstant)) - .filter(residual) .caseSensitive(caseSensitive()) - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newOrcIterable( - InputFile file, - long start, - long length, - Expression residual, - Schema readSchema, - Map idToConstant) { - Schema readSchemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - readSchema, Sets.union(idToConstant.keySet(), MetadataColumns.metadataFieldIds())); - - return ORC.read(file) - .project(readSchemaWithoutConstantAndMetadataFields) - .split(start, length) - .createReaderFunc( - readOrcSchema -> new SparkOrcReader(readSchema, readOrcSchema, idToConstant)) .filter(residual) - .caseSensitive(caseSensitive()) .withNameMapping(nameMapping()) .build(); } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..9298c2bbdfcc --- /dev/null +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Locale; +import org.apache.iceberg.DataOperations; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.CompositeReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadMaxFiles; +import org.apache.spark.sql.connector.read.streaming.ReadMaxRows; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class BaseSparkMicroBatchPlanner implements SparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(BaseSparkMicroBatchPlanner.class); + private final Table table; + private final SparkReadConf readConf; + + BaseSparkMicroBatchPlanner(Table table, SparkReadConf readConf) { + this.table = table; + this.readConf = readConf; + } + + protected Table table() { + return table; + } + + protected SparkReadConf readConf() { + return readConf; + } + + protected boolean shouldProcess(Snapshot snapshot) { + String op = snapshot.operation(); + switch (op) { + case DataOperations.APPEND: + return true; + case DataOperations.REPLACE: + return false; + case DataOperations.DELETE: + Preconditions.checkState( + readConf.streamingSkipDeleteSnapshots(), + "Cannot process delete snapshot: %s, to ignore deletes, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS); + return false; + case DataOperations.OVERWRITE: + Preconditions.checkState( + readConf.streamingSkipOverwriteSnapshots(), + "Cannot process overwrite snapshot: %s, to ignore overwrites, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS); + return false; + default: + throw new IllegalStateException( + String.format( + "Cannot process unknown snapshot operation: %s (snapshot id %s)", + op.toLowerCase(Locale.ROOT), snapshot.snapshotId())); + } + } + + /** + * Get the next snapshot skipping over rewrite and delete snapshots. Async must handle nulls. + * + * @param curSnapshot the current snapshot + * @return the next valid snapshot (not a rewrite or delete snapshot), returns null if all + * remaining snapshots should be skipped. + */ + protected Snapshot nextValidSnapshot(Snapshot curSnapshot) { + Snapshot nextSnapshot; + // if there were no valid snapshots, check for an initialOffset again + if (curSnapshot == null) { + StreamingOffset startingOffset = + MicroBatchUtils.determineStartingOffset(table, readConf.streamFromTimestamp()); + LOG.debug("determineStartingOffset picked startingOffset: {}", startingOffset); + if (StreamingOffset.START_OFFSET.equals(startingOffset)) { + return null; + } + nextSnapshot = table.snapshot(startingOffset.snapshotId()); + } else { + if (curSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, curSnapshot.snapshotId()); + } + // skip over rewrite and delete snapshots + while (!shouldProcess(nextSnapshot)) { + LOG.debug("Skipping snapshot: {}", nextSnapshot); + // if the currentSnapShot was also the mostRecentSnapshot then break + // avoids snapshotAfter throwing exception since there are no more snapshots to process + if (nextSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, nextSnapshot.snapshotId()); + } + return nextSnapshot; + } + + static class UnpackedLimits { + private long maxRows = Integer.MAX_VALUE; + private long maxFiles = Integer.MAX_VALUE; + + UnpackedLimits(ReadLimit limit) { + if (limit instanceof CompositeReadLimit) { + ReadLimit[] compositeLimits = ((CompositeReadLimit) limit).getReadLimits(); + for (ReadLimit individualLimit : compositeLimits) { + if (individualLimit instanceof ReadMaxRows) { + ReadMaxRows readMaxRows = (ReadMaxRows) individualLimit; + this.maxRows = Math.min(this.maxRows, readMaxRows.maxRows()); + } else if (individualLimit instanceof ReadMaxFiles) { + ReadMaxFiles readMaxFiles = (ReadMaxFiles) individualLimit; + this.maxFiles = Math.min(this.maxFiles, readMaxFiles.maxFiles()); + } + } + } else if (limit instanceof ReadMaxRows) { + this.maxRows = ((ReadMaxRows) limit).maxRows(); + } else if (limit instanceof ReadMaxFiles) { + this.maxFiles = ((ReadMaxFiles) limit).maxFiles(); + } + } + + public long getMaxRows() { + return maxRows; + } + + public long getMaxFiles() { + return maxFiles; + } + } +} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java index 9ec0f885775f..3dcfb604ea46 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java @@ -26,6 +26,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.OrcBatchReadConf; @@ -53,6 +54,7 @@ class BatchDataReader extends BaseBatchReader OrcBatchReadConf orcBatchReadConf) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -64,6 +66,7 @@ class BatchDataReader extends BaseBatchReader BatchDataReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -73,6 +76,7 @@ class BatchDataReader extends BaseBatchReader boolean cacheDeleteFilesOnExecutors) { super( table, + fileIO, taskGroup, tableSchema, expectedSchema, diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java index b8fa129f6a44..365747565165 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java @@ -35,6 +35,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.SnapshotUtil; @@ -51,6 +52,7 @@ class ChangelogRowReader extends BaseRowReader ChangelogRowReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -60,6 +62,7 @@ class ChangelogRowReader extends BaseRowReader ChangelogRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -67,6 +70,7 @@ class ChangelogRowReader extends BaseRowReader boolean cacheDeleteFilesOnExecutors) { super( table, + fileIO, taskGroup, tableSchema, ChangelogUtil.dropChangelogMetadata(expectedSchema), diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java index e1292647b797..96dd99ea64e3 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java @@ -25,6 +25,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.spark.rdd.InputFileBlockHolder; import org.apache.spark.sql.catalyst.InternalRow; @@ -32,11 +33,19 @@ public class EqualityDeleteRowReader extends RowDataReader { public EqualityDeleteRowReader( CombinedScanTask task, Table table, + FileIO fileIO, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { - super(table, task, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + super( + table, + fileIO, + task, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); } @Override diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java index 1afde6bb5180..869e80bbd32b 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java @@ -116,13 +116,13 @@ public Table getTable(StructType schema, Transform[] partitioning, Map PositionDeletesRowReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -58,14 +57,20 @@ class PositionDeletesRowReader extends BaseRowReader PositionDeletesRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { - super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); int numSplits = taskGroup.tasks().size(); LOG.debug("Reading {} position delete file split(s) for table {}", numSplits, table.name()); @@ -88,12 +93,16 @@ protected CloseableIterator open(PositionDeletesScanTask task) { InputFile inputFile = getInputFile(task.file().location()); Preconditions.checkNotNull(inputFile, "Could not find InputFile associated with %s", task); - // select out constant fields when pushing down filter to row reader + // Retain predicates on non-constant fields for row reader filter Map idToConstant = constantsMap(task, expectedSchema()); - Set nonConstantFieldIds = nonConstantFieldIds(idToConstant); + int[] nonConstantFieldIds = + expectedSchema().idToName().keySet().stream() + .filter(id -> !idToConstant.containsKey(id)) + .mapToInt(Integer::intValue) + .toArray(); Expression residualWithoutConstants = ExpressionUtil.extractByIdInclusive( - task.residual(), expectedSchema(), caseSensitive(), Ints.toArray(nonConstantFieldIds)); + task.residual(), expectedSchema(), caseSensitive(), nonConstantFieldIds); if (ContentFileUtil.isDV(task.file())) { return new DVIterator(inputFile, task.file(), expectedSchema(), idToConstant); @@ -109,12 +118,4 @@ protected CloseableIterator open(PositionDeletesScanTask task) { idToConstant) .iterator(); } - - private Set nonConstantFieldIds(Map idToConstant) { - Set fields = expectedSchema().idToName().keySet(); - return fields.stream() - .filter(id -> expectedSchema().findField(id).type().isPrimitiveType()) - .filter(id -> !idToConstant.containsKey(id)) - .collect(Collectors.toSet()); - } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java index b2b3c7856389..08aa44f71041 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java @@ -28,6 +28,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.source.metrics.TaskNumDeletes; @@ -48,6 +49,7 @@ class RowDataReader extends BaseRowReader implements PartitionRead RowDataReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -57,6 +59,7 @@ class RowDataReader extends BaseRowReader implements PartitionRead RowDataReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -64,7 +67,13 @@ class RowDataReader extends BaseRowReader implements PartitionRead boolean cacheDeleteFilesOnExecutors) { super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); numSplits = taskGroup.tasks().size(); LOG.debug("Reading {} file split(s) for table {}", numSplits, table.name()); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java new file mode 100644 index 000000000000..2a359110c810 --- /dev/null +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java @@ -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. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.function.Function; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.hadoop.HadoopConfigurable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.util.SerializableSupplier; +import org.apache.spark.util.KnownSizeEstimation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class provides a serializable {@link FileIO} with a known size estimate. Spark calls its + * {@link org.apache.spark.util.SizeEstimator} class when broadcasting variables and this can be an + * expensive operation, so providing a known size estimate allows that operation to be skipped. + * + *

    This class also implements {@link AutoCloseable} to avoid leaking resources upon broadcasting. + * Broadcast variables are destroyed and cleaned up on the driver and executors once they are + * garbage collected on the driver. The implementation ensures only resources used by copies of the + * main {@link FileIO} are released. + */ +class SerializableFileIOWithSize + implements FileIO, HadoopConfigurable, KnownSizeEstimation, AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(SerializableFileIOWithSize.class); + private static final long SIZE_ESTIMATE = 32_768L; + private final transient Object serializationMarker; + private final FileIO fileIO; + + private SerializableFileIOWithSize(FileIO fileIO) { + this.fileIO = fileIO; + this.serializationMarker = new Object(); + } + + @Override + public long estimatedSize() { + return SIZE_ESTIMATE; + } + + public static FileIO wrap(FileIO fileIO) { + return new SerializableFileIOWithSize(fileIO); + } + + @Override + public void close() { + if (null == serializationMarker) { + LOG.debug("Closing FileIO"); + fileIO.close(); + } + } + + @Override + public InputFile newInputFile(String path) { + return fileIO.newInputFile(path); + } + + @Override + public InputFile newInputFile(String path, long length) { + return fileIO.newInputFile(path, length); + } + + @Override + public OutputFile newOutputFile(String path) { + return fileIO.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + fileIO.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + fileIO.initialize(properties); + } + + @Override + public Map properties() { + return fileIO.properties(); + } + + @Override + public void serializeConfWith( + Function> confSerializer) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.serializeConfWith(confSerializer); + } + } + + @Override + public void setConf(Configuration conf) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.setConf(conf); + } + } + + @Override + public Configuration getConf() { + if (fileIO instanceof HadoopConfigurable hadoopConfigurable) { + return hadoopConfigurable.getConf(); + } + + return null; + } +} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java index 0626d0b43985..2109936c96b9 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Objects; +import java.util.function.Supplier; import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.MetadataColumns; @@ -28,14 +29,13 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.spark.ImmutableOrcBatchReadConf; import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; import org.apache.iceberg.spark.OrcBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.SparkReadConf; import org.apache.iceberg.spark.SparkUtil; -import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; @@ -47,6 +47,7 @@ class SparkBatch implements Batch { private final JavaSparkContext sparkContext; private final Table table; + private final Supplier fileIO; private final String branch; private final SparkReadConf readConf; private final Types.StructType groupingKeyType; @@ -61,6 +62,7 @@ class SparkBatch implements Batch { SparkBatch( JavaSparkContext sparkContext, Table table, + Supplier fileIO, SparkReadConf readConf, Types.StructType groupingKeyType, List> taskGroups, @@ -68,6 +70,7 @@ class SparkBatch implements Batch { int scanHashCode) { this.sparkContext = sparkContext; this.table = table; + this.fileIO = fileIO; this.branch = readConf.branch(); this.readConf = readConf; this.groupingKeyType = groupingKeyType; @@ -85,6 +88,8 @@ public InputPartition[] planInputPartitions() { // broadcast the table metadata as input partitions will be sent to executors Broadcast

    tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + Broadcast fileIOBroadcast = + sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); String expectedSchemaString = SchemaParser.toJson(expectedSchema); String[][] locations = computePreferredLocations(); @@ -96,6 +101,7 @@ public InputPartition[] planInputPartitions() { groupingKeyType, taskGroups.get(index), tableBroadcast, + fileIOBroadcast, branch, expectedSchemaString, caseSensitive, @@ -108,7 +114,7 @@ public InputPartition[] planInputPartitions() { private String[][] computePreferredLocations() { if (localityEnabled) { - return SparkPlanningUtil.fetchBlockLocations(table.io(), taskGroups); + return SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups); } else if (executorCacheLocalityEnabled) { List executorLocations = SparkUtil.executorLocations(); @@ -122,11 +128,8 @@ private String[][] computePreferredLocations() { @Override public PartitionReaderFactory createReaderFactory() { - if (useCometBatchReads()) { - return new SparkColumnarReaderFactory(parquetBatchReadConf(ParquetReaderType.COMET)); - - } else if (useParquetBatchReads()) { - return new SparkColumnarReaderFactory(parquetBatchReadConf(ParquetReaderType.ICEBERG)); + if (useParquetBatchReads()) { + return new SparkColumnarReaderFactory(parquetBatchReadConf()); } else if (useOrcBatchReads()) { return new SparkColumnarReaderFactory(orcBatchReadConf()); @@ -136,11 +139,8 @@ public PartitionReaderFactory createReaderFactory() { } } - private ParquetBatchReadConf parquetBatchReadConf(ParquetReaderType readerType) { - return ImmutableParquetBatchReadConf.builder() - .batchSize(readConf.parquetBatchSize()) - .readerType(readerType) - .build(); + private ParquetBatchReadConf parquetBatchReadConf() { + return ImmutableParquetBatchReadConf.builder().batchSize(readConf.parquetBatchSize()).build(); } private OrcBatchReadConf orcBatchReadConf() { @@ -175,20 +175,6 @@ private boolean supportsParquetBatchReads(Types.NestedField field) { return field.type().isPrimitiveType() || MetadataColumns.isMetadataColumn(field.fieldId()); } - private boolean useCometBatchReads() { - return readConf.parquetVectorizationEnabled() - && readConf.parquetReaderType() == ParquetReaderType.COMET - && expectedSchema.columns().stream().allMatch(this::supportsCometBatchReads) - && taskGroups.stream().allMatch(this::supportsParquetBatchReads); - } - - private boolean supportsCometBatchReads(Types.NestedField field) { - return field.type().isPrimitiveType() - && !field.type().typeId().equals(Type.TypeID.UUID) - && field.fieldId() != MetadataColumns.ROW_ID.fieldId() - && field.fieldId() != MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(); - } - // conditions for using ORC batch reads: // - ORC vectorization is enabled // - all tasks are of type FileScanTask and read only ORC files with no delete files diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java index 55ea137ca1b0..eba0431e3adf 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java @@ -106,6 +106,7 @@ public Batch toBatch() { return new SparkBatch( sparkContext, table, + null != scan ? scan.fileIO() : table::io, readConf, EMPTY_GROUPING_KEY_TYPE, taskGroups(), diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java index 93b27c566c9f..8d5972ac7acf 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java @@ -21,9 +21,9 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.CatalogUtil; import org.apache.iceberg.ContentFile; import org.apache.iceberg.exceptions.NotFoundException; -import org.apache.iceberg.io.BulkDeletionFailureException; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -84,34 +84,13 @@ private static String taskInfo() { */ public static void deleteFiles(String context, FileIO io, List> files) { List paths = Lists.transform(files, ContentFile::location); - deletePaths(context, io, paths); - } - - private static void deletePaths(String context, FileIO io, List paths) { if (io instanceof SupportsBulkOperations) { - SupportsBulkOperations bulkIO = (SupportsBulkOperations) io; - bulkDelete(context, bulkIO, paths); + CatalogUtil.deleteFiles(io, paths, ""); } else { delete(context, io, paths); } } - private static void bulkDelete(String context, SupportsBulkOperations io, List paths) { - try { - io.deleteFiles(paths); - LOG.info("Deleted {} file(s) using bulk deletes ({})", paths.size(), context); - - } catch (BulkDeletionFailureException e) { - int deletedFilesCount = paths.size() - e.numberFailedObjects(); - LOG.warn( - "Deleted only {} of {} file(s) using bulk deletes ({})", - deletedFilesCount, - paths.size(), - context, - e); - } - } - private static void delete(String context, FileIO io, List paths) { AtomicInteger deletedFilesCount = new AtomicInteger(0); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java index dbf5d455b948..9674a7333fa8 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java @@ -39,11 +39,11 @@ import org.apache.iceberg.util.SnapshotUtil; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.Literal; import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.Statistics; -import org.apache.spark.sql.connector.read.SupportsRuntimeFiltering; -import org.apache.spark.sql.sources.Filter; -import org.apache.spark.sql.sources.In; +import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering; import org.apache.spark.sql.types.Metadata; import org.apache.spark.sql.types.MetadataBuilder; import org.apache.spark.sql.types.StructField; @@ -52,7 +52,7 @@ import org.slf4j.LoggerFactory; class SparkCopyOnWriteScan extends SparkPartitioningAwareScan - implements SupportsRuntimeFiltering { + implements SupportsRuntimeV2Filtering { private static final Logger LOG = LoggerFactory.getLogger(SparkCopyOnWriteScan.class); @@ -118,7 +118,7 @@ public NamedReference[] filterAttributes() { } @Override - public void filter(Filter[] filters) { + public void filter(Predicate[] predicates) { Preconditions.checkState( Objects.equals(snapshotId(), currentSnapshotId()), "Runtime file filtering is not possible: the table has been concurrently modified. " @@ -128,16 +128,10 @@ public void filter(Filter[] filters) { snapshotId(), currentSnapshotId()); - for (Filter filter : filters) { - // Spark can only pass In filters at the moment - if (filter instanceof In - && ((In) filter).attribute().equalsIgnoreCase(MetadataColumns.FILE_PATH.name())) { - In in = (In) filter; - - Set fileLocations = Sets.newHashSet(); - for (Object value : in.values()) { - fileLocations.add((String) value); - } + for (Predicate predicate : predicates) { + // Spark can only pass IN predicates at the moment + if (isFilePathInPredicate(predicate)) { + Set fileLocations = extractStringLiterals(predicate); // Spark may call this multiple times for UPDATEs with subqueries // as such cases are rewritten using UNION and the same scan on both sides @@ -159,7 +153,7 @@ public void filter(Filter[] filters) { resetTasks(filteredTasks); } } else { - LOG.warn("Unsupported runtime filter {}", filter); + LOG.warn("Unsupported runtime filter {}", predicate); } } } @@ -228,4 +222,32 @@ private boolean isRowLineageField(StructField field) { || field.name().equals(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()); return hasLineageFieldName && field.metadata().contains("__metadata_col"); } + + private static boolean isFilePathInPredicate(Predicate predicate) { + if (!"IN".equals(predicate.name()) || predicate.children().length < 1) { + return false; + } + + if (!(predicate.children()[0] instanceof NamedReference)) { + return false; + } + + String[] fieldNames = ((NamedReference) predicate.children()[0]).fieldNames(); + + return fieldNames.length == 1 + && fieldNames[0].equalsIgnoreCase(MetadataColumns.FILE_PATH.name()); + } + + private static Set extractStringLiterals(Predicate predicate) { + Set values = Sets.newHashSet(); + for (int i = 1; i < predicate.children().length; i++) { + if (predicate.children()[i] instanceof Literal) { + Object value = ((Literal) predicate.children()[i]).value(); + // V2 string literals come through as UTF8String; toString() materializes the Java String + values.add(value.toString()); + } + } + + return values; + } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java index a93db17e4a0f..39110f0b0597 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java @@ -23,13 +23,20 @@ import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; import static org.apache.iceberg.TableProperties.DELETE_DEFAULT_FILE_FORMAT; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.Map; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; import org.apache.iceberg.io.DeleteSchemaUtil; import org.apache.iceberg.orc.ORC; import org.apache.iceberg.parquet.Parquet; @@ -40,14 +47,20 @@ import org.apache.iceberg.spark.data.SparkOrcWriter; import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.spark.sql.catalyst.InternalRow; -import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; import org.apache.spark.unsafe.types.UTF8String; - -class SparkFileWriterFactory extends BaseFileWriterFactory { - private StructType dataSparkType; - private StructType equalityDeleteSparkType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SparkFileWriterFactory extends RegistryBasedFileWriterFactory { + private static final Logger LOG = LoggerFactory.getLogger(SparkFileWriterFactory.class); + // We need to use old writers to write position deletes with row data, which is a deprecated + // feature. + private final boolean useDeprecatedPositionDeleteWriter; private StructType positionDeleteSparkType; + private final Schema positionDeleteRowSchema; + private final Table table; + private final FileFormat deleteFormat; private final Map writeProperties; /** @@ -75,18 +88,26 @@ class SparkFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + InternalRow.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - positionDeleteRowSchema); + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); - this.dataSparkType = dataSparkType; - this.equalityDeleteSparkType = equalityDeleteSparkType; - this.positionDeleteSparkType = positionDeleteSparkType; + this.table = table; + this.deleteFormat = deleteFileFormat; this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = positionDeleteRowSchema; + this.positionDeleteSparkType = positionDeleteSparkType; + this.useDeprecatedPositionDeleteWriter = + positionDeleteRowSchema != null + || (positionDeleteSparkType != null + && positionDeleteSparkType.getFieldIndex(DELETE_FILE_ROW_FIELD_NAME).isDefined()); } SparkFileWriterFactory( @@ -105,119 +126,109 @@ class SparkFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + InternalRow.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - ImmutableMap.of()); + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); - this.dataSparkType = dataSparkType; - this.equalityDeleteSparkType = equalityDeleteSparkType; - this.positionDeleteSparkType = null; + this.table = table; + this.deleteFormat = deleteFileFormat; this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = null; + this.useDeprecatedPositionDeleteWriter = false; } static Builder builderFor(Table table) { return new Builder(table); } - @Override - protected void configureDataWrite(Avro.DataWriteBuilder builder) { - builder.createWriterFunc(ignored -> new SparkAvroWriter(dataSparkType())); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(Avro.DeleteWriteBuilder builder) { - builder.createWriterFunc(ignored -> new SparkAvroWriter(equalityDeleteSparkType())); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(Avro.DeleteWriteBuilder builder) { - boolean withRow = - positionDeleteSparkType().getFieldIndex(DELETE_FILE_ROW_FIELD_NAME).isDefined(); - if (withRow) { - // SparkAvroWriter accepts just the Spark type of the row ignoring the path and pos - StructField rowField = positionDeleteSparkType().apply(DELETE_FILE_ROW_FIELD_NAME); - StructType positionDeleteRowSparkType = (StructType) rowField.dataType(); - builder.createWriterFunc(ignored -> new SparkAvroWriter(positionDeleteRowSparkType)); - } - - builder.setAll(writeProperties); - } - - @Override - protected void configureDataWrite(Parquet.DataWriteBuilder builder) { - builder.createWriterFunc(msgType -> SparkParquetWriters.buildWriter(dataSparkType(), msgType)); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> SparkParquetWriters.buildWriter(equalityDeleteSparkType(), msgType)); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> SparkParquetWriters.buildWriter(positionDeleteSparkType(), msgType)); - builder.transformPaths(path -> UTF8String.fromString(path.toString())); - builder.setAll(writeProperties); - } - - @Override - protected void configureDataWrite(ORC.DataWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.transformPaths(path -> UTF8String.fromString(path.toString())); - builder.setAll(writeProperties); - } - - private StructType dataSparkType() { - if (dataSparkType == null) { - Preconditions.checkNotNull(dataSchema(), "Data schema must not be null"); - this.dataSparkType = SparkSchemaUtil.convert(dataSchema()); - } - - return dataSparkType; - } - - private StructType equalityDeleteSparkType() { - if (equalityDeleteSparkType == null) { - Preconditions.checkNotNull( - equalityDeleteRowSchema(), "Equality delete schema must not be null"); - this.equalityDeleteSparkType = SparkSchemaUtil.convert(equalityDeleteRowSchema()); - } - - return equalityDeleteSparkType; - } - private StructType positionDeleteSparkType() { if (positionDeleteSparkType == null) { // wrap the optional row schema into the position delete schema containing path and position - Schema positionDeleteSchema = DeleteSchemaUtil.posDeleteSchema(positionDeleteRowSchema()); + Schema positionDeleteSchema = DeleteSchemaUtil.posDeleteSchema(positionDeleteRowSchema); this.positionDeleteSparkType = SparkSchemaUtil.convert(positionDeleteSchema); } return positionDeleteSparkType; } + @Override + public PositionDeleteWriter newPositionDeleteWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + if (!useDeprecatedPositionDeleteWriter) { + return super.newPositionDeleteWriter(file, spec, partition); + } else { + LOG.warn("Position deletes with deleted rows are deprecated and will be removed in 1.12.0."); + Map properties = table == null ? ImmutableMap.of() : table.properties(); + MetricsConfig metricsConfig = + table == null + ? MetricsConfig.forPositionDelete() + : MetricsConfig.forPositionDelete(table); + + try { + return switch (deleteFormat) { + case AVRO -> + Avro.writeDeletes(file) + .createWriterFunc( + ignored -> + new SparkAvroWriter( + (StructType) + positionDeleteSparkType() + .apply(DELETE_FILE_ROW_FIELD_NAME) + .dataType())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(file) + .createWriterFunc(SparkOrcWriter::new) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(file) + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter(positionDeleteSparkType(), msgType)) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + default -> + throw new UnsupportedOperationException( + "Cannot write pos-deletes for unsupported file format: " + deleteFormat); + }; + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new position delete writer", e); + } + } + } + static class Builder { private final Table table; private FileFormat dataFileFormat; @@ -340,4 +351,14 @@ SparkFileWriterFactory build() { writeProperties); } } + + private static StructType useOrConvert(StructType sparkType, Schema schema) { + if (sparkType != null) { + return sparkType; + } else if (schema != null) { + return SparkSchemaUtil.convert(schema); + } else { + return null; + } + } } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java new file mode 100644 index 000000000000..23fbe54a4be3 --- /dev/null +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; +import org.apache.iceberg.spark.data.SparkAvroWriter; +import org.apache.iceberg.spark.data.SparkOrcReader; +import org.apache.iceberg.spark.data.SparkOrcWriter; +import org.apache.iceberg.spark.data.SparkParquetReaders; +import org.apache.iceberg.spark.data.SparkParquetWriters; +import org.apache.iceberg.spark.data.SparkPlannedAvroReader; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +public class SparkFormatModels { + public static void register() { + FormatModelRegistry.register( + AvroFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkAvroWriter(icebergSchema, engineSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkPlannedAvroReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register( + ParquetFormatModel.create( + InternalRow.class, + StructType.class, + SparkParquetWriters::buildWriter, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ParquetFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkParquetReaders.buildReader( + icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkOrcWriter(icebergSchema, fileSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + new SparkOrcReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkOrcReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + } + + private SparkFormatModels() {} +} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java index 99b1d78a86b0..a93031780255 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java @@ -24,6 +24,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.types.Types; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; @@ -34,6 +35,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab private final Types.StructType groupingKeyType; private final ScanTaskGroup taskGroup; private final Broadcast
    tableBroadcast; + private final Broadcast fileIOBroadcast; private final String branch; private final String expectedSchemaString; private final boolean caseSensitive; @@ -46,6 +48,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab Types.StructType groupingKeyType, ScanTaskGroup taskGroup, Broadcast
    tableBroadcast, + Broadcast fileIOBroadcast, String branch, String expectedSchemaString, boolean caseSensitive, @@ -54,6 +57,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab this.groupingKeyType = groupingKeyType; this.taskGroup = taskGroup; this.tableBroadcast = tableBroadcast; + this.fileIOBroadcast = fileIOBroadcast; this.branch = branch; this.expectedSchemaString = expectedSchemaString; this.caseSensitive = caseSensitive; @@ -84,6 +88,10 @@ public Table table() { return tableBroadcast.value(); } + public FileIO io() { + return fileIOBroadcast.value(); + } + public String branch() { return branch; } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java new file mode 100644 index 000000000000..1986ddac5d8e --- /dev/null +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.FileScanTask; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; + +interface SparkMicroBatchPlanner { + /** + * Return the {@link FileScanTask}s for data added between the start and end offsets. + * + * @param startOffset the offset to start planning from + * @param endOffset the offset to plan up to + * @return file scan tasks for data in the offset range + */ + List planFiles(StreamingOffset startOffset, StreamingOffset endOffset); + + /** + * Return the latest offset the stream can advance to from {@code startOffset}, respecting the + * given {@link ReadLimit}. + * + * @param startOffset the current offset of the stream + * @param limit the read limit bounding how far ahead to advance + * @return the latest available offset, or {@code null} if no new data is available + */ + StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit); + + /** Stop the planner and release any resources. */ + void stop(); +} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java index 4b79da813322..a1ff767fe2a0 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java @@ -26,44 +26,32 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.Locale; +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.CombinedScanTask; -import org.apache.iceberg.DataOperations; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.MicroBatches; -import org.apache.iceberg.MicroBatches.MicroBatch; import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; +import org.apache.iceberg.hadoop.HadoopFileIO; import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.relocated.com.google.common.base.Joiner; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadConf; -import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.Pair; -import org.apache.iceberg.util.PropertyUtil; -import org.apache.iceberg.util.SnapshotUtil; import org.apache.iceberg.util.TableScanUtil; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.connector.read.InputPartition; import org.apache.spark.sql.connector.read.PartitionReaderFactory; -import org.apache.spark.sql.connector.read.streaming.CompositeReadLimit; import org.apache.spark.sql.connector.read.streaming.MicroBatchStream; import org.apache.spark.sql.connector.read.streaming.Offset; import org.apache.spark.sql.connector.read.streaming.ReadLimit; -import org.apache.spark.sql.connector.read.streaming.ReadMaxFiles; -import org.apache.spark.sql.connector.read.streaming.ReadMaxRows; import org.apache.spark.sql.connector.read.streaming.SupportsTriggerAvailableNow; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,35 +62,41 @@ public class SparkMicroBatchStream implements MicroBatchStream, SupportsTriggerA private static final Types.StructType EMPTY_GROUPING_KEY_TYPE = Types.StructType.of(); private final Table table; + private final Supplier fileIO; + private final SparkReadConf readConf; private final String branch; private final boolean caseSensitive; private final String expectedSchema; private final Broadcast
    tableBroadcast; + private final Broadcast fileIOBroadcast; private final long splitSize; private final int splitLookback; private final long splitOpenFileCost; private final boolean localityPreferred; private final StreamingOffset initialOffset; - private final boolean skipDelete; - private final boolean skipOverwrite; private final long fromTimestamp; private final int maxFilesPerMicroBatch; private final int maxRecordsPerMicroBatch; private final boolean cacheDeleteFilesOnExecutors; + private SparkMicroBatchPlanner planner; private StreamingOffset lastOffsetForTriggerAvailableNow; SparkMicroBatchStream( JavaSparkContext sparkContext, Table table, + Supplier fileIO, SparkReadConf readConf, Schema expectedSchema, String checkpointLocation) { this.table = table; + this.fileIO = fileIO; + this.readConf = readConf; this.branch = readConf.branch(); this.caseSensitive = readConf.caseSensitive(); this.expectedSchema = SchemaParser.toJson(expectedSchema); this.localityPreferred = readConf.localityEnabled(); this.tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + this.fileIOBroadcast = sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); this.splitSize = readConf.splitSize(); this.splitLookback = readConf.splitLookback(); this.splitOpenFileCost = readConf.splitOpenFileCost(); @@ -112,11 +106,9 @@ public class SparkMicroBatchStream implements MicroBatchStream, SupportsTriggerA this.cacheDeleteFilesOnExecutors = readConf.cacheDeleteFilesOnExecutors(); InitialOffsetStore initialOffsetStore = - new InitialOffsetStore(table, checkpointLocation, fromTimestamp); + new InitialOffsetStore( + table, checkpointLocation, fromTimestamp, sparkContext.hadoopConfiguration()); this.initialOffset = initialOffsetStore.initialOffset(); - - this.skipDelete = readConf.streamingSkipDeleteSnapshots(); - this.skipOverwrite = readConf.streamingSkipOverwriteSnapshots(); } @Override @@ -131,8 +123,8 @@ public Offset latestOffset() { } Snapshot latestSnapshot = table.currentSnapshot(); - - return new StreamingOffset(latestSnapshot.snapshotId(), addedFilesCount(latestSnapshot), false); + return new StreamingOffset( + latestSnapshot.snapshotId(), MicroBatchUtils.addedFilesCount(table, latestSnapshot), false); } @Override @@ -151,7 +143,11 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { StreamingOffset endOffset = (StreamingOffset) end; StreamingOffset startOffset = (StreamingOffset) start; - List fileScanTasks = planFiles(startOffset, endOffset); + if (planner == null) { + initializePlanner(startOffset, endOffset); + } + + List fileScanTasks = planner.planFiles(startOffset, endOffset); CloseableIterable splitTasks = TableScanUtil.splitFiles(CloseableIterable.withNoopClose(fileScanTasks), splitSize); @@ -161,13 +157,13 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { String[][] locations = computePreferredLocations(combinedScanTasks); InputPartition[] partitions = new InputPartition[combinedScanTasks.size()]; - for (int index = 0; index < combinedScanTasks.size(); index++) { partitions[index] = new SparkInputPartition( EMPTY_GROUPING_KEY_TYPE, combinedScanTasks.get(index), tableBroadcast, + fileIOBroadcast, branch, expectedSchema, caseSensitive, @@ -179,7 +175,9 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { } private String[][] computePreferredLocations(List taskGroups) { - return localityPreferred ? SparkPlanningUtil.fetchBlockLocations(table.io(), taskGroups) : null; + return localityPreferred + ? SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups) + : null; } @Override @@ -201,317 +199,35 @@ public Offset deserializeOffset(String json) { public void commit(Offset end) {} @Override - public void stop() {} - - private List planFiles(StreamingOffset startOffset, StreamingOffset endOffset) { - List fileScanTasks = Lists.newArrayList(); - StreamingOffset batchStartOffset = - StreamingOffset.START_OFFSET.equals(startOffset) - ? determineStartingOffset(table, fromTimestamp) - : startOffset; - - StreamingOffset currentOffset = null; - - // [(startOffset : startFileIndex), (endOffset : endFileIndex) ) - do { - long endFileIndex; - if (currentOffset == null) { - currentOffset = batchStartOffset; - } else { - Snapshot snapshotAfter = SnapshotUtil.snapshotAfter(table, currentOffset.snapshotId()); - // it may happen that we need to read this snapshot partially in case it's equal to - // endOffset. - if (currentOffset.snapshotId() != endOffset.snapshotId()) { - currentOffset = new StreamingOffset(snapshotAfter.snapshotId(), 0L, false); - } else { - currentOffset = endOffset; - } - } - - Snapshot snapshot = table.snapshot(currentOffset.snapshotId()); - - validateCurrentSnapshotExists(snapshot, currentOffset); - - if (!shouldProcess(snapshot)) { - LOG.debug("Skipping snapshot: {} of table {}", currentOffset.snapshotId(), table.name()); - continue; - } - - Snapshot currentSnapshot = table.snapshot(currentOffset.snapshotId()); - if (currentOffset.snapshotId() == endOffset.snapshotId()) { - endFileIndex = endOffset.position(); - } else { - endFileIndex = addedFilesCount(currentSnapshot); - } - - MicroBatch latestMicroBatch = - MicroBatches.from(currentSnapshot, table.io()) - .caseSensitive(caseSensitive) - .specsById(table.specs()) - .generate( - currentOffset.position(), - endFileIndex, - Long.MAX_VALUE, - currentOffset.shouldScanAllFiles()); - - fileScanTasks.addAll(latestMicroBatch.tasks()); - } while (currentOffset.snapshotId() != endOffset.snapshotId()); - - return fileScanTasks; - } - - private boolean shouldProcess(Snapshot snapshot) { - String op = snapshot.operation(); - switch (op) { - case DataOperations.APPEND: - return true; - case DataOperations.REPLACE: - return false; - case DataOperations.DELETE: - Preconditions.checkState( - skipDelete, - "Cannot process delete snapshot: %s, to ignore deletes, set %s=true", - snapshot.snapshotId(), - SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS); - return false; - case DataOperations.OVERWRITE: - Preconditions.checkState( - skipOverwrite, - "Cannot process overwrite snapshot: %s, to ignore overwrites, set %s=true", - snapshot.snapshotId(), - SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS); - return false; - default: - throw new IllegalStateException( - String.format( - "Cannot process unknown snapshot operation: %s (snapshot id %s)", - op.toLowerCase(Locale.ROOT), snapshot.snapshotId())); - } - } - - private static StreamingOffset determineStartingOffset(Table table, Long fromTimestamp) { - if (table.currentSnapshot() == null) { - return StreamingOffset.START_OFFSET; - } - - if (fromTimestamp == null) { - // match existing behavior and start from the oldest snapshot - return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); - } - - if (table.currentSnapshot().timestampMillis() < fromTimestamp) { - return StreamingOffset.START_OFFSET; - } - - try { - Snapshot snapshot = SnapshotUtil.oldestAncestorAfter(table, fromTimestamp); - if (snapshot != null) { - return new StreamingOffset(snapshot.snapshotId(), 0, false); - } else { - return StreamingOffset.START_OFFSET; - } - } catch (IllegalStateException e) { - // could not determine the first snapshot after the timestamp. use the oldest ancestor instead - return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); + public void stop() { + if (planner != null) { + planner.stop(); } } - private static int getMaxFiles(ReadLimit readLimit) { - if (readLimit instanceof ReadMaxFiles) { - return ((ReadMaxFiles) readLimit).maxFiles(); - } - - if (readLimit instanceof CompositeReadLimit) { - // We do not expect a CompositeReadLimit to contain a nested CompositeReadLimit. - // In fact, it should only be a composite of two or more of ReadMinRows, ReadMaxRows and - // ReadMaxFiles, with no more than one of each. - ReadLimit[] limits = ((CompositeReadLimit) readLimit).getReadLimits(); - for (ReadLimit limit : limits) { - if (limit instanceof ReadMaxFiles) { - return ((ReadMaxFiles) limit).maxFiles(); - } - } - } - - // there is no ReadMaxFiles, so return the default - return Integer.MAX_VALUE; - } - - private static int getMaxRows(ReadLimit readLimit) { - if (readLimit instanceof ReadMaxRows) { - long maxRows = ((ReadMaxRows) readLimit).maxRows(); - return Math.toIntExact(maxRows); - } - - if (readLimit instanceof CompositeReadLimit) { - ReadLimit[] limits = ((CompositeReadLimit) readLimit).getReadLimits(); - for (ReadLimit limit : limits) { - if (limit instanceof ReadMaxRows) { - long maxRows = ((ReadMaxRows) limit).maxRows(); - return Math.toIntExact(maxRows); - } - } + private void initializePlanner(StreamingOffset startOffset, StreamingOffset endOffset) { + if (readConf.asyncMicroBatchPlanningEnabled()) { + this.planner = + new AsyncSparkMicroBatchPlanner( + table, readConf, startOffset, endOffset, lastOffsetForTriggerAvailableNow); + } else { + this.planner = + new SyncSparkMicroBatchPlanner(table, readConf, lastOffsetForTriggerAvailableNow); } - - // There is no ReadMaxRows, so return the default - return Integer.MAX_VALUE; } @Override - @SuppressWarnings("checkstyle:CyclomaticComplexity") public Offset latestOffset(Offset startOffset, ReadLimit limit) { - // calculate end offset get snapshotId from the startOffset Preconditions.checkArgument( startOffset instanceof StreamingOffset, "Invalid start offset: %s is not a StreamingOffset", startOffset); - table.refresh(); - if (table.currentSnapshot() == null) { - return StreamingOffset.START_OFFSET; - } - - if (table.currentSnapshot().timestampMillis() < fromTimestamp) { - return StreamingOffset.START_OFFSET; + if (planner == null) { + initializePlanner((StreamingOffset) startOffset, null); } - // end offset can expand to multiple snapshots - StreamingOffset startingOffset = (StreamingOffset) startOffset; - - if (startOffset.equals(StreamingOffset.START_OFFSET)) { - startingOffset = determineStartingOffset(table, fromTimestamp); - } - - Snapshot curSnapshot = table.snapshot(startingOffset.snapshotId()); - validateCurrentSnapshotExists(curSnapshot, startingOffset); - - // Use the pre-computed snapshotId when Trigger.AvailableNow is enabled. - long latestSnapshotId = - lastOffsetForTriggerAvailableNow != null - ? lastOffsetForTriggerAvailableNow.snapshotId() - : table.currentSnapshot().snapshotId(); - - int startPosOfSnapOffset = (int) startingOffset.position(); - - boolean scanAllFiles = startingOffset.shouldScanAllFiles(); - - boolean shouldContinueReading = true; - int curFilesAdded = 0; - long curRecordCount = 0; - int curPos = 0; - - // Note : we produce nextOffset with pos as non-inclusive - while (shouldContinueReading) { - // generate manifest index for the curSnapshot - List> indexedManifests = - MicroBatches.skippedManifestIndexesFromSnapshot( - table.io(), curSnapshot, startPosOfSnapOffset, scanAllFiles); - // this is under assumption we will be able to add at-least 1 file in the new offset - for (int idx = 0; idx < indexedManifests.size() && shouldContinueReading; idx++) { - // be rest assured curPos >= startFileIndex - curPos = indexedManifests.get(idx).second(); - try (CloseableIterable taskIterable = - MicroBatches.openManifestFile( - table.io(), - table.specs(), - caseSensitive, - curSnapshot, - indexedManifests.get(idx).first(), - scanAllFiles); - CloseableIterator taskIter = taskIterable.iterator()) { - while (taskIter.hasNext()) { - FileScanTask task = taskIter.next(); - if (curPos >= startPosOfSnapOffset) { - if ((curFilesAdded + 1) > getMaxFiles(limit)) { - // On including the file it might happen that we might exceed, the configured - // soft limit on the number of records, since this is a soft limit its acceptable. - shouldContinueReading = false; - break; - } - - curFilesAdded += 1; - curRecordCount += task.file().recordCount(); - - if (curRecordCount >= getMaxRows(limit)) { - // we included the file, so increment the number of files - // read in the current snapshot. - ++curPos; - shouldContinueReading = false; - break; - } - } - ++curPos; - } - } catch (IOException ioe) { - LOG.warn("Failed to close task iterable", ioe); - } - } - // if the currentSnapShot was also the latestSnapshot then break - if (curSnapshot.snapshotId() == latestSnapshotId) { - break; - } - - // if everything was OK and we consumed complete snapshot then move to next snapshot - if (shouldContinueReading) { - Snapshot nextValid = nextValidSnapshot(curSnapshot); - if (nextValid == null) { - // nextValid implies all the remaining snapshots should be skipped. - break; - } - // we found the next available snapshot, continue from there. - curSnapshot = nextValid; - startPosOfSnapOffset = -1; - // if anyhow we are moving to next snapshot we should only scan addedFiles - scanAllFiles = false; - } - } - - StreamingOffset latestStreamingOffset = - new StreamingOffset(curSnapshot.snapshotId(), curPos, scanAllFiles); - - // if no new data arrived, then return null. - return latestStreamingOffset.equals(startingOffset) ? null : latestStreamingOffset; - } - - /** - * Get the next snapshot skiping over rewrite and delete snapshots. - * - * @param curSnapshot the current snapshot - * @return the next valid snapshot (not a rewrite or delete snapshot), returns null if all - * remaining snapshots should be skipped. - */ - private Snapshot nextValidSnapshot(Snapshot curSnapshot) { - Snapshot nextSnapshot = SnapshotUtil.snapshotAfter(table, curSnapshot.snapshotId()); - // skip over rewrite and delete snapshots - while (!shouldProcess(nextSnapshot)) { - LOG.debug("Skipping snapshot: {} of table {}", nextSnapshot.snapshotId(), table.name()); - // if the currentSnapShot was also the mostRecentSnapshot then break - if (nextSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { - return null; - } - nextSnapshot = SnapshotUtil.snapshotAfter(table, nextSnapshot.snapshotId()); - } - return nextSnapshot; - } - - private long addedFilesCount(Snapshot snapshot) { - long addedFilesCount = - PropertyUtil.propertyAsLong(snapshot.summary(), SnapshotSummary.ADDED_FILES_PROP, -1); - // If snapshotSummary doesn't have SnapshotSummary.ADDED_FILES_PROP, - // iterate through addedFiles iterator to find addedFilesCount. - return addedFilesCount == -1 - ? Iterables.size(snapshot.addedDataFiles(table.io())) - : addedFilesCount; - } - - private void validateCurrentSnapshotExists(Snapshot snapshot, StreamingOffset currentOffset) { - if (snapshot == null) { - throw new IllegalStateException( - String.format( - Locale.ROOT, - "Cannot load current offset at snapshot %d, the snapshot was expired or removed", - currentOffset.snapshotId())); - } + return planner.latestOffset((StreamingOffset) startOffset, limit); } @Override @@ -539,17 +255,23 @@ public void prepareForTriggerAvailableNow() { (StreamingOffset) latestOffset(initialOffset, ReadLimit.allAvailable()); LOG.info("lastOffset for Trigger.AvailableNow is {}", lastOffsetForTriggerAvailableNow.json()); + + if (planner != null) { + planner.stop(); + planner = null; + } } private static class InitialOffsetStore { private final Table table; private final FileIO io; private final String initialOffsetLocation; - private final Long fromTimestamp; + private final long fromTimestamp; - InitialOffsetStore(Table table, String checkpointLocation, Long fromTimestamp) { + InitialOffsetStore( + Table table, String checkpointLocation, long fromTimestamp, Configuration conf) { this.table = table; - this.io = table.io(); + this.io = new HadoopFileIO(conf); this.initialOffsetLocation = SLASH.join(checkpointLocation, "offsets/0"); this.fromTimestamp = fromTimestamp; } @@ -561,7 +283,7 @@ public StreamingOffset initialOffset() { } table.refresh(); - StreamingOffset offset = determineStartingOffset(table, fromTimestamp); + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, fromTimestamp); OutputFile outputFile = io.newOutputFile(initialOffsetLocation); writeOffset(offset, outputFile); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java index c9726518ee4e..4d9fb7556b4e 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java @@ -78,7 +78,14 @@ abstract class SparkPartitioningAwareScan extends S Schema expectedSchema, List filters, Supplier scanReportSupplier) { - super(spark, table, readConf, expectedSchema, filters, scanReportSupplier); + super( + spark, + table, + null != scan ? scan.fileIO() : table::io, + readConf, + expectedSchema, + filters, + scanReportSupplier); this.scan = scan; this.preserveDataGrouping = readConf.preserveDataGrouping(); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java index ddad1a749aa9..f926bd96389a 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java @@ -110,6 +110,7 @@ class SparkPositionDeltaWrite implements DeltaWrite, RequiresDistributionAndOrde private final String branch; private final Map extraSnapshotMetadata; private final SparkWriteRequirements writeRequirements; + private final int sortOrderId; private final Context context; private final Map writeProperties; @@ -135,6 +136,7 @@ class SparkPositionDeltaWrite implements DeltaWrite, RequiresDistributionAndOrde this.branch = writeConf.branch(); this.extraSnapshotMetadata = writeConf.extraSnapshotMetadata(); this.writeRequirements = writeConf.positionDeltaRequirements(command); + this.sortOrderId = writeConf.outputSortOrderId(writeRequirements); this.context = new Context(dataSchema, writeConf, info, writeRequirements); this.writeProperties = writeConf.writeProperties(); } @@ -180,7 +182,8 @@ public DeltaWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) { broadcastRewritableDeletes(), command, context, - writeProperties); + writeProperties, + sortOrderId); } private Broadcast> broadcastRewritableDeletes() { @@ -390,18 +393,21 @@ private static class PositionDeltaWriteFactory implements DeltaWriterFactory { private final Command command; private final Context context; private final Map writeProperties; + private final int sortOrderId; PositionDeltaWriteFactory( Broadcast
    tableBroadcast, Broadcast> rewritableDeletesBroadcast, Command command, Context context, - Map writeProperties) { + Map writeProperties, + int sortOrderId) { this.tableBroadcast = tableBroadcast; this.rewritableDeletesBroadcast = rewritableDeletesBroadcast; this.command = command; this.context = context; this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; } @Override @@ -428,6 +434,7 @@ public DeltaWriter createWriter(int partitionId, long taskId) { .deleteFileFormat(context.deleteFileFormat()) .positionDeleteSparkType(context.deleteSparkType()) .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) .build(); if (command == DELETE) { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java index 106b296de098..6cf12f6e7667 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java @@ -33,6 +33,7 @@ import org.apache.iceberg.StatisticsFile; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.metrics.ScanReport; import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -101,6 +102,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { private final JavaSparkContext sparkContext; private final Table table; + private final Supplier fileIO; private final SparkSession spark; private final SparkReadConf readConf; private final boolean caseSensitive; @@ -115,6 +117,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { SparkScan( SparkSession spark, Table table, + Supplier fileIO, SparkReadConf readConf, Schema expectedSchema, List filters, @@ -125,6 +128,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { this.spark = spark; this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); this.table = table; + this.fileIO = fileIO; this.readConf = readConf; this.caseSensitive = readConf.caseSensitive(); this.expectedSchema = expectedSchema; @@ -162,13 +166,20 @@ protected Types.StructType groupingKeyType() { @Override public Batch toBatch() { return new SparkBatch( - sparkContext, table, readConf, groupingKeyType(), taskGroups(), expectedSchema, hashCode()); + sparkContext, + table, + fileIO, + readConf, + groupingKeyType(), + taskGroups(), + expectedSchema, + hashCode()); } @Override public MicroBatchStream toMicroBatchStream(String checkpointLocation) { return new SparkMicroBatchStream( - sparkContext, table, readConf, expectedSchema, checkpointLocation); + sparkContext, table, fileIO, readConf, expectedSchema, checkpointLocation); } @Override @@ -345,8 +356,17 @@ public CustomMetric[] supportedCustomMetrics() { protected long adjustSplitSize(List tasks, long splitSize) { if (readConf.splitSizeOption() == null && readConf.adaptiveSplitSizeEnabled()) { long scanSize = tasks.stream().mapToLong(ScanTask::sizeBytes).sum(); - int parallelism = readConf.parallelism(); - return TableScanUtil.adjustSplitSize(scanSize, parallelism, splitSize); + int parallelism = readConf.splitParallelism(); + long adjustedSplitSize = TableScanUtil.adjustSplitSize(scanSize, parallelism, splitSize); + if (adjustedSplitSize != splitSize) { + LOG.debug( + "Adjusted split size from {} to {} for table {} with parallelism {}", + splitSize, + adjustedSplitSize, + table().name(), + parallelism); + } + return adjustedSplitSize; } else { return splitSize; } diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java index 11b0ba58af51..8b75906a628f 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java @@ -307,16 +307,16 @@ private boolean metricsModeSupportsAggregatePushDown(List> colName); return false; } - } else if (mode instanceof MetricsModes.Truncate) { - // lower_bounds and upper_bounds may be truncated, so disable push down - if (aggregate.type().typeId() == Type.TypeID.STRING) { - if (aggregate.op() == Expression.Operation.MAX - || aggregate.op() == Expression.Operation.MIN) { - LOG.info( - "Skipping aggregate pushdown: Cannot produce min or max from truncated values for column {}", - colName); - return false; - } + } else if (aggregate.type().typeId() == Type.TypeID.STRING + || aggregate.type().typeId() == Type.TypeID.BINARY) { + // lower_bounds and upper_bounds may have been truncated before, so disable push down + // regardless of the current mode + if (aggregate.op() == Expression.Operation.MAX + || aggregate.op() == Expression.Operation.MIN) { + LOG.info( + "Skipping aggregate pushdown: Cannot produce min or max from truncated values for column {}", + colName); + return false; } } } @@ -760,7 +760,7 @@ public StructType readSchema() { } private BatchScan newBatchScan() { - if (table instanceof BaseTable && readConf.distributedPlanningEnabled()) { + if (readConf.distributedPlanningEnabled()) { return new SparkDistributedDataScan(spark, table, readConf); } else { return table.newBatchScan(); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java index d2eb4e5a56e9..99e0deabb0fb 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java @@ -41,7 +41,7 @@ class SparkStagedScan extends SparkScan { private List> taskGroups = null; // lazy cache of tasks SparkStagedScan(SparkSession spark, Table table, Schema expectedSchema, SparkReadConf readConf) { - super(spark, table, readConf, expectedSchema, ImmutableList.of(), null); + super(spark, table, table::io, readConf, expectedSchema, ImmutableList.of(), null); this.taskSetId = readConf.scanTaskSetId(); this.splitSize = readConf.splitSize(); this.splitLookback = readConf.splitLookback(); diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java index 353566eb7f34..1348afff6475 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.util.Map; +import java.util.Objects; import java.util.Set; import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.BaseTable; @@ -56,6 +57,7 @@ import org.apache.iceberg.spark.CommitMetadata; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSQLProperties; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkTableUtil; import org.apache.iceberg.spark.SparkUtil; @@ -333,11 +335,31 @@ public boolean canDeleteWhere(Predicate[] predicates) { } } - return canDeleteUsingMetadata(deleteExpr); + return canDeleteUsingMetadata(deleteExpr, scanBranchForDelete()); + } + + // Resolves the branch to scan during canDeleteWhere so it matches the branch deleteWhere + // will commit to. Falls back to main when WAP is configured but the WAP branch does not + // exist yet, since this is a read scan. + private String scanBranchForDelete() { + if (branch != null) { + return branch; + } + + if (!SparkTableUtil.wapEnabled(table())) { + return null; + } + + String wapBranch = sparkSession().conf().get(SparkSQLProperties.WAP_BRANCH, null); + if (wapBranch != null && table().refs().containsKey(wapBranch)) { + return wapBranch; + } + + return null; } // a metadata delete is possible iff matching files can be deleted entirely - private boolean canDeleteUsingMetadata(Expression deleteExpr) { + private boolean canDeleteUsingMetadata(Expression deleteExpr, String scanBranch) { boolean caseSensitive = SparkUtil.caseSensitive(sparkSession()); if (ExpressionUtil.selectsPartitions(deleteExpr, table(), caseSensitive)) { @@ -352,14 +374,14 @@ private boolean canDeleteUsingMetadata(Expression deleteExpr) { .includeColumnStats() .ignoreResiduals(); - if (branch != null) { - scan = scan.useRef(branch); + if (scanBranch != null) { + scan = scan.useRef(scanBranch); } try (CloseableIterable tasks = scan.planFiles()) { Map evaluators = Maps.newHashMap(); StrictMetricsEvaluator metricsEvaluator = - new StrictMetricsEvaluator(SnapshotUtil.schemaFor(table(), branch), deleteExpr); + new StrictMetricsEvaluator(SnapshotUtil.schemaFor(table(), scanBranch), deleteExpr); return Iterables.all( tasks, @@ -396,12 +418,13 @@ public void deleteWhere(Predicate[] predicates) { .set("spark.app.id", sparkSession().sparkContext().applicationId()) .deleteFromRowFilter(deleteExpr); + String writeBranch = branch; if (SparkTableUtil.wapEnabled(table())) { - branch = SparkTableUtil.determineWriteBranch(sparkSession(), branch); + writeBranch = SparkTableUtil.determineWriteBranch(sparkSession(), branch); } - if (branch != null) { - deleteFiles.toBranch(branch); + if (writeBranch != null) { + deleteFiles.toBranch(writeBranch); } if (!CommitMetadata.commitProperties().isEmpty()) { @@ -424,15 +447,16 @@ public boolean equals(Object other) { return false; } - // use only name in order to correctly invalidate Spark cache SparkTable that = (SparkTable) other; - return icebergTable.name().equals(that.icebergTable.name()); + return icebergTable.name().equals(that.icebergTable.name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && Objects.equals(snapshotId, that.snapshotId) + && Objects.equals(branch, that.branch); } @Override public int hashCode() { - // use only name in order to correctly invalidate Spark cache - return icebergTable.name().hashCode(); + return Objects.hash(icebergTable.name(), table().uuid(), snapshotId, branch); } private static CaseInsensitiveStringMap addSnapshotId( diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java index 15c70e4a6621..aff8864b6d2a 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java @@ -193,6 +193,7 @@ private WriterFactory createWriterFactory() { // broadcast the table metadata as the writer factory will be sent to executors Broadcast
    tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + int sortOrderId = writeConf.outputSortOrderId(writeRequirements); return new WriterFactory( tableBroadcast, queryId, @@ -202,7 +203,8 @@ private WriterFactory createWriterFactory() { writeSchema, dsSchema, useFanoutWriter, - writeProperties); + writeProperties, + sortOrderId); } private void commitOperation(SnapshotUpdate operation, String description) { @@ -672,6 +674,7 @@ private static class WriterFactory implements DataWriterFactory, StreamingDataWr private final boolean useFanoutWriter; private final String queryId; private final Map writeProperties; + private final int sortOrderId; protected WriterFactory( Broadcast
    tableBroadcast, @@ -682,7 +685,8 @@ protected WriterFactory( Schema writeSchema, StructType dsSchema, boolean useFanoutWriter, - Map writeProperties) { + Map writeProperties, + int sortOrderId) { this.tableBroadcast = tableBroadcast; this.format = format; this.outputSpecId = outputSpecId; @@ -692,6 +696,7 @@ protected WriterFactory( this.useFanoutWriter = useFanoutWriter; this.queryId = queryId; this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; } @Override @@ -716,6 +721,7 @@ public DataWriter createWriter(int partitionId, long taskId, long e .dataSchema(writeSchema) .dataSparkType(dsSchema) .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) .build(); if (spec.isUnpartitioned()) { diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java index e608a40b72ad..df4566da0c90 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java @@ -27,27 +27,27 @@ import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkFilters; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.spark.SparkV2Filters; import org.apache.iceberg.spark.SparkWriteConf; import org.apache.iceberg.spark.SparkWriteRequirements; import org.apache.iceberg.types.TypeUtil; import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.Scan; import org.apache.spark.sql.connector.write.BatchWrite; import org.apache.spark.sql.connector.write.LogicalWriteInfo; import org.apache.spark.sql.connector.write.RowLevelOperation.Command; import org.apache.spark.sql.connector.write.SupportsDynamicOverwrite; -import org.apache.spark.sql.connector.write.SupportsOverwrite; +import org.apache.spark.sql.connector.write.SupportsOverwriteV2; import org.apache.spark.sql.connector.write.Write; import org.apache.spark.sql.connector.write.WriteBuilder; import org.apache.spark.sql.connector.write.streaming.StreamingWrite; -import org.apache.spark.sql.sources.Filter; import org.apache.spark.sql.types.LongType$; import org.apache.spark.sql.types.StructType; -class SparkWriteBuilder implements WriteBuilder, SupportsDynamicOverwrite, SupportsOverwrite { +class SparkWriteBuilder implements WriteBuilder, SupportsDynamicOverwrite, SupportsOverwriteV2 { private final SparkSession spark; private final Table table; private final SparkWriteConf writeConf; @@ -100,12 +100,12 @@ public WriteBuilder overwriteDynamicPartitions() { } @Override - public WriteBuilder overwrite(Filter[] filters) { + public WriteBuilder overwrite(Predicate[] predicates) { Preconditions.checkState( !overwriteFiles, "Cannot overwrite individual files and using filters"); Preconditions.checkState(rewrittenFileSetId == null, "Cannot overwrite and rewrite"); - this.overwriteExpr = SparkFilters.convert(filters); + this.overwriteExpr = SparkV2Filters.convert(predicates); if (overwriteExpr == Expressions.alwaysTrue() && "dynamic".equals(overwriteMode)) { // use the write option to override truncating the table. use dynamic overwrite instead. this.overwriteDynamic = true; diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..f1b0029c5432 --- /dev/null +++ b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.MicroBatches.MicroBatch; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(SyncSparkMicroBatchPlanner.class); + + private final boolean caseSensitive; + private final long fromTimestamp; + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + SyncSparkMicroBatchPlanner( + Table table, SparkReadConf readConf, StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.caseSensitive = readConf().caseSensitive(); + this.fromTimestamp = readConf().streamFromTimestamp(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + } + + @Override + public List planFiles(StreamingOffset startOffset, StreamingOffset endOffset) { + List fileScanTasks = Lists.newArrayList(); + StreamingOffset batchStartOffset = + StreamingOffset.START_OFFSET.equals(startOffset) + ? MicroBatchUtils.determineStartingOffset(table(), fromTimestamp) + : startOffset; + + StreamingOffset currentOffset = null; + + // [(startOffset : startFileIndex), (endOffset : endFileIndex) ) + do { + long endFileIndex; + if (currentOffset == null) { + currentOffset = batchStartOffset; + } else { + Snapshot snapshotAfter = SnapshotUtil.snapshotAfter(table(), currentOffset.snapshotId()); + // it may happen that we need to read this snapshot partially in case it's equal to + // endOffset. + if (currentOffset.snapshotId() != endOffset.snapshotId()) { + currentOffset = new StreamingOffset(snapshotAfter.snapshotId(), 0L, false); + } else { + currentOffset = endOffset; + } + } + + Snapshot snapshot = table().snapshot(currentOffset.snapshotId()); + + validateCurrentSnapshotExists(snapshot, currentOffset); + + if (!shouldProcess(snapshot)) { + LOG.debug("Skipping snapshot: {} of table {}", currentOffset.snapshotId(), table().name()); + continue; + } + + Snapshot currentSnapshot = table().snapshot(currentOffset.snapshotId()); + if (currentOffset.snapshotId() == endOffset.snapshotId()) { + endFileIndex = endOffset.position(); + } else { + endFileIndex = MicroBatchUtils.addedFilesCount(table(), currentSnapshot); + } + + MicroBatch latestMicroBatch = + MicroBatches.from(currentSnapshot, table().io()) + .caseSensitive(caseSensitive) + .specsById(table().specs()) + .generate( + currentOffset.position(), + endFileIndex, + Long.MAX_VALUE, + currentOffset.shouldScanAllFiles()); + + fileScanTasks.addAll(latestMicroBatch.tasks()); + } while (currentOffset.snapshotId() != endOffset.snapshotId()); + + return fileScanTasks; + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + table().refresh(); + if (table().currentSnapshot() == null) { + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < fromTimestamp) { + return StreamingOffset.START_OFFSET; + } + + // end offset can expand to multiple snapshots + StreamingOffset startingOffset = startOffset; + + if (startOffset.equals(StreamingOffset.START_OFFSET)) { + startingOffset = MicroBatchUtils.determineStartingOffset(table(), fromTimestamp); + } + + Snapshot curSnapshot = table().snapshot(startingOffset.snapshotId()); + validateCurrentSnapshotExists(curSnapshot, startingOffset); + + // Use the pre-computed snapshotId when Trigger.AvailableNow is enabled. + long latestSnapshotId = + lastOffsetForTriggerAvailableNow != null + ? lastOffsetForTriggerAvailableNow.snapshotId() + : table().currentSnapshot().snapshotId(); + + int startPosOfSnapOffset = (int) startingOffset.position(); + + boolean scanAllFiles = startingOffset.shouldScanAllFiles(); + + boolean shouldContinueReading = true; + int curFilesAdded = 0; + long curRecordCount = 0; + int curPos = 0; + + // Extract limits once to avoid repeated calls in tight loop + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long maxFiles = unpackedLimits.getMaxFiles(); + long maxRows = unpackedLimits.getMaxRows(); + + // Note : we produce nextOffset with pos as non-inclusive + while (shouldContinueReading) { + // generate manifest index for the curSnapshot + List> indexedManifests = + MicroBatches.skippedManifestIndexesFromSnapshot( + table().io(), curSnapshot, startPosOfSnapOffset, scanAllFiles); + // this is under assumption we will be able to add at-least 1 file in the new offset + for (int idx = 0; idx < indexedManifests.size() && shouldContinueReading; idx++) { + // be rest assured curPos >= startFileIndex + curPos = indexedManifests.get(idx).second(); + try (CloseableIterable taskIterable = + MicroBatches.openManifestFile( + table().io(), + table().specs(), + caseSensitive, + curSnapshot, + indexedManifests.get(idx).first(), + scanAllFiles); + CloseableIterator taskIter = taskIterable.iterator()) { + while (taskIter.hasNext()) { + FileScanTask task = taskIter.next(); + if (curPos >= startPosOfSnapOffset) { + if ((curFilesAdded + 1) > maxFiles) { + // On including the file it might happen that we might exceed, the configured + // soft limit on the number of records, since this is a soft limit its acceptable. + shouldContinueReading = false; + break; + } + + curFilesAdded += 1; + curRecordCount += task.file().recordCount(); + + if (curRecordCount >= maxRows) { + // we included the file, so increment the number of files + // read in the current snapshot. + if (curFilesAdded == 1 && curRecordCount > maxRows) { + LOG.warn( + "File {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + task.file().location(), + task.file().recordCount(), + maxRows); + } + ++curPos; + shouldContinueReading = false; + break; + } + } + ++curPos; + } + } catch (IOException ioe) { + LOG.warn("Failed to close task iterable", ioe); + } + } + // if the currentSnapShot was also the latestSnapshot then break + if (curSnapshot.snapshotId() == latestSnapshotId) { + break; + } + + // if everything was OK and we consumed complete snapshot then move to next snapshot + if (shouldContinueReading) { + Snapshot nextValid = nextValidSnapshot(curSnapshot); + if (nextValid == null) { + // nextValid implies all the remaining snapshots should be skipped. + break; + } + // we found the next available snapshot, continue from there. + curSnapshot = nextValid; + startPosOfSnapOffset = -1; + // if anyhow we are moving to next snapshot we should only scan addedFiles + scanAllFiles = false; + } + } + + StreamingOffset latestStreamingOffset = + new StreamingOffset(curSnapshot.snapshotId(), curPos, scanAllFiles); + + // if no new data arrived, then return null. + return latestStreamingOffset.equals(startingOffset) ? null : latestStreamingOffset; + } + + @Override + public void stop() {} + + private void validateCurrentSnapshotExists(Snapshot snapshot, StreamingOffset currentOffset) { + if (snapshot == null) { + throw new IllegalStateException( + String.format( + Locale.ROOT, + "Cannot load current offset at snapshot %d, the snapshot was expired or removed", + currentOffset.snapshotId())); + } + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java index 404ba7284606..9b08d6f7ab1e 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.BeforeEach; @@ -90,6 +91,7 @@ protected static SparkSession initSpark(String serializer) { .master("local[2]") .config("spark.serializer", serializer) .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java index 659507e4c5e3..e28603c0b43a 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -73,6 +74,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java index a218f965ea65..2967f0e22cec 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java @@ -23,6 +23,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -61,6 +62,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config(TestBase.DISABLE_UI) .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java index 2665d7ba8d3b..4f789d2c5ae9 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -63,6 +64,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java index dac09fea7562..1090245326fb 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java @@ -58,7 +58,8 @@ public static void validateDataManifest( List actualSnapshotIds = Lists.newArrayList(); List actualFiles = Lists.newArrayList(); - for (ManifestEntry entry : ManifestFiles.read(manifest, table.io()).entries()) { + for (ManifestEntry entry : + ManifestFiles.read(manifest, table.io(), table.specs()).entries()) { actualDataSeqs.add(entry.dataSequenceNumber()); actualFileSeqs.add(entry.fileSequenceNumber()); actualSnapshotIds.add(entry.snapshotId()); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java new file mode 100644 index 000000000000..ee1f29e56fb3 --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import com.codahale.metrics.MetricRegistry; +import java.util.Properties; +import org.apache.spark.SparkConf; +import org.apache.spark.metrics.sink.MetricsServlet; +import org.sparkproject.jetty.servlet.ServletContextHandler; + +/** + * A dummy implementation of {@link MetricsServlet} that does not start a server or report metrics. + * This is used in tests to avoid conflicts with Spark's jetty dependencies. + */ +public class DummyMetricsServlet extends MetricsServlet { + + /** + * Constructor required by Spark's reflection-based instantiation. + * + * @param properties Metrics properties + * @param registry Metric registry + */ + public DummyMetricsServlet(Properties properties, MetricRegistry registry) { + super(properties, registry); + } + + @Override + public ServletContextHandler[] getHandlers(SparkConf conf) { + return new ServletContextHandler[] {}; + } + + @Override + public void start() { + // No-op for tests + } + + @Override + public void stop() { + // No-op for tests + } + + @Override + public void report() { + // No-op for tests + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java index 1d96d6f93959..2350aab09b64 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java @@ -42,11 +42,14 @@ public enum SparkCatalogConfig { "spark_catalog", SparkSessionCatalog.class.getName(), ImmutableMap.of( - "type", "hive", - "default-namespace", "default", - "parquet-enabled", "true", + "type", + "hive", + "default-namespace", + "default", + "parquet-enabled", + "true", "cache-enabled", - "false" // Spark will delete tables using v1, leaving the cache out of sync + "false" // Spark will delete tables using v1, leaving the cache out of sync )), SPARK_WITH_VIEWS( "spark_with_views", @@ -61,7 +64,11 @@ public enum SparkCatalogConfig { SPARK_SESSION_WITH_VIEWS( "spark_catalog", SparkSessionCatalog.class.getName(), - ImmutableMap.of("type", "rest", "default-namespace", "default", "cache-enabled", "false")); + ImmutableMap.of("type", "rest", "default-namespace", "default", "cache-enabled", "false")), + SPARK_WITH_HIVE_VIEWS( + "spark_hive_with_views", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hive", "default-namespace", "default", "cache-enabled", "false")); private final String catalogName; private final String implementation; diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBase.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBase.java index daf4e29ac075..5e7e1a1f6193 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBase.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBase.java @@ -65,6 +65,13 @@ public abstract class TestBase extends SparkTestHelperBase { protected static SparkSession spark = null; protected static JavaSparkContext sparkContext = null; protected static HiveCatalog catalog = null; + // disable Spark UI and use dummy servlet to avoid dependency conflicts with Spark's Jetty version + public static final Map DISABLE_UI = + ImmutableMap.of( + "spark.ui.enabled", + "false", + "spark.metrics.conf.*.sink.servlet.class", + "org.apache.iceberg.spark.DummyMetricsServlet"); @BeforeAll public static void startMetastoreAndSpark() { @@ -79,6 +86,7 @@ public static void startMetastoreAndSpark() { .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config(DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java index 7df9c75fb3dd..1760143d2cb1 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java @@ -69,7 +69,11 @@ public abstract class TestBaseWithCatalog extends TestBase { // status even belonging to the same catalog. Reference: // https://www.sqlite.org/inmemorydb.html CatalogProperties.CLIENT_POOL_SIZE, - "1")); + "1", + "include-credentials", + "true", + "gcs.oauth2.token", + "dummyToken")); protected static RESTCatalog restCatalog; diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java new file mode 100644 index 000000000000..c3fc69c8b25c --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReadConf extends TestBaseWithCatalog { + + @BeforeEach + public void before() { + super.before(); + sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName); + } + + @AfterEach + public void after() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSplitParallelismDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(conf.parallelism()); + } + + @TestTemplate + public void testSplitParallelismSessionConf() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "999", + SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, + "42"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(42); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsZero() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "0"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsNegative() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "-5"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java index 4045847d5a4a..b8f436cf2d86 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java @@ -24,10 +24,12 @@ import java.util.List; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; +import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.expressions.AttributeReference; import org.apache.spark.sql.catalyst.expressions.MetadataAttribute; import org.apache.spark.sql.catalyst.types.DataTypeUtils; +import org.apache.spark.sql.types.DataTypes; import org.apache.spark.sql.types.StructType; import org.junit.jupiter.api.Test; @@ -80,4 +82,18 @@ public void testSchemaConversionWithMetaDataColumnSchema() { } } } + + @Test + public void testUnknownTypeToSpark() { + Schema schema = new Schema(optional(1, "col", Types.UnknownType.get())); + StructType sparkType = SparkSchemaUtil.convert(schema); + assertThat(sparkType.fields()[0].dataType()).isEqualTo(DataTypes.NullType); + } + + @Test + public void testNullTypeToIceberg() { + StructType sparkType = new StructType().add("col", DataTypes.NullType, true); + Type icebergType = SparkSchemaUtil.convert(sparkType).findField("col").type(); + assertThat(icebergType).isEqualTo(Types.UnknownType.get()); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java index a9b5d1a237b4..89daf195ca73 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java @@ -45,6 +45,7 @@ import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE; import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Duration; @@ -552,6 +553,51 @@ public void testDVWriteConf() { assertThat(writeConf.deleteFileFormat()).isEqualTo(FileFormat.PUFFIN); } + @TestTemplate + public void testSortOrderWriteConf() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConf = + new SparkWriteConf( + spark, table, ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "1")); + + assertThat(writeConf.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .isEqualTo(table.sortOrder().orderId()); + } + + @TestTemplate + public void testSortOrderWriteConfWithInvalidId() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfForUnknownSortOrder = + new SparkWriteConf( + spark, table, ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "999")); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> writeConfForUnknownSortOrder.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .withMessage( + "Cannot use output sort order id 999 because the table does not contain a sort order with that id"); + } + + @TestTemplate + public void testSortOrderWriteConfWithNoOption() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfNoOption = new SparkWriteConf(spark, table, ImmutableMap.of()); + + assertThat(writeConfNoOption.outputSortOrderId(writeConfNoOption.writeRequirements())) + .isEqualTo(table.sortOrder().orderId()); + + assertThat(writeConfNoOption.outputSortOrderId(SparkWriteRequirements.EMPTY)).isEqualTo(0); + } + private void testWriteProperties(List> propertiesSuite) { withSQLConf( propertiesSuite.get(0), diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java index 303411eb7ddd..9ca6a9e57b8c 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java @@ -23,12 +23,9 @@ import java.io.IOException; import java.util.List; -import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionStatistics; import org.apache.iceberg.PartitionStatisticsFile; -import org.apache.iceberg.PartitionStats; -import org.apache.iceberg.PartitionStatsHandler; import org.apache.iceberg.Partitioning; -import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; @@ -120,10 +117,9 @@ public void partitionStatsComputeOnLatestSnapshot() throws IOException { assertThat(table.partitionStatisticsFiles()).containsExactly(statisticsFile); Types.StructType partitionType = Partitioning.partitionType(table); - Schema dataSchema = PartitionStatsHandler.schema(partitionType, 2); validatePartitionStats( - statisticsFile, - dataSchema, + table, + statisticsFile.snapshotId(), Tuple.tuple( partitionRecord(partitionType, "foo", "A"), DEFAULT_SPEC_ID, @@ -209,11 +205,10 @@ public void partitionStatsComputeOnSnapshot() throws IOException { assertThat(table.partitionStatisticsFiles()).containsExactly(statisticsFile); Types.StructType partitionType = Partitioning.partitionType(table); - Schema dataSchema = PartitionStatsHandler.schema(partitionType, 2); // should contain stats for only partitions of snapshot1 (no entry for partition bar, A) validatePartitionStats( - statisticsFile, - dataSchema, + table, + statisticsFile.snapshotId(), Tuple.tuple( partitionRecord(partitionType, "foo", "A"), DEFAULT_SPEC_ID, @@ -272,31 +267,28 @@ private void createPartitionedTableV1() { tableName); } - private void validatePartitionStats( - PartitionStatisticsFile result, Schema recordSchema, Tuple... expectedValues) + private void validatePartitionStats(Table table, long snapshotId, Tuple... expectedValues) throws IOException { - // read the partition entries from the stats file - List partitionStats; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - recordSchema, Files.localInput(result.path()))) { + List partitionStats; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()) { partitionStats = Lists.newArrayList(recordIterator); } assertThat(partitionStats) .extracting( - PartitionStats::partition, - PartitionStats::specId, - PartitionStats::dataRecordCount, - PartitionStats::dataFileCount, - PartitionStats::totalDataFileSizeInBytes, - PartitionStats::positionDeleteRecordCount, - PartitionStats::positionDeleteFileCount, - PartitionStats::equalityDeleteRecordCount, - PartitionStats::equalityDeleteFileCount, - PartitionStats::totalRecords, - PartitionStats::lastUpdatedAt, - PartitionStats::lastUpdatedSnapshotId) + PartitionStatistics::partition, + PartitionStatistics::specId, + PartitionStatistics::dataRecordCount, + PartitionStatistics::dataFileCount, + PartitionStatistics::totalDataFileSizeInBytes, + PartitionStatistics::positionDeleteRecordCount, + PartitionStatistics::positionDeleteFileCount, + PartitionStatistics::equalityDeleteRecordCount, + PartitionStatistics::equalityDeleteFileCount, + PartitionStatistics::totalRecords, + PartitionStatistics::lastUpdatedAt, + PartitionStatistics::lastUpdatedSnapshotId) .containsExactlyInAnyOrder(expectedValues); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java index eb89b0a23274..50afb53e0539 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java @@ -662,7 +662,7 @@ public void schemaEvolutionTestWithSparkSQL() throws Exception { "CAST(id AS FLOAT) col1", "CAST(id AS STRING) col2", "CAST(id AS INT) col3") - .registerTempTable("tempdata"); + .createOrReplaceTempView("tempdata"); sql("INSERT INTO TABLE %s SELECT * FROM tempdata", tblName); List expectedBeforeAddColumn = sql("SELECT * FROM %s ORDER BY col0", tblName); List expectedAfterAddColumn = diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java index 7e07c66e0650..c89e4f7852d5 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java @@ -47,6 +47,7 @@ import org.apache.iceberg.ReachableFileUtil; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableProperties; @@ -628,8 +629,10 @@ public void testWithExpiringDanglingStageCommit() { expectedDeletes.add(snapshotA.manifestListLocation()); // Files should be deleted of dangling staged snapshot - snapshotB - .addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(snapshotB) + .build() + .addedDataFiles() .forEach( i -> { expectedDeletes.add(i.location()); @@ -699,7 +702,10 @@ public void testWithCherryPickTableSnapshot() { Lists.newArrayList(snapshotB, snapshotC, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -748,7 +754,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -768,7 +777,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java index 76084c2b9402..4df99ca1998b 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java @@ -77,7 +77,7 @@ public class TestRemoveDanglingDeleteAction extends TestBase { .build(); static final DataFile FILE_A2 = DataFiles.builder(SPEC) - .withPath("/path/to/data-a.parquet") + .withPath("/path/to/data-a2.parquet") .withFileSizeInBytes(10) .withPartitionPath("c1=a") // easy way to set partition data for now .withRecordCount(1) @@ -91,7 +91,7 @@ public class TestRemoveDanglingDeleteAction extends TestBase { .build(); static final DataFile FILE_B2 = DataFiles.builder(SPEC) - .withPath("/path/to/data-b.parquet") + .withPath("/path/to/data-b2.parquet") .withFileSizeInBytes(10) .withPartitionPath("c1=b") // easy way to set partition data for now .withRecordCount(1) @@ -105,7 +105,7 @@ public class TestRemoveDanglingDeleteAction extends TestBase { .build(); static final DataFile FILE_C2 = DataFiles.builder(SPEC) - .withPath("/path/to/data-c.parquet") + .withPath("/path/to/data-c2.parquet") .withFileSizeInBytes(10) .withPartitionPath("c1=c") // easy way to set partition data for now .withRecordCount(1) @@ -119,7 +119,7 @@ public class TestRemoveDanglingDeleteAction extends TestBase { .build(); static final DataFile FILE_D2 = DataFiles.builder(SPEC) - .withPath("/path/to/data-d.parquet") + .withPath("/path/to/data-d2.parquet") .withFileSizeInBytes(10) .withPartitionPath("c1=d") // easy way to set partition data for now .withRecordCount(1) @@ -370,7 +370,6 @@ public void testPartitionedDeletesWithEqSeqNo() { // Add Data Files with EQ and POS deletes DeleteFile fileADeletes = fileADeletes(); DeleteFile fileA2Deletes = fileA2Deletes(); - DeleteFile fileBDeletes = fileBDeletes(); DeleteFile fileB2Deletes = fileB2Deletes(); table .newRowDelta() @@ -382,7 +381,6 @@ public void testPartitionedDeletesWithEqSeqNo() { .addDeletes(fileA2Deletes) .addDeletes(FILE_A_EQ_DELETES) .addDeletes(FILE_A2_EQ_DELETES) - .addDeletes(fileBDeletes) .addDeletes(fileB2Deletes) .addDeletes(FILE_B_EQ_DELETES) .addDeletes(FILE_B2_EQ_DELETES) @@ -400,7 +398,6 @@ public void testPartitionedDeletesWithEqSeqNo() { Tuple2.apply(2L, FILE_A2_EQ_DELETES.location()), Tuple2.apply(2L, fileA2Deletes.location()), Tuple2.apply(2L, FILE_B_EQ_DELETES.location()), - Tuple2.apply(2L, fileBDeletes.location()), Tuple2.apply(2L, FILE_B2.location()), Tuple2.apply(2L, FILE_B2_EQ_DELETES.location()), Tuple2.apply(2L, fileB2Deletes.location()), @@ -433,7 +430,6 @@ public void testPartitionedDeletesWithEqSeqNo() { Tuple2.apply(2L, FILE_A2.location()), Tuple2.apply(2L, FILE_A2_EQ_DELETES.location()), Tuple2.apply(2L, fileA2Deletes.location()), - Tuple2.apply(2L, fileBDeletes.location()), Tuple2.apply(2L, FILE_B2.location()), Tuple2.apply(2L, fileB2Deletes.location()), Tuple2.apply(2L, FILE_C2.location()), diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java index 40505b856737..0d2a5c0a4daf 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java @@ -184,6 +184,9 @@ public void testDryRun() throws IOException { assertThat(result1.orphanFileLocations()) .as("Default olderThan interval should be safe") .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); DeleteOrphanFiles.Result result2 = actions @@ -195,6 +198,9 @@ public void testDryRun() throws IOException { assertThat(result2.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFiles); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -210,6 +216,9 @@ public void testDryRun() throws IOException { assertThat(result3.orphanFileLocations()) .as("Streaming dry run should find 1 file") .isEqualTo(invalidFiles); + assertThat(result3.orphanFilesCount()) + .as("Streaming dry run should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present after streaming dry run") .isTrue(); @@ -223,6 +232,9 @@ public void testDryRun() throws IOException { assertThat(result4.orphanFileLocations()) .as("Action should delete 1 file") .isEqualTo(invalidFiles); + assertThat(result4.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should not be present") .isFalse(); @@ -286,6 +298,7 @@ public void testAllValidFilesAreKept() throws IOException { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 4 files").hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); Path dataPath = new Path(tableLocation + "/data"); FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); @@ -366,6 +379,7 @@ public void orphanedFileRemovedWithParallelTasks() { .containsExactlyInAnyOrder( "remove-orphan-0", "remove-orphan-1", "remove-orphan-2", "remove-orphan-3"); assertThat(deletedFiles).hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); } @TestTemplate @@ -410,6 +424,7 @@ public void testWapFilesAreKept() { actions.deleteOrphanFiles(table).olderThan(System.currentTimeMillis()).execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); } @TestTemplate @@ -440,6 +455,7 @@ public void testMetadataFolderIsIntact() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); List actualRecords = @@ -478,6 +494,7 @@ public void testOlderThanTimestamp() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete only 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete only 2 files").isEqualTo(2L); } @TestTemplate @@ -509,6 +526,7 @@ public void testRemoveUnreachableMetadataVersionFiles() { assertThat(result.orphanFileLocations()) .containsExactly(tableLocation + "metadata/v1.metadata.json"); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); List expectedRecords = Lists.newArrayList(); expectedRecords.addAll(records); @@ -545,6 +563,7 @@ public void testManyTopLevelPartitions() { .execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); assertThat(resultDF.count()).as("Rows count must match").isEqualTo(records.size()); @@ -575,6 +594,7 @@ public void testManyLeafPartitions() { .execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); assertThat(resultDF.count()).as("Row count must match").isEqualTo(records.size()); @@ -615,6 +635,7 @@ public void testHiddenPartitionPaths() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); } @TestTemplate @@ -655,6 +676,7 @@ public void testHiddenPartitionPathsWithPartitionEvolution() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); } @TestTemplate @@ -694,6 +716,7 @@ public void testHiddenPathsStartingWithPartitionNamesAreIgnored() throws IOExcep .execute(); assertThat(result.orphanFileLocations()).as("Should delete 0 files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should delete 0 files").isEqualTo(0L); assertThat(fs.exists(pathToFileInHiddenFolder)).isTrue(); } @@ -766,6 +789,9 @@ public void testRemoveOrphanFilesWithRelativeFilePath() throws IOException { assertThat(result.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFiles); + assertThat(result.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -803,6 +829,7 @@ public void testRemoveOrphanFilesWithHadoopCatalog() throws InterruptedException .execute(); assertThat(result.orphanFileLocations()).as("Should delete only 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete only 1 file").isEqualTo(1L); Dataset resultDF = spark.read().format("iceberg").load(table.location()); List actualRecords = @@ -838,6 +865,7 @@ public void testHiveCatalogTable() throws IOException { assertThat(result.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + "/data/trashfile"); + assertThat(result.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -932,6 +960,9 @@ public void testCompareToFileList() throws IOException { assertThat(result1.orphanFileLocations()) .as("Default olderThan interval should be safe") .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); DeleteOrphanFiles.Result result2 = actions @@ -943,6 +974,9 @@ public void testCompareToFileList() throws IOException { assertThat(result2.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFilePaths); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFilePaths.size()); assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -956,6 +990,9 @@ public void testCompareToFileList() throws IOException { assertThat(result3.orphanFileLocations()) .as("Action should delete 1 file") .isEqualTo(invalidFilePaths); + assertThat(result3.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFilePaths.size()); assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) .as("Invalid file should not be present") .isFalse(); @@ -985,6 +1022,7 @@ public void testCompareToFileList() throws IOException { .deleteWith(s -> {}) .execute(); assertThat(result4.orphanFileLocations()).as("Action should find nothing").isEmpty(); + assertThat(result4.orphanFilesCount()).as("Action should find nothing").isEqualTo(0L); } protected long waitUntilAfter(long timestampMillis) { @@ -1064,6 +1102,7 @@ public void testRemoveOrphanFilesWithStatisticFiles() throws Exception { .execute(); Iterable orphanFileLocations = result.orphanFileLocations(); assertThat(orphanFileLocations).hasSize(1).containsExactly(statsLocation.toURI().toString()); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); assertThat(statsLocation).as("stats file should be deleted").doesNotExist(); } @@ -1282,6 +1321,9 @@ public void testStreamResultsDeletion() throws IOException { .as("Non-streaming dry-run should return all 10 orphan files") .hasSize(10) .containsExactlyInAnyOrderElementsOf(invalidFiles); + assertThat(nonStreamingResult.orphanFilesCount()) + .as("Non-streaming dry-run should return all 10 orphan files") + .isEqualTo((long) invalidFiles.size()); DeleteOrphanFiles.Result streamingResult = SparkActions.get() @@ -1295,6 +1337,9 @@ public void testStreamResultsDeletion() throws IOException { assertThat(streamingResult.orphanFileLocations()) .as("Streaming with sample size 5 should return only 5 orphan files") .hasSize(5); + assertThat(streamingResult.orphanFilesCount()) + .as("Deleted 10 files") + .isEqualTo((long) invalidFiles.size()); for (String invalidFile : invalidFiles) { assertThat(fs.exists(new Path(invalidFile))).as("Orphan file should be deleted").isFalse(); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java index 5f98287951f1..88ac800b158f 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java @@ -60,6 +60,7 @@ public void testSparkCatalogTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -89,6 +90,7 @@ public void testSparkCatalogNamedHadoopTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -119,6 +121,7 @@ public void testSparkCatalogNamedHiveTable() throws Exception { assertThat(StreamSupport.stream(results.orphanFileLocations().spliterator(), false)) .as("trash file should be removed") .anyMatch(file -> file.contains("file:" + location + trashFile)); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -151,6 +154,7 @@ public void testSparkSessionCatalogHadoopTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -183,6 +187,7 @@ public void testSparkSessionCatalogHiveTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @AfterEach diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java index 2b4a2a211ec0..d74d8a29f994 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java @@ -22,6 +22,7 @@ import static org.apache.iceberg.data.FileHelpers.encrypt; import static org.apache.iceberg.types.Types.NestedField.optional; import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.spark.sql.functions.col; import static org.apache.spark.sql.functions.current_date; import static org.apache.spark.sql.functions.date_add; import static org.apache.spark.sql.functions.expr; @@ -71,6 +72,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.SortOrder; import org.apache.iceberg.StructLike; @@ -126,6 +128,7 @@ import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.DataTypes; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; @@ -173,7 +176,6 @@ public void setupTableLocation() { private RewriteDataFilesSparkAction basicRewrite(Table table) { // Always compact regardless of input files - table.refresh(); return actions() .rewriteDataFiles(table) .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1"); @@ -289,6 +291,7 @@ public void testBinPackAfterPartitionChange() { Table table = createTable(); writeRecords(20, SCALE, 20); + table.refresh(); shouldHaveFiles(table, 20); table.updateSpec().addField(Expressions.ref("c1")).commit(); @@ -362,7 +365,6 @@ public void testDataFilesRewrittenWithMaxDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).isEmpty(); @@ -412,7 +414,6 @@ public void testDataFilesRewrittenWithHighDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).hasSize(1); @@ -462,7 +463,6 @@ public void testDataFilesNotRewrittenWithLowDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(0); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).hasSameSizeAs(dataFiles); @@ -475,7 +475,6 @@ public void testBinPackWithV2PositionDeletes() throws IOException { assumeThat(formatVersion).isEqualTo(2); Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); @@ -492,7 +491,6 @@ public void testBinPackWithV2PositionDeletes() throws IOException { } rowDelta.commit(); - table.refresh(); List expectedRecords = currentData(); long dataSizeBefore = testDataSize(table); Result result = @@ -519,7 +517,6 @@ public void testBinPackWithDVs() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(3); Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); - table.refresh(); List initialRecords = currentDataWithLineage(); Set rowIds = initialRecords.stream().map(record -> (Long) record[0]).collect(Collectors.toSet()); @@ -551,7 +548,6 @@ public void testBinPackWithDVs() throws IOException { } rowDelta.commit(); - table.refresh(); List recordsWithLineageAfterDelete = currentDataWithLineage(); rowIds.removeAll(rowIdsBeingRemoved); assertThat(rowIds) @@ -630,7 +626,6 @@ public void removeDanglingDVsFromDeleteManifest() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); assertThat(result.removedDeleteFilesCount()).isEqualTo(numDataFiles); - table.refresh(); assertThat(TestHelpers.dataFiles(table)).hasSize(1); assertThat(TestHelpers.deleteFiles(table)).isEmpty(); @@ -681,7 +676,6 @@ public void testRemoveDangledEqualityDeletesPartitionEvolution() { .hasSize(1); // partition evolution - table.refresh(); table.updateSpec().addField(Expressions.ref("c3")).commit(); // data seq = 4, write 2 new data files in both partitions for evolved spec @@ -779,7 +773,6 @@ public void testBinPackWithDeleteAllData() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(2); Table table = createTablePartitioned(1, 1, 1); shouldHaveFiles(table, 1); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); @@ -795,7 +788,6 @@ public void testBinPackWithDeleteAllData() throws IOException { } rowDelta.commit(); - table.refresh(); List expectedRecords = currentData(); long dataSizeBefore = testDataSize(table); @@ -832,7 +824,6 @@ public void testBinPackWithStartingSequenceNumber() { Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); List expectedRecords = currentData(); - table.refresh(); long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); long dataSizeBefore = testDataSize(table); @@ -848,7 +839,6 @@ public void testBinPackWithStartingSequenceNumber() { List actualRecords = currentData(); assertEquals("Rows must match", expectedRecords, actualRecords); - table.refresh(); assertThat(table.currentSnapshot().sequenceNumber()) .as("Table sequence number should be incremented") .isGreaterThan(oldSequenceNumber); @@ -869,7 +859,6 @@ public void testBinPackWithStartingSequenceNumberV1Compatibility() { Table table = createTablePartitioned(4, 2, SCALE, properties); shouldHaveFiles(table, 8); List expectedRecords = currentData(); - table.refresh(); long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); assertThat(oldSequenceNumber).as("Table sequence number should be 0").isZero(); long dataSizeBefore = testDataSize(table); @@ -886,7 +875,6 @@ public void testBinPackWithStartingSequenceNumberV1Compatibility() { List actualRecords = currentData(); assertEquals("Rows must match", expectedRecords, actualRecords); - table.refresh(); assertThat(table.currentSnapshot().sequenceNumber()) .as("Table sequence number should still be 0") .isEqualTo(oldSequenceNumber); @@ -980,6 +968,7 @@ public void testBinPackCombineMixedFiles() { // Add one more small file, and one large file writeRecords(1, SCALE); writeRecords(1, SCALE * 3); + table.refresh(); shouldHaveFiles(table, 3); List expectedRecords = currentData(); @@ -1069,8 +1058,6 @@ public void testPartialProgressEnabled() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - shouldHaveSnapshots(table, 11); shouldHaveACleanCache(table); @@ -1097,8 +1084,6 @@ public void testMultipleGroups() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1126,8 +1111,6 @@ public void testPartialProgressMaxCommits() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1159,8 +1142,6 @@ public void testSingleCommitWithRewriteFailure() { .isInstanceOf(RuntimeException.class) .hasMessage("Rewrite Failed"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1193,8 +1174,6 @@ public void testSingleCommitWithCommitFailure() { .isInstanceOf(RuntimeException.class) .hasMessageContaining("Cannot commit rewrite"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1227,8 +1206,6 @@ public void testCommitFailsWithUncleanableFailure() { .isInstanceOf(RuntimeException.class) .hasMessageContaining("Arbitrary Failure"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1262,8 +1239,6 @@ public void testParallelSingleCommitWithRewriteFailure() { .isInstanceOf(CommitFailedException.class) .hasMessage("Rewrite Failed"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1302,8 +1277,6 @@ public void testPartialProgressWithRewriteFailure() { assertThat(result.failedDataFilesCount()).isEqualTo(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1345,8 +1318,6 @@ public void testParallelPartialProgressWithRewriteFailure() { assertThat(result.failedDataFilesCount()).isEqualTo(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1391,8 +1362,6 @@ public void testParallelPartialProgressWithCommitFailure() { assertThat(result.rewriteResults()).as("Should have 6 fileGroups").hasSize(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1431,8 +1400,6 @@ public void testParallelPartialProgressWithMaxFailedCommits() { .hasMessageContaining( "1 rewrite commits failed. This is more than the maximum allowed failures of 0"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1464,11 +1431,8 @@ public void testParallelPartialProgressWithMaxCommitsLargerThanTotalGroupCount() .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_FAILED_COMMITS, "1"); rewrite.execute(); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); - table.refresh(); assertThat(table.snapshots()) .as("Table did not have the expected number of snapshots") // To tolerate 1 random commit failure @@ -1544,8 +1508,6 @@ public void testSortMultipleGroups() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1554,7 +1516,7 @@ public void testSortMultipleGroups() { } @TestTemplate - public void testSimpleSort() { + public void testSimpleSort() throws IOException { Table table = createTable(20); shouldHaveFiles(table, 20); table.replaceSortOrder().asc("c2").commit(); @@ -1575,8 +1537,6 @@ public void testSimpleSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1584,10 +1544,11 @@ public void testSimpleSort() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); } @TestTemplate - public void testSortAfterPartitionChange() { + public void testSortAfterPartitionChange() throws IOException { Table table = createTable(20); shouldHaveFiles(table, 20); table.updateSpec().addField(Expressions.bucket("c1", 4)).commit(); @@ -1611,8 +1572,6 @@ public void testSortAfterPartitionChange() { .hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1620,10 +1579,11 @@ public void testSortAfterPartitionChange() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); } @TestTemplate - public void testSortCustomSortOrder() { + public void testSortCustomSortOrder() throws IOException { Table table = createTable(20); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveFiles(table, 20); @@ -1642,8 +1602,6 @@ public void testSortCustomSortOrder() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1651,13 +1609,15 @@ public void testSortCustomSortOrder() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate - public void testSortCustomSortOrderRequiresRepartition() { + public void testSortCustomSortOrderRequiresRepartition() throws IOException { int partitions = 4; Table table = createTable(); writeRecords(20, SCALE, partitions); + table.refresh(); shouldHaveLastCommitUnsorted(table, "c3"); // Add a partition column so this requires repartitioning @@ -1681,8 +1641,6 @@ public void testSortCustomSortOrderRequiresRepartition() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1691,10 +1649,40 @@ public void testSortCustomSortOrderRequiresRepartition() { shouldHaveMultipleFiles(table); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveLastCommitSorted(table, "c3"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate - public void testAutoSortShuffleOutput() { + public void testSortPastTableSortOrderGetsAppliedToFiles() throws IOException { + Table table = createTable(1); + + table.replaceSortOrder().asc("c3").commit(); + SortOrder c3SortOrder = table.sortOrder(); + + table.replaceSortOrder().asc("c2").commit(); + + List originalData = currentData(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c3").build()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + + table.refresh(); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + dataFilesShouldHaveSortOrderIdMatching(table, c3SortOrder); + } + + @TestTemplate + public void testAutoSortShuffleOutput() throws IOException { Table table = createTable(20); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveFiles(table, 20); @@ -1718,12 +1706,10 @@ public void testAutoSortShuffleOutput() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 40+ files") .hasSizeGreaterThanOrEqualTo(40); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1731,6 +1717,7 @@ public void testAutoSortShuffleOutput() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate @@ -1765,6 +1752,26 @@ public void testCommitStateUnknownException() { shouldHaveSnapshots(table, 2); // Commit actually Succeeded } + @TestTemplate + public void testZOrderWithZColumnCollision() { + Schema schema = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "ICEZVALUE", Types.StringType.get())); + + Table table = + TABLES.create( + schema, + PartitionSpec.unpartitioned(), + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + assertThatThrownBy(() -> basicRewrite(table).zOrder("c1", "c2").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot zorder because the table has a column named 'ICEZVALUE'"); + } + @TestTemplate public void testZOrderSort() { int originalFiles = 20; @@ -1797,12 +1804,10 @@ public void testZOrderSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 40+ files") .hasSizeGreaterThanOrEqualTo(40); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1862,12 +1867,10 @@ public void testZOrderAllTypesSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 1 file") .hasSize(1); - table.refresh(); - List postRaw = spark .read() @@ -2144,7 +2147,6 @@ protected long testDataSize(Table table) { } protected void shouldHaveMultipleFiles(Table table) { - table.refresh(); int numFiles = Iterables.size(table.newScan().planFiles()); assertThat(numFiles) .as(String.format("Should have multiple files, had %d", numFiles)) @@ -2152,7 +2154,6 @@ protected void shouldHaveMultipleFiles(Table table) { } protected void shouldHaveFiles(Table table, int numExpected) { - table.refresh(); int numFiles = Iterables.size(table.newScan().planFiles()); assertThat(numFiles).as("Did not have the expected number of files").isEqualTo(numExpected); } @@ -2173,7 +2174,6 @@ protected long shouldHaveMinSequenceNumberInPartition( } protected void shouldHaveSnapshots(Table table, int expectedSnapshots) { - table.refresh(); assertThat(table.snapshots()) .as("Table did not have the expected number of snapshots") .hasSize(expectedSnapshots); @@ -2226,13 +2226,13 @@ private Pair boundsOf(DataFile file, NestedField field, Class javaC private List, Pair>> checkForOverlappingFiles( Table table, String column) { - table.refresh(); NestedField field = table.schema().caseInsensitiveFindField(column); Class javaClass = (Class) field.type().typeId().javaClass(); Snapshot snapshot = table.currentSnapshot(); Map> filesByPartition = - Streams.stream(snapshot.addedDataFiles(table.io())) + Streams.stream( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) .collect(Collectors.groupingBy(DataFile::partition)); Stream, Pair>> overlaps = @@ -2307,6 +2307,7 @@ protected Table createTable() { protected Table createTable(int files) { Table table = createTable(); writeRecords(files, SCALE); + table.refresh(); return table; } @@ -2317,6 +2318,7 @@ protected Table createTablePartitioned( assertThat(table.currentSnapshot()).as("Table must be empty").isNull(); writeRecords(files, numRecords, partitions); + table.refresh(); return table; } @@ -2369,11 +2371,11 @@ private Table createTypeTestTable() { .mode("append") .save(tableLocation); + table.refresh(); return table; } protected int averageFileSize(Table table) { - table.refresh(); return (int) Streams.stream(table.newScan().planFiles()) .mapToLong(FileScanTask::length) @@ -2607,6 +2609,23 @@ public void testExecutorCacheForDeleteFilesDisabled() { .isFalse(); } + @TestTemplate + public void testZOrderUDFWithTimestampNTZType() { + SparkZOrderUDF zorderUDF = new SparkZOrderUDF(1, 16, 1024); + Dataset result = + spark + .sql("SELECT timestamp_ntz '2025-01-01 12:00:00' as test_col") + .withColumn( + "zorder_result", + zorderUDF.sortedLexicographically(col("test_col"), DataTypes.TimestampNTZType)); + + assertThat(result.schema().apply("zorder_result").dataType()).isEqualTo(DataTypes.BinaryType); + List rows = result.collectAsList(); + Row row = rows.get(0); + byte[] zorderBytes = row.getAs("zorder_result"); + assertThat(zorderBytes).isNotNull().isNotEmpty(); + } + private double percentFilesRequired(Table table, String col, String value) { return percentFilesRequired(table, new String[] {col}, new String[] {value}); } @@ -2634,4 +2653,17 @@ public boolean matches(RewriteFileGroup argument) { return groupIDs.contains(argument.info().globalIndex()); } } + + private void dataFilesSortOrderShouldMatchTableSortOrder(Table table) throws IOException { + dataFilesShouldHaveSortOrderIdMatching(table, table.sortOrder()); + } + + private void dataFilesShouldHaveSortOrderIdMatching(Table table, SortOrder sortOrder) + throws IOException { + try (CloseableIterable files = table.newScan().planFiles()) { + assertThat(files) + .extracting(fileScanTask -> fileScanTask.file().sortOrderId()) + .containsOnly(sortOrder.orderId()); + } + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java index 4f95416878e2..0120d4b5fe78 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java @@ -51,6 +51,7 @@ import org.apache.iceberg.ManifestFile; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.ManifestWriter; +import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; @@ -58,6 +59,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -195,6 +197,119 @@ public void testRewriteManifestsPreservesOptionalFields() throws IOException { } } + @TestTemplate + public void testRewriteV3ManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, null, "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteV3PartitionedManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, "AAAA", "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "BBBB", "BBBB"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteManifestsAfterV2ToV3Upgrade() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, spec, ImmutableMap.of(TableProperties.FORMAT_VERSION, "2"), tableLocation); + + ThreeColumnRecord record1 = new ThreeColumnRecord(1, null, "AAAA"); + ThreeColumnRecord record2 = new ThreeColumnRecord(2, "CCCC", "CCCC"); + writeRecords(Lists.newArrayList(record1)); + writeRecords(Lists.newArrayList(record2)); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + table + .updateProperties() + .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)) + .commit(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull() + .doesNotHaveDuplicates(); + assertThat(rowsAfter) + .extracting(r -> new ThreeColumnRecord(r.getAs("c1"), r.getAs("c2"), r.getAs("c3"))) + .containsExactlyInAnyOrder(record1, record2); + } + @TestTemplate public void testRewriteManifestsEmptyTable() throws IOException { PartitionSpec spec = PartitionSpec.unpartitioned(); @@ -728,7 +843,9 @@ public void testRewriteSmallManifestsNonPartitionedV2Table() { table.refresh(); Snapshot snapshot1 = table.currentSnapshot(); - DataFile file1 = Iterables.getOnlyElement(snapshot1.addedDataFiles(table.io())); + DataFile file1 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot1).build().addedDataFiles()); List records2 = Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC")); writeRecords(records2); @@ -736,7 +853,9 @@ public void testRewriteSmallManifestsNonPartitionedV2Table() { table.refresh(); Snapshot snapshot2 = table.currentSnapshot(); - DataFile file2 = Iterables.getOnlyElement(snapshot2.addedDataFiles(table.io())); + DataFile file2 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot2).build().addedDataFiles()); List manifests = table.currentSnapshot().allManifests(table.io()); assertThat(manifests).as("Should have 2 manifests before rewrite").hasSize(2); @@ -1134,6 +1253,17 @@ private List actualRecords() { .collectAsList(); } + private List recordsWithLineage() { + return spark + .read() + .format("iceberg") + .load(tableLocation) + .selectExpr( + MetadataColumns.ROW_ID.name(), MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), "*") + .orderBy(MetadataColumns.ROW_ID.name()) + .collectAsList(); + } + private void writeRecords(List records) { Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); writeDF(df); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java index 829ac761c876..7adbe760e763 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java @@ -251,7 +251,6 @@ public void testRewriteAll() throws Exception { @TestTemplate public void testRewriteFilter() throws Exception { Table table = createTablePartitioned(4, 2, SCALE); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); @@ -260,7 +259,6 @@ public void testRewriteFilter() throws Exception { List deleteFiles = deleteFiles(table); assertThat(deleteFiles).hasSize(8); - table.refresh(); List expectedRecords = records(table); List expectedDeletes = deleteRecords(table); assertThat(expectedRecords).hasSize(12000); @@ -536,7 +534,6 @@ public void testSomePartitionsDanglingDeletes() throws Exception { @TestTemplate public void testRewriteFilterRemoveDangling() throws Exception { Table table = createTablePartitioned(4, 2, SCALE); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles, true); @@ -545,7 +542,6 @@ public void testRewriteFilterRemoveDangling() throws Exception { List deleteFiles = deleteFiles(table); assertThat(deleteFiles).hasSize(8); - table.refresh(); List expectedRecords = records(table); List expectedDeletes = deleteRecords(table); assertThat(expectedRecords).hasSize(12000); // 16000 data - 4000 delete rows @@ -705,7 +701,6 @@ public void testSchemaEvolution() throws Exception { List newSchemaDeleteFiles = except(deleteFiles(table), deleteFiles); assertThat(newSchemaDeleteFiles).hasSize(4); - table.refresh(); List expectedDeletes = deleteRecords(table); List expectedRecords = records(table); assertThat(expectedDeletes).hasSize(4000); // 4 files * 1000 per file diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java index 0bcaf0af6581..dae721b1d73d 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java @@ -30,6 +30,8 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -46,6 +48,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StaticTableOperations; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; @@ -433,9 +436,9 @@ private void runPositionDeletesTest(String fileFormat) throws Exception { List> deletes = Lists.newArrayList( Pair.of( - tableWithPosDeletes - .currentSnapshot() - .addedDataFiles(tableWithPosDeletes.io()) + SnapshotChanges.builderFor(tableWithPosDeletes) + .build() + .addedDataFiles() .iterator() .next() .location(), @@ -480,7 +483,7 @@ private void runPositionDeletesTest(String fileFormat) throws Exception { @TestTemplate public void testPositionDeleteWithRow() throws Exception { String dataFileLocation = - table.currentSnapshot().addedDataFiles(table.io()).iterator().next().location(); + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next().location(); List> deletes = Lists.newArrayList(); OutputFile deleteFile = table @@ -530,7 +533,15 @@ public void testPositionDeletesAcrossFiles() throws Exception { .isEqualTo(2); Stream allFiles = StreamSupport.stream(table.snapshots().spliterator(), false) - .flatMap(s -> StreamSupport.stream(s.addedDataFiles(table.io()).spliterator(), false)); + .flatMap( + s -> + StreamSupport.stream( + SnapshotChanges.builderFor(table) + .snapshot(s) + .build() + .addedDataFiles() + .spliterator(), + false)); List> deletes = allFiles.map(f -> Pair.of((CharSequence) f.location(), 0L)).collect(Collectors.toList()); @@ -565,6 +576,79 @@ public void testPositionDeletesAcrossFiles() throws Exception { .isEmpty(); } + /** + * Test for https://github.com/apache/iceberg/issues/14814 + * + *

    This test verifies that rewrite_table_path correctly deduplicates delete files when the same + * delete file appears in multiple manifests. Without the DeleteFileSet fix, this test would fail + * with AlreadyExistsException because DeleteFile objects don't override equals() and the same + * file would be processed multiple times. + * + *

    The test creates a scenario where the same delete file is added to multiple snapshots, + * causing it to appear in multiple manifest entries. When these manifests are processed, the same + * delete file is returned as different object instances which need proper deduplication. + */ + @TestTemplate + public void testPositionDeletesDeduplication() throws Exception { + // Format versions 3 and 4 use Deletion Vectors stored in Puffin files, which have different + // validation rules that prevent adding the same delete file multiple times + assumeThat(formatVersion) + .as("Format versions 3+ use DVs with different validation rules") + .isEqualTo(2); + + Table tableWithPosDeletes = + createTableWithSnapshots( + tableDir.toFile().toURI().toString().concat("tableWithDuplicateDeletes"), + 2, + Map.of(TableProperties.DELETE_DEFAULT_FILE_FORMAT, "parquet")); + + // Get a data file to create position deletes for + DataFile dataFile = + tableWithPosDeletes + .currentSnapshot() + .addedDataFiles(tableWithPosDeletes.io()) + .iterator() + .next(); + + // Create a position delete file + List> deletes = Lists.newArrayList(Pair.of(dataFile.location(), 0L)); + File deleteFile = + new File( + removePrefix(tableWithPosDeletes.location() + "/data/deeply/nested/deletes.parquet")); + DeleteFile positionDeletes = + FileHelpers.writeDeleteFile( + tableWithPosDeletes, + tableWithPosDeletes.io().newOutputFile(deleteFile.toURI().toString()), + deletes, + formatVersion) + .first(); + + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // Add the SAME delete file AGAIN in a second snapshot - this creates a duplicate entry + // in a new manifest, which will cause duplicate DeleteFile objects when processing + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // This should NOT throw AlreadyExistsException - the fix uses DeleteFileSet to deduplicate + // Without the fix (using Collectors.toSet()), this would fail because: + // 1. Both manifests contain entries for the same delete file + // 2. Processing returns two different DeleteFile objects for the same file + // 3. HashSet doesn't deduplicate them (DeleteFile doesn't override equals()) + // 4. rewritePositionDeletes tries to write the same file twice -> AlreadyExistsException + RewriteTablePath.Result result = + actions() + .rewriteTablePath(tableWithPosDeletes) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(tableWithPosDeletes.location(), targetTableLocation()) + .execute(); + + // Verify the rewrite completed successfully - should have rewritten exactly 1 delete file + // (the duplicate should be deduplicated by DeleteFileSet) + assertThat(result.rewrittenDeleteFilePathsCount()) + .as("Should have rewritten exactly 1 delete file after deduplication") + .isEqualTo(1); + } + @TestTemplate public void testEqualityDeletes() throws Exception { Table sourceTable = createTableWithSnapshots(newTableLocation(), 1); @@ -704,7 +788,10 @@ public void testRewritePathWithNonLiveEntry() throws Exception { Snapshot oldest = SnapshotUtil.oldestAncestor(tableWith3Snaps); String oldestDataFilePath = Iterables.getOnlyElement( - tableWith3Snaps.snapshot(oldest.snapshotId()).addedDataFiles(tableWith3Snaps.io())) + SnapshotChanges.builderFor(tableWith3Snaps) + .snapshot(tableWith3Snaps.snapshot(oldest.snapshotId())) + .build() + .addedDataFiles()) .location(); String deletedDataFilePathInTargetLocation = String.format("%sdata/%s", targetTableLocation(), fileName(oldestDataFilePath)); @@ -1228,27 +1315,14 @@ public void testNestedDirectoryStructurePreservation() throws Exception { // Create position delete files with same names in different nested directories // This simulates the scenario tested in // TestRewriteTablePathUtil.testStagingPathPreservesDirectoryStructure + SnapshotChanges sourceChanges = SnapshotChanges.builderFor(sourceTable).build(); List> deletes1 = Lists.newArrayList( - Pair.of( - sourceTable - .currentSnapshot() - .addedDataFiles(sourceTable.io()) - .iterator() - .next() - .location(), - 0L)); + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); List> deletes2 = Lists.newArrayList( - Pair.of( - sourceTable - .currentSnapshot() - .addedDataFiles(sourceTable.io()) - .iterator() - .next() - .location(), - 0L)); + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); // Create delete files with same name in different nested paths (hash1/ and hash2/) File file1 = @@ -1331,6 +1405,28 @@ public void testRewritePathWithoutCreateFileList() throws Exception { .isEqualTo(NOT_APPLICABLE); } + @TestTemplate + public void testRewritePathWithExecutorService() throws Exception { + String sourceLocation = newTableLocation(); + Table sourceTable = createTableWithSnapshots(sourceLocation, 50); + + ExecutorService service = Executors.newFixedThreadPool(4); + try { + sourceTable.refresh(); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceLocation, targetTableLocation()) + .startVersion("v1.metadata.json") + .executeWith(service) + .execute(); + + checkFileNum(50, 50, 50, 200, result); + } finally { + service.shutdown(); + } + } + protected void checkFileNum( int versionFileCount, int manifestListCount, diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java index d9c42a07b853..9fac633e75ac 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java @@ -18,7 +18,11 @@ */ package org.apache.iceberg.spark.actions; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_HADOOP; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.IOException; import java.nio.file.Files; @@ -33,6 +37,7 @@ @ExtendWith(ParameterizedTestExtension.class) public class TestSnapshotTableAction extends CatalogTestBase { private static final String SOURCE_NAME = "spark_catalog.default.source"; + private static final String SOURCE = "source"; @AfterEach public void removeTables() { @@ -65,4 +70,92 @@ public void testSnapshotWithParallelTasks() throws IOException { .execute(); assertThat(snapshotThreadsIndex.get()).isEqualTo(2); } + + @TestTemplate + public void testSnapshotWithOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(actualSourceLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "The snapshot table location cannot be same as the source table location."); + + String destAsSubdirectory = actualSourceLocation + "/nested"; + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(destAsSubdirectory) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + + String parentLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - ("/" + SOURCE).length()); + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(parentLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + } + + @TestTemplate + public void testSnapshotWithNonOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + String validDestLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - SOURCE.length()) + + "newDestination"; + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(validDestLocation) + .execute(); + assertThat(sql("SELECT * FROM %s", tableName)).hasSize(2); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java index 0db6a65fd394..45053c1a4f1f 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java @@ -32,6 +32,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Schema; @@ -108,8 +109,8 @@ protected boolean supportsRowLineage() { required(114, "dec_9_0", Types.DecimalType.of(9, 0)), // int encoded required(115, "dec_11_2", Types.DecimalType.of(11, 2)), // long encoded required(116, "dec_20_5", Types.DecimalType.of(20, 5)), // requires padding - required(117, "dec_38_10", Types.DecimalType.of(38, 10)) // Spark's maximum precision - ); + required(117, "dec_38_10", Types.DecimalType.of(38, 10)), // Spark's maximum precision + optional(118, "unk", Types.UnknownType.get())); @TempDir protected Path temp; @@ -120,10 +121,13 @@ public void testSimpleStruct() throws IOException { @Test public void testStructWithRequiredFields() throws IOException { + List supportedPrimitives = + SUPPORTED_PRIMITIVES.fields().stream() + .filter(f -> f.type().typeId() != Type.TypeID.UNKNOWN) + .collect(Collectors.toList()); writeAndValidate( TypeUtil.assignIncreasingFreshIds( - new Schema( - Lists.transform(SUPPORTED_PRIMITIVES.fields(), Types.NestedField::asRequired)))); + new Schema(Lists.transform(supportedPrimitives, Types.NestedField::asRequired)))); } @Test @@ -603,4 +607,48 @@ public void testRowLineage() throws Exception { record.copy(Map.of("id", 4L, "data", "d", "_row_id", 1_001L)), record.copy(Map.of("id", 5L, "data", "e")))); } + + @Test + public void testUnknownNestedLevel() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(1, "id", LongType.get()), + optional( + 2, + "nested", + Types.StructType.of( + required(20, "int", Types.IntegerType.get()), + optional(21, "unk", Types.UnknownType.get())))); + + writeAndValidate(schema); + } + + @Test + public void testUnknownListType() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional(1, "data", ListType.ofOptional(2, Types.UnknownType.get()))); + + writeAndValidate(schema); + } + + @Test + public void testUnknownMapType() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional( + 1, + "data", + MapType.ofOptional(2, 3, Types.StringType.get(), Types.UnknownType.get()))); + + writeAndValidate(schema); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java new file mode 100644 index 000000000000..b7ba7a34309a --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; + +/** Converts Iceberg Record to Spark InternalRow for testing. */ +public class InternalRowConverter { + private static final OffsetDateTime EPOCH = Instant.ofEpochSecond(0).atOffset(ZoneOffset.UTC); + private static final LocalDate EPOCH_DAY = EPOCH.toLocalDate(); + + private InternalRowConverter() {} + + public static InternalRow convert(Schema schema, Record record) { + return convert(schema.asStruct(), record); + } + + private static InternalRow convert(Types.StructType struct, Record record) { + GenericInternalRow internalRow = new GenericInternalRow(struct.fields().size()); + List fields = struct.fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + + Type fieldType = field.type(); + internalRow.update(i, convert(fieldType, record.get(i))); + } + + return internalRow; + } + + private static Object convert(Type type, Object value) { + if (value == null) { + return null; + } + + return switch (type.typeId()) { + case BOOLEAN, INTEGER, LONG, FLOAT, DOUBLE -> value; + case DATE -> (int) ChronoUnit.DAYS.between(EPOCH_DAY, (LocalDate) value); + case TIMESTAMP -> + ((Types.TimestampType) type).shouldAdjustToUTC() + ? ChronoUnit.MICROS.between(EPOCH, (OffsetDateTime) value) + : ChronoUnit.MICROS.between(EPOCH, ((LocalDateTime) value).atZone(ZoneId.of("UTC"))); + case STRING -> UTF8String.fromString((String) value); + case UUID -> UTF8String.fromString(value.toString()); + case FIXED, BINARY -> { + ByteBuffer buffer = (ByteBuffer) value; + yield Arrays.copyOfRange( + buffer.array(), + buffer.arrayOffset() + buffer.position(), + buffer.arrayOffset() + buffer.remaining()); + } + case DECIMAL -> Decimal.apply((BigDecimal) value); + case STRUCT -> convert((Types.StructType) type, (Record) value); + case LIST -> + new GenericArrayData( + ((List) value) + .stream() + .map(element -> convert(type.asListType().elementType(), element)) + .toArray()); + case MAP -> + new ArrayBasedMapData( + new GenericArrayData( + ((Map) value) + .keySet().stream() + .map(o -> convert(type.asMapType().keyType(), o)) + .toArray()), + new GenericArrayData( + ((Map) value) + .values().stream() + .map(o -> convert(type.asMapType().valueType(), o)) + .toArray())); + // TIME is not supported by Spark, VARIANT not yet implemented + default -> + throw new UnsupportedOperationException( + "Unsupported type for conversion to InternalRow: " + type); + }; + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java index 82ebb1d95016..dae8612f7db6 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java @@ -52,10 +52,16 @@ import org.apache.iceberg.ManifestFile; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableScan; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.deletes.DeleteCounter; +import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.relocated.com.google.common.collect.Streams; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.types.Type; @@ -876,4 +882,69 @@ public static Dataset selectNonDerived(Dataset metadataTable) { public static Types.StructType nonDerivedSchema(Dataset metadataTable) { return SparkSchemaUtil.convert(TestHelpers.selectNonDerived(metadataTable).schema()).asStruct(); } + + public static class CustomizedDeleteFilter extends DeleteFilter { + private final boolean hasDeletes; + + protected CustomizedDeleteFilter( + boolean hasDeletes, Schema tableSchema, Schema projectedSchema) { + super("", List.of(), tableSchema, projectedSchema, new DeleteCounter(), true); + this.hasDeletes = hasDeletes; + } + + @Override + protected StructLike asStructLike(InternalRow record) { + return null; + } + + @Override + protected InputFile getInputFile(String location) { + return null; + } + + @Override + public boolean hasPosDeletes() { + return hasDeletes; + } + + @Override + public PositionDeleteIndex deletedRowPositions() { + PositionDeleteIndex deletedRowPos = new CustomizedPositionDeleteIndex(); + if (hasDeletes) { + deletedRowPos.delete(98, 103); + } + + return deletedRowPos; + } + } + + public static class CustomizedPositionDeleteIndex implements PositionDeleteIndex { + private final Set deleteIndex; + + private CustomizedPositionDeleteIndex() { + deleteIndex = Sets.newHashSet(); + } + + @Override + public void delete(long position) { + deleteIndex.add(position); + } + + @Override + public void delete(long posStart, long posEnd) { + for (long l = posStart; l < posEnd; l++) { + delete(l); + } + } + + @Override + public boolean isDeleted(long position) { + return deleteIndex.contains(position); + } + + @Override + public boolean isEmpty() { + return deleteIndex.isEmpty(); + } + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java new file mode 100644 index 000000000000..291bb2bca4f5 --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseFormatModelTests; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Type; +import org.apache.spark.sql.catalyst.InternalRow; + +public class TestSparkFormatModel extends BaseFormatModelTests { + + @Override + protected Class engineType() { + return InternalRow.class; + } + + @Override + protected Object engineSchema(Schema schema) { + return SparkSchemaUtil.convert(schema); + } + + @Override + protected InternalRow convertToEngine(Record record, Schema schema) { + return InternalRowConverter.convert(schema, record); + } + + @Override + protected void assertEquals(Schema schema, List expected, List actual) { + assertThat(actual).hasSameSizeAs(expected); + for (int i = 0; i < expected.size(); i++) { + TestHelpers.assertEquals(schema, expected.get(i), actual.get(i)); + } + } + + @Override + protected Object convertConstantToEngine(Type type, Object value) { + return SparkUtil.internalToSpark(type, value); + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java index 9d725250d3d2..13acaa1e3a7b 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java @@ -20,6 +20,7 @@ import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.File; import java.io.IOException; @@ -36,6 +37,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; +import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.exceptions.RuntimeIOException; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; @@ -46,6 +48,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.spark.source.BatchReaderUtil; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; import org.apache.orc.OrcConf; @@ -74,7 +77,11 @@ public class TestSparkOrcReadMetadataColumns { MetadataColumns.ROW_POSITION, MetadataColumns.IS_DELETED); + private static final DeleteFilter NO_DELETES_FILTER = + new TestHelpers.CustomizedDeleteFilter(false, DATA_SCHEMA, PROJECTION_SCHEMA); + private static final int NUM_ROWS = 1000; + private static final int RECORDS_PER_BATCH = 10; private static final List DATA_ROWS; private static final List EXPECTED_ROWS; @@ -128,13 +135,35 @@ public void writeFile() throws IOException { @TestTemplate public void testReadRowNumbers() throws IOException { - readAndValidate(null, null, null, EXPECTED_ROWS); + readAndValidate(null, null, null, EXPECTED_ROWS, NO_DELETES_FILTER); + } + + @TestTemplate + public void testReadRowNumbersWithDelete() throws IOException { + assumeThat(vectorized).isTrue(); + + List expectedRowsAfterDelete = Lists.newArrayList(); + EXPECTED_ROWS.forEach(row -> expectedRowsAfterDelete.add(row.copy())); + // remove row at position 98, 99, 100, 101, 102, this crosses two row groups [0, 100) and [100, + // 200) + for (int i = 98; i <= 102; i++) { + expectedRowsAfterDelete.get(i).update(3, true); + } + + DeleteFilter deleteFilter = + new TestHelpers.CustomizedDeleteFilter(true, DATA_SCHEMA, PROJECTION_SCHEMA); + + readAndValidate(null, null, null, expectedRowsAfterDelete, deleteFilter); } @TestTemplate public void testReadRowNumbersWithFilter() throws IOException { readAndValidate( - Expressions.greaterThanOrEqual("id", 500), null, null, EXPECTED_ROWS.subList(500, 1000)); + Expressions.greaterThanOrEqual("id", 500), + null, + null, + EXPECTED_ROWS.subList(500, 1000), + NO_DELETES_FILTER); } @TestTemplate @@ -157,12 +186,17 @@ public void testReadRowNumbersWithSplits() throws IOException { null, splitOffsets.get(i), splitLengths.get(i), - EXPECTED_ROWS.subList(i * 100, (i + 1) * 100)); + EXPECTED_ROWS.subList(i * 100, (i + 1) * 100), + NO_DELETES_FILTER); } } private void readAndValidate( - Expression filter, Long splitStart, Long splitLength, List expected) + Expression filter, + Long splitStart, + Long splitLength, + List expected, + DeleteFilter deleteFilter) throws IOException { Schema projectionWithoutMetadataFields = TypeUtil.selectNot(PROJECTION_SCHEMA, MetadataColumns.metadataFieldIds()); @@ -173,10 +207,12 @@ private void readAndValidate( if (vectorized) { builder = - builder.createBatchedReaderFunc( - readOrcSchema -> - VectorizedSparkOrcReaders.buildReader( - PROJECTION_SCHEMA, readOrcSchema, ImmutableMap.of())); + builder + .recordsPerBatch(RECORDS_PER_BATCH) + .createBatchedReaderFunc( + readOrcSchema -> + VectorizedSparkOrcReaders.buildReader( + PROJECTION_SCHEMA, readOrcSchema, ImmutableMap.of())); } else { builder = builder.createReaderFunc( @@ -192,7 +228,7 @@ private void readAndValidate( } if (vectorized) { - reader = batchesToRows(builder.build()); + reader = batchesToRows(BatchReaderUtil.applyDeleteFilter(builder.build(), deleteFilter)); } else { reader = builder.build(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java index 3b68a830b088..3fcfe6845c99 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java @@ -21,6 +21,7 @@ import static org.apache.iceberg.spark.data.TestHelpers.assertEquals; import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.io.IOException; @@ -107,4 +108,20 @@ private void writeAndValidateRecords(Schema schema, Iterable expect private Iterator batchesToRows(Iterator batches) { return Iterators.concat(Iterators.transform(batches, ColumnarBatch::rowIterator)); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java index ccd783915c62..e2e5a98ccb8b 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java @@ -26,7 +26,6 @@ import java.io.IOException; import java.util.Iterator; import java.util.List; -import java.util.Set; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.iceberg.Files; @@ -35,21 +34,16 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; -import org.apache.iceberg.StructLike; import org.apache.iceberg.data.DeleteFilter; -import org.apache.iceberg.deletes.DeleteCounter; -import org.apache.iceberg.deletes.PositionDeleteIndex; import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; -import org.apache.iceberg.io.InputFile; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.parquet.ParquetSchemaUtil; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; import org.apache.iceberg.spark.source.BatchReaderUtil; @@ -183,7 +177,8 @@ public void testReadRowNumbersWithDelete() throws IOException { Parquet.ReadBuilder builder = Parquet.read(Files.localInput(testFile)).project(PROJECTION_SCHEMA); - DeleteFilter deleteFilter = new TestDeleteFilter(true); + DeleteFilter deleteFilter = + new TestHelpers.CustomizedDeleteFilter(true, DATA_SCHEMA, PROJECTION_SCHEMA); builder.createBatchedReaderFunc( fileSchema -> @@ -194,70 +189,6 @@ public void testReadRowNumbersWithDelete() throws IOException { validate(expectedRowsAfterDelete, builder, deleteFilter); } - private static class TestDeleteFilter extends DeleteFilter { - private final boolean hasDeletes; - - protected TestDeleteFilter(boolean hasDeletes) { - super("", List.of(), DATA_SCHEMA, PROJECTION_SCHEMA, new DeleteCounter(), true); - this.hasDeletes = hasDeletes; - } - - @Override - protected StructLike asStructLike(InternalRow record) { - return null; - } - - @Override - protected InputFile getInputFile(String location) { - return null; - } - - @Override - public boolean hasPosDeletes() { - return hasDeletes; - } - - @Override - public PositionDeleteIndex deletedRowPositions() { - PositionDeleteIndex deletedRowPos = new CustomizedPositionDeleteIndex(); - if (hasDeletes) { - deletedRowPos.delete(98, 103); - } - - return deletedRowPos; - } - } - - private static class CustomizedPositionDeleteIndex implements PositionDeleteIndex { - private final Set deleteIndex; - - private CustomizedPositionDeleteIndex() { - deleteIndex = Sets.newHashSet(); - } - - @Override - public void delete(long position) { - deleteIndex.add(position); - } - - @Override - public void delete(long posStart, long posEnd) { - for (long l = posStart; l < posEnd; l++) { - delete(l); - } - } - - @Override - public boolean isDeleted(long position) { - return deleteIndex.contains(position); - } - - @Override - public boolean isEmpty() { - return deleteIndex.isEmpty(); - } - } - @TestTemplate public void testReadRowNumbersWithFilter() throws IOException { // current iceberg supports row group filter. @@ -314,7 +245,10 @@ private void readAndValidate( builder = builder.split(splitStart, splitLength); } - validate(expected, builder, new TestDeleteFilter(false)); + validate( + expected, + builder, + new TestHelpers.CustomizedDeleteFilter(false, DATA_SCHEMA, PROJECTION_SCHEMA)); } private void validate( diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java index 328dcaa0014c..bc4b77059d43 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java @@ -248,4 +248,20 @@ public void testMissingRequiredWithoutDefault() { .isInstanceOf(IllegalArgumentException.class) .hasMessage("Missing required field: missing_str"); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java index bf738be59cb8..634327a81d86 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java @@ -20,6 +20,7 @@ import static org.apache.iceberg.types.Types.NestedField.required; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.File; import java.io.IOException; @@ -150,4 +151,20 @@ private static void assertEqualsUnsafe( assertThat(expectedIter).as("Expected iterator should not have any extra rows.").isExhausted(); assertThat(actualIter).as("Actual iterator should not have any extra rows.").isExhausted(); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java index 284fa0b0552f..b61ecfa2f442 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java @@ -43,6 +43,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; import org.apache.iceberg.types.Types; @@ -65,6 +66,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java index 6e823a8bfc05..24a3603dc7f2 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java @@ -81,15 +81,24 @@ public class TestParquetVectorizedReads extends AvroDataTestBase { private static final String PLAIN = "PLAIN"; private static final List GOLDEN_FILE_ENCODINGS = - ImmutableList.of("PLAIN_DICTIONARY", "RLE_DICTIONARY", "DELTA_BINARY_PACKED"); + ImmutableList.of( + "PLAIN_DICTIONARY", + "RLE_DICTIONARY", + "DELTA_BINARY_PACKED", + "DELTA_LENGTH_BYTE_ARRAY", + "DELTA_BYTE_ARRAY", + "BYTE_STREAM_SPLIT"); private static final Map GOLDEN_FILE_TYPES = - ImmutableMap.of( - "string", Types.StringType.get(), - "float", Types.FloatType.get(), - "int32", Types.IntegerType.get(), - "int64", Types.LongType.get(), - "binary", Types.BinaryType.get(), - "boolean", Types.BooleanType.get()); + ImmutableMap.builder() + .put("string", Types.StringType.get()) + .put("float", Types.FloatType.get()) + .put("double", Types.DoubleType.get()) + .put("int32", Types.IntegerType.get()) + .put("int64", Types.LongType.get()) + .put("binary", Types.BinaryType.get()) + .put("boolean", Types.BooleanType.get()) + .put("uuid", Types.UUIDType.get()) + .buildOrThrow(); static final Function IDENTITY = record -> record; @@ -204,8 +213,7 @@ private void writeAndValidate( Map idToConstant) throws IOException { // write a test parquet file using iceberg writer - File testFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + File testFile = temp.resolve("data.parquet").toFile(); try (FileAppender writer = getParquetWriter(writeSchema, testFile)) { writer.addAll(expected); @@ -381,8 +389,7 @@ public void testReadsForTypePromotedColumns() throws Exception { optional(102, "float_data", Types.FloatType.get()), optional(103, "decimal_data", Types.DecimalType.of(10, 5))); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(writeSchema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); try (FileAppender writer = getParquetWriter(writeSchema, dataFile)) { @@ -401,19 +408,20 @@ public void testReadsForTypePromotedColumns() throws Exception { @Test public void testSupportedReadsForParquetV2() throws Exception { - // Float and double column types are written using plain encoding with Parquet V2, - // also Parquet V2 will dictionary encode decimals that use fixed length binary - // (i.e. decimals > 8 bytes). Int and long types use DELTA_BINARY_PACKED. + // Parquet V2 uses PLAIN for float/double, DELTA_BINARY_PACKED for int/long, + // DELTA_LENGTH_BYTE_ARRAY for string/binary, and dictionary encoding for decimals + // that use fixed length binary (i.e. decimals > 8 bytes). Schema schema = new Schema( optional(102, "float_data", Types.FloatType.get()), optional(103, "double_data", Types.DoubleType.get()), optional(104, "decimal_data", Types.DecimalType.of(25, 5)), optional(105, "int_data", Types.IntegerType.get()), - optional(106, "long_data", Types.LongType.get())); + optional(106, "long_data", Types.LongType.get()), + optional(107, "string_data", Types.StringType.get()), + optional(108, "binary_data", Types.BinaryType.get())); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(schema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { @@ -426,8 +434,7 @@ public void testSupportedReadsForParquetV2() throws Exception { public void testUnsupportedReadsForParquetV2() throws Exception { // Some types use delta encoding and which are not supported for vectorized reads Schema schema = new Schema(SUPPORTED_PRIMITIVES.fields()); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(schema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { @@ -445,8 +452,7 @@ public void testUuidReads() throws Exception { int numRows = 1; Schema schema = new Schema(optional(100, "uuid", Types.UUIDType.get())); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(schema, numRows, 0L, 0, IDENTITY); try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { writer.addAll(data); @@ -504,10 +510,16 @@ static Stream goldenFilesAndEncodings() { .flatMap( e -> Stream.of(true, false) - .map( + .flatMap( vectorized -> - Arguments.of( - encoding, e.getKey(), e.getValue(), vectorized)))); + Stream.of( + Arguments.of( + encoding, e.getKey(), e.getValue(), vectorized), + Arguments.of( + encoding, + e.getKey() + "_with_nulls", + e.getValue(), + vectorized))))); } private File resourceUrlToLocalFile(URL url) throws IOException, URISyntaxException { diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java index 05dec42a031a..80cc3556b3c4 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java @@ -32,6 +32,7 @@ import org.apache.iceberg.Files; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.Tables; @@ -125,9 +126,9 @@ public void testAlternateLocation() throws IOException { writeRecords(table, RandomData.generateList(table.schema(), 100, 87112L)); - table - .currentSnapshot() - .addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() .forEach( dataFile -> assertThat(dataFile.location()) diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java index 1c5905744a75..91d07e3647c9 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java @@ -37,6 +37,7 @@ import org.apache.iceberg.TableProperties; import org.apache.iceberg.hadoop.HadoopTables; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.AvroDataTestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; @@ -62,6 +63,7 @@ public static void startSpark() { SparkSession.builder() .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) .master("local[2]") + .config(TestBase.DISABLE_UI) .getOrCreate(); ScanTestBase.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); } @@ -93,11 +95,7 @@ protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throw HadoopTables tables = new HadoopTables(CONF); // If V3 spec features are used, set the format version to 3 - Map tableProperties = - writeSchema.columns().stream() - .anyMatch(f -> f.initialDefaultLiteral() != null || f.writeDefaultLiteral() != null) - ? ImmutableMap.of(TableProperties.FORMAT_VERSION, "3") - : ImmutableMap.of(); + Map tableProperties = ImmutableMap.of(TableProperties.FORMAT_VERSION, "3"); Table table = tables.create( writeSchema, PartitionSpec.unpartitioned(), tableProperties, location.toString()); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..b6017e2001e7 --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.iceberg.Snapshot; +import org.junit.jupiter.api.Test; + +class TestAsyncSparkMicroBatchPlanner { + + @Test + void reachedAvailableNowCapReturnsTrueOnlyForExactCapSnapshot() { + Snapshot capSnapshot = mockSnapshot(10L); + Snapshot laterSnapshotWithHigherId = mockSnapshot(20L); + Snapshot laterSnapshotWithLowerId = mockSnapshot(5L); + StreamingOffset capOffset = new StreamingOffset(10L, 3L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(capSnapshot, capOffset)).isTrue(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap( + laterSnapshotWithHigherId, capOffset)) + .isFalse(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(laterSnapshotWithLowerId, capOffset)) + .isFalse(); + } + + @Test + void reachedAvailableNowCapReturnsFalseWhenCapOrSnapshotIsMissing() { + Snapshot readFrom = mockSnapshot(10L); + StreamingOffset capOffset = new StreamingOffset(10L, 1L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(readFrom, null)).isFalse(); + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(null, capOffset)).isFalse(); + } + + private Snapshot mockSnapshot(long snapshotId) { + Snapshot snapshot = mock(Snapshot.class); + when(snapshot.snapshotId()).thenReturn(snapshotId); + return snapshot; + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java index 1e53710a0f7f..8e26a2f42646 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java @@ -90,7 +90,7 @@ private static class ClosureTrackingReader extends BaseReader tracker = Maps.newHashMap(); ClosureTrackingReader(Table table, List tasks) { - super(table, new BaseCombinedScanTask(tasks), null, null, false, true); + super(table, table.io(), new BaseCombinedScanTask(tasks), null, null, false, true); } @Override diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java index b88f0233e203..be4391aab668 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java @@ -105,7 +105,8 @@ public void testInsert() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -136,7 +137,8 @@ public void testDelete() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -170,7 +172,8 @@ public void testDataFileRewrite() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -197,7 +200,8 @@ public void testMixDeleteAndInsert() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java index 19ec6d13dd5d..d38182248391 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java @@ -207,7 +207,8 @@ public void testWriteDataWithDifferentSetting() throws Exception { .append(); Table table = catalog.loadTable(TableIdentifier.of("default", TABLE_NAME)); List manifestFiles = table.currentSnapshot().dataManifests(table.io()); - try (ManifestReader reader = ManifestFiles.read(manifestFiles.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifestFiles.get(0), table.io(), table.specs())) { DataFile file = reader.iterator().next(); InputFile inputFile = table.io().newInputFile(file.location()); assertThat(getCompressionType(inputFile)) diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java index a7702b169a60..9ad73baa3dc4 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java @@ -35,6 +35,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -206,7 +207,7 @@ public void testSplitOptionsOverridesTableProperties() throws IOException { .save(tableLocation); List files = - Lists.newArrayList(icebergTable.currentSnapshot().addedDataFiles(icebergTable.io())); + Lists.newArrayList(SnapshotChanges.builderFor(icebergTable).build().addedDataFiles()); assertThat(files).as("Should have written 1 file").hasSize(1); long fileSize = files.get(0).fileSizeInBytes(); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java index 61d6501a6847..26c2b6ab70cb 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java @@ -54,6 +54,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.GenericsHelpers; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; @@ -116,6 +117,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java index 153564f7d129..0ba3f0d35fd7 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java @@ -46,6 +46,7 @@ import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.types.Types; @@ -98,6 +99,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java index 63994726cafb..88113be2ddd9 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java @@ -46,10 +46,12 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.Files; +import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -256,7 +258,7 @@ public void testEntriesTableDataFilePrune() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List singleActual = rowsToJava( @@ -289,7 +291,7 @@ public void testEntriesTableDataFilePruneMulti() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List multiActual = rowsToJava( @@ -327,7 +329,7 @@ public void testFilesSelectMap() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List multiActual = rowsToJava( @@ -634,7 +636,7 @@ public void testFilesUnpartitionedTable() throws Exception { table.refresh(); DataFile toDelete = - Iterables.getOnlyElement(table.currentSnapshot().addedDataFiles(table.io())); + Iterables.getOnlyElement(SnapshotChanges.builderFor(table).build().addedDataFiles()); // add a second file df2.select("id", "data") @@ -821,6 +823,10 @@ public void testHistoryTable() { // rollback the table state to the first snapshot table.manageSnapshots().rollbackTo(firstSnapshotId).commit(); long rollbackTimestamp = Iterables.getLast(table.history()).timestampMillis(); + assertThat(rollbackTimestamp) + .as("Rollback history timestamp should be greater than first snapshot timestamp") + .isEqualTo(((HasTableOperations) table).operations().current().lastUpdatedMillis()) + .isGreaterThan(firstSnapshotTimestamp); inputDf .select("id", "data") @@ -1129,19 +1135,16 @@ public void testPruneManifestsTable() { .mode("append") .save(loadLocation(tableIdentifier)); - if (!spark.version().startsWith("2")) { - // Spark 2 isn't able to actually push down nested struct projections so this will not break - assertThatThrownBy( - () -> - spark - .read() - .format("iceberg") - .load(loadLocation(tableIdentifier, "manifests")) - .select("partition_spec_id", "path", "partition_summaries.contains_null") - .collectAsList()) - .isInstanceOf(SparkException.class) - .hasMessageContaining("Cannot project a partial list element struct"); - } + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "manifests")) + .select("partition_spec_id", "path", "partition_summaries.contains_null") + .collectAsList()) + .isInstanceOf(SparkException.class) + .hasMessageContaining("Cannot project a partial list element struct"); Dataset actualDf = spark @@ -1318,7 +1321,7 @@ public void testUnpartitionedPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.currentSnapshot().addedDataFiles(table.io()))) + totalSizeInBytes(SnapshotChanges.builderFor(table).build().addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1387,7 +1390,11 @@ public void testPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1403,7 +1410,11 @@ public void testPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(secondCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(secondCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1669,7 +1680,11 @@ public void testPartitionsTableDeleteStats() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1685,7 +1700,11 @@ public void testPartitionsTableDeleteStats() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 2L) // should be incremented now .set("position_delete_file_count", 2) // should be incremented now .set("equality_delete_record_count", 0L) @@ -2413,7 +2432,7 @@ private DeleteFile writePosDeleteFile(Table table) { private DeleteFile writePosDeleteFile(Table table, long pos) { DataFile dataFile = - Iterables.getFirst(table.currentSnapshot().addedDataFiles(table.io()), null); + Iterables.getFirst(SnapshotChanges.builderFor(table).build().addedDataFiles(), null); PartitionSpec dataFileSpec = table.specs().get(dataFile.specId()); StructLike dataFilePartition = dataFile.partition(); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java index f4f57157e479..a637b975fe2b 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java @@ -28,6 +28,7 @@ import java.sql.Timestamp; import java.util.List; import org.apache.iceberg.spark.IcebergSpark; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.transforms.Transforms; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Row; @@ -51,6 +52,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java index 05e1cae51666..63fef0f2e37a 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java @@ -47,6 +47,18 @@ public void testTime() { // Spark does not support time fields. } + @Disabled + @Override + public void testTimestampNanoWithoutZone() { + // Spark does not support nanosecond timestamp without zone. + } + + @Disabled + @Override + public void testTimestampNanoWithZone() { + // Spark does not support nanosecond timestamp with zone. + } + @Override protected void generateAndValidate(Schema schema, AssertMethod assertMethod) { int numRecords = 100; diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java index c21ccd0100db..f74446b2f416 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java @@ -33,6 +33,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; @@ -200,7 +201,8 @@ private GenericRecord createNestedRecord(Long longCol, Double doubleCol) { @TestTemplate public void testPrimitiveColumns() throws Exception { Table table = createPrimitiveTable(); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); Map columnSizeStats = dataFile.columnSizes(); Object[] binaryCol = diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java new file mode 100644 index 000000000000..a9ce340fd4ec --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMicroBatchPlanningUtils extends CatalogTestBase { + + private Table table; + + @BeforeEach + public void setupTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql( + "CREATE TABLE %s " + + "(id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(3, id))", + tableName); + this.table = validationCatalog.loadTable(tableIdent); + } + + @AfterEach + public void dropTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testUnpackedLimitsCompositeChoosesMinimum() { + ReadLimit[] limits = + new ReadLimit[] { + ReadLimit.maxRows(10), ReadLimit.maxRows(4), ReadLimit.maxFiles(8), ReadLimit.maxFiles(2) + }; + + ReadLimit composite = ReadLimit.compositeLimit(limits); + + BaseSparkMicroBatchPlanner.UnpackedLimits unpacked = + new BaseSparkMicroBatchPlanner.UnpackedLimits(composite); + + assertThat(unpacked.getMaxRows()).isEqualTo(4); + assertThat(unpacked.getMaxFiles()).isEqualTo(2); + } + + @TestTemplate + public void testDetermineStartingOffsetWithTimestampBetweenSnapshots() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + long snapshot1Time = table.currentSnapshot().timestampMillis(); + + sql("INSERT INTO %s VALUES (2, 'two')", tableName); + table.refresh(); + long snapshot2Id = table.currentSnapshot().snapshotId(); + + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, snapshot1Time + 1); + + assertThat(offset.snapshotId()).isEqualTo(snapshot2Id); + assertThat(offset.position()).isEqualTo(0L); + assertThat(offset.shouldScanAllFiles()).isFalse(); + } + + @TestTemplate + public void testAddedFilesCountUsesSummaryWhenPresent() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + + long expectedAddedFiles = + Long.parseLong(table.currentSnapshot().summary().get(SnapshotSummary.ADDED_FILES_PROP)); + + long actual = MicroBatchUtils.addedFilesCount(table, table.currentSnapshot()); + + assertThat(actual).isEqualTo(expectedAddedFiles); + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java index 35be6423ee23..892e260f66f0 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java @@ -18,9 +18,13 @@ */ package org.apache.iceberg.spark.source; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import org.apache.iceberg.FileFormat; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; +import org.apache.spark.SparkException; +import org.junit.jupiter.api.Test; public class TestORCDataFrameWrite extends DataFrameWriteTestBase { @Override @@ -30,4 +34,24 @@ protected void configureTable(Table table) { .set(TableProperties.DEFAULT_FILE_FORMAT, FileFormat.ORC.toString()) .commit(); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java index 90a9ac48a486..c24d92ef30af 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java @@ -18,9 +18,13 @@ */ package org.apache.iceberg.spark.source; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + import org.apache.iceberg.FileFormat; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; +import org.apache.spark.SparkException; +import org.junit.jupiter.api.Test; public class TestParquetDataFrameWrite extends DataFrameWriteTestBase { @Override @@ -30,4 +34,24 @@ protected void configureTable(Table table) { .set(TableProperties.DEFAULT_FILE_FORMAT, FileFormat.PARQUET.toString()) .commit(); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java index c0dee43d6de1..8b567bcaf11e 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java @@ -19,6 +19,7 @@ package org.apache.iceberg.spark.source; import static org.apache.iceberg.Files.localOutput; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assumptions.assumeThat; import java.io.File; @@ -37,6 +38,7 @@ import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; public class TestParquetScan extends ScanTestBase { protected boolean vectorized() { @@ -83,4 +85,20 @@ protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throw super.writeAndValidate(writeSchema, expectedSchema); } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java index e1402396fa7f..cf3097ebdb30 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java @@ -59,6 +59,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.transforms.Transforms; import org.apache.iceberg.types.Types; import org.apache.spark.api.java.JavaRDD; @@ -118,6 +119,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestPartitionPruning.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); @@ -241,14 +243,7 @@ public void testPartitionPruningTruncatedStringComparingValueShorterThanPartitio @TestTemplate public void testPartitionPruningHourlyPartition() { - String filterCond; - if (spark.version().startsWith("2")) { - // Looks like from Spark 2 we need to compare timestamp with timestamp to push down the - // filter. - filterCond = "timestamp >= to_timestamp('2020-02-03T01:00:00')"; - } else { - filterCond = "timestamp >= '2020-02-03T01:00:00'"; - } + String filterCond = "timestamp >= '2020-02-03T01:00:00'"; Predicate partCondition = (Row r) -> { int hourValue = r.getInt(4); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java index 0b6ab2052b66..9b5b22a73f36 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java @@ -46,6 +46,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.types.Types; @@ -112,6 +113,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java index 764e1c6c9370..92aace3dfd5c 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java @@ -180,6 +180,7 @@ public void readPositionDeletesTableWithMultipleDeleteFiles() throws IOException try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), positionDeletesTable.schema(), projectedSchema, @@ -220,6 +221,7 @@ public void readPositionDeletesTableWithMultipleDeleteFiles() throws IOException try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask2)), positionDeletesTable.schema(), projectedSchema, @@ -292,6 +294,7 @@ public void readPositionDeletesTableWithDifferentColumnOrdering() throws IOExcep try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), positionDeletesTable.schema(), projectedSchema, diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java index 87cbbe3cea5f..3cfd9c848fcb 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java @@ -209,6 +209,60 @@ public void testPartitionedTable() throws IOException { dropTable(tableName); } + @TestTemplate + public void testArrayColumnFilter() throws IOException { + assumeThat(formatVersion) + .as("Row content in position_deletes is required for array column filter test") + .isEqualTo(2); + String tableName = "array_column_filter"; + Schema schemaWithArray = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional( + 3, "arr_col", Types.ListType.ofOptional(4, Types.IntegerType.get()))); + Table tab = createTable(tableName, schemaWithArray, PartitionSpec.unpartitioned()); + + GenericRecord record1 = GenericRecord.create(schemaWithArray); + record1.set(0, 1); + record1.set(1, "a"); + record1.set(2, ImmutableList.of(1, 2)); + GenericRecord record2 = GenericRecord.create(schemaWithArray); + record2.set(0, 2); + record2.set(1, "b"); + record2.set(2, ImmutableList.of(3, 4)); + List dataRecords = ImmutableList.of(record1, record2); + DataFile dFile = + FileHelpers.writeDataFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + dataRecords); + tab.newAppend().appendFile(dFile).commit(); + + List> deletes = + ImmutableList.of( + positionDelete(schemaWithArray, dFile.location(), 0L, 1, "a", ImmutableList.of(1, 2)), + positionDelete(schemaWithArray, dFile.location(), 1L, 2, "b", ImmutableList.of(3, 4))); + DeleteFile posDeletes = + FileHelpers.writePosDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + deletes, + formatVersion); + tab.newRowDelta().addDeletes(posDeletes).commit(); + + // Filter directly on array column: row.arr_col = array(1, 2) + StructLikeSet actual = actual(tableName, tab, "row.arr_col = array(1, 2)"); + StructLikeSet expected = expected(tab, ImmutableList.of(deletes.get(0)), null, posDeletes); + + assertThat(actual) + .as("Filtering position_deletes by row.arr_col = array(1, 2) should return matching row") + .isEqualTo(expected); + dropTable(tableName); + } + @TestTemplate public void testSelect() throws IOException { assumeThat(formatVersion).as("DVs don't have row info in PositionDeletesTable").isEqualTo(2); @@ -409,7 +463,7 @@ public void testPartitionFilter() throws IOException { // Add position deletes for both partitions Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); - Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileA, "b"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); @@ -455,7 +509,7 @@ public void testPartitionTransformFilter() throws IOException { Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, new Object[] {"aa"}, new Object[] {"a"}); Pair>, DeleteFile> deletesB = - deleteFile(tab, dataFileA, new Object[] {"bb"}, new Object[] {"b"}); + deleteFile(tab, dataFileB, new Object[] {"bb"}, new Object[] {"b"}); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); // Prepare expected values @@ -496,7 +550,7 @@ public void testPartitionEvolutionReplace() throws Exception { DataFile dataFileB = dataFile(tab, "b"); tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); - Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileA, "b"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); // Switch partition spec from (data) to (id) @@ -508,7 +562,7 @@ public void testPartitionEvolutionReplace() throws Exception { tab.newAppend().appendFile(dataFile10).appendFile(dataFile99).commit(); Pair>, DeleteFile> deletes10 = deleteFile(tab, dataFile10, 10); - Pair>, DeleteFile> deletes99 = deleteFile(tab, dataFile10, 99); + Pair>, DeleteFile> deletes99 = deleteFile(tab, dataFile99, 99); tab.newRowDelta().addDeletes(deletes10.second()).addDeletes(deletes99.second()).commit(); // Query partition of old spec diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java new file mode 100644 index 000000000000..e025114f4fd9 --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.apache.iceberg.io.FileIO; +import org.junit.jupiter.api.Test; + +class TestSerializableFileIOWithSize { + + @Test + void newInputFileWithLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + long length = 1024L; + + serializableFileIO.newInputFile(path, length); + + verify(mockFileIO).newInputFile(path, length); + } + + @Test + void newInputFileWithoutLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + + serializableFileIO.newInputFile(path); + + verify(mockFileIO).newInputFile(path); + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java index 11865db7fce5..fe754f4a02ba 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java @@ -42,6 +42,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; @@ -91,6 +92,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java index 3051e27d7202..2122f2579e4a 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java @@ -59,6 +59,7 @@ import org.apache.iceberg.spark.SparkDataFile; import org.apache.iceberg.spark.SparkDeleteFile; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.types.Conversions; import org.apache.iceberg.types.Types; @@ -125,6 +126,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestSparkDataFile.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); } @@ -183,7 +185,8 @@ private void checkSparkContentFiles(Table table) throws IOException { assertThat(manifests).hasSize(1); List dataFiles = Lists.newArrayList(); - try (ManifestReader reader = ManifestFiles.read(manifests.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { for (DataFile dataFile : reader) { checkDataFile(dataFile.copy(), DataFiles.builder(dataFilesSpec).copy(dataFile).build()); dataFiles.add(dataFile.copy()); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java index 4ccbf86f1257..70f3b986d23b 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java @@ -22,12 +22,12 @@ import static org.apache.iceberg.types.Types.NestedField.optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assumptions.assumeThat; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.io.File; +import java.io.IOException; import java.net.InetAddress; import java.nio.file.Path; import java.util.List; @@ -36,6 +36,7 @@ import org.apache.iceberg.AppendFiles; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.Parameter; @@ -44,14 +45,17 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.exceptions.CommitStateUnknownException; import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.SnapshotUtil; import org.apache.spark.sql.Dataset; @@ -87,7 +91,7 @@ public class TestSparkDataWrite { public static Object[][] parameters() { return new Object[][] { new Object[] {FileFormat.PARQUET, null}, - new Object[] {FileFormat.PARQUET, "main"}, + new Object[] {FileFormat.PARQUET, SnapshotRef.MAIN_BRANCH}, new Object[] {FileFormat.PARQUET, "testBranch"}, new Object[] {FileFormat.AVRO, null}, new Object[] {FileFormat.ORC, "testBranch"} @@ -100,6 +104,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } @@ -148,12 +153,13 @@ public void testBasicWrite() { assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { // TODO: avro not support split if (!format.equals(FileFormat.AVRO)) { assertThat(file.splitOffsets()).as("Split offsets not present").isNotNull(); } assertThat(file.recordCount()).as("Should have reported record count as 1").isEqualTo(1); + assertThat(file.sortOrderId()).isEqualTo(SortOrder.unsorted().orderId()); // TODO: append more metric info if (format.equals(FileFormat.PARQUET)) { assertThat(file.columnSizes()).as("Column sizes metric not present").isNotNull(); @@ -397,7 +403,7 @@ public void testUnpartitionedCreateWithTargetFileSizeViaTableProperties() { List files = Lists.newArrayList(); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { files.add(file); } } @@ -427,100 +433,127 @@ public void testPartitionedFanoutCreateWithTargetFileSizeViaOption2() { } @TestTemplate - public void testWriteProjection() { - assumeThat(spark.version()) - .as("Not supported in Spark 3; analysis requires all columns are present") - .startsWith("2"); - + public void testViewsReturnRecentResults() { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); - PartitionSpec spec = PartitionSpec.unpartitioned(); - Table table = tables.create(SCHEMA, spec, location.toString()); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + tables.create(SCHEMA, spec, location.toString()); - List expected = + List records = Lists.newArrayList( - new SimpleRecord(1, null), new SimpleRecord(2, null), new SimpleRecord(3, null)); + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); - Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); - df.select("id") - .write() // select only id column + df.select("id", "data") + .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) .save(location.toString()); + Table table = tables.load(location.toString()); createBranch(table); - table.refresh(); - Dataset result = spark.read().format("iceberg").load(targetLocation); + Dataset query = spark.read().format("iceberg").load(targetLocation).where("id = 1"); + query.createOrReplaceTempView("tmp"); + + List actual1 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected1 = Lists.newArrayList(new SimpleRecord(1, "a")); + assertThat(actual1).hasSameSizeAs(expected1).isEqualTo(expected1); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(targetLocation); + + List actual2 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected2 = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "a")); + assertThat(actual2).hasSameSizeAs(expected2).isEqualTo(expected2); + } + + @TestTemplate + public void testWriteDataFilesInTableSortOrder() throws IOException { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + List expected = Lists.newArrayListWithCapacity(10); + for (int i = 0; i < 10; i++) { + expected.add(new SimpleRecord(i, "a")); + } + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + Dataset result = spark.read().format("iceberg").load(location.toString()); List actual = result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + assertThat(fileScanTasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } } @TestTemplate - public void testWriteProjectionWithMiddle() { - assumeThat(spark.version()) - .as("Not supported in Spark 3; analysis requires all columns are present") - .startsWith("2"); - + public void testWriteDataFilesUnsortedTable() throws IOException { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); - String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); PartitionSpec spec = PartitionSpec.unpartitioned(); - Schema schema = - new Schema( - optional(1, "c1", Types.IntegerType.get()), - optional(2, "c2", Types.StringType.get()), - optional(3, "c3", Types.StringType.get())); - Table table = tables.create(schema, spec, location.toString()); - - List expected = - Lists.newArrayList( - new ThreeColumnRecord(1, null, "hello"), - new ThreeColumnRecord(2, null, "world"), - new ThreeColumnRecord(3, null, null)); + Table table = tables.create(SCHEMA, spec, location.toString()); - Dataset df = spark.createDataFrame(expected, ThreeColumnRecord.class); + List expected = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); - df.select("c1", "c3") + df.select("id", "data") .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) .save(location.toString()); - createBranch(table); - table.refresh(); - - Dataset result = spark.read().format("iceberg").load(targetLocation); - - List actual = - result.orderBy("c1").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); - assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles should have unsorted sort order id") + .containsOnly(SortOrder.unsorted().orderId()); + } } @TestTemplate - public void testViewsReturnRecentResults() { + public void testWriteDataFilesAfterSortOrderChange() throws IOException { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); - String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); - PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); - tables.create(SCHEMA, spec, location.toString()); - - List records = - Lists.newArrayList( - new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + List records = Lists.newArrayList(new SimpleRecord(1, "a")); Dataset df = spark.createDataFrame(records, SimpleRecord.class); df.select("id", "data") @@ -530,29 +563,31 @@ public void testViewsReturnRecentResults() { .mode(SaveMode.Append) .save(location.toString()); - Table table = tables.load(location.toString()); - createBranch(table); + table.refresh(); + int unsortedId = SortOrder.unsorted().orderId(); - Dataset query = spark.read().format("iceberg").load(targetLocation).where("id = 1"); - query.createOrReplaceTempView("tmp"); + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks).extracting(task -> task.file().sortOrderId()).containsOnly(unsortedId); + } - List actual1 = - spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); - List expected1 = Lists.newArrayList(new SimpleRecord(1, "a")); - assertThat(actual1).hasSameSizeAs(expected1).isEqualTo(expected1); + table.replaceSortOrder().asc("id").commit(); + int sortedId = table.sortOrder().orderId(); df.select("id", "data") .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) - .save(targetLocation); + .save(location.toString()); - List actual2 = - spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); - List expected2 = - Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "a")); - assertThat(actual2).hasSameSizeAs(expected2).isEqualTo(expected2); + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("Should contain both unsorted and sorted files") + .containsOnly(unsortedId, sortedId); + } } public void partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType option) { @@ -624,7 +659,7 @@ public void partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType opti List files = Lists.newArrayList(); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { files.add(file); } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java index 8ccea303d0c1..de6a5e59029c 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java @@ -50,6 +50,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; @@ -88,6 +89,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); ImmutableMap config = ImmutableMap.of( diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java index c7716d343024..2fb6933ce933 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java @@ -73,9 +73,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkStructLike; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.spark.source.metrics.NumDeletes; @@ -138,6 +138,7 @@ public static void startMetastoreAndSpark() { .config("spark.ui.liveUpdate.period", 0) .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); @@ -330,7 +331,8 @@ public void testReadEqualityDeleteRows() throws IOException { for (CombinedScanTask task : tasks) { try (EqualityDeleteRowReader reader = - new EqualityDeleteRowReader(task, table, null, table.schema(), false, true)) { + new EqualityDeleteRowReader( + task, table, table.io(), table.schema(), table.schema(), false, true)) { while (reader.next()) { actualRowSet.add( new InternalRowWrapper( @@ -669,17 +671,14 @@ public void testEqualityDeleteWithDifferentScanAndDeleteColumns() throws IOExcep TableProperties.SPLIT_LOOKBACK_DEFAULT, TableProperties.SPLIT_OPEN_FILE_COST_DEFAULT); - ParquetBatchReadConf conf = - ImmutableParquetBatchReadConf.builder() - .batchSize(7) - .readerType(ParquetReaderType.ICEBERG) - .build(); + ParquetBatchReadConf conf = ImmutableParquetBatchReadConf.builder().batchSize(7).build(); for (CombinedScanTask task : tasks) { try (BatchDataReader reader = new BatchDataReader( // expected column is id, while the equality filter column is dt dateTable, + dateTable.io(), task, dateTable.schema(), dateTable.schema().select("id"), diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java index d22ecb02d483..cb2f866fab10 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java @@ -64,6 +64,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PropertyUtil; import org.apache.spark.sql.Dataset; @@ -182,6 +183,7 @@ public static void startMetastoreAndSpark() { SparkSession.builder() .master("local[2]") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java index 1ddf9318f608..417a84d82769 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java @@ -461,8 +461,7 @@ public void testUnpartitionedYears() throws Exception { pushFilters(builder, predicate); scan = builder.build().toBatch(); - // notEq can't be answered using column bounds because they are not exact - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); } @TestTemplate @@ -771,7 +770,7 @@ public void testUnpartitionedTruncateString() throws Exception { pushFilters(builder, predicate); Batch scan = builder.build().toBatch(); - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); // NOT NotEqual builder = scanBuilder(); @@ -990,7 +989,7 @@ public void testUnpartitionedOr() throws Exception { pushFilters(builder, predicate); scan = builder.build().toBatch(); - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); } @TestTemplate diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java index d14b1a52cf82..e3934faa60ce 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java @@ -20,7 +20,9 @@ import static org.assertj.core.api.Assertions.assertThat; +import org.apache.iceberg.HistoryEntry; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; import org.apache.iceberg.spark.CatalogTestBase; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; import org.apache.spark.sql.connector.catalog.CatalogManager; @@ -56,4 +58,58 @@ public void testTableEquality() throws NoSuchTableException { assertThat(table1).as("References must be different").isNotSameAs(table2); assertThat(table1).as("Tables must be equivalent").isEqualTo(table2); } + + @TestTemplate + public void testTableInequalityWithDifferentSnapshots() throws NoSuchTableException { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + SparkTable table = (SparkTable) catalog.loadTable(identifier); + + Table icebergTable = validationCatalog.loadTable(tableIdent); + long[] snapshotIds = + icebergTable.history().stream().mapToLong(HistoryEntry::snapshotId).toArray(); + + SparkTable tableAtSnapshot1 = table.copyWithSnapshotId(snapshotIds[0]); + SparkTable tableAtSnapshot2 = table.copyWithSnapshotId(snapshotIds[1]); + + assertThat(tableAtSnapshot1) + .as("Tables at different snapshots must not be equal") + .isNotEqualTo(tableAtSnapshot2); + assertThat(tableAtSnapshot1.hashCode()) + .as("Hash codes should differ for different snapshots") + .isNotEqualTo(tableAtSnapshot2.hashCode()); + } + + @TestTemplate + public void testTableInequalityWithDifferentBranches() throws NoSuchTableException { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + + Table icebergTable = validationCatalog.loadTable(tableIdent); + icebergTable + .manageSnapshots() + .createBranch("testBranch", icebergTable.currentSnapshot().snapshotId()) + .commit(); + + // reload after branch creation so the table sees the new ref + SparkTable table = (SparkTable) catalog.loadTable(identifier); + table.table().refresh(); + + SparkTable tableOnMain = table.copyWithBranch("main"); + SparkTable tableOnBranch = table.copyWithBranch("testBranch"); + + assertThat(tableOnMain) + .as("Tables on different branches must not be equal") + .isNotEqualTo(tableOnBranch); + assertThat(tableOnMain.hashCode()) + .as("Hash codes should differ for different branches") + .isNotEqualTo(tableOnBranch.hashCode()); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java new file mode 100644 index 000000000000..6428689aaa19 --- /dev/null +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.streaming.StreamingQuery; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests to verify that streaming checkpoints always use HadoopFileIO and never use the table's + * FileIO implementation. + */ +@ExtendWith(ParameterizedTestExtension.class) +public class TestStreamingCheckpointHadoopIO extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + "testtableio", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hadoop", + CatalogProperties.FILE_IO_IMPL, + TrackingFileIO.class.getName(), + "cache-enabled", + "false") + } + }; + } + + @AfterEach + public void stopStreams() throws TimeoutException { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + TrackingFileIO.reset(); + } + + @TestTemplate + public void testCheckpointsUseHadoopIONotTableIO() throws Exception { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b')", tableName); + + // Use nested checkpoint path to verify parent directory creation + File checkpointDir = new File(temp.toFile(), "nested/checkpoint"); + TrackingFileIO.reset(); + + // Run streaming query with checkpoints + StreamingQuery query = + spark + .readStream() + .format("iceberg") + .load(tableName) + .writeStream() + .format("console") + .option("checkpointLocation", checkpointDir.getAbsolutePath()) + .start(); + + query.processAllAvailable(); + query.stop(); + + // Verify TrackingFileIO (table's FileIO) was NOT used for checkpoint operations + assertThat(TrackingFileIO.wasUsed()) + .as("HadoopFileIO should be used for checkpoints, not table's FileIO") + .isFalse(); + + // Verify checkpoint files were actually created using HadoopFileIO + assertThat(new File(checkpointDir, "offsets/0")).exists().isFile(); + } + + /** + * A FileIO that tracks whether it was used for checkpoint operations. This allows us to verify + * that the table's FileIO is NOT being used for checkpoints. + */ + public static class TrackingFileIO implements FileIO { + private static final String CHECKPOINT_OFFSETS_PATH = "/offsets/"; + private static final AtomicBoolean USED = new AtomicBoolean(false); + private FileIO delegate; + + public static void reset() { + USED.set(false); + } + + public static boolean wasUsed() { + return USED.get(); + } + + @Override + public InputFile newInputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newInputFile(path); + } + + @Override + public OutputFile newOutputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + delegate.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + this.delegate = new HadoopFileIO(); + this.delegate.initialize(properties); + } + + @Override + public void close() { + if (delegate != null) { + delegate.close(); + } + } + } +} diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java index 54048bbf218a..ab760010535b 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java @@ -29,11 +29,16 @@ import java.nio.file.Paths; import java.util.List; import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoder; @@ -69,6 +74,7 @@ public static void startSpark() { .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) .config("spark.sql.shuffle.partitions", 4) + .config(TestBase.DISABLE_UI) .getOrCreate(); } @@ -263,6 +269,50 @@ public void testStreamingWriteCompleteModeWithProjection() throws Exception { } } + @Test + public void testStreamingWriteDataFilesInTableSortOrder() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + MemoryStream inputStream = newMemoryStream(1, spark.sqlContext(), Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .selectExpr("value AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("append") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + query.processAllAvailable(); + query.stop(); + + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + @Test public void testStreamingWriteUpdateMode() throws Exception { File parent = temp.resolve("parquet").toFile(); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java index 5a68b7a41488..d97e6ec00d7f 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java @@ -31,16 +31,21 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.IntStream; import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.DataFile; import org.apache.iceberg.DataFiles; import org.apache.iceberg.DataOperations; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.Files; +import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; import org.apache.iceberg.RewriteFiles; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableOperations; @@ -49,15 +54,22 @@ import org.apache.iceberg.data.GenericRecord; import org.apache.iceberg.data.Record; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadConf; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.api.java.function.VoidFunction2; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; import org.apache.spark.sql.Row; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.streaming.Offset; import org.apache.spark.sql.internal.SQLConf; import org.apache.spark.sql.streaming.DataStreamWriter; import org.apache.spark.sql.streaming.OutputMode; @@ -72,10 +84,73 @@ @ExtendWith(ParameterizedTestExtension.class) public final class TestStructuredStreamingRead3 extends CatalogTestBase { + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, async = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + false + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + true + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + false + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + true + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + false + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + true + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + false + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + true + } + }; + } + private Table table; private final AtomicInteger microBatches = new AtomicInteger(); + @Parameter(index = 3) + private Boolean async; + /** * test data to be used by multiple writes each write creates a snapshot and writes a list of * records @@ -249,15 +324,41 @@ public void testReadStreamWithCompositeReadLimit() throws Exception { Trigger.AvailableNow()); } + @TestTemplate + public void testReadStreamWithLowAsyncQueuePreload() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + // Set low preload limits to test async queue behavior - background thread should load + // remaining data + + StreamingQuery query = + startStream( + ImmutableMap.of( + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "5", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "5")); + + List actual = rowsAvailable(query); + assertThat(actual) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(TEST_DATA_MULTIPLE_SNAPSHOTS)); + } + @TestTemplate public void testAvailableNowStreamReadShouldNotHangOrReprocessData() throws Exception { File writerCheckpointFolder = temp.resolve("writer-checkpoint-folder").toFile(); File writerCheckpoint = new File(writerCheckpointFolder, "writer-checkpoint"); File output = temp.resolve("junit").toFile(); + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + DataStreamWriter querySource = spark .readStream() + .options(options) .format("iceberg") .load(tableName) .writeStream() @@ -312,10 +413,17 @@ public void testTriggerAvailableNowDoesNotProcessNewDataWhileRunning() throws Ex long expectedSnapshotId = table.currentSnapshot().snapshotId(); String sinkTable = "availablenow_sink"; + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + StreamingQuery query = spark .readStream() - .option(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1") + .options(options) .format("iceberg") .load(tableName) .writeStream() @@ -357,6 +465,142 @@ public void testTriggerAvailableNowDoesNotProcessNewDataWhileRunning() throws Ex assertThat(actualResults).containsExactlyInAnyOrderElementsOf(Iterables.concat(expectedData)); } + @TestTemplate + public void testTriggerAvailableNowCapsAsyncPreloadAfterPrepare() { + List> initialData = + List.of(List.of(new SimpleRecord(1, "one")), List.of(new SimpleRecord(2, "two"))); + appendDataAsMultipleSnapshots(initialData); + + table.refresh(); + long expectedCapSnapshotId = table.currentSnapshot().snapshotId(); + + SparkMicroBatchStream stream = + new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf( + spark, + table, + ImmutableMap.of( + SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, + async.toString(), + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, + "1", + SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, + "1", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "10", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "10")), + table.schema(), + temp.resolve("available-now-cap-checkpoint").toString()); + + try { + stream.prepareForTriggerAvailableNow(); + + appendData(List.of(new SimpleRecord(3, "three"))); + + Offset startOffset = stream.initialOffset(); + Offset firstEndOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + assertThat(firstEndOffset).isNotNull(); + stream.planInputPartitions(startOffset, firstEndOffset); + + Offset secondEndOffset = stream.latestOffset(firstEndOffset, stream.getDefaultReadLimit()); + assertThat(secondEndOffset).isNotNull(); + stream.planInputPartitions(firstEndOffset, secondEndOffset); + + assertThat(stream.latestOffset(secondEndOffset, stream.getDefaultReadLimit())).isNull(); + assertThat(((StreamingOffset) secondEndOffset).snapshotId()).isEqualTo(expectedCapSnapshotId); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testLatestOffsetReturnsNullAfterFinalBatchIsConsumed() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + table.refresh(); + int expectedBatchCount; + try (CloseableIterable tasks = table.newScan().planFiles()) { + expectedBatchCount = Iterables.size(tasks); + } + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "drain-to-null-checkpoint"); + + try { + int plannedBatchCount = 0; + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + InputPartition[] partitions = stream.planInputPartitions(startOffset, endOffset); + assertThat(partitions).isNotEmpty(); + plannedBatchCount += 1; + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + assertThat(plannedBatchCount).isEqualTo(expectedBatchCount); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testPlanInputPartitionsIsIdempotentForSameOffsets() { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "idempotent-plan-files-checkpoint"); + + try { + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + + assertThat(endOffset).isNotNull(); + + InputPartition[] firstPartitions = stream.planInputPartitions(startOffset, endOffset); + InputPartition[] secondPartitions = stream.planInputPartitions(startOffset, endOffset); + + List firstFileLocations = Lists.newArrayList(); + for (InputPartition partition : firstPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + firstFileLocations.add(task.file().location()); + } + } + + List secondFileLocations = Lists.newArrayList(); + for (InputPartition partition : secondPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + secondFileLocations.add(task.file().location()); + } + } + + assertThat(firstFileLocations).containsExactlyInAnyOrderElementsOf(secondFileLocations); + + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + assertThat(stream.planInputPartitions(startOffset, endOffset)).isNotEmpty(); + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + } finally { + stream.stop(); + } + } + @TestTemplate public void testReadStreamOnIcebergThenAddData() throws Exception { List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; @@ -424,6 +668,8 @@ public void testReadingStreamFromFutureTimetsamp() throws Exception { // Data appended after the timestamp should appear appendData(data); + // Allow async background thread to refresh, else test sometimes fails + Thread.sleep(50); actual = rowsAvailable(query); assertThat(actual).containsExactlyInAnyOrderElementsOf(data); } @@ -833,7 +1079,8 @@ public void makeRewriteDataFiles() { Iterable it = table.snapshots(); for (Snapshot snapshot : it) { if (snapshot.operation().equals(DataOperations.APPEND)) { - Iterable datafiles = snapshot.addedDataFiles(table.io()); + Iterable datafiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(); for (DataFile datafile : datafiles) { rewrite.addFile(datafile); rewrite.deleteFile(datafile); @@ -870,13 +1117,18 @@ private void appendData(List data, String format) { private static final String MEMORY_TABLE = "_stream_view_mem"; private StreamingQuery startStream(Map options) throws TimeoutException { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } return spark .readStream() - .options(options) + .options(allOptions) .format("iceberg") .load(tableName) .writeStream() - .options(options) + .options(allOptions) .format("memory") .queryName(MEMORY_TABLE) .outputMode(OutputMode.Append()) @@ -901,11 +1153,17 @@ private void assertMicroBatchRecordSizes( private void assertMicroBatchRecordSizes( Map options, List expectedMicroBatchRecordSize, Trigger trigger) throws TimeoutException { - Dataset ds = spark.readStream().options(options).format("iceberg").load(tableName); + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + Dataset ds = spark.readStream().options(allOptions).format("iceberg").load(tableName); List syncList = Collections.synchronizedList(Lists.newArrayList()); ds.writeStream() - .options(options) + .options(allOptions) .trigger(trigger) .foreachBatch( (VoidFunction2, Long>) @@ -927,4 +1185,21 @@ private List rowsAvailable(StreamingQuery query) { .as(Encoders.bean(SimpleRecord.class)) .collectAsList(); } + + private SparkMicroBatchStream newMicroBatchStream( + Map options, String checkpointDirName) { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + return new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf(spark, table, allOptions), + table.schema(), + temp.resolve(checkpointDirName).toString()); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java index c3fac70dd3fc..45ff9184566b 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java @@ -41,6 +41,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.ByteBuffers; @@ -84,6 +85,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestWriteMetricsConfig.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java index 5ce56b4feca7..646e96eb541a 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java @@ -63,6 +63,7 @@ public static void startMetastoreAndSpark() { SparkSession.builder() .master("local[2]") .config("spark.sql.iceberg.aggregate_pushdown", "true") + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); @@ -123,8 +124,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { + "max(boolean_data), min(boolean_data), count(boolean_data), " + "max(float_data), min(float_data), count(float_data), " + "max(double_data), min(double_data), count(double_data), " - + "max(decimal_data), min(decimal_data), count(decimal_data), " - + "max(binary_data), min(binary_data), count(binary_data) FROM %s"; + + "max(decimal_data), min(decimal_data), count(decimal_data) FROM %s"; List explain = sql("EXPLAIN " + select, tableName); String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); @@ -147,10 +147,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { && explainString.contains("count(double_data)") && explainString.contains("max(decimal_data)") && explainString.contains("min(decimal_data)") - && explainString.contains("count(decimal_data)") - && explainString.contains("max(binary_data)") - && explainString.contains("min(binary_data)") - && explainString.contains("count(binary_data)")) { + && explainString.contains("count(decimal_data)")) { explainContainsPushDownAggregates = true; } @@ -180,10 +177,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { 5L, new BigDecimal("66.66"), new BigDecimal("11.11"), - 6L, - new byte[] {85, 85}, - new byte[] {17, 17}, - 5L + 6L }); assertEquals("min/max/count push down", expected, actual); } @@ -333,14 +327,14 @@ public void testAggregatePushDownWithMetricsMode() { @TestTemplate public void testAggregateNotPushDownForStringType() { sql("CREATE TABLE %s (id LONG, data STRING) USING iceberg", tableName); - sql( - "INSERT INTO TABLE %s VALUES (1, '1111'), (1, '2222'), (2, '3333'), (2, '4444'), (3, '5555'), (3, '6666') ", - tableName); sql( "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", - tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(16)"); + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, '111111'), (1, '2222'), (2, '3333'), (2, '4444'), (3, '5555'), (3, '666666') ", + tableName); - String select1 = "SELECT MAX(id), MAX(data) FROM %s"; + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; List explain1 = sql("EXPLAIN " + select1, tableName); String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); @@ -355,7 +349,7 @@ public void testAggregateNotPushDownForStringType() { List actual1 = sql(select1, tableName); List expected1 = Lists.newArrayList(); - expected1.add(new Object[] {3L, "6666"}); + expected1.add(new Object[] {3L, "666666", "111111"}); assertEquals("expected and actual should equal", expected1, actual1); String select2 = "SELECT COUNT(data) FROM %s"; @@ -378,20 +372,93 @@ public void testAggregateNotPushDownForStringType() { sql( "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); - String select3 = "SELECT count(data), max(data) FROM %s"; + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; List explain3 = sql("EXPLAIN " + select3, tableName); String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { explainContainsPushDownAggregates = true; } + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual3 = sql(select3, tableName); + List expected3 = Lists.newArrayList(); + expected3.add(new Object[] {6L, "666666", "111111"}); + assertEquals("expected and actual should equal", expected3, actual3); + } + + @TestTemplate + public void testAggregateNotPushDownForBinaryType() { + sql("CREATE TABLE %s (id LONG, data BINARY) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, X'111111111111'), (1, X'2222')," + + " (2, X'3333'), (2, X'4444')," + + " (3, X'5555'), (3, X'555555555555') ", + tableName); + + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; + + List explain1 = sql("EXPLAIN " + select1, tableName); + String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString1.contains("max(id)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual1 = sql(select1, tableName); + List expected1 = Lists.newArrayList(); + expected1.add( + new Object[] { + 3L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); + assertEquals("expected and actual should equal", expected1, actual1); + + String select2 = "SELECT COUNT(data) FROM %s"; + List explain2 = sql("EXPLAIN " + select2, tableName); + String explainString2 = explain2.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString2.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + assertThat(explainContainsPushDownAggregates) .as("explain should contain the pushed down aggregates") .isTrue(); + List actual2 = sql(select2, tableName); + List expected2 = Lists.newArrayList(); + expected2.add(new Object[] {6L}); + assertEquals("expected and actual should equal", expected2, actual2); + + explainContainsPushDownAggregates = false; + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; + List explain3 = sql("EXPLAIN " + select3, tableName); + String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + List actual3 = sql(select3, tableName); List expected3 = Lists.newArrayList(); - expected3.add(new Object[] {6L, "6666"}); + expected3.add( + new Object[] { + 6L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); assertEquals("expected and actual should equal", expected3, actual3); } @@ -766,6 +833,51 @@ public void testNaN() { assertEquals("expected and actual should equal", expected, actual); } + @TestTemplate + public void testNanWithLowerAndUpperBoundMetrics() { + sql("CREATE TABLE %s (id int, data float) USING iceberg PARTITIONED BY (id)", tableName); + sql( + "INSERT INTO %s VALUES (1, float('nan'))," + + "(1, float('nan')), " + + "(1, 10.0), " + + "(2, 2), " + + "(2, float('nan')), " + + "(3, float('nan')), " + + "(3, 1)", + tableName); + + // Validate all files has upper bound, lower bound and nan count + String countsQuery = + "select readable_metrics.data.nan_value_count > 0, " + + "isnull(readable_metrics.data.lower_bound), " + + "isnull(readable_metrics.data.upper_bound) " + + "from %s.files"; + + Object[] expectedResult = new Object[] {true, false, false}; + assertThat(sql(countsQuery, tableName)) + .as("Data files should contain nan count, lower bound and upper bound.") + .allMatch(row -> Arrays.equals(row, expectedResult)); + + // Check aggregates are not pushed down + String select = "SELECT count(*), max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = + (explainString.contains("max(data)") + || explainString.contains("min(data)") + || explainString.contains("count(data)")); + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {7L, Float.NaN, 1.0F, 7L}); + assertEquals("expected and actual should equal", expected, actual); + } + @TestTemplate public void testInfinity() { sql( diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java index 7fb1fda3364f..afb4e9964842 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java @@ -40,7 +40,6 @@ import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.NestedField; import org.apache.iceberg.types.Types.StructType; -import org.apache.spark.sql.connector.catalog.TableCatalog; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; @@ -273,7 +272,7 @@ public void testCreateTableComment() { assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); assertThat(table.properties()) .doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT) - .containsEntry(TableCatalog.PROP_COMMENT, "Table doc"); + .containsEntry(TableProperties.COMMENT, "Table doc"); } @TestTemplate diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java index bd4a41593c34..2389bcc17387 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java @@ -162,4 +162,27 @@ public void testTruncate() { ImmutableList.of(), sql("SELECT * FROM %s ORDER BY id", tableName)); } + + @TestTemplate + public void testDeleteFromTablePartitionedByVarbinary() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data binary) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES(1, X'e3bcd1'), (2, X'bcd1')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47}), row(2L, new byte[] {-68, -47})), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE data = X'bcd1'", tableName); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47})), + sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47})), + sql("SELECT * FROM %s where data = X'e3bcd1'", tableName)); + } } diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java index 1bfeaded61c9..b5c357e6363e 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java @@ -22,6 +22,7 @@ import java.util.Set; import org.apache.iceberg.DataFile; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.spark.CatalogTestBase; @@ -65,7 +66,7 @@ public void testRefreshCommand() { // Modify table outside of spark, it should be cached so Spark should see the same value after // mutation Table table = validationCatalog.loadTable(tableIdent); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); table.newDelete().deleteFile(file).commit(); List cachedActual = sql("SELECT * FROM %s", tableName); diff --git a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java index 8fa8406278ea..21d5afcf1db7 100644 --- a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java +++ b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java @@ -22,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.sql.Timestamp; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; @@ -30,7 +31,10 @@ import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.events.Listeners; import org.apache.iceberg.events.ScanEvent; import org.apache.iceberg.exceptions.ValidationException; @@ -40,6 +44,7 @@ import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkCatalogConfig; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.junit.jupiter.api.AfterEach; @@ -107,7 +112,10 @@ public void testSelect() { List expected = ImmutableList.of(row(1L, "a", 1.0F), row(2L, "b", 2.0F), row(3L, "c", Float.NaN)); - assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); } @TestTemplate @@ -118,7 +126,10 @@ public void testSelectWithSpecifiedTargetSplitSize() { Table table = validationCatalog.loadTable(tableIdent); table.updateProperties().set("read.split.target-size", "1024").commit(); spark.sql("REFRESH TABLE " + tableName); - assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); // Query failed when `SPLIT_SIZE` < 0 table.updateProperties().set(SPLIT_SIZE, "-1").commit(); @@ -158,16 +169,20 @@ public void selectWithLimit() { // verify that LIMIT is properly applied in case SupportsPushDownLimit.isPartiallyPushed() is // ever overridden in SparkScanBuilder - assertThat(sql("SELECT * FROM %s LIMIT 1", tableName)).containsExactly(first); - assertThat(sql("SELECT * FROM %s LIMIT 2", tableName)).containsExactly(first, second); - assertThat(sql("SELECT * FROM %s LIMIT 3", tableName)).containsExactly(first, second, third); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 1", tableName)).containsExactly(first); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 2", tableName)) + .containsExactly(first, second); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 3", tableName)) + .containsExactly(first, second, third); } @TestTemplate public void testProjection() { List expected = ImmutableList.of(row(1L), row(2L), row(3L)); - - assertEquals("Should return all expected rows", expected, sql("SELECT id FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id FROM %s ORDER BY id", tableName)); assertThat(scanEventCount).as("Should create only one scan").isEqualTo(1); assertThat(lastScanEvent.filter()) @@ -209,14 +224,14 @@ public void testMetadataTables() { public void testSnapshotInTableName() { // get the snapshot ID of the last write and get the current row set as expected long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); String prefix = "snapshot_id_"; // read the table at the snapshot - List actual = sql("SELECT * FROM %s.%s", tableName, prefix + snapshotId); + List actual = sql("SELECT * FROM %s.%s ORDER BY id", tableName, prefix + snapshotId); assertEquals("Snapshot at specific ID, prefix " + prefix, expected, actual); // read the table using DataFrameReader option @@ -225,7 +240,8 @@ public void testSnapshotInTableName() { .read() .format("iceberg") .option(SparkReadOptions.SNAPSHOT_ID, snapshotId) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); } @@ -235,14 +251,14 @@ public void testTimestampInTableName() { // get a timestamp just after the last write and get the current row set as expected long snapshotTs = validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis(); long timestamp = waitUntilAfter(snapshotTs + 2); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); String prefix = "at_timestamp_"; // read the table at the snapshot - List actual = sql("SELECT * FROM %s.%s", tableName, prefix + timestamp); + List actual = sql("SELECT * FROM %s.%s ORDER BY id", tableName, prefix + timestamp); assertEquals("Snapshot at timestamp, prefix " + prefix, expected, actual); // read the table using DataFrameReader option @@ -251,7 +267,8 @@ public void testTimestampInTableName() { .read() .format("iceberg") .option(SparkReadOptions.AS_OF_TIMESTAMP, timestamp) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); } @@ -260,19 +277,20 @@ public void testTimestampInTableName() { public void testVersionAsOf() { // get the snapshot ID of the last write and get the current row set as expected long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); // read the table at the snapshot - List actual1 = sql("SELECT * FROM %s VERSION AS OF %s", tableName, snapshotId); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, snapshotId); assertEquals("Snapshot at specific ID", expected, actual1); // read the table at the snapshot // HIVE time travel syntax List actual2 = - sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF %s", tableName, snapshotId); + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF %s ORDER BY id", tableName, snapshotId); assertEquals("Snapshot at specific ID", expected, actual2); // read the table using DataFrameReader option: versionAsOf @@ -281,7 +299,8 @@ public void testVersionAsOf() { .read() .format("iceberg") .option(SparkReadOptions.VERSION_AS_OF, snapshotId) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); } @@ -291,28 +310,35 @@ public void testTagReference() { Table table = validationCatalog.loadTable(tableIdent); long snapshotId = table.currentSnapshot().snapshotId(); table.manageSnapshots().createTag("test_tag", snapshotId).commit(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot, read the table at the tag sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); - List actual1 = sql("SELECT * FROM %s VERSION AS OF 'test_tag'", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_tag' ORDER BY id", tableName); assertEquals("Snapshot at specific tag reference name", expected, actual1); // read the table at the tag // HIVE time travel syntax - List actual2 = sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_tag'", tableName); + List actual2 = + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_tag' ORDER BY id", tableName); assertEquals("Snapshot at specific tag reference name", expected, actual2); // Spark session catalog does not support extended table names if (!"spark_catalog".equals(catalogName)) { // read the table using the "tag_" prefix in the table name - List actual3 = sql("SELECT * FROM %s.tag_test_tag", tableName); + List actual3 = sql("SELECT * FROM %s.tag_test_tag ORDER BY id", tableName); assertEquals("Snapshot at specific tag reference name, prefix", expected, actual3); } // read the table using DataFrameReader option: tag Dataset df = - spark.read().format("iceberg").option(SparkReadOptions.TAG, "test_tag").load(tableName); + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TAG, "test_tag") + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific tag reference name", expected, fromDF); } @@ -323,7 +349,7 @@ public void testUseSnapshotIdForTagReferenceAsOf() { long snapshotId1 = table.currentSnapshot().snapshotId(); // create a second snapshot, read the table at the snapshot - List actual = sql("SELECT * FROM %s", tableName); + List actual = sql("SELECT * FROM %s ORDER BY id", tableName); sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); table.refresh(); @@ -334,11 +360,11 @@ public void testUseSnapshotIdForTagReferenceAsOf() { // this means if a tag name matches a snapshot ID, it will always choose snapshotID to travel // to. List travelWithStringResult = - sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, snapshotId1); + sql("SELECT * FROM %s VERSION AS OF '%s' ORDER BY id", tableName, snapshotId1); assertEquals("Snapshot at specific tag reference name", actual, travelWithStringResult); List travelWithLongResult = - sql("SELECT * FROM %s VERSION AS OF %s", tableName, snapshotId1); + sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, snapshotId1); assertEquals("Snapshot at specific tag reference name", actual, travelWithLongResult); } @@ -347,23 +373,24 @@ public void testBranchReference() { Table table = validationCatalog.loadTable(tableIdent); long snapshotId = table.currentSnapshot().snapshotId(); table.manageSnapshots().createBranch("test_branch", snapshotId).commit(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot, read the table at the branch sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); - List actual1 = sql("SELECT * FROM %s VERSION AS OF 'test_branch'", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_branch' ORDER BY id", tableName); assertEquals("Snapshot at specific branch reference name", expected, actual1); // read the table at the branch // HIVE time travel syntax List actual2 = - sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_branch'", tableName); + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_branch' ORDER BY id", tableName); assertEquals("Snapshot at specific branch reference name", expected, actual2); // Spark session catalog does not support extended table names if (!"spark_catalog".equals(catalogName)) { // read the table using the "branch_" prefix in the table name - List actual3 = sql("SELECT * FROM %s.branch_test_branch", tableName); + List actual3 = sql("SELECT * FROM %s.branch_test_branch ORDER BY id", tableName); assertEquals("Snapshot at specific branch reference name, prefix", expected, actual3); } @@ -373,7 +400,8 @@ public void testBranchReference() { .read() .format("iceberg") .option(SparkReadOptions.BRANCH, "test_branch") - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific branch reference name", expected, fromDF); } @@ -386,7 +414,7 @@ public void readAndWriteWithBranchAfterSchemaChange() { List expected = Arrays.asList(row(1L, "a", 1.0f), row(2L, "b", 2.0f), row(3L, "c", Float.NaN)); - assertThat(sql("SELECT * FROM %s", tableName)).containsExactlyElementsOf(expected); + assertThat(sql("SELECT * FROM %s", tableName)).containsExactlyInAnyOrderElementsOf(expected); // change schema on the table and add more data sql("ALTER TABLE %s DROP COLUMN float", tableName); @@ -397,16 +425,16 @@ public void readAndWriteWithBranchAfterSchemaChange() { // time-travel query using snapshot id should return the snapshot's schema long branchSnapshotId = table.refs().get(branchName).snapshotId(); - assertThat(sql("SELECT * FROM %s VERSION AS OF %s", tableName, branchSnapshotId)) + assertThat(sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, branchSnapshotId)) .containsExactlyElementsOf(expected); // querying the head of the branch should return the table's schema - assertThat(sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, branchName)) + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER BY id", tableName, branchName)) .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); if (!"spark_catalog".equals(catalogName)) { // querying the head of the branch using 'branch_' should return the table's schema - assertThat(sql("SELECT * FROM %s.branch_%s", tableName, branchName)) + assertThat(sql("SELECT * FROM %s.branch_%s ORDER BY id", tableName, branchName)) .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); } @@ -416,7 +444,7 @@ public void readAndWriteWithBranchAfterSchemaChange() { tableName, branchName); // querying the head of the branch returns the table's schema - assertThat(sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, branchName)) + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER BY id", tableName, branchName)) .containsExactlyInAnyOrder( row(1L, "a", null), row(2L, "b", null), @@ -453,30 +481,30 @@ public void testTimestampAsOf() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String formattedDate = sdf.format(new Date(timestamp)); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); // read the table at the timestamp in long format i.e 1656507980463. List actualWithLongFormat = - sql("SELECT * FROM %s TIMESTAMP AS OF %s", tableName, timestampInSeconds); + sql("SELECT * FROM %s TIMESTAMP AS OF %s ORDER BY id", tableName, timestampInSeconds); assertEquals("Snapshot at timestamp", expected, actualWithLongFormat); // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 List actualWithDateFormat = - sql("SELECT * FROM %s TIMESTAMP AS OF '%s'", tableName, formattedDate); + sql("SELECT * FROM %s TIMESTAMP AS OF '%s' ORDER BY id", tableName, formattedDate); assertEquals("Snapshot at timestamp", expected, actualWithDateFormat); // HIVE time travel syntax // read the table at the timestamp in long format i.e 1656507980463. List actualWithLongFormatInHiveSyntax = - sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF %s", tableName, timestampInSeconds); + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF %s ORDER BY id", tableName, timestampInSeconds); assertEquals("Snapshot at specific ID", expected, actualWithLongFormatInHiveSyntax); // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 List actualWithDateFormatInHiveSyntax = - sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF '%s'", tableName, formattedDate); + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF '%s' ORDER BY id", tableName, formattedDate); assertEquals("Snapshot at specific ID", expected, actualWithDateFormatInHiveSyntax); // read the table using DataFrameReader option @@ -485,7 +513,8 @@ public void testTimestampAsOf() { .read() .format("iceberg") .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedDate) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); } @@ -605,6 +634,29 @@ public void testBinaryInFilter() { sql("SELECT id, binary FROM %s where binary > X'11'", binaryTableName)); } + @TestTemplate + public void testFixedInFilter() { + // Create table programmatically with fixed type since Spark SQL DDL doesn't support it + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "fixed", Types.FixedType.ofLength(2))); + + TableIdentifier fixedTableIdent = TableIdentifier.of(tableIdent.namespace(), "fixed_table"); + validationCatalog.createTable(fixedTableIdent, schema, PartitionSpec.unpartitioned()); + + String fixedTableName = tableName("fixed_table"); + sql("INSERT INTO %s VALUES (1, X'0000'), (2, X'1111'), (3, X'0011')", fixedTableName); + List expected = ImmutableList.of(row(2L, new byte[] {0x11, 0x11})); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id, fixed FROM %s WHERE fixed > X'0011'", fixedTableName)); + + sql("DROP TABLE IF EXISTS %s", fixedTableName); + } + @TestTemplate public void testComplexTypeFilter() { String complexTypeTableName = tableName("complex_table"); @@ -643,4 +695,40 @@ public void testRequiredNestedFieldInOptionalStructFilter() { assertEquals("Should return all expected rows", ImmutableList.of(row(0)), result); sql("DROP TABLE IF EXISTS %s", nestedStructTable); } + + @TestTemplate + public void simpleTypesInFilter() { + String tableName = tableName("simple_types_table"); + sql( + "CREATE TABLE IF NOT EXISTS %s (id bigint, boolean boolean, integer integer, long long, " + + "float float, double double, string string, date date, timestamp timestamp) USING iceberg", + tableName); + sql( + "INSERT INTO %s VALUES (1, true, 1, 1L, 1.1, 1.3, '1.5', to_date('2021-01-01'), to_timestamp('2021-01-01T00:00:00')), " + + "(2, false, 2, 2L, 2.2, 2.4, '2.6', to_date('2022-02-02'), to_timestamp('2022-02-02T00:00:00')), " + + "(3, true, 3, 3L, 3.3, 3.6, '3.9', to_date('2023-03-03'), to_timestamp('2023-03-03T00:00:00'))", + tableName); + assertThat(sql("SELECT id FROM %s where id > 1", tableName)) + .containsExactlyInAnyOrder(row(2L), row(3L)); + assertThat(sql("SELECT id, boolean FROM %s where boolean = true", tableName)) + .containsExactlyInAnyOrder(row(1L, true), row(3L, true)); + assertThat(sql("SELECT long FROM %s where long > 1", tableName)) + .containsExactlyInAnyOrder(row(2L), row(3L)); + assertThat(sql("SELECT float FROM %s where float > 1.1f", tableName)) + .containsExactlyInAnyOrder(row(2.2f), row(3.3f)); + assertThat(sql("SELECT double FROM %s where double > 1.3", tableName)) + .containsExactlyInAnyOrder(row(2.4d), row(3.6d)); + assertThat(sql("SELECT string FROM %s where string > '1.5'", tableName)) + .containsExactlyInAnyOrder(row("2.6"), row("3.9")); + java.sql.Date dateOne = java.sql.Date.valueOf("2022-02-02"); + java.sql.Date dateTwo = java.sql.Date.valueOf("2023-03-03"); + assertThat(sql("SELECT date FROM %s where date > to_date('2021-01-01')", tableName)) + .containsExactlyInAnyOrder(row(dateOne), row(dateTwo)); + assertThat( + sql("SELECT timestamp FROM %s where timestamp > to_timestamp('2021-01-01')", tableName)) + .containsExactlyInAnyOrder( + row(new Timestamp(dateOne.getTime())), row(new Timestamp(dateTwo.getTime()))); + + sql("DROP TABLE IF EXISTS %s", tableName); + } } diff --git a/spark/v4.0/build.gradle b/spark/v4.0/build.gradle index 8ebed9bd439b..3707e01e4865 100644 --- a/spark/v4.0/build.gradle +++ b/spark/v4.0/build.gradle @@ -20,12 +20,6 @@ String sparkMajorVersion = '4.0' String scalaVersion = '2.13' -JavaVersion javaVersion = JavaVersion.current() -Boolean javaVersionSupported = javaVersion == JavaVersion.VERSION_17 || javaVersion == JavaVersion.VERSION_21 -if (!javaVersionSupported) { - logger.warn("Skip Spark 4.0 build which requires JDK 17 or 21 but was executed with JDK " + javaVersion) -} - def sparkProjects = [ project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}"), project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}"), @@ -33,9 +27,6 @@ def sparkProjects = [ ] configure(sparkProjects) { - tasks.configureEach { - onlyIf { javaVersionSupported } - } configurations { all { resolutionStrategy { @@ -84,8 +75,6 @@ project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") { exclude group: 'org.roaringbitmap' } - compileOnly "org.apache.datafusion:comet-spark-spark${sparkMajorVersion}_2.13:${libs.versions.comet.get()}" - implementation libs.parquet.column implementation libs.parquet.hadoop @@ -116,14 +105,10 @@ project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") { testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') - testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - testImplementation libs.sqlite.jdbc + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) testImplementation libs.awaitility testImplementation(testFixtures(project(':iceberg-parquet'))) - // runtime dependencies for running REST Catalog based integration test - testRuntimeOnly libs.jetty.servlet } test { @@ -184,17 +169,10 @@ project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVer testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') testImplementation project(path: ':iceberg-hive-metastore', configuration: 'testArtifacts') testImplementation project(path: ":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}", configuration: 'testArtifacts') - testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - // runtime dependencies for running REST Catalog based integration test - testRuntimeOnly libs.jetty.servlet - testRuntimeOnly libs.sqlite.jdbc - + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) testImplementation libs.avro.avro testImplementation libs.parquet.hadoop testImplementation libs.awaitility - testImplementation "org.apache.datafusion:comet-spark-spark${sparkMajorVersion}_2.13:${libs.versions.comet.get()}" testImplementation(testFixtures(project(':iceberg-parquet'))) // Required because we remove antlr plugin dependencies from the compile configuration, see note above @@ -267,7 +245,9 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio } integrationImplementation "org.scala-lang.modules:scala-collection-compat_${scalaVersion}:${libs.versions.scala.collection.compat.get()}" - integrationImplementation "org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark40.get()}" + integrationImplementation("org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark40.get()}") { + exclude group: 'org.roaringbitmap' + } integrationImplementation libs.junit.jupiter integrationImplementation libs.junit.platform.launcher integrationImplementation libs.slf4j.simple @@ -281,11 +261,7 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio integrationRuntimeOnly project(':iceberg-hive-metastore') // runtime dependencies for running REST Catalog based integration test integrationRuntimeOnly project(path: ':iceberg-core', configuration: 'testArtifacts') - integrationRuntimeOnly (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) { - transitive = false - } - integrationRuntimeOnly libs.jetty.servlet - integrationRuntimeOnly libs.sqlite.jdbc + integrationRuntimeOnly (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) // Not allowed on our classpath, only the runtime jar is allowed integrationCompileOnly project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}") @@ -345,5 +321,7 @@ project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersio jar { enabled = false } + + apply from: "${rootDir}/runtime-deps.gradle" } diff --git a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java index 9375ca3a4f46..5287ccd514ab 100644 --- a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java +++ b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java @@ -31,6 +31,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; import org.apache.iceberg.util.ThreadPools; import org.apache.spark.sql.SparkSession; @@ -205,7 +206,7 @@ private void initDataAndDVs() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) diff --git a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java index 963daa2c364c..ea31b98f1ac9 100644 --- a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java +++ b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java @@ -155,7 +155,7 @@ private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java index 34d9d70e6ccb..f9558240f8cb 100644 --- a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java +++ b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java @@ -215,7 +215,7 @@ public void localPlanningViaDistributedScanWithoutFilterWithStats(Blackhole blac private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.driver.maxResultSize", "8G") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) diff --git a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java index 7c2def237874..e9c563b9b0ef 100644 --- a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java +++ b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java @@ -199,7 +199,7 @@ private void initDataAndDeletes() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) diff --git a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java index d917eae5eb0f..caa23625fc44 100644 --- a/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java +++ b/spark/v4.0/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java @@ -138,7 +138,7 @@ private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java index bfcb5af235d3..ef4f0090292c 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java @@ -49,7 +49,12 @@ public class TestExtendedParser { @BeforeAll public static void before() { - spark = SparkSession.builder().master("local").appName("TestExtendedParser").getOrCreate(); + spark = + SparkSession.builder() + .master("local") + .appName("TestExtendedParser") + .config(TestBase.DISABLE_UI) + .getOrCreate(); } @AfterAll diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java index 796c47b545cc..f23a5d9db3ad 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java @@ -59,6 +59,7 @@ public static void startMetastoreAndSpark() { .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") .config( SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), String.valueOf(RANDOM.nextBoolean())) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java index c49def204f7e..26dde2059966 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java @@ -26,6 +26,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -109,10 +110,12 @@ public void testCreateBranchOnEmptyTable() { Snapshot snapshot = table.snapshot(ref.snapshotId()); assertThat(snapshot.parentId()).isNull(); - assertThat(snapshot.addedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); }); } @@ -370,10 +373,12 @@ public void testCreateOrReplaceBranchOnEmptyTable() { Snapshot snapshot = table.snapshot(ref.snapshotId()); assertThat(snapshot.parentId()).isNull(); - assertThat(snapshot.addedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDataFiles(table.io())).isEmpty(); - assertThat(snapshot.addedDeleteFiles(table.io())).isEmpty(); - assertThat(snapshot.removedDeleteFiles(table.io())).isEmpty(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); }); } diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java index f7ded0c4d7d2..d39dff060c9a 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java @@ -162,6 +162,25 @@ public synchronized void testDeleteWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteDeleteSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("DELETE FROM %s WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithPreservedDataGrouping() throws NoSuchTableException { createAndInitPartitionedTable(); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java index fef8b28c689a..394dbbda1a3d 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java @@ -151,6 +151,34 @@ public synchronized void testMergeWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteMergeSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("source", Collections.singletonList(1), Encoders.INT()); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'changed' " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'new')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithReportedPartitioning() { createAndInitTable("id INT, dep STRING"); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java index 21d1377b2b98..b547218acbd4 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java @@ -149,6 +149,25 @@ public synchronized void testUpdateWithConcurrentTableRefresh() throws Exception assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); } + @TestTemplate + public void testCopyOnWriteUpdateSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET dep = 'changed' WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + @TestTemplate public void testRuntimeFilteringWithReportedPartitioning() { createAndInitTable("id INT, dep STRING"); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java index fbf6ce3559a7..79d6bea12f67 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java @@ -1422,6 +1422,62 @@ public void testDeleteToCustomWapBranchWithoutWhereClause() throws NoSuchTableEx }); } + @TestTemplate + public void testDeleteToWapBranchCanDeleteWhereScansWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + + sql("DELETE FROM %s WHERE id = 1", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("DELETE should remove the matching rows from the WAP branch") + .containsExactly(row(0, "hr"), row(2, "hr")); + assertThat(sql("SELECT id, dep FROM %s.branch_main", tableName)) + .as("Main branch must not be modified by a WAP-targeted DELETE") + .containsExactly(row(1, "hr")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + + @TestTemplate + public void testMetadataDeleteToWapBranchCommitsToWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr"), new Employee(5, "eng")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(2, "eng")); + + sql("DELETE FROM %s WHERE dep = 'hr'", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("Metadata delete should remove the hr partition on the WAP branch") + .containsExactly(row(2, "eng"), row(5, "eng")); + assertThat(sql("SELECT id, dep FROM %s.branch_main ORDER BY id", tableName)) + .as("Metadata delete must not commit to main when WAP is set") + .containsExactly(row(1, "hr"), row(5, "eng")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + @TestTemplate public void testDeleteWithFilterOnNestedColumn() { createAndInitNestedColumnsTable(); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java index 9b7ed8f9be95..1d9a3de9fa23 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java @@ -57,6 +57,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -503,7 +504,7 @@ public void testMergeWithStaticPredicatePushDown() { // remove the data file from the 'hr' partition to ensure it is not scanned withUnavailableFiles( - snapshot.addedDataFiles(table.io()), + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(), () -> { // disable dynamic pruning and rely only on static predicate pushdown withSQLConf( diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java index bf9c53f82dbd..11643c495400 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java @@ -36,6 +36,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.deletes.DeleteGranularity; @@ -140,7 +141,8 @@ public void testPositionDeletesAreMaintainedDuringDelete() throws NoSuchTableExc Table table = validationCatalog.loadTable(tableIdent); Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); - assertThat(latest.removedDeleteFiles(table.io())).hasSize(1); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); assertEquals( "Should have expected rows", ImmutableList.of(row(1, "b"), row(2, "e")), @@ -181,7 +183,8 @@ public void testUnpartitionedPositionDeletesAreMaintainedDuringDelete() Table table = validationCatalog.loadTable(tableIdent); Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); - assertThat(latest.removedDeleteFiles(table.io())).hasSize(1); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); assertEquals( "Should have expected rows", ImmutableList.of(row(1, "b"), row(2, "e")), diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java index 737f19e86a95..9a42b58e3434 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java @@ -26,6 +26,7 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.ParameterizedTestExtension; @@ -136,6 +137,34 @@ public void testMergeWithDVAndHistoricalPositionDeletes() { assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); } + @TestTemplate + public void testMergeOnReadMergeSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + createOrReplaceView("source", ImmutableList.of(1, 3), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET id = id + 10 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'hr')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + private void checkMergeDeleteGranularity(DeleteGranularity deleteGranularity) { createTableWithDeleteGranularity( "id INT, dep STRING", "PARTITIONED BY (dep)", deleteGranularity); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java index 1bec21b9b68d..d1c336d5ddeb 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java @@ -25,11 +25,13 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.deletes.DeleteGranularity; @@ -75,7 +77,12 @@ public void testPositionDeletesAreMaintainedDuringUpdate() { String expectedDeleteFilesCount = "2"; validateMergeOnRead(currentSnapshot, "2", expectedDeleteFilesCount, "2"); - assertThat(currentSnapshot.removedDeleteFiles(table.io())).hasSize(2); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); assertEquals( "Should have expected rows", ImmutableList.of( @@ -123,7 +130,12 @@ public void testUnpartitionedPositionDeletesAreMaintainedDuringUpdate() { expectedDeleteFilesCount = "2"; validateMergeOnRead(currentSnapshot, "1", expectedDeleteFilesCount, "1"); - assertThat(currentSnapshot.removedDeleteFiles(table.io())).hasSize(2); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); assertEquals( "Should have expected rows", ImmutableList.of( @@ -213,6 +225,25 @@ public void testUpdateWithDVAndHistoricalPositionDeletes() { assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); } + @TestTemplate + public void testMergeOnReadUpdateSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET id = id + 10 WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + private void initTable(String partitionedBy, DeleteGranularity deleteGranularity) { createTableWithDeleteGranularity("id INT, dep STRING", partitionedBy, deleteGranularity); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java index ac528d1c470e..9ca29635f060 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java @@ -44,6 +44,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.avro.Avro; @@ -578,7 +579,7 @@ public void testMetadataLogEntries() throws Exception { parentSnapshot.schemaId(), parentSnapshot.sequenceNumber()), row( - DateTimeUtils.toJavaTimestamp(currentSnapshot.timestampMillis() * 1000), + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), tableMetadata.metadataFileLocation(), currentSnapshot.snapshotId(), currentSnapshot.schemaId(), @@ -597,8 +598,7 @@ public void testMetadataLogEntries() throws Exception { "Result should match the latest snapshot entry", ImmutableList.of( row( - DateTimeUtils.toJavaTimestamp( - tableMetadata.currentSnapshot().timestampMillis() * 1000), + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), tableMetadata.metadataFileLocation(), tableMetadata.currentSnapshot().snapshotId(), tableMetadata.currentSnapshot().schemaId(), @@ -749,7 +749,9 @@ public void testSnapshotReferencesMetatable() throws Exception { .collectAsList(); assertThat(mainBranch) .hasSize(1) - .containsExactly(RowFactory.create("main", "BRANCH", currentSnapshotId, null, null, null)); + .containsExactly( + RowFactory.create( + SnapshotRef.MAIN_BRANCH, "BRANCH", currentSnapshotId, null, null, null)); assertThat(mainBranch.get(0).schema().fieldNames()) .containsExactly( "name", @@ -817,7 +819,7 @@ public void testSnapshotReferencesMetatable() throws Exception { .collectAsList(); assertThat(mainBranchProjection) .hasSize(1) - .containsExactly(RowFactory.create("main", "BRANCH")); + .containsExactly(RowFactory.create(SnapshotRef.MAIN_BRANCH, "BRANCH")); assertThat(mainBranchProjection.get(0).schema().fieldNames()).containsExactly("name", "type"); List testBranchProjection = diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java index 9246671c0023..ec2b9564da40 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java @@ -288,4 +288,21 @@ public void testMigratePartitionedWithParallelism() throws IOException { ImmutableList.of(row("a", 1L), row("b", 2L)), sql("SELECT * FROM %s ORDER BY id", tableName)); } + + @TestTemplate + public void testMigrateBucketedTable() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet " + + "CLUSTERED BY (id) INTO 4 BUCKETS LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertThatThrownBy(() -> sql("CALL %s.system.migrate('%s')", catalogName, tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot create an Iceberg table from a bucketed source table: " + + "4 buckets, bucket columns: [id]"); + } } diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java index 4958fde15d55..c72770e1cec6 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java @@ -159,6 +159,26 @@ public void testApplyInvalidWapId() { .hasMessage("Cannot apply unknown WAP ID 'not_valid'"); } + @TestTemplate + public void testApplyDuplicateWapId() { + + String wapId = "wap_id_1"; + + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", wapId); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.publish_changes('%s', '%s')", catalogName, tableIdent, wapId)) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID 'wap_id_1'"); + } + @TestTemplate public void testInvalidApplyWapChangesCases() { assertThatThrownBy( diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java index 3aabd635bb69..6b8244a331e7 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java @@ -32,13 +32,9 @@ import java.util.stream.LongStream; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.EnvironmentContext; -import org.apache.iceberg.Files; import org.apache.iceberg.ParameterizedTestExtension; -import org.apache.iceberg.PartitionStatisticsFile; -import org.apache.iceberg.PartitionStats; +import org.apache.iceberg.PartitionStatistics; import org.apache.iceberg.PartitionStatsHandler; -import org.apache.iceberg.Partitioning; -import org.apache.iceberg.Schema; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -54,6 +50,7 @@ import org.apache.iceberg.spark.SparkCatalogConfig; import org.apache.iceberg.spark.SparkTableCache; import org.apache.iceberg.spark.SystemFunctionPushDownHelper; +import org.apache.iceberg.spark.actions.SparkActions; import org.apache.iceberg.spark.source.ThreeColumnRecord; import org.apache.spark.sql.AnalysisException; import org.apache.spark.sql.Dataset; @@ -154,33 +151,36 @@ public void testPartitionStatsIncrementalCompute() throws IOException { insertData(10); Table table = validationCatalog.loadTable(tableIdent); - PartitionStatisticsFile statisticsFile = PartitionStatsHandler.computeAndWriteStatsFile(table); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - - Schema dataSchema = PartitionStatsHandler.schema(Partitioning.partitionType(table), 2); - List statsBeforeCompaction; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsBeforeCompaction; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsBeforeCompaction = Lists.newArrayList(recordIterator); } sql("CALL %s.system.rewrite_data_files(table => '%s')", catalogName, tableIdent); table.refresh(); - statisticsFile = - PartitionStatsHandler.computeAndWriteStatsFile(table, table.currentSnapshot().snapshotId()); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - List statsAfterCompaction; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsAfterCompaction; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsAfterCompaction = Lists.newArrayList(recordIterator); } for (int index = 0; index < statsBeforeCompaction.size(); index++) { - PartitionStats statsAfter = statsAfterCompaction.get(index); - PartitionStats statsBefore = statsBeforeCompaction.get(index); + PartitionStatistics statsAfter = statsAfterCompaction.get(index); + PartitionStatistics statsBefore = statsBeforeCompaction.get(index); assertThat(statsAfter.partition()).isEqualTo(statsBefore.partition()); // data count should match after compaction @@ -976,6 +976,7 @@ public void testRewriteDataFilesSummary() { Map summary = snapshotSummary(); assertThat(summary) .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") .hasEntrySatisfying( EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("4.0")); @@ -1105,4 +1106,209 @@ private List currentData() { private List currentData(String table) { return rowsToJava(spark.sql("SELECT * FROM " + table + " order by c1, c2, c3").collectAsList()); } + + @TestTemplate + public void testRewriteDataFilesOnBranch() { + createTable(); + insertData(10); + + String branchName = "testBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testRewriteDataFilesToNullBranchFails() { + createTable(); + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThatThrownBy(() -> SparkActions.get(spark).rewriteDataFiles(table).toBranch(null)) + .as("Invalid branch") + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid branch name: null"); + } + + @TestTemplate + public void testRewriteDataFilesOnBranchWithFilter() { + createPartitionTable(); + insertData(10); + + String branchName = "filteredBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch with filter (select only partition c2 = 'bar') + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s', where => 'c2 = \"bar\"')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 5 data files from single matching partition" + + "(containing c2 = bar) and add 1 data file", + row(5, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed after rewrite + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testBranchCompactionDoesNotAffectMain() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + // Create branch from current main state + String branchName = "compactionBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + // Add more data to MAIN to make it diverge from branch + insertData(tableName, 10); + + // Refresh to get new main snapshot after divergence + table.refresh(); + long mainSnapshotAfterDivergence = table.currentSnapshot().snapshotId(); + List expectedMainRecords = currentData(); + + // Get branch data before compaction + List expectedBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + long branchSnapshotBeforeCompaction = table.refs().get(branchName).snapshotId(); + + // Verify that branch and main have diverged + assertThat(branchSnapshotBeforeCompaction) + .as("Branch and main should have different snapshots") + .isNotEqualTo(mainSnapshotAfterDivergence); + + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + table.refresh(); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot ID must remain unchanged after branch compaction") + .isEqualTo(mainSnapshotAfterDivergence); + + // Verify main data unchanged + List actualMainRecords = currentData(); + assertEquals( + "Main data after compaction should not change", expectedMainRecords, actualMainRecords); + + // Verify branch data unchanged + List actualBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals( + "Branch data after compaction should not change", + expectedBranchRecords, + actualBranchRecords); + + // Verify branch snapshot changed + long branchSnapshotAfterCompaction = table.refs().get(branchName).snapshotId(); + assertThat(branchSnapshotAfterCompaction) + .as("Branch snapshot must be updated after compaction") + .isNotEqualTo(branchSnapshotBeforeCompaction); + + // Verify the new branch snapshot is a child of the previous branch snapshot + assertThat(table.snapshot(branchSnapshotAfterCompaction).parentId()) + .as("New branch snapshot must be a child of the previous branch snapshot") + .isEqualTo(branchSnapshotBeforeCompaction); + } } diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java index b8dca4b2cd18..b9379fdbd587 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java @@ -26,13 +26,9 @@ import java.sql.Date; import java.sql.Timestamp; import java.util.List; -import org.apache.iceberg.Files; import org.apache.iceberg.ParameterizedTestExtension; -import org.apache.iceberg.PartitionStatisticsFile; -import org.apache.iceberg.PartitionStats; +import org.apache.iceberg.PartitionStatistics; import org.apache.iceberg.PartitionStatsHandler; -import org.apache.iceberg.Partitioning; -import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -408,14 +404,15 @@ public void testPartitionStatsIncrementalCompute() throws IOException { sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); Table table = validationCatalog.loadTable(tableIdent); - PartitionStatisticsFile statisticsFile = PartitionStatsHandler.computeAndWriteStatsFile(table); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - - Schema dataSchema = PartitionStatsHandler.schema(Partitioning.partitionType(table), 2); - List statsBeforeRewrite; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsBeforeRewrite; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsBeforeRewrite = Lists.newArrayList(recordIterator); } @@ -424,19 +421,21 @@ public void testPartitionStatsIncrementalCompute() throws IOException { catalogName, tableIdent); table.refresh(); - statisticsFile = - PartitionStatsHandler.computeAndWriteStatsFile(table, table.currentSnapshot().snapshotId()); - table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); - List statsAfterRewrite; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - dataSchema, Files.localInput(statisticsFile.path()))) { + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsAfterRewrite; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { statsAfterRewrite = Lists.newArrayList(recordIterator); } for (int index = 0; index < statsBeforeRewrite.size(); index++) { - PartitionStats statsAfter = statsAfterRewrite.get(index); - PartitionStats statsBefore = statsBeforeRewrite.get(index); + PartitionStatistics statsAfter = statsAfterRewrite.get(index); + PartitionStatistics statsBefore = statsBeforeRewrite.get(index); assertThat(statsAfter.partition()).isEqualTo(statsBefore.partition()); // data count should match diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java index 4a6d2ff1979e..d5e5aecc75a5 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java @@ -239,11 +239,74 @@ public void testRewriteSummary() throws Exception { Map summary = snapshotSummary(); assertThat(summary) .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") .hasEntrySatisfying( EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("4.0")); } + @TestTemplate + public void testRewritePositionDeletesWithArrayColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, items ARRAY>) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', array(named_struct('value', cast(10 as bigint), 'count', 1))), " + + "(2, 'b', array(named_struct('value', cast(20 as bigint), 'count', 2))), " + + "(3, 'c', array(named_struct('value', cast(30 as bigint), 'count', 3))), " + + "(4, 'd', array(named_struct('value', cast(40 as bigint), 'count', 4))), " + + "(5, 'e', array(named_struct('value', cast(50 as bigint), 'count', 5))), " + + "(6, 'f', array(named_struct('value', cast(60 as bigint), 'count', 6)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + + @TestTemplate + public void testRewritePositionDeletesWithMapColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, props MAP) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', map('x', cast(10 as bigint))), " + + "(2, 'b', map('y', cast(20 as bigint))), " + + "(3, 'c', map('z', cast(30 as bigint))), " + + "(4, 'd', map('w', cast(40 as bigint))), " + + "(5, 'e', map('v', cast(50 as bigint))), " + + "(6, 'f', map('u', cast(60 as bigint)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + private Map snapshotSummary() { return validationCatalog.loadTable(tableIdent).currentSnapshot().summary(); } diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java index 0c83f34219b1..78f6b80ac948 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java @@ -30,6 +30,7 @@ import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RewriteTablePathUtil; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableUtil; import org.apache.iceberg.data.FileHelpers; @@ -213,7 +214,12 @@ public void testRewriteTablePathWithManifestAndDeleteCounts() throws IOException List> rowsToDelete = Lists.newArrayList( Pair.of( - table.currentSnapshot().addedDataFiles(table.io()).iterator().next().location(), + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .iterator() + .next() + .location(), 0L)); File file = new File(removePrefix(table.location()) + "/data/deletes.parquet"); diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSnapshotTableProcedure.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSnapshotTableProcedure.java index 19800c2f4666..3f8b574126ba 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSnapshotTableProcedure.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSnapshotTableProcedure.java @@ -375,4 +375,54 @@ public void testSnapshotPartitionedV1() throws IOException { } } } + + @TestTemplate + public void testSnapshotWithVariant() throws IOException { + assumeThat(catalogName) + .as("Variant type requires Hive 4 which is not yet supported") + .isNotEqualTo("testhive") + .isNotEqualTo("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data variant) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, parse_json('{\"key\": 123}'))", SOURCE_NAME); + + Object result = + scalarSql( + "CALL %s.system.snapshot('%s', '%s', properties => map('format-version','3'))", + catalogName, SOURCE_NAME, tableName); + assertThat(result).as("Should have added one file").isEqualTo(1L); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, 123)), + sql("SELECT id, variant_get(data, '$.key', 'int') FROM %s", tableName)); + } + + @TestTemplate + public void testSnapshotPartitionedWithVariant() throws IOException { + assumeThat(catalogName) + .as("Variant type requires Hive 4 which is not yet supported") + .isNotEqualTo("testhive") + .isNotEqualTo("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data variant) USING parquet PARTITIONED BY (id) LOCATION '%s'", + SOURCE_NAME, location); + sql( + "INSERT INTO TABLE %s (id, data) VALUES (1, parse_json('{\"key\": 123}')), (2, parse_json('{\"key\": 456}'))", + SOURCE_NAME); + + Object result = + scalarSql( + "CALL %s.system.snapshot('%s', '%s', properties => map('format-version','3'))", + catalogName, SOURCE_NAME, tableName); + assertThat(result).as("Should have added two files").isEqualTo(2L); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(123, 1L), row(456, 2L)), + sql("SELECT variant_get(data, '$.key', 'int'), id FROM %s ORDER BY id", tableName)); + } } diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java index 77011fd28cf0..27c64bb28ec5 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java @@ -54,6 +54,7 @@ import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.RowLevelOperationMode; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -1306,7 +1307,9 @@ public void testUpdateWithStaticPredicatePushdown() { assertThat(dataFilesCount).as("Must have 2 files before UPDATE").isEqualTo("2"); // remove the data file from the 'hr' partition to ensure it is not scanned - DataFile dataFile = Iterables.getOnlyElement(snapshot.addedDataFiles(table.io())); + DataFile dataFile = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()); table.io().deleteFile(dataFile.location()); // disable dynamic pruning and rely only on static predicate pushdown diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java index 2406a909049e..237563860366 100644 --- a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java @@ -25,7 +25,6 @@ import java.nio.file.Paths; import java.util.List; -import java.util.Locale; import java.util.Random; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -108,7 +107,12 @@ public static Object[][] parameters() { .putAll(SparkCatalogConfig.SPARK_SESSION_WITH_VIEWS.properties()) .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) .build() - } + }, + { + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.catalogName(), + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.implementation(), + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.properties() + }, }; } @@ -1350,6 +1354,10 @@ public void createViewWithSubqueryExpressionUsingTempFunction() { @TestTemplate public void createViewWithSubqueryExpressionInFilterThatIsRewritten() throws NoSuchTableException { + assumeThat(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE)) + .as( + "Executing subquery expression with Hive fails due to an exception when instantiating the FileInputFormat") + .isNotEqualTo("hive"); insertRows(5); String viewName = viewName("viewWithSubqueryExpression"); String sql = @@ -1376,6 +1384,10 @@ public void createViewWithSubqueryExpressionInFilterThatIsRewritten() @TestTemplate public void createViewWithSubqueryExpressionInQueryThatIsRewritten() throws NoSuchTableException { + assumeThat(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE)) + .as( + "Executing subquery expression with Hive fails due to an exception when instantiating the FileInputFormat") + .isNotEqualTo("hive"); insertRows(3); String viewName = viewName("viewWithSubqueryExpression"); String sql = @@ -1533,25 +1545,26 @@ public void showViews() throws NoSuchTableException { insertRows(6); String sql = String.format("SELECT * from %s", tableName); String v1 = viewName("v1"); - String prefixV2 = viewName("prefixV2"); - String prefixV3 = viewName("prefixV3"); - String globalViewForListing = viewName("globalViewForListing"); - String tempViewForListing = viewName("tempViewForListing"); + String prefixV2 = viewName("prefix_v2"); + String prefixV3 = viewName("prefix_v3"); + String globalViewForListing = viewName("global_view_for_listing"); + String tempViewForListing = viewName("temp_view_for_listing"); sql("CREATE VIEW %s AS %s", v1, sql); sql("CREATE VIEW %s AS %s", prefixV2, sql); sql("CREATE VIEW %s AS %s", prefixV3, sql); sql("CREATE GLOBAL TEMPORARY VIEW %s AS %s", globalViewForListing, sql); sql("CREATE TEMPORARY VIEW %s AS %s", tempViewForListing, sql); - // spark stores temp views case-insensitive by default - Object[] tempView = row("", tempViewForListing.toLowerCase(Locale.ROOT), true); + Object[] globalView = row("global_temp", globalViewForListing, true); + Object[] tempView = row("", tempViewForListing, true); Object[] v1Row = row(NAMESPACE.toString(), v1, false); Object[] v2Row = row(NAMESPACE.toString(), prefixV2, false); Object[] v3Row = row(NAMESPACE.toString(), prefixV3, false); assertThat(sql("SHOW VIEWS")).contains(v2Row, v3Row, v1Row, tempView); - if (!"rest".equals(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE))) { - // REST catalog requires a namespace + String catalogType = catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE); + if (!"rest".equals(catalogType) && !"hive".equals(catalogType)) { + // REST & Hive catalog require a namespace assertThat(sql("SHOW VIEWS IN %s", catalogName)) .contains(tempView) .doesNotContain(v1Row, v2Row, v3Row); @@ -1566,7 +1579,7 @@ public void showViews() throws NoSuchTableException { assertThat(sql("SHOW VIEWS LIKE 'non-existing'")).isEmpty(); - if (!catalogName.equals(SPARK_CATALOG)) { + if (catalogName.equals(SparkCatalogConfig.SPARK_WITH_VIEWS.catalogName())) { sql("CREATE NAMESPACE IF NOT EXISTS spark_catalog.%s", NAMESPACE); assertThat(sql("SHOW VIEWS IN spark_catalog.%s", NAMESPACE)) .contains(tempView) @@ -1574,9 +1587,7 @@ public void showViews() throws NoSuchTableException { } assertThat(sql("SHOW VIEWS IN global_temp")) - .contains( - // spark stores temp views case-insensitive by default - row("global_temp", globalViewForListing.toLowerCase(Locale.ROOT), true), tempView) + .contains(globalView, tempView) .doesNotContain(v1Row, v2Row, v3Row); sql("USE spark_catalog"); @@ -1589,8 +1600,8 @@ public void showViews() throws NoSuchTableException { public void showViewsWithCurrentNamespace() { String namespaceOne = "show_views_ns1"; String namespaceTwo = "show_views_ns2"; - String viewOne = viewName("viewOne"); - String viewTwo = viewName("viewTwo"); + String viewOne = viewName("view_one"); + String viewTwo = viewName("view_two"); sql("CREATE NAMESPACE IF NOT EXISTS %s", namespaceOne); sql("CREATE NAMESPACE IF NOT EXISTS %s", namespaceTwo); @@ -1606,14 +1617,14 @@ public void showViewsWithCurrentNamespace() { .doesNotContain(v2); sql("USE %s", namespaceOne); assertThat(sql("SHOW VIEWS")).contains(v1).doesNotContain(v2); - assertThat(sql("SHOW VIEWS LIKE 'viewOne*'")).contains(v1).doesNotContain(v2); + assertThat(sql("SHOW VIEWS LIKE 'view_one*'")).contains(v1).doesNotContain(v2); assertThat(sql("SHOW VIEWS IN %s.%s", catalogName, namespaceTwo)) .contains(v2) .doesNotContain(v1); sql("USE %s", namespaceTwo); assertThat(sql("SHOW VIEWS")).contains(v2).doesNotContain(v1); - assertThat(sql("SHOW VIEWS LIKE 'viewTwo*'")).contains(v2).doesNotContain(v1); + assertThat(sql("SHOW VIEWS LIKE 'view_two*'")).contains(v2).doesNotContain(v1); } @TestTemplate diff --git a/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java new file mode 100644 index 000000000000..0152c8e0e6f0 --- /dev/null +++ b/spark/v4.0/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.RESTCatalog; +import org.apache.iceberg.rest.RESTCatalogProperties; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.sql.TestSelect; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRemoteScanPlanning extends TestSelect { + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, binaryTableName = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build(), + SparkCatalogConfig.REST.catalogName() + ".default.binary_table" + } + }; + } + + @TestTemplate + public void fileIOIsPropagated() { + RESTCatalog catalog = new RESTCatalog(); + catalog.setConf(new Configuration()); + catalog.initialize( + "test", + ImmutableMap.builder() + .putAll(restCatalog.properties()) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build()); + Table table = catalog.loadTable(tableIdent); + + SparkScanBuilder builder = new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + verifyFileIOHasPlanId(builder.build().toBatch(), table); + verifyFileIOHasPlanId(builder.buildCopyOnWriteScan().toBatch(), table); + } + + private void verifyFileIOHasPlanId(Batch batch, Table table) { + FileIO fileIOForScan = + (FileIO) + assertThat(batch) + .extracting("fileIO") + .isInstanceOf(Supplier.class) + .asInstanceOf(InstanceOfAssertFactories.type(Supplier.class)) + .actual() + .get(); + assertThat(fileIOForScan.properties()).containsKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + assertThat(table.io().properties()).doesNotContainKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + } +} diff --git a/spark/v4.0/spark-runtime/LICENSE b/spark/v4.0/spark-runtime/LICENSE index e36e0b684501..50c91faf8edb 100644 --- a/spark/v4.0/spark-runtime/LICENSE +++ b/spark/v4.0/spark-runtime/LICENSE @@ -203,77 +203,170 @@ -------------------------------------------------------------------------------- -This binary artifact contains Apache Avro. +This product bundles Apache Avro. Copyright: Copyright 2010-2019 The Apache Software Foundation -Home page: https://avro.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://avro.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains the Jackson JSON processor. +This product bundles Jackson JSON processor. Copyright: 2007-2019 Tatu Saloranta and other contributors -Home page: http://jackson.codehaus.org/ -License: http://www.apache.org/licenses/LICENSE-2.0.txt +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Parquet. +This product bundles FastDoubleParser (via Jackson JSON Processor). + +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles fast_float (bundled by FastDoubleParser). + +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles bigint (bundled by FastDoubleParser). + +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Parquet. Copyright: 2014-2024 The Apache Software Foundation -Home page: https://parquet.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://parquet.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Thrift. +This product bundles Apache Thrift (bundled by Parquet). Copyright: 2006-2017 The Apache Software Foundation. -Home page: https://thrift.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://thrift.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Daniel Lemire's JavaFastPFOR project. +This product includes code from Daniel Lemire's JavaFastPFOR project (bundled by Parquet). Copyright: 2013 Daniel Lemire -Home page: https://github.com/lemire/JavaFastPFOR -License: Apache License Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/lemire/JavaFastPFOR +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains fastutil. +This product bundles fastutil (bundled by Parquet). Copyright: 2002-2014 Sebastiano Vigna -Home page: http://fastutil.di.unimi.it/ -License: http://www.apache.org/licenses/LICENSE-2.0.html +Project URL: http://fastutil.di.unimi.it/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache ORC. +This product bundles Zero-Allocation Hashing (bundled by Parquet). + +Project URL: https://github.com/OpenHFT/Zero-Allocation-Hashing +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Apache ORC. Copyright: 2013 and onwards The Apache Software Foundation. -Home page: https://orc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://orc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Hive's storage API via ORC. +This product bundles Apache Hive's Storage API (bundled by ORC). Copyright: 2008-2020 The Apache Software Foundation -Home page: https://hive.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://hive.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google protobuf via ORC. +This product bundles Google protobuf (bundled by ORC). Copyright: 2008 Google Inc. -Home page: https://developers.google.com/protocol-buffers -License: https://github.com/protocolbuffers/protobuf/blob/master/LICENSE (BSD) - -License text: +Project URL: https://developers.google.com/protocol-buffers +License: BSD 3-Clause | Copyright 2008 Google Inc. All rights reserved. | @@ -310,53 +403,433 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Airlift Aircompressor. +This product bundles Airlift Aircompressor. Copyright: 2011-2019 Aircompressor authors. -Home page: https://github.com/airlift/aircompressor -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Cloudera Kite. +This product includes code from Cloudera Kite. Copyright: 2013-2017 Cloudera Inc. -Home page: https://kitesdk.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://kitesdk.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains code from Presto. +This product includes code from Presto. Copyright: 2016 Facebook and contributors -Home page: https://prestodb.io/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://prestodb.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Guava. +This product bundles Google Guava. Copyright: 2006-2019 The Guava Authors -Home page: https://github.com/google/guava -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google Error Prone Annotations. +This product bundles the Mozilla Public Suffix List, distributed by Apache HttpComponents. + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + +This product bundles Google Error Prone Annotations. Copyright: Copyright 2011-2019 The Error Prone Authors -Home page: https://github.com/google/error-prone -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains checkerframework checker-qual Annotations. +This product bundles checkerframework checker-qual. Copyright: 2004-2019 the Checker Framework developers -Home page: https://github.com/typetools/checker-framework -License: https://github.com/typetools/checker-framework/blob/master/LICENSE.txt (MIT license) +Project URL: https://github.com/typetools/checker-framework +License: MIT license -License text: | The annotations are licensed under the MIT License. (The text of this | license appears below.) More specifically, all the parts of the Checker | Framework that you might want to include with your own program use the @@ -387,45 +860,50 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains Caffeine by Ben Manes. +This product bundles Caffeine by Ben Manes. Copyright: 2014-2019 Ben Manes and contributors -Home page: https://github.com/ben-manes/caffeine -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache Arrow. +This product bundles Apache Arrow. Copyright: 2016-2019 The Apache Software Foundation. -Home page: https://arrow.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://arrow.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Netty's buffer library. +This product bundles Netty. Copyright: 2014-2020 The Netty Project -Home page: https://netty.io/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://netty.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JCTools (via Netty). + +Project URL: https://github.com/JCTools/JCTools +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Google FlatBuffers. +This product bundles Google FlatBuffers. Copyright: 2013-2020 Google Inc. -Home page: https://google.github.io/flatbuffers/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://google.github.io/flatbuffers/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains ThreeTen. +This product bundles ThreeTen Extra. Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. -Home page: https://www.threeten.org/threeten-extra/ -License: https://github.com/ThreeTen/threeten-extra/blob/master/LICENSE.txt (BSD 3-clause) - -License text: +Project URL: https://www.threeten.org/threeten-extra/ +License: BSD 3-Clause | All rights reserved. | @@ -457,15 +935,15 @@ License text: -------------------------------------------------------------------------------- -This binary artifact contains code from Project Nessie. +This product bundles Project Nessie. Copyright: 2015-2025 Dremio Corporation. -Home page: https://projectnessie.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://projectnessie.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache Spark. +This product bundles and includes code from Apache Spark. * vectorized reading of definition levels in BaseVectorizedParquetValuesReader.java * portions of the extensions parser @@ -473,8 +951,8 @@ This product includes code from Apache Spark. * implementation of SetAccumulator. Copyright: 2011-2018 The Apache Software Foundation -Home page: https://spark.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://spark.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- @@ -483,48 +961,128 @@ This product includes code from Delta Lake. * AssignmentAlignmentSupport is an independent development but UpdateExpressionsSupport in Delta was used as a reference. Copyright: 2020 The Delta Lake Project Authors. -Home page: https://delta.io/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://delta.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary includes code from Apache Commons. +This product includes code from Apache Commons. * Core ArrayUtil. Copyright: 2020 The Apache Software Foundation -Home page: https://commons.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +Project URL: https://commons.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains Apache HttpComponents Client. +This product bundles and includes code from Apache HttpComponents (core/client). + +* retry and error handling logic in ExponentialHttpRequestRetryStrategy.java -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: http://www.apache.org/licenses/LICENSE-2.0 +Copyright: 1999-2022 The Apache Software Foundation +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This product includes code from Apache HttpComponents Client. +This product bundles failsafe. -* retry and error handling logic in ExponentialHttpRequestRetryStrategy.java +Copyright: Jonathan Halterman and friends +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -Copyright: 1999-2022 The Apache Software Foundation. -Home page: https://hc.apache.org/ -License: https://www.apache.org/licenses/LICENSE-2.0 +-------------------------------------------------------------------------------- + +This product bundles Eclipse MicroProfile OpenAPI. + +Project URL: https://github.com/microprofile/microprofile-open-api +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- -This binary artifact contains failsafe. +This product bundles Eclipse Collections. -Copyright: Jonathan Halterman and friends -Home page: https://failsafe.dev/ -License: https://www.apache.org/licenses/LICENSE-2.0.html +Project URL: https://github.com/eclipse-collections/eclipse-collections +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Datasketches. + +Project URL: https://datasketches.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JTS Topology Suite. + +Project URL: https://github.com/locationtech/jts +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- -This binary artifact contains Eclipse MicroProfile OpenAPI. +This product bundles RoaringBitmap. -Home page: https://github.com/microprofile/microprofile-open-api -License: https://www.apache.org/licenses/LICENSE-2.0 +Copyright: (c) 2013-... the RoaringBitmap authors +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 diff --git a/spark/v4.0/spark-runtime/NOTICE b/spark/v4.0/spark-runtime/NOTICE index b5d1c8d1be4b..797765628363 100644 --- a/spark/v4.0/spark-runtime/NOTICE +++ b/spark/v4.0/spark-runtime/NOTICE @@ -1,15 +1,14 @@ Apache Iceberg -Copyright 2017-2025 The Apache Software Foundation +Copyright 2017-2026 The Apache Software Foundation This product includes software developed at The Apache Software Foundation (http://www.apache.org/). -------------------------------------------------------------------------------- -This binary artifact contains code from Kite, developed at Cloudera, Inc. with -the following copyright notice: - +This product includes code from Kite, developed at Cloudera, Inc. with +the following in its NOTICE file: | Copyright 2013 Cloudera Inc. | | Licensed under the Apache License, Version 2.0 (the "License"); @@ -26,9 +25,7 @@ the following copyright notice: -------------------------------------------------------------------------------- -This binary artifact includes Airlift Aircompressor with the following in its -NOTICE file: - +This product bundles Airlift Aircompressor with the following in its NOTICE file: | Snappy Copyright Notices | ========================= | @@ -69,47 +66,7 @@ NOTICE file: -------------------------------------------------------------------------------- -This binary artifact includes Google Protobuf with the following copyright -notice: - -| Copyright 2008 Google Inc. All rights reserved. -| -| Redistribution and use in source and binary forms, with or without -| modification, are permitted provided that the following conditions are -| met: -| -| * Redistributions of source code must retain the above copyright -| notice, this list of conditions and the following disclaimer. -| * Redistributions in binary form must reproduce the above -| copyright notice, this list of conditions and the following disclaimer -| in the documentation and/or other materials provided with the -| distribution. -| * Neither the name of Google Inc. nor the names of its -| contributors may be used to endorse or promote products derived from -| this software without specific prior written permission. -| -| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -| -| Code generated by the Protocol Buffer compiler is owned by the owner -| of the input file used when generating it. This code is not -| standalone and requires a support library to be linked with it. This -| support library is itself covered by the above license. - --------------------------------------------------------------------------------- - -This binary artifact includes Netty buffers with the following in its NOTICE -file: - +This product bundles Netty with the following in its NOTICE file: | The Netty Project | ================= | @@ -360,9 +317,7 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Project Nessie with the following in its NOTICE -file: - +This product bundles Project Nessie with the following in its NOTICE file: | Nessie | Copyright 2015-2025 Dremio Corporation | @@ -380,8 +335,7 @@ file: -------------------------------------------------------------------------------- -This binary artifact includes Eclipse MicroProfile OpenAPI with the following in its NOTICE file: - +This product bundles Eclipse MicroProfile OpenAPI with the following in its NOTICE file: | ========================================================================= | == NOTICE file corresponding to section 4(d) of the Apache License, == | == Version 2.0, in this case for MicroProfile OpenAPI == @@ -401,3 +355,39 @@ This binary artifact includes Eclipse MicroProfile OpenAPI with the following in | PackageCopyrightText: | Arthur De Magalhaes arthurdm@ca.ibm.com | + +-------------------------------------------------------------------------------- + +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. diff --git a/spark/v4.0/spark-runtime/runtime-deps.txt b/spark/v4.0/spark-runtime/runtime-deps.txt new file mode 100644 index 000000000000..dc3fbeb7e9d5 --- /dev/null +++ b/spark/v4.0/spark-runtime/runtime-deps.txt @@ -0,0 +1,40 @@ +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.15.2 +com.fasterxml.jackson.core:jackson-databind:2.15.2 +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.3 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.google.errorprone:error_prone_annotations:2.10.0 +com.google.flatbuffers:flatbuffers-java:23.5.26 +dev.failsafe:failsafe:3.3.2 +io.airlift:aircompressor:2.0.3 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-common:4.2.13.Final +org.apache.arrow:arrow-format:15.0.2 +org.apache.arrow:arrow-memory-core:15.0.2 +org.apache.arrow:arrow-memory-netty:15.0.2 +org.apache.arrow:arrow-vector:15.0.2 +org.apache.avro:avro:1.12.1 +org.apache.datasketches:datasketches-java:6.2.0 +org.apache.datasketches:datasketches-memory:3.0.2 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.checkerframework:checker-qual:3.19.0 +org.eclipse.collections:eclipse-collections-api:11.1.0 +org.eclipse.collections:eclipse-collections:11.1.0 +org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1 +org.locationtech.jts:jts-core:1.20.0 +org.projectnessie.nessie:nessie-client:0.107.5 +org.projectnessie.nessie:nessie-model:0.107.5 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.threeten:threeten-extra:1.7.1 diff --git a/spark/v4.0/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java b/spark/v4.0/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java index 29f725615a21..709f626b08c1 100644 --- a/spark/v4.0/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java +++ b/spark/v4.0/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java @@ -38,8 +38,6 @@ public void dropTable() { } // Run through our Doc's Getting Started Example - // TODO Update doc example so that it can actually be run, modifications were required for this - // test suite to run @TestTemplate public void testGettingStarted() throws IOException { // Creating a table diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java index e1d9ac18dac1..ad4c0f3e67e4 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java @@ -37,6 +37,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.actions.SparkActions; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; @@ -179,6 +180,7 @@ private void setupSpark() { .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") .config("spark.sql.catalog.spark_catalog.warehouse", catalogWarehouse()) + .config(TestBase.DISABLE_UI) .master("local"); spark = builder.getOrCreate(); } diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java index 77b79384a6d8..683f6bb46d05 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java @@ -26,6 +26,7 @@ import static org.apache.spark.sql.functions.expr; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.util.Collections; import java.util.UUID; @@ -40,6 +41,7 @@ import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.actions.SparkActions; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; @@ -373,11 +375,12 @@ protected final SparkSession spark() { protected String getCatalogWarehouse() { try { - String location = - Files.createTempDirectory("benchmark-").toAbsolutePath() + "/" + UUID.randomUUID() + "/"; - return location; + return Files.createTempDirectory("benchmark-").toAbsolutePath() + + "/" + + UUID.randomUUID() + + "/"; } catch (IOException e) { - throw new RuntimeException(e); + throw new UncheckedIOException(e); } } @@ -392,6 +395,7 @@ protected void setupSpark() { "spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog") .config("spark.sql.catalog.spark_catalog.type", "hadoop") .config("spark.sql.catalog.spark_catalog.warehouse", getCatalogWarehouse()) + .config(TestBase.DISABLE_UI) .master("local[*]"); spark = builder.getOrCreate(); Configuration sparkHadoopConf = spark.sessionState().newHadoopConf(); diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java index 3dbee5dfd0f5..9b7bbe0eb465 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java @@ -25,9 +25,11 @@ import java.io.IOException; import java.util.List; import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; import org.apache.iceberg.Schema; import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; @@ -113,9 +115,9 @@ public void tearDownBenchmark() { @Threads(1) public void readUsingIcebergReader(Blackhole blackHole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) .build()) { for (InternalRow row : rows) { @@ -171,9 +173,9 @@ public void readUsingSparkReader(Blackhole blackhole) throws IOException { @Threads(1) public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(PROJECTED_SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) .build()) { for (InternalRow row : rows) { diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java index 8487988d9e5b..e77191d5e5cf 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java @@ -25,9 +25,11 @@ import java.io.IOException; import java.util.List; import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; import org.apache.iceberg.Schema; import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; @@ -111,9 +113,9 @@ public void tearDownBenchmark() { @Threads(1) public void readUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) .build()) { for (InternalRow row : rows) { @@ -169,9 +171,9 @@ public void readUsingSparkReader(Blackhole blackhole) throws IOException { @Threads(1) public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { try (CloseableIterable rows = - Parquet.read(Files.localInput(dataFile)) + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) .project(PROJECTED_SCHEMA) - .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) .build()) { for (InternalRow row : rows) { diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java index 47f0b72088f5..46fb96b8a305 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java @@ -23,13 +23,17 @@ import java.io.File; import java.io.IOException; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.RandomData; -import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; @@ -95,15 +99,16 @@ public void tearDownBenchmark() { @Benchmark @Threads(1) public void writeUsingIcebergWriter() throws IOException { - try (FileAppender writer = - Parquet.write(Files.localOutput(dataFile)) - .createWriterFunc( - msgType -> - SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) .build()) { - writer.addAll(rows); + writer.write(rows); } } @@ -121,6 +126,7 @@ public void writeUsingSparkWriter() throws IOException { .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") .set("spark.sql.caseSensitive", "false") .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") .schema(SCHEMA) .build()) { diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java index 4df890d86164..6cb5e23d0bb3 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java @@ -23,13 +23,17 @@ import java.io.File; import java.io.IOException; +import org.apache.iceberg.FileFormat; import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; import org.apache.iceberg.io.FileAppender; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.data.RandomData; -import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.types.Types; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; @@ -95,15 +99,16 @@ public void tearDownBenchmark() { @Benchmark @Threads(1) public void writeUsingIcebergWriter() throws IOException { - try (FileAppender writer = - Parquet.write(Files.localOutput(dataFile)) - .createWriterFunc( - msgType -> - SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) .build()) { - writer.addAll(rows); + writer.write(rows); } } @@ -121,6 +126,7 @@ public void writeUsingSparkWriter() throws IOException { .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") .set("spark.sql.caseSensitive", "false") .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") .schema(SCHEMA) .build()) { diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java index c6794e43c636..3f242ce228ca 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java @@ -49,6 +49,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.util.ContentFileUtil; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.InternalRow; @@ -234,7 +235,7 @@ private String generateDataFilePath() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java index ac74fb5a109c..db5789724056 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java @@ -43,6 +43,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; @@ -218,7 +219,7 @@ private String generateDataFilePath() { private void setupSpark() { this.spark = SparkSession.builder() - .config("spark.ui.enabled", false) + .config(TestBase.DISABLE_UI) .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) .config("spark.sql.catalog.spark_catalog.type", "hadoop") diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java index 68c537e34a4a..debe37866ff7 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java @@ -30,6 +30,7 @@ import org.apache.iceberg.UpdateProperties; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.apache.spark.sql.SaveMode; @@ -94,7 +95,7 @@ protected void cleanupFiles() throws IOException { } protected void setupSpark(boolean enableDictionaryEncoding) { - SparkSession.Builder builder = SparkSession.builder().config("spark.ui.enabled", false); + SparkSession.Builder builder = SparkSession.builder().config(TestBase.DISABLE_UI); if (!enableDictionaryEncoding) { builder .config("parquet.dictionary.page.size", "1") diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java index 7891890dff4a..bbf099fae433 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -44,7 +45,7 @@ protected void appendData() throws IOException { writeData(fileNum); table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletes(file.location(), NUM_ROWS, 0.25, numDeleteFile); } } diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java index 3c6dfa6bd94f..f8767797cad3 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -46,7 +47,7 @@ protected void appendData() throws IOException { if (percentDeleteRow > 0) { // add pos-deletes table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletes(file.location(), NUM_ROWS, percentDeleteRow); } } diff --git a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java index 01096ac79649..8337d67182ee 100644 --- a/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java +++ b/spark/v4.0/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java @@ -21,6 +21,7 @@ import java.io.IOException; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; import org.openjdk.jmh.annotations.Param; @@ -46,7 +47,7 @@ protected void appendData() throws IOException { writeData(fileNum); table().refresh(); - for (DataFile file : table().currentSnapshot().addedDataFiles(table().io())) { + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { writePosDeletesWithNoise( file.location(), NUM_ROWS, diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java index ea400a779235..cb9da3edc678 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java @@ -56,7 +56,7 @@ public static R withCommitProperties( ExceptionUtil.castAndThrow(e, exClass); return null; } finally { - COMMIT_PROPERTIES.set(ImmutableMap.of()); + COMMIT_PROPERTIES.remove(); } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java index 442d728d4d69..b7cd441c9642 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java @@ -24,6 +24,4 @@ @Value.Immutable public interface ParquetBatchReadConf extends Serializable { int batchSize(); - - ParquetReaderType readerType(); } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java deleted file mode 100644 index d9742c048251..000000000000 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/ParquetReaderType.java +++ /dev/null @@ -1,47 +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. - */ -package org.apache.iceberg.spark; - -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; - -/** Enumerates the types of Parquet readers. */ -public enum ParquetReaderType { - /** ICEBERG type utilizes the built-in Parquet reader. */ - ICEBERG, - - /** - * COMET type changes the Parquet reader to the Apache DataFusion Comet Parquet reader. Comet - * Parquet reader performs I/O and decompression in the JVM but decodes in native to improve - * performance. Additionally, Comet will convert Spark's physical plan into a native physical plan - * and execute this plan natively. - * - *

    TODO: Implement {@link org.apache.comet.parquet.SupportsComet} in SparkScan to convert Spark - * physical plan to native physical plan for native execution. - */ - COMET; - - public static ParquetReaderType fromString(String typeAsString) { - Preconditions.checkArgument(typeAsString != null, "Parquet reader type is null"); - try { - return ParquetReaderType.valueOf(typeAsString.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Unknown parquet reader type: " + typeAsString); - } - } -} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java index bc8a966488ee..f1709277525a 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java @@ -24,17 +24,17 @@ import java.util.function.Function; import org.apache.spark.sql.connector.catalog.Identifier; import org.apache.spark.sql.connector.catalog.StagedTable; -import org.apache.spark.sql.connector.catalog.SupportsDelete; +import org.apache.spark.sql.connector.catalog.SupportsDeleteV2; import org.apache.spark.sql.connector.catalog.SupportsRead; import org.apache.spark.sql.connector.catalog.SupportsWrite; import org.apache.spark.sql.connector.catalog.Table; import org.apache.spark.sql.connector.catalog.TableCapability; import org.apache.spark.sql.connector.catalog.TableCatalog; import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.ScanBuilder; import org.apache.spark.sql.connector.write.LogicalWriteInfo; import org.apache.spark.sql.connector.write.WriteBuilder; -import org.apache.spark.sql.sources.Filter; import org.apache.spark.sql.types.StructType; import org.apache.spark.sql.util.CaseInsensitiveStringMap; @@ -58,7 +58,7 @@ * #capabilities()}. */ public class RollbackStagedTable - implements StagedTable, SupportsRead, SupportsWrite, SupportsDelete { + implements StagedTable, SupportsRead, SupportsWrite, SupportsDeleteV2 { private final TableCatalog catalog; private final Identifier ident; private final Table table; @@ -106,8 +106,8 @@ public Set capabilities() { } @Override - public void deleteWhere(Filter[] filters) { - call(SupportsDelete.class, t -> t.deleteWhere(filters)); + public void deleteWhere(Predicate[] predicates) { + call(SupportsDeleteV2.class, t -> t.deleteWhere(predicates)); } @Override diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java index dba3ea6ecc1c..da22607d05b0 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java @@ -797,6 +797,7 @@ public final void initialize(String name, CaseInsensitiveStringMap options) { EnvironmentContext.put( EnvironmentContext.ENGINE_VERSION, sparkSession.sparkContext().version()); EnvironmentContext.put(CatalogProperties.APP_ID, sparkSession.sparkContext().applicationId()); + EnvironmentContext.put(CatalogProperties.APP_NAME, sparkSession.sparkContext().appName()); } @Override diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java index bad31d8d85f4..7b44d93e53b5 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java @@ -35,8 +35,6 @@ public abstract class SparkContentFile implements ContentFile { - private static final FileContent[] FILE_CONTENT_VALUES = FileContent.values(); - private final int fileContentPosition; private final int filePathPosition; private final int fileFormatPosition; @@ -54,6 +52,7 @@ public abstract class SparkContentFile implements ContentFile { private final int sortOrderIdPosition; private final int fileSpecIdPosition; private final int equalityIdsPosition; + private final int firstRowIdPosition; private final int referencedDataFilePosition; private final int contentOffsetPosition; private final int contentSizePosition; @@ -106,6 +105,7 @@ public abstract class SparkContentFile implements ContentFile { this.sortOrderIdPosition = positions.get(DataFile.SORT_ORDER_ID.name()); this.fileSpecIdPosition = positions.get(DataFile.SPEC_ID.name()); this.equalityIdsPosition = positions.get(DataFile.EQUALITY_IDS.name()); + this.firstRowIdPosition = positions.get(DataFile.FIRST_ROW_ID.name()); this.referencedDataFilePosition = positions.get(DataFile.REFERENCED_DATA_FILE.name()); this.contentOffsetPosition = positions.get(DataFile.CONTENT_OFFSET.name()); this.contentSizePosition = positions.get(DataFile.CONTENT_SIZE.name()); @@ -139,7 +139,7 @@ public FileContent content() { if (wrapped.isNullAt(fileContentPosition)) { return null; } - return FILE_CONTENT_VALUES[wrapped.getInt(fileContentPosition)]; + return FileContent.fromId(wrapped.getInt(fileContentPosition)); } @Override @@ -258,6 +258,15 @@ public Long contentSizeInBytes() { return wrapped.getLong(contentSizePosition); } + @Override + public Long firstRowId() { + if (wrapped.isNullAt(firstRowIdPosition)) { + return null; + } + + return wrapped.getLong(firstRowIdPosition); + } + private int fieldPosition(String name, StructType sparkType) { try { return sparkType.fieldIndex(name); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java index 2788e160d526..da1093627f8b 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java @@ -22,10 +22,12 @@ import java.util.Map; import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.SupportsDistributedScanPlanning; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.hadoop.Util; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.PropertyUtil; import org.apache.spark.SparkConf; import org.apache.spark.sql.SparkSession; @@ -260,6 +262,39 @@ public int maxRecordsPerMicroBatch() { .parse(); } + public boolean asyncMicroBatchPlanningEnabled() { + return confParser + .booleanConf() + .option(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .sessionConf(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .defaultValue(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT) + .parse(); + } + + public long streamingSnapshotPollingIntervalMs() { + return confParser + .longConf() + .option(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS) + .defaultValue(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadFileLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadRowLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT) + .parse(); + } + public boolean preserveDataGrouping() { return confParser .booleanConf() @@ -280,6 +315,7 @@ public boolean aggregatePushDownEnabled() { public boolean adaptiveSplitSizeEnabled() { return confParser .booleanConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_ENABLED) .tableProperty(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED) .defaultValue(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED_DEFAULT) .parse(); @@ -291,8 +327,21 @@ public int parallelism() { return Math.max(defaultParallelism, numShufflePartitions); } + public int splitParallelism() { + int parallelism = + confParser + .intConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM) + .defaultValue(parallelism()) + .parse(); + Preconditions.checkArgument(parallelism > 0, "Split parallelism must be > 0: %s", parallelism); + return parallelism; + } + public boolean distributedPlanningEnabled() { - return dataPlanningMode() != LOCAL || deletePlanningMode() != LOCAL; + return table instanceof SupportsDistributedScanPlanning distributed + && distributed.allowDistributedPlanning() + && (dataPlanningMode() != LOCAL || deletePlanningMode() != LOCAL); } public PlanningMode dataPlanningMode() { @@ -367,12 +416,4 @@ public boolean reportColumnStats() { .defaultValue(SparkSQLProperties.REPORT_COLUMN_STATS_DEFAULT) .parse(); } - - public ParquetReaderType parquetReaderType() { - return confParser - .enumConf(ParquetReaderType::fromString) - .sessionConf(SparkSQLProperties.PARQUET_READER_TYPE) - .defaultValue(SparkSQLProperties.PARQUET_READER_TYPE_DEFAULT) - .parse(); - } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java index 17f2bfee69b8..5262310e2c5e 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java @@ -87,6 +87,21 @@ private SparkReadOptions() {} public static final String STREAMING_MAX_ROWS_PER_MICRO_BATCH = "streaming-max-rows-per-micro-batch"; + // Enable async micro batch planning + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "async-micro-batch-planning-enabled"; + + // Polling interval for async planner to refresh table metadata (ms) + public static final String STREAMING_SNAPSHOT_POLLING_INTERVAL_MS = + "streaming-snapshot-polling-interval-ms"; + public static final long STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT = 30000L; + + // Initial queue preload limits for async micro batch planner + public static final String ASYNC_QUEUE_PRELOAD_FILE_LIMIT = "async-queue-preload-file-limit"; + public static final long ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT = 100L; + public static final String ASYNC_QUEUE_PRELOAD_ROW_LIMIT = "async-queue-preload-row-limit"; + public static final long ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT = 100000L; + // Table path public static final String PATH = "path"; diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java index 81139969f746..b0ac0c055bbe 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java @@ -27,9 +27,6 @@ private SparkSQLProperties() {} // Controls whether vectorized reads are enabled public static final String VECTORIZATION_ENABLED = "spark.sql.iceberg.vectorization.enabled"; - // Controls which Parquet reader implementation to use - public static final String PARQUET_READER_TYPE = "spark.sql.iceberg.parquet.reader-type"; - public static final ParquetReaderType PARQUET_READER_TYPE_DEFAULT = ParquetReaderType.ICEBERG; // Controls whether to perform the nullability check during writes public static final String CHECK_NULLABILITY = "spark.sql.iceberg.check-nullability"; public static final boolean CHECK_NULLABILITY_DEFAULT = true; @@ -107,6 +104,28 @@ private SparkSQLProperties() {} public static final String REPORT_COLUMN_STATS = "spark.sql.iceberg.report-column-stats"; public static final boolean REPORT_COLUMN_STATS_DEFAULT = true; + // Controls whether to enable async micro batch planning for session + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "spark.sql.iceberg.async-micro-batch-planning-enabled"; + public static final boolean ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT = false; + // Prefix for custom snapshot properties public static final String SNAPSHOT_PROPERTY_PREFIX = "spark.sql.iceberg.snapshot-property."; + + // Controls whether adaptive split sizing is enabled + public static final String READ_ADAPTIVE_SPLIT_SIZE_ENABLED = + "spark.sql.iceberg.read.adaptive-split-size.enabled"; + + // Overrides the parallelism used for adaptive split sizing. When unset, the parallelism + // defaults to max(spark.default.parallelism, spark.sql.shuffle.partitions). + public static final String READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM = + "spark.sql.iceberg.read.adaptive-split-size.parallelism"; + + // Controls whether to shred variant columns during write operations + public static final String SHRED_VARIANTS = "spark.sql.iceberg.shred-variants"; + + // Controls the buffer size for variant schema inference during writes + // This determines how many rows are buffered before inferring shredded schema + public static final String VARIANT_INFERENCE_BUFFER_SIZE = + "spark.sql.iceberg.variant-inference-buffer-size"; } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java index 98d3e41535cf..0e9edac3fbd5 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java @@ -44,6 +44,7 @@ import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CatalogUtil; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; @@ -66,7 +67,6 @@ import org.apache.iceberg.hadoop.Util; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.OutputFile; -import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.mapping.NameMappingParser; import org.apache.iceberg.relocated.com.google.common.base.Joiner; @@ -80,8 +80,6 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.source.SparkTable; import org.apache.iceberg.util.PropertyUtil; -import org.apache.iceberg.util.Tasks; -import org.apache.iceberg.util.ThreadPools; import org.apache.spark.TaskContext; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; @@ -332,15 +330,15 @@ private static List listPartition( private static SparkPartition toSparkPartition( CatalogTablePartition partition, CatalogTable table) { Option locationUri = partition.storage().locationUri(); - Option serde = partition.storage().serde(); + Option partitionSerde = partition.storage().serde(); Preconditions.checkArgument(locationUri.nonEmpty(), "Partition URI should be defined"); Preconditions.checkArgument( - serde.nonEmpty() || table.provider().nonEmpty(), "Partition format should be defined"); + partitionSerde.nonEmpty() || table.provider().nonEmpty(), + "Partition format should be defined"); String uri = Util.uriToString(locationUri.get()); - String format = serde.nonEmpty() ? serde.get() : table.provider().get(); - + String format = resolveFileFormat(partitionSerde.getOrElse(() -> null), table); Map partitionSpec = JavaConverters.mapAsJavaMapConverter(partition.spec()).asJava(); return new SparkPartition(partitionSpec, uri, format); @@ -685,11 +683,7 @@ private static void importUnpartitionedSparkTable( ExecutorService service) { try { CatalogTable sourceTable = spark.sessionState().catalog().getTableMetadata(sourceTableIdent); - Option format = - sourceTable.storage().serde().nonEmpty() - ? sourceTable.storage().serde() - : sourceTable.provider(); - Preconditions.checkArgument(format.nonEmpty(), "Could not determine table format"); + String format = resolveFileFormat(null, sourceTable); Map partition = Collections.emptyMap(); PartitionSpec spec = PartitionSpec.unpartitioned(); @@ -703,7 +697,7 @@ private static void importUnpartitionedSparkTable( TableMigrationUtil.listPartition( partition, Util.uriToString(sourceTable.location()), - format.get(), + format, spec, conf, metricsConfig, @@ -976,15 +970,7 @@ public static List filterPartitions( } private static void deleteManifests(FileIO io, List manifests) { - if (io instanceof SupportsBulkOperations) { - ((SupportsBulkOperations) io).deleteFiles(Lists.transform(manifests, ManifestFile::path)); - } else { - Tasks.foreach(manifests) - .executeWith(ThreadPools.getWorkerPool()) - .noRetry() - .suppressFailureWhenFinished() - .run(item -> io.deleteFile(item.path())); - } + CatalogUtil.deleteFiles(io, Lists.transform(manifests, ManifestFile::path), "manifests"); } public static Dataset loadTable(SparkSession spark, Table table, long snapshotId) { @@ -1061,6 +1047,30 @@ public static boolean wapEnabled(Table table) { Boolean.parseBoolean(TableProperties.WRITE_AUDIT_PUBLISH_ENABLED_DEFAULT)); } + private static String resolveFileFormat(String partitionSerde, CatalogTable table) { + if (partitionSerde != null && isKnownFileFormat(partitionSerde)) { + return partitionSerde; + } + + Option serde = table.storage().serde(); + if (serde.nonEmpty() && isKnownFileFormat(serde.get())) { + return serde.get(); + } + + Preconditions.checkArgument( + table.provider().nonEmpty(), + "Could not determine table format from serde %s and no provider set", + serde.getOrElse(() -> "unknown")); + return table.provider().get(); + } + + private static boolean isKnownFileFormat(String serde) { + String lowerSerde = serde.toLowerCase(Locale.ROOT); + return lowerSerde.contains("parquet") + || lowerSerde.contains("avro") + || lowerSerde.contains("orc"); + } + /** Class representing a table partition. */ public static class SparkPartition implements Serializable { private final Map values; diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java index 96131e0e56dd..add12e6040b0 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java @@ -33,6 +33,8 @@ import static org.apache.iceberg.TableProperties.ORC_COMPRESSION_STRATEGY; import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION; import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.PARQUET_SHRED_VARIANTS; +import static org.apache.iceberg.TableProperties.PARQUET_VARIANT_BUFFER_SIZE; import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE; import java.util.Locale; @@ -42,6 +44,7 @@ import org.apache.iceberg.FileFormat; import org.apache.iceberg.IsolationLevel; import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.TableUtil; @@ -164,6 +167,25 @@ public int outputSpecId() { return outputSpecId; } + public int outputSortOrderId(SparkWriteRequirements writeRequirements) { + Integer explicitId = + confParser.intConf().option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID).parseOptional(); + + if (explicitId != null) { + Preconditions.checkArgument( + table.sortOrders().containsKey(explicitId), + "Cannot use output sort order id %s because the table does not contain a sort order with that id", + explicitId); + return explicitId; + } + + if (writeRequirements.hasOrdering()) { + return table.sortOrder().orderId(); + } + + return SortOrder.unsorted().orderId(); + } + public FileFormat dataFileFormat() { String valueAsString = confParser @@ -509,6 +531,14 @@ private Map dataWriteProperties() { if (parquetCompressionLevel != null) { writeProperties.put(PARQUET_COMPRESSION_LEVEL, parquetCompressionLevel); } + boolean shouldShredVariants = shredVariants(); + writeProperties.put(PARQUET_SHRED_VARIANTS, String.valueOf(shouldShredVariants)); + + // Add variant shredding configuration properties + if (shouldShredVariants) { + writeProperties.put( + PARQUET_VARIANT_BUFFER_SIZE, String.valueOf(variantInferenceBufferSize())); + } break; case AVRO: @@ -729,4 +759,24 @@ public DeleteGranularity deleteGranularity() { .defaultValue(DeleteGranularity.FILE) .parse(); } + + public boolean shredVariants() { + return confParser + .booleanConf() + .option(SparkWriteOptions.SHRED_VARIANTS) + .sessionConf(SparkSQLProperties.SHRED_VARIANTS) + .tableProperty(TableProperties.PARQUET_SHRED_VARIANTS) + .defaultValue(TableProperties.PARQUET_SHRED_VARIANTS_DEFAULT) + .parse(); + } + + public int variantInferenceBufferSize() { + return confParser + .intConf() + .option(SparkWriteOptions.VARIANT_INFERENCE_BUFFER_SIZE) + .sessionConf(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE) + .tableProperty(TableProperties.PARQUET_VARIANT_BUFFER_SIZE) + .defaultValue(TableProperties.PARQUET_VARIANT_BUFFER_SIZE_DEFAULT) + .parse(); + } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java index 33db70bae587..6c76b5c873c5 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java @@ -54,6 +54,7 @@ private SparkWriteOptions() {} public static final String REWRITTEN_FILE_SCAN_TASK_SET_ID = "rewritten-file-scan-task-set-id"; public static final String OUTPUT_SPEC_ID = "output-spec-id"; + public static final String OUTPUT_SORT_ORDER_ID = "output-sort-order-id"; public static final String OVERWRITE_MODE = "overwrite-mode"; @@ -85,4 +86,10 @@ private SparkWriteOptions() {} // Overrides the delete granularity public static final String DELETE_GRANULARITY = "delete-granularity"; + + // Controls whether to shred variant columns during write operations + public static final String SHRED_VARIANTS = "shred-variants"; + + // Controls the buffer size for variant schema inference during writes + public static final String VARIANT_INFERENCE_BUFFER_SIZE = "variant-inference-buffer-size"; } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java index 833e0e44e391..072727ac4bb0 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java @@ -26,8 +26,10 @@ /** A set of requirements such as distribution and ordering reported to Spark during writes. */ public class SparkWriteRequirements { + private static final long NO_ADVISORY_PARTITION_SIZE = 0; public static final SparkWriteRequirements EMPTY = - new SparkWriteRequirements(Distributions.unspecified(), new SortOrder[0], 0); + new SparkWriteRequirements( + Distributions.unspecified(), new SortOrder[0], NO_ADVISORY_PARTITION_SIZE); private final Distribution distribution; private final SortOrder[] ordering; @@ -37,7 +39,11 @@ public class SparkWriteRequirements { Distribution distribution, SortOrder[] ordering, long advisoryPartitionSize) { this.distribution = distribution; this.ordering = ordering; - this.advisoryPartitionSize = advisoryPartitionSize; + // Spark prohibits requesting a particular advisory partition size without distribution + this.advisoryPartitionSize = + distribution instanceof UnspecifiedDistribution + ? NO_ADVISORY_PARTITION_SIZE + : advisoryPartitionSize; } public Distribution distribution() { @@ -53,7 +59,6 @@ public boolean hasOrdering() { } public long advisoryPartitionSize() { - // Spark prohibits requesting a particular advisory partition size without distribution - return distribution instanceof UnspecifiedDistribution ? 0 : advisoryPartitionSize; + return advisoryPartitionSize; } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteUtil.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteUtil.java index 0d68a0d8cdd0..86902c15e139 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteUtil.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/SparkWriteUtil.java @@ -23,10 +23,38 @@ import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; import java.util.Arrays; +import java.util.List; import org.apache.iceberg.DistributionMode; import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Table; +import org.apache.iceberg.metrics.CommitMetricsResult; +import org.apache.iceberg.metrics.CommitReport; +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.ObjectArrays; +import org.apache.iceberg.spark.source.metrics.AddedDataFiles; +import org.apache.iceberg.spark.source.metrics.AddedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.AddedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.AddedRecords; +import org.apache.iceberg.spark.source.metrics.RemovedDataFiles; +import org.apache.iceberg.spark.source.metrics.RemovedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedRecords; +import org.apache.iceberg.spark.source.metrics.TotalDataFiles; +import org.apache.iceberg.spark.source.metrics.TotalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TotalEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.TotalFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.TotalPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.TotalRecords; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.SortOrderUtil; import org.apache.spark.sql.connector.distributions.Distribution; @@ -36,6 +64,8 @@ import org.apache.spark.sql.connector.expressions.NamedReference; import org.apache.spark.sql.connector.expressions.SortDirection; import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; import org.apache.spark.sql.connector.write.RowLevelOperation.Command; /** @@ -256,4 +286,83 @@ private static SortOrder[] orderBy(Expression... exprs) { private static SortOrder sort(Expression expr) { return Expressions.sort(expr, SortDirection.ASCENDING); } + + public static CustomMetric[] supportedCustomMetrics() { + return new CustomMetric[] { + new AddedDataFiles(), + new AddedDeleteFiles(), + new AddedEqualityDeletes(), + new AddedEqualityDeleteFiles(), + new AddedFileSizeInBytes(), + new AddedPositionalDeletes(), + new AddedPositionalDeleteFiles(), + new AddedRecords(), + new RemovedDataFiles(), + new RemovedDeleteFiles(), + new RemovedRecords(), + new RemovedEqualityDeleteFiles(), + new RemovedEqualityDeletes(), + new RemovedFileSizeInBytes(), + new RemovedPositionalDeleteFiles(), + new RemovedPositionalDeletes(), + new TotalDataFiles(), + new TotalDeleteFiles(), + new TotalEqualityDeletes(), + new TotalFileSizeInBytes(), + new TotalPositionalDeletes(), + new TotalRecords() + }; + } + + public static CustomTaskMetric[] customTaskMetrics(InMemoryMetricsReporter metricsReporter) { + List metrics = Lists.newArrayList(); + if (metricsReporter != null) { + CommitReport commitReport = metricsReporter.commitReport(); + if (commitReport != null) { + CommitMetricsResult result = commitReport.commitMetrics(); + addValue(new AddedDataFiles(), result.addedDataFiles(), metrics); + addValue(new AddedDeleteFiles(), result.addedDeleteFiles(), metrics); + addValue(new AddedEqualityDeletes(), result.addedEqualityDeletes(), metrics); + addValue(new AddedEqualityDeleteFiles(), result.addedEqualityDeleteFiles(), metrics); + addValue(new AddedFileSizeInBytes(), result.addedFilesSizeInBytes(), metrics); + addValue(new AddedPositionalDeletes(), result.addedPositionalDeletes(), metrics); + addValue(new AddedPositionalDeleteFiles(), result.addedPositionalDeleteFiles(), metrics); + addValue(new AddedRecords(), result.addedRecords(), metrics); + addValue(new RemovedDataFiles(), result.removedDataFiles(), metrics); + addValue(new RemovedDeleteFiles(), result.removedDeleteFiles(), metrics); + addValue(new RemovedRecords(), result.removedRecords(), metrics); + addValue(new RemovedEqualityDeleteFiles(), result.removedEqualityDeleteFiles(), metrics); + addValue(new RemovedEqualityDeletes(), result.removedEqualityDeletes(), metrics); + addValue(new RemovedFileSizeInBytes(), result.removedFilesSizeInBytes(), metrics); + addValue( + new RemovedPositionalDeleteFiles(), result.removedPositionalDeleteFiles(), metrics); + addValue(new RemovedPositionalDeletes(), result.removedPositionalDeletes(), metrics); + addValue(new TotalDataFiles(), result.totalDataFiles(), metrics); + addValue(new TotalDeleteFiles(), result.totalDeleteFiles(), metrics); + addValue(new TotalEqualityDeletes(), result.totalEqualityDeletes(), metrics); + addValue(new TotalFileSizeInBytes(), result.totalFilesSizeInBytes(), metrics); + addValue(new TotalPositionalDeletes(), result.totalPositionalDeletes(), metrics); + addValue(new TotalRecords(), result.totalRecords(), metrics); + } + } + return metrics.toArray(new CustomTaskMetric[0]); + } + + private static void addValue( + CustomMetric metric, CounterResult result, List taskMetrics) { + if (result != null) { + taskMetrics.add( + new CustomTaskMetric() { + @Override + public String name() { + return metric.name(); + } + + @Override + public long value() { + return result.value(); + } + }); + } + } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java index a8e82d101fbf..f53859ef97d6 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java @@ -161,7 +161,8 @@ protected Dataset contentFileDS(Table table, Set snapshotIds) { "length", "0 as sequenceNumber", "partition_spec_id as partitionSpecId", - "added_snapshot_id as addedSnapshotId") + "added_snapshot_id as addedSnapshotId", + "key_metadata as keyMetadata") .dropDuplicates("path") .repartition(numShufflePartitions) // avoid adaptive execution combining tasks .as(ManifestFileBean.ENCODER); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java index 0ca73bef4d7e..37e1ec4ce788 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java @@ -134,6 +134,10 @@ private void validateSourceTable() { Preconditions.checkArgument( !sourceCatalogTable.storage().locationUri().isEmpty(), "Cannot create an Iceberg table from a source without an explicit location"); + Preconditions.checkArgument( + sourceCatalogTable.bucketSpec().isEmpty(), + "Cannot create an Iceberg table from a bucketed source table: %s", + (Object) sourceCatalogTable.bucketSpec().getOrElse(() -> null)); } protected StagingTableCatalog checkDestinationCatalog(CatalogPlugin catalog) { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java index 78662159b0bb..92bfc880ad7f 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java @@ -300,7 +300,10 @@ private DeleteOrphanFiles.Result deleteFiles(Dataset orphanFileDS) { LOG.info("Deleted {} orphan files", filesCount); - return ImmutableDeleteOrphanFiles.Result.builder().orphanFileLocations(orphanFileList).build(); + return ImmutableDeleteOrphanFiles.Result.builder() + .orphanFileLocations(orphanFileList) + .orphanFilesCount(filesCount) + .build(); } private void collectPathsForOutput( diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java index fd4639897743..599e27b71c45 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java @@ -37,6 +37,7 @@ public class ManifestFileBean implements ManifestFile, Serializable { private Integer content = null; private Long sequenceNumber = null; private Long firstRowId = null; + private byte[] keyMetadata = null; public static ManifestFileBean fromManifest(ManifestFile manifest) { ManifestFileBean bean = new ManifestFileBean(); @@ -48,6 +49,7 @@ public static ManifestFileBean fromManifest(ManifestFile manifest) { bean.setContent(manifest.content().id()); bean.setSequenceNumber(manifest.sequenceNumber()); bean.setFirstRowId(manifest.firstRowId()); + bean.setKeyMetadata(manifest.keyMetadata() == null ? null : manifest.keyMetadata().array()); return bean; } @@ -104,6 +106,14 @@ public void setFirstRowId(Long firstRowId) { this.firstRowId = firstRowId; } + public byte[] getKeyMetadata() { + return keyMetadata; + } + + public void setKeyMetadata(byte[] keyMetadata) { + this.keyMetadata = keyMetadata; + } + @Override public String path() { return path; @@ -176,7 +186,7 @@ public List partitions() { @Override public ByteBuffer keyMetadata() { - return null; + return keyMetadata == null ? null : ByteBuffer.wrap(keyMetadata); } @Override diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java index 61d1fdfe605f..3415b6a551ae 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java @@ -31,6 +31,7 @@ import java.util.stream.Collectors; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.SortOrder; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; @@ -96,6 +97,7 @@ public class RewriteDataFilesSparkAction private boolean removeDanglingDeletes; private boolean useStartingSequenceNumber; private boolean caseSensitive; + private String branch = SnapshotRef.MAIN_BRANCH; private BinPackRewriteFilePlanner planner = null; private FileRewriteRunner runner = null; @@ -157,13 +159,24 @@ public RewriteDataFilesSparkAction filter(Expression expression) { return this; } + public RewriteDataFilesSparkAction toBranch(String targetBranch) { + Preconditions.checkArgument(targetBranch != null, "Invalid branch name: null"); + this.branch = targetBranch; + return this; + } + @Override public RewriteDataFiles.Result execute() { if (table.currentSnapshot() == null) { return EMPTY_RESULT; } - long startingSnapshotId = table.currentSnapshot().snapshotId(); + Preconditions.checkArgument( + table.snapshot(branch) != null, + "Cannot rewrite data files for branch %s: branch does not exist", + branch); + + long startingSnapshotId = table.snapshot(branch).snapshotId(); init(startingSnapshotId); @@ -230,7 +243,7 @@ private ExecutorService rewriteService() { @VisibleForTesting RewriteDataFilesCommitManager commitManager(long startingSnapshotId) { return new RewriteDataFilesCommitManager( - table, startingSnapshotId, useStartingSequenceNumber, commitSummary()); + table, startingSnapshotId, useStartingSequenceNumber, commitSummary(), branch); } private Builder doExecute( diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java index ca9d562194d2..77aadc1d5da8 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java @@ -304,7 +304,7 @@ private Result doExecuteWithPartialProgress( } private void validateAndInitOptions() { - Set validOptions = Sets.newHashSet(planner.validOptions()); + Set validOptions = Sets.newHashSet(runner.validOptions()); validOptions.addAll(VALID_OPTIONS); validOptions.addAll(planner.validOptions()); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java index d6a13bcd515d..aedb25e4a4a6 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java @@ -26,6 +26,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutorService; import java.util.stream.Collectors; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; @@ -52,13 +53,12 @@ import org.apache.iceberg.avro.Avro; import org.apache.iceberg.data.Record; import org.apache.iceberg.data.avro.DataWriter; -import org.apache.iceberg.data.avro.PlannedDataReader; -import org.apache.iceberg.data.orc.GenericOrcReader; import org.apache.iceberg.data.orc.GenericOrcWriter; -import org.apache.iceberg.data.parquet.GenericParquetReaders; import org.apache.iceberg.data.parquet.GenericParquetWriter; import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedFiles; import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.formats.FormatModelRegistry; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.DeleteSchemaUtil; import org.apache.iceberg.io.FileIO; @@ -72,7 +72,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.JobGroupInfo; import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.util.DeleteFileSet; import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.Tasks; import org.apache.spark.api.java.function.ForeachFunction; import org.apache.spark.api.java.function.MapFunction; import org.apache.spark.api.java.function.ReduceFunction; @@ -99,6 +101,7 @@ public class RewriteTablePathSparkAction extends BaseSparkAction tableBroadcast = null; @@ -158,6 +161,12 @@ public RewriteTablePath createFileList(boolean createFileListFlag) { return this; } + @Override + public RewriteTablePath executeWith(ExecutorService service) { + this.executorService = service; + return this; + } + @Override public Result execute() { validateInputs(); @@ -286,22 +295,30 @@ private Result rebuildMetadata() { Sets.difference(snapshotSet(endMetadata), snapshotSet(startMetadata)); // rebuild manifest-list files - RewriteResult rewriteManifestListResult = - validSnapshots.stream() - .map(snapshot -> rewriteManifestList(snapshot, endMetadata, manifestsToRewrite)) - .reduce(new RewriteResult<>(), RewriteResult::append); + Set> manifestListResults = Sets.newConcurrentHashSet(); + Tasks.foreach(validSnapshots) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + snapshot -> + manifestListResults.add( + rewriteManifestList(snapshot, endMetadata, manifestsToRewrite))); + + RewriteResult rewriteManifestListResult = new RewriteResult<>(); + manifestListResults.forEach(rewriteManifestListResult::append); // rebuild manifest files Set metaFiles = rewriteManifestListResult.toRewrite(); RewriteContentFileResult rewriteManifestResult = - rewriteManifests(deltaSnapshots, endMetadata, rewriteManifestListResult.toRewrite()); + rewriteManifests(deltaSnapshots, endMetadata, metaFiles); // rebuild position delete files Set deleteFiles = rewriteManifestResult.toRewrite().stream() .filter(e -> e instanceof DeleteFile) .map(e -> (DeleteFile) e) - .collect(Collectors.toSet()); + .collect(Collectors.toCollection(DeleteFileSet::create)); rewritePositionDeletes(deleteFiles); ImmutableRewriteTablePath.Result.Builder builder = @@ -362,6 +379,7 @@ private RewriteResult rewriteVersionFiles(TableMetadata endMetadata) { result.copyPlan().addAll(rewriteVersionFile(endMetadata, endVersionName)); List versions = endMetadata.previousFiles(); + List versionFilePaths = Lists.newArrayList(); for (int i = versions.size() - 1; i >= 0; i--) { String versionFilePath = versions.get(i).file(); if (versionFilePath.equals(startVersionName)) { @@ -371,13 +389,26 @@ private RewriteResult rewriteVersionFiles(TableMetadata endMetadata) { Preconditions.checkArgument( fileExist(versionFilePath), String.format("Version file %s doesn't exist", versionFilePath)); - TableMetadata tableMetadata = - new StaticTableOperations(versionFilePath, table.io()).current(); - - result.toRewrite().addAll(tableMetadata.snapshots()); - result.copyPlan().addAll(rewriteVersionFile(tableMetadata, versionFilePath)); + versionFilePaths.add(versionFilePath); } + Set allSnapshots = Sets.newConcurrentHashSet(); + Set> allCopyPlan = Sets.newConcurrentHashSet(); + Tasks.foreach(versionFilePaths) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + versionFilePath -> { + TableMetadata tableMetadata = + new StaticTableOperations(versionFilePath, table.io()).current(); + allSnapshots.addAll(tableMetadata.snapshots()); + allCopyPlan.addAll(rewriteVersionFile(tableMetadata, versionFilePath)); + }); + + result.toRewrite().addAll(allSnapshots); + result.copyPlan().addAll(allCopyPlan); + return result; } @@ -719,32 +750,10 @@ private ForeachFunction rewritePositionDelete( private static CloseableIterable positionDeletesReader( InputFile inputFile, FileFormat format, PartitionSpec spec) { - Schema deleteSchema = DeleteSchemaUtil.posDeleteReadSchema(spec.schema()); - switch (format) { - case AVRO: - return Avro.read(inputFile) - .project(deleteSchema) - .reuseContainers() - .createReaderFunc(fileSchema -> PlannedDataReader.create(deleteSchema)) - .build(); - - case PARQUET: - return Parquet.read(inputFile) - .project(deleteSchema) - .reuseContainers() - .createReaderFunc( - fileSchema -> GenericParquetReaders.buildReader(deleteSchema, fileSchema)) - .build(); - - case ORC: - return ORC.read(inputFile) - .project(deleteSchema) - .createReaderFunc(fileSchema -> GenericOrcReader.buildReader(deleteSchema, fileSchema)) - .build(); - - default: - throw new UnsupportedOperationException("Unsupported file format: " + format); - } + return FormatModelRegistry.readBuilder(format, Record.class, inputFile) + .project(DeleteSchemaUtil.posDeleteReadSchema(spec.schema())) + .reuseContainers() + .build(); } private static PositionDeleteWriter positionDeletesWriter( @@ -754,30 +763,37 @@ private static PositionDeleteWriter positionDeletesWriter( StructLike partition, Schema rowSchema) throws IOException { - switch (format) { - case AVRO: - return Avro.writeDeletes(outputFile) - .createWriterFunc(DataWriter::create) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - case PARQUET: - return Parquet.writeDeletes(outputFile) - .createWriterFunc(GenericParquetWriter::create) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - case ORC: - return ORC.writeDeletes(outputFile) - .createWriterFunc(GenericOrcWriter::buildWriter) - .withPartition(partition) - .rowSchema(rowSchema) - .withSpec(spec) - .buildPositionWriter(); - default: - throw new UnsupportedOperationException("Unsupported file format: " + format); + if (rowSchema == null) { + return FormatModelRegistry.positionDeleteWriteBuilder( + format, EncryptedFiles.plainAsEncryptedOutput(outputFile)) + .partition(partition) + .spec(spec) + .build(); + } else { + return switch (format) { + case AVRO -> + Avro.writeDeletes(outputFile) + .createWriterFunc(DataWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(outputFile) + .createWriterFunc(GenericParquetWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(outputFile) + .createWriterFunc(GenericOrcWriter::buildWriter) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + default -> throw new UnsupportedOperationException("Unsupported file format: " + format); + }; } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java index 5f7f408cb099..043b63870ca9 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java @@ -124,7 +124,16 @@ private SnapshotTable.Result doExecute() { StagedSparkTable stagedTable = stageDestTable(); Table icebergTable = stagedTable.table(); - // TODO: Check the dest table location does not overlap with the source table location + String sourceTableLocation = sourceTableLocation(); + String stagedTableLocation = icebergTable.location(); + Preconditions.checkArgument( + !sourceTableLocation.equals(stagedTableLocation) + && !stagedTableLocation.startsWith(sourceTableLocation + "/") + && !sourceTableLocation.startsWith(stagedTableLocation + "/"), + "Cannot create a snapshot at location %s because it would overlap with source table location %s. " + + "Overlapping snapshot and source would mix table files.", + stagedTableLocation, + sourceTableLocation); boolean threw = true; try { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java index 569eb252cba5..f1d45a4b142b 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java @@ -47,10 +47,14 @@ import org.apache.spark.sql.connector.expressions.SortOrder; import org.apache.spark.sql.connector.write.RequiresDistributionAndOrdering; import org.apache.spark.sql.execution.datasources.v2.DistributionAndOrderingUtils$; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import scala.Option; abstract class SparkShufflingFileRewriteRunner extends SparkDataFileRewriteRunner { + private static final Logger LOG = LoggerFactory.getLogger(SparkShufflingFileRewriteRunner.class); + /** * The number of shuffle partitions to use for each output file. By default, this file rewriter * assumes each shuffle partition would become a separate output file. Attempting to generate @@ -119,6 +123,17 @@ public void doRewrite(String groupId, RewriteFileGroup fileGroup) { spec(fileGroup.outputSpecId()), fileGroup.expectedOutputFiles())); + org.apache.iceberg.SortOrder sortOrderInJobSpec = sortOrder(); + + org.apache.iceberg.SortOrder maybeMatchingTableSortOrder = + SortOrderUtil.findTableSortOrder(table(), sortOrder()); + + if (sortOrderInJobSpec.isSorted() && maybeMatchingTableSortOrder.isUnsorted()) { + LOG.warn( + "Sort order specified for job {} doesn't match any table sort orders, rewritten files will not be marked as sorted in the manifest files", + Spark3Util.describe(sortOrderInJobSpec)); + } + sortedDF .write() .format("iceberg") @@ -126,6 +141,7 @@ public void doRewrite(String groupId, RewriteFileGroup fileGroup) { .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, fileGroup.maxOutputFileSize()) .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") .option(SparkWriteOptions.OUTPUT_SPEC_ID, fileGroup.outputSpecId()) + .option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, maybeMatchingTableSortOrder.orderId()) .mode("append") .save(groupId); } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java index 9a6811d1a46e..fa8d765541da 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java @@ -176,6 +176,13 @@ private List validZOrderColNames( Set identityPartitionFieldIds = table.spec().identitySourceIds(); boolean caseSensitive = SparkUtil.caseSensitive(spark); + Preconditions.checkArgument( + caseSensitive + ? schema.findField(Z_COLUMN) == null + : schema.caseInsensitiveFindField(Z_COLUMN) == null, + "Cannot zorder because the table has a column named '%s', which conflicts with Iceberg's internal Z-order column name", + Z_COLUMN); + List validZOrderColNames = Lists.newArrayList(); for (String colName : inputZOrderColNames) { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java index d142e3fd1aee..cf9cc8fd511a 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java @@ -24,6 +24,8 @@ import java.nio.ByteBuffer; import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import org.apache.iceberg.util.DateTimeUtil; import org.apache.iceberg.util.ZOrderByteUtils; import org.apache.spark.sql.Column; import org.apache.spark.sql.expressions.UserDefinedFunction; @@ -40,6 +42,7 @@ import org.apache.spark.sql.types.LongType; import org.apache.spark.sql.types.ShortType; import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.TimestampNTZType; import org.apache.spark.sql.types.TimestampType; import scala.collection.JavaConverters; import scala.collection.Seq; @@ -180,6 +183,29 @@ value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) return udf; } + private UserDefinedFunction timestampNtzToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (LocalDateTime value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + long micros = DateTimeUtil.microsFromTimestamp(value); + return ZOrderByteUtils.longToOrderedBytes( + micros, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("TIMESTAMP_NTZ_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + private UserDefinedFunction floatToOrderedBytesUDF() { int position = inputCol; UserDefinedFunction udf = @@ -309,6 +335,8 @@ Column sortedLexicographically(Column column, DataType type) { return booleanToOrderedBytesUDF().apply(column); } else if (type instanceof TimestampType) { return longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType)); + } else if (type instanceof TimestampNTZType) { + return timestampNtzToOrderedBytesUDF().apply(column); } else if (type instanceof DateType) { return longToOrderedBytesUDF().apply(functions.unix_date(column).cast(DataTypes.LongType)); } else { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java index 4946ac103188..fc7d64743d0c 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java @@ -32,6 +32,7 @@ import org.apache.iceberg.avro.ValueWriter; import org.apache.iceberg.avro.ValueWriters; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.types.ByteType; import org.apache.spark.sql.types.DataType; @@ -46,6 +47,10 @@ public SparkAvroWriter(StructType dsSchema) { this.dsSchema = dsSchema; } + public SparkAvroWriter(org.apache.iceberg.Schema icebergSchema, StructType dsSchema) { + this(dsSchema != null ? dsSchema : SparkSchemaUtil.convert(icebergSchema)); + } + @Override @SuppressWarnings("unchecked") public void setSchema(Schema schema) { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java index 8bdfe7c3a810..ba816efc0ac8 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java @@ -30,6 +30,7 @@ import java.util.stream.IntStream; import java.util.stream.Stream; import org.apache.iceberg.FieldMetrics; +import org.apache.iceberg.Schema; import org.apache.iceberg.parquet.ParquetValueReaders.ReusableEntry; import org.apache.iceberg.parquet.ParquetValueWriter; import org.apache.iceberg.parquet.ParquetValueWriters; @@ -41,6 +42,7 @@ import org.apache.iceberg.parquet.VariantWriterBuilder; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.DecimalUtil; import org.apache.iceberg.util.UUIDUtil; @@ -75,10 +77,18 @@ public class SparkParquetWriters { private SparkParquetWriters() {} - @SuppressWarnings("unchecked") public static ParquetValueWriter buildWriter(StructType dfSchema, MessageType type) { + return buildWriter(null, type, dfSchema); + } + + @SuppressWarnings("unchecked") + public static ParquetValueWriter buildWriter( + Schema icebergSchema, MessageType type, StructType dfSchema) { return (ParquetValueWriter) - ParquetWithSparkSchemaVisitor.visit(dfSchema, type, new WriteBuilder(type)); + ParquetWithSparkSchemaVisitor.visit( + dfSchema != null ? dfSchema : SparkSchemaUtil.convert(icebergSchema), + type, + new WriteBuilder(type)); } private static class WriteBuilder extends ParquetWithSparkSchemaVisitor> { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java deleted file mode 100644 index 81b7d83a7077..000000000000 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnReader.java +++ /dev/null @@ -1,140 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.io.IOException; -import org.apache.comet.CometSchemaImporter; -import org.apache.comet.parquet.AbstractColumnReader; -import org.apache.comet.parquet.ColumnReader; -import org.apache.comet.parquet.TypeUtil; -import org.apache.comet.parquet.Utils; -import org.apache.comet.shaded.arrow.memory.RootAllocator; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.column.page.PageReader; -import org.apache.spark.sql.types.DataType; -import org.apache.spark.sql.types.Metadata; -import org.apache.spark.sql.types.StructField; -import org.apache.spark.sql.vectorized.ColumnVector; - -class CometColumnReader implements VectorizedReader { - // use the Comet default batch size - public static final int DEFAULT_BATCH_SIZE = 8192; - - private final ColumnDescriptor descriptor; - private final DataType sparkType; - - // The delegated ColumnReader from Comet side - private AbstractColumnReader delegate; - private boolean initialized = false; - private int batchSize = DEFAULT_BATCH_SIZE; - private CometSchemaImporter importer; - - CometColumnReader(DataType sparkType, ColumnDescriptor descriptor) { - this.sparkType = sparkType; - this.descriptor = descriptor; - } - - CometColumnReader(Types.NestedField field) { - DataType dataType = SparkSchemaUtil.convert(field.type()); - StructField structField = new StructField(field.name(), dataType, false, Metadata.empty()); - this.sparkType = dataType; - this.descriptor = TypeUtil.convertToParquet(structField); - } - - public AbstractColumnReader delegate() { - return delegate; - } - - void setDelegate(AbstractColumnReader delegate) { - this.delegate = delegate; - } - - void setInitialized(boolean initialized) { - this.initialized = initialized; - } - - public int batchSize() { - return batchSize; - } - - /** - * This method is to initialized/reset the CometColumnReader. This needs to be called for each row - * group after readNextRowGroup, so a new dictionary encoding can be set for each of the new row - * groups. - */ - public void reset() { - if (importer != null) { - importer.close(); - } - - if (delegate != null) { - delegate.close(); - } - - this.importer = new CometSchemaImporter(new RootAllocator()); - this.delegate = Utils.getColumnReader(sparkType, descriptor, importer, batchSize, false, false); - this.initialized = true; - } - - public ColumnDescriptor descriptor() { - return descriptor; - } - - /** Returns the Spark data type for this column. */ - public DataType sparkType() { - return sparkType; - } - - /** - * Set the page reader to be 'pageReader'. - * - *

    NOTE: this should be called before reading a new Parquet column chunk, and after {@link - * CometColumnReader#reset} is called. - */ - public void setPageReader(PageReader pageReader) throws IOException { - Preconditions.checkState(initialized, "Invalid state: 'reset' should be called first"); - ((ColumnReader) delegate).setPageReader(pageReader); - } - - @Override - public void close() { - // close resources on native side - if (importer != null) { - importer.close(); - } - - if (delegate != null) { - delegate.close(); - } - } - - @Override - public void setBatchSize(int size) { - this.batchSize = size; - } - - @Override - public ColumnVector read(ColumnVector reuse, int numRowsToRead) { - throw new UnsupportedOperationException("Not supported"); - } -} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java deleted file mode 100644 index 3d3e9aca24de..000000000000 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometColumnarBatchReader.java +++ /dev/null @@ -1,137 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.util.List; -import java.util.Map; -import org.apache.comet.parquet.AbstractColumnReader; -import org.apache.comet.parquet.BatchReader; -import org.apache.iceberg.Schema; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.parquet.column.page.PageReadStore; -import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData; -import org.apache.parquet.hadoop.metadata.ColumnPath; -import org.apache.spark.sql.vectorized.ColumnVector; -import org.apache.spark.sql.vectorized.ColumnarBatch; - -/** - * {@link VectorizedReader} that returns Spark's {@link ColumnarBatch} to support Spark's vectorized - * read path. The {@link ColumnarBatch} returned is created by passing in the Arrow vectors - * populated via delegated read calls to {@link CometColumnReader VectorReader(s)}. - */ -@SuppressWarnings("checkstyle:VisibilityModifier") -class CometColumnarBatchReader implements VectorizedReader { - - private final CometColumnReader[] readers; - - // The delegated BatchReader on the Comet side does the real work of loading a batch of rows. - // The Comet BatchReader contains an array of ColumnReader. There is no need to explicitly call - // ColumnReader.readBatch; instead, BatchReader.nextBatch will be called, which underneath calls - // ColumnReader.readBatch. The only exception is DeleteColumnReader, because at the time of - // calling BatchReader.nextBatch, the isDeleted value is not yet available, so - // DeleteColumnReader.readBatch must be called explicitly later, after the isDeleted value is - // available. - private final BatchReader delegate; - - CometColumnarBatchReader(List> readers, Schema schema) { - this.readers = - readers.stream().map(CometColumnReader.class::cast).toArray(CometColumnReader[]::new); - - AbstractColumnReader[] abstractColumnReaders = new AbstractColumnReader[readers.size()]; - this.delegate = new BatchReader(abstractColumnReaders); - delegate.setSparkSchema(SparkSchemaUtil.convert(schema)); - } - - @Override - public void setRowGroupInfo( - PageReadStore pageStore, Map metaData) { - for (int i = 0; i < readers.length; i++) { - try { - if (!(readers[i] instanceof CometConstantColumnReader) - && !(readers[i] instanceof CometPositionColumnReader) - && !(readers[i] instanceof CometDeleteColumnReader)) { - readers[i].reset(); - readers[i].setPageReader(pageStore.getPageReader(readers[i].descriptor())); - } - } catch (IOException e) { - throw new UncheckedIOException("Failed to setRowGroupInfo for Comet vectorization", e); - } - } - - for (int i = 0; i < readers.length; i++) { - delegate.getColumnReaders()[i] = this.readers[i].delegate(); - } - } - - @Override - public final ColumnarBatch read(ColumnarBatch reuse, int numRowsToRead) { - return new ColumnBatchLoader(numRowsToRead).loadDataToColumnBatch(); - } - - @Override - public void setBatchSize(int batchSize) { - for (CometColumnReader reader : readers) { - if (reader != null) { - reader.setBatchSize(batchSize); - } - } - } - - @Override - public void close() { - for (CometColumnReader reader : readers) { - if (reader != null) { - reader.close(); - } - } - } - - private class ColumnBatchLoader { - private final int batchSize; - - ColumnBatchLoader(int numRowsToRead) { - Preconditions.checkArgument( - numRowsToRead > 0, "Invalid number of rows to read: %s", numRowsToRead); - this.batchSize = numRowsToRead; - } - - ColumnarBatch loadDataToColumnBatch() { - ColumnVector[] vectors = readDataToColumnVectors(); - - ColumnarBatch batch = new ColumnarBatch(vectors); - batch.setNumRows(batchSize); - return batch; - } - - ColumnVector[] readDataToColumnVectors() { - ColumnVector[] columnVectors = new ColumnVector[readers.length]; - // Fetch rows for all readers in the delegate - delegate.nextBatch(batchSize); - for (int i = 0; i < readers.length; i++) { - columnVectors[i] = readers[i].delegate().currentBatch(); - } - - return columnVectors; - } - } -} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java deleted file mode 100644 index 047c96314b13..000000000000 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometConstantColumnReader.java +++ /dev/null @@ -1,65 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.math.BigDecimal; -import java.nio.ByteBuffer; -import org.apache.comet.parquet.ConstantColumnReader; -import org.apache.iceberg.types.Types; -import org.apache.spark.sql.types.DataType; -import org.apache.spark.sql.types.DataTypes; -import org.apache.spark.sql.types.Decimal; -import org.apache.spark.sql.types.DecimalType; -import org.apache.spark.unsafe.types.UTF8String; - -class CometConstantColumnReader extends CometColumnReader { - - CometConstantColumnReader(T value, Types.NestedField field) { - super(field); - // use delegate to set constant value on the native side to be consumed by native execution. - setDelegate( - new ConstantColumnReader(sparkType(), descriptor(), convertToSparkValue(value), false)); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private Object convertToSparkValue(T value) { - DataType dataType = sparkType(); - // Match the value to Spark internal type if necessary - if (dataType == DataTypes.StringType && value instanceof String) { - // the internal type for StringType is UTF8String - return UTF8String.fromString((String) value); - } else if (dataType instanceof DecimalType && value instanceof BigDecimal) { - // the internal type for DecimalType is Decimal - return Decimal.apply((BigDecimal) value); - } else if (dataType == DataTypes.BinaryType && value instanceof ByteBuffer) { - // the internal type for DecimalType is byte[] - // Iceberg default value should always use HeapBufferBuffer, so calling ByteBuffer.array() - // should be safe. - return ((ByteBuffer) value).array(); - } else { - return value; - } - } -} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java deleted file mode 100644 index 26219014f777..000000000000 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeleteColumnReader.java +++ /dev/null @@ -1,74 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import org.apache.comet.parquet.MetadataColumnReader; -import org.apache.comet.parquet.Native; -import org.apache.comet.parquet.TypeUtil; -import org.apache.comet.vector.CometVector; -import org.apache.iceberg.types.Types; -import org.apache.spark.sql.types.DataTypes; -import org.apache.spark.sql.types.Metadata; -import org.apache.spark.sql.types.StructField; - -class CometDeleteColumnReader extends CometColumnReader { - CometDeleteColumnReader(Types.NestedField field) { - super(field); - setDelegate(new DeleteColumnReader()); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private static class DeleteColumnReader extends MetadataColumnReader { - private final CometDeletedColumnVector deletedVector; - - DeleteColumnReader() { - this(new boolean[0]); - } - - DeleteColumnReader(boolean[] isDeleted) { - super( - DataTypes.BooleanType, - TypeUtil.convertToParquet( - new StructField("_deleted", DataTypes.BooleanType, false, Metadata.empty())), - false /* useDecimal128 = false */, - false /* isConstant = false */); - this.deletedVector = new CometDeletedColumnVector(isDeleted); - } - - @Override - public void readBatch(int total) { - Native.resetBatch(nativeHandle); - // set isDeleted on the native side to be consumed by native execution - Native.setIsDeleted(nativeHandle, deletedVector.isDeleted()); - - super.readBatch(total); - } - - @Override - public CometVector currentBatch() { - return deletedVector; - } - } -} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java deleted file mode 100644 index bcc0e514c28d..000000000000 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometPositionColumnReader.java +++ /dev/null @@ -1,62 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import org.apache.comet.parquet.MetadataColumnReader; -import org.apache.comet.parquet.Native; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.spark.sql.types.DataTypes; - -class CometPositionColumnReader extends CometColumnReader { - CometPositionColumnReader(Types.NestedField field) { - super(field); - setDelegate(new PositionColumnReader(descriptor())); - } - - @Override - public void setBatchSize(int batchSize) { - super.setBatchSize(batchSize); - delegate().setBatchSize(batchSize); - setInitialized(true); - } - - private static class PositionColumnReader extends MetadataColumnReader { - /** The current position value of the column that are used to initialize this column reader. */ - private long position; - - PositionColumnReader(ColumnDescriptor descriptor) { - super( - DataTypes.LongType, - descriptor, - false /* useDecimal128 = false */, - false /* isConstant = false */); - } - - @Override - public void readBatch(int total) { - Native.resetBatch(nativeHandle); - // set position on the native side to be consumed by native execution - Native.setPosition(nativeHandle, position, total); - position += total; - - super.readBatch(total); - } - } -} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java deleted file mode 100644 index 779dc240d4f6..000000000000 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometVectorizedReaderBuilder.java +++ /dev/null @@ -1,138 +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. - */ -package org.apache.iceberg.spark.data.vectorized; - -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.IntStream; -import org.apache.iceberg.MetadataColumns; -import org.apache.iceberg.Schema; -import org.apache.iceberg.parquet.TypeWithSchemaVisitor; -import org.apache.iceberg.parquet.VectorizedReader; -import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; -import org.apache.iceberg.relocated.com.google.common.collect.Lists; -import org.apache.iceberg.relocated.com.google.common.collect.Maps; -import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.iceberg.types.Types; -import org.apache.parquet.column.ColumnDescriptor; -import org.apache.parquet.schema.GroupType; -import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; - -class CometVectorizedReaderBuilder extends TypeWithSchemaVisitor> { - - private final MessageType parquetSchema; - private final Schema icebergSchema; - private final Map idToConstant; - private final Function>, VectorizedReader> readerFactory; - - CometVectorizedReaderBuilder( - Schema expectedSchema, - MessageType parquetSchema, - Map idToConstant, - Function>, VectorizedReader> readerFactory) { - this.parquetSchema = parquetSchema; - this.icebergSchema = expectedSchema; - this.idToConstant = idToConstant; - this.readerFactory = readerFactory; - } - - @Override - public VectorizedReader message( - Types.StructType expected, MessageType message, List> fieldReaders) { - GroupType groupType = message.asGroupType(); - Map> readersById = Maps.newHashMap(); - List fields = groupType.getFields(); - - IntStream.range(0, fields.size()) - .filter(pos -> fields.get(pos).getId() != null) - .forEach(pos -> readersById.put(fields.get(pos).getId().intValue(), fieldReaders.get(pos))); - - List icebergFields = - expected != null ? expected.fields() : ImmutableList.of(); - - List> reorderedFields = - Lists.newArrayListWithExpectedSize(icebergFields.size()); - - for (Types.NestedField field : icebergFields) { - int id = field.fieldId(); - VectorizedReader reader = readersById.get(id); - if (idToConstant.containsKey(id)) { - CometConstantColumnReader constantReader = - new CometConstantColumnReader<>(idToConstant.get(id), field); - reorderedFields.add(constantReader); - } else if (id == MetadataColumns.ROW_POSITION.fieldId()) { - reorderedFields.add(new CometPositionColumnReader(field)); - } else if (id == MetadataColumns.IS_DELETED.fieldId()) { - CometColumnReader deleteReader = new CometDeleteColumnReader<>(field); - reorderedFields.add(deleteReader); - } else if (reader != null) { - reorderedFields.add(reader); - } else if (field.initialDefault() != null) { - CometColumnReader constantReader = - new CometConstantColumnReader<>(field.initialDefault(), field); - reorderedFields.add(constantReader); - } else if (field.isOptional()) { - CometColumnReader constantReader = new CometConstantColumnReader<>(null, field); - reorderedFields.add(constantReader); - } else { - throw new IllegalArgumentException( - String.format("Missing required field: %s", field.name())); - } - } - return vectorizedReader(reorderedFields); - } - - protected VectorizedReader vectorizedReader(List> reorderedFields) { - return readerFactory.apply(reorderedFields); - } - - @Override - public VectorizedReader struct( - Types.StructType expected, GroupType groupType, List> fieldReaders) { - if (expected != null) { - throw new UnsupportedOperationException( - "Vectorized reads are not supported yet for struct fields"); - } - return null; - } - - @Override - public VectorizedReader primitive( - org.apache.iceberg.types.Type.PrimitiveType expected, PrimitiveType primitive) { - - if (primitive.getId() == null) { - return null; - } - int parquetFieldId = primitive.getId().intValue(); - ColumnDescriptor desc = parquetSchema.getColumnDescription(currentPath()); - // Nested types not yet supported for vectorized reads - if (desc.getMaxRepetitionLevel() > 0) { - return null; - } - Types.NestedField icebergField = icebergSchema.findField(parquetFieldId); - if (icebergField == null) { - return null; - } - - return new CometColumnReader(SparkSchemaUtil.convert(icebergField.type()), desc); - } -} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java index 8e25e81a05b2..a80249ee7c36 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java @@ -75,19 +75,6 @@ public static ColumnarBatchReader buildReader( return buildReader(expectedSchema, fileSchema, idToConstant, ArrowAllocation.rootAllocator()); } - public static CometColumnarBatchReader buildCometReader( - Schema expectedSchema, MessageType fileSchema, Map idToConstant) { - return (CometColumnarBatchReader) - TypeWithSchemaVisitor.visit( - expectedSchema.asStruct(), - fileSchema, - new CometVectorizedReaderBuilder( - expectedSchema, - fileSchema, - idToConstant, - readers -> new CometColumnarBatchReader(readers, expectedSchema))); - } - // enables unsafe memory access to avoid costly checks to see if index is within bounds // as long as it is not configured explicitly (see BoundsChecking in Arrow) private static void enableUnsafeMemoryAccess() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java index 874888204334..8cb0a2bfb759 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java @@ -19,10 +19,8 @@ package org.apache.iceberg.spark.procedures; import java.util.Iterator; -import java.util.Optional; import org.apache.iceberg.Snapshot; import org.apache.iceberg.exceptions.ValidationException; -import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; import org.apache.iceberg.util.WapUtil; import org.apache.spark.sql.catalyst.InternalRow; @@ -97,21 +95,26 @@ public Iterator call(InternalRow args) { return modifyIcebergTable( tableIdent, table -> { - Optional wapSnapshot = - Optional.ofNullable( - Iterables.find( - table.snapshots(), - snapshot -> wapId.equals(WapUtil.stagedWapId(snapshot)), - null)); - if (!wapSnapshot.isPresent()) { + Snapshot matchingSnap = null; + for (Snapshot snap : table.snapshots()) { + if (wapId.equals(WapUtil.stagedWapId(snap))) { + if (matchingSnap != null) { + throw new ValidationException( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID '%s'", + wapId); + } else { + matchingSnap = snap; + } + } + } + + if (matchingSnap == null) { throw new ValidationException("Cannot apply unknown WAP ID '%s'", wapId); } - long wapSnapshotId = wapSnapshot.get().snapshotId(); + long wapSnapshotId = matchingSnap.snapshotId(); table.manageSnapshots().cherrypick(wapSnapshotId).commit(); - Snapshot currentSnapshot = table.currentSnapshot(); - InternalRow outputRow = newInternalRow(wapSnapshotId, currentSnapshot.snapshotId()); return asScanIterator(OUTPUT_TYPE, outputRow); }); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java index 3e4f9ea58723..5ab07f4d0a67 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Map; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.actions.RewriteDataFiles; @@ -31,6 +32,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.ExtendedParser; +import org.apache.iceberg.spark.actions.RewriteDataFilesSparkAction; import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.connector.catalog.Identifier; @@ -62,10 +64,12 @@ class RewriteDataFilesProcedure extends BaseProcedure { optionalInParameter("options", STRING_MAP); private static final ProcedureParameter WHERE_PARAM = optionalInParameter("where", DataTypes.StringType); + private static final ProcedureParameter BRANCH_PARAM = + optionalInParameter("branch", DataTypes.StringType); private static final ProcedureParameter[] PARAMETERS = new ProcedureParameter[] { - TABLE_PARAM, STRATEGY_PARAM, SORT_ORDER_PARAM, OPTIONS_PARAM, WHERE_PARAM + TABLE_PARAM, STRATEGY_PARAM, SORT_ORDER_PARAM, OPTIONS_PARAM, WHERE_PARAM, BRANCH_PARAM }; // counts are not nullable since the action result is never null @@ -114,11 +118,21 @@ public Iterator call(InternalRow args) { String sortOrderString = input.asString(SORT_ORDER_PARAM, null); Map options = input.asStringMap(OPTIONS_PARAM, ImmutableMap.of()); String where = input.asString(WHERE_PARAM, null); + // Determine target branch: explicit parameter > table branch > main branch + String branchParam = input.asString(BRANCH_PARAM, null); + if (branchParam == null) { + branchParam = loadSparkTable(tableIdent).branch(); + if (branchParam == null) { + branchParam = SnapshotRef.MAIN_BRANCH; + } + } + String branch = branchParam; return modifyIcebergTable( tableIdent, table -> { - RewriteDataFiles action = actions().rewriteDataFiles(table).options(options); + RewriteDataFilesSparkAction action = + actions().rewriteDataFiles(table).options(options).toBranch(branch); if (strategy != null || sortOrderString != null) { action = checkAndApplyStrategy(action, strategy, sortOrderString, table.schema()); @@ -132,8 +146,8 @@ public Iterator call(InternalRow args) { }); } - private RewriteDataFiles checkAndApplyFilter( - RewriteDataFiles action, String where, Identifier ident) { + private RewriteDataFilesSparkAction checkAndApplyFilter( + RewriteDataFilesSparkAction action, String where, Identifier ident) { if (where != null) { Expression expression = filterExpression(ident, where); return action.filter(expression); @@ -141,8 +155,8 @@ private RewriteDataFiles checkAndApplyFilter( return action; } - private RewriteDataFiles checkAndApplyStrategy( - RewriteDataFiles action, String strategy, String sortOrderString, Schema schema) { + private RewriteDataFilesSparkAction checkAndApplyStrategy( + RewriteDataFilesSparkAction action, String strategy, String sortOrderString, Schema schema) { List zOrderTerms = Lists.newArrayList(); List sortOrderFields = Lists.newArrayList(); if (sortOrderString != null) { @@ -179,13 +193,13 @@ private RewriteDataFiles checkAndApplyStrategy( } } if (strategy.equalsIgnoreCase("binpack")) { - RewriteDataFiles rewriteDataFiles = action.binPack(); + RewriteDataFilesSparkAction binPackAction = action.binPack(); if (sortOrderString != null) { // calling below method to throw the error as user has set both binpack strategy and sort // order - return rewriteDataFiles.sort(buildSortOrder(sortOrderFields, schema)); + return binPackAction.sort(buildSortOrder(sortOrderFields, schema)); } - return rewriteDataFiles; + return binPackAction; } else { throw new IllegalArgumentException( "unsupported strategy: " + strategy + ". Only binpack or sort is supported"); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..3e442f9917d4 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java @@ -0,0 +1,543 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.connector.read.streaming.ReadAllAvailable; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class AsyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(AsyncSparkMicroBatchPlanner.class); + private static final int PLAN_FILES_CACHE_MAX_SIZE = 10; + private static final long QUEUE_POLL_TIMEOUT_MS = 100L; // 100 ms + + private final long minQueuedFiles; + private final long minQueuedRows; + + // Cache for planFiles results to handle duplicate calls + private final Cache, List> planFilesCache; + + // Queue to buffer pre-fetched file scan tasks + private final LinkedBlockingDeque> queue; + + // Background executor for async operations + private final ScheduledExecutorService executor; + + // Error tracking + private volatile Throwable refreshFailedThrowable; + private volatile Throwable fillQueueFailedThrowable; + + // Tracking queue state + private final AtomicLong queuedFileCount = new AtomicLong(0); + private final AtomicLong queuedRowCount = new AtomicLong(0); + private Snapshot lastQueuedSnapshot; + private boolean stopped; + + // Cap for Trigger.AvailableNow - don't process beyond this offset + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + /** + * This class manages a queue of FileScanTask + StreamingOffset. On creation, it starts up an + * asynchronous polling process which populates the queue when a new snapshot arrives or the + * minimum amount of queued data is too low. + * + *

    Note: this will capture the state of the table when snapshots are added to the queue. If a + * snapshot is expired after being added to the queue, the job will still process it. + */ + AsyncSparkMicroBatchPlanner( + Table table, + SparkReadConf readConf, + StreamingOffset initialOffset, + StreamingOffset maybeEndOffset, + StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.minQueuedFiles = readConf().maxFilesPerMicroBatch(); + this.minQueuedRows = readConf().maxRecordsPerMicroBatch(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + this.planFilesCache = Caffeine.newBuilder().maximumSize(PLAN_FILES_CACHE_MAX_SIZE).build(); + this.queue = new LinkedBlockingDeque<>(); + + table().refresh(); + + // Synchronously add data to the queue to meet our initial constraints. + // For Trigger.AvailableNow, constructor-time preload is normally initialized from + // latestOffset(...) with no explicit end offset, so bounded preload must stop at + // Trigger.AvailableNow snapshot. + fillQueue(initialOffset, maybeEndOffset); + + this.executor = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread thread = new Thread(r, "iceberg-async-planner-" + table().name()); + thread.setDaemon(true); + return thread; + }); + // Schedule table refresh at configured interval + long pollingIntervalMs = readConf().streamingSnapshotPollingIntervalMs(); + this.executor.scheduleWithFixedDelay( + this::refreshAndTrapException, pollingIntervalMs, pollingIntervalMs, TimeUnit.MILLISECONDS); + // Schedule queue fill to run frequently (use polling interval for tests, cap at 100ms for + // production) + long queueFillIntervalMs = Math.min(QUEUE_POLL_TIMEOUT_MS, pollingIntervalMs); + executor.scheduleWithFixedDelay( + () -> fillQueueAndTrapException(lastQueuedSnapshot), + 0, + queueFillIntervalMs, + TimeUnit.MILLISECONDS); + + LOG.info( + "Started AsyncSparkMicroBatchPlanner for {} from initialOffset: {}", + table().name(), + initialOffset); + } + + @Override + public synchronized void stop() { + Preconditions.checkArgument( + !stopped, "AsyncSparkMicroBatchPlanner for {} was already stopped", table().name()); + stopped = true; + LOG.info("Stopping AsyncSparkMicroBatchPlanner for table: {}", table().name()); + executor.shutdownNow(); + boolean terminated = false; + try { + terminated = + executor.awaitTermination( + readConf().streamingSnapshotPollingIntervalMs() * 2, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + // Restore interrupt status + Thread.currentThread().interrupt(); + } + LOG.info("AsyncSparkMicroBatchPlanner for table: {}, stopped: {}", table().name(), terminated); + } + + @Override + public void close() { + stop(); + } + + /** + * Spark can call this multiple times; it should produce the same answer every time. + * + * @param startOffset the starting offset of this microbatch, position is inclusive + * @param endOffset the end offset of this microbatch, position is exclusive + * @return the list of files to scan between these offsets + */ + @Override + public synchronized List planFiles( + StreamingOffset startOffset, StreamingOffset endOffset) { + return planFilesCache.get( + Pair.of(startOffset, endOffset), + key -> { + LOG.info( + "running planFiles for {}, startOffset: {}, endOffset: {}", + table().name(), + startOffset, + endOffset); + List result = new LinkedList<>(); + Pair elem; + StreamingOffset currentOffset; + boolean shouldTerminate = false; + long filesInPlan = 0; + long rowsInPlan = 0; + + do { + try { + elem = queue.pollFirst(QUEUE_POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while polling queue", e); + } + + if (elem != null) { + currentOffset = elem.first(); + LOG.debug("planFiles consumed: {}", currentOffset); + FileScanTask currentTask = elem.second(); + filesInPlan += 1; + long elemRows = currentTask.file().recordCount(); + rowsInPlan += elemRows; + queuedFileCount.decrementAndGet(); + queuedRowCount.addAndGet(-elemRows); + result.add(currentTask); + + // try to peek at the next entry of the queue and see if we should stop + Pair nextElem = queue.peekFirst(); + boolean endOffsetPeek = false; + if (nextElem != null) { + endOffsetPeek = endOffset.equals(nextElem.first()); + } + // end offset may be synthetic and not exist in the queue + boolean endOffsetSynthetic = + currentOffset.snapshotId() == endOffset.snapshotId() + && (currentOffset.position() + 1) == endOffset.position(); + shouldTerminate = endOffsetPeek || endOffsetSynthetic; + } else { + LOG.trace("planFiles hasn't reached {}, waiting", endOffset); + } + } while (!shouldTerminate + && refreshFailedThrowable == null + && fillQueueFailedThrowable == null); + + if (refreshFailedThrowable != null) { + throw new RuntimeException("Table refresh failed", refreshFailedThrowable); + } + + if (fillQueueFailedThrowable != null) { + throw new RuntimeException("Queue filling failed", fillQueueFailedThrowable); + } + + LOG.info( + "completed planFiles for {}, startOffset: {}, endOffset: {}, files: {}, rows: {}", + table().name(), + startOffset, + endOffset, + filesInPlan, + rowsInPlan); + return result; + }); + } + + /** + * This needs to be non destructive on the queue as spark could call this multiple times. Each + * time, depending on the table state it could return something different + * + * @param startOffset the starting offset of the next microbatch + * @param limit a limit for how many files/bytes/rows the next microbatch should include + * @return The end offset to use for the next microbatch, null signals that no data is available + */ + @Override + public synchronized StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + LOG.info( + "running latestOffset for {}, startOffset: {}, limit: {}", + table().name(), + startOffset, + limit); + + if (table().currentSnapshot() == null) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() is null"); + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < readConf().streamFromTimestamp()) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() < fromTimestamp"); + return StreamingOffset.START_OFFSET; + } + + // if any exceptions were encountered in the background process, raise them here + if (refreshFailedThrowable != null) { + throw new RuntimeException(refreshFailedThrowable); + } + if (fillQueueFailedThrowable != null) { + throw new RuntimeException(fillQueueFailedThrowable); + } + + // if we want to read all available we don't need to scan files, just snapshots + if (limit instanceof ReadAllAvailable) { + // If Trigger.AvailableNow cap is set, return it directly + if (this.lastOffsetForTriggerAvailableNow != null) { + return this.lastOffsetForTriggerAvailableNow; + } + Snapshot lastValidSnapshot = table().snapshot(startOffset.snapshotId()); + Snapshot nextValidSnapshot; + do { + nextValidSnapshot = nextValidSnapshot(lastValidSnapshot); + if (nextValidSnapshot != null) { + lastValidSnapshot = nextValidSnapshot; + } + } while (nextValidSnapshot != null); + return new StreamingOffset( + lastValidSnapshot.snapshotId(), + MicroBatchUtils.addedFilesCount(table(), lastValidSnapshot), + false); + } + + return computeLimitedOffset(limit); + } + + private StreamingOffset computeLimitedOffset(ReadLimit limit) { + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long rowsSeen = 0; + long filesSeen = 0; + LOG.debug( + "latestOffset queue status, queuedFiles: {}, queuedRows: {}", + queuedFileCount.get(), + queuedRowCount.get()); + + List> queueSnapshot = Lists.newArrayList(queue); + Pair queueTail = + queueSnapshot.isEmpty() ? null : queueSnapshot.get(queueSnapshot.size() - 1); + + for (int i = 0; i < queueSnapshot.size(); i++) { + Pair elem = queueSnapshot.get(i); + long fileRows = elem.second().file().recordCount(); + + // Hard limit on files - stop BEFORE exceeding + if (filesSeen + 1 > unpackedLimits.getMaxFiles()) { + if (filesSeen == 0) { + return null; + } + LOG.debug( + "latestOffset hit file limit at {}, rows: {}, files: {}", + elem.first(), + rowsSeen, + filesSeen); + return elem.first(); + } + + // Soft limit on rows - include file FIRST, then check + rowsSeen += fileRows; + filesSeen += 1; + + // Check if we've hit the row limit after including this file + if (rowsSeen >= unpackedLimits.getMaxRows()) { + if (filesSeen == 1 && rowsSeen > unpackedLimits.getMaxRows()) { + LOG.warn( + "File {} at offset {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + elem.second().file().location(), + elem.first(), + fileRows, + unpackedLimits.getMaxRows()); + } + // Return the offset of the NEXT element (or synthesize tail+1) + if (i + 1 < queueSnapshot.size()) { + LOG.debug( + "latestOffset hit row limit at {}, rows: {}, files: {}", + queueSnapshot.get(i + 1).first(), + rowsSeen, + filesSeen); + return queueSnapshot.get(i + 1).first(); + } else { + // This is the last element - return tail+1 + StreamingOffset current = elem.first(); + StreamingOffset result = + new StreamingOffset( + current.snapshotId(), current.position() + 1, current.shouldScanAllFiles()); + LOG.debug( + "latestOffset hit row limit at tail {}, rows: {}, files: {}", + result, + rowsSeen, + filesSeen); + return result; + } + } + } + + // if we got here there aren't enough files to exceed our limits + if (queueTail != null) { + StreamingOffset tailOffset = queueTail.first(); + // we have to increment the position by 1 since we want to include the tail in the read and + // position is non-inclusive + StreamingOffset latestOffset = + new StreamingOffset( + tailOffset.snapshotId(), tailOffset.position() + 1, tailOffset.shouldScanAllFiles()); + LOG.debug("latestOffset returning all queued data {}", latestOffset); + return latestOffset; + } + + // if we got here the queue is empty + LOG.debug("latestOffset no data, returning null"); + return null; + } + + // Background task wrapper that traps exceptions + private void refreshAndTrapException() { + try { + table().refresh(); + } catch (Throwable t) { + LOG.error("Failed to refresh table {}", table().name(), t); + refreshFailedThrowable = t; + } + } + + // Background task wrapper that traps exceptions + private void fillQueueAndTrapException(Snapshot snapshot) { + try { + fillQueue(snapshot); + } catch (Throwable t) { + LOG.error("Failed to fill queue for table {}", table().name(), t); + fillQueueFailedThrowable = t; + } + } + + /** Generate a MicroBatch based on input parameters and add to the queue */ + private void addMicroBatchToQueue( + Snapshot snapshot, long startFileIndex, long endFileIndex, boolean shouldScanAllFile) { + LOG.info("Adding MicroBatch for snapshot: {} to the queue", snapshot.snapshotId()); + MicroBatches.MicroBatch microBatch = + MicroBatches.from(snapshot, table().io()) + .caseSensitive(readConf().caseSensitive()) + .specsById(table().specs()) + .generate(startFileIndex, endFileIndex, Long.MAX_VALUE, shouldScanAllFile); + + long position = startFileIndex; + for (FileScanTask task : microBatch.tasks()) { + Pair elem = + Pair.of(new StreamingOffset(microBatch.snapshotId(), position, shouldScanAllFile), task); + queuedFileCount.incrementAndGet(); + queuedRowCount.addAndGet(task.file().recordCount()); + queue.addLast(elem); + position += 1; + } + if (LOG.isDebugEnabled()) { + StringBuilder sb = new StringBuilder("\n"); + for (Pair elem : queue) { + sb.append(elem.first()).append("\n"); + } + LOG.debug(sb.toString()); + } + lastQueuedSnapshot = snapshot; + } + + private void fillQueue(StreamingOffset fromOffset, StreamingOffset toOffset) { + LOG.debug("filling queue from {}, to: {}", fromOffset, toOffset); + Snapshot currentSnapshot = table().snapshot(fromOffset.snapshotId()); + // this could be a partial snapshot so add it outside the loop + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + fromOffset.position(), + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + fromOffset.shouldScanAllFiles()); + } + if (toOffset != null) { + if (currentSnapshot != null) { + while (currentSnapshot.snapshotId() != toOffset.snapshotId()) { + currentSnapshot = nextValidSnapshot(currentSnapshot); + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + false); + } else { + break; + } + } + } + // toOffset snapshot already added in loop when currentSnapshot == toOffset + } else { + fillQueueInitialBuffer(currentSnapshot); + } + } + + private void fillQueueInitialBuffer(Snapshot startSnapshot) { + // toOffset is null - fill initial buffer to prevent queue starvation before background + // thread starts. Use configured limits to avoid loading all snapshots + // (which could cause OOM on tables with thousands of snapshots). + long targetRows = readConf().asyncQueuePreloadRowLimit(); + long targetFiles = readConf().asyncQueuePreloadFileLimit(); + + Snapshot preloadEndSnapshot = initialPreloadEndSnapshot(); + if (preloadEndSnapshot == null) { + return; // Empty table + } + + // START_OFFSET case: initialize using nextValidSnapshot which respects timestamp filtering + Snapshot current = startSnapshot; + if (current == null) { + current = nextValidSnapshot(null); + if (current != null) { + addMicroBatchToQueue(current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } + } + + // Continue loading more snapshots within safety limits + if (current != null) { + while ((queuedRowCount.get() < targetRows || queuedFileCount.get() < targetFiles) + && current.snapshotId() != preloadEndSnapshot.snapshotId()) { + current = nextValidSnapshot(current); + if (current != null) { + addMicroBatchToQueue( + current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } else { + break; + } + } + } + } + + private Snapshot initialPreloadEndSnapshot() { + if (lastOffsetForTriggerAvailableNow != null) { + return table().snapshot(lastOffsetForTriggerAvailableNow.snapshotId()); + } + + return table().currentSnapshot(); + } + + @VisibleForTesting + static boolean reachedAvailableNowCap( + Snapshot readFrom, StreamingOffset lastOffsetForTriggerAvailableNow) { + return lastOffsetForTriggerAvailableNow != null + && readFrom != null + && readFrom.snapshotId() == lastOffsetForTriggerAvailableNow.snapshotId(); + } + + /** Try to populate the queue with data from unread snapshots */ + private void fillQueue(Snapshot readFrom) { + // Don't add beyond cap for Trigger.AvailableNow + if (reachedAvailableNowCap(readFrom, lastOffsetForTriggerAvailableNow)) { + LOG.debug( + "Reached cap snapshot {}, not adding more", + this.lastOffsetForTriggerAvailableNow.snapshotId()); + return; + } + + if ((queuedRowCount.get() > minQueuedRows) || (queuedFileCount.get() > minQueuedFiles)) { + // we have enough data buffered, check back shortly + LOG.debug( + "Buffer is full, {} > {} or {} > {}", + queuedRowCount.get(), + minQueuedRows, + queuedFileCount.get(), + minQueuedFiles); + } else { + // add an entire snapshot to the queue + Snapshot nextValidSnapshot = nextValidSnapshot(readFrom); + if (nextValidSnapshot != null) { + addMicroBatchToQueue( + nextValidSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), nextValidSnapshot), + false); + } else { + LOG.debug("No snapshots ready to be read"); + } + } + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java index ff30f29aeae6..c1b2a5873730 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java @@ -19,7 +19,6 @@ package org.apache.iceberg.spark.source; import java.util.Map; -import java.util.Set; import javax.annotation.Nonnull; import org.apache.iceberg.FileFormat; import org.apache.iceberg.MetadataColumns; @@ -29,21 +28,17 @@ import org.apache.iceberg.Table; import org.apache.iceberg.data.DeleteFilter; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.OrcBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.data.vectorized.ColumnVectorWithFilter; import org.apache.iceberg.spark.data.vectorized.ColumnarBatchUtil; import org.apache.iceberg.spark.data.vectorized.UpdatableDeletedColumnVector; -import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; -import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; -import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.util.Pair; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.vectorized.ColumnVector; @@ -55,6 +50,7 @@ abstract class BaseBatchReader extends BaseReader taskGroup, Schema tableSchema, Schema expectedSchema, @@ -63,7 +59,13 @@ abstract class BaseBatchReader extends BaseReader newBatchIterable( Expression residual, Map idToConstant, @Nonnull SparkDeleteFilter deleteFilter) { - CloseableIterable iterable; - switch (format) { - case PARQUET: - iterable = - newParquetIterable( - inputFile, start, length, residual, idToConstant, deleteFilter.requiredSchema()); - break; - case ORC: - iterable = newOrcIterable(inputFile, start, length, residual, idToConstant); - break; - default: - throw new UnsupportedOperationException( - "Format: " + format + " not supported for batched reads"); + ReadBuilder readBuilder = + FormatModelRegistry.readBuilder(format, ColumnarBatch.class, inputFile); + + if (parquetConf != null) { + readBuilder = readBuilder.recordsPerBatch(parquetConf.batchSize()); + } else if (orcConf != null) { + readBuilder = readBuilder.recordsPerBatch(orcConf.batchSize()); } - return CloseableIterable.transform(iterable, new BatchDeleteFilter(deleteFilter)::filterBatch); - } + CloseableIterable iterable = + readBuilder + .project(deleteFilter.requiredSchema()) + .idToConstant(idToConstant) + .split(start, length) + .filter(residual) + .caseSensitive(caseSensitive()) + // Spark eagerly consumes the batches. So the underlying memory allocated could be + // reused without worrying about subsequent reads clobbering over each other. This + // improves read performance as every batch read doesn't have to pay the cost of + // allocating memory. + .reuseContainers() + .withNameMapping(nameMapping()) + .build(); - private CloseableIterable newParquetIterable( - InputFile inputFile, - long start, - long length, - Expression residual, - Map idToConstant, - Schema requiredSchema) { - return Parquet.read(inputFile) - .project(requiredSchema) - .split(start, length) - .createBatchedReaderFunc( - fileSchema -> { - if (parquetConf.readerType() == ParquetReaderType.COMET) { - return VectorizedSparkParquetReaders.buildCometReader( - requiredSchema, fileSchema, idToConstant); - } else { - return VectorizedSparkParquetReaders.buildReader( - requiredSchema, fileSchema, idToConstant); - } - }) - .recordsPerBatch(parquetConf.batchSize()) - .filter(residual) - .caseSensitive(caseSensitive()) - // Spark eagerly consumes the batches. So the underlying memory allocated could be reused - // without worrying about subsequent reads clobbering over each other. This improves - // read performance as every batch read doesn't have to pay the cost of allocating memory. - .reuseContainers() - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newOrcIterable( - InputFile inputFile, - long start, - long length, - Expression residual, - Map idToConstant) { - Set constantFieldIds = idToConstant.keySet(); - Set metadataFieldIds = MetadataColumns.metadataFieldIds(); - Sets.SetView constantAndMetadataFieldIds = - Sets.union(constantFieldIds, metadataFieldIds); - Schema schemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot(expectedSchema(), constantAndMetadataFieldIds); - - return ORC.read(inputFile) - .project(schemaWithoutConstantAndMetadataFields) - .split(start, length) - .createBatchedReaderFunc( - fileSchema -> - VectorizedSparkOrcReaders.buildReader(expectedSchema(), fileSchema, idToConstant)) - .recordsPerBatch(orcConf.batchSize()) - .filter(residual) - .caseSensitive(caseSensitive()) - .withNameMapping(nameMapping()) - .build(); + return CloseableIterable.transform(iterable, new BatchDeleteFilter(deleteFilter)::filterBatch); } @VisibleForTesting diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java index bf1622617185..0333f1e45dd4 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java @@ -44,6 +44,7 @@ import org.apache.iceberg.deletes.DeleteCounter; import org.apache.iceberg.encryption.EncryptingFileIO; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.mapping.NameMapping; import org.apache.iceberg.mapping.NameMappingParser; @@ -66,6 +67,7 @@ abstract class BaseReader implements Closeable { private static final Logger LOG = LoggerFactory.getLogger(BaseReader.class); private final Table table; + private final EncryptingFileIO fileIO; private final Schema tableSchema; private final Schema expectedSchema; private final boolean caseSensitive; @@ -82,12 +84,14 @@ abstract class BaseReader implements Closeable { BaseReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { this.table = table; + this.fileIO = EncryptingFileIO.combine(fileIO, table().encryption()); this.taskGroup = taskGroup; this.tasks = taskGroup.tasks().iterator(); this.currentIterator = CloseableIterator.empty(); @@ -180,9 +184,8 @@ protected InputFile getInputFile(String location) { private Map inputFiles() { if (lazyInputFiles == null) { this.lazyInputFiles = - EncryptingFileIO.combine(table().io(), table().encryption()) - .bulkDecrypt( - () -> taskGroup.tasks().stream().flatMap(this::referencedFiles).iterator()); + fileIO.bulkDecrypt( + () -> taskGroup.tasks().stream().flatMap(this::referencedFiles).iterator()); } return lazyInputFiles; diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java index c12931e786b1..a7016e3b09b7 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java @@ -20,34 +20,35 @@ import java.util.Map; import org.apache.iceberg.FileFormat; -import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.ScanTask; import org.apache.iceberg.ScanTaskGroup; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; -import org.apache.iceberg.avro.Avro; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; -import org.apache.iceberg.orc.ORC; -import org.apache.iceberg.parquet.Parquet; -import org.apache.iceberg.relocated.com.google.common.collect.Sets; -import org.apache.iceberg.spark.data.SparkOrcReader; -import org.apache.iceberg.spark.data.SparkParquetReaders; -import org.apache.iceberg.spark.data.SparkPlannedAvroReader; -import org.apache.iceberg.types.TypeUtil; import org.apache.spark.sql.catalyst.InternalRow; abstract class BaseRowReader extends BaseReader { BaseRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); } protected CloseableIterable newIterable( @@ -58,69 +59,15 @@ protected CloseableIterable newIterable( Expression residual, Schema projection, Map idToConstant) { - switch (format) { - case PARQUET: - return newParquetIterable(file, start, length, residual, projection, idToConstant); - - case AVRO: - return newAvroIterable(file, start, length, projection, idToConstant); - - case ORC: - return newOrcIterable(file, start, length, residual, projection, idToConstant); - - default: - throw new UnsupportedOperationException("Cannot read unknown format: " + format); - } - } - - private CloseableIterable newAvroIterable( - InputFile file, long start, long length, Schema projection, Map idToConstant) { - return Avro.read(file) - .reuseContainers() + ReadBuilder reader = + FormatModelRegistry.readBuilder(format, InternalRow.class, file); + return reader .project(projection) - .split(start, length) - .createResolvingReader(schema -> SparkPlannedAvroReader.create(schema, idToConstant)) - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newParquetIterable( - InputFile file, - long start, - long length, - Expression residual, - Schema readSchema, - Map idToConstant) { - return Parquet.read(file) + .idToConstant(idToConstant) .reuseContainers() .split(start, length) - .project(readSchema) - .createReaderFunc( - fileSchema -> SparkParquetReaders.buildReader(readSchema, fileSchema, idToConstant)) - .filter(residual) .caseSensitive(caseSensitive()) - .withNameMapping(nameMapping()) - .build(); - } - - private CloseableIterable newOrcIterable( - InputFile file, - long start, - long length, - Expression residual, - Schema readSchema, - Map idToConstant) { - Schema readSchemaWithoutConstantAndMetadataFields = - TypeUtil.selectNot( - readSchema, Sets.union(idToConstant.keySet(), MetadataColumns.metadataFieldIds())); - - return ORC.read(file) - .project(readSchemaWithoutConstantAndMetadataFields) - .split(start, length) - .createReaderFunc( - readOrcSchema -> new SparkOrcReader(readSchema, readOrcSchema, idToConstant)) .filter(residual) - .caseSensitive(caseSensitive()) .withNameMapping(nameMapping()) .build(); } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..9298c2bbdfcc --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Locale; +import org.apache.iceberg.DataOperations; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.CompositeReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadMaxFiles; +import org.apache.spark.sql.connector.read.streaming.ReadMaxRows; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class BaseSparkMicroBatchPlanner implements SparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(BaseSparkMicroBatchPlanner.class); + private final Table table; + private final SparkReadConf readConf; + + BaseSparkMicroBatchPlanner(Table table, SparkReadConf readConf) { + this.table = table; + this.readConf = readConf; + } + + protected Table table() { + return table; + } + + protected SparkReadConf readConf() { + return readConf; + } + + protected boolean shouldProcess(Snapshot snapshot) { + String op = snapshot.operation(); + switch (op) { + case DataOperations.APPEND: + return true; + case DataOperations.REPLACE: + return false; + case DataOperations.DELETE: + Preconditions.checkState( + readConf.streamingSkipDeleteSnapshots(), + "Cannot process delete snapshot: %s, to ignore deletes, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS); + return false; + case DataOperations.OVERWRITE: + Preconditions.checkState( + readConf.streamingSkipOverwriteSnapshots(), + "Cannot process overwrite snapshot: %s, to ignore overwrites, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS); + return false; + default: + throw new IllegalStateException( + String.format( + "Cannot process unknown snapshot operation: %s (snapshot id %s)", + op.toLowerCase(Locale.ROOT), snapshot.snapshotId())); + } + } + + /** + * Get the next snapshot skipping over rewrite and delete snapshots. Async must handle nulls. + * + * @param curSnapshot the current snapshot + * @return the next valid snapshot (not a rewrite or delete snapshot), returns null if all + * remaining snapshots should be skipped. + */ + protected Snapshot nextValidSnapshot(Snapshot curSnapshot) { + Snapshot nextSnapshot; + // if there were no valid snapshots, check for an initialOffset again + if (curSnapshot == null) { + StreamingOffset startingOffset = + MicroBatchUtils.determineStartingOffset(table, readConf.streamFromTimestamp()); + LOG.debug("determineStartingOffset picked startingOffset: {}", startingOffset); + if (StreamingOffset.START_OFFSET.equals(startingOffset)) { + return null; + } + nextSnapshot = table.snapshot(startingOffset.snapshotId()); + } else { + if (curSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, curSnapshot.snapshotId()); + } + // skip over rewrite and delete snapshots + while (!shouldProcess(nextSnapshot)) { + LOG.debug("Skipping snapshot: {}", nextSnapshot); + // if the currentSnapShot was also the mostRecentSnapshot then break + // avoids snapshotAfter throwing exception since there are no more snapshots to process + if (nextSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, nextSnapshot.snapshotId()); + } + return nextSnapshot; + } + + static class UnpackedLimits { + private long maxRows = Integer.MAX_VALUE; + private long maxFiles = Integer.MAX_VALUE; + + UnpackedLimits(ReadLimit limit) { + if (limit instanceof CompositeReadLimit) { + ReadLimit[] compositeLimits = ((CompositeReadLimit) limit).getReadLimits(); + for (ReadLimit individualLimit : compositeLimits) { + if (individualLimit instanceof ReadMaxRows) { + ReadMaxRows readMaxRows = (ReadMaxRows) individualLimit; + this.maxRows = Math.min(this.maxRows, readMaxRows.maxRows()); + } else if (individualLimit instanceof ReadMaxFiles) { + ReadMaxFiles readMaxFiles = (ReadMaxFiles) individualLimit; + this.maxFiles = Math.min(this.maxFiles, readMaxFiles.maxFiles()); + } + } + } else if (limit instanceof ReadMaxRows) { + this.maxRows = ((ReadMaxRows) limit).maxRows(); + } else if (limit instanceof ReadMaxFiles) { + this.maxFiles = ((ReadMaxFiles) limit).maxFiles(); + } + } + + public long getMaxRows() { + return maxRows; + } + + public long getMaxFiles() { + return maxFiles; + } + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java index 9ec0f885775f..3dcfb604ea46 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java @@ -26,6 +26,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.OrcBatchReadConf; @@ -53,6 +54,7 @@ class BatchDataReader extends BaseBatchReader OrcBatchReadConf orcBatchReadConf) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -64,6 +66,7 @@ class BatchDataReader extends BaseBatchReader BatchDataReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -73,6 +76,7 @@ class BatchDataReader extends BaseBatchReader boolean cacheDeleteFilesOnExecutors) { super( table, + fileIO, taskGroup, tableSchema, expectedSchema, diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java index b8fa129f6a44..365747565165 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java @@ -35,6 +35,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.util.SnapshotUtil; @@ -51,6 +52,7 @@ class ChangelogRowReader extends BaseRowReader ChangelogRowReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -60,6 +62,7 @@ class ChangelogRowReader extends BaseRowReader ChangelogRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -67,6 +70,7 @@ class ChangelogRowReader extends BaseRowReader boolean cacheDeleteFilesOnExecutors) { super( table, + fileIO, taskGroup, tableSchema, ChangelogUtil.dropChangelogMetadata(expectedSchema), diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java index e1292647b797..96dd99ea64e3 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java @@ -25,6 +25,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.spark.rdd.InputFileBlockHolder; import org.apache.spark.sql.catalyst.InternalRow; @@ -32,11 +33,19 @@ public class EqualityDeleteRowReader extends RowDataReader { public EqualityDeleteRowReader( CombinedScanTask task, Table table, + FileIO fileIO, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { - super(table, task, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + super( + table, + fileIO, + task, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); } @Override diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java index 45d842f1c36a..a9df99461558 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java @@ -116,13 +116,13 @@ public Table getTable(StructType schema, Transform[] partitioning, Map PositionDeletesRowReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -58,14 +57,20 @@ class PositionDeletesRowReader extends BaseRowReader PositionDeletesRowReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, boolean caseSensitive, boolean cacheDeleteFilesOnExecutors) { - super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); int numSplits = taskGroup.tasks().size(); LOG.debug("Reading {} position delete file split(s) for table {}", numSplits, table.name()); @@ -88,12 +93,16 @@ protected CloseableIterator open(PositionDeletesScanTask task) { InputFile inputFile = getInputFile(task.file().location()); Preconditions.checkNotNull(inputFile, "Could not find InputFile associated with %s", task); - // select out constant fields when pushing down filter to row reader + // Retain predicates on non-constant fields for row reader filter Map idToConstant = constantsMap(task, expectedSchema()); - Set nonConstantFieldIds = nonConstantFieldIds(idToConstant); + int[] nonConstantFieldIds = + expectedSchema().idToName().keySet().stream() + .filter(id -> !idToConstant.containsKey(id)) + .mapToInt(Integer::intValue) + .toArray(); Expression residualWithoutConstants = ExpressionUtil.extractByIdInclusive( - task.residual(), expectedSchema(), caseSensitive(), Ints.toArray(nonConstantFieldIds)); + task.residual(), expectedSchema(), caseSensitive(), nonConstantFieldIds); if (ContentFileUtil.isDV(task.file())) { return new DVIterator(inputFile, task.file(), expectedSchema(), idToConstant); @@ -109,12 +118,4 @@ protected CloseableIterator open(PositionDeletesScanTask task) { idToConstant) .iterator(); } - - private Set nonConstantFieldIds(Map idToConstant) { - Set fields = expectedSchema().idToName().keySet(); - return fields.stream() - .filter(id -> expectedSchema().findField(id).type().isPrimitiveType()) - .filter(id -> !idToConstant.containsKey(id)) - .collect(Collectors.toSet()); - } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java index b2b3c7856389..08aa44f71041 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java @@ -28,6 +28,7 @@ import org.apache.iceberg.Table; import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.spark.source.metrics.TaskNumDeletes; @@ -48,6 +49,7 @@ class RowDataReader extends BaseRowReader implements PartitionRead RowDataReader(SparkInputPartition partition) { this( partition.table(), + partition.io(), partition.taskGroup(), SnapshotUtil.schemaFor(partition.table(), partition.branch()), partition.expectedSchema(), @@ -57,6 +59,7 @@ class RowDataReader extends BaseRowReader implements PartitionRead RowDataReader( Table table, + FileIO fileIO, ScanTaskGroup taskGroup, Schema tableSchema, Schema expectedSchema, @@ -64,7 +67,13 @@ class RowDataReader extends BaseRowReader implements PartitionRead boolean cacheDeleteFilesOnExecutors) { super( - table, taskGroup, tableSchema, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + table, + fileIO, + taskGroup, + tableSchema, + expectedSchema, + caseSensitive, + cacheDeleteFilesOnExecutors); numSplits = taskGroup.tasks().size(); LOG.debug("Reading {} file split(s) for table {}", numSplits, table.name()); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java new file mode 100644 index 000000000000..2a359110c810 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java @@ -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. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.function.Function; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.hadoop.HadoopConfigurable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.util.SerializableSupplier; +import org.apache.spark.util.KnownSizeEstimation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class provides a serializable {@link FileIO} with a known size estimate. Spark calls its + * {@link org.apache.spark.util.SizeEstimator} class when broadcasting variables and this can be an + * expensive operation, so providing a known size estimate allows that operation to be skipped. + * + *

    This class also implements {@link AutoCloseable} to avoid leaking resources upon broadcasting. + * Broadcast variables are destroyed and cleaned up on the driver and executors once they are + * garbage collected on the driver. The implementation ensures only resources used by copies of the + * main {@link FileIO} are released. + */ +class SerializableFileIOWithSize + implements FileIO, HadoopConfigurable, KnownSizeEstimation, AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(SerializableFileIOWithSize.class); + private static final long SIZE_ESTIMATE = 32_768L; + private final transient Object serializationMarker; + private final FileIO fileIO; + + private SerializableFileIOWithSize(FileIO fileIO) { + this.fileIO = fileIO; + this.serializationMarker = new Object(); + } + + @Override + public long estimatedSize() { + return SIZE_ESTIMATE; + } + + public static FileIO wrap(FileIO fileIO) { + return new SerializableFileIOWithSize(fileIO); + } + + @Override + public void close() { + if (null == serializationMarker) { + LOG.debug("Closing FileIO"); + fileIO.close(); + } + } + + @Override + public InputFile newInputFile(String path) { + return fileIO.newInputFile(path); + } + + @Override + public InputFile newInputFile(String path, long length) { + return fileIO.newInputFile(path, length); + } + + @Override + public OutputFile newOutputFile(String path) { + return fileIO.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + fileIO.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + fileIO.initialize(properties); + } + + @Override + public Map properties() { + return fileIO.properties(); + } + + @Override + public void serializeConfWith( + Function> confSerializer) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.serializeConfWith(confSerializer); + } + } + + @Override + public void setConf(Configuration conf) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.setConf(conf); + } + } + + @Override + public Configuration getConf() { + if (fileIO instanceof HadoopConfigurable hadoopConfigurable) { + return hadoopConfigurable.getConf(); + } + + return null; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java index 0626d0b43985..2109936c96b9 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Objects; +import java.util.function.Supplier; import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; import org.apache.iceberg.MetadataColumns; @@ -28,14 +29,13 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.spark.ImmutableOrcBatchReadConf; import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; import org.apache.iceberg.spark.OrcBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.SparkReadConf; import org.apache.iceberg.spark.SparkUtil; -import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; @@ -47,6 +47,7 @@ class SparkBatch implements Batch { private final JavaSparkContext sparkContext; private final Table table; + private final Supplier fileIO; private final String branch; private final SparkReadConf readConf; private final Types.StructType groupingKeyType; @@ -61,6 +62,7 @@ class SparkBatch implements Batch { SparkBatch( JavaSparkContext sparkContext, Table table, + Supplier fileIO, SparkReadConf readConf, Types.StructType groupingKeyType, List> taskGroups, @@ -68,6 +70,7 @@ class SparkBatch implements Batch { int scanHashCode) { this.sparkContext = sparkContext; this.table = table; + this.fileIO = fileIO; this.branch = readConf.branch(); this.readConf = readConf; this.groupingKeyType = groupingKeyType; @@ -85,6 +88,8 @@ public InputPartition[] planInputPartitions() { // broadcast the table metadata as input partitions will be sent to executors Broadcast

    tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + Broadcast fileIOBroadcast = + sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); String expectedSchemaString = SchemaParser.toJson(expectedSchema); String[][] locations = computePreferredLocations(); @@ -96,6 +101,7 @@ public InputPartition[] planInputPartitions() { groupingKeyType, taskGroups.get(index), tableBroadcast, + fileIOBroadcast, branch, expectedSchemaString, caseSensitive, @@ -108,7 +114,7 @@ public InputPartition[] planInputPartitions() { private String[][] computePreferredLocations() { if (localityEnabled) { - return SparkPlanningUtil.fetchBlockLocations(table.io(), taskGroups); + return SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups); } else if (executorCacheLocalityEnabled) { List executorLocations = SparkUtil.executorLocations(); @@ -122,11 +128,8 @@ private String[][] computePreferredLocations() { @Override public PartitionReaderFactory createReaderFactory() { - if (useCometBatchReads()) { - return new SparkColumnarReaderFactory(parquetBatchReadConf(ParquetReaderType.COMET)); - - } else if (useParquetBatchReads()) { - return new SparkColumnarReaderFactory(parquetBatchReadConf(ParquetReaderType.ICEBERG)); + if (useParquetBatchReads()) { + return new SparkColumnarReaderFactory(parquetBatchReadConf()); } else if (useOrcBatchReads()) { return new SparkColumnarReaderFactory(orcBatchReadConf()); @@ -136,11 +139,8 @@ public PartitionReaderFactory createReaderFactory() { } } - private ParquetBatchReadConf parquetBatchReadConf(ParquetReaderType readerType) { - return ImmutableParquetBatchReadConf.builder() - .batchSize(readConf.parquetBatchSize()) - .readerType(readerType) - .build(); + private ParquetBatchReadConf parquetBatchReadConf() { + return ImmutableParquetBatchReadConf.builder().batchSize(readConf.parquetBatchSize()).build(); } private OrcBatchReadConf orcBatchReadConf() { @@ -175,20 +175,6 @@ private boolean supportsParquetBatchReads(Types.NestedField field) { return field.type().isPrimitiveType() || MetadataColumns.isMetadataColumn(field.fieldId()); } - private boolean useCometBatchReads() { - return readConf.parquetVectorizationEnabled() - && readConf.parquetReaderType() == ParquetReaderType.COMET - && expectedSchema.columns().stream().allMatch(this::supportsCometBatchReads) - && taskGroups.stream().allMatch(this::supportsParquetBatchReads); - } - - private boolean supportsCometBatchReads(Types.NestedField field) { - return field.type().isPrimitiveType() - && !field.type().typeId().equals(Type.TypeID.UUID) - && field.fieldId() != MetadataColumns.ROW_ID.fieldId() - && field.fieldId() != MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(); - } - // conditions for using ORC batch reads: // - ORC vectorization is enabled // - all tasks are of type FileScanTask and read only ORC files with no delete files diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java index 55ea137ca1b0..eba0431e3adf 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java @@ -106,6 +106,7 @@ public Batch toBatch() { return new SparkBatch( sparkContext, table, + null != scan ? scan.fileIO() : table::io, readConf, EMPTY_GROUPING_KEY_TYPE, taskGroups(), diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java index 93b27c566c9f..8d5972ac7acf 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java @@ -21,9 +21,9 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.CatalogUtil; import org.apache.iceberg.ContentFile; import org.apache.iceberg.exceptions.NotFoundException; -import org.apache.iceberg.io.BulkDeletionFailureException; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.SupportsBulkOperations; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -84,34 +84,13 @@ private static String taskInfo() { */ public static void deleteFiles(String context, FileIO io, List> files) { List paths = Lists.transform(files, ContentFile::location); - deletePaths(context, io, paths); - } - - private static void deletePaths(String context, FileIO io, List paths) { if (io instanceof SupportsBulkOperations) { - SupportsBulkOperations bulkIO = (SupportsBulkOperations) io; - bulkDelete(context, bulkIO, paths); + CatalogUtil.deleteFiles(io, paths, ""); } else { delete(context, io, paths); } } - private static void bulkDelete(String context, SupportsBulkOperations io, List paths) { - try { - io.deleteFiles(paths); - LOG.info("Deleted {} file(s) using bulk deletes ({})", paths.size(), context); - - } catch (BulkDeletionFailureException e) { - int deletedFilesCount = paths.size() - e.numberFailedObjects(); - LOG.warn( - "Deleted only {} of {} file(s) using bulk deletes ({})", - deletedFilesCount, - paths.size(), - context, - e); - } - } - private static void delete(String context, FileIO io, List paths) { AtomicInteger deletedFilesCount = new AtomicInteger(0); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java index ee4be2461894..f957b97d60f5 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java @@ -38,16 +38,16 @@ import org.apache.iceberg.util.SnapshotUtil; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.Literal; import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.Statistics; -import org.apache.spark.sql.connector.read.SupportsRuntimeFiltering; -import org.apache.spark.sql.sources.Filter; -import org.apache.spark.sql.sources.In; +import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering; import org.slf4j.Logger; import org.slf4j.LoggerFactory; class SparkCopyOnWriteScan extends SparkPartitioningAwareScan - implements SupportsRuntimeFiltering { + implements SupportsRuntimeV2Filtering { private static final Logger LOG = LoggerFactory.getLogger(SparkCopyOnWriteScan.class); @@ -103,7 +103,7 @@ public NamedReference[] filterAttributes() { } @Override - public void filter(Filter[] filters) { + public void filter(Predicate[] predicates) { Preconditions.checkState( Objects.equals(snapshotId(), currentSnapshotId()), "Runtime file filtering is not possible: the table has been concurrently modified. " @@ -113,16 +113,10 @@ public void filter(Filter[] filters) { snapshotId(), currentSnapshotId()); - for (Filter filter : filters) { - // Spark can only pass In filters at the moment - if (filter instanceof In - && ((In) filter).attribute().equalsIgnoreCase(MetadataColumns.FILE_PATH.name())) { - In in = (In) filter; - - Set fileLocations = Sets.newHashSet(); - for (Object value : in.values()) { - fileLocations.add((String) value); - } + for (Predicate predicate : predicates) { + // Spark can only pass IN predicates at the moment + if (isFilePathInPredicate(predicate)) { + Set fileLocations = extractStringLiterals(predicate); // Spark may call this multiple times for UPDATEs with subqueries // as such cases are rewritten using UNION and the same scan on both sides @@ -144,7 +138,7 @@ public void filter(Filter[] filters) { resetTasks(filteredTasks); } } else { - LOG.warn("Unsupported runtime filter {}", filter); + LOG.warn("Unsupported runtime filter {}", predicate); } } } @@ -188,4 +182,32 @@ private Long currentSnapshotId() { Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table(), branch()); return currentSnapshot != null ? currentSnapshot.snapshotId() : null; } + + private static boolean isFilePathInPredicate(Predicate predicate) { + if (!"IN".equals(predicate.name()) || predicate.children().length < 1) { + return false; + } + + if (!(predicate.children()[0] instanceof NamedReference)) { + return false; + } + + String[] fieldNames = ((NamedReference) predicate.children()[0]).fieldNames(); + + return fieldNames.length == 1 + && fieldNames[0].equalsIgnoreCase(MetadataColumns.FILE_PATH.name()); + } + + private static Set extractStringLiterals(Predicate predicate) { + Set values = Sets.newHashSet(); + for (int i = 1; i < predicate.children().length; i++) { + if (predicate.children()[i] instanceof Literal) { + Object value = ((Literal) predicate.children()[i]).value(); + // V2 string literals come through as UTF8String; toString() materializes the Java String + values.add(value.toString()); + } + } + + return values; + } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java index a93db17e4a0f..39110f0b0597 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java @@ -23,13 +23,20 @@ import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; import static org.apache.iceberg.TableProperties.DELETE_DEFAULT_FILE_FORMAT; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.Map; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.avro.Avro; -import org.apache.iceberg.data.BaseFileWriterFactory; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; import org.apache.iceberg.io.DeleteSchemaUtil; import org.apache.iceberg.orc.ORC; import org.apache.iceberg.parquet.Parquet; @@ -40,14 +47,20 @@ import org.apache.iceberg.spark.data.SparkOrcWriter; import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.spark.sql.catalyst.InternalRow; -import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; import org.apache.spark.unsafe.types.UTF8String; - -class SparkFileWriterFactory extends BaseFileWriterFactory { - private StructType dataSparkType; - private StructType equalityDeleteSparkType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SparkFileWriterFactory extends RegistryBasedFileWriterFactory { + private static final Logger LOG = LoggerFactory.getLogger(SparkFileWriterFactory.class); + // We need to use old writers to write position deletes with row data, which is a deprecated + // feature. + private final boolean useDeprecatedPositionDeleteWriter; private StructType positionDeleteSparkType; + private final Schema positionDeleteRowSchema; + private final Table table; + private final FileFormat deleteFormat; private final Map writeProperties; /** @@ -75,18 +88,26 @@ class SparkFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + InternalRow.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - positionDeleteRowSchema); + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); - this.dataSparkType = dataSparkType; - this.equalityDeleteSparkType = equalityDeleteSparkType; - this.positionDeleteSparkType = positionDeleteSparkType; + this.table = table; + this.deleteFormat = deleteFileFormat; this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = positionDeleteRowSchema; + this.positionDeleteSparkType = positionDeleteSparkType; + this.useDeprecatedPositionDeleteWriter = + positionDeleteRowSchema != null + || (positionDeleteSparkType != null + && positionDeleteSparkType.getFieldIndex(DELETE_FILE_ROW_FIELD_NAME).isDefined()); } SparkFileWriterFactory( @@ -105,119 +126,109 @@ class SparkFileWriterFactory extends BaseFileWriterFactory { super( table, dataFileFormat, + InternalRow.class, dataSchema, dataSortOrder, deleteFileFormat, equalityFieldIds, equalityDeleteRowSchema, equalityDeleteSortOrder, - ImmutableMap.of()); + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); - this.dataSparkType = dataSparkType; - this.equalityDeleteSparkType = equalityDeleteSparkType; - this.positionDeleteSparkType = null; + this.table = table; + this.deleteFormat = deleteFileFormat; this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = null; + this.useDeprecatedPositionDeleteWriter = false; } static Builder builderFor(Table table) { return new Builder(table); } - @Override - protected void configureDataWrite(Avro.DataWriteBuilder builder) { - builder.createWriterFunc(ignored -> new SparkAvroWriter(dataSparkType())); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(Avro.DeleteWriteBuilder builder) { - builder.createWriterFunc(ignored -> new SparkAvroWriter(equalityDeleteSparkType())); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(Avro.DeleteWriteBuilder builder) { - boolean withRow = - positionDeleteSparkType().getFieldIndex(DELETE_FILE_ROW_FIELD_NAME).isDefined(); - if (withRow) { - // SparkAvroWriter accepts just the Spark type of the row ignoring the path and pos - StructField rowField = positionDeleteSparkType().apply(DELETE_FILE_ROW_FIELD_NAME); - StructType positionDeleteRowSparkType = (StructType) rowField.dataType(); - builder.createWriterFunc(ignored -> new SparkAvroWriter(positionDeleteRowSparkType)); - } - - builder.setAll(writeProperties); - } - - @Override - protected void configureDataWrite(Parquet.DataWriteBuilder builder) { - builder.createWriterFunc(msgType -> SparkParquetWriters.buildWriter(dataSparkType(), msgType)); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> SparkParquetWriters.buildWriter(equalityDeleteSparkType(), msgType)); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(Parquet.DeleteWriteBuilder builder) { - builder.createWriterFunc( - msgType -> SparkParquetWriters.buildWriter(positionDeleteSparkType(), msgType)); - builder.transformPaths(path -> UTF8String.fromString(path.toString())); - builder.setAll(writeProperties); - } - - @Override - protected void configureDataWrite(ORC.DataWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.setAll(writeProperties); - } - - @Override - protected void configureEqualityDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.setAll(writeProperties); - } - - @Override - protected void configurePositionDelete(ORC.DeleteWriteBuilder builder) { - builder.createWriterFunc(SparkOrcWriter::new); - builder.transformPaths(path -> UTF8String.fromString(path.toString())); - builder.setAll(writeProperties); - } - - private StructType dataSparkType() { - if (dataSparkType == null) { - Preconditions.checkNotNull(dataSchema(), "Data schema must not be null"); - this.dataSparkType = SparkSchemaUtil.convert(dataSchema()); - } - - return dataSparkType; - } - - private StructType equalityDeleteSparkType() { - if (equalityDeleteSparkType == null) { - Preconditions.checkNotNull( - equalityDeleteRowSchema(), "Equality delete schema must not be null"); - this.equalityDeleteSparkType = SparkSchemaUtil.convert(equalityDeleteRowSchema()); - } - - return equalityDeleteSparkType; - } - private StructType positionDeleteSparkType() { if (positionDeleteSparkType == null) { // wrap the optional row schema into the position delete schema containing path and position - Schema positionDeleteSchema = DeleteSchemaUtil.posDeleteSchema(positionDeleteRowSchema()); + Schema positionDeleteSchema = DeleteSchemaUtil.posDeleteSchema(positionDeleteRowSchema); this.positionDeleteSparkType = SparkSchemaUtil.convert(positionDeleteSchema); } return positionDeleteSparkType; } + @Override + public PositionDeleteWriter newPositionDeleteWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + if (!useDeprecatedPositionDeleteWriter) { + return super.newPositionDeleteWriter(file, spec, partition); + } else { + LOG.warn("Position deletes with deleted rows are deprecated and will be removed in 1.12.0."); + Map properties = table == null ? ImmutableMap.of() : table.properties(); + MetricsConfig metricsConfig = + table == null + ? MetricsConfig.forPositionDelete() + : MetricsConfig.forPositionDelete(table); + + try { + return switch (deleteFormat) { + case AVRO -> + Avro.writeDeletes(file) + .createWriterFunc( + ignored -> + new SparkAvroWriter( + (StructType) + positionDeleteSparkType() + .apply(DELETE_FILE_ROW_FIELD_NAME) + .dataType())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(file) + .createWriterFunc(SparkOrcWriter::new) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(file) + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter(positionDeleteSparkType(), msgType)) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + default -> + throw new UnsupportedOperationException( + "Cannot write pos-deletes for unsupported file format: " + deleteFormat); + }; + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new position delete writer", e); + } + } + } + static class Builder { private final Table table; private FileFormat dataFileFormat; @@ -340,4 +351,14 @@ SparkFileWriterFactory build() { writeProperties); } } + + private static StructType useOrConvert(StructType sparkType, Schema schema) { + if (sparkType != null) { + return sparkType; + } else if (schema != null) { + return SparkSchemaUtil.convert(schema); + } else { + return null; + } + } } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java new file mode 100644 index 000000000000..5b7862116aea --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; +import org.apache.iceberg.spark.data.SparkAvroWriter; +import org.apache.iceberg.spark.data.SparkOrcReader; +import org.apache.iceberg.spark.data.SparkOrcWriter; +import org.apache.iceberg.spark.data.SparkParquetReaders; +import org.apache.iceberg.spark.data.SparkParquetWriters; +import org.apache.iceberg.spark.data.SparkPlannedAvroReader; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +public class SparkFormatModels { + public static void register() { + FormatModelRegistry.register( + AvroFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkAvroWriter(icebergSchema, engineSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkPlannedAvroReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register( + ParquetFormatModel.create( + InternalRow.class, + StructType.class, + SparkParquetWriters::buildWriter, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant), + new SparkVariantShreddingAnalyzer(), + InternalRow::copy)); + + FormatModelRegistry.register( + ParquetFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkParquetReaders.buildReader( + icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkOrcWriter(icebergSchema, fileSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + new SparkOrcReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkOrcReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + } + + private SparkFormatModels() {} +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java index 99b1d78a86b0..a93031780255 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java @@ -24,6 +24,7 @@ import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.types.Types; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.catalyst.InternalRow; @@ -34,6 +35,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab private final Types.StructType groupingKeyType; private final ScanTaskGroup taskGroup; private final Broadcast
    tableBroadcast; + private final Broadcast fileIOBroadcast; private final String branch; private final String expectedSchemaString; private final boolean caseSensitive; @@ -46,6 +48,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab Types.StructType groupingKeyType, ScanTaskGroup taskGroup, Broadcast
    tableBroadcast, + Broadcast fileIOBroadcast, String branch, String expectedSchemaString, boolean caseSensitive, @@ -54,6 +57,7 @@ class SparkInputPartition implements InputPartition, HasPartitionKey, Serializab this.groupingKeyType = groupingKeyType; this.taskGroup = taskGroup; this.tableBroadcast = tableBroadcast; + this.fileIOBroadcast = fileIOBroadcast; this.branch = branch; this.expectedSchemaString = expectedSchemaString; this.caseSensitive = caseSensitive; @@ -84,6 +88,10 @@ public Table table() { return tableBroadcast.value(); } + public FileIO io() { + return fileIOBroadcast.value(); + } + public String branch() { return branch; } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java new file mode 100644 index 000000000000..1986ddac5d8e --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.FileScanTask; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; + +interface SparkMicroBatchPlanner { + /** + * Return the {@link FileScanTask}s for data added between the start and end offsets. + * + * @param startOffset the offset to start planning from + * @param endOffset the offset to plan up to + * @return file scan tasks for data in the offset range + */ + List planFiles(StreamingOffset startOffset, StreamingOffset endOffset); + + /** + * Return the latest offset the stream can advance to from {@code startOffset}, respecting the + * given {@link ReadLimit}. + * + * @param startOffset the current offset of the stream + * @param limit the read limit bounding how far ahead to advance + * @return the latest available offset, or {@code null} if no new data is available + */ + StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit); + + /** Stop the planner and release any resources. */ + void stop(); +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java index 60dd1f318ca5..a1ff767fe2a0 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java @@ -26,44 +26,32 @@ import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.Locale; +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.CombinedScanTask; -import org.apache.iceberg.DataOperations; import org.apache.iceberg.FileScanTask; -import org.apache.iceberg.ManifestFile; -import org.apache.iceberg.MicroBatches; -import org.apache.iceberg.MicroBatches.MicroBatch; import org.apache.iceberg.Schema; import org.apache.iceberg.SchemaParser; import org.apache.iceberg.Snapshot; -import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; +import org.apache.iceberg.hadoop.HadoopFileIO; import org.apache.iceberg.io.CloseableIterable; -import org.apache.iceberg.io.CloseableIterator; import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.InputFile; import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.relocated.com.google.common.base.Joiner; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadConf; -import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.types.Types; -import org.apache.iceberg.util.Pair; -import org.apache.iceberg.util.PropertyUtil; -import org.apache.iceberg.util.SnapshotUtil; import org.apache.iceberg.util.TableScanUtil; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.connector.read.InputPartition; import org.apache.spark.sql.connector.read.PartitionReaderFactory; -import org.apache.spark.sql.connector.read.streaming.CompositeReadLimit; import org.apache.spark.sql.connector.read.streaming.MicroBatchStream; import org.apache.spark.sql.connector.read.streaming.Offset; import org.apache.spark.sql.connector.read.streaming.ReadLimit; -import org.apache.spark.sql.connector.read.streaming.ReadMaxFiles; -import org.apache.spark.sql.connector.read.streaming.ReadMaxRows; import org.apache.spark.sql.connector.read.streaming.SupportsTriggerAvailableNow; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,35 +62,41 @@ public class SparkMicroBatchStream implements MicroBatchStream, SupportsTriggerA private static final Types.StructType EMPTY_GROUPING_KEY_TYPE = Types.StructType.of(); private final Table table; + private final Supplier fileIO; + private final SparkReadConf readConf; private final String branch; private final boolean caseSensitive; private final String expectedSchema; private final Broadcast
    tableBroadcast; + private final Broadcast fileIOBroadcast; private final long splitSize; private final int splitLookback; private final long splitOpenFileCost; private final boolean localityPreferred; private final StreamingOffset initialOffset; - private final boolean skipDelete; - private final boolean skipOverwrite; private final long fromTimestamp; private final int maxFilesPerMicroBatch; private final int maxRecordsPerMicroBatch; private final boolean cacheDeleteFilesOnExecutors; + private SparkMicroBatchPlanner planner; private StreamingOffset lastOffsetForTriggerAvailableNow; SparkMicroBatchStream( JavaSparkContext sparkContext, Table table, + Supplier fileIO, SparkReadConf readConf, Schema expectedSchema, String checkpointLocation) { this.table = table; + this.fileIO = fileIO; + this.readConf = readConf; this.branch = readConf.branch(); this.caseSensitive = readConf.caseSensitive(); this.expectedSchema = SchemaParser.toJson(expectedSchema); this.localityPreferred = readConf.localityEnabled(); this.tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + this.fileIOBroadcast = sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); this.splitSize = readConf.splitSize(); this.splitLookback = readConf.splitLookback(); this.splitOpenFileCost = readConf.splitOpenFileCost(); @@ -112,11 +106,9 @@ public class SparkMicroBatchStream implements MicroBatchStream, SupportsTriggerA this.cacheDeleteFilesOnExecutors = readConf.cacheDeleteFilesOnExecutors(); InitialOffsetStore initialOffsetStore = - new InitialOffsetStore(table, checkpointLocation, fromTimestamp); + new InitialOffsetStore( + table, checkpointLocation, fromTimestamp, sparkContext.hadoopConfiguration()); this.initialOffset = initialOffsetStore.initialOffset(); - - this.skipDelete = readConf.streamingSkipDeleteSnapshots(); - this.skipOverwrite = readConf.streamingSkipOverwriteSnapshots(); } @Override @@ -131,8 +123,8 @@ public Offset latestOffset() { } Snapshot latestSnapshot = table.currentSnapshot(); - - return new StreamingOffset(latestSnapshot.snapshotId(), addedFilesCount(latestSnapshot), false); + return new StreamingOffset( + latestSnapshot.snapshotId(), MicroBatchUtils.addedFilesCount(table, latestSnapshot), false); } @Override @@ -151,7 +143,11 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { StreamingOffset endOffset = (StreamingOffset) end; StreamingOffset startOffset = (StreamingOffset) start; - List fileScanTasks = planFiles(startOffset, endOffset); + if (planner == null) { + initializePlanner(startOffset, endOffset); + } + + List fileScanTasks = planner.planFiles(startOffset, endOffset); CloseableIterable splitTasks = TableScanUtil.splitFiles(CloseableIterable.withNoopClose(fileScanTasks), splitSize); @@ -161,13 +157,13 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { String[][] locations = computePreferredLocations(combinedScanTasks); InputPartition[] partitions = new InputPartition[combinedScanTasks.size()]; - for (int index = 0; index < combinedScanTasks.size(); index++) { partitions[index] = new SparkInputPartition( EMPTY_GROUPING_KEY_TYPE, combinedScanTasks.get(index), tableBroadcast, + fileIOBroadcast, branch, expectedSchema, caseSensitive, @@ -179,7 +175,9 @@ public InputPartition[] planInputPartitions(Offset start, Offset end) { } private String[][] computePreferredLocations(List taskGroups) { - return localityPreferred ? SparkPlanningUtil.fetchBlockLocations(table.io(), taskGroups) : null; + return localityPreferred + ? SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups) + : null; } @Override @@ -201,317 +199,35 @@ public Offset deserializeOffset(String json) { public void commit(Offset end) {} @Override - public void stop() {} - - private List planFiles(StreamingOffset startOffset, StreamingOffset endOffset) { - List fileScanTasks = Lists.newArrayList(); - StreamingOffset batchStartOffset = - StreamingOffset.START_OFFSET.equals(startOffset) - ? determineStartingOffset(table, fromTimestamp) - : startOffset; - - StreamingOffset currentOffset = null; - - // [(startOffset : startFileIndex), (endOffset : endFileIndex) ) - do { - long endFileIndex; - if (currentOffset == null) { - currentOffset = batchStartOffset; - } else { - Snapshot snapshotAfter = SnapshotUtil.snapshotAfter(table, currentOffset.snapshotId()); - // it may happen that we need to read this snapshot partially in case it's equal to - // endOffset. - if (currentOffset.snapshotId() != endOffset.snapshotId()) { - currentOffset = new StreamingOffset(snapshotAfter.snapshotId(), 0L, false); - } else { - currentOffset = endOffset; - } - } - - Snapshot snapshot = table.snapshot(currentOffset.snapshotId()); - - validateCurrentSnapshotExists(snapshot, currentOffset); - - if (!shouldProcess(snapshot)) { - LOG.debug("Skipping snapshot: {} of table {}", currentOffset.snapshotId(), table.name()); - continue; - } - - Snapshot currentSnapshot = table.snapshot(currentOffset.snapshotId()); - if (currentOffset.snapshotId() == endOffset.snapshotId()) { - endFileIndex = endOffset.position(); - } else { - endFileIndex = addedFilesCount(currentSnapshot); - } - - MicroBatch latestMicroBatch = - MicroBatches.from(currentSnapshot, table.io()) - .caseSensitive(caseSensitive) - .specsById(table.specs()) - .generate( - currentOffset.position(), - endFileIndex, - Long.MAX_VALUE, - currentOffset.shouldScanAllFiles()); - - fileScanTasks.addAll(latestMicroBatch.tasks()); - } while (currentOffset.snapshotId() != endOffset.snapshotId()); - - return fileScanTasks; - } - - private boolean shouldProcess(Snapshot snapshot) { - String op = snapshot.operation(); - switch (op) { - case DataOperations.APPEND: - return true; - case DataOperations.REPLACE: - return false; - case DataOperations.DELETE: - Preconditions.checkState( - skipDelete, - "Cannot process delete snapshot: %s, to ignore deletes, set %s=true", - snapshot.snapshotId(), - SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS); - return false; - case DataOperations.OVERWRITE: - Preconditions.checkState( - skipOverwrite, - "Cannot process overwrite snapshot: %s, to ignore overwrites, set %s=true", - snapshot.snapshotId(), - SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS); - return false; - default: - throw new IllegalStateException( - String.format( - "Cannot process unknown snapshot operation: %s (snapshot id %s)", - op.toLowerCase(Locale.ROOT), snapshot.snapshotId())); - } - } - - private static StreamingOffset determineStartingOffset(Table table, Long fromTimestamp) { - if (table.currentSnapshot() == null) { - return StreamingOffset.START_OFFSET; - } - - if (fromTimestamp == null) { - // match existing behavior and start from the oldest snapshot - return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); - } - - if (table.currentSnapshot().timestampMillis() < fromTimestamp) { - return StreamingOffset.START_OFFSET; - } - - try { - Snapshot snapshot = SnapshotUtil.oldestAncestorAfter(table, fromTimestamp); - if (snapshot != null) { - return new StreamingOffset(snapshot.snapshotId(), 0, false); - } else { - return StreamingOffset.START_OFFSET; - } - } catch (IllegalStateException e) { - // could not determine the first snapshot after the timestamp. use the oldest ancestor instead - return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); + public void stop() { + if (planner != null) { + planner.stop(); } } - private static int getMaxFiles(ReadLimit readLimit) { - if (readLimit instanceof ReadMaxFiles) { - return ((ReadMaxFiles) readLimit).maxFiles(); - } - - if (readLimit instanceof CompositeReadLimit) { - // We do not expect a CompositeReadLimit to contain a nested CompositeReadLimit. - // In fact, it should only be a composite of two or more of ReadMinRows, ReadMaxRows and - // ReadMaxFiles, with no more than one of each. - ReadLimit[] limits = ((CompositeReadLimit) readLimit).getReadLimits(); - for (ReadLimit limit : limits) { - if (limit instanceof ReadMaxFiles) { - return ((ReadMaxFiles) limit).maxFiles(); - } - } - } - - // there is no ReadMaxFiles, so return the default - return Integer.MAX_VALUE; - } - - private static int getMaxRows(ReadLimit readLimit) { - if (readLimit instanceof ReadMaxRows) { - long maxRows = ((ReadMaxRows) readLimit).maxRows(); - return Math.toIntExact(maxRows); - } - - if (readLimit instanceof CompositeReadLimit) { - ReadLimit[] limits = ((CompositeReadLimit) readLimit).getReadLimits(); - for (ReadLimit limit : limits) { - if (limit instanceof ReadMaxRows) { - long maxRows = ((ReadMaxRows) limit).maxRows(); - return Math.toIntExact(maxRows); - } - } + private void initializePlanner(StreamingOffset startOffset, StreamingOffset endOffset) { + if (readConf.asyncMicroBatchPlanningEnabled()) { + this.planner = + new AsyncSparkMicroBatchPlanner( + table, readConf, startOffset, endOffset, lastOffsetForTriggerAvailableNow); + } else { + this.planner = + new SyncSparkMicroBatchPlanner(table, readConf, lastOffsetForTriggerAvailableNow); } - - // there is no ReadMaxRows, so return the default - return Integer.MAX_VALUE; } @Override - @SuppressWarnings("checkstyle:CyclomaticComplexity") public Offset latestOffset(Offset startOffset, ReadLimit limit) { - // calculate end offset get snapshotId from the startOffset Preconditions.checkArgument( startOffset instanceof StreamingOffset, "Invalid start offset: %s is not a StreamingOffset", startOffset); - table.refresh(); - if (table.currentSnapshot() == null) { - return StreamingOffset.START_OFFSET; - } - - if (table.currentSnapshot().timestampMillis() < fromTimestamp) { - return StreamingOffset.START_OFFSET; + if (planner == null) { + initializePlanner((StreamingOffset) startOffset, null); } - // end offset can expand to multiple snapshots - StreamingOffset startingOffset = (StreamingOffset) startOffset; - - if (startOffset.equals(StreamingOffset.START_OFFSET)) { - startingOffset = determineStartingOffset(table, fromTimestamp); - } - - Snapshot curSnapshot = table.snapshot(startingOffset.snapshotId()); - validateCurrentSnapshotExists(curSnapshot, startingOffset); - - // Use the pre-computed snapshotId when Trigger.AvailableNow is enabled. - long latestSnapshotId = - lastOffsetForTriggerAvailableNow != null - ? lastOffsetForTriggerAvailableNow.snapshotId() - : table.currentSnapshot().snapshotId(); - - int startPosOfSnapOffset = (int) startingOffset.position(); - - boolean scanAllFiles = startingOffset.shouldScanAllFiles(); - - boolean shouldContinueReading = true; - int curFilesAdded = 0; - long curRecordCount = 0; - int curPos = 0; - - // Note : we produce nextOffset with pos as non-inclusive - while (shouldContinueReading) { - // generate manifest index for the curSnapshot - List> indexedManifests = - MicroBatches.skippedManifestIndexesFromSnapshot( - table.io(), curSnapshot, startPosOfSnapOffset, scanAllFiles); - // this is under assumption we will be able to add at-least 1 file in the new offset - for (int idx = 0; idx < indexedManifests.size() && shouldContinueReading; idx++) { - // be rest assured curPos >= startFileIndex - curPos = indexedManifests.get(idx).second(); - try (CloseableIterable taskIterable = - MicroBatches.openManifestFile( - table.io(), - table.specs(), - caseSensitive, - curSnapshot, - indexedManifests.get(idx).first(), - scanAllFiles); - CloseableIterator taskIter = taskIterable.iterator()) { - while (taskIter.hasNext()) { - FileScanTask task = taskIter.next(); - if (curPos >= startPosOfSnapOffset) { - if ((curFilesAdded + 1) > getMaxFiles(limit)) { - // On including the file it might happen that we might exceed, the configured - // soft limit on the number of records, since this is a soft limit its acceptable. - shouldContinueReading = false; - break; - } - - curFilesAdded += 1; - curRecordCount += task.file().recordCount(); - - if (curRecordCount >= getMaxRows(limit)) { - // we included the file, so increment the number of files - // read in the current snapshot. - ++curPos; - shouldContinueReading = false; - break; - } - } - ++curPos; - } - } catch (IOException ioe) { - LOG.warn("Failed to close task iterable", ioe); - } - } - // if the currentSnapShot was also the latestSnapshot then break - if (curSnapshot.snapshotId() == latestSnapshotId) { - break; - } - - // if everything was OK and we consumed complete snapshot then move to next snapshot - if (shouldContinueReading) { - Snapshot nextValid = nextValidSnapshot(curSnapshot); - if (nextValid == null) { - // nextValid implies all the remaining snapshots should be skipped. - break; - } - // we found the next available snapshot, continue from there. - curSnapshot = nextValid; - startPosOfSnapOffset = -1; - // if anyhow we are moving to next snapshot we should only scan addedFiles - scanAllFiles = false; - } - } - - StreamingOffset latestStreamingOffset = - new StreamingOffset(curSnapshot.snapshotId(), curPos, scanAllFiles); - - // if no new data arrived, then return null. - return latestStreamingOffset.equals(startingOffset) ? null : latestStreamingOffset; - } - - /** - * Get the next snapshot skiping over rewrite and delete snapshots. - * - * @param curSnapshot the current snapshot - * @return the next valid snapshot (not a rewrite or delete snapshot), returns null if all - * remaining snapshots should be skipped. - */ - private Snapshot nextValidSnapshot(Snapshot curSnapshot) { - Snapshot nextSnapshot = SnapshotUtil.snapshotAfter(table, curSnapshot.snapshotId()); - // skip over rewrite and delete snapshots - while (!shouldProcess(nextSnapshot)) { - LOG.debug("Skipping snapshot: {} of table {}", nextSnapshot.snapshotId(), table.name()); - // if the currentSnapShot was also the mostRecentSnapshot then break - if (nextSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { - return null; - } - nextSnapshot = SnapshotUtil.snapshotAfter(table, nextSnapshot.snapshotId()); - } - return nextSnapshot; - } - - private long addedFilesCount(Snapshot snapshot) { - long addedFilesCount = - PropertyUtil.propertyAsLong(snapshot.summary(), SnapshotSummary.ADDED_FILES_PROP, -1); - // If snapshotSummary doesn't have SnapshotSummary.ADDED_FILES_PROP, - // iterate through addedFiles iterator to find addedFilesCount. - return addedFilesCount == -1 - ? Iterables.size(snapshot.addedDataFiles(table.io())) - : addedFilesCount; - } - - private void validateCurrentSnapshotExists(Snapshot snapshot, StreamingOffset currentOffset) { - if (snapshot == null) { - throw new IllegalStateException( - String.format( - Locale.ROOT, - "Cannot load current offset at snapshot %d, the snapshot was expired or removed", - currentOffset.snapshotId())); - } + return planner.latestOffset((StreamingOffset) startOffset, limit); } @Override @@ -539,17 +255,23 @@ public void prepareForTriggerAvailableNow() { (StreamingOffset) latestOffset(initialOffset, ReadLimit.allAvailable()); LOG.info("lastOffset for Trigger.AvailableNow is {}", lastOffsetForTriggerAvailableNow.json()); + + if (planner != null) { + planner.stop(); + planner = null; + } } private static class InitialOffsetStore { private final Table table; private final FileIO io; private final String initialOffsetLocation; - private final Long fromTimestamp; + private final long fromTimestamp; - InitialOffsetStore(Table table, String checkpointLocation, Long fromTimestamp) { + InitialOffsetStore( + Table table, String checkpointLocation, long fromTimestamp, Configuration conf) { this.table = table; - this.io = table.io(); + this.io = new HadoopFileIO(conf); this.initialOffsetLocation = SLASH.join(checkpointLocation, "offsets/0"); this.fromTimestamp = fromTimestamp; } @@ -561,7 +283,7 @@ public StreamingOffset initialOffset() { } table.refresh(); - StreamingOffset offset = determineStartingOffset(table, fromTimestamp); + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, fromTimestamp); OutputFile outputFile = io.newOutputFile(initialOffsetLocation); writeOffset(offset, outputFile); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java index c9726518ee4e..4d9fb7556b4e 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java @@ -78,7 +78,14 @@ abstract class SparkPartitioningAwareScan extends S Schema expectedSchema, List filters, Supplier scanReportSupplier) { - super(spark, table, readConf, expectedSchema, filters, scanReportSupplier); + super( + spark, + table, + null != scan ? scan.fileIO() : table::io, + readConf, + expectedSchema, + filters, + scanReportSupplier); this.scan = scan; this.preserveDataGrouping = readConf.preserveDataGrouping(); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewrite.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewrite.java index 0ec7084bfd1b..3bbea7b5c98a 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewrite.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewrite.java @@ -22,6 +22,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import org.apache.iceberg.BaseTable; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.MetadataColumns; @@ -37,16 +38,20 @@ import org.apache.iceberg.io.FileIO; import org.apache.iceberg.io.OutputFileFactory; import org.apache.iceberg.io.PartitioningDVWriter; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.PositionDeletesRewriteCoordinator; import org.apache.iceberg.spark.ScanTaskSetManager; import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.spark.SparkWriteUtil; import org.apache.iceberg.util.DeleteFileSet; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; import org.apache.spark.sql.connector.write.BatchWrite; import org.apache.spark.sql.connector.write.DataWriter; import org.apache.spark.sql.connector.write.DataWriterFactory; @@ -80,6 +85,7 @@ public class SparkPositionDeletesRewrite implements Write { private final int specId; private final StructLike partition; private final Map writeProperties; + private InMemoryMetricsReporter metricsReporter; /** * Constructs a {@link SparkPositionDeletesRewrite}. @@ -114,6 +120,11 @@ public class SparkPositionDeletesRewrite implements Write { this.specId = specId; this.partition = partition; this.writeProperties = writeConf.writeProperties(); + + if (this.table instanceof BaseTable) { + this.metricsReporter = new InMemoryMetricsReporter(); + ((BaseTable) this.table).combineMetricsReporter(metricsReporter); + } } @Override @@ -121,6 +132,16 @@ public BatchWrite toBatch() { return new PositionDeleteBatchWrite(); } + @Override + public CustomTaskMetric[] reportDriverMetrics() { + return SparkWriteUtil.customTaskMetrics(metricsReporter); + } + + @Override + public CustomMetric[] supportedCustomMetrics() { + return SparkWriteUtil.supportedCustomMetrics(); + } + /** {@link BatchWrite} class for rewriting position deletes files from Spark */ class PositionDeleteBatchWrite implements BatchWrite { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java index d072397dc6a3..f0a58fc42107 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java @@ -30,6 +30,7 @@ import java.util.Locale; import java.util.Map; import java.util.function.Function; +import org.apache.iceberg.BaseTable; import org.apache.iceberg.ContentFile; import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; @@ -66,12 +67,14 @@ import org.apache.iceberg.io.PartitioningWriter; import org.apache.iceberg.io.PositionDeltaWriter; import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.CommitMetadata; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkWriteConf; import org.apache.iceberg.spark.SparkWriteRequirements; +import org.apache.iceberg.spark.SparkWriteUtil; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.CharSequenceSet; import org.apache.iceberg.util.DeleteFileSet; @@ -83,6 +86,8 @@ import org.apache.spark.sql.catalyst.expressions.JoinedRow; import org.apache.spark.sql.connector.distributions.Distribution; import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; import org.apache.spark.sql.connector.write.DeltaBatchWrite; import org.apache.spark.sql.connector.write.DeltaWrite; import org.apache.spark.sql.connector.write.DeltaWriter; @@ -112,10 +117,12 @@ class SparkPositionDeltaWrite implements DeltaWrite, RequiresDistributionAndOrde private final String branch; private final Map extraSnapshotMetadata; private final SparkWriteRequirements writeRequirements; + private final int sortOrderId; private final Context context; private final Map writeProperties; private boolean cleanupOnAbort = false; + private InMemoryMetricsReporter metricsReporter; SparkPositionDeltaWrite( SparkSession spark, @@ -137,8 +144,14 @@ class SparkPositionDeltaWrite implements DeltaWrite, RequiresDistributionAndOrde this.branch = writeConf.branch(); this.extraSnapshotMetadata = writeConf.extraSnapshotMetadata(); this.writeRequirements = writeConf.positionDeltaRequirements(command); + this.sortOrderId = writeConf.outputSortOrderId(writeRequirements); this.context = new Context(dataSchema, writeConf, info, writeRequirements); this.writeProperties = writeConf.writeProperties(); + + if (this.table instanceof BaseTable) { + this.metricsReporter = new InMemoryMetricsReporter(); + ((BaseTable) this.table).combineMetricsReporter(metricsReporter); + } } @Override @@ -172,6 +185,16 @@ public DeltaBatchWrite toBatch() { return new PositionDeltaBatchWrite(); } + @Override + public CustomMetric[] supportedCustomMetrics() { + return SparkWriteUtil.supportedCustomMetrics(); + } + + @Override + public CustomTaskMetric[] reportDriverMetrics() { + return SparkWriteUtil.customTaskMetrics(metricsReporter); + } + private class PositionDeltaBatchWrite implements DeltaBatchWrite { @Override @@ -182,7 +205,8 @@ public DeltaWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) { broadcastRewritableDeletes(), command, context, - writeProperties); + writeProperties, + sortOrderId); } private Broadcast> broadcastRewritableDeletes() { @@ -392,18 +416,21 @@ private static class PositionDeltaWriteFactory implements DeltaWriterFactory { private final Command command; private final Context context; private final Map writeProperties; + private final int sortOrderId; PositionDeltaWriteFactory( Broadcast
    tableBroadcast, Broadcast> rewritableDeletesBroadcast, Command command, Context context, - Map writeProperties) { + Map writeProperties, + int sortOrderId) { this.tableBroadcast = tableBroadcast; this.rewritableDeletesBroadcast = rewritableDeletesBroadcast; this.command = command; this.context = context; this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; } @Override @@ -430,6 +457,7 @@ public DeltaWriter createWriter(int partitionId, long taskId) { .deleteFileFormat(context.deleteFileFormat()) .positionDeleteSparkType(context.deleteSparkType()) .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) .build(); if (command == DELETE) { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java index 106b296de098..6cf12f6e7667 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java @@ -33,6 +33,7 @@ import org.apache.iceberg.StatisticsFile; import org.apache.iceberg.Table; import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.FileIO; import org.apache.iceberg.metrics.ScanReport; import org.apache.iceberg.relocated.com.google.common.base.Strings; import org.apache.iceberg.relocated.com.google.common.collect.Lists; @@ -101,6 +102,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { private final JavaSparkContext sparkContext; private final Table table; + private final Supplier fileIO; private final SparkSession spark; private final SparkReadConf readConf; private final boolean caseSensitive; @@ -115,6 +117,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { SparkScan( SparkSession spark, Table table, + Supplier fileIO, SparkReadConf readConf, Schema expectedSchema, List filters, @@ -125,6 +128,7 @@ abstract class SparkScan implements Scan, SupportsReportStatistics { this.spark = spark; this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); this.table = table; + this.fileIO = fileIO; this.readConf = readConf; this.caseSensitive = readConf.caseSensitive(); this.expectedSchema = expectedSchema; @@ -162,13 +166,20 @@ protected Types.StructType groupingKeyType() { @Override public Batch toBatch() { return new SparkBatch( - sparkContext, table, readConf, groupingKeyType(), taskGroups(), expectedSchema, hashCode()); + sparkContext, + table, + fileIO, + readConf, + groupingKeyType(), + taskGroups(), + expectedSchema, + hashCode()); } @Override public MicroBatchStream toMicroBatchStream(String checkpointLocation) { return new SparkMicroBatchStream( - sparkContext, table, readConf, expectedSchema, checkpointLocation); + sparkContext, table, fileIO, readConf, expectedSchema, checkpointLocation); } @Override @@ -345,8 +356,17 @@ public CustomMetric[] supportedCustomMetrics() { protected long adjustSplitSize(List tasks, long splitSize) { if (readConf.splitSizeOption() == null && readConf.adaptiveSplitSizeEnabled()) { long scanSize = tasks.stream().mapToLong(ScanTask::sizeBytes).sum(); - int parallelism = readConf.parallelism(); - return TableScanUtil.adjustSplitSize(scanSize, parallelism, splitSize); + int parallelism = readConf.splitParallelism(); + long adjustedSplitSize = TableScanUtil.adjustSplitSize(scanSize, parallelism, splitSize); + if (adjustedSplitSize != splitSize) { + LOG.debug( + "Adjusted split size from {} to {} for table {} with parallelism {}", + splitSize, + adjustedSplitSize, + table().name(), + parallelism); + } + return adjustedSplitSize; } else { return splitSize; } diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java index 11b0ba58af51..8b75906a628f 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java @@ -307,16 +307,16 @@ private boolean metricsModeSupportsAggregatePushDown(List> colName); return false; } - } else if (mode instanceof MetricsModes.Truncate) { - // lower_bounds and upper_bounds may be truncated, so disable push down - if (aggregate.type().typeId() == Type.TypeID.STRING) { - if (aggregate.op() == Expression.Operation.MAX - || aggregate.op() == Expression.Operation.MIN) { - LOG.info( - "Skipping aggregate pushdown: Cannot produce min or max from truncated values for column {}", - colName); - return false; - } + } else if (aggregate.type().typeId() == Type.TypeID.STRING + || aggregate.type().typeId() == Type.TypeID.BINARY) { + // lower_bounds and upper_bounds may have been truncated before, so disable push down + // regardless of the current mode + if (aggregate.op() == Expression.Operation.MAX + || aggregate.op() == Expression.Operation.MIN) { + LOG.info( + "Skipping aggregate pushdown: Cannot produce min or max from truncated values for column {}", + colName); + return false; } } } @@ -760,7 +760,7 @@ public StructType readSchema() { } private BatchScan newBatchScan() { - if (table instanceof BaseTable && readConf.distributedPlanningEnabled()) { + if (readConf.distributedPlanningEnabled()) { return new SparkDistributedDataScan(spark, table, readConf); } else { return table.newBatchScan(); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java index d2eb4e5a56e9..99e0deabb0fb 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java @@ -41,7 +41,7 @@ class SparkStagedScan extends SparkScan { private List> taskGroups = null; // lazy cache of tasks SparkStagedScan(SparkSession spark, Table table, Schema expectedSchema, SparkReadConf readConf) { - super(spark, table, readConf, expectedSchema, ImmutableList.of(), null); + super(spark, table, table::io, readConf, expectedSchema, ImmutableList.of(), null); this.taskSetId = readConf.scanTaskSetId(); this.splitSize = readConf.splitSize(); this.splitLookback = readConf.splitLookback(); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java index 1ee9e9b08074..9e3c9a7e69e6 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java @@ -23,6 +23,7 @@ import java.io.IOException; import java.util.Map; +import java.util.Objects; import java.util.Set; import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.BaseTable; @@ -57,6 +58,7 @@ import org.apache.iceberg.spark.CommitMetadata; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSQLProperties; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkTableUtil; import org.apache.iceberg.spark.SparkUtil; @@ -375,11 +377,31 @@ public boolean canDeleteWhere(Predicate[] predicates) { } } - return canDeleteUsingMetadata(deleteExpr); + return canDeleteUsingMetadata(deleteExpr, scanBranchForDelete()); + } + + // Resolves the branch to scan during canDeleteWhere so it matches the branch deleteWhere + // will commit to. Falls back to main when WAP is configured but the WAP branch does not + // exist yet, since this is a read scan. + private String scanBranchForDelete() { + if (branch != null) { + return branch; + } + + if (!SparkTableUtil.wapEnabled(table())) { + return null; + } + + String wapBranch = sparkSession().conf().get(SparkSQLProperties.WAP_BRANCH, null); + if (wapBranch != null && table().refs().containsKey(wapBranch)) { + return wapBranch; + } + + return null; } // a metadata delete is possible iff matching files can be deleted entirely - private boolean canDeleteUsingMetadata(Expression deleteExpr) { + private boolean canDeleteUsingMetadata(Expression deleteExpr, String scanBranch) { boolean caseSensitive = SparkUtil.caseSensitive(sparkSession()); if (ExpressionUtil.selectsPartitions(deleteExpr, table(), caseSensitive)) { @@ -394,14 +416,14 @@ private boolean canDeleteUsingMetadata(Expression deleteExpr) { .includeColumnStats() .ignoreResiduals(); - if (branch != null) { - scan = scan.useRef(branch); + if (scanBranch != null) { + scan = scan.useRef(scanBranch); } try (CloseableIterable tasks = scan.planFiles()) { Map evaluators = Maps.newHashMap(); StrictMetricsEvaluator metricsEvaluator = - new StrictMetricsEvaluator(SnapshotUtil.schemaFor(table(), branch), deleteExpr); + new StrictMetricsEvaluator(SnapshotUtil.schemaFor(table(), scanBranch), deleteExpr); return Iterables.all( tasks, @@ -438,12 +460,13 @@ public void deleteWhere(Predicate[] predicates) { .set("spark.app.id", sparkSession().sparkContext().applicationId()) .deleteFromRowFilter(deleteExpr); + String writeBranch = branch; if (SparkTableUtil.wapEnabled(table())) { - branch = SparkTableUtil.determineWriteBranch(sparkSession(), branch); + writeBranch = SparkTableUtil.determineWriteBranch(sparkSession(), branch); } - if (branch != null) { - deleteFiles.toBranch(branch); + if (writeBranch != null) { + deleteFiles.toBranch(writeBranch); } if (!CommitMetadata.commitProperties().isEmpty()) { @@ -466,15 +489,16 @@ public boolean equals(Object other) { return false; } - // use only name in order to correctly invalidate Spark cache SparkTable that = (SparkTable) other; - return icebergTable.name().equals(that.icebergTable.name()); + return icebergTable.name().equals(that.icebergTable.name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && Objects.equals(snapshotId, that.snapshotId) + && Objects.equals(branch, that.branch); } @Override public int hashCode() { - // use only name in order to correctly invalidate Spark cache - return icebergTable.name().hashCode(); + return Objects.hash(icebergTable.name(), table().uuid(), snapshotId, branch); } private static CaseInsensitiveStringMap addSnapshotId( diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkVariantShreddingAnalyzer.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkVariantShreddingAnalyzer.java new file mode 100644 index 000000000000..2c08c662c9da --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkVariantShreddingAnalyzer.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.List; +import org.apache.iceberg.parquet.VariantShreddingAnalyzer; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.VariantVal; + +/** + * Spark-specific implementation that extracts variant values from {@link InternalRow} instances. + */ +class SparkVariantShreddingAnalyzer extends VariantShreddingAnalyzer { + + SparkVariantShreddingAnalyzer() {} + + @Override + protected int resolveColumnIndex(StructType sparkSchema, String columnName) { + try { + return sparkSchema.fieldIndex(columnName); + } catch (IllegalArgumentException e) { + return -1; + } + } + + @Override + protected List extractVariantValues( + List bufferedRows, int variantFieldIndex) { + List values = Lists.newArrayList(); + + for (InternalRow row : bufferedRows) { + if (!row.isNullAt(variantFieldIndex)) { + VariantVal variantVal = row.getVariant(variantFieldIndex); + if (variantVal != null) { + VariantValue variantValue = + VariantValue.from( + VariantMetadata.from( + ByteBuffer.wrap(variantVal.getMetadata()).order(ByteOrder.LITTLE_ENDIAN)), + ByteBuffer.wrap(variantVal.getValue()).order(ByteOrder.LITTLE_ENDIAN)); + values.add(variantValue); + } + } + } + + return values; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java index c9a94090ef89..c73a37ba3426 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java @@ -29,6 +29,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.BaseTable; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; import org.apache.iceberg.FileScanTask; @@ -53,12 +54,14 @@ import org.apache.iceberg.io.OutputFileFactory; import org.apache.iceberg.io.PartitioningWriter; import org.apache.iceberg.io.RollingDataWriter; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.CommitMetadata; import org.apache.iceberg.spark.FileRewriteCoordinator; import org.apache.iceberg.spark.SparkWriteConf; import org.apache.iceberg.spark.SparkWriteRequirements; +import org.apache.iceberg.spark.SparkWriteUtil; import org.apache.iceberg.util.ContentFileUtil; import org.apache.iceberg.util.DataFileSet; import org.apache.iceberg.util.DeleteFileSet; @@ -72,6 +75,8 @@ import org.apache.spark.sql.catalyst.expressions.JoinedRow; import org.apache.spark.sql.connector.distributions.Distribution; import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; import org.apache.spark.sql.connector.write.BatchWrite; import org.apache.spark.sql.connector.write.DataWriter; import org.apache.spark.sql.connector.write.DataWriterFactory; @@ -108,6 +113,7 @@ abstract class SparkWrite implements Write, RequiresDistributionAndOrdering { private final Map writeProperties; private boolean cleanupOnAbort = false; + private InMemoryMetricsReporter metricsReporter; SparkWrite( SparkSession spark, @@ -135,6 +141,11 @@ abstract class SparkWrite implements Write, RequiresDistributionAndOrdering { this.writeRequirements = writeRequirements; this.outputSpecId = writeConf.outputSpecId(); this.writeProperties = writeConf.writeProperties(); + + if (this.table instanceof BaseTable) { + this.metricsReporter = new InMemoryMetricsReporter(); + ((BaseTable) this.table).combineMetricsReporter(metricsReporter); + } } @Override @@ -163,6 +174,11 @@ public long advisoryPartitionSizeInBytes() { return size; } + @Override + public CustomMetric[] supportedCustomMetrics() { + return SparkWriteUtil.supportedCustomMetrics(); + } + BatchWrite asBatchAppend() { return new BatchAppend(); } @@ -196,6 +212,7 @@ private WriterFactory createWriterFactory() { // broadcast the table metadata as the writer factory will be sent to executors Broadcast
    tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + int sortOrderId = writeConf.outputSortOrderId(writeRequirements); return new WriterFactory( tableBroadcast, queryId, @@ -205,7 +222,8 @@ private WriterFactory createWriterFactory() { writeSchema, dsSchema, useFanoutWriter, - writeProperties); + writeProperties, + sortOrderId); } private void commitOperation(SnapshotUpdate operation, String description) { @@ -265,6 +283,11 @@ private DataFileSet files(WriterCommitMessage[] messages) { return files; } + @Override + public CustomTaskMetric[] reportDriverMetrics() { + return SparkWriteUtil.customTaskMetrics(metricsReporter); + } + @Override public String toString() { return String.format("IcebergWrite(table=%s, format=%s)", table, format); @@ -675,6 +698,7 @@ private static class WriterFactory implements DataWriterFactory, StreamingDataWr private final boolean useFanoutWriter; private final String queryId; private final Map writeProperties; + private final int sortOrderId; protected WriterFactory( Broadcast
    tableBroadcast, @@ -685,7 +709,8 @@ protected WriterFactory( Schema writeSchema, StructType dsSchema, boolean useFanoutWriter, - Map writeProperties) { + Map writeProperties, + int sortOrderId) { this.tableBroadcast = tableBroadcast; this.format = format; this.outputSpecId = outputSpecId; @@ -695,6 +720,7 @@ protected WriterFactory( this.useFanoutWriter = useFanoutWriter; this.queryId = queryId; this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; } @Override @@ -719,6 +745,7 @@ public DataWriter createWriter(int partitionId, long taskId, long e .dataSchema(writeSchema) .dataSparkType(dsSchema) .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) .build(); Function rowLineageExtractor = new ExtractRowLineage(writeSchema); diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java index 89af7740d988..c1867433fd8d 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java @@ -27,27 +27,27 @@ import org.apache.iceberg.expressions.Expression; import org.apache.iceberg.expressions.Expressions; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; -import org.apache.iceberg.spark.SparkFilters; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.spark.SparkV2Filters; import org.apache.iceberg.spark.SparkWriteConf; import org.apache.iceberg.spark.SparkWriteRequirements; import org.apache.iceberg.types.TypeUtil; import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.filter.Predicate; import org.apache.spark.sql.connector.read.Scan; import org.apache.spark.sql.connector.write.BatchWrite; import org.apache.spark.sql.connector.write.LogicalWriteInfo; import org.apache.spark.sql.connector.write.RowLevelOperation.Command; import org.apache.spark.sql.connector.write.SupportsDynamicOverwrite; -import org.apache.spark.sql.connector.write.SupportsOverwrite; +import org.apache.spark.sql.connector.write.SupportsOverwriteV2; import org.apache.spark.sql.connector.write.Write; import org.apache.spark.sql.connector.write.WriteBuilder; import org.apache.spark.sql.connector.write.streaming.StreamingWrite; -import org.apache.spark.sql.sources.Filter; import org.apache.spark.sql.types.LongType$; import org.apache.spark.sql.types.StructType; -class SparkWriteBuilder implements WriteBuilder, SupportsDynamicOverwrite, SupportsOverwrite { +class SparkWriteBuilder implements WriteBuilder, SupportsDynamicOverwrite, SupportsOverwriteV2 { private final SparkSession spark; private final Table table; private final SparkWriteConf writeConf; @@ -100,12 +100,12 @@ public WriteBuilder overwriteDynamicPartitions() { } @Override - public WriteBuilder overwrite(Filter[] filters) { + public WriteBuilder overwrite(Predicate[] predicates) { Preconditions.checkState( !overwriteFiles, "Cannot overwrite individual files and using filters"); Preconditions.checkState(rewrittenFileSetId == null, "Cannot overwrite and rewrite"); - this.overwriteExpr = SparkFilters.convert(filters); + this.overwriteExpr = SparkV2Filters.convert(predicates); if (overwriteExpr == Expressions.alwaysTrue() && "dynamic".equals(overwriteMode)) { // use the write option to override truncating the table. use dynamic overwrite instead. this.overwriteDynamic = true; diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..f1b0029c5432 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.MicroBatches.MicroBatch; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(SyncSparkMicroBatchPlanner.class); + + private final boolean caseSensitive; + private final long fromTimestamp; + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + SyncSparkMicroBatchPlanner( + Table table, SparkReadConf readConf, StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.caseSensitive = readConf().caseSensitive(); + this.fromTimestamp = readConf().streamFromTimestamp(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + } + + @Override + public List planFiles(StreamingOffset startOffset, StreamingOffset endOffset) { + List fileScanTasks = Lists.newArrayList(); + StreamingOffset batchStartOffset = + StreamingOffset.START_OFFSET.equals(startOffset) + ? MicroBatchUtils.determineStartingOffset(table(), fromTimestamp) + : startOffset; + + StreamingOffset currentOffset = null; + + // [(startOffset : startFileIndex), (endOffset : endFileIndex) ) + do { + long endFileIndex; + if (currentOffset == null) { + currentOffset = batchStartOffset; + } else { + Snapshot snapshotAfter = SnapshotUtil.snapshotAfter(table(), currentOffset.snapshotId()); + // it may happen that we need to read this snapshot partially in case it's equal to + // endOffset. + if (currentOffset.snapshotId() != endOffset.snapshotId()) { + currentOffset = new StreamingOffset(snapshotAfter.snapshotId(), 0L, false); + } else { + currentOffset = endOffset; + } + } + + Snapshot snapshot = table().snapshot(currentOffset.snapshotId()); + + validateCurrentSnapshotExists(snapshot, currentOffset); + + if (!shouldProcess(snapshot)) { + LOG.debug("Skipping snapshot: {} of table {}", currentOffset.snapshotId(), table().name()); + continue; + } + + Snapshot currentSnapshot = table().snapshot(currentOffset.snapshotId()); + if (currentOffset.snapshotId() == endOffset.snapshotId()) { + endFileIndex = endOffset.position(); + } else { + endFileIndex = MicroBatchUtils.addedFilesCount(table(), currentSnapshot); + } + + MicroBatch latestMicroBatch = + MicroBatches.from(currentSnapshot, table().io()) + .caseSensitive(caseSensitive) + .specsById(table().specs()) + .generate( + currentOffset.position(), + endFileIndex, + Long.MAX_VALUE, + currentOffset.shouldScanAllFiles()); + + fileScanTasks.addAll(latestMicroBatch.tasks()); + } while (currentOffset.snapshotId() != endOffset.snapshotId()); + + return fileScanTasks; + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + table().refresh(); + if (table().currentSnapshot() == null) { + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < fromTimestamp) { + return StreamingOffset.START_OFFSET; + } + + // end offset can expand to multiple snapshots + StreamingOffset startingOffset = startOffset; + + if (startOffset.equals(StreamingOffset.START_OFFSET)) { + startingOffset = MicroBatchUtils.determineStartingOffset(table(), fromTimestamp); + } + + Snapshot curSnapshot = table().snapshot(startingOffset.snapshotId()); + validateCurrentSnapshotExists(curSnapshot, startingOffset); + + // Use the pre-computed snapshotId when Trigger.AvailableNow is enabled. + long latestSnapshotId = + lastOffsetForTriggerAvailableNow != null + ? lastOffsetForTriggerAvailableNow.snapshotId() + : table().currentSnapshot().snapshotId(); + + int startPosOfSnapOffset = (int) startingOffset.position(); + + boolean scanAllFiles = startingOffset.shouldScanAllFiles(); + + boolean shouldContinueReading = true; + int curFilesAdded = 0; + long curRecordCount = 0; + int curPos = 0; + + // Extract limits once to avoid repeated calls in tight loop + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long maxFiles = unpackedLimits.getMaxFiles(); + long maxRows = unpackedLimits.getMaxRows(); + + // Note : we produce nextOffset with pos as non-inclusive + while (shouldContinueReading) { + // generate manifest index for the curSnapshot + List> indexedManifests = + MicroBatches.skippedManifestIndexesFromSnapshot( + table().io(), curSnapshot, startPosOfSnapOffset, scanAllFiles); + // this is under assumption we will be able to add at-least 1 file in the new offset + for (int idx = 0; idx < indexedManifests.size() && shouldContinueReading; idx++) { + // be rest assured curPos >= startFileIndex + curPos = indexedManifests.get(idx).second(); + try (CloseableIterable taskIterable = + MicroBatches.openManifestFile( + table().io(), + table().specs(), + caseSensitive, + curSnapshot, + indexedManifests.get(idx).first(), + scanAllFiles); + CloseableIterator taskIter = taskIterable.iterator()) { + while (taskIter.hasNext()) { + FileScanTask task = taskIter.next(); + if (curPos >= startPosOfSnapOffset) { + if ((curFilesAdded + 1) > maxFiles) { + // On including the file it might happen that we might exceed, the configured + // soft limit on the number of records, since this is a soft limit its acceptable. + shouldContinueReading = false; + break; + } + + curFilesAdded += 1; + curRecordCount += task.file().recordCount(); + + if (curRecordCount >= maxRows) { + // we included the file, so increment the number of files + // read in the current snapshot. + if (curFilesAdded == 1 && curRecordCount > maxRows) { + LOG.warn( + "File {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + task.file().location(), + task.file().recordCount(), + maxRows); + } + ++curPos; + shouldContinueReading = false; + break; + } + } + ++curPos; + } + } catch (IOException ioe) { + LOG.warn("Failed to close task iterable", ioe); + } + } + // if the currentSnapShot was also the latestSnapshot then break + if (curSnapshot.snapshotId() == latestSnapshotId) { + break; + } + + // if everything was OK and we consumed complete snapshot then move to next snapshot + if (shouldContinueReading) { + Snapshot nextValid = nextValidSnapshot(curSnapshot); + if (nextValid == null) { + // nextValid implies all the remaining snapshots should be skipped. + break; + } + // we found the next available snapshot, continue from there. + curSnapshot = nextValid; + startPosOfSnapOffset = -1; + // if anyhow we are moving to next snapshot we should only scan addedFiles + scanAllFiles = false; + } + } + + StreamingOffset latestStreamingOffset = + new StreamingOffset(curSnapshot.snapshotId(), curPos, scanAllFiles); + + // if no new data arrived, then return null. + return latestStreamingOffset.equals(startingOffset) ? null : latestStreamingOffset; + } + + @Override + public void stop() {} + + private void validateCurrentSnapshotExists(Snapshot snapshot, StreamingOffset currentOffset) { + if (snapshot == null) { + throw new IllegalStateException( + String.format( + Locale.ROOT, + "Cannot load current offset at snapshot %d, the snapshot was expired or removed", + currentOffset.snapshotId())); + } + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDataFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDataFiles.java new file mode 100644 index 000000000000..70df1ca2ca22 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedDataFiles extends CustomSumMetric { + + public static final String NAME = "addedDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added data files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDeleteFiles.java new file mode 100644 index 000000000000..381e912407a5 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedDeleteFiles extends CustomSumMetric { + + public static final String NAME = "addedDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added delete files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeleteFiles.java new file mode 100644 index 000000000000..bd653803fa47 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedEqualityDeleteFiles extends CustomSumMetric { + + public static final String NAME = "addedEqualityDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added equality delete files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeletes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeletes.java new file mode 100644 index 000000000000..c5ee8c952dd9 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedEqualityDeletes extends CustomSumMetric { + + public static final String NAME = "addedEqualityDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added equality deletes records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedFileSizeInBytes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedFileSizeInBytes.java new file mode 100644 index 000000000000..8e5a16dbf2a9 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedFileSizeInBytes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedFileSizeInBytes extends CustomSumMetric { + + public static final String NAME = "addedFileSizeInBytes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total size of added files (bytes)"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeleteFiles.java new file mode 100644 index 000000000000..d9d5501a80e3 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedPositionalDeleteFiles extends CustomSumMetric { + + public static final String NAME = "addedPositionalDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added positional delete files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeletes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeletes.java new file mode 100644 index 000000000000..bd8995eac604 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedPositionalDeletes extends CustomSumMetric { + + public static final String NAME = "addedPositionalDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added positional deletes records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedRecords.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedRecords.java new file mode 100644 index 000000000000..240ec5b34bd2 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedRecords.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedRecords extends CustomSumMetric { + + public static final String NAME = "addedRecords"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/EqualityDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/EqualityDeleteFiles.java index 754145f7d252..4c258c01c907 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/EqualityDeleteFiles.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/EqualityDeleteFiles.java @@ -22,7 +22,7 @@ public class EqualityDeleteFiles extends CustomSumMetric { - static final String NAME = "equalityDeleteFiles"; + public static final String NAME = "equalityDeleteFiles"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/IndexedDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/IndexedDeleteFiles.java index 7fc5b9066cdc..93dd410b2ae0 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/IndexedDeleteFiles.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/IndexedDeleteFiles.java @@ -22,7 +22,7 @@ public class IndexedDeleteFiles extends CustomSumMetric { - static final String NAME = "indexedDeleteFiles"; + public static final String NAME = "indexedDeleteFiles"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/PositionalDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/PositionalDeleteFiles.java index 5de75776ea4f..f362d3353505 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/PositionalDeleteFiles.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/PositionalDeleteFiles.java @@ -22,7 +22,7 @@ public class PositionalDeleteFiles extends CustomSumMetric { - static final String NAME = "positionalDeleteFiles"; + public static final String NAME = "positionalDeleteFiles"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDataFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDataFiles.java new file mode 100644 index 000000000000..96e21e96c20e --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedDataFiles extends CustomSumMetric { + + public static final String NAME = "removedDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed data files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDeleteFiles.java new file mode 100644 index 000000000000..9e1267592fc1 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedDeleteFiles extends CustomSumMetric { + + public static final String NAME = "removedDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed delete files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeleteFiles.java new file mode 100644 index 000000000000..07c0de3325d2 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedEqualityDeleteFiles extends CustomSumMetric { + + public static final String NAME = "removedEqualityDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed equality delete files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeletes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeletes.java new file mode 100644 index 000000000000..2ad7f5a34419 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedEqualityDeletes extends CustomSumMetric { + + public static final String NAME = "removedEqualityDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed equality deletes records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedFileSizeInBytes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedFileSizeInBytes.java new file mode 100644 index 000000000000..b7ec8b519564 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedFileSizeInBytes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedFileSizeInBytes extends CustomSumMetric { + + public static final String NAME = "removedFileSizeInBytes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total size of removed files (bytes)"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeleteFiles.java new file mode 100644 index 000000000000..d01529753b64 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedPositionalDeleteFiles extends CustomSumMetric { + + public static final String NAME = "removedPositionalDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed positional delete files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeletes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeletes.java new file mode 100644 index 000000000000..b99892b2116e --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedPositionalDeletes extends CustomSumMetric { + + public static final String NAME = "removedPositionalDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed positional deletes records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedRecords.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedRecords.java new file mode 100644 index 000000000000..b186aff8e075 --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedRecords.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedRecords extends CustomSumMetric { + + public static final String NAME = "removedRecords"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDataFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDataFiles.java index 21959cbf6c63..af75b746e437 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDataFiles.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDataFiles.java @@ -22,7 +22,7 @@ public class ResultDataFiles extends CustomSumMetric { - static final String NAME = "resultDataFiles"; + public static final String NAME = "resultDataFiles"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDeleteFiles.java index 9c6ad2ca328a..54d7afac81bc 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDeleteFiles.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDeleteFiles.java @@ -22,7 +22,7 @@ public class ResultDeleteFiles extends CustomSumMetric { - static final String NAME = "resultDeleteFiles"; + public static final String NAME = "resultDeleteFiles"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDataManifests.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDataManifests.java index a167904280e6..f52efec55b3b 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDataManifests.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDataManifests.java @@ -22,7 +22,7 @@ public class ScannedDataManifests extends CustomSumMetric { - static final String NAME = "scannedDataManifests"; + public static final String NAME = "scannedDataManifests"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDeleteManifests.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDeleteManifests.java index 1fa006b7b193..8f11eac2f286 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDeleteManifests.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDeleteManifests.java @@ -22,7 +22,7 @@ public class ScannedDeleteManifests extends CustomSumMetric { - static final String NAME = "scannedDeleteManifests"; + public static final String NAME = "scannedDeleteManifests"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataFiles.java index 7fd17425313d..0e57eb31ea72 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataFiles.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataFiles.java @@ -22,7 +22,7 @@ public class SkippedDataFiles extends CustomSumMetric { - static final String NAME = "skippedDataFiles"; + public static final String NAME = "skippedDataFiles"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataManifests.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataManifests.java index b0eaeb5d87f2..a02644643bd4 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataManifests.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataManifests.java @@ -22,7 +22,7 @@ public class SkippedDataManifests extends CustomSumMetric { - static final String NAME = "skippedDataManifests"; + public static final String NAME = "skippedDataManifests"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteFiles.java index 70597be67113..517415a51945 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteFiles.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteFiles.java @@ -22,7 +22,7 @@ public class SkippedDeleteFiles extends CustomSumMetric { - static final String NAME = "skippedDeleteFiles"; + public static final String NAME = "skippedDeleteFiles"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteManifests.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteManifests.java index 0336170b45a1..c76aa28834cd 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteManifests.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteManifests.java @@ -22,7 +22,7 @@ public class SkippedDeleteManifests extends CustomSumMetric { - static final String NAME = "skippedDeleteManifests"; + public static final String NAME = "skippedDeleteManifests"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFileSize.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFileSize.java index b1ff8a46368c..2f93dcabb0ec 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFileSize.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFileSize.java @@ -22,7 +22,7 @@ public class TotalDataFileSize extends CustomSumMetric { - static final String NAME = "totalDataFileSize"; + public static final String NAME = "totalDataFileSize"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFiles.java new file mode 100644 index 000000000000..a18126db7a1f --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDataFiles extends CustomSumMetric { + + public static final String NAME = "totalDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of data files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataManifests.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataManifests.java index de8f04be7767..33a0656dcfa6 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataManifests.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataManifests.java @@ -22,7 +22,7 @@ public class TotalDataManifests extends CustomSumMetric { - static final String NAME = "totalDataManifest"; + public static final String NAME = "totalDataManifest"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFileSize.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFileSize.java index da4303325273..d374a23df1e6 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFileSize.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFileSize.java @@ -22,7 +22,7 @@ public class TotalDeleteFileSize extends CustomSumMetric { - static final String NAME = "totalDeleteFileSize"; + public static final String NAME = "totalDeleteFileSize"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFiles.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFiles.java new file mode 100644 index 000000000000..66e94162530f --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDeleteFiles extends CustomSumMetric { + + public static final String NAME = "totalDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of delete files"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteManifests.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteManifests.java index 7442dfdb6ffb..58ac739ea3ff 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteManifests.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteManifests.java @@ -22,7 +22,7 @@ public class TotalDeleteManifests extends CustomSumMetric { - static final String NAME = "totalDeleteManifests"; + public static final String NAME = "totalDeleteManifests"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalEqualityDeletes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalEqualityDeletes.java new file mode 100644 index 000000000000..fff664cc9bdd --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalEqualityDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalEqualityDeletes extends CustomSumMetric { + + public static final String NAME = "totalEqualityDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of equality deletes records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalFileSizeInBytes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalFileSizeInBytes.java new file mode 100644 index 000000000000..ec97bfec552c --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalFileSizeInBytes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalFileSizeInBytes extends CustomSumMetric { + + public static final String NAME = "totalFileSizeInBytes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total data file size (bytes)"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPlanningDuration.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPlanningDuration.java index 8b66eeac4046..f1051bd928a9 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPlanningDuration.java +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPlanningDuration.java @@ -22,7 +22,7 @@ public class TotalPlanningDuration extends CustomSumMetric { - static final String NAME = "totalPlanningDuration"; + public static final String NAME = "totalPlanningDuration"; @Override public String name() { diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPositionalDeletes.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPositionalDeletes.java new file mode 100644 index 000000000000..da339688a3aa --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPositionalDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalPositionalDeletes extends CustomSumMetric { + + public static final String NAME = "totalPositionalDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of positional deletes records"; + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalRecords.java b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalRecords.java new file mode 100644 index 000000000000..3cc1123343ae --- /dev/null +++ b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalRecords.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalRecords extends CustomSumMetric { + + public static final String NAME = "totalRecords"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of records"; + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java index 404ba7284606..9b08d6f7ab1e 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.BeforeEach; @@ -90,6 +91,7 @@ protected static SparkSession initSpark(String serializer) { .master("local[2]") .config("spark.serializer", serializer) .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java index 659507e4c5e3..e28603c0b43a 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -73,6 +74,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java index a218f965ea65..eae640528f9e 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java @@ -23,6 +23,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -62,6 +63,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java index 2665d7ba8d3b..4f789d2c5ae9 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java @@ -25,6 +25,7 @@ import java.util.List; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; import org.apache.spark.sql.SparkSession; import org.apache.spark.sql.internal.SQLConf; import org.junit.jupiter.api.AfterAll; @@ -63,6 +64,7 @@ public static void startSpark() { .master("local[2]") .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java index dac09fea7562..1090245326fb 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java @@ -58,7 +58,8 @@ public static void validateDataManifest( List actualSnapshotIds = Lists.newArrayList(); List actualFiles = Lists.newArrayList(); - for (ManifestEntry entry : ManifestFiles.read(manifest, table.io()).entries()) { + for (ManifestEntry entry : + ManifestFiles.read(manifest, table.io(), table.specs()).entries()) { actualDataSeqs.add(entry.dataSequenceNumber()); actualFileSeqs.add(entry.fileSequenceNumber()); actualSnapshotIds.add(entry.snapshotId()); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java new file mode 100644 index 000000000000..ee1f29e56fb3 --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import com.codahale.metrics.MetricRegistry; +import java.util.Properties; +import org.apache.spark.SparkConf; +import org.apache.spark.metrics.sink.MetricsServlet; +import org.sparkproject.jetty.servlet.ServletContextHandler; + +/** + * A dummy implementation of {@link MetricsServlet} that does not start a server or report metrics. + * This is used in tests to avoid conflicts with Spark's jetty dependencies. + */ +public class DummyMetricsServlet extends MetricsServlet { + + /** + * Constructor required by Spark's reflection-based instantiation. + * + * @param properties Metrics properties + * @param registry Metric registry + */ + public DummyMetricsServlet(Properties properties, MetricRegistry registry) { + super(properties, registry); + } + + @Override + public ServletContextHandler[] getHandlers(SparkConf conf) { + return new ServletContextHandler[] {}; + } + + @Override + public void start() { + // No-op for tests + } + + @Override + public void stop() { + // No-op for tests + } + + @Override + public void report() { + // No-op for tests + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java index 1d96d6f93959..2350aab09b64 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java @@ -42,11 +42,14 @@ public enum SparkCatalogConfig { "spark_catalog", SparkSessionCatalog.class.getName(), ImmutableMap.of( - "type", "hive", - "default-namespace", "default", - "parquet-enabled", "true", + "type", + "hive", + "default-namespace", + "default", + "parquet-enabled", + "true", "cache-enabled", - "false" // Spark will delete tables using v1, leaving the cache out of sync + "false" // Spark will delete tables using v1, leaving the cache out of sync )), SPARK_WITH_VIEWS( "spark_with_views", @@ -61,7 +64,11 @@ public enum SparkCatalogConfig { SPARK_SESSION_WITH_VIEWS( "spark_catalog", SparkSessionCatalog.class.getName(), - ImmutableMap.of("type", "rest", "default-namespace", "default", "cache-enabled", "false")); + ImmutableMap.of("type", "rest", "default-namespace", "default", "cache-enabled", "false")), + SPARK_WITH_HIVE_VIEWS( + "spark_hive_with_views", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hive", "default-namespace", "default", "cache-enabled", "false")); private final String catalogName; private final String implementation; diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBase.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBase.java index daf4e29ac075..5e7e1a1f6193 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBase.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBase.java @@ -65,6 +65,13 @@ public abstract class TestBase extends SparkTestHelperBase { protected static SparkSession spark = null; protected static JavaSparkContext sparkContext = null; protected static HiveCatalog catalog = null; + // disable Spark UI and use dummy servlet to avoid dependency conflicts with Spark's Jetty version + public static final Map DISABLE_UI = + ImmutableMap.of( + "spark.ui.enabled", + "false", + "spark.metrics.conf.*.sink.servlet.class", + "org.apache.iceberg.spark.DummyMetricsServlet"); @BeforeAll public static void startMetastoreAndSpark() { @@ -79,6 +86,7 @@ public static void startMetastoreAndSpark() { .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config(DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java index 7df9c75fb3dd..1760143d2cb1 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java @@ -69,7 +69,11 @@ public abstract class TestBaseWithCatalog extends TestBase { // status even belonging to the same catalog. Reference: // https://www.sqlite.org/inmemorydb.html CatalogProperties.CLIENT_POOL_SIZE, - "1")); + "1", + "include-credentials", + "true", + "gcs.oauth2.token", + "dummyToken")); protected static RESTCatalog restCatalog; diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java new file mode 100644 index 000000000000..c3fc69c8b25c --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReadConf extends TestBaseWithCatalog { + + @BeforeEach + public void before() { + super.before(); + sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName); + } + + @AfterEach + public void after() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSplitParallelismDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(conf.parallelism()); + } + + @TestTemplate + public void testSplitParallelismSessionConf() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "999", + SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, + "42"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(42); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsZero() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "0"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsNegative() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "-5"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java index 61aacfa4589d..c5cfbe62b1be 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java @@ -34,6 +34,7 @@ import static org.apache.iceberg.TableProperties.ORC_COMPRESSION_STRATEGY; import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION; import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.PARQUET_SHRED_VARIANTS; import static org.apache.iceberg.TableProperties.UPDATE_DISTRIBUTION_MODE; import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE; import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_HASH; @@ -45,6 +46,7 @@ import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE; import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Duration; @@ -60,6 +62,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; @@ -339,6 +342,8 @@ public void testSparkConfOverride() { TableProperties.DELETE_PARQUET_COMPRESSION, "snappy"), ImmutableMap.of( + PARQUET_SHRED_VARIANTS, + "false", DELETE_PARQUET_COMPRESSION, "zstd", PARQUET_COMPRESSION, @@ -460,6 +465,8 @@ public void testDataPropsDefaultsAsDeleteProps() { PARQUET_COMPRESSION_LEVEL, "5"), ImmutableMap.of( + PARQUET_SHRED_VARIANTS, + "false", DELETE_PARQUET_COMPRESSION, "zstd", PARQUET_COMPRESSION, @@ -531,6 +538,8 @@ public void testDeleteFileWriteConf() { DELETE_PARQUET_COMPRESSION_LEVEL, "6"), ImmutableMap.of( + PARQUET_SHRED_VARIANTS, + "false", DELETE_PARQUET_COMPRESSION, "zstd", PARQUET_COMPRESSION, @@ -600,6 +609,51 @@ public void testDVWriteConf() { assertThat(writeConf.deleteFileFormat()).isEqualTo(FileFormat.PUFFIN); } + @TestTemplate + public void testSortOrderWriteConf() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConf = + new SparkWriteConf( + spark, table, ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "1")); + + assertThat(writeConf.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .isEqualTo(table.sortOrder().orderId()); + } + + @TestTemplate + public void testSortOrderWriteConfWithInvalidId() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfForUnknownSortOrder = + new SparkWriteConf( + spark, table, ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "999")); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> writeConfForUnknownSortOrder.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .withMessage( + "Cannot use output sort order id 999 because the table does not contain a sort order with that id"); + } + + @TestTemplate + public void testSortOrderWriteConfWithNoOption() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfNoOption = new SparkWriteConf(spark, table, ImmutableMap.of()); + + assertThat(writeConfNoOption.outputSortOrderId(writeConfNoOption.writeRequirements())) + .isEqualTo(table.sortOrder().orderId()); + + assertThat(writeConfNoOption.outputSortOrderId(SparkWriteRequirements.EMPTY)).isEqualTo(0); + } + private void testWriteProperties(List> propertiesSuite) { withSQLConf( propertiesSuite.get(0), @@ -640,4 +694,81 @@ private void checkMode(DistributionMode expectedMode, SparkWriteConf writeConf) assertThat(writeConf.copyOnWriteDistributionMode(MERGE)).isEqualTo(expectedMode); assertThat(writeConf.positionDeltaDistributionMode(MERGE)).isEqualTo(expectedMode); } + + @TestTemplate + public void testShredVariantsDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table, ImmutableMap.of()); + assertThat(writeConf.shredVariants()).isFalse(); + } + + @TestTemplate + public void testVariantInferenceBufferSizeDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table, ImmutableMap.of()); + assertThat(writeConf.variantInferenceBufferSize()) + .isEqualTo(TableProperties.PARQUET_VARIANT_BUFFER_SIZE_DEFAULT); + } + + @TestTemplate + public void testVariantInferenceBufferSizeTableProperty() { + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(TableProperties.PARQUET_VARIANT_BUFFER_SIZE, "500").commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table, ImmutableMap.of()); + assertThat(writeConf.variantInferenceBufferSize()).isEqualTo(500); + } + + @TestTemplate + public void testShredVariantsSessionOverridesTableProperty() { + Table table = validationCatalog.loadTable(tableIdent); + table.updateProperties().set(TableProperties.PARQUET_SHRED_VARIANTS, "false").commit(); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.SHRED_VARIANTS, "true"), + () -> { + SparkWriteConf writeConf = new SparkWriteConf(spark, table, ImmutableMap.of()); + assertThat(writeConf.shredVariants()).isTrue(); + }); + } + + @TestTemplate + public void testShredVariantsWriteOptionOverridesSessionConf() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.SHRED_VARIANTS, "false"), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = + new SparkWriteConf( + spark, + table, + new CaseInsensitiveStringMap( + ImmutableMap.of(SparkWriteOptions.SHRED_VARIANTS, "true"))); + assertThat(writeConf.shredVariants()).isTrue(); + }); + } + + @TestTemplate + public void testVariantInferenceBufferSizeSessionConf() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "250"), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table, ImmutableMap.of()); + assertThat(writeConf.variantInferenceBufferSize()).isEqualTo(250); + }); + } + + @TestTemplate + public void testWritePropertiesIncludeVariantShredding() { + Table table = validationCatalog.loadTable(tableIdent); + table.updateProperties().set(TableProperties.PARQUET_SHRED_VARIANTS, "true").commit(); + table.updateProperties().set(TableProperties.PARQUET_VARIANT_BUFFER_SIZE, "200").commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table, ImmutableMap.of()); + Map writeProperties = writeConf.writeProperties(); + assertThat(writeProperties).containsEntry(PARQUET_SHRED_VARIANTS, "true"); + assertThat(writeProperties).containsEntry(TableProperties.PARQUET_VARIANT_BUFFER_SIZE, "200"); + } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java index 303411eb7ddd..9ca6a9e57b8c 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java @@ -23,12 +23,9 @@ import java.io.IOException; import java.util.List; -import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionStatistics; import org.apache.iceberg.PartitionStatisticsFile; -import org.apache.iceberg.PartitionStats; -import org.apache.iceberg.PartitionStatsHandler; import org.apache.iceberg.Partitioning; -import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; @@ -120,10 +117,9 @@ public void partitionStatsComputeOnLatestSnapshot() throws IOException { assertThat(table.partitionStatisticsFiles()).containsExactly(statisticsFile); Types.StructType partitionType = Partitioning.partitionType(table); - Schema dataSchema = PartitionStatsHandler.schema(partitionType, 2); validatePartitionStats( - statisticsFile, - dataSchema, + table, + statisticsFile.snapshotId(), Tuple.tuple( partitionRecord(partitionType, "foo", "A"), DEFAULT_SPEC_ID, @@ -209,11 +205,10 @@ public void partitionStatsComputeOnSnapshot() throws IOException { assertThat(table.partitionStatisticsFiles()).containsExactly(statisticsFile); Types.StructType partitionType = Partitioning.partitionType(table); - Schema dataSchema = PartitionStatsHandler.schema(partitionType, 2); // should contain stats for only partitions of snapshot1 (no entry for partition bar, A) validatePartitionStats( - statisticsFile, - dataSchema, + table, + statisticsFile.snapshotId(), Tuple.tuple( partitionRecord(partitionType, "foo", "A"), DEFAULT_SPEC_ID, @@ -272,31 +267,28 @@ private void createPartitionedTableV1() { tableName); } - private void validatePartitionStats( - PartitionStatisticsFile result, Schema recordSchema, Tuple... expectedValues) + private void validatePartitionStats(Table table, long snapshotId, Tuple... expectedValues) throws IOException { - // read the partition entries from the stats file - List partitionStats; - try (CloseableIterable recordIterator = - PartitionStatsHandler.readPartitionStatsFile( - recordSchema, Files.localInput(result.path()))) { + List partitionStats; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()) { partitionStats = Lists.newArrayList(recordIterator); } assertThat(partitionStats) .extracting( - PartitionStats::partition, - PartitionStats::specId, - PartitionStats::dataRecordCount, - PartitionStats::dataFileCount, - PartitionStats::totalDataFileSizeInBytes, - PartitionStats::positionDeleteRecordCount, - PartitionStats::positionDeleteFileCount, - PartitionStats::equalityDeleteRecordCount, - PartitionStats::equalityDeleteFileCount, - PartitionStats::totalRecords, - PartitionStats::lastUpdatedAt, - PartitionStats::lastUpdatedSnapshotId) + PartitionStatistics::partition, + PartitionStatistics::specId, + PartitionStatistics::dataRecordCount, + PartitionStatistics::dataFileCount, + PartitionStatistics::totalDataFileSizeInBytes, + PartitionStatistics::positionDeleteRecordCount, + PartitionStatistics::positionDeleteFileCount, + PartitionStatistics::equalityDeleteRecordCount, + PartitionStatistics::equalityDeleteFileCount, + PartitionStatistics::totalRecords, + PartitionStatistics::lastUpdatedAt, + PartitionStatistics::lastUpdatedSnapshotId) .containsExactlyInAnyOrder(expectedValues); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java index eb89b0a23274..50afb53e0539 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java @@ -662,7 +662,7 @@ public void schemaEvolutionTestWithSparkSQL() throws Exception { "CAST(id AS FLOAT) col1", "CAST(id AS STRING) col2", "CAST(id AS INT) col3") - .registerTempTable("tempdata"); + .createOrReplaceTempView("tempdata"); sql("INSERT INTO TABLE %s SELECT * FROM tempdata", tblName); List expectedBeforeAddColumn = sql("SELECT * FROM %s ORDER BY col0", tblName); List expectedAfterAddColumn = diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java index 7e07c66e0650..c89e4f7852d5 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java @@ -47,6 +47,7 @@ import org.apache.iceberg.ReachableFileUtil; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableProperties; @@ -628,8 +629,10 @@ public void testWithExpiringDanglingStageCommit() { expectedDeletes.add(snapshotA.manifestListLocation()); // Files should be deleted of dangling staged snapshot - snapshotB - .addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(snapshotB) + .build() + .addedDataFiles() .forEach( i -> { expectedDeletes.add(i.location()); @@ -699,7 +702,10 @@ public void testWithCherryPickTableSnapshot() { Lists.newArrayList(snapshotB, snapshotC, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -748,7 +754,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); @@ -768,7 +777,10 @@ public void testWithExpiringStagedThenCherrypick() { Lists.newArrayList(snapshotB, snapshotD) .forEach( i -> { - i.addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() .forEach( item -> { assertThat(deletedFiles).doesNotContain(item.location()); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java index 76084c2b9402..1e4c21d214a8 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java @@ -77,7 +77,7 @@ public class TestRemoveDanglingDeleteAction extends TestBase { .build(); static final DataFile FILE_A2 = DataFiles.builder(SPEC) - .withPath("/path/to/data-a.parquet") + .withPath("/path/to/data-a2.parquet") .withFileSizeInBytes(10) .withPartitionPath("c1=a") // easy way to set partition data for now .withRecordCount(1) @@ -91,7 +91,7 @@ public class TestRemoveDanglingDeleteAction extends TestBase { .build(); static final DataFile FILE_B2 = DataFiles.builder(SPEC) - .withPath("/path/to/data-b.parquet") + .withPath("/path/to/data-b2.parquet") .withFileSizeInBytes(10) .withPartitionPath("c1=b") // easy way to set partition data for now .withRecordCount(1) @@ -105,7 +105,7 @@ public class TestRemoveDanglingDeleteAction extends TestBase { .build(); static final DataFile FILE_C2 = DataFiles.builder(SPEC) - .withPath("/path/to/data-c.parquet") + .withPath("/path/to/data-c2.parquet") .withFileSizeInBytes(10) .withPartitionPath("c1=c") // easy way to set partition data for now .withRecordCount(1) @@ -370,7 +370,6 @@ public void testPartitionedDeletesWithEqSeqNo() { // Add Data Files with EQ and POS deletes DeleteFile fileADeletes = fileADeletes(); DeleteFile fileA2Deletes = fileA2Deletes(); - DeleteFile fileBDeletes = fileBDeletes(); DeleteFile fileB2Deletes = fileB2Deletes(); table .newRowDelta() @@ -382,7 +381,6 @@ public void testPartitionedDeletesWithEqSeqNo() { .addDeletes(fileA2Deletes) .addDeletes(FILE_A_EQ_DELETES) .addDeletes(FILE_A2_EQ_DELETES) - .addDeletes(fileBDeletes) .addDeletes(fileB2Deletes) .addDeletes(FILE_B_EQ_DELETES) .addDeletes(FILE_B2_EQ_DELETES) @@ -400,7 +398,6 @@ public void testPartitionedDeletesWithEqSeqNo() { Tuple2.apply(2L, FILE_A2_EQ_DELETES.location()), Tuple2.apply(2L, fileA2Deletes.location()), Tuple2.apply(2L, FILE_B_EQ_DELETES.location()), - Tuple2.apply(2L, fileBDeletes.location()), Tuple2.apply(2L, FILE_B2.location()), Tuple2.apply(2L, FILE_B2_EQ_DELETES.location()), Tuple2.apply(2L, fileB2Deletes.location()), @@ -433,7 +430,6 @@ public void testPartitionedDeletesWithEqSeqNo() { Tuple2.apply(2L, FILE_A2.location()), Tuple2.apply(2L, FILE_A2_EQ_DELETES.location()), Tuple2.apply(2L, fileA2Deletes.location()), - Tuple2.apply(2L, fileBDeletes.location()), Tuple2.apply(2L, FILE_B2.location()), Tuple2.apply(2L, fileB2Deletes.location()), Tuple2.apply(2L, FILE_C2.location()), diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java index 40505b856737..0d2a5c0a4daf 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java @@ -184,6 +184,9 @@ public void testDryRun() throws IOException { assertThat(result1.orphanFileLocations()) .as("Default olderThan interval should be safe") .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); DeleteOrphanFiles.Result result2 = actions @@ -195,6 +198,9 @@ public void testDryRun() throws IOException { assertThat(result2.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFiles); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -210,6 +216,9 @@ public void testDryRun() throws IOException { assertThat(result3.orphanFileLocations()) .as("Streaming dry run should find 1 file") .isEqualTo(invalidFiles); + assertThat(result3.orphanFilesCount()) + .as("Streaming dry run should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present after streaming dry run") .isTrue(); @@ -223,6 +232,9 @@ public void testDryRun() throws IOException { assertThat(result4.orphanFileLocations()) .as("Action should delete 1 file") .isEqualTo(invalidFiles); + assertThat(result4.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should not be present") .isFalse(); @@ -286,6 +298,7 @@ public void testAllValidFilesAreKept() throws IOException { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 4 files").hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); Path dataPath = new Path(tableLocation + "/data"); FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); @@ -366,6 +379,7 @@ public void orphanedFileRemovedWithParallelTasks() { .containsExactlyInAnyOrder( "remove-orphan-0", "remove-orphan-1", "remove-orphan-2", "remove-orphan-3"); assertThat(deletedFiles).hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); } @TestTemplate @@ -410,6 +424,7 @@ public void testWapFilesAreKept() { actions.deleteOrphanFiles(table).olderThan(System.currentTimeMillis()).execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); } @TestTemplate @@ -440,6 +455,7 @@ public void testMetadataFolderIsIntact() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); List actualRecords = @@ -478,6 +494,7 @@ public void testOlderThanTimestamp() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete only 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete only 2 files").isEqualTo(2L); } @TestTemplate @@ -509,6 +526,7 @@ public void testRemoveUnreachableMetadataVersionFiles() { assertThat(result.orphanFileLocations()) .containsExactly(tableLocation + "metadata/v1.metadata.json"); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); List expectedRecords = Lists.newArrayList(); expectedRecords.addAll(records); @@ -545,6 +563,7 @@ public void testManyTopLevelPartitions() { .execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); assertThat(resultDF.count()).as("Rows count must match").isEqualTo(records.size()); @@ -575,6 +594,7 @@ public void testManyLeafPartitions() { .execute(); assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); Dataset resultDF = spark.read().format("iceberg").load(tableLocation); assertThat(resultDF.count()).as("Row count must match").isEqualTo(records.size()); @@ -615,6 +635,7 @@ public void testHiddenPartitionPaths() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); } @TestTemplate @@ -655,6 +676,7 @@ public void testHiddenPartitionPathsWithPartitionEvolution() { .execute(); assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); } @TestTemplate @@ -694,6 +716,7 @@ public void testHiddenPathsStartingWithPartitionNamesAreIgnored() throws IOExcep .execute(); assertThat(result.orphanFileLocations()).as("Should delete 0 files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should delete 0 files").isEqualTo(0L); assertThat(fs.exists(pathToFileInHiddenFolder)).isTrue(); } @@ -766,6 +789,9 @@ public void testRemoveOrphanFilesWithRelativeFilePath() throws IOException { assertThat(result.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFiles); + assertThat(result.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); assertThat(fs.exists(new Path(invalidFiles.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -803,6 +829,7 @@ public void testRemoveOrphanFilesWithHadoopCatalog() throws InterruptedException .execute(); assertThat(result.orphanFileLocations()).as("Should delete only 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete only 1 file").isEqualTo(1L); Dataset resultDF = spark.read().format("iceberg").load(table.location()); List actualRecords = @@ -838,6 +865,7 @@ public void testHiveCatalogTable() throws IOException { assertThat(result.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + "/data/trashfile"); + assertThat(result.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -932,6 +960,9 @@ public void testCompareToFileList() throws IOException { assertThat(result1.orphanFileLocations()) .as("Default olderThan interval should be safe") .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); DeleteOrphanFiles.Result result2 = actions @@ -943,6 +974,9 @@ public void testCompareToFileList() throws IOException { assertThat(result2.orphanFileLocations()) .as("Action should find 1 file") .isEqualTo(invalidFilePaths); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFilePaths.size()); assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) .as("Invalid file should be present") .isTrue(); @@ -956,6 +990,9 @@ public void testCompareToFileList() throws IOException { assertThat(result3.orphanFileLocations()) .as("Action should delete 1 file") .isEqualTo(invalidFilePaths); + assertThat(result3.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFilePaths.size()); assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) .as("Invalid file should not be present") .isFalse(); @@ -985,6 +1022,7 @@ public void testCompareToFileList() throws IOException { .deleteWith(s -> {}) .execute(); assertThat(result4.orphanFileLocations()).as("Action should find nothing").isEmpty(); + assertThat(result4.orphanFilesCount()).as("Action should find nothing").isEqualTo(0L); } protected long waitUntilAfter(long timestampMillis) { @@ -1064,6 +1102,7 @@ public void testRemoveOrphanFilesWithStatisticFiles() throws Exception { .execute(); Iterable orphanFileLocations = result.orphanFileLocations(); assertThat(orphanFileLocations).hasSize(1).containsExactly(statsLocation.toURI().toString()); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); assertThat(statsLocation).as("stats file should be deleted").doesNotExist(); } @@ -1282,6 +1321,9 @@ public void testStreamResultsDeletion() throws IOException { .as("Non-streaming dry-run should return all 10 orphan files") .hasSize(10) .containsExactlyInAnyOrderElementsOf(invalidFiles); + assertThat(nonStreamingResult.orphanFilesCount()) + .as("Non-streaming dry-run should return all 10 orphan files") + .isEqualTo((long) invalidFiles.size()); DeleteOrphanFiles.Result streamingResult = SparkActions.get() @@ -1295,6 +1337,9 @@ public void testStreamResultsDeletion() throws IOException { assertThat(streamingResult.orphanFileLocations()) .as("Streaming with sample size 5 should return only 5 orphan files") .hasSize(5); + assertThat(streamingResult.orphanFilesCount()) + .as("Deleted 10 files") + .isEqualTo((long) invalidFiles.size()); for (String invalidFile : invalidFiles) { assertThat(fs.exists(new Path(invalidFile))).as("Orphan file should be deleted").isFalse(); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java index 5f98287951f1..88ac800b158f 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java @@ -60,6 +60,7 @@ public void testSparkCatalogTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -89,6 +90,7 @@ public void testSparkCatalogNamedHadoopTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -119,6 +121,7 @@ public void testSparkCatalogNamedHiveTable() throws Exception { assertThat(StreamSupport.stream(results.orphanFileLocations().spliterator(), false)) .as("trash file should be removed") .anyMatch(file -> file.contains("file:" + location + trashFile)); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -151,6 +154,7 @@ public void testSparkSessionCatalogHadoopTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @TestTemplate @@ -183,6 +187,7 @@ public void testSparkSessionCatalogHiveTable() throws Exception { assertThat(results.orphanFileLocations()) .as("trash file should be removed") .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); } @AfterEach diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java index 6d965f3dcc62..38ddefd26a45 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java @@ -73,6 +73,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.SortOrder; import org.apache.iceberg.StructLike; @@ -176,7 +177,6 @@ public void setupTableLocation() { private RewriteDataFilesSparkAction basicRewrite(Table table) { // Always compact regardless of input files - table.refresh(); return actions() .rewriteDataFiles(table) .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1"); @@ -292,6 +292,7 @@ public void testBinPackAfterPartitionChange() { Table table = createTable(); writeRecords(20, SCALE, 20); + table.refresh(); shouldHaveFiles(table, 20); table.updateSpec().addField(Expressions.ref("c1")).commit(); @@ -365,7 +366,6 @@ public void testDataFilesRewrittenWithMaxDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).isEmpty(); @@ -415,7 +415,6 @@ public void testDataFilesRewrittenWithHighDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).hasSize(1); @@ -465,7 +464,6 @@ public void testDataFilesNotRewrittenWithLowDeleteRatio() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(0); - table.refresh(); List newDataFiles = TestHelpers.dataFiles(table); assertThat(newDataFiles).hasSameSizeAs(dataFiles); @@ -478,7 +476,6 @@ public void testBinPackWithV2PositionDeletes() throws IOException { assumeThat(formatVersion).isEqualTo(2); Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); @@ -495,7 +492,6 @@ public void testBinPackWithV2PositionDeletes() throws IOException { } rowDelta.commit(); - table.refresh(); List expectedRecords = currentData(); long dataSizeBefore = testDataSize(table); Result result = @@ -522,7 +518,6 @@ public void testBinPackWithDVs() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(3); Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); - table.refresh(); List initialRecords = currentDataWithLineage(); Set rowIds = initialRecords.stream().map(record -> (Long) record[0]).collect(Collectors.toSet()); @@ -554,7 +549,6 @@ public void testBinPackWithDVs() throws IOException { } rowDelta.commit(); - table.refresh(); List recordsWithLineageAfterDelete = currentDataWithLineage(); rowIds.removeAll(rowIdsBeingRemoved); assertThat(rowIds) @@ -633,7 +627,6 @@ public void removeDanglingDVsFromDeleteManifest() throws Exception { assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); assertThat(result.removedDeleteFilesCount()).isEqualTo(numDataFiles); - table.refresh(); assertThat(TestHelpers.dataFiles(table)).hasSize(1); assertThat(TestHelpers.deleteFiles(table)).isEmpty(); @@ -684,7 +677,6 @@ public void testRemoveDangledEqualityDeletesPartitionEvolution() { .hasSize(1); // partition evolution - table.refresh(); table.updateSpec().addField(Expressions.ref("c3")).commit(); // data seq = 4, write 2 new data files in both partitions for evolved spec @@ -782,7 +774,6 @@ public void testBinPackWithDeleteAllData() throws IOException { assumeThat(formatVersion).isGreaterThanOrEqualTo(2); Table table = createTablePartitioned(1, 1, 1); shouldHaveFiles(table, 1); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); @@ -798,7 +789,6 @@ public void testBinPackWithDeleteAllData() throws IOException { } rowDelta.commit(); - table.refresh(); List expectedRecords = currentData(); long dataSizeBefore = testDataSize(table); @@ -835,7 +825,6 @@ public void testBinPackWithStartingSequenceNumber() { Table table = createTablePartitioned(4, 2); shouldHaveFiles(table, 8); List expectedRecords = currentData(); - table.refresh(); long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); long dataSizeBefore = testDataSize(table); @@ -851,7 +840,6 @@ public void testBinPackWithStartingSequenceNumber() { List actualRecords = currentData(); assertEquals("Rows must match", expectedRecords, actualRecords); - table.refresh(); assertThat(table.currentSnapshot().sequenceNumber()) .as("Table sequence number should be incremented") .isGreaterThan(oldSequenceNumber); @@ -872,7 +860,6 @@ public void testBinPackWithStartingSequenceNumberV1Compatibility() { Table table = createTablePartitioned(4, 2, SCALE, properties); shouldHaveFiles(table, 8); List expectedRecords = currentData(); - table.refresh(); long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); assertThat(oldSequenceNumber).as("Table sequence number should be 0").isZero(); long dataSizeBefore = testDataSize(table); @@ -889,7 +876,6 @@ public void testBinPackWithStartingSequenceNumberV1Compatibility() { List actualRecords = currentData(); assertEquals("Rows must match", expectedRecords, actualRecords); - table.refresh(); assertThat(table.currentSnapshot().sequenceNumber()) .as("Table sequence number should still be 0") .isEqualTo(oldSequenceNumber); @@ -983,6 +969,7 @@ public void testBinPackCombineMixedFiles() { // Add one more small file, and one large file writeRecords(1, SCALE); writeRecords(1, SCALE * 3); + table.refresh(); shouldHaveFiles(table, 3); List expectedRecords = currentData(); @@ -1072,8 +1059,6 @@ public void testPartialProgressEnabled() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - shouldHaveSnapshots(table, 11); shouldHaveACleanCache(table); @@ -1100,8 +1085,6 @@ public void testMultipleGroups() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1129,8 +1112,6 @@ public void testPartialProgressMaxCommits() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1162,8 +1143,6 @@ public void testSingleCommitWithRewriteFailure() { .isInstanceOf(RuntimeException.class) .hasMessage("Rewrite Failed"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1196,8 +1175,6 @@ public void testSingleCommitWithCommitFailure() { .isInstanceOf(RuntimeException.class) .hasMessageContaining("Cannot commit rewrite"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1230,8 +1207,6 @@ public void testCommitFailsWithUncleanableFailure() { .isInstanceOf(RuntimeException.class) .hasMessageContaining("Arbitrary Failure"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1265,8 +1240,6 @@ public void testParallelSingleCommitWithRewriteFailure() { .isInstanceOf(CommitFailedException.class) .hasMessage("Rewrite Failed"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1305,8 +1278,6 @@ public void testPartialProgressWithRewriteFailure() { assertThat(result.failedDataFilesCount()).isEqualTo(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1348,8 +1319,6 @@ public void testParallelPartialProgressWithRewriteFailure() { assertThat(result.failedDataFilesCount()).isEqualTo(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1394,8 +1363,6 @@ public void testParallelPartialProgressWithCommitFailure() { assertThat(result.rewriteResults()).as("Should have 6 fileGroups").hasSize(6); assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1434,8 +1401,6 @@ public void testParallelPartialProgressWithMaxFailedCommits() { .hasMessageContaining( "1 rewrite commits failed. This is more than the maximum allowed failures of 0"); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1467,11 +1432,8 @@ public void testParallelPartialProgressWithMaxCommitsLargerThanTotalGroupCount() .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_FAILED_COMMITS, "1"); rewrite.execute(); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); - table.refresh(); assertThat(table.snapshots()) .as("Table did not have the expected number of snapshots") // To tolerate 1 random commit failure @@ -1547,8 +1509,6 @@ public void testSortMultipleGroups() { assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1557,7 +1517,7 @@ public void testSortMultipleGroups() { } @TestTemplate - public void testSimpleSort() { + public void testSimpleSort() throws IOException { Table table = createTable(20); shouldHaveFiles(table, 20); table.replaceSortOrder().asc("c2").commit(); @@ -1578,8 +1538,6 @@ public void testSimpleSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1587,10 +1545,11 @@ public void testSimpleSort() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); } @TestTemplate - public void testSortAfterPartitionChange() { + public void testSortAfterPartitionChange() throws IOException { Table table = createTable(20); shouldHaveFiles(table, 20); table.updateSpec().addField(Expressions.bucket("c1", 4)).commit(); @@ -1614,8 +1573,6 @@ public void testSortAfterPartitionChange() { .hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1623,10 +1580,11 @@ public void testSortAfterPartitionChange() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); } @TestTemplate - public void testSortCustomSortOrder() { + public void testSortCustomSortOrder() throws IOException { Table table = createTable(20); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveFiles(table, 20); @@ -1645,8 +1603,6 @@ public void testSortCustomSortOrder() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1654,13 +1610,15 @@ public void testSortCustomSortOrder() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate - public void testSortCustomSortOrderRequiresRepartition() { + public void testSortCustomSortOrderRequiresRepartition() throws IOException { int partitions = 4; Table table = createTable(); writeRecords(20, SCALE, partitions); + table.refresh(); shouldHaveLastCommitUnsorted(table, "c3"); // Add a partition column so this requires repartitioning @@ -1684,8 +1642,6 @@ public void testSortCustomSortOrderRequiresRepartition() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1694,10 +1650,40 @@ public void testSortCustomSortOrderRequiresRepartition() { shouldHaveMultipleFiles(table); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveLastCommitSorted(table, "c3"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); + } + + @TestTemplate + public void testSortPastTableSortOrderGetsAppliedToFiles() throws IOException { + Table table = createTable(1); + + table.replaceSortOrder().asc("c3").commit(); + SortOrder c3SortOrder = table.sortOrder(); + + table.replaceSortOrder().asc("c2").commit(); + + List originalData = currentData(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c3").build()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + + table.refresh(); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + dataFilesShouldHaveSortOrderIdMatching(table, c3SortOrder); } @TestTemplate - public void testAutoSortShuffleOutput() { + public void testAutoSortShuffleOutput() throws IOException { Table table = createTable(20); shouldHaveLastCommitUnsorted(table, "c2"); shouldHaveFiles(table, 20); @@ -1721,12 +1707,10 @@ public void testAutoSortShuffleOutput() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 40+ files") .hasSizeGreaterThanOrEqualTo(40); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1734,6 +1718,7 @@ public void testAutoSortShuffleOutput() { shouldHaveACleanCache(table); shouldHaveMultipleFiles(table); shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); } @TestTemplate @@ -1768,6 +1753,26 @@ public void testCommitStateUnknownException() { shouldHaveSnapshots(table, 2); // Commit actually Succeeded } + @TestTemplate + public void testZOrderWithZColumnCollision() { + Schema schema = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "ICEZVALUE", Types.StringType.get())); + + Table table = + TABLES.create( + schema, + PartitionSpec.unpartitioned(), + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + assertThatThrownBy(() -> basicRewrite(table).zOrder("c1", "c2").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot zorder because the table has a column named 'ICEZVALUE'"); + } + @TestTemplate public void testZOrderSort() { int originalFiles = 20; @@ -1800,12 +1805,10 @@ public void testZOrderSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 40+ files") .hasSizeGreaterThanOrEqualTo(40); - table.refresh(); - List postRewriteData = currentData(); assertEquals("We shouldn't have changed the data", originalData, postRewriteData); @@ -1866,12 +1869,10 @@ public void testZOrderAllTypesSort() { assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); - assertThat(table.currentSnapshot().addedDataFiles(table.io())) + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) .as("Should have written 1 file") .hasSize(1); - table.refresh(); - List postRaw = spark .read() @@ -2128,6 +2129,23 @@ public void testZOrderUDFWithDateType() { assertThat(zorderBytes).isNotNull().isNotEmpty(); } + @TestTemplate + public void testZOrderUDFWithTimestampNTZType() { + SparkZOrderUDF zorderUDF = new SparkZOrderUDF(1, 16, 1024); + Dataset result = + spark + .sql("SELECT timestamp_ntz '2025-01-01 12:00:00' as test_col") + .withColumn( + "zorder_result", + zorderUDF.sortedLexicographically(col("test_col"), DataTypes.TimestampNTZType)); + + assertThat(result.schema().apply("zorder_result").dataType()).isEqualTo(DataTypes.BinaryType); + List rows = result.collectAsList(); + Row row = rows.get(0); + byte[] zorderBytes = row.getAs("zorder_result"); + assertThat(zorderBytes).isNotNull().isNotEmpty(); + } + protected void shouldRewriteDataFilesWithPartitionSpec(Table table, int outputSpecId) { List rewrittenFiles = currentDataFiles(table); assertThat(rewrittenFiles).allMatch(file -> file.specId() == outputSpecId); @@ -2177,7 +2195,6 @@ protected long testDataSize(Table table) { } protected void shouldHaveMultipleFiles(Table table) { - table.refresh(); int numFiles = Iterables.size(table.newScan().planFiles()); assertThat(numFiles) .as(String.format("Should have multiple files, had %d", numFiles)) @@ -2185,7 +2202,6 @@ protected void shouldHaveMultipleFiles(Table table) { } protected void shouldHaveFiles(Table table, int numExpected) { - table.refresh(); List files = StreamSupport.stream(table.newScan().planFiles().spliterator(), false) .collect(Collectors.toList()); @@ -2208,7 +2224,6 @@ protected long shouldHaveMinSequenceNumberInPartition( } protected void shouldHaveSnapshots(Table table, int expectedSnapshots) { - table.refresh(); assertThat(table.snapshots()) .as("Table did not have the expected number of snapshots") .hasSize(expectedSnapshots); @@ -2261,13 +2276,13 @@ private Pair boundsOf(DataFile file, NestedField field, Class javaC private List, Pair>> checkForOverlappingFiles( Table table, String column) { - table.refresh(); NestedField field = table.schema().caseInsensitiveFindField(column); Class javaClass = (Class) field.type().typeId().javaClass(); Snapshot snapshot = table.currentSnapshot(); Map> filesByPartition = - Streams.stream(snapshot.addedDataFiles(table.io())) + Streams.stream( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) .collect(Collectors.groupingBy(DataFile::partition)); Stream, Pair>> overlaps = @@ -2342,6 +2357,7 @@ protected Table createTable() { protected Table createTable(int files) { Table table = createTable(); writeRecords(files, SCALE); + table.refresh(); return table; } @@ -2352,6 +2368,7 @@ protected Table createTablePartitioned( assertThat(table.currentSnapshot()).as("Table must be empty").isNull(); writeRecords(files, numRecords, partitions); + table.refresh(); return table; } @@ -2404,11 +2421,11 @@ private Table createTypeTestTable() { .mode("append") .save(tableLocation); + table.refresh(); return table; } protected int averageFileSize(Table table) { - table.refresh(); return (int) Streams.stream(table.newScan().planFiles()) .mapToLong(FileScanTask::length) @@ -2657,4 +2674,17 @@ public boolean matches(RewriteFileGroup argument) { return groupIDs.contains(argument.info().globalIndex()); } } + + private void dataFilesSortOrderShouldMatchTableSortOrder(Table table) throws IOException { + dataFilesShouldHaveSortOrderIdMatching(table, table.sortOrder()); + } + + private void dataFilesShouldHaveSortOrderIdMatching(Table table, SortOrder sortOrder) + throws IOException { + try (CloseableIterable files = table.newScan().planFiles()) { + assertThat(files) + .extracting(fileScanTask -> fileScanTask.file().sortOrderId()) + .containsOnly(sortOrder.orderId()); + } + } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java index 4f95416878e2..0120d4b5fe78 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java @@ -51,6 +51,7 @@ import org.apache.iceberg.ManifestFile; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.ManifestWriter; +import org.apache.iceberg.MetadataColumns; import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; @@ -58,6 +59,7 @@ import org.apache.iceberg.RowDelta; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -195,6 +197,119 @@ public void testRewriteManifestsPreservesOptionalFields() throws IOException { } } + @TestTemplate + public void testRewriteV3ManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, null, "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteV3PartitionedManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, "AAAA", "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "BBBB", "BBBB"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteManifestsAfterV2ToV3Upgrade() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, spec, ImmutableMap.of(TableProperties.FORMAT_VERSION, "2"), tableLocation); + + ThreeColumnRecord record1 = new ThreeColumnRecord(1, null, "AAAA"); + ThreeColumnRecord record2 = new ThreeColumnRecord(2, "CCCC", "CCCC"); + writeRecords(Lists.newArrayList(record1)); + writeRecords(Lists.newArrayList(record2)); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + table + .updateProperties() + .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)) + .commit(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull() + .doesNotHaveDuplicates(); + assertThat(rowsAfter) + .extracting(r -> new ThreeColumnRecord(r.getAs("c1"), r.getAs("c2"), r.getAs("c3"))) + .containsExactlyInAnyOrder(record1, record2); + } + @TestTemplate public void testRewriteManifestsEmptyTable() throws IOException { PartitionSpec spec = PartitionSpec.unpartitioned(); @@ -728,7 +843,9 @@ public void testRewriteSmallManifestsNonPartitionedV2Table() { table.refresh(); Snapshot snapshot1 = table.currentSnapshot(); - DataFile file1 = Iterables.getOnlyElement(snapshot1.addedDataFiles(table.io())); + DataFile file1 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot1).build().addedDataFiles()); List records2 = Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC")); writeRecords(records2); @@ -736,7 +853,9 @@ public void testRewriteSmallManifestsNonPartitionedV2Table() { table.refresh(); Snapshot snapshot2 = table.currentSnapshot(); - DataFile file2 = Iterables.getOnlyElement(snapshot2.addedDataFiles(table.io())); + DataFile file2 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot2).build().addedDataFiles()); List manifests = table.currentSnapshot().allManifests(table.io()); assertThat(manifests).as("Should have 2 manifests before rewrite").hasSize(2); @@ -1134,6 +1253,17 @@ private List actualRecords() { .collectAsList(); } + private List recordsWithLineage() { + return spark + .read() + .format("iceberg") + .load(tableLocation) + .selectExpr( + MetadataColumns.ROW_ID.name(), MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), "*") + .orderBy(MetadataColumns.ROW_ID.name()) + .collectAsList(); + } + private void writeRecords(List records) { Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); writeDF(df); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java index 829ac761c876..7adbe760e763 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java @@ -251,7 +251,6 @@ public void testRewriteAll() throws Exception { @TestTemplate public void testRewriteFilter() throws Exception { Table table = createTablePartitioned(4, 2, SCALE); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); @@ -260,7 +259,6 @@ public void testRewriteFilter() throws Exception { List deleteFiles = deleteFiles(table); assertThat(deleteFiles).hasSize(8); - table.refresh(); List expectedRecords = records(table); List expectedDeletes = deleteRecords(table); assertThat(expectedRecords).hasSize(12000); @@ -536,7 +534,6 @@ public void testSomePartitionsDanglingDeletes() throws Exception { @TestTemplate public void testRewriteFilterRemoveDangling() throws Exception { Table table = createTablePartitioned(4, 2, SCALE); - table.refresh(); List dataFiles = TestHelpers.dataFiles(table); writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles, true); @@ -545,7 +542,6 @@ public void testRewriteFilterRemoveDangling() throws Exception { List deleteFiles = deleteFiles(table); assertThat(deleteFiles).hasSize(8); - table.refresh(); List expectedRecords = records(table); List expectedDeletes = deleteRecords(table); assertThat(expectedRecords).hasSize(12000); // 16000 data - 4000 delete rows @@ -705,7 +701,6 @@ public void testSchemaEvolution() throws Exception { List newSchemaDeleteFiles = except(deleteFiles(table), deleteFiles); assertThat(newSchemaDeleteFiles).hasSize(4); - table.refresh(); List expectedDeletes = deleteRecords(table); List expectedRecords = records(table); assertThat(expectedDeletes).hasSize(4000); // 4 files * 1000 per file diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java index 0bcaf0af6581..dae721b1d73d 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java @@ -30,6 +30,8 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -46,6 +48,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StaticTableOperations; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; @@ -433,9 +436,9 @@ private void runPositionDeletesTest(String fileFormat) throws Exception { List> deletes = Lists.newArrayList( Pair.of( - tableWithPosDeletes - .currentSnapshot() - .addedDataFiles(tableWithPosDeletes.io()) + SnapshotChanges.builderFor(tableWithPosDeletes) + .build() + .addedDataFiles() .iterator() .next() .location(), @@ -480,7 +483,7 @@ private void runPositionDeletesTest(String fileFormat) throws Exception { @TestTemplate public void testPositionDeleteWithRow() throws Exception { String dataFileLocation = - table.currentSnapshot().addedDataFiles(table.io()).iterator().next().location(); + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next().location(); List> deletes = Lists.newArrayList(); OutputFile deleteFile = table @@ -530,7 +533,15 @@ public void testPositionDeletesAcrossFiles() throws Exception { .isEqualTo(2); Stream allFiles = StreamSupport.stream(table.snapshots().spliterator(), false) - .flatMap(s -> StreamSupport.stream(s.addedDataFiles(table.io()).spliterator(), false)); + .flatMap( + s -> + StreamSupport.stream( + SnapshotChanges.builderFor(table) + .snapshot(s) + .build() + .addedDataFiles() + .spliterator(), + false)); List> deletes = allFiles.map(f -> Pair.of((CharSequence) f.location(), 0L)).collect(Collectors.toList()); @@ -565,6 +576,79 @@ public void testPositionDeletesAcrossFiles() throws Exception { .isEmpty(); } + /** + * Test for https://github.com/apache/iceberg/issues/14814 + * + *

    This test verifies that rewrite_table_path correctly deduplicates delete files when the same + * delete file appears in multiple manifests. Without the DeleteFileSet fix, this test would fail + * with AlreadyExistsException because DeleteFile objects don't override equals() and the same + * file would be processed multiple times. + * + *

    The test creates a scenario where the same delete file is added to multiple snapshots, + * causing it to appear in multiple manifest entries. When these manifests are processed, the same + * delete file is returned as different object instances which need proper deduplication. + */ + @TestTemplate + public void testPositionDeletesDeduplication() throws Exception { + // Format versions 3 and 4 use Deletion Vectors stored in Puffin files, which have different + // validation rules that prevent adding the same delete file multiple times + assumeThat(formatVersion) + .as("Format versions 3+ use DVs with different validation rules") + .isEqualTo(2); + + Table tableWithPosDeletes = + createTableWithSnapshots( + tableDir.toFile().toURI().toString().concat("tableWithDuplicateDeletes"), + 2, + Map.of(TableProperties.DELETE_DEFAULT_FILE_FORMAT, "parquet")); + + // Get a data file to create position deletes for + DataFile dataFile = + tableWithPosDeletes + .currentSnapshot() + .addedDataFiles(tableWithPosDeletes.io()) + .iterator() + .next(); + + // Create a position delete file + List> deletes = Lists.newArrayList(Pair.of(dataFile.location(), 0L)); + File deleteFile = + new File( + removePrefix(tableWithPosDeletes.location() + "/data/deeply/nested/deletes.parquet")); + DeleteFile positionDeletes = + FileHelpers.writeDeleteFile( + tableWithPosDeletes, + tableWithPosDeletes.io().newOutputFile(deleteFile.toURI().toString()), + deletes, + formatVersion) + .first(); + + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // Add the SAME delete file AGAIN in a second snapshot - this creates a duplicate entry + // in a new manifest, which will cause duplicate DeleteFile objects when processing + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // This should NOT throw AlreadyExistsException - the fix uses DeleteFileSet to deduplicate + // Without the fix (using Collectors.toSet()), this would fail because: + // 1. Both manifests contain entries for the same delete file + // 2. Processing returns two different DeleteFile objects for the same file + // 3. HashSet doesn't deduplicate them (DeleteFile doesn't override equals()) + // 4. rewritePositionDeletes tries to write the same file twice -> AlreadyExistsException + RewriteTablePath.Result result = + actions() + .rewriteTablePath(tableWithPosDeletes) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(tableWithPosDeletes.location(), targetTableLocation()) + .execute(); + + // Verify the rewrite completed successfully - should have rewritten exactly 1 delete file + // (the duplicate should be deduplicated by DeleteFileSet) + assertThat(result.rewrittenDeleteFilePathsCount()) + .as("Should have rewritten exactly 1 delete file after deduplication") + .isEqualTo(1); + } + @TestTemplate public void testEqualityDeletes() throws Exception { Table sourceTable = createTableWithSnapshots(newTableLocation(), 1); @@ -704,7 +788,10 @@ public void testRewritePathWithNonLiveEntry() throws Exception { Snapshot oldest = SnapshotUtil.oldestAncestor(tableWith3Snaps); String oldestDataFilePath = Iterables.getOnlyElement( - tableWith3Snaps.snapshot(oldest.snapshotId()).addedDataFiles(tableWith3Snaps.io())) + SnapshotChanges.builderFor(tableWith3Snaps) + .snapshot(tableWith3Snaps.snapshot(oldest.snapshotId())) + .build() + .addedDataFiles()) .location(); String deletedDataFilePathInTargetLocation = String.format("%sdata/%s", targetTableLocation(), fileName(oldestDataFilePath)); @@ -1228,27 +1315,14 @@ public void testNestedDirectoryStructurePreservation() throws Exception { // Create position delete files with same names in different nested directories // This simulates the scenario tested in // TestRewriteTablePathUtil.testStagingPathPreservesDirectoryStructure + SnapshotChanges sourceChanges = SnapshotChanges.builderFor(sourceTable).build(); List> deletes1 = Lists.newArrayList( - Pair.of( - sourceTable - .currentSnapshot() - .addedDataFiles(sourceTable.io()) - .iterator() - .next() - .location(), - 0L)); + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); List> deletes2 = Lists.newArrayList( - Pair.of( - sourceTable - .currentSnapshot() - .addedDataFiles(sourceTable.io()) - .iterator() - .next() - .location(), - 0L)); + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); // Create delete files with same name in different nested paths (hash1/ and hash2/) File file1 = @@ -1331,6 +1405,28 @@ public void testRewritePathWithoutCreateFileList() throws Exception { .isEqualTo(NOT_APPLICABLE); } + @TestTemplate + public void testRewritePathWithExecutorService() throws Exception { + String sourceLocation = newTableLocation(); + Table sourceTable = createTableWithSnapshots(sourceLocation, 50); + + ExecutorService service = Executors.newFixedThreadPool(4); + try { + sourceTable.refresh(); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceLocation, targetTableLocation()) + .startVersion("v1.metadata.json") + .executeWith(service) + .execute(); + + checkFileNum(50, 50, 50, 200, result); + } finally { + service.shutdown(); + } + } + protected void checkFileNum( int versionFileCount, int manifestListCount, diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java index d9c42a07b853..9fac633e75ac 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java @@ -18,7 +18,11 @@ */ package org.apache.iceberg.spark.actions; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_HADOOP; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.IOException; import java.nio.file.Files; @@ -33,6 +37,7 @@ @ExtendWith(ParameterizedTestExtension.class) public class TestSnapshotTableAction extends CatalogTestBase { private static final String SOURCE_NAME = "spark_catalog.default.source"; + private static final String SOURCE = "source"; @AfterEach public void removeTables() { @@ -65,4 +70,92 @@ public void testSnapshotWithParallelTasks() throws IOException { .execute(); assertThat(snapshotThreadsIndex.get()).isEqualTo(2); } + + @TestTemplate + public void testSnapshotWithOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(actualSourceLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "The snapshot table location cannot be same as the source table location."); + + String destAsSubdirectory = actualSourceLocation + "/nested"; + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(destAsSubdirectory) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + + String parentLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - ("/" + SOURCE).length()); + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(parentLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + } + + @TestTemplate + public void testSnapshotWithNonOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + String validDestLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - SOURCE.length()) + + "newDestination"; + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(validDestLocation) + .execute(); + assertThat(sql("SELECT * FROM %s", tableName)).hasSize(2); + } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java new file mode 100644 index 000000000000..b7ba7a34309a --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; + +/** Converts Iceberg Record to Spark InternalRow for testing. */ +public class InternalRowConverter { + private static final OffsetDateTime EPOCH = Instant.ofEpochSecond(0).atOffset(ZoneOffset.UTC); + private static final LocalDate EPOCH_DAY = EPOCH.toLocalDate(); + + private InternalRowConverter() {} + + public static InternalRow convert(Schema schema, Record record) { + return convert(schema.asStruct(), record); + } + + private static InternalRow convert(Types.StructType struct, Record record) { + GenericInternalRow internalRow = new GenericInternalRow(struct.fields().size()); + List fields = struct.fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + + Type fieldType = field.type(); + internalRow.update(i, convert(fieldType, record.get(i))); + } + + return internalRow; + } + + private static Object convert(Type type, Object value) { + if (value == null) { + return null; + } + + return switch (type.typeId()) { + case BOOLEAN, INTEGER, LONG, FLOAT, DOUBLE -> value; + case DATE -> (int) ChronoUnit.DAYS.between(EPOCH_DAY, (LocalDate) value); + case TIMESTAMP -> + ((Types.TimestampType) type).shouldAdjustToUTC() + ? ChronoUnit.MICROS.between(EPOCH, (OffsetDateTime) value) + : ChronoUnit.MICROS.between(EPOCH, ((LocalDateTime) value).atZone(ZoneId.of("UTC"))); + case STRING -> UTF8String.fromString((String) value); + case UUID -> UTF8String.fromString(value.toString()); + case FIXED, BINARY -> { + ByteBuffer buffer = (ByteBuffer) value; + yield Arrays.copyOfRange( + buffer.array(), + buffer.arrayOffset() + buffer.position(), + buffer.arrayOffset() + buffer.remaining()); + } + case DECIMAL -> Decimal.apply((BigDecimal) value); + case STRUCT -> convert((Types.StructType) type, (Record) value); + case LIST -> + new GenericArrayData( + ((List) value) + .stream() + .map(element -> convert(type.asListType().elementType(), element)) + .toArray()); + case MAP -> + new ArrayBasedMapData( + new GenericArrayData( + ((Map) value) + .keySet().stream() + .map(o -> convert(type.asMapType().keyType(), o)) + .toArray()), + new GenericArrayData( + ((Map) value) + .values().stream() + .map(o -> convert(type.asMapType().valueType(), o)) + .toArray())); + // TIME is not supported by Spark, VARIANT not yet implemented + default -> + throw new UnsupportedOperationException( + "Unsupported type for conversion to InternalRow: " + type); + }; + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java new file mode 100644 index 000000000000..291bb2bca4f5 --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseFormatModelTests; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Type; +import org.apache.spark.sql.catalyst.InternalRow; + +public class TestSparkFormatModel extends BaseFormatModelTests { + + @Override + protected Class engineType() { + return InternalRow.class; + } + + @Override + protected Object engineSchema(Schema schema) { + return SparkSchemaUtil.convert(schema); + } + + @Override + protected InternalRow convertToEngine(Record record, Schema schema) { + return InternalRowConverter.convert(schema, record); + } + + @Override + protected void assertEquals(Schema schema, List expected, List actual) { + assertThat(actual).hasSameSizeAs(expected); + for (int i = 0; i < expected.size(); i++) { + TestHelpers.assertEquals(schema, expected.get(i), actual.get(i)); + } + } + + @Override + protected Object convertConstantToEngine(Type type, Object value) { + return SparkUtil.internalToSpark(type, value); + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java index 284fa0b0552f..b61ecfa2f442 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java @@ -43,6 +43,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; import org.apache.iceberg.types.Types; @@ -65,6 +66,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java index 46a6a302e1c4..6011c6dad7d2 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java @@ -81,15 +81,24 @@ public class TestParquetVectorizedReads extends AvroDataTestBase { private static final String PLAIN = "PLAIN"; private static final List GOLDEN_FILE_ENCODINGS = - ImmutableList.of("PLAIN_DICTIONARY", "RLE_DICTIONARY", "DELTA_BINARY_PACKED"); + ImmutableList.of( + "PLAIN_DICTIONARY", + "RLE_DICTIONARY", + "DELTA_BINARY_PACKED", + "DELTA_LENGTH_BYTE_ARRAY", + "DELTA_BYTE_ARRAY", + "BYTE_STREAM_SPLIT"); private static final Map GOLDEN_FILE_TYPES = - ImmutableMap.of( - "string", Types.StringType.get(), - "float", Types.FloatType.get(), - "int32", Types.IntegerType.get(), - "int64", Types.LongType.get(), - "binary", Types.BinaryType.get(), - "boolean", Types.BooleanType.get()); + ImmutableMap.builder() + .put("string", Types.StringType.get()) + .put("float", Types.FloatType.get()) + .put("double", Types.DoubleType.get()) + .put("int32", Types.IntegerType.get()) + .put("int64", Types.LongType.get()) + .put("binary", Types.BinaryType.get()) + .put("boolean", Types.BooleanType.get()) + .put("uuid", Types.UUIDType.get()) + .buildOrThrow(); static final Function IDENTITY = record -> record; @@ -204,8 +213,7 @@ private void writeAndValidate( Map idToConstant) throws IOException { // write a test parquet file using iceberg writer - File testFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + File testFile = temp.resolve("data.parquet").toFile(); try (FileAppender writer = getParquetWriter(writeSchema, testFile)) { writer.addAll(expected); @@ -381,8 +389,7 @@ public void testReadsForTypePromotedColumns() throws Exception { optional(102, "float_data", Types.FloatType.get()), optional(103, "decimal_data", Types.DecimalType.of(10, 5))); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(writeSchema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); try (FileAppender writer = getParquetWriter(writeSchema, dataFile)) { @@ -401,19 +408,20 @@ public void testReadsForTypePromotedColumns() throws Exception { @Test public void testSupportedReadsForParquetV2() throws Exception { - // Float and double column types are written using plain encoding with Parquet V2, - // also Parquet V2 will dictionary encode decimals that use fixed length binary - // (i.e. decimals > 8 bytes). Int and long types use DELTA_BINARY_PACKED. + // Parquet V2 uses PLAIN for float/double, DELTA_BINARY_PACKED for int/long, + // DELTA_LENGTH_BYTE_ARRAY for string/binary, and dictionary encoding for decimals + // that use fixed length binary (i.e. decimals > 8 bytes). Schema schema = new Schema( optional(102, "float_data", Types.FloatType.get()), optional(103, "double_data", Types.DoubleType.get()), optional(104, "decimal_data", Types.DecimalType.of(25, 5)), optional(105, "int_data", Types.IntegerType.get()), - optional(106, "long_data", Types.LongType.get())); + optional(106, "long_data", Types.LongType.get()), + optional(107, "string_data", Types.StringType.get()), + optional(108, "binary_data", Types.BinaryType.get())); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(schema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { @@ -426,8 +434,7 @@ public void testSupportedReadsForParquetV2() throws Exception { public void testUnsupportedReadsForParquetV2() throws Exception { // Some types use delta encoding and which are not supported for vectorized reads Schema schema = new Schema(SUPPORTED_PRIMITIVES.fields()); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(schema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { @@ -445,8 +452,7 @@ public void testUuidReads() throws Exception { int numRows = 1; Schema schema = new Schema(optional(100, "uuid", Types.UUIDType.get())); - File dataFile = File.createTempFile("junit", null, temp.toFile()); - assertThat(dataFile.delete()).as("Delete should succeed").isTrue(); + File dataFile = temp.resolve("data.parquet").toFile(); Iterable data = generateData(schema, numRows, 0L, 0, IDENTITY); try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { writer.addAll(data); @@ -490,10 +496,16 @@ static Stream goldenFilesAndEncodings() { .flatMap( e -> Stream.of(true, false) - .map( + .flatMap( vectorized -> - Arguments.of( - encoding, e.getKey(), e.getValue(), vectorized)))); + Stream.of( + Arguments.of( + encoding, e.getKey(), e.getValue(), vectorized), + Arguments.of( + encoding, + e.getKey() + "_with_nulls", + e.getValue(), + vectorized))))); } private File resourceUrlToLocalFile(URL url) throws IOException, URISyntaxException { diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java index 9b5d8f5978ba..09dfaecdf417 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java @@ -30,6 +30,7 @@ import org.apache.iceberg.Files; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.Tables; @@ -134,9 +135,9 @@ public void testAlternateLocation() throws IOException { writeRecords(table, RandomGenericData.generate(table.schema(), 100, 87112L)); - table - .currentSnapshot() - .addedDataFiles(table.io()) + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() .forEach( dataFile -> assertThat(dataFile.location()) diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java index 33c842d94be1..da9cd639218f 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java @@ -38,6 +38,7 @@ import org.apache.iceberg.data.Record; import org.apache.iceberg.hadoop.HadoopTables; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.AvroDataTestBase; import org.apache.iceberg.spark.data.GenericsHelpers; import org.apache.iceberg.types.TypeUtil; @@ -62,6 +63,7 @@ public static void startSpark() { SparkSession.builder() .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) .master("local[2]") + .config(TestBase.DISABLE_UI) .getOrCreate(); ScanTestBase.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..b6017e2001e7 --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.iceberg.Snapshot; +import org.junit.jupiter.api.Test; + +class TestAsyncSparkMicroBatchPlanner { + + @Test + void reachedAvailableNowCapReturnsTrueOnlyForExactCapSnapshot() { + Snapshot capSnapshot = mockSnapshot(10L); + Snapshot laterSnapshotWithHigherId = mockSnapshot(20L); + Snapshot laterSnapshotWithLowerId = mockSnapshot(5L); + StreamingOffset capOffset = new StreamingOffset(10L, 3L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(capSnapshot, capOffset)).isTrue(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap( + laterSnapshotWithHigherId, capOffset)) + .isFalse(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(laterSnapshotWithLowerId, capOffset)) + .isFalse(); + } + + @Test + void reachedAvailableNowCapReturnsFalseWhenCapOrSnapshotIsMissing() { + Snapshot readFrom = mockSnapshot(10L); + StreamingOffset capOffset = new StreamingOffset(10L, 1L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(readFrom, null)).isFalse(); + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(null, capOffset)).isFalse(); + } + + private Snapshot mockSnapshot(long snapshotId) { + Snapshot snapshot = mock(Snapshot.class); + when(snapshot.snapshotId()).thenReturn(snapshotId); + return snapshot; + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java index 1e53710a0f7f..8e26a2f42646 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java @@ -90,7 +90,7 @@ private static class ClosureTrackingReader extends BaseReader tracker = Maps.newHashMap(); ClosureTrackingReader(Table table, List tasks) { - super(table, new BaseCombinedScanTask(tasks), null, null, false, true); + super(table, table.io(), new BaseCombinedScanTask(tasks), null, null, false, true); } @Override diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java index b88f0233e203..be4391aab668 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java @@ -105,7 +105,8 @@ public void testInsert() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -136,7 +137,8 @@ public void testDelete() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -170,7 +172,8 @@ public void testDataFileRewrite() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } @@ -197,7 +200,8 @@ public void testMixDeleteAndInsert() throws IOException { for (ScanTaskGroup taskGroup : taskGroups) { ChangelogRowReader reader = - new ChangelogRowReader(table, taskGroup, table.schema(), table.schema(), false, true); + new ChangelogRowReader( + table, table.io(), taskGroup, table.schema(), table.schema(), false, true); while (reader.next()) { rows.add(reader.get().copy()); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java index 19ec6d13dd5d..d38182248391 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java @@ -207,7 +207,8 @@ public void testWriteDataWithDifferentSetting() throws Exception { .append(); Table table = catalog.loadTable(TableIdentifier.of("default", TABLE_NAME)); List manifestFiles = table.currentSnapshot().dataManifests(table.io()); - try (ManifestReader reader = ManifestFiles.read(manifestFiles.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifestFiles.get(0), table.io(), table.specs())) { DataFile file = reader.iterator().next(); InputFile inputFile = table.io().newInputFile(file.location()); assertThat(getCompressionType(inputFile)) diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java index a7702b169a60..9ad73baa3dc4 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java @@ -35,6 +35,7 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.SnapshotSummary; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -206,7 +207,7 @@ public void testSplitOptionsOverridesTableProperties() throws IOException { .save(tableLocation); List files = - Lists.newArrayList(icebergTable.currentSnapshot().addedDataFiles(icebergTable.io())); + Lists.newArrayList(SnapshotChanges.builderFor(icebergTable).build().addedDataFiles()); assertThat(files).as("Should have written 1 file").hasSize(1); long fileSize = files.get(0).fileSizeInBytes(); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java index 61d6501a6847..26c2b6ab70cb 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java @@ -54,6 +54,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.GenericsHelpers; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; @@ -116,6 +117,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java index c4e0d26c1c31..d7d8756a43b4 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java @@ -46,6 +46,7 @@ import org.apache.iceberg.io.OutputFile; import org.apache.iceberg.parquet.Parquet; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.types.Types; @@ -98,6 +99,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java index 63994726cafb..88113be2ddd9 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java @@ -46,10 +46,12 @@ import org.apache.iceberg.DataFile; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.Files; +import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.StructLike; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; @@ -256,7 +258,7 @@ public void testEntriesTableDataFilePrune() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List singleActual = rowsToJava( @@ -289,7 +291,7 @@ public void testEntriesTableDataFilePruneMulti() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List multiActual = rowsToJava( @@ -327,7 +329,7 @@ public void testFilesSelectMap() { .save(loadLocation(tableIdentifier)); table.refresh(); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); List multiActual = rowsToJava( @@ -634,7 +636,7 @@ public void testFilesUnpartitionedTable() throws Exception { table.refresh(); DataFile toDelete = - Iterables.getOnlyElement(table.currentSnapshot().addedDataFiles(table.io())); + Iterables.getOnlyElement(SnapshotChanges.builderFor(table).build().addedDataFiles()); // add a second file df2.select("id", "data") @@ -821,6 +823,10 @@ public void testHistoryTable() { // rollback the table state to the first snapshot table.manageSnapshots().rollbackTo(firstSnapshotId).commit(); long rollbackTimestamp = Iterables.getLast(table.history()).timestampMillis(); + assertThat(rollbackTimestamp) + .as("Rollback history timestamp should be greater than first snapshot timestamp") + .isEqualTo(((HasTableOperations) table).operations().current().lastUpdatedMillis()) + .isGreaterThan(firstSnapshotTimestamp); inputDf .select("id", "data") @@ -1129,19 +1135,16 @@ public void testPruneManifestsTable() { .mode("append") .save(loadLocation(tableIdentifier)); - if (!spark.version().startsWith("2")) { - // Spark 2 isn't able to actually push down nested struct projections so this will not break - assertThatThrownBy( - () -> - spark - .read() - .format("iceberg") - .load(loadLocation(tableIdentifier, "manifests")) - .select("partition_spec_id", "path", "partition_summaries.contains_null") - .collectAsList()) - .isInstanceOf(SparkException.class) - .hasMessageContaining("Cannot project a partial list element struct"); - } + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "manifests")) + .select("partition_spec_id", "path", "partition_summaries.contains_null") + .collectAsList()) + .isInstanceOf(SparkException.class) + .hasMessageContaining("Cannot project a partial list element struct"); Dataset actualDf = spark @@ -1318,7 +1321,7 @@ public void testUnpartitionedPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.currentSnapshot().addedDataFiles(table.io()))) + totalSizeInBytes(SnapshotChanges.builderFor(table).build().addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1387,7 +1390,11 @@ public void testPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1403,7 +1410,11 @@ public void testPartitionsTable() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(secondCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(secondCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1669,7 +1680,11 @@ public void testPartitionsTableDeleteStats() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 0L) .set("position_delete_file_count", 0) .set("equality_delete_record_count", 0L) @@ -1685,7 +1700,11 @@ public void testPartitionsTableDeleteStats() { .set("file_count", 1) .set( "total_data_file_size_in_bytes", - totalSizeInBytes(table.snapshot(firstCommitId).addedDataFiles(table.io()))) + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) .set("position_delete_record_count", 2L) // should be incremented now .set("position_delete_file_count", 2) // should be incremented now .set("equality_delete_record_count", 0L) @@ -2413,7 +2432,7 @@ private DeleteFile writePosDeleteFile(Table table) { private DeleteFile writePosDeleteFile(Table table, long pos) { DataFile dataFile = - Iterables.getFirst(table.currentSnapshot().addedDataFiles(table.io()), null); + Iterables.getFirst(SnapshotChanges.builderFor(table).build().addedDataFiles(), null); PartitionSpec dataFileSpec = table.specs().get(dataFile.specId()); StructLike dataFilePartition = dataFile.partition(); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java index f4f57157e479..a637b975fe2b 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java @@ -28,6 +28,7 @@ import java.sql.Timestamp; import java.util.List; import org.apache.iceberg.spark.IcebergSpark; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.transforms.Transforms; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Row; @@ -51,6 +52,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java index 05e1cae51666..63fef0f2e37a 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java @@ -47,6 +47,18 @@ public void testTime() { // Spark does not support time fields. } + @Disabled + @Override + public void testTimestampNanoWithoutZone() { + // Spark does not support nanosecond timestamp without zone. + } + + @Disabled + @Override + public void testTimestampNanoWithZone() { + // Spark does not support nanosecond timestamp with zone. + } + @Override protected void generateAndValidate(Schema schema, AssertMethod assertMethod) { int numRecords = 100; diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java index c21ccd0100db..f74446b2f416 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java @@ -33,6 +33,7 @@ import org.apache.iceberg.Parameters; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; @@ -200,7 +201,8 @@ private GenericRecord createNestedRecord(Long longCol, Double doubleCol) { @TestTemplate public void testPrimitiveColumns() throws Exception { Table table = createPrimitiveTable(); - DataFile dataFile = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile dataFile = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); Map columnSizeStats = dataFile.columnSizes(); Object[] binaryCol = diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java new file mode 100644 index 000000000000..a9ce340fd4ec --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMicroBatchPlanningUtils extends CatalogTestBase { + + private Table table; + + @BeforeEach + public void setupTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql( + "CREATE TABLE %s " + + "(id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(3, id))", + tableName); + this.table = validationCatalog.loadTable(tableIdent); + } + + @AfterEach + public void dropTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testUnpackedLimitsCompositeChoosesMinimum() { + ReadLimit[] limits = + new ReadLimit[] { + ReadLimit.maxRows(10), ReadLimit.maxRows(4), ReadLimit.maxFiles(8), ReadLimit.maxFiles(2) + }; + + ReadLimit composite = ReadLimit.compositeLimit(limits); + + BaseSparkMicroBatchPlanner.UnpackedLimits unpacked = + new BaseSparkMicroBatchPlanner.UnpackedLimits(composite); + + assertThat(unpacked.getMaxRows()).isEqualTo(4); + assertThat(unpacked.getMaxFiles()).isEqualTo(2); + } + + @TestTemplate + public void testDetermineStartingOffsetWithTimestampBetweenSnapshots() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + long snapshot1Time = table.currentSnapshot().timestampMillis(); + + sql("INSERT INTO %s VALUES (2, 'two')", tableName); + table.refresh(); + long snapshot2Id = table.currentSnapshot().snapshotId(); + + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, snapshot1Time + 1); + + assertThat(offset.snapshotId()).isEqualTo(snapshot2Id); + assertThat(offset.position()).isEqualTo(0L); + assertThat(offset.shouldScanAllFiles()).isFalse(); + } + + @TestTemplate + public void testAddedFilesCountUsesSummaryWhenPresent() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + + long expectedAddedFiles = + Long.parseLong(table.currentSnapshot().summary().get(SnapshotSummary.ADDED_FILES_PROP)); + + long actual = MicroBatchUtils.addedFilesCount(table, table.currentSnapshot()); + + assertThat(actual).isEqualTo(expectedAddedFiles); + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java index b31880e8e958..8098db81f999 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java @@ -60,6 +60,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.transforms.Transforms; import org.apache.iceberg.types.Types; import org.apache.spark.api.java.JavaRDD; @@ -119,6 +120,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestPartitionPruning.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); @@ -242,14 +244,7 @@ public void testPartitionPruningTruncatedStringComparingValueShorterThanPartitio @TestTemplate public void testPartitionPruningHourlyPartition() { - String filterCond; - if (spark.version().startsWith("2")) { - // Looks like from Spark 2 we need to compare timestamp with timestamp to push down the - // filter. - filterCond = "timestamp >= to_timestamp('2020-02-03T01:00:00')"; - } else { - filterCond = "timestamp >= '2020-02-03T01:00:00'"; - } + String filterCond = "timestamp >= '2020-02-03T01:00:00'"; Predicate partCondition = (Row r) -> { int hourValue = r.getInt(4); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java index 0b6ab2052b66..9b5b22a73f36 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java @@ -46,6 +46,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.TestHelpers; import org.apache.iceberg.types.Types; @@ -112,6 +113,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java index c0db51ec8c7e..a84c2516ddc1 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java @@ -180,6 +180,7 @@ public void readPositionDeletesTableWithMultipleDeleteFiles() throws IOException try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), positionDeletesTable.schema(), projectedSchema, @@ -220,6 +221,7 @@ public void readPositionDeletesTableWithMultipleDeleteFiles() throws IOException try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask2)), positionDeletesTable.schema(), projectedSchema, @@ -292,6 +294,7 @@ public void readPositionDeletesTableWithDifferentColumnOrdering() throws IOExcep try (PositionDeletesRowReader reader = new PositionDeletesRowReader( table, + table.io(), new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), positionDeletesTable.schema(), projectedSchema, diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java index 7892fd65b405..f9a2fea30cf0 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java @@ -209,6 +209,60 @@ public void testPartitionedTable() throws IOException { dropTable(tableName); } + @TestTemplate + public void testArrayColumnFilter() throws IOException { + assumeThat(formatVersion) + .as("Row content in position_deletes is required for array column filter test") + .isEqualTo(2); + String tableName = "array_column_filter"; + Schema schemaWithArray = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional( + 3, "arr_col", Types.ListType.ofOptional(4, Types.IntegerType.get()))); + Table tab = createTable(tableName, schemaWithArray, PartitionSpec.unpartitioned()); + + GenericRecord record1 = GenericRecord.create(schemaWithArray); + record1.set(0, 1); + record1.set(1, "a"); + record1.set(2, ImmutableList.of(1, 2)); + GenericRecord record2 = GenericRecord.create(schemaWithArray); + record2.set(0, 2); + record2.set(1, "b"); + record2.set(2, ImmutableList.of(3, 4)); + List dataRecords = ImmutableList.of(record1, record2); + DataFile dFile = + FileHelpers.writeDataFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + dataRecords); + tab.newAppend().appendFile(dFile).commit(); + + List> deletes = + ImmutableList.of( + positionDelete(schemaWithArray, dFile.location(), 0L, 1, "a", ImmutableList.of(1, 2)), + positionDelete(schemaWithArray, dFile.location(), 1L, 2, "b", ImmutableList.of(3, 4))); + DeleteFile posDeletes = + FileHelpers.writePosDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + deletes, + formatVersion); + tab.newRowDelta().addDeletes(posDeletes).commit(); + + // Filter directly on array column: row.arr_col = array(1, 2) + StructLikeSet actual = actual(tableName, tab, "row.arr_col = array(1, 2)"); + StructLikeSet expected = expected(tab, ImmutableList.of(deletes.get(0)), null, posDeletes); + + assertThat(actual) + .as("Filtering position_deletes by row.arr_col = array(1, 2) should return matching row") + .isEqualTo(expected); + dropTable(tableName); + } + @TestTemplate public void testSelect() throws IOException { assumeThat(formatVersion).as("DVs don't have row info in PositionDeletesTable").isEqualTo(2); @@ -409,7 +463,7 @@ public void testPartitionFilter() throws IOException { // Add position deletes for both partitions Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); - Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileA, "b"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); @@ -455,7 +509,7 @@ public void testPartitionTransformFilter() throws IOException { Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, new Object[] {"aa"}, new Object[] {"a"}); Pair>, DeleteFile> deletesB = - deleteFile(tab, dataFileA, new Object[] {"bb"}, new Object[] {"b"}); + deleteFile(tab, dataFileB, new Object[] {"bb"}, new Object[] {"b"}); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); // Prepare expected values @@ -496,7 +550,7 @@ public void testPartitionEvolutionReplace() throws Exception { DataFile dataFileB = dataFile(tab, "b"); tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); - Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileA, "b"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); // Switch partition spec from (data) to (id) @@ -508,7 +562,7 @@ public void testPartitionEvolutionReplace() throws Exception { tab.newAppend().appendFile(dataFile10).appendFile(dataFile99).commit(); Pair>, DeleteFile> deletes10 = deleteFile(tab, dataFile10, 10); - Pair>, DeleteFile> deletes99 = deleteFile(tab, dataFile10, 99); + Pair>, DeleteFile> deletes99 = deleteFile(tab, dataFile99, 99); tab.newRowDelta().addDeletes(deletes10.second()).addDeletes(deletes99.second()).commit(); // Query partition of old spec diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java new file mode 100644 index 000000000000..e025114f4fd9 --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.apache.iceberg.io.FileIO; +import org.junit.jupiter.api.Test; + +class TestSerializableFileIOWithSize { + + @Test + void newInputFileWithLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + long length = 1024L; + + serializableFileIO.newInputFile(path, length); + + verify(mockFileIO).newInputFile(path, length); + } + + @Test + void newInputFileWithoutLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + + serializableFileIO.newInputFile(path); + + verify(mockFileIO).newInputFile(path); + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java index 11865db7fce5..fe754f4a02ba 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java @@ -42,6 +42,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; @@ -91,6 +92,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java index 35a837b6b88b..d719ca6751a0 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java @@ -60,6 +60,7 @@ import org.apache.iceberg.spark.SparkDataFile; import org.apache.iceberg.spark.SparkDeleteFile; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.types.Conversions; import org.apache.iceberg.types.Types; @@ -126,6 +127,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestSparkDataFile.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); } @@ -190,7 +192,8 @@ private void checkSparkContentFiles(Table table) throws IOException { assertThat(manifests).hasSize(1); List dataFiles = Lists.newArrayList(); - try (ManifestReader reader = ManifestFiles.read(manifests.get(0), table.io())) { + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { for (DataFile dataFile : reader) { checkDataFile(dataFile.copy(), DataFiles.builder(dataFilesSpec).copy(dataFile).build()); dataFiles.add(dataFile.copy()); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java index 4ccbf86f1257..70f3b986d23b 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java @@ -22,12 +22,12 @@ import static org.apache.iceberg.types.Types.NestedField.optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assumptions.assumeThat; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.io.File; +import java.io.IOException; import java.net.InetAddress; import java.nio.file.Path; import java.util.List; @@ -36,6 +36,7 @@ import org.apache.iceberg.AppendFiles; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.ManifestFile; import org.apache.iceberg.ManifestFiles; import org.apache.iceberg.Parameter; @@ -44,14 +45,17 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.TableProperties; import org.apache.iceberg.exceptions.CommitStateUnknownException; import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.SnapshotUtil; import org.apache.spark.sql.Dataset; @@ -87,7 +91,7 @@ public class TestSparkDataWrite { public static Object[][] parameters() { return new Object[][] { new Object[] {FileFormat.PARQUET, null}, - new Object[] {FileFormat.PARQUET, "main"}, + new Object[] {FileFormat.PARQUET, SnapshotRef.MAIN_BRANCH}, new Object[] {FileFormat.PARQUET, "testBranch"}, new Object[] {FileFormat.AVRO, null}, new Object[] {FileFormat.ORC, "testBranch"} @@ -100,6 +104,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); } @@ -148,12 +153,13 @@ public void testBasicWrite() { assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { // TODO: avro not support split if (!format.equals(FileFormat.AVRO)) { assertThat(file.splitOffsets()).as("Split offsets not present").isNotNull(); } assertThat(file.recordCount()).as("Should have reported record count as 1").isEqualTo(1); + assertThat(file.sortOrderId()).isEqualTo(SortOrder.unsorted().orderId()); // TODO: append more metric info if (format.equals(FileFormat.PARQUET)) { assertThat(file.columnSizes()).as("Column sizes metric not present").isNotNull(); @@ -397,7 +403,7 @@ public void testUnpartitionedCreateWithTargetFileSizeViaTableProperties() { List files = Lists.newArrayList(); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { files.add(file); } } @@ -427,100 +433,127 @@ public void testPartitionedFanoutCreateWithTargetFileSizeViaOption2() { } @TestTemplate - public void testWriteProjection() { - assumeThat(spark.version()) - .as("Not supported in Spark 3; analysis requires all columns are present") - .startsWith("2"); - + public void testViewsReturnRecentResults() { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); - PartitionSpec spec = PartitionSpec.unpartitioned(); - Table table = tables.create(SCHEMA, spec, location.toString()); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + tables.create(SCHEMA, spec, location.toString()); - List expected = + List records = Lists.newArrayList( - new SimpleRecord(1, null), new SimpleRecord(2, null), new SimpleRecord(3, null)); + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); - Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); - df.select("id") - .write() // select only id column + df.select("id", "data") + .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) .save(location.toString()); + Table table = tables.load(location.toString()); createBranch(table); - table.refresh(); - Dataset result = spark.read().format("iceberg").load(targetLocation); + Dataset query = spark.read().format("iceberg").load(targetLocation).where("id = 1"); + query.createOrReplaceTempView("tmp"); + + List actual1 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected1 = Lists.newArrayList(new SimpleRecord(1, "a")); + assertThat(actual1).hasSameSizeAs(expected1).isEqualTo(expected1); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(targetLocation); + + List actual2 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected2 = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "a")); + assertThat(actual2).hasSameSizeAs(expected2).isEqualTo(expected2); + } + + @TestTemplate + public void testWriteDataFilesInTableSortOrder() throws IOException { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + List expected = Lists.newArrayListWithCapacity(10); + for (int i = 0; i < 10; i++) { + expected.add(new SimpleRecord(i, "a")); + } + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + Dataset result = spark.read().format("iceberg").load(location.toString()); List actual = result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + assertThat(fileScanTasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } } @TestTemplate - public void testWriteProjectionWithMiddle() { - assumeThat(spark.version()) - .as("Not supported in Spark 3; analysis requires all columns are present") - .startsWith("2"); - + public void testWriteDataFilesUnsortedTable() throws IOException { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); - String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); PartitionSpec spec = PartitionSpec.unpartitioned(); - Schema schema = - new Schema( - optional(1, "c1", Types.IntegerType.get()), - optional(2, "c2", Types.StringType.get()), - optional(3, "c3", Types.StringType.get())); - Table table = tables.create(schema, spec, location.toString()); - - List expected = - Lists.newArrayList( - new ThreeColumnRecord(1, null, "hello"), - new ThreeColumnRecord(2, null, "world"), - new ThreeColumnRecord(3, null, null)); + Table table = tables.create(SCHEMA, spec, location.toString()); - Dataset df = spark.createDataFrame(expected, ThreeColumnRecord.class); + List expected = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); - df.select("c1", "c3") + df.select("id", "data") .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) .save(location.toString()); - createBranch(table); - table.refresh(); - - Dataset result = spark.read().format("iceberg").load(targetLocation); - - List actual = - result.orderBy("c1").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); - assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles should have unsorted sort order id") + .containsOnly(SortOrder.unsorted().orderId()); + } } @TestTemplate - public void testViewsReturnRecentResults() { + public void testWriteDataFilesAfterSortOrderChange() throws IOException { File parent = temp.resolve(format.toString()).toFile(); File location = new File(parent, "test"); - String targetLocation = locationWithBranch(location); HadoopTables tables = new HadoopTables(CONF); - PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); - tables.create(SCHEMA, spec, location.toString()); - - List records = - Lists.newArrayList( - new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + List records = Lists.newArrayList(new SimpleRecord(1, "a")); Dataset df = spark.createDataFrame(records, SimpleRecord.class); df.select("id", "data") @@ -530,29 +563,31 @@ public void testViewsReturnRecentResults() { .mode(SaveMode.Append) .save(location.toString()); - Table table = tables.load(location.toString()); - createBranch(table); + table.refresh(); + int unsortedId = SortOrder.unsorted().orderId(); - Dataset query = spark.read().format("iceberg").load(targetLocation).where("id = 1"); - query.createOrReplaceTempView("tmp"); + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks).extracting(task -> task.file().sortOrderId()).containsOnly(unsortedId); + } - List actual1 = - spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); - List expected1 = Lists.newArrayList(new SimpleRecord(1, "a")); - assertThat(actual1).hasSameSizeAs(expected1).isEqualTo(expected1); + table.replaceSortOrder().asc("id").commit(); + int sortedId = table.sortOrder().orderId(); df.select("id", "data") .write() .format("iceberg") .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) .mode(SaveMode.Append) - .save(targetLocation); + .save(location.toString()); - List actual2 = - spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); - List expected2 = - Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "a")); - assertThat(actual2).hasSameSizeAs(expected2).isEqualTo(expected2); + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("Should contain both unsorted and sorted files") + .containsOnly(unsortedId, sortedId); + } } public void partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType option) { @@ -624,7 +659,7 @@ public void partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType opti List files = Lists.newArrayList(); for (ManifestFile manifest : SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { - for (DataFile file : ManifestFiles.read(manifest, table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { files.add(file); } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java index 8ccea303d0c1..de6a5e59029c 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java @@ -50,6 +50,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkReadOptions; import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Type; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; @@ -88,6 +89,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); ImmutableMap config = ImmutableMap.of( diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java index c7716d343024..2fb6933ce933 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java @@ -73,9 +73,9 @@ import org.apache.iceberg.relocated.com.google.common.collect.Sets; import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; import org.apache.iceberg.spark.ParquetBatchReadConf; -import org.apache.iceberg.spark.ParquetReaderType; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.spark.SparkStructLike; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.spark.data.SparkParquetWriters; import org.apache.iceberg.spark.source.metrics.NumDeletes; @@ -138,6 +138,7 @@ public static void startMetastoreAndSpark() { .config("spark.ui.liveUpdate.period", 0) .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); @@ -330,7 +331,8 @@ public void testReadEqualityDeleteRows() throws IOException { for (CombinedScanTask task : tasks) { try (EqualityDeleteRowReader reader = - new EqualityDeleteRowReader(task, table, null, table.schema(), false, true)) { + new EqualityDeleteRowReader( + task, table, table.io(), table.schema(), table.schema(), false, true)) { while (reader.next()) { actualRowSet.add( new InternalRowWrapper( @@ -669,17 +671,14 @@ public void testEqualityDeleteWithDifferentScanAndDeleteColumns() throws IOExcep TableProperties.SPLIT_LOOKBACK_DEFAULT, TableProperties.SPLIT_OPEN_FILE_COST_DEFAULT); - ParquetBatchReadConf conf = - ImmutableParquetBatchReadConf.builder() - .batchSize(7) - .readerType(ParquetReaderType.ICEBERG) - .build(); + ParquetBatchReadConf conf = ImmutableParquetBatchReadConf.builder().batchSize(7).build(); for (CombinedScanTask task : tasks) { try (BatchDataReader reader = new BatchDataReader( // expected column is id, while the equality filter column is dt dateTable, + dateTable.io(), task, dateTable.schema(), dateTable.schema().select("id"), diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java index d22ecb02d483..cb2f866fab10 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java @@ -64,6 +64,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.PropertyUtil; import org.apache.spark.sql.Dataset; @@ -182,6 +183,7 @@ public static void startMetastoreAndSpark() { SparkSession.builder() .master("local[2]") .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java index 1ddf9318f608..417a84d82769 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java @@ -461,8 +461,7 @@ public void testUnpartitionedYears() throws Exception { pushFilters(builder, predicate); scan = builder.build().toBatch(); - // notEq can't be answered using column bounds because they are not exact - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); } @TestTemplate @@ -771,7 +770,7 @@ public void testUnpartitionedTruncateString() throws Exception { pushFilters(builder, predicate); Batch scan = builder.build().toBatch(); - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); // NOT NotEqual builder = scanBuilder(); @@ -990,7 +989,7 @@ public void testUnpartitionedOr() throws Exception { pushFilters(builder, predicate); scan = builder.build().toBatch(); - assertThat(scan.planInputPartitions()).hasSize(10); + assertThat(scan.planInputPartitions()).hasSize(5); } @TestTemplate diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java index d14b1a52cf82..e3934faa60ce 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java @@ -20,7 +20,9 @@ import static org.assertj.core.api.Assertions.assertThat; +import org.apache.iceberg.HistoryEntry; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; import org.apache.iceberg.spark.CatalogTestBase; import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; import org.apache.spark.sql.connector.catalog.CatalogManager; @@ -56,4 +58,58 @@ public void testTableEquality() throws NoSuchTableException { assertThat(table1).as("References must be different").isNotSameAs(table2); assertThat(table1).as("Tables must be equivalent").isEqualTo(table2); } + + @TestTemplate + public void testTableInequalityWithDifferentSnapshots() throws NoSuchTableException { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + SparkTable table = (SparkTable) catalog.loadTable(identifier); + + Table icebergTable = validationCatalog.loadTable(tableIdent); + long[] snapshotIds = + icebergTable.history().stream().mapToLong(HistoryEntry::snapshotId).toArray(); + + SparkTable tableAtSnapshot1 = table.copyWithSnapshotId(snapshotIds[0]); + SparkTable tableAtSnapshot2 = table.copyWithSnapshotId(snapshotIds[1]); + + assertThat(tableAtSnapshot1) + .as("Tables at different snapshots must not be equal") + .isNotEqualTo(tableAtSnapshot2); + assertThat(tableAtSnapshot1.hashCode()) + .as("Hash codes should differ for different snapshots") + .isNotEqualTo(tableAtSnapshot2.hashCode()); + } + + @TestTemplate + public void testTableInequalityWithDifferentBranches() throws NoSuchTableException { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + + Table icebergTable = validationCatalog.loadTable(tableIdent); + icebergTable + .manageSnapshots() + .createBranch("testBranch", icebergTable.currentSnapshot().snapshotId()) + .commit(); + + // reload after branch creation so the table sees the new ref + SparkTable table = (SparkTable) catalog.loadTable(identifier); + table.table().refresh(); + + SparkTable tableOnMain = table.copyWithBranch("main"); + SparkTable tableOnBranch = table.copyWithBranch("testBranch"); + + assertThat(tableOnMain) + .as("Tables on different branches must not be equal") + .isNotEqualTo(tableOnBranch); + assertThat(tableOnMain.hashCode()) + .as("Hash codes should differ for different branches") + .isNotEqualTo(tableOnBranch.hashCode()); + } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriteMetrics.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriteMetrics.java new file mode 100644 index 000000000000..661aac1372ec --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriteMetrics.java @@ -0,0 +1,223 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.spark.source.metrics.AddedDataFiles; +import org.apache.iceberg.spark.source.metrics.AddedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.AddedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.AddedRecords; +import org.apache.iceberg.spark.source.metrics.RemovedDataFiles; +import org.apache.iceberg.spark.source.metrics.RemovedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedRecords; +import org.apache.iceberg.spark.source.metrics.TotalDataFiles; +import org.apache.iceberg.spark.source.metrics.TotalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TotalEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.TotalFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.TotalPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.TotalRecords; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.execution.metric.SQLMetric; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import scala.jdk.javaapi.CollectionConverters; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkWriteMetrics extends TestBaseWithCatalog { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void writeMetrics() { + sql("CREATE TABLE %s (id BIGINT) USING iceberg", tableName); + + String insertSql = String.format("INSERT INTO %s SELECT id FROM range(1000)", tableName); + Dataset result = spark.sql(insertSql); + result.collect(); + + SparkPlan plan = result.queryExecution().executedPlan(); + Map metricsMap = CollectionConverters.asJava(plan.metrics()); + + // If we are at the root, check if we have the metrics. + // Sometimes the plan structure is complex (e.g. AdaptiveSparkPlanExec). + // We might want to find the specific write node. + + if (!metricsMap.containsKey(AddedDataFiles.NAME)) { + // Attempt to find a node with these metrics + metricsMap = findMetrics(plan, AddedDataFiles.NAME); + } + + assertThat(metricsMap).isNotNull(); + assertThat(metricsMap) + .hasEntrySatisfying(AddedDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedRecords.NAME, metric -> assertThat(metric.value()).isEqualTo(1000)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + assertThat(metricsMap) + .hasEntrySatisfying(TotalDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalRecords.NAME, metric -> assertThat(metric.value()).isEqualTo(1000)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + + // Verify other metrics are 0 + String[] zeroMetrics = { + AddedDeleteFiles.NAME, + AddedEqualityDeleteFiles.NAME, + AddedPositionalDeleteFiles.NAME, + AddedEqualityDeletes.NAME, + AddedPositionalDeletes.NAME, + RemovedDataFiles.NAME, + RemovedDeleteFiles.NAME, + RemovedEqualityDeleteFiles.NAME, + RemovedPositionalDeleteFiles.NAME, + RemovedEqualityDeletes.NAME, + RemovedPositionalDeletes.NAME, + RemovedRecords.NAME, + RemovedFileSizeInBytes.NAME, + TotalDeleteFiles.NAME, + TotalEqualityDeletes.NAME, + TotalPositionalDeletes.NAME + }; + + for (String metric : zeroMetrics) { + assertThat(metricsMap) + .hasEntrySatisfying(metric, m -> assertThat(m.value()).as(metric).isEqualTo(0)); + } + } + + @TestTemplate + public void deleteMetrics() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT) USING iceberg TBLPROPERTIES ('write.delete.mode'='merge-on-read')", + tableName); + + spark.range(100).coalesce(1).writeTo(tableName).append(); + + String deleteSql = String.format("DELETE FROM %s WHERE id = 1", tableName); + Dataset result = spark.sql(deleteSql); + result.collect(); + + SparkPlan plan = result.queryExecution().executedPlan(); + + Map metricsMap = findMetrics(plan, AddedPositionalDeleteFiles.NAME); + + assertThat(metricsMap).isNotNull(); + + assertThat(metricsMap) + .hasEntrySatisfying( + AddedPositionalDeleteFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + RemovedDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedPositionalDeletes.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalDeleteFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalPositionalDeletes.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedDeleteFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + assertThat(metricsMap) + .hasEntrySatisfying(TotalDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying(TotalRecords.NAME, metric -> assertThat(metric.value()).isEqualTo(100)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + + // Verify other metrics are 0 + String[] zeroMetrics = { + AddedDataFiles.NAME, + AddedEqualityDeleteFiles.NAME, + AddedEqualityDeletes.NAME, + AddedRecords.NAME, + RemovedDeleteFiles.NAME, + RemovedEqualityDeleteFiles.NAME, + RemovedPositionalDeleteFiles.NAME, + RemovedEqualityDeletes.NAME, + RemovedPositionalDeletes.NAME, + RemovedRecords.NAME, + RemovedFileSizeInBytes.NAME, + TotalEqualityDeletes.NAME + }; + + for (String metric : zeroMetrics) { + assertThat(metricsMap) + .hasEntrySatisfying(metric, m -> assertThat(m.value()).as(metric).isEqualTo(0)); + } + } + + private Map findMetrics(SparkPlan plan, String metricName) { + Map metrics = CollectionConverters.asJava(plan.metrics()); + if (metrics.containsKey(metricName)) { + return metrics; + } + + for (SparkPlan child : CollectionConverters.asJava(plan.children())) { + Map result = findMetrics(child, metricName); + if (result != null) { + return result; + } + } + + for (Object child : CollectionConverters.asJava(plan.innerChildren())) { + if (child instanceof SparkPlan) { + Map result = findMetrics((SparkPlan) child, metricName); + if (result != null) { + return result; + } + } + } + + return null; + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java new file mode 100644 index 000000000000..6428689aaa19 --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.streaming.StreamingQuery; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests to verify that streaming checkpoints always use HadoopFileIO and never use the table's + * FileIO implementation. + */ +@ExtendWith(ParameterizedTestExtension.class) +public class TestStreamingCheckpointHadoopIO extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + "testtableio", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hadoop", + CatalogProperties.FILE_IO_IMPL, + TrackingFileIO.class.getName(), + "cache-enabled", + "false") + } + }; + } + + @AfterEach + public void stopStreams() throws TimeoutException { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + TrackingFileIO.reset(); + } + + @TestTemplate + public void testCheckpointsUseHadoopIONotTableIO() throws Exception { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b')", tableName); + + // Use nested checkpoint path to verify parent directory creation + File checkpointDir = new File(temp.toFile(), "nested/checkpoint"); + TrackingFileIO.reset(); + + // Run streaming query with checkpoints + StreamingQuery query = + spark + .readStream() + .format("iceberg") + .load(tableName) + .writeStream() + .format("console") + .option("checkpointLocation", checkpointDir.getAbsolutePath()) + .start(); + + query.processAllAvailable(); + query.stop(); + + // Verify TrackingFileIO (table's FileIO) was NOT used for checkpoint operations + assertThat(TrackingFileIO.wasUsed()) + .as("HadoopFileIO should be used for checkpoints, not table's FileIO") + .isFalse(); + + // Verify checkpoint files were actually created using HadoopFileIO + assertThat(new File(checkpointDir, "offsets/0")).exists().isFile(); + } + + /** + * A FileIO that tracks whether it was used for checkpoint operations. This allows us to verify + * that the table's FileIO is NOT being used for checkpoints. + */ + public static class TrackingFileIO implements FileIO { + private static final String CHECKPOINT_OFFSETS_PATH = "/offsets/"; + private static final AtomicBoolean USED = new AtomicBoolean(false); + private FileIO delegate; + + public static void reset() { + USED.set(false); + } + + public static boolean wasUsed() { + return USED.get(); + } + + @Override + public InputFile newInputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newInputFile(path); + } + + @Override + public OutputFile newOutputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + delegate.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + this.delegate = new HadoopFileIO(); + this.delegate.initialize(properties); + } + + @Override + public void close() { + if (delegate != null) { + delegate.close(); + } + } + } +} diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java index 54048bbf218a..ab760010535b 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java @@ -29,11 +29,16 @@ import java.nio.file.Paths; import java.util.List; import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; import org.apache.iceberg.Table; import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoder; @@ -69,6 +74,7 @@ public static void startSpark() { .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) .config("spark.sql.shuffle.partitions", 4) + .config(TestBase.DISABLE_UI) .getOrCreate(); } @@ -263,6 +269,50 @@ public void testStreamingWriteCompleteModeWithProjection() throws Exception { } } + @Test + public void testStreamingWriteDataFilesInTableSortOrder() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + MemoryStream inputStream = newMemoryStream(1, spark.sqlContext(), Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .selectExpr("value AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("append") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + query.processAllAvailable(); + query.stop(); + + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + @Test public void testStreamingWriteUpdateMode() throws Exception { File parent = temp.resolve("parquet").toFile(); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java index 1a726c365ace..5f9b460f3707 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java @@ -31,16 +31,21 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.IntStream; import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.DataFile; import org.apache.iceberg.DataFiles; import org.apache.iceberg.DataOperations; import org.apache.iceberg.DeleteFile; import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; import org.apache.iceberg.Files; +import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; import org.apache.iceberg.RewriteFiles; import org.apache.iceberg.Schema; import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.TableMetadata; import org.apache.iceberg.TableOperations; @@ -49,15 +54,22 @@ import org.apache.iceberg.data.GenericRecord; import org.apache.iceberg.data.Record; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.relocated.com.google.common.collect.Iterables; import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadConf; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.api.java.function.VoidFunction2; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Encoders; import org.apache.spark.sql.Row; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.streaming.Offset; import org.apache.spark.sql.internal.SQLConf; import org.apache.spark.sql.streaming.DataStreamWriter; import org.apache.spark.sql.streaming.OutputMode; @@ -72,10 +84,73 @@ @ExtendWith(ParameterizedTestExtension.class) public final class TestStructuredStreamingRead3 extends CatalogTestBase { + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, async = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + false + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + true + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + false + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + true + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + false + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + true + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + false + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + true + } + }; + } + private Table table; private final AtomicInteger microBatches = new AtomicInteger(); + @Parameter(index = 3) + private Boolean async; + /** * test data to be used by multiple writes each write creates a snapshot and writes a list of * records @@ -196,8 +271,7 @@ public void testReadStreamWithMaxRows1() throws Exception { Trigger.AvailableNow()); // soft limit of 1 is being enforced, the stream is not blocked. - StreamingQuery query = - startStream(ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "1")); + StreamingQuery query = startStream(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "1"); // check answer correctness only 1 record read the micro-batch will be stuck List actual = rowsAvailable(query); @@ -257,15 +331,41 @@ public void testReadStreamWithCompositeReadLimit() throws Exception { Trigger.AvailableNow()); } + @TestTemplate + public void testReadStreamWithLowAsyncQueuePreload() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + // Set low preload limits to test async queue behavior - background thread should load + // remaining data + + StreamingQuery query = + startStream( + ImmutableMap.of( + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "5", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "5")); + + List actual = rowsAvailable(query); + assertThat(actual) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(TEST_DATA_MULTIPLE_SNAPSHOTS)); + } + @TestTemplate public void testAvailableNowStreamReadShouldNotHangOrReprocessData() throws Exception { File writerCheckpointFolder = temp.resolve("writer-checkpoint-folder").toFile(); File writerCheckpoint = new File(writerCheckpointFolder, "writer-checkpoint"); File output = temp.resolve("junit").toFile(); + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + DataStreamWriter querySource = spark .readStream() + .options(options) .format("iceberg") .load(tableName) .writeStream() @@ -320,10 +420,17 @@ public void testTriggerAvailableNowDoesNotProcessNewDataWhileRunning() throws Ex long expectedSnapshotId = table.currentSnapshot().snapshotId(); String sinkTable = "availablenow_sink"; + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + StreamingQuery query = spark .readStream() - .option(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1") + .options(options) .format("iceberg") .load(tableName) .writeStream() @@ -365,6 +472,142 @@ public void testTriggerAvailableNowDoesNotProcessNewDataWhileRunning() throws Ex assertThat(actualResults).containsExactlyInAnyOrderElementsOf(Iterables.concat(expectedData)); } + @TestTemplate + public void testTriggerAvailableNowCapsAsyncPreloadAfterPrepare() { + List> initialData = + List.of(List.of(new SimpleRecord(1, "one")), List.of(new SimpleRecord(2, "two"))); + appendDataAsMultipleSnapshots(initialData); + + table.refresh(); + long expectedCapSnapshotId = table.currentSnapshot().snapshotId(); + + SparkMicroBatchStream stream = + new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf( + spark, + table, + ImmutableMap.of( + SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, + async.toString(), + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, + "1", + SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, + "1", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "10", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "10")), + table.schema(), + temp.resolve("available-now-cap-checkpoint").toString()); + + try { + stream.prepareForTriggerAvailableNow(); + + appendData(List.of(new SimpleRecord(3, "three"))); + + Offset startOffset = stream.initialOffset(); + Offset firstEndOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + assertThat(firstEndOffset).isNotNull(); + stream.planInputPartitions(startOffset, firstEndOffset); + + Offset secondEndOffset = stream.latestOffset(firstEndOffset, stream.getDefaultReadLimit()); + assertThat(secondEndOffset).isNotNull(); + stream.planInputPartitions(firstEndOffset, secondEndOffset); + + assertThat(stream.latestOffset(secondEndOffset, stream.getDefaultReadLimit())).isNull(); + assertThat(((StreamingOffset) secondEndOffset).snapshotId()).isEqualTo(expectedCapSnapshotId); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testLatestOffsetReturnsNullAfterFinalBatchIsConsumed() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + table.refresh(); + int expectedBatchCount; + try (CloseableIterable tasks = table.newScan().planFiles()) { + expectedBatchCount = Iterables.size(tasks); + } + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "drain-to-null-checkpoint"); + + try { + int plannedBatchCount = 0; + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + InputPartition[] partitions = stream.planInputPartitions(startOffset, endOffset); + assertThat(partitions).isNotEmpty(); + plannedBatchCount += 1; + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + assertThat(plannedBatchCount).isEqualTo(expectedBatchCount); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testPlanInputPartitionsIsIdempotentForSameOffsets() { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "idempotent-plan-files-checkpoint"); + + try { + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + + assertThat(endOffset).isNotNull(); + + InputPartition[] firstPartitions = stream.planInputPartitions(startOffset, endOffset); + InputPartition[] secondPartitions = stream.planInputPartitions(startOffset, endOffset); + + List firstFileLocations = Lists.newArrayList(); + for (InputPartition partition : firstPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + firstFileLocations.add(task.file().location()); + } + } + + List secondFileLocations = Lists.newArrayList(); + for (InputPartition partition : secondPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + secondFileLocations.add(task.file().location()); + } + } + + assertThat(firstFileLocations).containsExactlyInAnyOrderElementsOf(secondFileLocations); + + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + assertThat(stream.planInputPartitions(startOffset, endOffset)).isNotEmpty(); + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + } finally { + stream.stop(); + } + } + @TestTemplate public void testReadStreamOnIcebergThenAddData() throws Exception { List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; @@ -432,6 +675,8 @@ public void testReadingStreamFromFutureTimetsamp() throws Exception { // Data appended after the timestamp should appear appendData(data); + // Allow async background thread to refresh, else test sometimes fails + Thread.sleep(50); actual = rowsAvailable(query); assertThat(actual).containsExactlyInAnyOrderElementsOf(data); } @@ -846,7 +1091,8 @@ public void makeRewriteDataFiles() { Iterable it = table.snapshots(); for (Snapshot snapshot : it) { if (snapshot.operation().equals(DataOperations.APPEND)) { - Iterable datafiles = snapshot.addedDataFiles(table.io()); + Iterable datafiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(); for (DataFile datafile : datafiles) { rewrite.addFile(datafile); rewrite.deleteFile(datafile); @@ -883,13 +1129,18 @@ private void appendData(List data, String format) { private static final String MEMORY_TABLE = "_stream_view_mem"; private StreamingQuery startStream(Map options) throws TimeoutException { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } return spark .readStream() - .options(options) + .options(allOptions) .format("iceberg") .load(tableName) .writeStream() - .options(options) + .options(allOptions) .format("memory") .queryName(MEMORY_TABLE) .outputMode(OutputMode.Append()) @@ -914,11 +1165,17 @@ private void assertMicroBatchRecordSizes( private void assertMicroBatchRecordSizes( Map options, List expectedMicroBatchRecordSize, Trigger trigger) throws TimeoutException { - Dataset ds = spark.readStream().options(options).format("iceberg").load(tableName); + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + Dataset ds = spark.readStream().options(allOptions).format("iceberg").load(tableName); List syncList = Collections.synchronizedList(Lists.newArrayList()); ds.writeStream() - .options(options) + .options(allOptions) .trigger(trigger) .foreachBatch( (VoidFunction2, Long>) @@ -939,4 +1196,21 @@ private List rowsAvailable(StreamingQuery query) { .as(Encoders.bean(SimpleRecord.class)) .collectAsList(); } + + private SparkMicroBatchStream newMicroBatchStream( + Map options, String checkpointDirName) { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + return new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf(spark, table, allOptions), + table.schema(), + temp.resolve(checkpointDirName).toString()); + } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java index e2b5d8920e9f..ab2479d61058 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java @@ -42,6 +42,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.Lists; import org.apache.iceberg.relocated.com.google.common.collect.Maps; import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; import org.apache.iceberg.spark.data.RandomData; import org.apache.iceberg.types.Types; import org.apache.iceberg.util.ByteBuffers; @@ -85,6 +86,7 @@ public static void startSpark() { SparkSession.builder() .master("local[2]") .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) .getOrCreate(); TestWriteMetricsConfig.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java index ce0a0f26a096..1669301d2d66 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java @@ -63,6 +63,7 @@ public static void startMetastoreAndSpark() { SparkSession.builder() .master("local[2]") .config("spark.sql.iceberg.aggregate_pushdown", "true") + .config(TestBase.DISABLE_UI) .enableHiveSupport() .getOrCreate(); @@ -123,8 +124,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { + "max(boolean_data), min(boolean_data), count(boolean_data), " + "max(float_data), min(float_data), count(float_data), " + "max(double_data), min(double_data), count(double_data), " - + "max(decimal_data), min(decimal_data), count(decimal_data), " - + "max(binary_data), min(binary_data), count(binary_data) FROM %s"; + + "max(decimal_data), min(decimal_data), count(decimal_data) FROM %s"; List explain = sql("EXPLAIN " + select, tableName); String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); @@ -147,10 +147,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { && explainString.contains("count(double_data)") && explainString.contains("max(decimal_data)") && explainString.contains("min(decimal_data)") - && explainString.contains("count(decimal_data)") - && explainString.contains("max(binary_data)") - && explainString.contains("min(binary_data)") - && explainString.contains("count(binary_data)")) { + && explainString.contains("count(decimal_data)")) { explainContainsPushDownAggregates = true; } @@ -180,10 +177,7 @@ private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { 5L, new BigDecimal("66.66"), new BigDecimal("11.11"), - 6L, - new byte[] {85, 85}, - new byte[] {17, 17}, - 5L + 6L }); assertEquals("min/max/count push down", expected, actual); } @@ -333,14 +327,14 @@ public void testAggregatePushDownWithMetricsMode() { @TestTemplate public void testAggregateNotPushDownForStringType() { sql("CREATE TABLE %s (id LONG, data STRING) USING iceberg", tableName); - sql( - "INSERT INTO TABLE %s VALUES (1, '1111'), (1, '2222'), (2, '3333'), (2, '4444'), (3, '5555'), (3, '6666') ", - tableName); sql( "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", - tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(16)"); + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, '111111'), (1, '2222'), (2, '3333'), (2, '4444'), (3, '5555'), (3, '666666') ", + tableName); - String select1 = "SELECT MAX(id), MAX(data) FROM %s"; + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; List explain1 = sql("EXPLAIN " + select1, tableName); String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); @@ -355,7 +349,7 @@ public void testAggregateNotPushDownForStringType() { List actual1 = sql(select1, tableName); List expected1 = Lists.newArrayList(); - expected1.add(new Object[] {3L, "6666"}); + expected1.add(new Object[] {3L, "666666", "111111"}); assertEquals("expected and actual should equal", expected1, actual1); String select2 = "SELECT COUNT(data) FROM %s"; @@ -378,20 +372,93 @@ public void testAggregateNotPushDownForStringType() { sql( "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); - String select3 = "SELECT count(data), max(data) FROM %s"; + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; List explain3 = sql("EXPLAIN " + select3, tableName); String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { explainContainsPushDownAggregates = true; } + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual3 = sql(select3, tableName); + List expected3 = Lists.newArrayList(); + expected3.add(new Object[] {6L, "666666", "111111"}); + assertEquals("expected and actual should equal", expected3, actual3); + } + + @TestTemplate + public void testAggregateNotPushDownForBinaryType() { + sql("CREATE TABLE %s (id LONG, data BINARY) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, X'111111111111'), (1, X'2222')," + + " (2, X'3333'), (2, X'4444')," + + " (3, X'5555'), (3, X'555555555555') ", + tableName); + + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; + + List explain1 = sql("EXPLAIN " + select1, tableName); + String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString1.contains("max(id)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual1 = sql(select1, tableName); + List expected1 = Lists.newArrayList(); + expected1.add( + new Object[] { + 3L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); + assertEquals("expected and actual should equal", expected1, actual1); + + String select2 = "SELECT COUNT(data) FROM %s"; + List explain2 = sql("EXPLAIN " + select2, tableName); + String explainString2 = explain2.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString2.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + assertThat(explainContainsPushDownAggregates) .as("explain should contain the pushed down aggregates") .isTrue(); + List actual2 = sql(select2, tableName); + List expected2 = Lists.newArrayList(); + expected2.add(new Object[] {6L}); + assertEquals("expected and actual should equal", expected2, actual2); + + explainContainsPushDownAggregates = false; + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; + List explain3 = sql("EXPLAIN " + select3, tableName); + String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + List actual3 = sql(select3, tableName); List expected3 = Lists.newArrayList(); - expected3.add(new Object[] {6L, "6666"}); + expected3.add( + new Object[] { + 6L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); assertEquals("expected and actual should equal", expected3, actual3); } @@ -766,6 +833,51 @@ public void testNaN() { assertEquals("expected and actual should equal", expected, actual); } + @TestTemplate + public void testNanWithLowerAndUpperBoundMetrics() { + sql("CREATE TABLE %s (id int, data float) USING iceberg PARTITIONED BY (id)", tableName); + sql( + "INSERT INTO %s VALUES (1, float('nan'))," + + "(1, float('nan')), " + + "(1, 10.0), " + + "(2, 2), " + + "(2, float('nan')), " + + "(3, float('nan')), " + + "(3, 1)", + tableName); + + // Validate all files has upper bound, lower bound and nan count + String countsQuery = + "select readable_metrics.data.nan_value_count > 0, " + + "isnull(readable_metrics.data.lower_bound), " + + "isnull(readable_metrics.data.upper_bound) " + + "from %s.files"; + + Object[] expectedResult = new Object[] {true, false, false}; + assertThat(sql(countsQuery, tableName)) + .as("Data files should contain nan count, lower bound and upper bound.") + .allMatch(row -> Arrays.equals(row, expectedResult)); + + // Check aggregates are not pushed down + String select = "SELECT count(*), max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = + (explainString.contains("max(data)") + || explainString.contains("min(data)") + || explainString.contains("count(data)")); + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {7L, Float.NaN, 1.0F, 7L}); + assertEquals("expected and actual should equal", expected, actual); + } + @TestTemplate public void testInfinity() { sql( diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java index 7fb1fda3364f..afb4e9964842 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java @@ -40,7 +40,6 @@ import org.apache.iceberg.types.Types; import org.apache.iceberg.types.Types.NestedField; import org.apache.iceberg.types.Types.StructType; -import org.apache.spark.sql.connector.catalog.TableCatalog; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; @@ -273,7 +272,7 @@ public void testCreateTableComment() { assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); assertThat(table.properties()) .doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT) - .containsEntry(TableCatalog.PROP_COMMENT, "Table doc"); + .containsEntry(TableProperties.COMMENT, "Table doc"); } @TestTemplate diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java index bd4a41593c34..2389bcc17387 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java @@ -162,4 +162,27 @@ public void testTruncate() { ImmutableList.of(), sql("SELECT * FROM %s ORDER BY id", tableName)); } + + @TestTemplate + public void testDeleteFromTablePartitionedByVarbinary() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data binary) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES(1, X'e3bcd1'), (2, X'bcd1')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47}), row(2L, new byte[] {-68, -47})), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE data = X'bcd1'", tableName); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47})), + sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47})), + sql("SELECT * FROM %s where data = X'e3bcd1'", tableName)); + } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java index 6becc07b2db2..34e9dbc87d02 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java @@ -22,6 +22,7 @@ import java.util.Set; import org.apache.iceberg.DataFile; import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotChanges; import org.apache.iceberg.Table; import org.apache.iceberg.relocated.com.google.common.base.Preconditions; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; @@ -71,7 +72,7 @@ public void testRefreshCommand() { // Modify table outside of spark, it should be cached so Spark should see the same value after // mutation Table table = validationCatalog.loadTable(tableIdent); - DataFile file = table.currentSnapshot().addedDataFiles(table.io()).iterator().next(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); table.newDelete().deleteFile(file).commit(); List cachedActual = sql("SELECT * FROM %s", tableName); diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java index 8fa8406278ea..78a6718a9f84 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java @@ -21,7 +21,9 @@ import static org.apache.iceberg.TableProperties.SPLIT_SIZE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import java.sql.Timestamp; import java.text.SimpleDateFormat; import java.util.Arrays; import java.util.Date; @@ -30,16 +32,21 @@ import org.apache.iceberg.Parameter; import org.apache.iceberg.ParameterizedTestExtension; import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.events.Listeners; import org.apache.iceberg.events.ScanEvent; import org.apache.iceberg.exceptions.ValidationException; import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.hive.HiveCatalog; import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; import org.apache.iceberg.spark.CatalogTestBase; import org.apache.iceberg.spark.Spark3Util; import org.apache.iceberg.spark.SparkCatalogConfig; import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.types.Types; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.junit.jupiter.api.AfterEach; @@ -107,7 +114,10 @@ public void testSelect() { List expected = ImmutableList.of(row(1L, "a", 1.0F), row(2L, "b", 2.0F), row(3L, "c", Float.NaN)); - assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); } @TestTemplate @@ -118,7 +128,10 @@ public void testSelectWithSpecifiedTargetSplitSize() { Table table = validationCatalog.loadTable(tableIdent); table.updateProperties().set("read.split.target-size", "1024").commit(); spark.sql("REFRESH TABLE " + tableName); - assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); // Query failed when `SPLIT_SIZE` < 0 table.updateProperties().set(SPLIT_SIZE, "-1").commit(); @@ -158,16 +171,21 @@ public void selectWithLimit() { // verify that LIMIT is properly applied in case SupportsPushDownLimit.isPartiallyPushed() is // ever overridden in SparkScanBuilder - assertThat(sql("SELECT * FROM %s LIMIT 1", tableName)).containsExactly(first); - assertThat(sql("SELECT * FROM %s LIMIT 2", tableName)).containsExactly(first, second); - assertThat(sql("SELECT * FROM %s LIMIT 3", tableName)).containsExactly(first, second, third); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 1", tableName)).containsExactly(first); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 2", tableName)) + .containsExactly(first, second); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 3", tableName)) + .containsExactly(first, second, third); } @TestTemplate public void testProjection() { List expected = ImmutableList.of(row(1L), row(2L), row(3L)); - assertEquals("Should return all expected rows", expected, sql("SELECT id FROM %s", tableName)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id FROM %s ORDER BY id", tableName)); assertThat(scanEventCount).as("Should create only one scan").isEqualTo(1); assertThat(lastScanEvent.filter()) @@ -209,14 +227,14 @@ public void testMetadataTables() { public void testSnapshotInTableName() { // get the snapshot ID of the last write and get the current row set as expected long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); String prefix = "snapshot_id_"; // read the table at the snapshot - List actual = sql("SELECT * FROM %s.%s", tableName, prefix + snapshotId); + List actual = sql("SELECT * FROM %s.%s ORDER by id", tableName, prefix + snapshotId); assertEquals("Snapshot at specific ID, prefix " + prefix, expected, actual); // read the table using DataFrameReader option @@ -225,7 +243,8 @@ public void testSnapshotInTableName() { .read() .format("iceberg") .option(SparkReadOptions.SNAPSHOT_ID, snapshotId) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); } @@ -235,14 +254,14 @@ public void testTimestampInTableName() { // get a timestamp just after the last write and get the current row set as expected long snapshotTs = validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis(); long timestamp = waitUntilAfter(snapshotTs + 2); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); String prefix = "at_timestamp_"; // read the table at the snapshot - List actual = sql("SELECT * FROM %s.%s", tableName, prefix + timestamp); + List actual = sql("SELECT * FROM %s.%s ORDER by id", tableName, prefix + timestamp); assertEquals("Snapshot at timestamp, prefix " + prefix, expected, actual); // read the table using DataFrameReader option @@ -251,7 +270,8 @@ public void testTimestampInTableName() { .read() .format("iceberg") .option(SparkReadOptions.AS_OF_TIMESTAMP, timestamp) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); } @@ -260,19 +280,20 @@ public void testTimestampInTableName() { public void testVersionAsOf() { // get the snapshot ID of the last write and get the current row set as expected long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); // read the table at the snapshot - List actual1 = sql("SELECT * FROM %s VERSION AS OF %s", tableName, snapshotId); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, snapshotId); assertEquals("Snapshot at specific ID", expected, actual1); // read the table at the snapshot // HIVE time travel syntax List actual2 = - sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF %s", tableName, snapshotId); + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF %s ORDER BY id", tableName, snapshotId); assertEquals("Snapshot at specific ID", expected, actual2); // read the table using DataFrameReader option: versionAsOf @@ -281,7 +302,8 @@ public void testVersionAsOf() { .read() .format("iceberg") .option(SparkReadOptions.VERSION_AS_OF, snapshotId) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); } @@ -291,28 +313,35 @@ public void testTagReference() { Table table = validationCatalog.loadTable(tableIdent); long snapshotId = table.currentSnapshot().snapshotId(); table.manageSnapshots().createTag("test_tag", snapshotId).commit(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); // create a second snapshot, read the table at the tag sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); - List actual1 = sql("SELECT * FROM %s VERSION AS OF 'test_tag'", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_tag' ORDER by id", tableName); assertEquals("Snapshot at specific tag reference name", expected, actual1); // read the table at the tag // HIVE time travel syntax - List actual2 = sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_tag'", tableName); + List actual2 = + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_tag' ORDER by id", tableName); assertEquals("Snapshot at specific tag reference name", expected, actual2); // Spark session catalog does not support extended table names if (!"spark_catalog".equals(catalogName)) { // read the table using the "tag_" prefix in the table name - List actual3 = sql("SELECT * FROM %s.tag_test_tag", tableName); + List actual3 = sql("SELECT * FROM %s.tag_test_tag ORDER by id", tableName); assertEquals("Snapshot at specific tag reference name, prefix", expected, actual3); } // read the table using DataFrameReader option: tag Dataset df = - spark.read().format("iceberg").option(SparkReadOptions.TAG, "test_tag").load(tableName); + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TAG, "test_tag") + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific tag reference name", expected, fromDF); } @@ -323,7 +352,7 @@ public void testUseSnapshotIdForTagReferenceAsOf() { long snapshotId1 = table.currentSnapshot().snapshotId(); // create a second snapshot, read the table at the snapshot - List actual = sql("SELECT * FROM %s", tableName); + List actual = sql("SELECT * FROM %s ORDER by id", tableName); sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); table.refresh(); @@ -334,11 +363,11 @@ public void testUseSnapshotIdForTagReferenceAsOf() { // this means if a tag name matches a snapshot ID, it will always choose snapshotID to travel // to. List travelWithStringResult = - sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, snapshotId1); + sql("SELECT * FROM %s VERSION AS OF '%s' ORDER by id", tableName, snapshotId1); assertEquals("Snapshot at specific tag reference name", actual, travelWithStringResult); List travelWithLongResult = - sql("SELECT * FROM %s VERSION AS OF %s", tableName, snapshotId1); + sql("SELECT * FROM %s VERSION AS OF %s ORDER by id", tableName, snapshotId1); assertEquals("Snapshot at specific tag reference name", actual, travelWithLongResult); } @@ -347,23 +376,24 @@ public void testBranchReference() { Table table = validationCatalog.loadTable(tableIdent); long snapshotId = table.currentSnapshot().snapshotId(); table.manageSnapshots().createBranch("test_branch", snapshotId).commit(); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); // create a second snapshot, read the table at the branch sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); - List actual1 = sql("SELECT * FROM %s VERSION AS OF 'test_branch'", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_branch' ORDER by id", tableName); assertEquals("Snapshot at specific branch reference name", expected, actual1); // read the table at the branch // HIVE time travel syntax List actual2 = - sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_branch'", tableName); + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_branch' ORDER by id", tableName); assertEquals("Snapshot at specific branch reference name", expected, actual2); // Spark session catalog does not support extended table names if (!"spark_catalog".equals(catalogName)) { // read the table using the "branch_" prefix in the table name - List actual3 = sql("SELECT * FROM %s.branch_test_branch", tableName); + List actual3 = sql("SELECT * FROM %s.branch_test_branch ORDER by id", tableName); assertEquals("Snapshot at specific branch reference name, prefix", expected, actual3); } @@ -373,7 +403,8 @@ public void testBranchReference() { .read() .format("iceberg") .option(SparkReadOptions.BRANCH, "test_branch") - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at specific branch reference name", expected, fromDF); } @@ -386,7 +417,7 @@ public void readAndWriteWithBranchAfterSchemaChange() { List expected = Arrays.asList(row(1L, "a", 1.0f), row(2L, "b", 2.0f), row(3L, "c", Float.NaN)); - assertThat(sql("SELECT * FROM %s", tableName)).containsExactlyElementsOf(expected); + assertThat(sql("SELECT * FROM %s", tableName)).containsExactlyInAnyOrderElementsOf(expected); // change schema on the table and add more data sql("ALTER TABLE %s DROP COLUMN float", tableName); @@ -397,16 +428,16 @@ public void readAndWriteWithBranchAfterSchemaChange() { // time-travel query using snapshot id should return the snapshot's schema long branchSnapshotId = table.refs().get(branchName).snapshotId(); - assertThat(sql("SELECT * FROM %s VERSION AS OF %s", tableName, branchSnapshotId)) + assertThat(sql("SELECT * FROM %s VERSION AS OF %s ORDER by id", tableName, branchSnapshotId)) .containsExactlyElementsOf(expected); // querying the head of the branch should return the table's schema - assertThat(sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, branchName)) + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER by id", tableName, branchName)) .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); if (!"spark_catalog".equals(catalogName)) { // querying the head of the branch using 'branch_' should return the table's schema - assertThat(sql("SELECT * FROM %s.branch_%s", tableName, branchName)) + assertThat(sql("SELECT * FROM %s.branch_%s ORDER by id", tableName, branchName)) .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); } @@ -416,7 +447,7 @@ public void readAndWriteWithBranchAfterSchemaChange() { tableName, branchName); // querying the head of the branch returns the table's schema - assertThat(sql("SELECT * FROM %s VERSION AS OF '%s'", tableName, branchName)) + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER by id", tableName, branchName)) .containsExactlyInAnyOrder( row(1L, "a", null), row(2L, "b", null), @@ -426,7 +457,12 @@ public void readAndWriteWithBranchAfterSchemaChange() { // using DataFrameReader with the 'branch' option should return the table's schema Dataset df = - spark.read().format("iceberg").option(SparkReadOptions.BRANCH, branchName).load(tableName); + spark + .read() + .format("iceberg") + .option(SparkReadOptions.BRANCH, branchName) + .load(tableName) + .orderBy("id"); assertThat(rowsToJava(df.collectAsList())) .containsExactlyInAnyOrder( row(1L, "a", null), @@ -453,30 +489,30 @@ public void testTimestampAsOf() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String formattedDate = sdf.format(new Date(timestamp)); - List expected = sql("SELECT * FROM %s", tableName); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); // create a second snapshot sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); // read the table at the timestamp in long format i.e 1656507980463. List actualWithLongFormat = - sql("SELECT * FROM %s TIMESTAMP AS OF %s", tableName, timestampInSeconds); + sql("SELECT * FROM %s TIMESTAMP AS OF %s ORDER BY id", tableName, timestampInSeconds); assertEquals("Snapshot at timestamp", expected, actualWithLongFormat); // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 List actualWithDateFormat = - sql("SELECT * FROM %s TIMESTAMP AS OF '%s'", tableName, formattedDate); + sql("SELECT * FROM %s TIMESTAMP AS OF '%s' ORDER BY id", tableName, formattedDate); assertEquals("Snapshot at timestamp", expected, actualWithDateFormat); // HIVE time travel syntax // read the table at the timestamp in long format i.e 1656507980463. List actualWithLongFormatInHiveSyntax = - sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF %s", tableName, timestampInSeconds); + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF %s ORDER BY id", tableName, timestampInSeconds); assertEquals("Snapshot at specific ID", expected, actualWithLongFormatInHiveSyntax); // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 List actualWithDateFormatInHiveSyntax = - sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF '%s'", tableName, formattedDate); + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF '%s' ORDER BY id", tableName, formattedDate); assertEquals("Snapshot at specific ID", expected, actualWithDateFormatInHiveSyntax); // read the table using DataFrameReader option @@ -485,7 +521,8 @@ public void testTimestampAsOf() { .read() .format("iceberg") .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedDate) - .load(tableName); + .load(tableName) + .orderBy("id"); List fromDF = rowsToJava(df.collectAsList()); assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); } @@ -605,6 +642,29 @@ public void testBinaryInFilter() { sql("SELECT id, binary FROM %s where binary > X'11'", binaryTableName)); } + @TestTemplate + public void testFixedInFilter() { + // Create table programmatically with fixed type since Spark SQL DDL doesn't support it + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "fixed", Types.FixedType.ofLength(2))); + + TableIdentifier fixedTableIdent = TableIdentifier.of(tableIdent.namespace(), "fixed_table"); + validationCatalog.createTable(fixedTableIdent, schema, PartitionSpec.unpartitioned()); + + String fixedTableName = tableName("fixed_table"); + sql("INSERT INTO %s VALUES (1, X'0000'), (2, X'1111'), (3, X'0011')", fixedTableName); + List expected = ImmutableList.of(row(2L, new byte[] {0x11, 0x11})); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id, fixed FROM %s WHERE fixed > X'0011'", fixedTableName)); + + sql("DROP TABLE IF EXISTS %s", fixedTableName); + } + @TestTemplate public void testComplexTypeFilter() { String complexTypeTableName = tableName("complex_table"); @@ -643,4 +703,71 @@ public void testRequiredNestedFieldInOptionalStructFilter() { assertEquals("Should return all expected rows", ImmutableList.of(row(0)), result); sql("DROP TABLE IF EXISTS %s", nestedStructTable); } + + @TestTemplate + public void simpleTypesInFilter() { + String tableName = tableName("simple_types_table"); + sql( + "CREATE TABLE IF NOT EXISTS %s (id bigint, boolean boolean, integer integer, long long, " + + "float float, double double, string string, date date, timestamp timestamp) USING iceberg", + tableName); + sql( + "INSERT INTO %s VALUES (1, true, 1, 1L, 1.1, 1.3, '1.5', to_date('2021-01-01'), to_timestamp('2021-01-01T00:00:00')), " + + "(2, false, 2, 2L, 2.2, 2.4, '2.6', to_date('2022-02-02'), to_timestamp('2022-02-02T00:00:00')), " + + "(3, true, 3, 3L, 3.3, 3.6, '3.9', to_date('2023-03-03'), to_timestamp('2023-03-03T00:00:00'))", + tableName); + assertThat(sql("SELECT id FROM %s where id > 1", tableName)) + .containsExactlyInAnyOrder(row(2L), row(3L)); + assertThat(sql("SELECT id, boolean FROM %s where boolean = true", tableName)) + .containsExactlyInAnyOrder(row(1L, true), row(3L, true)); + assertThat(sql("SELECT long FROM %s where long > 1", tableName)) + .containsExactlyInAnyOrder(row(2L), row(3L)); + assertThat(sql("SELECT float FROM %s where float > 1.1f", tableName)) + .containsExactlyInAnyOrder(row(2.2f), row(3.3f)); + assertThat(sql("SELECT double FROM %s where double > 1.3", tableName)) + .containsExactlyInAnyOrder(row(2.4d), row(3.6d)); + assertThat(sql("SELECT string FROM %s where string > '1.5'", tableName)) + .containsExactlyInAnyOrder(row("2.6"), row("3.9")); + java.sql.Date dateOne = java.sql.Date.valueOf("2022-02-02"); + java.sql.Date dateTwo = java.sql.Date.valueOf("2023-03-03"); + assertThat(sql("SELECT date FROM %s where date > to_date('2021-01-01')", tableName)) + .containsExactlyInAnyOrder(row(dateOne), row(dateTwo)); + assertThat( + sql("SELECT timestamp FROM %s where timestamp > to_timestamp('2021-01-01')", tableName)) + .containsExactlyInAnyOrder( + row(new Timestamp(dateOne.getTime())), row(new Timestamp(dateTwo.getTime()))); + + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void variantTypeInFilter() { + assumeThat(validationCatalog) + .as("Variant is not supported in Hive catalog") + .isNotInstanceOf(HiveCatalog.class); + + String tableName = tableName("variant_table"); + sql( + "CREATE TABLE %s (id BIGINT, v1 VARIANT, v2 VARIANT) USING iceberg TBLPROPERTIES ('format-version'='3')", + tableName); + + String v1r1 = "{\"a\":5}"; + String v1r2 = "{\"a\":10}"; + String v2r1 = "{\"x\":15}"; + String v2r2 = "{\"x\":20}"; + + sql("INSERT INTO %s SELECT 1, parse_json('%s'), parse_json('%s')", tableName, v1r1, v2r1); + sql("INSERT INTO %s SELECT 2, parse_json('%s'), parse_json('%s')", tableName, v1r2, v2r2); + + assertThat( + sql( + "SELECT id, try_variant_get(v1, '$.a', 'int') FROM %s WHERE try_variant_get(v1, '$.a', 'int') > 5", + tableName)) + .containsExactly(row(2L, 10)); + assertThat( + sql( + "SELECT id, try_variant_get(v2, '$.x', 'int') FROM %s WHERE try_variant_get(v2, '$.x', 'int') < 100", + tableName)) + .containsExactlyInAnyOrder(row(1L, 15), row(2L, 20)); + } } diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkVariantRead.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkVariantRead.java index 599bf591e9a4..2d6e919a91ee 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkVariantRead.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkVariantRead.java @@ -302,6 +302,55 @@ public void testNestedMapVariant(boolean vectorized) { sql("DROP TABLE IF EXISTS %s", mapTable); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testMergeIntoWithVariant(boolean vectorized) { + // Variant columns are not vectorized yet, but MERGE INTO should not crash regardless of the + // vectorization setting. The reader falls back to non-vectorized for variant columns. + String mergeTable = CATALOG + ".default.var_merge"; + sql("DROP TABLE IF EXISTS %s", mergeTable); + sql( + "CREATE TABLE %s (id BIGINT, data VARIANT) USING iceberg " + + "TBLPROPERTIES ('format-version'='3')", + mergeTable); + setVectorization(mergeTable, vectorized); + + sql( + "INSERT INTO %s VALUES " + + "(1, parse_json('{\"name\":\"alice\",\"age\":30}')), " + + "(2, parse_json('{\"name\":\"bob\",\"age\":25}'))", + mergeTable); + + sql( + "MERGE INTO %s AS target " + + "USING (SELECT 1 AS id, parse_json('{\"name\":\"alice\",\"age\":31}') AS data) AS source " + + "ON target.id = source.id " + + "WHEN MATCHED THEN UPDATE SET target.data = source.data " + + "WHEN NOT MATCHED THEN INSERT *", + mergeTable); + + List rows = spark.table(mergeTable).select("id", "data").orderBy("id").collectAsList(); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0).getLong(0)).isEqualTo(1L); + Variant v1 = + new Variant( + ((VariantVal) rows.get(0).get(1)).getValue(), + ((VariantVal) rows.get(0).get(1)).getMetadata()); + assertThat(v1.getFieldByKey("name").getString()).describedAs("v1.name").isEqualTo("alice"); + assertThat(v1.getFieldByKey("age").getLong()).describedAs("v1.age").isEqualTo(31L); + + assertThat(rows.get(1).getLong(0)).isEqualTo(2L); + Variant v2 = + new Variant( + ((VariantVal) rows.get(1).get(1)).getValue(), + ((VariantVal) rows.get(1).get(1)).getMetadata()); + assertThat(v2.getFieldByKey("name").getString()).describedAs("v2.name").isEqualTo("bob"); + assertThat(v2.getFieldByKey("age").getLong()).describedAs("v2.age").isEqualTo(25L); + + sql("DROP TABLE IF EXISTS %s", mergeTable); + } + private void setVectorization(boolean on) { sql( "ALTER TABLE %s SET TBLPROPERTIES ('read.parquet.vectorization.enabled'='%s')", diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestTableEncryption.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestTableEncryption.java index e35ebf71b5c5..3b36b7bb0a25 100644 --- a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestTableEncryption.java +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/sql/TestTableEncryption.java @@ -29,14 +29,20 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.ChecksumFileSystem; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; import org.apache.iceberg.AppendFiles; import org.apache.iceberg.CatalogProperties; import org.apache.iceberg.DataFile; import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.MetadataTableType; import org.apache.iceberg.Parameters; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; import org.apache.iceberg.Transaction; import org.apache.iceberg.encryption.Ciphers; import org.apache.iceberg.encryption.UnitestKMS; @@ -50,9 +56,11 @@ import org.apache.iceberg.spark.SparkCatalogConfig; import org.apache.iceberg.types.Types; import org.apache.parquet.crypto.ParquetCryptoRuntimeException; +import org.apache.spark.SparkException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestTemplate; +import org.mockito.internal.util.collections.Iterables; public class TestTableEncryption extends CatalogTestBase { private static Map appendCatalogEncryptionProperties(Map props) { @@ -105,8 +113,8 @@ private static List currentDataFiles(Table table) { @TestTemplate public void testRefresh() { - catalog.initialize(catalogName, catalogConfig); - Table table = catalog.loadTable(tableIdent); + validationCatalog.initialize(catalogName, catalogConfig); + Table table = validationCatalog.loadTable(tableIdent); assertThat(currentDataFiles(table)).isNotEmpty(); @@ -117,10 +125,26 @@ public void testRefresh() { } @TestTemplate - public void testTransaction() { - catalog.initialize(catalogName, catalogConfig); + public void testAppendTransaction() { + validationCatalog.initialize(catalogName, catalogConfig); + Table table = validationCatalog.loadTable(tableIdent); - Table table = catalog.loadTable(tableIdent); + List dataFiles = currentDataFiles(table); + Transaction transaction = table.newTransaction(); + AppendFiles append = transaction.newAppend(); + + // add an arbitrary datafile + append.appendFile(dataFiles.get(0)); + append.commit(); + transaction.commitTransaction(); + + assertThat(currentDataFiles(table)).hasSize(dataFiles.size() + 1); + } + + @TestTemplate + public void testConcurrentAppendTransactions() { + validationCatalog.initialize(catalogName, catalogConfig); + Table table = validationCatalog.loadTable(tableIdent); List dataFiles = currentDataFiles(table); Transaction transaction = table.newTransaction(); @@ -128,10 +152,46 @@ public void testTransaction() { // add an arbitrary datafile append.appendFile(dataFiles.get(0)); + + // append to the table in the meantime. use a separate load to avoid shared operations + validationCatalog.loadTable(tableIdent).newFastAppend().appendFile(dataFiles.get(0)).commit(); + append.commit(); transaction.commitTransaction(); - assertThat(currentDataFiles(table).size()).isEqualTo(dataFiles.size() + 1); + assertThat(currentDataFiles(table)).hasSize(dataFiles.size() + 2); + } + + // See CatalogTests#testConcurrentReplaceTransactions + @TestTemplate + public void testConcurrentReplaceTransactions() { + validationCatalog.initialize(catalogName, catalogConfig); + + Table table = validationCatalog.loadTable(tableIdent); + DataFile file = currentDataFiles(table).get(0); + Schema schema = table.schema(); + + // Write data for a replace transaction that will be committed later + Transaction secondReplace = + validationCatalog + .buildTable(tableIdent, schema) + .withProperty("encryption.key-id", UnitestKMS.MASTER_KEY_NAME1) + .replaceTransaction(); + secondReplace.newFastAppend().appendFile(file).commit(); + + // Commit another replace transaction first + Transaction firstReplace = + validationCatalog + .buildTable(tableIdent, schema) + .withProperty("encryption.key-id", UnitestKMS.MASTER_KEY_NAME1) + .replaceTransaction(); + firstReplace.newFastAppend().appendFile(file).commit(); + firstReplace.commitTransaction(); + + secondReplace.commitTransaction(); + + Table afterSecondReplace = validationCatalog.loadTable(tableIdent); + assertThat(currentDataFiles(afterSecondReplace)).hasSize(1); } @TestTemplate @@ -162,18 +222,55 @@ public void testInsertAndDelete() { sql("SELECT * FROM %s ORDER BY id", tableName)); } + @TestTemplate + public void testMetadataTamperproofing() throws IOException { + ChecksumFileSystem fs = ((ChecksumFileSystem) FileSystem.newInstance(new Configuration())); + catalog.initialize(catalogName, catalogConfig); + + Table table = catalog.loadTable(tableIdent); + TableMetadata currentMetadata = ((HasTableOperations) table).operations().current(); + Path metadataFile = new Path(currentMetadata.metadataFileLocation()); + Path previousMetadataFile = new Path(Iterables.firstOf(currentMetadata.previousFiles()).file()); + + // manual FS tampering: replacing the current metadata file with a previous one + Path crcPath = fs.getChecksumFile(metadataFile); + fs.delete(crcPath, false); + fs.delete(metadataFile, false); + fs.rename(previousMetadataFile, metadataFile); + + assertThatThrownBy(() -> catalog.loadTable(tableIdent)) + .hasMessageContaining( + String.format( + "The current metadata file %s might have been modified. Hash of metadata loaded from storage differs from HMS-stored metadata hash.", + metadataFile)); + } + @TestTemplate public void testKeyDelete() { assertThatThrownBy( () -> sql("ALTER TABLE %s UNSET TBLPROPERTIES (`encryption.key-id`)", tableName)) - .hasMessageContaining("Cannot remove key in encrypted table"); + .isInstanceOf(SparkException.class) + .hasMessage("Unsupported table change: Cannot remove key ID from an encrypted table"); } @TestTemplate public void testKeyAlter() { assertThatThrownBy( () -> sql("ALTER TABLE %s SET TBLPROPERTIES ('encryption.key-id'='abcd')", tableName)) - .hasMessageContaining("Cannot modify key in encrypted table"); + .isInstanceOf(SparkException.class) + .hasMessage("Unsupported table change: Cannot modify key ID of an encrypted table"); + } + + @TestTemplate + public void testReplaceKeyChange() { + // Replacing a table with a different encryption key is disallowed + assertThatThrownBy( + () -> + sql( + "REPLACE TABLE %s (id bigint) USING iceberg TBLPROPERTIES ('encryption.key-id'='%s')", + tableName, UnitestKMS.MASTER_KEY_NAME2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot modify key ID of an encrypted table"); } @TestTemplate @@ -249,6 +346,25 @@ public void testManifestEncryption() throws IOException { } } + @TestTemplate + public void testDropTableWithPurge() { + List dataFileTable = + sql("SELECT file_path FROM %s.%s", tableName, MetadataTableType.ALL_DATA_FILES); + List dataFiles = + Streams.concat(dataFileTable.stream()) + .map(row -> (String) row[0]) + .collect(Collectors.toList()); + assertThat(dataFiles).isNotEmpty(); + assertThat(dataFiles) + .allSatisfy(filePath -> assertThat(localInput(filePath).exists()).isTrue()); + + sql("DROP TABLE %s PURGE", tableName); + + assertThat(catalog.tableExists(tableIdent)).as("Table should not exist").isFalse(); + assertThat(dataFiles) + .allSatisfy(filePath -> assertThat(localInput(filePath).exists()).isFalse()); + } + private void checkMetadataFileEncryption(InputFile file) throws IOException { SeekableInputStream stream = file.newStream(); byte[] magic = new byte[4]; diff --git a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/variant/TestVariantShredding.java b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/variant/TestVariantShredding.java new file mode 100644 index 000000000000..8cdcf22e5817 --- /dev/null +++ b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/variant/TestVariantShredding.java @@ -0,0 +1,1101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.variant; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; +import static org.apache.parquet.schema.Types.optional; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.variants.Variant; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestVariantShredding extends CatalogTestBase { + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "address", Types.VariantType.get())); + + private static final Schema SCHEMA2 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "address", Types.VariantType.get()), + Types.NestedField.optional(3, "metadata", Types.VariantType.get())); + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties() + }, + }; + } + + @BeforeAll + public static void startMetastoreAndSpark() { + // First call parent to initialize metastore and spark with local[2] + CatalogTestBase.startMetastoreAndSpark(); + + // Now stop and recreate spark with local[1] to write all rows to a single file + if (spark != null) { + spark.stop(); + } + + spark = + SparkSession.builder() + .master("local[1]") // Use one thread to write the rows to a single parquet file + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") + .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config(DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + } + + @BeforeEach + public void before() { + super.before(); + validationCatalog.createTable( + tableIdent, SCHEMA, null, Map.of(TableProperties.FORMAT_VERSION, "3")); + } + + @AfterEach + public void after() { + spark.conf().unset(SparkSQLProperties.SHRED_VARIANTS); + spark.conf().unset(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE); + validationCatalog.dropTable(tableIdent, true); + } + + @TestTemplate + public void testVariantShreddingDisabled() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "false"); + + String values = "(1, parse_json('{\"city\": \"NYC\", \"zip\": 10001}')), (2, null)"; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = variant("address", 2, Type.Repetition.OPTIONAL); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testExcludingNullValue() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30, "dummy": null}')),\ + (2, parse_json('{"name": "Bob", "age": 25}')),\ + (3, parse_json('{"name": "Charlie", "age": 35}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testInconsistentType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"age": "25"}')),\ + (2, parse_json('{"age": 30}')),\ + (3, parse_json('{"age": "35"}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + List rows = + sql("SELECT variant_get(address, '$.age', 'int') FROM %s WHERE id = 2", tableName); + assertThat(rows).hasSize(1); + assertThat(rows.get(0)[0]).isEqualTo(30); + } + + @TestTemplate + public void testPrimitiveType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = "(1, parse_json('123')), (2, parse_json('456')), (3, parse_json('789'))"; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = + variant( + "address", + 2, + Type.Repetition.REQUIRED, + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(16, true))); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testPrimitiveDecimalType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + "(1, parse_json('123.56')), (2, parse_json('\"abc\"')), (3, parse_json('12.56'))"; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = + variant( + "address", + 2, + Type.Repetition.REQUIRED, + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.decimalType(2, 5))); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testBooleanType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"active": true}')),\ + (2, parse_json('{"active": false}')),\ + (3, parse_json('{"active": true}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType active = field("active", shreddedPrimitive(PrimitiveType.PrimitiveTypeName.BOOLEAN)); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(active)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDecimalTypeWithInconsistentScales() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"price": 123.456789}')),\ + (2, parse_json('{"price": 678.90}')),\ + (3, parse_json('{"price": 999.99}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType price = + field( + "price", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.decimalType(6, 9))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(price)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDecimalTypeWithConsistentScales() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"price": 123.45}')),\ + (2, parse_json('{"price": 678.90}')),\ + (3, parse_json('{"price": 999.99}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType price = + field( + "price", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.decimalType(2, 5))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(price)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testArrayType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('["java", "scala", "python"]')),\ + (2, parse_json('["rust", "go"]')),\ + (3, parse_json('["javascript"]'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType arr = + list( + element( + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType()))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, arr); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testNestedArrayType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"tags": ["java", "scala", "python"]}')),\ + (2, parse_json('{"tags": ["rust", "go"]}')),\ + (3, parse_json('{"tags": ["javascript"]}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType tags = + field( + "tags", + list( + element( + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, + LogicalTypeAnnotation.stringType())))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(tags)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testNestedObjectType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"location": {"city": "Seattle", "zip": 98101}, "tags": ["java", "scala", "python"]}')),\ + (2, parse_json('{"location": {"city": "Portland", "zip": 97201}}')),\ + (3, parse_json('{"location": {"city": "NYC", "zip": 10001}}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType city = + field( + "city", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType zip = + field( + "zip", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(32, true))); + GroupType location = field("location", objectFields(city, zip)); + GroupType tags = + field( + "tags", + list( + element( + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, + LogicalTypeAnnotation.stringType())))); + + GroupType address = + variant("address", 2, Type.Repetition.REQUIRED, objectFields(location, tags)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testLazyInitializationWithBufferedRows() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "5"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30}')),\ + (2, parse_json('{"name": "Bob", "age": 25}')),\ + (3, parse_json('{"name": "Charlie", "age": 35}')),\ + (4, parse_json('{"name": "David", "age": 28}')),\ + (5, parse_json('{"name": "Eve", "age": 32}')),\ + (6, parse_json('{"name": "Frank", "age": 40}')),\ + (7, parse_json('{"name": "Grace", "age": 27}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(7); + } + + @TestTemplate + public void testMultipleRowGroups() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + int numRows = 1000; + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= numRows; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + valuesBuilder.append( + String.format("(%d, parse_json('{\"name\": \"User%d\", \"age\": %d}'))", i, i, 20 + i)); + } + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", + tableName, PARQUET_ROW_GROUP_SIZE_BYTES, 1024); + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(numRows); + } + + @TestTemplate + public void testColumnIndexTruncateLength() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + int customTruncateLength = 10; + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", + tableName, "parquet.columnindex.truncate.length", customTruncateLength); + + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= 10; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + String longValue = "A".repeat(20); + valuesBuilder.append( + String.format( + "(%d, parse_json('{\"description\": \"%s\", \"id\": %d}'))", i, longValue, i)); + } + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + GroupType description = + field( + "description", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType id = + field( + "id", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = + variant("address", 2, Type.Repetition.REQUIRED, objectFields(description, id)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(10); + } + + @TestTemplate + public void testIntegerFamilyPromotion() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + // Mix of INT8, INT16, INT32, INT64 - should promote to INT64 + String values = + """ + (1, parse_json('{"value": 10}')),\ + (2, parse_json('{"value": 1000}')),\ + (3, parse_json('{"value": 100000}')),\ + (4, parse_json('{"value": 10000000000}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType value = + field( + "value", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT64, LogicalTypeAnnotation.intType(64, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(value)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDecimalFamilyPromotion() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + // Test that they get promoted to the most capable decimal type observed + String values = + """ + (1, parse_json('{"value": 1.5}')),\ + (2, parse_json('{"value": 123.456789}')),\ + (3, parse_json('{"value": 123456789123456.789}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType value = + field( + "value", + optional(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) + .length(16) + .as(LogicalTypeAnnotation.decimalType(6, 21)) + .named("typed_value")); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(value)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDataRoundTripWithShredding() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30}')),\ + (2, parse_json('{"name": "Bob", "age": 25}')),\ + (3, parse_json('{"name": "Charlie", "age": 35}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + // Verify that we can read the data back correctly + List rows = + sql( + "SELECT id, variant_get(address, '$.name', 'string')," + + " variant_get(address, '$.age', 'int')" + + " FROM %s ORDER BY id", + tableName); + assertThat(rows).hasSize(3); + assertThat(rows.get(0)[0]).isEqualTo(1); + assertThat(rows.get(0)[1]).isEqualTo("Alice"); + assertThat(rows.get(0)[2]).isEqualTo(30); + assertThat(rows.get(1)[0]).isEqualTo(2); + assertThat(rows.get(1)[1]).isEqualTo("Bob"); + assertThat(rows.get(1)[2]).isEqualTo(25); + assertThat(rows.get(2)[0]).isEqualTo(3); + assertThat(rows.get(2)[1]).isEqualTo("Charlie"); + assertThat(rows.get(2)[2]).isEqualTo(35); + } + + @TestTemplate + public void testMultipleVariantsWithShredding() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + // Recreate table with SCHEMA2 (address + metadata variant columns) + validationCatalog.dropTable(tableIdent, true); + validationCatalog.createTable( + tableIdent, SCHEMA2, null, Map.of(TableProperties.FORMAT_VERSION, "3")); + + String values = + """ + (1, parse_json('{"city": "NYC"}'), parse_json('{"source": "web"}')),\ + (2, parse_json('{"city": "LA"}'), parse_json('{"source": "app"}')),\ + (3, parse_json('{"city": "SF"}'), parse_json('{"source": "api"}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType city = + field( + "city", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(city)); + + GroupType source = + field( + "source", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType metadata = variant("metadata", 3, Type.Repetition.REQUIRED, objectFields(source)); + MessageType expectedSchema = parquetSchema(address, metadata); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testVariantWithNullValues() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('null')),\ + (2, parse_json('null')),\ + (3, parse_json('null'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = variant("address", 2, Type.Repetition.REQUIRED); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testArrayOfNullElementsWithShredding() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + sql( + "INSERT INTO %s VALUES (1, parse_json('[null, null, null]')), " + + "(2, parse_json('[null]'))", + tableName); + + // Array elements are all null, element type is null, falls back to unshredded + GroupType address = variant("address", 2, Type.Repetition.REQUIRED); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testMixedNullAndNonNullVariantValues() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30}')),\ + (2, null),\ + (3, parse_json('{"name": "Charlie", "age": 35}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.OPTIONAL, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(3); + } + + @TestTemplate + public void testWriteOptionOverridesSessionConfig() throws IOException, NoSuchTableException { + // Disable shredding at session level + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "false"); + + // Enable shredding via per-write option + String query = + "SELECT 1 as id, parse_json('{\"name\": \"Alice\", \"age\": 30}') as address" + + " UNION ALL SELECT 2, parse_json('{\"name\": \"Bob\", \"age\": 25}')" + + " UNION ALL SELECT 3, parse_json('{\"name\": \"Charlie\", \"age\": 35}')"; + spark.sql(query).writeTo(tableName).option("shred-variants", "true").append(); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testInfrequentFieldPruning() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "11"); + + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= 11; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + if (i == 1) { + // Only the first row has rare_field + valuesBuilder.append( + String.format( + "(%d, parse_json('{\"name\": \"User%d\", \"rare_field\": \"rare\"}'))", i, i)); + } else { + valuesBuilder.append(String.format("(%d, parse_json('{\"name\": \"User%d\"}'))", i, i)); + } + } + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + // rare_field appears in 1/11 rows, should be pruned + // name appears in 11/11 rows and should be kept + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testMixedTypeTieBreaking() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "10"); + + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= 10; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + if (i <= 5) { + valuesBuilder.append(String.format("(%d, parse_json('{\"val\": %d}'))", i, i)); + } else { + valuesBuilder.append(String.format("(%d, parse_json('{\"val\": \"text%d\"}'))", i, i)); + } + } + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + // 5 ints + 5 strings is a tie so STRING wins (higher TIE_BREAK_PRIORITY) + GroupType val = + field( + "val", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(val)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + // Verify data round-trips correctly + List rows = + sql("SELECT id, variant_get(address, '$.val', 'string') FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(10); + assertThat(rows.get(0)[1]).isEqualTo("1"); + assertThat(rows.get(5)[1]).isEqualTo("text6"); + } + + @TestTemplate + public void testFieldOnlyAfterBuffer() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + String values = + """ + (1, parse_json('{"name": "Alice"}')),\ + (2, parse_json('{"name": "Bob"}')),\ + (3, parse_json('{"name": "Charlie"}')),\ + (4, parse_json('{"name": "David", "score": 95}')),\ + (5, parse_json('{"name": "Eve", "score": 88}')),\ + (6, parse_json('{"name": "Frank", "score": 72}')),\ + (7, parse_json('{"name": "Grace", "score": 91}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + // Schema is determined from buffer (rows 1-3) which only has "name". + // "score" is not shredded + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + // Verify all data round-trips despite "score" not being shredded + List rows = + sql( + "SELECT id, variant_get(address, '$.name', 'string')," + + " variant_get(address, '$.score', 'int')" + + " FROM %s ORDER BY id", + tableName); + assertThat(rows).hasSize(7); + assertThat(rows.get(0)[1]).isEqualTo("Alice"); + assertThat(rows.get(0)[2]).isNull(); + assertThat(rows.get(3)[1]).isEqualTo("David"); + assertThat(rows.get(3)[2]).isEqualTo(95); + assertThat(rows.get(6)[1]).isEqualTo("Grace"); + assertThat(rows.get(6)[2]).isEqualTo(91); + } + + @TestTemplate + public void testCrossFileDifferentShreddedType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + // File 1: "score" is always integer → shredded as INT8 + String batch1 = + """ + (1, parse_json('{"score": 95}')),\ + (2, parse_json('{"score": 88}')),\ + (3, parse_json('{"score": 72}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, batch1); + + // Verify file 1 schema: score shredded as INT8 + Table table = validationCatalog.loadTable(tableIdent); + GroupType scoreInt = + field( + "score", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + MessageType expectedSchema1 = + parquetSchema(variant("address", 2, Type.Repetition.REQUIRED, objectFields(scoreInt))); + verifyParquetSchema(table, expectedSchema1); + + // File 2: "score" is always string → shredded as STRING + String batch2 = + """ + (4, parse_json('{"score": "high"}')),\ + (5, parse_json('{"score": "medium"}')),\ + (6, parse_json('{"score": "low"}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, batch2); + + // Query across both files, reader must handle different shredded types + List rows = + sql("SELECT id, variant_get(address, '$.score', 'string') FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(6); + assertThat(rows.get(0)[1]).isEqualTo("95"); + assertThat(rows.get(1)[1]).isEqualTo("88"); + assertThat(rows.get(3)[1]).isEqualTo("high"); + assertThat(rows.get(5)[1]).isEqualTo("low"); + } + + @TestTemplate + public void testAllNullVariantColumn() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + sql("INSERT INTO %s VALUES (1, null), (2, null), (3, null)", tableName); + + // All variant values are SQL NULL, so no shredding should occur + Table table = validationCatalog.loadTable(tableIdent); + MessageType expectedSchema = parquetSchema(variant("address", 2, Type.Repetition.OPTIONAL)); + verifyParquetSchema(table, expectedSchema); + + List rows = sql("SELECT id, address FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(3); + assertThat(rows.get(0)[1]).isNull(); + assertThat(rows.get(1)[1]).isNull(); + assertThat(rows.get(2)[1]).isNull(); + } + + @TestTemplate + public void testBufferSizeOne() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "1"); + + sql( + """ + INSERT INTO %s VALUES + (1, parse_json('{"name": "Alice", "age": 30}')), + (2, parse_json('{"name": "Bob", "age": 25}')), + (3, parse_json('{"name": "Charlie", "age": 35}')) + """, + tableName); + + // Schema inferred from first row only, should still shred name and age + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + List rows = + sql("SELECT id, variant_get(address, '$.name', 'string') FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(3); + assertThat(rows.get(0)[1]).isEqualTo("Alice"); + assertThat(rows.get(2)[1]).isEqualTo("Charlie"); + } + + @TestTemplate + public void testDecimalFallbackAfterBuffer() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + // Buffer: scale=2, 3 integer digits -> DECIMAL(5,2) + // Row 4: precision overflow -> fallback to value field + // Row 5: scale overflow -> fallback to value field + // Row 6: fits typed column, scale widened from 1 to 2 via setScale + String values = + """ + (1, parse_json('{"val": 123.45}')),\ + (2, parse_json('{"val": 678.90}')),\ + (3, parse_json('{"val": 999.99}')),\ + (4, parse_json('{"val": 123456.78}')),\ + (5, parse_json('{"val": 1.2345}')),\ + (6, parse_json('{"val": 12.3}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + List rows = + sql( + "SELECT id, variant_get(address, '$.val', 'decimal(10,4)') FROM %s ORDER BY id", + tableName); + assertThat(rows).hasSize(6); + assertThat(rows.get(0)[1]).isEqualTo(new BigDecimal("123.4500")); + assertThat(rows.get(3)[1]).isEqualTo(new BigDecimal("123456.7800")); + assertThat(rows.get(4)[1]).isEqualTo(new BigDecimal("1.2345")); + assertThat(rows.get(5)[1]).isEqualTo(new BigDecimal("12.3000")); + } + + private void verifyParquetSchema(Table table, MessageType expectedSchema) throws IOException { + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks).isNotEmpty(); + + for (FileScanTask task : tasks) { + String path = task.file().location(); + + HadoopInputFile inputFile = HadoopInputFile.fromPath(new Path(path), new Configuration()); + + try (ParquetFileReader reader = ParquetFileReader.open(inputFile)) { + MessageType actualSchema = reader.getFileMetaData().getSchema(); + assertThat(actualSchema).isEqualTo(expectedSchema); + } + } + } + } + + private static MessageType parquetSchema(Type... variantTypes) { + return org.apache.parquet.schema.Types.buildMessage() + .required(PrimitiveType.PrimitiveTypeName.INT32) + .id(1) + .named("id") + .addFields(variantTypes) + .named("table"); + } + + private static GroupType variant(String name, int fieldId, Type.Repetition repetition) { + return org.apache.parquet.schema.Types.buildGroup(repetition) + .id(fieldId) + .as(LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION)) + .required(PrimitiveType.PrimitiveTypeName.BINARY) + .named("metadata") + .required(PrimitiveType.PrimitiveTypeName.BINARY) + .named("value") + .named(name); + } + + private static GroupType variant( + String name, int fieldId, Type.Repetition repetition, Type shreddedType) { + checkShreddedType(shreddedType); + return org.apache.parquet.schema.Types.buildGroup(repetition) + .id(fieldId) + .as(LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION)) + .required(PrimitiveType.PrimitiveTypeName.BINARY) + .named("metadata") + .optional(PrimitiveType.PrimitiveTypeName.BINARY) + .named("value") + .addField(shreddedType) + .named(name); + } + + private static Type shreddedPrimitive(PrimitiveType.PrimitiveTypeName primitive) { + return optional(primitive).named("typed_value"); + } + + private static Type shreddedPrimitive( + PrimitiveType.PrimitiveTypeName primitive, LogicalTypeAnnotation annotation) { + return optional(primitive).as(annotation).named("typed_value"); + } + + private static GroupType objectFields(GroupType... fields) { + for (GroupType fieldType : fields) { + checkField(fieldType); + } + + return org.apache.parquet.schema.Types.buildGroup(Type.Repetition.OPTIONAL) + .addFields(fields) + .named("typed_value"); + } + + private static GroupType field(String name, Type shreddedType) { + checkShreddedType(shreddedType); + return org.apache.parquet.schema.Types.buildGroup(Type.Repetition.REQUIRED) + .optional(PrimitiveType.PrimitiveTypeName.BINARY) + .named("value") + .addField(shreddedType) + .named(name); + } + + private static GroupType element(Type shreddedType) { + return field("element", shreddedType); + } + + private static GroupType list(GroupType elementType) { + return org.apache.parquet.schema.Types.optionalList().element(elementType).named("typed_value"); + } + + private static void checkShreddedType(Type shreddedType) { + Preconditions.checkArgument( + shreddedType.getName().equals("typed_value"), + "Invalid shredded type name: %s should be typed_value", + shreddedType.getName()); + Preconditions.checkArgument( + shreddedType.isRepetition(Type.Repetition.OPTIONAL), + "Invalid shredded type repetition: %s should be OPTIONAL", + shreddedType.getRepetition()); + } + + private static void checkField(GroupType fieldType) { + Preconditions.checkArgument( + fieldType.isRepetition(Type.Repetition.REQUIRED), + "Invalid field type repetition: %s should be REQUIRED", + fieldType.getRepetition()); + } +} diff --git a/spark/v4.1/build.gradle b/spark/v4.1/build.gradle new file mode 100644 index 000000000000..e6455fa34f88 --- /dev/null +++ b/spark/v4.1/build.gradle @@ -0,0 +1,327 @@ +/* + * 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. + */ + +String sparkMajorVersion = '4.1' +String scalaVersion = '2.13' + +def sparkProjects = [ + project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}"), + project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}"), + project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersion}"), +] + +configure(sparkProjects) { + configurations { + all { + resolutionStrategy { + force "com.fasterxml.jackson.module:jackson-module-scala_${scalaVersion}:${libs.versions.jackson215.get()}" + force "com.fasterxml.jackson.core:jackson-databind:${libs.versions.jackson215.get()}" + force "com.fasterxml.jackson.core:jackson-core:${libs.versions.jackson215.get()}" + } + } + } +} + +project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") { + apply plugin: 'scala' + apply plugin: 'com.github.alisiikh.scalastyle' + + sourceSets { + main { + scala.srcDirs = ['src/main/scala', 'src/main/java'] + java.srcDirs = [] + } + } + + dependencies { + implementation project(path: ':iceberg-bundled-guava', configuration: 'shadow') + api project(':iceberg-api') + annotationProcessor libs.immutables.value + compileOnly libs.immutables.value + implementation project(':iceberg-common') + implementation project(':iceberg-core') + implementation project(':iceberg-data') + implementation project(':iceberg-orc') + implementation project(':iceberg-parquet') + implementation project(':iceberg-arrow') + implementation("org.scala-lang.modules:scala-collection-compat_${scalaVersion}:${libs.versions.scala.collection.compat.get()}") + implementation("org.apache.datasketches:datasketches-java:${libs.versions.datasketches.get()}") + + compileOnly libs.errorprone.annotations + compileOnly libs.avro.avro + compileOnly("org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark41.get()}") { + exclude group: 'org.apache.avro', module: 'avro' + exclude group: 'org.apache.arrow' + exclude group: 'org.apache.parquet' + // to make sure netty libs only come from project(':iceberg-arrow') + exclude group: 'io.netty', module: 'netty-buffer' + exclude group: 'io.netty', module: 'netty-common' + exclude group: 'org.roaringbitmap' + } + + implementation libs.parquet.column + implementation libs.parquet.hadoop + + implementation("${libs.orc.core.get().module}:${libs.versions.orc.get()}:nohive") { + exclude group: 'org.apache.hadoop' + exclude group: 'commons-lang' + // These artifacts are shaded and included in the orc-core fat jar + exclude group: 'com.google.protobuf', module: 'protobuf-java' + exclude group: 'org.apache.hive', module: 'hive-storage-api' + } + + implementation(libs.arrow.vector) { + exclude group: 'io.netty', module: 'netty-buffer' + exclude group: 'io.netty', module: 'netty-common' + exclude group: 'com.google.code.findbugs', module: 'jsr305' + } + + implementation libs.caffeine + + testImplementation(libs.hadoop3.minicluster) { + exclude group: 'org.apache.avro', module: 'avro' + // to make sure netty libs only come from project(':iceberg-arrow') + exclude group: 'io.netty', module: 'netty-buffer' + exclude group: 'io.netty', module: 'netty-common' + } + testImplementation project(path: ':iceberg-hive-metastore') + testImplementation project(path: ':iceberg-hive-metastore', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-orc', configuration: 'testArtifacts') + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) + testImplementation libs.awaitility + testImplementation(testFixtures(project(':iceberg-parquet'))) + } + + test { + useJUnitPlatform() + } + + tasks.withType(Test) { + // Vectorized reads need more memory + maxHeapSize '3160m' + } +} + +project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}") { + apply plugin: 'java-library' + apply plugin: 'scala' + apply plugin: 'com.github.alisiikh.scalastyle' + apply plugin: 'antlr' + + configurations { + /* + The Gradle Antlr plugin erroneously adds both antlr-build and runtime dependencies to the runtime path. This + bug https://github.com/gradle/gradle/issues/820 exists because older versions of Antlr do not have separate + runtime and implementation dependencies and they do not want to break backwards compatibility. So to only end up with + the runtime dependency on the runtime classpath we remove the dependencies added by the plugin here. Then add + the runtime dependency back to only the runtime configuration manually. + */ + implementation { + extendsFrom = extendsFrom.findAll { it != configurations.antlr } + } + } + + dependencies { + implementation("org.scala-lang.modules:scala-collection-compat_${scalaVersion}:${libs.versions.scala.collection.compat.get()}") + implementation libs.roaringbitmap + + compileOnly "org.scala-lang:scala-library" + compileOnly project(path: ':iceberg-bundled-guava', configuration: 'shadow') + compileOnly project(':iceberg-api') + compileOnly project(':iceberg-core') + compileOnly project(':iceberg-common') + compileOnly project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") + compileOnly("org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark41.get()}") { + exclude group: 'org.apache.avro', module: 'avro' + exclude group: 'org.apache.arrow' + exclude group: 'org.apache.parquet' + // to make sure netty libs only come from project(':iceberg-arrow') + exclude group: 'io.netty', module: 'netty-buffer' + exclude group: 'io.netty', module: 'netty-common' + exclude group: 'org.roaringbitmap' + } + compileOnly libs.errorprone.annotations + + testImplementation project(path: ':iceberg-data') + testImplementation project(path: ':iceberg-parquet') + testImplementation project(path: ':iceberg-hive-metastore') + testImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-core', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-data', configuration: 'testArtifacts') + testImplementation project(path: ':iceberg-hive-metastore', configuration: 'testArtifacts') + testImplementation project(path: ":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}", configuration: 'testArtifacts') + testImplementation (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) + testImplementation libs.avro.avro + testImplementation libs.parquet.hadoop + testImplementation libs.awaitility + testImplementation(testFixtures(project(':iceberg-parquet'))) + + // Required because we remove antlr plugin dependencies from the compile configuration, see note above + runtimeOnly libs.antlr.runtime413 + antlr libs.antlr.antlr413 + } + + test { + useJUnitPlatform() + } + + generateGrammarSource { + maxHeapSize = "64m" + arguments += ['-visitor', '-package', 'org.apache.spark.sql.catalyst.parser.extensions'] + } +} + +project(":iceberg-spark:iceberg-spark-runtime-${sparkMajorVersion}_${scalaVersion}") { + apply plugin: 'com.gradleup.shadow' + + tasks.jar.dependsOn tasks.shadowJar + + sourceSets { + integration { + java.srcDir "$projectDir/src/integration/java" + resources.srcDir "$projectDir/src/integration/resources" + } + } + + configurations { + implementation { + exclude group: 'org.apache.spark' + // included in Spark + exclude group: 'org.slf4j' + exclude group: 'org.apache.commons' + exclude group: 'commons-pool' + exclude group: 'commons-codec' + exclude group: 'org.xerial.snappy' + exclude group: 'javax.xml.bind' + exclude group: 'javax.annotation' + exclude group: 'com.github.luben' + exclude group: 'com.ibm.icu' + exclude group: 'org.glassfish' + exclude group: 'org.abego.treelayout' + exclude group: 'org.antlr' + exclude group: 'org.scala-lang' + exclude group: 'org.scala-lang.modules' + } + } + + dependencies { + api project(':iceberg-api') + implementation project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") + implementation project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}") + implementation project(':iceberg-aws') + implementation project(':iceberg-azure') + implementation(project(':iceberg-aliyun')) { + exclude group: 'edu.umd.cs.findbugs', module: 'findbugs' + exclude group: 'org.apache.httpcomponents', module: 'httpclient' + exclude group: 'commons-logging', module: 'commons-logging' + } + implementation project(':iceberg-gcp') + implementation project(':iceberg-bigquery') + implementation project(':iceberg-hive-metastore') + implementation(project(':iceberg-nessie')) { + exclude group: 'com.google.code.findbugs', module: 'jsr305' + } + implementation (project(':iceberg-snowflake')) { + exclude group: 'net.snowflake' , module: 'snowflake-jdbc' + } + + integrationImplementation "org.scala-lang.modules:scala-collection-compat_${scalaVersion}:${libs.versions.scala.collection.compat.get()}" + integrationImplementation("org.apache.spark:spark-hive_${scalaVersion}:${libs.versions.spark41.get()}") { + exclude group: 'org.roaringbitmap' + } + integrationImplementation libs.junit.jupiter + integrationImplementation libs.junit.platform.launcher + integrationImplementation libs.slf4j.simple + integrationImplementation libs.assertj.core + integrationImplementation project(path: ':iceberg-api', configuration: 'testArtifacts') + integrationImplementation project(path: ':iceberg-hive-metastore', configuration: 'testArtifacts') + integrationImplementation project(path: ":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}", configuration: 'testArtifacts') + integrationImplementation project(path: ":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}", configuration: 'testArtifacts') + + // runtime dependencies for running Hive Catalog based integration test + integrationRuntimeOnly project(':iceberg-hive-metastore') + // runtime dependencies for running REST Catalog based integration test + integrationRuntimeOnly project(path: ':iceberg-core', configuration: 'testArtifacts') + integrationRuntimeOnly (project(path: ':iceberg-open-api', configuration: 'testFixturesRuntimeElements')) + + // Not allowed on our classpath, only the runtime jar is allowed + integrationCompileOnly project(":iceberg-spark:iceberg-spark-extensions-${sparkMajorVersion}_${scalaVersion}") + integrationCompileOnly project(":iceberg-spark:iceberg-spark-${sparkMajorVersion}_${scalaVersion}") + integrationCompileOnly project(':iceberg-api') + } + + shadowJar { + configurations = [project.configurations.runtimeClasspath] + + zip64 true + + // include the LICENSE and NOTICE files for the shaded Jar + from(projectDir) { + include 'LICENSE' + include 'NOTICE' + } + + // Relocate dependencies to avoid conflicts + relocate 'com.google.errorprone', 'org.apache.iceberg.shaded.com.google.errorprone' + relocate 'com.google.flatbuffers', 'org.apache.iceberg.shaded.com.google.flatbuffers' + relocate 'com.fasterxml', 'org.apache.iceberg.shaded.com.fasterxml' + relocate 'com.github.benmanes', 'org.apache.iceberg.shaded.com.github.benmanes' + relocate 'org.checkerframework', 'org.apache.iceberg.shaded.org.checkerframework' + relocate 'org.apache.avro', 'org.apache.iceberg.shaded.org.apache.avro' + relocate 'avro.shaded', 'org.apache.iceberg.shaded.org.apache.avro.shaded' + relocate 'com.thoughtworks.paranamer', 'org.apache.iceberg.shaded.com.thoughtworks.paranamer' + relocate 'org.apache.parquet', 'org.apache.iceberg.shaded.org.apache.parquet' + relocate 'shaded.parquet', 'org.apache.iceberg.shaded.org.apache.parquet.shaded' + relocate 'org.apache.orc', 'org.apache.iceberg.shaded.org.apache.orc' + relocate 'io.airlift', 'org.apache.iceberg.shaded.io.airlift' + relocate 'org.apache.hc.client5', 'org.apache.iceberg.shaded.org.apache.hc.client5' + relocate 'org.apache.hc.core5', 'org.apache.iceberg.shaded.org.apache.hc.core5' + // relocate Arrow and related deps to shade Iceberg specific version + relocate 'io.netty', 'org.apache.iceberg.shaded.io.netty' + relocate 'org.apache.arrow', 'org.apache.iceberg.shaded.org.apache.arrow' + relocate 'com.carrotsearch', 'org.apache.iceberg.shaded.com.carrotsearch' + relocate 'org.threeten.extra', 'org.apache.iceberg.shaded.org.threeten.extra' + relocate 'org.roaringbitmap', 'org.apache.iceberg.shaded.org.roaringbitmap' + relocate 'org.apache.datasketches', 'org.apache.iceberg.shaded.org.apache.datasketches' + + archiveClassifier.set(null) + } + + task integrationTest(type: Test) { + useJUnitPlatform() + description = "Test Spark3 Runtime Jar against Spark ${sparkMajorVersion}" + group = "verification" + jvmArgs += project.property('extraJvmArgs') + testClassesDirs = sourceSets.integration.output.classesDirs + classpath = sourceSets.integration.runtimeClasspath + files(shadowJar.archiveFile.get().asFile.path) + inputs.file(shadowJar.archiveFile.get().asFile.path) + } + integrationTest.dependsOn shadowJar + check.dependsOn integrationTest + + jar { + enabled = false + } + + apply from: "${rootDir}/runtime-deps.gradle" +} + diff --git a/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java new file mode 100644 index 000000000000..a468a1cc8717 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/DeleteFileIndexBenchmark.java @@ -0,0 +1,280 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; +import org.apache.iceberg.util.ThreadPools; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that evaluates the delete file index build and lookup performance. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-extensions-4.1_2.13:jmh + * -PjmhIncludeRegex=DeleteFileIndexBenchmark + * -PjmhOutputPath=benchmark/iceberg-delete-file-index-benchmark.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 10) +@Timeout(time = 20, timeUnit = TimeUnit.MINUTES) +@BenchmarkMode(Mode.SingleShotTime) +public class DeleteFileIndexBenchmark { + + private static final String TABLE_NAME = "test_table"; + private static final String PARTITION_COLUMN = "ss_ticket_number"; + + private static final int NUM_PARTITIONS = 50; + private static final int NUM_DATA_FILES_PER_PARTITION = 50_000; + private static final int NUM_DELETE_FILES_PER_PARTITION = 100; + + private final Configuration hadoopConf = new Configuration(); + private SparkSession spark; + private Table table; + + private List dataFiles; + + @Param({"partition", "file", "dv"}) + private String type; + + @Setup + public void setupBenchmark() throws NoSuchTableException, ParseException { + setupSpark(); + initTable(); + initDataAndDeletes(); + loadDataFiles(); + } + + private void initDataAndDeletes() { + if (type.equals("partition")) { + initDataAndPartitionScopedDeletes(); + } else if (type.equals("file")) { + initDataAndFileScopedDeletes(); + } else { + initDataAndDVs(); + } + } + + @TearDown + public void tearDownBenchmark() { + dropTable(); + tearDownSpark(); + } + + @Benchmark + @Threads(1) + public void buildIndexAndLookup(Blackhole blackhole) { + DeleteFileIndex deletes = buildDeletes(); + for (DataFile dataFile : dataFiles) { + DeleteFile[] deleteFiles = deletes.forDataFile(dataFile.dataSequenceNumber(), dataFile); + blackhole.consume(deleteFiles); + } + } + + private void loadDataFiles() { + table.refresh(); + + Snapshot snapshot = table.currentSnapshot(); + + ManifestGroup manifestGroup = + new ManifestGroup(table.io(), snapshot.dataManifests(table.io()), ImmutableList.of()); + + try (CloseableIterable> entries = manifestGroup.entries()) { + List files = Lists.newArrayList(); + for (ManifestEntry entry : entries) { + files.add(entry.file().copyWithoutStats()); + } + this.dataFiles = files; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private DeleteFileIndex buildDeletes() { + table.refresh(); + + List deleteManifests = table.currentSnapshot().deleteManifests(table.io()); + + return DeleteFileIndex.builderFor(table.io(), deleteManifests) + .specsById(table.specs()) + .planWith(ThreadPools.getWorkerPool()) + .build(); + } + + private void initDataAndPartitionScopedDeletes() { + for (int partitionOrdinal = 0; partitionOrdinal < NUM_PARTITIONS; partitionOrdinal++) { + StructLike partition = TestHelpers.Row.of(partitionOrdinal); + + RowDelta rowDelta = table.newRowDelta(); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DATA_FILES_PER_PARTITION; fileOrdinal++) { + DataFile dataFile = FileGenerationUtil.generateDataFile(table, partition); + rowDelta.addRows(dataFile); + } + + for (int fileOrdinal = 0; fileOrdinal < NUM_DELETE_FILES_PER_PARTITION; fileOrdinal++) { + DeleteFile deleteFile = FileGenerationUtil.generatePositionDeleteFile(table, partition); + rowDelta.addDeletes(deleteFile); + } + + rowDelta.commit(); + } + } + + private void initDataAndFileScopedDeletes() { + for (int partitionOrdinal = 0; partitionOrdinal < NUM_PARTITIONS; partitionOrdinal++) { + StructLike partition = TestHelpers.Row.of(partitionOrdinal); + + RowDelta rowDelta = table.newRowDelta(); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DATA_FILES_PER_PARTITION; fileOrdinal++) { + DataFile dataFile = FileGenerationUtil.generateDataFile(table, partition); + DeleteFile deleteFile = FileGenerationUtil.generatePositionDeleteFile(table, dataFile); + rowDelta.addRows(dataFile); + rowDelta.addDeletes(deleteFile); + } + + rowDelta.commit(); + } + } + + private void initDataAndDVs() { + for (int partitionOrdinal = 0; partitionOrdinal < NUM_PARTITIONS; partitionOrdinal++) { + StructLike partition = TestHelpers.Row.of(partitionOrdinal); + + RowDelta rowDelta = table.newRowDelta(); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DATA_FILES_PER_PARTITION; fileOrdinal++) { + DataFile dataFile = FileGenerationUtil.generateDataFile(table, partition); + DeleteFile dv = FileGenerationUtil.generateDV(table, dataFile); + rowDelta.addRows(dataFile); + rowDelta.addDeletes(dv); + } + + rowDelta.commit(); + } + } + + private void setupSpark() { + this.spark = + SparkSession.builder() + .config(TestBase.DISABLE_UI) + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", newWarehouseDir()) + .master("local[*]") + .getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } + + private void initTable() throws NoSuchTableException, ParseException { + sql( + "CREATE TABLE %s ( " + + " `ss_sold_date_sk` INT, " + + " `ss_sold_time_sk` INT, " + + " `ss_item_sk` INT, " + + " `ss_customer_sk` STRING, " + + " `ss_cdemo_sk` STRING, " + + " `ss_hdemo_sk` STRING, " + + " `ss_addr_sk` STRING, " + + " `ss_store_sk` STRING, " + + " `ss_promo_sk` STRING, " + + " `ss_ticket_number` INT, " + + " `ss_quantity` STRING, " + + " `ss_wholesale_cost` STRING, " + + " `ss_list_price` STRING, " + + " `ss_sales_price` STRING, " + + " `ss_ext_discount_amt` STRING, " + + " `ss_ext_sales_price` STRING, " + + " `ss_ext_wholesale_cost` STRING, " + + " `ss_ext_list_price` STRING, " + + " `ss_ext_tax` STRING, " + + " `ss_coupon_amt` STRING, " + + " `ss_net_paid` STRING, " + + " `ss_net_paid_inc_tax` STRING, " + + " `ss_net_profit` STRING " + + ")" + + "USING iceberg " + + "PARTITIONED BY (%s) " + + "TBLPROPERTIES (" + + " '%s' '%b'," + + " '%s' '%s'," + + " '%s' '%d')", + TABLE_NAME, + PARTITION_COLUMN, + TableProperties.MANIFEST_MERGE_ENABLED, + false, + TableProperties.DELETE_MODE, + RowLevelOperationMode.MERGE_ON_READ.modeName(), + TableProperties.FORMAT_VERSION, + type.equals("dv") ? 3 : 2); + + this.table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + } + + private void dropTable() { + sql("DROP TABLE IF EXISTS %s PURGE", TABLE_NAME); + } + + private String newWarehouseDir() { + return hadoopConf.get("hadoop.tmp.dir") + UUID.randomUUID(); + } + + @FormatMethod + private void sql(@FormatString String query, Object... args) { + spark.sql(String.format(query, args)); + } +} diff --git a/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java new file mode 100644 index 000000000000..bc34bf33e35e --- /dev/null +++ b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/MergeCardinalityCheckBenchmark.java @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.util.UUID; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * A benchmark that evaluates the performance of the cardinality check in MERGE operations. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-extensions-4.1_2.13:jmh + * -PjmhIncludeRegex=MergeCardinalityCheckBenchmark + * -PjmhOutputPath=benchmark/iceberg-merge-cardinality-check-benchmark.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +public class MergeCardinalityCheckBenchmark { + + private static final String TABLE_NAME = "test_table"; + private static final int NUM_FILES = 5; + private static final int NUM_ROWS_PER_FILE = 1_000_000; + private static final int NUM_UNMATCHED_RECORDS_PER_MERGE = 100_000; + + private final Configuration hadoopConf = new Configuration(); + private SparkSession spark; + private long originalSnapshotId; + + @Setup + public void setupBenchmark() throws NoSuchTableException, ParseException { + setupSpark(); + initTable(); + appendData(); + + Table table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + this.originalSnapshotId = table.currentSnapshot().snapshotId(); + } + + @TearDown + public void tearDownBenchmark() { + tearDownSpark(); + dropTable(); + } + + @Benchmark + @Threads(1) + public void copyOnWriteMergeCardinalityCheck10PercentUpdates() { + runBenchmark(RowLevelOperationMode.COPY_ON_WRITE, 0.1); + } + + @Benchmark + @Threads(1) + public void copyOnWriteMergeCardinalityCheck30PercentUpdates() { + runBenchmark(RowLevelOperationMode.COPY_ON_WRITE, 0.3); + } + + @Benchmark + @Threads(1) + public void copyOnWriteMergeCardinalityCheck90PercentUpdates() { + runBenchmark(RowLevelOperationMode.COPY_ON_WRITE, 0.9); + } + + @Benchmark + @Threads(1) + public void mergeOnReadMergeCardinalityCheck10PercentUpdates() { + runBenchmark(RowLevelOperationMode.MERGE_ON_READ, 0.1); + } + + @Benchmark + @Threads(1) + public void mergeOnReadMergeCardinalityCheck30PercentUpdates() { + runBenchmark(RowLevelOperationMode.MERGE_ON_READ, 0.3); + } + + @Benchmark + @Threads(1) + public void mergeOnReadMergeCardinalityCheck90PercentUpdates() { + runBenchmark(RowLevelOperationMode.MERGE_ON_READ, 0.9); + } + + private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s')", + TABLE_NAME, TableProperties.MERGE_MODE, mode.modeName()); + + Dataset insertDataDF = spark.range(-NUM_UNMATCHED_RECORDS_PER_MERGE, 0, 1); + Dataset updateDataDF = spark.range((long) (updatePercentage * NUM_ROWS_PER_FILE)); + Dataset sourceDF = updateDataDF.union(insertDataDF); + sourceDF.createOrReplaceTempView("source"); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id = s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET stringCol = 'invalid' " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, intCol, floatCol, doubleCol, decimalCol, dateCol, timestampCol, stringCol) " + + " VALUES (s.id, null, null, null, null, null, null, 'new')", + TABLE_NAME); + + sql( + "CALL system.rollback_to_snapshot(table => '%s', snapshot_id => %dL)", + TABLE_NAME, originalSnapshotId); + } + + private void setupSpark() { + this.spark = + SparkSession.builder() + .config(TestBase.DISABLE_UI) + .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", newWarehouseDir()) + .config(SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED().key(), "false") + .config(SQLConf.RUNTIME_ROW_LEVEL_OPERATION_GROUP_FILTER_ENABLED().key(), "false") + .config(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false") + .config(SQLConf.SHUFFLE_PARTITIONS().key(), "2") + .master("local") + .getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } + + private void initTable() { + sql( + "CREATE TABLE %s ( " + + " id LONG, intCol INT, floatCol FLOAT, doubleCol DOUBLE, " + + " decimalCol DECIMAL(20, 5), dateCol DATE, timestampCol TIMESTAMP, " + + " stringCol STRING)" + + "USING iceberg " + + "TBLPROPERTIES (" + + " '%s' '%s'," + + " '%s' '%d'," + + " '%s' '%d')", + TABLE_NAME, + TableProperties.MERGE_DISTRIBUTION_MODE, + DistributionMode.NONE.modeName(), + TableProperties.SPLIT_OPEN_FILE_COST, + Integer.MAX_VALUE, + TableProperties.FORMAT_VERSION, + 2); + + sql("ALTER TABLE %s WRITE ORDERED BY id", TABLE_NAME); + } + + private void dropTable() { + sql("DROP TABLE IF EXISTS %s PURGE", TABLE_NAME); + } + + private void appendData() throws NoSuchTableException { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset inputDF = + spark + .range(NUM_ROWS_PER_FILE) + .withColumn("intCol", expr("CAST(id AS INT)")) + .withColumn("floatCol", expr("CAST(id AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(id AS DOUBLE)")) + .withColumn("decimalCol", expr("CAST(id AS DECIMAL(20, 5))")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")); + appendAsFile(inputDF); + } + } + + private void appendAsFile(Dataset df) throws NoSuchTableException { + // ensure the schema is precise (including nullability) + StructType sparkSchema = spark.table(TABLE_NAME).schema(); + spark.createDataFrame(df.rdd(), sparkSchema).coalesce(1).writeTo(TABLE_NAME).append(); + } + + private String newWarehouseDir() { + return hadoopConf.get("hadoop.tmp.dir") + UUID.randomUUID(); + } + + @FormatMethod + private void sql(@FormatString String query, Object... args) { + spark.sql(String.format(query, args)); + } +} diff --git a/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java new file mode 100644 index 000000000000..0df55de933cf --- /dev/null +++ b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/PlanningBenchmark.java @@ -0,0 +1,409 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.BatchScan; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.SparkDistributedDataScan; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; +import org.apache.iceberg.types.Conversions; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that evaluates the job planning performance. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-extensions-4.1_2.13:jmh + * -PjmhIncludeRegex=PlanningBenchmark + * -PjmhOutputPath=benchmark/iceberg-planning-benchmark.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@Timeout(time = 20, timeUnit = TimeUnit.MINUTES) +@BenchmarkMode(Mode.SingleShotTime) +public class PlanningBenchmark { + + private static final String TABLE_NAME = "test_table"; + private static final String PARTITION_COLUMN = "ss_ticket_number"; + private static final int PARTITION_VALUE = 10; + private static final String SORT_KEY_COLUMN = "ss_sold_date_sk"; + private static final int SORT_KEY_VALUE = 5; + + private static final Expression SORT_KEY_PREDICATE = + Expressions.equal(SORT_KEY_COLUMN, SORT_KEY_VALUE); + private static final Expression PARTITION_PREDICATE = + Expressions.equal(PARTITION_COLUMN, PARTITION_VALUE); + private static final Expression PARTITION_AND_SORT_KEY_PREDICATE = + Expressions.and(PARTITION_PREDICATE, SORT_KEY_PREDICATE); + + private static final int NUM_PARTITIONS = 30; + private static final int NUM_DATA_FILES_PER_PARTITION = 50_000; + private static final int NUM_DELETE_FILES_PER_PARTITION = 50; + + private final Configuration hadoopConf = new Configuration(); + private SparkSession spark; + private Table table; + + @Param({"partition", "file", "dv"}) + private String type; + + @Setup + public void setupBenchmark() throws NoSuchTableException, ParseException { + setupSpark(); + initTable(); + initDataAndDeletes(); + } + + @TearDown + public void tearDownBenchmark() { + dropTable(); + tearDownSpark(); + } + + @Benchmark + @Threads(1) + public void localPlanningWithPartitionAndMinMaxFilter(Blackhole blackhole) { + BatchScan scan = table.newBatchScan(); + List fileTasks = planFilesWithoutColumnStats(scan, PARTITION_AND_SORT_KEY_PREDICATE); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void distributedPlanningWithPartitionAndMinMaxFilter(Blackhole blackhole) { + BatchScan scan = newDistributedScan(DISTRIBUTED, DISTRIBUTED); + List fileTasks = planFilesWithoutColumnStats(scan, PARTITION_AND_SORT_KEY_PREDICATE); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void localPlanningWithMinMaxFilter(Blackhole blackhole) { + BatchScan scan = table.newBatchScan(); + List fileTasks = planFilesWithoutColumnStats(scan, SORT_KEY_PREDICATE); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void distributedPlanningWithMinMaxFilter(Blackhole blackhole) { + BatchScan scan = newDistributedScan(DISTRIBUTED, DISTRIBUTED); + List fileTasks = planFilesWithoutColumnStats(scan, SORT_KEY_PREDICATE); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void localPlanningWithoutFilter(Blackhole blackhole) { + BatchScan scan = table.newBatchScan(); + List fileTasks = planFilesWithoutColumnStats(scan, Expressions.alwaysTrue()); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void distributedPlanningWithoutFilter(Blackhole blackhole) { + BatchScan scan = newDistributedScan(DISTRIBUTED, DISTRIBUTED); + List fileTasks = planFilesWithoutColumnStats(scan, Expressions.alwaysTrue()); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void localPlanningWithoutFilterWithStats(Blackhole blackhole) { + BatchScan scan = table.newBatchScan(); + List fileTasks = planFilesWithColumnStats(scan, Expressions.alwaysTrue()); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void distributedPlanningWithoutFilterWithStats(Blackhole blackhole) { + BatchScan scan = newDistributedScan(DISTRIBUTED, DISTRIBUTED); + List fileTasks = planFilesWithColumnStats(scan, Expressions.alwaysTrue()); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void distributedDataLocalDeletesPlanningWithoutFilterWithStats(Blackhole blackhole) { + BatchScan scan = newDistributedScan(DISTRIBUTED, LOCAL); + List fileTasks = planFilesWithColumnStats(scan, Expressions.alwaysTrue()); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void localDataDistributedDeletesPlanningWithoutFilterWithStats(Blackhole blackhole) { + BatchScan scan = newDistributedScan(LOCAL, DISTRIBUTED); + List fileTasks = planFilesWithColumnStats(scan, Expressions.alwaysTrue()); + blackhole.consume(fileTasks); + } + + @Benchmark + @Threads(1) + public void localPlanningViaDistributedScanWithoutFilterWithStats(Blackhole blackhole) { + BatchScan scan = newDistributedScan(LOCAL, LOCAL); + List fileTasks = planFilesWithColumnStats(scan, Expressions.alwaysTrue()); + blackhole.consume(fileTasks); + } + + private void setupSpark() { + this.spark = + SparkSession.builder() + .config(TestBase.DISABLE_UI) + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config("spark.driver.maxResultSize", "8G") + .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", newWarehouseDir()) + .master("local[*]") + .getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } + + private void initTable() throws NoSuchTableException, ParseException { + sql( + "CREATE TABLE %s ( " + + " `ss_sold_date_sk` INT, " + + " `ss_sold_time_sk` INT, " + + " `ss_item_sk` INT, " + + " `ss_customer_sk` STRING, " + + " `ss_cdemo_sk` STRING, " + + " `ss_hdemo_sk` STRING, " + + " `ss_addr_sk` STRING, " + + " `ss_store_sk` STRING, " + + " `ss_promo_sk` STRING, " + + " `ss_ticket_number` INT, " + + " `ss_quantity` STRING, " + + " `ss_wholesale_cost` STRING, " + + " `ss_list_price` STRING, " + + " `ss_sales_price` STRING, " + + " `ss_ext_discount_amt` STRING, " + + " `ss_ext_sales_price` STRING, " + + " `ss_ext_wholesale_cost` STRING, " + + " `ss_ext_list_price` STRING, " + + " `ss_ext_tax` STRING, " + + " `ss_coupon_amt` STRING, " + + " `ss_net_paid` STRING, " + + " `ss_net_paid_inc_tax` STRING, " + + " `ss_net_profit` STRING " + + ")" + + "USING iceberg " + + "PARTITIONED BY (%s) " + + "TBLPROPERTIES (" + + " '%s' '%b'," + + " '%s' '%s'," + + " '%s' '%d')", + TABLE_NAME, + PARTITION_COLUMN, + TableProperties.MANIFEST_MERGE_ENABLED, + false, + TableProperties.DELETE_MODE, + RowLevelOperationMode.MERGE_ON_READ.modeName(), + TableProperties.FORMAT_VERSION, + type.equals("dv") ? 3 : 2); + + this.table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + } + + private void dropTable() { + sql("DROP TABLE IF EXISTS %s PURGE", TABLE_NAME); + } + + private void initDataAndDeletes() { + if (type.equals("partition")) { + initDataAndPartitionScopedDeletes(); + } else if (type.equals("file")) { + initDataAndFileScopedDeletes(); + } else { + initDataAndDVs(); + } + } + + private void initDataAndPartitionScopedDeletes() { + for (int partitionOrdinal = 0; partitionOrdinal < NUM_PARTITIONS; partitionOrdinal++) { + StructLike partition = TestHelpers.Row.of(partitionOrdinal); + + RowDelta rowDelta = table.newRowDelta(); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DATA_FILES_PER_PARTITION; fileOrdinal++) { + DataFile dataFile = generateDataFile(partition, Integer.MIN_VALUE, Integer.MIN_VALUE); + rowDelta.addRows(dataFile); + } + + // add one data file that would match the sort key predicate + DataFile sortKeyDataFile = generateDataFile(partition, SORT_KEY_VALUE, SORT_KEY_VALUE); + rowDelta.addRows(sortKeyDataFile); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DELETE_FILES_PER_PARTITION; fileOrdinal++) { + DeleteFile deleteFile = FileGenerationUtil.generatePositionDeleteFile(table, partition); + rowDelta.addDeletes(deleteFile); + } + + rowDelta.commit(); + } + } + + private void initDataAndFileScopedDeletes() { + for (int partitionOrdinal = 0; partitionOrdinal < NUM_PARTITIONS; partitionOrdinal++) { + StructLike partition = TestHelpers.Row.of(partitionOrdinal); + + RowDelta rowDelta = table.newRowDelta(); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DATA_FILES_PER_PARTITION; fileOrdinal++) { + DataFile dataFile = generateDataFile(partition, Integer.MIN_VALUE, Integer.MIN_VALUE); + DeleteFile deleteFile = FileGenerationUtil.generatePositionDeleteFile(table, dataFile); + rowDelta.addRows(dataFile); + rowDelta.addDeletes(deleteFile); + } + + // add one data file that would match the sort key predicate + DataFile sortKeyDataFile = generateDataFile(partition, SORT_KEY_VALUE, SORT_KEY_VALUE); + rowDelta.addRows(sortKeyDataFile); + + rowDelta.commit(); + } + } + + private void initDataAndDVs() { + for (int partitionOrdinal = 0; partitionOrdinal < NUM_PARTITIONS; partitionOrdinal++) { + StructLike partition = TestHelpers.Row.of(partitionOrdinal); + + RowDelta rowDelta = table.newRowDelta(); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DATA_FILES_PER_PARTITION; fileOrdinal++) { + DataFile dataFile = generateDataFile(partition, Integer.MIN_VALUE, Integer.MIN_VALUE); + DeleteFile dv = FileGenerationUtil.generateDV(table, dataFile); + rowDelta.addRows(dataFile); + rowDelta.addDeletes(dv); + } + + // add one data file that would match the sort key predicate + DataFile sortKeyDataFile = generateDataFile(partition, SORT_KEY_VALUE, SORT_KEY_VALUE); + rowDelta.addRows(sortKeyDataFile); + + rowDelta.commit(); + } + } + + private DataFile generateDataFile(StructLike partition, int sortKeyMin, int sortKeyMax) { + int sortKeyFieldId = table.schema().findField(SORT_KEY_COLUMN).fieldId(); + ByteBuffer lower = Conversions.toByteBuffer(Types.IntegerType.get(), sortKeyMin); + Map lowerBounds = ImmutableMap.of(sortKeyFieldId, lower); + ByteBuffer upper = Conversions.toByteBuffer(Types.IntegerType.get(), sortKeyMax); + Map upperBounds = ImmutableMap.of(sortKeyFieldId, upper); + return FileGenerationUtil.generateDataFile(table, partition, lowerBounds, upperBounds); + } + + private String newWarehouseDir() { + return hadoopConf.get("hadoop.tmp.dir") + UUID.randomUUID(); + } + + private List planFilesWithoutColumnStats(BatchScan scan, Expression predicate) { + return planFiles(scan, predicate, false); + } + + private List planFilesWithColumnStats(BatchScan scan, Expression predicate) { + return planFiles(scan, predicate, true); + } + + private List planFiles(BatchScan scan, Expression predicate, boolean withColumnStats) { + table.refresh(); + + BatchScan configuredScan = scan.filter(predicate); + + if (withColumnStats) { + configuredScan = scan.includeColumnStats(); + } + + try (CloseableIterable fileTasks = configuredScan.planFiles()) { + return Lists.newArrayList(fileTasks); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private BatchScan newDistributedScan(PlanningMode dataMode, PlanningMode deleteMode) { + table + .updateProperties() + .set(TableProperties.DATA_PLANNING_MODE, dataMode.modeName()) + .set(TableProperties.DELETE_PLANNING_MODE, deleteMode.modeName()) + .commit(); + SparkReadConf readConf = new SparkReadConf(spark, table); + return new SparkDistributedDataScan(spark, table, readConf); + } + + @FormatMethod + private void sql(@FormatString String query, Object... args) { + spark.sql(String.format(query, args)); + } +} diff --git a/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java new file mode 100644 index 000000000000..fd3eab4d9df6 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/TaskGroupPlanningBenchmark.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; +import org.apache.iceberg.util.TableScanUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that evaluates the task group planning performance. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-extensions-4.1_2.13:jmh + * -PjmhIncludeRegex=TaskGroupPlanningBenchmark + * -PjmhOutputPath=benchmark/iceberg-task-group-planning-benchmark.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@Timeout(time = 30, timeUnit = TimeUnit.MINUTES) +@BenchmarkMode(Mode.SingleShotTime) +public class TaskGroupPlanningBenchmark { + + private static final String TABLE_NAME = "test_table"; + private static final String PARTITION_COLUMN = "ss_ticket_number"; + + private static final int NUM_PARTITIONS = 150; + private static final int NUM_DATA_FILES_PER_PARTITION = 50_000; + private static final int NUM_DELETE_FILES_PER_PARTITION = 25; + + private final Configuration hadoopConf = new Configuration(); + private SparkSession spark; + private Table table; + + private List fileTasks; + + @Setup + public void setupBenchmark() throws NoSuchTableException, ParseException { + setupSpark(); + initTable(); + initDataAndDeletes(); + loadFileTasks(); + } + + @TearDown + public void tearDownBenchmark() { + dropTable(); + tearDownSpark(); + } + + @Benchmark + @Threads(1) + public void planTaskGroups(Blackhole blackhole) { + SparkReadConf readConf = new SparkReadConf(spark, table); + List> taskGroups = + TableScanUtil.planTaskGroups( + fileTasks, + readConf.splitSize(), + readConf.splitLookback(), + readConf.splitOpenFileCost()); + + long rowsCount = 0L; + for (ScanTaskGroup taskGroup : taskGroups) { + rowsCount += taskGroup.estimatedRowsCount(); + } + blackhole.consume(rowsCount); + + long filesCount = 0L; + for (ScanTaskGroup taskGroup : taskGroups) { + filesCount += taskGroup.filesCount(); + } + blackhole.consume(filesCount); + + long sizeBytes = 0L; + for (ScanTaskGroup taskGroup : taskGroups) { + sizeBytes += taskGroup.sizeBytes(); + } + blackhole.consume(sizeBytes); + } + + @Benchmark + @Threads(1) + public void planTaskGroupsWithGrouping(Blackhole blackhole) { + SparkReadConf readConf = new SparkReadConf(spark, table); + + List> taskGroups = + TableScanUtil.planTaskGroups( + fileTasks, + readConf.splitSize(), + readConf.splitLookback(), + readConf.splitOpenFileCost(), + Partitioning.groupingKeyType(table.schema(), table.specs().values())); + + long rowsCount = 0L; + for (ScanTaskGroup taskGroup : taskGroups) { + rowsCount += taskGroup.estimatedRowsCount(); + } + blackhole.consume(rowsCount); + + long filesCount = 0L; + for (ScanTaskGroup taskGroup : taskGroups) { + filesCount += taskGroup.filesCount(); + } + blackhole.consume(filesCount); + + long sizeBytes = 0L; + for (ScanTaskGroup taskGroup : taskGroups) { + sizeBytes += taskGroup.sizeBytes(); + } + blackhole.consume(sizeBytes); + } + + private void loadFileTasks() { + table.refresh(); + + try (CloseableIterable fileTasksIterable = table.newScan().planFiles()) { + this.fileTasks = Lists.newArrayList(fileTasksIterable); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void initDataAndDeletes() { + for (int partitionOrdinal = 0; partitionOrdinal < NUM_PARTITIONS; partitionOrdinal++) { + StructLike partition = TestHelpers.Row.of(partitionOrdinal); + + RowDelta rowDelta = table.newRowDelta(); + + for (int fileOrdinal = 0; fileOrdinal < NUM_DATA_FILES_PER_PARTITION; fileOrdinal++) { + DataFile dataFile = FileGenerationUtil.generateDataFile(table, partition); + rowDelta.addRows(dataFile); + } + + for (int fileOrdinal = 0; fileOrdinal < NUM_DELETE_FILES_PER_PARTITION; fileOrdinal++) { + DeleteFile deleteFile = FileGenerationUtil.generatePositionDeleteFile(table, partition); + rowDelta.addDeletes(deleteFile); + } + + rowDelta.commit(); + } + } + + private void setupSpark() { + this.spark = + SparkSession.builder() + .config(TestBase.DISABLE_UI) + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", newWarehouseDir()) + .master("local[*]") + .getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } + + private void initTable() throws NoSuchTableException, ParseException { + sql( + "CREATE TABLE %s ( " + + " `ss_sold_date_sk` INT, " + + " `ss_sold_time_sk` INT, " + + " `ss_item_sk` INT, " + + " `ss_customer_sk` STRING, " + + " `ss_cdemo_sk` STRING, " + + " `ss_hdemo_sk` STRING, " + + " `ss_addr_sk` STRING, " + + " `ss_store_sk` STRING, " + + " `ss_promo_sk` STRING, " + + " `ss_ticket_number` INT, " + + " `ss_quantity` STRING, " + + " `ss_wholesale_cost` STRING, " + + " `ss_list_price` STRING, " + + " `ss_sales_price` STRING, " + + " `ss_ext_discount_amt` STRING, " + + " `ss_ext_sales_price` STRING, " + + " `ss_ext_wholesale_cost` STRING, " + + " `ss_ext_list_price` STRING, " + + " `ss_ext_tax` STRING, " + + " `ss_coupon_amt` STRING, " + + " `ss_net_paid` STRING, " + + " `ss_net_paid_inc_tax` STRING, " + + " `ss_net_profit` STRING " + + ")" + + "USING iceberg " + + "PARTITIONED BY (%s) " + + "TBLPROPERTIES (" + + " '%s' '%b'," + + " '%s' '%s'," + + " '%s' '%d')", + TABLE_NAME, + PARTITION_COLUMN, + TableProperties.MANIFEST_MERGE_ENABLED, + false, + TableProperties.DELETE_MODE, + RowLevelOperationMode.MERGE_ON_READ.modeName(), + TableProperties.FORMAT_VERSION, + 2); + + this.table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + } + + private void dropTable() { + sql("DROP TABLE IF EXISTS %s PURGE", TABLE_NAME); + } + + private String newWarehouseDir() { + return hadoopConf.get("hadoop.tmp.dir") + UUID.randomUUID(); + } + + @FormatMethod + private void sql(@FormatString String query, Object... args) { + spark.sql(String.format(query, args)); + } +} diff --git a/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java new file mode 100644 index 000000000000..caa23625fc44 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/jmh/java/org/apache/iceberg/spark/UpdateProjectionBenchmark.java @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.util.UUID; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +public class UpdateProjectionBenchmark { + + private static final String TABLE_NAME = "test_table"; + private static final int NUM_FILES = 5; + private static final int NUM_ROWS_PER_FILE = 1_000_000; + + private final Configuration hadoopConf = new Configuration(); + private SparkSession spark; + private long originalSnapshotId; + + @Setup + public void setupBenchmark() throws NoSuchTableException, ParseException { + setupSpark(); + initTable(); + appendData(); + + Table table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + this.originalSnapshotId = table.currentSnapshot().snapshotId(); + } + + @TearDown + public void tearDownBenchmark() { + tearDownSpark(); + dropTable(); + } + + @Benchmark + @Threads(1) + public void copyOnWriteUpdate10Percent() { + runBenchmark(RowLevelOperationMode.COPY_ON_WRITE, 0.1); + } + + @Benchmark + @Threads(1) + public void copyOnWriteUpdate30Percent() { + runBenchmark(RowLevelOperationMode.COPY_ON_WRITE, 0.3); + } + + @Benchmark + @Threads(1) + public void copyOnWriteUpdate75Percent() { + runBenchmark(RowLevelOperationMode.COPY_ON_WRITE, 0.75); + } + + @Benchmark + @Threads(1) + public void mergeOnRead10Percent() { + runBenchmark(RowLevelOperationMode.MERGE_ON_READ, 0.1); + } + + @Benchmark + @Threads(1) + public void mergeOnReadUpdate30Percent() { + runBenchmark(RowLevelOperationMode.MERGE_ON_READ, 0.3); + } + + @Benchmark + @Threads(1) + public void mergeOnReadUpdate75Percent() { + runBenchmark(RowLevelOperationMode.MERGE_ON_READ, 0.75); + } + + private void runBenchmark(RowLevelOperationMode mode, double updatePercentage) { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s')", + TABLE_NAME, TableProperties.UPDATE_MODE, mode.modeName()); + + int mod = (int) (NUM_ROWS_PER_FILE / (NUM_ROWS_PER_FILE * updatePercentage)); + + sql( + "UPDATE %s " + + "SET intCol = intCol + 10, dateCol = date_add(dateCol, 1) " + + "WHERE mod(id, %d) = 0", + TABLE_NAME, mod); + + sql( + "CALL system.rollback_to_snapshot(table => '%s', snapshot_id => %dL)", + TABLE_NAME, originalSnapshotId); + } + + private void setupSpark() { + this.spark = + SparkSession.builder() + .config(TestBase.DISABLE_UI) + .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", newWarehouseDir()) + .config(SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED().key(), "false") + .config(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false") + .config(SQLConf.SHUFFLE_PARTITIONS().key(), "2") + .master("local") + .getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } + + private void initTable() { + sql( + "CREATE TABLE %s ( " + + " id LONG, intCol INT, floatCol FLOAT, doubleCol DOUBLE, " + + " decimalCol DECIMAL(20, 5), dateCol DATE, timestampCol TIMESTAMP, " + + " stringCol STRING)" + + "USING iceberg " + + "TBLPROPERTIES (" + + " '%s' '%s'," + + " '%s' '%d'," + + " '%s' '%d')", + TABLE_NAME, + TableProperties.UPDATE_DISTRIBUTION_MODE, + DistributionMode.NONE.modeName(), + TableProperties.SPLIT_OPEN_FILE_COST, + Integer.MAX_VALUE, + TableProperties.FORMAT_VERSION, + 2); + + sql("ALTER TABLE %s WRITE ORDERED BY id", TABLE_NAME); + } + + private void dropTable() { + sql("DROP TABLE IF EXISTS %s PURGE", TABLE_NAME); + } + + private void appendData() throws NoSuchTableException { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset inputDF = + spark + .range(NUM_ROWS_PER_FILE) + .withColumn("intCol", expr("CAST(id AS INT)")) + .withColumn("floatCol", expr("CAST(id AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(id AS DOUBLE)")) + .withColumn("decimalCol", expr("CAST(id AS DECIMAL(20, 5))")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")); + appendAsFile(inputDF); + } + } + + private void appendAsFile(Dataset df) throws NoSuchTableException { + // ensure the schema is precise (including nullability) + StructType sparkSchema = spark.table(TABLE_NAME).schema(); + spark.createDataFrame(df.rdd(), sparkSchema).coalesce(1).writeTo(TABLE_NAME).append(); + } + + private String newWarehouseDir() { + return hadoopConf.get("hadoop.tmp.dir") + UUID.randomUUID(); + } + + @FormatMethod + private void sql(@FormatString String query, Object... args) { + spark.sql(String.format(query, args)); + } +} diff --git a/spark/v4.1/spark-extensions/src/main/antlr/org.apache.spark.sql.catalyst.parser.extensions/IcebergSqlExtensions.g4 b/spark/v4.1/spark-extensions/src/main/antlr/org.apache.spark.sql.catalyst.parser.extensions/IcebergSqlExtensions.g4 new file mode 100644 index 000000000000..4c2a16d7b19a --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/antlr/org.apache.spark.sql.catalyst.parser.extensions/IcebergSqlExtensions.g4 @@ -0,0 +1,367 @@ +/* + * 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. + * + * This file is an adaptation of Presto's and Spark's grammar files. + */ + +grammar IcebergSqlExtensions; + +@lexer::members { + /** + * Verify whether current token is a valid decimal token (which contains dot). + * Returns true if the character that follows the token is not a digit or letter or underscore. + * + * For example: + * For char stream "2.3", "2." is not a valid decimal token, because it is followed by digit '3'. + * For char stream "2.3_", "2.3" is not a valid decimal token, because it is followed by '_'. + * For char stream "2.3W", "2.3" is not a valid decimal token, because it is followed by 'W'. + * For char stream "12.0D 34.E2+0.12 " 12.0D is a valid decimal token because it is followed + * by a space. 34.E2 is a valid decimal token because it is followed by symbol '+' + * which is not a digit or letter or underscore. + */ + public boolean isValidDecimal() { + int nextChar = _input.LA(1); + if (nextChar >= 'A' && nextChar <= 'Z' || nextChar >= '0' && nextChar <= '9' || + nextChar == '_') { + return false; + } else { + return true; + } + } + + /** + * This method will be called when we see '/*' and try to match it as a bracketed comment. + * If the next character is '+', it should be parsed as hint later, and we cannot match + * it as a bracketed comment. + * + * Returns true if the next character is '+'. + */ + public boolean isHint() { + int nextChar = _input.LA(1); + if (nextChar == '+') { + return true; + } else { + return false; + } + } +} + +singleStatement + : statement EOF + ; + +statement + : ALTER TABLE multipartIdentifier ADD PARTITION FIELD transform (AS name=identifier)? #addPartitionField + | ALTER TABLE multipartIdentifier DROP PARTITION FIELD transform #dropPartitionField + | ALTER TABLE multipartIdentifier REPLACE PARTITION FIELD transform WITH transform (AS name=identifier)? #replacePartitionField + | ALTER TABLE multipartIdentifier WRITE writeSpec #setWriteDistributionAndOrdering + | ALTER TABLE multipartIdentifier SET IDENTIFIER_KW FIELDS fieldList #setIdentifierFields + | ALTER TABLE multipartIdentifier DROP IDENTIFIER_KW FIELDS fieldList #dropIdentifierFields + | ALTER TABLE multipartIdentifier createReplaceBranchClause #createOrReplaceBranch + | ALTER TABLE multipartIdentifier createReplaceTagClause #createOrReplaceTag + | ALTER TABLE multipartIdentifier DROP BRANCH (IF EXISTS)? identifier #dropBranch + | ALTER TABLE multipartIdentifier DROP TAG (IF EXISTS)? identifier #dropTag + ; + +createReplaceTagClause + : (CREATE OR)? REPLACE TAG identifier tagOptions + | CREATE TAG (IF NOT EXISTS)? identifier tagOptions + ; + +createReplaceBranchClause + : (CREATE OR)? REPLACE BRANCH identifier branchOptions + | CREATE BRANCH (IF NOT EXISTS)? identifier branchOptions + ; + +tagOptions + : (AS OF VERSION snapshotId)? (refRetain)? + ; + +branchOptions + : (AS OF VERSION snapshotId)? (refRetain)? (snapshotRetention)? + ; + +snapshotRetention + : WITH SNAPSHOT RETENTION minSnapshotsToKeep + | WITH SNAPSHOT RETENTION maxSnapshotAge + | WITH SNAPSHOT RETENTION minSnapshotsToKeep maxSnapshotAge + ; + +refRetain + : RETAIN number timeUnit + ; + +maxSnapshotAge + : number timeUnit + ; + +minSnapshotsToKeep + : number SNAPSHOTS + ; + +writeSpec + : (writeDistributionSpec | writeOrderingSpec)* + ; + +writeDistributionSpec + : DISTRIBUTED BY PARTITION + ; + +writeOrderingSpec + : LOCALLY? ORDERED BY order + | UNORDERED + ; + +singleOrder + : order EOF + ; + +order + : fields+=orderField (',' fields+=orderField)* + | '(' fields+=orderField (',' fields+=orderField)* ')' + ; + +orderField + : transform direction=(ASC | DESC)? (NULLS nullOrder=(FIRST | LAST))? + ; + +transform + : multipartIdentifier #identityTransform + | transformName=identifier + '(' arguments+=transformArgument (',' arguments+=transformArgument)* ')' #applyTransform + ; + +transformArgument + : multipartIdentifier + | constant + ; + +expression + : constant + | stringMap + | stringArray + ; + +constant + : number #numericLiteral + | booleanValue #booleanLiteral + | STRING+ #stringLiteral + | identifier STRING #typeConstructor + ; + +stringMap + : MAP '(' constant (',' constant)* ')' + ; + +stringArray + : ARRAY '(' constant (',' constant)* ')' + ; + +booleanValue + : TRUE | FALSE + ; + +number + : MINUS? EXPONENT_VALUE #exponentLiteral + | MINUS? DECIMAL_VALUE #decimalLiteral + | MINUS? INTEGER_VALUE #integerLiteral + | MINUS? BIGINT_LITERAL #bigIntLiteral + | MINUS? SMALLINT_LITERAL #smallIntLiteral + | MINUS? TINYINT_LITERAL #tinyIntLiteral + | MINUS? DOUBLE_LITERAL #doubleLiteral + | MINUS? FLOAT_LITERAL #floatLiteral + | MINUS? BIGDECIMAL_LITERAL #bigDecimalLiteral + ; + +multipartIdentifier + : parts+=identifier ('.' parts+=identifier)* + ; + +identifier + : IDENTIFIER #unquotedIdentifier + | quotedIdentifier #quotedIdentifierAlternative + | nonReserved #unquotedIdentifier + ; + +quotedIdentifier + : BACKQUOTED_IDENTIFIER + ; + +fieldList + : fields+=multipartIdentifier (',' fields+=multipartIdentifier)* + ; + +nonReserved + : ADD | ALTER | AS | ASC | BRANCH | BY | CREATE | DAYS | DESC | DROP | EXISTS | FIELD | FIRST | HOURS | IF | LAST | NOT | NULLS | OF | OR | ORDERED | PARTITION | TABLE | WRITE + | DISTRIBUTED | LOCALLY | MINUTES | MONTHS | UNORDERED | REPLACE | RETAIN | VERSION | WITH | IDENTIFIER_KW | FIELDS | SET | SNAPSHOT | SNAPSHOTS + | TAG | TRUE | FALSE + | MAP + ; + +snapshotId + : number + ; + +numSnapshots + : number + ; + +timeUnit + : DAYS + | HOURS + | MINUTES + ; + +ADD: 'ADD'; +ALTER: 'ALTER'; +AS: 'AS'; +ASC: 'ASC'; +BRANCH: 'BRANCH'; +BY: 'BY'; +DAYS: 'DAYS'; +DESC: 'DESC'; +DISTRIBUTED: 'DISTRIBUTED'; +DROP: 'DROP'; +EXISTS: 'EXISTS'; +FIELD: 'FIELD'; +FIELDS: 'FIELDS'; +FIRST: 'FIRST'; +HOURS: 'HOURS'; +IF : 'IF'; +LAST: 'LAST'; +LOCALLY: 'LOCALLY'; +MINUTES: 'MINUTES'; +MONTHS: 'MONTHS'; +CREATE: 'CREATE'; +NOT: 'NOT'; +NULLS: 'NULLS'; +OF: 'OF'; +OR: 'OR'; +ORDERED: 'ORDERED'; +PARTITION: 'PARTITION'; +REPLACE: 'REPLACE'; +RETAIN: 'RETAIN'; +RETENTION: 'RETENTION'; +IDENTIFIER_KW: 'IDENTIFIER'; +SET: 'SET'; +SNAPSHOT: 'SNAPSHOT'; +SNAPSHOTS: 'SNAPSHOTS'; +TABLE: 'TABLE'; +TAG: 'TAG'; +UNORDERED: 'UNORDERED'; +VERSION: 'VERSION'; +WITH: 'WITH'; +WRITE: 'WRITE'; + +TRUE: 'TRUE'; +FALSE: 'FALSE'; + +MAP: 'MAP'; +ARRAY: 'ARRAY'; + +PLUS: '+'; +MINUS: '-'; + +STRING + : '\'' ( ~('\''|'\\') | ('\\' .) )* '\'' + | '"' ( ~('"'|'\\') | ('\\' .) )* '"' + ; + +BIGINT_LITERAL + : DIGIT+ 'L' + ; + +SMALLINT_LITERAL + : DIGIT+ 'S' + ; + +TINYINT_LITERAL + : DIGIT+ 'Y' + ; + +INTEGER_VALUE + : DIGIT+ + ; + +EXPONENT_VALUE + : DIGIT+ EXPONENT + | DECIMAL_DIGITS EXPONENT {isValidDecimal()}? + ; + +DECIMAL_VALUE + : DECIMAL_DIGITS {isValidDecimal()}? + ; + +FLOAT_LITERAL + : DIGIT+ EXPONENT? 'F' + | DECIMAL_DIGITS EXPONENT? 'F' {isValidDecimal()}? + ; + +DOUBLE_LITERAL + : DIGIT+ EXPONENT? 'D' + | DECIMAL_DIGITS EXPONENT? 'D' {isValidDecimal()}? + ; + +BIGDECIMAL_LITERAL + : DIGIT+ EXPONENT? 'BD' + | DECIMAL_DIGITS EXPONENT? 'BD' {isValidDecimal()}? + ; + +IDENTIFIER + : (LETTER | DIGIT | '_')+ + ; + +BACKQUOTED_IDENTIFIER + : '`' ( ~'`' | '``' )* '`' + ; + +fragment DECIMAL_DIGITS + : DIGIT+ '.' DIGIT* + | '.' DIGIT+ + ; + +fragment EXPONENT + : 'E' [+-]? DIGIT+ + ; + +fragment DIGIT + : [0-9] + ; + +fragment LETTER + : [A-Z] + ; + +SIMPLE_COMMENT + : '--' ('\\\n' | ~[\r\n])* '\r'? '\n'? -> channel(HIDDEN) + ; + +BRACKETED_COMMENT + : '/*' {!isHint()}? (BRACKETED_COMMENT|.)*? '*/' -> channel(HIDDEN) + ; + +WS + : [ \r\n\t]+ -> channel(HIDDEN) + ; + +// Catch-all for anything we can't recognize. +// We use this to be able to ignore and recover all the text +// when splitting statements with DelimiterLexer +UNRECOGNIZED + : . + ; diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala new file mode 100644 index 000000000000..81824e05e92d --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions + +import org.apache.spark.sql.SparkSessionExtensions +import org.apache.spark.sql.catalyst.analysis.CheckViews +import org.apache.spark.sql.catalyst.analysis.ResolveBranch +import org.apache.spark.sql.catalyst.analysis.ResolveViews +import org.apache.spark.sql.catalyst.optimizer.ReplaceStaticInvoke +import org.apache.spark.sql.catalyst.parser.extensions.IcebergSparkSqlExtensionsParser +import org.apache.spark.sql.execution.datasources.v2.ExtendedDataSourceV2Strategy + +class IcebergSparkSessionExtensions extends (SparkSessionExtensions => Unit) { + + override def apply(extensions: SparkSessionExtensions): Unit = { + // parser extensions + extensions.injectParser { case (_, parser) => new IcebergSparkSqlExtensionsParser(parser) } + + // analyzer extensions + extensions.injectResolutionRule { spark => ResolveViews(spark) } + extensions.injectPostHocResolutionRule { spark => ResolveBranch(spark) } + extensions.injectCheckRule(_ => CheckViews) + + // optimizer extensions + extensions.injectOptimizerRule { _ => ReplaceStaticInvoke } + + // planner extensions + extensions.injectPlannerStrategy { spark => ExtendedDataSourceV2Strategy(spark) } + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckViews.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckViews.scala new file mode 100644 index 000000000000..5ad4b9c01409 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckViews.scala @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.analysis + +import org.apache.spark.sql.AnalysisException +import org.apache.spark.sql.catalyst.expressions.SubqueryExpression +import org.apache.spark.sql.catalyst.plans.logical.AlterViewAs +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.Project +import org.apache.spark.sql.catalyst.plans.logical.SubqueryAlias +import org.apache.spark.sql.catalyst.plans.logical.View +import org.apache.spark.sql.catalyst.plans.logical.views.CreateIcebergView +import org.apache.spark.sql.catalyst.plans.logical.views.ResolvedV2View +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.internal.SQLConf +import org.apache.spark.sql.util.SchemaUtils + +object CheckViews extends (LogicalPlan => Unit) { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override def apply(plan: LogicalPlan): Unit = { + plan foreach { + case CreateIcebergView( + resolvedIdent @ ResolvedIdentifier(_: ViewCatalog, _), + _, + query, + columnAliases, + _, + _, + _, + _, + _, + replace, + _, + _) => + verifyColumnCount(resolvedIdent, columnAliases, query) + SchemaUtils.checkColumnNameDuplication( + query.schema.fieldNames.toIndexedSeq, + SQLConf.get.resolver) + if (replace) { + val viewIdent: Seq[String] = + resolvedIdent.catalog.name() +: resolvedIdent.identifier.asMultipartIdentifier + checkCyclicViewReference(viewIdent, query, Seq(viewIdent)) + } + + case AlterViewAs(ResolvedV2View(_, _), _, _) => + throw new IcebergAnalysisException( + "ALTER VIEW AS is not supported. Use CREATE OR REPLACE VIEW instead") + + case _ => // OK + } + } + + private def verifyColumnCount( + ident: ResolvedIdentifier, + columns: Seq[String], + query: LogicalPlan): Unit = { + if (columns.nonEmpty) { + if (columns.length > query.output.length) { + throw new AnalysisException( + errorClass = "CREATE_VIEW_COLUMN_ARITY_MISMATCH.NOT_ENOUGH_DATA_COLUMNS", + messageParameters = Map( + "viewName" -> String.format("%s.%s", ident.catalog.name(), ident.identifier), + "viewColumns" -> columns.mkString(", "), + "dataColumns" -> query.output.map(c => c.name).mkString(", "))) + } else if (columns.length < query.output.length) { + throw new AnalysisException( + errorClass = "CREATE_VIEW_COLUMN_ARITY_MISMATCH.TOO_MANY_DATA_COLUMNS", + messageParameters = Map( + "viewName" -> String.format("%s.%s", ident.catalog.name(), ident.identifier), + "viewColumns" -> columns.mkString(", "), + "dataColumns" -> query.output.map(c => c.name).mkString(", "))) + } + } + } + + private def checkCyclicViewReference( + viewIdent: Seq[String], + plan: LogicalPlan, + cyclePath: Seq[Seq[String]]): Unit = { + plan match { + case sub @ SubqueryAlias(_, Project(_, _)) => + val currentViewIdent: Seq[String] = sub.identifier.qualifier :+ sub.identifier.name + checkIfRecursiveView(viewIdent, currentViewIdent, cyclePath, sub.children) + case v1View: View => + val currentViewIdent: Seq[String] = v1View.desc.identifier.nameParts + checkIfRecursiveView(viewIdent, currentViewIdent, cyclePath, v1View.children) + case _ => + plan.children.foreach(child => checkCyclicViewReference(viewIdent, child, cyclePath)) + } + + plan.expressions.flatMap(_.flatMap { + case e: SubqueryExpression => + checkCyclicViewReference(viewIdent, e.plan, cyclePath) + None + case _ => None + }) + } + + private def checkIfRecursiveView( + viewIdent: Seq[String], + currentViewIdent: Seq[String], + cyclePath: Seq[Seq[String]], + children: Seq[LogicalPlan]): Unit = { + val newCyclePath = cyclePath :+ currentViewIdent + if (currentViewIdent == viewIdent) { + throw new IcebergAnalysisException( + String.format( + "Recursive cycle in view detected: %s (cycle: %s)", + viewIdent.asIdentifier, + newCyclePath.map(p => p.mkString(".")).mkString(" -> "))) + } else { + children.foreach { c => + checkCyclicViewReference(viewIdent, c, newCyclePath) + } + } + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBranch.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBranch.scala new file mode 100644 index 000000000000..397c93324cd7 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveBranch.scala @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.analysis + +import org.apache.iceberg.spark.PathIdentifier +import org.apache.iceberg.spark.SparkTableUtil +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.RowLevelWrite +import org.apache.spark.sql.catalyst.plans.logical.V2WriteCommand +import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.write.RowLevelOperation +import org.apache.spark.sql.connector.write.RowLevelOperationInfoImpl +import org.apache.spark.sql.connector.write.RowLevelOperationTable +import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation +import org.apache.spark.sql.execution.datasources.v2.ExtractV2Table +import org.apache.spark.sql.util.CaseInsensitiveStringMap + +/** + * A rule that resolves the target branch for Iceberg reads and writes. + *

    + * The branch must be determined and pinned during analysis. The current DSv2 framework + * doesn't provide access to all necessary options during the initial table loading, + * forcing us to finalize the branch selection in a custom analyzer rule. Future Spark + * versions will have a built-in mechanism to cleanly determine the target branch. + * See SPARK-55842 for details. + */ +case class ResolveBranch(spark: SparkSession) extends Rule[LogicalPlan] { + + override def apply(plan: LogicalPlan): LogicalPlan = plan resolveOperators { + // row-level operations like DELETE, UPDATE, and MERGE + case w @ IcebergRowLevelWrite(table, operation, options) => + val branch = SparkTableUtil.determineWriteBranch(spark, table, options) + if (table.branch != branch) { + val newTable = table.copyWithBranch(branch) + val info = RowLevelOperationInfoImpl(operation.command, options) + val newOperation = newTable.newRowLevelOperationBuilder(info).build() + val newOperationTable = RowLevelOperationTable(newTable, newOperation) + val newTarget = transformPreservingType(w.table) { + case r @ ExtractV2Table(RowLevelOperationTable(_, _)) => r.copy(table = newOperationTable) + } + val newQuery = transformPreservingType(w.query) { + case r @ ExtractV2Table(RowLevelOperationTable(_, _)) => r.copy(table = newOperationTable) + } + w.withNewTable(newTarget).withNewQuery(newQuery) + } else { + w + } + + // batch write operations like append or overwrite + case w: V2WriteCommand => + val newTarget = transformPreservingType(w.table) { + case r @ DataSourceV2Relation(table: SparkTable, _, _, _, options, _) => + val branch = SparkTableUtil.determineWriteBranch(spark, table, options) + if (table.branch != branch) r.copy(table = table.copyWithBranch(branch)) else r + } + w.withNewTable(newTarget) + + // scan operations + // branch selector is added to identifier to ensure further refreshes point to correct branch + case r @ DataSourceV2Relation(table: SparkTable, _, _, Some(ident), options, None) => + val branch = SparkTableUtil.determineReadBranch(spark, table, options) + if (table.branch != branch) { + val branchSelector = s"branch_$branch" + val newIdent = ident match { + case path: PathIdentifier if path.location.contains("#") => + new PathIdentifier(path.location + "," + branchSelector) + case path: PathIdentifier => + new PathIdentifier(path.location + "#" + branchSelector) + case _ => + Identifier.of(ident.namespace :+ ident.name, branchSelector) + } + r.copy(table = table.copyWithBranch(branch), identifier = Some(newIdent)) + } else { + r + } + } + + private def transformPreservingType[T <: LogicalPlan](plan: T)( + func: PartialFunction[LogicalPlan, LogicalPlan]): T = { + plan.transform(func).asInstanceOf[T] + } +} + +// Iceberg specific extractor for row-level operations like DELETE, UPDATE, and MERGE +private object IcebergRowLevelWrite { + def unapply( + write: RowLevelWrite): Option[(SparkTable, RowLevelOperation, CaseInsensitiveStringMap)] = { + EliminateSubqueryAliases(write.table) match { + case DataSourceV2Relation( + RowLevelOperationTable(table: SparkTable, operation), + _, + _, + _, + options, + _) => + Some((table, operation, options)) + case _ => None + } + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveViews.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveViews.scala new file mode 100644 index 000000000000..76db30a5b619 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveViews.scala @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.analysis + +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.FunctionIdentifier +import org.apache.spark.sql.catalyst.analysis.ViewUtil.IcebergViewHelper +import org.apache.spark.sql.catalyst.expressions.Alias +import org.apache.spark.sql.catalyst.expressions.SubqueryExpression +import org.apache.spark.sql.catalyst.expressions.UpCast +import org.apache.spark.sql.catalyst.parser.ParseException +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.Project +import org.apache.spark.sql.catalyst.plans.logical.SubqueryAlias +import org.apache.spark.sql.catalyst.plans.logical.views.CreateIcebergView +import org.apache.spark.sql.catalyst.plans.logical.views.ResolvedV2View +import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.spark.sql.catalyst.trees.CurrentOrigin +import org.apache.spark.sql.catalyst.trees.Origin +import org.apache.spark.sql.connector.catalog.CatalogManager +import org.apache.spark.sql.connector.catalog.LookupCatalog +import org.apache.spark.sql.connector.catalog.View +import org.apache.spark.sql.errors.QueryCompilationErrors +import org.apache.spark.sql.types.MetadataBuilder + +case class ResolveViews(spark: SparkSession) extends Rule[LogicalPlan] with LookupCatalog { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + protected lazy val catalogManager: CatalogManager = spark.sessionState.catalogManager + + override def apply(plan: LogicalPlan): LogicalPlan = plan resolveOperators { + case u @ UnresolvedRelation(nameParts, _, _) + if catalogManager.v1SessionCatalog.isTempView(nameParts) => + u + + case u @ UnresolvedRelation(parts @ CatalogAndIdentifier(catalog, ident), _, _) => + ViewUtil + .loadView(catalog, ident) + .map(createViewRelation(parts, _)) + .getOrElse(u) + + case u @ UnresolvedTableOrView(CatalogAndIdentifier(catalog, ident), _, _) => + ViewUtil + .loadView(catalog, ident) + .map(_ => ResolvedV2View(catalog.asViewCatalog, ident)) + .getOrElse(u) + + case c @ CreateIcebergView( + ResolvedIdentifier(_, _), + _, + query, + columnAliases, + columnComments, + _, + _, + _, + _, + _, + _, + _) if query.resolved && !c.rewritten => + val aliased = aliasColumns(query, columnAliases, columnComments) + c.copy( + query = aliased, + queryColumnNames = query.schema.fieldNames.toIndexedSeq, + rewritten = true) + } + + private def aliasColumns( + plan: LogicalPlan, + columnAliases: Seq[String], + columnComments: Seq[Option[String]]): LogicalPlan = { + if (columnAliases.isEmpty || columnAliases.length != plan.output.length) { + plan + } else { + val projectList = plan.output.zipWithIndex.map { case (attr, pos) => + if (columnComments.apply(pos).isDefined) { + val meta = + new MetadataBuilder().putString("comment", columnComments.apply(pos).get).build() + Alias(attr, columnAliases.apply(pos))(explicitMetadata = Some(meta)) + } else { + Alias(attr, columnAliases.apply(pos))() + } + } + Project(projectList, plan) + } + } + + private def createViewRelation(nameParts: Seq[String], view: View): LogicalPlan = { + val parsed = parseViewText(nameParts.quoted, view.query) + + // Apply any necessary rewrites to preserve correct resolution + val viewCatalogAndNamespace: Seq[String] = view.currentCatalog +: view.currentNamespace.toSeq + val rewritten = rewriteIdentifiers(parsed, viewCatalogAndNamespace); + + // Apply the field aliases and column comments + // This logic differs from how Spark handles views in SessionCatalog.fromCatalogTable. + // This is more strict because it doesn't allow resolution by field name. + val aliases = view.schema.fields.zipWithIndex.map { case (expected, pos) => + val attr = GetColumnByOrdinal(pos, expected.dataType) + Alias(UpCast(attr, expected.dataType), expected.name)(explicitMetadata = + Some(expected.metadata)) + }.toIndexedSeq + + SubqueryAlias(nameParts, Project(aliases, rewritten)) + } + + private def parseViewText(name: String, viewText: String): LogicalPlan = { + val origin = Origin(objectType = Some("VIEW"), objectName = Some(name)) + + try { + CurrentOrigin.withOrigin(origin) { + spark.sessionState.sqlParser.parseQuery(viewText) + } + } catch { + case _: ParseException => + throw QueryCompilationErrors.invalidViewNameError(name) + } + } + + private def rewriteIdentifiers( + plan: LogicalPlan, + catalogAndNamespace: Seq[String]): LogicalPlan = { + // Rewrite unresolved functions and relations + qualifyTableIdentifiers( + qualifyFunctionIdentifiers(CTESubstitution.apply(plan), catalogAndNamespace), + catalogAndNamespace) + } + + private def qualifyFunctionIdentifiers( + plan: LogicalPlan, + catalogAndNamespace: Seq[String]): LogicalPlan = plan transformExpressions { + case u @ UnresolvedFunction(Seq(name), _, _, _, _, _, _) => + if (!isBuiltinFunction(name)) { + u.copy(nameParts = catalogAndNamespace :+ name) + } else { + u + } + case u @ UnresolvedFunction(parts, _, _, _, _, _, _) if !isCatalog(parts.head) => + u.copy(nameParts = catalogAndNamespace.head +: parts) + } + + /** + * Qualify table identifiers with default catalog and namespace if necessary. + */ + private def qualifyTableIdentifiers( + child: LogicalPlan, + catalogAndNamespace: Seq[String]): LogicalPlan = + child transform { + case u @ UnresolvedRelation(Seq(table), _, _) => + u.copy(multipartIdentifier = catalogAndNamespace :+ table) + case u @ UnresolvedRelation(parts, _, _) if !isCatalog(parts.head) => + u.copy(multipartIdentifier = catalogAndNamespace.head +: parts) + case other => + other.transformExpressions { case subquery: SubqueryExpression => + subquery.withNewPlan(qualifyTableIdentifiers(subquery.plan, catalogAndNamespace)) + } + } + + private def isCatalog(name: String): Boolean = { + catalogManager.isCatalogRegistered(name) + } + + private def isBuiltinFunction(name: String): Boolean = { + catalogManager.v1SessionCatalog.isBuiltinFunction(FunctionIdentifier(name)) + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteViewCommands.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteViewCommands.scala new file mode 100644 index 000000000000..ac0f75c422d1 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/RewriteViewCommands.scala @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.analysis + +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.analysis.ViewUtil.IcebergViewHelper +import org.apache.spark.sql.catalyst.expressions.SubqueryExpression +import org.apache.spark.sql.catalyst.plans.logical.CreateView +import org.apache.spark.sql.catalyst.plans.logical.DropView +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.ShowViews +import org.apache.spark.sql.catalyst.plans.logical.View +import org.apache.spark.sql.catalyst.plans.logical.views.CreateIcebergView +import org.apache.spark.sql.catalyst.plans.logical.views.DropIcebergView +import org.apache.spark.sql.catalyst.plans.logical.views.ResolvedV2View +import org.apache.spark.sql.catalyst.plans.logical.views.ShowIcebergViews +import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.spark.sql.catalyst.trees.TreePattern.UNRESOLVED_FUNCTION +import org.apache.spark.sql.connector.catalog.CatalogManager +import org.apache.spark.sql.connector.catalog.LookupCatalog +import scala.collection.mutable + +/** + * ResolveSessionCatalog exits early for some v2 View commands, + * thus they are pre-substituted here and then handled in ResolveViews + */ +case class RewriteViewCommands(spark: SparkSession) extends Rule[LogicalPlan] with LookupCatalog { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + protected lazy val catalogManager: CatalogManager = spark.sessionState.catalogManager + + override def apply(plan: LogicalPlan): LogicalPlan = plan.resolveOperatorsUp { + case DropView(ResolvedIdent(resolved), ifExists) => + DropIcebergView(resolved, ifExists) + + case CreateView( + ResolvedIdent(resolved), + userSpecifiedColumns, + comment, + _, + properties, + Some(queryText), + query, + allowExisting, + replace, + _) => + val q = CTESubstitution.apply(query) + verifyTemporaryObjectsDontExist(resolved, q) + CreateIcebergView( + child = resolved, + queryText = queryText, + query = q, + columnAliases = userSpecifiedColumns.map(_._1), + columnComments = userSpecifiedColumns.map(_._2.orElse(Option.empty)), + comment = comment, + properties = properties, + allowExisting = allowExisting, + replace = replace) + + case view @ ShowViews(CurrentNamespace, pattern, output) => + if (ViewUtil.isViewCatalog(catalogManager.currentCatalog)) { + ShowIcebergViews( + ResolvedNamespace( + catalogManager.currentCatalog, + catalogManager.currentNamespace.toIndexedSeq), + pattern, + output) + } else { + view + } + + case ShowViews(UnresolvedNamespace(CatalogAndNamespace(catalog, ns), _), pattern, output) + if ViewUtil.isViewCatalog(catalog) => + ShowIcebergViews(ResolvedNamespace(catalog, ns), pattern, output) + + // needs to be done here instead of in ResolveViews, so that a V2 view can be resolved before the Analyzer + // tries to resolve it, which would result in an error, saying that V2 views aren't supported + case u @ UnresolvedView(ResolvedView(resolved), _, _, _) => + ViewUtil + .loadView(resolved.catalog, resolved.identifier) + .map(_ => ResolvedV2View(resolved.catalog.asViewCatalog, resolved.identifier)) + .getOrElse(u) + } + + private def isTempView(nameParts: Seq[String]): Boolean = { + catalogManager.v1SessionCatalog.isTempView(nameParts) + } + + private def isTempFunction(nameParts: Seq[String]): Boolean = { + if (nameParts.size > 1) { + return false + } + catalogManager.v1SessionCatalog.isTemporaryFunction(nameParts.asFunctionIdentifier) + } + + private object ResolvedIdent { + def unapply(unresolved: UnresolvedIdentifier): Option[ResolvedIdentifier] = unresolved match { + case UnresolvedIdentifier(nameParts, true) if isTempView(nameParts) => + None + + case UnresolvedIdentifier(CatalogAndIdentifier(catalog, ident), _) + if ViewUtil.isViewCatalog(catalog) => + Some(ResolvedIdentifier(catalog, ident)) + + case _ => + None + } + } + + /** + * Permanent views are not allowed to reference temp objects + */ + private def verifyTemporaryObjectsDontExist( + identifier: ResolvedIdentifier, + child: LogicalPlan): Unit = { + val tempViews = collectTemporaryViews(child) + if (tempViews.nonEmpty) { + throw invalidRefToTempObject( + identifier, + tempViews.map(v => v.quoted).mkString("[", ", ", "]"), + "view") + } + + val tempFunctions = collectTemporaryFunctions(child) + if (tempFunctions.nonEmpty) { + throw invalidRefToTempObject(identifier, tempFunctions.mkString("[", ", ", "]"), "function") + } + } + + private def invalidRefToTempObject( + ident: ResolvedIdentifier, + tempObjectNames: String, + tempObjectType: String) = { + new IcebergAnalysisException( + String.format( + "Cannot create view %s.%s that references temporary %s: %s", + ident.catalog.name(), + ident.identifier, + tempObjectType, + tempObjectNames)) + } + + /** + * Collect all temporary views and return the identifiers separately + */ + private def collectTemporaryViews(child: LogicalPlan): Seq[Seq[String]] = { + def collectTempViews(child: LogicalPlan): Seq[Seq[String]] = { + child.flatMap { + case unresolved: UnresolvedRelation if isTempView(unresolved.multipartIdentifier) => + Seq(unresolved.multipartIdentifier) + case view: View if view.isTempView => Seq(view.desc.identifier.nameParts) + case plan => + plan.expressions.flatMap(_.flatMap { + case e: SubqueryExpression => collectTempViews(e.plan) + case _ => Seq.empty + }) + }.distinct + } + + collectTempViews(child) + } + + private object ResolvedView { + def unapply(identifier: Seq[String]): Option[ResolvedV2View] = identifier match { + case nameParts if isTempView(nameParts) => + None + + case CatalogAndIdentifier(catalog, ident) if ViewUtil.isViewCatalog(catalog) => + ViewUtil + .loadView(catalog, ident) + .flatMap(_ => Some(ResolvedV2View(catalog.asViewCatalog, ident))) + + case _ => + None + } + } + + /** + * Collect the names of all temporary functions. + */ + private def collectTemporaryFunctions(child: LogicalPlan): Seq[String] = { + val tempFunctions = new mutable.HashSet[String]() + child.resolveExpressionsWithPruning(_.containsAnyPattern(UNRESOLVED_FUNCTION)) { + case f @ UnresolvedFunction(nameParts, _, _, _, _, _, _) if isTempFunction(nameParts) => + tempFunctions += nameParts.head + f + case e: SubqueryExpression => + tempFunctions ++= collectTemporaryFunctions(e.plan) + e + } + tempFunctions.toSeq + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ViewUtil.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ViewUtil.scala new file mode 100644 index 000000000000..c27cb140347e --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ViewUtil.scala @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.analysis + +import org.apache.spark.sql.connector.catalog.CatalogPlugin +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.View +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.errors.QueryCompilationErrors + +object ViewUtil { + def loadView(catalog: CatalogPlugin, ident: Identifier): Option[View] = catalog match { + case viewCatalog: ViewCatalog => + try { + Option(viewCatalog.loadView(ident)) + } catch { + case _: NoSuchViewException => None + } + case _ => None + } + + def isViewCatalog(catalog: CatalogPlugin): Boolean = { + catalog.isInstanceOf[ViewCatalog] + } + + implicit class IcebergViewHelper(plugin: CatalogPlugin) { + def asViewCatalog: ViewCatalog = plugin match { + case viewCatalog: ViewCatalog => + viewCatalog + case _ => + throw QueryCompilationErrors.missingCatalogViewsAbilityError(plugin) + } + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ReplaceStaticInvoke.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ReplaceStaticInvoke.scala new file mode 100644 index 000000000000..bdec4aae884d --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ReplaceStaticInvoke.scala @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.optimizer + +import org.apache.iceberg.spark.functions.SparkFunctions +import org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression +import org.apache.spark.sql.catalyst.expressions.BinaryComparison +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.expressions.In +import org.apache.spark.sql.catalyst.expressions.InSet +import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke +import org.apache.spark.sql.catalyst.plans.logical.Filter +import org.apache.spark.sql.catalyst.plans.logical.Join +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.ReplaceData +import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.spark.sql.catalyst.trees.TreePattern.BINARY_COMPARISON +import org.apache.spark.sql.catalyst.trees.TreePattern.COMMAND +import org.apache.spark.sql.catalyst.trees.TreePattern.FILTER +import org.apache.spark.sql.catalyst.trees.TreePattern.IN +import org.apache.spark.sql.catalyst.trees.TreePattern.INSET +import org.apache.spark.sql.catalyst.trees.TreePattern.JOIN +import org.apache.spark.sql.connector.catalog.functions.ScalarFunction +import org.apache.spark.sql.types.StructField +import org.apache.spark.sql.types.StructType + +/** + * Spark analyzes the Iceberg system function to {@link StaticInvoke} which could not be pushed + * down to datasource. This rule will replace {@link StaticInvoke} to + * {@link ApplyFunctionExpression} for Iceberg system function in a filter condition. + */ +object ReplaceStaticInvoke extends Rule[LogicalPlan] { + + override def apply(plan: LogicalPlan): LogicalPlan = + plan.transformWithPruning(_.containsAnyPattern(COMMAND, FILTER, JOIN)) { + case replace @ ReplaceData(_, cond, _, _, _, _, _) => + replaceStaticInvoke(replace, cond, newCond => replace.copy(condition = newCond)) + + case join @ Join(_, _, _, Some(cond), _) => + replaceStaticInvoke(join, cond, newCond => join.copy(condition = Some(newCond))) + + case filter @ Filter(cond, _) => + replaceStaticInvoke(filter, cond, newCond => filter.copy(condition = newCond)) + } + + private def replaceStaticInvoke[T <: LogicalPlan]( + node: T, + condition: Expression, + copy: Expression => T): T = { + val newCondition = replaceStaticInvoke(condition) + if (newCondition fastEquals condition) node else copy(newCondition) + } + + private def replaceStaticInvoke(condition: Expression): Expression = { + condition.transformWithPruning(_.containsAnyPattern(BINARY_COMPARISON, IN, INSET)) { + case in @ In(value: StaticInvoke, _) if canReplace(value) => + in.copy(value = replaceStaticInvoke(value)) + + case in @ InSet(value: StaticInvoke, _) if canReplace(value) => + in.copy(child = replaceStaticInvoke(value)) + + case c @ BinaryComparison(left: StaticInvoke, right) if canReplace(left) && right.foldable => + c.withNewChildren(Seq(replaceStaticInvoke(left), right)) + + case c @ BinaryComparison(left, right: StaticInvoke) if canReplace(right) && left.foldable => + c.withNewChildren(Seq(left, replaceStaticInvoke(right))) + } + } + + private def replaceStaticInvoke(invoke: StaticInvoke): Expression = { + // Adaptive from `resolveV2Function` in org.apache.spark.sql.catalyst.analysis.ResolveFunctions + val unbound = SparkFunctions.loadFunctionByClass(invoke.staticObject) + if (unbound == null) { + return invoke + } + + val inputType = StructType(invoke.arguments.zipWithIndex.map { case (exp, pos) => + StructField(s"_$pos", exp.dataType, exp.nullable) + }) + + val bound = + try { + unbound.bind(inputType) + } catch { + case _: Exception => + return invoke + } + + if (bound.inputTypes().length != invoke.arguments.length) { + return invoke + } + + bound match { + case scalarFunc: ScalarFunction[_] => + ApplyFunctionExpression(scalarFunc, invoke.arguments) + case _ => invoke + } + } + + @inline + private def canReplace(invoke: StaticInvoke): Boolean = { + invoke.functionName == ScalarFunction.MAGIC_METHOD_NAME && !invoke.foldable + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/IcebergSparkSqlExtensionsParser.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/IcebergSparkSqlExtensionsParser.scala new file mode 100644 index 000000000000..ac127f754a91 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/IcebergSparkSqlExtensionsParser.scala @@ -0,0 +1,342 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.parser.extensions + +import java.util.Locale +import org.antlr.v4.runtime._ +import org.antlr.v4.runtime.atn.PredictionMode +import org.antlr.v4.runtime.misc.Interval +import org.antlr.v4.runtime.misc.ParseCancellationException +import org.antlr.v4.runtime.tree.TerminalNodeImpl +import org.apache.iceberg.common.DynConstructors +import org.apache.iceberg.spark.ExtendedParser +import org.apache.iceberg.spark.ExtendedParser.RawOrderField +import org.apache.spark.sql.AnalysisException +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.FunctionIdentifier +import org.apache.spark.sql.catalyst.TableIdentifier +import org.apache.spark.sql.catalyst.analysis.RewriteViewCommands +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.parser.ParserInterface +import org.apache.spark.sql.catalyst.parser.extensions.IcebergSqlExtensionsParser.NonReservedContext +import org.apache.spark.sql.catalyst.parser.extensions.IcebergSqlExtensionsParser.QuotedIdentifierContext +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.trees.Origin +import org.apache.spark.sql.internal.SQLConf +import org.apache.spark.sql.internal.VariableSubstitution +import org.apache.spark.sql.types.DataType +import org.apache.spark.sql.types.StructType +import scala.jdk.CollectionConverters._ + +class IcebergSparkSqlExtensionsParser(delegate: ParserInterface) + extends ParserInterface + with ExtendedParser { + + import IcebergSparkSqlExtensionsParser._ + + private lazy val substitutor = substitutorCtor.newInstance(SQLConf.get) + private lazy val astBuilder = new IcebergSqlExtensionsAstBuilder(delegate) + + /** + * Parse a string to a DataType. + */ + override def parseDataType(sqlText: String): DataType = { + delegate.parseDataType(sqlText) + } + + /** + * Parse a string to a raw DataType without CHAR/VARCHAR replacement. + */ + def parseRawDataType(sqlText: String): DataType = throw new UnsupportedOperationException() + + /** + * Parse a string to an Expression. + */ + override def parseExpression(sqlText: String): Expression = { + delegate.parseExpression(sqlText) + } + + /** + * Parse a string to a TableIdentifier. + */ + override def parseTableIdentifier(sqlText: String): TableIdentifier = { + delegate.parseTableIdentifier(sqlText) + } + + /** + * Parse a string to a FunctionIdentifier. + */ + override def parseFunctionIdentifier(sqlText: String): FunctionIdentifier = { + delegate.parseFunctionIdentifier(sqlText) + } + + /** + * Parse a string to a multi-part identifier. + */ + override def parseMultipartIdentifier(sqlText: String): Seq[String] = { + delegate.parseMultipartIdentifier(sqlText) + } + + /** + * Creates StructType for a given SQL string, which is a comma separated list of field + * definitions which will preserve the correct Hive metadata. + */ + override def parseTableSchema(sqlText: String): StructType = { + delegate.parseTableSchema(sqlText) + } + + override def parseSortOrder(sqlText: String): java.util.List[RawOrderField] = { + val fields = parse(sqlText) { parser => astBuilder.visitSingleOrder(parser.singleOrder()) } + fields.map { field => + val (term, direction, order) = field + new RawOrderField(term, direction, order) + }.asJava + } + + override def parseRoutineParam(sqlText: String): StructType = + throw new UnsupportedOperationException() + + /** + * Parse a string to a LogicalPlan. + */ + override def parsePlan(sqlText: String): LogicalPlan = { + val sqlTextAfterSubstitution = substitutor.substitute(sqlText) + if (isIcebergCommand(sqlTextAfterSubstitution)) { + parse(sqlTextAfterSubstitution) { parser => astBuilder.visit(parser.singleStatement()) } + .asInstanceOf[LogicalPlan] + } else { + RewriteViewCommands(SparkSession.active).apply(delegate.parsePlan(sqlText)) + } + } + + private def isIcebergCommand(sqlText: String): Boolean = { + val normalized = sqlText + .toLowerCase(Locale.ROOT) + .trim() + // Strip simple SQL comments that terminate a line, e.g. comments starting with `--` . + .replaceAll("--.*?\\n", " ") + // Strip newlines. + .replaceAll("\\s+", " ") + // Strip comments of the form /* ... */. This must come after stripping newlines so that + // comments that span multiple lines are caught. + .replaceAll("/\\*.*?\\*/", " ") + // Strip backtick then `system`.`ancestors_of` changes to system.ancestors_of + .replaceAll("`", "") + .trim() + + normalized.startsWith("alter table") && (normalized.contains("add partition field") || + normalized.contains("drop partition field") || + normalized.contains("replace partition field") || + normalized.contains("write ordered by") || + normalized.contains("write locally ordered by") || + normalized.contains("write distributed by") || + normalized.contains("write unordered") || + normalized.contains("set identifier fields") || + normalized.contains("drop identifier fields") || + isSnapshotRefDdl(normalized)) + } + + private def isSnapshotRefDdl(normalized: String): Boolean = { + normalized.contains("create branch") || + normalized.contains("replace branch") || + normalized.contains("create tag") || + normalized.contains("replace tag") || + normalized.contains("drop branch") || + normalized.contains("drop tag") + } + + protected def parse[T](command: String)(toResult: IcebergSqlExtensionsParser => T): T = { + val lexer = new IcebergSqlExtensionsLexer( + new UpperCaseCharStream(CharStreams.fromString(command))) + lexer.removeErrorListeners() + lexer.addErrorListener(IcebergParseErrorListener) + + val tokenStream = new CommonTokenStream(lexer) + val parser = new IcebergSqlExtensionsParser(tokenStream) + parser.addParseListener(IcebergSqlExtensionsPostProcessor) + parser.removeErrorListeners() + parser.addErrorListener(IcebergParseErrorListener) + + // https://github.com/antlr/antlr4/issues/192#issuecomment-15238595 + // Save a great deal of time on correct inputs by using a two-stage parsing strategy. + try { + try { + // first, try parsing with potentially faster SLL mode and BailErrorStrategy + parser.setErrorHandler(new BailErrorStrategy) + parser.getInterpreter.setPredictionMode(PredictionMode.SLL) + toResult(parser) + } catch { + case _: ParseCancellationException => + // if we fail, parse with LL mode with DefaultErrorStrategy + tokenStream.seek(0) // rewind input stream + parser.reset() + + // Try Again. + parser.setErrorHandler(new DefaultErrorStrategy) + parser.getInterpreter.setPredictionMode(PredictionMode.LL) + toResult(parser) + } + } catch { + case e: IcebergParseException if e.command.isDefined => + throw e + case e: IcebergParseException => + throw e.withCommand(command) + case e: AnalysisException => + val position = Origin(e.line, e.startPosition) + throw new IcebergParseException(Option(command), e.message, position, position) + } + } + + override def parseQuery(sqlText: String): LogicalPlan = { + parsePlan(sqlText) + } +} + +object IcebergSparkSqlExtensionsParser { + private val substitutorCtor: DynConstructors.Ctor[VariableSubstitution] = + DynConstructors + .builder() + .impl(classOf[VariableSubstitution]) + .impl(classOf[VariableSubstitution], classOf[SQLConf]) + .build() +} + +/* Copied from Apache Spark's to avoid dependency on Spark Internals */ +class UpperCaseCharStream(wrapped: CodePointCharStream) extends CharStream { + override def consume(): Unit = wrapped.consume + override def getSourceName(): String = wrapped.getSourceName + override def index(): Int = wrapped.index + override def mark(): Int = wrapped.mark + override def release(marker: Int): Unit = wrapped.release(marker) + override def seek(where: Int): Unit = wrapped.seek(where) + override def size(): Int = wrapped.size + + override def getText(interval: Interval): String = wrapped.getText(interval) + + // scalastyle:off + override def LA(i: Int): Int = { + val la = wrapped.LA(i) + if (la == 0 || la == IntStream.EOF) la + else Character.toUpperCase(la) + } + // scalastyle:on +} + +/** + * The post-processor validates & cleans-up the parse tree during the parse process. + */ +case object IcebergSqlExtensionsPostProcessor extends IcebergSqlExtensionsBaseListener { + + /** Remove the back ticks from an Identifier. */ + override def exitQuotedIdentifier(ctx: QuotedIdentifierContext): Unit = { + replaceTokenByIdentifier(ctx, 1) { token => + // Remove the double back ticks in the string. + token.setText(token.getText.replace("``", "`")) + token + } + } + + /** Treat non-reserved keywords as Identifiers. */ + override def exitNonReserved(ctx: NonReservedContext): Unit = { + replaceTokenByIdentifier(ctx, 0)(identity) + } + + private def replaceTokenByIdentifier(ctx: ParserRuleContext, stripMargins: Int)( + f: CommonToken => CommonToken = identity): Unit = { + val parent = ctx.getParent + parent.removeLastChild() + val token = ctx.getChild(0).getPayload.asInstanceOf[Token] + val newToken = new CommonToken( + new org.antlr.v4.runtime.misc.Pair(token.getTokenSource, token.getInputStream), + IcebergSqlExtensionsParser.IDENTIFIER, + token.getChannel, + token.getStartIndex + stripMargins, + token.getStopIndex - stripMargins) + parent.addChild(new TerminalNodeImpl(f(newToken))) + } +} + +/* Partially copied from Apache Spark's Parser to avoid dependency on Spark Internals */ +case object IcebergParseErrorListener extends BaseErrorListener { + override def syntaxError( + recognizer: Recognizer[_, _], + offendingSymbol: scala.Any, + line: Int, + charPositionInLine: Int, + msg: String, + e: RecognitionException): Unit = { + val (start, stop) = offendingSymbol match { + case token: CommonToken => + val start = Origin(Some(line), Some(token.getCharPositionInLine)) + val length = token.getStopIndex - token.getStartIndex + 1 + val stop = Origin(Some(line), Some(token.getCharPositionInLine + length)) + (start, stop) + case _ => + val start = Origin(Some(line), Some(charPositionInLine)) + (start, start) + } + throw new IcebergParseException(None, msg, start, stop) + } +} + +/** + * Copied from Apache Spark + * A [[ParseException]] is an [[AnalysisException]] that is thrown during the parse process. It + * contains fields and an extended error message that make reporting and diagnosing errors easier. + */ +class IcebergParseException( + val command: Option[String], + message: String, + val start: Origin, + val stop: Origin) + extends AnalysisException(message, start.line, start.startPosition) { + + def this(message: String, ctx: ParserRuleContext) = { + this( + Option(IcebergParserUtils.command(ctx)), + message, + IcebergParserUtils.position(ctx.getStart), + IcebergParserUtils.position(ctx.getStop)) + } + + override def getMessage: String = { + val builder = new StringBuilder + builder ++= "\n" ++= message + start match { + case Origin(Some(l), Some(p), Some(_), Some(_), Some(_), Some(_), Some(_), _, _, _) => + builder ++= s"(line $l, pos $p)\n" + command.foreach { cmd => + val (above, below) = cmd.split("\n").splitAt(l) + builder ++= "\n== SQL ==\n" + above.foreach(builder ++= _ += '\n') + builder ++= (0 until p).map(_ => "-").mkString("") ++= "^^^\n" + below.foreach(builder ++= _ += '\n') + } + case _ => + command.foreach { cmd => + builder ++= "\n== SQL ==\n" ++= cmd + } + } + builder.toString + } + + def withCommand(cmd: String): IcebergParseException = { + new IcebergParseException(Option(cmd), message, start, stop) + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/IcebergSqlExtensionsAstBuilder.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/IcebergSqlExtensionsAstBuilder.scala new file mode 100644 index 000000000000..724101cfe11d --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/parser/extensions/IcebergSqlExtensionsAstBuilder.scala @@ -0,0 +1,385 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.parser.extensions + +import java.util.Locale +import java.util.concurrent.TimeUnit +import org.antlr.v4.runtime._ +import org.antlr.v4.runtime.misc.Interval +import org.antlr.v4.runtime.tree.ParseTree +import org.antlr.v4.runtime.tree.TerminalNode +import org.apache.iceberg.DistributionMode +import org.apache.iceberg.NullOrder +import org.apache.iceberg.SortDirection +import org.apache.iceberg.expressions.Term +import org.apache.iceberg.spark.Spark3Util +import org.apache.spark.sql.catalyst.analysis.IcebergAnalysisException +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.expressions.Literal +import org.apache.spark.sql.catalyst.parser.ParserInterface +import org.apache.spark.sql.catalyst.parser.extensions.IcebergParserUtils.withOrigin +import org.apache.spark.sql.catalyst.parser.extensions.IcebergSqlExtensionsParser._ +import org.apache.spark.sql.catalyst.plans.logical.AddPartitionField +import org.apache.spark.sql.catalyst.plans.logical.BranchOptions +import org.apache.spark.sql.catalyst.plans.logical.CreateOrReplaceBranch +import org.apache.spark.sql.catalyst.plans.logical.CreateOrReplaceTag +import org.apache.spark.sql.catalyst.plans.logical.DropBranch +import org.apache.spark.sql.catalyst.plans.logical.DropIdentifierFields +import org.apache.spark.sql.catalyst.plans.logical.DropPartitionField +import org.apache.spark.sql.catalyst.plans.logical.DropTag +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.ReplacePartitionField +import org.apache.spark.sql.catalyst.plans.logical.SetIdentifierFields +import org.apache.spark.sql.catalyst.plans.logical.SetWriteDistributionAndOrdering +import org.apache.spark.sql.catalyst.plans.logical.TagOptions +import org.apache.spark.sql.catalyst.trees.CurrentOrigin +import org.apache.spark.sql.catalyst.trees.Origin +import org.apache.spark.sql.connector.expressions +import org.apache.spark.sql.connector.expressions.ApplyTransform +import org.apache.spark.sql.connector.expressions.FieldReference +import org.apache.spark.sql.connector.expressions.IdentityTransform +import org.apache.spark.sql.connector.expressions.LiteralValue +import org.apache.spark.sql.connector.expressions.Transform +import scala.jdk.CollectionConverters._ + +class IcebergSqlExtensionsAstBuilder(delegate: ParserInterface) + extends IcebergSqlExtensionsBaseVisitor[AnyRef] { + + private def toBuffer[T](list: java.util.List[T]): scala.collection.mutable.Buffer[T] = + list.asScala + private def toSeq[T](list: java.util.List[T]): Seq[T] = toBuffer(list).toSeq + + /** + * Create an ADD PARTITION FIELD logical command. + */ + override def visitAddPartitionField(ctx: AddPartitionFieldContext): AddPartitionField = + withOrigin(ctx) { + AddPartitionField( + typedVisit[Seq[String]](ctx.multipartIdentifier), + typedVisit[Transform](ctx.transform), + Option(ctx.name).map(_.getText)) + } + + /** + * Create a DROP PARTITION FIELD logical command. + */ + override def visitDropPartitionField(ctx: DropPartitionFieldContext): DropPartitionField = + withOrigin(ctx) { + DropPartitionField( + typedVisit[Seq[String]](ctx.multipartIdentifier), + typedVisit[Transform](ctx.transform)) + } + + /** + * Create a CREATE OR REPLACE BRANCH logical command. + */ + override def visitCreateOrReplaceBranch( + ctx: CreateOrReplaceBranchContext): CreateOrReplaceBranch = withOrigin(ctx) { + val createOrReplaceBranchClause = ctx.createReplaceBranchClause() + + val branchName = createOrReplaceBranchClause.identifier() + val branchOptionsContext = Option(createOrReplaceBranchClause.branchOptions()) + val snapshotId = branchOptionsContext + .flatMap(branchOptions => Option(branchOptions.snapshotId())) + .map(_.getText.toLong) + val snapshotRetention = + branchOptionsContext.flatMap(branchOptions => Option(branchOptions.snapshotRetention())) + val minSnapshotsToKeep = snapshotRetention + .flatMap(retention => Option(retention.minSnapshotsToKeep())) + .map(minSnapshots => minSnapshots.number().getText.toLong) + val maxSnapshotAgeMs = snapshotRetention + .flatMap(retention => Option(retention.maxSnapshotAge())) + .map(retention => + TimeUnit + .valueOf(retention.timeUnit().getText.toUpperCase(Locale.ENGLISH)) + .toMillis(retention.number().getText.toLong)) + val branchRetention = + branchOptionsContext.flatMap(branchOptions => Option(branchOptions.refRetain())) + val branchRefAgeMs = branchRetention.map(retain => + TimeUnit + .valueOf(retain.timeUnit().getText.toUpperCase(Locale.ENGLISH)) + .toMillis(retain.number().getText.toLong)) + val create = createOrReplaceBranchClause.CREATE() != null + val replace = ctx.createReplaceBranchClause().REPLACE() != null + val ifNotExists = createOrReplaceBranchClause.EXISTS() != null + + val branchOptions = + BranchOptions(snapshotId, minSnapshotsToKeep, maxSnapshotAgeMs, branchRefAgeMs) + + CreateOrReplaceBranch( + typedVisit[Seq[String]](ctx.multipartIdentifier), + branchName.getText, + branchOptions, + create, + replace, + ifNotExists) + } + + /** + * Create an CREATE OR REPLACE TAG logical command. + */ + override def visitCreateOrReplaceTag(ctx: CreateOrReplaceTagContext): CreateOrReplaceTag = + withOrigin(ctx) { + val createTagClause = ctx.createReplaceTagClause() + + val tagName = createTagClause.identifier().getText + + val tagOptionsContext = Option(createTagClause.tagOptions()) + val snapshotId = tagOptionsContext + .flatMap(tagOptions => Option(tagOptions.snapshotId())) + .map(_.getText.toLong) + val tagRetain = tagOptionsContext.flatMap(tagOptions => Option(tagOptions.refRetain())) + val tagRefAgeMs = tagRetain.map(retain => + TimeUnit + .valueOf(retain.timeUnit().getText.toUpperCase(Locale.ENGLISH)) + .toMillis(retain.number().getText.toLong)) + val tagOptions = TagOptions(snapshotId, tagRefAgeMs) + + val create = createTagClause.CREATE() != null + val replace = createTagClause.REPLACE() != null + val ifNotExists = createTagClause.EXISTS() != null + + CreateOrReplaceTag( + typedVisit[Seq[String]](ctx.multipartIdentifier), + tagName, + tagOptions, + create, + replace, + ifNotExists) + } + + /** + * Create an DROP BRANCH logical command. + */ + override def visitDropBranch(ctx: DropBranchContext): DropBranch = withOrigin(ctx) { + DropBranch( + typedVisit[Seq[String]](ctx.multipartIdentifier), + ctx.identifier().getText, + ctx.EXISTS() != null) + } + + /** + * Create an DROP TAG logical command. + */ + override def visitDropTag(ctx: DropTagContext): DropTag = withOrigin(ctx) { + DropTag( + typedVisit[Seq[String]](ctx.multipartIdentifier), + ctx.identifier().getText, + ctx.EXISTS() != null) + } + + /** + * Create an REPLACE PARTITION FIELD logical command. + */ + override def visitReplacePartitionField( + ctx: ReplacePartitionFieldContext): ReplacePartitionField = withOrigin(ctx) { + ReplacePartitionField( + typedVisit[Seq[String]](ctx.multipartIdentifier), + typedVisit[Transform](ctx.transform(0)), + typedVisit[Transform](ctx.transform(1)), + Option(ctx.name).map(_.getText)) + } + + /** + * Create an SET IDENTIFIER FIELDS logical command. + */ + override def visitSetIdentifierFields(ctx: SetIdentifierFieldsContext): SetIdentifierFields = + withOrigin(ctx) { + SetIdentifierFields( + typedVisit[Seq[String]](ctx.multipartIdentifier), + toSeq(ctx.fieldList.fields).map(_.getText)) + } + + /** + * Create an DROP IDENTIFIER FIELDS logical command. + */ + override def visitDropIdentifierFields(ctx: DropIdentifierFieldsContext): DropIdentifierFields = + withOrigin(ctx) { + DropIdentifierFields( + typedVisit[Seq[String]](ctx.multipartIdentifier), + toSeq(ctx.fieldList.fields).map(_.getText)) + } + + /** + * Create a [[SetWriteDistributionAndOrdering]] for changing the write distribution and ordering. + */ + override def visitSetWriteDistributionAndOrdering( + ctx: SetWriteDistributionAndOrderingContext): SetWriteDistributionAndOrdering = { + + val tableName = typedVisit[Seq[String]](ctx.multipartIdentifier) + + val (distributionSpec, orderingSpec) = toDistributionAndOrderingSpec(ctx.writeSpec) + + if (distributionSpec == null && orderingSpec == null) { + throw new IcebergAnalysisException( + "ALTER TABLE has no changes: missing both distribution and ordering clauses") + } + + val distributionMode = if (distributionSpec != null) { + Some(DistributionMode.HASH) + } else if (orderingSpec.UNORDERED != null) { + Some(DistributionMode.NONE) + } else if (orderingSpec.LOCALLY() != null) { + None + } else { + Some(DistributionMode.RANGE) + } + + val ordering = if (orderingSpec != null && orderingSpec.order != null) { + toSeq(orderingSpec.order.fields).map(typedVisit[(Term, SortDirection, NullOrder)]) + } else { + Seq.empty + } + + SetWriteDistributionAndOrdering(tableName, distributionMode, ordering) + } + + private def toDistributionAndOrderingSpec( + writeSpec: WriteSpecContext): (WriteDistributionSpecContext, WriteOrderingSpecContext) = { + + if (writeSpec.writeDistributionSpec.size > 1) { + throw new IcebergAnalysisException("ALTER TABLE contains multiple distribution clauses") + } + + if (writeSpec.writeOrderingSpec.size > 1) { + throw new IcebergAnalysisException("ALTER TABLE contains multiple ordering clauses") + } + + val distributionSpec = toBuffer(writeSpec.writeDistributionSpec).headOption.orNull + val orderingSpec = toBuffer(writeSpec.writeOrderingSpec).headOption.orNull + + (distributionSpec, orderingSpec) + } + + /** + * Create an order field. + */ + override def visitOrderField(ctx: OrderFieldContext): (Term, SortDirection, NullOrder) = { + val term = Spark3Util.toIcebergTerm(typedVisit[Transform](ctx.transform)) + val direction = Option(ctx.ASC) + .map(_ => SortDirection.ASC) + .orElse(Option(ctx.DESC).map(_ => SortDirection.DESC)) + .getOrElse(SortDirection.ASC) + val nullOrder = Option(ctx.FIRST) + .map(_ => NullOrder.NULLS_FIRST) + .orElse(Option(ctx.LAST).map(_ => NullOrder.NULLS_LAST)) + .getOrElse( + if (direction == SortDirection.ASC) NullOrder.NULLS_FIRST else NullOrder.NULLS_LAST) + (term, direction, nullOrder) + } + + /** + * Create an IdentityTransform for a column reference. + */ + override def visitIdentityTransform(ctx: IdentityTransformContext): Transform = withOrigin(ctx) { + IdentityTransform(FieldReference(typedVisit[Seq[String]](ctx.multipartIdentifier()))) + } + + /** + * Create a named Transform from argument expressions. + */ + override def visitApplyTransform(ctx: ApplyTransformContext): Transform = withOrigin(ctx) { + val args = toSeq(ctx.arguments).map(typedVisit[expressions.Expression]) + ApplyTransform(ctx.transformName.getText, args) + } + + /** + * Create a transform argument from a column reference or a constant. + */ + override def visitTransformArgument(ctx: TransformArgumentContext): expressions.Expression = + withOrigin(ctx) { + val reference = Option(ctx.multipartIdentifier()) + .map(typedVisit[Seq[String]]) + .map(FieldReference(_)) + val literal = Option(ctx.constant) + .map(visitConstant) + .map(lit => LiteralValue(lit.value, lit.dataType)) + reference + .orElse(literal) + .getOrElse(throw new IcebergParseException(s"Invalid transform argument", ctx)) + } + + /** + * Return a multi-part identifier as Seq[String]. + */ + override def visitMultipartIdentifier(ctx: MultipartIdentifierContext): Seq[String] = + withOrigin(ctx) { + toSeq(ctx.parts).map(_.getText) + } + + override def visitSingleOrder(ctx: SingleOrderContext): Seq[(Term, SortDirection, NullOrder)] = + withOrigin(ctx) { + toSeq(ctx.order.fields).map(typedVisit[(Term, SortDirection, NullOrder)]) + } + + override def visitSingleStatement(ctx: SingleStatementContext): LogicalPlan = withOrigin(ctx) { + visit(ctx.statement).asInstanceOf[LogicalPlan] + } + + def visitConstant(ctx: ConstantContext): Literal = { + delegate.parseExpression(ctx.getText).asInstanceOf[Literal] + } + + override def visitExpression(ctx: ExpressionContext): Expression = { + // reconstruct the SQL string and parse it using the main Spark parser + // while we can avoid the logic to build Spark expressions, we still have to parse them + // we cannot call ctx.getText directly since it will not render spaces correctly + // that's why we need to recurse down the tree in reconstructSqlString + val sqlString = reconstructSqlString(ctx) + delegate.parseExpression(sqlString) + } + + private def reconstructSqlString(ctx: ParserRuleContext): String = { + toBuffer(ctx.children) + .map { + case c: ParserRuleContext => reconstructSqlString(c) + case t: TerminalNode => t.getText + } + .mkString(" ") + } + + private def typedVisit[T](ctx: ParseTree): T = { + ctx.accept(this).asInstanceOf[T] + } +} + +/* Partially copied from Apache Spark's Parser to avoid dependency on Spark Internals */ +object IcebergParserUtils { + + private[sql] def withOrigin[T](ctx: ParserRuleContext)(f: => T): T = { + val current = CurrentOrigin.get + CurrentOrigin.set(position(ctx.getStart)) + try { + f + } finally { + CurrentOrigin.set(current) + } + } + + private[sql] def position(token: Token): Origin = { + val opt = Option(token) + Origin(opt.map(_.getLine), opt.map(_.getCharPositionInLine)) + } + + /** Get the command which created the token. */ + private[sql] def command(ctx: ParserRuleContext): String = { + val stream = ctx.getStart.getInputStream + stream.getText(Interval.of(0, stream.size() - 1)) + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/AddPartitionField.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/AddPartitionField.scala new file mode 100644 index 000000000000..0a830dbd4f6a --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/AddPartitionField.scala @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.expressions.Transform + +case class AddPartitionField(table: Seq[String], transform: Transform, name: Option[String]) + extends LeafCommand { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"AddPartitionField ${table.quoted} ${name.map(n => s"$n=").getOrElse("")}${transform.describe}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/BranchOptions.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/BranchOptions.scala new file mode 100644 index 000000000000..15b908300213 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/BranchOptions.scala @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +case class BranchOptions( + snapshotId: Option[Long], + numSnapshots: Option[Long], + snapshotRetain: Option[Long], + snapshotRefRetain: Option[Long]) diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/CreateOrReplaceBranch.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/CreateOrReplaceBranch.scala new file mode 100644 index 000000000000..6900f6e8cc50 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/CreateOrReplaceBranch.scala @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute + +case class CreateOrReplaceBranch( + table: Seq[String], + branch: String, + branchOptions: BranchOptions, + create: Boolean, + replace: Boolean, + ifNotExists: Boolean) + extends LeafCommand { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"CreateOrReplaceBranch branch: ${branch} for table: ${table.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/CreateOrReplaceTag.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/CreateOrReplaceTag.scala new file mode 100644 index 000000000000..957c68e7a540 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/CreateOrReplaceTag.scala @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute + +case class CreateOrReplaceTag( + table: Seq[String], + tag: String, + tagOptions: TagOptions, + create: Boolean, + replace: Boolean, + ifNotExists: Boolean) + extends LeafCommand { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"CreateOrReplaceTag tag: ${tag} for table: ${table.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropBranch.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropBranch.scala new file mode 100644 index 000000000000..ed4f1f512b85 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropBranch.scala @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute + +case class DropBranch(table: Seq[String], branch: String, ifExists: Boolean) extends LeafCommand { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"DropBranch branch: ${branch} for table: ${table.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropIdentifierFields.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropIdentifierFields.scala new file mode 100644 index 000000000000..1a91806280b3 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropIdentifierFields.scala @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute + +case class DropIdentifierFields(table: Seq[String], fields: Seq[String]) extends LeafCommand { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"DropIdentifierFields ${table.quoted} (${fields.quoted})" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropPartitionField.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropPartitionField.scala new file mode 100644 index 000000000000..ec952c8c7118 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropPartitionField.scala @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.expressions.Transform + +case class DropPartitionField(table: Seq[String], transform: Transform) extends LeafCommand { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"DropPartitionField ${table.quoted} ${transform.describe}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropTag.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropTag.scala new file mode 100644 index 000000000000..da69ca0383a1 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/DropTag.scala @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute + +case class DropTag(table: Seq[String], tag: String, ifExists: Boolean) extends LeafCommand { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"DropTag tag: ${tag} for table: ${table.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/ReplacePartitionField.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/ReplacePartitionField.scala new file mode 100644 index 000000000000..c2525369e7c7 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/ReplacePartitionField.scala @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.expressions.Transform + +case class ReplacePartitionField( + table: Seq[String], + transformFrom: Transform, + transformTo: Transform, + name: Option[String]) + extends LeafCommand { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"ReplacePartitionField ${table.quoted} ${transformFrom.describe} " + + s"with ${name.map(n => s"$n=").getOrElse("")}${transformTo.describe}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SetIdentifierFields.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SetIdentifierFields.scala new file mode 100644 index 000000000000..8cd2c0ddad05 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SetIdentifierFields.scala @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.sql.catalyst.expressions.Attribute + +case class SetIdentifierFields(table: Seq[String], fields: Seq[String]) extends LeafCommand { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + s"SetIdentifierFields ${table.quoted} (${fields.quoted})" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/TagOptions.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/TagOptions.scala new file mode 100644 index 000000000000..6afe1478d747 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/TagOptions.scala @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +case class TagOptions(snapshotId: Option[Long], snapshotRefRetain: Option[Long]) diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/CreateIcebergView.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/CreateIcebergView.scala new file mode 100644 index 000000000000..84a00a4a9a88 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/CreateIcebergView.scala @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical.views + +import org.apache.spark.sql.catalyst.analysis.AnalysisContext +import org.apache.spark.sql.catalyst.plans.logical.AnalysisOnlyCommand +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan + +// Align Iceberg's CreateIcebergView with Spark’s CreateViewCommand by extending AnalysisOnlyCommand. +// The command’s children are analyzed then hidden, so the optimizer/planner won’t traverse the view body. +case class CreateIcebergView( + child: LogicalPlan, + queryText: String, + query: LogicalPlan, + columnAliases: Seq[String], + columnComments: Seq[Option[String]], + queryColumnNames: Seq[String] = Seq.empty, + comment: Option[String], + properties: Map[String, String], + allowExisting: Boolean, + replace: Boolean, + rewritten: Boolean = false, + isAnalyzed: Boolean = false) + extends AnalysisOnlyCommand { + + override def childrenToAnalyze: Seq[LogicalPlan] = child :: query :: Nil + + override def markAsAnalyzed(analysisContext: AnalysisContext): LogicalPlan = { + copy(isAnalyzed = true) + } + + override protected def withNewChildrenInternal( + newChildren: IndexedSeq[LogicalPlan]): LogicalPlan = { + assert(!isAnalyzed) + copy(child = newChildren.head, query = newChildren.last) + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/DropIcebergView.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/DropIcebergView.scala new file mode 100644 index 000000000000..092b6b33fb0c --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/DropIcebergView.scala @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical.views + +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.UnaryCommand + +case class DropIcebergView(child: LogicalPlan, ifExists: Boolean) extends UnaryCommand { + override protected def withNewChildInternal(newChild: LogicalPlan): DropIcebergView = + copy(child = newChild) +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/ResolvedV2View.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/ResolvedV2View.scala new file mode 100644 index 000000000000..4d384e857703 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/ResolvedV2View.scala @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical.views + +import org.apache.spark.sql.catalyst.analysis.LeafNodeWithoutStats +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.ViewCatalog + +case class ResolvedV2View(catalog: ViewCatalog, identifier: Identifier) + extends LeafNodeWithoutStats { + override def output: Seq[Attribute] = Nil +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/ShowIcebergViews.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/ShowIcebergViews.scala new file mode 100644 index 000000000000..cbfe23d94cbe --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/ShowIcebergViews.scala @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical.views + +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.ShowViews +import org.apache.spark.sql.catalyst.plans.logical.UnaryCommand + +case class ShowIcebergViews( + namespace: LogicalPlan, + pattern: Option[String], + override val output: Seq[Attribute] = ShowViews.getOutputAttrs) + extends UnaryCommand { + override def child: LogicalPlan = namespace + + override protected def withNewChildInternal(newChild: LogicalPlan): ShowIcebergViews = + copy(namespace = newChild) +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AddPartitionFieldExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AddPartitionFieldExec.scala new file mode 100644 index 000000000000..e28dcfb194b6 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AddPartitionFieldExec.scala @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.Spark3Util +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog +import org.apache.spark.sql.connector.expressions.Transform + +case class AddPartitionFieldExec( + catalog: TableCatalog, + ident: Identifier, + transform: Transform, + name: Option[String]) + extends LeafV2CommandExec { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + iceberg.table + .updateSpec() + .addField(name.orNull, Spark3Util.toIcebergTerm(transform)) + .commit() + + case table => + throw new UnsupportedOperationException( + s"Cannot add partition field to non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"AddPartitionField ${catalog.name}.${ident.quoted} ${name.map(n => s"$n=").getOrElse("")}${transform.describe}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AlterV2ViewSetPropertiesExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AlterV2ViewSetPropertiesExec.scala new file mode 100644 index 000000000000..d6630e51ff5a --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AlterV2ViewSetPropertiesExec.scala @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.connector.catalog.ViewChange + +case class AlterV2ViewSetPropertiesExec( + catalog: ViewCatalog, + ident: Identifier, + properties: Map[String, String]) + extends LeafV2CommandExec { + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + val changes = properties.map { case (property, value) => + ViewChange.setProperty(property, value) + }.toSeq + + catalog.alterView(ident, changes: _*) + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"AlterV2ViewSetProperties: ${ident}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AlterV2ViewUnsetPropertiesExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AlterV2ViewUnsetPropertiesExec.scala new file mode 100644 index 000000000000..aa57842f58b5 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AlterV2ViewUnsetPropertiesExec.scala @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.analysis.IcebergAnalysisException +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.connector.catalog.ViewChange + +case class AlterV2ViewUnsetPropertiesExec( + catalog: ViewCatalog, + ident: Identifier, + propertyKeys: Seq[String], + ifExists: Boolean) + extends LeafV2CommandExec { + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + if (!ifExists) { + propertyKeys.filterNot(catalog.loadView(ident).properties.containsKey).foreach { property => + throw new IcebergAnalysisException(s"Cannot remove property that is not set: '$property'") + } + } + + val changes = propertyKeys.map(ViewChange.removeProperty) + catalog.alterView(ident, changes: _*) + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"AlterV2ViewUnsetProperties: ${ident}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateOrReplaceBranchExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateOrReplaceBranchExec.scala new file mode 100644 index 000000000000..baf985f53a22 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateOrReplaceBranchExec.scala @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.relocated.com.google.common.base.Preconditions +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.plans.logical.BranchOptions +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog + +case class CreateOrReplaceBranchExec( + catalog: TableCatalog, + ident: Identifier, + branch: String, + branchOptions: BranchOptions, + create: Boolean, + replace: Boolean, + ifNotExists: Boolean) + extends LeafV2CommandExec { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val snapshotId: java.lang.Long = branchOptions.snapshotId + .orElse(Option(iceberg.table.currentSnapshot()).map(_.snapshotId())) + .map(java.lang.Long.valueOf) + .orNull + + val manageSnapshots = iceberg.table().manageSnapshots() + val refExists = null != iceberg.table().refs().get(branch) + + def safeCreateBranch(): Unit = { + if (snapshotId == null) { + manageSnapshots.createBranch(branch) + } else { + manageSnapshots.createBranch(branch, snapshotId) + } + } + + if (create && replace && !refExists) { + safeCreateBranch() + } else if (replace) { + Preconditions.checkArgument( + snapshotId != null, + "Cannot complete replace branch operation on %s, main has no snapshot", + ident) + manageSnapshots.replaceBranch(branch, snapshotId) + } else { + if (refExists && ifNotExists) { + return Nil + } + + safeCreateBranch() + } + + if (branchOptions.numSnapshots.nonEmpty) { + manageSnapshots.setMinSnapshotsToKeep(branch, branchOptions.numSnapshots.get.toInt) + } + + if (branchOptions.snapshotRetain.nonEmpty) { + manageSnapshots.setMaxSnapshotAgeMs(branch, branchOptions.snapshotRetain.get) + } + + if (branchOptions.snapshotRefRetain.nonEmpty) { + manageSnapshots.setMaxRefAgeMs(branch, branchOptions.snapshotRefRetain.get) + } + + manageSnapshots.commit() + + case table => + throw new UnsupportedOperationException( + s"Cannot create or replace branch on non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"CreateOrReplace branch: $branch for table: ${ident.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateOrReplaceTagExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateOrReplaceTagExec.scala new file mode 100644 index 000000000000..e486892614cb --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateOrReplaceTagExec.scala @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.relocated.com.google.common.base.Preconditions +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.plans.logical.TagOptions +import org.apache.spark.sql.connector.catalog._ + +case class CreateOrReplaceTagExec( + catalog: TableCatalog, + ident: Identifier, + tag: String, + tagOptions: TagOptions, + create: Boolean, + replace: Boolean, + ifNotExists: Boolean) + extends LeafV2CommandExec { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val snapshotId: java.lang.Long = tagOptions.snapshotId + .orElse(Option(iceberg.table.currentSnapshot()).map(_.snapshotId())) + .map(java.lang.Long.valueOf) + .orNull + + Preconditions.checkArgument( + snapshotId != null, + "Cannot complete create or replace tag operation on %s, main has no snapshot", + ident) + + val manageSnapshot = iceberg.table.manageSnapshots() + val refExists = null != iceberg.table().refs().get(tag) + + if (create && replace && !refExists) { + manageSnapshot.createTag(tag, snapshotId) + } else if (replace) { + manageSnapshot.replaceTag(tag, snapshotId) + } else { + if (refExists && ifNotExists) { + return Nil + } + + manageSnapshot.createTag(tag, snapshotId) + } + + if (tagOptions.snapshotRefRetain.nonEmpty) { + manageSnapshot.setMaxRefAgeMs(tag, tagOptions.snapshotRefRetain.get) + } + + manageSnapshot.commit() + + case table => + throw new UnsupportedOperationException(s"Cannot create tag to non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"Create tag: $tag for table: ${ident.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateV2ViewExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateV2ViewExec.scala new file mode 100644 index 000000000000..04f2b2af731d --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/CreateV2ViewExec.scala @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.SupportsReplaceView +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.analysis.NoSuchViewException +import org.apache.spark.sql.catalyst.analysis.ViewAlreadyExistsException +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.connector.catalog.ViewInfo +import org.apache.spark.sql.types.StructType +import scala.jdk.CollectionConverters._ + +case class CreateV2ViewExec( + catalog: ViewCatalog, + ident: Identifier, + queryText: String, + viewSchema: StructType, + columnAliases: Seq[String], + columnComments: Seq[Option[String]], + queryColumnNames: Seq[String], + comment: Option[String], + properties: Map[String, String], + allowExisting: Boolean, + replace: Boolean) + extends LeafV2CommandExec { + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + val currentCatalogName = session.sessionState.catalogManager.currentCatalog.name + val currentCatalog = + if (!catalog.name().equals(currentCatalogName)) currentCatalogName else null + val currentNamespace = session.sessionState.catalogManager.currentNamespace + + val engineVersion = "Spark " + org.apache.spark.SPARK_VERSION + val newProperties = properties ++ + comment.map(ViewCatalog.PROP_COMMENT -> _) ++ + Map( + ViewCatalog.PROP_CREATE_ENGINE_VERSION -> engineVersion, + ViewCatalog.PROP_ENGINE_VERSION -> engineVersion) + + if (replace) { + // CREATE OR REPLACE VIEW + catalog match { + case c: SupportsReplaceView => + try { + replaceView(c, currentCatalog, currentNamespace, newProperties) + } catch { + // view might have been concurrently dropped during replace + case _: NoSuchViewException => + replaceView(c, currentCatalog, currentNamespace, newProperties) + } + case _ => + if (catalog.viewExists(ident)) { + catalog.dropView(ident) + } + + createView(currentCatalog, currentNamespace, newProperties) + } + } else { + try { + // CREATE VIEW [IF NOT EXISTS] + createView(currentCatalog, currentNamespace, newProperties) + } catch { + case _: ViewAlreadyExistsException if allowExisting => // Ignore + } + } + + Nil + } + + private def replaceView( + supportsReplaceView: SupportsReplaceView, + currentCatalog: String, + currentNamespace: Array[String], + newProperties: Map[String, String]) = { + supportsReplaceView.replaceView( + ident, + queryText, + currentCatalog, + currentNamespace, + viewSchema, + queryColumnNames.toArray, + columnAliases.toArray, + columnComments.map(c => c.orNull).toArray, + newProperties.asJava) + } + + private def createView( + currentCatalog: String, + currentNamespace: Array[String], + newProperties: Map[String, String]) = { + val viewInfo: ViewInfo = new ViewInfo( + ident, + queryText, + currentCatalog, + currentNamespace, + viewSchema, + queryColumnNames.toArray, + columnAliases.toArray, + columnComments.map(c => c.orNull).toArray, + newProperties.asJava) + catalog.createView(viewInfo) + } + + override def simpleString(maxFields: Int): String = { + s"CreateV2ViewExec: ${ident}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeV2ViewExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeV2ViewExec.scala new file mode 100644 index 000000000000..106734d2078f --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DescribeV2ViewExec.scala @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.util.escapeSingleQuotedString +import org.apache.spark.sql.connector.catalog.View +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.execution.LeafExecNode +import scala.jdk.CollectionConverters._ + +case class DescribeV2ViewExec(output: Seq[Attribute], view: View, isExtended: Boolean) + extends V2CommandExec + with LeafExecNode { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override protected def run(): Seq[InternalRow] = { + if (isExtended) { + (describeSchema :+ emptyRow) ++ describeExtended + } else { + describeSchema + } + } + + private def describeSchema: Seq[InternalRow] = + view.schema().map { column => + toCatalystRow(column.name, column.dataType.simpleString, column.getComment().getOrElse("")) + } + + private def emptyRow: InternalRow = toCatalystRow("", "", "") + + private def describeExtended: Seq[InternalRow] = { + val outputColumns = view.queryColumnNames.mkString("[", ", ", "]") + val properties: Map[String, String] = + view.properties.asScala.toMap -- ViewCatalog.RESERVED_PROPERTIES.asScala + val viewCatalogAndNamespace: Seq[String] = view.name.split("\\.").take(2).toIndexedSeq + val viewProperties = properties.toSeq + .sortBy(_._1) + .map { case (key, value) => + s"'${escapeSingleQuotedString(key)}' = '${escapeSingleQuotedString(value)}'" + } + .mkString("[", ", ", "]") + + // omitting view text here because it is shown as + // part of SHOW CREATE TABLE and can result in weird formatting in the DESCRIBE output + toCatalystRow("# Detailed View Information", "", "") :: + toCatalystRow("Comment", view.properties.getOrDefault(ViewCatalog.PROP_COMMENT, ""), "") :: + toCatalystRow("View Catalog and Namespace", viewCatalogAndNamespace.quoted, "") :: + toCatalystRow("View Query Output Columns", outputColumns, "") :: + toCatalystRow("View Properties", viewProperties, "") :: + toCatalystRow( + "Created By", + view.properties.getOrDefault(ViewCatalog.PROP_CREATE_ENGINE_VERSION, ""), + "") :: + Nil + } + + override def simpleString(maxFields: Int): String = { + s"DescribeV2ViewExec" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropBranchExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropBranchExec.scala new file mode 100644 index 000000000000..e7d9c7b70d82 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropBranchExec.scala @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog + +case class DropBranchExec( + catalog: TableCatalog, + ident: Identifier, + branch: String, + ifExists: Boolean) + extends LeafV2CommandExec { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val ref = iceberg.table().refs().get(branch) + if (ref != null || !ifExists) { + iceberg.table().manageSnapshots().removeBranch(branch).commit() + } + + case table => + throw new UnsupportedOperationException(s"Cannot drop branch on non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"DropBranch branch: ${branch} for table: ${ident.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropIdentifierFieldsExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropIdentifierFieldsExec.scala new file mode 100644 index 000000000000..87b18594d573 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropIdentifierFieldsExec.scala @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.relocated.com.google.common.base.Preconditions +import org.apache.iceberg.relocated.com.google.common.collect.Sets +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog + +case class DropIdentifierFieldsExec(catalog: TableCatalog, ident: Identifier, fields: Seq[String]) + extends LeafV2CommandExec { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val schema = iceberg.table.schema + val identifierFieldNames = Sets.newHashSet(schema.identifierFieldNames) + + for (name <- fields) { + Preconditions.checkArgument( + schema.findField(name) != null, + "Cannot complete drop identifier fields operation: field %s not found", + name) + Preconditions.checkArgument( + identifierFieldNames.contains(name), + "Cannot complete drop identifier fields operation: %s is not an identifier field", + name) + identifierFieldNames.remove(name) + } + + iceberg.table + .updateSchema() + .setIdentifierFields(identifierFieldNames) + .commit(); + case table => + throw new UnsupportedOperationException( + s"Cannot drop identifier fields in non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"DropIdentifierFields ${catalog.name}.${ident.quoted} (${fields.quoted})"; + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropPartitionFieldExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropPartitionFieldExec.scala new file mode 100644 index 000000000000..db43263e0e66 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropPartitionFieldExec.scala @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.Spark3Util +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog +import org.apache.spark.sql.connector.expressions.FieldReference +import org.apache.spark.sql.connector.expressions.IdentityTransform +import org.apache.spark.sql.connector.expressions.Transform + +case class DropPartitionFieldExec(catalog: TableCatalog, ident: Identifier, transform: Transform) + extends LeafV2CommandExec { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val schema = iceberg.table.schema + transform match { + case IdentityTransform(FieldReference(parts)) + if parts.size == 1 && schema.findField(parts.head) == null => + // the name is not present in the Iceberg schema, so it must be a partition field name, not a column name + iceberg.table + .updateSpec() + .removeField(parts.head) + .commit() + + case _ => + iceberg.table + .updateSpec() + .removeField(Spark3Util.toIcebergTerm(transform)) + .commit() + } + + case table => + throw new UnsupportedOperationException( + s"Cannot drop partition field in non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"DropPartitionField ${catalog.name}.${ident.quoted} ${transform.describe}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropTagExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropTagExec.scala new file mode 100644 index 000000000000..79b4a1525591 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropTagExec.scala @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog + +case class DropTagExec(catalog: TableCatalog, ident: Identifier, tag: String, ifExists: Boolean) + extends LeafV2CommandExec { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val ref = iceberg.table().refs().get(tag) + if (ref != null || !ifExists) { + iceberg.table().manageSnapshots().removeTag(tag).commit() + } + + case table => + throw new UnsupportedOperationException(s"Cannot drop tag on non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"DropTag tag: ${tag} for table: ${ident.quoted}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropV2ViewExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropV2ViewExec.scala new file mode 100644 index 000000000000..6dd1188b78e8 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/DropV2ViewExec.scala @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.analysis.NoSuchViewException +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.ViewCatalog + +case class DropV2ViewExec(catalog: ViewCatalog, ident: Identifier, ifExists: Boolean) + extends LeafV2CommandExec { + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + val dropped = catalog.dropView(ident) + if (!dropped && !ifExists) { + throw new NoSuchViewException(ident) + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"DropV2View: ${ident}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ExtendedDataSourceV2Strategy.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ExtendedDataSourceV2Strategy.scala new file mode 100644 index 000000000000..da540f5891b7 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ExtendedDataSourceV2Strategy.scala @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.Spark3Util +import org.apache.iceberg.spark.SparkCatalog +import org.apache.iceberg.spark.SparkSessionCatalog +import org.apache.spark.sql.SparkSession +import org.apache.spark.sql.catalyst.analysis.IcebergAnalysisException +import org.apache.spark.sql.catalyst.analysis.ResolvedIdentifier +import org.apache.spark.sql.catalyst.analysis.ResolvedNamespace +import org.apache.spark.sql.catalyst.expressions.PredicateHelper +import org.apache.spark.sql.catalyst.plans.logical.AddPartitionField +import org.apache.spark.sql.catalyst.plans.logical.CreateOrReplaceBranch +import org.apache.spark.sql.catalyst.plans.logical.CreateOrReplaceTag +import org.apache.spark.sql.catalyst.plans.logical.DescribeRelation +import org.apache.spark.sql.catalyst.plans.logical.DropBranch +import org.apache.spark.sql.catalyst.plans.logical.DropIdentifierFields +import org.apache.spark.sql.catalyst.plans.logical.DropPartitionField +import org.apache.spark.sql.catalyst.plans.logical.DropTag +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.OrderAwareCoalesce +import org.apache.spark.sql.catalyst.plans.logical.RenameTable +import org.apache.spark.sql.catalyst.plans.logical.ReplacePartitionField +import org.apache.spark.sql.catalyst.plans.logical.SetIdentifierFields +import org.apache.spark.sql.catalyst.plans.logical.SetViewProperties +import org.apache.spark.sql.catalyst.plans.logical.SetWriteDistributionAndOrdering +import org.apache.spark.sql.catalyst.plans.logical.ShowCreateTable +import org.apache.spark.sql.catalyst.plans.logical.ShowTableProperties +import org.apache.spark.sql.catalyst.plans.logical.UnsetViewProperties +import org.apache.spark.sql.catalyst.plans.logical.views.CreateIcebergView +import org.apache.spark.sql.catalyst.plans.logical.views.DropIcebergView +import org.apache.spark.sql.catalyst.plans.logical.views.ResolvedV2View +import org.apache.spark.sql.catalyst.plans.logical.views.ShowIcebergViews +import org.apache.spark.sql.classic.Strategy +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.execution.OrderAwareCoalesceExec +import org.apache.spark.sql.execution.SparkPlan +import scala.jdk.CollectionConverters._ + +case class ExtendedDataSourceV2Strategy(spark: SparkSession) extends Strategy with PredicateHelper { + + override def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match { + case AddPartitionField(IcebergCatalogAndIdentifier(catalog, ident), transform, name) => + AddPartitionFieldExec(catalog, ident, transform, name) :: Nil + + case CreateOrReplaceBranch( + IcebergCatalogAndIdentifier(catalog, ident), + branch, + branchOptions, + create, + replace, + ifNotExists) => + CreateOrReplaceBranchExec( + catalog, + ident, + branch, + branchOptions, + create, + replace, + ifNotExists) :: Nil + + case CreateOrReplaceTag( + IcebergCatalogAndIdentifier(catalog, ident), + tag, + tagOptions, + create, + replace, + ifNotExists) => + CreateOrReplaceTagExec(catalog, ident, tag, tagOptions, create, replace, ifNotExists) :: Nil + + case DropBranch(IcebergCatalogAndIdentifier(catalog, ident), branch, ifExists) => + DropBranchExec(catalog, ident, branch, ifExists) :: Nil + + case DropTag(IcebergCatalogAndIdentifier(catalog, ident), tag, ifExists) => + DropTagExec(catalog, ident, tag, ifExists) :: Nil + + case DropPartitionField(IcebergCatalogAndIdentifier(catalog, ident), transform) => + DropPartitionFieldExec(catalog, ident, transform) :: Nil + + case ReplacePartitionField( + IcebergCatalogAndIdentifier(catalog, ident), + transformFrom, + transformTo, + name) => + ReplacePartitionFieldExec(catalog, ident, transformFrom, transformTo, name) :: Nil + + case SetIdentifierFields(IcebergCatalogAndIdentifier(catalog, ident), fields) => + SetIdentifierFieldsExec(catalog, ident, fields) :: Nil + + case DropIdentifierFields(IcebergCatalogAndIdentifier(catalog, ident), fields) => + DropIdentifierFieldsExec(catalog, ident, fields) :: Nil + + case SetWriteDistributionAndOrdering( + IcebergCatalogAndIdentifier(catalog, ident), + distributionMode, + ordering) => + SetWriteDistributionAndOrderingExec(catalog, ident, distributionMode, ordering) :: Nil + + case OrderAwareCoalesce(numPartitions, coalescer, child) => + OrderAwareCoalesceExec(numPartitions, coalescer, planLater(child)) :: Nil + + case RenameTable(ResolvedV2View(oldCatalog: ViewCatalog, oldIdent), newName, isView @ true) => + val newIdent = Spark3Util.catalogAndIdentifier(spark, newName.toList.asJava) + if (oldCatalog.name != newIdent.catalog().name()) { + throw new IcebergAnalysisException( + s"Cannot move view between catalogs: from=${oldCatalog.name} and to=${newIdent.catalog().name()}") + } + RenameV2ViewExec(oldCatalog, oldIdent, newIdent.identifier()) :: Nil + + case DropIcebergView(ResolvedIdentifier(viewCatalog: ViewCatalog, ident), ifExists) => + DropV2ViewExec(viewCatalog, ident, ifExists) :: Nil + + case CreateIcebergView( + ResolvedIdentifier(viewCatalog: ViewCatalog, ident), + queryText, + query, + columnAliases, + columnComments, + queryColumnNames, + comment, + properties, + allowExisting, + replace, + _, + _) => + CreateV2ViewExec( + catalog = viewCatalog, + ident = ident, + queryText = queryText, + columnAliases = columnAliases, + columnComments = columnComments, + queryColumnNames = queryColumnNames, + viewSchema = query.schema, + comment = comment, + properties = properties, + allowExisting = allowExisting, + replace = replace) :: Nil + + case DescribeRelation(ResolvedV2View(catalog, ident), _, isExtended, output) => + DescribeV2ViewExec(output, catalog.loadView(ident), isExtended) :: Nil + + case ShowTableProperties(ResolvedV2View(catalog, ident), propertyKey, output) => + ShowV2ViewPropertiesExec(output, catalog.loadView(ident), propertyKey) :: Nil + + case ShowIcebergViews(ResolvedNamespace(catalog: ViewCatalog, namespace, _), pattern, output) => + ShowV2ViewsExec(output, catalog, namespace, pattern) :: Nil + + case ShowCreateTable(ResolvedV2View(catalog, ident), _, output) => + ShowCreateV2ViewExec(output, catalog.loadView(ident)) :: Nil + + case SetViewProperties(ResolvedV2View(catalog, ident), properties) => + AlterV2ViewSetPropertiesExec(catalog, ident, properties) :: Nil + + case UnsetViewProperties(ResolvedV2View(catalog, ident), propertyKeys, ifExists) => + AlterV2ViewUnsetPropertiesExec(catalog, ident, propertyKeys, ifExists) :: Nil + + case _ => Nil + } + + private object IcebergCatalogAndIdentifier { + def unapply(identifier: Seq[String]): Option[(TableCatalog, Identifier)] = { + val catalogAndIdentifier = Spark3Util.catalogAndIdentifier(spark, identifier.asJava) + catalogAndIdentifier.catalog match { + case icebergCatalog: SparkCatalog => + Some((icebergCatalog, catalogAndIdentifier.identifier)) + case icebergCatalog: SparkSessionCatalog[_] => + Some((icebergCatalog, catalogAndIdentifier.identifier)) + case _ => + None + } + } + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RenameV2ViewExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RenameV2ViewExec.scala new file mode 100644 index 000000000000..5dada1cab0bb --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/RenameV2ViewExec.scala @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.ViewCatalog + +case class RenameV2ViewExec(catalog: ViewCatalog, oldIdent: Identifier, newIdent: Identifier) + extends LeafV2CommandExec { + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.renameView(oldIdent, newIdent) + + Seq.empty + } + + override def simpleString(maxFields: Int): String = { + s"RenameV2View ${oldIdent} to {newIdent}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ReplacePartitionFieldExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ReplacePartitionFieldExec.scala new file mode 100644 index 000000000000..00b998c49e83 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ReplacePartitionFieldExec.scala @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.Spark3Util +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog +import org.apache.spark.sql.connector.expressions.FieldReference +import org.apache.spark.sql.connector.expressions.IdentityTransform +import org.apache.spark.sql.connector.expressions.Transform + +case class ReplacePartitionFieldExec( + catalog: TableCatalog, + ident: Identifier, + transformFrom: Transform, + transformTo: Transform, + name: Option[String]) + extends LeafV2CommandExec { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val schema = iceberg.table.schema + transformFrom match { + case IdentityTransform(FieldReference(parts)) + if parts.size == 1 && schema.findField(parts.head) == null => + // the name is not present in the Iceberg schema, so it must be a partition field name, not a column name + iceberg.table + .updateSpec() + .removeField(parts.head) + .addField(name.orNull, Spark3Util.toIcebergTerm(transformTo)) + .commit() + + case _ => + iceberg.table + .updateSpec() + .removeField(Spark3Util.toIcebergTerm(transformFrom)) + .addField(name.orNull, Spark3Util.toIcebergTerm(transformTo)) + .commit() + } + + case table => + throw new UnsupportedOperationException( + s"Cannot replace partition field in non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"ReplacePartitionField ${catalog.name}.${ident.quoted} ${transformFrom.describe} " + + s"with ${name.map(n => s"$n=").getOrElse("")}${transformTo.describe}" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/SetIdentifierFieldsExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/SetIdentifierFieldsExec.scala new file mode 100644 index 000000000000..50c53473ab60 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/SetIdentifierFieldsExec.scala @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog +import scala.jdk.CollectionConverters._ + +case class SetIdentifierFieldsExec(catalog: TableCatalog, ident: Identifier, fields: Seq[String]) + extends LeafV2CommandExec { + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + iceberg.table + .updateSchema() + .setIdentifierFields(fields.asJava) + .commit(); + case table => + throw new UnsupportedOperationException( + s"Cannot set identifier fields in non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + s"SetIdentifierFields ${catalog.name}.${ident.quoted} (${fields.quoted})"; + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/SetWriteDistributionAndOrderingExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/SetWriteDistributionAndOrderingExec.scala new file mode 100644 index 000000000000..9a10949d5e9e --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/SetWriteDistributionAndOrderingExec.scala @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.iceberg.DistributionMode +import org.apache.iceberg.NullOrder +import org.apache.iceberg.SortDirection +import org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE +import org.apache.iceberg.expressions.Term +import org.apache.iceberg.spark.SparkUtil +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.CatalogV2Implicits +import org.apache.spark.sql.connector.catalog.Identifier +import org.apache.spark.sql.connector.catalog.TableCatalog + +case class SetWriteDistributionAndOrderingExec( + catalog: TableCatalog, + ident: Identifier, + distributionMode: Option[DistributionMode], + sortOrder: Seq[(Term, SortDirection, NullOrder)]) + extends LeafV2CommandExec { + + import CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override protected def run(): Seq[InternalRow] = { + catalog.loadTable(ident) match { + case iceberg: SparkTable => + val txn = iceberg.table.newTransaction() + + val orderBuilder = txn.replaceSortOrder().caseSensitive(SparkUtil.caseSensitive(session)) + sortOrder.foreach { + case (term, SortDirection.ASC, nullOrder) => + orderBuilder.asc(term, nullOrder) + case (term, SortDirection.DESC, nullOrder) => + orderBuilder.desc(term, nullOrder) + } + orderBuilder.commit() + + distributionMode.foreach { mode => + txn + .updateProperties() + .set(WRITE_DISTRIBUTION_MODE, mode.modeName()) + .commit() + } + + txn.commitTransaction() + + case table => + throw new UnsupportedOperationException( + s"Cannot set write order of non-Iceberg table: $table") + } + + Nil + } + + override def simpleString(maxFields: Int): String = { + val tableIdent = s"${catalog.name}.${ident.quoted}" + val order = sortOrder + .map { case (term, direction, nullOrder) => + s"$term $direction $nullOrder" + } + .mkString(", ") + s"SetWriteDistributionAndOrdering $tableIdent $distributionMode $order" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateV2ViewExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateV2ViewExec.scala new file mode 100644 index 000000000000..07ac4aeda8fb --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowCreateV2ViewExec.scala @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.util.escapeSingleQuotedString +import org.apache.spark.sql.connector.catalog.View +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.execution.LeafExecNode +import scala.jdk.CollectionConverters._ + +case class ShowCreateV2ViewExec(output: Seq[Attribute], view: View) + extends V2CommandExec + with LeafExecNode { + + override protected def run(): Seq[InternalRow] = { + val builder = new StringBuilder + builder ++= s"CREATE VIEW ${view.name} " + showColumns(view, builder) + showComment(view, builder) + showProperties(view, builder) + builder ++= s"AS\n${view.query}\n" + + Seq(toCatalystRow(builder.toString)) + } + + private def showColumns(view: View, builder: StringBuilder): Unit = { + val columns = concatByMultiLines( + view + .schema() + .fields + .map(x => s"${x.name}${x.getComment().map(c => s" COMMENT '$c'").getOrElse("")}")) + builder ++= columns + } + + private def showComment(view: View, builder: StringBuilder): Unit = { + Option(view.properties.get(ViewCatalog.PROP_COMMENT)) + .map("COMMENT '" + escapeSingleQuotedString(_) + "'\n") + .foreach(builder.append) + } + + private def showProperties(view: View, builder: StringBuilder): Unit = { + val showProps = view.properties.asScala.toMap -- ViewCatalog.RESERVED_PROPERTIES.asScala + if (showProps.nonEmpty) { + val props = conf.redactOptions(showProps).toSeq.sortBy(_._1).map { case (key, value) => + s"'${escapeSingleQuotedString(key)}' = '${escapeSingleQuotedString(value)}'" + } + + builder ++= "TBLPROPERTIES " + builder ++= concatByMultiLines(props) + } + } + + private def concatByMultiLines(iter: Iterable[String]): String = { + iter.mkString("(\n ", ",\n ", ")\n") + } + + override def simpleString(maxFields: Int): String = { + s"ShowCreateV2ViewExec" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowV2ViewPropertiesExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowV2ViewPropertiesExec.scala new file mode 100644 index 000000000000..ace43eb6c07b --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowV2ViewPropertiesExec.scala @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.View +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.execution.LeafExecNode +import scala.jdk.CollectionConverters._ + +case class ShowV2ViewPropertiesExec(output: Seq[Attribute], view: View, propertyKey: Option[String]) + extends V2CommandExec + with LeafExecNode { + + override protected def run(): Seq[InternalRow] = { + propertyKey match { + case Some(p) => + val propValue = properties.getOrElse(p, s"View ${view.name()} does not have property: $p") + Seq(toCatalystRow(p, propValue)) + case None => + properties.map { case (k, v) => + toCatalystRow(k, v) + }.toSeq + } + } + + private def properties = { + view.properties.asScala.toMap -- ViewCatalog.RESERVED_PROPERTIES.asScala + } + + override def simpleString(maxFields: Int): String = { + s"ShowV2ViewPropertiesExec" + } +} diff --git a/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowV2ViewsExec.scala b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowV2ViewsExec.scala new file mode 100644 index 000000000000..4e7700b43978 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/main/scala/org/apache/spark/sql/execution/datasources/v2/ShowV2ViewsExec.scala @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution.datasources.v2 + +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.util.StringUtils +import org.apache.spark.sql.connector.catalog.ViewCatalog +import org.apache.spark.sql.execution.LeafExecNode +import org.apache.spark.sql.internal.SQLConf +import scala.collection.mutable.ArrayBuffer + +case class ShowV2ViewsExec( + output: Seq[Attribute], + catalog: ViewCatalog, + namespace: Seq[String], + pattern: Option[String]) + extends V2CommandExec + with LeafExecNode { + + import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._ + + override protected def run(): Seq[InternalRow] = { + val rows = new ArrayBuffer[InternalRow]() + + // handle GLOBAL VIEWS + val globalTemp = SQLConf.get.globalTempDatabase + if (namespace.nonEmpty && globalTemp == namespace.head) { + pattern + .map(p => session.sessionState.catalog.globalTempViewManager.listViewNames(p)) + .getOrElse(session.sessionState.catalog.globalTempViewManager.listViewNames("*")) + .map(name => rows += toCatalystRow(globalTemp, name, true)) + } else { + val views = catalog.listViews(namespace: _*) + views.map { view => + if (pattern.map(StringUtils.filterPattern(Seq(view.name()), _).nonEmpty).getOrElse(true)) { + rows += toCatalystRow(view.namespace().quoted, view.name(), false) + } + } + } + + // include TEMP VIEWS + pattern + .map(p => session.sessionState.catalog.listLocalTempViews(p)) + .getOrElse(session.sessionState.catalog.listLocalTempViews("*")) + .map(v => rows += toCatalystRow(v.database.toArray.quoted, v.table, true)) + + rows.toSeq + } + + override def simpleString(maxFields: Int): String = { + s"ShowV2ViewsExec" + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java new file mode 100644 index 000000000000..ef4f0090292c --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/TestExtendedParser.java @@ -0,0 +1,236 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.expressions.Term; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.parser.AbstractSqlParser; +import org.apache.spark.sql.catalyst.parser.AstBuilder; +import org.apache.spark.sql.catalyst.parser.ParserInterface; +import org.apache.spark.sql.catalyst.parser.extensions.IcebergSparkSqlExtensionsParser; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestExtendedParser { + + private static SparkSession spark; + private static final String SQL_PARSER_FIELD = "sqlParser"; + private ParserInterface originalParser; + + @BeforeAll + public static void before() { + spark = + SparkSession.builder() + .master("local") + .appName("TestExtendedParser") + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void after() { + if (spark != null) { + spark.stop(); + } + } + + @BeforeEach + public void saveOriginalParser() throws Exception { + Class clazz = spark.sessionState().getClass(); + Field parserField = null; + while (clazz != null && parserField == null) { + try { + parserField = clazz.getDeclaredField(SQL_PARSER_FIELD); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + parserField.setAccessible(true); + originalParser = (ParserInterface) parserField.get(spark.sessionState()); + } + + @AfterEach + public void restoreOriginalParser() throws Exception { + setSessionStateParser(spark.sessionState(), originalParser); + } + + /** + * Tests that the Iceberg extended SQL parser can correctly parse a sort order string and return + * the expected RawOrderField. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderWithRealIcebergExtendedParser() throws Exception { + ParserInterface origParser = null; + Class clazz = spark.sessionState().getClass(); + while (clazz != null && origParser == null) { + try { + Field parserField = clazz.getDeclaredField(SQL_PARSER_FIELD); + parserField.setAccessible(true); + origParser = (ParserInterface) parserField.get(spark.sessionState()); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + assertThat(origParser).isNotNull(); + + IcebergSparkSqlExtensionsParser icebergParser = new IcebergSparkSqlExtensionsParser(origParser); + + setSessionStateParser(spark.sessionState(), icebergParser); + + List fields = + ExtendedParser.parseSortOrder(spark, "id ASC NULLS FIRST"); + + assertThat(fields).isNotEmpty(); + ExtendedParser.RawOrderField first = fields.get(0); + assertThat(first.direction()).isEqualTo(SortDirection.ASC); + assertThat(first.nullOrder()).isEqualTo(NullOrder.NULLS_FIRST); + } + + /** + * Tests that parseSortOrder can find and use an ExtendedParser that is wrapped inside another + * ParserInterface implementation. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderFindsNestedExtendedParser() throws Exception { + ExtendedParser icebergParser = mock(ExtendedParser.class); + + ExtendedParser.RawOrderField field = + new ExtendedParser.RawOrderField( + mock(Term.class), SortDirection.ASC, NullOrder.NULLS_FIRST); + List expected = Collections.singletonList(field); + + when(icebergParser.parseSortOrder("id ASC NULLS FIRST")).thenReturn(expected); + + ParserInterface wrapper = new WrapperParser(icebergParser); + + setSessionStateParser(spark.sessionState(), wrapper); + + List result = + ExtendedParser.parseSortOrder(spark, "id ASC NULLS FIRST"); + assertThat(result).isSameAs(expected); + + verify(icebergParser).parseSortOrder("id ASC NULLS FIRST"); + } + + /** + * Tests that parseSortOrder throws an exception if no ExtendedParser instance can be found in the + * parser chain. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderThrowsWhenNoExtendedParserFound() throws Exception { + ParserInterface dummy = mock(ParserInterface.class); + setSessionStateParser(spark.sessionState(), dummy); + + assertThatThrownBy(() -> ExtendedParser.parseSortOrder(spark, "id ASC")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Iceberg ExtendedParser"); + } + + /** + * Tests that parseSortOrder can find an ExtendedParser in a parent class field of the parser. + * + * @throws Exception if reflection access fails + */ + @Test + public void testParseSortOrderFindsExtendedParserInParentClassField() throws Exception { + ExtendedParser icebergParser = mock(ExtendedParser.class); + ExtendedParser.RawOrderField field = + new ExtendedParser.RawOrderField( + mock(Term.class), SortDirection.ASC, NullOrder.NULLS_FIRST); + List expected = Collections.singletonList(field); + when(icebergParser.parseSortOrder("id ASC NULLS FIRST")).thenReturn(expected); + ParserInterface parser = new GrandChildParser(icebergParser); + setSessionStateParser(spark.sessionState(), parser); + + List result = + ExtendedParser.parseSortOrder(spark, "id ASC NULLS FIRST"); + assertThat(result).isSameAs(expected); + verify(icebergParser).parseSortOrder("id ASC NULLS FIRST"); + } + + private static void setSessionStateParser(Object sessionState, ParserInterface parser) + throws Exception { + Class clazz = sessionState.getClass(); + Field targetField = null; + while (clazz != null && targetField == null) { + try { + targetField = clazz.getDeclaredField(SQL_PARSER_FIELD); + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (targetField == null) { + throw new IllegalStateException( + "No suitable sqlParser field found in sessionState class hierarchy!"); + } + targetField.setAccessible(true); + targetField.set(sessionState, parser); + } + + private static class WrapperParser extends AbstractSqlParser { + private final ParserInterface delegate; + private String name; + + WrapperParser(ParserInterface delegate) { + this.delegate = delegate; + this.name = "delegate"; + } + + public ParserInterface getDelegate() { + return delegate; + } + + @Override + public AstBuilder astBuilder() { + return null; + } + } + + private static class ChildParser extends WrapperParser { + ChildParser(ParserInterface parent) { + super(parent); + } + } + + private static class GrandChildParser extends ChildParser { + GrandChildParser(ParserInterface parent) { + super(parent); + } + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/Employee.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/Employee.java new file mode 100644 index 000000000000..8918dfec6584 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/Employee.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import java.util.Objects; + +public class Employee { + private Integer id; + private String dep; + + public Employee() {} + + public Employee(Integer id, String dep) { + this.id = id; + this.dep = dep; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getDep() { + return dep; + } + + public void setDep(String dep) { + this.dep = dep; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } else if (other == null || getClass() != other.getClass()) { + return false; + } + + Employee employee = (Employee) other; + return Objects.equals(id, employee.id) && Objects.equals(dep, employee.dep); + } + + @Override + public int hashCode() { + return Objects.hash(id, dep); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java new file mode 100644 index 000000000000..f766fbb79aff --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ExtensionsTestBase.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.Files; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.hive.TestHiveMetastore; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.BeforeAll; + +public abstract class ExtensionsTestBase extends CatalogTestBase { + + private static final Random RANDOM = ThreadLocalRandom.current(); + + @BeforeAll + public static void startMetastoreAndSpark() { + TestBase.metastore = new TestHiveMetastore(); + metastore.start(); + TestBase.hiveConf = metastore.hiveConf(); + + TestBase.spark.stop(); + + TestBase.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config("spark.testing", "true") + .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") + .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) + .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config("spark.sql.shuffle.partitions", "4") + .config("spark.sql.hive.metastorePartitionPruningFallbackOnException", "true") + .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config( + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), String.valueOf(RANDOM.nextBoolean())) + .config(TestBase.DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + TestBase.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + + TestBase.catalog = + (HiveCatalog) + CatalogUtil.loadCatalog( + HiveCatalog.class.getName(), "hive", ImmutableMap.of(), hiveConf); + } + + protected DataFile writeData(Table table, List records) throws IOException { + File tempFile = temp.resolve(UUID.randomUUID() + ".parquet").toFile(); + + OutputFile file = Files.localOutput(tempFile); + + DataWriter dataWriter = + Parquet.writeData(file) + .forTable(table) + .createWriterFunc(GenericParquetWriter::create) + .overwrite() + .build(); + + try (dataWriter) { + for (Record record : records) { + dataWriter.write(record); + } + } + + return dataWriter.toDataFile(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ProcedureUtil.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ProcedureUtil.java new file mode 100644 index 000000000000..de4acd74a7ed --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/ProcedureUtil.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.UUID; +import org.apache.iceberg.ImmutableGenericPartitionStatisticsFile; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.PositionOutputStream; + +public class ProcedureUtil { + + private ProcedureUtil() {} + + static PartitionStatisticsFile writePartitionStatsFile( + long snapshotId, String statsLocation, FileIO fileIO) { + PositionOutputStream positionOutputStream; + try { + positionOutputStream = fileIO.newOutputFile(statsLocation).create(); + positionOutputStream.close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + return ImmutableGenericPartitionStatisticsFile.builder() + .snapshotId(snapshotId) + .fileSizeInBytes(42L) + .path(statsLocation) + .build(); + } + + static String statsFileLocation(String tableLocation) { + String statsFileName = "stats-file-" + UUID.randomUUID(); + return tableLocation.replaceFirst("file:", "") + "/metadata/" + statsFileName; + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkPlanUtil.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkPlanUtil.java new file mode 100644 index 000000000000..830d07d86eab --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkPlanUtil.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static scala.collection.JavaConverters.seqAsJavaListConverter; + +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.execution.CommandResultExec; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper; +import org.apache.spark.sql.execution.datasources.v2.BatchScanExec; +import scala.PartialFunction; +import scala.collection.Seq; + +public class SparkPlanUtil { + + private static final AdaptiveSparkPlanHelper SPARK_HELPER = new AdaptiveSparkPlanHelper() {}; + + private SparkPlanUtil() {} + + public static List collectLeaves(SparkPlan plan) { + return toJavaList(SPARK_HELPER.collectLeaves(actualPlan(plan))); + } + + public static List collectBatchScans(SparkPlan plan) { + List leaves = collectLeaves(plan); + return leaves.stream() + .filter(scan -> scan instanceof BatchScanExec) + .collect(Collectors.toList()); + } + + private static SparkPlan actualPlan(SparkPlan plan) { + if (plan instanceof CommandResultExec) { + return ((CommandResultExec) plan).commandPhysicalPlan(); + } else { + return plan; + } + } + + public static List collectExprs( + SparkPlan sparkPlan, Predicate predicate) { + Seq> seq = + SPARK_HELPER.collect( + sparkPlan, + new PartialFunction>() { + @Override + public List apply(SparkPlan plan) { + List exprs = Lists.newArrayList(); + + for (Expression expr : toJavaList(plan.expressions())) { + exprs.addAll(collectExprs(expr, predicate)); + } + + return exprs; + } + + @Override + public boolean isDefinedAt(SparkPlan plan) { + return true; + } + }); + return toJavaList(seq).stream().flatMap(Collection::stream).collect(Collectors.toList()); + } + + private static List collectExprs( + Expression expression, Predicate predicate) { + Seq seq = + expression.collect( + new PartialFunction() { + @Override + public Expression apply(Expression expr) { + return expr; + } + + @Override + public boolean isDefinedAt(Expression expr) { + return predicate.test(expr); + } + }); + return toJavaList(seq); + } + + private static List toJavaList(Seq seq) { + return seqAsJavaListConverter(seq).asJava(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java new file mode 100644 index 000000000000..b5d641576314 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java @@ -0,0 +1,461 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.DataOperations.DELETE; +import static org.apache.iceberg.DataOperations.OVERWRITE; +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.apache.iceberg.SnapshotSummary.ADDED_DELETE_FILES_PROP; +import static org.apache.iceberg.SnapshotSummary.ADDED_DVS_PROP; +import static org.apache.iceberg.SnapshotSummary.ADDED_FILES_PROP; +import static org.apache.iceberg.SnapshotSummary.ADD_POS_DELETE_FILES_PROP; +import static org.apache.iceberg.SnapshotSummary.CHANGED_PARTITION_COUNT_PROP; +import static org.apache.iceberg.SnapshotSummary.DELETED_FILES_PROP; +import static org.apache.iceberg.TableProperties.DATA_PLANNING_MODE; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DELETE_PLANNING_MODE; +import static org.apache.iceberg.TableProperties.FORMAT_VERSION; +import static org.apache.iceberg.TableProperties.ORC_VECTORIZATION_ENABLED; +import static org.apache.iceberg.TableProperties.PARQUET_VECTORIZATION_ENABLED; +import static org.apache.iceberg.TableProperties.SPARK_WRITE_PARTITIONED_FANOUT_ENABLED; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_HASH; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_NONE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_RANGE; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.execution.SparkPlan; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class SparkRowLevelOperationsTestBase extends ExtensionsTestBase { + + private static final Random RANDOM = ThreadLocalRandom.current(); + + @Parameter(index = 3) + protected FileFormat fileFormat; + + @Parameter(index = 4) + protected boolean vectorized; + + @Parameter(index = 5) + protected String distributionMode; + + @Parameter(index = 6) + protected boolean fanoutEnabled; + + @Parameter(index = 7) + protected String branch; + + @Parameter(index = 8) + protected PlanningMode planningMode; + + @Parameter(index = 9) + protected int formatVersion; + + @Parameters( + name = + "catalogName = {0}, implementation = {1}, config = {2}," + + " format = {3}, vectorized = {4}, distributionMode = {5}," + + " fanout = {6}, branch = {7}, planningMode = {8}, formatVersion = {9}") + public static Object[][] parameters() { + return new Object[][] { + { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default"), + FileFormat.ORC, + true, + WRITE_DISTRIBUTION_MODE_NONE, + true, + SnapshotRef.MAIN_BRANCH, + LOCAL, + 2 + }, + { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default"), + FileFormat.PARQUET, + true, + WRITE_DISTRIBUTION_MODE_NONE, + false, + "test", + DISTRIBUTED, + 2 + }, + { + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hadoop"), + FileFormat.PARQUET, + RANDOM.nextBoolean(), + WRITE_DISTRIBUTION_MODE_HASH, + true, + null, + LOCAL, + 2 + }, + { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "clients", "1", + "parquet-enabled", "false", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + ), + FileFormat.AVRO, + false, + WRITE_DISTRIBUTION_MODE_RANGE, + false, + "test", + DISTRIBUTED, + 2 + }, + { + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hadoop"), + FileFormat.PARQUET, + RANDOM.nextBoolean(), + WRITE_DISTRIBUTION_MODE_HASH, + true, + null, + LOCAL, + 3 + }, + { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", + "hive", + "default-namespace", + "default", + "clients", + "1", + "parquet-enabled", + "false", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + ), + FileFormat.AVRO, + false, + WRITE_DISTRIBUTION_MODE_RANGE, + false, + "test", + DISTRIBUTED, + 3 + }, + }; + } + + protected abstract Map extraTableProperties(); + + protected void initTable() { + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s', '%s' '%s', '%s' '%s', '%s' '%s', '%s' '%s', '%s' '%s')", + tableName, + DEFAULT_FILE_FORMAT, + fileFormat, + WRITE_DISTRIBUTION_MODE, + distributionMode, + SPARK_WRITE_PARTITIONED_FANOUT_ENABLED, + String.valueOf(fanoutEnabled), + DATA_PLANNING_MODE, + planningMode.modeName(), + DELETE_PLANNING_MODE, + planningMode.modeName(), + FORMAT_VERSION, + formatVersion); + + switch (fileFormat) { + case PARQUET: + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%b')", + tableName, PARQUET_VECTORIZATION_ENABLED, vectorized); + break; + case ORC: + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%b')", + tableName, ORC_VECTORIZATION_ENABLED, vectorized); + break; + case AVRO: + assertThat(vectorized).isFalse(); + break; + } + + Map props = extraTableProperties(); + props.forEach( + (prop, value) -> { + sql("ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", tableName, prop, value); + }); + } + + protected void createAndInitTable(String schema) { + createAndInitTable(schema, null); + } + + protected void createAndInitTable(String schema, String jsonData) { + createAndInitTable(schema, "", jsonData); + } + + protected void createAndInitTable(String schema, String partitioning, String jsonData) { + sql("CREATE TABLE %s (%s) USING iceberg %s", tableName, schema, partitioning); + initTable(); + + if (jsonData != null) { + try { + Dataset ds = toDS(schema, jsonData); + ds.coalesce(1).writeTo(tableName).append(); + createBranchIfNeeded(); + } catch (NoSuchTableException e) { + throw new RuntimeException("Failed to write data", e); + } + } + } + + protected void append(String table, String jsonData) { + append(table, null, jsonData); + } + + protected void append(String table, String schema, String jsonData) { + try { + Dataset ds = toDS(schema, jsonData); + ds.coalesce(1).writeTo(table).append(); + } catch (NoSuchTableException e) { + throw new RuntimeException("Failed to write data", e); + } + } + + protected void createOrReplaceView(String name, String jsonData) { + createOrReplaceView(name, null, jsonData); + } + + protected void createOrReplaceView(String name, String schema, String jsonData) { + Dataset ds = toDS(schema, jsonData); + ds.createOrReplaceTempView(name); + } + + protected void createOrReplaceView(String name, List data, Encoder encoder) { + spark.createDataset(data, encoder).createOrReplaceTempView(name); + } + + private Dataset toDS(String schema, String jsonData) { + List jsonRows = + Arrays.stream(jsonData.split("\n")) + .filter(str -> !str.trim().isEmpty()) + .collect(Collectors.toList()); + Dataset jsonDS = spark.createDataset(jsonRows, Encoders.STRING()); + + if (schema != null) { + return spark.read().schema(schema).json(jsonDS); + } else { + return spark.read().json(jsonDS); + } + } + + protected void validateDelete( + Snapshot snapshot, String changedPartitionCount, String deletedDataFiles) { + validateSnapshot(snapshot, DELETE, changedPartitionCount, deletedDataFiles, null, null); + } + + protected void validateCopyOnWrite( + Snapshot snapshot, + String changedPartitionCount, + String deletedDataFiles, + String addedDataFiles) { + String operation = null == addedDataFiles && null != deletedDataFiles ? DELETE : OVERWRITE; + validateSnapshot( + snapshot, operation, changedPartitionCount, deletedDataFiles, null, addedDataFiles); + } + + protected void validateMergeOnRead( + Snapshot snapshot, + String changedPartitionCount, + String addedDeleteFiles, + String addedDataFiles) { + String operation = null == addedDataFiles && null != addedDeleteFiles ? DELETE : OVERWRITE; + validateSnapshot( + snapshot, operation, changedPartitionCount, null, addedDeleteFiles, addedDataFiles); + } + + protected void validateSnapshot( + Snapshot snapshot, + String operation, + String changedPartitionCount, + String deletedDataFiles, + String addedDeleteFiles, + String addedDataFiles) { + assertThat(snapshot.operation()).as("Operation must match").isEqualTo(operation); + validateProperty(snapshot, CHANGED_PARTITION_COUNT_PROP, changedPartitionCount); + validateProperty(snapshot, DELETED_FILES_PROP, deletedDataFiles); + validateProperty(snapshot, ADDED_DELETE_FILES_PROP, addedDeleteFiles); + validateProperty(snapshot, ADDED_FILES_PROP, addedDataFiles); + if (formatVersion >= 3) { + validateProperty(snapshot, ADDED_DVS_PROP, addedDeleteFiles); + assertThat(snapshot.summary()).doesNotContainKey(ADD_POS_DELETE_FILES_PROP); + } + } + + protected void validateProperty(Snapshot snapshot, String property, Set expectedValues) { + String actual = snapshot.summary().get(property); + assertThat(actual) + .as( + "Snapshot property " + + property + + " has unexpected value, actual = " + + actual + + ", expected one of : " + + String.join(",", expectedValues)) + .isIn(expectedValues); + } + + protected void validateProperty(Snapshot snapshot, String property, String expectedValue) { + if (null == expectedValue) { + assertThat(snapshot.summary()).doesNotContainKey(property); + } else { + assertThat(snapshot.summary()) + .as("Snapshot property " + property + " has unexpected value.") + .containsEntry(property, expectedValue); + } + } + + protected void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + protected DataFile writeDataFile(Table table, List records) { + try { + OutputFile file = + Files.localOutput( + temp.resolve(fileFormat.addExtension(UUID.randomUUID().toString())).toFile()); + + DataWriter dataWriter = + Parquet.writeData(file) + .forTable(table) + .createWriterFunc(GenericParquetWriter::create) + .overwrite() + .build(); + + try { + for (GenericRecord record : records) { + dataWriter.write(record); + } + } finally { + dataWriter.close(); + } + + return dataWriter.toDataFile(); + + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + protected String commitTarget() { + return branch == null ? tableName : String.format("%s.branch_%s", tableName, branch); + } + + @Override + protected String selectTarget() { + return branch == null ? tableName : String.format("%s VERSION AS OF '%s'", tableName, branch); + } + + protected void createBranchIfNeeded() { + if (branch != null && !branch.equals(SnapshotRef.MAIN_BRANCH)) { + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branch); + } + } + + // ORC currently does not support vectorized reads with deletes + protected boolean supportsVectorization() { + return vectorized && (isParquet() || isCopyOnWrite()); + } + + private boolean isParquet() { + return fileFormat.equals(FileFormat.PARQUET); + } + + private boolean isCopyOnWrite() { + return extraTableProperties().containsValue(RowLevelOperationMode.COPY_ON_WRITE.modeName()); + } + + protected void assertAllBatchScansVectorized(SparkPlan plan) { + List batchScans = SparkPlanUtil.collectBatchScans(plan); + assertThat(batchScans).hasSizeGreaterThan(0).allMatch(SparkPlan::supportsColumnar); + } + + protected void createTableWithDeleteGranularity( + String schema, String partitionedBy, DeleteGranularity deleteGranularity) { + createAndInitTable(schema, partitionedBy, null /* empty */); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s')", + tableName, TableProperties.DELETE_GRANULARITY, deleteGranularity); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAddFilesProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAddFilesProcedure.java new file mode 100644 index 000000000000..29993380b50c --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAddFilesProcedure.java @@ -0,0 +1,1479 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.avro.file.DataFileWriter; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericDatumWriter; +import org.apache.avro.generic.GenericRecord; +import org.apache.avro.io.DatumWriter; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.joda.time.DateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestAddFilesProcedure extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, formatVersion = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + 1 + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + 2 + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + 2 + } + }; + } + + @Parameter(index = 3) + private int formatVersion; + + private final String sourceTableName = "source_table"; + private File fileTableDir; + + @BeforeEach + public void setupTempDirs() { + fileTableDir = temp.toFile(); + } + + @AfterEach + public void dropTables() { + sql("DROP TABLE IF EXISTS %s PURGE", sourceTableName); + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void addDataUnpartitioned() { + createUnpartitionedFileTable("parquet"); + + createIcebergTable("id Integer, name String, dept String, subdept String"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT * FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void deleteAndAddBackUnpartitioned() { + createUnpartitionedFileTable("parquet"); + + createIcebergTable("id Integer, name String, dept String, subdept String"); + + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + String deleteData = "DELETE FROM %s"; + sql(deleteData, tableName); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT * FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @Disabled // TODO Classpath issues prevent us from actually writing to a Spark ORC table + public void addDataUnpartitionedOrc() { + createUnpartitionedFileTable("orc"); + + String createIceberg = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING iceberg"; + + sql(createIceberg, tableName); + + Object result = + scalarSql( + "CALL %s.system.add_files('%s', '`orc`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertThat(result).isEqualTo(2L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT * FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addAvroFile() throws Exception { + // Spark Session Catalog cannot load metadata tables + // with "The namespace in session catalog must have exactly one name part" + assumeThat(catalogName).isNotEqualTo("spark_catalog"); + + // Create an Avro file + + Schema schema = + SchemaBuilder.record("record") + .fields() + .requiredInt("id") + .requiredString("data") + .endRecord(); + GenericRecord record1 = new GenericData.Record(schema); + record1.put("id", 1L); + record1.put("data", "a"); + GenericRecord record2 = new GenericData.Record(schema); + record2.put("id", 2L); + record2.put("data", "b"); + File outputFile = temp.resolve("test.avro").toFile(); + + DatumWriter datumWriter = new GenericDatumWriter(schema); + DataFileWriter dataFileWriter = new DataFileWriter(datumWriter); + dataFileWriter.create(schema, outputFile); + dataFileWriter.append(record1); + dataFileWriter.append(record2); + dataFileWriter.close(); + + createIcebergTable("id Long, data String"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`avro`.`%s`')", + catalogName, tableName, outputFile.getPath()); + assertOutput(result, 1L, 1L); + + List expected = Lists.newArrayList(new Object[] {1L, "a"}, new Object[] {2L, "b"}); + + assertEquals( + "Iceberg table contains correct data", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); + + List actualRecordCount = + sql("select %s from %s.files", DataFile.RECORD_COUNT.name(), tableName); + List expectedRecordCount = Lists.newArrayList(); + expectedRecordCount.add(new Object[] {2L}); + assertEquals( + "Iceberg file metadata should have correct metadata count", + expectedRecordCount, + actualRecordCount); + } + + // TODO Adding spark-avro doesn't work in tests + @Disabled + public void addDataUnpartitionedAvro() { + createUnpartitionedFileTable("avro"); + + String createIceberg = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING iceberg"; + + sql(createIceberg, tableName); + + Object result = + scalarSql( + "CALL %s.system.add_files('%s', '`avro`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertThat(result).isEqualTo(2L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT * FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addDataUnpartitionedHive() { + createUnpartitionedHiveTable(); + + createIcebergTable("id Integer, name String, dept String, subdept String"); + + List result = + sql("CALL %s.system.add_files('%s', '%s')", catalogName, tableName, sourceTableName); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT * FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addDataUnpartitionedExtraCol() { + createUnpartitionedFileTable("parquet"); + + createIcebergTable("id Integer, name String, dept String, subdept String, foo string"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT * FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addDataUnpartitionedMissingCol() { + createUnpartitionedFileTable("parquet"); + + createIcebergTable("id Integer, name String, dept String"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addDataPartitionedMissingCol() { + createPartitionedFileTable("parquet"); + + createIcebergTable("id Integer, name String, dept String", "PARTITIONED BY (id)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 8L, 4L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addDataPartitioned() { + createPartitionedFileTable("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 8L, 4L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @Disabled // TODO Classpath issues prevent us from actually writing to a Spark ORC table + public void addDataPartitionedOrc() { + createPartitionedFileTable("orc"); + + String createIceberg = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING iceberg PARTITIONED BY (id)"; + + sql(createIceberg, tableName); + + Object result = + scalarSql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertThat(result).isEqualTo(8L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + // TODO Adding spark-avro doesn't work in tests + @Disabled + public void addDataPartitionedAvro() { + createPartitionedFileTable("avro"); + + String createIceberg = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING iceberg PARTITIONED BY (id)"; + + sql(createIceberg, tableName); + + Object result = + scalarSql( + "CALL %s.system.add_files('%s', '`avro`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertThat(result).isEqualTo(8L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addDataPartitionedHive() { + createPartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List result = + sql("CALL %s.system.add_files('%s', '%s')", catalogName, tableName, sourceTableName); + + assertOutput(result, 8L, 4L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addPartitionToPartitioned() { + createPartitionedFileTable("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 1))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void deleteAndAddBackPartitioned() { + createPartitionedFileTable("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 1))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + String deleteData = "DELETE FROM %s where id = 1"; + sql(deleteData, tableName); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 1))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addPartitionToPartitionedSnapshotIdInheritanceEnabledInTwoRuns() + throws NoSuchTableException, ParseException { + createPartitionedFileTable("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", + tableName, TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED); + + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 1))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 2))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id < 3 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + + manifestSpecMatchesTableSpec(); + + verifyUUIDInPath(); + } + + @TestTemplate + public void addPartitionsFromHiveSnapshotInheritanceEnabled() + throws NoSuchTableException, ParseException { + createPartitionedHiveTable(); + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", + tableName, TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED); + + sql("CALL %s.system.add_files('%s', '%s')", catalogName, tableName, sourceTableName); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + + manifestSpecMatchesTableSpec(); + + verifyUUIDInPath(); + } + + @TestTemplate + public void addDataPartitionedByDateToPartitioned() { + createDatePartitionedFileTable("parquet"); + + createIcebergTable("id Integer, name String, date Date", "PARTITIONED BY (date)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('date', '2021-01-01'))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, date FROM %s WHERE date = '2021-01-01' ORDER BY id", sourceTableName), + sql("SELECT id, name, date FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addDataPartitionedVerifyPartitionTypeInferredCorrectly() { + createTableWithTwoPartitions("parquet"); + + createIcebergTable( + "id Integer, name String, date Date, dept String", "PARTITIONED BY (date, dept)"); + + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('date', '2021-01-01'))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + String sqlFormat = + "SELECT id, name, dept, date FROM %s WHERE date = '2021-01-01' and dept= '01' ORDER BY id"; + assertEquals( + "Iceberg table contains correct data", + sql(sqlFormat, sourceTableName), + sql(sqlFormat, tableName)); + } + + @TestTemplate + public void addFilteredPartitionsToPartitioned() { + createCompositePartitionedTable("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id, dept)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 1))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addFilteredPartitionsToPartitioned2() { + createCompositePartitionedTable("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id, dept)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('dept', 'hr'))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 6L, 3L); + + assertEquals( + "Iceberg table contains correct data", + sql( + "SELECT id, name, dept, subdept FROM %s WHERE dept = 'hr' ORDER BY id", + sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addFilteredPartitionsToPartitionedWithNullValueFilteringOnId() { + createCompositePartitionedTableWithNullValueInPartitionColumn("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id, dept)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 1))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addFilteredPartitionsToPartitionedWithNullValueFilteringOnDept() { + createCompositePartitionedTableWithNullValueInPartitionColumn("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id, dept)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('dept', 'hr'))", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 6L, 3L); + + assertEquals( + "Iceberg table contains correct data", + sql( + "SELECT id, name, dept, subdept FROM %s WHERE dept = 'hr' ORDER BY id", + sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addAllPartitionsToPartitionedWithNullValue() { + createCompositePartitionedTableWithNullValueInPartitionColumn("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id, dept)"); + + // Add all partitions including null partitions. + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 10L, 5L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addAllPartitionsToNonStringPartitionedWithNullValue() { + createPartitionedTableWithNullValueInPartitionColumnOnId("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + // Add all partitions including null partitions. + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 10L, 5L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addPartitionsWithNullValueShouldAddFilesToNullPartition() { + // This test is to ensure that "null" string partition is not incorrectly created. + + createPartitionedTableWithNullValueInPartitionColumnOnDept("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (dept)"); + + // Add all partitions including null partitions. + List result = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 6L, 3L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + + // Check if correct partitions are created + List actualRows = + sql("SELECT partition from %s.partitions ORDER BY partition", tableName); + assertEquals( + "Other partitions should match", + ImmutableList.of( + row(new Object[] {new Object[] {null}}), + row(new Object[] {new Object[] {"facilities"}}), + row(new Object[] {new Object[] {"hr"}})), + actualRows); + } + + @TestTemplate + public void addFileTableOldSpecDataAfterPartitionSpecEvolved() + throws NoSuchTableException, ParseException { + createPartitionedFileTable("parquet"); + createIcebergTable( + "id Integer, name String, dept String, subdept String", + "PARTITIONED BY (id, dept, subdept)"); + sql("ALTER TABLE %s DROP PARTITION FIELD dept", tableName); + sql( + "ALTER TABLE %s DROP PARTITION FIELD subdept", + tableName); // This spec now matches the partitioning of the parquet table + sql("ALTER TABLE %s ADD PARTITION FIELD subdept", tableName); + + if (formatVersion == 1) { + // In V1, since we are dropping the partition field, it adds a void transform which will not + // match with the input spec + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Cannot find a partition spec in Iceberg table %s that matches the partition columns ([id]) in input table", + tableName); + return; + } + + List result = + sql( + "CALL %s.system.add_files(table => '%s', source_table => '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + + assertOutput(result, 8L, 4L); + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + // Find the spec that matches the partitioning of the parquet table + PartitionSpec compatibleSpec = + table.specs().values().stream() + .filter(spec -> spec.fields().size() == 1) + .filter(spec -> "id".equals(spec.fields().get(0).name())) + .findFirst() + .orElse(null); + + assertThat(compatibleSpec).isNotNull(); + manifestSpecMatchesGivenSpec(table, compatibleSpec); + verifyUUIDInPath(); + } + + @TestTemplate + public void addFileTableNoCompatibleSpec() { + createPartitionedFileTable("parquet"); + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (dept)"); + sql("ALTER TABLE %s ADD PARTITION FIELD subdept", tableName); + + String fullTableName = tableName; + if (implementation.equals(SparkCatalogConfig.SPARK_SESSION.implementation())) { + fullTableName = String.format("%s.%s", catalogName, tableName); + } + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files(table => '%s', source_table => '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Cannot find a partition spec in Iceberg table %s that matches the partition columns (%s) in input table", + fullTableName, "[id]"); + } + + @TestTemplate + public void addWeirdCaseHiveTable() { + createWeirdCaseTable(); + + createIcebergTable( + "id Integer, `naMe` String, dept String, subdept String", "PARTITIONED BY (`naMe`)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '%s', map('naMe', 'John Doe'))", + catalogName, tableName, sourceTableName); + + assertOutput(result, 2L, 1L); + + /* + While we would like to use + SELECT id, `naMe`, dept, subdept FROM %s WHERE `naMe` = 'John Doe' ORDER BY id + Spark does not actually handle this pushdown correctly for hive based tables and it returns 0 records + */ + List expected = + sql("SELECT id, `naMe`, dept, subdept from %s ORDER BY id", sourceTableName).stream() + .filter(r -> r[1].equals("John Doe")) + .collect(Collectors.toList()); + + // TODO when this assert breaks Spark fixed the pushdown issue + assertThat( + sql( + "SELECT id, `naMe`, dept, subdept from %s WHERE `naMe` = 'John Doe' ORDER BY id", + sourceTableName)) + .as("If this assert breaks it means that Spark has fixed the pushdown issue") + .isEmpty(); + + // Pushdown works for iceberg + assertThat( + sql( + "SELECT id, `naMe`, dept, subdept FROM %s WHERE `naMe` = 'John Doe' ORDER BY id", + tableName)) + .as("We should be able to pushdown mixed case partition keys") + .hasSize(2); + + assertEquals( + "Iceberg table contains correct data", + expected, + sql("SELECT id, `naMe`, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void addPartitionToPartitionedHive() { + createPartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List result = + sql( + "CALL %s.system.add_files('%s', '%s', map('id', 1))", + catalogName, tableName, sourceTableName); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void invalidDataImport() { + createPartitionedFileTable("parquet"); + + createIcebergTable("id Integer, name String, dept String, subdept String"); + + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('id', 1))", + catalogName, tableName, fileTableDir.getAbsolutePath())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot find a partition spec in Iceberg table") + .hasMessageContaining("that matches the partition columns"); + + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot find a partition spec in Iceberg table") + .hasMessageContaining("that matches the partition columns"); + } + + @TestTemplate + public void invalidDataImportPartitioned() { + createUnpartitionedFileTable("parquet"); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('x', '1', 'y', '2'))", + catalogName, tableName, fileTableDir.getAbsolutePath())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot find a partition spec in Iceberg table") + .hasMessageContaining("that matches the partition columns"); + + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`', map('dept', '2'))", + catalogName, tableName, fileTableDir.getAbsolutePath())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot find a partition spec in Iceberg table") + .hasMessageContaining("that matches the partition columns"); + } + + @TestTemplate + public void partitionColumnCountMismatchInFilter() { + createPartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '%s', map('id', '0', 'dept', '1'))", + catalogName, tableName, sourceTableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot add data files to target table") + .hasMessageContaining( + "because that table is partitioned, but the number of columns in the provided partition filter (2)" + + " is greater than the number of partitioned columns in table (1)"); + } + + @TestTemplate + public void invalidPartitionColumnsInFilter() { + createPartitionedHiveTable(); + + String icebergTablePartitionNames = "id"; + createIcebergTable( + "id Integer, name String, dept String, subdept String", + String.format("PARTITIONED BY (%s)", icebergTablePartitionNames)); + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '%s', map('dept', '1'))", + catalogName, tableName, sourceTableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot add files to target table") + .hasMessageContaining( + "specified partition filter refers to columns that are not partitioned: [dept]") + .hasMessageContaining("Valid partition columns: [%s]", icebergTablePartitionNames); + } + + @TestTemplate + public void addTwice() { + createPartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List result1 = + sql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s', " + + "partition_filter => map('id', 1))", + catalogName, tableName, sourceTableName); + assertOutput(result1, 2L, 1L); + + List result2 = + sql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s', " + + "partition_filter => map('id', 2))", + catalogName, tableName, sourceTableName); + assertOutput(result2, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", tableName)); + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 2 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 2 ORDER BY id", tableName)); + } + + @TestTemplate + public void duplicateDataPartitioned() { + createPartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + sql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s', " + + "partition_filter => map('id', 1))", + catalogName, tableName, sourceTableName); + + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s', " + + "partition_filter => map('id', 1))", + catalogName, tableName, sourceTableName)) + .isInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Cannot complete import because data files to be imported already" + + " exist within the target table"); + } + + @TestTemplate + public void duplicateDataPartitionedAllowed() { + createPartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List result1 = + sql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s', " + + "partition_filter => map('id', 1))", + catalogName, tableName, sourceTableName); + + assertOutput(result1, 2L, 1L); + + List result2 = + sql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s', " + + "partition_filter => map('id', 1)," + + "check_duplicate_files => false)", + catalogName, tableName, sourceTableName); + + assertOutput(result2, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql( + "SELECT id, name, dept, subdept FROM %s WHERE id = 1 UNION ALL " + + "SELECT id, name, dept, subdept FROM %s WHERE id = 1", + sourceTableName, sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s", tableName, tableName)); + } + + @TestTemplate + public void duplicateDataUnpartitioned() { + createUnpartitionedHiveTable(); + + createIcebergTable("id Integer, name String, dept String, subdept String"); + + sql("CALL %s.system.add_files('%s', '%s')", catalogName, tableName, sourceTableName); + + assertThatThrownBy( + () -> + scalarSql( + "CALL %s.system.add_files('%s', '%s')", + catalogName, tableName, sourceTableName)) + .isInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Cannot complete import because data files to be imported already" + + " exist within the target table"); + } + + @TestTemplate + public void duplicateDataUnpartitionedAllowed() { + createUnpartitionedHiveTable(); + + createIcebergTable("id Integer, name String, dept String, subdept String"); + + List result1 = + sql("CALL %s.system.add_files('%s', '%s')", catalogName, tableName, sourceTableName); + assertOutput(result1, 2L, 1L); + + List result2 = + sql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s'," + + "check_duplicate_files => false)", + catalogName, tableName, sourceTableName); + assertOutput(result2, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql( + "SELECT * FROM (SELECT * FROM %s UNION ALL " + "SELECT * from %s) ORDER BY id", + sourceTableName, sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testEmptyImportDoesNotThrow() { + createIcebergTable("id Integer, name String, dept String, subdept String"); + + // Empty path based import + List pathResult = + sql( + "CALL %s.system.add_files('%s', '`parquet`.`%s`')", + catalogName, tableName, fileTableDir.getAbsolutePath()); + assertOutput(pathResult, 0L, 0L); + assertEquals( + "Iceberg table contains no added data when importing from an empty path", + EMPTY_QUERY_RESULT, + sql("SELECT * FROM %s ORDER BY id", tableName)); + + // Empty table based import + String createHive = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) STORED AS parquet"; + sql(createHive, sourceTableName); + + List tableResult = + sql("CALL %s.system.add_files('%s', '%s')", catalogName, tableName, sourceTableName); + assertOutput(tableResult, 0L, 0L); + assertEquals( + "Iceberg table contains no added data when importing from an empty table", + EMPTY_QUERY_RESULT, + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testPartitionedImportFromEmptyPartitionDoesNotThrow() { + createPartitionedHiveTable(); + + final int emptyPartitionId = 999; + // Add an empty partition to the hive table + sql( + "ALTER TABLE %s ADD PARTITION (id = '%d') LOCATION '%d'", + sourceTableName, emptyPartitionId, emptyPartitionId); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List tableResult = + sql( + "CALL %s.system.add_files(" + + "table => '%s', " + + "source_table => '%s', " + + "partition_filter => map('id', %d))", + catalogName, tableName, sourceTableName, emptyPartitionId); + + assertOutput(tableResult, 0L, 0L); + assertEquals( + "Iceberg table contains no added data when importing from an empty table", + EMPTY_QUERY_RESULT, + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testAddFilesWithParallelism() { + createUnpartitionedHiveTable(); + + String createIceberg = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING iceberg"; + + sql(createIceberg, tableName); + + List result = + sql( + "CALL %s.system.add_files(table => '%s', source_table => '%s', parallelism => 2)", + catalogName, tableName, sourceTableName); + + assertEquals("Procedure output must match", ImmutableList.of(row(2L, 1L)), result); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT * FROM %s ORDER BY id", sourceTableName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testAddFilesPartitionedWithParallelism() { + createPartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + List result = + sql( + "CALL %s.system.add_files(table => '%s', source_table => '%s', parallelism => 2)", + catalogName, tableName, sourceTableName); + + assertOutput(result, 8L, 4L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testAddFilesWithInvalidParallelism() { + createUnpartitionedHiveTable(); + + createIcebergTable( + "id Integer, name String, dept String, subdept String", "PARTITIONED BY (id)"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.add_files(table => '%s', source_table => '%s', parallelism => -1)", + catalogName, tableName, sourceTableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Parallelism should be larger than 0"); + } + + @TestTemplate + public void testAddFilesToTableWithManySpecs() { + createPartitionedHiveTable(); + createIcebergTable("id Integer, name String, dept String, subdept String"); // Spec 0 + + sql("ALTER TABLE %s ADD PARTITION FIELD id", tableName); // Spec 1 + sql("ALTER TABLE %s ADD PARTITION FIELD name", tableName); // Spec 2 + sql("ALTER TABLE %s ADD PARTITION FIELD subdept", tableName); // Spec 3 + + List result = + sql( + "CALL %s.system.add_files('%s', '%s', map('id', 1))", + catalogName, tableName, sourceTableName); + + assertOutput(result, 2L, 1L); + + assertEquals( + "Iceberg table contains correct data", + sql("SELECT id, name, dept, subdept FROM %s WHERE id = 1 ORDER BY id", sourceTableName), + sql("SELECT id, name, dept, subdept FROM %s ORDER BY id", tableName)); + } + + private static final List EMPTY_QUERY_RESULT = Lists.newArrayList(); + + private static final StructField[] STRUCT = { + new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("name", DataTypes.StringType, true, Metadata.empty()), + new StructField("dept", DataTypes.StringType, true, Metadata.empty()), + new StructField("subdept", DataTypes.StringType, true, Metadata.empty()) + }; + + private Dataset unpartitionedDF() { + return spark + .createDataFrame( + ImmutableList.of( + RowFactory.create(1, "John Doe", "hr", "communications"), + RowFactory.create(2, "Jane Doe", "hr", "salary"), + RowFactory.create(3, "Matt Doe", "hr", "communications"), + RowFactory.create(4, "Will Doe", "facilities", "all")), + new StructType(STRUCT)) + .repartition(1); + } + + private Dataset singleNullRecordDF() { + return spark + .createDataFrame( + ImmutableList.of(RowFactory.create(null, null, null, null)), new StructType(STRUCT)) + .repartition(1); + } + + private Dataset partitionedDF() { + return unpartitionedDF().select("name", "dept", "subdept", "id"); + } + + private Dataset compositePartitionedDF() { + return unpartitionedDF().select("name", "subdept", "id", "dept"); + } + + private Dataset compositePartitionedNullRecordDF() { + return singleNullRecordDF().select("name", "subdept", "id", "dept"); + } + + private Dataset weirdColumnNamesDF() { + Dataset unpartitionedDF = unpartitionedDF(); + return unpartitionedDF.select( + unpartitionedDF.col("id"), + unpartitionedDF.col("subdept"), + unpartitionedDF.col("dept"), + unpartitionedDF.col("name").as("naMe")); + } + + private static final StructField[] DATE_STRUCT = { + new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("name", DataTypes.StringType, true, Metadata.empty()), + new StructField("ts", DataTypes.DateType, true, Metadata.empty()), + new StructField("dept", DataTypes.StringType, true, Metadata.empty()), + }; + + private static java.sql.Date toDate(String value) { + return new java.sql.Date(DateTime.parse(value).getMillis()); + } + + private Dataset dateDF() { + return spark + .createDataFrame( + ImmutableList.of( + RowFactory.create(1, "John Doe", toDate("2021-01-01"), "01"), + RowFactory.create(2, "Jane Doe", toDate("2021-01-01"), "01"), + RowFactory.create(3, "Matt Doe", toDate("2021-01-02"), "02"), + RowFactory.create(4, "Will Doe", toDate("2021-01-02"), "02")), + new StructType(DATE_STRUCT)) + .repartition(2); + } + + private void createUnpartitionedFileTable(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING %s LOCATION '%s'"; + + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + Dataset df = unpartitionedDF(); + df.write().insertInto(sourceTableName); + df.write().insertInto(sourceTableName); + } + + private void createPartitionedFileTable(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING %s PARTITIONED BY (id) " + + "LOCATION '%s'"; + + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + + Dataset df = partitionedDF(); + df.write().insertInto(sourceTableName); + df.write().insertInto(sourceTableName); + } + + private void createCompositePartitionedTable(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING %s " + + "PARTITIONED BY (id, dept) LOCATION '%s'"; + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + + Dataset df = compositePartitionedDF(); + df.write().insertInto(sourceTableName); + df.write().insertInto(sourceTableName); + } + + private void createCompositePartitionedTableWithNullValueInPartitionColumn(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING %s " + + "PARTITIONED BY (id, dept) LOCATION '%s'"; + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + + Dataset unionedDF = + compositePartitionedDF() + .unionAll(compositePartitionedNullRecordDF()) + .select("name", "subdept", "id", "dept") + .repartition(1); + + unionedDF.write().insertInto(sourceTableName); + unionedDF.write().insertInto(sourceTableName); + } + + private void createPartitionedTableWithNullValueInPartitionColumnOnDept(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING %s " + + "PARTITIONED BY (dept) LOCATION '%s'"; + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + + Dataset unionedDF = + unpartitionedDF() + .select("id", "name", "subdept", "dept") + .unionAll(singleNullRecordDF().select("id", "name", "subdept", "dept")) + .repartition(1); + + unionedDF.write().insertInto(sourceTableName); + unionedDF.write().insertInto(sourceTableName); + } + + private void createPartitionedTableWithNullValueInPartitionColumnOnId(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) USING %s " + + "PARTITIONED BY (id) LOCATION '%s'"; + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + + Dataset unionedDF = + unpartitionedDF() + .select("name", "subdept", "dept", "id") + .unionAll(singleNullRecordDF().select("name", "subdept", "dept", "id")) + .repartition(1); + + unionedDF.write().insertInto(sourceTableName); + unionedDF.write().insertInto(sourceTableName); + } + + private void createWeirdCaseTable() { + String createParquet = + "CREATE TABLE %s (id Integer, subdept String, dept String) " + + "PARTITIONED BY (`naMe` String) STORED AS parquet"; + + sql(createParquet, sourceTableName); + + Dataset df = weirdColumnNamesDF(); + df.write().insertInto(sourceTableName); + df.write().insertInto(sourceTableName); + } + + private void createUnpartitionedHiveTable() { + String createHive = + "CREATE TABLE %s (id Integer, name String, dept String, subdept String) STORED AS parquet"; + + sql(createHive, sourceTableName); + + Dataset df = unpartitionedDF(); + df.write().insertInto(sourceTableName); + df.write().insertInto(sourceTableName); + } + + private void createPartitionedHiveTable() { + String createHive = + "CREATE TABLE %s (name String, dept String, subdept String) " + + "PARTITIONED BY (id Integer) STORED AS parquet"; + + sql(createHive, sourceTableName); + + Dataset df = partitionedDF(); + df.write().insertInto(sourceTableName); + df.write().insertInto(sourceTableName); + } + + private void createDatePartitionedFileTable(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, date Date) USING %s " + + "PARTITIONED BY (date) LOCATION '%s'"; + + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + + dateDF().select("id", "name", "ts").write().insertInto(sourceTableName); + } + + private void createTableWithTwoPartitions(String format) { + String createParquet = + "CREATE TABLE %s (id Integer, name String, date Date, dept String) USING %s " + + "PARTITIONED BY (date, dept) LOCATION '%s'"; + + sql(createParquet, sourceTableName, format, fileTableDir.getAbsolutePath()); + + dateDF().write().insertInto(sourceTableName); + } + + private void createIcebergTable(String schema) { + createIcebergTable(schema, ""); + } + + private void createIcebergTable(String schema, String partitioning) { + sql( + "CREATE TABLE %s (%s) USING iceberg %s TBLPROPERTIES ('%s' '%d')", + tableName, schema, partitioning, TableProperties.FORMAT_VERSION, formatVersion); + } + + private void assertOutput( + List result, long expectedAddedFilesCount, long expectedChangedPartitionCount) { + Object[] output = Iterables.getOnlyElement(result); + assertThat(output[0]).isEqualTo(expectedAddedFilesCount); + if (formatVersion == 1) { + assertThat(output[1]).isEqualTo(expectedChangedPartitionCount); + } else { + // the number of changed partitions may not be populated in v2 tables + assertThat(output[1]).isIn(expectedChangedPartitionCount, null); + } + } + + private void manifestSpecMatchesTableSpec() throws NoSuchTableException, ParseException { + Table table = Spark3Util.loadIcebergTable(spark, tableName); + manifestSpecMatchesGivenSpec(table, table.spec()); + } + + private void manifestSpecMatchesGivenSpec(Table table, PartitionSpec partitionSpec) { + FileIO io = ((HasTableOperations) table).operations().io(); + // Check that the manifests have the correct partition spec + assertThat( + table.currentSnapshot().allManifests(io).stream() + .map(mf -> ManifestFiles.read(mf, io, null /* force reading spec from file*/)) + .map(ManifestReader::spec) + .collect(Collectors.toList())) + .allSatisfy(spec -> assertThat(spec).isEqualTo(partitionSpec)); + } + + private void verifyUUIDInPath() { + // verify manifest file name has uuid pattern + String manifestPath = (String) sql("select path from %s.manifests", tableName).get(0)[0]; + + Pattern uuidPattern = Pattern.compile("[a-f0-9]{8}(?:-[a-f0-9]{4}){4}[a-f0-9]{8}"); + + Matcher matcher = uuidPattern.matcher(manifestPath); + assertThat(matcher.find()).as("verify manifest path has uuid").isTrue(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAlterTablePartitionFields.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAlterTablePartitionFields.java new file mode 100644 index 000000000000..2db56fa844bb --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAlterTablePartitionFields.java @@ -0,0 +1,663 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.spark.sql.connector.catalog.CatalogManager; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestAlterTablePartitionFields extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, formatVersion = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + 1 + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + 2 + } + }; + } + + @Parameter(index = 3) + private int formatVersion; + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testAddIdentityPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD category", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).identity("category").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddBucketPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(16, id)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(1) + .bucket("id", 16, "id_bucket_16") + .build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddTruncatePartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD truncate(data, 4)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(1) + .truncate("data", 4, "data_trunc_4") + .build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddYearsPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD years(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).year("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddMonthsPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD months(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).month("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddDaysPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD days(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).day("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddHoursPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD hours(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).hour("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddYearPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD year(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).year("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddMonthPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD month(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).month("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddDayPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD day(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).day("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddHourPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD hour(ts)", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).hour("ts").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testAddNamedPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(16, id) AS shard", tableName); + + table.refresh(); + + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).bucket("id", 16, "shard").build(); + + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } + + @TestTemplate + public void testDropIdentityPartition() { + createTable("id bigint NOT NULL, category string, data string", "category"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().fields()).as("Table should start with 1 partition field").hasSize(1); + + sql("ALTER TABLE %s DROP PARTITION FIELD category", tableName); + + table.refresh(); + + if (formatVersion == 1) { + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(1) + .alwaysNull("category", "category") + .build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } else { + assertThat(table.spec().isUnpartitioned()).as("New spec must be unpartitioned").isTrue(); + } + } + + @TestTemplate + public void testDropDaysPartition() { + createTable("id bigint NOT NULL, ts timestamp, data string", "days(ts)"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().fields()).as("Table should start with 1 partition field").hasSize(1); + + sql("ALTER TABLE %s DROP PARTITION FIELD days(ts)", tableName); + + table.refresh(); + + if (formatVersion == 1) { + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).alwaysNull("ts", "ts_day").build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } else { + assertThat(table.spec().isUnpartitioned()).as("New spec must be unpartitioned").isTrue(); + } + } + + @TestTemplate + public void testDropBucketPartition() { + createTable("id bigint NOT NULL, data string", "bucket(16, id)"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().fields()).as("Table should start with 1 partition field").hasSize(1); + + sql("ALTER TABLE %s DROP PARTITION FIELD bucket(16, id)", tableName); + + table.refresh(); + + if (formatVersion == 1) { + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(1) + .alwaysNull("id", "id_bucket") + .build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } else { + assertThat(table.spec().isUnpartitioned()).as("New spec must be unpartitioned").isTrue(); + } + } + + @TestTemplate + public void testDropPartitionByName() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(16, id) AS shard", tableName); + + table.refresh(); + + assertThat(table.spec().fields()).as("Table should have 1 partition field").hasSize(1); + + // Should be recognized as iceberg command even with extra white spaces + sql("ALTER TABLE %s DROP PARTITION \n FIELD shard", tableName); + + table.refresh(); + + if (formatVersion == 1) { + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(2).alwaysNull("id", "shard").build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + } else { + assertThat(table.spec().isUnpartitioned()).as("New spec must be unpartitioned").isTrue(); + } + } + + @TestTemplate + public void testReplacePartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD days(ts)", tableName); + table.refresh(); + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).day("ts").build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + + sql("ALTER TABLE %s REPLACE PARTITION FIELD days(ts) WITH hours(ts)", tableName); + table.refresh(); + if (formatVersion == 1) { + expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(2) + .alwaysNull("ts", "ts_day") + .hour("ts") + .build(); + } else { + expected = + TestHelpers.newExpectedSpecBuilder() + .withSchema(table.schema()) + .withSpecId(2) + .addField("hour", 3, 1001, "ts_hour") + .build(); + } + assertThat(table.spec()) + .as("Should changed from daily to hourly partitioned field") + .isEqualTo(expected); + } + + @TestTemplate + public void testReplacePartitionAndRename() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD days(ts)", tableName); + table.refresh(); + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).day("ts").build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + + sql("ALTER TABLE %s REPLACE PARTITION FIELD days(ts) WITH hours(ts) AS hour_col", tableName); + table.refresh(); + if (formatVersion == 1) { + expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(2) + .alwaysNull("ts", "ts_day") + .hour("ts", "hour_col") + .build(); + } else { + expected = + TestHelpers.newExpectedSpecBuilder() + .withSchema(table.schema()) + .withSpecId(2) + .addField("hour", 3, 1001, "hour_col") + .build(); + } + assertThat(table.spec()) + .as("Should changed from daily to hourly partitioned field") + .isEqualTo(expected); + } + + @TestTemplate + public void testReplaceNamedPartition() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD days(ts) AS day_col", tableName); + table.refresh(); + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).day("ts", "day_col").build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + + sql("ALTER TABLE %s REPLACE PARTITION FIELD day_col WITH hours(ts)", tableName); + table.refresh(); + if (formatVersion == 1) { + expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(2) + .alwaysNull("ts", "day_col") + .hour("ts") + .build(); + } else { + expected = + TestHelpers.newExpectedSpecBuilder() + .withSchema(table.schema()) + .withSpecId(2) + .addField("hour", 3, 1001, "ts_hour") + .build(); + } + assertThat(table.spec()) + .as("Should changed from daily to hourly partitioned field") + .isEqualTo(expected); + } + + @TestTemplate + public void testReplaceNamedPartitionAndRenameDifferently() { + createTable("id bigint NOT NULL, category string, ts timestamp, data string"); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.spec().isUnpartitioned()).as("Table should start unpartitioned").isTrue(); + + sql("ALTER TABLE %s ADD PARTITION FIELD days(ts) AS day_col", tableName); + table.refresh(); + PartitionSpec expected = + PartitionSpec.builderFor(table.schema()).withSpecId(1).day("ts", "day_col").build(); + assertThat(table.spec()).as("Should have new spec field").isEqualTo(expected); + + sql("ALTER TABLE %s REPLACE PARTITION FIELD day_col WITH hours(ts) AS hour_col", tableName); + table.refresh(); + if (formatVersion == 1) { + expected = + PartitionSpec.builderFor(table.schema()) + .withSpecId(2) + .alwaysNull("ts", "day_col") + .hour("ts", "hour_col") + .build(); + } else { + expected = + TestHelpers.newExpectedSpecBuilder() + .withSchema(table.schema()) + .withSpecId(2) + .addField("hour", 3, 1001, "hour_col") + .build(); + } + assertThat(table.spec()) + .as("Should changed from daily to hourly partitioned field") + .isEqualTo(expected); + } + + @TestTemplate + public void testSparkTableAddDropPartitions() throws Exception { + createTable("id bigint NOT NULL, ts timestamp, data string"); + assertThat(sparkTable().partitioning()).as("spark table partition should be empty").isEmpty(); + + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(16, id) AS shard", tableName); + assertPartitioningEquals(sparkTable(), 1, "bucket(16, id)"); + + sql("ALTER TABLE %s ADD PARTITION FIELD truncate(data, 4)", tableName); + assertPartitioningEquals(sparkTable(), 2, "truncate(4, data)"); + + sql("ALTER TABLE %s ADD PARTITION FIELD years(ts)", tableName); + assertPartitioningEquals(sparkTable(), 3, "years(ts)"); + + sql("ALTER TABLE %s DROP PARTITION FIELD years(ts)", tableName); + assertPartitioningEquals(sparkTable(), 2, "truncate(4, data)"); + + sql("ALTER TABLE %s DROP PARTITION FIELD truncate(4, data)", tableName); + assertPartitioningEquals(sparkTable(), 1, "bucket(16, id)"); + + sql("ALTER TABLE %s DROP PARTITION FIELD shard", tableName); + sql("DESCRIBE %s", tableName); + assertThat(sparkTable().partitioning()).as("spark table partition should be empty").isEmpty(); + } + + @TestTemplate + public void testDropColumnOfOldPartitionFieldV1() { + // default table created in v1 format + sql( + "CREATE TABLE %s (id bigint NOT NULL, ts timestamp, day_of_ts date) USING iceberg PARTITIONED BY (day_of_ts) TBLPROPERTIES('format-version' = '1')", + tableName); + + sql("ALTER TABLE %s REPLACE PARTITION FIELD day_of_ts WITH days(ts)", tableName); + + sql("ALTER TABLE %s DROP COLUMN day_of_ts", tableName); + } + + @TestTemplate + public void testDropColumnOfOldPartitionFieldV2() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, ts timestamp, day_of_ts date) USING iceberg PARTITIONED BY (day_of_ts) TBLPROPERTIES('format-version' = '2')", + tableName); + + sql("ALTER TABLE %s REPLACE PARTITION FIELD day_of_ts WITH days(ts)", tableName); + + sql("ALTER TABLE %s DROP COLUMN day_of_ts", tableName); + } + + private void assertPartitioningEquals(SparkTable table, int len, String transform) { + assertThat(table.partitioning()).as("spark table partition should be " + len).hasSize(len); + assertThat(table.partitioning()[len - 1]) + .asString() + .as("latest spark table partition transform should match") + .isEqualTo(transform); + } + + private SparkTable sparkTable() throws Exception { + validationCatalog.loadTable(tableIdent).refresh(); + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + return (SparkTable) catalog.loadTable(identifier); + } + + private void createTable(String schema) { + createTable(schema, null); + } + + private void createTable(String schema, String spec) { + if (spec == null) { + sql( + "CREATE TABLE %s (%s) USING iceberg TBLPROPERTIES ('%s' '%d')", + tableName, schema, TableProperties.FORMAT_VERSION, formatVersion); + } else { + sql( + "CREATE TABLE %s (%s) USING iceberg PARTITIONED BY (%s) TBLPROPERTIES ('%s' '%d')", + tableName, schema, spec, TableProperties.FORMAT_VERSION, formatVersion); + } + } + + private void runCreateAndDropPartitionField( + String column, String partitionType, List expected, String predicate) { + sql("DROP TABLE IF EXISTS %s", tableName); + sql( + "CREATE TABLE %s (col_int INTEGER, col_ts TIMESTAMP_NTZ, col_long BIGINT) USING ICEBERG TBLPROPERTIES ('format-version' = %d)", + tableName, formatVersion); + sql("INSERT INTO %s VALUES (1000, CAST('2024-03-01 19:25:00' as TIMESTAMP), 2100)", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD %s AS col2_partition", tableName, partitionType); + sql("INSERT INTO %s VALUES (2000, CAST('2024-04-01 19:25:00' as TIMESTAMP), 2200)", tableName); + sql("ALTER TABLE %s DROP PARTITION FIELD col2_partition", tableName); + sql("INSERT INTO %s VALUES (3000, CAST('2024-05-01 19:25:00' as TIMESTAMP), 2300)", tableName); + sql("ALTER TABLE %s DROP COLUMN %s", tableName, column); + + assertThat(sql("SELECT * FROM %s WHERE %s ORDER BY col_int", tableName, predicate)) + .containsExactlyElementsOf(expected); + } + + @TestTemplate + public void testDropPartitionAndSourceColumnLong() { + String predicateTs = "col_long >= 2200"; + List expectedTs = + Lists.newArrayList(new Object[] {2000, 2200L}, new Object[] {3000, 2300L}); + runCreateAndDropPartitionField("col_ts", "col_ts", expectedTs, predicateTs); + runCreateAndDropPartitionField("col_ts", "year(col_ts)", expectedTs, predicateTs); + runCreateAndDropPartitionField("col_ts", "month(col_ts)", expectedTs, predicateTs); + runCreateAndDropPartitionField("col_ts", "day(col_ts)", expectedTs, predicateTs); + } + + @TestTemplate + public void testDropPartitionAndSourceColumnTimestamp() { + String predicate = "col_ts >= '2024-04-01 19:25:00'"; + List expected = + Lists.newArrayList( + new Object[] {2000, LocalDateTime.ofEpochSecond(1711999500, 0, ZoneOffset.UTC)}, + new Object[] {3000, LocalDateTime.ofEpochSecond(1714591500, 0, ZoneOffset.UTC)}); + runCreateAndDropPartitionField("col_long", "col_long", expected, predicate); + runCreateAndDropPartitionField("col_long", "truncate(2, col_long)", expected, predicate); + runCreateAndDropPartitionField("col_long", "bucket(16, col_long)", expected, predicate); + } + + @TestTemplate + public void deleteAfterDroppingPartitionAndSourceColumn() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql( + "CREATE TABLE %s (id INTEGER, data STRING) USING ICEBERG TBLPROPERTIES ('format-version' = %d)", + tableName, formatVersion); + sql("INSERT INTO %s VALUES (1, 'data1')", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD data", tableName); + sql("INSERT INTO %s VALUES (2, 'data2')", tableName); + sql("ALTER TABLE %s DROP PARTITION FIELD data", tableName); + sql("INSERT INTO %s VALUES (3, 'data3')", tableName); + sql("ALTER TABLE %s DROP COLUMN data", tableName); + + assertThat(sql("SELECT * FROM %s WHERE id >= 1 ORDER BY id", tableName)) + .containsExactly(row(1), row(2), row(3)); + + sql("DELETE FROM %s WHERE id >= 1", tableName); + assertThat(sql("SELECT * FROM %s WHERE id >= 1", tableName)).isEmpty(); + } + + @TestTemplate + public void testReaddColumnAfterIdentityPartitionDrop() { + createTable("id bigint NOT NULL, category string, data string", "category"); + + sql("ALTER TABLE %s DROP PARTITION FIELD category", tableName); + sql("ALTER TABLE %s DROP COLUMN category", tableName); + sql("ALTER TABLE %s ADD COLUMN category string", tableName); + + sql("INSERT INTO %s (id, category, data) VALUES (1, 'books', 'a')", tableName); + assertThat(sql("SELECT id, category, data FROM %s ORDER BY id", tableName)) + .containsExactly(row(1L, "books", "a")); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAlterTableSchema.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAlterTableSchema.java new file mode 100644 index 000000000000..f36a2e4470e3 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAlterTableSchema.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestAlterTableSchema extends ExtensionsTestBase { + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSetIdentifierFields() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, " + + "location struct NOT NULL) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.schema().identifierFieldIds()) + .as("Table should start without identifier") + .isEmpty(); + + sql("ALTER TABLE %s SET IDENTIFIER FIELDS id", tableName); + table.refresh(); + assertThat(table.schema().identifierFieldIds()) + .containsExactly(table.schema().findField("id").fieldId()); + + sql("ALTER TABLE %s SET IDENTIFIER FIELDS id, location.lon", tableName); + table.refresh(); + assertThat(table.schema().identifierFieldIds()) + .as("Should have new identifier field") + .containsExactlyInAnyOrder( + table.schema().findField("id").fieldId(), + table.schema().findField("location.lon").fieldId()); + + sql("ALTER TABLE %s SET IDENTIFIER FIELDS location.lon", tableName); + table.refresh(); + assertThat(table.schema().identifierFieldIds()) + .as("Should have new identifier field") + .containsExactly(table.schema().findField("location.lon").fieldId()); + } + + @TestTemplate + public void testSetInvalidIdentifierFields() { + sql("CREATE TABLE %s (id bigint NOT NULL, id2 bigint) USING iceberg", tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.schema().identifierFieldIds()) + .as("Table should start without identifier") + .isEmpty(); + assertThatThrownBy(() -> sql("ALTER TABLE %s SET IDENTIFIER FIELDS unknown", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageEndingWith("not found in current schema or added columns"); + + assertThatThrownBy(() -> sql("ALTER TABLE %s SET IDENTIFIER FIELDS id2", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageEndingWith("not a required field"); + } + + @TestTemplate + public void testDropIdentifierFields() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, " + + "location struct NOT NULL) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.schema().identifierFieldIds()) + .as("Table should start without identifier") + .isEmpty(); + + sql("ALTER TABLE %s SET IDENTIFIER FIELDS id, location.lon", tableName); + table.refresh(); + assertThat(table.schema().identifierFieldIds()) + .as("Should have new identifier fields") + .containsExactlyInAnyOrder( + table.schema().findField("id").fieldId(), + table.schema().findField("location.lon").fieldId()); + + sql("ALTER TABLE %s DROP IDENTIFIER FIELDS id", tableName); + table.refresh(); + assertThat(table.schema().identifierFieldIds()) + .as("Should removed identifier field") + .containsExactly(table.schema().findField("location.lon").fieldId()); + + sql("ALTER TABLE %s SET IDENTIFIER FIELDS id, location.lon", tableName); + table.refresh(); + assertThat(table.schema().identifierFieldIds()) + .as("Should have new identifier fields") + .containsExactlyInAnyOrder( + table.schema().findField("id").fieldId(), + table.schema().findField("location.lon").fieldId()); + + sql("ALTER TABLE %s DROP IDENTIFIER FIELDS id, location.lon", tableName); + table.refresh(); + assertThat(table.schema().identifierFieldIds()).as("Should have no identifier field").isEmpty(); + } + + @TestTemplate + public void testDropInvalidIdentifierFields() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string NOT NULL, " + + "location struct NOT NULL) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.schema().identifierFieldIds()) + .as("Table should start without identifier") + .isEmpty(); + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP IDENTIFIER FIELDS unknown", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot complete drop identifier fields operation: field unknown not found"); + + sql("ALTER TABLE %s SET IDENTIFIER FIELDS id", tableName); + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP IDENTIFIER FIELDS data", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot complete drop identifier fields operation: data is not an identifier field"); + + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP IDENTIFIER FIELDS location.lon", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot complete drop identifier fields operation: location.lon is not an identifier field"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAncestorsOfProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAncestorsOfProcedure.java new file mode 100644 index 000000000000..2408f3cfa2c0 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestAncestorsOfProcedure.java @@ -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. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestAncestorsOfProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testAncestorOfUsingEmptyArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Long currentSnapshotId = table.currentSnapshot().snapshotId(); + Long currentTimestamp = table.currentSnapshot().timestampMillis(); + Long preSnapshotId = table.currentSnapshot().parentId(); + Long preTimeStamp = table.snapshot(table.currentSnapshot().parentId()).timestampMillis(); + + List output = sql("CALL %s.system.ancestors_of('%s')", catalogName, tableIdent); + + assertEquals( + "Procedure output must match", + ImmutableList.of( + row(currentSnapshotId, currentTimestamp), row(preSnapshotId, preTimeStamp)), + output); + } + + @TestTemplate + public void testAncestorOfUsingSnapshotId() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Long currentSnapshotId = table.currentSnapshot().snapshotId(); + Long currentTimestamp = table.currentSnapshot().timestampMillis(); + Long preSnapshotId = table.currentSnapshot().parentId(); + Long preTimeStamp = table.snapshot(table.currentSnapshot().parentId()).timestampMillis(); + + assertEquals( + "Procedure output must match", + ImmutableList.of( + row(currentSnapshotId, currentTimestamp), row(preSnapshotId, preTimeStamp)), + sql("CALL %s.system.ancestors_of('%s', %dL)", catalogName, tableIdent, currentSnapshotId)); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(preSnapshotId, preTimeStamp)), + sql("CALL %s.system.ancestors_of('%s', %dL)", catalogName, tableIdent, preSnapshotId)); + } + + @TestTemplate + public void testAncestorOfWithRollBack() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + Table table = validationCatalog.loadTable(tableIdent); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + table.refresh(); + Long firstSnapshotId = table.currentSnapshot().snapshotId(); + Long firstTimestamp = table.currentSnapshot().timestampMillis(); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + table.refresh(); + Long secondSnapshotId = table.currentSnapshot().snapshotId(); + Long secondTimestamp = table.currentSnapshot().timestampMillis(); + sql("INSERT INTO TABLE %s VALUES (3, 'c')", tableName); + table.refresh(); + Long thirdSnapshotId = table.currentSnapshot().snapshotId(); + Long thirdTimestamp = table.currentSnapshot().timestampMillis(); + + // roll back + sql( + "CALL %s.system.rollback_to_snapshot('%s', %dL)", + catalogName, tableIdent, secondSnapshotId); + + sql("INSERT INTO TABLE %s VALUES (4, 'd')", tableName); + table.refresh(); + Long fourthSnapshotId = table.currentSnapshot().snapshotId(); + Long fourthTimestamp = table.currentSnapshot().timestampMillis(); + + assertEquals( + "Procedure output must match", + ImmutableList.of( + row(fourthSnapshotId, fourthTimestamp), + row(secondSnapshotId, secondTimestamp), + row(firstSnapshotId, firstTimestamp)), + sql("CALL %s.system.ancestors_of('%s', %dL)", catalogName, tableIdent, fourthSnapshotId)); + + assertEquals( + "Procedure output must match", + ImmutableList.of( + row(thirdSnapshotId, thirdTimestamp), + row(secondSnapshotId, secondTimestamp), + row(firstSnapshotId, firstTimestamp)), + sql("CALL %s.system.ancestors_of('%s', %dL)", catalogName, tableIdent, thirdSnapshotId)); + } + + @TestTemplate + public void testAncestorOfUsingNamedArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Long firstSnapshotId = table.currentSnapshot().snapshotId(); + Long firstTimestamp = table.currentSnapshot().timestampMillis(); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(firstSnapshotId, firstTimestamp)), + sql( + "CALL %s.system.ancestors_of(snapshot_id => %dL, table => '%s')", + catalogName, firstSnapshotId, tableIdent)); + } + + @TestTemplate + public void testInvalidAncestorOfCases() { + assertThatThrownBy(() -> sql("CALL %s.system.ancestors_of()", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `ancestors_of` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.ancestors_of('')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + + assertThatThrownBy( + () -> sql("CALL %s.system.ancestors_of('%s', '1.1')", catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "[CAST_INVALID_INPUT] The value '1.1' of the type \"STRING\" cannot be cast to \"BIGINT\" because it is malformed."); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java new file mode 100644 index 000000000000..26dde2059966 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestBranchDDL.java @@ -0,0 +1,437 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.extensions.IcebergParseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestBranchDDL extends ExtensionsTestBase { + + @BeforeEach + public void createTable() { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties() + } + }; + } + + @TestTemplate + public void testCreateBranch() throws NoSuchTableException { + Table table = insertRows(); + long snapshotId = table.currentSnapshot().snapshotId(); + String branchName = "b1"; + Integer minSnapshotsToKeep = 2; + long maxSnapshotAge = 2L; + long maxRefAge = 10L; + sql( + "ALTER TABLE %s CREATE BRANCH %s AS OF VERSION %d RETAIN %d DAYS WITH SNAPSHOT RETENTION %d SNAPSHOTS %d days", + tableName, branchName, snapshotId, maxRefAge, minSnapshotsToKeep, maxSnapshotAge); + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(ref.minSnapshotsToKeep()).isEqualTo(minSnapshotsToKeep); + assertThat(ref.maxSnapshotAgeMs().longValue()) + .isEqualTo(TimeUnit.DAYS.toMillis(maxSnapshotAge)); + assertThat(ref.maxRefAgeMs().longValue()) + .isEqualTo(TimeUnit.DAYS.toMillis(maxRefAge)); + }); + assertThatThrownBy(() -> sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Ref b1 already exists"); + } + + @TestTemplate + public void testCreateBranchOnEmptyTable() { + String branchName = "b1"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, "b1"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.refs()) + .doesNotContainKey(SnapshotRef.MAIN_BRANCH) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.minSnapshotsToKeep()).isNull(); + assertThat(ref.maxSnapshotAgeMs()).isNull(); + assertThat(ref.maxRefAgeMs()).isNull(); + + Snapshot snapshot = table.snapshot(ref.snapshotId()); + assertThat(snapshot.parentId()).isNull(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); + }); + } + + @TestTemplate + public void testCreateBranchUseDefaultConfig() throws NoSuchTableException { + Table table = insertRows(); + String branchName = "b1"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(ref.minSnapshotsToKeep()).isNull(); + assertThat(ref.maxSnapshotAgeMs()).isNull(); + assertThat(ref.maxRefAgeMs()).isNull(); + }); + } + + @TestTemplate + public void testCreateBranchUseCustomMinSnapshotsToKeep() throws NoSuchTableException { + Integer minSnapshotsToKeep = 2; + Table table = insertRows(); + String branchName = "b1"; + sql( + "ALTER TABLE %s CREATE BRANCH %s WITH SNAPSHOT RETENTION %d SNAPSHOTS", + tableName, branchName, minSnapshotsToKeep); + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(ref.minSnapshotsToKeep()).isEqualTo(minSnapshotsToKeep); + assertThat(ref.maxSnapshotAgeMs()).isNull(); + assertThat(ref.maxRefAgeMs()).isNull(); + }); + } + + @TestTemplate + public void testCreateBranchUseCustomMaxSnapshotAge() throws NoSuchTableException { + long maxSnapshotAge = 2L; + Table table = insertRows(); + String branchName = "b1"; + sql( + "ALTER TABLE %s CREATE BRANCH %s WITH SNAPSHOT RETENTION %d DAYS", + tableName, branchName, maxSnapshotAge); + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref).isNotNull(); + assertThat(ref.minSnapshotsToKeep()).isNull(); + assertThat(ref.maxSnapshotAgeMs().longValue()) + .isEqualTo(TimeUnit.DAYS.toMillis(maxSnapshotAge)); + assertThat(ref.maxRefAgeMs()).isNull(); + }); + } + + @TestTemplate + public void testCreateBranchIfNotExists() throws NoSuchTableException { + long maxSnapshotAge = 2L; + Table table = insertRows(); + String branchName = "b1"; + sql( + "ALTER TABLE %s CREATE BRANCH %s WITH SNAPSHOT RETENTION %d DAYS", + tableName, branchName, maxSnapshotAge); + sql("ALTER TABLE %s CREATE BRANCH IF NOT EXISTS %s", tableName, branchName); + + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(ref.minSnapshotsToKeep()).isNull(); + assertThat(ref.maxSnapshotAgeMs().longValue()) + .isEqualTo(TimeUnit.DAYS.toMillis(maxSnapshotAge)); + assertThat(ref.maxRefAgeMs()).isNull(); + }); + } + + @TestTemplate + public void testCreateBranchUseCustomMinSnapshotsToKeepAndMaxSnapshotAge() + throws NoSuchTableException { + Integer minSnapshotsToKeep = 2; + long maxSnapshotAge = 2L; + Table table = insertRows(); + String branchName = "b1"; + sql( + "ALTER TABLE %s CREATE BRANCH %s WITH SNAPSHOT RETENTION %d SNAPSHOTS %d DAYS", + tableName, branchName, minSnapshotsToKeep, maxSnapshotAge); + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(ref.minSnapshotsToKeep()).isEqualTo(minSnapshotsToKeep); + assertThat(ref.maxSnapshotAgeMs().longValue()) + .isEqualTo(TimeUnit.DAYS.toMillis(maxSnapshotAge)); + assertThat(ref.maxRefAgeMs()).isNull(); + }); + + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s CREATE BRANCH %s WITH SNAPSHOT RETENTION", + tableName, branchName)) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input 'WITH SNAPSHOT RETENTION'"); + } + + @TestTemplate + public void testCreateBranchUseCustomMaxRefAge() throws NoSuchTableException { + long maxRefAge = 10L; + Table table = insertRows(); + String branchName = "b1"; + sql("ALTER TABLE %s CREATE BRANCH %s RETAIN %d DAYS", tableName, branchName, maxRefAge); + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(ref.minSnapshotsToKeep()).isNull(); + assertThat(ref.maxSnapshotAgeMs()).isNull(); + assertThat(ref.maxRefAgeMs().longValue()) + .isEqualTo(TimeUnit.DAYS.toMillis(maxRefAge)); + }); + + assertThatThrownBy(() -> sql("ALTER TABLE %s CREATE BRANCH %s RETAIN", tableName, branchName)) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input"); + + assertThatThrownBy( + () -> + sql("ALTER TABLE %s CREATE BRANCH %s RETAIN %s DAYS", tableName, branchName, "abc")) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input"); + + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s CREATE BRANCH %s RETAIN %d SECONDS", + tableName, branchName, maxRefAge)) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("mismatched input 'SECONDS' expecting {'DAYS', 'HOURS', 'MINUTES'}"); + } + + @TestTemplate + public void testDropBranch() throws NoSuchTableException { + insertRows(); + + Table table = validationCatalog.loadTable(tableIdent); + String branchName = "b1"; + table.manageSnapshots().createBranch(branchName, table.currentSnapshot().snapshotId()).commit(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + }); + + sql("ALTER TABLE %s DROP BRANCH %s", tableName, branchName); + table.refresh(); + + assertThat(table.refs()).doesNotContainKey(branchName); + } + + @TestTemplate + public void testDropBranchDoesNotExist() { + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP BRANCH %s", tableName, "nonExistingBranch")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Branch does not exist: nonExistingBranch"); + } + + @TestTemplate + public void testDropBranchFailsForTag() throws NoSuchTableException { + String tagName = "b1"; + Table table = insertRows(); + table.manageSnapshots().createTag(tagName, table.currentSnapshot().snapshotId()).commit(); + + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP BRANCH %s", tableName, tagName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Ref b1 is a tag not a branch"); + } + + @TestTemplate + public void testDropBranchNonConformingName() { + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP BRANCH %s", tableName, "123")) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input '123'"); + } + + @TestTemplate + public void testDropMainBranchFails() { + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP BRANCH main", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot remove main branch"); + } + + @TestTemplate + public void testDropBranchIfExists() { + String branchName = "nonExistingBranch"; + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.refs()).doesNotContainKey(branchName); + + sql("ALTER TABLE %s DROP BRANCH IF EXISTS %s", tableName, branchName); + table.refresh(); + assertThat(table.refs()).doesNotContainKey(branchName); + } + + private Table insertRows() throws NoSuchTableException { + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + return validationCatalog.loadTable(tableIdent); + } + + @TestTemplate + public void createOrReplace() throws NoSuchTableException { + Table table = insertRows(); + long first = table.currentSnapshot().snapshotId(); + String branchName = "b1"; + insertRows(); + long second = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createBranch(branchName, second).commit(); + + sql( + "ALTER TABLE %s CREATE OR REPLACE BRANCH %s AS OF VERSION %d", + tableName, branchName, first); + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()).isEqualTo(second); + } + + @TestTemplate + public void testCreateOrReplaceBranchOnEmptyTable() { + String branchName = "b1"; + sql("ALTER TABLE %s CREATE OR REPLACE BRANCH %s", tableName, "b1"); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.refs()) + .doesNotContainKey(SnapshotRef.MAIN_BRANCH) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref).isNotNull(); + assertThat(ref.minSnapshotsToKeep()).isNull(); + assertThat(ref.maxSnapshotAgeMs()).isNull(); + assertThat(ref.maxRefAgeMs()).isNull(); + + Snapshot snapshot = table.snapshot(ref.snapshotId()); + assertThat(snapshot.parentId()).isNull(); + SnapshotChanges changes = + SnapshotChanges.builderFor(table).snapshot(snapshot).build(); + assertThat(changes.addedDataFiles()).isEmpty(); + assertThat(changes.removedDataFiles()).isEmpty(); + assertThat(changes.addedDeleteFiles()).isEmpty(); + assertThat(changes.removedDeleteFiles()).isEmpty(); + }); + } + + @TestTemplate + public void createOrReplaceWithNonExistingBranch() throws NoSuchTableException { + Table table = insertRows(); + String branchName = "b1"; + insertRows(); + long snapshotId = table.currentSnapshot().snapshotId(); + + sql( + "ALTER TABLE %s CREATE OR REPLACE BRANCH %s AS OF VERSION %d", + tableName, branchName, snapshotId); + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()).isEqualTo(snapshotId); + } + + @TestTemplate + public void replaceBranch() throws NoSuchTableException { + Table table = insertRows(); + long first = table.currentSnapshot().snapshotId(); + String branchName = "b1"; + long expectedMaxRefAgeMs = 1000; + table + .manageSnapshots() + .createBranch(branchName, first) + .setMaxRefAgeMs(branchName, expectedMaxRefAgeMs) + .commit(); + + insertRows(); + long second = table.currentSnapshot().snapshotId(); + + sql("ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d", tableName, branchName, second); + table.refresh(); + assertThat(table.refs()) + .hasEntrySatisfying( + branchName, + ref -> { + assertThat(ref.snapshotId()).isEqualTo(second); + assertThat(ref.maxRefAgeMs()).isEqualTo(expectedMaxRefAgeMs); + }); + } + + @TestTemplate + public void replaceBranchDoesNotExist() throws NoSuchTableException { + Table table = insertRows(); + + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d", + tableName, "someBranch", table.currentSnapshot().snapshotId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Branch does not exist: someBranch"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCachedTableRefresh.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCachedTableRefresh.java new file mode 100644 index 000000000000..fcdec0de0814 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCachedTableRefresh.java @@ -0,0 +1,396 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCachedTableRefresh extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.HIVE.properties()) + .put("cache-enabled", "false") + .build() + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.SPARK_SESSION.properties()) + .put("cache-enabled", "true") + .put("cache.expiration-interval-ms", "-1") // indefinite cache + .buildKeepingLast() + } + }; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("UNCACHE TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testCachedTableWithExternalWrite() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds (2, 200) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // query table again - should return cached data, not new data + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as("Cached table should not reflect external writes") + .hasSize(1) + .containsExactly(row(1, 100)); + } + + @TestTemplate + public void testCachedTableWithSessionWrite() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // session write adds (2, 200) - should invalidate cache + sql("INSERT INTO %s VALUES (2, 200)", tableName); + + // query table again - should reflect session writes + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as("Cached table should reflect session writes") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + + @TestTemplate + public void testCachedTableWithSessionWriteAndExternalWrite() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // session write invalidates cache + sql("INSERT INTO %s VALUES (2, 200)", tableName); + + // external writer adds (3, 300) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 3); + record.setField("salary", 300); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // query table again - should see session write but not external write + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as("Cached table should reflect session writes but not external writes after refresh") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + + @TestTemplate + public void testCachedTableWithExternalSchemaChangeAddColumn() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds column and data + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().addColumn("new_column", Types.IntegerType.get()).commit(); + + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + record.setField("new_column", -1); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // query table again - should return cached data with original schema + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as("Cached table should pin metadata and not reflect external schema changes") + .hasSize(1) + .containsExactly(row(1, 100)); + } + + @TestTemplate + public void testCachedTableWithSessionSchemaChangeAddColumn() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // session DDL adds column - should invalidate cache + sql("ALTER TABLE %s ADD COLUMN new_column INT", tableName); + sql("INSERT INTO %s VALUES (2, 200, -1)", tableName); + + // query table again - should reflect session schema changes + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as("Cached table should reflect session schema changes") + .hasSize(2) + .containsExactly(row(1, 100, null), row(2, 200, -1)); + } + + @TestTemplate + @Disabled("https://issues.apache.org/jira/browse/SPARK-55631") + public void testCachedTableWithSessionSchemaChangeAndExternalWrite() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // session DDL invalidates cache + sql("ALTER TABLE %s ADD COLUMN new_column INT", tableName); + + // external writer adds data with new column + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + record.setField("new_column", -1); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // query table again - should see new schema but not external data + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as( + "Cached table should reflect session schema change but not external write after refresh") + .hasSize(1) + .containsExactly(row(1, 100, null)); + } + + @TestTemplate + public void testCachedTableWithExternalDropAndRecreateTable() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer drops and recreates table + validationCatalog.dropTable(tableIdent, false /* keep files */); + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "salary", Types.IntegerType.get())); + validationCatalog.createTable(tableIdent, schema); + + // query table again + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Table cache should prevent from seeing new table ID") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2).as("Table ID change must prevent cache match").isEmpty(); + } + } + + @TestTemplate + public void testCachedTableUncacheReflectsExternalChanges() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds (2, 200) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // uncache table + sql("UNCACHE TABLE %s", tableName); + + // query table again + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Table cache should prevent from seeing external changes") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2) + .as("Table should reflect external writes if table cache is disabled") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + } + + @TestTemplate + public void testCachedTableRefreshReflectsExternalChanges() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // query table to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds (2, 200) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // refresh table cache + sql("REFRESH TABLE %s", tableName); + + // query table again - should reflect external changes after explicit refresh + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as("Refreshed cached table should reflect external writes") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + + @TestTemplate + public void testCachedTableMultipleQueriesReturnCachedData() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // cache table + sql("CACHE TABLE %s", tableName); + + // first query to populate cache + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds (2, 200) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // second query - should return cached data + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result2) + .as("Second query should return cached data") + .hasSize(1) + .containsExactly(row(1, 100)); + + // external writer adds (3, 300) + Record record2 = GenericRecord.create(table.schema()); + record2.setField("id", 3); + record2.setField("salary", 300); + DataFile dataFile2 = writeData(table, ImmutableList.of(record2)); + table.newAppend().appendFile(dataFile2).commit(); + + // third query - should still return cached data + List result3 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result3) + .as("Third query should return cached data") + .hasSize(1) + .containsExactly(row(1, 100)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestChangelogTable.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestChangelogTable.java new file mode 100644 index 000000000000..742235935eb2 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestChangelogTable.java @@ -0,0 +1,453 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.FORMAT_VERSION; +import static org.apache.iceberg.TableProperties.MANIFEST_MERGE_ENABLED; +import static org.apache.iceberg.TableProperties.MANIFEST_MIN_MERGE_COUNT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.apache.iceberg.DataOperations; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.source.SparkChangelogTable; +import org.apache.spark.sql.DataFrameReader; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestChangelogTable extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, formatVersion = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + 1 + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + 2 + } + }; + } + + @Parameter(index = 3) + private int formatVersion; + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testDataFilters() { + createTableWithDefaultRows(); + + sql("INSERT INTO %s VALUES (3, 'c')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snap3 = table.currentSnapshot(); + + sql("DELETE FROM %s WHERE id = 3", tableName); + + table.refresh(); + + Snapshot snap4 = table.currentSnapshot(); + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(3, "c", "INSERT", 2, snap3.snapshotId()), + row(3, "c", "DELETE", 3, snap4.snapshotId())), + sql("SELECT * FROM %s.changes WHERE id = 3 ORDER BY _change_ordinal, id", tableName)); + } + + @TestTemplate + public void testOverwrites() { + createTableWithDefaultRows(); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snap2 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + + table.refresh(); + + Snapshot snap3 = table.currentSnapshot(); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(2, "b", "DELETE", 0, snap3.snapshotId()), + row(-2, "b", "INSERT", 0, snap3.snapshotId())), + changelogRecords(snap2, snap3)); + } + + @TestTemplate + public void testQueryWithTimeRange() { + createTable(); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + long rightAfterSnap1 = waitUntilAfter(snap1.timestampMillis()); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + long rightAfterSnap2 = waitUntilAfter(snap2.timestampMillis()); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + Snapshot snap3 = table.currentSnapshot(); + long rightAfterSnap3 = waitUntilAfter(snap3.timestampMillis()); + + assertEquals( + "Should have expected changed rows only from snapshot 3", + ImmutableList.of( + row(2, "b", "DELETE", 0, snap3.snapshotId()), + row(-2, "b", "INSERT", 0, snap3.snapshotId())), + changelogRecords(rightAfterSnap2, snap3.timestampMillis())); + + assertEquals( + "Should have expected changed rows only from snapshot 3", + ImmutableList.of( + row(2, "b", "DELETE", 0, snap3.snapshotId()), + row(-2, "b", "INSERT", 0, snap3.snapshotId())), + changelogRecords(snap2.timestampMillis(), snap3.timestampMillis())); + + assertEquals( + "Should have expected changed rows from snapshot 2 and 3", + ImmutableList.of( + row(2, "b", "INSERT", 0, snap2.snapshotId()), + row(2, "b", "DELETE", 1, snap3.snapshotId()), + row(-2, "b", "INSERT", 1, snap3.snapshotId())), + changelogRecords(rightAfterSnap1, snap3.timestampMillis())); + + assertEquals( + "Should have expected changed rows up to the current snapshot", + ImmutableList.of( + row(2, "b", "INSERT", 0, snap2.snapshotId()), + row(2, "b", "DELETE", 1, snap3.snapshotId()), + row(-2, "b", "INSERT", 1, snap3.snapshotId())), + changelogRecords(rightAfterSnap1, null)); + + assertEquals( + "Should have empty changed rows if end time is before the first snapshot", + ImmutableList.of(), + changelogRecords(null, snap1.timestampMillis() - 1)); + + assertEquals( + "Should have empty changed rows if start time is after the current snapshot", + ImmutableList.of(), + changelogRecords(rightAfterSnap3, null)); + + assertEquals( + "Should have empty changed rows if end time is before the first snapshot", + ImmutableList.of(), + changelogRecords(null, snap1.timestampMillis() - 1)); + + assertEquals( + "Should have empty changed rows if there are no snapshots between start time and end time", + ImmutableList.of(), + changelogRecords(rightAfterSnap2, snap3.timestampMillis() - 1)); + } + + @TestTemplate + public void testTimeRangeValidation() { + createTableWithDefaultRows(); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snap2 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + Snapshot snap3 = table.currentSnapshot(); + long rightAfterSnap3 = waitUntilAfter(snap3.timestampMillis()); + assertThatThrownBy(() -> changelogRecords(snap3.timestampMillis(), snap2.timestampMillis())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot set start-timestamp to be greater than end-timestamp for changelogs"); + } + + @TestTemplate + public void testMetadataDeletes() { + createTableWithDefaultRows(); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snap2 = table.currentSnapshot(); + + sql("DELETE FROM %s WHERE data = 'a'", tableName); + + table.refresh(); + + Snapshot snap3 = table.currentSnapshot(); + assertThat(snap3.operation()).as("Operation must match").isEqualTo(DataOperations.DELETE); + + assertEquals( + "Rows should match", + ImmutableList.of(row(1, "a", "DELETE", 0, snap3.snapshotId())), + changelogRecords(snap2, snap3)); + } + + @TestTemplate + public void testExistingEntriesInNewDataManifestsAreIgnored() { + sql( + "CREATE TABLE %s (id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES ( " + + " '%s' = '%d', " + + " '%s' = '1', " + + " '%s' = 'true' " + + ")", + tableName, FORMAT_VERSION, formatVersion, MANIFEST_MIN_MERGE_COUNT, MANIFEST_MERGE_ENABLED); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + + table.refresh(); + + Snapshot snap2 = table.currentSnapshot(); + assertThat(snap2.dataManifests(table.io())).as("Manifest number must match").hasSize(1); + + assertEquals( + "Rows should match", + ImmutableList.of(row(2, "b", "INSERT", 0, snap2.snapshotId())), + changelogRecords(snap1, snap2)); + } + + @TestTemplate + public void testManifestRewritesAreIgnored() { + createTableWithDefaultRows(); + + sql("CALL %s.system.rewrite_manifests('%s')", catalogName, tableIdent); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Num snapshots must match").hasSize(3); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "INSERT"), row(2, "INSERT")), + sql("SELECT id, _change_type FROM %s.changes ORDER BY id", tableName)); + } + + @TestTemplate + public void testMetadataColumns() { + createTableWithDefaultRows(); + List rows = + sql( + "SELECT id, _file, _pos, _deleted, _spec_id, _partition FROM %s.changes ORDER BY id", + tableName); + + String file1 = rows.get(0)[1].toString(); + assertThat(file1).startsWith("file:/"); + String file2 = rows.get(1)[1].toString(); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, file1, 0L, false, 0, row("a")), row(2, file2, 0L, false, 0, row("b"))), + rows); + } + + @TestTemplate + public void testQueryWithRollback() { + createTable(); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + long rightAfterSnap1 = waitUntilAfter(snap1.timestampMillis()); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + long rightAfterSnap2 = waitUntilAfter(snap2.timestampMillis()); + + sql( + "CALL %s.system.rollback_to_snapshot('%s', %d)", + catalogName, tableIdent, snap1.snapshotId()); + table.refresh(); + assertThat(table.currentSnapshot()).isEqualTo(snap1); + + sql("INSERT OVERWRITE %s VALUES (-2, 'a')", tableName); + table.refresh(); + Snapshot snap3 = table.currentSnapshot(); + long rightAfterSnap3 = waitUntilAfter(snap3.timestampMillis()); + + assertEquals( + "Should have expected changed rows up to snapshot 3", + ImmutableList.of( + row(1, "a", "INSERT", 0, snap1.snapshotId()), + row(1, "a", "DELETE", 1, snap3.snapshotId()), + row(-2, "a", "INSERT", 1, snap3.snapshotId())), + changelogRecords(null, rightAfterSnap3)); + + assertEquals( + "Should have expected changed rows up to snapshot 2", + ImmutableList.of(row(1, "a", "INSERT", 0, snap1.snapshotId())), + changelogRecords(null, rightAfterSnap2)); + + assertEquals( + "Should have expected changed rows from snapshot 3 only since snapshot 2 is on a different branch.", + ImmutableList.of( + row(1, "a", "DELETE", 0, snap3.snapshotId()), + row(-2, "a", "INSERT", 0, snap3.snapshotId())), + changelogRecords(rightAfterSnap1, snap3.timestampMillis())); + + assertEquals( + "Should have expected changed rows from snapshot 3", + ImmutableList.of( + row(1, "a", "DELETE", 0, snap3.snapshotId()), + row(-2, "a", "INSERT", 0, snap3.snapshotId())), + changelogRecords(rightAfterSnap2, null)); + + sql( + "CALL %s.system.set_current_snapshot('%s', %d)", + catalogName, tableIdent, snap2.snapshotId()); + table.refresh(); + assertThat(table.currentSnapshot()).isEqualTo(snap2); + assertEquals( + "Should have expected changed rows from snapshot 2 only since snapshot 3 is on a different branch.", + ImmutableList.of(row(2, "b", "INSERT", 0, snap2.snapshotId())), + changelogRecords(rightAfterSnap1, null)); + } + + private void createTableWithDefaultRows() { + createTable(); + insertDefaultRows(); + } + + private void createTable() { + sql( + "CREATE TABLE %s (id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES ( " + + " '%s' = '%d' " + + ")", + tableName, FORMAT_VERSION, formatVersion); + } + + private void insertDefaultRows() { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + } + + private List changelogRecords(Snapshot startSnapshot, Snapshot endSnapshot) { + DataFrameReader reader = spark.read(); + + if (startSnapshot != null) { + reader = reader.option(SparkReadOptions.START_SNAPSHOT_ID, startSnapshot.snapshotId()); + } + + if (endSnapshot != null) { + reader = reader.option(SparkReadOptions.END_SNAPSHOT_ID, endSnapshot.snapshotId()); + } + + return rowsToJava(collect(reader)); + } + + private List changelogRecords(Long startTimestamp, Long endTimeStamp) { + DataFrameReader reader = spark.read(); + + if (startTimestamp != null) { + reader = reader.option(SparkReadOptions.START_TIMESTAMP, startTimestamp); + } + + if (endTimeStamp != null) { + reader = reader.option(SparkReadOptions.END_TIMESTAMP, endTimeStamp); + } + + return rowsToJava(collect(reader)); + } + + private List collect(DataFrameReader reader) { + return reader + .table(tableName + "." + SparkChangelogTable.TABLE_NAME) + .orderBy("_change_ordinal", "_commit_snapshot_id", "_change_type", "id") + .collectAsList(); + } + + @TestTemplate + public void testChangelogViewOutsideTimeRange() { + createTableWithDefaultRows(); + + // Insert new records + sql("INSERT INTO %s VALUES (3, 'c')", tableName); + sql("INSERT INTO %s VALUES (4, 'd')", tableName); + + // Small delay to ensure our timestamps are different + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException("Test interrupted", e); + } + + long startTime = System.currentTimeMillis(); + long endTime = startTime + 1000; // 1 second window + + // Create changelog view for a time window after our inserts + sql( + "CALL %s.system.create_changelog_view(" + + " table => '%s', " + + " options => map(" + + " 'start-timestamp', '%d'," + + " 'end-timestamp', '%d'" + + " )," + + " changelog_view => 'test_changelog_view'" + + ")", + catalogName, tableName, startTime, endTime); + + // Query the changelog view + List results = + sql( + "SELECT * FROM test_changelog_view WHERE _change_type IN ('INSERT', 'DELETE') ORDER BY _change_ordinal"); + + // Verify no changes are returned since our window is after the inserts + assertThat(results).as("Num records must be zero").isEmpty(); + + // Clean up the changelog view + sql("DROP VIEW IF EXISTS test_changelog_view"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCherrypickSnapshotProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCherrypickSnapshotProcedure.java new file mode 100644 index 000000000000..a236f13f12bd --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCherrypickSnapshotProcedure.java @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.WRITE_AUDIT_PUBLISH_ENABLED; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCherrypickSnapshotProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testCherrypickSnapshotUsingPositionalArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", "1"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot wapSnapshot = Iterables.getOnlyElement(table.snapshots()); + + List output = + sql( + "CALL %s.system.cherrypick_snapshot('%s', %dL)", + catalogName, tableIdent, wapSnapshot.snapshotId()); + + table.refresh(); + + Snapshot currentSnapshot = table.currentSnapshot(); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(wapSnapshot.snapshotId(), currentSnapshot.snapshotId())), + output); + + assertEquals( + "Cherrypick must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testCherrypickSnapshotUsingNamedArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", "1"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot wapSnapshot = Iterables.getOnlyElement(table.snapshots()); + + List output = + sql( + "CALL %s.system.cherrypick_snapshot(snapshot_id => %dL, table => '%s')", + catalogName, wapSnapshot.snapshotId(), tableIdent); + + table.refresh(); + + Snapshot currentSnapshot = table.currentSnapshot(); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(wapSnapshot.snapshotId(), currentSnapshot.snapshotId())), + output); + + assertEquals( + "Cherrypick must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testCherrypickSnapshotRefreshesRelationCache() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + Dataset query = spark.sql("SELECT * FROM " + tableName + " WHERE id = 1"); + query.createOrReplaceTempView("tmp"); + + spark.sql("CACHE TABLE tmp"); + + assertEquals("View should not produce rows", ImmutableList.of(), sql("SELECT * FROM tmp")); + + spark.conf().set("spark.wap.id", "1"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot wapSnapshot = Iterables.getOnlyElement(table.snapshots()); + + sql( + "CALL %s.system.cherrypick_snapshot('%s', %dL)", + catalogName, tableIdent, wapSnapshot.snapshotId()); + + assertEquals( + "Cherrypick snapshot should be visible", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM tmp")); + + sql("UNCACHE TABLE tmp"); + } + + @TestTemplate + public void testCherrypickInvalidSnapshot() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.cherrypick_snapshot('%s', -1L)", catalogName, tableIdent)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot cherry-pick unknown snapshot ID: -1"); + } + + @TestTemplate + public void testInvalidCherrypickSnapshotCases() { + assertThatThrownBy( + () -> sql("CALL %s.system.cherrypick_snapshot('n', table => 't', 1L)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[UNEXPECTED_POSITIONAL_ARGUMENT] Cannot invoke routine `cherrypick_snapshot` because it contains positional argument(s) following the named argument assigned to `table`; please rearrange them so the positional arguments come first and then retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.custom.cherrypick_snapshot('n', 't', 1L)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`cherrypick_snapshot`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.cherrypick_snapshot('t')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `cherrypick_snapshot` because the parameter named `snapshot_id` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.cherrypick_snapshot('', 1L)", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + + assertThatThrownBy(() -> sql("CALL %s.system.cherrypick_snapshot('t', '2.2')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "[CAST_INVALID_INPUT] The value '2.2' of the type \"STRING\" cannot be cast to \"BIGINT\" because it is malformed."); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestComputePartitionStatsProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestComputePartitionStatsProcedure.java new file mode 100644 index 000000000000..37423fc14736 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestComputePartitionStatsProcedure.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestComputePartitionStatsProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void procedureOnEmptyTable() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + List result = + sql("CALL %s.system.compute_partition_stats('%s')", catalogName, tableIdent); + assertThat(result).isEmpty(); + } + + @TestTemplate + public void procedureWithPositionalArgs() throws NoSuchTableException, ParseException { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", tableName); + List output = + sql("CALL %s.system.compute_partition_stats('%s')", catalogName, tableIdent); + assertThat(output.get(0)).isNotEmpty(); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + assertThat(table.partitionStatisticsFiles()).hasSize(1); + PartitionStatisticsFile statisticsFile = table.partitionStatisticsFiles().get(0); + assertThat(statisticsFile.path()).isEqualTo(output.get(0)[0].toString()); + assertThat(statisticsFile.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(new File(statisticsFile.path().replace("file:", ""))).exists(); + } + + @TestTemplate + public void procedureWithNamedArgs() throws NoSuchTableException, ParseException { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", tableName); + sql("ALTER TABLE %s CREATE BRANCH `b1`", tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + long branchSnapshotId = table.refs().get("b1").snapshotId(); + sql("INSERT INTO TABLE %s VALUES (5, 'e'), (6, 'f'), (7, 'g'), (8, 'h')", tableName); + + List output = + sql( + "CALL %s.system.compute_partition_stats(table => '%s', snapshot_id => %s)", + catalogName, tableIdent, branchSnapshotId); + table.refresh(); + assertThat(table.partitionStatisticsFiles()).hasSize(1); + PartitionStatisticsFile statisticsFile = table.partitionStatisticsFiles().get(0); + assertThat(statisticsFile.path()).isEqualTo(output.get(0)[0].toString()); + // should be from the branch's snapshot instead of latest snapshot of the table + assertThat(statisticsFile.snapshotId()).isEqualTo(branchSnapshotId); + assertThat(new File(statisticsFile.path().replace("file:", ""))).exists(); + } + + @TestTemplate + public void procedureWithInvalidSnapshotId() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.compute_partition_stats(table => '%s', snapshot_id => 42)", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Snapshot not found: 42"); + } + + @TestTemplate + public void procedureWithInvalidTable() { + assertThatThrownBy( + () -> + sql( + "CALL %s.system.compute_partition_stats(table => '%s')", + catalogName, TableIdentifier.of(Namespace.of("default"), "abcd"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Couldn't load table"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestComputeTableStatsProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestComputeTableStatsProcedure.java new file mode 100644 index 000000000000..c487d4b7c848 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestComputeTableStatsProcedure.java @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.apache.iceberg.BlobMetadata; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.actions.NDVSketchUtil; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestComputeTableStatsProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testProcedureOnEmptyTable() throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + List result = + sql("CALL %s.system.compute_table_stats('%s')", catalogName, tableIdent); + assertThat(result).isEmpty(); + } + + @TestTemplate + public void testProcedureWithNamedArgs() throws NoSuchTableException, ParseException { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", tableName); + List output = + sql( + "CALL %s.system.compute_table_stats(table => '%s', columns => array('id'))", + catalogName, tableIdent); + assertThat(output.get(0)).isNotEmpty(); + Object obj = output.get(0)[0]; + assertThat(obj.toString()).endsWith(".stats"); + verifyTableStats(tableName); + } + + @TestTemplate + public void testProcedureWithPositionalArgs() throws NoSuchTableException, ParseException { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + Snapshot snapshot = table.currentSnapshot(); + List output = + sql( + "CALL %s.system.compute_table_stats('%s', %dL)", + catalogName, tableIdent, snapshot.snapshotId()); + assertThat(output.get(0)).isNotEmpty(); + Object obj = output.get(0)[0]; + assertThat(obj.toString()).endsWith(".stats"); + verifyTableStats(tableName); + } + + @TestTemplate + public void testProcedureWithInvalidColumns() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", tableName); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.compute_table_stats(table => '%s', columns => array('id1'))", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Can't find column id1"); + } + + @TestTemplate + public void testProcedureWithInvalidSnapshot() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.compute_table_stats(table => '%s', snapshot_id => %dL)", + catalogName, tableIdent, 1234L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Snapshot not found"); + } + + @TestTemplate + public void testProcedureWithInvalidTable() { + assertThatThrownBy( + () -> + sql( + "CALL %s.system.compute_table_stats(table => '%s', snapshot_id => %dL)", + catalogName, TableIdentifier.of(Namespace.of("default"), "abcd"), 1234L)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Couldn't load table"); + } + + void verifyTableStats(String tableName) throws NoSuchTableException, ParseException { + Table table = Spark3Util.loadIcebergTable(spark, tableName); + StatisticsFile statisticsFile = table.statisticsFiles().get(0); + BlobMetadata blobMetadata = statisticsFile.blobMetadata().get(0); + assertThat(blobMetadata.properties()) + .containsKey(NDVSketchUtil.APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestConflictValidation.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestConflictValidation.java new file mode 100644 index 000000000000..b5ba7eec1b01 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestConflictValidation.java @@ -0,0 +1,394 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.functions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestConflictValidation extends ExtensionsTestBase { + + @BeforeEach + public void createTables() { + sql( + "CREATE TABLE %s (id int, data string) USING iceberg " + + "PARTITIONED BY (id)" + + "TBLPROPERTIES" + + "('format-version'='2'," + + "'write.delete.mode'='merge-on-read')", + tableName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testOverwriteFilterSerializableIsolation() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + spark.createDataFrame(records, SimpleRecord.class).writeTo(tableName).append(); + + // Validating from previous snapshot finds conflicts + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option( + SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwrite(functions.col("id").equalTo(1))) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found conflicting files that can contain records matching ref(name=\"id\") == 1:"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwrite(functions.col("id").equalTo(1)); + } + + @TestTemplate + public void testOverwriteFilterSerializableIsolation2() throws Exception { + List records = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "b")); + spark.createDataFrame(records, SimpleRecord.class).coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + // This should add a delete file + sql("DELETE FROM %s WHERE id='1' and data='b'", tableName); + table.refresh(); + + // Validating from previous snapshot finds conflicts + List conflictingRecords = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset conflictingDf = spark.createDataFrame(conflictingRecords, SimpleRecord.class); + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwrite(functions.col("id").equalTo(1))) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found new conflicting delete files that can apply to records matching ref(name=\"id\") == 1:"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwrite(functions.col("id").equalTo(1)); + } + + @TestTemplate + public void testOverwriteFilterSerializableIsolation3() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + // This should delete a data file + sql("DELETE FROM %s WHERE id='1'", tableName); + table.refresh(); + + // Validating from previous snapshot finds conflicts + List conflictingRecords = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset conflictingDf = spark.createDataFrame(conflictingRecords, SimpleRecord.class); + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option( + SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwrite(functions.col("id").equalTo(1))) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found conflicting deleted files that can contain records matching ref(name=\"id\") == 1:"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwrite(functions.col("id").equalTo(1)); + } + + @TestTemplate + public void testOverwriteFilterNoSnapshotIdValidation() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + spark.createDataFrame(records, SimpleRecord.class).writeTo(tableName).append(); + + // Validating from no snapshot id defaults to beginning snapshot id and finds conflicts + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option( + SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwrite(functions.col("id").equalTo(1))) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found conflicting files that can contain records matching ref(name=\"id\") == 1:"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwrite(functions.col("id").equalTo(1)); + } + + @TestTemplate + public void testOverwriteFilterSnapshotIsolation() throws Exception { + List records = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "b")); + spark.createDataFrame(records, SimpleRecord.class).coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + // This should add a delete file + sql("DELETE FROM %s WHERE id='1' and data='b'", tableName); + table.refresh(); + + // Validating from previous snapshot finds conflicts + List conflictingRecords = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset conflictingDf = spark.createDataFrame(conflictingRecords, SimpleRecord.class); + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwrite(functions.col("id").equalTo(1))) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found new conflicting delete files that can apply to records matching ref(name=\"id\") == 1:"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwrite(functions.col("id").equalTo(1)); + } + + @TestTemplate + public void testOverwriteFilterSnapshotIsolation2() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + spark.createDataFrame(records, SimpleRecord.class).writeTo(tableName).append(); + + // Validation should not fail due to conflicting data file in snapshot isolation mode + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwrite(functions.col("id").equalTo(1)); + } + + @TestTemplate + public void testOverwritePartitionSerializableIsolation() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + final long snapshotId = table.currentSnapshot().snapshotId(); + + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + spark.createDataFrame(records, SimpleRecord.class).writeTo(tableName).append(); + + // Validating from previous snapshot finds conflicts + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option( + SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwritePartitions()) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found conflicting files that can contain records matching partitions [id=1]"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwritePartitions(); + } + + @TestTemplate + public void testOverwritePartitionSnapshotIsolation() throws Exception { + List records = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "b")); + spark.createDataFrame(records, SimpleRecord.class).coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + final long snapshotId = table.currentSnapshot().snapshotId(); + + // This should generate a delete file + sql("DELETE FROM %s WHERE data='a'", tableName); + + // Validating from previous snapshot finds conflicts + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwritePartitions()) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found new conflicting delete files that can apply to records matching [id=1]"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwritePartitions(); + } + + @TestTemplate + public void testOverwritePartitionSnapshotIsolation2() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + final long snapshotId = table.currentSnapshot().snapshotId(); + + // This should delete a data file + sql("DELETE FROM %s WHERE id='1'", tableName); + + // Validating from previous snapshot finds conflicts + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + spark.createDataFrame(records, SimpleRecord.class).coalesce(1).writeTo(tableName).append(); + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwritePartitions()) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found conflicting deleted files that can apply to records matching [id=1]"); + + // Validating from latest snapshot should succeed + table.refresh(); + long newSnapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(newSnapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwritePartitions(); + } + + @TestTemplate + public void testOverwritePartitionSnapshotIsolation3() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + final long snapshotId = table.currentSnapshot().snapshotId(); + + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + spark.createDataFrame(records, SimpleRecord.class).writeTo(tableName).append(); + + // Validation should not find conflicting data file in snapshot isolation mode + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SNAPSHOT.toString()) + .overwritePartitions(); + } + + @TestTemplate + public void testOverwritePartitionNoSnapshotIdValidation() throws Exception { + Table table = validationCatalog.loadTable(tableIdent); + + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + spark.createDataFrame(records, SimpleRecord.class).writeTo(tableName).append(); + + // Validating from null snapshot is equivalent to validating from beginning + Dataset conflictingDf = spark.createDataFrame(records, SimpleRecord.class); + assertThatThrownBy( + () -> + conflictingDf + .writeTo(tableName) + .option( + SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwritePartitions()) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Found conflicting files that can contain records matching partitions [id=1]"); + + // Validating from latest snapshot should succeed + table.refresh(); + long snapshotId = table.currentSnapshot().snapshotId(); + conflictingDf + .writeTo(tableName) + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID, String.valueOf(snapshotId)) + .option(SparkWriteOptions.ISOLATION_LEVEL, IsolationLevel.SERIALIZABLE.toString()) + .overwritePartitions(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java new file mode 100644 index 000000000000..dd296e5d72ba --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteDelete.java @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.Files; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCopyOnWriteDelete extends TestDelete { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.DELETE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + } + + @TestTemplate + public void testCopyOnWriteDeleteSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("DELETE FROM %s WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + + @TestTemplate + public void testRuntimeFilteringWithPreservedDataGrouping() throws NoSuchTableException { + createAndInitPartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(3, "hr")); + createBranchIfNeeded(); + append(new Employee(1, "hardware"), new Employee(2, "hardware")); + + Map sqlConf = + ImmutableMap.of( + SQLConf.V2_BUCKETING_ENABLED().key(), + "true", + SparkSQLProperties.PRESERVE_DATA_GROUPING, + "true"); + + withSQLConf(sqlConf, () -> sql("DELETE FROM %s WHERE id = 2", commitTarget())); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hardware"), row(1, "hr"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } + + @TestTemplate + public void testEqualityDeletePreservation() throws NoSuchTableException, IOException { + createAndInitPartitionedTable(); + append(tableName, new Employee(1, "hr"), new Employee(2, "hr"), new Employee(3, "hr")); + + Table table = validationCatalog.loadTable(tableIdent); + OutputFile out = Files.localOutput(File.createTempFile("junit", null, temp.toFile())); + Schema deleteSchema = table.schema().select("id"); + GenericRecord deleteRecord = GenericRecord.create(deleteSchema); + DeleteFile eqDelete = + FileHelpers.writeDeleteFile( + table, + out, + TestHelpers.Row.of("hr"), + List.of(deleteRecord.copy("id", 2)), + deleteSchema); + + table.newRowDelta().addDeletes(eqDelete).commit(); + + sql("REFRESH TABLE %s", tableName); + + assertEquals( + "Equality delete should remove row with id 2", + ImmutableList.of(row(1, "hr"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", tableName)); + + sql("DELETE FROM %s WHERE id = 3", tableName); + + assertEquals( + "COW Delete should remove row with id 3", + ImmutableList.of(row(1, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", tableName)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java new file mode 100644 index 000000000000..3f4faa0be119 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMerge.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.Map; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCopyOnWriteMerge extends TestMerge { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.MERGE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + } + + @TestTemplate + public void testCopyOnWriteMergeSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("source", Collections.singletonList(1), Encoders.INT()); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'changed' " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'new')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + + @TestTemplate + public void testRuntimeFilteringWithReportedPartitioning() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 3, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hardware\" }\n" + "{ \"id\": 2, \"dep\": \"hardware\" }"); + + createOrReplaceView("source", Collections.singletonList(2), Encoders.INT()); + + Map sqlConf = + ImmutableMap.of( + SQLConf.V2_BUCKETING_ENABLED().key(), + "true", + SparkSQLProperties.PRESERVE_DATA_GROUPING, + "true"); + + withSQLConf( + sqlConf, + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET id = -1", + commitTarget())); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(1, "hardware"), row(1, "hr"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMergeMetrics.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMergeMetrics.java new file mode 100644 index 000000000000..10039e1ce4fa --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteMergeMetrics.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCopyOnWriteMergeMetrics extends TestMergeMetrics { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.MERGE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + } + + @Override + protected long expectedRowsCopied(long unchangedRowsInModifiedFiles) { + // In copy-on-write mode, unchanged rows in modified files are rewritten + return unchangedRowsInModifiedFiles; + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java new file mode 100644 index 000000000000..22a2b0932f93 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteUpdate.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCopyOnWriteUpdate extends TestUpdate { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.UPDATE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + } + + @TestTemplate + public void testCopyOnWriteUpdateSetsSortOrderIdOnRewrittenDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET dep = 'changed' WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("Rewritten data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + + @TestTemplate + public void testRuntimeFilteringWithReportedPartitioning() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 3, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hardware\" }\n" + "{ \"id\": 2, \"dep\": \"hardware\" }"); + + Map sqlConf = + ImmutableMap.of( + SQLConf.V2_BUCKETING_ENABLED().key(), + "true", + SparkSQLProperties.PRESERVE_DATA_GROUPING, + "true"); + + withSQLConf(sqlConf, () -> sql("UPDATE %s SET id = -1 WHERE id = 2", commitTarget())); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(1, "hardware"), row(1, "hr"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteWithLineage.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteWithLineage.java new file mode 100644 index 000000000000..5b82f55ead33 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCopyOnWriteWithLineage.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import java.util.Map; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; + +public class TestCopyOnWriteWithLineage extends TestRowLevelOperationsWithLineage { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.MERGE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName(), + TableProperties.UPDATE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName(), + TableProperties.DELETE_MODE, RowLevelOperationMode.COPY_ON_WRITE.modeName()); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCreateChangelogViewProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCreateChangelogViewProcedure.java new file mode 100644 index 000000000000..3734d24e969b --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCreateChangelogViewProcedure.java @@ -0,0 +1,694 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.ChangelogOperation; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.spark.sql.types.StructField; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCreateChangelogViewProcedure extends ExtensionsTestBase { + private static final String DELETE = ChangelogOperation.DELETE.name(); + private static final String INSERT = ChangelogOperation.INSERT.name(); + private static final String UPDATE_BEFORE = ChangelogOperation.UPDATE_BEFORE.name(); + private static final String UPDATE_AFTER = ChangelogOperation.UPDATE_AFTER.name(); + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + public void createTableWithTwoColumns() { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD data", tableName); + } + + private void createTableWithThreeColumns() { + sql("CREATE TABLE %s (id INT, data STRING, age INT) USING iceberg", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD id", tableName); + } + + private void createTableWithIdentifierField() { + sql("CREATE TABLE %s (id INT NOT NULL, data STRING) USING iceberg", tableName); + sql("ALTER TABLE %s SET IDENTIFIER FIELDS id", tableName); + } + + @TestTemplate + public void testCustomizedViewName() { + createTableWithTwoColumns(); + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + + table.refresh(); + + Snapshot snap2 = table.currentSnapshot(); + + sql( + "CALL %s.system.create_changelog_view(" + + "table => '%s'," + + "options => map('%s','%s','%s','%s')," + + "changelog_view => '%s')", + catalogName, + tableName, + SparkReadOptions.START_SNAPSHOT_ID, + snap1.snapshotId(), + SparkReadOptions.END_SNAPSHOT_ID, + snap2.snapshotId(), + "cdc_view"); + + long rowCount = sql("select * from %s", "cdc_view").stream().count(); + assertThat(rowCount).isEqualTo(2); + } + + @TestTemplate + public void testNonStandardColumnNames() { + sql("CREATE TABLE %s (`the id` INT, `the.data` STRING) USING iceberg", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD `the.data`", tableName); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + + table.refresh(); + + Snapshot snap2 = table.currentSnapshot(); + + sql( + "CALL %s.system.create_changelog_view(" + + "table => '%s'," + + "options => map('%s','%s','%s','%s')," + + "changelog_view => '%s')", + catalogName, + tableName, + SparkReadOptions.START_SNAPSHOT_ID, + snap1.snapshotId(), + SparkReadOptions.END_SNAPSHOT_ID, + snap2.snapshotId(), + "cdc_view"); + + var df = spark.sql("select * from cdc_view"); + var fieldNames = + Arrays.stream(df.schema().fields()).map(StructField::name).collect(Collectors.toList()); + assertThat(fieldNames) + .containsExactly( + "the id", "the.data", "_change_type", "_change_ordinal", "_commit_snapshot_id"); + + assertThat(df.collectAsList()).hasSize(2); + } + + @TestTemplate + public void testNoSnapshotIdInput() { + createTableWithTwoColumns(); + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(" + "table => '%s')", + catalogName, tableName, "cdc_view"); + + String viewName = (String) returns.get(0)[0]; + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", INSERT, 0, snap0.snapshotId()), + row(2, "b", INSERT, 1, snap1.snapshotId()), + row(-2, "b", INSERT, 2, snap2.snapshotId()), + row(2, "b", DELETE, 2, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id", viewName)); + } + + @TestTemplate + public void testOnlyStartSnapshotIdInput() { + createTableWithTwoColumns(); + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s'," + "options => map('%s', '%s'))", + catalogName, tableName, SparkReadOptions.START_SNAPSHOT_ID, snap0.snapshotId()); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(2, "b", INSERT, 0, snap1.snapshotId()), + row(-2, "b", INSERT, 1, snap2.snapshotId()), + row(2, "b", DELETE, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + } + + @TestTemplate + public void testOnlyEndTimestampIdInput() { + createTableWithTwoColumns(); + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s'," + "options => map('%s', '%s'))", + catalogName, tableName, SparkReadOptions.END_SNAPSHOT_ID, snap1.snapshotId()); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", INSERT, 0, snap0.snapshotId()), row(2, "b", INSERT, 1, snap1.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + } + + @TestTemplate + public void testTimestampsBasedQuery() { + createTableWithTwoColumns(); + long beginning = System.currentTimeMillis(); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + long afterFirstInsert = waitUntilAfter(snap0.timestampMillis()); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + long afterInsertOverwrite = waitUntilAfter(snap2.timestampMillis()); + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + + "options => map('%s', '%s','%s', '%s'))", + catalogName, + tableName, + SparkReadOptions.START_TIMESTAMP, + beginning, + SparkReadOptions.END_TIMESTAMP, + afterInsertOverwrite); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", INSERT, 0, snap0.snapshotId()), + row(2, "b", INSERT, 1, snap1.snapshotId()), + row(-2, "b", INSERT, 2, snap2.snapshotId()), + row(2, "b", DELETE, 2, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + + // query the timestamps starting from the second insert + returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + + "options => map('%s', '%s', '%s', '%s'))", + catalogName, + tableName, + SparkReadOptions.START_TIMESTAMP, + afterFirstInsert, + SparkReadOptions.END_TIMESTAMP, + afterInsertOverwrite); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(2, "b", INSERT, 0, snap1.snapshotId()), + row(-2, "b", INSERT, 1, snap2.snapshotId()), + row(2, "b", DELETE, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + } + + @TestTemplate + public void testOnlyStartTimestampInput() { + createTableWithTwoColumns(); + long beginning = System.currentTimeMillis(); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + long afterFirstInsert = waitUntilAfter(snap0.timestampMillis()); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + "options => map('%s', '%s'))", + catalogName, tableName, SparkReadOptions.START_TIMESTAMP, beginning); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", INSERT, 0, snap0.snapshotId()), + row(2, "b", INSERT, 1, snap1.snapshotId()), + row(-2, "b", INSERT, 2, snap2.snapshotId()), + row(2, "b", DELETE, 2, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + + returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + "options => map('%s', '%s'))", + catalogName, tableName, SparkReadOptions.START_TIMESTAMP, afterFirstInsert); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(2, "b", INSERT, 0, snap1.snapshotId()), + row(-2, "b", INSERT, 1, snap2.snapshotId()), + row(2, "b", DELETE, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + } + + @TestTemplate + public void testOnlyEndTimestampInput() { + createTableWithTwoColumns(); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + long afterSecondInsert = waitUntilAfter(snap1.timestampMillis()); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + "options => map('%s', '%s'))", + catalogName, tableName, SparkReadOptions.END_TIMESTAMP, afterSecondInsert); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", INSERT, 0, snap0.snapshotId()), row(2, "b", INSERT, 1, snap1.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + } + + @TestTemplate + public void testStartTimeStampEndSnapshotId() { + createTableWithTwoColumns(); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + long afterFirstInsert = waitUntilAfter(snap0.timestampMillis()); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + + "options => map('%s', '%s', '%s', '%s'))", + catalogName, + tableName, + SparkReadOptions.START_TIMESTAMP, + afterFirstInsert, + SparkReadOptions.END_SNAPSHOT_ID, + snap2.snapshotId()); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(2, "b", INSERT, 0, snap1.snapshotId()), + row(-2, "b", INSERT, 1, snap2.snapshotId()), + row(2, "b", DELETE, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + } + + @TestTemplate + public void testStartSnapshotIdEndTimestamp() { + createTableWithTwoColumns(); + + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap0 = table.currentSnapshot(); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + table.refresh(); + Snapshot snap1 = table.currentSnapshot(); + long afterSecondInsert = waitUntilAfter(snap1.timestampMillis()); + + sql("INSERT OVERWRITE %s VALUES (-2, 'b')", tableName); + table.refresh(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + + "options => map('%s', '%s', '%s', '%s'))", + catalogName, + tableName, + SparkReadOptions.START_SNAPSHOT_ID, + snap0.snapshotId(), + SparkReadOptions.END_TIMESTAMP, + afterSecondInsert); + + assertEquals( + "Rows should match", + ImmutableList.of(row(2, "b", INSERT, 0, snap1.snapshotId())), + sql("select * from %s order by _change_ordinal, id", returns.get(0)[0])); + } + + @TestTemplate + public void testUpdate() { + createTableWithTwoColumns(); + sql("ALTER TABLE %s DROP PARTITION FIELD data", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD id", tableName); + + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (3, 'c'), (2, 'd')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', identifier_columns => array('id'))", + catalogName, tableName); + + String viewName = (String) returns.get(0)[0]; + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", INSERT, 0, snap1.snapshotId()), + row(2, "b", INSERT, 0, snap1.snapshotId()), + row(2, "b", UPDATE_BEFORE, 1, snap2.snapshotId()), + row(2, "d", UPDATE_AFTER, 1, snap2.snapshotId()), + row(3, "c", INSERT, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id, data", viewName)); + } + + @TestTemplate + public void testUpdateWithIdentifierField() { + createTableWithIdentifierField(); + + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (3, 'c'), (2, 'd')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', compute_updates => true)", + catalogName, tableName); + + String viewName = (String) returns.get(0)[0]; + assertEquals( + "Rows should match", + ImmutableList.of( + row(2, "b", INSERT, 0, snap1.snapshotId()), + row(2, "b", UPDATE_BEFORE, 1, snap2.snapshotId()), + row(2, "d", UPDATE_AFTER, 1, snap2.snapshotId()), + row(3, "c", INSERT, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id, data", viewName)); + } + + @TestTemplate + public void testUpdateWithFilter() { + createTableWithTwoColumns(); + sql("ALTER TABLE %s DROP PARTITION FIELD data", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD id", tableName); + + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (3, 'c'), (2, 'd')", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', identifier_columns => array('id'))", + catalogName, tableName); + + String viewName = (String) returns.get(0)[0]; + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", INSERT, 0, snap1.snapshotId()), + row(2, "b", INSERT, 0, snap1.snapshotId()), + row(2, "b", UPDATE_BEFORE, 1, snap2.snapshotId()), + row(2, "d", UPDATE_AFTER, 1, snap2.snapshotId())), + // the predicate on partition columns will filter out the insert of (3, 'c') at the planning + // phase + sql("select * from %s where id != 3 order by _change_ordinal, id, data", viewName)); + } + + @TestTemplate + public void testUpdateWithMultipleIdentifierColumns() { + createTableWithThreeColumns(); + + sql("INSERT INTO %s VALUES (1, 'a', 12), (2, 'b', 11)", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + + sql("INSERT OVERWRITE %s VALUES (3, 'c', 13), (2, 'd', 11), (2, 'e', 12)", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(" + + "identifier_columns => array('id','age')," + + "table => '%s')", + catalogName, tableName); + + String viewName = (String) returns.get(0)[0]; + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", 12, INSERT, 0, snap1.snapshotId()), + row(2, "b", 11, INSERT, 0, snap1.snapshotId()), + row(2, "b", 11, UPDATE_BEFORE, 1, snap2.snapshotId()), + row(2, "d", 11, UPDATE_AFTER, 1, snap2.snapshotId()), + row(2, "e", 12, INSERT, 1, snap2.snapshotId()), + row(3, "c", 13, INSERT, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id, data", viewName)); + } + + @TestTemplate + public void testRemoveCarryOvers() { + createTableWithThreeColumns(); + + sql("INSERT INTO %s VALUES (1, 'a', 12), (2, 'b', 11), (2, 'e', 12)", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + + // carry-over row (2, 'e', 12) + sql("INSERT OVERWRITE %s VALUES (3, 'c', 13), (2, 'd', 11), (2, 'e', 12)", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql( + "CALL %s.system.create_changelog_view(" + + "identifier_columns => array('id','age'), " + + "table => '%s')", + catalogName, tableName); + + String viewName = (String) returns.get(0)[0]; + // the carry-over rows (2, 'e', 12, 'DELETE', 1), (2, 'e', 12, 'INSERT', 1) are removed + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", 12, INSERT, 0, snap1.snapshotId()), + row(2, "b", 11, INSERT, 0, snap1.snapshotId()), + row(2, "e", 12, INSERT, 0, snap1.snapshotId()), + row(2, "b", 11, UPDATE_BEFORE, 1, snap2.snapshotId()), + row(2, "d", 11, UPDATE_AFTER, 1, snap2.snapshotId()), + row(3, "c", 13, INSERT, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id, data", viewName)); + } + + @TestTemplate + public void testRemoveCarryOversWithoutUpdatedRows() { + createTableWithThreeColumns(); + + sql("INSERT INTO %s VALUES (1, 'a', 12), (2, 'b', 11), (2, 'e', 12)", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + + // carry-over row (2, 'e', 12) + sql("INSERT OVERWRITE %s VALUES (3, 'c', 13), (2, 'd', 11), (2, 'e', 12)", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + List returns = + sql("CALL %s.system.create_changelog_view(table => '%s')", catalogName, tableName); + + String viewName = (String) returns.get(0)[0]; + + // the carry-over rows (2, 'e', 12, 'DELETE', 1), (2, 'e', 12, 'INSERT', 1) are removed, even + // though update-row is not computed + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", 12, INSERT, 0, snap1.snapshotId()), + row(2, "b", 11, INSERT, 0, snap1.snapshotId()), + row(2, "e", 12, INSERT, 0, snap1.snapshotId()), + row(2, "b", 11, DELETE, 1, snap2.snapshotId()), + row(2, "d", 11, INSERT, 1, snap2.snapshotId()), + row(3, "c", 13, INSERT, 1, snap2.snapshotId())), + sql("select * from %s order by _change_ordinal, id, data", viewName)); + } + + @TestTemplate + public void testNetChangesWithRemoveCarryOvers() { + // partitioned by id + createTableWithThreeColumns(); + + // insert rows: (1, 'a', 12) (2, 'b', 11) (2, 'e', 12) + sql("INSERT INTO %s VALUES (1, 'a', 12), (2, 'b', 11), (2, 'e', 12)", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snap1 = table.currentSnapshot(); + + // delete rows: (2, 'b', 11) (2, 'e', 12) + // insert rows: (3, 'c', 13) (2, 'd', 11) (2, 'e', 12) + sql("INSERT OVERWRITE %s VALUES (3, 'c', 13), (2, 'd', 11), (2, 'e', 12)", tableName); + table.refresh(); + Snapshot snap2 = table.currentSnapshot(); + + // delete rows: (2, 'd', 11) (2, 'e', 12) (3, 'c', 13) + // insert rows: (3, 'c', 15) (2, 'e', 12) + sql("INSERT OVERWRITE %s VALUES (3, 'c', 15), (2, 'e', 12)", tableName); + table.refresh(); + Snapshot snap3 = table.currentSnapshot(); + + // test with all snapshots + List returns = + sql( + "CALL %s.system.create_changelog_view(table => '%s', net_changes => true)", + catalogName, tableName); + + String viewName = (String) returns.get(0)[0]; + + assertEquals( + "Rows should match", + ImmutableList.of( + row(1, "a", 12, INSERT, 0, snap1.snapshotId()), + row(3, "c", 15, INSERT, 2, snap3.snapshotId()), + row(2, "e", 12, INSERT, 2, snap3.snapshotId())), + sql("select * from %s order by _change_ordinal, data", viewName)); + + // test with snap2 and snap3 + sql( + "CALL %s.system.create_changelog_view(table => '%s', " + + "options => map('start-snapshot-id','%s'), " + + "net_changes => true)", + catalogName, tableName, snap1.snapshotId()); + + assertEquals( + "Rows should match", + ImmutableList.of( + row(2, "b", 11, DELETE, 0, snap2.snapshotId()), + row(3, "c", 15, INSERT, 1, snap3.snapshotId())), + sql("select * from %s order by _change_ordinal, data", viewName)); + } + + @TestTemplate + public void testNetChangesWithComputeUpdates() { + createTableWithTwoColumns(); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.create_changelog_view(table => '%s', identifier_columns => array('id'), net_changes => true)", + catalogName, tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Not support net changes with update images"); + } + + @TestTemplate + public void testUpdateWithInComparableType() { + sql( + "CREATE TABLE %s (id INT NOT NULL, data MAP, age INT) USING iceberg", + tableName); + + assertThatThrownBy( + () -> + sql("CALL %s.system.create_changelog_view(table => '%s')", catalogName, tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Identifier field is required as table contains unorderable columns: [data]"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java new file mode 100644 index 000000000000..9e9d751691be --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestDelete.java @@ -0,0 +1,1544 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.DataOperations.DELETE; +import static org.apache.iceberg.RowLevelOperationMode.COPY_ON_WRITE; +import static org.apache.iceberg.RowLevelOperationMode.MERGE_ON_READ; +import static org.apache.iceberg.SnapshotSummary.ADDED_DVS_PROP; +import static org.apache.iceberg.SnapshotSummary.ADD_POS_DELETE_FILES_PROP; +import static org.apache.iceberg.TableProperties.COMMIT_MAX_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_MIN_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES; +import static org.apache.iceberg.TableProperties.DELETE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.DELETE_ISOLATION_LEVEL; +import static org.apache.iceberg.TableProperties.DELETE_MODE; +import static org.apache.iceberg.TableProperties.DELETE_MODE_DEFAULT; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.iceberg.TableProperties.SPLIT_SIZE; +import static org.apache.spark.sql.functions.lit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.MoreExecutors; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.catalyst.plans.logical.DeleteFromTableWithFilters; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; +import org.apache.spark.sql.catalyst.plans.logical.RowLevelWrite; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.execution.datasources.v2.OptimizeMetadataOnlyDeleteFromTable; +import org.apache.spark.sql.internal.SQLConf; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class TestDelete extends SparkRowLevelOperationsTestBase { + + @BeforeAll + public static void setupSparkConf() { + spark.conf().set("spark.sql.shuffle.partitions", "4"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS deleted_id"); + sql("DROP TABLE IF EXISTS deleted_dep"); + sql("DROP TABLE IF EXISTS parquet_table"); + } + + @TestTemplate + public void testDeleteWithVectorizedReads() throws NoSuchTableException { + assumeThat(supportsVectorization()).isTrue(); + + createAndInitPartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hr")); + append(tableName, new Employee(3, "hardware"), new Employee(4, "hardware")); + + createBranchIfNeeded(); + + SparkPlan plan = executeAndKeepPlan("DELETE FROM %s WHERE id = 2", commitTarget()); + assertAllBatchScansVectorized(plan); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + } else { + validateMergeOnRead(currentSnapshot, "1", "1", null); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(3, "hardware"), row(4, "hardware")), + sql("SELECT * FROM %s ORDER BY id ASC", selectTarget())); + } + + @TestTemplate + public void testCoalesceDelete() throws Exception { + createAndInitUnpartitionedTable(); + + Employee[] employees = new Employee[100]; + for (int index = 0; index < 100; index++) { + employees[index] = new Employee(index, "hr"); + } + append(tableName, employees); + append(tableName, employees); + append(tableName, employees); + append(tableName, employees); + + // set the open file cost large enough to produce a separate scan task per file + // use range distribution to trigger a shuffle + // set partitioned scoped deletes so that 1 delete file is written as part of the output task + Map tableProps = + ImmutableMap.of( + SPLIT_OPEN_FILE_COST, + String.valueOf(Integer.MAX_VALUE), + DELETE_DISTRIBUTION_MODE, + DistributionMode.RANGE.modeName(), + TableProperties.DELETE_GRANULARITY, + DeleteGranularity.PARTITION.toString()); + sql("ALTER TABLE %s SET TBLPROPERTIES (%s)", tableName, tablePropsAsString(tableProps)); + + createBranchIfNeeded(); + + // enable AQE and set the advisory partition size big enough to trigger combining + // set the number of shuffle partitions to 200 to distribute the work across reducers + // set the advisory partition size for shuffles small enough to ensure writes override it + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "200", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "true", + SQLConf.COALESCE_PARTITIONS_ENABLED().key(), + "true", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), + "100", + SparkSQLProperties.ADVISORY_PARTITION_SIZE, + String.valueOf(256 * 1024 * 1024)), + () -> { + SparkPlan plan = + executeAndKeepPlan("DELETE FROM %s WHERE mod(id, 2) = 0", commitTarget()); + assertThat(plan.toString()).contains("REBALANCE_PARTITIONS_BY_COL"); + }); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + + if (mode(table) == COPY_ON_WRITE) { + // CoW DELETE requests the remaining records to be range distributed by `_file`, `_pos` + // every task has data for each of 200 reducers + // AQE detects that all shuffle blocks are small and processes them in 1 task + // otherwise, there would be 200 tasks writing to the table + validateProperty(snapshot, SnapshotSummary.ADDED_FILES_PROP, "1"); + } else if (mode(table) == MERGE_ON_READ && formatVersion >= 3) { + validateProperty(snapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "4"); + validateProperty(snapshot, SnapshotSummary.ADDED_DVS_PROP, "4"); + } else { + // MoR DELETE requests the deleted records to be range distributed by partition and `_file` + // each task contains only 1 file and therefore writes only 1 shuffle block + // that means 4 shuffle blocks are distributed among 200 reducers + // AQE detects that all 4 shuffle blocks are small and processes them in 1 task + // otherwise, there would be 4 tasks processing 1 shuffle block each + validateProperty(snapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "1"); + } + + assertThat(scalarSql("SELECT COUNT(*) FROM %s", commitTarget())) + .as("Row count must match") + .isEqualTo(200L); + } + + @TestTemplate + public void testSkewDelete() throws Exception { + createAndInitPartitionedTable(); + + Employee[] employees = new Employee[100]; + for (int index = 0; index < 100; index++) { + employees[index] = new Employee(index, "hr"); + } + append(tableName, employees); + append(tableName, employees); + append(tableName, employees); + append(tableName, employees); + + // set the open file cost large enough to produce a separate scan task per file + // use hash distribution to trigger a shuffle + Map tableProps = + ImmutableMap.of( + SPLIT_OPEN_FILE_COST, + String.valueOf(Integer.MAX_VALUE), + DELETE_DISTRIBUTION_MODE, + DistributionMode.HASH.modeName()); + sql("ALTER TABLE %s SET TBLPROPERTIES (%s)", tableName, tablePropsAsString(tableProps)); + + createBranchIfNeeded(); + + // enable AQE and set the advisory partition size small enough to trigger a split + // set the number of shuffle partitions to 2 to only have 2 reducers + // set the advisory partition size for shuffles big enough to ensure writes override it + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "2", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "true", + SQLConf.ADAPTIVE_OPTIMIZE_SKEWS_IN_REBALANCE_PARTITIONS_ENABLED().key(), + "true", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), + "256MB", + SparkSQLProperties.ADVISORY_PARTITION_SIZE, + "100"), + () -> { + SparkPlan plan = + executeAndKeepPlan("DELETE FROM %s WHERE mod(id, 2) = 0", commitTarget()); + assertThat(plan.toString()).contains("REBALANCE_PARTITIONS_BY_COL"); + }); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + + if (mode(table) == COPY_ON_WRITE) { + // CoW DELETE requests the remaining records to be clustered by `_file` + // each task contains only 1 file and therefore writes only 1 shuffle block + // that means 4 shuffle blocks are distributed among 2 reducers + // AQE detects that all shuffle blocks are big and processes them in 4 independent tasks + // otherwise, there would be 2 tasks processing 2 shuffle blocks each + validateProperty(snapshot, SnapshotSummary.ADDED_FILES_PROP, "4"); + } else { + // MoR DELETE requests the deleted records to be clustered by `_spec_id` and `_partition` + // all tasks belong to the same partition and therefore write only 1 shuffle block per task + // that means there are 4 shuffle blocks, all assigned to the same reducer + // AQE detects that all 4 shuffle blocks are big and processes them in 4 separate tasks + // otherwise, there would be 1 task processing 4 shuffle blocks + validateProperty(snapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "4"); + } + + assertThat(scalarSql("SELECT COUNT(*) FROM %s", commitTarget())) + .as("Row count must match") + .isEqualTo(200L); + } + + @TestTemplate + public void testDeleteWithoutScanningTable() throws Exception { + createAndInitPartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(3, "hr")); + createBranchIfNeeded(); + append(new Employee(1, "hardware"), new Employee(2, "hardware")); + + Table table = validationCatalog.loadTable(tableIdent); + + List manifestLocations = + SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io()).stream() + .map(ManifestFile::path) + .collect(Collectors.toList()); + + withUnavailableLocations( + manifestLocations, + () -> { + LogicalPlan parsed = parsePlan("DELETE FROM %s WHERE dep = 'hr'", commitTarget()); + + LogicalPlan analyzed = spark.sessionState().analyzer().execute(parsed); + assertThat(analyzed).isInstanceOf(RowLevelWrite.class); + + LogicalPlan optimized = OptimizeMetadataOnlyDeleteFromTable.apply(analyzed); + assertThat(optimized).isInstanceOf(DeleteFromTableWithFilters.class); + }); + + sql("DELETE FROM %s WHERE dep = 'hr'", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hardware"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDeleteFileThenMetadataDelete() throws Exception { + assumeThat(fileFormat) + .as("Avro does not support metadata delete") + .isNotEqualTo(FileFormat.AVRO); + createAndInitUnpartitionedTable(); + createBranchIfNeeded(); + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", commitTarget()); + + // MOR mode: writes a delete file as null cannot be deleted by metadata + sql("DELETE FROM %s AS t WHERE t.id IS NULL", commitTarget()); + + // Metadata Delete + Table table = Spark3Util.loadIcebergTable(spark, tableName); + List dataFilesBefore = TestHelpers.dataFiles(table, branch); + + sql("DELETE FROM %s AS t WHERE t.id = 1", commitTarget()); + + List dataFilesAfter = TestHelpers.dataFiles(table, branch); + assertThat(dataFilesAfter) + .as("Data file should have been removed") + .hasSizeLessThan(dataFilesBefore.size()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDeleteWithPartitionedTable() throws Exception { + createAndInitPartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(3, "hr")); + append(tableName, new Employee(1, "hardware"), new Employee(2, "hardware")); + + // row level delete + sql("DELETE FROM %s WHERE id = 1", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + List rowLevelDeletePartitions = + spark.sql("SELECT * FROM " + tableName + ".partitions ").collectAsList(); + assertThat(rowLevelDeletePartitions) + .as("row level delete does not reduce number of partition") + .hasSize(2); + + // partition aligned delete + sql("DELETE FROM %s WHERE dep = 'hr'", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + List actualPartitions = + spark.sql("SELECT * FROM " + tableName + ".partitions ").collectAsList(); + assertThat(actualPartitions).as("partition aligned delete results in 1 partition").hasSize(1); + } + + @TestTemplate + public void testDeleteWithFalseCondition() { + createAndInitUnpartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware')", tableName); + createBranchIfNeeded(); + + sql("DELETE FROM %s WHERE id = 1 AND id > 20", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 2 snapshots").hasSize(2); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDeleteFromEmptyTable() { + assumeThat(branch).as("Custom branch does not exist for empty table").isNotEqualTo("test"); + createAndInitUnpartitionedTable(); + + sql("DELETE FROM %s WHERE id IN (1)", commitTarget()); + sql("DELETE FROM %s WHERE dep = 'hr'", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 2 snapshots").hasSize(2); + + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDeleteFromNonExistingCustomBranch() { + assumeThat(branch).as("Test only applicable to custom branch").isEqualTo("test"); + createAndInitUnpartitionedTable(); + + assertThatThrownBy(() -> sql("DELETE FROM %s WHERE id IN (1)", commitTarget())) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot use branch (does not exist): test"); + } + + @TestTemplate + public void testExplain() { + createAndInitUnpartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", tableName); + createBranchIfNeeded(); + + sql("EXPLAIN DELETE FROM %s WHERE id <=> 1", commitTarget()); + + sql("EXPLAIN DELETE FROM %s WHERE true", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 1 snapshot").hasSize(1); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", commitTarget())); + } + + @TestTemplate + public void testDeleteWithAlias() { + createAndInitUnpartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", tableName); + createBranchIfNeeded(); + + sql("DELETE FROM %s AS t WHERE t.id IS NULL", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDeleteWithDynamicFileFiltering() throws NoSuchTableException { + createAndInitPartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(3, "hr")); + createBranchIfNeeded(); + append(new Employee(1, "hardware"), new Employee(2, "hardware")); + + sql("DELETE FROM %s WHERE id = 2", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + } else { + validateMergeOnRead(currentSnapshot, "1", "1", null); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hardware"), row(1, "hr"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } + + @TestTemplate + public void testDeleteNonExistingRecords() { + createAndInitPartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", tableName); + createBranchIfNeeded(); + + sql("DELETE FROM %s AS t WHERE t.id > 10", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 2 snapshots").hasSize(2); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + + if (fileFormat.equals(FileFormat.ORC) || fileFormat.equals(FileFormat.PARQUET)) { + validateDelete(currentSnapshot, "0", null); + } else { + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "0", null, null); + } else { + validateMergeOnRead(currentSnapshot, "0", null, null); + } + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void deleteSingleRecordProducesDeleteOperation() throws NoSuchTableException { + createAndInitPartitionedTable(); + append(tableName, new Employee(1, "eng"), new Employee(2, "eng"), new Employee(3, "eng")); + + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).hasSize(2); + + Snapshot currentSnapshot = table.currentSnapshot(); + + if (mode(table) == COPY_ON_WRITE) { + // this is an OverwriteFiles and produces "overwrite" + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + } else { + // this is a RowDelta that produces a "delete" instead of "overwrite" + validateMergeOnRead(currentSnapshot, "1", "1", null); + String property = formatVersion >= 3 ? ADDED_DVS_PROP : ADD_POS_DELETE_FILES_PROP; + validateProperty(currentSnapshot, property, "1"); + } + + assertThat(sql("SELECT * FROM %s", tableName)) + .containsExactlyInAnyOrder(row(1, "eng"), row(3, "eng")); + } + + @TestTemplate + public void testDeleteWithoutCondition() { + createAndInitPartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + sql("INSERT INTO TABLE %s VALUES (2, 'hardware')", commitTarget()); + sql("INSERT INTO TABLE %s VALUES (null, 'hr')", commitTarget()); + + sql("DELETE FROM %s", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 4 snapshots").hasSize(4); + + // should be a delete instead of an overwrite as it is done through a metadata operation + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + validateDelete(currentSnapshot, "2", "3"); + + assertEquals( + "Should have expected rows", ImmutableList.of(), sql("SELECT * FROM %s", commitTarget())); + } + + @TestTemplate + public void testDeleteUsingMetadataWithComplexCondition() { + createAndInitPartitionedTable(); + + sql("INSERT INTO %s VALUES (1, 'dep1')", tableName); + createBranchIfNeeded(); + sql("INSERT INTO %s VALUES (2, 'dep2')", commitTarget()); + sql("INSERT INTO %s VALUES (null, 'dep3')", commitTarget()); + + sql( + "DELETE FROM %s WHERE dep > 'dep2' OR dep = CAST(4 AS STRING) OR dep = 'dep2'", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 4 snapshots").hasSize(4); + + // should be a delete instead of an overwrite as it is done through a metadata operation + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + validateDelete(currentSnapshot, "2", "2"); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "dep1")), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testDeleteWithArbitraryPartitionPredicates() { + createAndInitPartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + sql("INSERT INTO TABLE %s VALUES (2, 'hardware')", commitTarget()); + sql("INSERT INTO TABLE %s VALUES (null, 'hr')", commitTarget()); + + // %% is an escaped version of % + sql("DELETE FROM %s WHERE id = 10 OR dep LIKE '%%ware'", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 4 snapshots").hasSize(4); + + // should be a "delete" instead of an "overwrite" as only data files have been removed (COW) / + // delete files have been added (MOR) + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(currentSnapshot.operation()).isEqualTo(DELETE); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "1", "1", null); + } else { + validateMergeOnRead(currentSnapshot, "1", "1", null); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testDeleteWithNonDeterministicCondition() { + createAndInitPartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware')", tableName); + createBranchIfNeeded(); + + assertThatThrownBy(() -> sql("DELETE FROM %s WHERE id = 1 AND rand() > 0.5", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The operator expects a deterministic expression"); + } + + @TestTemplate + public void testDeleteWithFoldableConditions() { + createAndInitPartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware')", tableName); + createBranchIfNeeded(); + + // should keep all rows and don't trigger execution + sql("DELETE FROM %s WHERE false", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // should keep all rows and don't trigger execution + sql("DELETE FROM %s WHERE 50 <> 50", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // should keep all rows and don't trigger execution + sql("DELETE FROM %s WHERE 1 > null", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // should remove all rows + sql("DELETE FROM %s WHERE 21 = 21", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 2 snapshots").hasSize(2); + } + + @TestTemplate + public void testDeleteWithNullConditions() { + createAndInitPartitionedTable(); + + sql( + "INSERT INTO TABLE %s VALUES (0, null), (1, 'hr'), (2, 'hardware'), (null, 'hr')", + tableName); + createBranchIfNeeded(); + + // should keep all rows as null is never equal to null + sql("DELETE FROM %s WHERE dep = null", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(0, null), row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + // null = 'software' -> null + // should delete using metadata operation only + sql("DELETE FROM %s WHERE dep = 'software'", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(0, null), row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + // should delete using metadata operation only + sql("DELETE FROM %s WHERE dep <=> NULL", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + validateDelete(currentSnapshot, "1", "1"); + } + + @TestTemplate + public void testDeleteWithInAndNotInConditions() { + createAndInitUnpartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", tableName); + createBranchIfNeeded(); + + sql("DELETE FROM %s WHERE id IN (1, null)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql("DELETE FROM %s WHERE id NOT IN (null, 1)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql("DELETE FROM %s WHERE id NOT IN (1, 10)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testDeleteWithMultipleRowGroupsParquet() throws NoSuchTableException { + assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET); + + createAndInitPartitionedTable(); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", + tableName, PARQUET_ROW_GROUP_SIZE_BYTES, 100); + sql("ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", tableName, SPLIT_SIZE, 100); + + List ids = Lists.newArrayListWithCapacity(200); + for (int id = 1; id <= 200; id++) { + ids.add(id); + } + Dataset df = + spark + .createDataset(ids, Encoders.INT()) + .withColumnRenamed("value", "id") + .withColumn("dep", lit("hr")); + df.coalesce(1).writeTo(tableName).append(); + createBranchIfNeeded(); + + assertThat(spark.table(commitTarget()).count()).isEqualTo(200); + + // delete a record from one of two row groups and copy over the second one + sql("DELETE FROM %s WHERE id IN (200, 201)", commitTarget()); + + assertThat(spark.table(commitTarget()).count()).isEqualTo(199); + } + + @TestTemplate + public void testDeleteWithConditionOnNestedColumn() { + createAndInitNestedColumnsTable(); + + sql("INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", 3, \"c2\", \"v1\"))", tableName); + createBranchIfNeeded(); + sql("INSERT INTO TABLE %s VALUES (2, named_struct(\"c1\", 2, \"c2\", \"v2\"))", commitTarget()); + + sql("DELETE FROM %s WHERE complex.c1 = id + 2", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2)), + sql("SELECT id FROM %s", selectTarget())); + + sql("DELETE FROM %s t WHERE t.complex.c1 = id", commitTarget()); + assertEquals( + "Should have expected rows", ImmutableList.of(), sql("SELECT id FROM %s", selectTarget())); + } + + @TestTemplate + public void testDeleteWithInSubquery() throws NoSuchTableException { + createAndInitUnpartitionedTable(); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", tableName); + createBranchIfNeeded(); + + createOrReplaceView("deleted_id", Arrays.asList(0, 1, null), Encoders.INT()); + createOrReplaceView("deleted_dep", Arrays.asList("software", "hr"), Encoders.STRING()); + + sql( + "DELETE FROM %s WHERE id IN (SELECT * FROM deleted_id) AND dep IN (SELECT * from deleted_dep)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + append(new Employee(1, "hr"), new Employee(-1, "hr")); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s WHERE id IS NULL OR id IN (SELECT value + 2 FROM deleted_id)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(1, "hr")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + append(new Employee(null, "hr"), new Employee(2, "hr")); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(1, "hr"), row(2, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s WHERE id IN (SELECT value + 2 FROM deleted_id) AND dep = 'hr'", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(1, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testDeleteWithMultiColumnInSubquery() throws NoSuchTableException { + createAndInitUnpartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hardware"), new Employee(null, "hr")); + createBranchIfNeeded(); + + List deletedEmployees = + Arrays.asList(new Employee(null, "hr"), new Employee(1, "hr")); + createOrReplaceView("deleted_employee", deletedEmployees, Encoders.bean(Employee.class)); + + sql("DELETE FROM %s WHERE (id, dep) IN (SELECT id, dep FROM deleted_employee)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testDeleteWithNotInSubquery() throws NoSuchTableException { + createAndInitUnpartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hardware"), new Employee(null, "hr")); + createBranchIfNeeded(); + + createOrReplaceView("deleted_id", Arrays.asList(-1, -2, null), Encoders.INT()); + createOrReplaceView("deleted_dep", Arrays.asList("software", "hr"), Encoders.STRING()); + + // the file filter subquery (nested loop lef-anti join) returns 0 records + sql("DELETE FROM %s WHERE id NOT IN (SELECT * FROM deleted_id)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s WHERE id NOT IN (SELECT * FROM deleted_id WHERE value IS NOT NULL)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s WHERE id NOT IN (SELECT * FROM deleted_id) OR dep IN ('software', 'hr')", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s t WHERE " + + "id NOT IN (SELECT * FROM deleted_id WHERE value IS NOT NULL) AND " + + "EXISTS (SELECT 1 FROM FROM deleted_dep WHERE t.dep = deleted_dep.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s t WHERE " + + "id NOT IN (SELECT * FROM deleted_id WHERE value IS NOT NULL) OR " + + "EXISTS (SELECT 1 FROM FROM deleted_dep WHERE t.dep = deleted_dep.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testDeleteOnNonIcebergTableNotSupported() { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + + sql("CREATE TABLE parquet_table (c1 INT, c2 INT) USING parquet"); + + assertThatThrownBy(() -> sql("DELETE FROM parquet_table WHERE c1 = -100")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("does not support DELETE"); + } + + @TestTemplate + public void testDeleteWithExistSubquery() throws NoSuchTableException { + createAndInitUnpartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hardware"), new Employee(null, "hr")); + createBranchIfNeeded(); + + createOrReplaceView("deleted_id", Arrays.asList(-1, -2, null), Encoders.INT()); + createOrReplaceView("deleted_dep", Arrays.asList("software", "hr"), Encoders.STRING()); + + sql( + "DELETE FROM %s t WHERE EXISTS (SELECT 1 FROM deleted_id d WHERE t.id = d.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s t WHERE EXISTS (SELECT 1 FROM deleted_id d WHERE t.id = d.value + 2)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s t WHERE EXISTS (SELECT 1 FROM deleted_id d WHERE t.id = d.value) OR t.id IS NULL", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware")), + sql("SELECT * FROM %s", selectTarget())); + + sql( + "DELETE FROM %s t WHERE " + + "EXISTS (SELECT 1 FROM deleted_id di WHERE t.id = di.value) AND " + + "EXISTS (SELECT 1 FROM deleted_dep dd WHERE t.dep = dd.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware")), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testDeleteWithNotExistsSubquery() throws NoSuchTableException { + createAndInitUnpartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hardware"), new Employee(null, "hr")); + createBranchIfNeeded(); + + createOrReplaceView("deleted_id", Arrays.asList(-1, -2, null), Encoders.INT()); + createOrReplaceView("deleted_dep", Arrays.asList("software", "hr"), Encoders.STRING()); + + sql( + "DELETE FROM %s t WHERE " + + "NOT EXISTS (SELECT 1 FROM deleted_id di WHERE t.id = di.value + 2) AND " + + "NOT EXISTS (SELECT 1 FROM deleted_dep dd WHERE t.dep = dd.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "DELETE FROM %s t WHERE NOT EXISTS (SELECT 1 FROM deleted_id d WHERE t.id = d.value + 2)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + String subquery = "SELECT 1 FROM deleted_id d WHERE t.id = d.value + 2"; + sql("DELETE FROM %s t WHERE NOT EXISTS (%s) OR t.id = 1", commitTarget(), subquery); + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testDeleteWithScalarSubquery() throws NoSuchTableException { + createAndInitUnpartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hardware"), new Employee(null, "hr")); + createBranchIfNeeded(); + + createOrReplaceView("deleted_id", Arrays.asList(1, 100, null), Encoders.INT()); + + // TODO: Spark does not support AQE and DPP with aggregates at the moment + withSQLConf( + ImmutableMap.of(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false"), + () -> { + sql("DELETE FROM %s t WHERE id <= (SELECT min(value) FROM deleted_id)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + }); + } + + @TestTemplate + public void testDeleteThatRequiresGroupingBeforeWrite() throws NoSuchTableException { + createAndInitPartitionedTable(); + + append(tableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + createBranchIfNeeded(); + append(new Employee(0, "ops"), new Employee(1, "ops"), new Employee(2, "ops")); + append(new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + append(new Employee(0, "ops"), new Employee(1, "ops"), new Employee(2, "ops")); + + createOrReplaceView("deleted_id", Arrays.asList(1, 100), Encoders.INT()); + + String originalNumOfShufflePartitions = spark.conf().get("spark.sql.shuffle.partitions"); + try { + // set the num of shuffle partitions to 1 to ensure we have only 1 writing task + spark.conf().set("spark.sql.shuffle.partitions", "1"); + + sql("DELETE FROM %s t WHERE id IN (SELECT * FROM deleted_id)", commitTarget()); + assertThat(spark.table(commitTarget()).count()) + .as("Should have expected num of rows") + .isEqualTo(8L); + } finally { + spark.conf().set("spark.sql.shuffle.partitions", originalNumOfShufflePartitions); + } + } + + @TestTemplate + public synchronized void testDeleteWithSerializableIsolation() throws InterruptedException { + // cannot run tests with concurrency for Hadoop tables without atomic renames + assumeThat(catalogName).isNotEqualToIgnoringCase("testhadoop"); + // if caching is off, the table is eagerly refreshed during runtime filtering + // this can cause a validation exception as concurrent changes would be visible + assumeThat(cachingCatalogEnabled()).isTrue(); + + createAndInitUnpartitionedTable(); + createOrReplaceView("deleted_id", Collections.singletonList(1), Encoders.INT()); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, DELETE_ISOLATION_LEVEL, "serializable"); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + + ExecutorService executorService = + MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) Executors.newFixedThreadPool(2)); + + AtomicInteger barrier = new AtomicInteger(0); + AtomicBoolean shouldAppend = new AtomicBoolean(true); + + // delete thread + Future deleteFuture = + executorService.submit( + () -> { + for (int numOperations = 0; numOperations < Integer.MAX_VALUE; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> barrier.get() >= currentNumOperations * 2); + + sql("DELETE FROM %s WHERE id IN (SELECT * FROM deleted_id)", commitTarget()); + + barrier.incrementAndGet(); + } + }); + + // append thread + Future appendFuture = + executorService.submit( + () -> { + // load the table via the validation catalog to use another table instance + Table table = validationCatalog.loadTable(tableIdent); + + GenericRecord record = GenericRecord.create(SnapshotUtil.schemaFor(table, branch)); + record.set(0, 1); // id + record.set(1, "hr"); // dep + + for (int numOperations = 0; numOperations < Integer.MAX_VALUE; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> !shouldAppend.get() || barrier.get() >= currentNumOperations * 2); + + if (!shouldAppend.get()) { + return; + } + + for (int numAppends = 0; numAppends < 5; numAppends++) { + DataFile dataFile = writeDataFile(table, ImmutableList.of(record)); + AppendFiles appendFiles = table.newFastAppend().appendFile(dataFile); + if (branch != null) { + appendFiles.toBranch(branch); + } + + appendFiles.commit(); + } + + barrier.incrementAndGet(); + } + }); + + try { + assertThatThrownBy(deleteFuture::get) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Found conflicting files that can contain"); + } finally { + shouldAppend.set(false); + appendFuture.cancel(true); + } + + executorService.shutdown(); + assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); + } + + @TestTemplate + public synchronized void testDeleteWithSnapshotIsolation() + throws InterruptedException, ExecutionException { + // cannot run tests with concurrency for Hadoop tables without atomic renames + assumeThat(catalogName).isNotEqualToIgnoringCase("testhadoop"); + // if caching is off, the table is eagerly refreshed during runtime filtering + // this can cause a validation exception as concurrent changes would be visible + assumeThat(cachingCatalogEnabled()).isTrue(); + + createAndInitUnpartitionedTable(); + createOrReplaceView("deleted_id", Collections.singletonList(1), Encoders.INT()); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s'='%s', '%s'='%s', '%s'='%s', '%s'='%s')", + tableName, + DELETE_ISOLATION_LEVEL, + "snapshot", + COMMIT_MIN_RETRY_WAIT_MS, + "10", + COMMIT_MAX_RETRY_WAIT_MS, + "1000", + COMMIT_NUM_RETRIES, + "7"); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + + ExecutorService executorService = + MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) Executors.newFixedThreadPool(2)); + + AtomicInteger barrier = new AtomicInteger(0); + AtomicBoolean shouldAppend = new AtomicBoolean(true); + + // delete thread + Future deleteFuture = + executorService.submit( + () -> { + for (int numOperations = 0; numOperations < 20; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> barrier.get() >= currentNumOperations * 2); + + sql("DELETE FROM %s WHERE id IN (SELECT * FROM deleted_id)", commitTarget()); + + barrier.incrementAndGet(); + } + }); + + // append thread + Future appendFuture = + executorService.submit( + () -> { + // load the table via the validation catalog to use another table instance for inserts + Table table = validationCatalog.loadTable(tableIdent); + + GenericRecord record = GenericRecord.create(SnapshotUtil.schemaFor(table, branch)); + record.set(0, 1); // id + record.set(1, "hr"); // dep + + for (int numOperations = 0; numOperations < 20; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> !shouldAppend.get() || barrier.get() >= currentNumOperations * 2); + + if (!shouldAppend.get()) { + return; + } + + for (int numAppends = 0; numAppends < 5; numAppends++) { + DataFile dataFile = writeDataFile(table, ImmutableList.of(record)); + AppendFiles appendFiles = table.newFastAppend().appendFile(dataFile); + if (branch != null) { + appendFiles.toBranch(branch); + } + + appendFiles.commit(); + } + + barrier.incrementAndGet(); + } + }); + + try { + deleteFuture.get(); + } finally { + shouldAppend.set(false); + appendFuture.cancel(true); + } + + executorService.shutdown(); + assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); + } + + @TestTemplate + public void testDeleteRefreshesRelationCache() throws NoSuchTableException { + createAndInitPartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(3, "hr")); + createBranchIfNeeded(); + append(new Employee(1, "hardware"), new Employee(2, "hardware")); + + Dataset query = spark.sql("SELECT * FROM " + commitTarget() + " WHERE id = 1"); + query.createOrReplaceTempView("tmp"); + + spark.sql("CACHE TABLE tmp"); + + assertEquals( + "View should have correct data", + ImmutableList.of(row(1, "hardware"), row(1, "hr")), + sql("SELECT * FROM tmp ORDER BY id, dep")); + + sql("DELETE FROM %s WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "2", "2", "2"); + } else { + validateMergeOnRead(currentSnapshot, "2", "2", null); + } + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", commitTarget())); + + assertEquals( + "Should refresh the relation cache", + ImmutableList.of(), + sql("SELECT * FROM tmp ORDER BY id, dep")); + + spark.sql("UNCACHE TABLE tmp"); + } + + @TestTemplate + public void testDeleteWithMultipleSpecs() { + createAndInitTable("id INT, dep STRING, category STRING"); + + // write an unpartitioned file + append(tableName, "{ \"id\": 1, \"dep\": \"hr\", \"category\": \"c1\"}"); + createBranchIfNeeded(); + + // write a file partitioned by dep + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + append( + commitTarget(), + "{ \"id\": 2, \"dep\": \"hr\", \"category\": \"c1\" }\n" + + "{ \"id\": 3, \"dep\": \"hr\", \"category\": \"c1\" }"); + + // write a file partitioned by dep and category + sql("ALTER TABLE %s ADD PARTITION FIELD category", tableName); + append(commitTarget(), "{ \"id\": 5, \"dep\": \"hr\", \"category\": \"c1\"}"); + + // write another file partitioned by dep + sql("ALTER TABLE %s DROP PARTITION FIELD category", tableName); + append(commitTarget(), "{ \"id\": 7, \"dep\": \"hr\", \"category\": \"c1\"}"); + + sql("DELETE FROM %s WHERE id IN (1, 3, 5, 7)", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 5 snapshots").hasSize(5); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "3", "4", "1"); + } else { + validateMergeOnRead(currentSnapshot, "3", "4", null); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hr", "c1")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDeleteToWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + append(new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql("DELETE FROM %s t WHERE id=0", tableName); + assertThat(spark.table(tableName).count()) + .as("Should have expected num of rows when reading table") + .isEqualTo(2L); + assertThat(spark.table(tableName + ".branch_wap").count()) + .as("Should have expected num of rows when reading WAP branch") + .isEqualTo(2L); + assertThat(spark.table(tableName + ".branch_main").count()) + .as("Should not modify main branch") + .isEqualTo(3L); + }); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql("DELETE FROM %s t WHERE id=1", tableName); + assertThat(spark.table(tableName).count()) + .as("Should have expected num of rows when reading table with multiple writes") + .isEqualTo(1L); + assertThat(spark.table(tableName + ".branch_wap").count()) + .as("Should have expected num of rows when reading WAP branch with multiple writes") + .isEqualTo(1L); + assertThat(spark.table(tableName + ".branch_main").count()) + .as("Should not modify main branch with multiple writes") + .isEqualTo(3L); + }); + } + + @TestTemplate + public void testDeleteToWapBranchWithTableBranchIdentifier() throws NoSuchTableException { + assumeThat(branch).as("Test must have branch name part in table identifier").isNotNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + append(tableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + createBranchIfNeeded(); + + // writing to explicit branch should succeed even with WAP branch set + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql("DELETE FROM %s t WHERE id=0", commitTarget()); + + assertEquals( + "Should have deleted row in explicit branch", + ImmutableList.of(row(1, "hr"), row(2, "hr")), + sql("SELECT * FROM %s ORDER BY id", commitTarget())); + }); + } + + @TestTemplate + public void testDeleteToCustomWapBranchWithoutWhereClause() throws NoSuchTableException { + assumeThat(branch) + .as("Run only if custom WAP branch is not main") + .isNotNull() + .isNotEqualTo(SnapshotRef.MAIN_BRANCH); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + append(tableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + createBranchIfNeeded(); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, branch), + () -> { + sql("DELETE FROM %s t WHERE id=1", tableName); + assertThat(spark.table(tableName).count()).isEqualTo(2L); + assertThat(spark.table(tableName + ".branch_" + branch).count()).isEqualTo(2L); + assertThat(spark.table(tableName + ".branch_main").count()) + .as("Should not modify main branch") + .isEqualTo(3L); + }); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, branch), + () -> { + sql("DELETE FROM %s t", tableName); + assertThat(spark.table(tableName).count()).isEqualTo(0L); + assertThat(spark.table(tableName + ".branch_" + branch).count()).isEqualTo(0L); + assertThat(spark.table(tableName + ".branch_main").count()) + .as("Should not modify main branch") + .isEqualTo(3L); + }); + } + + @TestTemplate + public void testDeleteToWapBranchCanDeleteWhereScansWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + + sql("DELETE FROM %s WHERE id = 1", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("DELETE should remove the matching rows from the WAP branch") + .containsExactly(row(0, "hr"), row(2, "hr")); + assertThat(sql("SELECT id, dep FROM %s.branch_main", tableName)) + .as("Main branch must not be modified by a WAP-targeted DELETE") + .containsExactly(row(1, "hr")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + + @TestTemplate + public void testMetadataDeleteToWapBranchCommitsToWapBranch() throws NoSuchTableException { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitPartitionedTable(); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + append(tableName, new Employee(1, "hr"), new Employee(5, "eng")); + + spark.conf().set(SparkSQLProperties.WAP_BRANCH, "wap"); + try { + append(tableName, new Employee(0, "hr"), new Employee(2, "eng")); + + sql("DELETE FROM %s WHERE dep = 'hr'", tableName); + + assertThat(sql("SELECT id, dep FROM %s.branch_wap ORDER BY id", tableName)) + .as("Metadata delete should remove the hr partition on the WAP branch") + .containsExactly(row(2, "eng"), row(5, "eng")); + assertThat(sql("SELECT id, dep FROM %s.branch_main ORDER BY id", tableName)) + .as("Metadata delete must not commit to main when WAP is set") + .containsExactly(row(1, "hr"), row(5, "eng")); + } finally { + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + } + } + + @TestTemplate + public void testDeleteWithFilterOnNestedColumn() { + createAndInitNestedColumnsTable(); + + sql("INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", 3, \"c2\", \"v1\"))", tableName); + sql("INSERT INTO TABLE %s VALUES (2, named_struct(\"c1\", 2, \"c2\", \"v2\"))", tableName); + + sql("DELETE FROM %s WHERE complex.c1 > 3", tableName); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1), row(2)), + sql("SELECT id FROM %s order by id", tableName)); + + sql("DELETE FROM %s WHERE complex.c1 = 3", tableName); + assertEquals( + "Should have expected rows", ImmutableList.of(row(2)), sql("SELECT id FROM %s", tableName)); + + sql("DELETE FROM %s t WHERE t.complex.c1 = 2", tableName); + assertEquals( + "Should have expected rows", ImmutableList.of(), sql("SELECT id FROM %s", tableName)); + } + + // TODO: multiple stripes for ORC + + protected void createAndInitPartitionedTable() { + sql("CREATE TABLE %s (id INT, dep STRING) USING iceberg PARTITIONED BY (dep)", tableName); + initTable(); + } + + protected void createAndInitUnpartitionedTable() { + sql("CREATE TABLE %s (id INT, dep STRING) USING iceberg", tableName); + initTable(); + } + + protected void createAndInitNestedColumnsTable() { + sql("CREATE TABLE %s (id INT, complex STRUCT) USING iceberg", tableName); + initTable(); + } + + protected void append(Employee... employees) throws NoSuchTableException { + append(commitTarget(), employees); + } + + protected void append(String target, Employee... employees) throws NoSuchTableException { + List input = Arrays.asList(employees); + Dataset inputDF = spark.createDataFrame(input, Employee.class); + inputDF.coalesce(1).writeTo(target).append(); + } + + private RowLevelOperationMode mode(Table table) { + String modeName = table.properties().getOrDefault(DELETE_MODE, DELETE_MODE_DEFAULT); + return RowLevelOperationMode.fromName(modeName); + } + + private LogicalPlan parsePlan(String query, Object... args) { + try { + return spark.sessionState().sqlParser().parsePlan(String.format(query, args)); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestExpireSnapshotsProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestExpireSnapshotsProcedure.java new file mode 100644 index 000000000000..c116cb4f857f --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestExpireSnapshotsProcedure.java @@ -0,0 +1,624 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.GC_ENABLED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.GenericBlobMetadata; +import org.apache.iceberg.GenericStatisticsFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.puffin.Blob; +import org.apache.iceberg.puffin.Puffin; +import org.apache.iceberg.puffin.PuffinWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Encoders; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestExpireSnapshotsProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testExpireSnapshotsInEmptyTable() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + List output = sql("CALL %s.system.expire_snapshots('%s')", catalogName, tableIdent); + assertEquals( + "Should not delete any files", ImmutableList.of(row(0L, 0L, 0L, 0L, 0L, 0L)), output); + } + + @TestTemplate + public void testExpireSnapshotsUsingPositionalArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + waitUntilAfter(firstSnapshot.timestampMillis()); + + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + Timestamp secondSnapshotTimestamp = + Timestamp.from(Instant.ofEpochMilli(secondSnapshot.timestampMillis())); + + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + + // expire without retainLast param + List output1 = + sql( + "CALL %s.system.expire_snapshots('%s', TIMESTAMP '%s')", + catalogName, tableIdent, secondSnapshotTimestamp); + assertEquals( + "Procedure output must match", ImmutableList.of(row(0L, 0L, 0L, 0L, 1L, 0L)), output1); + + table.refresh(); + + assertThat(table.snapshots()).as("Should expire one snapshot").hasSize(1); + + sql("INSERT OVERWRITE %s VALUES (3, 'c')", tableName); + sql("INSERT INTO TABLE %s VALUES (4, 'd')", tableName); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(3L, "c"), row(4L, "d")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + assertThat(table.snapshots()).as("Should be 3 snapshots").hasSize(3); + + // expire with retainLast param + List output = + sql( + "CALL %s.system.expire_snapshots('%s', TIMESTAMP '%s', 2)", + catalogName, tableIdent, currentTimestamp); + assertEquals( + "Procedure output must match", ImmutableList.of(row(2L, 0L, 0L, 2L, 1L, 0L)), output); + } + + @TestTemplate + public void testExpireSnapshotUsingNamedArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + List output = + sql( + "CALL %s.system.expire_snapshots(older_than => TIMESTAMP '%s',table => '%s')", + catalogName, currentTimestamp, tableIdent); + assertEquals( + "Procedure output must match", ImmutableList.of(row(0L, 0L, 0L, 0L, 1L, 0L)), output); + } + + @TestTemplate + public void testExpireSnapshotsGCDisabled() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'false')", tableName, GC_ENABLED); + + assertThatThrownBy(() -> sql("CALL %s.system.expire_snapshots('%s')", catalogName, tableIdent)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith("Cannot expire snapshots: GC is disabled"); + } + + @TestTemplate + public void testInvalidExpireSnapshotsCases() { + assertThatThrownBy(() -> sql("CALL %s.system.expire_snapshots('n', table => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[DUPLICATE_ROUTINE_PARAMETER_ASSIGNMENT.BOTH_POSITIONAL_AND_NAMED] Call to routine `expire_snapshots` is invalid because it includes multiple argument assignments to the same parameter name `table`. A positional argument and named argument both referred to the same parameter. Please remove the named argument referring to this parameter. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.custom.expire_snapshots('n', 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`expire_snapshots`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.expire_snapshots()", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `expire_snapshots` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.expire_snapshots('n', 2.2)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE] Cannot resolve CALL due to data type mismatch: The second parameter requires the \"TIMESTAMP\" type, however \"2.2\" has the type \"DECIMAL(2,1)\". SQLSTATE: 42K09"); + + assertThatThrownBy(() -> sql("CALL %s.system.expire_snapshots('')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + } + + @TestTemplate + public void testResolvingTableInAnotherCatalog() throws IOException { + String anotherCatalog = "another_" + catalogName; + spark.conf().set("spark.sql.catalog." + anotherCatalog, SparkCatalog.class.getName()); + spark.conf().set("spark.sql.catalog." + anotherCatalog + ".type", "hadoop"); + spark + .conf() + .set( + "spark.sql.catalog." + anotherCatalog + ".warehouse", + Files.createTempDirectory(temp, "junit").toFile().toURI().toString()); + + sql( + "CREATE TABLE %s.%s (id bigint NOT NULL, data string) USING iceberg", + anotherCatalog, tableIdent); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.expire_snapshots('%s')", + catalogName, anotherCatalog + "." + tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot run procedure in catalog"); + } + + @TestTemplate + public void testConcurrentExpireSnapshots() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + sql("INSERT INTO TABLE %s VALUES (3, 'c')", tableName); + sql("INSERT INTO TABLE %s VALUES (4, 'd')", tableName); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + List output = + sql( + "CALL %s.system.expire_snapshots(" + + "older_than => TIMESTAMP '%s'," + + "table => '%s'," + + "max_concurrent_deletes => %s)", + catalogName, currentTimestamp, tableIdent, 4); + assertEquals( + "Expiring snapshots concurrently should succeed", + ImmutableList.of(row(0L, 0L, 0L, 0L, 3L, 0L)), + output); + } + + @TestTemplate + public void testConcurrentExpireSnapshotsWithInvalidInput() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.expire_snapshots(table => '%s', max_concurrent_deletes => %s)", + catalogName, tableIdent, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("max_concurrent_deletes should have value > 0, value: 0"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.expire_snapshots(table => '%s', max_concurrent_deletes => %s)", + catalogName, tableIdent, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("max_concurrent_deletes should have value > 0, value: -1"); + } + + @TestTemplate + public void testExpireDeleteFiles() throws Exception { + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg TBLPROPERTIES" + + "('format-version'='2', 'write.delete.mode'='merge-on-read')", + tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + sql("DELETE FROM %s WHERE id=1", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(TestHelpers.deleteManifests(table)).as("Should have 1 delete manifest").hasSize(1); + assertThat(TestHelpers.deleteFiles(table)).as("Should have 1 delete file").hasSize(1); + Path deleteManifestPath = new Path(TestHelpers.deleteManifests(table).iterator().next().path()); + DeleteFile deleteFile = TestHelpers.deleteFiles(table).iterator().next(); + Path deleteFilePath = new Path(deleteFile.location()); + + sql( + "CALL %s.system.rewrite_data_files(" + + "table => '%s'," + + "options => map(" + + "'delete-file-threshold','1'," + + "'use-starting-sequence-number', 'false'))", + catalogName, tableIdent); + table.refresh(); + + table + .newRowDelta() + .removeDeletes(deleteFile) + .commit(); // this txn moves the file to the DELETED state + sql("INSERT INTO TABLE %s VALUES (6, 'f')", tableName); // this txn removes the file reference + table.refresh(); + + assertThat(TestHelpers.deleteManifests(table)).as("Should have no delete manifests").isEmpty(); + assertThat(TestHelpers.deleteFiles(table)).as("Should have no delete files").isEmpty(); + + FileSystem localFs = FileSystem.getLocal(new Configuration()); + assertThat(localFs.exists(deleteManifestPath)) + .as("Delete manifest should still exist") + .isTrue(); + assertThat(localFs.exists(deleteFilePath)).as("Delete file should still exist").isTrue(); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + List output = + sql( + "CALL %s.system.expire_snapshots(older_than => TIMESTAMP '%s',table => '%s')", + catalogName, currentTimestamp, tableIdent); + + assertEquals( + "Should deleted 1 data and pos delete file and 4 manifests and lists (one for each txn)", + ImmutableList.of(row(1L, 1L, 0L, 4L, 4L, 0L)), + output); + assertThat(localFs.exists(deleteManifestPath)) + .as("Delete manifest should be removed") + .isFalse(); + assertThat(localFs.exists(deleteFilePath)).as("Delete file should be removed").isFalse(); + } + + @TestTemplate + public void testExpireSnapshotWithStreamResultsEnabled() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + List output = + sql( + "CALL %s.system.expire_snapshots(" + + "older_than => TIMESTAMP '%s'," + + "table => '%s'," + + "stream_results => true)", + catalogName, currentTimestamp, tableIdent); + assertEquals( + "Procedure output must match", ImmutableList.of(row(0L, 0L, 0L, 0L, 1L, 0L)), output); + } + + @TestTemplate + public void testExpireSnapshotsWithSnapshotId() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + + // Expiring the snapshot specified by snapshot_id should keep only a single snapshot. + long firstSnapshotId = table.currentSnapshot().parentId(); + sql( + "CALL %s.system.expire_snapshots(" + "table => '%s'," + "snapshot_ids => ARRAY(%d))", + catalogName, tableIdent, firstSnapshotId); + + // There should only be one single snapshot left. + table.refresh(); + assertThat(table.snapshots()) + .hasSize(1) + .as("Snapshot ID should not be present") + .filteredOn(snapshot -> snapshot.snapshotId() == firstSnapshotId) + .isEmpty(); + } + + @TestTemplate + public void testExpireSnapshotShouldFailForCurrentSnapshot() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.expire_snapshots(" + + "table => '%s'," + + "snapshot_ids => ARRAY(%d, %d))", + catalogName, + tableIdent, + table.currentSnapshot().snapshotId(), + table.currentSnapshot().parentId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot expire"); + } + + @TestTemplate + public void testExpireSnapshotsProcedureWorksWithSqlComments() { + // Ensure that systems such as dbt, that inject comments into the generated SQL files, will + // work with Iceberg-specific DDL + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + String callStatement = + "/* CALL statement is used to expire snapshots */\n" + + "-- And we have single line comments as well \n" + + "/* And comments that span *multiple* \n" + + " lines */ CALL /* this is the actual CALL */ %s.system.expire_snapshots(" + + " older_than => TIMESTAMP '%s'," + + " table => '%s')"; + List output = sql(callStatement, catalogName, currentTimestamp, tableIdent); + assertEquals( + "Procedure output must match", ImmutableList.of(row(0L, 0L, 0L, 0L, 1L, 0L)), output); + + table.refresh(); + + assertThat(table.snapshots()).as("Should be 1 snapshot remaining").hasSize(1); + } + + @TestTemplate + public void testExpireSnapshotsWithStatisticFiles() throws Exception { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (10, 'abc')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + String statsFileLocation1 = ProcedureUtil.statsFileLocation(table.location()); + StatisticsFile statisticsFile1 = + writeStatsFile( + table.currentSnapshot().snapshotId(), + table.currentSnapshot().sequenceNumber(), + statsFileLocation1, + table.io()); + table.updateStatistics().setStatistics(statisticsFile1).commit(); + + sql("INSERT INTO %s SELECT 20, 'def'", tableName); + table.refresh(); + String statsFileLocation2 = ProcedureUtil.statsFileLocation(table.location()); + StatisticsFile statisticsFile2 = + writeStatsFile( + table.currentSnapshot().snapshotId(), + table.currentSnapshot().sequenceNumber(), + statsFileLocation2, + table.io()); + table.updateStatistics().setStatistics(statisticsFile2).commit(); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + List output = + sql( + "CALL %s.system.expire_snapshots(older_than => TIMESTAMP '%s',table => '%s')", + catalogName, currentTimestamp, tableIdent); + assertThat(output.get(0)[5]).as("should be 1 deleted statistics file").isEqualTo(1L); + + table.refresh(); + assertThat(table.statisticsFiles()) + .as( + "Statistics file entry in TableMetadata should be present only for the snapshot %s", + statisticsFile2.snapshotId()) + .extracting(StatisticsFile::snapshotId) + .containsExactly(statisticsFile2.snapshotId()); + + assertThat(new File(statsFileLocation1)) + .as("Statistics file should not exist for snapshot %s", statisticsFile1.snapshotId()) + .doesNotExist(); + + assertThat(new File(statsFileLocation2)) + .as("Statistics file should exist for snapshot %s", statisticsFile2.snapshotId()) + .exists(); + } + + @TestTemplate + public void testExpireSnapshotsWithPartitionStatisticFiles() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (10, 'abc')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + String partitionStatsFileLocation1 = ProcedureUtil.statsFileLocation(table.location()); + PartitionStatisticsFile partitionStatisticsFile1 = + ProcedureUtil.writePartitionStatsFile( + table.currentSnapshot().snapshotId(), partitionStatsFileLocation1, table.io()); + table.updatePartitionStatistics().setPartitionStatistics(partitionStatisticsFile1).commit(); + + sql("INSERT INTO %s SELECT 20, 'def'", tableName); + table.refresh(); + String partitionStatsFileLocation2 = ProcedureUtil.statsFileLocation(table.location()); + PartitionStatisticsFile partitionStatisticsFile2 = + ProcedureUtil.writePartitionStatsFile( + table.currentSnapshot().snapshotId(), partitionStatsFileLocation2, table.io()); + table.updatePartitionStatistics().setPartitionStatistics(partitionStatisticsFile2).commit(); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + List output = + sql( + "CALL %s.system.expire_snapshots(older_than => TIMESTAMP '%s',table => '%s')", + catalogName, currentTimestamp, tableIdent); + assertThat(output.get(0)[5]).as("should be 1 deleted partition statistics file").isEqualTo(1L); + + table.refresh(); + assertThat(table.partitionStatisticsFiles()) + .as( + "partition statistics file entry in TableMetadata should be present only for the snapshot %s", + partitionStatisticsFile2.snapshotId()) + .extracting(PartitionStatisticsFile::snapshotId) + .containsExactly(partitionStatisticsFile2.snapshotId()); + + assertThat(new File(partitionStatsFileLocation1)) + .as( + "partition statistics file should not exist for snapshot %s", + partitionStatisticsFile1.snapshotId()) + .doesNotExist(); + + assertThat(new File(partitionStatsFileLocation2)) + .as( + "partition statistics file should exist for snapshot %s", + partitionStatisticsFile2.snapshotId()) + .exists(); + } + + @TestTemplate + public void testNoExpiredMetadataCleanupByDefault() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("ALTER TABLE %s ADD COLUMN extra_col int", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b', 21)", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + assertThat(table.schemas()).as("Should have 2 schemas").hasSize(2); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + List output = + sql( + "CALL %s.system.expire_snapshots(older_than => TIMESTAMP '%s', table => '%s')", + catalogName, + Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())), + tableIdent); + + table.refresh(); + assertThat(table.schemas()).as("Should have 2 schemas").hasSize(2); + assertEquals( + "Procedure output must match", ImmutableList.of(row(0L, 0L, 0L, 0L, 1L, 0L)), output); + } + + @TestTemplate + public void testCleanExpiredMetadata() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("ALTER TABLE %s ADD COLUMN extra_col int", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b', 21)", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.snapshots()).as("Should be 2 snapshots").hasSize(2); + assertThat(table.schemas()).as("Should have 2 schemas").hasSize(2); + + waitUntilAfter(table.currentSnapshot().timestampMillis()); + + List output = + sql( + "CALL %s.system.expire_snapshots(" + + "older_than => TIMESTAMP '%s', " + + "clean_expired_metadata => true, " + + "table => '%s')", + catalogName, + Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())), + tableIdent); + + table.refresh(); + + assertThat(table.schemas().keySet()) + .as("Should have only the latest schema") + .containsExactly(table.schema().schemaId()); + assertEquals( + "Procedure output must match", ImmutableList.of(row(0L, 0L, 0L, 0L, 1L, 0L)), output); + } + + private static StatisticsFile writeStatsFile( + long snapshotId, long snapshotSequenceNumber, String statsLocation, FileIO fileIO) + throws IOException { + try (PuffinWriter puffinWriter = Puffin.write(fileIO.newOutputFile(statsLocation)).build()) { + puffinWriter.add( + new Blob( + "some-blob-type", + ImmutableList.of(1), + snapshotId, + snapshotSequenceNumber, + ByteBuffer.wrap("blob content".getBytes(StandardCharsets.UTF_8)))); + puffinWriter.finish(); + + return new GenericStatisticsFile( + snapshotId, + statsLocation, + puffinWriter.fileSize(), + puffinWriter.footerSize(), + puffinWriter.writtenBlobsMetadata().stream() + .map(GenericBlobMetadata::from) + .collect(ImmutableList.toImmutableList())); + } + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestFastForwardBranchProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestFastForwardBranchProcedure.java new file mode 100644 index 000000000000..69920e1d5402 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestFastForwardBranchProcedure.java @@ -0,0 +1,264 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestFastForwardBranchProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testFastForwardBranchUsingPositionalArgs() { + sql("CREATE TABLE %s (id int NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + table.refresh(); + + Snapshot currSnapshot = table.currentSnapshot(); + long sourceRef = currSnapshot.snapshotId(); + + String newBranch = "testBranch"; + String tableNameWithBranch = String.format("%s.branch_%s", tableName, newBranch); + + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, newBranch); + sql("INSERT INTO TABLE %s VALUES(3,'c')", tableNameWithBranch); + + table.refresh(); + long updatedRef = table.snapshot(newBranch).snapshotId(); + + assertEquals( + "Main branch should not have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b")), + sql("SELECT * FROM %s order by id", tableName)); + + assertEquals( + "Test branch should have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b"), row(3, "c")), + sql("SELECT * FROM %s order by id", tableNameWithBranch)); + + List output = + sql( + "CALL %s.system.fast_forward('%s', '%s', '%s')", + catalogName, tableIdent, SnapshotRef.MAIN_BRANCH, newBranch); + + assertThat(Arrays.stream(output.get(0)).collect(Collectors.toList()).get(0)) + .isEqualTo(SnapshotRef.MAIN_BRANCH); + + assertThat(Arrays.stream(output.get(0)).collect(Collectors.toList()).get(1)) + .isEqualTo(sourceRef); + + assertThat(Arrays.stream(output.get(0)).collect(Collectors.toList()).get(2)) + .isEqualTo(updatedRef); + + assertEquals( + "Main branch should have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b"), row(3, "c")), + sql("SELECT * FROM %s order by id", tableName)); + } + + @TestTemplate + public void testFastForwardBranchUsingNamedArgs() { + sql("CREATE TABLE %s (id int NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + String newBranch = "testBranch"; + String tableNameWithBranch = String.format("%s.branch_%s", tableName, newBranch); + + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, newBranch); + sql("INSERT INTO TABLE %s VALUES(3,'c')", tableNameWithBranch); + + assertEquals( + "Main branch should not have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b")), + sql("SELECT * FROM %s order by id", tableName)); + + assertEquals( + "Test branch should have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b"), row(3, "c")), + sql("SELECT * FROM %s order by id", tableNameWithBranch)); + + List output = + sql( + "CALL %s.system.fast_forward(table => '%s', branch => '%s', to => '%s')", + catalogName, tableIdent, SnapshotRef.MAIN_BRANCH, newBranch); + + assertEquals( + "Main branch should now have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b"), row(3, "c")), + sql("SELECT * FROM %s order by id", tableName)); + } + + @TestTemplate + public void testFastForwardWhenTargetIsNotAncestorFails() { + sql("CREATE TABLE %s (id int NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + String newBranch = "testBranch"; + String tableNameWithBranch = String.format("%s.branch_%s", tableName, newBranch); + + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, newBranch); + sql("INSERT INTO TABLE %s VALUES(3,'c')", tableNameWithBranch); + + assertEquals( + "Main branch should not have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b")), + sql("SELECT * FROM %s order by id", tableName)); + + assertEquals( + "Test branch should have the newly inserted record.", + ImmutableList.of(row(1, "a"), row(2, "b"), row(3, "c")), + sql("SELECT * FROM %s order by id", tableNameWithBranch)); + + // Commit a snapshot on main to deviate the branches + sql("INSERT INTO TABLE %s VALUES (4, 'd')", tableName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.fast_forward(table => '%s', branch => '%s', to => '%s')", + catalogName, tableIdent, SnapshotRef.MAIN_BRANCH, newBranch)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot fast-forward: main is not an ancestor of testBranch"); + } + + @TestTemplate + public void testInvalidFastForwardBranchCases() { + assertThatThrownBy( + () -> + sql( + "CALL %s.system.fast_forward(table => 'test_table', 'main', to => 'newBranch')", + catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[UNEXPECTED_POSITIONAL_ARGUMENT] Cannot invoke routine `fast_forward` " + + "because it contains positional argument(s) following the named argument assigned to `table`; " + + "please rearrange them so the positional arguments come first and then retry the query again. " + + "SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> + sql("CALL %s.custom.fast_forward('test_table', 'main', 'newBranch')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`fast_forward`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.fast_forward('test_table', 'main')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `fast_forward` because the parameter named `to` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> sql("CALL %s.system.fast_forward('', 'main', 'newBranch')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + } + + @TestTemplate + public void testFastForwardNonExistingToRefFails() { + sql("CREATE TABLE %s (id int NOT NULL, data string) USING iceberg", tableName); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.fast_forward(table => '%s', branch => '%s', to => '%s')", + catalogName, tableIdent, SnapshotRef.MAIN_BRANCH, "non_existing_branch")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Ref does not exist: non_existing_branch"); + } + + @TestTemplate + public void testFastForwardNonMain() { + sql("CREATE TABLE %s (id int NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + table.refresh(); + + String branch1 = "branch1"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branch1); + String tableNameWithBranch1 = String.format("%s.branch_%s", tableName, branch1); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableNameWithBranch1); + table.refresh(); + Snapshot branch1Snapshot = table.snapshot(branch1); + + // Create branch2 from branch1 + String branch2 = "branch2"; + sql( + "ALTER TABLE %s CREATE BRANCH %s AS OF VERSION %d", + tableName, branch2, branch1Snapshot.snapshotId()); + String tableNameWithBranch2 = String.format("%s.branch_%s", tableName, branch2); + sql("INSERT INTO TABLE %s VALUES (3, 'c')", tableNameWithBranch2); + table.refresh(); + Snapshot branch2Snapshot = table.snapshot(branch2); + assertThat( + sql( + "CALL %s.system.fast_forward('%s', '%s', '%s')", + catalogName, tableIdent, branch1, branch2)) + .containsExactly(row(branch1, branch1Snapshot.snapshotId(), branch2Snapshot.snapshotId())); + } + + @TestTemplate + public void testFastForwardNonExistingFromMainCreatesBranch() { + sql("CREATE TABLE %s (id int NOT NULL, data string) USING iceberg", tableName); + String branch1 = "branch1"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branch1); + String branchIdentifier = String.format("%s.branch_%s", tableName, branch1); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", branchIdentifier); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", branchIdentifier); + Table table = validationCatalog.loadTable(tableIdent); + table.refresh(); + Snapshot branch1Snapshot = table.snapshot(branch1); + + assertThat( + sql( + "CALL %s.system.fast_forward('%s', '%s', '%s')", + catalogName, tableIdent, SnapshotRef.MAIN_BRANCH, branch1)) + .containsExactly(row(SnapshotRef.MAIN_BRANCH, null, branch1Snapshot.snapshotId())); + + // Ensure the same behavior for non-main branches + String branch2 = "branch2"; + assertThat( + sql( + "CALL %s.system.fast_forward('%s', '%s', '%s')", + catalogName, tableIdent, branch2, branch1)) + .containsExactly(row(branch2, null, branch1Snapshot.snapshotId())); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestIncrementallyConstructedQueries.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestIncrementallyConstructedQueries.java new file mode 100644 index 000000000000..a5b63c358058 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestIncrementallyConstructedQueries.java @@ -0,0 +1,429 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestIncrementallyConstructedQueries extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.HIVE.properties()) + .put("cache-enabled", "false") + .build() + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties() + } + }; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testIncrementalQueryWithExternalWrite() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer adds (2, 200) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 (QueryExecution #2) + Dataset df2 = spark.table(tableName); + + // join two DataFrames (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + // query should refresh versions and use consistent snapshot + List result = rowsToJava(joined.collectAsList()); + assertThat(result) + .as("Join should use consistent table versions after refresh") + .hasSize(2) + .containsExactlyInAnyOrder(row(1, 100, 1, 100), row(2, 200, 2, 200)); + } + + @TestTemplate + public void testIncrementalQueryWithExternalSchemaChangeAddColumn() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer adds new column + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().addColumn("new_column", Types.IntegerType.get()).commit(); + + // external writer adds (2, 200, -1) + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + record.setField("new_column", -1); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 (QueryExecution #2) + Dataset df2 = spark.table(tableName); + + // join two DataFrames (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + // query should refresh versions, preserve original schema for df1, use new schema for df2 + List result = rowsToJava(joined.collectAsList()); + assertThat(result) + .as("Join should use consistent versions with compatible schemas") + .hasSize(2) + .containsExactlyInAnyOrder(row(1, 100, 1, 100, null), row(2, 200, 2, 200, -1)); + } + + @TestTemplate + public void testIncrementalQueryWithExternalSchemaChangeDropColumn() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT, extra INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100, 10)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer drops column + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().deleteColumn("extra").commit(); + + // external writer adds (2, 200) + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 (QueryExecution #2) + Dataset df2 = spark.table(tableName); + + // join two DataFrames (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + // query should fail due to incompatible schema change + assertThatThrownBy(joined::collect) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("extra"); + } + + @TestTemplate + public void testIncrementalQueryWithExternalDropAndRecreateTable() { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer drops and recreates table with same schema + validationCatalog.dropTable(tableIdent, false /* keep files */); + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "salary", Types.IntegerType.get())); + validationCatalog.createTable(tableIdent, schema); + + // resolves to table version X + 1 (QueryExecution #2) + Dataset df2 = spark.table(tableName); + + // join two DataFrames (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + if (cachingCatalogEnabled()) { + assertThat(rowsToJava(joined.collectAsList())) + .as("Table cache should prevent from seeing external changes") + .hasSize(2) + .containsExactlyInAnyOrder(row(1, 100), row(1, 100)); + } else { + assertThatThrownBy(joined::collect) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Table ID has changed"); + } + } + + @TestTemplate + public void testIncrementalQueryWithExternalDropAndAddColumnSameNameSameType() + throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer drops and adds column with same name and type + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().deleteColumn("salary").commit(); + table.updateSchema().addColumn("salary", Types.IntegerType.get()).commit(); + + // external writer adds (2, 200) + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 (QueryExecution #2) + Dataset df2 = spark.table(tableName); + + // join two DataFrames (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + // query should resolve columns by name and show null for old data + List result = rowsToJava(joined.collectAsList()); + assertThat(result) + .as("Join should resolve columns by name") + .hasSize(2) + .containsExactlyInAnyOrder(row(1, null, 1, null), row(2, 200, 2, 200)); + } + + @TestTemplate + public void testIncrementalQueryWithExternalDropAndAddColumnSameNameDifferentType() + throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer drops and adds column with same name but different type + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().deleteColumn("salary").commit(); + table.updateSchema().addColumn("salary", Types.StringType.get()).commit(); + + // external writer adds (2, "BBB") + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", "BBB"); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 (QueryExecution #2) + Dataset df2 = spark.table(tableName); + + // join two DataFrames (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + // query should fail due to incompatible type change + assertThatThrownBy(joined::collect) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("salary"); + } + + @TestTemplate + public void testIncrementalQueryMultipleReferencesConsistentVersions() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100), (2, 200)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer adds (3, 300) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 3); + record.setField("salary", 300); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 (QueryExecution #2) + Dataset df2 = spark.table(tableName); + + // external writer adds (4, 400) + Record record2 = GenericRecord.create(table.schema()); + record2.setField("id", 4); + record2.setField("salary", 400); + DataFile dataFile2 = writeData(table, ImmutableList.of(record2)); + table.newAppend().appendFile(dataFile2).commit(); + + // resolves to table version X + 2 (QueryExecution #3) + Dataset df3 = spark.table(tableName); + + // create complex query with multiple references (QueryExecution #4) + Dataset joined1 = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + Dataset joined2 = joined1.join(df3, df1.col("id").equalTo(df3.col("id"))); + + // all references should be aligned to consistent version + List result = rowsToJava(joined2.collectAsList()); + assertThat(result) + .as("Multiple table references should use consistent versions") + .hasSize(4) + .containsExactlyInAnyOrder( + row(1, 100, 1, 100, 1, 100), + row(2, 200, 2, 200, 2, 200), + row(3, 300, 3, 300, 3, 300), + row(4, 400, 4, 400, 4, 400)); + } + + @TestTemplate + public void testIncrementalQueryWithFilterAndExternalWrite() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100), (10, 1000)", tableName); + + // resolves to table version X with filter (QueryExecution #1) + Dataset df1 = spark.table(tableName).filter("salary < 999"); + + // external writer adds (2, 200) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 with different filter (QueryExecution #2) + Dataset df2 = spark.table(tableName).filter("salary > 150"); + + // join two DataFrames (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + // both DataFrames should use consistent version and apply their respective filters + List result = rowsToJava(joined.collectAsList()); + assertThat(result) + .as("Join should apply filters on consistent table version") + .hasSize(1) + .containsExactly(row(2, 200, 2, 200)); + } + + @TestTemplate + public void testIncrementalQueryWithAggregationAndExternalWrite() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100), (2, 200)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer adds (3, 300) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 3); + record.setField("salary", 300); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // resolves to table version X + 1 with aggregation (QueryExecution #2) + Dataset df2 = spark.table(tableName).groupBy("id").count(); + + // join aggregated result with original (QueryExecution #3) + Dataset joined = df1.join(df2, df1.col("id").equalTo(df2.col("id"))); + + // both should use consistent version + List result = rowsToJava(joined.collectAsList()); + assertThat(result) + .as("Join with aggregation should use consistent table version") + .hasSize(3) + .containsExactlyInAnyOrder(row(1, 100, 1, 1L), row(2, 200, 2, 1L), row(3, 300, 3, 1L)); + } + + @TestTemplate + public void testIncrementalQuerySelfJoinWithExternalWrite() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100), (2, 200)", tableName); + + // resolves to table version X (QueryExecution #1) + Dataset df1 = spark.table(tableName); + + // external writer adds (3, 300) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 3); + record.setField("salary", 300); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // self-join using same DataFrame reference (QueryExecution #2) + Dataset joined = df1.as("t1").join(df1.as("t2"), df1.col("id").equalTo(df1.col("id"))); + + // self-join should use consistent version for both sides + List result = rowsToJava(joined.collectAsList()); + assertThat(result) + .as("Self-join should use consistent table version") + .hasSize(3) + .containsExactlyInAnyOrder(row(1, 100, 1, 100), row(2, 200, 2, 200), row(3, 300, 3, 300)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java new file mode 100644 index 000000000000..3f428963c412 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMerge.java @@ -0,0 +1,3026 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.RowLevelOperationMode.COPY_ON_WRITE; +import static org.apache.iceberg.RowLevelOperationMode.MERGE_ON_READ; +import static org.apache.iceberg.TableProperties.COMMIT_MAX_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_MIN_RETRY_WAIT_MS; +import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES; +import static org.apache.iceberg.TableProperties.MERGE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.MERGE_ISOLATION_LEVEL; +import static org.apache.iceberg.TableProperties.MERGE_MODE; +import static org.apache.iceberg.TableProperties.MERGE_MODE_DEFAULT; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.iceberg.TableProperties.SPLIT_SIZE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.lit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import static scala.collection.JavaConverters.mapAsScalaMapConverter; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.MoreExecutors; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.SparkRuntimeException; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.internal.SQLConf; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class TestMerge extends SparkRowLevelOperationsTestBase { + + @BeforeAll + public static void setupSparkConf() { + spark.conf().set("spark.sql.shuffle.partitions", "4"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS source"); + } + + private void setupMergeWithAllClauses() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-two\" }\n" + + "{ \"id\": 3, \"dep\": \"emp-id-3\" }\n" + + "{ \"id\": 4, \"dep\": \"emp-id-4\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 5, \"dep\": \"emp-id-5\" }"); + } + + private void verifyMergeWithAllClauses() { + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(1, "emp-id-1"), // updated (matched) + // row(2, "emp-id-two) // deleted (matched) + row(3, "invalid"), // updated (not matched by source) + // row(4, "emp-id-4) // deleted (not matched by source) + row(5, "emp-id-5")), // new + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithAllClauses() { + setupMergeWithAllClauses(); + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 2 THEN " + + " DELETE " + + "WHEN NOT MATCHED THEN " + + " INSERT * " + + "WHEN NOT MATCHED BY SOURCE AND t.id = 3 THEN " + + " UPDATE SET dep = 'invalid' " + + "WHEN NOT MATCHED BY SOURCE AND t.id = 4 THEN " + + " DELETE ", + commitTarget()); + verifyMergeWithAllClauses(); + } + + @TestTemplate + public void testMergeWithAllClausesUsingDataFrameAPI() { + setupMergeWithAllClauses(); + spark + .table("source") + .mergeInto(commitTarget(), col(commitTarget() + ".id").equalTo(col("source.id"))) + .whenMatched(col(commitTarget() + ".id").equalTo(lit(1))) + .updateAll() + .whenMatched(col(commitTarget() + ".id").equalTo(lit(2))) + .delete() + .whenNotMatched() + .insertAll() + .whenNotMatchedBySource(col(commitTarget() + ".id").equalTo(lit(3))) + .update( + scala.collection.immutable.Map.from( + mapAsScalaMapConverter(ImmutableMap.of("dep", lit("invalid"))).asScala())) + .whenNotMatchedBySource(col(commitTarget() + ".id").equalTo(lit(4))) + .delete() + .merge(); + verifyMergeWithAllClauses(); + } + + @TestTemplate + public void testMergeWithOneNotMatchedBySourceClause() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 3, \"dep\": \"emp-id-3\" }\n" + + "{ \"id\": 4, \"dep\": \"emp-id-4\" }"); + + createOrReplaceView("source", ImmutableList.of(1, 4), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN NOT MATCHED BY SOURCE THEN " + + " DELETE ", + commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(1, "emp-id-1"), // existing + // row(2, "emp-id-2) // deleted (not matched by source) + // row(3, "emp-id-3") // deleted (not matched by source) + row(4, "emp-id-4")), // existing + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeNotMatchedBySourceClausesPartitionedTable() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hr\" }\n" + + "{ \"id\": 3, \"dep\": \"support\" }"); + + createOrReplaceView("source", ImmutableList.of(1, 2), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value AND t.dep = 'hr' " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'support' " + + "WHEN NOT MATCHED BY SOURCE THEN " + + " UPDATE SET dep = 'invalid' ", + commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(1, "support"), // updated (matched) + row(2, "support"), // updated (matched) + row(3, "invalid")), // updated (not matched by source) + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithVectorizedReads() { + assumeThat(supportsVectorization()).isTrue(); + + createAndInitTable( + "id INT, value INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"value\": 100, \"dep\": \"hr\" }\n" + + "{ \"id\": 6, \"value\": 600, \"dep\": \"software\" }"); + + createOrReplaceView( + "source", + "id INT, value INT", + "{ \"id\": 2, \"value\": 201 }\n" + + "{ \"id\": 1, \"value\": 101 }\n" + + "{ \"id\": 6, \"value\": 601 }"); + + SparkPlan plan = + executeAndKeepPlan( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET t.value = s.value " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT (id, value, dep) VALUES (s.id, s.value, 'invalid')", + commitTarget()); + + assertAllBatchScansVectorized(plan); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, 101, "hr"), // updated + row(2, 201, "invalid")); // new + + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testCoalesceMerge() { + createAndInitTable("id INT, salary INT, dep STRING"); + + String[] records = new String[100]; + for (int index = 0; index < 100; index++) { + records[index] = String.format("{ \"id\": %d, \"salary\": 100, \"dep\": \"hr\" }", index); + } + append(tableName, records); + append(tableName, records); + append(tableName, records); + append(tableName, records); + + // set the open file cost large enough to produce a separate scan task per file + // disable any write distribution + Map tableProps = + ImmutableMap.of( + SPLIT_OPEN_FILE_COST, + String.valueOf(Integer.MAX_VALUE), + MERGE_DISTRIBUTION_MODE, + DistributionMode.NONE.modeName(), + TableProperties.DELETE_GRANULARITY, + DeleteGranularity.PARTITION.toString()); + sql("ALTER TABLE %s SET TBLPROPERTIES (%s)", tableName, tablePropsAsString(tableProps)); + + createBranchIfNeeded(); + + spark.range(0, 100).createOrReplaceTempView("source"); + + // enable AQE and set the advisory partition big enough to trigger combining + // set the number of shuffle partitions to 200 to distribute the work across reducers + // disable broadcast joins to make sure the join triggers a shuffle + // set the advisory partition size for shuffles small enough to ensure writes override it + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "200", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD().key(), + "-1", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "true", + SQLConf.COALESCE_PARTITIONS_ENABLED().key(), + "true", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), + "100", + SparkSQLProperties.ADVISORY_PARTITION_SIZE, + String.valueOf(256 * 1024 * 1024)), + () -> { + sql( + "MERGE INTO %s t USING source " + + "ON t.id = source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET salary = -1 ", + commitTarget()); + }); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + + if (mode(table) == COPY_ON_WRITE) { + // CoW MERGE would perform a join on `id` + // every task has data for each of 200 reducers + // AQE detects that all shuffle blocks are small and processes them in 1 task + // otherwise, there would be 200 tasks writing to the table + validateProperty(currentSnapshot, SnapshotSummary.ADDED_FILES_PROP, "1"); + } else if (mode(table) == MERGE_ON_READ && formatVersion >= 3) { + validateProperty(currentSnapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "4"); + validateProperty(currentSnapshot, SnapshotSummary.ADDED_DVS_PROP, "4"); + } else { + // MoR MERGE would perform a join on `id` + // every task has data for each of 200 reducers + // AQE detects that all shuffle blocks are small and processes them in 1 task + // otherwise, there would be 200 tasks writing to the table + validateProperty(currentSnapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "1"); + } + + assertThat(scalarSql("SELECT COUNT(*) FROM %s WHERE salary = -1", commitTarget())) + .as("Row count must match") + .isEqualTo(400L); + } + + @TestTemplate + public void testSkewMerge() { + createAndInitTable("id INT, salary INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + String[] records = new String[100]; + for (int index = 0; index < 100; index++) { + records[index] = String.format("{ \"id\": %d, \"salary\": 100, \"dep\": \"hr\" }", index); + } + append(tableName, records); + append(tableName, records); + append(tableName, records); + append(tableName, records); + + // set the open file cost large enough to produce a separate scan task per file + // use hash distribution to trigger a shuffle + Map tableProps = + ImmutableMap.of( + SPLIT_OPEN_FILE_COST, + String.valueOf(Integer.MAX_VALUE), + MERGE_DISTRIBUTION_MODE, + DistributionMode.HASH.modeName()); + sql("ALTER TABLE %s SET TBLPROPERTIES (%s)", tableName, tablePropsAsString(tableProps)); + + createBranchIfNeeded(); + + spark.range(0, 100).createOrReplaceTempView("source"); + + // enable AQE and set the advisory partition size small enough to trigger a split + // set the number of shuffle partitions to 2 to only have 2 reducers + // set the min coalesce partition size small enough to avoid coalescing + // set the advisory partition size for shuffles big enough to ensure writes override it + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "4", + SQLConf.COALESCE_PARTITIONS_MIN_PARTITION_SIZE().key(), + "100", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "true", + SQLConf.ADAPTIVE_OPTIMIZE_SKEWS_IN_REBALANCE_PARTITIONS_ENABLED().key(), + "true", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), + "256MB", + SparkSQLProperties.ADVISORY_PARTITION_SIZE, + "100"), + () -> { + SparkPlan plan = + executeAndKeepPlan( + "MERGE INTO %s t USING source " + + "ON t.id = source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET salary = -1 ", + commitTarget()); + assertThat(plan.toString()).contains("REBALANCE_PARTITIONS_BY_COL"); + }); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + + if (mode(table) == COPY_ON_WRITE) { + // CoW MERGE would perform a join on `id` and then cluster records by `dep` + // the first shuffle distributes records into 4 shuffle partitions so that rows can be merged + // after existing and new rows are merged, the data is clustered by `dep` + // each task with merged data contains records for the same table partition + // that means there are 4 shuffle blocks, all assigned to the same reducer + // AQE detects that all shuffle blocks are big and processes them in 4 independent tasks + // otherwise, there would be 1 task processing all 4 shuffle blocks + validateProperty(currentSnapshot, SnapshotSummary.ADDED_FILES_PROP, "4"); + } else { + // MoR MERGE would perform a join on `id` and then cluster data based on the partition + // all tasks belong to the same partition and therefore write only 1 shuffle block per task + // that means there are 4 shuffle blocks, all assigned to the same reducer + // AQE detects that all 4 shuffle blocks are big and processes them in 4 separate tasks + // otherwise, there would be 1 task processing 4 shuffle blocks + validateProperty(currentSnapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "4"); + } + + assertThat(scalarSql("SELECT COUNT(*) FROM %s WHERE salary = -1", commitTarget())) + .as("Row count must match") + .isEqualTo(400L); + } + + @TestTemplate + public void testMergeConditionSplitIntoTargetPredicateAndJoinCondition() { + createAndInitTable( + "id INT, salary INT, dep STRING, sub_dep STRING", + "PARTITIONED BY (dep, sub_dep)", + "{ \"id\": 1, \"salary\": 100, \"dep\": \"d1\", \"sub_dep\": \"sd1\" }\n" + + "{ \"id\": 6, \"salary\": 600, \"dep\": \"d6\", \"sub_dep\": \"sd6\" }"); + + createOrReplaceView( + "source", + "id INT, salary INT, dep STRING, sub_dep STRING", + "{ \"id\": 1, \"salary\": 101, \"dep\": \"d1\", \"sub_dep\": \"sd1\" }\n" + + "{ \"id\": 2, \"salary\": 200, \"dep\": \"d2\", \"sub_dep\": \"sd2\" }\n" + + "{ \"id\": 3, \"salary\": 300, \"dep\": \"d3\", \"sub_dep\": \"sd3\" }"); + + String query = + String.format( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id AND ((t.dep = 'd1' AND t.sub_dep IN ('sd1', 'sd3')) OR (t.dep = 'd6' AND t.sub_dep IN ('sd2', 'sd6'))) " + + "WHEN MATCHED THEN " + + " UPDATE SET salary = s.salary " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + + if (mode(table) == COPY_ON_WRITE) { + checkJoinAndFilterConditions( + query, + "Join [id], [id], FullOuter", + "((dep = 'd1' AND sub_dep IN ('sd1', 'sd3')) OR (dep = 'd6' AND sub_dep IN ('sd2', 'sd6')))"); + } else { + checkJoinAndFilterConditions( + query, + "Join [id], [id], RightOuter", + "((dep = 'd1' AND sub_dep IN ('sd1', 'sd3')) OR (dep = 'd6' AND sub_dep IN ('sd2', 'sd6')))"); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(1, 101, "d1", "sd1"), // updated + row(2, 200, "d2", "sd2"), // new + row(3, 300, "d3", "sd3"), // new + row(6, 600, "d6", "sd6")), // existing + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithStaticPredicatePushDown() { + createAndInitTable("id BIGINT, dep STRING"); + + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + // add a data file to the 'software' partition + append(tableName, "{ \"id\": 1, \"dep\": \"software\" }"); + createBranchIfNeeded(); + + // add a data file to the 'hr' partition + append(commitTarget(), "{ \"id\": 1, \"dep\": \"hr\" }"); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + String dataFilesCount = snapshot.summary().get(SnapshotSummary.TOTAL_DATA_FILES_PROP); + assertThat(dataFilesCount).as("Must have 2 files before MERGE").isEqualTo("2"); + + createOrReplaceView( + "source", "{ \"id\": 1, \"dep\": \"finance\" }\n" + "{ \"id\": 2, \"dep\": \"hardware\" }"); + + // remove the data file from the 'hr' partition to ensure it is not scanned + withUnavailableFiles( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(), + () -> { + // disable dynamic pruning and rely only on static predicate pushdown + withSQLConf( + ImmutableMap.of( + SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED().key(), "false", + SQLConf.RUNTIME_ROW_LEVEL_OPERATION_GROUP_FILTER_ENABLED().key(), "false"), + () -> { + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id AND t.dep IN ('software') AND source.id < 10 " + + "WHEN MATCHED AND source.id = 1 THEN " + + " UPDATE SET dep = source.dep " + + "WHEN NOT MATCHED THEN " + + " INSERT (dep, id) VALUES (source.dep, source.id)", + commitTarget()); + }); + }); + + ImmutableList expectedRows = + ImmutableList.of( + row(1L, "finance"), // updated + row(1L, "hr"), // kept + row(2L, "hardware") // new + ); + assertEquals( + "Output should match", + expectedRows, + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } + + @TestTemplate + public void testMergeIntoEmptyTargetInsertAllNonMatchingRows() { + assumeThat(branch).as("Custom branch does not exist for empty table").isNotEqualTo("test"); + createAndInitTable("id INT, dep STRING"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 3, \"dep\": \"emp-id-3\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + tableName); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // new + row(2, "emp-id-2"), // new + row(3, "emp-id-3") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeIntoEmptyTargetInsertOnlyMatchingRows() { + assumeThat(branch).as("Custom branch does not exist for empty table").isNotEqualTo("test"); + createAndInitTable("id INT, dep STRING"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 3, \"dep\": \"emp-id-3\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN NOT MATCHED AND (s.id >=2) THEN " + + " INSERT *", + tableName); + + ImmutableList expectedRows = + ImmutableList.of( + row(2, "emp-id-2"), // new + row(3, "emp-id-3") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithOnlyUpdateClause() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-six\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(6, "emp-id-six") // kept + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithOnlyUpdateNullUnmatchedValues() { + createAndInitTable( + "id INT, value INT", "{ \"id\": 1, \"value\": 2 }\n" + "{ \"id\": 6, \"value\": null }"); + + createOrReplaceView("source", "id INT NOT NULL, value INT", "{ \"id\": 1, \"value\": 100 }\n"); + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET id=123, value=456", + commitTarget()); + + sql("SELECT * FROM %s", commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(6, null), // kept + row(123, 456)); // updated + + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithOnlyUpdateSingleFieldNullUnmatchedValues() { + createAndInitTable( + "id INT, value INT", "{ \"id\": 1, \"value\": 2 }\n" + "{ \"id\": 6, \"value\": null }"); + + createOrReplaceView("source", "id INT NOT NULL, value INT", "{ \"id\": 1, \"value\": 100 }\n"); + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET id=123", + commitTarget()); + + sql("SELECT * FROM %s", commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(6, null), // kept + row(123, 2)); // updated + + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithOnlyDeleteNullUnmatchedValues() { + createAndInitTable( + "id INT, value INT", "{ \"id\": 1, \"value\": 2 }\n" + "{ \"id\": 6, \"value\": null }"); + + createOrReplaceView("source", "id INT NOT NULL, value INT", "{ \"id\": 1, \"value\": 100 }\n"); + sql( + "MERGE INTO %s t USING source s " + "ON t.id == s.id " + "WHEN MATCHED THEN " + "DELETE", + commitTarget()); + + sql("SELECT * FROM %s", commitTarget()); + + ImmutableList expectedRows = ImmutableList.of(row(6, null)); // kept + + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithOnlyUpdateClauseAndNullValues() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": null, \"dep\": \"emp-id-one\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-six\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id AND t.id < 3 " + + "WHEN MATCHED THEN " + + " UPDATE SET *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(null, "emp-id-one"), // kept + row(1, "emp-id-1"), // updated + row(6, "emp-id-six")); // kept + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithOnlyDeleteClause() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-one") // kept + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithMatchedAndNotMatchedClauses() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithAllCausesWithExplicitColumnSpecification() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET t.id = s.id, t.dep = s.dep " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT (t.id, t.dep) VALUES (s.id, s.dep)", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSourceCTE() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-two\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-3\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 5, \"dep\": \"emp-id-6\" }"); + + sql( + "WITH cte1 AS (SELECT id + 1 AS id, dep FROM source) " + + "MERGE INTO %s AS t USING cte1 AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 2 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 3 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(2, "emp-id-2"), // updated + row(3, "emp-id-3") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSourceFromSetOps() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + String derivedSource = + "SELECT * FROM source WHERE id = 2 " + + "UNION ALL " + + "SELECT * FROM source WHERE id = 1 OR id = 6"; + + sql( + "MERGE INTO %s AS t USING (%s) AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget(), derivedSource); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithOneMatchingBranchButMultipleSourceRowsForTargetRow() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"state\": \"on\" }\n" + + "{ \"id\": 1, \"state\": \"off\" }\n" + + "{ \"id\": 10, \"state\": \"on\" }"); + + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.id, 'unknown')", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one"), row(6, "emp-id-6")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testMergeWithMultipleUpdatesForTargetRowSmallTargetLargeSource() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + List sourceIds = Lists.newArrayList(); + for (int i = 0; i < 10_000; i++) { + sourceIds.add(i); + } + Dataset ds = spark.createDataset(sourceIds, Encoders.INT()); + ds.union(ds).createOrReplaceTempView("source"); + + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET id = 10 " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.value = 2 THEN " + + " INSERT (id, dep) VALUES (s.value, null)", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one"), row(6, "emp-id-6")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void + testMergeWithMultipleUpdatesForTargetRowSmallTargetLargeSourceEnabledHashShuffleJoin() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + List sourceIds = Lists.newArrayList(); + for (int i = 0; i < 10_000; i++) { + sourceIds.add(i); + } + Dataset ds = spark.createDataset(sourceIds, Encoders.INT()); + ds.union(ds).createOrReplaceTempView("source"); + + withSQLConf( + ImmutableMap.of(SQLConf.PREFER_SORTMERGEJOIN().key(), "false"), + () -> { + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET id = 10 " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.value = 2 THEN " + + " INSERT (id, dep) VALUES (s.value, null)", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + }); + + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one"), row(6, "emp-id-6")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testMergeWithMultipleUpdatesForTargetRowSmallTargetLargeSourceNoEqualityCondition() { + createAndInitTable("id INT, dep STRING", "{ \"id\": 1, \"dep\": \"emp-id-one\" }"); + + List sourceIds = Lists.newArrayList(); + for (int i = 0; i < 10_000; i++) { + sourceIds.add(i); + } + Dataset ds = spark.createDataset(sourceIds, Encoders.INT()); + ds.union(ds).createOrReplaceTempView("source"); + + withSQLConf( + ImmutableMap.of(SQLConf.PREFER_SORTMERGEJOIN().key(), "false"), + () -> { + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id > s.value " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET id = 10 " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.value = 2 THEN " + + " INSERT (id, dep) VALUES (s.value, null)", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + }); + + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testMergeWithMultipleUpdatesForTargetRowSmallTargetLargeSourceNoNotMatchedActions() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + List sourceIds = Lists.newArrayList(); + for (int i = 0; i < 10_000; i++) { + sourceIds.add(i); + } + Dataset ds = spark.createDataset(sourceIds, Encoders.INT()); + ds.union(ds).createOrReplaceTempView("source"); + + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET id = 10 " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one"), row(6, "emp-id-6")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void + testMergeWithMultipleUpdatesForTargetRowSmallTargetLargeSourceNoNotMatchedActionsNoEqualityCondition() { + createAndInitTable("id INT, dep STRING", "{ \"id\": 1, \"dep\": \"emp-id-one\" }"); + + List sourceIds = Lists.newArrayList(); + for (int i = 0; i < 10_000; i++) { + sourceIds.add(i); + } + Dataset ds = spark.createDataset(sourceIds, Encoders.INT()); + ds.union(ds).createOrReplaceTempView("source"); + + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id > s.value " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET id = 10 " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testMergeWithMultipleUpdatesForTargetRow() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one"), row(6, "emp-id-6")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testMergeWithUnconditionalDelete() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSingleConditionalDelete() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + String errorMsg = + "MERGE statement matched a single row from the target table with multiple rows of the source table."; + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining(errorMsg); + + assertEquals( + "Target should be unchanged", + ImmutableList.of(row(1, "emp-id-one"), row(6, "emp-id-6")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testMergeWithIdentityTransform() { + for (DistributionMode mode : DistributionMode.values()) { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD identity(dep)", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, WRITE_DISTRIBUTION_MODE, mode.modeName()); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + createBranchIfNeeded(); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + removeTables(); + } + } + + @TestTemplate + public void testMergeWithDaysTransform() { + for (DistributionMode mode : DistributionMode.values()) { + createAndInitTable("id INT, ts TIMESTAMP"); + sql("ALTER TABLE %s ADD PARTITION FIELD days(ts)", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, WRITE_DISTRIBUTION_MODE, mode.modeName()); + + append( + tableName, + "id INT, ts TIMESTAMP", + "{ \"id\": 1, \"ts\": \"2000-01-01 00:00:00\" }\n" + + "{ \"id\": 6, \"ts\": \"2000-01-06 00:00:00\" }"); + createBranchIfNeeded(); + + createOrReplaceView( + "source", + "id INT, ts TIMESTAMP", + "{ \"id\": 2, \"ts\": \"2001-01-02 00:00:00\" }\n" + + "{ \"id\": 1, \"ts\": \"2001-01-01 00:00:00\" }\n" + + "{ \"id\": 6, \"ts\": \"2001-01-06 00:00:00\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "2001-01-01 00:00:00"), // updated + row(2, "2001-01-02 00:00:00") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT id, CAST(ts AS STRING) FROM %s ORDER BY id", selectTarget())); + + removeTables(); + } + } + + @TestTemplate + public void testMergeWithBucketTransform() { + for (DistributionMode mode : DistributionMode.values()) { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(2, dep)", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, WRITE_DISTRIBUTION_MODE, mode.modeName()); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + createBranchIfNeeded(); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + removeTables(); + } + } + + @TestTemplate + public void testMergeWithTruncateTransform() { + for (DistributionMode mode : DistributionMode.values()) { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD truncate(dep, 2)", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, WRITE_DISTRIBUTION_MODE, mode.modeName()); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + createBranchIfNeeded(); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + removeTables(); + } + } + + @TestTemplate + public void testMergeIntoPartitionedAndOrderedTable() { + for (DistributionMode mode : DistributionMode.values()) { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + sql("ALTER TABLE %s WRITE ORDERED BY (id)", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, WRITE_DISTRIBUTION_MODE, mode.modeName()); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + createBranchIfNeeded(); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + removeTables(); + } + } + + @TestTemplate + public void testSelfMerge() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": 1, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + sql( + "MERGE INTO %s t USING %s s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET v = 'x' " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget(), commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "x"), // updated + row(2, "v2") // kept + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testSelfMergeWithCaching() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": 1, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + sql("CACHE TABLE %s", tableName); + + sql( + "MERGE INTO %s t USING %s s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET v = 'x' " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget(), commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "x"), // updated + row(2, "v2") // kept + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY id", commitTarget())); + } + + @TestTemplate + public void testMergeWithSourceAsSelfSubquery() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": 1, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + createOrReplaceView("source", Arrays.asList(1, null), Encoders.INT()); + + sql( + "MERGE INTO %s t USING (SELECT id AS value FROM %s r JOIN source ON r.id = source.value) s " + + "ON t.id == s.value " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET v = 'x' " + + "WHEN NOT MATCHED THEN " + + " INSERT (v, id) VALUES ('invalid', -1) ", + commitTarget(), commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "x"), // updated + row(2, "v2") // kept + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public synchronized void testMergeWithSerializableIsolation() throws InterruptedException { + // cannot run tests with concurrency for Hadoop tables without atomic renames + assumeThat(catalogName).isNotEqualToIgnoringCase("testhadoop"); + // if caching is off, the table is eagerly refreshed during runtime filtering + // this can cause a validation exception as concurrent changes would be visible + assumeThat(cachingCatalogEnabled()).isTrue(); + + createAndInitTable("id INT, dep STRING"); + createOrReplaceView("source", Collections.singletonList(1), Encoders.INT()); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, MERGE_ISOLATION_LEVEL, "serializable"); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + + ExecutorService executorService = + MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) Executors.newFixedThreadPool(2)); + + AtomicInteger barrier = new AtomicInteger(0); + AtomicBoolean shouldAppend = new AtomicBoolean(true); + + // merge thread + Future mergeFuture = + executorService.submit( + () -> { + for (int numOperations = 0; numOperations < Integer.MAX_VALUE; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> barrier.get() >= currentNumOperations * 2); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'x'", + commitTarget()); + + barrier.incrementAndGet(); + } + }); + + // append thread + Future appendFuture = + executorService.submit( + () -> { + // load the table via the validation catalog to use another table instance + Table table = validationCatalog.loadTable(tableIdent); + + GenericRecord record = GenericRecord.create(table.schema()); + record.set(0, 1); // id + record.set(1, "hr"); // dep + + for (int numOperations = 0; numOperations < Integer.MAX_VALUE; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> !shouldAppend.get() || barrier.get() >= currentNumOperations * 2); + + if (!shouldAppend.get()) { + return; + } + + for (int numAppends = 0; numAppends < 5; numAppends++) { + DataFile dataFile = writeDataFile(table, ImmutableList.of(record)); + AppendFiles appendFiles = table.newFastAppend().appendFile(dataFile); + if (branch != null) { + appendFiles.toBranch(branch); + } + appendFiles.commit(); + } + + barrier.incrementAndGet(); + } + }); + + try { + assertThatThrownBy(mergeFuture::get) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Found conflicting files that can contain"); + } finally { + shouldAppend.set(false); + appendFuture.cancel(true); + } + + executorService.shutdown(); + assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); + } + + @TestTemplate + public synchronized void testMergeWithSnapshotIsolation() + throws InterruptedException, ExecutionException { + // cannot run tests with concurrency for Hadoop tables without atomic renames + assumeThat(catalogName).isNotEqualToIgnoringCase("testhadoop"); + // if caching is off, the table is eagerly refreshed during runtime filtering + // this can cause a validation exception as concurrent changes would be visible + assumeThat(cachingCatalogEnabled()).isTrue(); + + createAndInitTable("id INT, dep STRING"); + createOrReplaceView("source", Collections.singletonList(1), Encoders.INT()); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s'='%s', '%s'='%s', '%s'='%s', '%s'='%s')", + tableName, + MERGE_ISOLATION_LEVEL, + "snapshot", + COMMIT_MIN_RETRY_WAIT_MS, + "10", + COMMIT_MAX_RETRY_WAIT_MS, + "1000", + COMMIT_NUM_RETRIES, + "7"); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + + ExecutorService executorService = + MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) Executors.newFixedThreadPool(2)); + + AtomicInteger barrier = new AtomicInteger(0); + AtomicBoolean shouldAppend = new AtomicBoolean(true); + + // merge thread + Future mergeFuture = + executorService.submit( + () -> { + for (int numOperations = 0; numOperations < 20; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> barrier.get() >= currentNumOperations * 2); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'x'", + commitTarget()); + + barrier.incrementAndGet(); + } + }); + + // append thread + Future appendFuture = + executorService.submit( + () -> { + // load the table via the validation catalog to use another table instance for inserts + Table table = validationCatalog.loadTable(tableIdent); + + GenericRecord record = GenericRecord.create(table.schema()); + record.set(0, 1); // id + record.set(1, "hr"); // dep + + for (int numOperations = 0; numOperations < 20; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> !shouldAppend.get() || barrier.get() >= currentNumOperations * 2); + + if (!shouldAppend.get()) { + return; + } + + for (int numAppends = 0; numAppends < 5; numAppends++) { + DataFile dataFile = writeDataFile(table, ImmutableList.of(record)); + AppendFiles appendFiles = table.newFastAppend().appendFile(dataFile); + if (branch != null) { + appendFiles.toBranch(branch); + } + + appendFiles.commit(); + } + + barrier.incrementAndGet(); + } + }); + + try { + mergeFuture.get(); + } finally { + shouldAppend.set(false); + appendFuture.cancel(true); + } + + executorService.shutdown(); + assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); + } + + @TestTemplate + public void testMergeWithExtraColumnsInSource() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": 1, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + createOrReplaceView( + "source", + "{ \"id\": 1, \"extra_col\": -1, \"v\": \"v1_1\" }\n" + + "{ \"id\": 3, \"extra_col\": -1, \"v\": \"v3\" }\n" + + "{ \"id\": 4, \"extra_col\": -1, \"v\": \"v4\" }"); + + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET v = source.v " + + "WHEN NOT MATCHED THEN " + + " INSERT (v, id) VALUES (source.v, source.id)", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "v1_1"), // new + row(2, "v2"), // kept + row(3, "v3"), // new + row(4, "v4") // new + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithNullsInTargetAndSource() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": null, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + createOrReplaceView( + "source", "{ \"id\": null, \"v\": \"v1_1\" }\n" + "{ \"id\": 4, \"v\": \"v4\" }"); + + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET v = source.v " + + "WHEN NOT MATCHED THEN " + + " INSERT (v, id) VALUES (source.v, source.id)", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(null, "v1"), // kept + row(null, "v1_1"), // new + row(2, "v2"), // kept + row(4, "v4") // new + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY v", selectTarget())); + } + + @TestTemplate + public void testMergeWithNullSafeEquals() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": null, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + createOrReplaceView( + "source", "{ \"id\": null, \"v\": \"v1_1\" }\n" + "{ \"id\": 4, \"v\": \"v4\" }"); + + sql( + "MERGE INTO %s t USING source " + + "ON t.id <=> source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET v = source.v " + + "WHEN NOT MATCHED THEN " + + " INSERT (v, id) VALUES (source.v, source.id)", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(null, "v1_1"), // updated + row(2, "v2"), // kept + row(4, "v4") // new + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY v", selectTarget())); + } + + @TestTemplate + public void testMergeWithNullCondition() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": null, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + createOrReplaceView( + "source", "{ \"id\": null, \"v\": \"v1_1\" }\n" + "{ \"id\": 2, \"v\": \"v2_2\" }"); + + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id AND NULL " + + "WHEN MATCHED THEN " + + " UPDATE SET v = source.v " + + "WHEN NOT MATCHED THEN " + + " INSERT (v, id) VALUES (source.v, source.id)", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(null, "v1"), // kept + row(null, "v1_1"), // new + row(2, "v2"), // kept + row(2, "v2_2") // new + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY v", selectTarget())); + } + + @TestTemplate + public void testMergeWithNullActionConditions() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": 1, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + createOrReplaceView( + "source", + "{ \"id\": 1, \"v\": \"v1_1\" }\n" + + "{ \"id\": 2, \"v\": \"v2_2\" }\n" + + "{ \"id\": 3, \"v\": \"v3_3\" }"); + + // all conditions are NULL and will never match any rows + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED AND source.id = 1 AND NULL THEN " + + " UPDATE SET v = source.v " + + "WHEN MATCHED AND source.v = 'v1_1' AND NULL THEN " + + " DELETE " + + "WHEN NOT MATCHED AND source.id = 3 AND NULL THEN " + + " INSERT (v, id) VALUES (source.v, source.id)", + commitTarget()); + + ImmutableList expectedRows1 = + ImmutableList.of( + row(1, "v1"), // kept + row(2, "v2") // kept + ); + assertEquals( + "Output should match", expectedRows1, sql("SELECT * FROM %s ORDER BY v", selectTarget())); + + // only the update and insert conditions are NULL + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED AND source.id = 1 AND NULL THEN " + + " UPDATE SET v = source.v " + + "WHEN MATCHED AND source.v = 'v1_1' THEN " + + " DELETE " + + "WHEN NOT MATCHED AND source.id = 3 AND NULL THEN " + + " INSERT (v, id) VALUES (source.v, source.id)", + commitTarget()); + + ImmutableList expectedRows2 = + ImmutableList.of( + row(2, "v2") // kept + ); + assertEquals( + "Output should match", expectedRows2, sql("SELECT * FROM %s ORDER BY v", selectTarget())); + } + + @TestTemplate + public void testMergeWithMultipleMatchingActions() { + createAndInitTable( + "id INT, v STRING", "{ \"id\": 1, \"v\": \"v1\" }\n" + "{ \"id\": 2, \"v\": \"v2\" }"); + + createOrReplaceView( + "source", "{ \"id\": 1, \"v\": \"v1_1\" }\n" + "{ \"id\": 2, \"v\": \"v2_2\" }"); + + // the order of match actions is important in this case + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED AND source.id = 1 THEN " + + " UPDATE SET v = source.v " + + "WHEN MATCHED AND source.v = 'v1_1' THEN " + + " DELETE " + + "WHEN NOT MATCHED THEN " + + " INSERT (v, id) VALUES (source.v, source.id)", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "v1_1"), // updated (also matches the delete cond but update is first) + row(2, "v2") // kept (matches neither the update nor the delete cond) + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY v", selectTarget())); + } + + @TestTemplate + public void testMergeWithMultipleRowGroupsParquet() throws NoSuchTableException { + assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET); + + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", + tableName, PARQUET_ROW_GROUP_SIZE_BYTES, 100); + sql("ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", tableName, SPLIT_SIZE, 100); + + createOrReplaceView("source", Collections.singletonList(1), Encoders.INT()); + + List ids = Lists.newArrayListWithCapacity(200); + for (int id = 1; id <= 200; id++) { + ids.add(id); + } + Dataset df = + spark + .createDataset(ids, Encoders.INT()) + .withColumnRenamed("value", "id") + .withColumn("dep", lit("hr")); + df.coalesce(1).writeTo(tableName).append(); + createBranchIfNeeded(); + + assertThat(spark.table(commitTarget()).count()).isEqualTo(200); + + // update a record from one of two row groups and copy over the second one + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'x'", + commitTarget()); + + assertThat(spark.table(commitTarget()).count()).isEqualTo(200); + } + + @TestTemplate + public void testMergeInsertOnly() { + createAndInitTable( + "id STRING, v STRING", + "{ \"id\": \"a\", \"v\": \"v1\" }\n" + "{ \"id\": \"b\", \"v\": \"v2\" }"); + createOrReplaceView( + "source", + "{ \"id\": \"a\", \"v\": \"v1_1\" }\n" + + "{ \"id\": \"a\", \"v\": \"v1_2\" }\n" + + "{ \"id\": \"c\", \"v\": \"v3\" }\n" + + "{ \"id\": \"d\", \"v\": \"v4_1\" }\n" + + "{ \"id\": \"d\", \"v\": \"v4_2\" }"); + + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row("a", "v1"), // kept + row("b", "v2"), // kept + row("c", "v3"), // new + row("d", "v4_1"), // new + row("d", "v4_2") // new + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeInsertOnlyWithCondition() { + createAndInitTable("id INTEGER, v INTEGER", "{ \"id\": 1, \"v\": 1 }"); + createOrReplaceView( + "source", + "{ \"id\": 1, \"v\": 11, \"is_new\": true }\n" + + "{ \"id\": 2, \"v\": 21, \"is_new\": true }\n" + + "{ \"id\": 2, \"v\": 22, \"is_new\": false }"); + + // validate assignments are reordered to match the table attrs + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.id " + + "WHEN NOT MATCHED AND is_new = TRUE THEN " + + " INSERT (v, id) VALUES (s.v + 100, s.id)", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, 1), // kept + row(2, 121) // new + ); + assertEquals( + "Output should match", expectedRows, sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeAlignsUpdateAndInsertActions() { + createAndInitTable("id INT, a INT, b STRING", "{ \"id\": 1, \"a\": 2, \"b\": \"str\" }"); + createOrReplaceView( + "source", + "{ \"id\": 1, \"c1\": -2, \"c2\": \"new_str_1\" }\n" + + "{ \"id\": 2, \"c1\": -20, \"c2\": \"new_str_2\" }"); + + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET b = c2, a = c1, t.id = source.id " + + "WHEN NOT MATCHED THEN " + + " INSERT (b, a, id) VALUES (c2, c1, id)", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, -2, "new_str_1"), row(2, -20, "new_str_2")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeMixedCaseAlignsUpdateAndInsertActions() { + createAndInitTable("id INT, a INT, b STRING", "{ \"id\": 1, \"a\": 2, \"b\": \"str\" }"); + createOrReplaceView( + "source", + "{ \"id\": 1, \"c1\": -2, \"c2\": \"new_str_1\" }\n" + + "{ \"id\": 2, \"c1\": -20, \"c2\": \"new_str_2\" }"); + + sql( + "MERGE INTO %s t USING source " + + "ON t.iD == source.Id " + + "WHEN MATCHED THEN " + + " UPDATE SET B = c2, A = c1, t.Id = source.ID " + + "WHEN NOT MATCHED THEN " + + " INSERT (b, A, iD) VALUES (c2, c1, id)", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, -2, "new_str_1"), row(2, -20, "new_str_2")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, -2, "new_str_1")), + sql("SELECT * FROM %s WHERE id = 1 ORDER BY id", selectTarget())); + assertEquals( + "Output should match", + ImmutableList.of(row(2, -20, "new_str_2")), + sql("SELECT * FROM %s WHERE b = 'new_str_2'ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeUpdatesNestedStructFields() { + createAndInitTable( + "id INT, s STRUCT,m:MAP>>", + "{ \"id\": 1, \"s\": { \"c1\": 2, \"c2\": { \"a\": [1,2], \"m\": { \"a\": \"b\"} } } } }"); + createOrReplaceView("source", "{ \"id\": 1, \"c1\": -2 }"); + + // update primitive, array, map columns inside a struct + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.c1 = source.c1, t.s.c2.a = array(-1, -2), t.s.c2.m = map('k', 'v')", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, row(-2, row(ImmutableList.of(-1, -2), ImmutableMap.of("k", "v"))))), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // set primitive, array, map columns to NULL (proper casts should be in place) + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.c1 = NULL, t.s.c2 = NULL", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, row(null, null))), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // update all fields in a struct + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s = named_struct('c1', 100, 'c2', named_struct('a', array(1), 'm', map('x', 'y')))", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, row(100, row(ImmutableList.of(1), ImmutableMap.of("x", "y"))))), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithInferredCasts() { + createAndInitTable("id INT, s STRING", "{ \"id\": 1, \"s\": \"value\" }"); + createOrReplaceView("source", "{ \"id\": 1, \"c1\": -2}"); + + // -2 in source should be casted to "-2" in target + sql( + "MERGE INTO %s t USING source " + + "ON t.id == source.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s = source.c1", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, "-2")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeModifiesNullStruct() { + createAndInitTable("id INT, s STRUCT", "{ \"id\": 1, \"s\": null }"); + createOrReplaceView("source", "{ \"id\": 1, \"n1\": -10 }"); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.n1 = s.n1", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, row(-10, null))), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testMergeRefreshesRelationCache() { + createAndInitTable("id INT, name STRING", "{ \"id\": 1, \"name\": \"n1\" }"); + createOrReplaceView("source", "{ \"id\": 1, \"name\": \"n2\" }"); + + Dataset query = spark.sql("SELECT name FROM " + commitTarget()); + query.createOrReplaceTempView("tmp"); + + spark.sql("CACHE TABLE tmp"); + + assertEquals( + "View should have correct data", ImmutableList.of(row("n1")), sql("SELECT * FROM tmp")); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.name = s.name", + commitTarget()); + + assertEquals( + "View should have correct data", ImmutableList.of(row("n2")), sql("SELECT * FROM tmp")); + + spark.sql("UNCACHE TABLE tmp"); + } + + @TestTemplate + public void testMergeWithMultipleNotMatchedActions() { + createAndInitTable("id INT, dep STRING", "{ \"id\": 0, \"dep\": \"emp-id-0\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 3, \"dep\": \"emp-id-3\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN NOT MATCHED AND s.id = 1 THEN " + + " INSERT (dep, id) VALUES (s.dep, -1)" + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(-1, "emp-id-1"), // new + row(0, "emp-id-0"), // kept + row(2, "emp-id-2"), // new + row(3, "emp-id-3") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithMultipleConditionalNotMatchedActions() { + createAndInitTable("id INT, dep STRING", "{ \"id\": 0, \"dep\": \"emp-id-0\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 3, \"dep\": \"emp-id-3\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN NOT MATCHED AND s.id = 1 THEN " + + " INSERT (dep, id) VALUES (s.dep, -1)" + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(-1, "emp-id-1"), // new + row(0, "emp-id-0"), // kept + row(2, "emp-id-2") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeResolvesColumnsByName() { + createAndInitTable( + "id INT, badge INT, dep STRING", + "{ \"id\": 1, \"badge\": 1000, \"dep\": \"emp-id-one\" }\n" + + "{ \"id\": 6, \"badge\": 6000, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "badge INT, id INT, dep STRING", + "{ \"badge\": 1001, \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"badge\": 6006, \"id\": 6, \"dep\": \"emp-id-6\" }\n" + + "{ \"badge\": 7007, \"id\": 7, \"dep\": \"emp-id-7\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT * ", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, 1001, "emp-id-1"), // updated + row(6, 6006, "emp-id-6"), // updated + row(7, 7007, "emp-id-7") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT id, badge, dep FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeShouldResolveWhenThereAreNoUnresolvedExpressionsOrColumns() { + // ensures that MERGE INTO will resolve into the correct action even if no columns + // or otherwise unresolved expressions exist in the query (testing SPARK-34962) + createAndInitTable("id INT, dep STRING"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 3, \"dep\": \"emp-id-3\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON 1 != 1 " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + tableName); + createBranchIfNeeded(); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // new + row(2, "emp-id-2"), // new + row(3, "emp-id-3") // new + ); + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithTableWithNonNullableColumn() { + createAndInitTable( + "id INT NOT NULL, dep STRING", + "{ \"id\": 1, \"dep\": \"emp-id-one\" }\n" + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + createOrReplaceView( + "source", + "id INT NOT NULL, dep STRING", + "{ \"id\": 2, \"dep\": \"emp-id-2\" }\n" + + "{ \"id\": 1, \"dep\": \"emp-id-1\" }\n" + + "{ \"id\": 6, \"dep\": \"emp-id-6\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.id = 1 THEN " + + " UPDATE SET * " + + "WHEN MATCHED AND t.id = 6 THEN " + + " DELETE " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "emp-id-1"), // updated + row(2, "emp-id-2")); // new + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithNonExistingColumns() { + createAndInitTable( + "id INT, c STRUCT>", + "{ \"id\": 1, \"c\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.invalid_col = s.c2", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `t`.`invalid_col` cannot be resolved"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n2.invalid_col = s.c2", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("No such struct field `invalid_col`"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n2.dn1 = s.c2 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, invalid_col) VALUES (s.c1, null)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `invalid_col` cannot be resolved"); + } + + @TestTemplate + public void testMergeWithInvalidColumnsInInsert() { + createAndInitTable( + "id INT, c STRUCT> NOT NULL", + "{ \"id\": 1, \"c\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n2.dn1 = s.c2 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, c.n2) VALUES (s.c1, null)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("INSERT assignment keys cannot be nested fields"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n2.dn1 = s.c2 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, id) VALUES (s.c1, null)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Multiple assignments for 'id'"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id) VALUES (s.c1)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("No assignment for 'c'"); + } + + @TestTemplate + public void testMergeWithMissingOptionalColumnsInInsert() { + createAndInitTable("id INT, value LONG", "{ \"id\": 1, \"value\": 100}"); + createOrReplaceView("source", "{ \"c1\": 2, \"c2\": 200 }"); + + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id) VALUES (s.c1)", + commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(1, 100L), // existing + row(2, null)), // new + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithInvalidUpdates() { + createAndInitTable( + "id INT, a ARRAY>, m MAP", + "{ \"id\": 1, \"a\": [ { \"c1\": 2, \"c2\": 3 } ], \"m\": { \"k\": \"v\"} }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.a.c1 = s.c2", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Updating nested fields is only supported for StructType"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.m.key = 'new_key'", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Updating nested fields is only supported for StructType"); + } + + @TestTemplate + public void testMergeWithConflictingUpdates() { + createAndInitTable( + "id INT, c STRUCT>", + "{ \"id\": 1, \"c\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.id = 1, t.c.n1 = 2, t.id = 2", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Multiple assignments for 'id"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n1 = 1, t.id = 2, t.c.n1 = 2", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Multiple assignments for 'c.n1'"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET c.n1 = 1, c = named_struct('n1', 1, 'n2', named_struct('dn1', 1, 'dn2', 2))", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Conflicting assignments for 'c'"); + } + + @TestTemplate + public void testMergeWithInvalidAssignmentsAnsi() { + createAndInitTable( + "id INT NOT NULL, s STRUCT> NOT NULL", + "{ \"id\": 1, \"s\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView( + "source", + "c1 INT, c2 STRUCT NOT NULL, c3 STRING NOT NULL, c4 STRUCT", + "{ \"c1\": 1, \"c2\": { \"n1\" : 1 }, \"c3\" : 'str', \"c4\": { \"dn3\": 1, \"dn1\": 2 } }"); + + withSQLConf( + ImmutableMap.of(SQLConf.STORE_ASSIGNMENT_POLICY().key(), "ansi"), + () -> { + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.id = cast(NULL as int)", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining( + "[NOT_NULL_ASSERT_VIOLATION] NULL value appeared in non-nullable field"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.n1 = NULL", + commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining( + "[NOT_NULL_ASSERT_VIOLATION] NULL value appeared in non-nullable field"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.n1 = s.c3", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast `s`.`n1` \"STRING\" to \"INT\"."); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.n2 = s.c4", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot find data for the output column `s`.`n2`.`dn2`"); + }); + } + + @TestTemplate + public void testMergeWithInvalidAssignmentsStrict() { + createAndInitTable( + "id INT NOT NULL, s STRUCT> NOT NULL", + "{ \"id\": 1, \"s\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView( + "source", + "c1 INT, c2 STRUCT NOT NULL, c3 STRING NOT NULL, c4 STRUCT", + "{ \"c1\": 1, \"c2\": { \"n1\" : 1 }, \"c3\" : 'str', \"c4\": { \"dn3\": 1, \"dn1\": 2 } }"); + + withSQLConf( + ImmutableMap.of(SQLConf.STORE_ASSIGNMENT_POLICY().key(), "strict"), + () -> { + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.id = NULL", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast `id` \"VOID\" to \"INT\""); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.n1 = NULL", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast `s`.`n1` \"VOID\" to \"INT\""); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.n1 = s.c3", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast `s`.`n1` \"STRING\" to \"INT\"."); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.s.n2 = s.c4", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot find data for the output column `s`.`n2`.`dn2`"); + }); + } + + @TestTemplate + public void testMergeWithNonDeterministicConditions() { + createAndInitTable( + "id INT, c STRUCT>", + "{ \"id\": 1, \"c\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 AND rand() > t.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n1 = -1", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported SEARCH condition. Non-deterministic expressions are not allowed"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED AND rand() > t.id THEN " + + " UPDATE SET t.c.n1 = -1", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported UPDATE condition. Non-deterministic expressions are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED AND rand() > t.id THEN " + + " DELETE", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported DELETE condition. Non-deterministic expressions are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN NOT MATCHED AND rand() > c1 THEN " + + " INSERT (id, c) VALUES (1, null)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported INSERT condition. Non-deterministic expressions are not allowed"); + } + + @TestTemplate + public void testMergeWithAggregateExpressions() { + createAndInitTable( + "id INT, c STRUCT>", + "{ \"id\": 1, \"c\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 AND max(t.id) == 1 " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n1 = -1", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported SEARCH condition. Aggregates are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED AND sum(t.id) < 1 THEN " + + " UPDATE SET t.c.n1 = -1", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported UPDATE condition. Aggregates are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED AND sum(t.id) THEN " + + " DELETE", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported DELETE condition. Aggregates are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN NOT MATCHED AND sum(c1) < 1 THEN " + + " INSERT (id, c) VALUES (1, null)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported INSERT condition. Aggregates are not allowed"); + } + + @TestTemplate + public void testMergeWithSubqueriesInConditions() { + createAndInitTable( + "id INT, c STRUCT>", + "{ \"id\": 1, \"c\": { \"n1\": 2, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 AND t.id < (SELECT max(c2) FROM source) " + + "WHEN MATCHED THEN " + + " UPDATE SET t.c.n1 = s.c2", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported SEARCH condition. Subqueries are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED AND t.id < (SELECT max(c2) FROM source) THEN " + + " UPDATE SET t.c.n1 = s.c2", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported UPDATE condition. Subqueries are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN MATCHED AND t.id NOT IN (SELECT c2 FROM source) THEN " + + " DELETE", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported DELETE condition. Subqueries are not allowed"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.c1 " + + "WHEN NOT MATCHED AND s.c1 IN (SELECT c2 FROM source) THEN " + + " INSERT (id, c) VALUES (1, null)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "MERGE operation contains unsupported INSERT condition. Subqueries are not allowed"); + } + + @TestTemplate + public void testMergeWithTargetColumnsInInsertConditions() { + createAndInitTable("id INT, c2 INT", "{ \"id\": 1, \"c2\": 2 }"); + createOrReplaceView("source", "{ \"id\": 1, \"value\": 11 }"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s " + + "ON t.id == s.id " + + "WHEN NOT MATCHED AND c2 = 1 THEN " + + " INSERT (id, c2) VALUES (s.id, null)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `c2` cannot be resolved"); + } + + @TestTemplate + public void testMergeWithNonIcebergTargetTableNotSupported() { + createOrReplaceView("target", "{ \"c1\": -100, \"c2\": -200 }"); + createOrReplaceView("source", "{ \"c1\": -100, \"c2\": -200 }"); + + assertThatThrownBy( + () -> + sql( + "MERGE INTO target t USING source s " + + "ON t.c1 == s.c1 " + + "WHEN MATCHED THEN " + + " UPDATE SET *")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Table `unknown` does not support MERGE INTO TABLE"); + } + + /** + * Tests a merge where both the source and target are evaluated to be partitioned by + * SingePartition at planning time but DynamicFileFilterExec will return an empty target. + */ + @TestTemplate + public void testMergeSinglePartitionPartitioning() { + // This table will only have a single file and a single partition + createAndInitTable("id INT", "{\"id\": -1}"); + + // Coalesce forces our source into a SinglePartition distribution + spark.range(0, 5).coalesce(1).createOrReplaceTempView("source"); + + sql( + "MERGE INTO %s t USING source s ON t.id = s.id " + + "WHEN MATCHED THEN UPDATE SET *" + + "WHEN NOT MATCHED THEN INSERT *", + commitTarget()); + + ImmutableList expectedRows = + ImmutableList.of(row(-1), row(0), row(1), row(2), row(3), row(4)); + + List result = sql("SELECT * FROM %s ORDER BY id", selectTarget()); + assertEquals("Should correctly add the non-matching rows", expectedRows, result); + } + + @TestTemplate + public void testMergeEmptyTable() { + assumeThat(branch).as("Custom branch does not exist for empty table").isNotEqualTo("test"); + // This table will only have a single file and a single partition + createAndInitTable("id INT", null); + + // Coalesce forces our source into a SinglePartition distribution + spark.range(0, 5).coalesce(1).createOrReplaceTempView("source"); + + sql( + "MERGE INTO %s t USING source s ON t.id = s.id " + + "WHEN MATCHED THEN UPDATE SET *" + + "WHEN NOT MATCHED THEN INSERT *", + commitTarget()); + + ImmutableList expectedRows = ImmutableList.of(row(0), row(1), row(2), row(3), row(4)); + + List result = sql("SELECT * FROM %s ORDER BY id", selectTarget()); + assertEquals("Should correctly add the non-matching rows", expectedRows, result); + } + + @TestTemplate + public void testMergeNonExistingBranch() { + assumeThat(branch).as("Test only applicable to custom branch").isEqualTo("test"); + createAndInitTable("id INT", null); + + // Coalesce forces our source into a SinglePartition distribution + spark.range(0, 5).coalesce(1).createOrReplaceTempView("source"); + assertThatThrownBy( + () -> + sql( + "MERGE INTO %s t USING source s ON t.id = s.id " + + "WHEN MATCHED THEN UPDATE SET *" + + "WHEN NOT MATCHED THEN INSERT *", + commitTarget())) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot use branch (does not exist): test"); + } + + @TestTemplate + public void testMergeToWapBranch() { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitTable("id INT", "{\"id\": -1}"); + ImmutableList originalRows = ImmutableList.of(row(-1)); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + spark.range(0, 5).coalesce(1).createOrReplaceTempView("source"); + ImmutableList expectedRows = + ImmutableList.of(row(-1), row(0), row(1), row(2), row(3), row(4)); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql( + "MERGE INTO %s t USING source s ON t.id = s.id " + + "WHEN MATCHED THEN UPDATE SET *" + + "WHEN NOT MATCHED THEN INSERT *", + tableName); + assertEquals( + "Should have expected rows when reading table", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", tableName)); + assertEquals( + "Should have expected rows when reading WAP branch", + expectedRows, + sql("SELECT * FROM %s.branch_wap ORDER BY id", tableName)); + assertEquals( + "Should not modify main branch", + originalRows, + sql("SELECT * FROM %s.branch_main ORDER BY id", tableName)); + }); + + spark.range(3, 6).coalesce(1).createOrReplaceTempView("source2"); + ImmutableList expectedRows2 = + ImmutableList.of(row(-1), row(0), row(1), row(2), row(5)); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql( + "MERGE INTO %s t USING source2 s ON t.id = s.id " + + "WHEN MATCHED THEN DELETE " + + "WHEN NOT MATCHED THEN INSERT *", + tableName); + assertEquals( + "Should have expected rows when reading table with multiple writes", + expectedRows2, + sql("SELECT * FROM %s ORDER BY id", tableName)); + assertEquals( + "Should have expected rows when reading WAP branch with multiple writes", + expectedRows2, + sql("SELECT * FROM %s.branch_wap ORDER BY id", tableName)); + assertEquals( + "Should not modify main branch with multiple writes", + originalRows, + sql("SELECT * FROM %s.branch_main ORDER BY id", tableName)); + }); + } + + @TestTemplate + public void testMergeToWapBranchWithTableBranchIdentifier() { + assumeThat(branch).as("Test must have branch name part in table identifier").isNotNull(); + + createAndInitTable("id INT", "{\"id\": -1}"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + spark.range(0, 5).coalesce(1).createOrReplaceTempView("source"); + ImmutableList expectedRows = + ImmutableList.of(row(-1), row(0), row(1), row(2), row(3), row(4)); + + // writing to explicit branch should succeed even with WAP branch set + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql( + "MERGE INTO %s t USING source s ON t.id = s.id " + + "WHEN MATCHED THEN UPDATE SET *" + + "WHEN NOT MATCHED THEN INSERT *", + commitTarget()); + + assertEquals( + "Should have expected rows in explicit branch", + expectedRows, + sql("SELECT * FROM %s ORDER BY id", commitTarget())); + }); + } + + private void checkJoinAndFilterConditions(String query, String join, String icebergFilters) { + // disable runtime filtering for easier validation + withSQLConf( + ImmutableMap.of( + SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED().key(), "false", + SQLConf.RUNTIME_ROW_LEVEL_OPERATION_GROUP_FILTER_ENABLED().key(), "false"), + () -> { + SparkPlan sparkPlan = executeAndKeepPlan(() -> sql(query)); + String planAsString = sparkPlan.toString().replaceAll("#(\\d+L?)", ""); + + assertThat(planAsString).as("Join should match").contains(join + "\n"); + + assertThat(planAsString) + .as("Pushed filters must match") + .contains(", filters=" + icebergFilters + ","); + }); + } + + private RowLevelOperationMode mode(Table table) { + String modeName = table.properties().getOrDefault(MERGE_MODE, MERGE_MODE_DEFAULT); + return RowLevelOperationMode.fromName(modeName); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeMetrics.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeMetrics.java new file mode 100644 index 000000000000..9250075fe6f7 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeMetrics.java @@ -0,0 +1,304 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.Encoders; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests for verifying that merge metrics from Spark's MergeSummary are persisted in Iceberg's + * snapshot summary. + * + *

    Note: The numTargetRowsCopied metric behaves differently between copy-on-write and + * merge-on-read modes: + * + *

      + *
    • Copy-on-write: Unchanged rows in modified files are rewritten, so numTargetRowsCopied > 0 + *
    • Merge-on-read: Uses delete files, so unchanged rows aren't copied, numTargetRowsCopied = 0 + *
    + */ +@ExtendWith(ParameterizedTestExtension.class) +public abstract class TestMergeMetrics extends SparkRowLevelOperationsTestBase { + + @BeforeAll + public static void setupSparkConf() { + spark.conf().set("spark.sql.shuffle.partitions", "4"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS source"); + } + + /** + * Returns the expected number of rows copied for the given number of unchanged rows. In + * copy-on-write mode, unchanged rows in modified files are rewritten. In merge-on-read mode, + * unchanged rows aren't copied. + */ + protected abstract long expectedRowsCopied(long unchangedRowsInModifiedFiles); + + @TestTemplate + public void testMergeMetricsWithMatchedUpdate() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"software\" }"); + + createOrReplaceView("source", ImmutableList.of(1, 3), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = 'updated'", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + Map summary = currentSnapshot.summary(); + + // One row matched and was updated, one row unchanged + // In CoW mode, the unchanged row is copied; in MoR mode, it's not + assertMergeMetric(summary, "spark.merge-into.num-target-rows-copied", expectedRowsCopied(1)); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-updated", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-deleted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-inserted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-updated", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-deleted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-updated", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-deleted", 0); + } + + @TestTemplate + public void testMergeMetricsWithMatchedDelete() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"software\" }\n" + + "{ \"id\": 3, \"dep\": \"finance\" }"); + + createOrReplaceView("source", ImmutableList.of(1, 2), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " DELETE", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + Map summary = currentSnapshot.summary(); + + // Two rows matched and were deleted, one row unchanged + // In CoW mode, the unchanged row is copied; in MoR mode, it's not + assertMergeMetric(summary, "spark.merge-into.num-target-rows-copied", expectedRowsCopied(1)); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-updated", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-deleted", 2); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-inserted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-updated", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-deleted", 2); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-updated", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-deleted", 0); + } + + @TestTemplate + public void testMergeMetricsWithAllClauses() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"software\" }\n" + + "{ \"id\": 3, \"dep\": \"finance\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr-updated\" }\n" // will update id=1 + + "{ \"id\": 2, \"dep\": \"software-delete\" }\n" // will delete id=2 + + "{ \"id\": 4, \"dep\": \"new-dept\" }"); // will insert id=4 + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND s.dep LIKE '%%delete%%' THEN " + + " DELETE " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = s.dep " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + Map summary = currentSnapshot.summary(); + + // 1 update, 1 delete, 1 insert, 1 unchanged row (id=3) + // In CoW mode, the unchanged row is copied; in MoR mode, it's not + assertMergeMetric(summary, "spark.merge-into.num-target-rows-copied", expectedRowsCopied(1)); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-updated", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-deleted", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-inserted", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-updated", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-deleted", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-updated", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-deleted", 0); + } + + @TestTemplate + public void testMergeMetricsWithNotMatchedBySourceUpdate() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"software\" }\n" + + "{ \"id\": 3, \"dep\": \"finance\" }"); + + createOrReplaceView("source", "id INT, dep STRING", "{ \"id\": 1, \"dep\": \"hr-updated\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = s.dep " + + "WHEN NOT MATCHED BY SOURCE THEN " + + " UPDATE SET dep = 'orphaned'", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + Map summary = currentSnapshot.summary(); + + // 1 matched update, 2 not matched by source updates, 0 unchanged rows + assertMergeMetric(summary, "spark.merge-into.num-target-rows-copied", expectedRowsCopied(0)); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-updated", 3); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-deleted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-inserted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-updated", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-deleted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-updated", 2); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-deleted", 0); + } + + @TestTemplate + public void testMergeMetricsWithNotMatchedBySourceDelete() { + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"software\" }\n" + + "{ \"id\": 3, \"dep\": \"finance\" }"); + + createOrReplaceView("source", "id INT, dep STRING", "{ \"id\": 1, \"dep\": \"hr-updated\" }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET dep = s.dep " + + "WHEN NOT MATCHED BY SOURCE THEN " + + " DELETE", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + Map summary = currentSnapshot.summary(); + + // 1 matched update, 2 not matched by source deletes, 0 unchanged rows + assertMergeMetric(summary, "spark.merge-into.num-target-rows-copied", expectedRowsCopied(0)); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-updated", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-deleted", 2); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-inserted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-updated", 1); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-deleted", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-updated", 0); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-not-matched-by-source-deleted", 2); + } + + @TestTemplate + public void testMergeMetricsWithMultipleUpdatesAndDeletes() { + createAndInitTable( + "id INT, dep STRING, salary INT", + "{ \"id\": 1, \"dep\": \"hr\", \"salary\": 100 }\n" + + "{ \"id\": 2, \"dep\": \"software\", \"salary\": 200 }\n" + + "{ \"id\": 3, \"dep\": \"finance\", \"salary\": 300 }\n" + + "{ \"id\": 4, \"dep\": \"marketing\", \"salary\": 400 }\n" + + "{ \"id\": 5, \"dep\": \"sales\", \"salary\": 500 }"); + + createOrReplaceView( + "source", + "id INT, dep STRING, salary INT", + "{ \"id\": 1, \"dep\": \"hr\", \"salary\": 150 }\n" + + "{ \"id\": 2, \"dep\": \"software\", \"salary\": 250 }\n" + + "{ \"id\": 3, \"dep\": \"finance\", \"salary\": 350 }\n" + + "{ \"id\": 6, \"dep\": \"new\", \"salary\": 600 }\n" + + "{ \"id\": 7, \"dep\": \"newer\", \"salary\": 700 }"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED AND t.salary < 200 THEN " + + " DELETE " + + "WHEN MATCHED THEN " + + " UPDATE SET salary = s.salary " + + "WHEN NOT MATCHED THEN " + + " INSERT * " + + "WHEN NOT MATCHED BY SOURCE AND t.salary > 400 THEN " + + " DELETE " + + "WHEN NOT MATCHED BY SOURCE THEN " + + " UPDATE SET dep = 'orphaned'", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + Map summary = currentSnapshot.summary(); + + // id=1: matched delete + // id=2,3: matched updates + // id=4: not matched by source update + // id=5: not matched by source delete + // id=6,7: inserts + // All 5 target rows are either updated or deleted, so 0 unchanged rows + assertMergeMetric(summary, "spark.merge-into.num-target-rows-copied", expectedRowsCopied(0)); + assertMergeMetric(summary, "spark.merge-into.num-target-rows-updated", 3); // id=2,3,4 + assertMergeMetric(summary, "spark.merge-into.num-target-rows-deleted", 2); // id=1 and id=5 + assertMergeMetric(summary, "spark.merge-into.num-target-rows-inserted", 2); // id=6,7 + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-updated", 2); // id=2,3 + assertMergeMetric(summary, "spark.merge-into.num-target-rows-matched-deleted", 1); // id=1 + assertMergeMetric( + summary, "spark.merge-into.num-target-rows-not-matched-by-source-updated", 1); // id=4 + assertMergeMetric( + summary, "spark.merge-into.num-target-rows-not-matched-by-source-deleted", 1); // id=5 + } + + private void assertMergeMetric(Map summary, String key, long expectedValue) { + assertThat(summary).as("Snapshot summary should contain merge metric: " + key).containsKey(key); + assertThat(summary.get(key)) + .as("Merge metric " + key + " should have expected value") + .isEqualTo(String.valueOf(expectedValue)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java new file mode 100644 index 000000000000..fa2012d7dce2 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadDelete.java @@ -0,0 +1,360 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.exceptions.CommitStateUnknownException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.iceberg.spark.source.TestSparkCatalog; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMergeOnReadDelete extends TestDelete { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + } + + @BeforeEach + public void clearTestSparkCatalogCache() { + TestSparkCatalog.clearTables(); + } + + @TestTemplate + public void testDeleteWithExecutorCacheLocality() throws NoSuchTableException { + createAndInitPartitionedTable(); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hr")); + append(tableName, new Employee(3, "hr"), new Employee(4, "hr")); + append(tableName, new Employee(1, "hardware"), new Employee(2, "hardware")); + append(tableName, new Employee(3, "hardware"), new Employee(4, "hardware")); + + createBranchIfNeeded(); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.EXECUTOR_CACHE_LOCALITY_ENABLED, "true"), + () -> { + sql("DELETE FROM %s WHERE id = 1", commitTarget()); + sql("DELETE FROM %s WHERE id = 3", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(2, "hr"), row(4, "hardware"), row(4, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC, dep ASC", selectTarget())); + }); + } + + @TestTemplate + public void testDeleteFileGranularity() throws NoSuchTableException { + assumeThat(formatVersion).isEqualTo(2); + checkDeleteFileGranularity(DeleteGranularity.FILE); + } + + @TestTemplate + public void testDeletePartitionGranularity() throws NoSuchTableException { + assumeThat(formatVersion).isEqualTo(2); + checkDeleteFileGranularity(DeleteGranularity.PARTITION); + } + + @TestTemplate + public void testPositionDeletesAreMaintainedDuringDelete() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id int, data string) USING iceberg PARTITIONED BY (id) TBLPROPERTIES" + + "('%s'='%s', '%s'='%s', '%s'='%s')", + tableName, + TableProperties.FORMAT_VERSION, + 2, + TableProperties.DELETE_MODE, + "merge-on-read", + TableProperties.DELETE_GRANULARITY, + "file"); + createBranchIfNeeded(); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(1, "b"), + new SimpleRecord(1, "c"), + new SimpleRecord(2, "d"), + new SimpleRecord(2, "e")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(commitTarget()) + .append(); + + sql("DELETE FROM %s WHERE id = 1 and data='a'", commitTarget()); + sql("DELETE FROM %s WHERE id = 2 and data='d'", commitTarget()); + sql("DELETE FROM %s WHERE id = 1 and data='c'", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "b"), row(2, "e")), + sql("SELECT * FROM %s ORDER BY id ASC", selectTarget())); + } + + @TestTemplate + public void testUnpartitionedPositionDeletesAreMaintainedDuringDelete() + throws NoSuchTableException { + sql( + "CREATE TABLE %s (id int, data string) USING iceberg TBLPROPERTIES" + + "('%s'='%s', '%s'='%s', '%s'='%s')", + tableName, + TableProperties.FORMAT_VERSION, + 2, + TableProperties.DELETE_MODE, + "merge-on-read", + TableProperties.DELETE_GRANULARITY, + "file"); + createBranchIfNeeded(); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(1, "b"), + new SimpleRecord(1, "c"), + new SimpleRecord(2, "d"), + new SimpleRecord(2, "e")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(commitTarget()) + .append(); + + sql("DELETE FROM %s WHERE id = 1 and data='a'", commitTarget()); + sql("DELETE FROM %s WHERE id = 2 and data='d'", commitTarget()); + sql("DELETE FROM %s WHERE id = 1 and data='c'", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot latest = SnapshotUtil.latestSnapshot(table, branch); + assertThat(SnapshotChanges.builderFor(table).snapshot(latest).build().removedDeleteFiles()) + .hasSize(1); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "b"), row(2, "e")), + sql("SELECT * FROM %s ORDER BY id ASC", selectTarget())); + } + + @TestTemplate + public void testDeleteWithDVAndHistoricalPositionDeletes() { + assumeThat(formatVersion).isEqualTo(2); + createTableWithDeleteGranularity( + "id INT, dep STRING", "PARTITIONED BY (dep)", DeleteGranularity.PARTITION); + createBranchIfNeeded(); + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hr\" }\n" + + "{ \"id\": 3, \"dep\": \"hr\" }"); + append( + commitTarget(), + "{ \"id\": 4, \"dep\": \"hr\" }\n" + + "{ \"id\": 5, \"dep\": \"hr\" }\n" + + "{ \"id\": 6, \"dep\": \"hr\" }"); + + // Produce partition scoped deletes for the two modified files + sql("DELETE FROM %s WHERE id = 1 or id = 4", commitTarget()); + + // Produce 1 file-scoped deletes for the second update + Map fileGranularityProps = + ImmutableMap.of(TableProperties.DELETE_GRANULARITY, DeleteGranularity.FILE.toString()); + sql( + "ALTER TABLE %s SET TBLPROPERTIES (%s)", + tableName, tablePropsAsString(fileGranularityProps)); + sql("DELETE FROM %s WHERE id = 5", commitTarget()); + + // Produce a DV which will contain 3 positions from the second data file + // 2 existing deleted positions from the earlier file-scoped and partition-scoped deletes + // and 1 new deleted position + Map updateFormatProperties = + ImmutableMap.of(TableProperties.FORMAT_VERSION, "3"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES (%s)", + tableName, tablePropsAsString(updateFormatProperties)); + sql("DELETE FROM %s where id = 6", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Set deleteFiles = + TestHelpers.deleteFiles(table, SnapshotUtil.latestSnapshot(table, branch)); + List dvs = + deleteFiles.stream().filter(ContentFileUtil::isDV).collect(Collectors.toList()); + assertThat(dvs).hasSize(1); + assertThat(dvs).allMatch(dv -> dv.recordCount() == 3); + assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); + } + + private void checkDeleteFileGranularity(DeleteGranularity deleteGranularity) + throws NoSuchTableException { + createTableWithDeleteGranularity( + "id INT, dep STRING", "PARTITIONED BY (dep)", deleteGranularity); + + append(tableName, new Employee(1, "hr"), new Employee(2, "hr")); + append(tableName, new Employee(3, "hr"), new Employee(4, "hr")); + append(tableName, new Employee(1, "hardware"), new Employee(2, "hardware")); + append(tableName, new Employee(3, "hardware"), new Employee(4, "hardware")); + + createBranchIfNeeded(); + + sql("DELETE FROM %s WHERE id = 1 OR id = 3", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).hasSize(5); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + String expectedDeleteFilesCount = deleteGranularity == DeleteGranularity.FILE ? "4" : "2"; + validateMergeOnRead(currentSnapshot, "2", expectedDeleteFilesCount, null); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(2, "hardware"), row(2, "hr"), row(4, "hardware"), row(4, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC, dep ASC", selectTarget())); + } + + @TestTemplate + public void testCommitUnknownException() { + createAndInitTable("id INT, dep STRING, category STRING"); + + // write unpartitioned files + append(tableName, "{ \"id\": 1, \"dep\": \"hr\", \"category\": \"c1\"}"); + createBranchIfNeeded(); + append( + commitTarget(), + "{ \"id\": 2, \"dep\": \"hr\", \"category\": \"c1\" }\n" + + "{ \"id\": 3, \"dep\": \"hr\", \"category\": \"c1\" }"); + + Table table = validationCatalog.loadTable(tableIdent); + + RowDelta newRowDelta = table.newRowDelta(); + if (branch != null) { + newRowDelta.toBranch(branch); + } + + RowDelta spyNewRowDelta = spy(newRowDelta); + doAnswer( + invocation -> { + newRowDelta.commit(); + throw new CommitStateUnknownException(new RuntimeException("Datacenter on Fire")); + }) + .when(spyNewRowDelta) + .commit(); + + Table spyTable = spy(table); + when(spyTable.newRowDelta()).thenReturn(spyNewRowDelta); + SparkTable sparkTable = + branch == null ? new SparkTable(spyTable) : SparkTable.create(spyTable, branch); + + ImmutableMap config = + ImmutableMap.of( + "type", "hive", + "default-namespace", "default"); + spark + .conf() + .set("spark.sql.catalog.dummy_catalog", "org.apache.iceberg.spark.source.TestSparkCatalog"); + config.forEach( + (key, value) -> spark.conf().set("spark.sql.catalog.dummy_catalog." + key, value)); + Identifier ident = Identifier.of(new String[] {"default"}, "table"); + TestSparkCatalog.setTable(ident, sparkTable); + + // Although an exception is thrown here, write and commit have succeeded + assertThatThrownBy(() -> sql("DELETE FROM %s WHERE id = 2", "dummy_catalog.default.table")) + .isInstanceOf(CommitStateUnknownException.class) + .hasMessageStartingWith("Datacenter on Fire"); + + // Manually refresh Spark table because it always pins snapshot + sparkTable = branch == null ? new SparkTable(spyTable) : SparkTable.create(spyTable, branch); + TestSparkCatalog.unsetTable(ident); + TestSparkCatalog.setTable(ident, sparkTable); + + // Since write and commit succeeded, the rows should be readable + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr", "c1"), row(3, "hr", "c1")), + sql("SELECT * FROM %s ORDER BY id", "dummy_catalog.default.table")); + } + + @TestTemplate + public void testAggregatePushDownInMergeOnReadDelete() { + createAndInitTable("id LONG, data INT"); + sql( + "INSERT INTO TABLE %s VALUES (1, 1111), (1, 2222), (2, 3333), (2, 4444), (3, 5555), (3, 6666) ", + tableName); + createBranchIfNeeded(); + + sql("DELETE FROM %s WHERE data = 1111", commitTarget()); + String select = "SELECT max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, selectTarget()); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("max(data)") + || explainString.contains("min(data)") + || explainString.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("min/max/count not pushed down for deleted") + .isFalse(); + + List actual = sql(select, selectTarget()); + List expected = Lists.newArrayList(); + expected.add(new Object[] {6666, 2222, 5L}); + assertEquals("min/max/count push down", expected, actual); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java new file mode 100644 index 000000000000..9a42b58e3434 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMerge.java @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.Encoders; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMergeOnReadMerge extends TestMerge { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + } + + @TestTemplate + public void testMergeDeleteFileGranularity() { + assumeThat(formatVersion).isEqualTo(2); + checkMergeDeleteGranularity(DeleteGranularity.FILE); + } + + @TestTemplate + public void testMergeDeletePartitionGranularity() { + assumeThat(formatVersion).isEqualTo(2); + checkMergeDeleteGranularity(DeleteGranularity.PARTITION); + } + + @TestTemplate + public void testMergeWithDVAndHistoricalPositionDeletes() { + assumeThat(formatVersion).isEqualTo(2); + createTableWithDeleteGranularity( + "id INT, dep STRING", "PARTITIONED BY (dep)", DeleteGranularity.PARTITION); + createBranchIfNeeded(); + createOrReplaceView( + "source", IntStream.rangeClosed(1, 9).boxed().collect(Collectors.toList()), Encoders.INT()); + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hr\" }\n" + + "{ \"id\": 3, \"dep\": \"hr\" }"); + append( + commitTarget(), + "{ \"id\": 4, \"dep\": \"hr\" }\n" + + "{ \"id\": 5, \"dep\": \"hr\" }\n" + + "{ \"id\": 6, \"dep\": \"hr\" }"); + + // Produce partition scoped deletes for the two modified files + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value and (id = 1 or id = 4) " + + "WHEN MATCHED THEN " + + " DELETE " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (-1, 'other')", + commitTarget()); + + // Produce 1 file-scoped deletes for the second update + Map fileGranularityProps = + ImmutableMap.of(TableProperties.DELETE_GRANULARITY, DeleteGranularity.FILE.toString()); + sql( + "ALTER TABLE %s SET TBLPROPERTIES (%s)", + tableName, tablePropsAsString(fileGranularityProps)); + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value and id = 5 " + + "WHEN MATCHED THEN " + + " UPDATE SET id = id + 2 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (-1, 'other')", + commitTarget()); + + Map updateFormatProperties = + ImmutableMap.of(TableProperties.FORMAT_VERSION, "3"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES (%s)", + tableName, tablePropsAsString(updateFormatProperties)); + + // Produce a DV which will contain 3 positions from the second data file + // 2 existing deleted positions from the earlier file-scoped and partition-scoped deletes + // and 1 new deleted position + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value and id = 6 " + + "WHEN MATCHED THEN " + + " UPDATE SET id = id + 1 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (-1, 'other')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Set deleteFiles = + TestHelpers.deleteFiles(table, SnapshotUtil.latestSnapshot(table, branch)); + List dvs = + deleteFiles.stream().filter(ContentFileUtil::isDV).collect(Collectors.toList()); + assertThat(dvs).hasSize(1); + assertThat(dvs).allMatch(dv -> dv.recordCount() == 3); + assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); + } + + @TestTemplate + public void testMergeOnReadMergeSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + createOrReplaceView("source", ImmutableList.of(1, 3), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET id = id + 10 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (s.value, 'hr')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + + private void checkMergeDeleteGranularity(DeleteGranularity deleteGranularity) { + createTableWithDeleteGranularity( + "id INT, dep STRING", "PARTITIONED BY (dep)", deleteGranularity); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 3, \"dep\": \"hr\" }\n" + "{ \"id\": 4, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 1, \"dep\": \"it\" }\n" + "{ \"id\": 2, \"dep\": \"it\" }"); + append(tableName, "{ \"id\": 3, \"dep\": \"it\" }\n" + "{ \"id\": 4, \"dep\": \"it\" }"); + + createBranchIfNeeded(); + + createOrReplaceView("source", ImmutableList.of(1, 3, 5), Encoders.INT()); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " DELETE " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (-1, 'other')", + commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).hasSize(5); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + String expectedDeleteFilesCount = deleteGranularity == DeleteGranularity.FILE ? "4" : "2"; + validateMergeOnRead(currentSnapshot, "3", expectedDeleteFilesCount, "1"); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "other"), row(2, "hr"), row(2, "it"), row(4, "hr"), row(4, "it")), + sql("SELECT * FROM %s ORDER BY id ASC, dep ASC", selectTarget())); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMergeMetrics.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMergeMetrics.java new file mode 100644 index 000000000000..d91c4e5d4a56 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadMergeMetrics.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMergeOnReadMergeMetrics extends TestMergeMetrics { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + } + + @Override + protected long expectedRowsCopied(long unchangedRowsInModifiedFiles) { + // In merge-on-read mode, unchanged rows are not copied (uses delete files instead) + return 0; + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java new file mode 100644 index 000000000000..d1c336d5ddeb --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadUpdate.java @@ -0,0 +1,257 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.SnapshotUtil; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMergeOnReadUpdate extends TestUpdate { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + } + + @TestTemplate + public void testUpdateFileGranularity() { + assumeThat(formatVersion).isEqualTo(2); + checkUpdateFileGranularity(DeleteGranularity.FILE); + } + + @TestTemplate + public void testUpdatePartitionGranularity() { + assumeThat(formatVersion).isEqualTo(2); + checkUpdateFileGranularity(DeleteGranularity.PARTITION); + } + + @TestTemplate + public void testPositionDeletesAreMaintainedDuringUpdate() { + assumeThat(formatVersion).isEqualTo(2); + // Range distribution will produce partition scoped deletes which will not be cleaned up + assumeThat(distributionMode).isNotEqualToIgnoringCase("range"); + + checkUpdateFileGranularity(DeleteGranularity.FILE); + sql("UPDATE %s SET id = id + 1 WHERE id = 4", commitTarget()); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + String expectedDeleteFilesCount = "2"; + validateMergeOnRead(currentSnapshot, "2", expectedDeleteFilesCount, "2"); + + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(0, "hr"), + row(2, "hr"), + row(2, "hr"), + row(5, "hr"), + row(0, "it"), + row(2, "it"), + row(2, "it"), + row(5, "it")), + sql("SELECT * FROM %s ORDER BY dep ASC, id ASC", selectTarget())); + } + + @TestTemplate + public void testUnpartitionedPositionDeletesAreMaintainedDuringUpdate() { + assumeThat(formatVersion).isEqualTo(2); + // Range distribution will produce partition scoped deletes which will not be cleaned up + assumeThat(distributionMode).isNotEqualToIgnoringCase("range"); + initTable("", DeleteGranularity.FILE); + + sql("UPDATE %s SET id = id - 1 WHERE id = 1 OR id = 3", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).hasSize(5); + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + String expectedDeleteFilesCount = "4"; + validateMergeOnRead(currentSnapshot, "1", expectedDeleteFilesCount, "1"); + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(0, "hr"), + row(2, "hr"), + row(2, "hr"), + row(4, "hr"), + row(0, "it"), + row(2, "it"), + row(2, "it"), + row(4, "it")), + sql("SELECT * FROM %s ORDER BY dep ASC, id ASC", selectTarget())); + + sql("UPDATE %s SET id = id + 1 WHERE id = 4", commitTarget()); + table.refresh(); + currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + expectedDeleteFilesCount = "2"; + + validateMergeOnRead(currentSnapshot, "1", expectedDeleteFilesCount, "1"); + assertThat( + SnapshotChanges.builderFor(table) + .snapshot(currentSnapshot) + .build() + .removedDeleteFiles()) + .hasSize(2); + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(0, "hr"), + row(2, "hr"), + row(2, "hr"), + row(5, "hr"), + row(0, "it"), + row(2, "it"), + row(2, "it"), + row(5, "it")), + sql("SELECT * FROM %s ORDER BY dep ASC, id ASC", selectTarget())); + } + + private void checkUpdateFileGranularity(DeleteGranularity deleteGranularity) { + initTable("PARTITIONED BY (dep)", deleteGranularity); + + sql("UPDATE %s SET id = id - 1 WHERE id = 1 OR id = 3", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).hasSize(5); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + String expectedDeleteFilesCount = deleteGranularity == DeleteGranularity.FILE ? "4" : "2"; + validateMergeOnRead(currentSnapshot, "2", expectedDeleteFilesCount, "2"); + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(0, "hr"), + row(2, "hr"), + row(2, "hr"), + row(4, "hr"), + row(0, "it"), + row(2, "it"), + row(2, "it"), + row(4, "it")), + sql("SELECT * FROM %s ORDER BY dep ASC, id ASC", selectTarget())); + } + + @TestTemplate + public void testUpdateWithDVAndHistoricalPositionDeletes() { + assumeThat(formatVersion).isEqualTo(2); + createTableWithDeleteGranularity( + "id INT, dep STRING", "PARTITIONED BY (dep)", DeleteGranularity.PARTITION); + createBranchIfNeeded(); + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hr\" }\n" + + "{ \"id\": 3, \"dep\": \"hr\" }"); + append( + commitTarget(), + "{ \"id\": 4, \"dep\": \"hr\" }\n" + + "{ \"id\": 5, \"dep\": \"hr\" }\n" + + "{ \"id\": 6, \"dep\": \"hr\" }"); + + // Produce partition scoped deletes for the two modified files + sql("UPDATE %s SET id = id - 1 WHERE id = 1 or id = 4", commitTarget()); + + // Produce 1 file-scoped deletes for the second update + Map fileGranularityProps = + ImmutableMap.of(TableProperties.DELETE_GRANULARITY, DeleteGranularity.FILE.toString()); + sql( + "ALTER TABLE %s SET TBLPROPERTIES (%s)", + tableName, tablePropsAsString(fileGranularityProps)); + sql("UPDATE %s SET id = id + 2 WHERE id = 5", commitTarget()); + + Map updateFormatProperties = + ImmutableMap.of(TableProperties.FORMAT_VERSION, "3"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES (%s)", + tableName, tablePropsAsString(updateFormatProperties)); + + // Produce a DV which will contain 3 positions from the second data file + // 2 existing deleted positions from the earlier file-scoped and partition-scoped deletes + // and 1 new deleted position + sql("UPDATE %s SET id = id + 1 where id = 6", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Set deleteFiles = + TestHelpers.deleteFiles(table, SnapshotUtil.latestSnapshot(table, branch)); + List dvs = + deleteFiles.stream().filter(ContentFileUtil::isDV).collect(Collectors.toList()); + assertThat(dvs).hasSize(1); + assertThat(dvs.get(0).recordCount()).isEqualTo(3); + assertThat(dvs).allMatch(dv -> FileFormat.fromFileName(dv.location()) == FileFormat.PUFFIN); + } + + @TestTemplate + public void testMergeOnReadUpdateSetsSortOrderIdOnNewDataFiles() { + createAndInitTable( + "id INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + + sql("ALTER TABLE %s WRITE ORDERED BY id", tableName); + + sql("UPDATE %s SET id = id + 10 WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + assertThat(snapshot.addedDataFiles(table.io())) + .extracting(DataFile::sortOrderId) + .as("All new data files should carry the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + + private void initTable(String partitionedBy, DeleteGranularity deleteGranularity) { + createTableWithDeleteGranularity("id INT, dep STRING", partitionedBy, deleteGranularity); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 3, \"dep\": \"hr\" }\n" + "{ \"id\": 4, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 1, \"dep\": \"it\" }\n" + "{ \"id\": 2, \"dep\": \"it\" }"); + append(tableName, "{ \"id\": 3, \"dep\": \"it\" }\n" + "{ \"id\": 4, \"dep\": \"it\" }"); + + createBranchIfNeeded(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadWithLineage.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadWithLineage.java new file mode 100644 index 000000000000..3f038276af63 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeOnReadWithLineage.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import java.util.Map; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; + +public class TestMergeOnReadWithLineage extends TestRowLevelOperationsWithLineage { + + @Override + protected Map extraTableProperties() { + return ImmutableMap.of( + TableProperties.MERGE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName(), + TableProperties.UPDATE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName(), + TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeSchemaEvolution.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeSchemaEvolution.java new file mode 100644 index 000000000000..782321b588a7 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMergeSchemaEvolution.java @@ -0,0 +1,312 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.spark.sql.functions.col; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMergeSchemaEvolution extends SparkRowLevelOperationsTestBase { + + @BeforeAll + public static void setupSparkConf() { + spark.conf().set("spark.sql.shuffle.partitions", "4"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS source"); + } + + @TestTemplate + public void testMergeWithSchemaEvolutionSourceHasMoreColumns() { + assumeThat(branch).as("Schema evolution does not work for branches currently").isNull(); + + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"software\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING, salary INT", + "{ \"id\": 1, \"dep\": \"hr\", \"salary\": 100 }\n" + + "{ \"id\": 3, \"dep\": \"finance\", \"salary\": 300 }"); + + sql( + "MERGE WITH SCHEMA EVOLUTION INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + // The new 'salary' column should be added to the target table + ImmutableList expectedRows = + ImmutableList.of( + row(1, "hr", 100), // updated with salary + row(2, "software", null), // kept, salary is null + row(3, "finance", 300)); // new row with salary + assertEquals( + "Should have expected rows with new column", + expectedRows, + sql("SELECT id, dep, salary FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSchemaEvolutionSourceHasFewerColumns() { + assumeThat(branch).as("Schema evolution does not work for branches currently").isNull(); + + createAndInitTable( + "id INT, dep STRING, salary INT", + "{ \"id\": 1, \"dep\": \"hr\", \"salary\": 100 }\n" + + "{ \"id\": 2, \"dep\": \"software\", \"salary\": 200 }"); + + createOrReplaceView( + "source", + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr-updated\" }\n" + "{ \"id\": 3, \"dep\": \"finance\" }"); + + sql( + "MERGE WITH SCHEMA EVOLUTION INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + // Rows should have null for missing salary column from source + ImmutableList expectedRows = + ImmutableList.of( + row(1, "hr-updated", 100), // updated, salary retains value + row(2, "software", 200), // kept + row(3, "finance", null)); // new row, salary is null + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT id, dep, salary FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSchemaEvolutionUsingDataFrameApi() { + assumeThat(branch).as("Schema evolution does not work for branches currently").isNull(); + + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"software\" }"); + + createOrReplaceView( + "source", + "id INT, dep STRING, salary INT", + "{ \"id\": 1, \"dep\": \"hr-updated\", \"salary\": 100 }\n" + + "{ \"id\": 3, \"dep\": \"finance\", \"salary\": 300 }"); + + spark + .table("source") + .mergeInto(commitTarget(), col(commitTarget() + ".id").equalTo(col("source.id"))) + .whenMatched() + .updateAll() + .whenNotMatched() + .insertAll() + .withSchemaEvolution() + .merge(); + + // The new 'salary' column should be added + ImmutableList expectedRows = + ImmutableList.of( + row(1, "hr-updated", 100), // updated + row(2, "software", null), // kept + row(3, "finance", 300)); // new + assertEquals( + "Should have expected rows with schema evolution via DataFrame API", + expectedRows, + sql("SELECT id, dep, salary FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSchemaEvolutionNestedStructSourceHasMoreFields() { + assumeThat(branch).as("Schema evolution does not work for branches currently").isNull(); + + createAndInitTable( + "id INT, s STRUCT", + "{ \"id\": 1, \"s\": { \"c1\": 10, \"c2\": \"a\" } }\n" + + "{ \"id\": 2, \"s\": { \"c1\": 20, \"c2\": \"b\" } }"); + + createOrReplaceView( + "source", + "id INT, s STRUCT", + "{ \"id\": 1, \"s\": { \"c1\": 100, \"c2\": \"aa\", \"c3\": 1000 } }\n" + + "{ \"id\": 3, \"s\": { \"c1\": 300, \"c2\": \"cc\", \"c3\": 3000 } }"); + + sql( + "MERGE WITH SCHEMA EVOLUTION INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + // The nested struct should have the new c3 field + ImmutableList expectedRows = + ImmutableList.of( + row(1, row(100, "aa", 1000)), // updated with nested field + row(2, row(20, "b", null)), // kept, c3 is null + row(3, row(300, "cc", 3000))); // new + assertEquals( + "Should have expected rows with nested struct evolution", + expectedRows, + sql("SELECT id, s FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSchemaEvolutionNestedStructSourceHasFewerFields() { + assumeThat(branch).as("Schema evolution does not work for branches currently").isNull(); + + createAndInitTable( + "id INT, s STRUCT", + "{ \"id\": 1, \"s\": { \"c1\": 100, \"c2\": \"aa\", \"c3\": 1000 } }\n" + + "{ \"id\": 2, \"s\": { \"c1\": 200, \"c2\": \"bb\", \"c3\": 2000 } }"); + + createOrReplaceView( + "source", + "id INT, s STRUCT", + "{ \"id\": 1, \"s\": { \"c1\": 10, \"c2\": \"a\" } }\n" + + "{ \"id\": 3, \"s\": { \"c1\": 30, \"c2\": \"c\" } }"); + + withSQLConf( + ImmutableMap.of("spark.sql.mergeNestedTypeCoercion.enabled", "true"), + () -> { + sql( + "MERGE WITH SCHEMA EVOLUTION INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + // Rows should have null for missing c3 nested field from source + ImmutableList expectedRows = + ImmutableList.of( + row(1, row(10, "a", 1000)), // updated, c3 is retained + row(2, row(200, "bb", 2000)), // kept + row(3, row(30, "c", null))); // new, c3 is null + assertEquals( + "Should have expected rows with nested struct evolution", + expectedRows, + sql("SELECT id, s FROM %s ORDER BY id", selectTarget())); + }); + } + + @TestTemplate + public void testMergeWithSchemaEvolutionTypeWidening() { + assumeThat(branch).as("Schema evolution does not work for branches currently").isNull(); + + // Target has INT column + createAndInitTable( + "id INT, value INT", "{ \"id\": 1, \"value\": 100 }\n" + "{ \"id\": 2, \"value\": 200 }"); + + // Source has LONG column - should widen INT to LONG + createOrReplaceView( + "source", + "id INT, value LONG", + "{ \"id\": 1, \"value\": 1000000000000 }\n" + "{ \"id\": 3, \"value\": 3000000000000 }"); + + sql( + "MERGE WITH SCHEMA EVOLUTION INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + // The 'value' column should be widened from INT to LONG + ImmutableList expectedRows = + ImmutableList.of( + row(1, 1000000000000L), // updated with long value + row(2, 200L), // kept, value promoted to long + row(3, 3000000000000L)); // new row with long value + assertEquals( + "Should have expected rows with type widening", + expectedRows, + sql("SELECT id, value FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testMergeWithSchemaEvolutionDisabledByTableProperty() { + assumeThat(branch).as("Schema evolution does not work for branches currently").isNull(); + + createAndInitTable( + "id INT, dep STRING", + "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"software\" }"); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'false')", + tableName, TableProperties.SPARK_WRITE_AUTO_SCHEMA_EVOLUTION); + + createOrReplaceView( + "source", + "id INT, dep STRING, salary INT", + "{ \"id\": 1, \"dep\": \"hr\", \"salary\": 100 }\n" + + "{ \"id\": 3, \"dep\": \"finance\", \"salary\": 300 }"); + + sql( + "MERGE WITH SCHEMA EVOLUTION INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET * " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + // Schema should NOT be evolved - 'salary' column should not be added + assertThat(sql("SELECT * FROM %s", selectTarget()).get(0).length) + .as("Table should still have only 2 columns (id, dep)") + .isEqualTo(2); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, "hr"), // updated without salary + row(2, "software"), // kept + row(3, "finance")); // new without salary + assertEquals( + "Should have expected rows without schema evolution", + expectedRows, + sql("SELECT id, dep FROM %s ORDER BY id", selectTarget())); + } + + @Override + protected Map extraTableProperties() { + return Map.of(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetaColumnProjectionWithStageScan.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetaColumnProjectionWithStageScan.java new file mode 100644 index 000000000000..56191b38ef14 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetaColumnProjectionWithStageScan.java @@ -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. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkTableCache; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMetaColumnProjectionWithStageScan extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties() + } + }; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + private void stageTask( + Table tab, String fileSetID, CloseableIterable tasks) { + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + taskSetManager.stageTasks(tab, fileSetID, Lists.newArrayList(tasks)); + } + + @TestTemplate + public void testReadStageTableMeta() throws Exception { + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg TBLPROPERTIES" + + "('format-version'='2', 'write.delete.mode'='merge-on-read')", + tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + table.refresh(); + + try (CloseableIterable tasks = table.newBatchScan().planFiles()) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, table); + stageTask(table, fileSetID, tasks); + Dataset scanDF2 = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, "0") + .load(fileSetID); + + assertThat(scanDF2.columns()).hasSize(2); + } + + try (CloseableIterable tasks = table.newBatchScan().planFiles()) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, table); + stageTask(table, fileSetID, tasks); + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, "0") + .load(fileSetID) + .select("*", "_pos"); + + List rows = scanDF.collectAsList(); + ImmutableList expectedRows = + ImmutableList.of(row(1L, "a", 0L), row(2L, "b", 1L), row(3L, "c", 2L), row(4L, "d", 3L)); + assertEquals("result should match", expectedRows, rowsToJava(rows)); + } + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java new file mode 100644 index 000000000000..6e608f8c43a7 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMetadataTables.java @@ -0,0 +1,995 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_REST; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.avro.generic.GenericData.Record; +import org.apache.commons.collections4.ListUtils; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.HistoryEntry; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.catalyst.util.DateTimeUtils; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMetadataTables extends ExtensionsTestBase { + @Parameter(index = 3) + private int formatVersion; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, formatVersion = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + 2 + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + 2 + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + 2 + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + 3 + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + 2 + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + 3 + } + }; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testUnpartitionedTable() throws Exception { + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg TBLPROPERTIES" + + "('format-version'='%s', 'write.delete.mode'='merge-on-read')", + tableName, formatVersion); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + sql("DELETE FROM %s WHERE id=1", tableName); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + List expectedDataManifests = TestHelpers.dataManifests(table); + List expectedDeleteManifests = TestHelpers.deleteManifests(table); + assertThat(expectedDataManifests).as("Should have 1 data manifest").hasSize(1); + assertThat(expectedDeleteManifests).as("Should have 1 delete manifest").hasSize(1); + + Schema entriesTableSchema = + TypeUtil.selectNot( + Spark3Util.loadIcebergTable(spark, tableName + ".entries").schema(), + Set.of(DataFile.FIRST_ROW_ID.fieldId())); + Schema filesTableSchema = Spark3Util.loadIcebergTable(spark, tableName + ".files").schema(); + + // check delete files table + Dataset actualDeleteFilesDs = spark.sql("SELECT * FROM " + tableName + ".delete_files"); + List actualDeleteFiles = TestHelpers.selectNonDerived(actualDeleteFilesDs).collectAsList(); + assertThat(actualDeleteFiles).as("Metadata table should return one delete file").hasSize(1); + + List expectedDeleteFiles = + expectedEntries( + table, FileContent.POSITION_DELETES, entriesTableSchema, expectedDeleteManifests, null); + assertThat(expectedDeleteFiles).as("Should be one delete file manifest entry").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDeleteFilesDs), + expectedDeleteFiles.get(0), + actualDeleteFiles.get(0)); + + // check data files table + Dataset actualDataFilesDs = spark.sql("SELECT * FROM " + tableName + ".data_files"); + List actualDataFiles = TestHelpers.selectNonDerived(actualDataFilesDs).collectAsList(); + assertThat(actualDataFiles).as("Metadata table should return one data file").hasSize(1); + + List expectedDataFiles = + expectedEntries(table, FileContent.DATA, entriesTableSchema, expectedDataManifests, null); + assertThat(expectedDataFiles).as("Should be one data file manifest entry").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDataFilesDs), + expectedDataFiles.get(0), + actualDataFiles.get(0)); + + // check all files table + Dataset actualFilesDs = + spark.sql("SELECT * FROM " + tableName + ".files ORDER BY content"); + List actualFiles = TestHelpers.selectNonDerived(actualFilesDs).collectAsList(); + + assertThat(actualFiles).as("Metadata table should return two files").hasSize(2); + + List expectedFiles = + Stream.concat(expectedDataFiles.stream(), expectedDeleteFiles.stream()) + .collect(Collectors.toList()); + assertThat(expectedFiles).as("Should have two files manifest entries").hasSize(2); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualFilesDs), expectedFiles.get(0), actualFiles.get(0)); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualFilesDs), expectedFiles.get(1), actualFiles.get(1)); + } + + @TestTemplate + public void testPositionDeletesTable() throws Exception { + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg TBLPROPERTIES" + + "('format-version'='%s', 'write.delete.mode'='merge-on-read')", + tableName, formatVersion); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + sql("DELETE FROM %s WHERE id=1 OR id=3", tableName); + + // check delete files table + assertThat(sql("SELECT * FROM %s.delete_files", tableName)).hasSize(1); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + DataFile dataFile = Iterables.getOnlyElement(TestHelpers.dataFiles(table)); + DeleteFile deleteFile = Iterables.getOnlyElement(TestHelpers.deleteFiles(table)); + + List expectedRows; + if (formatVersion >= 3) { + expectedRows = + ImmutableList.of( + row( + dataFile.location(), + 0L, + null, + dataFile.specId(), + deleteFile.location(), + deleteFile.contentOffset(), + deleteFile.contentSizeInBytes()), + row( + dataFile.location(), + 2L, + null, + dataFile.specId(), + deleteFile.location(), + deleteFile.contentOffset(), + deleteFile.contentSizeInBytes())); + } else { + expectedRows = + ImmutableList.of( + row(dataFile.location(), 0L, null, dataFile.specId(), deleteFile.location()), + row(dataFile.location(), 2L, null, dataFile.specId(), deleteFile.location())); + } + + // check position_deletes table + assertThat(sql("SELECT * FROM %s.position_deletes", tableName)) + .hasSize(2) + .containsExactlyElementsOf(expectedRows); + } + + @TestTemplate + public void testPartitionedTable() throws Exception { + sql( + "CREATE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES" + + "('format-version'='%s', 'write.delete.mode'='merge-on-read')", + tableName, formatVersion); + + List recordsA = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "a")); + spark + .createDataset(recordsA, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + List recordsB = + Lists.newArrayList(new SimpleRecord(1, "b"), new SimpleRecord(2, "b")); + spark + .createDataset(recordsB, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + sql("DELETE FROM %s WHERE id=1 AND data='a'", tableName); + sql("DELETE FROM %s WHERE id=1 AND data='b'", tableName); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + Schema entriesTableSchema = + TypeUtil.selectNot( + Spark3Util.loadIcebergTable(spark, tableName + ".entries").schema(), + Set.of(DataFile.FIRST_ROW_ID.fieldId())); + + List expectedDataManifests = TestHelpers.dataManifests(table); + List expectedDeleteManifests = TestHelpers.deleteManifests(table); + assertThat(expectedDataManifests).as("Should have 2 data manifest").hasSize(2); + assertThat(expectedDeleteManifests).as("Should have 2 delete manifest").hasSize(2); + + Schema filesTableSchema = + Spark3Util.loadIcebergTable(spark, tableName + ".delete_files").schema(); + + // Check delete files table + List expectedDeleteFiles = + expectedEntries( + table, FileContent.POSITION_DELETES, entriesTableSchema, expectedDeleteManifests, "a"); + assertThat(expectedDeleteFiles).as("Should have one delete file manifest entry").hasSize(1); + + Dataset actualDeleteFilesDs = + spark.sql("SELECT * FROM " + tableName + ".delete_files " + "WHERE partition.data='a'"); + List actualDeleteFiles = TestHelpers.selectNonDerived(actualDeleteFilesDs).collectAsList(); + + assertThat(actualDeleteFiles).as("Metadata table should return one delete file").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDeleteFilesDs), + expectedDeleteFiles.get(0), + actualDeleteFiles.get(0)); + + // Check data files table + List expectedDataFiles = + expectedEntries(table, FileContent.DATA, entriesTableSchema, expectedDataManifests, "a"); + assertThat(expectedDataFiles).as("Should have one data file manifest entry").hasSize(1); + + Dataset actualDataFilesDs = + spark.sql("SELECT * FROM " + tableName + ".data_files " + "WHERE partition.data='a'"); + + List actualDataFiles = TestHelpers.selectNonDerived(actualDataFilesDs).collectAsList(); + assertThat(actualDataFiles).as("Metadata table should return one data file").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDataFilesDs), + expectedDataFiles.get(0), + actualDataFiles.get(0)); + + List actualPartitionsWithProjection = + spark.sql("SELECT file_count FROM " + tableName + ".partitions ").collectAsList(); + assertThat(actualPartitionsWithProjection) + .as("Metadata table should return two partitions record") + .hasSize(2) + .containsExactly(RowFactory.create(1), RowFactory.create(1)); + + // Check files table + List expectedFiles = + Stream.concat(expectedDataFiles.stream(), expectedDeleteFiles.stream()) + .collect(Collectors.toList()); + assertThat(expectedFiles).as("Should have two file manifest entries").hasSize(2); + + Dataset actualFilesDs = + spark.sql( + "SELECT * FROM " + tableName + ".files " + "WHERE partition.data='a' ORDER BY content"); + List actualFiles = TestHelpers.selectNonDerived(actualFilesDs).collectAsList(); + assertThat(actualFiles).as("Metadata table should return two files").hasSize(2); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualFilesDs), expectedFiles.get(0), actualFiles.get(0)); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualFilesDs), expectedFiles.get(1), actualFiles.get(1)); + } + + @TestTemplate + public void testAllFilesUnpartitioned() throws Exception { + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg TBLPROPERTIES" + + "('format-version'='%s', 'write.delete.mode'='merge-on-read')", + tableName, formatVersion); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + // Create delete file + sql("DELETE FROM %s WHERE id=1", tableName); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + List expectedDataManifests = TestHelpers.dataManifests(table); + assertThat(expectedDataManifests).as("Should have 1 data manifest").hasSize(1); + List expectedDeleteManifests = TestHelpers.deleteManifests(table); + assertThat(expectedDeleteManifests).as("Should have 1 delete manifest").hasSize(1); + + // Clear table to test whether 'all_files' can read past files + List results = sql("DELETE FROM %s", tableName); + assertThat(results).as("Table should be cleared").isEmpty(); + + Schema entriesTableSchema = + TypeUtil.selectNot( + Spark3Util.loadIcebergTable(spark, tableName + ".entries").schema(), + Set.of(DataFile.FIRST_ROW_ID.fieldId())); + Schema filesTableSchema = + Spark3Util.loadIcebergTable(spark, tableName + ".all_data_files").schema(); + + // Check all data files table + Dataset actualDataFilesDs = spark.sql("SELECT * FROM " + tableName + ".all_data_files"); + List actualDataFiles = TestHelpers.selectNonDerived(actualDataFilesDs).collectAsList(); + + List expectedDataFiles = + expectedEntries(table, FileContent.DATA, entriesTableSchema, expectedDataManifests, null); + assertThat(expectedDataFiles).as("Should be one data file manifest entry").hasSize(1); + assertThat(actualDataFiles).as("Metadata table should return one data file").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDataFilesDs), + expectedDataFiles.get(0), + actualDataFiles.get(0)); + + // Check all delete files table + Dataset actualDeleteFilesDs = + spark.sql("SELECT * FROM " + tableName + ".all_delete_files"); + List actualDeleteFiles = TestHelpers.selectNonDerived(actualDeleteFilesDs).collectAsList(); + List expectedDeleteFiles = + expectedEntries( + table, FileContent.POSITION_DELETES, entriesTableSchema, expectedDeleteManifests, null); + assertThat(expectedDeleteFiles).as("Should be one delete file manifest entry").hasSize(1); + assertThat(actualDeleteFiles).as("Metadata table should return one delete file").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDeleteFilesDs), + expectedDeleteFiles.get(0), + actualDeleteFiles.get(0)); + + // Check all files table + Dataset actualFilesDs = + spark.sql("SELECT * FROM " + tableName + ".all_files ORDER BY content"); + List actualFiles = TestHelpers.selectNonDerived(actualFilesDs).collectAsList(); + List expectedFiles = ListUtils.union(expectedDataFiles, expectedDeleteFiles); + expectedFiles.sort(Comparator.comparing(r -> ((Integer) r.get("content")))); + assertThat(actualFiles).as("Metadata table should return two files").hasSize(2); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualFilesDs), expectedFiles, actualFiles); + } + + @TestTemplate + public void testAllFilesPartitioned() throws Exception { + // Create table and insert data + sql( + "CREATE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES" + + "('format-version'='%s', 'write.delete.mode'='merge-on-read')", + tableName, formatVersion); + + List recordsA = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "a")); + spark + .createDataset(recordsA, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + List recordsB = + Lists.newArrayList(new SimpleRecord(1, "b"), new SimpleRecord(2, "b")); + spark + .createDataset(recordsB, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + // Create delete file + sql("DELETE FROM %s WHERE id=1", tableName); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + List expectedDataManifests = TestHelpers.dataManifests(table); + assertThat(expectedDataManifests).as("Should have 2 data manifests").hasSize(2); + List expectedDeleteManifests = TestHelpers.deleteManifests(table); + assertThat(expectedDeleteManifests).as("Should have 1 delete manifest").hasSize(1); + + // Clear table to test whether 'all_files' can read past files + List results = sql("DELETE FROM %s", tableName); + assertThat(results).as("Table should be cleared").isEmpty(); + + Schema entriesTableSchema = + TypeUtil.selectNot( + Spark3Util.loadIcebergTable(spark, tableName + ".entries").schema(), + Set.of(DataFile.FIRST_ROW_ID.fieldId())); + Schema filesTableSchema = + Spark3Util.loadIcebergTable(spark, tableName + ".all_data_files").schema(); + + // Check all data files table + Dataset actualDataFilesDs = + spark.sql("SELECT * FROM " + tableName + ".all_data_files " + "WHERE partition.data='a'"); + List actualDataFiles = TestHelpers.selectNonDerived(actualDataFilesDs).collectAsList(); + List expectedDataFiles = + expectedEntries(table, FileContent.DATA, entriesTableSchema, expectedDataManifests, "a"); + assertThat(expectedDataFiles).as("Should be one data file manifest entry").hasSize(1); + assertThat(actualDataFiles).as("Metadata table should return one data file").hasSize(1); + TestHelpers.assertEqualsSafe( + SparkSchemaUtil.convert(TestHelpers.selectNonDerived(actualDataFilesDs).schema()) + .asStruct(), + expectedDataFiles.get(0), + actualDataFiles.get(0)); + + // Check all delete files table + Dataset actualDeleteFilesDs = + spark.sql("SELECT * FROM " + tableName + ".all_delete_files " + "WHERE partition.data='a'"); + List actualDeleteFiles = TestHelpers.selectNonDerived(actualDeleteFilesDs).collectAsList(); + + List expectedDeleteFiles = + expectedEntries( + table, FileContent.POSITION_DELETES, entriesTableSchema, expectedDeleteManifests, "a"); + assertThat(expectedDeleteFiles).as("Should be one data file manifest entry").hasSize(1); + assertThat(actualDeleteFiles).as("Metadata table should return one data file").hasSize(1); + + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDeleteFilesDs), + expectedDeleteFiles.get(0), + actualDeleteFiles.get(0)); + + // Check all files table + Dataset actualFilesDs = + spark.sql( + "SELECT * FROM " + + tableName + + ".all_files WHERE partition.data='a' " + + "ORDER BY content"); + List actualFiles = TestHelpers.selectNonDerived(actualFilesDs).collectAsList(); + + List expectedFiles = ListUtils.union(expectedDataFiles, expectedDeleteFiles); + expectedFiles.sort(Comparator.comparing(r -> ((Integer) r.get("content")))); + assertThat(actualFiles).as("Metadata table should return two files").hasSize(2); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualDataFilesDs), expectedFiles, actualFiles); + } + + @TestTemplate + public void testMetadataLogEntries() throws Exception { + // Create table and insert data + sql( + "CREATE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES " + + "('format-version'='%s')", + tableName, formatVersion); + + List recordsA = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "a")); + spark.createDataset(recordsA, Encoders.bean(SimpleRecord.class)).writeTo(tableName).append(); + + List recordsB = + Lists.newArrayList(new SimpleRecord(1, "b"), new SimpleRecord(2, "b")); + spark.createDataset(recordsB, Encoders.bean(SimpleRecord.class)).writeTo(tableName).append(); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + Long currentSnapshotId = table.currentSnapshot().snapshotId(); + TableMetadata tableMetadata = ((HasTableOperations) table).operations().current(); + Snapshot currentSnapshot = tableMetadata.currentSnapshot(); + Snapshot parentSnapshot = table.snapshot(currentSnapshot.parentId()); + List metadataLogEntries = + Lists.newArrayList(tableMetadata.previousFiles()); + + // Check metadataLog table + List metadataLogs = sql("SELECT * FROM %s.metadata_log_entries", tableName); + assertEquals( + "MetadataLogEntriesTable result should match the metadataLog entries", + ImmutableList.of( + row( + DateTimeUtils.toJavaTimestamp(metadataLogEntries.get(0).timestampMillis() * 1000), + metadataLogEntries.get(0).file(), + null, + null, + null), + row( + DateTimeUtils.toJavaTimestamp(metadataLogEntries.get(1).timestampMillis() * 1000), + metadataLogEntries.get(1).file(), + parentSnapshot.snapshotId(), + parentSnapshot.schemaId(), + parentSnapshot.sequenceNumber()), + row( + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), + tableMetadata.metadataFileLocation(), + currentSnapshot.snapshotId(), + currentSnapshot.schemaId(), + currentSnapshot.sequenceNumber())), + metadataLogs); + + // test filtering + List metadataLogWithFilters = + sql( + "SELECT * FROM %s.metadata_log_entries WHERE latest_snapshot_id = %s", + tableName, currentSnapshotId); + assertThat(metadataLogWithFilters) + .as("metadataLogEntries table should return 1 row") + .hasSize(1); + assertEquals( + "Result should match the latest snapshot entry", + ImmutableList.of( + row( + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), + tableMetadata.metadataFileLocation(), + tableMetadata.currentSnapshot().snapshotId(), + tableMetadata.currentSnapshot().schemaId(), + tableMetadata.currentSnapshot().sequenceNumber())), + metadataLogWithFilters); + + // test projection + List metadataFiles = + metadataLogEntries.stream() + .map(TableMetadata.MetadataLogEntry::file) + .collect(Collectors.toList()); + metadataFiles.add(tableMetadata.metadataFileLocation()); + List metadataLogWithProjection = + sql("SELECT file FROM %s.metadata_log_entries", tableName); + assertThat(metadataLogWithProjection) + .as("metadataLogEntries table should return 3 rows") + .hasSize(3); + assertEquals( + "metadataLog entry should be of same file", + metadataFiles.stream().map(this::row).collect(Collectors.toList()), + metadataLogWithProjection); + } + + @TestTemplate + public void testFilesTableTimeTravelWithSchemaEvolution() throws Exception { + // Create table and insert data + sql( + "CREATE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES" + + "('format-version'='%s', 'write.delete.mode'='merge-on-read')", + tableName, formatVersion); + + List recordsA = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "a")); + spark + .createDataset(recordsA, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + + table.updateSchema().addColumn("category", Types.StringType.get()).commit(); + + List newRecords = + Lists.newArrayList(RowFactory.create(3, "b", "c"), RowFactory.create(4, "b", "c")); + + StructType newSparkSchema = + SparkSchemaUtil.convert( + new Schema( + optional(1, "id", Types.IntegerType.get()), + optional(2, "data", Types.StringType.get()), + optional(3, "category", Types.StringType.get()))); + + spark.createDataFrame(newRecords, newSparkSchema).coalesce(1).writeTo(tableName).append(); + table.refresh(); + Long currentSnapshotId = table.currentSnapshot().snapshotId(); + + Dataset actualFilesDs = + spark.sql( + "SELECT * FROM " + + tableName + + ".files VERSION AS OF " + + currentSnapshotId + + " ORDER BY content"); + List actualFiles = TestHelpers.selectNonDerived(actualFilesDs).collectAsList(); + Schema entriesTableSchema = + TypeUtil.selectNot( + Spark3Util.loadIcebergTable(spark, tableName + ".entries").schema(), + Set.of(DataFile.FIRST_ROW_ID.fieldId())); + List expectedDataManifests = TestHelpers.dataManifests(table); + List expectedFiles = + expectedEntries(table, FileContent.DATA, entriesTableSchema, expectedDataManifests, null); + + assertThat(actualFiles).as("actualFiles size should be 2").hasSize(2); + + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualFilesDs), expectedFiles.get(0), actualFiles.get(0)); + + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(actualFilesDs), expectedFiles.get(1), actualFiles.get(1)); + + assertThat(actualFiles) + .as("expectedFiles and actualFiles size should be the same") + .hasSameSizeAs(expectedFiles); + } + + @TestTemplate + public void testSnapshotReferencesMetatable() throws Exception { + // Create table and insert data + sql( + "CREATE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES" + + "('format-version'='%s', 'write.delete.mode'='merge-on-read')", + tableName, formatVersion); + + List recordsA = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "a")); + spark + .createDataset(recordsA, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + List recordsB = + Lists.newArrayList(new SimpleRecord(1, "b"), new SimpleRecord(2, "b")); + spark + .createDataset(recordsB, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + Long currentSnapshotId = table.currentSnapshot().snapshotId(); + + // Create branch + table + .manageSnapshots() + .createBranch("testBranch", currentSnapshotId) + .setMaxRefAgeMs("testBranch", 10) + .setMinSnapshotsToKeep("testBranch", 20) + .setMaxSnapshotAgeMs("testBranch", 30) + .commit(); + // Create Tag + table + .manageSnapshots() + .createTag("testTag", currentSnapshotId) + .setMaxRefAgeMs("testTag", 50) + .commit(); + // Check refs table + List references = spark.sql("SELECT * FROM " + tableName + ".refs").collectAsList(); + assertThat(references).as("Refs table should return 3 rows").hasSize(3); + List branches = + spark.sql("SELECT * FROM " + tableName + ".refs WHERE type='BRANCH'").collectAsList(); + assertThat(branches).as("Refs table should return 2 branches").hasSize(2); + List tags = + spark.sql("SELECT * FROM " + tableName + ".refs WHERE type='TAG'").collectAsList(); + assertThat(tags).as("Refs table should return 1 tag").hasSize(1); + + // Check branch entries in refs table + List mainBranch = + spark + .sql("SELECT * FROM " + tableName + ".refs WHERE name = 'main' AND type='BRANCH'") + .collectAsList(); + assertThat(mainBranch) + .hasSize(1) + .containsExactly( + RowFactory.create( + SnapshotRef.MAIN_BRANCH, "BRANCH", currentSnapshotId, null, null, null)); + assertThat(mainBranch.get(0).schema().fieldNames()) + .containsExactly( + "name", + "type", + "snapshot_id", + "max_reference_age_in_ms", + "min_snapshots_to_keep", + "max_snapshot_age_in_ms"); + + List testBranch = + spark + .sql("SELECT * FROM " + tableName + ".refs WHERE name = 'testBranch' AND type='BRANCH'") + .collectAsList(); + assertThat(testBranch) + .hasSize(1) + .containsExactly( + RowFactory.create("testBranch", "BRANCH", currentSnapshotId, 10L, 20L, 30L)); + assertThat(testBranch.get(0).schema().fieldNames()) + .containsExactly( + "name", + "type", + "snapshot_id", + "max_reference_age_in_ms", + "min_snapshots_to_keep", + "max_snapshot_age_in_ms"); + + // Check tag entries in refs table + List testTag = + spark + .sql("SELECT * FROM " + tableName + ".refs WHERE name = 'testTag' AND type='TAG'") + .collectAsList(); + assertThat(testTag) + .hasSize(1) + .containsExactly(RowFactory.create("testTag", "TAG", currentSnapshotId, 50L, null, null)); + assertThat(testTag.get(0).schema().fieldNames()) + .containsExactly( + "name", + "type", + "snapshot_id", + "max_reference_age_in_ms", + "min_snapshots_to_keep", + "max_snapshot_age_in_ms"); + + // Check projection in refs table + List testTagProjection = + spark + .sql( + "SELECT name,type,snapshot_id,max_reference_age_in_ms,min_snapshots_to_keep FROM " + + tableName + + ".refs where type='TAG'") + .collectAsList(); + assertThat(testTagProjection) + .hasSize(1) + .containsExactly(RowFactory.create("testTag", "TAG", currentSnapshotId, 50L, null)); + assertThat(testTagProjection.get(0).schema().fieldNames()) + .containsExactly( + "name", "type", "snapshot_id", "max_reference_age_in_ms", "min_snapshots_to_keep"); + + List mainBranchProjection = + spark + .sql( + "SELECT name, type FROM " + + tableName + + ".refs WHERE name = 'main' AND type = 'BRANCH'") + .collectAsList(); + assertThat(mainBranchProjection) + .hasSize(1) + .containsExactly(RowFactory.create(SnapshotRef.MAIN_BRANCH, "BRANCH")); + assertThat(mainBranchProjection.get(0).schema().fieldNames()).containsExactly("name", "type"); + + List testBranchProjection = + spark + .sql( + "SELECT name, type, snapshot_id, max_reference_age_in_ms FROM " + + tableName + + ".refs WHERE name = 'testBranch' AND type = 'BRANCH'") + .collectAsList(); + assertThat(testBranchProjection) + .hasSize(1) + .containsExactly(RowFactory.create("testBranch", "BRANCH", currentSnapshotId, 10L)); + assertThat(testBranchProjection.get(0).schema().fieldNames()) + .containsExactly("name", "type", "snapshot_id", "max_reference_age_in_ms"); + } + + /** + * Find matching manifest entries of an Iceberg table + * + * @param table iceberg table + * @param expectedContent file content to populate on entries + * @param entriesTableSchema schema of Manifest entries + * @param manifestsToExplore manifests to explore of the table + * @param partValue partition value that manifest entries must match, or null to skip filtering + */ + private List expectedEntries( + Table table, + FileContent expectedContent, + Schema entriesTableSchema, + List manifestsToExplore, + String partValue) + throws IOException { + List expected = Lists.newArrayList(); + for (ManifestFile manifest : manifestsToExplore) { + InputFile in = table.io().newInputFile(manifest.path()); + try (CloseableIterable rows = Avro.read(in).project(entriesTableSchema).build()) { + for (Record record : rows) { + if ((Integer) record.get("status") < 2 /* added or existing */) { + Record file = (Record) record.get("data_file"); + if (partitionMatch(file, partValue)) { + TestHelpers.asMetadataRecord(file, expectedContent); + expected.add(file); + } + } + } + } + } + return expected; + } + + private boolean partitionMatch(Record file, String partValue) { + if (partValue == null) { + return true; + } + Record partition = (Record) file.get(4); + return partValue.equals(partition.get(0).toString()); + } + + @TestTemplate + public void metadataLogEntriesAfterReplacingTable() throws Exception { + assumeThat(catalogConfig.get(ICEBERG_CATALOG_TYPE)) + .as( + "need to fix https://github.com/apache/iceberg/issues/11109 before enabling this for the REST catalog") + .isNotEqualTo(ICEBERG_CATALOG_TYPE_REST); + + sql( + "CREATE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES " + + "('format-version'='%s')", + tableName, formatVersion); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + TableMetadata tableMetadata = ((HasTableOperations) table).operations().current(); + assertThat(tableMetadata.snapshots()).isEmpty(); + assertThat(tableMetadata.snapshotLog()).isEmpty(); + assertThat(tableMetadata.currentSnapshot()).isNull(); + + Object[] firstEntry = + row( + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), + tableMetadata.metadataFileLocation(), + null, + null, + null); + + assertThat(sql("SELECT * FROM %s.metadata_log_entries", tableName)).containsExactly(firstEntry); + + sql("INSERT INTO %s (id, data) VALUES (1, 'a')", tableName); + + tableMetadata = ((HasTableOperations) table).operations().refresh(); + assertThat(tableMetadata.snapshots()).hasSize(1); + assertThat(tableMetadata.snapshotLog()).hasSize(1); + Snapshot currentSnapshot = tableMetadata.currentSnapshot(); + + Object[] secondEntry = + row( + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), + tableMetadata.metadataFileLocation(), + currentSnapshot.snapshotId(), + currentSnapshot.schemaId(), + currentSnapshot.sequenceNumber()); + + assertThat(sql("SELECT * FROM %s.metadata_log_entries", tableName)) + .containsExactly(firstEntry, secondEntry); + + sql("INSERT INTO %s (id, data) VALUES (1, 'a')", tableName); + + tableMetadata = ((HasTableOperations) table).operations().refresh(); + assertThat(tableMetadata.snapshots()).hasSize(2); + assertThat(tableMetadata.snapshotLog()).hasSize(2); + currentSnapshot = tableMetadata.currentSnapshot(); + + Object[] thirdEntry = + row( + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), + tableMetadata.metadataFileLocation(), + currentSnapshot.snapshotId(), + currentSnapshot.schemaId(), + currentSnapshot.sequenceNumber()); + + assertThat(sql("SELECT * FROM %s.metadata_log_entries", tableName)) + .containsExactly(firstEntry, secondEntry, thirdEntry); + + sql( + "CREATE OR REPLACE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (data) " + + "TBLPROPERTIES " + + "('format-version'='%s')", + tableName, formatVersion); + + tableMetadata = ((HasTableOperations) table).operations().refresh(); + assertThat(tableMetadata.snapshots()).hasSize(2); + assertThat(tableMetadata.snapshotLog()).hasSize(2); + + // currentSnapshot is null but the metadata_log_entries will refer to the last snapshot from the + // snapshotLog + assertThat(tableMetadata.currentSnapshot()).isNull(); + HistoryEntry historyEntry = tableMetadata.snapshotLog().get(1); + Snapshot lastSnapshot = tableMetadata.snapshot(historyEntry.snapshotId()); + + Object[] fourthEntry = + row( + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), + tableMetadata.metadataFileLocation(), + lastSnapshot.snapshotId(), + lastSnapshot.schemaId(), + lastSnapshot.sequenceNumber()); + + assertThat(sql("SELECT * FROM %s.metadata_log_entries", tableName)) + .containsExactly(firstEntry, secondEntry, thirdEntry, fourthEntry); + + sql("INSERT INTO %s (id, data) VALUES (1, 'a')", tableName); + + tableMetadata = ((HasTableOperations) table).operations().refresh(); + assertThat(tableMetadata.snapshots()).hasSize(3); + assertThat(tableMetadata.snapshotLog()).hasSize(3); + currentSnapshot = tableMetadata.currentSnapshot(); + + Object[] fifthEntry = + row( + DateTimeUtils.toJavaTimestamp(tableMetadata.lastUpdatedMillis() * 1000), + tableMetadata.metadataFileLocation(), + currentSnapshot.snapshotId(), + currentSnapshot.schemaId(), + currentSnapshot.sequenceNumber()); + + assertThat(sql("SELECT * FROM %s.metadata_log_entries", tableName)) + .containsExactly(firstEntry, secondEntry, thirdEntry, fourthEntry, fifthEntry); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java new file mode 100644 index 000000000000..60645e1c35c2 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestMigrateTableProcedure.java @@ -0,0 +1,308 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMigrateTableProcedure extends ExtensionsTestBase { + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s_BACKUP_", tableName); + } + + @TestTemplate + public void testMigrate() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + Object result = scalarSql("CALL %s.system.migrate('%s')", catalogName, tableName); + + assertThat(result).as("Should have added one file").isEqualTo(1L); + + Table createdTable = validationCatalog.loadTable(tableIdent); + + String tableLocation = createdTable.location().replace("file:", ""); + assertThat(tableLocation).as("Table should have original location").isEqualTo(location); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DROP TABLE IF EXISTS %s", tableName + "_BACKUP_"); + } + + @TestTemplate + public void testMigrateWithOptions() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Object result = + scalarSql("CALL %s.system.migrate('%s', map('foo', 'bar'))", catalogName, tableName); + + assertThat(result).as("Should have added one file").isEqualTo(1L); + + Table createdTable = validationCatalog.loadTable(tableIdent); + + Map props = createdTable.properties(); + assertThat(props).containsEntry("foo", "bar"); + + String tableLocation = createdTable.location().replace("file:", ""); + assertThat(tableLocation).as("Table should have original location").isEqualTo(location); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DROP TABLE IF EXISTS %s", tableName + "_BACKUP_"); + } + + @TestTemplate + public void testMigrateWithDropBackup() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Object result = + scalarSql( + "CALL %s.system.migrate(table => '%s', drop_backup => true)", catalogName, tableName); + assertThat(result).as("Should have added one file").isEqualTo(1L); + assertThat(spark.catalog().tableExists(tableName + "_BACKUP_")).isFalse(); + } + + @TestTemplate + public void testMigrateWithBackupTableName() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + String backupTableName = "backup_table"; + Object result = + scalarSql( + "CALL %s.system.migrate(table => '%s', backup_table_name => '%s')", + catalogName, tableName, backupTableName); + + assertThat(result).isEqualTo(1L); + String dbName = tableName.split("\\.")[0]; + assertThat(spark.catalog().tableExists(dbName + "." + backupTableName)).isTrue(); + } + + @TestTemplate + public void testMigrateWithInvalidMetricsConfig() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + + assertThatThrownBy( + () -> { + String props = "map('write.metadata.metrics.column.x', 'X')"; + sql("CALL %s.system.migrate('%s', %s)", catalogName, tableName, props); + }) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith("Invalid metrics config"); + } + + @TestTemplate + public void testMigrateWithConflictingProps() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Object result = + scalarSql("CALL %s.system.migrate('%s', map('migrated', 'false'))", catalogName, tableName); + assertThat(result).as("Should have added one file").isEqualTo(1L); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.properties()).containsEntry("migrated", "true"); + } + + @TestTemplate + public void testInvalidMigrateCases() { + assertThatThrownBy(() -> sql("CALL %s.system.migrate()", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `migrate` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.migrate(map('foo','bar'))", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE] Cannot resolve CALL due to data type mismatch: The first parameter requires the \"STRING\" type, however \"map(foo, bar)\" has the type \"MAP\". SQLSTATE: 42K09"); + + assertThatThrownBy(() -> sql("CALL %s.system.migrate('')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for argument table"); + } + + @TestTemplate + public void testMigratePartitionWithSpecialCharacter() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string, dt date) USING parquet " + + "PARTITIONED BY (data, dt) LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, '2023/05/30', date '2023-05-30')", tableName); + Object result = scalarSql("CALL %s.system.migrate('%s')", catalogName, tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "2023/05/30", java.sql.Date.valueOf("2023-05-30"))), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testMigrateEmptyPartitionedTable() throws Exception { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet PARTITIONED BY (id) LOCATION '%s'", + tableName, location); + Object result = scalarSql("CALL %s.system.migrate('%s')", catalogName, tableName); + assertThat(result).isEqualTo(0L); + } + + @TestTemplate + public void testMigrateEmptyTable() throws Exception { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + Object result = scalarSql("CALL %s.system.migrate('%s')", catalogName, tableName); + assertThat(result).isEqualTo(0L); + } + + @TestTemplate + public void testMigrateWithParallelism() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + List result = + sql("CALL %s.system.migrate(table => '%s', parallelism => %d)", catalogName, tableName, 2); + assertEquals("Procedure output must match", ImmutableList.of(row(2L)), result); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testMigrateWithInvalidParallelism() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.migrate(table => '%s', parallelism => %d)", + catalogName, tableName, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Parallelism should be larger than 0"); + } + + @TestTemplate + public void testMigratePartitionedWithParallelism() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet PARTITIONED BY (id) LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s (id, data) VALUES (1, 'a'), (2, 'b')", tableName); + List result = + sql("CALL %s.system.migrate(table => '%s', parallelism => %d)", catalogName, tableName, 2); + assertEquals("Procedure output must match", ImmutableList.of(row(2L)), result); + assertEquals( + "Should have expected rows", + ImmutableList.of(row("a", 1L), row("b", 2L)), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testMigrateBucketedTable() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet " + + "CLUSTERED BY (id) INTO 4 BUCKETS LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertThatThrownBy(() -> scalarSql("CALL %s.system.migrate('%s')", catalogName, tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot create an Iceberg table from a bucketed source table: " + + "4 buckets, bucket columns: [id]"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPartitionedWritesToWapBranch.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPartitionedWritesToWapBranch.java new file mode 100644 index 000000000000..af065451ab69 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPartitionedWritesToWapBranch.java @@ -0,0 +1,144 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.hive.TestHiveMetastore; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.sql.PartitionedWritesTestBase; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPartitionedWritesToWapBranch extends PartitionedWritesTestBase { + + private static final String BRANCH = "test"; + + @BeforeAll + public static void startMetastoreAndSpark() { + TestBase.metastore = new TestHiveMetastore(); + metastore.start(); + TestBase.hiveConf = metastore.hiveConf(); + + TestBase.spark.stop(); + + TestBase.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config("spark.testing", "true") + .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") + .config("spark.sql.extensions", IcebergSparkSessionExtensions.class.getName()) + .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config("spark.sql.shuffle.partitions", "4") + .config("spark.sql.hive.metastorePartitionPruningFallbackOnException", "true") + .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config(TestBase.DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + TestBase.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + + String className = HiveCatalog.class.getName(); + Map options = ImmutableMap.of(); + TestBase.catalog = (HiveCatalog) CatalogUtil.loadCatalog(className, "hive", options, hiveConf); + } + + @BeforeEach + @Override + public void createTables() { + spark.conf().set(SparkSQLProperties.WAP_BRANCH, BRANCH); + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg PARTITIONED BY (truncate(id, 3)) OPTIONS (%s = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", tableName); + } + + @AfterEach + @Override + public void removeTables() { + super.removeTables(); + spark.conf().unset(SparkSQLProperties.WAP_BRANCH); + spark.conf().unset(SparkSQLProperties.WAP_ID); + } + + @Override + protected String selectTarget() { + return String.format("%s VERSION AS OF '%s'", tableName, BRANCH); + } + + @TestTemplate + public void testWriteToBranchWithWapBranchSet() { + Table table = validationCatalog.loadTable(tableIdent); + table.manageSnapshots().createBranch("test2", table.refs().get(BRANCH).snapshotId()).commit(); + sql("REFRESH TABLE " + tableName); + + // writing to explicit branch should succeed even with WAP branch set + sql("INSERT INTO TABLE %s.branch_test2 VALUES (4, 'd')", tableName); + + // verify write went to branch test2 + assertEquals( + "Data should be written to branch test2", + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c"), row(4L, "d")), + sql("SELECT * FROM %s VERSION AS OF 'test2' ORDER BY id", tableName)); + + // verify current state is not affected + assertEquals( + "Data should be written to branch test2", + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testWapIdAndWapBranchCannotBothBeSetForWrite() { + String wapId = UUID.randomUUID().toString(); + spark.conf().set(SparkSQLProperties.WAP_ID, wapId); + assertThatThrownBy(() -> sql("INSERT INTO %s VALUES (4, 'd')", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot set both WAP ID and branch, but got ID [%s] and branch [%s]", wapId, BRANCH); + } + + @Override + protected void assertPartitionMetadata( + String tableName, List expected, String... selectPartitionColumns) { + // Cannot read from the .partitions table newly written data into the WAP branch. See + // https://github.com/apache/iceberg/issues/7297 for more details. + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java new file mode 100644 index 000000000000..c72770e1cec6 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestPublishChangesProcedure.java @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.WRITE_AUDIT_PUBLISH_ENABLED; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPublishChangesProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testApplyWapChangesUsingPositionalArgs() { + String wapId = "wap_id_1"; + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", wapId); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot wapSnapshot = Iterables.getOnlyElement(table.snapshots()); + + List output = + sql("CALL %s.system.publish_changes('%s', '%s')", catalogName, tableIdent, wapId); + + table.refresh(); + + Snapshot currentSnapshot = table.currentSnapshot(); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(wapSnapshot.snapshotId(), currentSnapshot.snapshotId())), + output); + + assertEquals( + "Apply of WAP changes must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testApplyWapChangesUsingNamedArgs() { + String wapId = "wap_id_1"; + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", wapId); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot wapSnapshot = Iterables.getOnlyElement(table.snapshots()); + + List output = + sql( + "CALL %s.system.publish_changes(wap_id => '%s', table => '%s')", + catalogName, wapId, tableIdent); + + table.refresh(); + + Snapshot currentSnapshot = table.currentSnapshot(); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(wapSnapshot.snapshotId(), currentSnapshot.snapshotId())), + output); + + assertEquals( + "Apply of WAP changes must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testApplyWapChangesRefreshesRelationCache() { + String wapId = "wap_id_1"; + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + Dataset query = spark.sql("SELECT * FROM " + tableName + " WHERE id = 1"); + query.createOrReplaceTempView("tmp"); + + spark.sql("CACHE TABLE tmp"); + + assertEquals("View should not produce rows", ImmutableList.of(), sql("SELECT * FROM tmp")); + + spark.conf().set("spark.wap.id", wapId); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + sql("CALL %s.system.publish_changes('%s', '%s')", catalogName, tableIdent, wapId); + + assertEquals( + "Apply of WAP changes should be visible", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM tmp")); + + sql("UNCACHE TABLE tmp"); + } + + @TestTemplate + public void testApplyInvalidWapId() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.publish_changes('%s', 'not_valid')", catalogName, tableIdent)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot apply unknown WAP ID 'not_valid'"); + } + + @TestTemplate + public void testApplyDuplicateWapId() { + + String wapId = "wap_id_1"; + + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", wapId); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.publish_changes('%s', '%s')", catalogName, tableIdent, wapId)) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID 'wap_id_1'"); + } + + @TestTemplate + public void testInvalidApplyWapChangesCases() { + assertThatThrownBy( + () -> + sql("CALL %s.system.publish_changes('n', table => 't', 'not_valid')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[UNEXPECTED_POSITIONAL_ARGUMENT] Cannot invoke routine `publish_changes` because it contains positional argument(s) following the named argument assigned to `table`; please rearrange them so the positional arguments come first and then retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> sql("CALL %s.custom.publish_changes('n', 't', 'not_valid')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`publish_changes`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.publish_changes('t')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `publish_changes` because the parameter named `wap_id` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.publish_changes('', 'not_valid')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRegisterTableProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRegisterTableProcedure.java new file mode 100644 index 000000000000..a06a67b7d612 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRegisterTableProcedure.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.atIndex; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.functions; +import org.apache.spark.sql.types.DataTypes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRegisterTableProcedure extends ExtensionsTestBase { + + private String targetName; + + @BeforeEach + public void setTargetName() { + targetName = tableName("register_table"); + } + + @AfterEach + public void dropTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", targetName); + } + + @TestTemplate + public void testRegisterTable() throws NoSuchTableException, ParseException { + long numRows = 1000; + + sql("CREATE TABLE %s (id int, data string) using ICEBERG", tableName); + spark + .range(0, numRows) + .withColumn("data", functions.col("id").cast(DataTypes.StringType)) + .writeTo(tableName) + .append(); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + long originalFileCount = (long) scalarSql("SELECT COUNT(*) from %s.files", tableName); + long currentSnapshotId = table.currentSnapshot().snapshotId(); + String metadataJson = TableUtil.metadataFileLocation(table); + + List result = + sql("CALL %s.system.register_table('%s', '%s')", catalogName, targetName, metadataJson); + assertThat(result.get(0)) + .as("Current Snapshot is not correct") + .contains(currentSnapshotId, atIndex(0)); + + List original = sql("SELECT * FROM %s", tableName); + List registered = sql("SELECT * FROM %s", targetName); + assertEquals("Registered table rows should match original table rows", original, registered); + assertThat(result.get(0)) + .as("Should have the right row count in the procedure result") + .contains(numRows, atIndex(1)) + .as("Should have the right datafile count in the procedure result") + .contains(originalFileCount, atIndex(2)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRemoveOrphanFilesProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRemoveOrphanFilesProcedure.java new file mode 100644 index 000000000000..a5ac8a7e01ac --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRemoveOrphanFilesProcedure.java @@ -0,0 +1,752 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.GC_ENABLED; +import static org.apache.iceberg.TableProperties.WRITE_AUDIT_PUBLISH_ENABLED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.Files; +import org.apache.iceberg.GenericBlobMetadata; +import org.apache.iceberg.GenericStatisticsFile; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.ReachableFileUtil; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.puffin.Blob; +import org.apache.iceberg.puffin.Puffin; +import org.apache.iceberg.puffin.PuffinWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.source.FilePathLastModifiedRecord; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRemoveOrphanFilesProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s PURGE", tableName); + sql("DROP TABLE IF EXISTS p PURGE"); + } + + @TestTemplate + public void testRemoveOrphanFilesInEmptyTable() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + List output = + sql("CALL %s.system.remove_orphan_files('%s')", catalogName, tableIdent); + assertEquals("Should be no orphan files", ImmutableList.of(), output); + + assertEquals("Should have no rows", ImmutableList.of(), sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testRemoveOrphanFilesInDataFolder() throws IOException { + if (catalogName.equals("testhadoop")) { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } else { + // give a fresh location to Hive tables as Spark will not clean up the table location + // correctly while dropping tables through spark_catalog + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg LOCATION '%s'", + tableName, java.nio.file.Files.createTempDirectory(temp, "junit")); + } + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + String metadataLocation = table.location() + "/metadata"; + String dataLocation = table.location() + "/data"; + + // produce orphan files in the data location using parquet + sql("CREATE TABLE p (id bigint) USING parquet LOCATION '%s'", dataLocation); + sql("INSERT INTO TABLE p VALUES (1)"); + + // wait to ensure files are old enough + waitUntilAfter(System.currentTimeMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + // check for orphans in the metadata folder + List output1 = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s'," + + "location => '%s')", + catalogName, tableIdent, currentTimestamp, metadataLocation); + assertEquals("Should be no orphan files in the metadata folder", ImmutableList.of(), output1); + + // check for orphans in the table location + List output2 = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output2).as("Should be orphan files in the data folder").hasSize(1); + + // the previous call should have deleted all orphan files + List output3 = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output3).as("Should be no more orphan files in the data folder").isEmpty(); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRemoveOrphanFilesDryRun() throws IOException { + if (catalogName.equals("testhadoop")) { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } else { + // give a fresh location to Hive tables as Spark will not clean up the table location + // correctly while dropping tables through spark_catalog + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg LOCATION '%s'", + tableName, java.nio.file.Files.createTempDirectory(temp, "junit")); + } + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + // produce orphan files in the table location using parquet + sql("CREATE TABLE p (id bigint) USING parquet LOCATION '%s'", table.location()); + sql("INSERT INTO TABLE p VALUES (1)"); + + // wait to ensure files are old enough + waitUntilAfter(System.currentTimeMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + // check for orphans without deleting + List output1 = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s'," + + "dry_run => true)", + catalogName, tableIdent, currentTimestamp); + assertThat(output1).as("Should be one orphan files").hasSize(1); + + // actually delete orphans + List output2 = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output2).as("Should be one orphan files").hasSize(1); + + // the previous call should have deleted all orphan files + List output3 = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output3).as("Should be no more orphan files").isEmpty(); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRemoveOrphanFilesGCDisabled() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'false')", tableName, GC_ENABLED); + + assertThatThrownBy( + () -> sql("CALL %s.system.remove_orphan_files('%s')", catalogName, tableIdent)) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot delete orphan files: GC is disabled (deleting files may corrupt other tables)"); + + // reset the property to enable the table purging in removeTable. + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, GC_ENABLED); + } + + @TestTemplate + public void testRemoveOrphanFilesWap() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", "1"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + List output = + sql("CALL %s.system.remove_orphan_files('%s')", catalogName, tableIdent); + assertEquals("Should be no orphan files", ImmutableList.of(), output); + } + + @TestTemplate + public void testInvalidRemoveOrphanFilesCases() { + assertThatThrownBy( + () -> sql("CALL %s.system.remove_orphan_files('n', table => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[DUPLICATE_ROUTINE_PARAMETER_ASSIGNMENT.BOTH_POSITIONAL_AND_NAMED] Call to routine `remove_orphan_files` is invalid because it includes multiple argument assignments to the same parameter name `table`. A positional argument and named argument both referred to the same parameter. Please remove the named argument referring to this parameter. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.custom.remove_orphan_files('n', 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`remove_orphan_files`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.remove_orphan_files()", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `remove_orphan_files` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.remove_orphan_files('n', 2.2)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE] Cannot resolve CALL due to data type mismatch: The second parameter requires the \"TIMESTAMP\" type, however \"2.2\" has the type \"DECIMAL(2,1)\". SQLSTATE: 42K09"); + + assertThatThrownBy(() -> sql("CALL %s.system.remove_orphan_files('')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + } + + @TestTemplate + public void testConcurrentRemoveOrphanFiles() throws IOException { + if (catalogName.equals("testhadoop")) { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } else { + // give a fresh location to Hive tables as Spark will not clean up the table location + // correctly while dropping tables through spark_catalog + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg LOCATION '%s'", + tableName, java.nio.file.Files.createTempDirectory(temp, "junit")); + } + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + String dataLocation = table.location() + "/data"; + + // produce orphan files in the data location using parquet + sql("CREATE TABLE p (id bigint) USING parquet LOCATION '%s'", dataLocation); + sql("INSERT INTO TABLE p VALUES (1)"); + sql("INSERT INTO TABLE p VALUES (10)"); + sql("INSERT INTO TABLE p VALUES (100)"); + sql("INSERT INTO TABLE p VALUES (1000)"); + + // wait to ensure files are old enough + waitUntilAfter(System.currentTimeMillis()); + + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + // check for orphans in the table location + List output = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "max_concurrent_deletes => %s," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, 4, currentTimestamp); + assertThat(output).as("Should be orphan files in the data folder").hasSize(4); + + // the previous call should have deleted all orphan files + List output3 = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "max_concurrent_deletes => %s," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, 4, currentTimestamp); + assertThat(output3).as("Should be no more orphan files in the data folder").isEmpty(); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testConcurrentRemoveOrphanFilesWithInvalidInput() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.remove_orphan_files(table => '%s', max_concurrent_deletes => %s)", + catalogName, tableIdent, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("max_concurrent_deletes should have value > 0, value: 0"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.remove_orphan_files(table => '%s', max_concurrent_deletes => %s)", + catalogName, tableIdent, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("max_concurrent_deletes should have value > 0, value: -1"); + + String tempViewName = "file_list_test"; + spark.emptyDataFrame().createOrReplaceTempView(tempViewName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.remove_orphan_files(table => '%s', file_list_view => '%s')", + catalogName, tableIdent, tempViewName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("[FIELD_NOT_FOUND] No such struct field `file_path` in . SQLSTATE: 42704"); + + spark + .createDataset(Lists.newArrayList(), Encoders.tuple(Encoders.INT(), Encoders.TIMESTAMP())) + .toDF("file_path", "last_modified") + .createOrReplaceTempView(tempViewName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.remove_orphan_files(table => '%s', file_list_view => '%s')", + catalogName, tableIdent, tempViewName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid file_path column: IntegerType is not a string"); + + spark + .createDataset(Lists.newArrayList(), Encoders.tuple(Encoders.STRING(), Encoders.STRING())) + .toDF("file_path", "last_modified") + .createOrReplaceTempView(tempViewName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.remove_orphan_files(table => '%s', file_list_view => '%s')", + catalogName, tableIdent, tempViewName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid last_modified column: StringType is not a timestamp"); + } + + @TestTemplate + public void testRemoveOrphanFilesWithDeleteFiles() throws Exception { + sql( + "CREATE TABLE %s (id int, data string) USING iceberg TBLPROPERTIES" + + "('format-version'='2', 'write.delete.mode'='merge-on-read')", + tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + sql("DELETE FROM %s WHERE id=1", tableName); + + Table table = Spark3Util.loadIcebergTable(spark, tableName); + assertThat(TestHelpers.deleteManifests(table)).as("Should have 1 delete manifest").hasSize(1); + assertThat(TestHelpers.deleteFiles(table)).as("Should have 1 delete file").hasSize(1); + Path deleteManifestPath = new Path(TestHelpers.deleteManifests(table).iterator().next().path()); + Path deleteFilePath = new Path(TestHelpers.deleteFiles(table).iterator().next().location()); + + // wait to ensure files are old enough + waitUntilAfter(System.currentTimeMillis()); + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + // delete orphans + List output = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output).as("Should be no orphan files").isEmpty(); + + FileSystem localFs = FileSystem.getLocal(new Configuration()); + assertThat(localFs.exists(deleteManifestPath)) + .as("Delete manifest should still exist") + .isTrue(); + assertThat(localFs.exists(deleteFilePath)).as("Delete file should still exist").isTrue(); + + records.remove(new SimpleRecord(1, "a")); + Dataset resultDF = spark.read().format("iceberg").load(tableName); + List actualRecords = + resultDF.as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actualRecords).as("Rows must match").isEqualTo(records); + } + + @TestTemplate + public void testRemoveOrphanFilesWithStatisticFiles() throws Exception { + sql( + "CREATE TABLE %s USING iceberg " + + "TBLPROPERTIES('format-version'='2') " + + "AS SELECT 10 int, 'abc' data", + tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + + String statsFileName = "stats-file-" + UUID.randomUUID(); + String location = table.location(); + // not every catalog will return file proto for local directories + // i.e. Hadoop and Hive Catalog do, Jdbc and REST do not + if (!location.startsWith("file:")) { + location = "file:" + location; + } + File statsLocation = + new File(new URI(location)).toPath().resolve("data").resolve(statsFileName).toFile(); + StatisticsFile statisticsFile; + try (PuffinWriter puffinWriter = Puffin.write(Files.localOutput(statsLocation)).build()) { + long snapshotId = table.currentSnapshot().snapshotId(); + long snapshotSequenceNumber = table.currentSnapshot().sequenceNumber(); + puffinWriter.add( + new Blob( + "some-blob-type", + ImmutableList.of(1), + snapshotId, + snapshotSequenceNumber, + ByteBuffer.wrap("blob content".getBytes(StandardCharsets.UTF_8)))); + puffinWriter.finish(); + statisticsFile = + new GenericStatisticsFile( + snapshotId, + statsLocation.toString(), + puffinWriter.fileSize(), + puffinWriter.footerSize(), + puffinWriter.writtenBlobsMetadata().stream() + .map(GenericBlobMetadata::from) + .collect(ImmutableList.toImmutableList())); + } + + Transaction transaction = table.newTransaction(); + transaction.updateStatistics().setStatistics(statisticsFile).commit(); + transaction.commitTransaction(); + + // wait to ensure files are old enough + waitUntilAfter(System.currentTimeMillis()); + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + List output = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output).as("Should be no orphan files").isEmpty(); + + assertThat(statsLocation).exists().hasSize(statisticsFile.fileSizeInBytes()); + + transaction = table.newTransaction(); + transaction.updateStatistics().removeStatistics(statisticsFile.snapshotId()).commit(); + transaction.commitTransaction(); + + output = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output) + .hasSize(1) + .first() + .satisfies(files -> assertThat(files).containsExactly(statsLocation.toURI().toString())); + assertThat(statsLocation).doesNotExist(); + } + + @TestTemplate + public void testRemoveOrphanFilesWithPartitionStatisticFiles() throws Exception { + sql( + "CREATE TABLE %s USING iceberg " + + "TBLPROPERTIES('format-version'='2') " + + "AS SELECT 10 int, 'abc' data", + tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + + String partitionStatsLocation = ProcedureUtil.statsFileLocation(table.location()); + PartitionStatisticsFile partitionStatisticsFile = + ProcedureUtil.writePartitionStatsFile( + table.currentSnapshot().snapshotId(), partitionStatsLocation, table.io()); + + commitPartitionStatsTxn(table, partitionStatisticsFile); + + // wait to ensure files are old enough + waitUntilAfter(System.currentTimeMillis()); + Timestamp currentTimestamp = Timestamp.from(Instant.ofEpochMilli(System.currentTimeMillis())); + + List output = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output).as("Should be no orphan files").isEmpty(); + + assertThat(new File(partitionStatsLocation)).as("partition stats file should exist").exists(); + + removePartitionStatsTxn(table, partitionStatisticsFile); + + output = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "older_than => TIMESTAMP '%s')", + catalogName, tableIdent, currentTimestamp); + assertThat(output) + .hasSize(1) + .first() + .satisfies(files -> assertThat(files).containsExactly("file:" + partitionStatsLocation)); + assertThat(new File(partitionStatsLocation)) + .as("partition stats file should be deleted") + .doesNotExist(); + } + + private static void removePartitionStatsTxn( + Table table, PartitionStatisticsFile partitionStatisticsFile) { + Transaction transaction = table.newTransaction(); + transaction + .updatePartitionStatistics() + .removePartitionStatistics(partitionStatisticsFile.snapshotId()) + .commit(); + transaction.commitTransaction(); + } + + private static void commitPartitionStatsTxn( + Table table, PartitionStatisticsFile partitionStatisticsFile) { + Transaction transaction = table.newTransaction(); + transaction + .updatePartitionStatistics() + .setPartitionStatistics(partitionStatisticsFile) + .commit(); + transaction.commitTransaction(); + } + + @TestTemplate + public void testRemoveOrphanFilesProcedureWithPrefixMode() + throws NoSuchTableException, ParseException, IOException { + if (catalogName.equals("testhadoop")) { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } else { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg LOCATION '%s'", + tableName, java.nio.file.Files.createTempDirectory(temp, "junit")); + } + Table table = Spark3Util.loadIcebergTable(spark, tableName); + String location = table.location(); + Path originalPath = new Path(location); + + URI uri = originalPath.toUri(); + Path newParentPath = new Path("file1", uri.getAuthority(), uri.getPath()); + + DataFile dataFile1 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(new Path(newParentPath, "path/to/data-a.parquet").toString()) + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + DataFile dataFile2 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(new Path(newParentPath, "path/to/data-b.parquet").toString()) + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + + table.newFastAppend().appendFile(dataFile1).appendFile(dataFile2).commit(); + + Timestamp lastModifiedTimestamp = new Timestamp(10000); + + List allFiles = + Lists.newArrayList( + new FilePathLastModifiedRecord( + new Path(originalPath, "path/to/data-a.parquet").toString(), lastModifiedTimestamp), + new FilePathLastModifiedRecord( + new Path(originalPath, "path/to/data-b.parquet").toString(), lastModifiedTimestamp), + new FilePathLastModifiedRecord( + ReachableFileUtil.versionHintLocation(table), lastModifiedTimestamp)); + + for (String file : ReachableFileUtil.metadataFileLocations(table, true)) { + allFiles.add(new FilePathLastModifiedRecord(file, lastModifiedTimestamp)); + } + + for (ManifestFile manifest : TestHelpers.dataManifests(table)) { + allFiles.add(new FilePathLastModifiedRecord(manifest.path(), lastModifiedTimestamp)); + } + + Dataset compareToFileList = + spark + .createDataFrame(allFiles, FilePathLastModifiedRecord.class) + .withColumnRenamed("filePath", "file_path") + .withColumnRenamed("lastModified", "last_modified"); + String fileListViewName = "files_view"; + compareToFileList.createOrReplaceTempView(fileListViewName); + List orphanFiles = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "equal_schemes => map('file1', 'file')," + + "file_list_view => '%s')", + catalogName, tableIdent, fileListViewName); + assertThat(orphanFiles).isEmpty(); + + // Test with no equal schemes + assertThatThrownBy( + () -> + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "file_list_view => '%s')", + catalogName, tableIdent, fileListViewName)) + .isInstanceOf(ValidationException.class) + .hasMessageEndingWith("Conflicting authorities/schemes: [(file1, file)]."); + + // Drop table in afterEach has purge and fails due to invalid scheme "file1" used in this test + // Dropping the table here + sql("DROP TABLE %s", tableName); + } + + @TestTemplate + public void testRemoveOrphanFilesProcedureWithEqualAuthorities() + throws NoSuchTableException, ParseException, IOException { + if (catalogName.equals("testhadoop")) { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } else { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg LOCATION '%s'", + tableName, java.nio.file.Files.createTempDirectory(temp, "junit")); + } + Table table = Spark3Util.loadIcebergTable(spark, tableName); + Path originalPath = new Path(table.location()); + + URI uri = originalPath.toUri(); + String originalAuthority = uri.getAuthority() == null ? "" : uri.getAuthority(); + Path newParentPath = new Path(uri.getScheme(), "localhost", uri.getPath()); + + DataFile dataFile1 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(new Path(newParentPath, "path/to/data-a.parquet").toString()) + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + DataFile dataFile2 = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(new Path(newParentPath, "path/to/data-b.parquet").toString()) + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + + table.newFastAppend().appendFile(dataFile1).appendFile(dataFile2).commit(); + + Timestamp lastModifiedTimestamp = new Timestamp(10000); + + List allFiles = + Lists.newArrayList( + new FilePathLastModifiedRecord( + new Path(originalPath, "path/to/data-a.parquet").toString(), lastModifiedTimestamp), + new FilePathLastModifiedRecord( + new Path(originalPath, "path/to/data-b.parquet").toString(), lastModifiedTimestamp), + new FilePathLastModifiedRecord( + ReachableFileUtil.versionHintLocation(table), lastModifiedTimestamp)); + + for (String file : ReachableFileUtil.metadataFileLocations(table, true)) { + allFiles.add(new FilePathLastModifiedRecord(file, lastModifiedTimestamp)); + } + + for (ManifestFile manifest : TestHelpers.dataManifests(table)) { + allFiles.add(new FilePathLastModifiedRecord(manifest.path(), lastModifiedTimestamp)); + } + + Dataset compareToFileList = + spark + .createDataFrame(allFiles, FilePathLastModifiedRecord.class) + .withColumnRenamed("filePath", "file_path") + .withColumnRenamed("lastModified", "last_modified"); + String fileListViewName = "files_view"; + compareToFileList.createOrReplaceTempView(fileListViewName); + List orphanFiles = + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "equal_authorities => map('localhost', '%s')," + + "file_list_view => '%s')", + catalogName, tableIdent, originalAuthority, fileListViewName); + assertThat(orphanFiles).isEmpty(); + + // Test with no equal authorities + assertThatThrownBy( + () -> + sql( + "CALL %s.system.remove_orphan_files(" + + "table => '%s'," + + "file_list_view => '%s')", + catalogName, tableIdent, fileListViewName)) + .isInstanceOf(ValidationException.class) + .hasMessageEndingWith("Conflicting authorities/schemes: [(localhost, null)]."); + + // Drop table in afterEach has purge and fails due to invalid authority "localhost" + // Dropping the table here + sql("DROP TABLE %s", tableName); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRepeatedTableAccess.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRepeatedTableAccess.java new file mode 100644 index 000000000000..a3716931109b --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRepeatedTableAccess.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRepeatedTableAccess extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.HIVE.properties()) + .put("cache-enabled", "false") + .build() + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.SPARK_SESSION.properties()) + .put("cache-enabled", "true") + .put("cache.expiration-interval-ms", "-1") // indefinite cache + .buildKeepingLast() + } + }; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testRepeatedAccessWithExternalWrite() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // query table + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds (2, 200) + Table table = validationCatalog.loadTable(tableIdent); + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // query table again + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Repeated access should not reflect external writes when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2) + .as("Repeated access should reflect external writes when cache is disabled") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + } + + @TestTemplate + public void testRepeatedAccessWithExternalSchemaChangeAddColumn() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // query table + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds new column + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().addColumn("new_column", Types.IntegerType.get()).commit(); + + // external writer adds (2, 200, -1) + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + record.setField("new_column", -1); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // query table again + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Repeated access should not reflect external schema changes when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2) + .as("Repeated access should reflect external schema changes when cache is disabled") + .hasSize(2) + .containsExactly(row(1, 100, null), row(2, 200, -1)); + } + } + + @TestTemplate + public void testRepeatedAccessWithExternalSchemaChangeDropColumn() throws IOException { + // create table + sql("CREATE TABLE %s (id INT, salary INT, extra INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100, 10)", tableName); + + // query table + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100, 10)); + + // external writer drops column + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().deleteColumn("extra").commit(); + + // external writer adds (2, 200) + Record record = GenericRecord.create(table.schema()); + record.setField("id", 2); + record.setField("salary", 200); + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newAppend().appendFile(dataFile).commit(); + + // query table again + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Repeated access should not reflect external schema changes when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100, 10)); + } else { + assertThat(result2) + .as("Repeated access should reflect external schema changes when cache is disabled") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + } + + @TestTemplate + public void testRepeatedAccessWithExternalDropAndRecreateTable() { + // create table + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + + // insert initial data + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // query table + List result1 = sql("SELECT * FROM %s ORDER BY id", tableName); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer drops and recreates table + validationCatalog.dropTable(tableIdent, false /* keep files */); + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "salary", Types.IntegerType.get())); + validationCatalog.createTable(tableIdent, schema); + + // query table again + List result2 = sql("SELECT * FROM %s ORDER BY id", tableName); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Repeated access should return stale data from old table when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2) + .as("Repeated access should resolve to newly created table when cache is disabled") + .isEmpty(); + } + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestReplaceBranch.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestReplaceBranch.java new file mode 100644 index 000000000000..078df9d8aa8d --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestReplaceBranch.java @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestReplaceBranch extends ExtensionsTestBase { + + private static final String[] TIME_UNITS = {"DAYS", "HOURS", "MINUTES"}; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties() + } + }; + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testReplaceBranchFailsForTag() throws NoSuchTableException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + String tagName = "tag1"; + + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + Table table = validationCatalog.loadTable(tableIdent); + long first = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createTag(tagName, first).commit(); + df.writeTo(tableName).append(); + long second = table.currentSnapshot().snapshotId(); + + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d", + tableName, tagName, second)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Ref tag1 is a tag not a branch"); + } + + @TestTemplate + public void testReplaceBranch() throws NoSuchTableException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + long first = table.currentSnapshot().snapshotId(); + String branchName = "b1"; + long expectedMaxRefAgeMs = 1000; + int expectedMinSnapshotsToKeep = 2; + long expectedMaxSnapshotAgeMs = 1000; + table + .manageSnapshots() + .createBranch(branchName, first) + .setMaxRefAgeMs(branchName, expectedMaxRefAgeMs) + .setMinSnapshotsToKeep(branchName, expectedMinSnapshotsToKeep) + .setMaxSnapshotAgeMs(branchName, expectedMaxSnapshotAgeMs) + .commit(); + + df.writeTo(tableName).append(); + long second = table.currentSnapshot().snapshotId(); + + sql("ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d", tableName, branchName, second); + + table.refresh(); + SnapshotRef ref = table.refs().get(branchName); + assertThat(ref).isNotNull(); + assertThat(ref.snapshotId()).isEqualTo(second); + assertThat(ref.minSnapshotsToKeep().intValue()).isEqualTo(expectedMinSnapshotsToKeep); + assertThat(ref.maxSnapshotAgeMs().longValue()).isEqualTo(expectedMaxSnapshotAgeMs); + assertThat(ref.maxRefAgeMs().longValue()).isEqualTo(expectedMaxRefAgeMs); + } + + @TestTemplate + public void testReplaceBranchDoesNotExist() throws NoSuchTableException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + Table table = validationCatalog.loadTable(tableIdent); + + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d", + tableName, "someBranch", table.currentSnapshot().snapshotId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Branch does not exist: someBranch"); + } + + @TestTemplate + public void testReplaceBranchWithRetain() throws NoSuchTableException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + long first = table.currentSnapshot().snapshotId(); + String branchName = "b1"; + table.manageSnapshots().createBranch(branchName, first).commit(); + df.writeTo(tableName).append(); + long second = table.currentSnapshot().snapshotId(); + + long maxRefAge = 10; + for (String timeUnit : TIME_UNITS) { + sql( + "ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d RETAIN %d %s", + tableName, branchName, second, maxRefAge, timeUnit); + + table.refresh(); + SnapshotRef ref = table.refs().get(branchName); + assertThat(ref).isNotNull(); + assertThat(ref.snapshotId()).isEqualTo(second); + assertThat(ref.minSnapshotsToKeep()).isNull(); + assertThat(ref.maxSnapshotAgeMs()).isNull(); + assertThat(ref.maxRefAgeMs().longValue()) + .isEqualTo(TimeUnit.valueOf(timeUnit).toMillis(maxRefAge)); + } + } + + @TestTemplate + public void testReplaceBranchWithSnapshotRetention() throws NoSuchTableException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + String branchName = "b1"; + Table table = validationCatalog.loadTable(tableIdent); + long first = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createBranch(branchName, first).commit(); + df.writeTo(tableName).append(); + long second = table.currentSnapshot().snapshotId(); + + Integer minSnapshotsToKeep = 2; + long maxSnapshotAge = 2; + Long maxRefAgeMs = table.refs().get(branchName).maxRefAgeMs(); + for (String timeUnit : TIME_UNITS) { + sql( + "ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d WITH SNAPSHOT RETENTION %d SNAPSHOTS %d %s", + tableName, branchName, second, minSnapshotsToKeep, maxSnapshotAge, timeUnit); + + table.refresh(); + SnapshotRef ref = table.refs().get(branchName); + assertThat(ref).isNotNull(); + assertThat(ref.snapshotId()).isEqualTo(second); + assertThat(ref.minSnapshotsToKeep()).isEqualTo(minSnapshotsToKeep); + assertThat(ref.maxSnapshotAgeMs().longValue()) + .isEqualTo(TimeUnit.valueOf(timeUnit).toMillis(maxSnapshotAge)); + assertThat(ref.maxRefAgeMs()).isEqualTo(maxRefAgeMs); + } + } + + @TestTemplate + public void testReplaceBranchWithRetainAndSnapshotRetention() throws NoSuchTableException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + long first = table.currentSnapshot().snapshotId(); + String branchName = "b1"; + table.manageSnapshots().createBranch(branchName, first).commit(); + df.writeTo(tableName).append(); + long second = table.currentSnapshot().snapshotId(); + + Integer minSnapshotsToKeep = 2; + long maxSnapshotAge = 2; + long maxRefAge = 10; + for (String timeUnit : TIME_UNITS) { + sql( + "ALTER TABLE %s REPLACE BRANCH %s AS OF VERSION %d RETAIN %d %s WITH SNAPSHOT RETENTION %d SNAPSHOTS %d %s", + tableName, + branchName, + second, + maxRefAge, + timeUnit, + minSnapshotsToKeep, + maxSnapshotAge, + timeUnit); + + table.refresh(); + SnapshotRef ref = table.refs().get(branchName); + assertThat(ref).isNotNull(); + assertThat(ref.snapshotId()).isEqualTo(second); + assertThat(ref.minSnapshotsToKeep()).isEqualTo(minSnapshotsToKeep); + assertThat(ref.maxSnapshotAgeMs().longValue()) + .isEqualTo(TimeUnit.valueOf(timeUnit).toMillis(maxSnapshotAge)); + assertThat(ref.maxRefAgeMs().longValue()) + .isEqualTo(TimeUnit.valueOf(timeUnit).toMillis(maxRefAge)); + } + } + + @TestTemplate + public void testCreateOrReplace() throws NoSuchTableException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + long first = table.currentSnapshot().snapshotId(); + String branchName = "b1"; + df.writeTo(tableName).append(); + long second = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createBranch(branchName, second).commit(); + + sql( + "ALTER TABLE %s CREATE OR REPLACE BRANCH %s AS OF VERSION %d", + tableName, branchName, first); + + table.refresh(); + SnapshotRef ref = table.refs().get(branchName); + assertThat(ref).isNotNull(); + assertThat(ref.snapshotId()).isEqualTo(first); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRequiredDistributionAndOrdering.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRequiredDistributionAndOrdering.java new file mode 100644 index 000000000000..fe1c38482bed --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRequiredDistributionAndOrdering.java @@ -0,0 +1,313 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRequiredDistributionAndOrdering extends ExtensionsTestBase { + + @AfterEach + public void dropTestTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testDefaultLocalSortWithBucketTransforms() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c1))", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + // should insert a local sort by partition columns by default + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testPartitionColumnsArePrependedForRangeDistribution() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c1))", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + // should automatically prepend partition columns to the ordering + sql("ALTER TABLE %s WRITE ORDERED BY c1, c2", tableName); + + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testSortOrderIncludesPartitionColumns() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c1))", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + // should succeed with a correct sort order + sql("ALTER TABLE %s WRITE ORDERED BY bucket(2, c3), c1, c2", tableName); + + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testHashDistributionOnBucketedColumn() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c1))", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + // should automatically prepend partition columns to the local ordering after hash distribution + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION ORDERED BY c1, c2", tableName); + + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testDisabledDistributionAndOrdering() { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c1))", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + // should fail if ordering is disabled + assertThatThrownBy( + () -> + inputDF + .writeTo(tableName) + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") + .option(SparkWriteOptions.FANOUT_ENABLED, "false") + .append()) + .cause() + .isInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Incoming records violate the writer assumption that records are clustered by spec " + + "and by partition within each spec. Either cluster the incoming records or switch to fanout writers."); + } + + @TestTemplate + public void testDefaultSortOnDecimalBucketedColumn() { + sql( + "CREATE TABLE %s (c1 INT, c2 DECIMAL(20, 2)) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c2))", + tableName); + + sql("INSERT INTO %s VALUES (1, 20.2), (2, 40.2), (3, 60.2)", tableName); + + List expected = + ImmutableList.of( + row(1, new BigDecimal("20.20")), + row(2, new BigDecimal("40.20")), + row(3, new BigDecimal("60.20"))); + + assertEquals("Rows must match", expected, sql("SELECT * FROM %s ORDER BY c1", tableName)); + } + + @TestTemplate + public void testDefaultSortOnStringBucketedColumn() { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c2))", + tableName); + + sql("INSERT INTO %s VALUES (1, 'A'), (2, 'B')", tableName); + + List expected = ImmutableList.of(row(1, "A"), row(2, "B")); + + assertEquals("Rows must match", expected, sql("SELECT * FROM %s ORDER BY c1", tableName)); + } + + @TestTemplate + public void testDefaultSortOnBinaryBucketedColumn() { + sql( + "CREATE TABLE %s (c1 INT, c2 Binary) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c2))", + tableName); + + sql("INSERT INTO %s VALUES (1, X'A1B1'), (2, X'A2B2')", tableName); + + byte[] bytes1 = new byte[] {-95, -79}; + byte[] bytes2 = new byte[] {-94, -78}; + List expected = ImmutableList.of(row(1, bytes1), row(2, bytes2)); + + assertEquals("Rows must match", expected, sql("SELECT * FROM %s ORDER BY c1", tableName)); + } + + @TestTemplate + public void testDefaultSortOnDecimalTruncatedColumn() { + sql( + "CREATE TABLE %s (c1 INT, c2 DECIMAL(20, 2)) " + + "USING iceberg " + + "PARTITIONED BY (truncate(2, c2))", + tableName); + + sql("INSERT INTO %s VALUES (1, 20.2), (2, 40.2)", tableName); + + List expected = + ImmutableList.of(row(1, new BigDecimal("20.20")), row(2, new BigDecimal("40.20"))); + + assertEquals("Rows must match", expected, sql("SELECT * FROM %s ORDER BY c1", tableName)); + } + + @TestTemplate + public void testDefaultSortOnLongTruncatedColumn() { + sql( + "CREATE TABLE %s (c1 INT, c2 BIGINT) " + + "USING iceberg " + + "PARTITIONED BY (truncate(2, c2))", + tableName); + + sql("INSERT INTO %s VALUES (1, 22222222222222), (2, 444444444444)", tableName); + + List expected = ImmutableList.of(row(1, 22222222222222L), row(2, 444444444444L)); + + assertEquals("Rows must match", expected, sql("SELECT * FROM %s ORDER BY c1", tableName)); + } + + @TestTemplate + public void testRangeDistributionWithQuotedColumnNames() throws NoSuchTableException { + sql( + "CREATE TABLE %s (`c.1` INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, `c.1`))", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = + ds.selectExpr("c1 as `c.1`", "c2", "c3").coalesce(1).sortWithinPartitions("`c.1`"); + + sql("ALTER TABLE %s WRITE ORDERED BY `c.1`, c2", tableName); + + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java new file mode 100644 index 000000000000..b37422beacf4 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteDataFilesProcedure.java @@ -0,0 +1,1323 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.LongStream; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.EnvironmentContext; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionStatistics; +import org.apache.iceberg.PartitionStatsHandler; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.NamedReference; +import org.apache.iceberg.expressions.Zorder; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.ExtendedParser; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkTableCache; +import org.apache.iceberg.spark.SystemFunctionPushDownHelper; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewriteDataFilesProcedure extends ExtensionsTestBase { + + private static final String QUOTED_SPECIAL_CHARS_TABLE_NAME = "`table:with.special:chars`"; + + @BeforeAll + public static void setupSpark() { + // disable AQE as tests assume that writes generate a particular number of files + spark.conf().set(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false"); + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + } + + @TestTemplate + public void testFilterCaseSensitivity() { + createTable(); + insertData(10); + sql("set %s = false", SQLConf.CASE_SENSITIVE().key()); + List expectedRecords = currentData(); + List output = + sql( + "CALL %s.system.rewrite_data_files(table=>'%s', where=>'C1 > 0')", + catalogName, tableIdent); + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testZOrderSortExpression() { + List order = + ExtendedParser.parseSortOrder(spark, "c1, zorder(c2, c3)"); + assertThat(order).as("Should parse 2 order fields").hasSize(2); + assertThat(((NamedReference) order.get(0).term()).name()) + .as("First field should be a ref") + .isEqualTo("c1"); + assertThat(order.get(1).term()).as("Second field should be zorder").isInstanceOf(Zorder.class); + } + + @TestTemplate + public void testRewriteDataFilesInEmptyTable() { + createTable(); + List output = sql("CALL %s.system.rewrite_data_files('%s')", catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(0, 0, 0L, 0, 0)), output); + } + + @TestTemplate + public void testRewriteDataFilesOnPartitionTable() { + createPartitionTable(); + // create 5 files for each partition (c2 = 'foo' and c2 = 'bar') + insertData(10); + List expectedRecords = currentData(); + + List output = + sql("CALL %s.system.rewrite_data_files(table => '%s')", catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 2 data files (one per partition) ", + row(10, 2), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testPartitionStatsIncrementalCompute() throws IOException { + createPartitionTable(); + // create 5 files for each partition (c2 = 'foo' and c2 = 'bar') + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsBeforeCompaction; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { + statsBeforeCompaction = Lists.newArrayList(recordIterator); + } + + sql("CALL %s.system.rewrite_data_files(table => '%s')", catalogName, tableIdent); + + table.refresh(); + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsAfterCompaction; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { + statsAfterCompaction = Lists.newArrayList(recordIterator); + } + + for (int index = 0; index < statsBeforeCompaction.size(); index++) { + PartitionStatistics statsAfter = statsAfterCompaction.get(index); + PartitionStatistics statsBefore = statsBeforeCompaction.get(index); + + assertThat(statsAfter.partition()).isEqualTo(statsBefore.partition()); + // data count should match after compaction + assertThat(statsAfter.dataRecordCount()).isEqualTo(statsBefore.dataRecordCount()); + // file count should not match as new file count will be one after compaction + assertThat(statsAfter.dataFileCount()).isNotEqualTo(statsBefore.dataFileCount()); + } + } + + @TestTemplate + public void testRewriteDataFilesOnNonPartitionTable() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + List expectedRecords = currentData(); + + List output = + sql("CALL %s.system.rewrite_data_files(table => '%s')", catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithOptions() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + List expectedRecords = currentData(); + + // set the min-input-files = 12, instead of default 5 to skip compacting the files. + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', options => map('min-input-files','12'))", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 0 data files and add 0 data files", + ImmutableList.of(row(0, 0, 0L, 0, 0)), + output); + + List actualRecords = currentData(); + assertEquals("Data should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithSortStrategy() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + List expectedRecords = currentData(); + + // set sort_order = c1 DESC LAST + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', " + + "strategy => 'sort', sort_order => 'c1 DESC NULLS LAST')", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithSortStrategyAndMultipleShufflePartitionsPerFile() { + createTable(); + insertData(10 /* file count */); + + List output = + sql( + "CALL %s.system.rewrite_data_files(" + + " table => '%s', " + + " strategy => 'sort', " + + " sort_order => 'c1', " + + " options => map('shuffle-partitions-per-file', '2'))", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + // as there is only one small output file, validate the query ordering (it will not change) + ImmutableList expectedRows = + ImmutableList.of( + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null)); + assertEquals("Should have expected rows", expectedRows, sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testRewriteDataFilesWithZOrder() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + + // set z_order = c1,c2 + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', " + + "strategy => 'sort', sort_order => 'zorder(c1,c2)')", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + // Due to Z_order, the data written will be in the below order. + // As there is only one small output file, we can validate the query ordering (as it will not + // change). + ImmutableList expectedRows = + ImmutableList.of( + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null)); + assertEquals("Should have expected rows", expectedRows, sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testRewriteDataFilesWithZOrderNullBinaryColumn() { + sql("CREATE TABLE %s (c1 int, c2 string, c3 binary) USING iceberg", tableName); + + for (int i = 0; i < 5; i++) { + sql("INSERT INTO %s values (1, 'foo', null), (2, 'bar', null)", tableName); + } + + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', " + + "strategy => 'sort', sort_order => 'zorder(c2,c3)')", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + assertThat(output.get(0)).hasSize(5); + assertThat(snapshotSummary()) + .containsEntry(SnapshotSummary.REMOVED_FILE_SIZE_PROP, String.valueOf(output.get(0)[2])); + assertThat(sql("SELECT * FROM %s", tableName)) + .containsExactly( + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null)); + } + + @TestTemplate + public void testRewriteDataFilesWithZOrderAndMultipleShufflePartitionsPerFile() { + createTable(); + insertData(10 /* file count */); + + List output = + sql( + "CALL %s.system.rewrite_data_files(" + + " table => '%s', " + + "strategy => 'sort', " + + " sort_order => 'zorder(c1, c2)', " + + " options => map('shuffle-partitions-per-file', '2'))", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + // due to z-ordering, the data will be written in the below order + // as there is only one small output file, validate the query ordering (it will not change) + ImmutableList expectedRows = + ImmutableList.of( + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(2, "bar", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null), + row(1, "foo", null)); + assertEquals("Should have expected rows", expectedRows, sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testRewriteDataFilesWithFilter() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + List expectedRecords = currentData(); + + // select only 5 files for compaction (files that may have c1 = 1) + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => 'c1 = 1 and c2 is not null')", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 5 data files (containing c1 = 1) and add 1 data files", + row(5, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithDeterministicTrueFilter() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + List expectedRecords = currentData(); + // select all 10 files for compaction + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', where => '1=1')", + catalogName, tableIdent); + assertEquals( + "Action should rewrite 10 data files and add 1 data files", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithDeterministicFalseFilter() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + List expectedRecords = currentData(); + // select no files for compaction + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', where => '0=1')", + catalogName, tableIdent); + assertEquals( + "Action should rewrite 0 data files and add 0 data files", + row(0, 0), + Arrays.copyOf(output.get(0), 2)); + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithFilterOnPartitionTable() { + createPartitionTable(); + // create 5 files for each partition (c2 = 'foo' and c2 = 'bar') + insertData(10); + List expectedRecords = currentData(); + + // select only 5 files for compaction (files in the partition c2 = 'bar') + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c2 = \"bar\"')", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 5 data files from single matching partition" + + "(containing c2 = bar) and add 1 data files", + row(5, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithFilterOnOnBucketExpression() { + // currently spark session catalog only resolve to v1 functions instead of desired v2 functions + // https://github.com/apache/spark/blob/branch-3.4/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala#L2070-L2083 + assumeThat(catalogName).isNotEqualTo(SparkCatalogConfig.SPARK_SESSION.catalogName()); + createBucketPartitionTable(); + // create 5 files for each partition (c2 = 'foo' and c2 = 'bar') + insertData(10); + List expectedRecords = currentData(); + + // select only 5 files for compaction (files in the partition c2 = 'bar') + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => '%s.system.bucket(2, c2) = 0')", + catalogName, tableIdent, catalogName); + + assertEquals( + "Action should rewrite 5 data files from single matching partition" + + "(containing bucket(c2) = 0) and add 1 data files", + row(5, 1), + row(output.get(0)[0], output.get(0)[1])); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithInFilterOnPartitionTable() { + createPartitionTable(); + // create 5 files for each partition (c2 = 'foo' and c2 = 'bar') + insertData(10); + List expectedRecords = currentData(); + + // select only 5 files for compaction (files in the partition c2 in ('bar')) + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c2 in (\"bar\")')", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 5 data files from single matching partition" + + "(containing c2 = bar) and add 1 data files", + row(5, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteDataFilesWithAllPossibleFilters() { + createPartitionTable(); + // create 5 files for each partition (c2 = 'foo' and c2 = 'bar') + insertData(10); + + // Pass the literal value which is not present in the data files. + // So that parsing can be tested on a same dataset without actually compacting the files. + + // EqualTo + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 = 3')", + catalogName, tableIdent); + // GreaterThan + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 > 3')", + catalogName, tableIdent); + // GreaterThanOrEqual + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 >= 3')", + catalogName, tableIdent); + // LessThan + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 < 0')", + catalogName, tableIdent); + // LessThanOrEqual + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 <= 0')", + catalogName, tableIdent); + // In + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 in (3,4,5)')", + catalogName, tableIdent); + // IsNull + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 is null')", + catalogName, tableIdent); + // IsNotNull + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c3 is not null')", + catalogName, tableIdent); + // And + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 = 3 and c2 = \"bar\"')", + catalogName, tableIdent); + // Or + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 = 3 or c1 = 5')", + catalogName, tableIdent); + // Not + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c1 not in (1,2)')", + catalogName, tableIdent); + // StringStartsWith + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + " where => 'c2 like \"%s\"')", + catalogName, tableIdent, "car%"); + // TODO: Enable when org.apache.iceberg.spark.SparkFilters have implementations for + // StringEndsWith & StringContains + // StringEndsWith + // sql("CALL %s.system.rewrite_data_files(table => '%s'," + + // " where => 'c2 like \"%s\"')", catalogName, tableIdent, "%car"); + // StringContains + // sql("CALL %s.system.rewrite_data_files(table => '%s'," + + // " where => 'c2 like \"%s\"')", catalogName, tableIdent, "%car%"); + } + + @TestTemplate + public void testRewriteDataFilesWithPossibleV2Filters() { + // currently spark session catalog only resolve to v1 functions instead of desired v2 functions + // https://github.com/apache/spark/blob/branch-3.4/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala#L2070-L2083 + assumeThat(catalogName).isNotEqualTo(SparkCatalogConfig.SPARK_SESSION.catalogName()); + + SystemFunctionPushDownHelper.createPartitionedTable(spark, tableName, "id"); + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => '%s.system.bucket(2, data) >= 0')", + catalogName, tableIdent, catalogName); + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => '%s.system.truncate(4, id) >= 1')", + catalogName, tableIdent, catalogName); + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => '%s.system.years(ts) >= 1')", + catalogName, tableIdent, catalogName); + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => '%s.system.months(ts) >= 1')", + catalogName, tableIdent, catalogName); + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => '%s.system.days(ts) >= date(\"2023-01-01\")')", + catalogName, tableIdent, catalogName); + sql( + "CALL %s.system.rewrite_data_files(table => '%s'," + + " where => '%s.system.hours(ts) >= 1')", + catalogName, tableIdent, catalogName); + } + + @TestTemplate + public void testRewriteDataFilesWithInvalidInputs() { + createTable(); + // create 2 files under non-partitioned table + insertData(2); + + // Test for invalid strategy + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', options => map('min-input-files','2'), " + + "strategy => 'temp')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("unsupported strategy: temp. Only binpack or sort is supported"); + + // Test for sort_order with binpack strategy + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', strategy => 'binpack', " + + "sort_order => 'c1 ASC NULLS FIRST')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot set rewrite mode, it has already been set to "); + + // Test for sort strategy without any (default/user defined) sort_order + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', strategy => 'sort')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot sort data without a valid sort order"); + + // Test for sort_order with invalid null order + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', strategy => 'sort', " + + "sort_order => 'c1 ASC none')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unable to parse sortOrder: c1 ASC none"); + + // Test for sort_order with invalid sort direction + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', strategy => 'sort', " + + "sort_order => 'c1 none NULLS FIRST')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unable to parse sortOrder: c1 none NULLS FIRST"); + + // Test for sort_order with invalid column name + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', strategy => 'sort', " + + "sort_order => 'col1 DESC NULLS FIRST')", + catalogName, tableIdent)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith("Cannot find field 'col1' in struct:"); + + // Test with invalid filter column col1 + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', " + "where => 'col1 = 3')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse predicates in where option: col1 = 3"); + + // Test for z_order with invalid column name + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', strategy => 'sort', " + + "sort_order => 'zorder(col1)')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot find column 'col1' in table schema (case sensitive = false): " + + "struct<1: c1: optional int, 2: c2: optional string, 3: c3: optional string>"); + + // Test for z_order with sort_order + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', strategy => 'sort', " + + "sort_order => 'c1,zorder(c2,c3)')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot mix identity sort columns and a Zorder sort expression:" + " c1,zorder(c2,c3)"); + } + + @TestTemplate + public void testInvalidCasesForRewriteDataFiles() { + assertThatThrownBy( + () -> sql("CALL %s.system.rewrite_data_files('n', table => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[DUPLICATE_ROUTINE_PARAMETER_ASSIGNMENT.BOTH_POSITIONAL_AND_NAMED] Call to routine `rewrite_data_files` is invalid because it includes multiple argument assignments to the same parameter name `table`. A positional argument and named argument both referred to the same parameter. Please remove the named argument referring to this parameter. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.custom.rewrite_data_files('n', 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`rewrite_data_files`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.rewrite_data_files()", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rewrite_data_files` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> sql("CALL %s.system.rewrite_data_files(table => 't', table => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageEndingWith( + "[DUPLICATE_ROUTINE_PARAMETER_ASSIGNMENT.DOUBLE_NAMED_ARGUMENT_REFERENCE] Call to routine `rewrite_data_files` is invalid because it includes multiple argument assignments to the same parameter name `table`. More than one named argument referred to the same parameter. Please assign a value only once. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.rewrite_data_files('')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + } + + @TestTemplate + public void testBinPackTableWithSpecialChars() { + assumeThat(catalogName).isEqualTo(SparkCatalogConfig.HADOOP.catalogName()); + + TableIdentifier identifier = + TableIdentifier.of("default", QUOTED_SPECIAL_CHARS_TABLE_NAME.replaceAll("`", "")); + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) USING iceberg", + tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + insertData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME), 10); + + List expectedRecords = currentData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', where => 'c2 is not null')", + catalogName, tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isEqualTo( + Long.valueOf(snapshotSummary(identifier).get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + Table table = validationCatalog.loadTable(identifier); + assertThat(SparkTableCache.get().tables()) + .as("Table cache must not contain the test table") + .noneMatch(cachedTable -> cachedTable.uuid().equals(table.uuid())); + } + + @TestTemplate + public void testSortTableWithSpecialChars() { + assumeThat(catalogName).isEqualTo(SparkCatalogConfig.HADOOP.catalogName()); + + TableIdentifier identifier = + TableIdentifier.of("default", QUOTED_SPECIAL_CHARS_TABLE_NAME.replaceAll("`", "")); + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) USING iceberg", + tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + insertData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME), 10); + + List expectedRecords = currentData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + List output = + sql( + "CALL %s.system.rewrite_data_files(" + + " table => '%s'," + + " strategy => 'sort'," + + " sort_order => 'c1'," + + " where => 'c2 is not null')", + catalogName, tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo( + Long.valueOf(snapshotSummary(identifier).get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + Table table = validationCatalog.loadTable(identifier); + assertThat(SparkTableCache.get().tables()) + .as("Table cache must not contain the test table") + .noneMatch(cachedTable -> cachedTable.uuid().equals(table.uuid())); + } + + @TestTemplate + public void testZOrderTableWithSpecialChars() { + assumeThat(catalogName).isEqualTo(SparkCatalogConfig.HADOOP.catalogName()); + + TableIdentifier identifier = + TableIdentifier.of("default", QUOTED_SPECIAL_CHARS_TABLE_NAME.replaceAll("`", "")); + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) USING iceberg", + tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + insertData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME), 10); + + List expectedRecords = currentData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + List output = + sql( + "CALL %s.system.rewrite_data_files(" + + " table => '%s'," + + " strategy => 'sort'," + + " sort_order => 'zorder(c1, c2)'," + + " where => 'c2 is not null')", + catalogName, tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo( + Long.valueOf(snapshotSummary(identifier).get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(tableName(QUOTED_SPECIAL_CHARS_TABLE_NAME)); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + Table table = validationCatalog.loadTable(identifier); + assertThat(SparkTableCache.get().tables()) + .as("Table cache must not contain the test table") + .noneMatch(cachedTable -> cachedTable.uuid().equals(table.uuid())); + } + + @TestTemplate + public void testDefaultSortOrder() { + createTable(); + // add a default sort order for a table + sql("ALTER TABLE %s WRITE ORDERED BY c2", tableName); + + // this creates 2 files under non-partitioned table due to sort order. + insertData(10); + List expectedRecords = currentData(); + + // When the strategy is set to 'sort' but the sort order is not specified, + // use table's default sort order. + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', " + + "strategy => 'sort', " + + "options => map('min-input-files','2'))", + catalogName, tableIdent); + + assertEquals( + "Action should rewrite 2 data files and add 1 data files", + row(2, 1), + Arrays.copyOf(output.get(0), 2)); + // verify rewritten bytes separately + assertThat(output.get(0)).hasSize(5); + assertThat(output.get(0)[2]) + .isInstanceOf(Long.class) + .isEqualTo(Long.valueOf(snapshotSummary().get(SnapshotSummary.REMOVED_FILE_SIZE_PROP))); + + List actualRecords = currentData(); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + } + + @TestTemplate + public void testRewriteWithUntranslatedOrUnconvertedFilter() { + createTable(); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', where => 'substr(encode(c2, \"utf-8\"), 2) = \"fo\"')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot translate Spark expression"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_data_files(table => '%s', where => 'substr(c2, 2) = \"fo\"')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot convert Spark filter"); + } + + @TestTemplate + public void testRewriteDataFilesSummary() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + sql("CALL %s.system.rewrite_data_files(table => '%s')", catalogName, tableIdent); + + Map summary = snapshotSummary(); + assertThat(summary) + .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) + .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") + .hasEntrySatisfying( + EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("4.1")); + } + + @TestTemplate + public void testRewriteDataFilesPreservesLineage() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) USING iceberg TBLPROPERTIES('format-version' = '3')", + tableName); + List records = Lists.newArrayList(); + int numRecords = 10; + for (int i = 0; i < numRecords; i++) { + records.add(new ThreeColumnRecord(i, null, null)); + } + + spark + .createDataFrame(records, ThreeColumnRecord.class) + .repartition(10) + .writeTo(tableName) + .append(); + List expectedRowsWithLineage = + sql( + "SELECT c1, _row_id, _last_updated_sequence_number FROM %s ORDER BY _row_id", + tableName); + List rowIds = + expectedRowsWithLineage.stream() + .map(record -> (Long) record[1]) + .collect(Collectors.toList()); + List sequenceNumbers = + expectedRowsWithLineage.stream() + .map(record -> (Long) record[2]) + .collect(Collectors.toList()); + assertThat(rowIds) + .isEqualTo(LongStream.range(0, numRecords).boxed().collect(Collectors.toList())); + assertThat(sequenceNumbers).isEqualTo(Collections.nCopies(numRecords, 1L)); + + List output = + sql("CALL %s.system.rewrite_data_files(table => '%s')", catalogName, tableIdent); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + List rowsWithLineageAfterRewrite = + sql( + "SELECT c1, _row_id, _last_updated_sequence_number FROM %s ORDER BY _row_id", + tableName); + assertEquals( + "Rows with lineage before rewrite should equal rows with lineage after rewrite", + expectedRowsWithLineage, + rowsWithLineageAfterRewrite); + } + + private void createTable() { + sql("CREATE TABLE %s (c1 int, c2 string, c3 string) USING iceberg", tableName); + } + + private void createPartitionTable() { + createPartitionTable( + ImmutableMap.of( + TableProperties.WRITE_DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_NONE)); + } + + private void createPartitionTable(Map properties) { + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) " + + "USING iceberg " + + "PARTITIONED BY (c2)", + tableName); + properties.forEach( + (prop, value) -> + this.sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + new Object[] {this.tableName, prop, value})); + } + + private void createBucketPartitionTable() { + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c2)) " + + "TBLPROPERTIES ('%s' '%s')", + tableName, + TableProperties.WRITE_DISTRIBUTION_MODE, + TableProperties.WRITE_DISTRIBUTION_MODE_NONE); + } + + private void insertData(int filesCount) { + insertData(tableName, filesCount); + } + + private void insertData(String table, int filesCount) { + ThreeColumnRecord record1 = new ThreeColumnRecord(1, "foo", null); + ThreeColumnRecord record2 = new ThreeColumnRecord(2, "bar", null); + + List records = Lists.newArrayList(); + IntStream.range(0, filesCount / 2) + .forEach( + i -> { + records.add(record1); + records.add(record2); + }); + + Dataset df = + spark.createDataFrame(records, ThreeColumnRecord.class).repartition(filesCount); + try { + df.writeTo(table).append(); + } catch (org.apache.spark.sql.catalyst.analysis.NoSuchTableException e) { + throw new RuntimeException(e); + } + } + + private Map snapshotSummary() { + return snapshotSummary(tableIdent); + } + + private Map snapshotSummary(TableIdentifier tableIdentifier) { + return validationCatalog.loadTable(tableIdentifier).currentSnapshot().summary(); + } + + private List currentData() { + return currentData(tableName); + } + + private List currentData(String table) { + return rowsToJava(spark.sql("SELECT * FROM " + table + " order by c1, c2, c3").collectAsList()); + } + + @TestTemplate + public void testRewriteDataFilesOnBranch() { + createTable(); + insertData(10); + + String branchName = "testBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testRewriteDataFilesToNullBranchFails() { + createTable(); + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThatThrownBy(() -> SparkActions.get(spark).rewriteDataFiles(table).toBranch(null)) + .as("Invalid branch") + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid branch name: null"); + } + + @TestTemplate + public void testRewriteDataFilesOnBranchWithFilter() { + createPartitionTable(); + insertData(10); + + String branchName = "filteredBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + List expectedRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + // Get snapshot IDs before rewrite + Table table = validationCatalog.loadTable(tableIdent); + long mainSnapshotId = table.currentSnapshot().snapshotId(); + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + + // Call rewrite_data_files on the branch with filter (select only partition c2 = 'bar') + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s', where => 'c2 = \"bar\"')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 5 data files from single matching partition" + + "(containing c2 = bar) and add 1 data file", + row(5, 1), + Arrays.copyOf(output.get(0), 2)); + + // Verify branch data is preserved after compaction + List actualRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals("Data after compaction should not change", expectedRecords, actualRecords); + + // Verify branch snapshot changed after rewrite + table.refresh(); + assertThat(table.refs().get(branchName).snapshotId()) + .as("Branch snapshot should be updated when files are rewritten") + .isNotEqualTo(branchSnapshotId); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot should remain unchanged") + .isEqualTo(mainSnapshotId); + } + + @TestTemplate + public void testBranchCompactionDoesNotAffectMain() { + createTable(); + // create 10 files under non-partitioned table + insertData(10); + + Table table = validationCatalog.loadTable(tableIdent); + + // Create branch from current main state + String branchName = "compactionBranch"; + sql("ALTER TABLE %s CREATE BRANCH %s", tableName, branchName); + + // Add more data to MAIN to make it diverge from branch + insertData(tableName, 10); + + // Refresh to get new main snapshot after divergence + table.refresh(); + long mainSnapshotAfterDivergence = table.currentSnapshot().snapshotId(); + List expectedMainRecords = currentData(); + + // Get branch data before compaction + List expectedBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + + long branchSnapshotBeforeCompaction = table.refs().get(branchName).snapshotId(); + + // Verify that branch and main have diverged + assertThat(branchSnapshotBeforeCompaction) + .as("Branch and main should have different snapshots") + .isNotEqualTo(mainSnapshotAfterDivergence); + + List output = + sql( + "CALL %s.system.rewrite_data_files(table => '%s', branch => '%s')", + catalogName, tableName, branchName); + + assertEquals( + "Action should rewrite 10 data files and add 1 data file", + row(10, 1), + Arrays.copyOf(output.get(0), 2)); + + table.refresh(); + + // Verify main snapshot unchanged + assertThat(table.currentSnapshot().snapshotId()) + .as("Main snapshot ID must remain unchanged after branch compaction") + .isEqualTo(mainSnapshotAfterDivergence); + + // Verify main data unchanged + List actualMainRecords = currentData(); + assertEquals( + "Main data after compaction should not change", expectedMainRecords, actualMainRecords); + + // Verify branch data unchanged + List actualBranchRecords = + rowsToJava( + spark + .sql( + String.format( + "SELECT * FROM %s.branch_%s ORDER BY c1, c2, c3", tableName, branchName)) + .collectAsList()); + assertEquals( + "Branch data after compaction should not change", + expectedBranchRecords, + actualBranchRecords); + + // Verify branch snapshot changed + long branchSnapshotAfterCompaction = table.refs().get(branchName).snapshotId(); + assertThat(branchSnapshotAfterCompaction) + .as("Branch snapshot must be updated after compaction") + .isNotEqualTo(branchSnapshotBeforeCompaction); + + // Verify the new branch snapshot is a child of the previous branch snapshot + assertThat(table.snapshot(branchSnapshotAfterCompaction).parentId()) + .as("New branch snapshot must be a child of the previous branch snapshot") + .isEqualTo(branchSnapshotBeforeCompaction); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java new file mode 100644 index 000000000000..ffc0383362ce --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteManifestsProcedure.java @@ -0,0 +1,542 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.sql.Date; +import java.sql.Timestamp; +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionStatistics; +import org.apache.iceberg.PartitionStatsHandler; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewriteManifestsProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testRewriteManifestsInEmptyTable() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + List output = sql("CALL %s.system.rewrite_manifests('%s')", catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(0, 0)), output); + } + + @TestTemplate + public void testRewriteLargeManifests() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + + sql("ALTER TABLE %s SET TBLPROPERTIES ('commit.manifest.target-size-bytes' '1')", tableName); + + List output = sql("CALL %s.system.rewrite_manifests('%s')", catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(1, 4)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 4 manifests") + .hasSize(4); + } + + @TestTemplate + public void testRewriteManifestsNoOp() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + + List output = sql("CALL %s.system.rewrite_manifests('%s')", catalogName, tableIdent); + // should not rewrite any manifests for no-op (output of rewrite is same as before and after) + assertEquals("Procedure output must match", ImmutableList.of(row(0, 0)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + } + + @TestTemplate + public void testRewriteLargeManifestsOnDatePartitionedTableWithJava8APIEnabled() { + withSQLConf( + ImmutableMap.of("spark.sql.datetime.java8API.enabled", "true"), + () -> { + sql( + "CREATE TABLE %s (id INTEGER, name STRING, dept STRING, ts DATE) USING iceberg PARTITIONED BY (ts)", + tableName); + try { + spark + .createDataFrame( + ImmutableList.of( + RowFactory.create(1, "John Doe", "hr", Date.valueOf("2021-01-01")), + RowFactory.create(2, "Jane Doe", "hr", Date.valueOf("2021-01-02")), + RowFactory.create(3, "Matt Doe", "hr", Date.valueOf("2021-01-03")), + RowFactory.create(4, "Will Doe", "facilities", Date.valueOf("2021-01-04"))), + spark.table(tableName).schema()) + .writeTo(tableName) + .append(); + } catch (NoSuchTableException e) { + // not possible as we already created the table above. + throw new RuntimeException(e); + } + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('commit.manifest.target-size-bytes' '1')", + tableName); + + List output = + sql("CALL %s.system.rewrite_manifests('%s')", catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(1, 4)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 4 manifests") + .hasSize(4); + }); + } + + @TestTemplate + public void testRewriteLargeManifestsOnTimestampPartitionedTableWithJava8APIEnabled() { + withSQLConf( + ImmutableMap.of("spark.sql.datetime.java8API.enabled", "true"), + () -> { + sql( + "CREATE TABLE %s (id INTEGER, name STRING, dept STRING, ts TIMESTAMP) USING iceberg PARTITIONED BY (ts)", + tableName); + try { + spark + .createDataFrame( + ImmutableList.of( + RowFactory.create( + 1, "John Doe", "hr", Timestamp.valueOf("2021-01-01 00:00:00")), + RowFactory.create( + 2, "Jane Doe", "hr", Timestamp.valueOf("2021-01-02 00:00:00")), + RowFactory.create( + 3, "Matt Doe", "hr", Timestamp.valueOf("2021-01-03 00:00:00")), + RowFactory.create( + 4, "Will Doe", "facilities", Timestamp.valueOf("2021-01-04 00:00:00"))), + spark.table(tableName).schema()) + .writeTo(tableName) + .append(); + } catch (NoSuchTableException e) { + // not possible as we already created the table above. + throw new RuntimeException(e); + } + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('commit.manifest.target-size-bytes' '1')", + tableName); + + List output = + sql("CALL %s.system.rewrite_manifests('%s')", catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(1, 4)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 4 manifests") + .hasSize(4); + }); + } + + @TestTemplate + public void testRewriteSmallManifestsWithSnapshotIdInheritance() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s')", + tableName, SNAPSHOT_ID_INHERITANCE_ENABLED, "true"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + sql("INSERT INTO TABLE %s VALUES (3, 'c')", tableName); + sql("INSERT INTO TABLE %s VALUES (4, 'd')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 4 manifests") + .hasSize(4); + + List output = + sql("CALL %s.system.rewrite_manifests(table => '%s')", catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(4, 1)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + } + + @TestTemplate + public void testRewriteSmallManifestsWithoutCaching() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 2 manifest") + .hasSize(2); + + List output = + sql( + "CALL %s.system.rewrite_manifests(use_caching => false, table => '%s')", + catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(2, 1)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + } + + @Disabled("Spark SQL does not support case insensitive for named arguments") + public void testRewriteManifestsCaseInsensitiveArgs() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 2 manifests") + .hasSize(2); + + List output = + sql( + "CALL %s.system.rewrite_manifests(usE_cAcHiNg => false, tAbLe => '%s')", + catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(2, 1)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + } + + @TestTemplate + public void testInvalidRewriteManifestsCases() { + assertThatThrownBy( + () -> sql("CALL %s.system.rewrite_manifests('n', table => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[DUPLICATE_ROUTINE_PARAMETER_ASSIGNMENT.BOTH_POSITIONAL_AND_NAMED] Call to routine `rewrite_manifests` is invalid because it includes multiple argument assignments to the same parameter name `table`. A positional argument and named argument both referred to the same parameter. Please remove the named argument referring to this parameter. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.custom.rewrite_manifests('n', 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`rewrite_manifests`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.rewrite_manifests()", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rewrite_manifests` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.rewrite_manifests('n', 2.2)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE] Cannot resolve CALL due to data type mismatch: The second parameter requires the \"BOOLEAN\" type, however \"2.2\" has the type \"DECIMAL(2,1)\". SQLSTATE: 42K09"); + + assertThatThrownBy( + () -> sql("CALL %s.system.rewrite_manifests(table => 't', tAbLe => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[DUPLICATE_ROUTINE_PARAMETER_ASSIGNMENT.DOUBLE_NAMED_ARGUMENT_REFERENCE] Call to routine `rewrite_manifests` is invalid because it includes multiple argument assignments to the same parameter name `tAbLe`. More than one named argument referred to the same parameter. Please assign a value only once. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.rewrite_manifests('')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + } + + @TestTemplate + public void testReplacePartitionField() { + sql( + "CREATE TABLE %s (id int, ts timestamp, day_of_ts date) USING iceberg PARTITIONED BY (day_of_ts)", + tableName); + + sql("ALTER TABLE %s SET TBLPROPERTIES ('format-version' = '2')", tableName); + sql("ALTER TABLE %s REPLACE PARTITION FIELD day_of_ts WITH days(ts)\n", tableName); + sql( + "INSERT INTO %s VALUES (1, CAST('2022-01-01 10:00:00' AS TIMESTAMP), CAST('2022-01-01' AS DATE))", + tableName); + sql( + "INSERT INTO %s VALUES (2, CAST('2022-01-01 11:00:00' AS TIMESTAMP), CAST('2022-01-01' AS DATE))", + tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(1, Timestamp.valueOf("2022-01-01 10:00:00"), Date.valueOf("2022-01-01")), + row(2, Timestamp.valueOf("2022-01-01 11:00:00"), Date.valueOf("2022-01-01"))), + sql("SELECT * FROM %s WHERE ts < current_timestamp() order by 1 asc", tableName)); + + List output = + sql("CALL %s.system.rewrite_manifests(table => '%s')", catalogName, tableName); + assertEquals("Procedure output must match", ImmutableList.of(row(2, 1)), output); + + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(1, Timestamp.valueOf("2022-01-01 10:00:00"), Date.valueOf("2022-01-01")), + row(2, Timestamp.valueOf("2022-01-01 11:00:00"), Date.valueOf("2022-01-01"))), + sql("SELECT * FROM %s WHERE ts < current_timestamp() order by 1 asc", tableName)); + } + + @TestTemplate + public void testWriteManifestWithSpecId() { + sql( + "CREATE TABLE %s (id int, dt string, hr string) USING iceberg PARTITIONED BY (dt)", + tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('commit.manifest-merge.enabled' = 'false')", tableName); + + sql("INSERT INTO %s VALUES (1, '2024-01-01', '00')", tableName); + sql("INSERT INTO %s VALUES (2, '2024-01-01', '00')", tableName); + assertEquals( + "Should have 2 manifests and their partition spec id should be 0", + ImmutableList.of(row(0), row(0)), + sql("SELECT partition_spec_id FROM %s.manifests order by 1 asc", tableName)); + + sql("ALTER TABLE %s ADD PARTITION FIELD hr", tableName); + sql("INSERT INTO %s VALUES (3, '2024-01-01', '00')", tableName); + assertEquals( + "Should have 3 manifests and their partition spec id should be 0 and 1", + ImmutableList.of(row(0), row(0), row(1)), + sql("SELECT partition_spec_id FROM %s.manifests order by 1 asc", tableName)); + + List output = sql("CALL %s.system.rewrite_manifests('%s')", catalogName, tableIdent); + assertEquals("Nothing should be rewritten", ImmutableList.of(row(0, 0)), output); + + output = + sql( + "CALL %s.system.rewrite_manifests(table => '%s', spec_id => 0)", + catalogName, tableIdent); + assertEquals("There should be 2 manifests rewriten", ImmutableList.of(row(2, 1)), output); + assertEquals( + "Should have 2 manifests and their partition spec id should be 0 and 1", + ImmutableList.of(row(0), row(1)), + sql("SELECT partition_spec_id FROM %s.manifests order by 1 asc", tableName)); + } + + @TestTemplate + public void testRewriteManifestsWithSortBy() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string, category string) USING iceberg PARTITIONED BY (data, category)", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a', 'x')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b', 'y')", tableName); + sql("INSERT INTO TABLE %s VALUES (3, 'c', 'x')", tableName); + sql("INSERT INTO TABLE %s VALUES (4, 'd', 'y')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 4 manifests") + .hasSize(4); + + List output = + sql( + "CALL %s.system.rewrite_manifests(table => '%s', sort_by => array('category', 'data'))", + catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(4, 1)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + } + + @TestTemplate + public void testRewriteManifestsWithSortBySingleColumn() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string, category string) USING iceberg PARTITIONED BY (data, category)", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a', 'x')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b', 'y')", tableName); + sql("INSERT INTO TABLE %s VALUES (3, 'c', 'x')", tableName); + sql("INSERT INTO TABLE %s VALUES (4, 'd', 'y')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 4 manifests") + .hasSize(4); + + List output = + sql( + "CALL %s.system.rewrite_manifests(table => '%s', sort_by => array('category'))", + catalogName, tableIdent); + assertEquals("Procedure output must match", ImmutableList.of(row(4, 1)), output); + + table.refresh(); + + assertThat(table.currentSnapshot().allManifests(table.io())) + .as("Must have 1 manifest") + .hasSize(1); + } + + @TestTemplate + public void testRewriteManifestsWithInvalidSortBy() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_manifests(table => '%s', sort_by => array('nonexistent'))", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not found in current partition spec"); + } + + @TestTemplate + public void testRewriteManifestsWithEmptySortBy() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_manifests(table => '%s', sort_by => array())", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("sort_by must not be empty when provided"); + } + + @TestTemplate + public void testPartitionStatsIncrementalCompute() throws IOException { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg PARTITIONED BY (data)", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsBeforeRewrite; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { + statsBeforeRewrite = Lists.newArrayList(recordIterator); + } + + sql( + "CALL %s.system.rewrite_manifests(use_caching => false, table => '%s')", + catalogName, tableIdent); + + table.refresh(); + + table + .updatePartitionStatistics() + .setPartitionStatistics(PartitionStatsHandler.computeAndWriteStatsFile(table)) + .commit(); + + List statsAfterRewrite; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().scan()) { + statsAfterRewrite = Lists.newArrayList(recordIterator); + } + + for (int index = 0; index < statsBeforeRewrite.size(); index++) { + PartitionStatistics statsAfter = statsAfterRewrite.get(index); + PartitionStatistics statsBefore = statsBeforeRewrite.get(index); + + assertThat(statsAfter.partition()).isEqualTo(statsBefore.partition()); + // data count should match + assertThat(statsAfter.dataRecordCount()).isEqualTo(statsBefore.dataRecordCount()); + // file count should match + assertThat(statsAfter.dataFileCount()).isEqualTo(statsBefore.dataFileCount()); + } + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFiles.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFiles.java new file mode 100644 index 000000000000..ad49da87bedd --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFiles.java @@ -0,0 +1,418 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; +import static org.apache.iceberg.data.FileHelpers.encrypt; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.Closeable; +import java.io.IOException; +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewritePositionDeleteFiles.FileGroupRewriteResult; +import org.apache.iceberg.actions.RewritePositionDeleteFiles.Result; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.data.GenericFileWriterFactory; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestRewritePositionDeleteFiles extends ExtensionsTestBase { + + private static final Map CATALOG_PROPS = + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "cache-enabled", "false"); + + private static final String PARTITION_COL = "partition_col"; + private static final int NUM_DATA_FILES = 5; + private static final int ROWS_PER_DATA_FILE = 100; + private static final int DELETE_FILES_PER_PARTITION = 2; + private static final int DELETE_FILE_SIZE = 10; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, formatVersion = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + CATALOG_PROPS, + 2 + } + }; + } + + @Parameter(index = 3) + private int formatVersion; + + @AfterEach + public void cleanup() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testDatePartition() throws Exception { + createTable("date"); + Date baseDate = Date.valueOf("2023-01-01"); + insertData(i -> Date.valueOf(baseDate.toLocalDate().plusDays(i))); + testDanglingDelete(); + } + + @TestTemplate + public void testBooleanPartition() throws Exception { + createTable("boolean"); + insertData(i -> i % 2 == 0, 2); + testDanglingDelete(2); + } + + @TestTemplate + public void testTimestampPartition() throws Exception { + createTable("timestamp"); + Timestamp baseTimestamp = Timestamp.valueOf("2023-01-01 15:30:00"); + insertData(i -> Timestamp.valueOf(baseTimestamp.toLocalDateTime().plusDays(i))); + testDanglingDelete(); + } + + @TestTemplate + public void testTimestampNtz() throws Exception { + createTable("timestamp_ntz"); + LocalDateTime baseTimestamp = Timestamp.valueOf("2023-01-01 15:30:00").toLocalDateTime(); + insertData(baseTimestamp::plusDays); + testDanglingDelete(); + } + + @TestTemplate + public void testBytePartition() throws Exception { + createTable("byte"); + insertData(i -> i); + testDanglingDelete(); + } + + @TestTemplate + public void testDecimalPartition() throws Exception { + createTable("decimal(18, 10)"); + BigDecimal baseDecimal = new BigDecimal("1.0"); + insertData(i -> baseDecimal.add(new BigDecimal(i))); + testDanglingDelete(); + } + + @TestTemplate + public void testBinaryPartition() throws Exception { + createTable("binary"); + insertData(i -> java.nio.ByteBuffer.allocate(4).putInt(i).array()); + testDanglingDelete(); + } + + @TestTemplate + public void testCharPartition() throws Exception { + createTable("char(10)"); + insertData(Object::toString); + testDanglingDelete(); + } + + @TestTemplate + public void testVarcharPartition() throws Exception { + createTable("varchar(10)"); + insertData(Object::toString); + testDanglingDelete(); + } + + @TestTemplate + public void testIntPartition() throws Exception { + createTable("int"); + insertData(i -> i); + testDanglingDelete(); + } + + @TestTemplate + public void testDaysPartitionTransform() throws Exception { + createTable("timestamp", PARTITION_COL, String.format("days(%s)", PARTITION_COL)); + Timestamp baseTimestamp = Timestamp.valueOf("2023-01-01 15:30:00"); + insertData(i -> Timestamp.valueOf(baseTimestamp.toLocalDateTime().plusDays(i))); + testDanglingDelete(); + } + + @TestTemplate + public void testNullTransform() throws Exception { + createTable("int"); + insertData(i -> i == 0 ? null : 1, 2); + testDanglingDelete(2); + } + + @TestTemplate + public void testPartitionColWithDot() throws Exception { + String partitionColWithDot = "`partition.col`"; + createTable("int", partitionColWithDot, partitionColWithDot); + insertData(partitionColWithDot, i -> i, NUM_DATA_FILES); + testDanglingDelete(partitionColWithDot, NUM_DATA_FILES); + } + + private void testDanglingDelete() throws Exception { + testDanglingDelete(NUM_DATA_FILES); + } + + private void testDanglingDelete(int numDataFiles) throws Exception { + testDanglingDelete(PARTITION_COL, numDataFiles); + } + + private void testDanglingDelete(String partitionCol, int numDataFiles) throws Exception { + Table table = Spark3Util.loadIcebergTable(spark, tableName); + + List dataFiles = dataFiles(table); + assertThat(dataFiles).hasSize(numDataFiles); + + SparkActions.get(spark) + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + // write dangling delete files for 'old data files' + writePosDeletesForFiles(table, dataFiles); + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(numDataFiles * DELETE_FILES_PER_PARTITION); + + List expectedRecords = records(tableName, partitionCol); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("Remaining dangling deletes").isEmpty(); + checkResult(result, deleteFiles, Lists.newArrayList(), numDataFiles); + + List actualRecords = records(tableName, partitionCol); + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + private void createTable(String partitionType) { + createTable(partitionType, PARTITION_COL, PARTITION_COL); + } + + private void createTable(String partitionType, String partitionCol, String partitionTransform) { + sql( + "CREATE TABLE %s (id long, %s %s, c1 string, c2 string) " + + "USING iceberg " + + "PARTITIONED BY (%s) " + + "TBLPROPERTIES('format-version'='%d')", + tableName, partitionCol, partitionType, partitionTransform, formatVersion); + } + + private void insertData(Function partitionValueFunction) throws Exception { + insertData(partitionValueFunction, NUM_DATA_FILES); + } + + private void insertData(Function partitionValueFunction, int numDataFiles) + throws Exception { + insertData(PARTITION_COL, partitionValueFunction, numDataFiles); + } + + private void insertData( + String partitionCol, Function partitionValue, int numDataFiles) throws Exception { + for (int i = 0; i < numDataFiles; i++) { + Dataset df = + spark + .range(0, ROWS_PER_DATA_FILE) + .withColumn(partitionCol, lit(partitionValue.apply(i))) + .withColumn("c1", expr("CAST(id AS STRING)")) + .withColumn("c2", expr("CAST(id AS STRING)")); + appendAsFile(df); + } + } + + private void appendAsFile(Dataset df) throws Exception { + // ensure the schema is precise + StructType sparkSchema = spark.table(tableName).schema(); + spark.createDataFrame(df.rdd(), sparkSchema).coalesce(1).writeTo(tableName).append(); + } + + private void writePosDeletesForFiles(Table table, List files) throws IOException { + + Map> filesByPartition = + files.stream().collect(Collectors.groupingBy(ContentFile::partition)); + List deleteFiles = + Lists.newArrayListWithCapacity(DELETE_FILES_PER_PARTITION * filesByPartition.size()); + + for (Map.Entry> filesByPartitionEntry : + filesByPartition.entrySet()) { + + StructLike partition = filesByPartitionEntry.getKey(); + List partitionFiles = filesByPartitionEntry.getValue(); + + int deletesForPartition = partitionFiles.size() * DELETE_FILE_SIZE; + assertThat(deletesForPartition % DELETE_FILE_SIZE) + .as("Number of delete files per partition modulo number of data files in this partition") + .isEqualTo(0); + int deleteFileSize = deletesForPartition / DELETE_FILES_PER_PARTITION; + + int counter = 0; + List> deletes = Lists.newArrayList(); + for (DataFile partitionFile : partitionFiles) { + for (int deletePos = 0; deletePos < DELETE_FILE_SIZE; deletePos++) { + deletes.add(Pair.of(partitionFile.location(), (long) deletePos)); + counter++; + if (counter == deleteFileSize) { + // Dump to file and reset variables + OutputFile output = + Files.localOutput(temp.resolve(UUID.randomUUID().toString()).toFile()); + deleteFiles.add(writeDeleteFile(table, output, partition, deletes)); + counter = 0; + deletes.clear(); + } + } + } + } + + RowDelta rowDelta = table.newRowDelta(); + deleteFiles.forEach(rowDelta::addDeletes); + rowDelta.commit(); + } + + private DeleteFile writeDeleteFile( + Table table, OutputFile out, StructLike partition, List> deletes) + throws IOException { + FileFormat format = defaultFormat(table.properties()); + + PositionDeleteWriter writer = + new GenericFileWriterFactory.Builder(table) + .deleteFileFormat(format) + .build() + .newPositionDeleteWriter(encrypt(out), table.spec(), partition); + PositionDelete posDelete = PositionDelete.create(); + try (Closeable toClose = writer) { + for (Pair delete : deletes) { + writer.write(posDelete.set(delete.first(), delete.second(), null)); + } + } + + return writer.toDeleteFile(); + } + + private static FileFormat defaultFormat(Map properties) { + String formatString = properties.getOrDefault(DEFAULT_FILE_FORMAT, DEFAULT_FILE_FORMAT_DEFAULT); + return FileFormat.fromString(formatString); + } + + private List records(String table, String partitionCol) { + return rowsToJava( + spark.read().format("iceberg").load(table).sort(partitionCol, "id").collectAsList()); + } + + private long size(List deleteFiles) { + return deleteFiles.stream().mapToLong(DeleteFile::fileSizeInBytes).sum(); + } + + private List dataFiles(Table table) { + CloseableIterable tasks = table.newScan().includeColumnStats().planFiles(); + return Lists.newArrayList(CloseableIterable.transform(tasks, FileScanTask::file)); + } + + private List deleteFiles(Table table) { + Table deletesTable = + MetadataTableUtils.createMetadataTableInstance(table, MetadataTableType.POSITION_DELETES); + CloseableIterable tasks = deletesTable.newBatchScan().planFiles(); + return Lists.newArrayList( + CloseableIterable.transform(tasks, t -> ((PositionDeletesScanTask) t).file())); + } + + private void checkResult( + Result result, + List rewrittenDeletes, + List newDeletes, + int expectedGroups) { + assertThat(result.rewrittenDeleteFilesCount()) + .as("Rewritten delete files") + .isEqualTo(rewrittenDeletes.size()); + assertThat(result.addedDeleteFilesCount()) + .as("Added delete files") + .isEqualTo(newDeletes.size()); + assertThat(result.rewrittenBytesCount()) + .as("Rewritten delete bytes") + .isEqualTo(size(rewrittenDeletes)); + assertThat(result.addedBytesCount()).as("New Delete byte count").isEqualTo(size(newDeletes)); + + assertThat(result.rewriteResults()).as("Rewritten group count").hasSize(expectedGroups); + assertThat( + result.rewriteResults().stream() + .mapToInt(FileGroupRewriteResult::rewrittenDeleteFilesCount) + .sum()) + .as("Rewritten delete file count in all groups") + .isEqualTo(rewrittenDeletes.size()); + assertThat( + result.rewriteResults().stream() + .mapToInt(FileGroupRewriteResult::addedDeleteFilesCount) + .sum()) + .as("Added delete file count in all groups") + .isEqualTo(newDeletes.size()); + assertThat( + result.rewriteResults().stream() + .mapToLong(FileGroupRewriteResult::rewrittenBytesCount) + .sum()) + .as("Rewritten delete bytes in all groups") + .isEqualTo(size(rewrittenDeletes)); + assertThat( + result.rewriteResults().stream() + .mapToLong(FileGroupRewriteResult::addedBytesCount) + .sum()) + .as("Added delete bytes in all groups") + .isEqualTo(size(newDeletes)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java new file mode 100644 index 000000000000..6c744c8df4fb --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewritePositionDeleteFilesProcedure.java @@ -0,0 +1,313 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.SnapshotSummary.ADDED_FILE_SIZE_PROP; +import static org.apache.iceberg.SnapshotSummary.REMOVED_FILE_SIZE_PROP; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.EnvironmentContext; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Encoders; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewritePositionDeleteFilesProcedure extends ExtensionsTestBase { + + private void createTable() throws Exception { + createTable(false); + } + + private void createTable(boolean partitioned) throws Exception { + String partitionStmt = partitioned ? "PARTITIONED BY (id)" : ""; + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg %s TBLPROPERTIES" + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.delete.granularity'='partition')", + tableName, partitionStmt); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(1, "b"), + new SimpleRecord(1, "c"), + new SimpleRecord(2, "d"), + new SimpleRecord(2, "e"), + new SimpleRecord(2, "f"), + new SimpleRecord(3, "g"), + new SimpleRecord(3, "h"), + new SimpleRecord(3, "i"), + new SimpleRecord(4, "j"), + new SimpleRecord(4, "k"), + new SimpleRecord(4, "l"), + new SimpleRecord(5, "m"), + new SimpleRecord(5, "n"), + new SimpleRecord(5, "o"), + new SimpleRecord(6, "p"), + new SimpleRecord(6, "q"), + new SimpleRecord(6, "r")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testExpireDeleteFilesAll() throws Exception { + createTable(); + + sql("DELETE FROM %s WHERE id=1", tableName); + sql("DELETE FROM %s WHERE id=2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSize(2); + + List output = + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map(" + + "'rewrite-all','true'))", + catalogName, tableIdent); + table.refresh(); + + Map snapshotSummary = snapshotSummary(); + assertEquals( + "Should delete 2 delete files and add 1", + ImmutableList.of( + row( + 2, + 1, + Long.valueOf(snapshotSummary.get(REMOVED_FILE_SIZE_PROP)), + Long.valueOf(snapshotSummary.get(ADDED_FILE_SIZE_PROP)))), + output); + + assertThat(TestHelpers.deleteFiles(table)).hasSize(1); + } + + @TestTemplate + public void testExpireDeleteFilesNoOption() throws Exception { + createTable(); + + sql("DELETE FROM %s WHERE id=1", tableName); + sql("DELETE FROM %s WHERE id=2", tableName); + sql("DELETE FROM %s WHERE id=3", tableName); + sql("DELETE FROM %s WHERE id=4", tableName); + sql("DELETE FROM %s WHERE id=5", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSize(5); + + List output = + sql( + "CALL %s.system.rewrite_position_delete_files(" + "table => '%s')", + catalogName, tableIdent); + table.refresh(); + + Map snapshotSummary = snapshotSummary(); + assertEquals( + "Should replace 5 delete files with 1", + ImmutableList.of( + row( + 5, + 1, + Long.valueOf(snapshotSummary.get(REMOVED_FILE_SIZE_PROP)), + Long.valueOf(snapshotSummary.get(ADDED_FILE_SIZE_PROP)))), + output); + } + + @TestTemplate + public void testExpireDeleteFilesFilter() throws Exception { + createTable(true); + + sql("DELETE FROM %s WHERE id = 1 and data='a'", tableName); + sql("DELETE FROM %s WHERE id = 1 and data='b'", tableName); + sql("DELETE FROM %s WHERE id = 2 and data='d'", tableName); + sql("DELETE FROM %s WHERE id = 2 and data='e'", tableName); + sql("DELETE FROM %s WHERE id = 3 and data='g'", tableName); + sql("DELETE FROM %s WHERE id = 3 and data='h'", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSize(6); + + List output = + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + // data filter is ignored as it cannot be applied to position deletes + + "where => 'id IN (1, 2) AND data=\"bar\"'," + + "options => map(" + + "'rewrite-all','true'))", + catalogName, tableIdent); + table.refresh(); + + Map snapshotSummary = snapshotSummary(); + assertEquals( + "Should delete 4 delete files and add 2", + ImmutableList.of( + row( + 4, + 2, + Long.valueOf(snapshotSummary.get(REMOVED_FILE_SIZE_PROP)), + Long.valueOf(snapshotSummary.get(ADDED_FILE_SIZE_PROP)))), + output); + + assertThat(TestHelpers.deleteFiles(table)).hasSize(4); + } + + @TestTemplate + public void testInvalidOption() throws Exception { + createTable(); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map(" + + "'foo', 'bar'))", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Cannot use options [foo], they are not supported by the action or the rewriter BIN-PACK"); + } + + @TestTemplate + public void testRewriteWithUntranslatedOrUnconvertedFilter() throws Exception { + createTable(); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_position_delete_files(table => '%s', where => 'substr(encode(data, \"utf-8\"), 2) = \"fo\"')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot translate Spark expression"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_position_delete_files(table => '%s', where => 'substr(data, 2) = \"fo\"')", + catalogName, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot convert Spark filter"); + } + + @TestTemplate + public void testRewriteSummary() throws Exception { + createTable(); + sql("DELETE FROM %s WHERE id=1", tableName); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map(" + + "'rewrite-all','true'))", + catalogName, tableIdent); + + Map summary = snapshotSummary(); + assertThat(summary) + .containsKey(CatalogProperties.APP_ID) + .containsKey(CatalogProperties.APP_NAME) + .containsEntry(EnvironmentContext.ENGINE_NAME, "spark") + .hasEntrySatisfying( + EnvironmentContext.ENGINE_VERSION, v -> assertThat(v).startsWith("4.1")); + } + + @TestTemplate + public void testRewritePositionDeletesWithArrayColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, items ARRAY>) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', array(named_struct('value', cast(10 as bigint), 'count', 1))), " + + "(2, 'b', array(named_struct('value', cast(20 as bigint), 'count', 2))), " + + "(3, 'c', array(named_struct('value', cast(30 as bigint), 'count', 3))), " + + "(4, 'd', array(named_struct('value', cast(40 as bigint), 'count', 4))), " + + "(5, 'e', array(named_struct('value', cast(50 as bigint), 'count', 5))), " + + "(6, 'f', array(named_struct('value', cast(60 as bigint), 'count', 6)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + + @TestTemplate + public void testRewritePositionDeletesWithMapColumns() throws Exception { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, props MAP) " + + "USING iceberg TBLPROPERTIES " + + "('format-version'='2', 'write.delete.mode'='merge-on-read', 'write.update.mode'='merge-on-read')", + tableName); + + sql( + "INSERT INTO %s VALUES " + + "(1, 'a', map('x', cast(10 as bigint))), " + + "(2, 'b', map('y', cast(20 as bigint))), " + + "(3, 'c', map('z', cast(30 as bigint))), " + + "(4, 'd', map('w', cast(40 as bigint))), " + + "(5, 'e', map('v', cast(50 as bigint))), " + + "(6, 'f', map('u', cast(60 as bigint)))", + tableName); + + sql("DELETE FROM %s WHERE id = 1", tableName); + sql("DELETE FROM %s WHERE id = 2", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(TestHelpers.deleteFiles(table)).hasSizeGreaterThanOrEqualTo(1); + + sql( + "CALL %s.system.rewrite_position_delete_files(" + + "table => '%s'," + + "options => map('rewrite-all','true'))", + catalogName, tableIdent); + } + + private Map snapshotSummary() { + return validationCatalog.loadTable(tableIdent).currentSnapshot().summary(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java new file mode 100644 index 000000000000..78f6b80ac948 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRewriteTablePathProcedure.java @@ -0,0 +1,276 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.atIndex; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RewriteTablePathUtil; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewriteTablePathProcedure extends ExtensionsTestBase { + @TempDir private Path staging; + @TempDir private Path targetTableDir; + + @BeforeEach + public void setupTableLocation() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testRewriteTablePathWithPositionalArgument() { + String location = targetTableDir.toFile().toURI().toString(); + Table table = validationCatalog.loadTable(tableIdent); + String metadataJson = TableUtil.metadataFileLocation(table); + + List result = + sql( + "CALL %s.system.rewrite_table_path('%s', '%s', '%s')", + catalogName, tableIdent, table.location(), location); + assertThat(result).hasSize(1); + assertThat(result.get(0)[0]) + .as("Should return correct latest version") + .isEqualTo(RewriteTablePathUtil.fileName(metadataJson)); + assertThat(result.get(0)[1]) + .as("Should return file_list_location") + .asString() + .startsWith(table.location()) + .endsWith("file-list"); + checkFileListLocationCount((String) result.get(0)[1], 1); + } + + @TestTemplate + public void testRewriteTablePathWithNamedArgument() { + Table table = validationCatalog.loadTable(tableIdent); + String v0Metadata = RewriteTablePathUtil.fileName(TableUtil.metadataFileLocation(table)); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + String v1Metadata = + RewriteTablePathUtil.fileName( + (((HasTableOperations) table).operations()).refresh().metadataFileLocation()); + + String targetLocation = targetTableDir.toFile().toURI().toString(); + String stagingLocation = staging.toFile().toURI().toString(); + String expectedFileListLocation = stagingLocation + "file-list"; + + List result = + sql( + "CALL %s.system.rewrite_table_path(" + + "table => '%s', " + + "target_prefix => '%s', " + + "source_prefix => '%s', " + + "end_version => '%s', " + + "start_version => '%s', " + + "staging_location => '%s')", + catalogName, + tableIdent, + targetLocation, + table.location(), + v1Metadata, + v0Metadata, + stagingLocation); + assertThat(result) + .singleElement() + .satisfies( + objects -> { + assertThat(objects).contains(v1Metadata, atIndex(0)); + assertThat(objects).contains(expectedFileListLocation, atIndex(1)); + }); + checkFileListLocationCount((String) result.get(0)[1], 4); + } + + @TestTemplate + public void testProcedureWithInvalidInput() { + String targetLocation = targetTableDir.toFile().toURI().toString(); + + assertThatThrownBy( + () -> sql("CALL %s.system.rewrite_table_path('%s')", catalogName, tableIdent)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rewrite_table_path` because the parameter named `source_prefix` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_table_path('%s','%s')", + catalogName, tableIdent, targetLocation)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rewrite_table_path` because the parameter named `target_prefix` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_table_path('%s', '%s','%s')", + catalogName, "notExists", targetLocation, targetLocation)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Couldn't load table"); + + Table table = validationCatalog.loadTable(tableIdent); + String v0Metadata = RewriteTablePathUtil.fileName(TableUtil.metadataFileLocation(table)); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_table_path(" + + "table => '%s', " + + "source_prefix => '%s', " + + "target_prefix => '%s', " + + "start_version => '%s')", + catalogName, tableIdent, table.location(), targetLocation, "v20.metadata.json")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Cannot find provided version file %s in metadata log.", "v20.metadata.json"); + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rewrite_table_path(" + + "table => '%s', " + + "source_prefix => '%s', " + + "target_prefix => '%s', " + + "start_version => '%s'," + + "end_version => '%s')", + catalogName, + tableIdent, + table.location(), + targetLocation, + v0Metadata, + "v11.metadata.json")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Cannot find provided version file %s in metadata log.", "v11.metadata.json"); + } + + @TestTemplate + public void testRewriteTablePathWithoutFileList() { + String location = targetTableDir.toFile().toURI().toString(); + Table table = validationCatalog.loadTable(tableIdent); + String metadataJson = TableUtil.metadataFileLocation(table); + + List result = + sql( + "CALL %s.system.rewrite_table_path(table => '%s', source_prefix => '%s', target_prefix => '%s', create_file_list => false)", + catalogName, tableIdent, table.location(), location); + assertThat(result).hasSize(1); + assertThat(result.get(0)[0]) + .as("Should return correct latest version") + .isEqualTo(RewriteTablePathUtil.fileName(metadataJson)); + assertThat(result.get(0)[1]) + .as("Check if file list location is correctly marked as N/A when not generated") + .asString() + .isEqualTo("N/A"); + } + + private void checkFileListLocationCount(String fileListLocation, long expectedFileCount) { + long fileCount = spark.read().format("text").load(fileListLocation).count(); + assertThat(fileCount).isEqualTo(expectedFileCount); + } + + @TestTemplate + public void testRewriteTablePathWithManifestAndDeleteCounts() throws IOException { + sql("INSERT INTO %s VALUES (1, 'a')", tableName); + sql("INSERT INTO %s VALUES (2, 'b')", tableName); + sql("INSERT INTO %s VALUES (3, 'c')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + List> rowsToDelete = + Lists.newArrayList( + Pair.of( + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .iterator() + .next() + .location(), + 0L)); + + File file = new File(removePrefix(table.location()) + "/data/deletes.parquet"); + String filePath = file.toURI().toString(); + if (SparkCatalogConfig.REST.catalogName().equals(catalogName)) { + // We applied this special handling because the base path for + // matching the RESTCATALOG's Hive BaseLocation is represented + // in the form of an AbsolutePath. + filePath = file.getAbsolutePath().toString(); + } + + DeleteFile positionDeletes = + FileHelpers.writeDeleteFile(table, table.io().newOutputFile(filePath), rowsToDelete) + .first(); + + table.newRowDelta().addDeletes(positionDeletes).commit(); + + sql("INSERT INTO %s VALUES (4, 'd')", tableName); + + String targetLocation = targetTableDir.toFile().toURI().toString(); + String stagingLocation = staging.toFile().toURI().toString(); + + List result = + sql( + "CALL %s.system.rewrite_table_path(" + + "table => '%s', " + + "source_prefix => '%s', " + + "target_prefix => '%s', " + + "staging_location => '%s', create_file_list => false)", + catalogName, tableIdent, table.location(), targetLocation, stagingLocation); + + assertThat(result).hasSize(1); + Object[] row = result.get(0); + + int rewrittenManifestFilesCount = ((Number) row[2]).intValue(); + int rewrittenDeleteFilesCount = ((Number) row[3]).intValue(); + + assertThat(rewrittenDeleteFilesCount) + .as( + "Expected exactly 1 delete file to be rewritten, but found " + + rewrittenDeleteFilesCount) + .isEqualTo(1); + + assertThat(rewrittenManifestFilesCount) + .as( + "Expected exactly 5 manifest files to be rewritten, but found " + + rewrittenManifestFilesCount) + .isEqualTo(5); + } + + private String removePrefix(String path) { + return path.substring(path.lastIndexOf(":") + 1); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRollbackToSnapshotProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRollbackToSnapshotProcedure.java new file mode 100644 index 000000000000..cffc65f5dceb --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRollbackToSnapshotProcedure.java @@ -0,0 +1,286 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRollbackToSnapshotProcedure extends ExtensionsTestBase { + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testRollbackToSnapshotUsingPositionalArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + List output = + sql( + "CALL %s.system.rollback_to_snapshot('%s', %dL)", + catalogName, tableIdent, firstSnapshot.snapshotId()); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToSnapshotUsingNamedArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + List output = + sql( + "CALL %s.system.rollback_to_snapshot(snapshot_id => %dL, table => '%s')", + catalogName, firstSnapshot.snapshotId(), tableIdent); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToSnapshotRefreshesRelationCache() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + Dataset query = spark.sql("SELECT * FROM " + tableName + " WHERE id = 1"); + query.createOrReplaceTempView("tmp"); + + spark.sql("CACHE TABLE tmp"); + + assertEquals( + "View should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM tmp")); + + List output = + sql( + "CALL %s.system.rollback_to_snapshot(table => '%s', snapshot_id => %dL)", + catalogName, tableIdent, firstSnapshot.snapshotId()); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "View cache must be invalidated", ImmutableList.of(row(1L, "a")), sql("SELECT * FROM tmp")); + + sql("UNCACHE TABLE tmp"); + } + + @TestTemplate + public void testRollbackToSnapshotWithQuotedIdentifiers() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + StringBuilder quotedNamespaceBuilder = new StringBuilder(); + for (String level : tableIdent.namespace().levels()) { + quotedNamespaceBuilder.append("`"); + quotedNamespaceBuilder.append(level); + quotedNamespaceBuilder.append("`"); + } + String quotedNamespace = quotedNamespaceBuilder.toString(); + + List output = + sql( + "CALL %s.system.rollback_to_snapshot('%s', %d)", + catalogName, + quotedNamespace + ".`" + tableIdent.name() + "`", + firstSnapshot.snapshotId()); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToSnapshotWithoutExplicitCatalog() { + assumeThat(catalogName).as("Working only with the session catalog").isEqualTo("spark_catalog"); + + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + // use camel case intentionally to test case sensitivity + List output = + sql("CALL SyStEm.rOLlBaCk_to_SnApShOt('%s', %dL)", tableIdent, firstSnapshot.snapshotId()); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToInvalidSnapshot() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.rollback_to_snapshot('%s', -1L)", catalogName, tableIdent)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot roll back to unknown snapshot id: -1"); + } + + @TestTemplate + public void testInvalidRollbackToSnapshotCases() { + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rollback_to_snapshot(namespace => 'n1', table => 't', 1L)", + catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[UNEXPECTED_POSITIONAL_ARGUMENT] Cannot invoke routine `rollback_to_snapshot` because it contains positional argument(s) following the named argument assigned to `table`; please rearrange them so the positional arguments come first and then retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.custom.rollback_to_snapshot('n', 't', 1L)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`rollback_to_snapshot`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_snapshot('t')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rollback_to_snapshot` because the parameter named `snapshot_id` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_snapshot(1L)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rollback_to_snapshot` because the parameter named `snapshot_id` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_snapshot(table => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rollback_to_snapshot` because the parameter named `snapshot_id` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 1 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_snapshot('t', '2.2')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "[CAST_INVALID_INPUT] The value '2.2' of the type \"STRING\" cannot be cast to \"BIGINT\" because it is malformed."); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_snapshot('', 1L)", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRollbackToTimestampProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRollbackToTimestampProcedure.java new file mode 100644 index 000000000000..b554061d2c39 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRollbackToTimestampProcedure.java @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRollbackToTimestampProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testRollbackToTimestampUsingPositionalArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + String firstSnapshotTimestamp = LocalDateTime.now().toString(); + + waitUntilAfter(firstSnapshot.timestampMillis()); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + List output = + sql( + "CALL %s.system.rollback_to_timestamp('%s',TIMESTAMP '%s')", + catalogName, tableIdent, firstSnapshotTimestamp); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToTimestampUsingNamedArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + String firstSnapshotTimestamp = LocalDateTime.now().toString(); + + waitUntilAfter(firstSnapshot.timestampMillis()); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + List output = + sql( + "CALL %s.system.rollback_to_timestamp(timestamp => TIMESTAMP '%s', table => '%s')", + catalogName, firstSnapshotTimestamp, tableIdent); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToTimestampRefreshesRelationCache() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + String firstSnapshotTimestamp = LocalDateTime.now().toString(); + + waitUntilAfter(firstSnapshot.timestampMillis()); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + Dataset query = spark.sql("SELECT * FROM " + tableName + " WHERE id = 1"); + query.createOrReplaceTempView("tmp"); + + spark.sql("CACHE TABLE tmp"); + + assertEquals( + "View should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM tmp")); + + List output = + sql( + "CALL %s.system.rollback_to_timestamp(table => '%s', timestamp => TIMESTAMP '%s')", + catalogName, tableIdent, firstSnapshotTimestamp); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "View cache must be invalidated", ImmutableList.of(row(1L, "a")), sql("SELECT * FROM tmp")); + + sql("UNCACHE TABLE tmp"); + } + + @TestTemplate + public void testRollbackToTimestampWithQuotedIdentifiers() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + String firstSnapshotTimestamp = LocalDateTime.now().toString(); + + waitUntilAfter(firstSnapshot.timestampMillis()); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + StringBuilder quotedNamespaceBuilder = new StringBuilder(); + for (String level : tableIdent.namespace().levels()) { + quotedNamespaceBuilder.append("`"); + quotedNamespaceBuilder.append(level); + quotedNamespaceBuilder.append("`"); + } + String quotedNamespace = quotedNamespaceBuilder.toString(); + + List output = + sql( + "CALL %s.system.rollback_to_timestamp('%s', TIMESTAMP '%s')", + catalogName, quotedNamespace + ".`" + tableIdent.name() + "`", firstSnapshotTimestamp); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToTimestampWithoutExplicitCatalog() { + assumeThat(catalogName).as("Working only with the session catalog").isEqualTo("spark_catalog"); + + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + String firstSnapshotTimestamp = LocalDateTime.now().toString(); + + waitUntilAfter(firstSnapshot.timestampMillis()); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + // use camel case intentionally to test case sensitivity + List output = + sql( + "CALL SyStEm.rOLlBaCk_to_TiMeStaMp('%s', TIMESTAMP '%s')", + tableIdent, firstSnapshotTimestamp); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Rollback must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRollbackToTimestampBeforeOrEqualToOldestSnapshot() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + Timestamp beforeFirstSnapshot = + Timestamp.from(Instant.ofEpochMilli(firstSnapshot.timestampMillis() - 1)); + Timestamp exactFirstSnapshot = + Timestamp.from(Instant.ofEpochMilli(firstSnapshot.timestampMillis())); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rollback_to_timestamp(timestamp => TIMESTAMP '%s', table => '%s')", + catalogName, beforeFirstSnapshot, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot roll back, no valid snapshot older than: %s", + beforeFirstSnapshot.toInstant().toEpochMilli()); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rollback_to_timestamp(timestamp => TIMESTAMP '%s', table => '%s')", + catalogName, exactFirstSnapshot, tableIdent)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot roll back, no valid snapshot older than: %s", + exactFirstSnapshot.toInstant().toEpochMilli()); + } + + @TestTemplate + public void testInvalidRollbackToTimestampCases() { + String timestamp = "TIMESTAMP '2007-12-03T10:15:30'"; + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rollback_to_timestamp(namespace => 'n1', 't', %s)", + catalogName, timestamp)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[UNEXPECTED_POSITIONAL_ARGUMENT] Cannot invoke routine `rollback_to_timestamp` because it contains positional argument(s) following the named argument assigned to `namespace`; please rearrange them so the positional arguments come first and then retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> sql("CALL %s.custom.rollback_to_timestamp('n', 't', %s)", catalogName, timestamp)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`rollback_to_timestamp`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_timestamp('t')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rollback_to_timestamp` because the parameter named `timestamp` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rollback_to_timestamp(timestamp => %s)", + catalogName, timestamp)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rollback_to_timestamp` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_timestamp(table => 't')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `rollback_to_timestamp` because the parameter named `timestamp` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 1 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.rollback_to_timestamp('n', 't', %s, 1L)", + catalogName, timestamp)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[WRONG_NUM_ARGS.WITHOUT_SUGGESTION] The `rollback_to_timestamp` requires 2 parameters but the actual number is 4."); + + assertThatThrownBy(() -> sql("CALL %s.system.rollback_to_timestamp('t', 2.2)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE] Cannot resolve CALL due to data type mismatch: The second parameter requires the \"TIMESTAMP\" type, however \"2.2\" has the type \"DECIMAL(2,1)\". SQLSTATE: 42K09"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRowLevelOperationsWithLineage.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRowLevelOperationsWithLineage.java new file mode 100644 index 000000000000..f38178a8e883 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestRowLevelOperationsWithLineage.java @@ -0,0 +1,643 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.MetadataColumns.schemaWithRowLineage; +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_HASH; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_RANGE; +import static org.apache.iceberg.spark.Spark3Util.loadIcebergTable; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.data.GenericFileWriterFactory; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.encryption.EncryptionUtil; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.functions.BucketFunction; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.PartitionMap; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; + +public abstract class TestRowLevelOperationsWithLineage extends SparkRowLevelOperationsTestBase { + static final Function BUCKET_PARTITION_GENERATOR = + record -> + TestHelpers.Row.of(BucketFunction.BucketInt.invoke(2, record.get(0, Integer.class))); + + static final Schema SCHEMA = + new Schema( + ImmutableList.of( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + MetadataColumns.ROW_ID, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER)); + + static final List INITIAL_RECORDS = + ImmutableList.of( + createRecord(SCHEMA, 100, "a", 0L, 1L), + createRecord(SCHEMA, 101, "b", 1L, 1L), + createRecord(SCHEMA, 102, "c", 2L, 1L), + createRecord(SCHEMA, 103, "d", 3L, 1L), + createRecord(SCHEMA, 104, "e", 4L, 1L)); + + @Parameters( + name = + "catalogName = {0}, implementation = {1}, config = {2}," + + " format = {3}, vectorized = {4}, distributionMode = {5}," + + " fanout = {6}, branch = {7}, planningMode = {8}, formatVersion = {9}") + public static Object[][] parameters() { + return new Object[][] { + { + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hadoop"), + FileFormat.ORC, + false, + WRITE_DISTRIBUTION_MODE_HASH, + true, + null, + LOCAL, + 3 + }, + { + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hadoop"), + FileFormat.PARQUET, + true, + WRITE_DISTRIBUTION_MODE_HASH, + true, + null, + LOCAL, + 3 + }, + { + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hadoop"), + FileFormat.PARQUET, + false, + WRITE_DISTRIBUTION_MODE_RANGE, + true, + null, + DISTRIBUTED, + 3 + }, + { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", + "hive", + "default-namespace", + "default", + "clients", + "1", + "parquet-enabled", + "false", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + ), + FileFormat.AVRO, + false, + WRITE_DISTRIBUTION_MODE_RANGE, + false, + null, + DISTRIBUTED, + 3 + }, + }; + } + + @BeforeAll + public static void setupSparkConf() { + spark.conf().set("spark.sql.shuffle.partitions", "4"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS source"); + } + + @TestTemplate + public void testMergeIntoWithBothMatchedAndNonMatched() + throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + createOrReplaceView( + "source", + "id int, data string", + "{ \"id\": 101, \"data\": \"updated_b\" }\n " + "{ \"id\": 200, \"data\": \"f\" }\n"); + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.data = s.data " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + Snapshot updateSnapshot = latestSnapshot(table); + long updateSnapshotFirstRowId = updateSnapshot.firstRowId(); + List allRows = rowsWithLineageAndFilePos(); + List carriedOverAndUpdatedRows = + allRows.stream() + .filter(row -> (long) row[3] < updateSnapshotFirstRowId) + .collect(Collectors.toList()); + + // Project sequence numbers first for easier comparison on the added row + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(1L, 100, "a", 0L, ANY, ANY), + row(updateSnapshot.sequenceNumber(), 101, "updated_b", 1L, ANY, ANY), + row(1L, 102, "c", 2L, ANY, ANY), + row(1L, 103, "d", 3L, ANY, ANY), + row(1L, 104, "e", 4L, ANY, ANY)), + carriedOverAndUpdatedRows); + + Object[] newRow = + Iterables.getOnlyElement( + allRows.stream() + .filter(row -> (long) row[3] >= updateSnapshotFirstRowId) + .collect(Collectors.toList())); + assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 200, "f"), newRow); + } + + @TestTemplate + public void testMergeIntoWithBothMatchedAndNonMatchedPartitioned() + throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data STRING", "PARTITIONED BY (bucket(2, id))", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendRecords( + table, partitionRecords(INITIAL_RECORDS, table.spec(), BUCKET_PARTITION_GENERATOR)); + createOrReplaceView( + "source", + "id int, data string", + "{ \"id\": 101, \"data\": \"updated_b\" }\n " + "{ \"id\": 200, \"data\": \"f\" }\n"); + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.data = s.data " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + Snapshot updateSnapshot = latestSnapshot(table); + long updateSnapshotFirstRowId = updateSnapshot.firstRowId(); + List allRows = rowsWithLineageAndFilePos(); + + List carriedOverAndUpdatedRows = + allRows.stream() + .filter(row -> (long) row[3] < updateSnapshotFirstRowId) + .collect(Collectors.toList()); + + // Project sequence numbers first for easier comparison on the added row + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(1L, 100, "a", 0L, ANY, ANY), + row(updateSnapshot.sequenceNumber(), 101, "updated_b", 1L, ANY, ANY), + row(1L, 102, "c", 2L, ANY, ANY), + row(1L, 103, "d", 3L, ANY, ANY), + row(1L, 104, "e", 4L, ANY, ANY)), + carriedOverAndUpdatedRows); + + Object[] newRow = + Iterables.getOnlyElement( + allRows.stream() + .filter(row -> (long) row[3] >= updateSnapshotFirstRowId) + .collect(Collectors.toList())); + assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 200, "f"), newRow); + } + + @TestTemplate + public void testMergeIntoWithOnlyNonMatched() + throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data string", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + createOrReplaceView( + "source", + "id INT, data STRING", + "{ \"id\": 101, \"data\": \"updated_b\" }\n " + "{ \"id\": 200, \"data\": \"f\" }\n"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN NOT MATCHED THEN " + + "INSERT *", + commitTarget()); + + Snapshot updateSnapshot = latestSnapshot(table); + long updateSnapshotFirstRowId = updateSnapshot.firstRowId(); + + List allRows = rowsWithLineageAndFilePos(); + List carriedOverAndUpdatedRows = + allRows.stream() + .filter(row -> (long) row[3] < updateSnapshotFirstRowId) + .collect(Collectors.toList()); + + // Project sequence numbers first for easier comparison on the added row + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(1L, 100, "a", 0L, ANY, ANY), + row(1L, 101, "b", 1L, ANY, ANY), + row(1L, 102, "c", 2L, ANY, ANY), + row(1L, 103, "d", 3L, ANY, ANY), + row(1L, 104, "e", 4L, ANY, ANY)), + carriedOverAndUpdatedRows); + + Object[] newRow = + Iterables.getOnlyElement( + allRows.stream() + .filter(row -> (long) row[3] >= updateSnapshotFirstRowId) + .collect(Collectors.toList())); + assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 200, "f"), newRow); + } + + @TestTemplate + public void testMergeIntoWithOnlyMatched() + throws IOException, NoSuchTableException, ParseException { + createAndInitTable("id INT, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + createOrReplaceView( + "source", + "id INT, data string", + "{ \"id\": 101, \"data\": \"updated_b\" }\n " + + "{ \"id\": 102, \"data\": \"updated_c\" }\n"); + + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.data = s.data ", + commitTarget()); + + long updateSequenceNumber = latestSnapshot(table).sequenceNumber(); + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(100, "a", 0L, 1L), + row(101, "updated_b", 1L, updateSequenceNumber), + row(102, "updated_c", 2L, updateSequenceNumber), + row(103, "d", 3L, 1L), + row(104, "e", 4L, 1L)), + rowsWithLineage()); + } + + @TestTemplate + public void testMergeMatchedDelete() throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + createOrReplaceView( + "source", + "id INT, data string", + "{ \"id\": 101, \"data\": \"delete_101\" }\n " + + "{ \"id\": 102, \"data\": \"delete_102\" }\n"); + sql( + "MERGE INTO %s AS t USING source AS s " + "ON t.id == s.id " + "WHEN MATCHED THEN DELETE", + commitTarget()); + + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of(row(100, "a", 0L, 1L), row(103, "d", 3L, 1L), row(104, "e", 4L, 1L)), + rowsWithLineage()); + } + + @TestTemplate + public void testMergeWhenNotMatchedBySource() + throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + createOrReplaceView( + "source", + "id INT, data STRING", + "{ \"id\": 101, \"data\": \"updated_b\" }\n " + "{ \"id\": 200, \"data\": \"f\" }\n"); + + sql( + "MERGE INTO %s AS t USING source AS s ON t.id == s.id" + + " WHEN MATCHED THEN UPDATE set t.data = s.data " + + "WHEN NOT MATCHED BY SOURCE THEN UPDATE set data = 'not_matched_by_source'", + commitTarget()); + + long updateSequenceNumber = latestSnapshot(table).sequenceNumber(); + + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(100, "not_matched_by_source", 0L, updateSequenceNumber), + row(101, "updated_b", 1L, updateSequenceNumber), + row(102, "not_matched_by_source", 2L, updateSequenceNumber), + row(103, "not_matched_by_source", 3L, updateSequenceNumber), + row(104, "not_matched_by_source", 4L, updateSequenceNumber)), + rowsWithLineage()); + } + + @TestTemplate + public void testMergeWhenNotMatchedBySourceDelete() + throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + createOrReplaceView( + "source", + "id INT, data STRING", + "{ \"id\": 101, \"data\": \"updated_b\" }\n " + + "{ \"id\": 102, \"data\": \"updated_c\" }\n"); + + sql( + "MERGE INTO %s AS t USING source AS s ON t.id == s.id" + + " WHEN MATCHED THEN UPDATE set t.data = s.data " + + "WHEN NOT MATCHED BY SOURCE THEN DELETE", + commitTarget()); + + long updateSequenceNumber = latestSnapshot(table).sequenceNumber(); + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(101, "updated_b", 1L, updateSequenceNumber), + row(102, "updated_c", 2L, updateSequenceNumber)), + rowsWithLineage()); + } + + @TestTemplate + public void testUpdate() throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + + sql("UPDATE %s AS t set data = 'updated_b' WHERE id = 101", commitTarget()); + long updateSequenceNumber = latestSnapshot(table).sequenceNumber(); + + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(100, "a", 0L, 1L), + row(101, "updated_b", 1L, updateSequenceNumber), + row(102, "c", 2L, 1L), + row(103, "d", 3L, 1L), + row(104, "e", 4L, 1L)), + rowsWithLineage()); + } + + @TestTemplate + public void testDelete() throws NoSuchTableException, ParseException, IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + createAndInitTable("id int, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + appendUnpartitionedRecords(table, INITIAL_RECORDS); + + sql("DELETE FROM %s WHERE id = 101", commitTarget()); + + assertEquals( + "Rows which are carried over or updated should have expected lineage", + ImmutableList.of( + row(100, "a", 0L, 1L), + row(102, "c", 2L, 1L), + row(103, "d", 3L, 1L), + row(104, "e", 4L, 1L)), + rowsWithLineage()); + } + + @TestTemplate + public void testMergeWithManyRecords() throws NoSuchTableException, ParseException, IOException { + createAndInitTable("id INT, data STRING", null); + createBranchIfNeeded(); + Table table = loadIcebergTable(spark, tableName); + + int numRecords = 25000; + int startingId = 100; + + List initialRecords = Lists.newArrayList(); + int rowId = 0; + for (int id = 100; id < startingId + numRecords; id++) { + initialRecords.add(createRecord(SCHEMA, id, "data_" + id, rowId++, 1L)); + } + + appendUnpartitionedRecords(table, initialRecords); + createOrReplaceView( + "source", + "id int, data string", + "{ \"id\": 101, \"data\": \"updated_data_101\" }\n " + + "{ \"id\": 26000, \"data\": \"data_26000\" }\n"); + sql( + "MERGE INTO %s AS t USING source AS s " + + "ON t.id == s.id " + + "WHEN MATCHED THEN " + + " UPDATE SET t.data = s.data " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + commitTarget()); + + Snapshot updateSnapshot = latestSnapshot(table); + long updateSnapshotFirstRowId = updateSnapshot.firstRowId(); + List allRows = rowsWithLineageAndFilePos(); + List carriedOverAndUpdatedRows = + allRows.stream() + .filter(row -> (long) row[3] < updateSnapshotFirstRowId) + .collect(Collectors.toList()); + + int newlyInsertedId = 26000; + int updatedId = 101; + List expectedCarriedOverAndUpdatedRows = + ImmutableList.builder() + .add(row(1L, 100, "data_100", 0L, ANY, ANY)) + .add(row(updateSnapshot.sequenceNumber(), updatedId, "updated_data_101", 1L, ANY, ANY)) + .addAll( + // Every record with higher ids than the updated excluding the new row should be a + // carry over + initialRecords.stream() + .filter( + initialRecord -> { + int id = initialRecord.get(0, Integer.class); + return id > updatedId && id != newlyInsertedId; + }) + .map(this::recordToExpectedRow) + .collect(Collectors.toList())) + .build(); + + assertEquals( + "Rows which are carried over or updated should have expected lineage", + expectedCarriedOverAndUpdatedRows, + carriedOverAndUpdatedRows); + + Object[] newRow = + Iterables.getOnlyElement( + allRows.stream() + .filter(row -> (long) row[3] >= updateSnapshotFirstRowId) + .collect(Collectors.toList())); + assertAddedRowLineage(row(updateSnapshot.sequenceNumber(), 26000, "data_26000"), newRow); + } + + private Object[] recordToExpectedRow(Record record) { + int id = record.get(0, Integer.class); + String data = record.get(1, String.class); + long rowId = record.get(2, Long.class); + long lastUpdated = record.get(3, Long.class); + return row(lastUpdated, id, data, rowId, ANY, ANY); + } + + private List rowsWithLineageAndFilePos() { + return sql( + "SELECT s._last_updated_sequence_number, s.id, s.data, s._row_id, files.first_row_id, s._pos FROM %s" + + " AS s JOIN %s.files AS files ON files.file_path = s._file ORDER BY s._row_id", + selectTarget(), selectTarget()); + } + + private List rowsWithLineage() { + return sql( + "SELECT id, data, _row_id, _last_updated_sequence_number FROM %s ORDER BY _row_id", + selectTarget()); + } + + /** + * Partitions the provided records based on the spec and partition function + * + * @return a partitioned map + */ + protected PartitionMap> partitionRecords( + List records, + PartitionSpec spec, + Function partitionGenerator) { + PartitionMap> recordsByPartition = + PartitionMap.create(Map.of(spec.specId(), spec)); + for (Record record : records) { + StructLike partition = partitionGenerator != null ? partitionGenerator.apply(record) : null; + List recordsForPartition = recordsByPartition.get(spec.specId(), partition); + if (recordsForPartition == null) { + recordsForPartition = Lists.newArrayList(); + } + + recordsForPartition.add(record); + recordsByPartition.put(spec.specId(), partition, recordsForPartition); + } + + return recordsByPartition; + } + + protected void appendUnpartitionedRecords(Table table, List records) throws IOException { + appendRecords(table, partitionRecords(records, table.spec(), record -> null)); + } + + protected void appendRecords(Table table, PartitionMap> partitionedRecords) + throws IOException { + AppendFiles append = table.newAppend(); + + for (Map.Entry, List> entry : partitionedRecords.entrySet()) { + OutputFile file = Files.localOutput(temp.resolve(UUID.randomUUID().toString()).toFile()); + DataWriter writer = + new GenericFileWriterFactory.Builder(table) + .dataSchema(schemaWithRowLineage(table.schema())) + .dataFileFormat(fileFormat) + .build() + .newDataWriter( + EncryptionUtil.plainAsEncryptedOutput(file), + table.spec(), + entry.getKey().second()); + List recordsForPartition = entry.getValue(); + writer.write(recordsForPartition); + writer.close(); + append = + append + .appendFile(writer.toDataFile()) + .toBranch(branch != null ? branch : SnapshotRef.MAIN_BRANCH); + } + + append.commit(); + } + + protected static Record createRecord( + Schema schema, int id, String data, long rowId, long lastUpdatedSequenceNumber) { + Record record = GenericRecord.create(schema); + record.set(0, id); + record.set(1, data); + record.set(2, rowId); + record.set(3, lastUpdatedSequenceNumber); + return record; + } + + private Snapshot latestSnapshot(Table table) { + table.refresh(); + return branch != null ? table.snapshot(branch) : table.currentSnapshot(); + } + + // Expected should have last updated sequence number followed by data columns + // Actual should have the contents of expected followed by the file first row ID and position + private void assertAddedRowLineage(Object[] expected, Object[] actual) { + // validate the sequence number and all the data columns + for (int pos = 0; pos < expected.length; pos++) { + assertThat(actual[pos]).isEqualTo(expected[pos]); + } + + int rowIdPos = expected.length; + int firstRowIdPos = rowIdPos + 1; + int positionPos = firstRowIdPos + 1; + long expectedRowId = (Long) actual[firstRowIdPos] + (Long) actual[positionPos]; + assertThat(actual[rowIdPos]).isEqualTo(expectedRowId); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSetCurrentSnapshotProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSetCurrentSnapshotProcedure.java new file mode 100644 index 000000000000..ab0eca78d53b --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSetCurrentSnapshotProcedure.java @@ -0,0 +1,291 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.TableProperties.WRITE_AUDIT_PUBLISH_ENABLED; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSetCurrentSnapshotProcedure extends ExtensionsTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSetCurrentSnapshotUsingPositionalArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + List output = + sql( + "CALL %s.system.set_current_snapshot('%s', %dL)", + catalogName, tableIdent, firstSnapshot.snapshotId()); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Set must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSetCurrentSnapshotUsingNamedArgs() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + List output = + sql( + "CALL %s.system.set_current_snapshot(snapshot_id => %dL, table => '%s')", + catalogName, firstSnapshot.snapshotId(), tableIdent); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Set must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSetCurrentSnapshotWap() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'true')", tableName, WRITE_AUDIT_PUBLISH_ENABLED); + + spark.conf().set("spark.wap.id", "1"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should not see rows from staged snapshot", + ImmutableList.of(), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot wapSnapshot = Iterables.getOnlyElement(table.snapshots()); + + List output = + sql( + "CALL %s.system.set_current_snapshot(table => '%s', snapshot_id => %dL)", + catalogName, tableIdent, wapSnapshot.snapshotId()); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(null, wapSnapshot.snapshotId())), + output); + + assertEquals( + "Current snapshot must be set correctly", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void tesSetCurrentSnapshotWithoutExplicitCatalog() { + assumeThat(catalogName).as("Working only with the session catalog").isEqualTo("spark_catalog"); + + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + // use camel case intentionally to test case sensitivity + List output = + sql("CALL SyStEm.sEt_cuRrEnT_sNaPsHot('%s', %dL)", tableIdent, firstSnapshot.snapshotId()); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Set must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSetCurrentSnapshotToInvalidSnapshot() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + + assertThatThrownBy( + () -> sql("CALL %s.system.set_current_snapshot('%s', -1L)", catalogName, tableIdent)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot roll back to unknown snapshot id: -1"); + } + + @TestTemplate + public void testInvalidRollbackToSnapshotCases() { + assertThatThrownBy( + () -> + sql( + "CALL %s.system.set_current_snapshot(namespace => 'n1', table => 't', 1L)", + catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[UNEXPECTED_POSITIONAL_ARGUMENT] Cannot invoke routine `set_current_snapshot` because it contains positional argument(s) following the named argument assigned to `table`; please rearrange them so the positional arguments come first and then retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.custom.set_current_snapshot('n', 't', 1L)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[FAILED_TO_LOAD_ROUTINE] Failed to load routine `%s`.`custom`.`set_current_snapshot`. SQLSTATE: 38000", + catalogName); + + assertThatThrownBy(() -> sql("CALL %s.system.set_current_snapshot('t')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Either snapshot_id or ref must be provided, not both"); + + assertThatThrownBy(() -> sql("CALL %s.system.set_current_snapshot(1L)", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot parse identifier for parameter 'table': 1"); + + assertThatThrownBy( + () -> sql("CALL %s.system.set_current_snapshot(snapshot_id => 1L)", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `set_current_snapshot` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy(() -> sql("CALL %s.system.set_current_snapshot(table => 't')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Either snapshot_id or ref must be provided, not both"); + + assertThatThrownBy(() -> sql("CALL %s.system.set_current_snapshot('t', '2.2')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "[CAST_INVALID_INPUT] The value '2.2' of the type \"STRING\" cannot be cast to \"BIGINT\" because it is malformed."); + + assertThatThrownBy(() -> sql("CALL %s.system.set_current_snapshot('', 1L)", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for parameter 'table'"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.set_current_snapshot(table => 't', snapshot_id => 1L, ref => 's1')", + catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Either snapshot_id or ref must be provided, not both"); + } + + @TestTemplate + public void testSetCurrentSnapshotToRef() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot firstSnapshot = table.currentSnapshot(); + String ref = "s1"; + sql("ALTER TABLE %s CREATE TAG %s", tableName, ref); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + table.refresh(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + List output = + sql( + "CALL %s.system.set_current_snapshot(table => '%s', ref => '%s')", + catalogName, tableIdent, ref); + + assertEquals( + "Procedure output must match", + ImmutableList.of(row(secondSnapshot.snapshotId(), firstSnapshot.snapshotId())), + output); + + assertEquals( + "Set must be successful", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + String notExistRef = "s2"; + assertThatThrownBy( + () -> + sql( + "CALL %s.system.set_current_snapshot(table => '%s', ref => '%s')", + catalogName, tableIdent, notExistRef)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot find matching snapshot ID for ref " + notExistRef); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSetWriteDistributionAndOrdering.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSetWriteDistributionAndOrdering.java new file mode 100644 index 000000000000..2bdeb197126a --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSetWriteDistributionAndOrdering.java @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.expressions.Expressions.bucket; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSetWriteDistributionAndOrdering extends ExtensionsTestBase { + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSetWriteOrderByColumn() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string, ts timestamp, data string) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE ORDERED BY category, id", tableName); + + table.refresh(); + + String distributionMode = table.properties().get(TableProperties.WRITE_DISTRIBUTION_MODE); + assertThat(distributionMode).as("Distribution mode must match").isEqualTo("range"); + + SortOrder expected = + SortOrder.builderFor(table.schema()) + .withOrderId(1) + .asc("category", NullOrder.NULLS_FIRST) + .asc("id", NullOrder.NULLS_FIRST) + .build(); + assertThat(table.sortOrder()).as("Should have expected order").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteOrderWithCaseSensitiveColumnNames() { + sql( + "CREATE TABLE %s (Id bigint NOT NULL, Category string, ts timestamp, data string) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + sql("SET %s=true", SQLConf.CASE_SENSITIVE().key()); + assertThatThrownBy( + () -> { + sql("ALTER TABLE %s WRITE ORDERED BY category, id", tableName); + }) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Cannot find field 'category' in struct"); + + sql("SET %s=false", SQLConf.CASE_SENSITIVE().key()); + sql("ALTER TABLE %s WRITE ORDERED BY category, id", tableName); + table = validationCatalog.loadTable(tableIdent); + SortOrder expected = + SortOrder.builderFor(table.schema()).withOrderId(1).asc("Category").asc("Id").build(); + assertThat(table.sortOrder()).as("Should have expected order").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteOrderByColumnWithDirection() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string, ts timestamp, data string) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE ORDERED BY category ASC, id DESC", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "range"); + + SortOrder expected = + SortOrder.builderFor(table.schema()) + .withOrderId(1) + .asc("category", NullOrder.NULLS_FIRST) + .desc("id", NullOrder.NULLS_LAST) + .build(); + assertThat(table.sortOrder()).as("Should have expected order").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteOrderByColumnWithDirectionAndNullOrder() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string, ts timestamp, data string) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE ORDERED BY category ASC NULLS LAST, id DESC NULLS FIRST", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "range"); + + SortOrder expected = + SortOrder.builderFor(table.schema()) + .withOrderId(1) + .asc("category", NullOrder.NULLS_LAST) + .desc("id", NullOrder.NULLS_FIRST) + .build(); + assertThat(table.sortOrder()).as("Should have expected order").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteOrderByTransform() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string, ts timestamp, data string) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).isTrue(); + + sql("ALTER TABLE %s WRITE ORDERED BY category DESC, bucket(16, id), id", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "range"); + + SortOrder expected = + SortOrder.builderFor(table.schema()) + .withOrderId(1) + .desc("category") + .asc(bucket("id", 16)) + .asc("id") + .build(); + assertThat(table.sortOrder()).as("Should have expected order").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteUnordered() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string, ts timestamp, data string) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE ORDERED BY category DESC, bucket(16, id), id", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "range"); + + assertThat(table.sortOrder()).as("Table must be sorted").isNotEqualTo(SortOrder.unsorted()); + + sql("ALTER TABLE %s WRITE UNORDERED", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "none"); + + assertThat(table.sortOrder()).as("New sort order must match").isEqualTo(SortOrder.unsorted()); + } + + @TestTemplate + public void testSetWriteLocallyOrdered() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string, ts timestamp, data string) USING iceberg", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE LOCALLY ORDERED BY category DESC, bucket(16, id), id", tableName); + + table.refresh(); + + assertThat(table.properties()).doesNotContainKey(TableProperties.WRITE_DISTRIBUTION_MODE); + + SortOrder expected = + SortOrder.builderFor(table.schema()) + .withOrderId(1) + .desc("category") + .asc(bucket("id", 16)) + .asc("id") + .build(); + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteLocallyOrderedToPartitionedTable() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string) USING iceberg PARTITIONED BY (id)", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE LOCALLY ORDERED BY category DESC", tableName); + + table.refresh(); + + assertThat(table.properties()).doesNotContainKey(TableProperties.WRITE_DISTRIBUTION_MODE); + + SortOrder expected = + SortOrder.builderFor(table.schema()).withOrderId(1).desc("category").build(); + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteDistributedByWithSort() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string) USING iceberg PARTITIONED BY (category)", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION ORDERED BY id", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "hash"); + + SortOrder expected = SortOrder.builderFor(table.schema()).withOrderId(1).asc("id").build(); + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(expected); + } + + @TestTemplate + public void testSetWriteDistributedByWithLocalSort() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string) USING iceberg PARTITIONED BY (category)", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION LOCALLY ORDERED BY id", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "hash"); + + SortOrder expected = SortOrder.builderFor(table.schema()).withOrderId(1).asc("id").build(); + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(expected); + + sql("ALTER TABLE %s WRITE LOCALLY ORDERED BY id", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "hash"); + } + + @TestTemplate + public void testSetWriteDistributedByAndUnordered() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string) USING iceberg PARTITIONED BY (category)", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION UNORDERED", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "hash"); + + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(SortOrder.unsorted()); + } + + @TestTemplate + public void testSetWriteDistributedByOnly() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string) USING iceberg PARTITIONED BY (category)", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION UNORDERED", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "hash"); + + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(SortOrder.unsorted()); + } + + @TestTemplate + public void testSetWriteDistributedAndUnorderedInverted() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string) USING iceberg PARTITIONED BY (category)", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE UNORDERED DISTRIBUTED BY PARTITION", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "hash"); + + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(SortOrder.unsorted()); + } + + @TestTemplate + public void testSetWriteDistributedAndLocallyOrderedInverted() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, category string) USING iceberg PARTITIONED BY (category)", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.sortOrder().isUnsorted()).as("Table should start unsorted").isTrue(); + + sql("ALTER TABLE %s WRITE ORDERED BY id DISTRIBUTED BY PARTITION", tableName); + + table.refresh(); + + assertThat(table.properties()).containsEntry(TableProperties.WRITE_DISTRIBUTION_MODE, "hash"); + + SortOrder expected = SortOrder.builderFor(table.schema()).withOrderId(1).asc("id").build(); + assertThat(table.sortOrder()).as("Sort order must match").isEqualTo(expected); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSnapshotTableProcedure.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSnapshotTableProcedure.java new file mode 100644 index 000000000000..3f8b574126ba --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSnapshotTableProcedure.java @@ -0,0 +1,428 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import org.apache.avro.generic.GenericData; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.avro.AvroIterable; +import org.apache.iceberg.avro.GenericAvroReader; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSnapshotTableProcedure extends ExtensionsTestBase { + private static final String SOURCE_NAME = "spark_catalog.default.source"; + + // Currently we can only Snapshot only out of the Spark Session Catalog + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s PURGE", SOURCE_NAME); + } + + @TestTemplate + public void testSnapshot() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + Object result = + scalarSql("CALL %s.system.snapshot('%s', '%s')", catalogName, SOURCE_NAME, tableName); + + assertThat(result).as("Should have added one file").isEqualTo(1L); + + Table createdTable = validationCatalog.loadTable(tableIdent); + String tableLocation = createdTable.location(); + assertThat(tableLocation) + .as("Table should not have the original location") + .isNotEqualTo(location); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSnapshotWithProperties() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + Object result = + scalarSql( + "CALL %s.system.snapshot(source_table => '%s', table => '%s', properties => map('foo','bar'))", + catalogName, SOURCE_NAME, tableName); + + assertThat(result).as("Should have added one file").isEqualTo(1L); + + Table createdTable = validationCatalog.loadTable(tableIdent); + + String tableLocation = createdTable.location(); + assertThat(tableLocation) + .as("Table should not have the original location") + .isNotEqualTo(location); + + Map props = createdTable.properties(); + assertThat(props).as("Should have extra property set").containsEntry("foo", "bar"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSnapshotWithAlternateLocation() throws IOException { + assumeThat(catalogName) + .as("No Snapshoting with Alternate locations with Hadoop Catalogs") + .doesNotContain("hadoop"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + String snapshotLocation = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + Object[] result = + sql( + "CALL %s.system.snapshot(source_table => '%s', table => '%s', location => '%s')", + catalogName, SOURCE_NAME, tableName, snapshotLocation) + .get(0); + + assertThat(result[0]).as("Should have added one file").isEqualTo(1L); + + String storageLocation = validationCatalog.loadTable(tableIdent).location(); + assertThat(storageLocation) + .as("Snapshot should be made at specified location") + .isEqualTo(snapshotLocation); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testDropTable() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + + Object result = + scalarSql("CALL %s.system.snapshot('%s', '%s')", catalogName, SOURCE_NAME, tableName); + assertThat(result).as("Should have added one file").isEqualTo(1L); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + + sql("DROP TABLE %s", tableName); + + assertEquals( + "Source table should be intact", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", SOURCE_NAME)); + } + + @TestTemplate + public void testSnapshotWithConflictingProps() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + + Object result = + scalarSql( + "CALL %s.system.snapshot(" + + "source_table => '%s'," + + "table => '%s'," + + "properties => map('%s', 'true', 'snapshot', 'false'))", + catalogName, SOURCE_NAME, tableName, TableProperties.GC_ENABLED); + assertThat(result).as("Should have added one file").isEqualTo(1L); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a")), + sql("SELECT * FROM %s", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + Map props = table.properties(); + assertThat(props) + .contains(entry("snapshot", "true"), entry(TableProperties.GC_ENABLED, "false")); + } + + @TestTemplate + public void testInvalidSnapshotsCases() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + + assertThatThrownBy(() -> sql("CALL %s.system.snapshot('foo')", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessage( + "[REQUIRED_PARAMETER_NOT_FOUND] Cannot invoke routine `snapshot` because the parameter named `table` is required, but the routine call did not supply a value. Please update the routine call to supply an argument value (either positionally at index 0 or by name) and retry the query again. SQLSTATE: 4274K"); + + assertThatThrownBy( + () -> sql("CALL %s.system.snapshot('n', 't', map('foo', 'bar'))", catalogName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[DATATYPE_MISMATCH.UNEXPECTED_INPUT_TYPE] Cannot resolve CALL due to data type mismatch: The third parameter requires the \"STRING\" type, however \"map(foo, bar)\" has the type \"MAP\". SQLSTATE: 42K09"); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.snapshot('%s', 'fable', 'loc', map(2, 1, 1))", + catalogName, SOURCE_NAME)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "The `map` requires 2n (n > 0) parameters but the actual number is 3"); + + assertThatThrownBy(() -> sql("CALL %s.system.snapshot('', 'dest')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for argument source_table"); + + assertThatThrownBy(() -> sql("CALL %s.system.snapshot('src', '')", catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot handle an empty identifier for argument table"); + } + + @TestTemplate + public void testSnapshotWithParallelism() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + + List result = + sql( + "CALL %s.system.snapshot(source_table => '%s', table => '%s', parallelism => %d)", + catalogName, SOURCE_NAME, tableName, 2); + assertEquals("Procedure output must match", ImmutableList.of(row(2L)), result); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSnapshotWithInvalidParallelism() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + + assertThatThrownBy( + () -> + sql( + "CALL %s.system.snapshot(source_table => '%s', table => '%s', parallelism => %d)", + catalogName, SOURCE_NAME, tableName, -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Parallelism should be larger than 0"); + } + + private static final Schema SNAPSHOT_ID_READ_SCHEMA = + new Schema( + Types.NestedField.required("snapshot_id") + .withId(1) + .ofType(Types.LongType.get()) + .asOptional() + .build()); + + @TestTemplate + public void testSnapshotPartitionedWithParallelism() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet PARTITIONED BY (id) LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s (id, data) VALUES (1, 'a'), (2, 'b')", SOURCE_NAME); + List result = + sql( + "CALL %s.system.snapshot(source_table => '%s', table => '%s', parallelism => %d)", + catalogName, SOURCE_NAME, tableName, 2); + assertEquals("Procedure output must match", ImmutableList.of(row(2L)), result); + assertEquals( + "Should have expected rows", + ImmutableList.of(row("a", 1L), row("b", 2L)), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSnapshotPartitioned() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet PARTITIONED BY (id) LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s (id, data) VALUES (1, 'a'), (2, 'b')", SOURCE_NAME); + assertThat( + sql( + "CALL %s.system.snapshot(source_table => '%s', table => '%s')", + catalogName, SOURCE_NAME, tableName)) + .containsExactly(row(2L)); + assertThat(sql("SELECT * FROM %s ORDER BY id", tableName)) + .containsExactly(row("a", 1L), row("b", 2L)); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row("a", 1L), row("b", 2L)), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + Table createdTable = validationCatalog.loadTable(tableIdent); + + for (ManifestFile manifest : createdTable.currentSnapshot().dataManifests(createdTable.io())) { + try (AvroIterable reader = + Avro.read(org.apache.iceberg.Files.localInput(manifest.path())) + .project(SNAPSHOT_ID_READ_SCHEMA) + .createResolvingReader(GenericAvroReader::create) + .build()) { + + assertThat(reader.getMetadata()).containsEntry("format-version", "2"); + + List records = Lists.newArrayList(reader.iterator()); + for (GenericData.Record row : records) { + assertThat(row.get(0)).as("Field-ID should be inherited").isNull(); + } + } + } + } + + @TestTemplate + public void testSnapshotPartitionedV1() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet PARTITIONED BY (id) LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s (id, data) VALUES (1, 'a'), (2, 'b')", SOURCE_NAME); + assertThat( + sql( + "CALL %s.system.snapshot(source_table => '%s', table => '%s', properties => map('format-version', '1'))", + catalogName, SOURCE_NAME, tableName)) + .containsExactly(row(2L)); + assertThat(sql("SELECT * FROM %s ORDER BY id", tableName)) + .containsExactly(row("a", 1L), row("b", 2L)); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row("a", 1L), row("b", 2L)), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + Table createdTable = validationCatalog.loadTable(tableIdent); + + for (ManifestFile manifest : createdTable.currentSnapshot().dataManifests(createdTable.io())) { + try (AvroIterable reader = + Avro.read(org.apache.iceberg.Files.localInput(manifest.path())) + .project(SNAPSHOT_ID_READ_SCHEMA) + .createResolvingReader(GenericAvroReader::create) + .build()) { + + assertThat(reader.getMetadata()).containsEntry("format-version", "1"); + + List records = Lists.newArrayList(reader.iterator()); + for (GenericData.Record row : records) { + assertThat(row.get(0)).as("Field-ID should not be inherited").isNotNull(); + } + } + } + } + + @TestTemplate + public void testSnapshotWithVariant() throws IOException { + assumeThat(catalogName) + .as("Variant type requires Hive 4 which is not yet supported") + .isNotEqualTo("testhive") + .isNotEqualTo("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data variant) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, parse_json('{\"key\": 123}'))", SOURCE_NAME); + + Object result = + scalarSql( + "CALL %s.system.snapshot('%s', '%s', properties => map('format-version','3'))", + catalogName, SOURCE_NAME, tableName); + assertThat(result).as("Should have added one file").isEqualTo(1L); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, 123)), + sql("SELECT id, variant_get(data, '$.key', 'int') FROM %s", tableName)); + } + + @TestTemplate + public void testSnapshotPartitionedWithVariant() throws IOException { + assumeThat(catalogName) + .as("Variant type requires Hive 4 which is not yet supported") + .isNotEqualTo("testhive") + .isNotEqualTo("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data variant) USING parquet PARTITIONED BY (id) LOCATION '%s'", + SOURCE_NAME, location); + sql( + "INSERT INTO TABLE %s (id, data) VALUES (1, parse_json('{\"key\": 123}')), (2, parse_json('{\"key\": 456}'))", + SOURCE_NAME); + + Object result = + scalarSql( + "CALL %s.system.snapshot('%s', '%s', properties => map('format-version','3'))", + catalogName, SOURCE_NAME, tableName); + assertThat(result).as("Should have added two files").isEqualTo(2L); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(123, 1L), row(456, 2L)), + sql("SELECT variant_get(data, '$.key', 'int'), id FROM %s ORDER BY id", tableName)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestStoragePartitionedJoinsInRowLevelOperations.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestStoragePartitionedJoinsInRowLevelOperations.java new file mode 100644 index 000000000000..ce609450c097 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestStoragePartitionedJoinsInRowLevelOperations.java @@ -0,0 +1,316 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.RowLevelOperationMode.COPY_ON_WRITE; +import static org.apache.iceberg.RowLevelOperationMode.MERGE_ON_READ; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestStoragePartitionedJoinsInRowLevelOperations extends ExtensionsTestBase { + + private static final String OTHER_TABLE_NAME = "other_table"; + + // open file cost and split size are set as 16 MB to produce a split per file + private static final Map COMMON_TABLE_PROPERTIES = + ImmutableMap.of( + TableProperties.FORMAT_VERSION, + "2", + TableProperties.SPLIT_SIZE, + "16777216", + TableProperties.SPLIT_OPEN_FILE_COST, + "16777216"); + + // only v2 bucketing and preserve data grouping properties have to be enabled to trigger SPJ + // other properties are only to simplify testing and validation + private static final Map ENABLED_SPJ_SQL_CONF = + ImmutableMap.of( + SQLConf.V2_BUCKETING_ENABLED().key(), + "true", + SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED().key(), + "true", + SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION().key(), + "false", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "false", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD().key(), + "-1", + SparkSQLProperties.PRESERVE_DATA_GROUPING, + "true"); + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties() + } + }; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testCopyOnWriteDeleteWithoutShuffles() { + checkDelete(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadDeleteWithoutShuffles() { + checkDelete(MERGE_ON_READ); + } + + private void checkDelete(RowLevelOperationMode mode) { + String createTableStmt = + "CREATE TABLE %s (id INT, salary INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep) " + + "TBLPROPERTIES (%s)"; + + sql(createTableStmt, tableName, tablePropsAsString(COMMON_TABLE_PROPERTIES)); + + append(tableName, "{ \"id\": 1, \"salary\": 100, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 2, \"salary\": 200, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 3, \"salary\": 300, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 4, \"salary\": 400, \"dep\": \"hardware\" }"); + + sql(createTableStmt, tableName(OTHER_TABLE_NAME), tablePropsAsString(COMMON_TABLE_PROPERTIES)); + + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 1, \"salary\": 110, \"dep\": \"hr\" }"); + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 5, \"salary\": 500, \"dep\": \"hr\" }"); + + Map deleteTableProps = + ImmutableMap.of( + TableProperties.DELETE_MODE, + mode.modeName(), + TableProperties.DELETE_DISTRIBUTION_MODE, + "none"); + + sql("ALTER TABLE %s SET TBLPROPERTIES(%s)", tableName, tablePropsAsString(deleteTableProps)); + + withSQLConf( + ENABLED_SPJ_SQL_CONF, + () -> { + SparkPlan plan = + executeAndKeepPlan( + "DELETE FROM %s t WHERE " + + "EXISTS (SELECT 1 FROM %s s WHERE t.id = s.id AND t.dep = s.dep)", + tableName, tableName(OTHER_TABLE_NAME)); + String planAsString = plan.toString(); + if (mode == COPY_ON_WRITE) { + int actualNumShuffles = StringUtils.countMatches(planAsString, "Exchange"); + assertThat(actualNumShuffles).as("Should be 1 shuffle with SPJ").isEqualTo(1); + assertThat(planAsString).contains("Exchange hashpartitioning(_file"); + } else { + assertThat(planAsString).doesNotContain("Exchange"); + } + }); + + ImmutableList expectedRows = + ImmutableList.of( + row(2, 200, "hr"), // remaining + row(3, 300, "hr"), // remaining + row(4, 400, "hardware")); // remaining + + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id, salary", tableName)); + } + + @TestTemplate + public void testCopyOnWriteUpdateWithoutShuffles() { + checkUpdate(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadUpdateWithoutShuffles() { + checkUpdate(MERGE_ON_READ); + } + + private void checkUpdate(RowLevelOperationMode mode) { + String createTableStmt = + "CREATE TABLE %s (id INT, salary INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep) " + + "TBLPROPERTIES (%s)"; + + sql(createTableStmt, tableName, tablePropsAsString(COMMON_TABLE_PROPERTIES)); + + append(tableName, "{ \"id\": 1, \"salary\": 100, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 2, \"salary\": 200, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 3, \"salary\": 300, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 4, \"salary\": 400, \"dep\": \"hardware\" }"); + + sql(createTableStmt, tableName(OTHER_TABLE_NAME), tablePropsAsString(COMMON_TABLE_PROPERTIES)); + + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 1, \"salary\": 110, \"dep\": \"hr\" }"); + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 5, \"salary\": 500, \"dep\": \"hr\" }"); + + Map updateTableProps = + ImmutableMap.of( + TableProperties.UPDATE_MODE, + mode.modeName(), + TableProperties.UPDATE_DISTRIBUTION_MODE, + "none"); + + sql("ALTER TABLE %s SET TBLPROPERTIES(%s)", tableName, tablePropsAsString(updateTableProps)); + + withSQLConf( + ENABLED_SPJ_SQL_CONF, + () -> { + SparkPlan plan = + executeAndKeepPlan( + "UPDATE %s t SET salary = -1 WHERE " + + "EXISTS (SELECT 1 FROM %s s WHERE t.id = s.id AND t.dep = s.dep)", + tableName, tableName(OTHER_TABLE_NAME)); + String planAsString = plan.toString(); + if (mode == COPY_ON_WRITE) { + int actualNumShuffles = StringUtils.countMatches(planAsString, "Exchange"); + assertThat(actualNumShuffles).as("Should be 1 shuffle with SPJ").isEqualTo(1); + assertThat(planAsString).contains("Exchange hashpartitioning(_file"); + } else { + assertThat(planAsString).doesNotContain("Exchange"); + } + }); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, -1, "hr"), // updated + row(2, 200, "hr"), // existing + row(3, 300, "hr"), // existing + row(4, 400, "hardware")); // existing + + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id, salary", tableName)); + } + + @TestTemplate + public void testCopyOnWriteMergeWithoutShuffles() { + checkMerge(COPY_ON_WRITE, false /* with ON predicate */); + } + + @TestTemplate + public void testCopyOnWriteMergeWithoutShufflesWithPredicate() { + checkMerge(COPY_ON_WRITE, true /* with ON predicate */); + } + + @TestTemplate + public void testMergeOnReadMergeWithoutShuffles() { + checkMerge(MERGE_ON_READ, false /* with ON predicate */); + } + + @TestTemplate + public void testMergeOnReadMergeWithoutShufflesWithPredicate() { + checkMerge(MERGE_ON_READ, true /* with ON predicate */); + } + + private void checkMerge(RowLevelOperationMode mode, boolean withPredicate) { + String createTableStmt = + "CREATE TABLE %s (id INT, salary INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep) " + + "TBLPROPERTIES (%s)"; + + sql(createTableStmt, tableName, tablePropsAsString(COMMON_TABLE_PROPERTIES)); + + append(tableName, "{ \"id\": 1, \"salary\": 100, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 2, \"salary\": 200, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 3, \"salary\": 300, \"dep\": \"hr\" }"); + append(tableName, "{ \"id\": 4, \"salary\": 400, \"dep\": \"hardware\" }"); + append(tableName, "{ \"id\": 6, \"salary\": 600, \"dep\": \"software\" }"); + + sql(createTableStmt, tableName(OTHER_TABLE_NAME), tablePropsAsString(COMMON_TABLE_PROPERTIES)); + + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 1, \"salary\": 110, \"dep\": \"hr\" }"); + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 5, \"salary\": 500, \"dep\": \"hr\" }"); + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 6, \"salary\": 300, \"dep\": \"software\" }"); + append(tableName(OTHER_TABLE_NAME), "{ \"id\": 10, \"salary\": 1000, \"dep\": \"ops\" }"); + + Map mergeTableProps = + ImmutableMap.of( + TableProperties.MERGE_MODE, + mode.modeName(), + TableProperties.MERGE_DISTRIBUTION_MODE, + "none"); + + sql("ALTER TABLE %s SET TBLPROPERTIES(%s)", tableName, tablePropsAsString(mergeTableProps)); + + withSQLConf( + ENABLED_SPJ_SQL_CONF, + () -> { + String predicate = withPredicate ? "AND t.dep IN ('hr', 'ops', 'software')" : ""; + SparkPlan plan = + executeAndKeepPlan( + "MERGE INTO %s AS t USING %s AS s " + + "ON t.id = s.id AND t.dep = s.dep %s " + + "WHEN MATCHED THEN " + + " UPDATE SET t.salary = s.salary " + + "WHEN NOT MATCHED THEN " + + " INSERT *", + tableName, tableName(OTHER_TABLE_NAME), predicate); + String planAsString = plan.toString(); + if (mode == COPY_ON_WRITE) { + int actualNumShuffles = StringUtils.countMatches(planAsString, "Exchange"); + assertThat(actualNumShuffles).as("Should be 1 shuffle with SPJ").isEqualTo(1); + assertThat(planAsString).contains("Exchange hashpartitioning(_file"); + } else { + assertThat(planAsString).doesNotContain("Exchange"); + } + }); + + ImmutableList expectedRows = + ImmutableList.of( + row(1, 110, "hr"), // updated + row(2, 200, "hr"), // existing + row(3, 300, "hr"), // existing + row(4, 400, "hardware"), // existing + row(5, 500, "hr"), // new + row(6, 300, "software"), // updated + row(10, 1000, "ops")); // new + + assertEquals( + "Should have expected rows", + expectedRows, + sql("SELECT * FROM %s ORDER BY id, salary", tableName)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownDQL.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownDQL.java new file mode 100644 index 000000000000..f6102bab69b0 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownDQL.java @@ -0,0 +1,313 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.expressions.Expressions.bucket; +import static org.apache.iceberg.expressions.Expressions.day; +import static org.apache.iceberg.expressions.Expressions.equal; +import static org.apache.iceberg.expressions.Expressions.greaterThan; +import static org.apache.iceberg.expressions.Expressions.greaterThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.hour; +import static org.apache.iceberg.expressions.Expressions.lessThan; +import static org.apache.iceberg.expressions.Expressions.lessThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.month; +import static org.apache.iceberg.expressions.Expressions.notEqual; +import static org.apache.iceberg.expressions.Expressions.truncate; +import static org.apache.iceberg.expressions.Expressions.year; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.STRUCT; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.createPartitionedTable; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.createUnpartitionedTable; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToDayOrdinal; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToHourOrdinal; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToMonthOrdinal; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToYearOrdinal; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.expressions.ExpressionUtil; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.source.PlanUtils; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSystemFunctionPushDownDQL extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + }, + }; + } + + @BeforeEach + public void before() { + super.before(); + sql("USE %s", catalogName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testYearsFunctionOnUnpartitionedTable() { + createUnpartitionedTable(spark, tableName); + testYearsFunction(false); + } + + @TestTemplate + public void testYearsFunctionOnPartitionedTable() { + createPartitionedTable(spark, tableName, "years(ts)"); + testYearsFunction(true); + } + + private void testYearsFunction(boolean partitioned) { + int targetYears = timestampStrToYearOrdinal("2017-11-22T00:00:00.000000+00:00"); + String query = + String.format( + "SELECT * FROM %s WHERE system.years(ts) = %s ORDER BY id", tableName, targetYears); + + Dataset df = spark.sql(query); + LogicalPlan optimizedPlan = df.queryExecution().optimizedPlan(); + + checkExpressions(optimizedPlan, partitioned, "years"); + checkPushedFilters(optimizedPlan, equal(year("ts"), targetYears)); + + List actual = rowsToJava(df.collectAsList()); + assertThat(actual).hasSize(5); + } + + @TestTemplate + public void testMonthsFunctionOnUnpartitionedTable() { + createUnpartitionedTable(spark, tableName); + testMonthsFunction(false); + } + + @TestTemplate + public void testMonthsFunctionOnPartitionedTable() { + createPartitionedTable(spark, tableName, "months(ts)"); + testMonthsFunction(true); + } + + private void testMonthsFunction(boolean partitioned) { + int targetMonths = timestampStrToMonthOrdinal("2017-11-22T00:00:00.000000+00:00"); + String query = + String.format( + "SELECT * FROM %s WHERE system.months(ts) > %s ORDER BY id", tableName, targetMonths); + + Dataset df = spark.sql(query); + LogicalPlan optimizedPlan = df.queryExecution().optimizedPlan(); + + checkExpressions(optimizedPlan, partitioned, "months"); + checkPushedFilters(optimizedPlan, greaterThan(month("ts"), targetMonths)); + + List actual = rowsToJava(df.collectAsList()); + assertThat(actual).hasSize(5); + } + + @TestTemplate + public void testDaysFunctionOnUnpartitionedTable() { + createUnpartitionedTable(spark, tableName); + testDaysFunction(false); + } + + @TestTemplate + public void testDaysFunctionOnPartitionedTable() { + createPartitionedTable(spark, tableName, "days(ts)"); + testDaysFunction(true); + } + + private void testDaysFunction(boolean partitioned) { + String timestamp = "2018-11-20T00:00:00.000000+00:00"; + int targetDays = timestampStrToDayOrdinal(timestamp); + String query = + String.format( + "SELECT * FROM %s WHERE system.days(ts) < date('%s') ORDER BY id", + tableName, timestamp); + + Dataset df = spark.sql(query); + LogicalPlan optimizedPlan = df.queryExecution().optimizedPlan(); + + checkExpressions(optimizedPlan, partitioned, "days"); + checkPushedFilters(optimizedPlan, lessThan(day("ts"), targetDays)); + + List actual = rowsToJava(df.collectAsList()); + assertThat(actual).hasSize(5); + } + + @TestTemplate + public void testHoursFunctionOnUnpartitionedTable() { + createUnpartitionedTable(spark, tableName); + testHoursFunction(false); + } + + @TestTemplate + public void testHoursFunctionOnPartitionedTable() { + createPartitionedTable(spark, tableName, "hours(ts)"); + testHoursFunction(true); + } + + private void testHoursFunction(boolean partitioned) { + int targetHours = timestampStrToHourOrdinal("2017-11-22T06:02:09.243857+00:00"); + String query = + String.format( + "SELECT * FROM %s WHERE system.hours(ts) >= %s ORDER BY id", tableName, targetHours); + + Dataset df = spark.sql(query); + LogicalPlan optimizedPlan = df.queryExecution().optimizedPlan(); + + checkExpressions(optimizedPlan, partitioned, "hours"); + checkPushedFilters(optimizedPlan, greaterThanOrEqual(hour("ts"), targetHours)); + + List actual = rowsToJava(df.collectAsList()); + assertThat(actual).hasSize(8); + } + + @TestTemplate + public void testBucketLongFunctionOnUnpartitionedTable() { + createUnpartitionedTable(spark, tableName); + testBucketLongFunction(false); + } + + @TestTemplate + public void testBucketLongFunctionOnPartitionedTable() { + createPartitionedTable(spark, tableName, "bucket(5, id)"); + testBucketLongFunction(true); + } + + private void testBucketLongFunction(boolean partitioned) { + int target = 2; + String query = + String.format( + "SELECT * FROM %s WHERE system.bucket(5, id) <= %s ORDER BY id", tableName, target); + + Dataset df = spark.sql(query); + LogicalPlan optimizedPlan = df.queryExecution().optimizedPlan(); + + checkExpressions(optimizedPlan, partitioned, "bucket"); + checkPushedFilters(optimizedPlan, lessThanOrEqual(bucket("id", 5), target)); + + List actual = rowsToJava(df.collectAsList()); + assertThat(actual).hasSize(5); + } + + @TestTemplate + public void testBucketStringFunctionOnUnpartitionedTable() { + createUnpartitionedTable(spark, tableName); + testBucketStringFunction(false); + } + + @TestTemplate + public void testBucketStringFunctionOnPartitionedTable() { + createPartitionedTable(spark, tableName, "bucket(5, data)"); + testBucketStringFunction(true); + } + + private void testBucketStringFunction(boolean partitioned) { + int target = 2; + String query = + String.format( + "SELECT * FROM %s WHERE system.bucket(5, data) != %s ORDER BY id", tableName, target); + + Dataset df = spark.sql(query); + LogicalPlan optimizedPlan = df.queryExecution().optimizedPlan(); + + checkExpressions(optimizedPlan, partitioned, "bucket"); + checkPushedFilters(optimizedPlan, notEqual(bucket("data", 5), target)); + + List actual = rowsToJava(df.collectAsList()); + assertThat(actual).hasSize(8); + } + + @TestTemplate + public void testTruncateFunctionOnUnpartitionedTable() { + createUnpartitionedTable(spark, tableName); + testTruncateFunction(false); + } + + @TestTemplate + public void testTruncateFunctionOnPartitionedTable() { + createPartitionedTable(spark, tableName, "truncate(4, data)"); + testTruncateFunction(true); + } + + private void testTruncateFunction(boolean partitioned) { + String target = "data"; + String query = + String.format( + "SELECT * FROM %s WHERE system.truncate(4, data) = '%s' ORDER BY id", + tableName, target); + + Dataset df = spark.sql(query); + LogicalPlan optimizedPlan = df.queryExecution().optimizedPlan(); + + checkExpressions(optimizedPlan, partitioned, "truncate"); + checkPushedFilters(optimizedPlan, equal(truncate("data", 4), target)); + + List actual = rowsToJava(df.collectAsList()); + assertThat(actual).hasSize(5); + } + + private void checkExpressions( + LogicalPlan optimizedPlan, boolean partitioned, String expectedFunctionName) { + List staticInvokes = + PlanUtils.collectSparkExpressions( + optimizedPlan, expression -> expression instanceof StaticInvoke); + assertThat(staticInvokes).isEmpty(); + + List applyExpressions = + PlanUtils.collectSparkExpressions( + optimizedPlan, expression -> expression instanceof ApplyFunctionExpression); + + if (partitioned) { + assertThat(applyExpressions).isEmpty(); + } else { + assertThat(applyExpressions).hasSize(1); + ApplyFunctionExpression expression = (ApplyFunctionExpression) applyExpressions.get(0); + assertThat(expression.name()).isEqualTo(expectedFunctionName); + } + } + + private void checkPushedFilters( + LogicalPlan optimizedPlan, org.apache.iceberg.expressions.Expression expected) { + List pushedFilters = + PlanUtils.collectPushDownFilters(optimizedPlan); + assertThat(pushedFilters).hasSize(1); + org.apache.iceberg.expressions.Expression actual = pushedFilters.get(0); + assertThat(ExpressionUtil.equivalent(expected, actual, STRUCT, true)) + .as("Pushed filter should match") + .isTrue(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java new file mode 100644 index 000000000000..934220e5d31e --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestSystemFunctionPushDownInRowLevelOperations.java @@ -0,0 +1,365 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.RowLevelOperationMode.COPY_ON_WRITE; +import static org.apache.iceberg.RowLevelOperationMode.MERGE_ON_READ; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException; +import org.apache.spark.sql.catalyst.expressions.ApplyFunctionExpression; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.catalyst.expressions.objects.StaticInvoke; +import org.apache.spark.sql.execution.CommandResultExec; +import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSystemFunctionPushDownInRowLevelOperations extends ExtensionsTestBase { + + private static final String CHANGES_TABLE_NAME = "changes"; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties() + } + }; + } + + @BeforeEach + public void beforeEach() { + sql("USE %s", catalogName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s PURGE", tableName); + sql("DROP TABLE IF EXISTS %s PURGE", tableName(CHANGES_TABLE_NAME)); + } + + @TestTemplate + public void testCopyOnWriteDeleteBucketTransformInPredicate() { + initTable("bucket(4, dep)"); + checkDelete(COPY_ON_WRITE, "system.bucket(4, dep) IN (2, 3)"); + } + + @TestTemplate + public void testMergeOnReadDeleteBucketTransformInPredicate() { + initTable("bucket(4, dep)"); + checkDelete(MERGE_ON_READ, "system.bucket(4, dep) IN (2, 3)"); + } + + @TestTemplate + public void testCopyOnWriteDeleteBucketTransformEqPredicate() { + initTable("bucket(4, dep)"); + checkDelete(COPY_ON_WRITE, "system.bucket(4, dep) = 2"); + } + + @TestTemplate + public void testMergeOnReadDeleteBucketTransformEqPredicate() { + initTable("bucket(4, dep)"); + checkDelete(MERGE_ON_READ, "system.bucket(4, dep) = 2"); + } + + @TestTemplate + public void testCopyOnWriteDeleteYearsTransform() { + initTable("years(ts)"); + checkDelete(COPY_ON_WRITE, "system.years(ts) > 30"); + } + + @TestTemplate + public void testMergeOnReadDeleteYearsTransform() { + initTable("years(ts)"); + checkDelete(MERGE_ON_READ, "system.years(ts) <= 30"); + } + + @TestTemplate + public void testCopyOnWriteDeleteMonthsTransform() { + initTable("months(ts)"); + checkDelete(COPY_ON_WRITE, "system.months(ts) <= 250"); + } + + @TestTemplate + public void testMergeOnReadDeleteMonthsTransform() { + initTable("months(ts)"); + checkDelete(MERGE_ON_READ, "system.months(ts) > 250"); + } + + @TestTemplate + public void testCopyOnWriteDeleteDaysTransform() { + initTable("days(ts)"); + checkDelete(COPY_ON_WRITE, "system.days(ts) <= date('2000-01-03 00:00:00')"); + } + + @TestTemplate + public void testMergeOnReadDeleteDaysTransform() { + initTable("days(ts)"); + checkDelete(MERGE_ON_READ, "system.days(ts) > date('2000-01-03 00:00:00')"); + } + + @TestTemplate + public void testCopyOnWriteDeleteHoursTransform() { + initTable("hours(ts)"); + checkDelete(COPY_ON_WRITE, "system.hours(ts) <= 100000"); + } + + @TestTemplate + public void testMergeOnReadDeleteHoursTransform() { + initTable("hours(ts)"); + checkDelete(MERGE_ON_READ, "system.hours(ts) > 100000"); + } + + @TestTemplate + public void testCopyOnWriteDeleteTruncateTransform() { + initTable("truncate(1, dep)"); + checkDelete(COPY_ON_WRITE, "system.truncate(1, dep) = 'i'"); + } + + @TestTemplate + public void testMergeOnReadDeleteTruncateTransform() { + initTable("truncate(1, dep)"); + checkDelete(MERGE_ON_READ, "system.truncate(1, dep) = 'i'"); + } + + @TestTemplate + public void testCopyOnWriteUpdateBucketTransform() { + initTable("bucket(4, dep)"); + checkUpdate(COPY_ON_WRITE, "system.bucket(4, dep) IN (2, 3)"); + } + + @TestTemplate + public void testMergeOnReadUpdateBucketTransform() { + initTable("bucket(4, dep)"); + checkUpdate(MERGE_ON_READ, "system.bucket(4, dep) = 2"); + } + + @TestTemplate + public void testCopyOnWriteUpdateYearsTransform() { + initTable("years(ts)"); + checkUpdate(COPY_ON_WRITE, "system.years(ts) > 30"); + } + + @TestTemplate + public void testMergeOnReadUpdateYearsTransform() { + initTable("years(ts)"); + checkUpdate(MERGE_ON_READ, "system.years(ts) <= 30"); + } + + @TestTemplate + public void testCopyOnWriteMergeBucketTransform() { + initTable("bucket(4, dep)"); + checkMerge(COPY_ON_WRITE, "system.bucket(4, dep) IN (2, 3)"); + } + + @TestTemplate + public void testMergeOnReadMergeBucketTransform() { + initTable("bucket(4, dep)"); + checkMerge(MERGE_ON_READ, "system.bucket(4, dep) = 2"); + } + + @TestTemplate + public void testCopyOnWriteMergeYearsTransform() { + initTable("years(ts)"); + checkMerge(COPY_ON_WRITE, "system.years(ts) > 30"); + } + + @TestTemplate + public void testMergeOnReadMergeYearsTransform() { + initTable("years(ts)"); + checkMerge(MERGE_ON_READ, "system.years(ts) <= 30"); + } + + @TestTemplate + public void testCopyOnWriteMergeTruncateTransform() { + initTable("truncate(1, dep)"); + checkMerge(COPY_ON_WRITE, "system.truncate(1, dep) = 'i'"); + } + + @TestTemplate + public void testMergeOnReadMergeTruncateTransform() { + initTable("truncate(1, dep)"); + checkMerge(MERGE_ON_READ, "system.truncate(1, dep) = 'i'"); + } + + private void checkDelete(RowLevelOperationMode mode, String cond) { + withUnavailableLocations( + findIrrelevantFileLocations(cond), + () -> { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s', '%s' '%s')", + tableName, + TableProperties.DELETE_MODE, + mode.modeName(), + TableProperties.DELETE_DISTRIBUTION_MODE, + DistributionMode.NONE.modeName()); + + Dataset changeDF = spark.table(tableName).where(cond).limit(2).select("id"); + try { + changeDF.coalesce(1).writeTo(tableName(CHANGES_TABLE_NAME)).create(); + } catch (TableAlreadyExistsException e) { + throw new AlreadyExistsException( + "Cannot create table %s as it already exists", CHANGES_TABLE_NAME); + } + + List calls = + executeAndCollectFunctionCalls( + "DELETE FROM %s t WHERE %s AND t.id IN (SELECT id FROM %s)", + tableName, cond, tableName(CHANGES_TABLE_NAME)); + // CoW planning currently does not optimize post-scan filters in DELETE + int expectedCallCount = mode == COPY_ON_WRITE ? 1 : 0; + assertThat(calls).hasSize(expectedCallCount); + + assertEquals( + "Should have no matching rows", + ImmutableList.of(), + sql( + "SELECT * FROM %s WHERE %s AND id IN (SELECT * FROM %s)", + tableName, cond, tableName(CHANGES_TABLE_NAME))); + }); + } + + private void checkUpdate(RowLevelOperationMode mode, String cond) { + withUnavailableLocations( + findIrrelevantFileLocations(cond), + () -> { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s', '%s' '%s')", + tableName, + TableProperties.UPDATE_MODE, + mode.modeName(), + TableProperties.UPDATE_DISTRIBUTION_MODE, + DistributionMode.NONE.modeName()); + + Dataset changeDF = spark.table(tableName).where(cond).limit(2).select("id"); + try { + changeDF.coalesce(1).writeTo(tableName(CHANGES_TABLE_NAME)).create(); + } catch (TableAlreadyExistsException e) { + throw new AlreadyExistsException( + "Cannot create table %s as it already exists", CHANGES_TABLE_NAME); + } + + List calls = + executeAndCollectFunctionCalls( + "UPDATE %s t SET t.salary = -1 WHERE %s AND t.id IN (SELECT id FROM %s)", + tableName, cond, tableName(CHANGES_TABLE_NAME)); + // CoW planning currently does not optimize post-scan filters in UPDATE + int expectedCallCount = mode == COPY_ON_WRITE ? 2 : 0; + assertThat(calls).hasSize(expectedCallCount); + + assertEquals( + "Should have correct updates", + sql("SELECT id FROM %s", tableName(CHANGES_TABLE_NAME)), + sql("SELECT id FROM %s WHERE %s AND salary = -1", tableName, cond)); + }); + } + + private void checkMerge(RowLevelOperationMode mode, String cond) { + withUnavailableLocations( + findIrrelevantFileLocations(cond), + () -> { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s', '%s' '%s')", + tableName, + TableProperties.MERGE_MODE, + mode.modeName(), + TableProperties.MERGE_DISTRIBUTION_MODE, + DistributionMode.NONE.modeName()); + + Dataset changeDF = + spark.table(tableName).where(cond).limit(2).selectExpr("id + 1 as id"); + try { + changeDF.coalesce(1).writeTo(tableName(CHANGES_TABLE_NAME)).create(); + } catch (TableAlreadyExistsException e) { + throw new AlreadyExistsException( + "Cannot create table %s as it already exists", CHANGES_TABLE_NAME); + } + + List calls = + executeAndCollectFunctionCalls( + "MERGE INTO %s t USING %s s " + + "ON t.id == s.id AND %s " + + "WHEN MATCHED THEN " + + " UPDATE SET salary = -1 " + + "WHEN NOT MATCHED AND s.id = 2 THEN " + + " INSERT (id, salary, dep, ts) VALUES (100, -1, 'hr', null)", + tableName, tableName(CHANGES_TABLE_NAME), cond); + assertThat(calls).isEmpty(); + + assertEquals( + "Should have correct updates", + sql("SELECT id FROM %s", tableName(CHANGES_TABLE_NAME)), + sql("SELECT id FROM %s WHERE %s AND salary = -1", tableName, cond)); + }); + } + + private List executeAndCollectFunctionCalls(String query, Object... args) { + CommandResultExec command = (CommandResultExec) executeAndKeepPlan(query, args); + V2TableWriteExec write = (V2TableWriteExec) command.commandPhysicalPlan(); + return SparkPlanUtil.collectExprs( + write.query(), + expr -> expr instanceof StaticInvoke || expr instanceof ApplyFunctionExpression); + } + + private List findIrrelevantFileLocations(String cond) { + return spark + .table(tableName) + .where("NOT " + cond) + .select(MetadataColumns.FILE_PATH.name()) + .distinct() + .as(Encoders.STRING()) + .collectAsList(); + } + + private void initTable(String transform) { + sql( + "CREATE TABLE %s (id BIGINT, salary INT, dep STRING, ts TIMESTAMP)" + + "USING iceberg " + + "PARTITIONED BY (%s)", + tableName, transform); + + append( + tableName, + "{ \"id\": 1, \"salary\": 100, \"dep\": \"hr\", \"ts\": \"1975-01-01 06:00:00\" }", + "{ \"id\": 2, \"salary\": 200, \"dep\": \"hr\", \"ts\": \"1975-01-01 06:00:00\" }", + "{ \"id\": 3, \"salary\": 300, \"dep\": \"hr\", \"ts\": \"1975-01-01 06:00:00\" }", + "{ \"id\": 4, \"salary\": 400, \"dep\": \"it\", \"ts\": \"2020-01-01 10:00:00\" }", + "{ \"id\": 5, \"salary\": 500, \"dep\": \"it\", \"ts\": \"2020-01-01 10:00:00\" }", + "{ \"id\": 6, \"salary\": 600, \"dep\": \"it\", \"ts\": \"2020-01-01 10:00:00\" }"); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestTagDDL.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestTagDDL.java new file mode 100644 index 000000000000..79d5b0ab41f1 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestTagDDL.java @@ -0,0 +1,365 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.extensions.IcebergParseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestTagDDL extends ExtensionsTestBase { + private static final String[] TIME_UNITS = {"DAYS", "HOURS", "MINUTES"}; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties() + } + }; + } + + @BeforeEach + public void createTable() { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testCreateTagWithRetain() throws NoSuchTableException { + Table table = insertRows(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + long maxRefAge = 10L; + + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + + for (String timeUnit : TIME_UNITS) { + String tagName = "t1" + timeUnit; + sql( + "ALTER TABLE %s CREATE TAG %s AS OF VERSION %d RETAIN %d %s", + tableName, tagName, firstSnapshotId, maxRefAge, timeUnit); + table.refresh(); + SnapshotRef ref = table.refs().get(tagName); + assertThat(ref.snapshotId()) + .as("The tag needs to point to a specific snapshot id.") + .isEqualTo(firstSnapshotId); + assertThat(ref.maxRefAgeMs().longValue()) + .as("The tag needs to have the correct max ref age.") + .isEqualTo(TimeUnit.valueOf(timeUnit.toUpperCase(Locale.ENGLISH)).toMillis(maxRefAge)); + } + + String tagName = "t1"; + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s CREATE TAG %s AS OF VERSION %d RETAIN", + tableName, tagName, firstSnapshotId, maxRefAge)) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input ''"); + + assertThatThrownBy( + () -> sql("ALTER TABLE %s CREATE TAG %s RETAIN %s DAYS", tableName, tagName, "abc")) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input 'abc'"); + + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s CREATE TAG %s AS OF VERSION %d RETAIN %d SECONDS", + tableName, tagName, firstSnapshotId, maxRefAge)) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("mismatched input 'SECONDS' expecting {'DAYS', 'HOURS', 'MINUTES'}"); + } + + @TestTemplate + public void testCreateTagOnEmptyTable() { + assertThatThrownBy(() -> sql("ALTER TABLE %s CREATE TAG %s", tableName, "abc")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining( + "Cannot complete create or replace tag operation on %s, main has no snapshot", + tableName); + } + + @TestTemplate + public void testCreateTagUseDefaultConfig() throws NoSuchTableException { + Table table = insertRows(); + long snapshotId = table.currentSnapshot().snapshotId(); + String tagName = "t1"; + + assertThatThrownBy( + () -> sql("ALTER TABLE %s CREATE TAG %s AS OF VERSION %d", tableName, tagName, -1)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot set " + tagName + " to unknown snapshot: -1"); + + sql("ALTER TABLE %s CREATE TAG %s", tableName, tagName); + table.refresh(); + SnapshotRef ref = table.refs().get(tagName); + assertThat(ref.snapshotId()) + .as("The tag needs to point to a specific snapshot id.") + .isEqualTo(snapshotId); + assertThat(ref.maxRefAgeMs()) + .as("The tag needs to have the default max ref age, which is null.") + .isNull(); + + assertThatThrownBy(() -> sql("ALTER TABLE %s CREATE TAG %s", tableName, tagName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already exists"); + + assertThatThrownBy(() -> sql("ALTER TABLE %s CREATE TAG %s", tableName, "123")) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input '123'"); + + table.manageSnapshots().removeTag(tagName).commit(); + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + snapshotId = table.currentSnapshot().snapshotId(); + sql("ALTER TABLE %s CREATE TAG %s AS OF VERSION %d", tableName, tagName, snapshotId); + table.refresh(); + ref = table.refs().get(tagName); + assertThat(ref.snapshotId()) + .as("The tag needs to point to a specific snapshot id.") + .isEqualTo(snapshotId); + assertThat(ref.maxRefAgeMs()) + .as("The tag needs to have the default max ref age, which is null.") + .isNull(); + } + + @TestTemplate + public void testCreateTagIfNotExists() throws NoSuchTableException { + long maxSnapshotAge = 2L; + Table table = insertRows(); + String tagName = "t1"; + sql("ALTER TABLE %s CREATE TAG %s RETAIN %d days", tableName, tagName, maxSnapshotAge); + sql("ALTER TABLE %s CREATE TAG IF NOT EXISTS %s", tableName, tagName); + + table.refresh(); + SnapshotRef ref = table.refs().get(tagName); + assertThat(ref.snapshotId()) + .as("The tag needs to point to a specific snapshot id.") + .isEqualTo(table.currentSnapshot().snapshotId()); + assertThat(ref.maxRefAgeMs().longValue()) + .as("The tag needs to have the correct max ref age.") + .isEqualTo(TimeUnit.DAYS.toMillis(maxSnapshotAge)); + } + + @TestTemplate + public void testReplaceTagFailsForBranch() throws NoSuchTableException { + String branchName = "branch1"; + Table table = insertRows(); + long first = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createBranch(branchName, first).commit(); + insertRows(); + long second = table.currentSnapshot().snapshotId(); + + assertThatThrownBy(() -> sql("ALTER TABLE %s REPLACE Tag %s", tableName, branchName, second)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Ref branch1 is a branch not a tag"); + } + + @TestTemplate + public void testReplaceTag() throws NoSuchTableException { + Table table = insertRows(); + long first = table.currentSnapshot().snapshotId(); + String tagName = "t1"; + long expectedMaxRefAgeMs = 1000; + table + .manageSnapshots() + .createTag(tagName, first) + .setMaxRefAgeMs(tagName, expectedMaxRefAgeMs) + .commit(); + + insertRows(); + long second = table.currentSnapshot().snapshotId(); + + sql("ALTER TABLE %s REPLACE Tag %s AS OF VERSION %d", tableName, tagName, second); + table.refresh(); + SnapshotRef ref = table.refs().get(tagName); + assertThat(ref.snapshotId()) + .as("The tag needs to point to a specific snapshot id.") + .isEqualTo(second); + assertThat(ref.maxRefAgeMs().longValue()) + .as("The tag needs to have the correct max ref age.") + .isEqualTo(expectedMaxRefAgeMs); + } + + @TestTemplate + public void testReplaceTagDoesNotExist() throws NoSuchTableException { + Table table = insertRows(); + + assertThatThrownBy( + () -> + sql( + "ALTER TABLE %s REPLACE Tag %s AS OF VERSION %d", + tableName, "someTag", table.currentSnapshot().snapshotId())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Tag does not exist"); + } + + @TestTemplate + public void testReplaceTagWithRetain() throws NoSuchTableException { + Table table = insertRows(); + long first = table.currentSnapshot().snapshotId(); + String tagName = "t1"; + table.manageSnapshots().createTag(tagName, first).commit(); + insertRows(); + long second = table.currentSnapshot().snapshotId(); + + long maxRefAge = 10; + for (String timeUnit : TIME_UNITS) { + sql( + "ALTER TABLE %s REPLACE Tag %s AS OF VERSION %d RETAIN %d %s", + tableName, tagName, second, maxRefAge, timeUnit); + + table.refresh(); + SnapshotRef ref = table.refs().get(tagName); + assertThat(ref.snapshotId()) + .as("The tag needs to point to a specific snapshot id.") + .isEqualTo(second); + assertThat(ref.maxRefAgeMs().longValue()) + .as("The tag needs to have the correct max ref age.") + .isEqualTo(TimeUnit.valueOf(timeUnit).toMillis(maxRefAge)); + } + } + + @TestTemplate + public void testCreateOrReplace() throws NoSuchTableException { + Table table = insertRows(); + long first = table.currentSnapshot().snapshotId(); + String tagName = "t1"; + insertRows(); + long second = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createTag(tagName, second).commit(); + + sql("ALTER TABLE %s CREATE OR REPLACE TAG %s AS OF VERSION %d", tableName, tagName, first); + table.refresh(); + SnapshotRef ref = table.refs().get(tagName); + assertThat(ref.snapshotId()) + .as("The tag needs to point to a specific snapshot id.") + .isEqualTo(first); + } + + @TestTemplate + public void testDropTag() throws NoSuchTableException { + insertRows(); + Table table = validationCatalog.loadTable(tableIdent); + String tagName = "t1"; + table.manageSnapshots().createTag(tagName, table.currentSnapshot().snapshotId()).commit(); + SnapshotRef ref = table.refs().get(tagName); + assertThat(ref.snapshotId()).isEqualTo(table.currentSnapshot().snapshotId()); + + sql("ALTER TABLE %s DROP TAG %s", tableName, tagName); + table.refresh(); + assertThat(table.refs()).doesNotContainKey(tagName); + } + + @TestTemplate + public void testDropTagNonConformingName() { + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP TAG %s", tableName, "123")) + .isInstanceOf(IcebergParseException.class) + .hasMessageContaining("no viable alternative at input '123'"); + } + + @TestTemplate + public void testDropTagDoesNotExist() { + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP TAG %s", tableName, "nonExistingTag")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Tag does not exist: nonExistingTag"); + } + + @TestTemplate + public void testDropTagFailesForBranch() throws NoSuchTableException { + String branchName = "b1"; + Table table = insertRows(); + table.manageSnapshots().createBranch(branchName, table.currentSnapshot().snapshotId()).commit(); + + assertThatThrownBy(() -> sql("ALTER TABLE %s DROP TAG %s", tableName, branchName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Ref b1 is a branch not a tag"); + } + + @TestTemplate + public void testDropTagIfExists() throws NoSuchTableException { + String tagName = "nonExistingTag"; + Table table = insertRows(); + assertThat(table.refs()).doesNotContainKey(tagName); + + sql("ALTER TABLE %s DROP TAG IF EXISTS %s", tableName, tagName); + table.refresh(); + assertThat(table.refs()).doesNotContainKey(tagName); + + table.manageSnapshots().createTag(tagName, table.currentSnapshot().snapshotId()).commit(); + assertThat(table.refs().get(tagName).snapshotId()) + .as("The tag has been created successfully.") + .isEqualTo(table.currentSnapshot().snapshotId()); + + sql("ALTER TABLE %s DROP TAG IF EXISTS %s", tableName, tagName); + table.refresh(); + assertThat(table.refs()).doesNotContainKey(tagName); + } + + @TestTemplate + public void createOrReplaceWithNonExistingTag() throws NoSuchTableException { + Table table = insertRows(); + String tagName = "t1"; + insertRows(); + long snapshotId = table.currentSnapshot().snapshotId(); + + sql("ALTER TABLE %s CREATE OR REPLACE TAG %s AS OF VERSION %d", tableName, tagName, snapshotId); + table.refresh(); + assertThat(table.refs().get(tagName).snapshotId()).isEqualTo(snapshotId); + } + + private Table insertRows() throws NoSuchTableException { + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + return validationCatalog.loadTable(tableIdent); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestTempViewRefresh.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestTempViewRefresh.java new file mode 100644 index 000000000000..a6b7a885b7d6 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestTempViewRefresh.java @@ -0,0 +1,472 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestTempViewRefresh extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.HIVE.properties()) + .put("cache-enabled", "false") + .build() + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.SPARK_SESSION.properties()) + .put("cache-enabled", "true") + .put("cache.expiration-interval-ms", "-1") // indefinite cache + .buildKeepingLast() + } + }; + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP VIEW IF EXISTS tmp"); + } + + @TestTemplate + public void testSessionWrite() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100), (10, 1000)", tableName); + + // create temp view from Dataset with filter + spark.table(tableName).filter("salary < 999").createOrReplaceTempView("tmp"); + + // query view + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100)); + + // add more data via session write + sql("INSERT INTO %s VALUES (2, 200)", tableName); + + // query view again + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result2) + .as("Temp view should reflect session writes") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + + @TestTemplate + public void testExternalWrite() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100), (10, 1000)", tableName); + + // create temp view from Dataset with filter + spark.table(tableName).filter("salary < 999").createOrReplaceTempView("tmp"); + + List result1 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds data + Table table = validationCatalog.loadTable(tableIdent); + + GenericRecord record = GenericRecord.create(table.schema()); + record.set(0, 2); // id + record.set(1, 200); // salary + + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newFastAppend().appendFile(dataFile).commit(); + + // query view again + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Temp view should not reflect external writes when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2) + .as("Temp view should reflect external writes when cache is disabled") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + } + + @TestTemplate + public void testSessionSchemaEvolutionAddColumn() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100), (10, 1000)", tableName); + + // create temp view from Dataset with filter + spark.table(tableName).filter("salary < 999").createOrReplaceTempView("tmp"); + + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100)); + + // add new column via session DDL + sql("ALTER TABLE %s ADD COLUMN new_column INT", tableName); + + // add data with new column + sql("INSERT INTO %s VALUES (2, 200, -1)", tableName); + + // query view again - should preserve original schema but pick up new data + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result2) + .as("Temp view should pick up new data but preserve original schema") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + + @TestTemplate + public void testExternalSchemaEvolutionAddColumn() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100), (10, 1000)", tableName); + + // create temp view from Dataset + spark.table(tableName).filter("salary < 999").createOrReplaceTempView("tmp"); + + List result1 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer adds column and data + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().addColumn("new_column", Types.IntegerType.get()).commit(); + + GenericRecord record = GenericRecord.create(table.schema()); + record.set(0, 2); // id + record.set(1, 200); // salary + record.set(2, -1); // new_column + + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newFastAppend().appendFile(dataFile).commit(); + + // query view again + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Temp view should not reflect external changes when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2) + .as("Temp view should preserve original schema but pick up new snapshot") + .hasSize(2) + .containsExactly(row(1, 100), row(2, 200)); + } + } + + @TestTemplate + public void testSessionSchemaEvolutionDropColumn() { + // create table with extra column + sql("CREATE TABLE %s (id INT, salary INT, new_column INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100, 10)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100, 10)); + + // drop column that view references + sql("ALTER TABLE %s DROP COLUMN new_column", tableName); + + assertThatThrownBy(() -> sql("SELECT * FROM tmp")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("new_column"); + } + + @TestTemplate + public void testExternalSchemaEvolutionDropColumn() { + // create table with extra column + sql("CREATE TABLE %s (id INT, salary INT, new_column INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100, 10)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result1 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result1).hasSize(1).containsExactly(row(1, 100, 10)); + + // external writer drops column + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().deleteColumn("new_column").commit(); + + if (cachingCatalogEnabled()) { + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result2) + .as("Temp view should return cached data when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100, 10)); + } else { + assertThatThrownBy(() -> sql("SELECT * FROM tmp")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("new_column"); + } + } + + @TestTemplate + public void testSessionDropAndRecreateTable() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100)); + + // drop and recreate table with same schema + sql("DROP TABLE %s", tableName); + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (2, 200)", tableName); + + // query view again - should resolve to new table + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result2) + .as("Temp view should resolve to newly created table") + .hasSize(1) + .containsExactly(row(2, 200)); + } + + @TestTemplate + public void testExternalDropAndRecreateTable() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result1 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer drops and recreates table + validationCatalog.dropTable(tableIdent, false /* keep files */); + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "salary", Types.IntegerType.get())); + validationCatalog.createTable(tableIdent, schema); + + // query view again + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Temp view should return stale data from old table when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2).as("Temp view should resolve to newly created table").isEmpty(); + } + } + + @TestTemplate + public void testSessionDropAndAddColumnSameNameSameType() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100)); + + // drop and add column with same name and type + sql("ALTER TABLE %s DROP COLUMN salary", tableName); + sql("ALTER TABLE %s ADD COLUMN salary INT", tableName); + sql("INSERT INTO %s VALUES (2, 200)", tableName); + + // query view again - should resolve to new column + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result2) + .as("Temp view should resolve columns by name") + .hasSize(2) + .containsExactly(row(1, null), row(2, 200)); + } + + @TestTemplate + public void testExternalDropAndAddColumnSameNameSameType() throws IOException { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result1 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer drops and adds column with same name and type + Table table = validationCatalog.loadTable(tableIdent); + table.updateSchema().deleteColumn("salary").commit(); + table.updateSchema().addColumn("salary", Types.IntegerType.get()).commit(); + + GenericRecord record = GenericRecord.create(table.schema()); + record.set(0, 2); // id + record.setField("salary", 200); // new salary column + + DataFile dataFile = writeData(table, ImmutableList.of(record)); + table.newFastAppend().appendFile(dataFile).commit(); + + // query view again + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + if (cachingCatalogEnabled()) { + assertThat(result2) + .as("Temp view should return stale data when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThat(result2) + .as("Temp view should resolve columns by name") + .hasSize(2) + .containsExactly(row(1, null), row(2, 200)); + } + } + + @TestTemplate + public void testSessionDropAndAddColumnSameNameDifferentType() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100)); + + // drop and add column with same name but different type + sql("ALTER TABLE %s DROP COLUMN salary", tableName); + sql("ALTER TABLE %s ADD COLUMN salary STRING", tableName); + + assertThatThrownBy(() -> sql("SELECT * FROM tmp")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("salary"); + } + + @TestTemplate + public void testExternalDropAndAddColumnSameNameDifferentType() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result1 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result1).hasSize(1).containsExactly(row(1, 100)); + + // external writer drops and recreates table with different column type + validationCatalog.dropTable(tableIdent, false /* keep files */); + Schema schema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(3, "salary", Types.StringType.get())); + validationCatalog.createTable(tableIdent, schema); + + if (cachingCatalogEnabled()) { + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result2) + .as("Temp view should return cached data when cache is enabled") + .hasSize(1) + .containsExactly(row(1, 100)); + } else { + assertThatThrownBy(() -> sql("SELECT * FROM tmp")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("salary"); + } + } + + @TestTemplate + public void testTypeWidening() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset + spark.table(tableName).createOrReplaceTempView("tmp"); + + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100)); + + // widen column type from INT to BIGINT + sql("ALTER TABLE %s ALTER COLUMN salary TYPE BIGINT", tableName); + + // query view again - should fail with analysis exception + // (type widening is not supported for temp views in current implementation) + assertThatThrownBy(() -> sql("SELECT * FROM tmp")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("type has changed from INT to BIGINT"); + } + + @TestTemplate + public void testMultipleQueriesReflectLatestData() { + // create table and insert initial data + sql("CREATE TABLE %s (id INT, salary INT) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 100)", tableName); + + // create temp view from Dataset with filter + spark.table(tableName).filter("salary < 999").createOrReplaceTempView("tmp"); + + // first query + List result = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result).hasSize(1).containsExactly(row(1, 100)); + + // add more data + sql("INSERT INTO %s VALUES (2, 200)", tableName); + + // second query - should see new data + List result2 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result2).hasSize(2).containsExactly(row(1, 100), row(2, 200)); + + // add more data + sql("INSERT INTO %s VALUES (3, 300)", tableName); + + // third query - should see all data + List result3 = sql("SELECT * FROM tmp ORDER BY id"); + assertThat(result3).hasSize(3).containsExactly(row(1, 100), row(2, 200), row(3, 300)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java new file mode 100644 index 000000000000..d32908aeb0ed --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestUpdate.java @@ -0,0 +1,1523 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.apache.iceberg.DataOperations.OVERWRITE; +import static org.apache.iceberg.RowLevelOperationMode.COPY_ON_WRITE; +import static org.apache.iceberg.RowLevelOperationMode.MERGE_ON_READ; +import static org.apache.iceberg.SnapshotSummary.ADDED_FILES_PROP; +import static org.apache.iceberg.SnapshotSummary.CHANGED_PARTITION_COUNT_PROP; +import static org.apache.iceberg.SnapshotSummary.DELETED_FILES_PROP; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.iceberg.TableProperties.SPLIT_SIZE; +import static org.apache.iceberg.TableProperties.UPDATE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.UPDATE_ISOLATION_LEVEL; +import static org.apache.iceberg.TableProperties.UPDATE_MODE; +import static org.apache.iceberg.TableProperties.UPDATE_MODE_DEFAULT; +import static org.apache.spark.sql.functions.lit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.MoreExecutors; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.SparkRuntimeException; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.internal.SQLConf; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class TestUpdate extends SparkRowLevelOperationsTestBase { + + @BeforeAll + public static void setupSparkConf() { + spark.conf().set("spark.sql.shuffle.partitions", "4"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS updated_id"); + sql("DROP TABLE IF EXISTS updated_dep"); + sql("DROP TABLE IF EXISTS deleted_employee"); + } + + @TestTemplate + public void testUpdateWithVectorizedReads() { + assumeThat(supportsVectorization()).isTrue(); + + createAndInitTable( + "id INT, value INT, dep STRING", + "PARTITIONED BY (dep)", + "{ \"id\": 1, \"value\": 100, \"dep\": \"hr\" }"); + + SparkPlan plan = executeAndKeepPlan("UPDATE %s SET value = -1 WHERE id = 1", commitTarget()); + + assertAllBatchScansVectorized(plan); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, -1, "hr")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testCoalesceUpdate() { + createAndInitTable("id INT, dep STRING"); + + String[] records = new String[100]; + for (int index = 0; index < 100; index++) { + records[index] = String.format("{ \"id\": %d, \"dep\": \"hr\" }", index); + } + append(tableName, records); + append(tableName, records); + append(tableName, records); + append(tableName, records); + + // set the open file cost large enough to produce a separate scan task per file + // use range distribution to trigger a shuffle + // set partitioned scoped deletes so that 1 delete file is written as part of the output task + Map tableProps = + ImmutableMap.of( + SPLIT_OPEN_FILE_COST, + String.valueOf(Integer.MAX_VALUE), + UPDATE_DISTRIBUTION_MODE, + DistributionMode.RANGE.modeName(), + TableProperties.DELETE_GRANULARITY, + DeleteGranularity.PARTITION.toString()); + sql("ALTER TABLE %s SET TBLPROPERTIES (%s)", tableName, tablePropsAsString(tableProps)); + + createBranchIfNeeded(); + + // enable AQE and set the advisory partition size big enough to trigger combining + // set the number of shuffle partitions to 200 to distribute the work across reducers + // set the advisory partition size for shuffles small enough to ensure writes override it + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "200", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "true", + SQLConf.COALESCE_PARTITIONS_ENABLED().key(), + "true", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), + "100", + SparkSQLProperties.ADVISORY_PARTITION_SIZE, + String.valueOf(256 * 1024 * 1024)), + () -> { + SparkPlan plan = + executeAndKeepPlan("UPDATE %s SET id = -1 WHERE mod(id, 2) = 0", commitTarget()); + assertThat(plan.toString()).contains("REBALANCE_PARTITIONS_BY_COL"); + }); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + + if (mode(table) == COPY_ON_WRITE) { + // CoW UPDATE requests the updated records to be range distributed by `_file`, `_pos` + // every task has data for each of 200 reducers + // AQE detects that all shuffle blocks are small and processes them in 1 task + // otherwise, there would be 200 tasks writing to the table + validateProperty(snapshot, SnapshotSummary.ADDED_FILES_PROP, "1"); + } else if (mode(table) == MERGE_ON_READ && formatVersion >= 3) { + validateProperty(snapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "4"); + validateProperty(snapshot, SnapshotSummary.ADDED_DVS_PROP, "4"); + } else { + // MoR UPDATE requests the deleted records to be range distributed by partition and `_file` + // each task contains only 1 file and therefore writes only 1 shuffle block + // that means 4 shuffle blocks are distributed among 200 reducers + // AQE detects that all 4 shuffle blocks are small and processes them in 1 task + // otherwise, there would be 4 tasks processing 1 shuffle block each + validateProperty(snapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "1"); + } + + assertThat(scalarSql("SELECT COUNT(*) FROM %s WHERE id = -1", commitTarget())) + .as("Row count must match") + .isEqualTo(200L); + } + + @TestTemplate + public void testSkewUpdate() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + String[] records = new String[100]; + for (int index = 0; index < 100; index++) { + records[index] = String.format("{ \"id\": %d, \"dep\": \"hr\" }", index); + } + append(tableName, records); + append(tableName, records); + append(tableName, records); + append(tableName, records); + + // set the open file cost large enough to produce a separate scan task per file + // use hash distribution to trigger a shuffle + Map tableProps = + ImmutableMap.of( + SPLIT_OPEN_FILE_COST, + String.valueOf(Integer.MAX_VALUE), + UPDATE_DISTRIBUTION_MODE, + DistributionMode.HASH.modeName()); + sql("ALTER TABLE %s SET TBLPROPERTIES (%s)", tableName, tablePropsAsString(tableProps)); + + createBranchIfNeeded(); + + // enable AQE and set the advisory partition size small enough to trigger a split + // set the number of shuffle partitions to 2 to only have 2 reducers + // set the advisory partition size for shuffles big enough to ensure writes override it + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "2", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "true", + SQLConf.ADAPTIVE_OPTIMIZE_SKEWS_IN_REBALANCE_PARTITIONS_ENABLED().key(), + "true", + SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), + "256MB", + SparkSQLProperties.ADVISORY_PARTITION_SIZE, + "100"), + () -> { + SparkPlan plan = + executeAndKeepPlan("UPDATE %s SET id = -1 WHERE mod(id, 2) = 0", commitTarget()); + assertThat(plan.toString()).contains("REBALANCE_PARTITIONS_BY_COL"); + }); + + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + + if (mode(table) == COPY_ON_WRITE) { + // CoW UPDATE requests the updated records to be clustered by `_file` + // each task contains only 1 file and therefore writes only 1 shuffle block + // that means 4 shuffle blocks are distributed among 2 reducers + // AQE detects that all shuffle blocks are big and processes them in 4 independent tasks + // otherwise, there would be 2 tasks processing 2 shuffle blocks each + validateProperty(snapshot, SnapshotSummary.ADDED_FILES_PROP, "4"); + } else { + // MoR UPDATE requests the deleted records to be clustered by `_spec_id` and `_partition` + // all tasks belong to the same partition and therefore write only 1 shuffle block per task + // that means there are 4 shuffle blocks, all assigned to the same reducer + // AQE detects that all 4 shuffle blocks are big and processes them in 4 separate tasks + // otherwise, there would be 1 task processing 4 shuffle blocks + validateProperty(snapshot, SnapshotSummary.ADDED_DELETE_FILES_PROP, "4"); + } + + assertThat(scalarSql("SELECT COUNT(*) FROM %s WHERE id = -1", commitTarget())) + .as("Row count must match") + .isEqualTo(200L); + } + + @TestTemplate + public void testExplain() { + createAndInitTable("id INT, dep STRING"); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", tableName); + createBranchIfNeeded(); + + sql("EXPLAIN UPDATE %s SET dep = 'invalid' WHERE id <=> 1", commitTarget()); + + sql("EXPLAIN UPDATE %s SET dep = 'invalid' WHERE true", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 1 snapshot").hasSize(1); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testUpdateEmptyTable() { + assumeThat(branch).as("Custom branch does not exist for empty table").isNotEqualTo("test"); + createAndInitTable("id INT, dep STRING"); + + sql("UPDATE %s SET dep = 'invalid' WHERE id IN (1)", commitTarget()); + sql("UPDATE %s SET id = -1 WHERE dep = 'hr'", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 2 snapshots").hasSize(2); + + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testUpdateNonExistingCustomBranch() { + assumeThat(branch).as("Test only applicable to custom branch").isEqualTo("test"); + createAndInitTable("id INT, dep STRING"); + + assertThatThrownBy(() -> sql("UPDATE %s SET dep = 'invalid' WHERE id IN (1)", commitTarget())) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot use branch (does not exist): test"); + } + + @TestTemplate + public void testUpdateWithAlias() { + createAndInitTable("id INT, dep STRING", "{ \"id\": 1, \"dep\": \"a\" }"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + sql("UPDATE %s AS t SET t.dep = 'invalid'", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 2 snapshots").hasSize(2); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "invalid")), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testUpdateAlignsAssignments() { + createAndInitTable("id INT, c1 INT, c2 INT"); + + sql("INSERT INTO TABLE %s VALUES (1, 11, 111), (2, 22, 222)", tableName); + createBranchIfNeeded(); + + sql("UPDATE %s SET `c2` = c2 - 2, c1 = `c1` - 1 WHERE id <=> 1", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, 10, 109), row(2, 22, 222)), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testUpdateWithUnsupportedPartitionPredicate() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'software'), (2, 'hr')", tableName); + createBranchIfNeeded(); + + sql("UPDATE %s t SET `t`.`id` = -1 WHERE t.dep LIKE '%%r' ", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(1, "software")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testUpdateWithDynamicFileFiltering() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 3, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hardware\" }\n" + "{ \"id\": 2, \"dep\": \"hardware\" }"); + + sql("UPDATE %s SET id = cast('-1' AS INT) WHERE id = 2", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + } else { + validateMergeOnRead(currentSnapshot, "1", "1", "1"); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(1, "hardware"), row(1, "hr"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", commitTarget())); + } + + @TestTemplate + public void testUpdateNonExistingRecords() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr'), (2, 'hardware'), (null, 'hr')", tableName); + createBranchIfNeeded(); + + sql("UPDATE %s SET id = -1 WHERE id > 10", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 2 snapshots").hasSize(2); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "0", null, null); + } else { + validateMergeOnRead(currentSnapshot, "0", null, null); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testUpdateWithoutCondition() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + sql("INSERT INTO TABLE %s VALUES (2, 'hardware')", commitTarget()); + sql("INSERT INTO TABLE %s VALUES (null, 'hr')", commitTarget()); + + // set the num of shuffle partitions to 200 instead of default 4 to reduce the chance of hashing + // records for multiple source files to one writing task (needed for a predictable num of output + // files) + withSQLConf( + ImmutableMap.of(SQLConf.SHUFFLE_PARTITIONS().key(), "200"), + () -> { + sql("UPDATE %s SET id = -1", commitTarget()); + }); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 4 snapshots").hasSize(4); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + + assertThat(currentSnapshot.operation()).as("Operation must match").isEqualTo(OVERWRITE); + if (mode(table) == COPY_ON_WRITE) { + assertThat(currentSnapshot.operation()).as("Operation must match").isEqualTo(OVERWRITE); + validateProperty(currentSnapshot, CHANGED_PARTITION_COUNT_PROP, "2"); + validateProperty(currentSnapshot, DELETED_FILES_PROP, "3"); + validateProperty(currentSnapshot, ADDED_FILES_PROP, ImmutableSet.of("2", "3")); + } else { + validateMergeOnRead(currentSnapshot, "2", "3", "2"); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(-1, "hr"), row(-1, "hr")), + sql("SELECT * FROM %s ORDER BY dep ASC", selectTarget())); + } + + @TestTemplate + public void testUpdateWithNullConditions() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 0, \"dep\": null }\n" + + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }"); + createBranchIfNeeded(); + + // should not update any rows as null is never equal to null + sql("UPDATE %s SET id = -1 WHERE dep = NULL", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(0, null), row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // should not update any rows the condition does not match any records + sql("UPDATE %s SET id = -1 WHERE dep = 'software'", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(0, null), row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // should update one matching row with a null-safe condition + sql("UPDATE %s SET dep = 'invalid', id = -1 WHERE dep <=> NULL", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "invalid"), row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testUpdateWithInAndNotInConditions() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }\n" + + "{ \"id\": null, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + sql("UPDATE %s SET id = -1 WHERE id IN (1, null)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql("UPDATE %s SET id = 100 WHERE id NOT IN (null, 1)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql("UPDATE %s SET id = 100 WHERE id NOT IN (1, 10)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(100, "hardware"), row(100, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST, dep", selectTarget())); + } + + @TestTemplate + public void testUpdateWithMultipleRowGroupsParquet() throws NoSuchTableException { + assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET); + + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", + tableName, PARQUET_ROW_GROUP_SIZE_BYTES, 100); + sql("ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", tableName, SPLIT_SIZE, 100); + + List ids = Lists.newArrayListWithCapacity(200); + for (int id = 1; id <= 200; id++) { + ids.add(id); + } + Dataset df = + spark + .createDataset(ids, Encoders.INT()) + .withColumnRenamed("value", "id") + .withColumn("dep", lit("hr")); + df.coalesce(1).writeTo(tableName).append(); + createBranchIfNeeded(); + + assertThat(spark.table(commitTarget()).count()).isEqualTo(200); + + // update a record from one of two row groups and copy over the second one + sql("UPDATE %s SET id = -1 WHERE id IN (200, 201)", commitTarget()); + + assertThat(spark.table(commitTarget()).count()).isEqualTo(200); + } + + @TestTemplate + public void testUpdateNestedStructFields() { + createAndInitTable( + "id INT, s STRUCT,m:MAP>>", + "{ \"id\": 1, \"s\": { \"c1\": 2, \"c2\": { \"a\": [1,2], \"m\": { \"a\": \"b\"} } } } }"); + + // update primitive, array, map columns inside a struct + sql("UPDATE %s SET s.c1 = -1, s.c2.m = map('k', 'v'), s.c2.a = array(-1)", commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, row(-1, row(ImmutableList.of(-1), ImmutableMap.of("k", "v"))))), + sql("SELECT * FROM %s", selectTarget())); + + // set primitive, array, map columns to NULL (proper casts should be in place) + sql("UPDATE %s SET s.c1 = NULL, s.c2 = NULL WHERE id IN (1)", commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, row(null, null))), + sql("SELECT * FROM %s", selectTarget())); + + // update all fields in a struct + sql( + "UPDATE %s SET s = named_struct('c1', 1, 'c2', named_struct('a', array(1), 'm', null))", + commitTarget()); + + assertEquals( + "Output should match", + ImmutableList.of(row(1, row(1, row(ImmutableList.of(1), null)))), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testUpdateWithUserDefinedDistribution() { + createAndInitTable("id INT, c2 INT, c3 INT"); + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(8, c3)", tableName); + + append( + tableName, + "{ \"id\": 1, \"c2\": 11, \"c3\": 1 }\n" + + "{ \"id\": 2, \"c2\": 22, \"c3\": 1 }\n" + + "{ \"id\": 3, \"c2\": 33, \"c3\": 1 }"); + createBranchIfNeeded(); + + // request a global sort + sql("ALTER TABLE %s WRITE ORDERED BY c2", tableName); + sql("UPDATE %s SET c2 = -22 WHERE id NOT IN (1, 3)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, 11, 1), row(2, -22, 1), row(3, 33, 1)), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + // request a local sort + sql("ALTER TABLE %s WRITE LOCALLY ORDERED BY id", tableName); + sql("UPDATE %s SET c2 = -33 WHERE id = 3", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, 11, 1), row(2, -22, 1), row(3, -33, 1)), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + // request a hash distribution + local sort + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION ORDERED BY id", tableName); + sql("UPDATE %s SET c2 = -11 WHERE id = 1", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, -11, 1), row(2, -22, 1), row(3, -33, 1)), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public synchronized void testUpdateWithSerializableIsolation() throws InterruptedException { + // cannot run tests with concurrency for Hadoop tables without atomic renames + assumeThat(catalogName).isNotEqualToIgnoringCase("testhadoop"); + // if caching is off, the table is eagerly refreshed during runtime filtering + // this can cause a validation exception as concurrent changes would be visible + assumeThat(cachingCatalogEnabled()).isTrue(); + + createAndInitTable("id INT, dep STRING"); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, UPDATE_ISOLATION_LEVEL, "serializable"); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + + ExecutorService executorService = + MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) Executors.newFixedThreadPool(2)); + + AtomicInteger barrier = new AtomicInteger(0); + AtomicBoolean shouldAppend = new AtomicBoolean(true); + + // update thread + Future updateFuture = + executorService.submit( + () -> { + for (int numOperations = 0; numOperations < Integer.MAX_VALUE; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> barrier.get() >= currentNumOperations * 2); + + sql("UPDATE %s SET id = -1 WHERE id = 1", commitTarget()); + + barrier.incrementAndGet(); + } + }); + + // append thread + Future appendFuture = + executorService.submit( + () -> { + // load the table via the validation catalog to use another table instance + Table table = validationCatalog.loadTable(tableIdent); + + GenericRecord record = GenericRecord.create(SnapshotUtil.schemaFor(table, branch)); + record.set(0, 1); // id + record.set(1, "hr"); // dep + + for (int numOperations = 0; numOperations < Integer.MAX_VALUE; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> !shouldAppend.get() || barrier.get() >= currentNumOperations * 2); + + if (!shouldAppend.get()) { + return; + } + + for (int numAppends = 0; numAppends < 5; numAppends++) { + DataFile dataFile = writeDataFile(table, ImmutableList.of(record)); + AppendFiles appendFiles = table.newFastAppend().appendFile(dataFile); + if (branch != null) { + appendFiles.toBranch(branch); + } + + appendFiles.commit(); + } + + barrier.incrementAndGet(); + } + }); + + try { + assertThatThrownBy(updateFuture::get) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Found conflicting files that can contain"); + } finally { + shouldAppend.set(false); + appendFuture.cancel(true); + } + + executorService.shutdown(); + assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); + } + + @TestTemplate + public synchronized void testUpdateWithSnapshotIsolation() + throws InterruptedException, ExecutionException { + // cannot run tests with concurrency for Hadoop tables without atomic renames + assumeThat(catalogName).isNotEqualToIgnoringCase("testhadoop"); + // if caching is off, the table is eagerly refreshed during runtime filtering + // this can cause a validation exception as concurrent changes would be visible + assumeThat(cachingCatalogEnabled()).isTrue(); + + createAndInitTable("id INT, dep STRING"); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, UPDATE_ISOLATION_LEVEL, "snapshot"); + + sql("INSERT INTO TABLE %s VALUES (1, 'hr')", tableName); + createBranchIfNeeded(); + + ExecutorService executorService = + MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) Executors.newFixedThreadPool(2)); + + AtomicInteger barrier = new AtomicInteger(0); + AtomicBoolean shouldAppend = new AtomicBoolean(true); + + // update thread + Future updateFuture = + executorService.submit( + () -> { + for (int numOperations = 0; numOperations < 20; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> barrier.get() >= currentNumOperations * 2); + + sql("UPDATE %s SET id = -1 WHERE id = 1", tableName); + + barrier.incrementAndGet(); + } + }); + + // append thread + Future appendFuture = + executorService.submit( + () -> { + // load the table via the validation catalog to use another table instance for inserts + Table table = validationCatalog.loadTable(tableIdent); + + GenericRecord record = GenericRecord.create(SnapshotUtil.schemaFor(table, branch)); + record.set(0, 1); // id + record.set(1, "hr"); // dep + + for (int numOperations = 0; numOperations < 20; numOperations++) { + int currentNumOperations = numOperations; + Awaitility.await() + .pollInterval(10, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .until(() -> !shouldAppend.get() || barrier.get() >= currentNumOperations * 2); + + if (!shouldAppend.get()) { + return; + } + + for (int numAppends = 0; numAppends < 5; numAppends++) { + DataFile dataFile = writeDataFile(table, ImmutableList.of(record)); + AppendFiles appendFiles = table.newFastAppend().appendFile(dataFile); + if (branch != null) { + appendFiles.toBranch(branch); + } + + appendFiles.commit(); + } + + barrier.incrementAndGet(); + } + }); + + try { + updateFuture.get(); + } finally { + shouldAppend.set(false); + appendFuture.cancel(true); + } + + executorService.shutdown(); + assertThat(executorService.awaitTermination(2, TimeUnit.MINUTES)).as("Timeout").isTrue(); + } + + @TestTemplate + public void testUpdateWithInferredCasts() { + createAndInitTable("id INT, s STRING", "{ \"id\": 1, \"s\": \"value\" }"); + + sql("UPDATE %s SET s = -1 WHERE id = 1", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "-1")), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testUpdateModifiesNullStruct() { + createAndInitTable("id INT, s STRUCT", "{ \"id\": 1, \"s\": null }"); + + sql("UPDATE %s SET s.n1 = -1 WHERE id = 1", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, row(-1, null))), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testUpdateRefreshesRelationCache() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 3, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hardware\" }\n" + "{ \"id\": 2, \"dep\": \"hardware\" }"); + + Dataset query = spark.sql("SELECT * FROM " + commitTarget() + " WHERE id = 1"); + query.createOrReplaceTempView("tmp"); + + spark.sql("CACHE TABLE tmp"); + + assertEquals( + "View should have correct data", + ImmutableList.of(row(1, "hardware"), row(1, "hr")), + sql("SELECT * FROM tmp ORDER BY id, dep")); + + sql("UPDATE %s SET id = -1 WHERE id = 1", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "2", "2", "2"); + } else { + validateMergeOnRead(currentSnapshot, "2", "2", "2"); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(-1, "hr"), row(2, "hardware"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", commitTarget())); + + assertEquals( + "Should refresh the relation cache", + ImmutableList.of(), + sql("SELECT * FROM tmp ORDER BY id, dep")); + + spark.sql("UNCACHE TABLE tmp"); + } + + @TestTemplate + public void testUpdateWithInSubquery() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }\n" + + "{ \"id\": null, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("updated_id", Arrays.asList(0, 1, null), Encoders.INT()); + createOrReplaceView("updated_dep", Arrays.asList("software", "hr"), Encoders.STRING()); + + sql( + "UPDATE %s SET id = -1 WHERE " + + "id IN (SELECT * FROM updated_id) AND " + + "dep IN (SELECT * from updated_dep)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "UPDATE %s SET id = 5 WHERE id IS NULL OR id IN (SELECT value + 1 FROM updated_id)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(5, "hardware"), row(5, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + + append( + commitTarget(), "{ \"id\": null, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(-1, "hr"), row(2, "hr"), row(5, "hardware"), row(5, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST, dep", selectTarget())); + + sql( + "UPDATE %s SET id = 10 WHERE id IN (SELECT value + 2 FROM updated_id) AND dep = 'hr'", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of( + row(-1, "hr"), row(5, "hardware"), row(5, "hr"), row(10, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST, dep", selectTarget())); + } + + @TestTemplate + public void testUpdateWithInSubqueryAndDynamicFileFiltering() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + sql("ALTER TABLE %s WRITE DISTRIBUTED BY PARTITION", tableName); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 3, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + append( + commitTarget(), + "{ \"id\": 1, \"dep\": \"hardware\" }\n" + "{ \"id\": 2, \"dep\": \"hardware\" }"); + + createOrReplaceView("updated_id", Arrays.asList(-1, 2), Encoders.INT()); + + sql("UPDATE %s SET id = -1 WHERE id IN (SELECT * FROM updated_id)", commitTarget()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 3 snapshots").hasSize(3); + + Snapshot currentSnapshot = SnapshotUtil.latestSnapshot(table, branch); + if (mode(table) == COPY_ON_WRITE) { + validateCopyOnWrite(currentSnapshot, "1", "1", "1"); + } else { + validateMergeOnRead(currentSnapshot, "1", "1", "1"); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(1, "hardware"), row(1, "hr"), row(3, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", commitTarget())); + } + + @TestTemplate + public void testUpdateWithSelfSubquery() { + createAndInitTable("id INT, dep STRING"); + + append(tableName, "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + sql( + "UPDATE %s SET dep = 'x' WHERE id IN (SELECT id + 1 FROM %s)", + commitTarget(), commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "x")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + + // TODO: Spark does not support AQE and DPP with aggregates at the moment + withSQLConf( + ImmutableMap.of(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false"), + () -> { + sql( + "UPDATE %s SET dep = 'y' WHERE " + + "id = (SELECT count(*) FROM (SELECT DISTINCT id FROM %s) AS t)", + commitTarget(), commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "y")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + }); + + sql("UPDATE %s SET id = (SELECT id - 2 FROM %s WHERE id = 1)", commitTarget(), commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(-1, "y")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } + + @TestTemplate + public void testUpdateWithMultiColumnInSubquery() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }\n" + + "{ \"id\": null, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + List deletedEmployees = + Arrays.asList(new Employee(null, "hr"), new Employee(1, "hr")); + createOrReplaceView("deleted_employee", deletedEmployees, Encoders.bean(Employee.class)); + + sql( + "UPDATE %s SET dep = 'x', id = -1 WHERE (id, dep) IN (SELECT id, dep FROM deleted_employee)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "x"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + } + + @TestTemplate + public void testUpdateWithNotInSubquery() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }\n" + + "{ \"id\": null, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("updated_id", Arrays.asList(-1, -2, null), Encoders.INT()); + createOrReplaceView("updated_dep", Arrays.asList("software", "hr"), Encoders.STRING()); + + // the file filter subquery (nested loop lef-anti join) returns 0 records + sql("UPDATE %s SET id = -1 WHERE id NOT IN (SELECT * FROM updated_id)", commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "UPDATE %s SET id = -1 WHERE id NOT IN (SELECT * FROM updated_id WHERE value IS NOT NULL)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(-1, "hr"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST, dep", selectTarget())); + + sql( + "UPDATE %s SET id = 5 WHERE id NOT IN (SELECT * FROM updated_id) OR dep IN ('software', 'hr')", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(5, "hr"), row(5, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST, dep", selectTarget())); + } + + @TestTemplate + public void testUpdateWithExistSubquery() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }\n" + + "{ \"id\": null, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("updated_id", Arrays.asList(-1, -2, null), Encoders.INT()); + createOrReplaceView("updated_dep", Arrays.asList("hr", null), Encoders.STRING()); + + sql( + "UPDATE %s t SET id = -1 WHERE EXISTS (SELECT 1 FROM updated_id u WHERE t.id = u.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "UPDATE %s t SET dep = 'x', id = -1 WHERE " + + "EXISTS (SELECT 1 FROM updated_id u WHERE t.id = u.value + 2)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "x"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + + sql( + "UPDATE %s t SET id = -2 WHERE " + + "EXISTS (SELECT 1 FROM updated_id u WHERE t.id = u.value) OR " + + "t.id IS NULL", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-2, "hr"), row(-2, "x"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + + sql( + "UPDATE %s t SET id = 1 WHERE " + + "EXISTS (SELECT 1 FROM updated_id ui WHERE t.id = ui.value) AND " + + "EXISTS (SELECT 1 FROM updated_dep ud WHERE t.dep = ud.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-2, "x"), row(1, "hr"), row(2, "hardware")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } + + @TestTemplate + public void testUpdateWithNotExistsSubquery() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }\n" + + "{ \"id\": null, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("updated_id", Arrays.asList(-1, -2, null), Encoders.INT()); + createOrReplaceView("updated_dep", Arrays.asList("hr", "software"), Encoders.STRING()); + + sql( + "UPDATE %s t SET id = -1 WHERE NOT EXISTS (SELECT 1 FROM updated_id u WHERE t.id = u.value + 2)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(-1, "hr"), row(1, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + + sql( + "UPDATE %s t SET id = 5 WHERE " + + "NOT EXISTS (SELECT 1 FROM updated_id u WHERE t.id = u.value) OR " + + "t.id = 1", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(-1, "hr"), row(5, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + + sql( + "UPDATE %s t SET id = 10 WHERE " + + "NOT EXISTS (SELECT 1 FROM updated_id ui WHERE t.id = ui.value) AND " + + "EXISTS (SELECT 1 FROM updated_dep ud WHERE t.dep = ud.value)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hardware"), row(-1, "hr"), row(10, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + } + + @TestTemplate + public void testUpdateWithScalarSubquery() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hardware\" }\n" + + "{ \"id\": null, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + createOrReplaceView("updated_id", Arrays.asList(1, 100, null), Encoders.INT()); + + // TODO: Spark does not support AQE and DPP with aggregates at the moment + withSQLConf( + ImmutableMap.of(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false"), + () -> { + sql( + "UPDATE %s SET id = -1 WHERE id <= (SELECT min(value) FROM updated_id)", + commitTarget()); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(2, "hardware"), row(null, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC NULLS LAST", selectTarget())); + }); + } + + @TestTemplate + public void testUpdateThatRequiresGroupingBeforeWrite() { + createAndInitTable("id INT, dep STRING"); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + append( + tableName, + "{ \"id\": 0, \"dep\": \"hr\" }\n" + + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + append( + commitTarget(), + "{ \"id\": 0, \"dep\": \"ops\" }\n" + + "{ \"id\": 1, \"dep\": \"ops\" }\n" + + "{ \"id\": 2, \"dep\": \"ops\" }"); + + append( + commitTarget(), + "{ \"id\": 0, \"dep\": \"hr\" }\n" + + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hr\" }"); + + append( + commitTarget(), + "{ \"id\": 0, \"dep\": \"ops\" }\n" + + "{ \"id\": 1, \"dep\": \"ops\" }\n" + + "{ \"id\": 2, \"dep\": \"ops\" }"); + + createOrReplaceView("updated_id", Arrays.asList(1, 100), Encoders.INT()); + + String originalNumOfShufflePartitions = spark.conf().get("spark.sql.shuffle.partitions"); + try { + // set the num of shuffle partitions to 1 to ensure we have only 1 writing task + spark.conf().set("spark.sql.shuffle.partitions", "1"); + + sql("UPDATE %s t SET id = -1 WHERE id IN (SELECT * FROM updated_id)", commitTarget()); + assertThat(spark.table(commitTarget()).count()) + .as("Should have expected num of rows") + .isEqualTo(12L); + } finally { + spark.conf().set("spark.sql.shuffle.partitions", originalNumOfShufflePartitions); + } + } + + @TestTemplate + public void testUpdateWithVectorization() { + createAndInitTable("id INT, dep STRING"); + + append( + tableName, + "{ \"id\": 0, \"dep\": \"hr\" }\n" + + "{ \"id\": 1, \"dep\": \"hr\" }\n" + + "{ \"id\": 2, \"dep\": \"hr\" }"); + createBranchIfNeeded(); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.VECTORIZATION_ENABLED, "true"), + () -> { + sql("UPDATE %s t SET id = -1", commitTarget()); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(-1, "hr"), row(-1, "hr")), + sql("SELECT * FROM %s ORDER BY id, dep", selectTarget())); + }); + } + + @TestTemplate + public void testUpdateModifyPartitionSourceField() throws NoSuchTableException { + createAndInitTable("id INT, dep STRING, country STRING"); + + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(4, id)", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + List ids = Lists.newArrayListWithCapacity(100); + for (int id = 1; id <= 100; id++) { + ids.add(id); + } + + Dataset df1 = + spark + .createDataset(ids, Encoders.INT()) + .withColumnRenamed("value", "id") + .withColumn("dep", lit("hr")) + .withColumn("country", lit("usa")); + df1.coalesce(1).writeTo(tableName).append(); + createBranchIfNeeded(); + + Dataset df2 = + spark + .createDataset(ids, Encoders.INT()) + .withColumnRenamed("value", "id") + .withColumn("dep", lit("software")) + .withColumn("country", lit("usa")); + df2.coalesce(1).writeTo(commitTarget()).append(); + + Dataset df3 = + spark + .createDataset(ids, Encoders.INT()) + .withColumnRenamed("value", "id") + .withColumn("dep", lit("hardware")) + .withColumn("country", lit("usa")); + df3.coalesce(1).writeTo(commitTarget()).append(); + + sql( + "UPDATE %s SET id = -1 WHERE id IN (10, 11, 12, 13, 14, 15, 16, 17, 18, 19)", + commitTarget()); + assertThat(scalarSql("SELECT count(*) FROM %s WHERE id = -1", selectTarget())).isEqualTo(30L); + } + + @TestTemplate + public void testUpdateWithStaticPredicatePushdown() { + createAndInitTable("id INT, dep STRING"); + + sql("ALTER TABLE %s ADD PARTITION FIELD dep", tableName); + + // add a data file to the 'software' partition + append(tableName, "{ \"id\": 1, \"dep\": \"software\" }"); + createBranchIfNeeded(); + + // add a data file to the 'hr' partition + append(commitTarget(), "{ \"id\": 1, \"dep\": \"hr\" }"); + + Table table = validationCatalog.loadTable(tableIdent); + + Snapshot snapshot = SnapshotUtil.latestSnapshot(table, branch); + String dataFilesCount = snapshot.summary().get(SnapshotSummary.TOTAL_DATA_FILES_PROP); + assertThat(dataFilesCount).as("Must have 2 files before UPDATE").isEqualTo("2"); + + // remove the data file from the 'hr' partition to ensure it is not scanned + DataFile dataFile = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()); + table.io().deleteFile(dataFile.location()); + + // disable dynamic pruning and rely only on static predicate pushdown + withSQLConf( + ImmutableMap.of( + SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED().key(), "false", + SQLConf.RUNTIME_ROW_LEVEL_OPERATION_GROUP_FILTER_ENABLED().key(), "false"), + () -> { + sql("UPDATE %s SET id = -1 WHERE dep IN ('software') AND id == 1", commitTarget()); + }); + } + + @TestTemplate + public void testUpdateWithInvalidUpdates() { + createAndInitTable( + "id INT, a ARRAY>, m MAP", + "{ \"id\": 0, \"a\": null, \"m\": null }"); + + assertThatThrownBy(() -> sql("UPDATE %s SET a.c1 = 1", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Updating nested fields is only supported for StructType"); + + assertThatThrownBy(() -> sql("UPDATE %s SET m.key = 'new_key'", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Updating nested fields is only supported for StructType"); + } + + @TestTemplate + public void testUpdateWithConflictingAssignments() { + createAndInitTable( + "id INT, c STRUCT>", "{ \"id\": 0, \"s\": null }"); + + assertThatThrownBy(() -> sql("UPDATE %s t SET t.id = 1, t.c.n1 = 2, t.id = 2", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Multiple assignments for 'id'"); + + assertThatThrownBy( + () -> sql("UPDATE %s t SET t.c.n1 = 1, t.id = 2, t.c.n1 = 2", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Multiple assignments for 'c.n1"); + + assertThatThrownBy( + () -> + sql( + "UPDATE %s SET c.n1 = 1, c = named_struct('n1', 1, 'n2', named_struct('dn1', 1, 'dn2', 2))", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Conflicting assignments for 'c'"); + } + + @TestTemplate + public void testUpdateWithInvalidAssignmentsAnsi() { + createAndInitTable( + "id INT NOT NULL, s STRUCT> NOT NULL", + "{ \"id\": 0, \"s\": { \"n1\": 1, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + + withSQLConf( + ImmutableMap.of("spark.sql.storeAssignmentPolicy", "ansi"), + () -> { + assertThatThrownBy(() -> sql("UPDATE %s t SET t.id = NULL", commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining( + "[NOT_NULL_ASSERT_VIOLATION] NULL value appeared in non-nullable field"); + + assertThatThrownBy(() -> sql("UPDATE %s t SET t.s.n1 = NULL", commitTarget())) + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining( + "[NOT_NULL_ASSERT_VIOLATION] NULL value appeared in non-nullable field"); + + assertThatThrownBy( + () -> sql("UPDATE %s t SET t.s = named_struct('n1', 1)", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot find data for the output column `s`.`n2`"); + + assertThatThrownBy(() -> sql("UPDATE %s t SET t.s.n1 = 'str'", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast"); + + assertThatThrownBy( + () -> + sql( + "UPDATE %s t SET t.s.n2 = named_struct('dn3', 1, 'dn1', 2)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot find data for the output column `s`.`n2`.`dn2`"); + }); + } + + @TestTemplate + public void testUpdateWithInvalidAssignmentsStrict() { + createAndInitTable( + "id INT NOT NULL, s STRUCT> NOT NULL", + "{ \"id\": 0, \"s\": { \"n1\": 1, \"n2\": { \"dn1\": 3, \"dn2\": 4 } } }"); + + withSQLConf( + ImmutableMap.of("spark.sql.storeAssignmentPolicy", "strict"), + () -> { + assertThatThrownBy(() -> sql("UPDATE %s t SET t.id = NULL", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast `id` \"VOID\" to \"INT\""); + + assertThatThrownBy(() -> sql("UPDATE %s t SET t.s.n1 = NULL", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast `s`.`n1` \"VOID\" to \"INT\""); + + assertThatThrownBy( + () -> sql("UPDATE %s t SET t.s = named_struct('n1', 1)", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot find data for the output column"); + + assertThatThrownBy(() -> sql("UPDATE %s t SET t.s.n1 = 'str'", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot safely cast"); + + assertThatThrownBy( + () -> + sql( + "UPDATE %s t SET t.s.n2 = named_struct('dn3', 1, 'dn1', 2)", + commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot find data for the output column"); + }); + } + + @TestTemplate + public void testUpdateWithNonDeterministicCondition() { + createAndInitTable("id INT, dep STRING", "{ \"id\": 1, \"dep\": \"hr\" }"); + + assertThatThrownBy( + () -> sql("UPDATE %s SET id = -1 WHERE id = 1 AND rand() > 0.5", commitTarget())) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The operator expects a deterministic expression"); + } + + @TestTemplate + public void testUpdateOnNonIcebergTableNotSupported() { + createOrReplaceView("testtable", "{ \"c1\": -100, \"c2\": -200 }"); + + assertThatThrownBy(() -> sql("UPDATE %s SET c1 = -1 WHERE c2 = 1", "testtable")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Table `unknown` does not support UPDATE TABLE."); + } + + @TestTemplate + public void testUpdateToWAPBranch() { + assumeThat(branch).as("WAP branch only works for table identifier without branch").isNull(); + + createAndInitTable( + "id INT, dep STRING", "{ \"id\": 1, \"dep\": \"hr\" }\n" + "{ \"id\": 2, \"dep\": \"a\" }"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql("UPDATE %s SET dep='hr' WHERE dep='a'", tableName); + assertThat(sql("SELECT * FROM %s WHERE dep='hr'", tableName)) + .as("Should have expected num of rows when reading table") + .hasSize(2); + assertThat(sql("SELECT * FROM %s.branch_wap WHERE dep='hr'", tableName)) + .as("Should have expected num of rows when reading WAP branch") + .hasSize(2); + assertThat(sql("SELECT * FROM %s.branch_main WHERE dep='hr'", tableName)) + .as("Should not modify main branch") + .hasSize(1); + }); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql("UPDATE %s SET dep='b' WHERE dep='hr'", tableName); + assertThat(sql("SELECT * FROM %s WHERE dep='b'", tableName)) + .as("Should have expected num of rows when reading table with multiple writes") + .hasSize(2); + assertThat(sql("SELECT * FROM %s.branch_wap WHERE dep='b'", tableName)) + .as("Should have expected num of rows when reading WAP branch with multiple writes") + .hasSize(2); + assertThat(sql("SELECT * FROM %s.branch_main WHERE dep='b'", tableName)) + .as("Should not modify main branch with multiple writes") + .hasSize(0); + }); + } + + @TestTemplate + public void testUpdateToWapBranchWithTableBranchIdentifier() { + assumeThat(branch).as("Test must have branch name part in table identifier").isNotNull(); + + createAndInitTable("id INT, dep STRING", "{ \"id\": 1, \"dep\": \"hr\" }"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.WRITE_AUDIT_PUBLISH_ENABLED); + + // writing to explicit branch should succeed even with WAP branch set + withSQLConf( + ImmutableMap.of(SparkSQLProperties.WAP_BRANCH, "wap"), + () -> { + sql("UPDATE %s SET dep='software' WHERE dep='hr'", commitTarget()); + + assertEquals( + "Should have updated row in explicit branch", + ImmutableList.of(row(1, "software")), + sql("SELECT * FROM %s ORDER BY id", commitTarget())); + }); + } + + private RowLevelOperationMode mode(Table table) { + String modeName = table.properties().getOrDefault(UPDATE_MODE, UPDATE_MODE_DEFAULT); + return RowLevelOperationMode.fromName(modeName); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java new file mode 100644 index 000000000000..237563860366 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestViews.java @@ -0,0 +1,2132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.nio.file.Paths; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.IcebergBuild; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.catalog.ViewCatalog; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.view.ImmutableSQLViewRepresentation; +import org.apache.iceberg.view.SQLViewRepresentation; +import org.apache.iceberg.view.View; +import org.apache.iceberg.view.ViewHistoryEntry; +import org.apache.iceberg.view.ViewProperties; +import org.apache.iceberg.view.ViewUtil; +import org.apache.iceberg.view.ViewVersion; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.catalog.SessionCatalog; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestViews extends ExtensionsTestBase { + private static final Namespace NAMESPACE = Namespace.of("default"); + private static final String SPARK_CATALOG = "spark_catalog"; + private final String tableName = "table"; + + @BeforeEach + @Override + public void before() { + super.before(); + spark.conf().set("spark.sql.defaultCatalog", catalogName); + sql("USE %s", catalogName); + sql("CREATE NAMESPACE IF NOT EXISTS %s", NAMESPACE); + sql( + "CREATE TABLE IF NOT EXISTS %s.%s (id INT, data STRING)%s", + NAMESPACE, tableName, catalogName.equals(SPARK_CATALOG) ? " USING iceberg" : ""); + sql("USE %s.%s", catalogName, NAMESPACE); + } + + @AfterEach + public void removeTable() { + sql("USE %s", catalogName); + sql("DROP TABLE IF EXISTS %s.%s", NAMESPACE, tableName); + + // reset spark session catalog + spark.sessionState().catalogManager().reset(); + spark.conf().unset("spark.sql.catalog.spark_catalog"); + } + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.SPARK_WITH_VIEWS.catalogName(), + SparkCatalogConfig.SPARK_WITH_VIEWS.implementation(), + SparkCatalogConfig.SPARK_WITH_VIEWS.properties() + }, + { + SparkCatalogConfig.SPARK_SESSION_WITH_VIEWS.catalogName(), + SparkCatalogConfig.SPARK_SESSION_WITH_VIEWS.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.SPARK_SESSION_WITH_VIEWS.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build() + }, + { + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.catalogName(), + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.implementation(), + SparkCatalogConfig.SPARK_WITH_HIVE_VIEWS.properties() + }, + }; + } + + @TestTemplate + public void readFromView() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("simpleView"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + // use non-existing column name to make sure only the SQL definition for spark is loaded + .withQuery("trino", String.format("SELECT non_existing FROM %s", tableName)) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + List expected = + IntStream.rangeClosed(1, 10).mapToObj(this::row).collect(Collectors.toList()); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(10) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @TestTemplate + public void readFromTrinoView() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("trinoView"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("trino", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + List expected = + IntStream.rangeClosed(1, 10).mapToObj(this::row).collect(Collectors.toList()); + + // there's no explicit view defined for spark, so it will fall back to the defined trino view + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(10) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @TestTemplate + public void readFromMultipleViews() throws NoSuchTableException { + insertRows(6); + String viewName = viewName("firstView"); + String secondView = viewName("secondView"); + String viewSQL = String.format("SELECT id FROM %s WHERE id <= 3", tableName); + String secondViewSQL = String.format("SELECT id FROM %s WHERE id > 3", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", viewSQL) + .withDefaultNamespace(NAMESPACE) + .withSchema(schema(viewSQL)) + .create(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, secondView)) + .withQuery("spark", secondViewSQL) + .withDefaultNamespace(NAMESPACE) + .withSchema(schema(secondViewSQL)) + .create(); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1), row(2), row(3)); + + assertThat(sql("SELECT * FROM %s", secondView)) + .hasSize(3) + .containsExactlyInAnyOrder(row(4), row(5), row(6)); + } + + @TestTemplate + public void readFromViewUsingNonExistingTable() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewWithNonExistingTable"); + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.LongType.get())); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", "SELECT id FROM non_existing") + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "The table or view `%s`.`%s`.`non_existing` cannot be found", catalogName, NAMESPACE); + } + + @TestTemplate + public void readFromViewUsingNonExistingTableColumn() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewWithNonExistingColumn"); + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = new Schema(Types.NestedField.required(1, "non_existing", Types.LongType.get())); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", String.format("SELECT non_existing FROM %s", tableName)) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `non_existing` cannot be resolved"); + } + + @TestTemplate + public void readFromViewUsingInvalidSQL() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewWithInvalidSQL"); + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = tableCatalog().loadTable(TableIdentifier.of(NAMESPACE, tableName)).schema(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", "invalid SQL") + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Invalid view name: %s", viewName); + } + + @TestTemplate + public void readFromViewWithStaleSchema() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("staleView"); + String sql = String.format("SELECT id, data FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + // drop a column the view depends on + // note that this tests `data` because it has an invalid ordinal + sql("ALTER TABLE %s DROP COLUMN data", tableName); + + // reading from the view should now fail + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `data` cannot be resolved"); + } + + @TestTemplate + public void readFromViewHiddenByTempView() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewHiddenByTempView"); + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = tableCatalog().loadTable(TableIdentifier.of(NAMESPACE, tableName)).schema(); + + sql("CREATE TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", viewName, tableName); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", String.format("SELECT id FROM %s WHERE id > 5", tableName)) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + List expected = + IntStream.rangeClosed(1, 5).mapToObj(this::row).collect(Collectors.toList()); + + // returns the results from the TEMP VIEW + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @TestTemplate + public void readFromViewWithGlobalTempView() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewWithGlobalTempView"); + String sql = String.format("SELECT id FROM %s WHERE id > 5", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + sql("CREATE GLOBAL TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", viewName, tableName); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + // GLOBAL TEMP VIEWS are stored in a global_temp namespace + assertThat(sql("SELECT * FROM global_temp.%s", viewName)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf( + IntStream.rangeClosed(1, 5).mapToObj(this::row).collect(Collectors.toList())); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf( + IntStream.rangeClosed(6, 10).mapToObj(this::row).collect(Collectors.toList())); + } + + @TestTemplate + public void readFromViewReferencingAnotherView() throws NoSuchTableException { + insertRows(10); + String firstView = viewName("viewBeingReferencedInAnotherView"); + String viewReferencingOtherView = viewName("viewReferencingOtherView"); + String firstSQL = String.format("SELECT id FROM %s WHERE id <= 5", tableName); + String secondSQL = String.format("SELECT id FROM %s WHERE id > 4", firstView); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, firstView)) + .withQuery("spark", firstSQL) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(firstSQL)) + .create(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewReferencingOtherView)) + .withQuery("spark", secondSQL) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(secondSQL)) + .create(); + + assertThat(sql("SELECT * FROM %s", viewReferencingOtherView)) + .hasSize(1) + .containsExactly(row(5)); + } + + @TestTemplate + public void readFromViewReferencingTempView() throws NoSuchTableException { + insertRows(10); + String tempView = viewName("tempViewBeingReferencedInAnotherView"); + String viewReferencingTempView = viewName("viewReferencingTempView"); + String sql = String.format("SELECT id FROM %s", tempView); + + ViewCatalog viewCatalog = viewCatalog(); + + sql("CREATE TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", tempView, tableName); + + // it wouldn't be possible to reference a TEMP VIEW if the view had been created via SQL, + // but this can't be prevented when using the API directly + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewReferencingTempView)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + List expected = + IntStream.rangeClosed(1, 5).mapToObj(this::row).collect(Collectors.toList()); + + assertThat(sql("SELECT * FROM %s", tempView)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf(expected); + + // reading from a view that references a TEMP VIEW shouldn't be possible + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewReferencingTempView)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The table or view") + .hasMessageContaining(tempView) + .hasMessageContaining("cannot be found"); + } + + @TestTemplate + public void readFromViewReferencingAnotherViewHiddenByTempView() throws NoSuchTableException { + insertRows(10); + String innerViewName = viewName("inner_view"); + String outerViewName = viewName("outer_view"); + String innerViewSQL = String.format("SELECT * FROM %s WHERE id > 5", tableName); + String outerViewSQL = String.format("SELECT id FROM %s", innerViewName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, innerViewName)) + .withQuery("spark", innerViewSQL) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(innerViewSQL)) + .create(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, outerViewName)) + .withQuery("spark", outerViewSQL) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(outerViewSQL)) + .create(); + + // create a temporary view that conflicts with the inner view to verify the inner name is + // resolved using the catalog and namespace defaults from the outer view + sql("CREATE TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", innerViewName, tableName); + + // ensure that the inner view resolution uses the view namespace and catalog + sql("USE spark_catalog"); + + List tempViewRows = + IntStream.rangeClosed(1, 5).mapToObj(this::row).collect(Collectors.toList()); + + assertThat(sql("SELECT * FROM %s", innerViewName)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf(tempViewRows); + + List expectedViewRows = + IntStream.rangeClosed(6, 10).mapToObj(this::row).collect(Collectors.toList()); + + assertThat(sql("SELECT * FROM %s.%s.%s", catalogName, NAMESPACE, outerViewName)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf(expectedViewRows); + } + + @TestTemplate + public void readFromViewReferencingGlobalTempView() throws NoSuchTableException { + insertRows(10); + String globalTempView = viewName("globalTempViewBeingReferenced"); + String viewReferencingTempView = viewName("viewReferencingGlobalTempView"); + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = tableCatalog().loadTable(TableIdentifier.of(NAMESPACE, tableName)).schema(); + + sql( + "CREATE GLOBAL TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", + globalTempView, tableName); + + // it wouldn't be possible to reference a GLOBAL TEMP VIEW if the view had been created via SQL, + // but this can't be prevented when using the API directly + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewReferencingTempView)) + .withQuery("spark", String.format("SELECT id FROM global_temp.%s", globalTempView)) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + List expected = + IntStream.rangeClosed(1, 5).mapToObj(this::row).collect(Collectors.toList()); + + assertThat(sql("SELECT * FROM global_temp.%s", globalTempView)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf(expected); + + // reading from a view that references a GLOBAL TEMP VIEW shouldn't be possible + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewReferencingTempView)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The table or view") + .hasMessageContaining(globalTempView) + .hasMessageContaining("cannot be found"); + } + + @TestTemplate + public void readFromViewReferencingTempFunction() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewReferencingTempFunction"); + String functionName = viewName("test_avg"); + String sql = String.format("SELECT %s(id) FROM %s", functionName, tableName); + sql( + "CREATE TEMPORARY FUNCTION %s AS 'org.apache.hadoop.hive.ql.udf.generic.GenericUDAFAverage'", + functionName); + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = tableCatalog().loadTable(TableIdentifier.of(NAMESPACE, tableName)).schema(); + + // it wouldn't be possible to reference a TEMP FUNCTION if the view had been created via SQL, + // but this can't be prevented when using the API directly + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + assertThat(sql(sql)).hasSize(1).containsExactly(row(5.5)); + + String expectedErrorMsg = + String.format("The routine %s.%s cannot be found", NAMESPACE, functionName); + if (SPARK_CATALOG.equals(catalogName)) { + // spark session catalog tries to load a V1 function and has a different error msg + expectedErrorMsg = + String.format( + "[ROUTINE_NOT_FOUND] The routine `%s`.`%s` cannot be found", NAMESPACE, functionName); + } + + // reading from a view that references a TEMP FUNCTION shouldn't be possible + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining(expectedErrorMsg); + } + + @TestTemplate + public void readFromViewWithCTE() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewWithCTE"); + String sql = + String.format( + "WITH max_by_data AS (SELECT max(id) as max FROM %s) " + + "SELECT max, count(1) AS count FROM max_by_data GROUP BY max", + tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThat(sql("SELECT * FROM %s", viewName)).hasSize(1).containsExactly(row(10, 1L)); + } + + @TestTemplate + public void readFromViewWithGroupByOrdinal() throws NoSuchTableException { + insertRows(3); + insertRows(2); + String viewName = viewName("viewWithGroupByOrdinal"); + String sql = String.format("SELECT id, count(1) FROM %s GROUP BY 1", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1, 2L), row(2, 2L), row(3, 1L)); + } + + @TestTemplate + public void createViewWithGroupByOrdinal() throws NoSuchTableException { + insertRows(3); + insertRows(2); + String viewName = viewName("createViewWithGroupByOrdinal"); + sql("CREATE VIEW %s AS SELECT id, count(1) FROM %s GROUP BY 1", viewName, tableName); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1, 2L), row(2, 2L), row(3, 1L)); + } + + @TestTemplate + public void rewriteFunctionIdentifier() { + assumeThat(catalogName) + .as("system namespace doesn't exist in SparkSessionCatalog") + .isNotEqualTo(SPARK_CATALOG); + String viewName = viewName("rewriteFunctionIdentifier"); + String sql = "SELECT iceberg_version() AS version"; + + assertThatThrownBy(() -> sql(sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot resolve routine") + .hasMessageContaining("iceberg_version"); + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = new Schema(Types.NestedField.required(1, "version", Types.StringType.get())); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(Namespace.of("system")) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(1) + .containsExactly(row(IcebergBuild.version())); + } + + @TestTemplate + public void builtinFunctionIdentifierNotRewritten() { + String viewName = viewName("builtinFunctionIdentifierNotRewritten"); + String sql = "SELECT trim(' abc ') AS result"; + + ViewCatalog viewCatalog = viewCatalog(); + Schema schema = new Schema(Types.NestedField.required(1, "result", Types.StringType.get())); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(Namespace.of("system")) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + assertThat(sql("SELECT * FROM %s", viewName)).hasSize(1).containsExactly(row("abc")); + } + + @TestTemplate + public void rewriteFunctionIdentifierWithNamespace() { + assumeThat(catalogName) + .as("system namespace doesn't exist in SparkSessionCatalog") + .isNotEqualTo(SPARK_CATALOG); + String viewName = viewName("rewriteFunctionIdentifierWithNamespace"); + String sql = "SELECT system.bucket(100, 'a') AS bucket_result, 'a' AS value"; + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(Namespace.of("system")) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + sql("USE spark_catalog"); + + assertThatThrownBy(() -> sql(sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot resolve routine `system`.`bucket`"); + + assertThat(sql("SELECT * FROM %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasSize(1) + .containsExactly(row(50, "a")); + } + + @TestTemplate + public void fullFunctionIdentifier() { + assumeThat(catalogName) + .as("system namespace doesn't exist in SparkSessionCatalog") + .isNotEqualTo(SPARK_CATALOG); + String viewName = viewName("fullFunctionIdentifier"); + String sql = + String.format( + "SELECT %s.system.bucket(100, 'a') AS bucket_result, 'a' AS value", catalogName); + + sql("USE spark_catalog"); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(Namespace.of("system")) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThat(sql("SELECT * FROM %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasSize(1) + .containsExactly(row(50, "a")); + } + + @TestTemplate + public void fullFunctionIdentifierNotRewrittenLoadFailure() { + String viewName = viewName("fullFunctionIdentifierNotRewrittenLoadFailure"); + String sql = "SELECT spark_catalog.system.bucket(100, 'a') AS bucket_result, 'a' AS value"; + + // avoid namespace failures + sql("USE spark_catalog"); + sql("CREATE NAMESPACE IF NOT EXISTS system"); + sql("USE %s", catalogName); + + Schema schema = + new Schema( + Types.NestedField.required(1, "bucket_result", Types.IntegerType.get()), + Types.NestedField.required(2, "value", Types.StringType.get())); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(Namespace.of("system")) + .withDefaultCatalog(catalogName) + .withSchema(schema) + .create(); + + // verify the v1 error message + assertThatThrownBy(() -> sql("SELECT * FROM %s", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The routine `system`.`bucket` cannot be found"); + } + + private Schema schema(String sql) { + return SparkSchemaUtil.convert(spark.sql(sql).schema()); + } + + private ViewCatalog viewCatalog() { + Catalog icebergCatalog = Spark3Util.loadIcebergCatalog(spark, catalogName); + assertThat(icebergCatalog).isInstanceOf(ViewCatalog.class); + return (ViewCatalog) icebergCatalog; + } + + private Catalog tableCatalog() { + return Spark3Util.loadIcebergCatalog(spark, catalogName); + } + + @TestTemplate + public void renameView() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("originalView"); + String renamedView = viewName("renamedView"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + sql("ALTER VIEW %s RENAME TO %s", viewName, renamedView); + + List expected = + IntStream.rangeClosed(1, 10).mapToObj(this::row).collect(Collectors.toList()); + assertThat(sql("SELECT * FROM %s", renamedView)) + .hasSize(10) + .containsExactlyInAnyOrderElementsOf(expected); + } + + @TestTemplate + public void renameViewHiddenByTempView() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("originalView"); + String renamedView = viewName("renamedView"); + String sql = String.format("SELECT id FROM %s WHERE id > 5", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + sql("CREATE TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", viewName, tableName); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + // renames the TEMP VIEW + sql("ALTER VIEW %s RENAME TO %s", viewName, renamedView); + assertThat(sql("SELECT * FROM %s", renamedView)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf( + IntStream.rangeClosed(1, 5).mapToObj(this::row).collect(Collectors.toList())); + + // original view still exists with its name + assertThat(viewCatalog.viewExists(TableIdentifier.of(NAMESPACE, viewName))).isTrue(); + assertThat(viewCatalog.viewExists(TableIdentifier.of(NAMESPACE, renamedView))).isFalse(); + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(5) + .containsExactlyInAnyOrderElementsOf( + IntStream.rangeClosed(6, 10).mapToObj(this::row).collect(Collectors.toList())); + + // will rename the Iceberg view + sql("ALTER VIEW %s RENAME TO %s", viewName, renamedView); + assertThat(viewCatalog.viewExists(TableIdentifier.of(NAMESPACE, renamedView))).isTrue(); + } + + @TestTemplate + public void renameViewToDifferentTargetCatalog() { + String viewName = viewName("originalView"); + String renamedView = viewName("renamedView"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + String targetCatalog = + catalogName.equals(SPARK_CATALOG) + ? SparkCatalogConfig.SPARK_WITH_VIEWS.catalogName() + : SPARK_CATALOG; + + assertThatThrownBy( + () -> + sql( + "ALTER VIEW %s RENAME TO %s.%s.%s", + viewName, targetCatalog, NAMESPACE, renamedView)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "Cannot move view between catalogs: from=%s and to=%s", catalogName, targetCatalog); + } + + @TestTemplate + public void renameNonExistingView() { + assertThatThrownBy(() -> sql("ALTER VIEW non_existing RENAME TO target")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The table or view `non_existing` cannot be found"); + } + + @TestTemplate + public void renameViewTargetAlreadyExistsAsView() { + String viewName = viewName("renameViewSource"); + String target = viewName("renameViewTarget"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, target)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThatThrownBy(() -> sql("ALTER VIEW %s RENAME TO %s", viewName, target)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view default.%s because it already exists", target); + } + + @TestTemplate + public void renameViewTargetAlreadyExistsAsTable() { + String viewName = viewName("renameViewSource"); + String target = viewName("renameViewTarget"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + sql( + "CREATE TABLE %s.%s.%s (id INT, data STRING)%s", + catalogName, NAMESPACE, target, catalogName.equals(SPARK_CATALOG) ? " USING iceberg" : ""); + assertThatThrownBy(() -> sql("ALTER VIEW %s RENAME TO %s", viewName, target)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view default.%s because it already exists", target); + } + + @TestTemplate + public void dropView() { + String viewName = viewName("viewToBeDropped"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + TableIdentifier identifier = TableIdentifier.of(NAMESPACE, viewName); + viewCatalog + .buildView(identifier) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThat(viewCatalog.viewExists(identifier)).isTrue(); + + sql("DROP VIEW %s", viewName); + assertThat(viewCatalog.viewExists(identifier)).isFalse(); + } + + @TestTemplate + public void dropNonExistingView() { + assertThatThrownBy(() -> sql("DROP VIEW non_existing")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The view %s.%s cannot be found", NAMESPACE, "non_existing"); + } + + @TestTemplate + public void dropViewIfExists() { + String viewName = viewName("viewToBeDropped"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + TableIdentifier identifier = TableIdentifier.of(NAMESPACE, viewName); + viewCatalog + .buildView(identifier) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThat(viewCatalog.viewExists(identifier)).isTrue(); + + sql("DROP VIEW IF EXISTS %s", viewName); + assertThat(viewCatalog.viewExists(identifier)).isFalse(); + + assertThatNoException().isThrownBy(() -> sql("DROP VIEW IF EXISTS %s", viewName)); + } + + /** The purpose of this test is mainly to make sure that normal view deletion isn't messed up */ + @TestTemplate + public void dropGlobalTempView() { + String globalTempView = viewName("globalViewToBeDropped"); + sql("CREATE GLOBAL TEMPORARY VIEW %s AS SELECT id FROM %s", globalTempView, tableName); + assertThat(v1SessionCatalog().getGlobalTempView(globalTempView).isDefined()).isTrue(); + + sql("DROP VIEW global_temp.%s", globalTempView); + assertThat(v1SessionCatalog().getGlobalTempView(globalTempView).isDefined()).isFalse(); + } + + /** The purpose of this test is mainly to make sure that normal view deletion isn't messed up */ + @TestTemplate + public void dropTempView() { + String tempView = viewName("tempViewToBeDropped"); + sql("CREATE TEMPORARY VIEW %s AS SELECT id FROM %s", tempView, tableName); + assertThat(v1SessionCatalog().getTempView(tempView).isDefined()).isTrue(); + + sql("DROP VIEW %s", tempView); + assertThat(v1SessionCatalog().getTempView(tempView).isDefined()).isFalse(); + } + + private SessionCatalog v1SessionCatalog() { + return spark.sessionState().catalogManager().v1SessionCatalog(); + } + + private String viewName(String viewName) { + return viewName + new Random().nextInt(1000000); + } + + @TestTemplate + public void createViewIfNotExists() { + String viewName = viewName("viewThatAlreadyExists"); + sql("CREATE VIEW %s AS SELECT id FROM %s", viewName, tableName); + + assertThatThrownBy(() -> sql("CREATE VIEW %s AS SELECT id FROM %s", viewName, tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "Cannot create view %s.%s because it already exists", NAMESPACE, viewName); + + // using IF NOT EXISTS should work + assertThatNoException() + .isThrownBy( + () -> sql("CREATE VIEW IF NOT EXISTS %s AS SELECT id FROM %s", viewName, tableName)); + } + + @TestTemplate + public void createOrReplaceView() throws NoSuchTableException { + insertRows(6); + String viewName = viewName("simpleView"); + + sql("CREATE OR REPLACE VIEW %s AS SELECT id FROM %s WHERE id <= 3", viewName, tableName); + assertThat(sql("SELECT id FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1), row(2), row(3)); + + sql("CREATE OR REPLACE VIEW %s AS SELECT id FROM %s WHERE id > 3", viewName, tableName); + assertThat(sql("SELECT id FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(4), row(5), row(6)); + } + + @TestTemplate + public void createViewWithInvalidSQL() { + assertThatThrownBy(() -> sql("CREATE VIEW simpleViewWithInvalidSQL AS invalid SQL")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Syntax error"); + } + + @TestTemplate + public void createViewReferencingTempView() throws NoSuchTableException { + insertRows(10); + String tempView = viewName("temporaryViewBeingReferencedInAnotherView"); + String viewReferencingTempView = viewName("viewReferencingTemporaryView"); + + sql("CREATE TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", tempView, tableName); + + // creating a view that references a TEMP VIEW shouldn't be possible + assertThatThrownBy( + () -> sql("CREATE VIEW %s AS SELECT id FROM %s", viewReferencingTempView, tempView)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewReferencingTempView) + .hasMessageContaining("that references temporary view:") + .hasMessageContaining(tempView); + } + + @TestTemplate + public void createViewReferencingGlobalTempView() throws NoSuchTableException { + insertRows(10); + String globalTempView = viewName("globalTemporaryViewBeingReferenced"); + String viewReferencingTempView = viewName("viewReferencingGlobalTemporaryView"); + + sql( + "CREATE GLOBAL TEMPORARY VIEW %s AS SELECT id FROM %s WHERE id <= 5", + globalTempView, tableName); + + // creating a view that references a GLOBAL TEMP VIEW shouldn't be possible + assertThatThrownBy( + () -> + sql( + "CREATE VIEW %s AS SELECT id FROM global_temp.%s", + viewReferencingTempView, globalTempView)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewReferencingTempView) + .hasMessageContaining("that references temporary view:") + .hasMessageContaining("%s.%s", "global_temp", globalTempView); + } + + @TestTemplate + public void createViewReferencingTempFunction() { + String viewName = viewName("viewReferencingTemporaryFunction"); + String functionName = viewName("test_avg_func"); + + sql( + "CREATE TEMPORARY FUNCTION %s AS 'org.apache.hadoop.hive.ql.udf.generic.GenericUDAFAverage'", + functionName); + + // creating a view that references a TEMP FUNCTION shouldn't be possible + assertThatThrownBy( + () -> sql("CREATE VIEW %s AS SELECT %s(id) FROM %s", viewName, functionName, tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("that references temporary function:") + .hasMessageContaining(functionName); + } + + @TestTemplate + public void createViewReferencingQualifiedTempFunction() { + String viewName = viewName("viewReferencingTemporaryFunction"); + String functionName = viewName("test_avg_func_qualified"); + + sql( + "CREATE TEMPORARY FUNCTION %s AS 'org.apache.hadoop.hive.ql.udf.generic.GenericUDAFAverage'", + functionName); + + // TEMP Function can't be referenced using catalog.schema.name + assertThatThrownBy( + () -> + sql( + "CREATE VIEW %s AS SELECT %s.%s.%s(id) FROM %s", + viewName, catalogName, NAMESPACE, functionName, tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot resolve routine") + .hasMessageContaining("`%s`.`%s`.`%s`", catalogName, NAMESPACE, functionName); + + // TEMP Function can't be referenced using schema.name + assertThatThrownBy( + () -> + sql( + "CREATE VIEW %s AS SELECT %s.%s(id) FROM %s", + viewName, NAMESPACE, functionName, tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot resolve routine") + .hasMessageContaining("`%s`.`%s`", NAMESPACE, functionName); + } + + @TestTemplate + public void createViewUsingNonExistingTable() { + assertThatThrownBy( + () -> sql("CREATE VIEW viewWithNonExistingTable AS SELECT id FROM non_existing")) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The table or view `non_existing` cannot be found"); + } + + @TestTemplate + public void createViewWithMismatchedColumnCounts() { + String viewName = viewName("viewWithMismatchedColumnCounts"); + + assertThatThrownBy( + () -> sql("CREATE VIEW %s (id, data) AS SELECT id FROM %s", viewName, tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("not enough data columns") + .hasMessageContaining("View columns: id, data") + .hasMessageContaining("Data columns: id"); + + assertThatThrownBy( + () -> sql("CREATE VIEW %s (id) AS SELECT id, data FROM %s", viewName, tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("too many data columns") + .hasMessageContaining("View columns: id") + .hasMessageContaining("Data columns: id, data"); + } + + @TestTemplate + public void createViewWithColumnAliases() throws NoSuchTableException { + insertRows(6); + String viewName = viewName("viewWithColumnAliases"); + + sql( + "CREATE VIEW %s (new_id COMMENT 'ID', new_data COMMENT 'DATA') AS SELECT id, data FROM %s WHERE id <= 3", + viewName, tableName); + + View view = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)); + assertThat(view.properties()).containsEntry("spark.query-column-names", "id,data"); + + assertThat(view.schema().columns()).hasSize(2); + Types.NestedField first = view.schema().columns().get(0); + assertThat(first.name()).isEqualTo("new_id"); + assertThat(first.doc()).isEqualTo("ID"); + + Types.NestedField second = view.schema().columns().get(1); + assertThat(second.name()).isEqualTo("new_data"); + assertThat(second.doc()).isEqualTo("DATA"); + + assertThat(sql("SELECT new_id FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1), row(2), row(3)); + + sql("DROP VIEW %s", viewName); + + sql( + "CREATE VIEW %s (new_data, new_id) AS SELECT data, id FROM %s WHERE id <= 3", + viewName, tableName); + + assertThat(sql("SELECT new_id FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1), row(2), row(3)); + } + + @TestTemplate + public void createViewWithDuplicateColumnNames() { + assertThatThrownBy( + () -> + sql( + "CREATE VIEW viewWithDuplicateColumnNames (new_id, new_id) AS SELECT id, id FROM %s WHERE id <= 3", + tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The column `new_id` already exists"); + } + + @TestTemplate + public void createViewWithDuplicateQueryColumnNames() throws NoSuchTableException { + insertRows(3); + String viewName = viewName("viewWithDuplicateQueryColumnNames"); + String sql = String.format("SELECT id, id FROM %s WHERE id <= 3", tableName); + + // not specifying column aliases in the view should fail + assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The column `id` already exists"); + + sql("CREATE VIEW %s (id_one, id_two) AS %s", viewName, sql); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1, 1), row(2, 2), row(3, 3)); + } + + @TestTemplate + public void createViewWithCTE() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("simpleViewWithCTE"); + String sql = + String.format( + "WITH max_by_data AS (SELECT max(id) as max FROM %s) " + + "SELECT max, count(1) AS count FROM max_by_data GROUP BY max", + tableName); + + sql("CREATE VIEW %s AS %s", viewName, sql); + + assertThat(sql("SELECT * FROM %s", viewName)).hasSize(1).containsExactly(row(10, 1L)); + } + + @TestTemplate + public void createViewWithConflictingNamesForCTEAndTempView() throws NoSuchTableException { + insertRows(10); + String viewName = viewName("viewWithConflictingNamesForCTEAndTempView"); + String cteName = viewName("cteName"); + String sql = + String.format( + "WITH %s AS (SELECT max(id) as max FROM %s) " + + "(SELECT max, count(1) AS count FROM %s GROUP BY max)", + cteName, tableName, cteName); + + // create a CTE and a TEMP VIEW with the same name + sql("CREATE TEMPORARY VIEW %s AS SELECT * from %s", cteName, tableName); + sql("CREATE VIEW %s AS %s", viewName, sql); + + // CTE should take precedence over the TEMP VIEW when data is read + assertThat(sql("SELECT * FROM %s", viewName)).hasSize(1).containsExactly(row(10, 1L)); + } + + @TestTemplate + public void createViewWithCTEReferencingTempView() { + String viewName = viewName("viewWithCTEReferencingTempView"); + String tempViewInCTE = viewName("tempViewInCTE"); + String sql = + String.format( + "WITH max_by_data AS (SELECT max(id) as max FROM %s) " + + "SELECT max, count(1) AS count FROM max_by_data GROUP BY max", + tempViewInCTE); + + sql("CREATE TEMPORARY VIEW %s AS SELECT id FROM %s WHERE ID <= 5", tempViewInCTE, tableName); + + assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("that references temporary view:") + .hasMessageContaining(tempViewInCTE); + } + + @TestTemplate + public void createViewWithCTEReferencingTempFunction() { + String viewName = viewName("viewWithCTEReferencingTempFunction"); + String functionName = viewName("avg_function_in_cte"); + String sql = + String.format( + "WITH avg_data AS (SELECT %s(id) as avg FROM %s) " + + "SELECT avg, count(1) AS count FROM avg_data GROUP BY max", + functionName, tableName); + + sql( + "CREATE TEMPORARY FUNCTION %s AS 'org.apache.hadoop.hive.ql.udf.generic.GenericUDAFAverage'", + functionName); + + assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("that references temporary function:") + .hasMessageContaining(functionName); + } + + @TestTemplate + public void createViewWithNonExistingQueryColumn() { + assertThatThrownBy( + () -> + sql( + "CREATE VIEW viewWithNonExistingQueryColumn AS SELECT non_existing FROM %s WHERE id <= 3", + tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `non_existing` cannot be resolved"); + } + + @TestTemplate + public void createViewWithSubqueryExpressionUsingTempView() { + String viewName = viewName("viewWithSubqueryExpression"); + String tempView = viewName("simpleTempView"); + String sql = + String.format("SELECT * FROM %s WHERE id = (SELECT id FROM %s)", tableName, tempView); + + sql("CREATE TEMPORARY VIEW %s AS SELECT id from %s WHERE id = 5", tempView, tableName); + + assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("that references temporary view:") + .hasMessageContaining(tempView); + } + + @TestTemplate + public void createViewWithSubqueryExpressionUsingGlobalTempView() { + String viewName = viewName("simpleViewWithSubqueryExpression"); + String globalTempView = viewName("simpleGlobalTempView"); + String sql = + String.format( + "SELECT * FROM %s WHERE id = (SELECT id FROM global_temp.%s)", + tableName, globalTempView); + + sql( + "CREATE GLOBAL TEMPORARY VIEW %s AS SELECT id from %s WHERE id = 5", + globalTempView, tableName); + + assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("that references temporary view:") + .hasMessageContaining("%s.%s", "global_temp", globalTempView); + } + + @TestTemplate + public void createViewWithSubqueryExpressionUsingTempFunction() { + String viewName = viewName("viewWithSubqueryExpression"); + String functionName = viewName("avg_function_in_subquery"); + String sql = + String.format( + "SELECT * FROM %s WHERE id < (SELECT %s(id) FROM %s)", + tableName, functionName, tableName); + + sql( + "CREATE TEMPORARY FUNCTION %s AS 'org.apache.hadoop.hive.ql.udf.generic.GenericUDAFAverage'", + functionName); + + assertThatThrownBy(() -> sql("CREATE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot create view %s.%s.%s", catalogName, NAMESPACE, viewName) + .hasMessageContaining("that references temporary function:") + .hasMessageContaining(functionName); + } + + @TestTemplate + public void createViewWithSubqueryExpressionInFilterThatIsRewritten() + throws NoSuchTableException { + assumeThat(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE)) + .as( + "Executing subquery expression with Hive fails due to an exception when instantiating the FileInputFormat") + .isNotEqualTo("hive"); + insertRows(5); + String viewName = viewName("viewWithSubqueryExpression"); + String sql = + String.format( + "SELECT id FROM %s WHERE id = (SELECT max(id) FROM %s)", tableName, tableName); + + sql("CREATE VIEW %s AS %s", viewName, sql); + + assertThat(sql("SELECT * FROM %s", viewName)).hasSize(1).containsExactly(row(5)); + + if (!catalogName.equals(SPARK_CATALOG)) { + sql("USE spark_catalog"); + + assertThatThrownBy(() -> sql(sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The table or view `%s` cannot be found", tableName); + } + + // the underlying SQL in the View should be rewritten to have catalog & namespace + assertThat(sql("SELECT * FROM %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasSize(1) + .containsExactly(row(5)); + } + + @TestTemplate + public void createViewWithSubqueryExpressionInQueryThatIsRewritten() throws NoSuchTableException { + assumeThat(catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE)) + .as( + "Executing subquery expression with Hive fails due to an exception when instantiating the FileInputFormat") + .isNotEqualTo("hive"); + insertRows(3); + String viewName = viewName("viewWithSubqueryExpression"); + String sql = + String.format("SELECT (SELECT max(id) FROM %s) max_id FROM %s", tableName, tableName); + + sql("CREATE VIEW %s AS %s", viewName, sql); + + assertThat(sql("SELECT * FROM %s", viewName)) + .hasSize(3) + .containsExactly(row(3), row(3), row(3)); + + if (!catalogName.equals(SPARK_CATALOG)) { + sql("USE spark_catalog"); + + assertThatThrownBy(() -> sql(sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("The table or view `%s` cannot be found", tableName); + } + + // the underlying SQL in the View should be rewritten to have catalog & namespace + assertThat(sql("SELECT * FROM %s.%s.%s", catalogName, NAMESPACE, viewName)) + .hasSize(3) + .containsExactly(row(3), row(3), row(3)); + } + + @TestTemplate + public void describeView() { + String viewName = viewName("describeView"); + + sql("CREATE VIEW %s AS SELECT id, data FROM %s WHERE id <= 3", viewName, tableName); + assertThat(sql("DESCRIBE %s", viewName)) + .containsExactly(row("id", "int", ""), row("data", "string", "")); + } + + @TestTemplate + public void describeExtendedView() { + String viewName = viewName("describeExtendedView"); + String sql = String.format("SELECT id, data FROM %s WHERE id <= 3", tableName); + + sql( + "CREATE VIEW %s (new_id COMMENT 'ID', new_data COMMENT 'DATA') COMMENT 'view comment' AS %s", + viewName, sql); + String location = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)).location(); + assertThat(sql("DESCRIBE EXTENDED %s", viewName)) + .contains( + row("new_id", "int", "ID"), + row("new_data", "string", "DATA"), + row("", "", ""), + row("# Detailed View Information", "", ""), + row("Comment", "view comment", ""), + row("View Catalog and Namespace", String.format("%s.%s", catalogName, NAMESPACE), ""), + row("View Query Output Columns", "[id, data]", ""), + row( + "View Properties", + String.format( + "['format-version' = '1', 'location' = '%s', 'provider' = 'iceberg']", + location), + "")); + } + + @TestTemplate + public void createAndDescribeViewInDefaultNamespace() { + String viewName = viewName("createViewInDefaultNamespace"); + String sql = String.format("SELECT id, data FROM %s WHERE id <= 3", tableName); + + sql("CREATE VIEW %s (id, data) AS %s", viewName, sql); + TableIdentifier identifier = TableIdentifier.of(NAMESPACE, viewName); + View view = viewCatalog().loadView(identifier); + assertThat(view.currentVersion().defaultCatalog()).isNull(); + assertThat(view.name()).isEqualTo(ViewUtil.fullViewName(catalogName, identifier)); + assertThat(view.currentVersion().defaultNamespace()).isEqualTo(NAMESPACE); + + String location = viewCatalog().loadView(identifier).location(); + assertThat(sql("DESCRIBE EXTENDED %s.%s", NAMESPACE, viewName)) + .contains( + row("id", "int", ""), + row("data", "string", ""), + row("", "", ""), + row("# Detailed View Information", "", ""), + row("Comment", "", ""), + row("View Catalog and Namespace", String.format("%s.%s", catalogName, NAMESPACE), ""), + row("View Query Output Columns", "[id, data]", ""), + row( + "View Properties", + String.format( + "['format-version' = '1', 'location' = '%s', 'provider' = 'iceberg']", + location), + "")); + } + + @TestTemplate + public void createAndDescribeViewWithoutCurrentNamespace() { + String viewName = viewName("createViewWithoutCurrentNamespace"); + Namespace namespace = Namespace.of("test_namespace"); + String sql = String.format("SELECT id, data FROM %s WHERE id <= 3", tableName); + + sql("CREATE NAMESPACE IF NOT EXISTS %s", namespace); + sql("CREATE VIEW %s.%s (id, data) AS %s", namespace, viewName, sql); + TableIdentifier identifier = TableIdentifier.of(namespace, viewName); + View view = viewCatalog().loadView(identifier); + assertThat(view.currentVersion().defaultCatalog()).isNull(); + assertThat(view.name()).isEqualTo(ViewUtil.fullViewName(catalogName, identifier)); + assertThat(view.currentVersion().defaultNamespace()).isEqualTo(NAMESPACE); + + String location = viewCatalog().loadView(identifier).location(); + assertThat(sql("DESCRIBE EXTENDED %s.%s", namespace, viewName)) + .contains( + row("id", "int", ""), + row("data", "string", ""), + row("", "", ""), + row("# Detailed View Information", "", ""), + row("Comment", "", ""), + row("View Catalog and Namespace", String.format("%s.%s", catalogName, namespace), ""), + row("View Query Output Columns", "[id, data]", ""), + row( + "View Properties", + String.format( + "['format-version' = '1', 'location' = '%s', 'provider' = 'iceberg']", + location), + "")); + } + + @TestTemplate + public void showViewProperties() { + String viewName = viewName("showViewProps"); + + sql( + "CREATE VIEW %s TBLPROPERTIES ('key1'='val1', 'key2'='val2') AS SELECT id, data FROM %s WHERE id <= 3", + viewName, tableName); + assertThat(sql("SHOW TBLPROPERTIES %s", viewName)) + .contains(row("key1", "val1"), row("key2", "val2")); + } + + @TestTemplate + public void showViewPropertiesByKey() { + String viewName = viewName("showViewPropsByKey"); + + sql("CREATE VIEW %s AS SELECT id, data FROM %s WHERE id <= 3", viewName, tableName); + assertThat(sql("SHOW TBLPROPERTIES %s", viewName)).contains(row("provider", "iceberg")); + + assertThat(sql("SHOW TBLPROPERTIES %s (provider)", viewName)) + .contains(row("provider", "iceberg")); + + assertThat(sql("SHOW TBLPROPERTIES %s (non.existing)", viewName)) + .contains( + row( + "non.existing", + String.format( + "View %s.%s.%s does not have property: non.existing", + catalogName, NAMESPACE, viewName))); + } + + @TestTemplate + public void showViews() throws NoSuchTableException { + insertRows(6); + String sql = String.format("SELECT * from %s", tableName); + String v1 = viewName("v1"); + String prefixV2 = viewName("prefix_v2"); + String prefixV3 = viewName("prefix_v3"); + String globalViewForListing = viewName("global_view_for_listing"); + String tempViewForListing = viewName("temp_view_for_listing"); + sql("CREATE VIEW %s AS %s", v1, sql); + sql("CREATE VIEW %s AS %s", prefixV2, sql); + sql("CREATE VIEW %s AS %s", prefixV3, sql); + sql("CREATE GLOBAL TEMPORARY VIEW %s AS %s", globalViewForListing, sql); + sql("CREATE TEMPORARY VIEW %s AS %s", tempViewForListing, sql); + + Object[] globalView = row("global_temp", globalViewForListing, true); + Object[] tempView = row("", tempViewForListing, true); + Object[] v1Row = row(NAMESPACE.toString(), v1, false); + Object[] v2Row = row(NAMESPACE.toString(), prefixV2, false); + Object[] v3Row = row(NAMESPACE.toString(), prefixV3, false); + assertThat(sql("SHOW VIEWS")).contains(v2Row, v3Row, v1Row, tempView); + + String catalogType = catalogConfig.get(CatalogUtil.ICEBERG_CATALOG_TYPE); + if (!"rest".equals(catalogType) && !"hive".equals(catalogType)) { + // REST & Hive catalog require a namespace + assertThat(sql("SHOW VIEWS IN %s", catalogName)) + .contains(tempView) + .doesNotContain(v1Row, v2Row, v3Row); + } + + assertThat(sql("SHOW VIEWS IN %s.%s", catalogName, NAMESPACE)) + .contains(v2Row, v3Row, v1Row, tempView); + + assertThat(sql("SHOW VIEWS LIKE 'pref*'")) + .contains(v2Row, v3Row) + .doesNotContain(v1Row, tempView); + + assertThat(sql("SHOW VIEWS LIKE 'non-existing'")).isEmpty(); + + if (catalogName.equals(SparkCatalogConfig.SPARK_WITH_VIEWS.catalogName())) { + sql("CREATE NAMESPACE IF NOT EXISTS spark_catalog.%s", NAMESPACE); + assertThat(sql("SHOW VIEWS IN spark_catalog.%s", NAMESPACE)) + .contains(tempView) + .doesNotContain(v1Row, v2Row, v3Row); + } + + assertThat(sql("SHOW VIEWS IN global_temp")) + .contains(globalView, tempView) + .doesNotContain(v1Row, v2Row, v3Row); + + sql("USE spark_catalog"); + assertThat(sql("SHOW VIEWS")).contains(tempView); + + assertThat(sql("SHOW VIEWS IN default")).contains(tempView); + } + + @TestTemplate + public void showViewsWithCurrentNamespace() { + String namespaceOne = "show_views_ns1"; + String namespaceTwo = "show_views_ns2"; + String viewOne = viewName("view_one"); + String viewTwo = viewName("view_two"); + sql("CREATE NAMESPACE IF NOT EXISTS %s", namespaceOne); + sql("CREATE NAMESPACE IF NOT EXISTS %s", namespaceTwo); + + // create one view in each namespace + sql("CREATE VIEW %s.%s AS SELECT * FROM %s.%s", namespaceOne, viewOne, NAMESPACE, tableName); + sql("CREATE VIEW %s.%s AS SELECT * FROM %s.%s", namespaceTwo, viewTwo, NAMESPACE, tableName); + + Object[] v1 = row(namespaceOne, viewOne, false); + Object[] v2 = row(namespaceTwo, viewTwo, false); + + assertThat(sql("SHOW VIEWS IN %s.%s", catalogName, namespaceOne)) + .contains(v1) + .doesNotContain(v2); + sql("USE %s", namespaceOne); + assertThat(sql("SHOW VIEWS")).contains(v1).doesNotContain(v2); + assertThat(sql("SHOW VIEWS LIKE 'view_one*'")).contains(v1).doesNotContain(v2); + + assertThat(sql("SHOW VIEWS IN %s.%s", catalogName, namespaceTwo)) + .contains(v2) + .doesNotContain(v1); + sql("USE %s", namespaceTwo); + assertThat(sql("SHOW VIEWS")).contains(v2).doesNotContain(v1); + assertThat(sql("SHOW VIEWS LIKE 'view_two*'")).contains(v2).doesNotContain(v1); + } + + @TestTemplate + public void showCreateSimpleView() { + String viewName = viewName("showCreateSimpleView"); + String sql = String.format("SELECT id, data FROM %s WHERE id <= 3", tableName); + + sql("CREATE VIEW %s AS %s", viewName, sql); + + String location = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)).location(); + String expected = + String.format( + "CREATE VIEW %s.%s.%s (\n" + + " id,\n" + + " data)\n" + + "TBLPROPERTIES (\n" + + " 'format-version' = '1',\n" + + " 'location' = '%s',\n" + + " 'provider' = 'iceberg')\n" + + "AS\n%s\n", + catalogName, NAMESPACE, viewName, location, sql); + assertThat(sql("SHOW CREATE TABLE %s", viewName)).containsExactly(row(expected)); + } + + @TestTemplate + public void showCreateComplexView() { + String viewName = viewName("showCreateComplexView"); + String sql = String.format("SELECT id, data FROM %s WHERE id <= 3", tableName); + + sql( + "CREATE VIEW %s (new_id COMMENT 'ID', new_data COMMENT 'DATA')" + + "COMMENT 'view comment' TBLPROPERTIES ('key1'='val1', 'key2'='val2') AS %s", + viewName, sql); + + String location = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)).location(); + String expected = + String.format( + "CREATE VIEW %s.%s.%s (\n" + + " new_id COMMENT 'ID',\n" + + " new_data COMMENT 'DATA')\n" + + "COMMENT 'view comment'\n" + + "TBLPROPERTIES (\n" + + " 'format-version' = '1',\n" + + " 'key1' = 'val1',\n" + + " 'key2' = 'val2',\n" + + " 'location' = '%s',\n" + + " 'provider' = 'iceberg')\n" + + "AS\n%s\n", + catalogName, NAMESPACE, viewName, location, sql); + assertThat(sql("SHOW CREATE TABLE %s", viewName)).containsExactly(row(expected)); + } + + @TestTemplate + public void alterViewSetProperties() { + String viewName = viewName("viewWithSetProperties"); + + sql("CREATE VIEW %s AS SELECT id FROM %s WHERE id <= 3", viewName, tableName); + + ViewCatalog viewCatalog = viewCatalog(); + assertThat(viewCatalog.loadView(TableIdentifier.of(NAMESPACE, viewName)).properties()) + .doesNotContainKey("key1") + .doesNotContainKey("comment"); + + sql("ALTER VIEW %s SET TBLPROPERTIES ('key1' = 'val1', 'comment' = 'view comment')", viewName); + assertThat(viewCatalog.loadView(TableIdentifier.of(NAMESPACE, viewName)).properties()) + .containsEntry("key1", "val1") + .containsEntry("comment", "view comment"); + + sql("ALTER VIEW %s SET TBLPROPERTIES ('key1' = 'new_val1')", viewName); + assertThat(viewCatalog.loadView(TableIdentifier.of(NAMESPACE, viewName)).properties()) + .containsEntry("key1", "new_val1") + .containsEntry("comment", "view comment"); + } + + @TestTemplate + public void alterViewSetReservedProperties() { + String viewName = viewName("viewWithSetReservedProperties"); + + sql("CREATE VIEW %s AS SELECT id FROM %s WHERE id <= 3", viewName, tableName); + + assertThatThrownBy(() -> sql("ALTER VIEW %s SET TBLPROPERTIES ('provider' = 'val1')", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "The feature is not supported: provider is a reserved table property"); + + assertThatThrownBy( + () -> sql("ALTER VIEW %s SET TBLPROPERTIES ('location' = 'random_location')", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "The feature is not supported: location is a reserved table property"); + + assertThatThrownBy( + () -> sql("ALTER VIEW %s SET TBLPROPERTIES ('format-version' = '99')", viewName)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Cannot set reserved property: 'format-version'"); + + assertThatThrownBy( + () -> + sql( + "ALTER VIEW %s SET TBLPROPERTIES ('spark.query-column-names' = 'a,b,c')", + viewName)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Cannot set reserved property: 'spark.query-column-names'"); + } + + @TestTemplate + public void alterViewUnsetProperties() { + String viewName = viewName("viewWithUnsetProperties"); + sql("CREATE VIEW %s AS SELECT id FROM %s WHERE id <= 3", viewName, tableName); + + ViewCatalog viewCatalog = viewCatalog(); + assertThat(viewCatalog.loadView(TableIdentifier.of(NAMESPACE, viewName)).properties()) + .doesNotContainKey("key1") + .doesNotContainKey("comment"); + + sql("ALTER VIEW %s SET TBLPROPERTIES ('key1' = 'val1', 'comment' = 'view comment')", viewName); + assertThat(viewCatalog.loadView(TableIdentifier.of(NAMESPACE, viewName)).properties()) + .containsEntry("key1", "val1") + .containsEntry("comment", "view comment"); + + sql("ALTER VIEW %s UNSET TBLPROPERTIES ('key1')", viewName); + assertThat(viewCatalog.loadView(TableIdentifier.of(NAMESPACE, viewName)).properties()) + .doesNotContainKey("key1") + .containsEntry("comment", "view comment"); + } + + @TestTemplate + public void alterViewUnsetUnknownProperty() { + String viewName = viewName("viewWithUnsetUnknownProp"); + sql("CREATE VIEW %s AS SELECT id FROM %s WHERE id <= 3", viewName, tableName); + + assertThatThrownBy(() -> sql("ALTER VIEW %s UNSET TBLPROPERTIES ('unknown-key')", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot remove property that is not set: 'unknown-key'"); + + assertThatNoException() + .isThrownBy( + () -> sql("ALTER VIEW %s UNSET TBLPROPERTIES IF EXISTS ('unknown-key')", viewName)); + } + + @TestTemplate + public void alterViewUnsetReservedProperties() { + String viewName = viewName("viewWithUnsetReservedProperties"); + + sql("CREATE VIEW %s AS SELECT id FROM %s WHERE id <= 3", viewName, tableName); + + assertThatThrownBy(() -> sql("ALTER VIEW %s UNSET TBLPROPERTIES ('provider')", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "The feature is not supported: provider is a reserved table property"); + + assertThatThrownBy(() -> sql("ALTER VIEW %s UNSET TBLPROPERTIES ('location')", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "The feature is not supported: location is a reserved table property"); + + assertThatThrownBy(() -> sql("ALTER VIEW %s UNSET TBLPROPERTIES ('format-version')", viewName)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Cannot unset reserved property: 'format-version'"); + + // spark.query-column-names is only used internally, so it technically doesn't exist on a Spark + // VIEW + assertThatThrownBy( + () -> sql("ALTER VIEW %s UNSET TBLPROPERTIES ('spark.query-column-names')", viewName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot remove property that is not set: 'spark.query-column-names'"); + + assertThatThrownBy( + () -> + sql( + "ALTER VIEW %s UNSET TBLPROPERTIES IF EXISTS ('spark.query-column-names')", + viewName)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Cannot unset reserved property: 'spark.query-column-names'"); + } + + @TestTemplate + public void createOrReplaceViewWithColumnAliases() throws NoSuchTableException { + insertRows(6); + String viewName = viewName("viewWithColumnAliases"); + + sql( + "CREATE VIEW %s (new_id COMMENT 'ID', new_data COMMENT 'DATA') AS SELECT id, data FROM %s WHERE id <= 3", + viewName, tableName); + + View view = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)); + assertThat(view.properties()).containsEntry("spark.query-column-names", "id,data"); + + assertThat(view.schema().columns()).hasSize(2); + Types.NestedField first = view.schema().columns().get(0); + assertThat(first.name()).isEqualTo("new_id"); + assertThat(first.doc()).isEqualTo("ID"); + + Types.NestedField second = view.schema().columns().get(1); + assertThat(second.name()).isEqualTo("new_data"); + assertThat(second.doc()).isEqualTo("DATA"); + + assertThat(sql("SELECT new_id FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1), row(2), row(3)); + + sql( + "CREATE OR REPLACE VIEW %s (data2 COMMENT 'new data', id2 COMMENT 'new ID') AS SELECT data, id FROM %s WHERE id <= 3", + viewName, tableName); + + assertThat(sql("SELECT data2, id2 FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row("2", 1), row("4", 2), row("6", 3)); + + view = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)); + assertThat(view.properties()).containsEntry("spark.query-column-names", "data,id"); + + assertThat(view.schema().columns()).hasSize(2); + first = view.schema().columns().get(0); + assertThat(first.name()).isEqualTo("data2"); + assertThat(first.doc()).isEqualTo("new data"); + + second = view.schema().columns().get(1); + assertThat(second.name()).isEqualTo("id2"); + assertThat(second.doc()).isEqualTo("new ID"); + } + + @TestTemplate + public void alterViewIsNotSupported() throws NoSuchTableException { + insertRows(6); + String viewName = viewName("alteredView"); + + sql("CREATE VIEW %s AS SELECT id, data FROM %s WHERE id <= 3", viewName, tableName); + + assertThat(sql("SELECT id FROM %s", viewName)) + .hasSize(3) + .containsExactlyInAnyOrder(row(1), row(2), row(3)); + + assertThatThrownBy( + () -> sql("ALTER VIEW %s AS SELECT id FROM %s WHERE id > 3", viewName, tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "ALTER VIEW AS is not supported. Use CREATE OR REPLACE VIEW instead"); + } + + @TestTemplate + public void createOrReplaceViewKeepsViewHistory() { + String viewName = viewName("viewWithHistoryAfterReplace"); + String sql = String.format("SELECT id, data FROM %s WHERE id <= 3", tableName); + String updatedSql = String.format("SELECT id FROM %s WHERE id > 3", tableName); + + sql( + "CREATE VIEW %s (new_id COMMENT 'some ID', new_data COMMENT 'some data') AS %s", + viewName, sql); + + View view = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)); + assertThat(view.history()).hasSize(1); + assertThat(view.sqlFor("spark").sql()).isEqualTo(sql); + assertThat(view.currentVersion().versionId()).isEqualTo(1); + assertThat(view.currentVersion().schemaId()).isEqualTo(0); + assertThat(view.schemas()).hasSize(1); + assertThat(view.schema().asStruct()) + .isEqualTo( + new Schema( + Types.NestedField.optional(0, "new_id", Types.IntegerType.get(), "some ID"), + Types.NestedField.optional(1, "new_data", Types.StringType.get(), "some data")) + .asStruct()); + + sql("CREATE OR REPLACE VIEW %s (updated_id COMMENT 'updated ID') AS %s", viewName, updatedSql); + + view = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)); + assertThat(view.history()).hasSize(2); + assertThat(view.sqlFor("spark").sql()).isEqualTo(updatedSql); + assertThat(view.currentVersion().versionId()).isEqualTo(2); + assertThat(view.currentVersion().schemaId()).isEqualTo(1); + assertThat(view.schemas()).hasSize(2); + assertThat(view.schema().asStruct()) + .isEqualTo( + new Schema( + Types.NestedField.optional( + 0, "updated_id", Types.IntegerType.get(), "updated ID")) + .asStruct()); + } + + @TestTemplate + public void replacingTrinoViewShouldFail() { + String viewName = viewName("trinoView"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("trino", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Cannot replace view due to loss of view dialects (replace.drop-dialect.allowed=false):\n" + + "Previous dialects: [trino]\n" + + "New dialects: [spark]"); + } + + @TestTemplate + public void replacingTrinoAndSparkViewShouldFail() { + String viewName = viewName("trinoAndSparkView"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("trino", sql) + .withQuery("spark", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS %s", viewName, sql)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Cannot replace view due to loss of view dialects (replace.drop-dialect.allowed=false):\n" + + "Previous dialects: [trino, spark]\n" + + "New dialects: [spark]"); + } + + @TestTemplate + public void replacingViewWithDialectDropAllowed() { + String viewName = viewName("trinoView"); + String sql = String.format("SELECT id FROM %s", tableName); + + ViewCatalog viewCatalog = viewCatalog(); + + viewCatalog + .buildView(TableIdentifier.of(NAMESPACE, viewName)) + .withQuery("trino", sql) + .withDefaultNamespace(NAMESPACE) + .withDefaultCatalog(catalogName) + .withSchema(schema(sql)) + .create(); + + // allowing to drop the trino dialect should replace the view + sql( + "CREATE OR REPLACE VIEW %s TBLPROPERTIES ('%s'='true') AS SELECT id FROM %s", + viewName, ViewProperties.REPLACE_DROP_DIALECT_ALLOWED, tableName); + + View view = viewCatalog.loadView(TableIdentifier.of(NAMESPACE, viewName)); + assertThat(view.currentVersion().representations()) + .hasSize(1) + .first() + .asInstanceOf(InstanceOfAssertFactories.type(SQLViewRepresentation.class)) + .isEqualTo(ImmutableSQLViewRepresentation.builder().dialect("spark").sql(sql).build()); + + // trino view should show up in the view versions & history + assertThat(view.history()).hasSize(2); + assertThat(view.history()).element(0).extracting(ViewHistoryEntry::versionId).isEqualTo(1); + assertThat(view.history()).element(1).extracting(ViewHistoryEntry::versionId).isEqualTo(2); + + assertThat(view.versions()).hasSize(2); + assertThat(view.versions()).element(0).extracting(ViewVersion::versionId).isEqualTo(1); + assertThat(view.versions()).element(1).extracting(ViewVersion::versionId).isEqualTo(2); + + assertThat(Lists.newArrayList(view.versions()).get(0).representations()) + .hasSize(1) + .first() + .asInstanceOf(InstanceOfAssertFactories.type(SQLViewRepresentation.class)) + .isEqualTo(ImmutableSQLViewRepresentation.builder().dialect("trino").sql(sql).build()); + + assertThat(Lists.newArrayList(view.versions()).get(1).representations()) + .hasSize(1) + .first() + .asInstanceOf(InstanceOfAssertFactories.type(SQLViewRepresentation.class)) + .isEqualTo(ImmutableSQLViewRepresentation.builder().dialect("spark").sql(sql).build()); + } + + @TestTemplate + public void createViewWithRecursiveCycle() { + String viewOne = viewName("viewOne"); + String viewTwo = viewName("viewTwo"); + + sql("CREATE VIEW %s AS SELECT * FROM %s", viewOne, tableName); + // viewTwo points to viewOne + sql("CREATE VIEW %s AS SELECT * FROM %s", viewTwo, viewOne); + + // viewOne points to viewTwo points to viewOne, creating a recursive cycle + String view1 = String.format("%s.%s.%s", catalogName, NAMESPACE, viewOne); + String view2 = String.format("%s.%s.%s", catalogName, NAMESPACE, viewTwo); + String cycle = String.format("%s -> %s -> %s", view1, view2, view1); + assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS SELECT * FROM %s", viewOne, view2)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); + } + + @TestTemplate + public void createViewWithRecursiveCycleToV1View() { + assumeThat(catalogName).isNotEqualTo(SPARK_CATALOG); + String viewOne = viewName("view_one"); + String viewTwo = viewName("view_two"); + + sql("CREATE VIEW %s AS SELECT * FROM %s", viewOne, tableName); + // viewTwo points to viewOne + sql("USE spark_catalog"); + sql("CREATE VIEW %s AS SELECT * FROM %s.%s.%s", viewTwo, catalogName, NAMESPACE, viewOne); + + sql("USE %s", catalogName); + // viewOne points to viewTwo points to viewOne, creating a recursive cycle + String view1 = String.format("%s.%s.%s", catalogName, NAMESPACE, viewOne); + String view2 = String.format("%s.%s.%s", SPARK_CATALOG, NAMESPACE, viewTwo); + String cycle = String.format("%s -> %s -> %s", view1, view2, view1); + assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS SELECT * FROM %s", viewOne, view2)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); + } + + @TestTemplate + public void createViewWithRecursiveCycleInCTE() { + String viewOne = viewName("viewOne"); + String viewTwo = viewName("viewTwo"); + + sql("CREATE VIEW %s AS SELECT * FROM %s", viewOne, tableName); + // viewTwo points to viewOne + sql("CREATE VIEW %s AS SELECT * FROM %s", viewTwo, viewOne); + + // CTE points to viewTwo + String sql = + String.format( + "WITH max_by_data AS (SELECT max(id) as max FROM %s) " + + "SELECT max, count(1) AS count FROM max_by_data GROUP BY max", + viewTwo); + + // viewOne points to CTE, creating a recursive cycle + String view1 = String.format("%s.%s.%s", catalogName, NAMESPACE, viewOne); + String cycle = String.format("%s -> %s -> %s", view1, viewTwo, view1); + assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS %s", viewOne, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); + } + + @TestTemplate + public void createViewWithRecursiveCycleInSubqueryExpression() { + String viewOne = viewName("viewOne"); + String viewTwo = viewName("viewTwo"); + + sql("CREATE VIEW %s AS SELECT * FROM %s", viewOne, tableName); + // viewTwo points to viewOne + sql("CREATE VIEW %s AS SELECT * FROM %s", viewTwo, viewOne); + + // subquery expression points to viewTwo + String sql = + String.format("SELECT * FROM %s WHERE id = (SELECT id FROM %s)", tableName, viewTwo); + + // viewOne points to subquery expression, creating a recursive cycle + String view1 = String.format("%s.%s.%s", catalogName, NAMESPACE, viewOne); + String cycle = String.format("%s -> %s -> %s", view1, viewTwo, view1); + assertThatThrownBy(() -> sql("CREATE OR REPLACE VIEW %s AS %s", viewOne, sql)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Recursive cycle in view detected: %s (cycle: %s)", view1, cycle); + } + + @TestTemplate + public void createViewWithCustomMetadataLocation() { + String viewName = viewName("v"); + String customMetadataLocation = + Paths.get(temp.toUri().toString(), "custom-metadata-location").toString(); + sql( + "CREATE VIEW %s TBLPROPERTIES ('%s'='%s') AS SELECT * FROM %s", + viewName, ViewProperties.WRITE_METADATA_LOCATION, customMetadataLocation, tableName); + String location = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)).location(); + + assertThat(sql("DESCRIBE EXTENDED %s", viewName)) + .contains( + row( + "View Properties", + String.format( + "['format-version' = '1', 'location' = '%s', 'provider' = 'iceberg', 'write.metadata.path' = '%s']", + location, customMetadataLocation), + "")); + } + + @TestTemplate + public void createViewWithCustomMetadataLocationWithLocation() { + String viewName = viewName("v"); + String customMetadataLocation = + Paths.get(temp.toUri().toString(), "custom-metadata-location").toString(); + sql( + "CREATE VIEW %s TBLPROPERTIES ('location'='%s') AS SELECT * FROM %s", + viewName, customMetadataLocation, tableName); + + assertThat(sql("SHOW TBLPROPERTIES %s", viewName)) + .contains(row("location", customMetadataLocation)); + + String location = viewCatalog().loadView(TableIdentifier.of(NAMESPACE, viewName)).location(); + assertThat(location).isEqualTo(customMetadataLocation); + } + + private void insertRows(int numRows) throws NoSuchTableException { + List records = Lists.newArrayListWithCapacity(numRows); + for (int i = 1; i <= numRows; i++) { + records.add(new SimpleRecord(i, Integer.toString(i * 2))); + } + + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestWriteAborts.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestWriteAborts.java new file mode 100644 index 000000000000..1786a7f3a3c8 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestWriteAborts.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.extensions; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.BulkDeletionFailureException; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.SparkException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestWriteAborts extends ExtensionsTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hive", + CatalogProperties.FILE_IO_IMPL, + CustomFileIO.class.getName(), + "default-namespace", + "default") + }, + { + "testhivebulk", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hive", + CatalogProperties.FILE_IO_IMPL, + CustomBulkFileIO.class.getName(), + "default-namespace", + "default") + } + }; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testBatchAppend() throws IOException { + String dataLocation = Files.createTempDirectory(temp, "junit").toFile().toString(); + + sql( + "CREATE TABLE %s (id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (data)" + + "TBLPROPERTIES ('%s' '%s')", + tableName, TableProperties.WRITE_DATA_LOCATION, dataLocation); + + List records = + ImmutableList.of( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "a"), + new SimpleRecord(4, "b")); + Dataset inputDF = spark.createDataFrame(records, SimpleRecord.class); + + assertThatThrownBy( + () -> + // incoming records are not ordered by partitions so the job must fail + inputDF + .coalesce(1) + .sortWithinPartitions("id") + .writeTo(tableName) + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") + .option(SparkWriteOptions.FANOUT_ENABLED, "false") + .append()) + .isInstanceOf(SparkException.class) + .hasMessageContaining("Encountered records that belong to already closed files"); + + assertEquals("Should be no records", sql("SELECT * FROM %s", tableName), ImmutableList.of()); + + assertEquals( + "Should be no orphan data files", + ImmutableList.of(), + sql( + "CALL %s.system.remove_orphan_files(table => '%s', older_than => CAST(%dL AS TIMESTAMP), location => '%s')", + catalogName, tableName, System.currentTimeMillis() + 5000, dataLocation)); + } + + public static class CustomFileIO implements FileIO { + + private final FileIO delegate = new HadoopFileIO(new Configuration()); + + public CustomFileIO() {} + + protected FileIO delegate() { + return delegate; + } + + @Override + public InputFile newInputFile(String path) { + return delegate.newInputFile(path); + } + + @Override + public OutputFile newOutputFile(String path) { + return delegate.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + delegate.deleteFile(path); + } + + @Override + public Map properties() { + return delegate.properties(); + } + + @Override + public void initialize(Map properties) { + delegate.initialize(properties); + } + + @Override + public void close() { + delegate.close(); + } + } + + public static class CustomBulkFileIO extends CustomFileIO implements SupportsBulkOperations { + + public CustomBulkFileIO() {} + + @Override + public void deleteFile(String path) { + throw new UnsupportedOperationException("Only bulk deletes are supported"); + } + + @Override + public void deleteFiles(Iterable paths) throws BulkDeletionFailureException { + for (String path : paths) { + delegate().deleteFile(path); + } + } + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/source/PlanUtils.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/source/PlanUtils.java new file mode 100644 index 000000000000..18fc63fc619e --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/source/PlanUtils.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Collection; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; +import org.apache.spark.sql.execution.datasources.v2.DataSourceV2ScanRelation; +import scala.PartialFunction; +import scala.collection.JavaConverters; +import scala.collection.Seq; + +public class PlanUtils { + private PlanUtils() {} + + public static List collectPushDownFilters( + LogicalPlan logicalPlan) { + return JavaConverters.asJavaCollection(logicalPlan.collectLeaves()).stream() + .flatMap( + plan -> { + if (!(plan instanceof DataSourceV2ScanRelation)) { + return Stream.empty(); + } + + DataSourceV2ScanRelation scanRelation = (DataSourceV2ScanRelation) plan; + if (!(scanRelation.scan() instanceof SparkBatchQueryScan)) { + return Stream.empty(); + } + + SparkBatchQueryScan batchQueryScan = (SparkBatchQueryScan) scanRelation.scan(); + return batchQueryScan.filters().stream(); + }) + .collect(Collectors.toList()); + } + + public static List collectSparkExpressions( + LogicalPlan logicalPlan, Predicate predicate) { + Seq> list = + logicalPlan.collect( + new PartialFunction>() { + + @Override + public List apply(LogicalPlan plan) { + return JavaConverters.asJavaCollection(plan.expressions()).stream() + .flatMap(expr -> collectSparkExpressions(expr, predicate).stream()) + .collect(Collectors.toList()); + } + + @Override + public boolean isDefinedAt(LogicalPlan plan) { + return true; + } + }); + + return JavaConverters.asJavaCollection(list).stream() + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } + + private static List collectSparkExpressions( + Expression expression, Predicate predicate) { + Seq list = + expression.collect( + new PartialFunction() { + @Override + public Expression apply(Expression expr) { + return expr; + } + + @Override + public boolean isDefinedAt(Expression expr) { + return predicate.test(expr); + } + }); + + return Lists.newArrayList(JavaConverters.asJavaCollection(list)); + } +} diff --git a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java new file mode 100644 index 000000000000..0152c8e0e6f0 --- /dev/null +++ b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/source/TestRemoteScanPlanning.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.rest.RESTCatalog; +import org.apache.iceberg.rest.RESTCatalogProperties; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.sql.TestSelect; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRemoteScanPlanning extends TestSelect { + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, binaryTableName = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build(), + SparkCatalogConfig.REST.catalogName() + ".default.binary_table" + } + }; + } + + @TestTemplate + public void fileIOIsPropagated() { + RESTCatalog catalog = new RESTCatalog(); + catalog.setConf(new Configuration()); + catalog.initialize( + "test", + ImmutableMap.builder() + .putAll(restCatalog.properties()) + .put( + RESTCatalogProperties.SCAN_PLANNING_MODE, + RESTCatalogProperties.ScanPlanningMode.SERVER.modeName()) + .build()); + Table table = catalog.loadTable(tableIdent); + + SparkScanBuilder builder = new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + verifyFileIOHasPlanId(builder.build().toBatch(), table); + verifyFileIOHasPlanId(builder.buildCopyOnWriteScan().toBatch(), table); + } + + private void verifyFileIOHasPlanId(Batch batch, Table table) { + FileIO fileIOForScan = + (FileIO) + assertThat(batch) + .extracting("fileIO") + .isInstanceOf(Supplier.class) + .asInstanceOf(InstanceOfAssertFactories.type(Supplier.class)) + .actual() + .get(); + assertThat(fileIOForScan.properties()).containsKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + assertThat(table.io().properties()).doesNotContainKey(RESTCatalogProperties.REST_SCAN_PLAN_ID); + } +} diff --git a/spark/v4.1/spark-runtime/LICENSE b/spark/v4.1/spark-runtime/LICENSE new file mode 100644 index 000000000000..50c91faf8edb --- /dev/null +++ b/spark/v4.1/spark-runtime/LICENSE @@ -0,0 +1,1088 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. + +-------------------------------------------------------------------------------- + +This product bundles Apache Avro. + +Copyright: Copyright 2010-2019 The Apache Software Foundation +Project URL: https://avro.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Jackson JSON processor. + +Copyright: 2007-2019 Tatu Saloranta and other contributors +Project URL: https://github.com/FasterXML/jackson +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles FastDoubleParser (via Jackson JSON Processor). + +Copyright: 2023 Werner Randelshofer, Switzerland +Project URL: https://github.com/wrandelshofer/FastDoubleParser +License: MIT + +| Copyright (c) 2023 Werner Randelshofer, Switzerland +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles fast_float (bundled by FastDoubleParser). + +Copyright: 2021 The fast_float authors +Project URL: https://github.com/fastfloat/fast_float +License: MIT + +| Copyright (c) 2021 The fast_float authors +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in all +| copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +| SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles bigint (bundled by FastDoubleParser). + +Copyright: 2022 Tim Buktu +Project URL: https://github.com/tbuktu/bigint +License: BSD 2-Clause + +| Copyright 2022 Tim Buktu +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions +| are met: +| +| 1. Redistributions of source code must retain the above copyright notice, this +| list of conditions and the following disclaimer. +| +| 2. Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +| ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +| WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +| DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +| FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +| DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +| SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +| CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +| OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Parquet. + +Copyright: 2014-2024 The Apache Software Foundation +Project URL: https://parquet.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Apache Thrift (bundled by Parquet). + +Copyright: 2006-2017 The Apache Software Foundation. +Project URL: https://thrift.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product includes code from Daniel Lemire's JavaFastPFOR project (bundled by Parquet). + +Copyright: 2013 Daniel Lemire +Project URL: https://github.com/lemire/JavaFastPFOR +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles fastutil (bundled by Parquet). + +Copyright: 2002-2014 Sebastiano Vigna +Project URL: http://fastutil.di.unimi.it/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Zero-Allocation Hashing (bundled by Parquet). + +Project URL: https://github.com/OpenHFT/Zero-Allocation-Hashing +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Apache ORC. + +Copyright: 2013 and onwards The Apache Software Foundation. +Project URL: https://orc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Apache Hive's Storage API (bundled by ORC). + +Copyright: 2008-2020 The Apache Software Foundation +Project URL: https://hive.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Google protobuf (bundled by ORC). + +Copyright: 2008 Google Inc. +Project URL: https://developers.google.com/protocol-buffers +License: BSD 3-Clause + +| Copyright 2008 Google Inc. All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +| +| Code generated by the Protocol Buffer compiler is owned by the owner +| of the input file used when generating it. This code is not +| standalone and requires a support library to be linked with it. This +| support library is itself covered by the above license. + +-------------------------------------------------------------------------------- + +This product bundles Airlift Aircompressor. + +Copyright: 2011-2019 Aircompressor authors. +Project URL: https://github.com/airlift/aircompressor +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product includes code from Cloudera Kite. + +Copyright: 2013-2017 Cloudera Inc. +Project URL: https://kitesdk.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product includes code from Presto. + +Copyright: 2016 Facebook and contributors +Project URL: https://prestodb.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Google Guava. + +Copyright: 2006-2019 The Guava Authors +Project URL: https://github.com/google/guava +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles the Mozilla Public Suffix List, distributed by Apache HttpComponents. + +Project URL: https://publicsuffix.org/ +License: Mozilla Public License, Version 2.0 - https://mozilla.org/MPL/2.0/ + +| Mozilla Public License Version 2.0 +| ================================== +| +| 1. Definitions +| -------------- +| +| 1.1. "Contributor" +| means each individual or legal entity that creates, contributes to +| the creation of, or owns Covered Software. +| +| 1.2. "Contributor Version" +| means the combination of the Contributions of others (if any) used +| by a Contributor and that particular Contributor's Contribution. +| +| 1.3. "Contribution" +| means Covered Software of a particular Contributor. +| +| 1.4. "Covered Software" +| means Source Code Form to which the initial Contributor has attached +| the notice in Exhibit A, the Executable Form of such Source Code +| Form, and Modifications of such Source Code Form, in each case +| including portions thereof. +| +| 1.5. "Incompatible With Secondary Licenses" +| means +| +| (a) that the initial Contributor has attached the notice described +| in Exhibit B to the Covered Software; or +| +| (b) that the Covered Software was made available under the terms of +| version 1.1 or earlier of the License, but not also under the +| terms of a Secondary License. +| +| 1.6. "Executable Form" +| means any form of the work other than Source Code Form. +| +| 1.7. "Larger Work" +| means a work that combines Covered Software with other material, in +| a separate file or files, that is not Covered Software. +| +| 1.8. "License" +| means this document. +| +| 1.9. "Licensable" +| means having the right to grant, to the maximum extent possible, +| whether at the time of the initial grant or subsequently, any and +| all of the rights conveyed by this License. +| +| 1.10. "Modifications" +| means any of the following: +| +| (a) any file in Source Code Form that results from an addition to, +| deletion from, or modification of the contents of Covered +| Software; or +| +| (b) any new file in Source Code Form that contains any Covered +| Software. +| +| 1.11. "Patent Claims" of a Contributor +| means any patent claim(s), including without limitation, method, +| process, and apparatus claims, in any patent Licensable by such +| Contributor that would be infringed, but for the grant of the +| License, by the making, using, selling, offering for sale, having +| made, import, or transfer of either its Contributions or its +| Contributor Version. +| +| 1.12. "Secondary License" +| means either the GNU General Public License, Version 2.0, the GNU +| Lesser General Public License, Version 2.1, the GNU Affero General +| Public License, Version 3.0, or any later versions of those +| licenses. +| +| 1.13. "Source Code Form" +| means the form of the work preferred for making modifications. +| +| 1.14. "You" (or "Your") +| means an individual or a legal entity exercising rights under this +| License. For legal entities, "You" includes any entity that +| controls, is controlled by, or is under common control with You. For +| purposes of this definition, "control" means (a) the power, direct +| or indirect, to cause the direction or management of such entity, +| whether by contract or otherwise, or (b) ownership of more than +| fifty percent (50%) of the outstanding shares or beneficial +| ownership of such entity. +| +| 2. License Grants and Conditions +| -------------------------------- +| +| 2.1. Grants +| +| Each Contributor hereby grants You a world-wide, royalty-free, +| non-exclusive license: +| +| (a) under intellectual property rights (other than patent or trademark) +| Licensable by such Contributor to use, reproduce, make available, +| modify, display, perform, distribute, and otherwise exploit its +| Contributions, either on an unmodified basis, with Modifications, or +| as part of a Larger Work; and +| +| (b) under Patent Claims of such Contributor to make, use, sell, offer +| for sale, have made, import, and otherwise transfer either its +| Contributions or its Contributor Version. +| +| 2.2. Effective Date +| +| The licenses granted in Section 2.1 with respect to any Contribution +| become effective for each Contribution on the date the Contributor first +| distributes such Contribution. +| +| 2.3. Limitations on Grant Scope +| +| The licenses granted in this Section 2 are the only rights granted under +| this License. No additional rights or licenses will be implied from the +| distribution or licensing of Covered Software under this License. +| Notwithstanding Section 2.1(b) above, no patent license is granted by a +| Contributor: +| +| (a) for any code that a Contributor has removed from Covered Software; +| or +| +| (b) for infringements caused by: (i) Your and any other third party's +| modifications of Covered Software, or (ii) the combination of its +| Contributions with other software (except as part of its Contributor +| Version); or +| +| (c) under Patent Claims infringed by Covered Software in the absence of +| its Contributions. +| +| This License does not grant any rights in the trademarks, service marks, +| or logos of any Contributor (except as may be necessary to comply with +| the notice requirements in Section 3.4). +| +| 2.4. Subsequent Licenses +| +| No Contributor makes additional grants as a result of Your choice to +| distribute the Covered Software under a subsequent version of this +| License (see Section 10.2) or under the terms of a Secondary License (if +| permitted under the terms of Section 3.3). +| +| 2.5. Representation +| +| Each Contributor represents that the Contributor believes its +| Contributions are its original creation(s) or it has sufficient rights +| to grant the rights to its Contributions conveyed by this License. +| +| 2.6. Fair Use +| +| This License is not intended to limit any rights You have under +| applicable copyright doctrines of fair use, fair dealing, or other +| equivalents. +| +| 2.7. Conditions +| +| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +| in Section 2.1. +| +| 3. Responsibilities +| ------------------- +| +| 3.1. Distribution of Source Form +| +| All distribution of Covered Software in Source Code Form, including any +| Modifications that You create or to which You contribute, must be under +| the terms of this License. You must inform recipients that the Source +| Code Form of the Covered Software is governed by the terms of this +| License, and how they can obtain a copy of this License. You may not +| attempt to alter or restrict the recipients' rights in the Source Code +| Form. +| +| 3.2. Distribution of Executable Form +| +| If You distribute Covered Software in Executable Form then: +| +| (a) such Covered Software must also be made available in Source Code +| Form, as described in Section 3.1, and You must inform recipients of +| the Executable Form how they can obtain a copy of such Source Code +| Form by reasonable means in a timely manner, at a charge no more +| than the cost of distribution to the recipient; and +| +| (b) You may distribute such Executable Form under the terms of this +| License, or sublicense it under different terms, provided that the +| license for the Executable Form does not attempt to limit or alter +| the recipients' rights in the Source Code Form under this License. +| +| 3.3. Distribution of a Larger Work +| +| You may create and distribute a Larger Work under terms of Your choice, +| provided that You also comply with the requirements of this License for +| the Covered Software. If the Larger Work is a combination of Covered +| Software with a work governed by one or more Secondary Licenses, and the +| Covered Software is not Incompatible With Secondary Licenses, this +| License permits You to additionally distribute such Covered Software +| under the terms of such Secondary License(s), so that the recipient of +| the Larger Work may, at their option, further distribute the Covered +| Software under the terms of either this License or such Secondary +| License(s). +| +| 3.4. Notices +| +| You may not remove or alter the substance of any license notices +| (including copyright notices, patent notices, disclaimers of warranty, +| or limitations of liability) contained within the Source Code Form of +| the Covered Software, except that You may alter any license notices to +| the extent required to remedy known factual inaccuracies. +| +| 3.5. Application of Additional Terms +| +| You may choose to offer, and to charge a fee for, warranty, support, +| indemnity or liability obligations to one or more recipients of Covered +| Software. However, You may do so only on Your own behalf, and not on +| behalf of any Contributor. You must make it absolutely clear that any +| such warranty, support, indemnity, or liability obligation is offered by +| You alone, and You hereby agree to indemnify every Contributor for any +| liability incurred by such Contributor as a result of warranty, support, +| indemnity or liability terms You offer. You may include additional +| disclaimers of warranty and limitations of liability specific to any +| jurisdiction. +| +| 4. Inability to Comply Due to Statute or Regulation +| --------------------------------------------------- +| +| If it is impossible for You to comply with any of the terms of this +| License with respect to some or all of the Covered Software due to +| statute, judicial order, or regulation then You must: (a) comply with +| the terms of this License to the maximum extent possible; and (b) +| describe the limitations and the code they affect. Such description must +| be placed in a text file included with all distributions of the Covered +| Software under this License. Except to the extent prohibited by statute +| or regulation, such description must be sufficiently detailed for a +| recipient of ordinary skill to be able to understand it. +| +| 5. Termination +| -------------- +| +| 5.1. The rights granted under this License will terminate automatically +| if You fail to comply with any of its terms. However, if You become +| compliant, then the rights granted under this License from a particular +| Contributor are reinstated (a) provisionally, unless and until such +| Contributor explicitly and finally terminates Your grants, and (b) on an +| ongoing basis, if such Contributor fails to notify You of the +| non-compliance by some reasonable means prior to 60 days after You have +| come back into compliance. Moreover, Your grants from a particular +| Contributor are reinstated on an ongoing basis if such Contributor +| notifies You of the non-compliance by some reasonable means, this is the +| first time You have received notice of non-compliance with this License +| from such Contributor, and You become compliant prior to 30 days after +| Your receipt of the notice. +| +| 5.2. If You initiate litigation against any entity by asserting a patent +| infringement claim (excluding declaratory judgment actions, +| counter-claims, and cross-claims) alleging that a Contributor Version +| directly or indirectly infringes any patent, then the rights granted to +| You by any and all Contributors for the Covered Software under Section +| 2.1 of this License shall terminate. +| +| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all +| end user license agreements (excluding distributors and resellers) which +| have been validly granted by You or Your distributors under this License +| prior to termination shall survive termination. +| +| ************************************************************************ +| * * +| * 6. Disclaimer of Warranty * +| * ------------------------- * +| * * +| * Covered Software is provided under this License on an "as is" * +| * basis, without warranty of any kind, either expressed, implied, or * +| * statutory, including, without limitation, warranties that the * +| * Covered Software is free of defects, merchantable, fit for a * +| * particular purpose or non-infringing. The entire risk as to the * +| * quality and performance of the Covered Software is with You. * +| * Should any Covered Software prove defective in any respect, You * +| * (not any Contributor) assume the cost of any necessary servicing, * +| * repair, or correction. This disclaimer of warranty constitutes an * +| * essential part of this License. No use of any Covered Software is * +| * authorized under this License except under this disclaimer. * +| * * +| ************************************************************************ +| +| ************************************************************************ +| * * +| * 7. Limitation of Liability * +| * -------------------------- * +| * * +| * Under no circumstances and under no legal theory, whether tort * +| * (including negligence), contract, or otherwise, shall any * +| * Contributor, or anyone who distributes Covered Software as * +| * permitted above, be liable to You for any direct, indirect, * +| * special, incidental, or consequential damages of any character * +| * including, without limitation, damages for lost profits, loss of * +| * goodwill, work stoppage, computer failure or malfunction, or any * +| * and all other commercial damages or losses, even if such party * +| * shall have been informed of the possibility of such damages. This * +| * limitation of liability shall not apply to liability for death or * +| * personal injury resulting from such party's negligence to the * +| * extent applicable law prohibits such limitation. Some * +| * jurisdictions do not allow the exclusion or limitation of * +| * incidental or consequential damages, so this exclusion and * +| * limitation may not apply to You. * +| * * +| ************************************************************************ +| +| 8. Litigation +| ------------- +| +| Any litigation relating to this License may be brought only in the +| courts of a jurisdiction where the defendant maintains its principal +| place of business and such litigation shall be governed by laws of that +| jurisdiction, without reference to its conflict-of-law provisions. +| Nothing in this Section shall prevent a party's ability to bring +| cross-claims or counter-claims. +| +| 9. Miscellaneous +| ---------------- +| +| This License represents the complete agreement concerning the subject +| matter hereof. If any provision of this License is held to be +| unenforceable, such provision shall be reformed only to the extent +| necessary to make it enforceable. Any law or regulation which provides +| that the language of a contract shall be construed against the drafter +| shall not be used to construe this License against a Contributor. +| +| 10. Versions of the License +| --------------------------- +| +| 10.1. New Versions +| +| Mozilla Foundation is the license steward. Except as provided in Section +| 10.3, no one other than the license steward has the right to modify or +| publish new versions of this License. Each version will be given a +| distinguishing version number. +| +| 10.2. Effect of New Versions +| +| You may distribute the Covered Software under the terms of the version +| of the License under which You originally received the Covered Software, +| or under the terms of any subsequent version published by the license +| steward. +| +| 10.3. Modified Versions +| +| If you create software not governed by this License, and you want to +| create a new license for such software, you may create and use a +| modified version of this License if you rename the license and remove +| any references to the name of the license steward (except to note that +| such modified license differs from this License). +| +| 10.4. Distributing Source Code Form that is Incompatible With Secondary +| Licenses +| +| If You choose to distribute Source Code Form that is Incompatible With +| Secondary Licenses under the terms of this version of the License, the +| notice described in Exhibit B of this License must be attached. +| +| Exhibit A - Source Code Form License Notice +| ------------------------------------------- +| +| This Source Code Form is subject to the terms of the Mozilla Public +| License, v. 2.0. If a copy of the MPL was not distributed with this +| file, You can obtain one at http://mozilla.org/MPL/2.0/. +| +| If it is not possible or desirable to put the notice in a particular +| file, then You may include the notice in a location (such as a LICENSE +| file in a relevant directory) where a recipient would be likely to look +| for such a notice. +| +| You may add additional accurate notices of copyright ownership. +| +| Exhibit B - "Incompatible With Secondary Licenses" Notice +| --------------------------------------------------------- +| +| This Source Code Form is "Incompatible With Secondary Licenses", as +| defined by the Mozilla Public License, v. 2.0. + +-------------------------------------------------------------------------------- + +This product bundles Google Error Prone Annotations. + +Copyright: Copyright 2011-2019 The Error Prone Authors +Project URL: https://github.com/google/error-prone +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles checkerframework checker-qual. + +Copyright: 2004-2019 the Checker Framework developers +Project URL: https://github.com/typetools/checker-framework +License: MIT license + +| The annotations are licensed under the MIT License. (The text of this +| license appears below.) More specifically, all the parts of the Checker +| Framework that you might want to include with your own program use the +| MIT License. This is the checker-qual.jar file and all the files that +| appear in it: every file in a qual/ directory, plus utility files such +| as NullnessUtil.java, RegexUtil.java, SignednessUtil.java, etc. +| In addition, the cleanroom implementations of third-party annotations, +| which the Checker Framework recognizes as aliases for its own +| annotations, are licensed under the MIT License. +| +| Permission is hereby granted, free of charge, to any person obtaining a copy +| of this software and associated documentation files (the "Software"), to deal +| in the Software without restriction, including without limitation the rights +| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +| copies of the Software, and to permit persons to whom the Software is +| furnished to do so, subject to the following conditions: +| +| The above copyright notice and this permission notice shall be included in +| all copies or substantial portions of the Software. +| +| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +| THE SOFTWARE. + +-------------------------------------------------------------------------------- + +This product bundles Caffeine by Ben Manes. + +Copyright: 2014-2019 Ben Manes and contributors +Project URL: https://github.com/ben-manes/caffeine +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Apache Arrow. + +Copyright: 2016-2019 The Apache Software Foundation. +Project URL: https://arrow.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Netty. + +Copyright: 2014-2020 The Netty Project +Project URL: https://netty.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JCTools (via Netty). + +Project URL: https://github.com/JCTools/JCTools +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Google FlatBuffers. + +Copyright: 2013-2020 Google Inc. +Project URL: https://google.github.io/flatbuffers/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles ThreeTen Extra. + +Copyright: 2007-present, Stephen Colebourne & Michael Nascimento Santos. +Project URL: https://www.threeten.org/threeten-extra/ +License: BSD 3-Clause + +| All rights reserved. +| +| * Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| +| * Neither the name of JSR-310 nor the names of its contributors +| may be used to endorse or promote products derived from this software +| without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +| CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +| EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +| PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +| PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +| LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +| NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +| SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Project Nessie. + +Copyright: 2015-2025 Dremio Corporation. +Project URL: https://projectnessie.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles and includes code from Apache Spark. + +* vectorized reading of definition levels in BaseVectorizedParquetValuesReader.java +* portions of the extensions parser +* casting logic in AssignmentAlignmentSupport +* implementation of SetAccumulator. + +Copyright: 2011-2018 The Apache Software Foundation +Project URL: https://spark.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product includes code from Delta Lake. + +* AssignmentAlignmentSupport is an independent development but UpdateExpressionsSupport in Delta was used as a reference. + +Copyright: 2020 The Delta Lake Project Authors. +Project URL: https://delta.io/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product includes code from Apache Commons. + +* Core ArrayUtil. + +Copyright: 2020 The Apache Software Foundation +Project URL: https://commons.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles and includes code from Apache HttpComponents (core/client). + +* retry and error handling logic in ExponentialHttpRequestRetryStrategy.java + +Copyright: 1999-2022 The Apache Software Foundation +Project URL: https://hc.apache.org/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles failsafe. + +Copyright: Jonathan Halterman and friends +Project URL: https://failsafe.dev/ +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Eclipse MicroProfile OpenAPI. + +Project URL: https://github.com/microprofile/microprofile-open-api +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles Eclipse Collections. + +Project URL: https://github.com/eclipse-collections/eclipse-collections +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Apache Datasketches. + +Project URL: https://datasketches.apache.org +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 + +-------------------------------------------------------------------------------- + +This product bundles JTS Topology Suite. + +Project URL: https://github.com/locationtech/jts +License: Eclipse Distribution License v. 1.0 - https://www.eclipse.org/org/documents/edl-v10.php + +| Eclipse Distribution License - v 1.0 +| +| Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. +| +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are met: +| +| * Redistributions of source code must retain the above copyright notice, +| this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above copyright notice, +| this list of conditions and the following disclaimer in the documentation +| and/or other materials provided with the distribution. +| * Neither the name of the Eclipse Foundation, Inc. nor the names of its +| contributors may be used to endorse or promote products derived from this +| software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +| AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +| IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +| ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +| LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +| CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +| SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +| INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +| CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +| ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +| POSSIBILITY OF SUCH DAMAGE. +-------------------------------------------------------------------------------- + +This product bundles RoaringBitmap. + +Copyright: (c) 2013-... the RoaringBitmap authors +Project URL: https://github.com/RoaringBitmap/RoaringBitmap +License: Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 diff --git a/spark/v4.1/spark-runtime/NOTICE b/spark/v4.1/spark-runtime/NOTICE new file mode 100644 index 000000000000..551ef59f2010 --- /dev/null +++ b/spark/v4.1/spark-runtime/NOTICE @@ -0,0 +1,393 @@ + +Apache Iceberg +Copyright 2017-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +-------------------------------------------------------------------------------- + +This product includes code from Kite, developed at Cloudera, Inc. with +the following in its NOTICE file: +| Copyright 2013 Cloudera Inc. +| +| Licensed 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. + +-------------------------------------------------------------------------------- + +This product bundles Airlift Aircompressor with the following in its NOTICE file: +| Snappy Copyright Notices +| ========================= +| +| * Copyright 2011 Dain Sundstrom +| * Copyright 2011, Google Inc. +| +| +| Snappy License +| =============== +| Copyright 2011, Google Inc. +| All rights reserved. +| +| Redistribution and use in source and binary forms, with or without +| modification, are permitted provided that the following conditions are +| met: +| +| * Redistributions of source code must retain the above copyright +| notice, this list of conditions and the following disclaimer. +| * Redistributions in binary form must reproduce the above +| copyright notice, this list of conditions and the following disclaimer +| in the documentation and/or other materials provided with the +| distribution. +| * Neither the name of Google Inc. nor the names of its +| contributors may be used to endorse or promote products derived from +| this software without specific prior written permission. +| +| THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +| "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +| A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +| OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +| SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +| LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +| DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +| THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +| (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +| OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +This product bundles Netty with the following in its NOTICE file: +| The Netty Project +| ================= +| +| Please visit the Netty web site for more information: +| +| * https://netty.io/ +| +| Copyright 2014 The Netty Project +| +| The Netty Project 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. +| +| Also, please refer to each LICENSE..txt file, which is located in +| the 'license' directory of the distribution file, for the license terms of the +| components that this product depends on. +| +| ------------------------------------------------------------------------------- +| This product contains the extensions to Java Collections Framework which has +| been derived from the works by JSR-166 EG, Doug Lea, and Jason T. Greene: +| +| * LICENSE: +| * license/LICENSE.jsr166y.txt (Public Domain) +| * HOMEPAGE: +| * http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/ +| * http://viewvc.jboss.org/cgi-bin/viewvc.cgi/jbosscache/experimental/jsr166/ +| +| This product contains a modified version of Robert Harder's Public Domain +| Base64 Encoder and Decoder, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.base64.txt (Public Domain) +| * HOMEPAGE: +| * http://iharder.sourceforge.net/current/java/base64/ +| +| This product contains a modified portion of 'Webbit', an event based +| WebSocket and HTTP server, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.webbit.txt (BSD License) +| * HOMEPAGE: +| * https://github.com/joewalnes/webbit +| +| This product contains a modified portion of 'SLF4J', a simple logging +| facade for Java, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.slf4j.txt (MIT License) +| * HOMEPAGE: +| * http://www.slf4j.org/ +| +| This product contains a modified portion of 'Apache Harmony', an open source +| Java SE, which can be obtained at: +| +| * NOTICE: +| * license/NOTICE.harmony.txt +| * LICENSE: +| * license/LICENSE.harmony.txt (Apache License 2.0) +| * HOMEPAGE: +| * http://archive.apache.org/dist/harmony/ +| +| This product contains a modified portion of 'jbzip2', a Java bzip2 compression +| and decompression library written by Matthew J. Francis. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.jbzip2.txt (MIT License) +| * HOMEPAGE: +| * https://code.google.com/p/jbzip2/ +| +| This product contains a modified portion of 'libdivsufsort', a C API library to construct +| the suffix array and the Burrows-Wheeler transformed string for any input string of +| a constant-size alphabet written by Yuta Mori. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.libdivsufsort.txt (MIT License) +| * HOMEPAGE: +| * https://github.com/y-256/libdivsufsort +| +| This product contains a modified portion of Nitsan Wakart's 'JCTools', Java Concurrency Tools for the JVM, +| which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.jctools.txt (ASL2 License) +| * HOMEPAGE: +| * https://github.com/JCTools/JCTools +| +| This product optionally depends on 'JZlib', a re-implementation of zlib in +| pure Java, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.jzlib.txt (BSD style License) +| * HOMEPAGE: +| * http://www.jcraft.com/jzlib/ +| +| This product optionally depends on 'Compress-LZF', a Java library for encoding and +| decoding data in LZF format, written by Tatu Saloranta. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.compress-lzf.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://github.com/ning/compress +| +| This product optionally depends on 'lz4', a LZ4 Java compression +| and decompression library written by Adrien Grand. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.lz4.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://github.com/jpountz/lz4-java +| +| This product optionally depends on 'lzma-java', a LZMA Java compression +| and decompression library, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.lzma-java.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://github.com/jponge/lzma-java +| +| This product contains a modified portion of 'jfastlz', a Java port of FastLZ compression +| and decompression library written by William Kinney. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.jfastlz.txt (MIT License) +| * HOMEPAGE: +| * https://code.google.com/p/jfastlz/ +| +| This product contains a modified portion of and optionally depends on 'Protocol Buffers', Google's data +| interchange format, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.protobuf.txt (New BSD License) +| * HOMEPAGE: +| * https://github.com/google/protobuf +| +| This product optionally depends on 'Bouncy Castle Crypto APIs' to generate +| a temporary self-signed X.509 certificate when the JVM does not provide the +| equivalent functionality. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.bouncycastle.txt (MIT License) +| * HOMEPAGE: +| * http://www.bouncycastle.org/ +| +| This product optionally depends on 'Snappy', a compression library produced +| by Google Inc, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.snappy.txt (New BSD License) +| * HOMEPAGE: +| * https://github.com/google/snappy +| +| This product optionally depends on 'JBoss Marshalling', an alternative Java +| serialization API, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.jboss-marshalling.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://github.com/jboss-remoting/jboss-marshalling +| +| This product optionally depends on 'Caliper', Google's micro- +| benchmarking framework, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.caliper.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://github.com/google/caliper +| +| This product optionally depends on 'Apache Commons Logging', a logging +| framework, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.commons-logging.txt (Apache License 2.0) +| * HOMEPAGE: +| * http://commons.apache.org/logging/ +| +| This product optionally depends on 'Apache Log4J', a logging framework, which +| can be obtained at: +| +| * LICENSE: +| * license/LICENSE.log4j.txt (Apache License 2.0) +| * HOMEPAGE: +| * http://logging.apache.org/log4j/ +| +| This product optionally depends on 'Aalto XML', an ultra-high performance +| non-blocking XML processor, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.aalto-xml.txt (Apache License 2.0) +| * HOMEPAGE: +| * http://wiki.fasterxml.com/AaltoHome +| +| This product contains a modified version of 'HPACK', a Java implementation of +| the HTTP/2 HPACK algorithm written by Twitter. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.hpack.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://github.com/twitter/hpack +| +| This product contains a modified version of 'HPACK', a Java implementation of +| the HTTP/2 HPACK algorithm written by Cory Benfield. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.hyper-hpack.txt (MIT License) +| * HOMEPAGE: +| * https://github.com/python-hyper/hpack/ +| +| This product contains a modified version of 'HPACK', a Java implementation of +| the HTTP/2 HPACK algorithm written by Tatsuhiro Tsujikawa. It can be obtained at: +| +| * LICENSE: +| * license/LICENSE.nghttp2-hpack.txt (MIT License) +| * HOMEPAGE: +| * https://github.com/nghttp2/nghttp2/ +| +| This product contains a modified portion of 'Apache Commons Lang', a Java library +| provides utilities for the java.lang API, which can be obtained at: +| +| * LICENSE: +| * license/LICENSE.commons-lang.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://commons.apache.org/proper/commons-lang/ +| +| +| This product contains the Maven wrapper scripts from 'Maven Wrapper', that provides an easy way to ensure a user has everything necessary to run the Maven build. +| +| * LICENSE: +| * license/LICENSE.mvn-wrapper.txt (Apache License 2.0) +| * HOMEPAGE: +| * https://github.com/takari/maven-wrapper +| +| This product contains the dnsinfo.h header file, that provides a way to retrieve the system DNS configuration on MacOS. +| This private header is also used by Apple's open source +| mDNSResponder (https://opensource.apple.com/tarballs/mDNSResponder/). +| +| * LICENSE: +| * license/LICENSE.dnsinfo.txt (Apache License 2.0) +| * HOMEPAGE: +| * http://www.opensource.apple.com/source/configd/configd-453.19/dnsinfo/dnsinfo.h + +-------------------------------------------------------------------------------- + +This product bundles Jackson JSON Processor with the following in its NOTICE file: +| # Jackson JSON processor +| +| Jackson is a high-performance, Free/Open Source JSON processing library. +| It was originally written by Tatu Saloranta (tatu.saloranta@iki.fi), and has +| been in development since 2007. +| It is currently developed by a community of developers. +| +| ## Copyright +| +| Copyright 2007-, Tatu Saloranta (tatu.saloranta@iki.fi) +| +| ## Licensing +| +| Jackson 2.x core and extension components are licensed under Apache License 2.0 +| To find the details that apply to this artifact see the accompanying LICENSE file. +| +| ## Credits +| +| A list of contributors may be found from CREDITS(-2.x) file, which is included +| in some artifacts (usually source distributions); but is always available +| from the source code management (SCM) system project uses. +| +| ## FastDoubleParser +| +| jackson-core bundles a shaded copy of FastDoubleParser . +| That code is available under an MIT license +| under the following copyright. +| +| Copyright © 2023 Werner Randelshofer, Switzerland. MIT License. +| +| See FastDoubleParser-NOTICE for details of other source code included in FastDoubleParser +| and the licenses and copyrights that apply to that code. + +-------------------------------------------------------------------------------- + +This product bundles Project Nessie with the following in its NOTICE file: +| Nessie +| Copyright 2015-2025 Dremio Corporation +| +| --------------------------------------- +| This project includes code from Apache Polaris (incubating), with the following in its NOTICE file: +| +| | Apache Polaris (incubating) +| | Copyright 2024 The Apache Software Foundation +| | +| | This product includes software developed at +| | The Apache Software Foundation (http://www.apache.org/). +| | +| | The initial code for the Polaris project was donated +| | to the ASF by Snowflake Inc. (https://www.snowflake.com/) copyright 2024. + +-------------------------------------------------------------------------------- + +This product bundles Eclipse MicroProfile OpenAPI with the following in its NOTICE file: +| ========================================================================= +| == NOTICE file corresponding to section 4(d) of the Apache License, == +| == Version 2.0, in this case for MicroProfile OpenAPI == +| ========================================================================= +| +| The majority of this software were originally based on the following: +| * Swagger Core +| https://github.com/swagger-api/swagger-core +| under Apache License, v2.0 +| +| +| SPDXVersion: SPDX-2.1 +| PackageName: Eclipse MicroProfile +| PackageHomePage: http://www.eclipse.org/microprofile +| PackageLicenseDeclared: Apache-2.0 +| +| PackageCopyrightText: +| Arthur De Magalhaes arthurdm@ca.ibm.com +| diff --git a/spark/v4.1/spark-runtime/runtime-deps.txt b/spark/v4.1/spark-runtime/runtime-deps.txt new file mode 100644 index 000000000000..dc3fbeb7e9d5 --- /dev/null +++ b/spark/v4.1/spark-runtime/runtime-deps.txt @@ -0,0 +1,40 @@ +com.fasterxml.jackson.core:jackson-annotations:2.21 +com.fasterxml.jackson.core:jackson-core:2.15.2 +com.fasterxml.jackson.core:jackson-databind:2.15.2 +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.3 +com.github.ben-manes.caffeine:caffeine:2.9.3 +com.google.errorprone:error_prone_annotations:2.10.0 +com.google.flatbuffers:flatbuffers-java:23.5.26 +dev.failsafe:failsafe:3.3.2 +io.airlift:aircompressor:2.0.3 +io.netty:netty-buffer:4.2.13.Final +io.netty:netty-common:4.2.13.Final +org.apache.arrow:arrow-format:15.0.2 +org.apache.arrow:arrow-memory-core:15.0.2 +org.apache.arrow:arrow-memory-netty:15.0.2 +org.apache.arrow:arrow-vector:15.0.2 +org.apache.avro:avro:1.12.1 +org.apache.datasketches:datasketches-java:6.2.0 +org.apache.datasketches:datasketches-memory:3.0.2 +org.apache.httpcomponents.client5:httpclient5:5.6.1 +org.apache.httpcomponents.core5:httpcore5-h2:5.4 +org.apache.httpcomponents.core5:httpcore5:5.4 +org.apache.orc:orc-core:1.9.8 +org.apache.orc:orc-shims:1.9.8 +org.apache.parquet:parquet-avro:1.17.0 +org.apache.parquet:parquet-column:1.17.0 +org.apache.parquet:parquet-common:1.17.0 +org.apache.parquet:parquet-encoding:1.17.0 +org.apache.parquet:parquet-format-structures:1.17.0 +org.apache.parquet:parquet-hadoop:1.17.0 +org.apache.parquet:parquet-jackson:1.17.0 +org.apache.parquet:parquet-variant:1.17.0 +org.checkerframework:checker-qual:3.19.0 +org.eclipse.collections:eclipse-collections-api:11.1.0 +org.eclipse.collections:eclipse-collections:11.1.0 +org.eclipse.microprofile.openapi:microprofile-openapi-api:4.1.1 +org.locationtech.jts:jts-core:1.20.0 +org.projectnessie.nessie:nessie-client:0.107.5 +org.projectnessie.nessie:nessie-model:0.107.5 +org.roaringbitmap:RoaringBitmap:1.6.14 +org.threeten:threeten-extra:1.7.1 diff --git a/spark/v4.1/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java b/spark/v4.1/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java new file mode 100644 index 000000000000..709f626b08c1 --- /dev/null +++ b/spark/v4.1/spark-runtime/src/integration/java/org/apache/iceberg/spark/TestRoundTrip.java @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.spark.extensions.ExtensionsTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRoundTrip extends ExtensionsTestBase { + @AfterEach + public void dropTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + // Run through our Doc's Getting Started Example + @TestTemplate + public void testGettingStarted() throws IOException { + // Creating a table + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + // Writing + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", tableName); + assertThat(scalarSql("SELECT COUNT(*) FROM %s", tableName)) + .as("Should have inserted 3 rows") + .isEqualTo(3L); + + sql("DROP TABLE IF EXISTS source PURGE"); + sql( + "CREATE TABLE source (id bigint, data string) USING parquet LOCATION '%s'", + Files.createTempDirectory(temp, "junit")); + sql("INSERT INTO source VALUES (10, 'd'), (11, 'ee')"); + + sql("INSERT INTO %s SELECT id, data FROM source WHERE length(data) = 1", tableName); + assertThat(scalarSql("SELECT COUNT(*) FROM %s", tableName)) + .as("Table should now have 4 rows") + .isEqualTo(4L); + + sql("DROP TABLE IF EXISTS updates PURGE"); + sql( + "CREATE TABLE updates (id bigint, data string) USING parquet LOCATION '%s'", + Files.createTempDirectory(temp, "junit")); + sql("INSERT INTO updates VALUES (1, 'x'), (2, 'x'), (4, 'z')"); + + sql( + "MERGE INTO %s t USING (SELECT * FROM updates) u ON t.id = u.id\n" + + "WHEN MATCHED THEN UPDATE SET t.data = u.data\n" + + "WHEN NOT MATCHED THEN INSERT *", + tableName); + assertThat(scalarSql("SELECT COUNT(*) FROM %s", tableName)) + .as("Table should now have 5 rows") + .isEqualTo(5L); + assertThat(scalarSql("SELECT data FROM %s WHERE id = 1", tableName)) + .as("Record 1 should now have data x") + .isEqualTo("x"); + + // Reading + assertThat( + scalarSql( + "SELECT count(1) as count FROM %s WHERE data = 'x' GROUP BY data ", tableName)) + .as("There should be 2 records with data x") + .isEqualTo(2L); + + // Not supported because of Spark limitation + if (!catalogName.equals("spark_catalog")) { + assertThat(scalarSql("SELECT COUNT(*) FROM %s.snapshots", tableName)) + .as("There should be 3 snapshots") + .isEqualTo(3L); + } + } + + // From Spark DDL Docs section + @TestTemplate + public void testAlterTable() { + sql( + "CREATE TABLE %s (category int, id bigint, data string, ts timestamp) USING iceberg", + tableName); + Table table; + // Add examples + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(16, id)", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD truncate(data, 4)", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD years(ts)", tableName); + sql("ALTER TABLE %s ADD PARTITION FIELD bucket(16, category) AS shard", tableName); + table = getTable(); + assertThat(table.spec().fields()).as("Table should have 4 partition fields").hasSize(4); + + // Drop Examples + sql("ALTER TABLE %s DROP PARTITION FIELD bucket(16, id)", tableName); + sql("ALTER TABLE %s DROP PARTITION FIELD truncate(data, 4)", tableName); + sql("ALTER TABLE %s DROP PARTITION FIELD years(ts)", tableName); + sql("ALTER TABLE %s DROP PARTITION FIELD shard", tableName); + + table = getTable(); + assertThat(table.spec().isUnpartitioned()).as("Table should be unpartitioned").isTrue(); + + // Sort order examples + sql("ALTER TABLE %s WRITE ORDERED BY category, id", tableName); + sql("ALTER TABLE %s WRITE ORDERED BY category ASC, id DESC", tableName); + sql("ALTER TABLE %s WRITE ORDERED BY category ASC NULLS LAST, id DESC NULLS FIRST", tableName); + table = getTable(); + assertThat(table.sortOrder().fields()).as("Table should be sorted on 2 fields").hasSize(2); + } + + @TestTemplate + public void testCreateTable() { + sql("DROP TABLE IF EXISTS %s", tableName("first")); + sql("DROP TABLE IF EXISTS %s", tableName("second")); + sql("DROP TABLE IF EXISTS %s", tableName("third")); + + sql( + "CREATE TABLE %s (\n" + + " id bigint COMMENT 'unique id',\n" + + " data string)\n" + + "USING iceberg", + tableName("first")); + getTable("first"); // Table should exist + + sql( + "CREATE TABLE %s (\n" + + " id bigint,\n" + + " data string,\n" + + " category string)\n" + + "USING iceberg\n" + + "PARTITIONED BY (category)", + tableName("second")); + Table second = getTable("second"); + assertThat(second.spec().fields()).as("Should be partitioned on 1 column").hasSize(1); + + sql( + "CREATE TABLE %s (\n" + + " id bigint,\n" + + " data string,\n" + + " category string,\n" + + " ts timestamp)\n" + + "USING iceberg\n" + + "PARTITIONED BY (bucket(16, id), days(ts), category)", + tableName("third")); + Table third = getTable("third"); + assertThat(third.spec().fields()).as("Should be partitioned on 3 columns").hasSize(3); + } + + @TestTemplate + public void showView() { + sql("CREATE VIEW %s AS SELECT 1 AS id", "test"); + assertThat(sql("SHOW VIEWS")).contains(row("default", "test", false)); + sql("DROP VIEW %s", "test"); + } + + private Table getTable(String name) { + return validationCatalog.loadTable(TableIdentifier.of("default", name)); + } + + private Table getTable() { + return validationCatalog.loadTable(tableIdent); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/SparkBenchmarkUtil.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/SparkBenchmarkUtil.java new file mode 100644 index 000000000000..b980c39b5bc3 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/SparkBenchmarkUtil.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.expressions.Attribute; +import org.apache.spark.sql.catalyst.expressions.AttributeReference; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.catalyst.expressions.UnsafeProjection; +import org.apache.spark.sql.catalyst.types.DataTypeUtils; +import org.apache.spark.sql.types.StructType; +import scala.collection.JavaConverters; + +public class SparkBenchmarkUtil { + + private SparkBenchmarkUtil() {} + + public static UnsafeProjection projection(Schema expectedSchema, Schema actualSchema) { + StructType struct = SparkSchemaUtil.convert(actualSchema); + + List refs = + JavaConverters.seqAsJavaListConverter(DataTypeUtils.toAttributes(struct)).asJava(); + List attrs = Lists.newArrayListWithExpectedSize(struct.fields().length); + List exprs = Lists.newArrayListWithExpectedSize(struct.fields().length); + + for (AttributeReference ref : refs) { + attrs.add(ref.toAttribute()); + } + + for (Types.NestedField field : expectedSchema.columns()) { + int indexInIterSchema = struct.fieldIndex(field.name()); + exprs.add(refs.get(indexInIterSchema)); + } + + return UnsafeProjection.create( + JavaConverters.asScalaBufferConverter(exprs).asScala().toSeq(), + JavaConverters.asScalaBufferConverter(attrs).asScala().toSeq()); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java new file mode 100644 index 000000000000..3fd84553f033 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.action; + +import static org.apache.spark.sql.functions.lit; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.sql.Timestamp; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that evaluates the performance of remove orphan files action in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=DeleteOrphanFilesBenchmark + * -PjmhOutputPath=benchmark/delete-orphan-files-benchmark-results.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +@Timeout(time = 1000, timeUnit = TimeUnit.HOURS) +public class DeleteOrphanFilesBenchmark { + + private static final String TABLE_NAME = "delete_orphan_perf"; + private static final int NUM_SNAPSHOTS = 1000; + private static final int NUM_FILES = 1000; + + private SparkSession spark; + private final List validAndOrphanPaths = Lists.newArrayList(); + private Table table; + + @Setup + public void setupBench() { + setupSpark(); + initTable(); + appendData(); + addOrphans(); + } + + @TearDown + public void teardownBench() { + tearDownSpark(); + } + + @Benchmark + @Threads(1) + public void testDeleteOrphanFiles(Blackhole blackhole) { + Dataset validAndOrphanPathsDF = + spark + .createDataset(validAndOrphanPaths, Encoders.STRING()) + .withColumnRenamed("value", "file_path") + .withColumn("last_modified", lit(new Timestamp(10000))); + + DeleteOrphanFiles.Result results = + SparkActions.get(spark) + .deleteOrphanFiles(table()) + .compareToFileList(validAndOrphanPathsDF) + .execute(); + blackhole.consume(results); + } + + private void initTable() { + spark.sql( + String.format( + "CREATE TABLE %s(id INT, name STRING)" + + " USING ICEBERG" + + " TBLPROPERTIES ( 'format-version' = '2')", + TABLE_NAME)); + } + + private void appendData() { + String location = table().location(); + PartitionSpec partitionSpec = table().spec(); + + for (int i = 0; i < NUM_SNAPSHOTS; i++) { + AppendFiles appendFiles = table().newFastAppend(); + for (int j = 0; j < NUM_FILES; j++) { + String path = String.format(Locale.ROOT, "%s/path/to/data-%d-%d.parquet", location, i, j); + validAndOrphanPaths.add(path); + DataFile dataFile = + DataFiles.builder(partitionSpec) + .withPath(path) + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + appendFiles.appendFile(dataFile); + } + appendFiles.commit(); + } + } + + private void addOrphans() { + String location = table.location(); + // Generate 10% orphan files + int orphanFileCount = (NUM_FILES * NUM_SNAPSHOTS) / 10; + for (int i = 0; i < orphanFileCount; i++) { + validAndOrphanPaths.add( + String.format("%s/path/to/data-%s.parquet", location, UUID.randomUUID())); + } + } + + private Table table() { + if (table == null) { + try { + table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return table; + } + + private String catalogWarehouse() { + try { + return Files.createTempDirectory("benchmark-").toAbsolutePath() + + "/" + + UUID.randomUUID() + + "/"; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private void setupSpark() { + SparkSession.Builder builder = + SparkSession.builder() + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", catalogWarehouse()) + .config(TestBase.DISABLE_UI) + .master("local"); + spark = builder.getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java new file mode 100644 index 000000000000..683f6bb46d05 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/IcebergSortCompactionBenchmark.java @@ -0,0 +1,408 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.action; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.DataTypes; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; + +@Fork(1) +@State(Scope.Benchmark) +@Measurement(iterations = 10) +@BenchmarkMode(Mode.SingleShotTime) +@Timeout(time = 1000, timeUnit = TimeUnit.HOURS) +public class IcebergSortCompactionBenchmark { + + private static final String[] NAMESPACE = new String[] {"default"}; + private static final String NAME = "sortbench"; + private static final Identifier IDENT = Identifier.of(NAMESPACE, NAME); + private static final int NUM_FILES = 8; + private static final long NUM_ROWS = 7500000L; + private static final long UNIQUE_VALUES = NUM_ROWS / 4; + + private final Configuration hadoopConf = initHadoopConf(); + private SparkSession spark; + + @Setup + public void setupBench() { + setupSpark(); + } + + @TearDown + public void teardownBench() { + tearDownSpark(); + } + + @Setup(Level.Iteration) + public void setupIteration() { + initTable(); + appendData(); + } + + @TearDown(Level.Iteration) + public void cleanUpIteration() throws IOException { + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void sortInt() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort( + SortOrder.builderFor(table().schema()) + .sortBy("intCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .build()) + .execute(); + } + + @Benchmark + @Threads(1) + public void sortInt2() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort( + SortOrder.builderFor(table().schema()) + .sortBy("intCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol2", SortDirection.ASC, NullOrder.NULLS_FIRST) + .build()) + .execute(); + } + + @Benchmark + @Threads(1) + public void sortInt3() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort( + SortOrder.builderFor(table().schema()) + .sortBy("intCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol2", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol3", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol4", SortDirection.ASC, NullOrder.NULLS_FIRST) + .build()) + .execute(); + } + + @Benchmark + @Threads(1) + public void sortInt4() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort( + SortOrder.builderFor(table().schema()) + .sortBy("intCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol2", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol3", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol4", SortDirection.ASC, NullOrder.NULLS_FIRST) + .build()) + .execute(); + } + + @Benchmark + @Threads(1) + public void sortString() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort( + SortOrder.builderFor(table().schema()) + .sortBy("stringCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .build()) + .execute(); + } + + @Benchmark + @Threads(1) + public void sortFourColumns() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort( + SortOrder.builderFor(table().schema()) + .sortBy("stringCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("dateCol", SortDirection.DESC, NullOrder.NULLS_FIRST) + .sortBy("doubleCol", SortDirection.DESC, NullOrder.NULLS_FIRST) + .build()) + .execute(); + } + + @Benchmark + @Threads(1) + public void sortSixColumns() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort( + SortOrder.builderFor(table().schema()) + .sortBy("stringCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("intCol", SortDirection.ASC, NullOrder.NULLS_FIRST) + .sortBy("dateCol", SortDirection.DESC, NullOrder.NULLS_FIRST) + .sortBy("timestampCol", SortDirection.DESC, NullOrder.NULLS_FIRST) + .sortBy("doubleCol", SortDirection.DESC, NullOrder.NULLS_FIRST) + .sortBy("longCol", SortDirection.DESC, NullOrder.NULLS_FIRST) + .build()) + .execute(); + } + + @Benchmark + @Threads(1) + public void zSortInt() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("intCol") + .execute(); + } + + @Benchmark + @Threads(1) + public void zSortInt2() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("intCol", "intCol2") + .execute(); + } + + @Benchmark + @Threads(1) + public void zSortInt3() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("intCol", "intCol2", "intCol3") + .execute(); + } + + @Benchmark + @Threads(1) + public void zSortInt4() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("intCol", "intCol2", "intCol3", "intCol4") + .execute(); + } + + @Benchmark + @Threads(1) + public void zSortString() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("stringCol") + .execute(); + } + + @Benchmark + @Threads(1) + public void zSortFourColumns() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("stringCol", "intCol", "dateCol", "doubleCol") + .execute(); + } + + @Benchmark + @Threads(1) + public void zSortSixColumns() { + SparkActions.get() + .rewriteDataFiles(table()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("stringCol", "intCol", "dateCol", "timestampCol", "doubleCol", "longCol") + .execute(); + } + + protected Configuration initHadoopConf() { + return new Configuration(); + } + + protected final void initTable() { + Schema schema = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "intCol2", Types.IntegerType.get()), + required(4, "intCol3", Types.IntegerType.get()), + required(5, "intCol4", Types.IntegerType.get()), + required(6, "floatCol", Types.FloatType.get()), + optional(7, "doubleCol", Types.DoubleType.get()), + optional(8, "dateCol", Types.DateType.get()), + optional(9, "timestampCol", Types.TimestampType.withZone()), + optional(10, "stringCol", Types.StringType.get())); + + SparkSessionCatalog catalog; + try { + catalog = + (SparkSessionCatalog) + Spark3Util.catalogAndIdentifier(spark(), "spark_catalog").catalog(); + catalog.dropTable(IDENT); + catalog.createTable( + IDENT, SparkSchemaUtil.convert(schema), new Transform[0], Collections.emptyMap()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void appendData() { + Dataset df = + spark() + .range(0, NUM_ROWS * NUM_FILES, 1, NUM_FILES) + .drop("id") + .withColumn("longCol", new RandomGeneratingUDF(UNIQUE_VALUES).randomLongUDF().apply()) + .withColumn( + "intCol", + new RandomGeneratingUDF(UNIQUE_VALUES) + .randomLongUDF() + .apply() + .cast(DataTypes.IntegerType)) + .withColumn( + "intCol2", + new RandomGeneratingUDF(UNIQUE_VALUES) + .randomLongUDF() + .apply() + .cast(DataTypes.IntegerType)) + .withColumn( + "intCol3", + new RandomGeneratingUDF(UNIQUE_VALUES) + .randomLongUDF() + .apply() + .cast(DataTypes.IntegerType)) + .withColumn( + "intCol4", + new RandomGeneratingUDF(UNIQUE_VALUES) + .randomLongUDF() + .apply() + .cast(DataTypes.IntegerType)) + .withColumn( + "floatCol", + new RandomGeneratingUDF(UNIQUE_VALUES) + .randomLongUDF() + .apply() + .cast(DataTypes.FloatType)) + .withColumn( + "doubleCol", + new RandomGeneratingUDF(UNIQUE_VALUES) + .randomLongUDF() + .apply() + .cast(DataTypes.DoubleType)) + .withColumn("dateCol", date_add(current_date(), col("intCol").mod(NUM_FILES))) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", new RandomGeneratingUDF(UNIQUE_VALUES).randomString().apply()); + writeData(df); + } + + private void writeData(Dataset df) { + df.write().format("iceberg").mode(SaveMode.Append).save(NAME); + } + + protected final Table table() { + try { + return Spark3Util.loadIcebergTable(spark(), NAME); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected final SparkSession spark() { + return spark; + } + + protected String getCatalogWarehouse() { + try { + return Files.createTempDirectory("benchmark-").toAbsolutePath() + + "/" + + UUID.randomUUID() + + "/"; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + protected void cleanupFiles() throws IOException { + spark.sql("DROP TABLE IF EXISTS " + NAME); + } + + protected void setupSpark() { + SparkSession.Builder builder = + SparkSession.builder() + .config( + "spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog") + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", getCatalogWarehouse()) + .config(TestBase.DISABLE_UI) + .master("local[*]"); + spark = builder.getOrCreate(); + Configuration sparkHadoopConf = spark.sessionState().newHadoopConf(); + hadoopConf.forEach(entry -> sparkHadoopConf.set(entry.getKey(), entry.getValue())); + } + + protected void tearDownSpark() { + spark.stop(); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/RandomGeneratingUDF.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/RandomGeneratingUDF.java new file mode 100644 index 000000000000..d8f9301a7d82 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/RandomGeneratingUDF.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.action; + +import static org.apache.spark.sql.functions.udf; + +import java.io.Serializable; +import java.util.Random; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.RandomUtil; +import org.apache.spark.sql.expressions.UserDefinedFunction; +import org.apache.spark.sql.types.DataTypes; + +class RandomGeneratingUDF implements Serializable { + private final long uniqueValues; + private final Random rand = new Random(); + + RandomGeneratingUDF(long uniqueValues) { + this.uniqueValues = uniqueValues; + } + + UserDefinedFunction randomLongUDF() { + return udf(() -> rand.nextLong() % (uniqueValues / 2), DataTypes.LongType) + .asNondeterministic() + .asNonNullable(); + } + + UserDefinedFunction randomString() { + return udf( + () -> RandomUtil.generatePrimitive(Types.StringType.get(), rand), DataTypes.StringType) + .asNondeterministic() + .asNonNullable(); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java new file mode 100644 index 000000000000..c900a3d53c78 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersFlatDataBenchmark.java @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.parquet; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.spark.SparkBenchmarkUtil; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.data.SparkParquetReaders; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.UnsafeProjection; +import org.apache.spark.sql.execution.datasources.parquet.ParquetReadSupport; +import org.apache.spark.sql.types.StructType; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that evaluates the performance of reading Parquet data with a flat schema using + * Iceberg and Spark Parquet readers. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=SparkParquetReadersFlatDataBenchmark + * -PjmhOutputPath=benchmark/spark-parquet-readers-flat-data-benchmark-result.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +public class SparkParquetReadersFlatDataBenchmark { + + private static final DynMethods.UnboundMethod APPLY_PROJECTION = + DynMethods.builder("apply").impl(UnsafeProjection.class, InternalRow.class).build(); + private static final Schema SCHEMA = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(5, "decimalCol", Types.DecimalType.of(20, 5)), + optional(6, "dateCol", Types.DateType.get()), + optional(7, "timestampCol", Types.TimestampType.withZone()), + optional(8, "stringCol", Types.StringType.get())); + private static final Schema PROJECTED_SCHEMA = + new Schema( + required(1, "longCol", Types.LongType.get()), + optional(5, "decimalCol", Types.DecimalType.of(20, 5)), + optional(8, "stringCol", Types.StringType.get())); + private static final int NUM_RECORDS = 1000000; + private File dataFile; + + @Setup + public void setupBenchmark() throws IOException { + dataFile = File.createTempFile("parquet-flat-data-benchmark", ".parquet"); + dataFile.delete(); + List records = RandomData.generateList(SCHEMA, NUM_RECORDS, 0L); + try (FileAppender writer = + Parquet.write(Files.localOutput(dataFile)).schema(SCHEMA).named("benchmark").build()) { + writer.addAll(records); + } + } + + @TearDown + public void tearDownBenchmark() { + if (dataFile != null) { + dataFile.delete(); + } + } + + @Benchmark + @Threads(1) + public void readUsingIcebergReader(Blackhole blackHole) throws IOException { + try (CloseableIterable rows = + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) + .project(SCHEMA) + .build()) { + + for (InternalRow row : rows) { + blackHole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readUsingIcebergReaderUnsafe(Blackhole blackhole) throws IOException { + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(SCHEMA) + .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) + .build()) { + + Iterable unsafeRows = + Iterables.transform( + rows, APPLY_PROJECTION.bind(SparkBenchmarkUtil.projection(SCHEMA, SCHEMA))::invoke); + + for (InternalRow row : unsafeRows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readUsingSparkReader(Blackhole blackhole) throws IOException { + StructType sparkSchema = SparkSchemaUtil.convert(SCHEMA); + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(SCHEMA) + .readSupport(new ParquetReadSupport()) + .set("org.apache.spark.sql.parquet.row.requested_schema", sparkSchema.json()) + .set("spark.sql.parquet.binaryAsString", "false") + .set("spark.sql.parquet.int96AsTimestamp", "false") + .set("spark.sql.caseSensitive", "false") + .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.inferTimestampNTZ.enabled", "false") + .set("spark.sql.legacy.parquet.nanosAsLong", "false") + .callInit() + .build()) { + + for (InternalRow row : rows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { + try (CloseableIterable rows = + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) + .project(PROJECTED_SCHEMA) + .build()) { + + for (InternalRow row : rows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readWithProjectionUsingIcebergReaderUnsafe(Blackhole blackhole) throws IOException { + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(PROJECTED_SCHEMA) + .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) + .build()) { + + Iterable unsafeRows = + Iterables.transform( + rows, + APPLY_PROJECTION.bind( + SparkBenchmarkUtil.projection(PROJECTED_SCHEMA, PROJECTED_SCHEMA)) + ::invoke); + + for (InternalRow row : unsafeRows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readWithProjectionUsingSparkReader(Blackhole blackhole) throws IOException { + StructType sparkSchema = SparkSchemaUtil.convert(PROJECTED_SCHEMA); + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(PROJECTED_SCHEMA) + .readSupport(new ParquetReadSupport()) + .set("org.apache.spark.sql.parquet.row.requested_schema", sparkSchema.json()) + .set("spark.sql.parquet.binaryAsString", "false") + .set("spark.sql.parquet.int96AsTimestamp", "false") + .set("spark.sql.caseSensitive", "false") + .set("spark.sql.parquet.inferTimestampNTZ.enabled", "false") + .set("spark.sql.legacy.parquet.nanosAsLong", "false") + .callInit() + .build()) { + + for (InternalRow row : rows) { + blackhole.consume(row); + } + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java new file mode 100644 index 000000000000..32c45b1496ee --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetReadersNestedDataBenchmark.java @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.parquet; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import org.apache.avro.generic.GenericData; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.common.DynMethods; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.spark.SparkBenchmarkUtil; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.data.SparkParquetReaders; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.UnsafeProjection; +import org.apache.spark.sql.execution.datasources.parquet.ParquetReadSupport; +import org.apache.spark.sql.types.StructType; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that evaluates the performance of reading nested Parquet data using Iceberg and Spark + * Parquet readers. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=SparkParquetReadersNestedDataBenchmark + * -PjmhOutputPath=benchmark/spark-parquet-readers-nested-data-benchmark-result.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +public class SparkParquetReadersNestedDataBenchmark { + + private static final DynMethods.UnboundMethod APPLY_PROJECTION = + DynMethods.builder("apply").impl(UnsafeProjection.class, InternalRow.class).build(); + private static final Schema SCHEMA = + new Schema( + required(0, "id", Types.LongType.get()), + optional( + 4, + "nested", + Types.StructType.of( + required(1, "col1", Types.StringType.get()), + required(2, "col2", Types.DoubleType.get()), + required(3, "col3", Types.LongType.get())))); + private static final Schema PROJECTED_SCHEMA = + new Schema( + optional(4, "nested", Types.StructType.of(required(1, "col1", Types.StringType.get())))); + private static final int NUM_RECORDS = 1000000; + private File dataFile; + + @Setup + public void setupBenchmark() throws IOException { + dataFile = File.createTempFile("parquet-nested-data-benchmark", ".parquet"); + dataFile.delete(); + List records = RandomData.generateList(SCHEMA, NUM_RECORDS, 0L); + try (FileAppender writer = + Parquet.write(Files.localOutput(dataFile)).schema(SCHEMA).named("benchmark").build()) { + writer.addAll(records); + } + } + + @TearDown + public void tearDownBenchmark() { + if (dataFile != null) { + dataFile.delete(); + } + } + + @Benchmark + @Threads(1) + public void readUsingIcebergReader(Blackhole blackhole) throws IOException { + try (CloseableIterable rows = + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) + .project(SCHEMA) + .build()) { + + for (InternalRow row : rows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readUsingIcebergReaderUnsafe(Blackhole blackhole) throws IOException { + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(SCHEMA) + .createReaderFunc(type -> SparkParquetReaders.buildReader(SCHEMA, type)) + .build()) { + + Iterable unsafeRows = + Iterables.transform( + rows, APPLY_PROJECTION.bind(SparkBenchmarkUtil.projection(SCHEMA, SCHEMA))::invoke); + + for (InternalRow row : unsafeRows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readUsingSparkReader(Blackhole blackhole) throws IOException { + StructType sparkSchema = SparkSchemaUtil.convert(SCHEMA); + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(SCHEMA) + .readSupport(new ParquetReadSupport()) + .set("org.apache.spark.sql.parquet.row.requested_schema", sparkSchema.json()) + .set("spark.sql.parquet.binaryAsString", "false") + .set("spark.sql.parquet.int96AsTimestamp", "false") + .set("spark.sql.caseSensitive", "false") + .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.inferTimestampNTZ.enabled", "false") + .set("spark.sql.legacy.parquet.nanosAsLong", "false") + .callInit() + .build()) { + + for (InternalRow row : rows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readWithProjectionUsingIcebergReader(Blackhole blackhole) throws IOException { + try (CloseableIterable rows = + FormatModelRegistry.readBuilder( + FileFormat.PARQUET, InternalRow.class, Files.localInput(dataFile)) + .project(PROJECTED_SCHEMA) + .build()) { + + for (InternalRow row : rows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readWithProjectionUsingIcebergReaderUnsafe(Blackhole blackhole) throws IOException { + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(PROJECTED_SCHEMA) + .createReaderFunc(type -> SparkParquetReaders.buildReader(PROJECTED_SCHEMA, type)) + .build()) { + + Iterable unsafeRows = + Iterables.transform( + rows, + APPLY_PROJECTION.bind( + SparkBenchmarkUtil.projection(PROJECTED_SCHEMA, PROJECTED_SCHEMA)) + ::invoke); + + for (InternalRow row : unsafeRows) { + blackhole.consume(row); + } + } + } + + @Benchmark + @Threads(1) + public void readWithProjectionUsingSparkReader(Blackhole blackhole) throws IOException { + StructType sparkSchema = SparkSchemaUtil.convert(PROJECTED_SCHEMA); + try (CloseableIterable rows = + Parquet.read(Files.localInput(dataFile)) + .project(PROJECTED_SCHEMA) + .readSupport(new ParquetReadSupport()) + .set("org.apache.spark.sql.parquet.row.requested_schema", sparkSchema.json()) + .set("spark.sql.parquet.binaryAsString", "false") + .set("spark.sql.parquet.int96AsTimestamp", "false") + .set("spark.sql.caseSensitive", "false") + .set("spark.sql.parquet.inferTimestampNTZ.enabled", "false") + .set("spark.sql.legacy.parquet.nanosAsLong", "false") + .callInit() + .build()) { + + for (InternalRow row : rows) { + blackhole.consume(row); + } + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java new file mode 100644 index 000000000000..21d5ec147789 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersFlatDataBenchmark.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.parquet; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.io.File; +import java.io.IOException; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; +import org.apache.spark.sql.types.StructType; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * A benchmark that evaluates the performance of writing Parquet data with a flat schema using + * Iceberg and Spark Parquet writers. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=SparkParquetWritersFlatDataBenchmark + * -PjmhOutputPath=benchmark/spark-parquet-writers-flat-data-benchmark-result.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +public class SparkParquetWritersFlatDataBenchmark { + + private static final Schema SCHEMA = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(5, "decimalCol", Types.DecimalType.of(20, 5)), + optional(6, "dateCol", Types.DateType.get()), + optional(7, "timestampCol", Types.TimestampType.withZone()), + optional(8, "stringCol", Types.StringType.get())); + private static final int NUM_RECORDS = 1000000; + private Iterable rows; + private File dataFile; + + @Setup + public void setupBenchmark() throws IOException { + rows = RandomData.generateSpark(SCHEMA, NUM_RECORDS, 0L); + dataFile = File.createTempFile("parquet-flat-data-benchmark", ".parquet"); + dataFile.delete(); + } + + @TearDown(Level.Iteration) + public void tearDownBenchmark() { + if (dataFile != null) { + dataFile.delete(); + } + } + + @Benchmark + @Threads(1) + public void writeUsingIcebergWriter() throws IOException { + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) + .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) + .build()) { + + writer.write(rows); + } + } + + @Benchmark + @Threads(1) + public void writeUsingSparkWriter() throws IOException { + StructType sparkSchema = SparkSchemaUtil.convert(SCHEMA); + try (FileAppender writer = + Parquet.write(Files.localOutput(dataFile)) + .writeSupport(new ParquetWriteSupport()) + .set("org.apache.spark.sql.parquet.row.attributes", sparkSchema.json()) + .set("spark.sql.parquet.writeLegacyFormat", "false") + .set("spark.sql.parquet.binaryAsString", "false") + .set("spark.sql.parquet.int96AsTimestamp", "false") + .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") + .set("spark.sql.caseSensitive", "false") + .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") + .schema(SCHEMA) + .build()) { + + writer.addAll(rows); + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java new file mode 100644 index 000000000000..7e9e1ab08551 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/data/parquet/SparkParquetWritersNestedDataBenchmark.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.parquet; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.io.File; +import java.io.IOException; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport; +import org.apache.spark.sql.types.StructType; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +/** + * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and Spark + * Parquet writers. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=SparkParquetWritersNestedDataBenchmark + * -PjmhOutputPath=benchmark/spark-parquet-writers-nested-data-benchmark-result.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +public class SparkParquetWritersNestedDataBenchmark { + + private static final Schema SCHEMA = + new Schema( + required(0, "id", Types.LongType.get()), + optional( + 4, + "nested", + Types.StructType.of( + required(1, "col1", Types.StringType.get()), + required(2, "col2", Types.DoubleType.get()), + required(3, "col3", Types.LongType.get())))); + private static final int NUM_RECORDS = 1000000; + private Iterable rows; + private File dataFile; + + @Setup + public void setupBenchmark() throws IOException { + rows = RandomData.generateSpark(SCHEMA, NUM_RECORDS, 0L); + dataFile = File.createTempFile("parquet-nested-data-benchmark", ".parquet"); + dataFile.delete(); + } + + @TearDown(Level.Iteration) + public void tearDownBenchmark() { + if (dataFile != null) { + dataFile.delete(); + } + } + + @Benchmark + @Threads(1) + public void writeUsingIcebergWriter() throws IOException { + try (DataWriter writer = + FormatModelRegistry.dataWriteBuilder( + FileFormat.PARQUET, + InternalRow.class, + EncryptedFiles.plainAsEncryptedOutput(Files.localOutput(dataFile))) + .schema(SCHEMA) + .spec(PartitionSpec.unpartitioned()) + .build()) { + + writer.write(rows); + } + } + + @Benchmark + @Threads(1) + public void writeUsingSparkWriter() throws IOException { + StructType sparkSchema = SparkSchemaUtil.convert(SCHEMA); + try (FileAppender writer = + Parquet.write(Files.localOutput(dataFile)) + .writeSupport(new ParquetWriteSupport()) + .set("org.apache.spark.sql.parquet.row.attributes", sparkSchema.json()) + .set("spark.sql.parquet.writeLegacyFormat", "false") + .set("spark.sql.parquet.binaryAsString", "false") + .set("spark.sql.parquet.int96AsTimestamp", "false") + .set("spark.sql.parquet.outputTimestampType", "TIMESTAMP_MICROS") + .set("spark.sql.caseSensitive", "false") + .set("spark.sql.parquet.fieldId.write.enabled", "false") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "false") + .schema(SCHEMA) + .build()) { + + writer.addAll(rows); + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/Action.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/Action.java new file mode 100644 index 000000000000..0dbf07285060 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/Action.java @@ -0,0 +1,24 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +@FunctionalInterface +public interface Action { + void invoke(); +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java new file mode 100644 index 000000000000..3f242ce228ca --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/DVReaderBenchmark.java @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.data.DeleteLoader; +import org.apache.iceberg.deletes.BaseDVFileWriter; +import org.apache.iceberg.deletes.DVFileWriter; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.io.DeleteWriteResult; +import org.apache.iceberg.io.FanoutPositionOnlyDeleteWriter; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.unsafe.types.UTF8String; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that compares the performance of DV and position delete readers. + * + *

    To run this benchmark for spark-3.5: + * ./gradlew -DsparkVersions=3.5 :iceberg-spark:iceberg-spark-3.5_2.12:jmh + * -PjmhIncludeRegex=DVReaderBenchmark + * -PjmhOutputPath=benchmark/iceberg-dv-reader-benchmark-result.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 15) +@Timeout(time = 20, timeUnit = TimeUnit.MINUTES) +@BenchmarkMode(Mode.SingleShotTime) +public class DVReaderBenchmark { + + private static final String TABLE_NAME = "test_table"; + private static final int DATA_FILE_RECORD_COUNT = 2_000_000; + private static final long TARGET_FILE_SIZE = Long.MAX_VALUE; + + @Param({"5", "10"}) + private int referencedDataFileCount; + + @Param({"0.01", "0.03", "0.05", "0.10", "0.2"}) + private double deletedRowsRatio; + + private final Configuration hadoopConf = new Configuration(); + private final Random random = ThreadLocalRandom.current(); + private SparkSession spark; + private Table table; + private DeleteWriteResult dvsResult; + private DeleteWriteResult fileDeletesResult; + private DeleteWriteResult partitionDeletesResult; + + @Setup + public void setupBenchmark() throws NoSuchTableException, ParseException, IOException { + setupSpark(); + initTable(); + List deletes = generatePositionDeletes(); + this.dvsResult = writeDVs(deletes); + this.fileDeletesResult = writePositionDeletes(deletes, DeleteGranularity.FILE); + this.partitionDeletesResult = writePositionDeletes(deletes, DeleteGranularity.PARTITION); + } + + @TearDown + public void tearDownBenchmark() { + dropTable(); + tearDownSpark(); + } + + @Benchmark + @Threads(1) + public void dv(Blackhole blackhole) { + DeleteLoader loader = new BaseDeleteLoader(file -> table.io().newInputFile(file), null); + DeleteFile dv = dvsResult.deleteFiles().get(0); + CharSequence dataFile = dv.referencedDataFile(); + PositionDeleteIndex index = loader.loadPositionDeletes(ImmutableList.of(dv), dataFile); + blackhole.consume(index); + } + + @Benchmark + @Threads(1) + public void fileScopedParquetDeletes(Blackhole blackhole) { + DeleteLoader loader = new BaseDeleteLoader(file -> table.io().newInputFile(file), null); + DeleteFile deleteFile = fileDeletesResult.deleteFiles().get(0); + CharSequence dataFile = ContentFileUtil.referencedDataFile(deleteFile); + PositionDeleteIndex index = loader.loadPositionDeletes(ImmutableList.of(deleteFile), dataFile); + blackhole.consume(index); + } + + @Benchmark + @Threads(1) + public void partitionScopedParquetDeletes(Blackhole blackhole) { + DeleteLoader loader = new BaseDeleteLoader(file -> table.io().newInputFile(file), null); + DeleteFile deleteFile = Iterables.getOnlyElement(partitionDeletesResult.deleteFiles()); + CharSequence dataFile = Iterables.getLast(partitionDeletesResult.referencedDataFiles()); + PositionDeleteIndex index = loader.loadPositionDeletes(ImmutableList.of(deleteFile), dataFile); + blackhole.consume(index); + } + + private FanoutPositionOnlyDeleteWriter newWriter(DeleteGranularity granularity) { + return new FanoutPositionOnlyDeleteWriter<>( + newWriterFactory(), + newFileFactory(FileFormat.PARQUET), + table.io(), + TARGET_FILE_SIZE, + granularity); + } + + private SparkFileWriterFactory newWriterFactory() { + return SparkFileWriterFactory.builderFor(table).dataFileFormat(FileFormat.PARQUET).build(); + } + + private OutputFileFactory newFileFactory(FileFormat format) { + return OutputFileFactory.builderFor(table, 1, 1).format(format).build(); + } + + private List generatePositionDeletes() { + int numDeletesPerFile = (int) (DATA_FILE_RECORD_COUNT * deletedRowsRatio); + int numDeletes = referencedDataFileCount * numDeletesPerFile; + List deletes = Lists.newArrayListWithExpectedSize(numDeletes); + + for (int pathIndex = 0; pathIndex < referencedDataFileCount; pathIndex++) { + UTF8String dataFilePath = UTF8String.fromString(generateDataFilePath()); + Set positions = generatePositions(numDeletesPerFile); + for (long pos : positions) { + deletes.add(new GenericInternalRow(new Object[] {dataFilePath, pos})); + } + } + + Collections.shuffle(deletes); + + return deletes; + } + + private DeleteWriteResult writeDVs(Iterable rows) throws IOException { + OutputFileFactory fileFactory = newFileFactory(FileFormat.PUFFIN); + DVFileWriter writer = new BaseDVFileWriter(fileFactory, path -> null); + try (DVFileWriter closableWriter = writer) { + for (InternalRow row : rows) { + String path = row.getString(0); + long pos = row.getLong(1); + closableWriter.delete(path, pos, table.spec(), null); + } + } + return writer.result(); + } + + private DeleteWriteResult writePositionDeletes( + Iterable rows, DeleteGranularity granularity) throws IOException { + FanoutPositionOnlyDeleteWriter writer = newWriter(granularity); + try (FanoutPositionOnlyDeleteWriter closableWriter = writer) { + PositionDelete positionDelete = PositionDelete.create(); + for (InternalRow row : rows) { + String path = row.getString(0); + long pos = row.getLong(1); + positionDelete.set(path, pos, null /* no row */); + closableWriter.write(positionDelete, table.spec(), null); + } + } + return writer.result(); + } + + public Set generatePositions(int numPositions) { + Set positions = Sets.newHashSet(); + + while (positions.size() < numPositions) { + long pos = random.nextInt(DATA_FILE_RECORD_COUNT); + positions.add(pos); + } + + return positions; + } + + private String generateDataFilePath() { + String fileName = FileGenerationUtil.generateFileName(); + return table.locationProvider().newDataLocation(table.spec(), null, fileName); + } + + private void setupSpark() { + this.spark = + SparkSession.builder() + .config(TestBase.DISABLE_UI) + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", newWarehouseDir()) + .master("local[*]") + .getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } + + private void initTable() throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (c1 INT, c2 INT, c3 STRING) USING iceberg", TABLE_NAME); + this.table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + } + + private void dropTable() { + sql("DROP TABLE IF EXISTS %s PURGE", TABLE_NAME); + } + + private String newWarehouseDir() { + return hadoopConf.get("hadoop.tmp.dir") + UUID.randomUUID(); + } + + @FormatMethod + private void sql(@FormatString String query, Object... args) { + spark.sql(String.format(query, args)); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java new file mode 100644 index 000000000000..db5789724056 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/DVWriterBenchmark.java @@ -0,0 +1,252 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import com.google.errorprone.annotations.FormatMethod; +import com.google.errorprone.annotations.FormatString; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.Table; +import org.apache.iceberg.deletes.BaseDVFileWriter; +import org.apache.iceberg.deletes.DVFileWriter; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.io.DeleteWriteResult; +import org.apache.iceberg.io.FanoutPositionOnlyDeleteWriter; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.unsafe.types.UTF8String; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Timeout; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * A benchmark that compares the performance of DV and position delete writers. + * + *

    To run this benchmark for spark-3.5: + * ./gradlew -DsparkVersions=3.5 :iceberg-spark:iceberg-spark-3.5_2.12:jmh + * -PjmhIncludeRegex=DVWriterBenchmark + * -PjmhOutputPath=benchmark/iceberg-dv-writer-benchmark-result.txt + * + */ +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 10) +@Timeout(time = 20, timeUnit = TimeUnit.MINUTES) +@BenchmarkMode(Mode.SingleShotTime) +public class DVWriterBenchmark { + + private static final String TABLE_NAME = "test_table"; + private static final int DATA_FILE_RECORD_COUNT = 2_000_000; + private static final long TARGET_FILE_SIZE = Long.MAX_VALUE; + + @Param({"5", "10"}) + private int referencedDataFileCount; + + @Param({"0.01", "0.03", "0.05", "0.10", "0.2"}) + private double deletedRowsRatio; + + private final Configuration hadoopConf = new Configuration(); + private final Random random = ThreadLocalRandom.current(); + private SparkSession spark; + private Table table; + private Iterable positionDeletes; + + @Setup + public void setupBenchmark() throws NoSuchTableException, ParseException { + setupSpark(); + initTable(); + generatePositionDeletes(); + } + + @TearDown + public void tearDownBenchmark() { + dropTable(); + tearDownSpark(); + } + + @Benchmark + @Threads(1) + public void dv(Blackhole blackhole) throws IOException { + OutputFileFactory fileFactory = newFileFactory(FileFormat.PUFFIN); + DVFileWriter writer = new BaseDVFileWriter(fileFactory, path -> null); + + try (DVFileWriter closableWriter = writer) { + for (InternalRow row : positionDeletes) { + String path = row.getString(0); + long pos = row.getLong(1); + closableWriter.delete(path, pos, table.spec(), null); + } + } + + DeleteWriteResult result = writer.result(); + blackhole.consume(result); + } + + @Benchmark + @Threads(1) + public void fileScopedParquetDeletes(Blackhole blackhole) throws IOException { + FanoutPositionOnlyDeleteWriter writer = newWriter(DeleteGranularity.FILE); + write(writer, positionDeletes); + DeleteWriteResult result = writer.result(); + blackhole.consume(result); + } + + @Benchmark + @Threads(1) + public void partitionScopedParquetDeletes(Blackhole blackhole) throws IOException { + FanoutPositionOnlyDeleteWriter writer = newWriter(DeleteGranularity.PARTITION); + write(writer, positionDeletes); + DeleteWriteResult result = writer.result(); + blackhole.consume(result); + } + + private FanoutPositionOnlyDeleteWriter newWriter(DeleteGranularity granularity) { + return new FanoutPositionOnlyDeleteWriter<>( + newWriterFactory(), + newFileFactory(FileFormat.PARQUET), + table.io(), + TARGET_FILE_SIZE, + granularity); + } + + private DeleteWriteResult write( + FanoutPositionOnlyDeleteWriter writer, Iterable rows) + throws IOException { + + try (FanoutPositionOnlyDeleteWriter closableWriter = writer) { + PositionDelete positionDelete = PositionDelete.create(); + + for (InternalRow row : rows) { + String path = row.getString(0); + long pos = row.getLong(1); + positionDelete.set(path, pos, null /* no row */); + closableWriter.write(positionDelete, table.spec(), null); + } + } + + return writer.result(); + } + + private SparkFileWriterFactory newWriterFactory() { + return SparkFileWriterFactory.builderFor(table).dataFileFormat(FileFormat.PARQUET).build(); + } + + private OutputFileFactory newFileFactory(FileFormat format) { + return OutputFileFactory.builderFor(table, 1, 1).format(format).build(); + } + + private void generatePositionDeletes() { + int numDeletesPerFile = (int) (DATA_FILE_RECORD_COUNT * deletedRowsRatio); + int numDeletes = referencedDataFileCount * numDeletesPerFile; + List deletes = Lists.newArrayListWithExpectedSize(numDeletes); + + for (int pathIndex = 0; pathIndex < referencedDataFileCount; pathIndex++) { + UTF8String dataFilePath = UTF8String.fromString(generateDataFilePath()); + Set positions = generatePositions(numDeletesPerFile); + for (long pos : positions) { + deletes.add(new GenericInternalRow(new Object[] {dataFilePath, pos})); + } + } + + Collections.shuffle(deletes); + + this.positionDeletes = deletes; + } + + public Set generatePositions(int numPositions) { + Set positions = Sets.newHashSet(); + + while (positions.size() < numPositions) { + long pos = random.nextInt(DATA_FILE_RECORD_COUNT); + positions.add(pos); + } + + return positions; + } + + private String generateDataFilePath() { + String fileName = FileGenerationUtil.generateFileName(); + return table.locationProvider().newDataLocation(table.spec(), null, fileName); + } + + private void setupSpark() { + this.spark = + SparkSession.builder() + .config(TestBase.DISABLE_UI) + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config("spark.sql.catalog.spark_catalog", SparkSessionCatalog.class.getName()) + .config("spark.sql.catalog.spark_catalog.type", "hadoop") + .config("spark.sql.catalog.spark_catalog.warehouse", newWarehouseDir()) + .master("local[*]") + .getOrCreate(); + } + + private void tearDownSpark() { + spark.stop(); + } + + private void initTable() throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (c1 INT, c2 INT, c3 STRING) USING iceberg", TABLE_NAME); + this.table = Spark3Util.loadIcebergTable(spark, TABLE_NAME); + } + + private void dropTable() { + sql("DROP TABLE IF EXISTS %s PURGE", TABLE_NAME); + } + + private String newWarehouseDir() { + return hadoopConf.get("hadoop.tmp.dir") + UUID.randomUUID(); + } + + @FormatMethod + private void sql(@FormatString String query, Object... args) { + spark.sql(String.format(query, args)); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java new file mode 100644 index 000000000000..debe37866ff7 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceBenchmark.java @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.UpdateProperties; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +@Fork(1) +@State(Scope.Benchmark) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@BenchmarkMode(Mode.SingleShotTime) +public abstract class IcebergSourceBenchmark { + + private final Configuration hadoopConf = initHadoopConf(); + private final Table table = initTable(); + private SparkSession spark; + + protected abstract Configuration initHadoopConf(); + + protected final Configuration hadoopConf() { + return hadoopConf; + } + + protected abstract Table initTable(); + + protected final Table table() { + return table; + } + + protected final SparkSession spark() { + return spark; + } + + protected String newTableLocation() { + String tmpDir = hadoopConf.get("hadoop.tmp.dir"); + Path tablePath = new Path(tmpDir, "spark-iceberg-table-" + UUID.randomUUID()); + return tablePath.toString(); + } + + protected String dataLocation() { + Map properties = table.properties(); + return properties.getOrDefault( + TableProperties.WRITE_DATA_LOCATION, String.format("%s/data", table.location())); + } + + protected void cleanupFiles() throws IOException { + try (FileSystem fileSystem = FileSystem.get(hadoopConf)) { + Path dataPath = new Path(dataLocation()); + fileSystem.delete(dataPath, true); + Path tablePath = new Path(table.location()); + fileSystem.delete(tablePath, true); + } + } + + protected void setupSpark(boolean enableDictionaryEncoding) { + SparkSession.Builder builder = SparkSession.builder().config(TestBase.DISABLE_UI); + if (!enableDictionaryEncoding) { + builder + .config("parquet.dictionary.page.size", "1") + .config("parquet.enable.dictionary", false) + .config(TableProperties.PARQUET_DICT_SIZE_BYTES, "1"); + } + builder.master("local"); + spark = builder.getOrCreate(); + Configuration sparkHadoopConf = spark.sessionState().newHadoopConf(); + hadoopConf.forEach(entry -> sparkHadoopConf.set(entry.getKey(), entry.getValue())); + } + + protected void setupSpark() { + setupSpark(false); + } + + protected void tearDownSpark() { + spark.stop(); + } + + protected void materialize(Dataset ds) { + ds.queryExecution().toRdd().toJavaRDD().foreach(record -> {}); + } + + protected void materialize(Dataset ds, Blackhole blackhole) { + blackhole.consume(ds.queryExecution().toRdd().toJavaRDD().count()); + } + + protected void appendAsFile(Dataset ds) { + // ensure the schema is precise (including nullability) + StructType sparkSchema = SparkSchemaUtil.convert(table.schema()); + spark + .createDataFrame(ds.rdd(), sparkSchema) + .coalesce(1) + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(table.location()); + } + + protected void withSQLConf(Map conf, Action action) { + SQLConf sqlConf = SQLConf.get(); + + Map currentConfValues = Maps.newHashMap(); + conf.keySet() + .forEach( + confKey -> { + if (sqlConf.contains(confKey)) { + String currentConfValue = sqlConf.getConfString(confKey); + currentConfValues.put(confKey, currentConfValue); + } + }); + + conf.forEach( + (confKey, confValue) -> { + if (SQLConf.isStaticConfigKey(confKey)) { + throw new RuntimeException("Cannot modify the value of a static config: " + confKey); + } + sqlConf.setConfString(confKey, confValue); + }); + + try { + action.invoke(); + } finally { + conf.forEach( + (confKey, confValue) -> { + if (currentConfValues.containsKey(confKey)) { + sqlConf.setConfString(confKey, currentConfValues.get(confKey)); + } else { + sqlConf.unsetConf(confKey); + } + }); + } + } + + protected void withTableProperties(Map props, Action action) { + Map tableProps = table.properties(); + Map currentPropValues = Maps.newHashMap(); + props + .keySet() + .forEach( + propKey -> { + if (tableProps.containsKey(propKey)) { + String currentPropValue = tableProps.get(propKey); + currentPropValues.put(propKey, currentPropValue); + } + }); + + UpdateProperties updateProperties = table.updateProperties(); + props.forEach(updateProperties::set); + updateProperties.commit(); + + try { + action.invoke(); + } finally { + UpdateProperties restoreProperties = table.updateProperties(); + props.forEach( + (propKey, propValue) -> { + if (currentPropValues.containsKey(propKey)) { + restoreProperties.set(propKey, currentPropValues.get(propKey)); + } else { + restoreProperties.remove(propKey); + } + }); + restoreProperties.commit(); + } + } + + protected FileFormat fileFormat() { + throw new UnsupportedOperationException("Unsupported file format"); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceDeleteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceDeleteBenchmark.java new file mode 100644 index 000000000000..e42707bf102b --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceDeleteBenchmark.java @@ -0,0 +1,340 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.TableProperties.PARQUET_VECTORIZATION_ENABLED; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.ClusteredEqualityDeleteWriter; +import org.apache.iceberg.io.ClusteredPositionDeleteWriter; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.infra.Blackhole; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public abstract class IcebergSourceDeleteBenchmark extends IcebergSourceBenchmark { + private static final Logger LOG = LoggerFactory.getLogger(IcebergSourceDeleteBenchmark.class); + private static final long TARGET_FILE_SIZE_IN_BYTES = 512L * 1024 * 1024; + + protected static final int NUM_FILES = 1; + protected static final int NUM_ROWS = 10 * 1000 * 1000; + + @Setup + public void setupBenchmark() throws IOException { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readIceberg(Blackhole blackhole) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + tableProperties.put(PARQUET_VECTORIZATION_ENABLED, "false"); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df, blackhole); + }); + } + + @Benchmark + @Threads(1) + public void readIcebergWithIsDeletedColumn(Blackhole blackhole) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + tableProperties.put(PARQUET_VECTORIZATION_ENABLED, "false"); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).filter("_deleted = false"); + materialize(df, blackhole); + }); + } + + @Benchmark + @Threads(1) + public void readDeletedRows(Blackhole blackhole) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + tableProperties.put(PARQUET_VECTORIZATION_ENABLED, "false"); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).filter("_deleted = true"); + materialize(df, blackhole); + }); + } + + @Benchmark + @Threads(1) + public void readIcebergVectorized(Blackhole blackhole) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + tableProperties.put(PARQUET_VECTORIZATION_ENABLED, "true"); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df, blackhole); + }); + } + + @Benchmark + @Threads(1) + public void readIcebergWithIsDeletedColumnVectorized(Blackhole blackhole) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + tableProperties.put(PARQUET_VECTORIZATION_ENABLED, "true"); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).filter("_deleted = false"); + materialize(df, blackhole); + }); + } + + @Benchmark + @Threads(1) + public void readDeletedRowsVectorized(Blackhole blackhole) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + tableProperties.put(PARQUET_VECTORIZATION_ENABLED, "true"); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).filter("_deleted = true"); + materialize(df, blackhole); + }); + } + + protected abstract void appendData() throws IOException; + + protected void writeData(int fileNum) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumnRenamed("id", "longCol") + .withColumn("intCol", expr("CAST(MOD(longCol, 2147483647) AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")); + appendAsFile(df); + } + + @Override + protected Table initTable() { + Schema schema = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(6, "dateCol", Types.DateType.get()), + optional(7, "timestampCol", Types.TimestampType.withZone()), + optional(8, "stringCol", Types.StringType.get())); + PartitionSpec partitionSpec = PartitionSpec.unpartitioned(); + HadoopTables tables = new HadoopTables(hadoopConf()); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + properties.put(TableProperties.FORMAT_VERSION, "2"); + return tables.create(schema, partitionSpec, properties, newTableLocation()); + } + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + protected void writePosDeletes(CharSequence path, long numRows, double percentage) + throws IOException { + writePosDeletes(path, numRows, percentage, 1); + } + + protected void writePosDeletes( + CharSequence path, long numRows, double percentage, int numDeleteFile) throws IOException { + writePosDeletesWithNoise(path, numRows, percentage, 0, numDeleteFile); + } + + protected void writePosDeletesWithNoise( + CharSequence path, long numRows, double percentage, int numNoise, int numDeleteFile) + throws IOException { + Set deletedPos = Sets.newHashSet(); + while (deletedPos.size() < numRows * percentage) { + deletedPos.add(ThreadLocalRandom.current().nextLong(numRows)); + } + LOG.info("pos delete row count: {}, num of delete files: {}", deletedPos.size(), numDeleteFile); + + int partitionSize = (int) (numRows * percentage) / numDeleteFile; + Iterable> sets = Iterables.partition(deletedPos, partitionSize); + for (List item : sets) { + writePosDeletes(path, item, numNoise); + } + } + + protected void writePosDeletes(CharSequence path, List deletedPos, int numNoise) + throws IOException { + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()).dataFileFormat(fileFormat()).build(); + + ClusteredPositionDeleteWriter writer = + new ClusteredPositionDeleteWriter<>( + writerFactory, fileFactory, table().io(), TARGET_FILE_SIZE_IN_BYTES); + + PartitionSpec unpartitionedSpec = table().specs().get(0); + + PositionDelete positionDelete = PositionDelete.create(); + try (ClusteredPositionDeleteWriter closeableWriter = writer) { + for (Long pos : deletedPos) { + positionDelete.set(path, pos, null); + closeableWriter.write(positionDelete, unpartitionedSpec, null); + for (int i = 0; i < numNoise; i++) { + positionDelete.set(noisePath(path), pos, null); + closeableWriter.write(positionDelete, unpartitionedSpec, null); + } + } + } + + RowDelta rowDelta = table().newRowDelta(); + writer.result().deleteFiles().forEach(rowDelta::addDeletes); + rowDelta.validateDeletedFiles().commit(); + } + + protected void writeEqDeletes(long numRows, double percentage) throws IOException { + Set deletedValues = Sets.newHashSet(); + while (deletedValues.size() < numRows * percentage) { + deletedValues.add(ThreadLocalRandom.current().nextLong(numRows)); + } + + List rows = Lists.newArrayList(); + for (Long value : deletedValues) { + GenericInternalRow genericInternalRow = new GenericInternalRow(7); + genericInternalRow.setLong(0, value); + genericInternalRow.setInt(1, (int) (value % Integer.MAX_VALUE)); + genericInternalRow.setFloat(2, (float) value); + genericInternalRow.setNullAt(3); + genericInternalRow.setNullAt(4); + genericInternalRow.setNullAt(5); + genericInternalRow.setNullAt(6); + rows.add(genericInternalRow); + } + LOG.info("Num of equality deleted rows: {}", rows.size()); + + writeEqDeletes(rows); + } + + private void writeEqDeletes(List rows) throws IOException { + int equalityFieldId = table().schema().findField("longCol").fieldId(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()) + .dataFileFormat(fileFormat()) + .equalityDeleteRowSchema(table().schema()) + .equalityFieldIds(new int[] {equalityFieldId}) + .build(); + + ClusteredEqualityDeleteWriter writer = + new ClusteredEqualityDeleteWriter<>( + writerFactory, fileFactory, table().io(), TARGET_FILE_SIZE_IN_BYTES); + + PartitionSpec unpartitionedSpec = table().specs().get(0); + try (ClusteredEqualityDeleteWriter closeableWriter = writer) { + for (InternalRow row : rows) { + closeableWriter.write(row, unpartitionedSpec, null); + } + } + + RowDelta rowDelta = table().newRowDelta(); + LOG.info("Num of Delete File: {}", writer.result().deleteFiles().size()); + writer.result().deleteFiles().forEach(rowDelta::addDeletes); + rowDelta.validateDeletedFiles().commit(); + } + + private OutputFileFactory newFileFactory() { + return OutputFileFactory.builderFor(table(), 1, 1).format(fileFormat()).build(); + } + + private CharSequence noisePath(CharSequence path) { + // assume the data file name would be something like + // "00000-0-30da64e0-56b5-4743-a11b-3188a1695bf7-00001.parquet" + // so the dataFileSuffixLen is the UUID string length + length of "-00001.parquet", which is 36 + // + 14 = 60. It's OK + // to be not accurate here. + int dataFileSuffixLen = 60; + UUID uuid = UUID.randomUUID(); + if (path.length() > dataFileSuffixLen) { + return path.subSequence(0, dataFileSuffixLen) + uuid.toString(); + } else { + return uuid.toString(); + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceFlatDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceFlatDataBenchmark.java new file mode 100644 index 000000000000..59e6230350d9 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceFlatDataBenchmark.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; + +public abstract class IcebergSourceFlatDataBenchmark extends IcebergSourceBenchmark { + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + @Override + protected final Table initTable() { + Schema schema = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(5, "decimalCol", Types.DecimalType.of(20, 5)), + optional(6, "dateCol", Types.DateType.get()), + optional(7, "timestampCol", Types.TimestampType.withZone()), + optional(8, "stringCol", Types.StringType.get())); + PartitionSpec partitionSpec = PartitionSpec.unpartitioned(); + HadoopTables tables = new HadoopTables(hadoopConf()); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + return tables.create(schema, partitionSpec, properties, newTableLocation()); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceNestedDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceNestedDataBenchmark.java new file mode 100644 index 000000000000..a1c61b9b4de0 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceNestedDataBenchmark.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; + +public abstract class IcebergSourceNestedDataBenchmark extends IcebergSourceBenchmark { + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + @Override + protected final Table initTable() { + Schema schema = + new Schema( + required(0, "id", Types.LongType.get()), + optional( + 4, + "nested", + Types.StructType.of( + required(1, "col1", Types.StringType.get()), + required(2, "col2", Types.DoubleType.get()), + required(3, "col3", Types.LongType.get())))); + PartitionSpec partitionSpec = PartitionSpec.unpartitioned(); + HadoopTables tables = new HadoopTables(hadoopConf()); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + return tables.create(schema, partitionSpec, properties, newTableLocation()); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceNestedListDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceNestedListDataBenchmark.java new file mode 100644 index 000000000000..f68b587735dd --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/IcebergSourceNestedListDataBenchmark.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; + +public abstract class IcebergSourceNestedListDataBenchmark extends IcebergSourceBenchmark { + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + @Override + protected final Table initTable() { + Schema schema = + new Schema( + required(0, "id", Types.LongType.get()), + optional( + 1, + "outerlist", + Types.ListType.ofOptional( + 2, + Types.StructType.of( + required( + 3, + "innerlist", + Types.ListType.ofRequired(4, Types.StringType.get())))))); + PartitionSpec partitionSpec = PartitionSpec.unpartitioned(); + HadoopTables tables = new HadoopTables(hadoopConf()); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + return tables.create(schema, partitionSpec, properties, newTableLocation()); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/WritersBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/WritersBenchmark.java new file mode 100644 index 000000000000..06efb6ba20e4 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/WritersBenchmark.java @@ -0,0 +1,393 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.io.IOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionKey; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.ClusteredDataWriter; +import org.apache.iceberg.io.ClusteredEqualityDeleteWriter; +import org.apache.iceberg.io.ClusteredPositionDeleteWriter; +import org.apache.iceberg.io.FanoutDataWriter; +import org.apache.iceberg.io.FanoutPositionOnlyDeleteWriter; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.transforms.Transform; +import org.apache.iceberg.transforms.Transforms; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.infra.Blackhole; + +public abstract class WritersBenchmark extends IcebergSourceBenchmark { + + private static final int NUM_ROWS = 2500000; + private static final int NUM_DATA_FILES_PER_POSITION_DELETE_FILE = 100; + private static final int NUM_DELETED_POSITIONS_PER_DATA_FILE = 50_000; + private static final int DELETE_POSITION_STEP = 10; + private static final long TARGET_FILE_SIZE_IN_BYTES = 50L * 1024 * 1024; + + private static final Schema SCHEMA = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(5, "decimalCol", Types.DecimalType.of(20, 5)), + optional(6, "timestampCol", Types.TimestampType.withZone()), + optional(7, "stringCol", Types.StringType.get())); + + private Iterable rows; + private Iterable positionDeleteRows; + private Iterable shuffledPositionDeleteRows; + private PartitionSpec unpartitionedSpec; + private PartitionSpec partitionedSpec; + + @Override + protected abstract FileFormat fileFormat(); + + @Setup + public void setupBenchmark() { + setupSpark(); + + List data = Lists.newArrayList(RandomData.generateSpark(SCHEMA, NUM_ROWS, 0L)); + Transform transform = Transforms.bucket(32); + data.sort( + Comparator.comparingInt( + row -> transform.bind(Types.IntegerType.get()).apply(row.getInt(1)))); + this.rows = data; + + this.positionDeleteRows = generatePositionDeletes(false /* no shuffle */); + this.shuffledPositionDeleteRows = generatePositionDeletes(true /* shuffle */); + + this.unpartitionedSpec = table().specs().get(0); + Preconditions.checkArgument(unpartitionedSpec.isUnpartitioned()); + this.partitionedSpec = table().specs().get(1); + } + + private Iterable generatePositionDeletes(boolean shuffle) { + int numDeletes = NUM_DATA_FILES_PER_POSITION_DELETE_FILE * NUM_DELETED_POSITIONS_PER_DATA_FILE; + List deletes = Lists.newArrayListWithExpectedSize(numDeletes); + + for (int pathIndex = 0; pathIndex < NUM_DATA_FILES_PER_POSITION_DELETE_FILE; pathIndex++) { + UTF8String path = UTF8String.fromString("path/to/position/delete/file/" + UUID.randomUUID()); + for (long pos = 0; pos < NUM_DELETED_POSITIONS_PER_DATA_FILE; pos++) { + deletes.add(new GenericInternalRow(new Object[] {path, pos * DELETE_POSITION_STEP})); + } + } + + if (shuffle) { + Collections.shuffle(deletes); + } + + return deletes; + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + @Override + protected final Table initTable() { + HadoopTables tables = new HadoopTables(hadoopConf()); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map properties = Maps.newHashMap(); + Table table = tables.create(SCHEMA, spec, properties, newTableLocation()); + + // add a partitioned spec to the table + table.updateSpec().addField(Expressions.bucket("intCol", 32)).commit(); + + return table; + } + + @Benchmark + @Threads(1) + public void writeUnpartitionedClusteredDataWriter(Blackhole blackhole) throws IOException { + FileIO io = table().io(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()) + .dataFileFormat(fileFormat()) + .dataSchema(table().schema()) + .build(); + + ClusteredDataWriter writer = + new ClusteredDataWriter<>(writerFactory, fileFactory, io, TARGET_FILE_SIZE_IN_BYTES); + + try (ClusteredDataWriter closeableWriter = writer) { + for (InternalRow row : rows) { + closeableWriter.write(row, unpartitionedSpec, null); + } + } + + blackhole.consume(writer); + } + + @Benchmark + @Threads(1) + public void writePartitionedClusteredDataWriter(Blackhole blackhole) throws IOException { + FileIO io = table().io(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()) + .dataFileFormat(fileFormat()) + .dataSchema(table().schema()) + .build(); + + ClusteredDataWriter writer = + new ClusteredDataWriter<>(writerFactory, fileFactory, io, TARGET_FILE_SIZE_IN_BYTES); + + PartitionKey partitionKey = new PartitionKey(partitionedSpec, table().schema()); + StructType dataSparkType = SparkSchemaUtil.convert(table().schema()); + InternalRowWrapper internalRowWrapper = + new InternalRowWrapper(dataSparkType, table().schema().asStruct()); + + try (ClusteredDataWriter closeableWriter = writer) { + for (InternalRow row : rows) { + partitionKey.partition(internalRowWrapper.wrap(row)); + closeableWriter.write(row, partitionedSpec, partitionKey); + } + } + + blackhole.consume(writer); + } + + @Benchmark + @Threads(1) + public void writePartitionedFanoutDataWriter(Blackhole blackhole) throws IOException { + FileIO io = table().io(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()) + .dataFileFormat(fileFormat()) + .dataSchema(table().schema()) + .build(); + + FanoutDataWriter writer = + new FanoutDataWriter<>(writerFactory, fileFactory, io, TARGET_FILE_SIZE_IN_BYTES); + + PartitionKey partitionKey = new PartitionKey(partitionedSpec, table().schema()); + StructType dataSparkType = SparkSchemaUtil.convert(table().schema()); + InternalRowWrapper internalRowWrapper = + new InternalRowWrapper(dataSparkType, table().schema().asStruct()); + + try (FanoutDataWriter closeableWriter = writer) { + for (InternalRow row : rows) { + partitionKey.partition(internalRowWrapper.wrap(row)); + closeableWriter.write(row, partitionedSpec, partitionKey); + } + } + + blackhole.consume(writer); + } + + @Benchmark + @Threads(1) + public void writePartitionedClusteredEqualityDeleteWriter(Blackhole blackhole) + throws IOException { + FileIO io = table().io(); + + int equalityFieldId = table().schema().findField("longCol").fieldId(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()) + .dataFileFormat(fileFormat()) + .equalityDeleteRowSchema(table().schema()) + .equalityFieldIds(new int[] {equalityFieldId}) + .build(); + + ClusteredEqualityDeleteWriter writer = + new ClusteredEqualityDeleteWriter<>( + writerFactory, fileFactory, io, TARGET_FILE_SIZE_IN_BYTES); + + PartitionKey partitionKey = new PartitionKey(partitionedSpec, table().schema()); + StructType deleteSparkType = SparkSchemaUtil.convert(table().schema()); + InternalRowWrapper internalRowWrapper = + new InternalRowWrapper(deleteSparkType, table().schema().asStruct()); + + try (ClusteredEqualityDeleteWriter closeableWriter = writer) { + for (InternalRow row : rows) { + partitionKey.partition(internalRowWrapper.wrap(row)); + closeableWriter.write(row, partitionedSpec, partitionKey); + } + } + + blackhole.consume(writer); + } + + @Benchmark + @Threads(1) + public void writeUnpartitionedClusteredPositionDeleteWriterPartitionGranularity( + Blackhole blackhole) throws IOException { + writeUnpartitionedClusteredPositionDeleteWriter(blackhole, DeleteGranularity.PARTITION); + } + + @Benchmark + @Threads(1) + public void writeUnpartitionedClusteredPositionDeleteWriterFileGranularity(Blackhole blackhole) + throws IOException { + writeUnpartitionedClusteredPositionDeleteWriter(blackhole, DeleteGranularity.FILE); + } + + private void writeUnpartitionedClusteredPositionDeleteWriter( + Blackhole blackhole, DeleteGranularity deleteGranularity) throws IOException { + FileIO io = table().io(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()).dataFileFormat(fileFormat()).build(); + + ClusteredPositionDeleteWriter writer = + new ClusteredPositionDeleteWriter<>( + writerFactory, fileFactory, io, TARGET_FILE_SIZE_IN_BYTES, deleteGranularity); + + PositionDelete positionDelete = PositionDelete.create(); + try (ClusteredPositionDeleteWriter closeableWriter = writer) { + for (InternalRow row : positionDeleteRows) { + String path = row.getString(0); + long pos = row.getLong(1); + positionDelete.set(path, pos, null); + closeableWriter.write(positionDelete, unpartitionedSpec, null); + } + } + + blackhole.consume(writer); + } + + @Benchmark + @Threads(1) + public void writeUnpartitionedFanoutPositionDeleteWriterPartitionGranularity(Blackhole blackhole) + throws IOException { + writeUnpartitionedFanoutPositionDeleteWriterPartition(blackhole, DeleteGranularity.PARTITION); + } + + @Benchmark + @Threads(1) + public void writeUnpartitionedFanoutPositionDeleteWriterFileGranularity(Blackhole blackhole) + throws IOException { + writeUnpartitionedFanoutPositionDeleteWriterPartition(blackhole, DeleteGranularity.FILE); + } + + private void writeUnpartitionedFanoutPositionDeleteWriterPartition( + Blackhole blackhole, DeleteGranularity deleteGranularity) throws IOException { + FileIO io = table().io(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()).dataFileFormat(fileFormat()).build(); + + FanoutPositionOnlyDeleteWriter writer = + new FanoutPositionOnlyDeleteWriter<>( + writerFactory, fileFactory, io, TARGET_FILE_SIZE_IN_BYTES, deleteGranularity); + + PositionDelete positionDelete = PositionDelete.create(); + try (FanoutPositionOnlyDeleteWriter closeableWriter = writer) { + for (InternalRow row : positionDeleteRows) { + String path = row.getString(0); + long pos = row.getLong(1); + positionDelete.set(path, pos, null); + closeableWriter.write(positionDelete, unpartitionedSpec, null); + } + } + + blackhole.consume(writer); + } + + @Benchmark + @Threads(1) + public void writeUnpartitionedFanoutPositionDeleteWriterShuffledPartitionGranularity( + Blackhole blackhole) throws IOException { + writeUnpartitionedFanoutPositionDeleteWriterShuffled(blackhole, DeleteGranularity.PARTITION); + } + + @Benchmark + @Threads(1) + public void writeUnpartitionedFanoutPositionDeleteWriterShuffledFileGranularity( + Blackhole blackhole) throws IOException { + writeUnpartitionedFanoutPositionDeleteWriterShuffled(blackhole, DeleteGranularity.FILE); + } + + private void writeUnpartitionedFanoutPositionDeleteWriterShuffled( + Blackhole blackhole, DeleteGranularity deleteGranularity) throws IOException { + + FileIO io = table().io(); + + OutputFileFactory fileFactory = newFileFactory(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table()).dataFileFormat(fileFormat()).build(); + + FanoutPositionOnlyDeleteWriter writer = + new FanoutPositionOnlyDeleteWriter<>( + writerFactory, fileFactory, io, TARGET_FILE_SIZE_IN_BYTES, deleteGranularity); + + PositionDelete positionDelete = PositionDelete.create(); + try (FanoutPositionOnlyDeleteWriter closeableWriter = writer) { + for (InternalRow row : shuffledPositionDeleteRows) { + String path = row.getString(0); + long pos = row.getLong(1); + positionDelete.set(path, pos, null); + closeableWriter.write(positionDelete, unpartitionedSpec, null); + } + } + + blackhole.consume(writer); + } + + private OutputFileFactory newFileFactory() { + return OutputFileFactory.builderFor(table(), 1, 1).format(fileFormat()).build(); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/AvroWritersBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/AvroWritersBenchmark.java new file mode 100644 index 000000000000..5c46a740dabe --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/AvroWritersBenchmark.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.avro; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.spark.source.WritersBenchmark; + +/** + * A benchmark that evaluates the performance of various Iceberg writers for Avro data. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=AvroWritersBenchmark + * -PjmhOutputPath=benchmark/avro-writers-benchmark-result.txt + * + */ +public class AvroWritersBenchmark extends WritersBenchmark { + + @Override + protected FileFormat fileFormat() { + return FileFormat.AVRO; + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceFlatAvroDataReadBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceFlatAvroDataReadBenchmark.java new file mode 100644 index 000000000000..23f72f0dff9f --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceFlatAvroDataReadBenchmark.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.avro; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceFlatDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of reading Avro data with a flat schema using Iceberg + * and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceFlatAvroDataReadBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-flat-avro-data-read-benchmark-result.txt + * + */ +public class IcebergSourceFlatAvroDataReadBenchmark extends IcebergSourceFlatDataBenchmark { + + private static final int NUM_FILES = 10; + private static final int NUM_ROWS = 1000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSource() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().format("avro").load(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation).select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSource() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().format("avro").load(dataLocation()).select("longCol"); + materialize(df); + }); + } + + private void appendData() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(DEFAULT_FILE_FORMAT, "avro"); + withTableProperties( + tableProperties, + () -> { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumnRenamed("id", "longCol") + .withColumn("intCol", expr("CAST(longCol AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("decimalCol", expr("CAST(longCol AS DECIMAL(20, 5))")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")); + appendAsFile(df); + } + }); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceNestedAvroDataReadBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceNestedAvroDataReadBenchmark.java new file mode 100644 index 000000000000..fe641747dfd7 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/avro/IcebergSourceNestedAvroDataReadBenchmark.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.avro; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.struct; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceNestedDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of reading Avro data with a flat schema using Iceberg + * and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceNestedAvroDataReadBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-nested-avro-data-read-benchmark-result.txt + * + */ +public class IcebergSourceNestedAvroDataReadBenchmark extends IcebergSourceNestedDataBenchmark { + + private static final int NUM_FILES = 10; + private static final int NUM_ROWS = 1000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSource() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().format("avro").load(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("nested.col3"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSource() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = + spark().read().format("avro").load(dataLocation()).select("nested.col3"); + materialize(df); + }); + } + + private void appendData() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(DEFAULT_FILE_FORMAT, "avro"); + withTableProperties( + tableProperties, + () -> { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumn( + "nested", + struct( + expr("CAST(id AS string) AS col1"), + expr("CAST(id AS double) AS col2"), + lit(fileNum).cast("long").as("col3"))); + appendAsFile(df); + } + }); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataBenchmark.java new file mode 100644 index 000000000000..d0fdd8915780 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataBenchmark.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.orc; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceBenchmark; +import org.apache.iceberg.types.Types; + +/** + * Same as {@link org.apache.iceberg.spark.source.IcebergSourceFlatDataBenchmark} but we disable the + * Timestamp with zone type for ORC performance tests as Spark native reader does not support ORC's + * TIMESTAMP_INSTANT type + */ +public abstract class IcebergSourceFlatORCDataBenchmark extends IcebergSourceBenchmark { + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + @Override + protected final Table initTable() { + Schema schema = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(5, "decimalCol", Types.DecimalType.of(20, 5)), + optional(6, "dateCol", Types.DateType.get()), + // Disable timestamp column for ORC performance tests as Spark native reader does not + // support ORC's + // TIMESTAMP_INSTANT type + // optional(7, "timestampCol", Types.TimestampType.withZone()), + optional(8, "stringCol", Types.StringType.get())); + PartitionSpec partitionSpec = PartitionSpec.unpartitioned(); + HadoopTables tables = new HadoopTables(hadoopConf()); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + return tables.create(schema, partitionSpec, properties, newTableLocation()); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataReadBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataReadBenchmark.java new file mode 100644 index 000000000000..7b473770bf3e --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceFlatORCDataReadBenchmark.java @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.orc; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of reading ORC data with a flat schema using Iceberg + * and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceFlatORCDataReadBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-flat-orc-data-read-benchmark-result.txt + * + */ +public class IcebergSourceFlatORCDataReadBenchmark extends IcebergSourceFlatORCDataBenchmark { + + private static final int NUM_FILES = 10; + private static final int NUM_ROWS = 1000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readIcebergNonVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readIcebergVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark() + .read() + .option(SparkReadOptions.VECTORIZATION_ENABLED, "true") + .format("iceberg") + .load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.ORC_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().orc(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.ORC_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().orc(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIcebergNonVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation).select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIcebergVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark() + .read() + .option(SparkReadOptions.VECTORIZATION_ENABLED, "true") + .format("iceberg") + .load(tableLocation) + .select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.ORC_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().orc(dataLocation()).select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.ORC_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().orc(dataLocation()).select("longCol"); + materialize(df); + }); + } + + private void appendData() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(DEFAULT_FILE_FORMAT, "orc"); + withTableProperties( + tableProperties, + () -> { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumnRenamed("id", "longCol") + .withColumn("intCol", expr("CAST(longCol AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("decimalCol", expr("CAST(longCol AS DECIMAL(20, 5))")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")); + appendAsFile(df); + } + }); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedListORCDataWriteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedListORCDataWriteBenchmark.java new file mode 100644 index 000000000000..3053b3c93c07 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedListORCDataWriteBenchmark.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.orc; + +import static org.apache.spark.sql.functions.array_repeat; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.struct; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceNestedListDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the + * built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceNestedListORCDataWriteBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-nested-list-orc-data-write-benchmark-result.txt + * + */ +public class IcebergSourceNestedListORCDataWriteBenchmark + extends IcebergSourceNestedListDataBenchmark { + + @Setup + public void setupBenchmark() { + setupSpark(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Param({"2000", "20000"}) + private int numRows; + + @Benchmark + @Threads(1) + public void writeIceberg() { + String tableLocation = table().location(); + benchmarkData() + .write() + .format("iceberg") + .option("write-format", "orc") + .mode(SaveMode.Append) + .save(tableLocation); + } + + @Benchmark + @Threads(1) + public void writeIcebergDictionaryOff() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put("orc.dictionary.key.threshold", "0"); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + benchmarkData() + .write() + .format("iceberg") + .option("write-format", "orc") + .mode(SaveMode.Append) + .save(tableLocation); + }); + } + + @Benchmark + @Threads(1) + public void writeFileSource() { + benchmarkData().write().mode(SaveMode.Append).orc(dataLocation()); + } + + private Dataset benchmarkData() { + return spark() + .range(numRows) + .withColumn( + "outerlist", + array_repeat(struct(expr("array_repeat(CAST(id AS string), 1000) AS innerlist")), 10)) + .coalesce(1); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedORCDataReadBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedORCDataReadBenchmark.java new file mode 100644 index 000000000000..6faeb21b5a16 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/orc/IcebergSourceNestedORCDataReadBenchmark.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.orc; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.struct; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.source.IcebergSourceNestedDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of reading ORC data with a flat schema using Iceberg + * and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceNestedORCDataReadBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-nested-orc-data-read-benchmark-result.txt + * + */ +public class IcebergSourceNestedORCDataReadBenchmark extends IcebergSourceNestedDataBenchmark { + + private static final int NUM_FILES = 10; + private static final int NUM_ROWS = 1000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readIcebergNonVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readIcebergVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark() + .read() + .option(SparkReadOptions.VECTORIZATION_ENABLED, "true") + .format("iceberg") + .load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.ORC_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().orc(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIcebergNonVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).selectExpr("nested.col3"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIcebergVectorized() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark() + .read() + .option(SparkReadOptions.VECTORIZATION_ENABLED, "true") + .format("iceberg") + .load(tableLocation) + .selectExpr("nested.col3"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.ORC_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().orc(dataLocation()).selectExpr("nested.col3"); + materialize(df); + }); + } + + private void appendData() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(DEFAULT_FILE_FORMAT, "orc"); + withTableProperties( + tableProperties, + () -> { + for (int fileNum = 0; fileNum < NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumn( + "nested", + struct( + expr("CAST(id AS string) AS col1"), + expr("CAST(id AS double) AS col2"), + lit(fileNum).cast("long").as("col3"))); + appendAsFile(df); + } + }); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataFilterBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataFilterBenchmark.java new file mode 100644 index 000000000000..73e479053b70 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataFilterBenchmark.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceFlatDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the file skipping capabilities in the Spark data source for Iceberg. + * + *

    This class uses a dataset with a flat schema, where the records are clustered according to the + * column used in the filter predicate. + * + *

    The performance is compared to the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceFlatParquetDataFilterBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-filter-benchmark-result.txt + * + */ +public class IcebergSourceFlatParquetDataFilterBenchmark extends IcebergSourceFlatDataBenchmark { + + private static final String FILTER_COND = "dateCol == date_add(current_date(), 1)"; + private static final int NUM_FILES = 500; + private static final int NUM_ROWS = 10000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readWithFilterIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).filter(FILTER_COND); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithFilterFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).filter(FILTER_COND); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithFilterFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).filter(FILTER_COND); + materialize(df); + }); + } + + private void appendData() { + for (int fileNum = 1; fileNum < NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumnRenamed("id", "longCol") + .withColumn("intCol", expr("CAST(longCol AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("decimalCol", expr("CAST(longCol AS DECIMAL(20, 5))")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")); + appendAsFile(df); + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataReadBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataReadBenchmark.java new file mode 100644 index 000000000000..f95de3cf7f6f --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataReadBenchmark.java @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceFlatDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of reading Parquet data with a flat schema using + * Iceberg and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceFlatParquetDataReadBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-read-benchmark-result.txt + * + */ +public class IcebergSourceFlatParquetDataReadBenchmark extends IcebergSourceFlatDataBenchmark { + + private static final int NUM_FILES = 10; + private static final int NUM_ROWS = 1000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation).select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("longCol"); + materialize(df); + }); + } + + private void appendData() { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumnRenamed("id", "longCol") + .withColumn("intCol", expr("CAST(longCol AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("decimalCol", expr("CAST(longCol AS DECIMAL(20, 5))")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")); + appendAsFile(df); + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataWriteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataWriteBenchmark.java new file mode 100644 index 000000000000..dccd0c76647c --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceFlatParquetDataWriteBenchmark.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import static org.apache.spark.sql.functions.expr; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceFlatDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of writing Parquet data with a flat schema using + * Iceberg and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceFlatParquetDataWriteBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-flat-parquet-data-write-benchmark-result.txt + * + */ +public class IcebergSourceFlatParquetDataWriteBenchmark extends IcebergSourceFlatDataBenchmark { + + private static final int NUM_ROWS = 5000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void writeIceberg() { + String tableLocation = table().location(); + benchmarkData().write().format("iceberg").mode(SaveMode.Append).save(tableLocation); + } + + @Benchmark + @Threads(1) + public void writeFileSource() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_COMPRESSION().key(), "gzip"); + withSQLConf(conf, () -> benchmarkData().write().mode(SaveMode.Append).parquet(dataLocation())); + } + + private Dataset benchmarkData() { + return spark() + .range(NUM_ROWS) + .withColumnRenamed("id", "longCol") + .withColumn("intCol", expr("CAST(longCol AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("decimalCol", expr("CAST(longCol AS DECIMAL(20, 5))")) + .withColumn("dateCol", expr("DATE_ADD(CURRENT_DATE(), (intCol % 20))")) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")) + .coalesce(1); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedListParquetDataWriteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedListParquetDataWriteBenchmark.java new file mode 100644 index 000000000000..383604c186ef --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedListParquetDataWriteBenchmark.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import static org.apache.spark.sql.functions.array_repeat; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.struct; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceNestedListDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the + * built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceNestedListParquetDataWriteBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-nested-list-parquet-data-write-benchmark-result.txt + * + */ +public class IcebergSourceNestedListParquetDataWriteBenchmark + extends IcebergSourceNestedListDataBenchmark { + + @Setup + public void setupBenchmark() { + setupSpark(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Param({"2000", "20000"}) + private int numRows; + + @Benchmark + @Threads(1) + public void writeIceberg() { + String tableLocation = table().location(); + benchmarkData().write().format("iceberg").mode(SaveMode.Append).save(tableLocation); + } + + @Benchmark + @Threads(1) + public void writeFileSource() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_COMPRESSION().key(), "gzip"); + withSQLConf(conf, () -> benchmarkData().write().mode(SaveMode.Append).parquet(dataLocation())); + } + + private Dataset benchmarkData() { + return spark() + .range(numRows) + .withColumn( + "outerlist", + array_repeat(struct(expr("array_repeat(CAST(id AS string), 1000) AS innerlist")), 10)) + .coalesce(1); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataFilterBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataFilterBenchmark.java new file mode 100644 index 000000000000..c24e336c1283 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataFilterBenchmark.java @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.struct; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceNestedDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the file skipping capabilities in the Spark data source for Iceberg. + * + *

    This class uses a dataset with nested data, where the records are clustered according to the + * column used in the filter predicate. + * + *

    The performance is compared to the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceNestedParquetDataFilterBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-filter-benchmark-result.txt + * + */ +public class IcebergSourceNestedParquetDataFilterBenchmark + extends IcebergSourceNestedDataBenchmark { + + private static final String FILTER_COND = "nested.col3 == 0"; + private static final int NUM_FILES = 500; + private static final int NUM_ROWS = 10000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readWithFilterIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).filter(FILTER_COND); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithFilterFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).filter(FILTER_COND); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithFilterFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).filter(FILTER_COND); + materialize(df); + }); + } + + private void appendData() { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumn( + "nested", + struct( + expr("CAST(id AS string) AS col1"), + expr("CAST(id AS double) AS col2"), + lit(fileNum).cast("long").as("col3"))); + appendAsFile(df); + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataReadBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataReadBenchmark.java new file mode 100644 index 000000000000..1c365d49f274 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataReadBenchmark.java @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.struct; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceNestedDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of reading nested Parquet data using Iceberg and the + * built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceNestedParquetDataReadBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-read-benchmark-result.txt + * + */ +public class IcebergSourceNestedParquetDataReadBenchmark extends IcebergSourceNestedDataBenchmark { + + private static final int NUM_FILES = 10; + private static final int NUM_ROWS = 1000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void readIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionIceberg() { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(SPLIT_OPEN_FILE_COST, Integer.toString(128 * 1024 * 1024)); + withTableProperties( + tableProperties, + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).selectExpr("nested.col3"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSourceVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + conf.put(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED().key(), "true"); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).selectExpr("nested.col3"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readWithProjectionFileSourceNonVectorized() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "false"); + conf.put(SQLConf.FILES_OPEN_COST_IN_BYTES().key(), Integer.toString(128 * 1024 * 1024)); + conf.put(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED().key(), "true"); + withSQLConf( + conf, + () -> { + Dataset df = spark().read().parquet(dataLocation()).selectExpr("nested.col3"); + materialize(df); + }); + } + + private void appendData() { + for (int fileNum = 0; fileNum < NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS) + .withColumn( + "nested", + struct( + expr("CAST(id AS string) AS col1"), + expr("CAST(id AS double) AS col2"), + lit(fileNum).cast("long").as("col3"))); + appendAsFile(df); + } + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataWriteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataWriteBenchmark.java new file mode 100644 index 000000000000..e851e6a930e4 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceNestedParquetDataWriteBenchmark.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.struct; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceNestedDataBenchmark; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * A benchmark that evaluates the performance of writing nested Parquet data using Iceberg and the + * built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh + * -PjmhIncludeRegex=IcebergSourceNestedParquetDataWriteBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-nested-parquet-data-write-benchmark-result.txt + * + */ +public class IcebergSourceNestedParquetDataWriteBenchmark extends IcebergSourceNestedDataBenchmark { + + private static final int NUM_ROWS = 5000000; + + @Setup + public void setupBenchmark() { + setupSpark(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Benchmark + @Threads(1) + public void writeIceberg() { + String tableLocation = table().location(); + benchmarkData().write().format("iceberg").mode(SaveMode.Append).save(tableLocation); + } + + @Benchmark + @Threads(1) + public void writeFileSource() { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_COMPRESSION().key(), "gzip"); + withSQLConf(conf, () -> benchmarkData().write().mode(SaveMode.Append).parquet(dataLocation())); + } + + private Dataset benchmarkData() { + return spark() + .range(NUM_ROWS) + .withColumn( + "nested", + struct( + expr("CAST(id AS string) AS col1"), + expr("CAST(id AS double) AS col2"), + expr("id AS col3"))) + .coalesce(1); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetEqDeleteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetEqDeleteBenchmark.java new file mode 100644 index 000000000000..d4555f8c6b76 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetEqDeleteBenchmark.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import java.io.IOException; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; +import org.openjdk.jmh.annotations.Param; + +/** + * A benchmark that evaluates the non-vectorized read and vectorized read with equality delete in + * the Spark data source for Iceberg. + * + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1:jmh + * -PjmhIncludeRegex=IcebergSourceParquetEqDeleteBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-parquet-eq-delete-benchmark-result.txt + * + */ +public class IcebergSourceParquetEqDeleteBenchmark extends IcebergSourceDeleteBenchmark { + @Param({"0", "0.000001", "0.05", "0.25", "0.5", "1"}) + private double percentDeleteRow; + + @Override + protected void appendData() throws IOException { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + writeData(fileNum); + + if (percentDeleteRow > 0) { + // add equality deletes + table().refresh(); + writeEqDeletes(NUM_ROWS, percentDeleteRow); + } + } + } + + @Override + protected FileFormat fileFormat() { + return FileFormat.PARQUET; + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java new file mode 100644 index 000000000000..2ebb3ff5b4f2 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetMultiDeleteFileBenchmark.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import java.io.IOException; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; +import org.openjdk.jmh.annotations.Param; + +/** + * A benchmark that evaluates the non-vectorized read and vectorized read with pos-delete in the + * Spark data source for Iceberg. + * + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1:jmh \ + * -PjmhIncludeRegex=IcebergSourceParquetMultiDeleteFileBenchmark \ + * -PjmhOutputPath=benchmark/iceberg-source-parquet-multi-delete-file-benchmark-result.txt + * + */ +public class IcebergSourceParquetMultiDeleteFileBenchmark extends IcebergSourceDeleteBenchmark { + @Param({"1", "2", "5", "10"}) + private int numDeleteFile; + + @Override + protected void appendData() throws IOException { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + writeData(fileNum); + + table().refresh(); + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { + writePosDeletes(file.location(), NUM_ROWS, 0.25, numDeleteFile); + } + } + } + + @Override + protected FileFormat fileFormat() { + return FileFormat.PARQUET; + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java new file mode 100644 index 000000000000..fd837e8e826e --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetPosDeleteBenchmark.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import java.io.IOException; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; +import org.openjdk.jmh.annotations.Param; + +/** + * A benchmark that evaluates the non-vectorized read and vectorized read with pos-delete in the + * Spark data source for Iceberg. + * + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1:jmh + * -PjmhIncludeRegex=IcebergSourceParquetPosDeleteBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-parquet-pos-delete-benchmark-result.txt + * + */ +public class IcebergSourceParquetPosDeleteBenchmark extends IcebergSourceDeleteBenchmark { + @Param({"0", "0.000001", "0.05", "0.25", "0.5", "1"}) + private double percentDeleteRow; + + @Override + protected void appendData() throws IOException { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + writeData(fileNum); + + if (percentDeleteRow > 0) { + // add pos-deletes + table().refresh(); + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { + writePosDeletes(file.location(), NUM_ROWS, percentDeleteRow); + } + } + } + } + + @Override + protected FileFormat fileFormat() { + return FileFormat.PARQUET; + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java new file mode 100644 index 000000000000..273b3f1cee4d --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/IcebergSourceParquetWithUnrelatedDeleteBenchmark.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import java.io.IOException; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.spark.source.IcebergSourceDeleteBenchmark; +import org.openjdk.jmh.annotations.Param; + +/** + * A benchmark that evaluates the non-vectorized read and vectorized read with pos-delete in the + * Spark data source for Iceberg. + * + *

    This class uses a dataset with a flat schema. To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1:jmh + * -PjmhIncludeRegex=IcebergSourceParquetWithUnrelatedDeleteBenchmark + * -PjmhOutputPath=benchmark/iceberg-source-parquet-with-unrelated-delete-benchmark-result.txt + * + */ +public class IcebergSourceParquetWithUnrelatedDeleteBenchmark extends IcebergSourceDeleteBenchmark { + private static final double PERCENT_DELETE_ROW = 0.05; + + @Param({"0", "0.05", "0.25", "0.5"}) + private double percentUnrelatedDeletes; + + @Override + protected void appendData() throws IOException { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + writeData(fileNum); + + table().refresh(); + for (DataFile file : SnapshotChanges.builderFor(table()).build().addedDataFiles()) { + writePosDeletesWithNoise( + file.location(), + NUM_ROWS, + PERCENT_DELETE_ROW, + (int) (percentUnrelatedDeletes / PERCENT_DELETE_ROW), + 1); + } + } + } + + @Override + protected FileFormat fileFormat() { + return FileFormat.PARQUET; + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/ParquetWritersBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/ParquetWritersBenchmark.java new file mode 100644 index 000000000000..2b8e522148ed --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/ParquetWritersBenchmark.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.spark.source.WritersBenchmark; + +/** + * A benchmark that evaluates the performance of various Iceberg writers for Parquet data. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh \ + * -PjmhIncludeRegex=ParquetWritersBenchmark \ + * -PjmhOutputPath=benchmark/parquet-writers-benchmark-result.txt + * + */ +public class ParquetWritersBenchmark extends WritersBenchmark { + + @Override + protected FileFormat fileFormat() { + return FileFormat.PARQUET; + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadDictionaryEncodedFlatParquetDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadDictionaryEncodedFlatParquetDataBenchmark.java new file mode 100644 index 000000000000..8c88a4952f2c --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadDictionaryEncodedFlatParquetDataBenchmark.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet.vectorized; + +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.pmod; +import static org.apache.spark.sql.functions.to_date; +import static org.apache.spark.sql.functions.to_timestamp; + +import java.util.Map; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.types.DataTypes; +import org.openjdk.jmh.annotations.Setup; + +/** + * Benchmark to compare performance of reading Parquet dictionary encoded data with a flat schema + * using vectorized Iceberg read path and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh \ + * -PjmhIncludeRegex=VectorizedReadDictionaryEncodedFlatParquetDataBenchmark \ + * -PjmhOutputPath=benchmark/results.txt + * + */ +public class VectorizedReadDictionaryEncodedFlatParquetDataBenchmark + extends VectorizedReadFlatParquetDataBenchmark { + + @Setup + @Override + public void setupBenchmark() { + setupSpark(true); + appendData(); + } + + @Override + Map parquetWriteProps() { + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + return properties; + } + + @Override + void appendData() { + Dataset df = idDF(); + df = withLongColumnDictEncoded(df); + df = withIntColumnDictEncoded(df); + df = withFloatColumnDictEncoded(df); + df = withDoubleColumnDictEncoded(df); + df = withBigDecimalColumnNotDictEncoded(df); // no dictionary for fixed len binary in Parquet v1 + df = withDecimalColumnDictEncoded(df); + df = withDateColumnDictEncoded(df); + df = withTimestampColumnDictEncoded(df); + df = withStringColumnDictEncoded(df); + df = df.drop("id"); + df.write().format("iceberg").mode(SaveMode.Append).save(table().location()); + } + + private static Column modColumn() { + return pmod(col("id"), lit(9)); + } + + private Dataset idDF() { + return spark().range(0, NUM_ROWS_PER_FILE * NUM_FILES, 1, NUM_FILES).toDF(); + } + + private static Dataset withLongColumnDictEncoded(Dataset df) { + return df.withColumn("longCol", modColumn().cast(DataTypes.LongType)); + } + + private static Dataset withIntColumnDictEncoded(Dataset df) { + return df.withColumn("intCol", modColumn().cast(DataTypes.IntegerType)); + } + + private static Dataset withFloatColumnDictEncoded(Dataset df) { + return df.withColumn("floatCol", modColumn().cast(DataTypes.FloatType)); + } + + private static Dataset withDoubleColumnDictEncoded(Dataset df) { + return df.withColumn("doubleCol", modColumn().cast(DataTypes.DoubleType)); + } + + private static Dataset withBigDecimalColumnNotDictEncoded(Dataset df) { + return df.withColumn("bigDecimalCol", modColumn().cast("decimal(20,5)")); + } + + private static Dataset withDecimalColumnDictEncoded(Dataset df) { + return df.withColumn("decimalCol", modColumn().cast("decimal(18,5)")); + } + + private static Dataset withDateColumnDictEncoded(Dataset df) { + Column days = modColumn().cast(DataTypes.ShortType); + return df.withColumn("dateCol", date_add(to_date(lit("04/12/2019"), "MM/dd/yyyy"), days)); + } + + private static Dataset withTimestampColumnDictEncoded(Dataset df) { + Column days = modColumn().cast(DataTypes.ShortType); + return df.withColumn( + "timestampCol", to_timestamp(date_add(to_date(lit("04/12/2019"), "MM/dd/yyyy"), days))); + } + + private static Dataset withStringColumnDictEncoded(Dataset df) { + return df.withColumn("stringCol", modColumn().cast(DataTypes.StringType)); + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadFlatParquetDataBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadFlatParquetDataBenchmark.java new file mode 100644 index 000000000000..429171212a77 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadFlatParquetDataBenchmark.java @@ -0,0 +1,355 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet.vectorized; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.pmod; +import static org.apache.spark.sql.functions.when; + +import java.io.IOException; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceBenchmark; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * Benchmark to compare performance of reading Parquet data with a flat schema using vectorized + * Iceberg read path and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh \ + * -PjmhIncludeRegex=VectorizedReadFlatParquetDataBenchmark \ + * -PjmhOutputPath=benchmark/results.txt + * + */ +public class VectorizedReadFlatParquetDataBenchmark extends IcebergSourceBenchmark { + + static final int NUM_FILES = 5; + static final int NUM_ROWS_PER_FILE = 10_000_000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + @Override + protected Table initTable() { + // bigDecimalCol is big enough to be encoded as fix len binary (9 bytes), + // decimalCol is small enough to be encoded as a 64-bit int + Schema schema = + new Schema( + optional(1, "longCol", Types.LongType.get()), + optional(2, "intCol", Types.IntegerType.get()), + optional(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(5, "bigDecimalCol", Types.DecimalType.of(20, 5)), + optional(6, "decimalCol", Types.DecimalType.of(18, 5)), + optional(7, "dateCol", Types.DateType.get()), + optional(8, "timestampCol", Types.TimestampType.withZone()), + optional(9, "stringCol", Types.StringType.get())); + PartitionSpec partitionSpec = PartitionSpec.unpartitioned(); + HadoopTables tables = new HadoopTables(hadoopConf()); + Map properties = parquetWriteProps(); + return tables.create(schema, partitionSpec, properties, newTableLocation()); + } + + Map parquetWriteProps() { + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + properties.put(TableProperties.PARQUET_DICT_SIZE_BYTES, "1"); + return properties; + } + + void appendData() { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS_PER_FILE) + .withColumn( + "longCol", + when(pmod(col("id"), lit(10)).equalTo(lit(0)), lit(null)).otherwise(col("id"))) + .drop("id") + .withColumn("intCol", expr("CAST(longCol AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("bigDecimalCol", expr("CAST(longCol AS DECIMAL(20, 5))")) + .withColumn("decimalCol", expr("CAST(longCol AS DECIMAL(18, 5))")) + .withColumn("dateCol", date_add(current_date(), fileNum)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(longCol AS STRING)")); + appendAsFile(df); + } + } + + @Benchmark + @Threads(1) + public void readIntegersIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation).select("intCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readIntegersSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("intCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readLongsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation).select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readLongsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("longCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFloatsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation).select("floatCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readFloatsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("floatCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDoublesIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("doubleCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDoublesSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("doubleCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDecimalsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("decimalCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDecimalsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("decimalCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readBigDecimalsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("bigDecimalCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readBigDecimalsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("bigDecimalCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDatesIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = spark().read().format("iceberg").load(tableLocation).select("dateCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDatesSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("dateCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readTimestampsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("timestampCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readTimestampsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("timestampCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readStringsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("stringCol"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readStringsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("stringCol"); + materialize(df); + }); + } + + private static Map tablePropsWithVectorizationEnabled(int batchSize) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(TableProperties.PARQUET_VECTORIZATION_ENABLED, "true"); + tableProperties.put(TableProperties.PARQUET_BATCH_SIZE, String.valueOf(batchSize)); + return tableProperties; + } + + private static Map sparkConfWithVectorizationEnabled(int batchSize) { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_BATCH_SIZE().key(), String.valueOf(batchSize)); + return conf; + } +} diff --git a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadParquetDecimalBenchmark.java b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadParquetDecimalBenchmark.java new file mode 100644 index 000000000000..3aafe8a72ac9 --- /dev/null +++ b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/source/parquet/vectorized/VectorizedReadParquetDecimalBenchmark.java @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.parquet.vectorized; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.pmod; +import static org.apache.spark.sql.functions.when; + +import java.io.IOException; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.IcebergSourceBenchmark; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; + +/** + * Benchmark to compare performance of reading Parquet decimal data using vectorized Iceberg read + * path and the built-in file source in Spark. + * + *

    To run this benchmark for spark-4.1: + * ./gradlew -DsparkVersions=4.1 :iceberg-spark:iceberg-spark-4.1_2.13:jmh \ + * -PjmhIncludeRegex=VectorizedReadParquetDecimalBenchmark \ + * -PjmhOutputPath=benchmark/results.txt + * + */ +public class VectorizedReadParquetDecimalBenchmark extends IcebergSourceBenchmark { + + static final int NUM_FILES = 5; + static final int NUM_ROWS_PER_FILE = 10_000_000; + + @Setup + public void setupBenchmark() { + setupSpark(); + appendData(); + // Allow unsafe memory access to avoid the costly check arrow does to check if index is within + // bounds + System.setProperty("arrow.enable_unsafe_memory_access", "true"); + // Disable expensive null check for every get(index) call. + // Iceberg manages nullability checks itself instead of relying on arrow. + System.setProperty("arrow.enable_null_check_for_get", "false"); + } + + @TearDown + public void tearDownBenchmark() throws IOException { + tearDownSpark(); + cleanupFiles(); + } + + @Override + protected Configuration initHadoopConf() { + return new Configuration(); + } + + @Override + protected Table initTable() { + Schema schema = + new Schema( + optional(1, "decimalCol1", Types.DecimalType.of(7, 2)), + optional(2, "decimalCol2", Types.DecimalType.of(15, 2)), + optional(3, "decimalCol3", Types.DecimalType.of(20, 2))); + PartitionSpec partitionSpec = PartitionSpec.unpartitioned(); + HadoopTables tables = new HadoopTables(hadoopConf()); + Map properties = parquetWriteProps(); + return tables.create(schema, partitionSpec, properties, newTableLocation()); + } + + Map parquetWriteProps() { + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + properties.put(TableProperties.PARQUET_DICT_SIZE_BYTES, "1"); + return properties; + } + + void appendData() { + for (int fileNum = 1; fileNum <= NUM_FILES; fileNum++) { + Dataset df = + spark() + .range(NUM_ROWS_PER_FILE) + .withColumn( + "longCol", + when(pmod(col("id"), lit(10)).equalTo(lit(0)), lit(null)).otherwise(col("id"))) + .drop("id") + .withColumn("decimalCol1", expr("CAST(longCol AS DECIMAL(7, 2))")) + .withColumn("decimalCol2", expr("CAST(longCol AS DECIMAL(15, 2))")) + .withColumn("decimalCol3", expr("CAST(longCol AS DECIMAL(20, 2))")) + .drop("longCol"); + appendAsFile(df); + } + } + + @Benchmark + @Threads(1) + public void readIntBackedDecimalsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("decimalCol1"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readIntBackedDecimalsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("decimalCol1"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readLongBackedDecimalsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("decimalCol2"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readLongBackedDecimalsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("decimalCol2"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDecimalsIcebergVectorized5k() { + withTableProperties( + tablePropsWithVectorizationEnabled(5000), + () -> { + String tableLocation = table().location(); + Dataset df = + spark().read().format("iceberg").load(tableLocation).select("decimalCol3"); + materialize(df); + }); + } + + @Benchmark + @Threads(1) + public void readDecimalsSparkVectorized5k() { + withSQLConf( + sparkConfWithVectorizationEnabled(5000), + () -> { + Dataset df = spark().read().parquet(dataLocation()).select("decimalCol3"); + materialize(df); + }); + } + + private static Map tablePropsWithVectorizationEnabled(int batchSize) { + Map tableProperties = Maps.newHashMap(); + tableProperties.put(TableProperties.PARQUET_VECTORIZATION_ENABLED, "true"); + tableProperties.put(TableProperties.PARQUET_BATCH_SIZE, String.valueOf(batchSize)); + return tableProperties; + } + + private static Map sparkConfWithVectorizationEnabled(int batchSize) { + Map conf = Maps.newHashMap(); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_ENABLED().key(), "true"); + conf.put(SQLConf.PARQUET_VECTORIZED_READER_BATCH_SIZE().key(), String.valueOf(batchSize)); + return conf; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/SparkDistributedDataScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/SparkDistributedDataScan.java new file mode 100644 index 000000000000..569bf7adda13 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/SparkDistributedDataScan.java @@ -0,0 +1,274 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.ClosingIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.metrics.MetricsReporter; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.spark.JobGroupUtils; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.actions.ManifestFileBean; +import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.api.java.function.FlatMapFunction; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.SparkSession; + +/** + * A batch data scan that can utilize Spark cluster resources for planning. + * + *

    This scan remotely filters manifests, fetching only the relevant data and delete files to the + * driver. The delete file assignment is done locally after the remote filtering step. Such approach + * is beneficial if the remote parallelism is much higher than the number of driver cores. + * + *

    This scan is best suited for queries with selective filters on lower/upper bounds across all + * partitions, or against poorly clustered metadata. This allows job planning to benefit from highly + * concurrent remote filtering while not incurring high serialization and data transfer costs. This + * class is also useful for full table scans over large tables but the cost of bringing data and + * delete file details to the driver may become noticeable. Make sure to follow the performance tips + * below in such cases. + * + *

    Ensure the filtered metadata size doesn't exceed the driver's max result size. For large table + * scans, consider increasing `spark.driver.maxResultSize` to avoid job failures. + * + *

    Performance tips: + * + *

      + *
    • Enable Kryo serialization (`spark.serializer`) + *
    • Increase the number of driver cores (`spark.driver.cores`) + *
    • Tune the number of threads used to fetch task results (`spark.resultGetter.threads`) + *
    + */ +public class SparkDistributedDataScan extends BaseDistributedDataScan { + + private static final Joiner COMMA = Joiner.on(','); + private static final String DELETE_PLANNING_JOB_GROUP_ID = "DELETE-PLANNING"; + private static final String DATA_PLANNING_JOB_GROUP_ID = "DATA-PLANNING"; + + private final SparkSession spark; + private final JavaSparkContext sparkContext; + private final SparkReadConf readConf; + + private Broadcast
    tableBroadcast = null; + + public SparkDistributedDataScan(SparkSession spark, Table table, SparkReadConf readConf) { + this(spark, table, readConf, table.schema(), newTableScanContext(table)); + } + + private SparkDistributedDataScan( + SparkSession spark, + Table table, + SparkReadConf readConf, + Schema schema, + TableScanContext context) { + super(table, schema, context); + this.spark = spark; + this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + this.readConf = readConf; + } + + @Override + protected BatchScan newRefinedScan( + Table newTable, Schema newSchema, TableScanContext newContext) { + return new SparkDistributedDataScan(spark, newTable, readConf, newSchema, newContext); + } + + @Override + protected int remoteParallelism() { + return readConf.parallelism(); + } + + @Override + protected PlanningMode dataPlanningMode() { + return readConf.dataPlanningMode(); + } + + @Override + protected boolean shouldCopyRemotelyPlannedDataFiles() { + return false; + } + + @Override + protected Iterable> planDataRemotely( + List dataManifests, boolean withColumnStats) { + JobGroupInfo info = new JobGroupInfo(DATA_PLANNING_JOB_GROUP_ID, jobDesc("data")); + return withJobGroupInfo(info, () -> doPlanDataRemotely(dataManifests, withColumnStats)); + } + + private Iterable> doPlanDataRemotely( + List dataManifests, boolean withColumnStats) { + scanMetrics().scannedDataManifests().increment(dataManifests.size()); + + JavaRDD dataFileRDD = + sparkContext + .parallelize(toBeans(dataManifests), dataManifests.size()) + .flatMap(new ReadDataManifest(tableBroadcast(), context(), withColumnStats)); + List> dataFileGroups = collectPartitions(dataFileRDD); + + int matchingFilesCount = dataFileGroups.stream().mapToInt(List::size).sum(); + int skippedFilesCount = liveFilesCount(dataManifests) - matchingFilesCount; + scanMetrics().skippedDataFiles().increment(skippedFilesCount); + + return Iterables.transform(dataFileGroups, CloseableIterable::withNoopClose); + } + + @Override + protected PlanningMode deletePlanningMode() { + return readConf.deletePlanningMode(); + } + + @Override + protected DeleteFileIndex planDeletesRemotely(List deleteManifests) { + JobGroupInfo info = new JobGroupInfo(DELETE_PLANNING_JOB_GROUP_ID, jobDesc("deletes")); + return withJobGroupInfo(info, () -> doPlanDeletesRemotely(deleteManifests)); + } + + private DeleteFileIndex doPlanDeletesRemotely(List deleteManifests) { + scanMetrics().scannedDeleteManifests().increment(deleteManifests.size()); + + List deleteFiles = + sparkContext + .parallelize(toBeans(deleteManifests), deleteManifests.size()) + .flatMap(new ReadDeleteManifest(tableBroadcast(), context())) + .collect(); + + int skippedFilesCount = liveFilesCount(deleteManifests) - deleteFiles.size(); + scanMetrics().skippedDeleteFiles().increment(skippedFilesCount); + + return DeleteFileIndex.builderFor(deleteFiles) + .schemasById(schemas()) + .specsById(table().specs()) + .caseSensitive(isCaseSensitive()) + .scanMetrics(scanMetrics()) + .build(); + } + + private T withJobGroupInfo(JobGroupInfo info, Supplier supplier) { + return JobGroupUtils.withJobGroupInfo(sparkContext, info, supplier); + } + + private String jobDesc(String type) { + List options = Lists.newArrayList(); + options.add("snapshot_id=" + snapshot().snapshotId()); + String optionsAsString = COMMA.join(options); + return String.format("Planning %s (%s) for %s", type, optionsAsString, table().name()); + } + + private List toBeans(List manifests) { + return manifests.stream().map(ManifestFileBean::fromManifest).collect(Collectors.toList()); + } + + private Broadcast
    tableBroadcast() { + if (tableBroadcast == null) { + Table serializableTable = SerializableTableWithSize.copyOf(table()); + this.tableBroadcast = sparkContext.broadcast(serializableTable); + } + + return tableBroadcast; + } + + private List> collectPartitions(JavaRDD rdd) { + int[] partitionIds = IntStream.range(0, rdd.getNumPartitions()).toArray(); + return Arrays.asList(rdd.collectPartitions(partitionIds)); + } + + private int liveFilesCount(List manifests) { + return manifests.stream().mapToInt(this::liveFilesCount).sum(); + } + + private int liveFilesCount(ManifestFile manifest) { + return manifest.existingFilesCount() + manifest.addedFilesCount(); + } + + private static TableScanContext newTableScanContext(Table table) { + if (table instanceof BaseTable) { + MetricsReporter reporter = ((BaseTable) table).reporter(); + return ImmutableTableScanContext.builder().metricsReporter(reporter).build(); + } else { + return TableScanContext.empty(); + } + } + + private static class ReadDataManifest implements FlatMapFunction { + + private final Broadcast
    table; + private final Expression filter; + private final boolean withStats; + private final boolean isCaseSensitive; + + ReadDataManifest(Broadcast
    table, TableScanContext context, boolean withStats) { + this.table = table; + this.filter = context.rowFilter(); + this.withStats = withStats; + this.isCaseSensitive = context.caseSensitive(); + } + + @Override + public Iterator call(ManifestFileBean manifest) throws Exception { + FileIO io = table.value().io(); + Map specs = table.value().specs(); + return new ClosingIterator<>( + ManifestFiles.read(manifest, io, specs) + .select(withStats ? SCAN_WITH_STATS_COLUMNS : SCAN_COLUMNS) + .filterRows(filter) + .caseSensitive(isCaseSensitive) + .iterator()); + } + } + + private static class ReadDeleteManifest implements FlatMapFunction { + + private final Broadcast
    table; + private final Expression filter; + private final boolean isCaseSensitive; + + ReadDeleteManifest(Broadcast
    table, TableScanContext context) { + this.table = table; + this.filter = context.ignoreResiduals() ? Expressions.alwaysTrue() : context.rowFilter(); + this.isCaseSensitive = context.caseSensitive(); + } + + @Override + public Iterator call(ManifestFileBean manifest) throws Exception { + FileIO io = table.value().io(); + Map specs = table.value().specs(); + return new ClosingIterator<>( + ManifestFiles.readDeleteManifest(manifest, io, specs) + .select(DELETE_SCAN_WITH_STATS_COLUMNS) + .filterRows(filter) + .caseSensitive(isCaseSensitive) + .iterator()); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/BaseCatalog.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/BaseCatalog.java new file mode 100644 index 000000000000..a0a5b0518d21 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/BaseCatalog.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.spark.procedures.SparkProcedures; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.iceberg.spark.source.HasIcebergCatalog; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.ProcedureCatalog; +import org.apache.spark.sql.connector.catalog.StagingTableCatalog; +import org.apache.spark.sql.connector.catalog.SupportsNamespaces; +import org.apache.spark.sql.connector.catalog.ViewCatalog; +import org.apache.spark.sql.connector.catalog.procedures.UnboundProcedure; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +abstract class BaseCatalog + implements StagingTableCatalog, + ProcedureCatalog, + SupportsNamespaces, + HasIcebergCatalog, + SupportsFunctions, + ViewCatalog, + SupportsReplaceView { + private static final String USE_NULLABLE_QUERY_SCHEMA_CTAS_RTAS = "use-nullable-query-schema"; + private static final boolean USE_NULLABLE_QUERY_SCHEMA_CTAS_RTAS_DEFAULT = true; + + private boolean useNullableQuerySchema = USE_NULLABLE_QUERY_SCHEMA_CTAS_RTAS_DEFAULT; + + @Override + public UnboundProcedure loadProcedure(Identifier ident) { + String[] namespace = ident.namespace(); + String name = ident.name(); + + // namespace resolution is case insensitive until we have a way to configure case sensitivity in + // catalogs + if (isSystemNamespace(namespace)) { + ProcedureBuilder builder = SparkProcedures.newBuilder(name); + if (builder != null) { + return builder.withTableCatalog(this).build(); + } + } + + throw new RuntimeException("Procedure " + ident + " not found"); + } + + @Override + public Identifier[] listProcedures(String[] namespace) { + if (isSystemNamespace(namespace)) { + return SparkProcedures.names().stream() + .map(name -> Identifier.of(namespace, name)) + .toArray(Identifier[]::new); + } else { + return new Identifier[0]; + } + } + + @Override + public boolean isFunctionNamespace(String[] namespace) { + // Allow for empty namespace, as Spark's storage partitioned joins look up + // the corresponding functions to generate transforms for partitioning + // with an empty namespace, such as `bucket`. + // Otherwise, use `system` namespace. + return namespace.length == 0 || isSystemNamespace(namespace); + } + + @Override + public boolean isExistingNamespace(String[] namespace) { + return namespaceExists(namespace); + } + + @Override + public void initialize(String name, CaseInsensitiveStringMap options) { + this.useNullableQuerySchema = + PropertyUtil.propertyAsBoolean( + options, + USE_NULLABLE_QUERY_SCHEMA_CTAS_RTAS, + USE_NULLABLE_QUERY_SCHEMA_CTAS_RTAS_DEFAULT); + } + + @Override + public boolean useNullableQuerySchema() { + return useNullableQuerySchema; + } + + private static boolean isSystemNamespace(String[] namespace) { + return namespace.length == 1 && namespace[0].equalsIgnoreCase("system"); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/BaseFileRewriteCoordinator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/BaseFileRewriteCoordinator.java new file mode 100644 index 000000000000..5c95475d3302 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/BaseFileRewriteCoordinator.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.Pair; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class BaseFileRewriteCoordinator> { + + private static final Logger LOG = LoggerFactory.getLogger(BaseFileRewriteCoordinator.class); + + private final Map, Set> resultMap = Maps.newConcurrentMap(); + + /** + * Called to persist the output of a rewrite action for a specific group. Since the write is done + * via a Spark Datasource, we have to propagate the result through this side-effect call. + * + * @param table table where the rewrite is occurring + * @param fileSetId the id used to identify the source set of files being rewritten + * @param newFiles the new files which have been written + */ + public void stageRewrite(Table table, String fileSetId, Set newFiles) { + LOG.debug( + "Staging the output for {} - fileset {} with {} files", + table.name(), + fileSetId, + newFiles.size()); + Pair id = toId(table, fileSetId); + resultMap.put(id, newFiles); + } + + public Set fetchNewFiles(Table table, String fileSetId) { + Pair id = toId(table, fileSetId); + Set result = resultMap.get(id); + ValidationException.check( + result != null, "No results for rewrite of file set %s in table %s", fileSetId, table); + + return result; + } + + public void clearRewrite(Table table, String fileSetId) { + LOG.debug("Removing entry for {} - id {}", table.name(), fileSetId); + Pair id = toId(table, fileSetId); + resultMap.remove(id); + } + + public Set fetchSetIds(Table table) { + return resultMap.keySet().stream() + .filter(e -> e.first().equals(Spark3Util.baseTableUUID(table))) + .map(Pair::second) + .collect(Collectors.toSet()); + } + + private Pair toId(Table table, String setId) { + return Pair.of(Spark3Util.baseTableUUID(table), setId); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ChangelogIterator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ChangelogIterator.java new file mode 100644 index 000000000000..cc44b1f3992c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ChangelogIterator.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Iterator; +import java.util.Objects; +import java.util.Set; +import org.apache.iceberg.ChangelogOperation; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterators; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.types.StructType; + +/** An iterator that transforms rows from changelog tables within a single Spark task. */ +public abstract class ChangelogIterator implements Iterator { + protected static final String DELETE = ChangelogOperation.DELETE.name(); + protected static final String INSERT = ChangelogOperation.INSERT.name(); + protected static final String UPDATE_BEFORE = ChangelogOperation.UPDATE_BEFORE.name(); + protected static final String UPDATE_AFTER = ChangelogOperation.UPDATE_AFTER.name(); + + private final Iterator rowIterator; + private final int changeTypeIndex; + private final StructType rowType; + + protected ChangelogIterator(Iterator rowIterator, StructType rowType) { + this.rowIterator = rowIterator; + this.rowType = rowType; + this.changeTypeIndex = rowType.fieldIndex(MetadataColumns.CHANGE_TYPE.name()); + } + + protected int changeTypeIndex() { + return changeTypeIndex; + } + + protected StructType rowType() { + return rowType; + } + + protected String changeType(Row row) { + String changeType = row.getString(changeTypeIndex()); + Preconditions.checkNotNull(changeType, "Change type should not be null"); + return changeType; + } + + protected Iterator rowIterator() { + return rowIterator; + } + + /** + * Creates an iterator composing {@link RemoveCarryoverIterator} and {@link ComputeUpdateIterator} + * to remove carry-over rows and compute update rows + * + * @param rowIterator the iterator of rows from a changelog table + * @param rowType the schema of the rows + * @param identifierFields the names of the identifier columns, which determine if rows are the + * same + * @return a new iterator instance + */ + public static Iterator computeUpdates( + Iterator rowIterator, StructType rowType, String[] identifierFields) { + Iterator carryoverRemoveIterator = removeCarryovers(rowIterator, rowType); + ChangelogIterator changelogIterator = + new ComputeUpdateIterator(carryoverRemoveIterator, rowType, identifierFields); + return Iterators.filter(changelogIterator, Objects::nonNull); + } + + /** + * Creates an iterator that removes carry-over rows from a changelog table. + * + * @param rowIterator the iterator of rows from a changelog table + * @param rowType the schema of the rows + * @return a new iterator instance + */ + public static Iterator removeCarryovers(Iterator rowIterator, StructType rowType) { + RemoveCarryoverIterator changelogIterator = new RemoveCarryoverIterator(rowIterator, rowType); + return Iterators.filter(changelogIterator, Objects::nonNull); + } + + public static Iterator removeNetCarryovers(Iterator rowIterator, StructType rowType) { + ChangelogIterator changelogIterator = new RemoveNetCarryoverIterator(rowIterator, rowType); + return Iterators.filter(changelogIterator, Objects::nonNull); + } + + protected boolean isSameRecord(Row currentRow, Row nextRow, int[] indicesToIdentifySameRow) { + for (int idx : indicesToIdentifySameRow) { + if (isDifferentValue(currentRow, nextRow, idx)) { + return false; + } + } + + return true; + } + + protected boolean isDifferentValue(Row currentRow, Row nextRow, int idx) { + return !Objects.equals(nextRow.get(idx), currentRow.get(idx)); + } + + protected static int[] generateIndicesToIdentifySameRow( + int totalColumnCount, Set metadataColumnIndices) { + int[] indices = new int[totalColumnCount - metadataColumnIndices.size()]; + + for (int i = 0, j = 0; i < indices.length; i++) { + if (!metadataColumnIndices.contains(i)) { + indices[j] = i; + j++; + } + } + return indices; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java new file mode 100644 index 000000000000..cb9da3edc678 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/CommitMetadata.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import java.util.concurrent.Callable; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.ExceptionUtil; + +/** utility class to accept thread local commit properties */ +public class CommitMetadata { + + private CommitMetadata() {} + + private static final ThreadLocal> COMMIT_PROPERTIES = + ThreadLocal.withInitial(ImmutableMap::of); + + /** + * running the code wrapped as a caller, and any snapshot committed within the callable object + * will be attached with the metadata defined in properties + * + * @param properties extra commit metadata to attach to the snapshot committed within callable. + * The prefix will be removed for properties starting with {@link + * SnapshotSummary#EXTRA_METADATA_PREFIX} + * @param callable the code to be executed + * @param exClass the expected type of exception which would be thrown from callable + */ + public static R withCommitProperties( + Map properties, Callable callable, Class exClass) throws E { + Map props = Maps.newHashMap(); + properties.forEach( + (k, v) -> props.put(k.replace(SnapshotSummary.EXTRA_METADATA_PREFIX, ""), v)); + + COMMIT_PROPERTIES.set(props); + try { + return callable.call(); + } catch (Throwable e) { + ExceptionUtil.castAndThrow(e, exClass); + return null; + } finally { + COMMIT_PROPERTIES.remove(); + } + } + + public static Map commitProperties() { + return COMMIT_PROPERTIES.get(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ComputeUpdateIterator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ComputeUpdateIterator.java new file mode 100644 index 000000000000..6951c33e51aa --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ComputeUpdateIterator.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.catalyst.expressions.GenericRow; +import org.apache.spark.sql.types.StructType; + +/** + * An iterator that finds delete/insert rows which represent an update, and converts them into + * update records from changelog tables within a single Spark task. It assumes that rows are sorted + * by identifier columns and change type. + * + *

    For example, these two rows + * + *

      + *
    • (id=1, data='a', op='DELETE') + *
    • (id=1, data='b', op='INSERT') + *
    + * + *

    will be marked as update-rows: + * + *

      + *
    • (id=1, data='a', op='UPDATE_BEFORE') + *
    • (id=1, data='b', op='UPDATE_AFTER') + *
    + */ +public class ComputeUpdateIterator extends ChangelogIterator { + + private final String[] identifierFields; + private final List identifierFieldIdx; + + private Row cachedRow = null; + + ComputeUpdateIterator(Iterator rowIterator, StructType rowType, String[] identifierFields) { + super(rowIterator, rowType); + this.identifierFieldIdx = + Arrays.stream(identifierFields).map(rowType::fieldIndex).collect(Collectors.toList()); + this.identifierFields = identifierFields; + } + + @Override + public boolean hasNext() { + if (cachedRow != null) { + return true; + } + return rowIterator().hasNext(); + } + + @Override + public Row next() { + // if there is an updated cached row, return it directly + if (cachedUpdateRecord()) { + Row row = cachedRow; + cachedRow = null; + return row; + } + + // either a cached record which is not an UPDATE or the next record in the iterator. + Row currentRow = currentRow(); + + if (changeType(currentRow).equals(DELETE) && rowIterator().hasNext()) { + Row nextRow = rowIterator().next(); + cachedRow = nextRow; + + if (sameLogicalRow(currentRow, nextRow)) { + Preconditions.checkState( + changeType(nextRow).equals(INSERT), + "Cannot compute updates because there are multiple rows with the same identifier" + + " fields([%s]). Please make sure the rows are unique.", + String.join(",", identifierFields)); + + currentRow = modify(currentRow, changeTypeIndex(), UPDATE_BEFORE); + cachedRow = modify(nextRow, changeTypeIndex(), UPDATE_AFTER); + } + } + + return currentRow; + } + + private Row modify(Row row, int valueIndex, Object value) { + if (row instanceof GenericRow) { + GenericRow genericRow = (GenericRow) row; + genericRow.values()[valueIndex] = value; + return genericRow; + } else { + Object[] values = new Object[row.size()]; + for (int index = 0; index < row.size(); index++) { + values[index] = row.get(index); + } + values[valueIndex] = value; + return RowFactory.create(values); + } + } + + private boolean cachedUpdateRecord() { + return cachedRow != null && changeType(cachedRow).equals(UPDATE_AFTER); + } + + private Row currentRow() { + if (cachedRow != null) { + Row row = cachedRow; + cachedRow = null; + return row; + } else { + return rowIterator().next(); + } + } + + private boolean sameLogicalRow(Row currentRow, Row nextRow) { + for (int idx : identifierFieldIdx) { + if (isDifferentValue(currentRow, nextRow, idx)) { + return false; + } + } + return true; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ExtendedParser.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ExtendedParser.java new file mode 100644 index 000000000000..5bfefbb97409 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ExtendedParser.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.lang.reflect.Field; +import java.util.List; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.expressions.Term; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.parser.ParserInterface; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public interface ExtendedParser extends ParserInterface { + class RawOrderField { + private final Term term; + private final SortDirection direction; + private final NullOrder nullOrder; + + public RawOrderField(Term term, SortDirection direction, NullOrder nullOrder) { + this.term = term; + this.direction = direction; + this.nullOrder = nullOrder; + } + + public Term term() { + return term; + } + + public SortDirection direction() { + return direction; + } + + public NullOrder nullOrder() { + return nullOrder; + } + } + + static List parseSortOrder(SparkSession spark, String orderString) { + ExtendedParser extParser = findParser(spark.sessionState().sqlParser(), ExtendedParser.class); + if (extParser != null) { + try { + return extParser.parseSortOrder(orderString); + } catch (AnalysisException e) { + throw new IllegalArgumentException( + String.format("Unable to parse sortOrder: %s", orderString), e); + } + } else { + throw new IllegalStateException( + "Cannot parse order: parser is not an Iceberg ExtendedParser"); + } + } + + private static T findParser(ParserInterface parser, Class clazz) { + ParserInterface current = parser; + while (current != null) { + if (clazz.isInstance(current)) { + return clazz.cast(current); + } + + current = getNextDelegateParser(current); + } + + return null; + } + + private static ParserInterface getNextDelegateParser(ParserInterface parser) { + try { + Class clazz = parser.getClass(); + while (clazz != null) { + for (Field field : clazz.getDeclaredFields()) { + field.setAccessible(true); + Object value = field.get(parser); + if (value instanceof ParserInterface && value != parser) { + return (ParserInterface) value; + } + } + clazz = clazz.getSuperclass(); + } + } catch (Exception e) { + log().warn("Failed to scan delegate parser in {}: ", parser.getClass().getName(), e); + } + + return null; + } + + private static Logger log() { + return LoggerFactory.getLogger(ExtendedParser.class); + } + + List parseSortOrder(String orderString) throws AnalysisException; +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/FileRewriteCoordinator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/FileRewriteCoordinator.java new file mode 100644 index 000000000000..432f7737d623 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/FileRewriteCoordinator.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.DataFile; + +public class FileRewriteCoordinator extends BaseFileRewriteCoordinator { + + private static final FileRewriteCoordinator INSTANCE = new FileRewriteCoordinator(); + + private FileRewriteCoordinator() {} + + public static FileRewriteCoordinator get() { + return INSTANCE; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/IcebergSpark.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/IcebergSpark.java new file mode 100644 index 000000000000..eb2420c0b254 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/IcebergSpark.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.function.Function; +import org.apache.iceberg.transforms.Transforms; +import org.apache.iceberg.types.Type; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; + +public class IcebergSpark { + private IcebergSpark() {} + + public static void registerBucketUDF( + SparkSession session, String funcName, DataType sourceType, int numBuckets) { + SparkTypeToType typeConverter = new SparkTypeToType(); + Type sourceIcebergType = typeConverter.atomic(sourceType); + Function bucket = Transforms.bucket(numBuckets).bind(sourceIcebergType); + session + .udf() + .register( + funcName, + value -> bucket.apply(SparkValueConverter.convert(sourceIcebergType, value)), + DataTypes.IntegerType); + } + + public static void registerTruncateUDF( + SparkSession session, String funcName, DataType sourceType, int width) { + SparkTypeToType typeConverter = new SparkTypeToType(); + Type sourceIcebergType = typeConverter.atomic(sourceType); + Function truncate = Transforms.truncate(width).bind(sourceIcebergType); + session + .udf() + .register( + funcName, + value -> truncate.apply(SparkValueConverter.convert(sourceIcebergType, value)), + sourceType); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/JobGroupInfo.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/JobGroupInfo.java new file mode 100644 index 000000000000..dc59fc70880e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/JobGroupInfo.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +/** Captures information about the current job which is used for displaying on the UI */ +public class JobGroupInfo { + private final String groupId; + private final String description; + private final boolean interruptOnCancel; + + public JobGroupInfo(String groupId, String desc) { + this(groupId, desc, false); + } + + public JobGroupInfo(String groupId, String desc, boolean interruptOnCancel) { + this.groupId = groupId; + this.description = desc; + this.interruptOnCancel = interruptOnCancel; + } + + public String groupId() { + return groupId; + } + + public String description() { + return description; + } + + public boolean interruptOnCancel() { + return interruptOnCancel; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/JobGroupUtils.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/JobGroupUtils.java new file mode 100644 index 000000000000..a6aadf7ebd0e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/JobGroupUtils.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.function.Supplier; +import org.apache.spark.SparkContext; +import org.apache.spark.SparkContext$; +import org.apache.spark.api.java.JavaSparkContext; + +public class JobGroupUtils { + + private static final String JOB_GROUP_ID = SparkContext$.MODULE$.SPARK_JOB_GROUP_ID(); + private static final String JOB_GROUP_DESC = SparkContext$.MODULE$.SPARK_JOB_DESCRIPTION(); + private static final String JOB_INTERRUPT_ON_CANCEL = + SparkContext$.MODULE$.SPARK_JOB_INTERRUPT_ON_CANCEL(); + + private JobGroupUtils() {} + + public static JobGroupInfo getJobGroupInfo(SparkContext sparkContext) { + String groupId = sparkContext.getLocalProperty(JOB_GROUP_ID); + String description = sparkContext.getLocalProperty(JOB_GROUP_DESC); + String interruptOnCancel = sparkContext.getLocalProperty(JOB_INTERRUPT_ON_CANCEL); + return new JobGroupInfo(groupId, description, Boolean.parseBoolean(interruptOnCancel)); + } + + public static void setJobGroupInfo(SparkContext sparkContext, JobGroupInfo info) { + sparkContext.setLocalProperty(JOB_GROUP_ID, info.groupId()); + sparkContext.setLocalProperty(JOB_GROUP_DESC, info.description()); + sparkContext.setLocalProperty( + JOB_INTERRUPT_ON_CANCEL, String.valueOf(info.interruptOnCancel())); + } + + public static T withJobGroupInfo( + JavaSparkContext sparkContext, JobGroupInfo info, Supplier supplier) { + return withJobGroupInfo(sparkContext.sc(), info, supplier); + } + + public static T withJobGroupInfo( + SparkContext sparkContext, JobGroupInfo info, Supplier supplier) { + JobGroupInfo previousInfo = getJobGroupInfo(sparkContext); + try { + setJobGroupInfo(sparkContext, info); + return supplier.get(); + } finally { + setJobGroupInfo(sparkContext, previousInfo); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/OrcBatchReadConf.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/OrcBatchReadConf.java new file mode 100644 index 000000000000..d3b339d60e3f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/OrcBatchReadConf.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.io.Serializable; +import org.immutables.value.Value; + +@Value.Immutable +public interface OrcBatchReadConf extends Serializable { + int batchSize(); +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java new file mode 100644 index 000000000000..b7cd441c9642 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ParquetBatchReadConf.java @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.io.Serializable; +import org.immutables.value.Value; + +@Value.Immutable +public interface ParquetBatchReadConf extends Serializable { + int batchSize(); +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PathIdentifier.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PathIdentifier.java new file mode 100644 index 000000000000..110af6b87de5 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PathIdentifier.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.List; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.spark.sql.connector.catalog.Identifier; + +public class PathIdentifier implements Identifier { + private static final Splitter SPLIT = Splitter.on("/"); + private static final Joiner JOIN = Joiner.on("/"); + private final String[] namespace; + private final String location; + private final String name; + + public PathIdentifier(String location) { + this.location = location; + List pathParts = SPLIT.splitToList(location); + name = Iterables.getLast(pathParts); + namespace = + pathParts.size() > 1 + ? new String[] {JOIN.join(pathParts.subList(0, pathParts.size() - 1))} + : new String[0]; + } + + @Override + public String[] namespace() { + return namespace; + } + + @Override + public String name() { + return name; + } + + public String location() { + return location; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PositionDeletesRewriteCoordinator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PositionDeletesRewriteCoordinator.java new file mode 100644 index 000000000000..c7568005e22f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PositionDeletesRewriteCoordinator.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.DeleteFile; + +public class PositionDeletesRewriteCoordinator extends BaseFileRewriteCoordinator { + + private static final PositionDeletesRewriteCoordinator INSTANCE = + new PositionDeletesRewriteCoordinator(); + + private PositionDeletesRewriteCoordinator() {} + + public static PositionDeletesRewriteCoordinator get() { + return INSTANCE; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java new file mode 100644 index 000000000000..f4323f1c0350 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/PruneColumnsWithoutReordering.java @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import org.apache.iceberg.Schema; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Type.TypeID; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.BinaryType$; +import org.apache.spark.sql.types.BooleanType$; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DateType$; +import org.apache.spark.sql.types.DecimalType; +import org.apache.spark.sql.types.DoubleType$; +import org.apache.spark.sql.types.FloatType$; +import org.apache.spark.sql.types.IntegerType$; +import org.apache.spark.sql.types.LongType$; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType$; +import org.apache.spark.sql.types.StringType$; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.TimestampNTZType$; +import org.apache.spark.sql.types.TimestampType$; + +public class PruneColumnsWithoutReordering extends TypeUtil.CustomOrderSchemaVisitor { + private final StructType requestedType; + private final Set filterRefs; + private DataType current = null; + + PruneColumnsWithoutReordering(StructType requestedType, Set filterRefs) { + this.requestedType = requestedType; + this.filterRefs = filterRefs; + } + + @Override + public Type schema(Schema schema, Supplier structResult) { + this.current = requestedType; + try { + return structResult.get(); + } finally { + this.current = null; + } + } + + @Override + public Type struct(Types.StructType struct, Iterable fieldResults) { + Preconditions.checkNotNull( + struct, "Cannot prune null struct. Pruning must start with a schema."); + Preconditions.checkArgument(current instanceof StructType, "Not a struct: %s", current); + + List fields = struct.fields(); + List types = Lists.newArrayList(fieldResults); + + boolean changed = false; + List newFields = Lists.newArrayListWithExpectedSize(types.size()); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + Type type = types.get(i); + + if (type == null) { + changed = true; + + } else if (field.type() == type) { + newFields.add(field); + + } else if (field.isOptional()) { + changed = true; + newFields.add(Types.NestedField.optional(field.fieldId(), field.name(), type)); + + } else { + changed = true; + newFields.add(Types.NestedField.required(field.fieldId(), field.name(), type)); + } + } + + if (changed) { + return Types.StructType.of(newFields); + } + + return struct; + } + + @Override + public Type field(Types.NestedField field, Supplier fieldResult) { + Preconditions.checkArgument(current instanceof StructType, "Not a struct: %s", current); + StructType requestedStruct = (StructType) current; + + // fields are resolved by name because Spark only sees the current table schema. + if (requestedStruct.getFieldIndex(field.name()).isEmpty()) { + // make sure that filter fields are projected even if they aren't in the requested schema. + if (filterRefs.contains(field.fieldId())) { + return field.type(); + } + return null; + } + + int fieldIndex = requestedStruct.fieldIndex(field.name()); + StructField requestedField = requestedStruct.fields()[fieldIndex]; + + Preconditions.checkArgument( + requestedField.nullable() || field.isRequired(), + "Cannot project an optional field as non-null: %s", + field.name()); + + this.current = requestedField.dataType(); + try { + return fieldResult.get(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid projection for field " + field.name() + ": " + e.getMessage(), e); + } finally { + this.current = requestedStruct; + } + } + + @Override + public Type list(Types.ListType list, Supplier elementResult) { + Preconditions.checkArgument(current instanceof ArrayType, "Not an array: %s", current); + ArrayType requestedArray = (ArrayType) current; + + Preconditions.checkArgument( + requestedArray.containsNull() || !list.isElementOptional(), + "Cannot project an array of optional elements as required elements: %s", + requestedArray); + + this.current = requestedArray.elementType(); + try { + Type elementType = elementResult.get(); + if (list.elementType() == elementType) { + return list; + } + + // must be a projected element type, create a new list + if (list.isElementOptional()) { + return Types.ListType.ofOptional(list.elementId(), elementType); + } else { + return Types.ListType.ofRequired(list.elementId(), elementType); + } + } finally { + this.current = requestedArray; + } + } + + @Override + public Type map(Types.MapType map, Supplier keyResult, Supplier valueResult) { + Preconditions.checkArgument(current instanceof MapType, "Not a map: %s", current); + MapType requestedMap = (MapType) current; + + Preconditions.checkArgument( + requestedMap.valueContainsNull() || !map.isValueOptional(), + "Cannot project a map of optional values as required values: %s", + map); + + this.current = requestedMap.valueType(); + try { + Type valueType = valueResult.get(); + if (map.valueType() == valueType) { + return map; + } + + if (map.isValueOptional()) { + return Types.MapType.ofOptional(map.keyId(), map.valueId(), map.keyType(), valueType); + } else { + return Types.MapType.ofRequired(map.keyId(), map.valueId(), map.keyType(), valueType); + } + } finally { + this.current = requestedMap; + } + } + + @Override + public Type variant(Types.VariantType variant) { + return Types.VariantType.get(); + } + + @Override + public Type primitive(Type.PrimitiveType primitive) { + Set> expectedType = TYPES.get(primitive.typeId()); + Preconditions.checkArgument( + expectedType != null && expectedType.contains(current.getClass()), + "Cannot project %s to incompatible type: %s", + primitive, + current); + + // additional checks based on type + switch (primitive.typeId()) { + case DECIMAL: + Types.DecimalType decimal = (Types.DecimalType) primitive; + DecimalType requestedDecimal = (DecimalType) current; + Preconditions.checkArgument( + requestedDecimal.scale() == decimal.scale(), + "Cannot project decimal with incompatible scale: %s != %s", + requestedDecimal.scale(), + decimal.scale()); + Preconditions.checkArgument( + requestedDecimal.precision() >= decimal.precision(), + "Cannot project decimal with incompatible precision: %s < %s", + requestedDecimal.precision(), + decimal.precision()); + break; + default: + } + + return primitive; + } + + private static final ImmutableMap>> TYPES = + ImmutableMap.>>builder() + .put(TypeID.BOOLEAN, ImmutableSet.of(BooleanType$.class)) + .put(TypeID.INTEGER, ImmutableSet.of(IntegerType$.class)) + .put(TypeID.LONG, ImmutableSet.of(LongType$.class)) + .put(TypeID.FLOAT, ImmutableSet.of(FloatType$.class)) + .put(TypeID.DOUBLE, ImmutableSet.of(DoubleType$.class)) + .put(TypeID.DATE, ImmutableSet.of(DateType$.class)) + .put(TypeID.TIMESTAMP, ImmutableSet.of(TimestampType$.class, TimestampNTZType$.class)) + .put(TypeID.DECIMAL, ImmutableSet.of(DecimalType.class)) + .put(TypeID.UUID, ImmutableSet.of(StringType$.class)) + .put(TypeID.STRING, ImmutableSet.of(StringType$.class)) + .put(TypeID.FIXED, ImmutableSet.of(BinaryType$.class)) + .put(TypeID.BINARY, ImmutableSet.of(BinaryType$.class)) + .put(TypeID.UNKNOWN, ImmutableSet.of(NullType$.class)) + .buildOrThrow(); +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RemoveCarryoverIterator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RemoveCarryoverIterator.java new file mode 100644 index 000000000000..2e90dc7749d1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RemoveCarryoverIterator.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Iterator; +import java.util.Set; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.types.StructType; + +/** + * An iterator that removes the carry-over rows from changelog tables within a single Spark task. It + * assumes that rows are partitioned by identifier(or all) columns, and it is sorted by both + * identifier(or all) columns and change type. + * + *

    Carry-over rows are the result of a removal and insertion of the same row within an operation + * because of the copy-on-write mechanism. For example, given a file which contains row1 (id=1, + * data='a') and row2 (id=2, data='b'). A copy-on-write delete of row2 would require erasing this + * file and preserving row1 in a new file. The change-log table would report this as follows, + * despite it not being an actual change to the table. + * + *

      + *
    • (id=1, data='a', op='DELETE') + *
    • (id=1, data='a', op='INSERT') + *
    • (id=2, data='b', op='DELETE') + *
    + * + * The iterator finds the carry-over rows and removes them from the result. For example, the above + * rows will be converted to: + * + *
      + *
    • (id=2, data='b', op='DELETE') + *
    + */ +class RemoveCarryoverIterator extends ChangelogIterator { + private final int[] indicesToIdentifySameRow; + + private Row cachedDeletedRow = null; + private long deletedRowCount = 0; + private Row cachedNextRecord = null; + + RemoveCarryoverIterator(Iterator rowIterator, StructType rowType) { + super(rowIterator, rowType); + this.indicesToIdentifySameRow = generateIndicesToIdentifySameRow(); + } + + @Override + public boolean hasNext() { + if (hasCachedDeleteRow() || cachedNextRecord != null) { + return true; + } + return rowIterator().hasNext(); + } + + @Override + public Row next() { + Row currentRow; + + if (returnCachedDeleteRow()) { + // Non-carryover delete rows found. One or more identical delete rows were seen followed by a + // non-identical row. This means none of the delete rows were carry over rows. Emit one + // delete row and decrease the amount of delete rows seen. + deletedRowCount--; + currentRow = cachedDeletedRow; + if (deletedRowCount == 0) { + cachedDeletedRow = null; + } + return currentRow; + } else if (cachedNextRecord != null) { + currentRow = cachedNextRecord; + cachedNextRecord = null; + } else { + currentRow = rowIterator().next(); + } + + // If the current row is a delete row, drain all identical delete rows + if (changeType(currentRow).equals(DELETE) && rowIterator().hasNext()) { + cachedDeletedRow = currentRow; + deletedRowCount = 1; + + Row nextRow = rowIterator().next(); + + // drain all identical delete rows when there is at least one cached delete row and the next + // row is the same record + while (nextRow != null + && cachedDeletedRow != null + && isSameRecord(cachedDeletedRow, nextRow, indicesToIdentifySameRow)) { + if (changeType(nextRow).equals(INSERT)) { + deletedRowCount--; + if (deletedRowCount == 0) { + cachedDeletedRow = null; + } + } else { + deletedRowCount++; + } + + if (rowIterator().hasNext()) { + nextRow = rowIterator().next(); + } else { + nextRow = null; + } + } + + cachedNextRecord = nextRow; + return null; + } else { + // either there is no cached delete row or the current row is not a delete row + return currentRow; + } + } + + /** + * The iterator returns a cached delete row if there are delete rows cached and the next row is + * not the same record or there is no next row. + */ + private boolean returnCachedDeleteRow() { + return hitBoundary() && hasCachedDeleteRow(); + } + + private boolean hitBoundary() { + return !rowIterator().hasNext() || cachedNextRecord != null; + } + + private boolean hasCachedDeleteRow() { + return cachedDeletedRow != null; + } + + private int[] generateIndicesToIdentifySameRow() { + Set metadataColumnIndices = Sets.newHashSet(changeTypeIndex()); + return generateIndicesToIdentifySameRow(rowType().size(), metadataColumnIndices); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RemoveNetCarryoverIterator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RemoveNetCarryoverIterator.java new file mode 100644 index 000000000000..941e4a4731e2 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RemoveNetCarryoverIterator.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Iterator; +import java.util.Set; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.types.StructType; + +/** + * This class computes the net changes across multiple snapshots. It is different from {@link + * org.apache.iceberg.spark.RemoveCarryoverIterator}, which only removes carry-over rows within a + * single snapshot. It takes a row iterator, and assumes the following: + * + *
      + *
    • The row iterator is partitioned by all columns. + *
    • The row iterator is sorted by all columns, change order, and change type. The change order + * is 1-to-1 mapping to snapshot id. + *
    + */ +public class RemoveNetCarryoverIterator extends ChangelogIterator { + + private final int[] indicesToIdentifySameRow; + + private Row cachedNextRow; + private Row cachedRow; + private long cachedRowCount; + + protected RemoveNetCarryoverIterator(Iterator rowIterator, StructType rowType) { + super(rowIterator, rowType); + this.indicesToIdentifySameRow = generateIndicesToIdentifySameRow(); + } + + @Override + public boolean hasNext() { + if (cachedRowCount > 0) { + return true; + } + + if (cachedNextRow != null) { + return true; + } + + return rowIterator().hasNext(); + } + + @Override + public Row next() { + // if there are cached rows, return one of them from the beginning + if (cachedRowCount > 0) { + cachedRowCount--; + return cachedRow; + } + + cachedRow = getCurrentRow(); + // return it directly if there is no more rows + if (!rowIterator().hasNext()) { + return cachedRow; + } + cachedRowCount = 1; + + cachedNextRow = rowIterator().next(); + + // pull rows from the iterator until two consecutive rows are different + while (isSameRecord(cachedRow, cachedNextRow, indicesToIdentifySameRow)) { + if (oppositeChangeType(cachedRow, cachedNextRow)) { + // two rows with opposite change types means no net changes, remove both + cachedRowCount--; + } else { + // two rows with same change types means potential net changes, cache the next row + cachedRowCount++; + } + + // stop pulling rows if there is no more rows or the next row is different + if (cachedRowCount <= 0 || !rowIterator().hasNext()) { + // reset the cached next row if there is no more rows + cachedNextRow = null; + break; + } + + cachedNextRow = rowIterator().next(); + } + + return null; + } + + private Row getCurrentRow() { + Row currentRow; + if (cachedNextRow != null) { + currentRow = cachedNextRow; + cachedNextRow = null; + } else { + currentRow = rowIterator().next(); + } + return currentRow; + } + + private boolean oppositeChangeType(Row currentRow, Row nextRow) { + return (changeType(nextRow).equals(INSERT) && changeType(currentRow).equals(DELETE)) + || (changeType(nextRow).equals(DELETE) && changeType(currentRow).equals(INSERT)); + } + + private int[] generateIndicesToIdentifySameRow() { + Set metadataColumnIndices = + Sets.newHashSet( + rowType().fieldIndex(MetadataColumns.CHANGE_ORDINAL.name()), + rowType().fieldIndex(MetadataColumns.COMMIT_SNAPSHOT_ID.name()), + changeTypeIndex()); + return generateIndicesToIdentifySameRow(rowType().size(), metadataColumnIndices); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java new file mode 100644 index 000000000000..f1709277525a --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/RollbackStagedTable.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.StagedTable; +import org.apache.spark.sql.connector.catalog.SupportsDeleteV2; +import org.apache.spark.sql.connector.catalog.SupportsRead; +import org.apache.spark.sql.connector.catalog.SupportsWrite; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCapability; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.WriteBuilder; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +/** + * An implementation of StagedTable that mimics the behavior of Spark's non-atomic CTAS and RTAS. + * + *

    A Spark catalog can implement StagingTableCatalog to support atomic operations by producing + * StagedTable. But if a catalog implements StagingTableCatalog, Spark expects the catalog to be + * able to produce a StagedTable for any table loaded by the catalog. This assumption doesn't always + * work, as in the case of {@link SparkSessionCatalog}, which supports atomic operations can produce + * a StagedTable for Iceberg tables, but wraps the session catalog and cannot necessarily produce a + * working StagedTable implementation for tables that it loads. + * + *

    The work-around is this class, which implements the StagedTable interface but does not have + * atomic behavior. Instead, the StagedTable interface is used to implement the behavior of the + * non-atomic SQL plans that will create a table, write, and will drop the table to roll back. + * + *

    This StagedTable implements SupportsRead, SupportsWrite, and SupportsDelete by passing the + * calls to the real table. Implementing those interfaces is safe because Spark will not use them + * unless the table supports them and returns the corresponding capabilities from {@link + * #capabilities()}. + */ +public class RollbackStagedTable + implements StagedTable, SupportsRead, SupportsWrite, SupportsDeleteV2 { + private final TableCatalog catalog; + private final Identifier ident; + private final Table table; + + public RollbackStagedTable(TableCatalog catalog, Identifier ident, Table table) { + this.catalog = catalog; + this.ident = ident; + this.table = table; + } + + @Override + public void commitStagedChanges() { + // the changes have already been committed to the table at the end of the write + } + + @Override + public void abortStagedChanges() { + // roll back changes by dropping the table + catalog.dropTable(ident); + } + + @Override + public String name() { + return table.name(); + } + + @Override + public StructType schema() { + return table.schema(); + } + + @Override + public Transform[] partitioning() { + return table.partitioning(); + } + + @Override + public Map properties() { + return table.properties(); + } + + @Override + public Set capabilities() { + return table.capabilities(); + } + + @Override + public void deleteWhere(Predicate[] predicates) { + call(SupportsDeleteV2.class, t -> t.deleteWhere(predicates)); + } + + @Override + public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) { + return callReturning(SupportsRead.class, t -> t.newScanBuilder(options)); + } + + @Override + public WriteBuilder newWriteBuilder(LogicalWriteInfo info) { + return callReturning(SupportsWrite.class, t -> t.newWriteBuilder(info)); + } + + private void call(Class requiredClass, Consumer task) { + callReturning( + requiredClass, + inst -> { + task.accept(inst); + return null; + }); + } + + private R callReturning(Class requiredClass, Function task) { + if (requiredClass.isInstance(table)) { + return task.apply(requiredClass.cast(table)); + } else { + throw new UnsupportedOperationException( + String.format( + "Table does not implement %s: %s (%s)", + requiredClass.getSimpleName(), table.name(), table.getClass().getName())); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ScanTaskSetManager.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ScanTaskSetManager.java new file mode 100644 index 000000000000..cab40d103171 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/ScanTaskSetManager.java @@ -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 org.apache.iceberg.spark; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.Pair; + +public class ScanTaskSetManager { + + private static final ScanTaskSetManager INSTANCE = new ScanTaskSetManager(); + + private final Map, List> tasksMap = + Maps.newConcurrentMap(); + + private ScanTaskSetManager() {} + + public static ScanTaskSetManager get() { + return INSTANCE; + } + + public void stageTasks(Table table, String setId, List tasks) { + Preconditions.checkArgument( + tasks != null && !tasks.isEmpty(), "Cannot stage null or empty tasks"); + Pair id = toId(table, setId); + tasksMap.put(id, tasks); + } + + @SuppressWarnings("unchecked") + public List fetchTasks(Table table, String setId) { + Pair id = toId(table, setId); + return (List) tasksMap.get(id); + } + + @SuppressWarnings("unchecked") + public List removeTasks(Table table, String setId) { + Pair id = toId(table, setId); + return (List) tasksMap.remove(id); + } + + public Set fetchSetIds(Table table) { + return tasksMap.keySet().stream() + .filter(e -> e.first().equals(Spark3Util.baseTableUUID(table))) + .map(Pair::second) + .collect(Collectors.toSet()); + } + + private Pair toId(Table table, String setId) { + return Pair.of(Spark3Util.baseTableUUID(table), setId); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SortOrderToSpark.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SortOrderToSpark.java new file mode 100644 index 000000000000..781f61b33f0e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SortOrderToSpark.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.transforms.SortOrderVisitor; +import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.NullOrdering; +import org.apache.spark.sql.connector.expressions.SortOrder; + +class SortOrderToSpark implements SortOrderVisitor { + + private final Map quotedNameById; + + SortOrderToSpark(Schema schema) { + this.quotedNameById = SparkSchemaUtil.indexQuotedNameById(schema); + } + + @Override + public SortOrder field(String sourceName, int id, SortDirection direction, NullOrder nullOrder) { + return Expressions.sort( + Expressions.column(quotedName(id)), toSpark(direction), toSpark(nullOrder)); + } + + @Override + public SortOrder bucket( + String sourceName, int id, int width, SortDirection direction, NullOrder nullOrder) { + return Expressions.sort( + Expressions.bucket(width, quotedName(id)), toSpark(direction), toSpark(nullOrder)); + } + + @Override + public SortOrder truncate( + String sourceName, int id, int width, SortDirection direction, NullOrder nullOrder) { + return Expressions.sort( + Expressions.apply( + "truncate", Expressions.literal(width), Expressions.column(quotedName(id))), + toSpark(direction), + toSpark(nullOrder)); + } + + @Override + public SortOrder year(String sourceName, int id, SortDirection direction, NullOrder nullOrder) { + return Expressions.sort( + Expressions.years(quotedName(id)), toSpark(direction), toSpark(nullOrder)); + } + + @Override + public SortOrder month(String sourceName, int id, SortDirection direction, NullOrder nullOrder) { + return Expressions.sort( + Expressions.months(quotedName(id)), toSpark(direction), toSpark(nullOrder)); + } + + @Override + public SortOrder day(String sourceName, int id, SortDirection direction, NullOrder nullOrder) { + return Expressions.sort( + Expressions.days(quotedName(id)), toSpark(direction), toSpark(nullOrder)); + } + + @Override + public SortOrder hour(String sourceName, int id, SortDirection direction, NullOrder nullOrder) { + return Expressions.sort( + Expressions.hours(quotedName(id)), toSpark(direction), toSpark(nullOrder)); + } + + private String quotedName(int id) { + return quotedNameById.get(id); + } + + private org.apache.spark.sql.connector.expressions.SortDirection toSpark( + SortDirection direction) { + if (direction == SortDirection.ASC) { + return org.apache.spark.sql.connector.expressions.SortDirection.ASCENDING; + } else { + return org.apache.spark.sql.connector.expressions.SortDirection.DESCENDING; + } + } + + private NullOrdering toSpark(NullOrder nullOrder) { + return nullOrder == NullOrder.NULLS_FIRST ? NullOrdering.NULLS_FIRST : NullOrdering.NULLS_LAST; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/Spark3Util.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/Spark3Util.java new file mode 100644 index 000000000000..064e4f7d6dc7 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/Spark3Util.java @@ -0,0 +1,1109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.BaseMetadataTable; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.UpdateProperties; +import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.expressions.BoundPredicate; +import org.apache.iceberg.expressions.ExpressionVisitors; +import org.apache.iceberg.expressions.Term; +import org.apache.iceberg.expressions.UnboundPredicate; +import org.apache.iceberg.expressions.UnboundTerm; +import org.apache.iceberg.expressions.UnboundTransform; +import org.apache.iceberg.expressions.Zorder; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.io.BaseEncoding; +import org.apache.iceberg.spark.SparkTableUtil.SparkPartition; +import org.apache.iceberg.spark.source.HasIcebergCatalog; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.iceberg.transforms.PartitionSpecVisitor; +import org.apache.iceberg.transforms.SortOrderVisitor; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.CatalystTypeConverters; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.catalyst.parser.ParserInterface; +import org.apache.spark.sql.connector.catalog.CatalogManager; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.CatalogV2Implicits; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.TableChange; +import org.apache.spark.sql.connector.expressions.Expression; +import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.Literal; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.execution.datasources.FileStatusCache; +import org.apache.spark.sql.execution.datasources.FileStatusWithMetadata; +import org.apache.spark.sql.execution.datasources.InMemoryFileIndex; +import org.apache.spark.sql.execution.datasources.PartitionDirectory; +import org.apache.spark.sql.types.IntegerType; +import org.apache.spark.sql.types.LongType; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.Option; +import scala.collection.JavaConverters; +import scala.collection.immutable.Seq; + +public class Spark3Util { + + private static final Logger LOG = LoggerFactory.getLogger(Spark3Util.class); + + private static final Set RESERVED_PROPERTIES = + ImmutableSet.of(TableCatalog.PROP_LOCATION, TableCatalog.PROP_PROVIDER); + private static final Joiner DOT = Joiner.on("."); + private static final String HIVE_NULL = "__HIVE_DEFAULT_PARTITION__"; + + private Spark3Util() {} + + public static Map rebuildCreateProperties(Map createProperties) { + ImmutableMap.Builder tableProperties = ImmutableMap.builder(); + createProperties.entrySet().stream() + .filter(entry -> !RESERVED_PROPERTIES.contains(entry.getKey())) + .forEach(tableProperties::put); + + String provider = createProperties.get(TableCatalog.PROP_PROVIDER); + if ("parquet".equalsIgnoreCase(provider)) { + tableProperties.put(TableProperties.DEFAULT_FILE_FORMAT, "parquet"); + } else if ("avro".equalsIgnoreCase(provider)) { + tableProperties.put(TableProperties.DEFAULT_FILE_FORMAT, "avro"); + } else if ("orc".equalsIgnoreCase(provider)) { + tableProperties.put(TableProperties.DEFAULT_FILE_FORMAT, "orc"); + } else if (provider != null && !"iceberg".equalsIgnoreCase(provider)) { + throw new IllegalArgumentException("Unsupported format in USING: " + provider); + } + + return tableProperties.build(); + } + + /** + * Applies a list of Spark table changes to an {@link UpdateProperties} operation. + * + * @param pendingUpdate an uncommitted UpdateProperties operation to configure + * @param changes a list of Spark table changes + * @return the UpdateProperties operation configured with the changes + */ + public static UpdateProperties applyPropertyChanges( + UpdateProperties pendingUpdate, List changes) { + for (TableChange change : changes) { + if (change instanceof TableChange.SetProperty) { + TableChange.SetProperty set = (TableChange.SetProperty) change; + pendingUpdate.set(set.property(), set.value()); + + } else if (change instanceof TableChange.RemoveProperty) { + TableChange.RemoveProperty remove = (TableChange.RemoveProperty) change; + pendingUpdate.remove(remove.property()); + + } else { + throw new UnsupportedOperationException("Cannot apply unknown table change: " + change); + } + } + + return pendingUpdate; + } + + /** + * Applies a list of Spark table changes to an {@link UpdateSchema} operation. + * + * @param pendingUpdate an uncommitted UpdateSchema operation to configure + * @param changes a list of Spark table changes + * @return the UpdateSchema operation configured with the changes + */ + public static UpdateSchema applySchemaChanges( + UpdateSchema pendingUpdate, List changes) { + for (TableChange change : changes) { + if (change instanceof TableChange.AddColumn) { + apply(pendingUpdate, (TableChange.AddColumn) change); + + } else if (change instanceof TableChange.UpdateColumnType) { + TableChange.UpdateColumnType update = (TableChange.UpdateColumnType) change; + Type newType = SparkSchemaUtil.convert(update.newDataType()); + Preconditions.checkArgument( + newType.isPrimitiveType(), + "Cannot update '%s', not a primitive type: %s", + DOT.join(update.fieldNames()), + update.newDataType()); + pendingUpdate.updateColumn(DOT.join(update.fieldNames()), newType.asPrimitiveType()); + + } else if (change instanceof TableChange.UpdateColumnComment) { + TableChange.UpdateColumnComment update = (TableChange.UpdateColumnComment) change; + pendingUpdate.updateColumnDoc(DOT.join(update.fieldNames()), update.newComment()); + + } else if (change instanceof TableChange.RenameColumn) { + TableChange.RenameColumn rename = (TableChange.RenameColumn) change; + pendingUpdate.renameColumn(DOT.join(rename.fieldNames()), rename.newName()); + + } else if (change instanceof TableChange.DeleteColumn) { + TableChange.DeleteColumn delete = (TableChange.DeleteColumn) change; + pendingUpdate.deleteColumn(DOT.join(delete.fieldNames())); + + } else if (change instanceof TableChange.UpdateColumnNullability) { + TableChange.UpdateColumnNullability update = (TableChange.UpdateColumnNullability) change; + if (update.nullable()) { + pendingUpdate.makeColumnOptional(DOT.join(update.fieldNames())); + } else { + pendingUpdate.requireColumn(DOT.join(update.fieldNames())); + } + + } else if (change instanceof TableChange.UpdateColumnPosition) { + apply(pendingUpdate, (TableChange.UpdateColumnPosition) change); + + } else { + throw new UnsupportedOperationException("Cannot apply unknown table change: " + change); + } + } + + return pendingUpdate; + } + + private static void apply(UpdateSchema pendingUpdate, TableChange.UpdateColumnPosition update) { + Preconditions.checkArgument(update.position() != null, "Invalid position: null"); + + if (update.position() instanceof TableChange.After) { + TableChange.After after = (TableChange.After) update.position(); + String referenceField = peerName(update.fieldNames(), after.column()); + pendingUpdate.moveAfter(DOT.join(update.fieldNames()), referenceField); + + } else if (update.position() instanceof TableChange.First) { + pendingUpdate.moveFirst(DOT.join(update.fieldNames())); + + } else { + throw new IllegalArgumentException("Unknown position for reorder: " + update.position()); + } + } + + private static void apply(UpdateSchema pendingUpdate, TableChange.AddColumn add) { + Preconditions.checkArgument( + add.isNullable(), + "Incompatible change: cannot add required column: %s", + leafName(add.fieldNames())); + if (add.defaultValue() != null) { + throw new UnsupportedOperationException( + String.format( + "Cannot add column %s since setting default values in Spark is currently unsupported", + leafName(add.fieldNames()))); + } + + Type type = SparkSchemaUtil.convert(add.dataType()); + pendingUpdate.addColumn( + parentName(add.fieldNames()), leafName(add.fieldNames()), type, add.comment()); + + if (add.position() instanceof TableChange.After) { + TableChange.After after = (TableChange.After) add.position(); + String referenceField = peerName(add.fieldNames(), after.column()); + pendingUpdate.moveAfter(DOT.join(add.fieldNames()), referenceField); + + } else if (add.position() instanceof TableChange.First) { + pendingUpdate.moveFirst(DOT.join(add.fieldNames())); + + } else { + Preconditions.checkArgument( + add.position() == null, + "Cannot add '%s' at unknown position: %s", + DOT.join(add.fieldNames()), + add.position()); + } + } + + public static org.apache.iceberg.Table toIcebergTable(Table table) { + Preconditions.checkArgument( + table instanceof SparkTable, "Table %s is not an Iceberg table", table); + SparkTable sparkTable = (SparkTable) table; + return sparkTable.table(); + } + + public static SortOrder[] toOrdering(org.apache.iceberg.SortOrder sortOrder) { + SortOrderToSpark visitor = new SortOrderToSpark(sortOrder.schema()); + List ordering = SortOrderVisitor.visit(sortOrder, visitor); + return ordering.toArray(new SortOrder[0]); + } + + public static Transform[] toTransforms(Schema schema, List fields) { + SpecTransformToSparkTransform visitor = new SpecTransformToSparkTransform(schema); + + List transforms = Lists.newArrayList(); + + for (PartitionField field : fields) { + Transform transform = PartitionSpecVisitor.visit(schema, field, visitor); + if (transform != null) { + transforms.add(transform); + } + } + + return transforms.toArray(new Transform[0]); + } + + /** + * Converts a PartitionSpec to Spark transforms. + * + * @param spec a PartitionSpec + * @return an array of Transforms + */ + public static Transform[] toTransforms(PartitionSpec spec) { + SpecTransformToSparkTransform visitor = new SpecTransformToSparkTransform(spec.schema()); + List transforms = PartitionSpecVisitor.visit(spec, visitor); + return transforms.stream().filter(Objects::nonNull).toArray(Transform[]::new); + } + + private static class SpecTransformToSparkTransform implements PartitionSpecVisitor { + private final Map quotedNameById; + + SpecTransformToSparkTransform(Schema schema) { + this.quotedNameById = SparkSchemaUtil.indexQuotedNameById(schema); + } + + @Override + public Transform identity(String sourceName, int sourceId) { + return Expressions.identity(quotedName(sourceId)); + } + + @Override + public Transform bucket(String sourceName, int sourceId, int numBuckets) { + return Expressions.bucket(numBuckets, quotedName(sourceId)); + } + + @Override + public Transform truncate(String sourceName, int sourceId, int width) { + NamedReference column = Expressions.column(quotedName(sourceId)); + return Expressions.apply("truncate", Expressions.literal(width), column); + } + + @Override + public Transform year(String sourceName, int sourceId) { + return Expressions.years(quotedName(sourceId)); + } + + @Override + public Transform month(String sourceName, int sourceId) { + return Expressions.months(quotedName(sourceId)); + } + + @Override + public Transform day(String sourceName, int sourceId) { + return Expressions.days(quotedName(sourceId)); + } + + @Override + public Transform hour(String sourceName, int sourceId) { + return Expressions.hours(quotedName(sourceId)); + } + + @Override + public Transform alwaysNull(int fieldId, String sourceName, int sourceId) { + // do nothing for alwaysNull, it doesn't need to be converted to a transform + return null; + } + + @Override + public Transform unknown(int fieldId, String sourceName, int sourceId, String transform) { + return Expressions.apply(transform, Expressions.column(quotedName(sourceId))); + } + + private String quotedName(int id) { + return quotedNameById.get(id); + } + } + + public static NamedReference toNamedReference(String name) { + return Expressions.column(name); + } + + public static NamedReference[] toNamedReferences(Set names) { + return names.stream().map(Spark3Util::toNamedReference).toArray(NamedReference[]::new); + } + + public static Term toIcebergTerm(Expression expr) { + if (expr instanceof Transform) { + Transform transform = (Transform) expr; + Preconditions.checkArgument( + "zorder".equals(transform.name()) || transform.references().length == 1, + "Cannot convert transform with more than one column reference: %s", + transform); + String colName = DOT.join(transform.references()[0].fieldNames()); + switch (transform.name().toLowerCase(Locale.ROOT)) { + case "identity": + return org.apache.iceberg.expressions.Expressions.ref(colName); + case "bucket": + return org.apache.iceberg.expressions.Expressions.bucket(colName, findWidth(transform)); + case "year": + case "years": + return org.apache.iceberg.expressions.Expressions.year(colName); + case "month": + case "months": + return org.apache.iceberg.expressions.Expressions.month(colName); + case "date": + case "day": + case "days": + return org.apache.iceberg.expressions.Expressions.day(colName); + case "date_hour": + case "hour": + case "hours": + return org.apache.iceberg.expressions.Expressions.hour(colName); + case "truncate": + return org.apache.iceberg.expressions.Expressions.truncate(colName, findWidth(transform)); + case "zorder": + return new Zorder( + Stream.of(transform.references()) + .map(ref -> DOT.join(ref.fieldNames())) + .map(org.apache.iceberg.expressions.Expressions::ref) + .collect(Collectors.toList())); + default: + throw new UnsupportedOperationException("Transform is not supported: " + transform); + } + + } else if (expr instanceof NamedReference) { + NamedReference ref = (NamedReference) expr; + return org.apache.iceberg.expressions.Expressions.ref(DOT.join(ref.fieldNames())); + + } else { + throw new UnsupportedOperationException("Cannot convert unknown expression: " + expr); + } + } + + /** + * Converts Spark transforms into a {@link PartitionSpec}. + * + * @param schema the table schema + * @param partitioning Spark Transforms + * @return a PartitionSpec + */ + public static PartitionSpec toPartitionSpec(Schema schema, Transform[] partitioning) { + if (partitioning == null || partitioning.length == 0) { + return PartitionSpec.unpartitioned(); + } + + PartitionSpec.Builder builder = PartitionSpec.builderFor(schema); + for (Transform transform : partitioning) { + Preconditions.checkArgument( + transform.references().length == 1, + "Cannot convert transform with more than one column reference: %s", + transform); + String colName = DOT.join(transform.references()[0].fieldNames()); + switch (transform.name().toLowerCase(Locale.ROOT)) { + case "identity": + builder.identity(colName); + break; + case "bucket": + builder.bucket(colName, findWidth(transform)); + break; + case "year": + case "years": + builder.year(colName); + break; + case "month": + case "months": + builder.month(colName); + break; + case "date": + case "day": + case "days": + builder.day(colName); + break; + case "date_hour": + case "hour": + case "hours": + builder.hour(colName); + break; + case "truncate": + builder.truncate(colName, findWidth(transform)); + break; + default: + throw new UnsupportedOperationException("Transform is not supported: " + transform); + } + } + + return builder.build(); + } + + @SuppressWarnings("unchecked") + private static int findWidth(Transform transform) { + for (Expression expr : transform.arguments()) { + if (expr instanceof Literal) { + if (((Literal) expr).dataType() instanceof IntegerType) { + Literal lit = (Literal) expr; + Preconditions.checkArgument( + lit.value() > 0, "Unsupported width for transform: %s", transform.describe()); + return lit.value(); + + } else if (((Literal) expr).dataType() instanceof LongType) { + Literal lit = (Literal) expr; + Preconditions.checkArgument( + lit.value() > 0 && lit.value() < Integer.MAX_VALUE, + "Unsupported width for transform: %s", + transform.describe()); + if (lit.value() > Integer.MAX_VALUE) { + throw new IllegalArgumentException(); + } + return lit.value().intValue(); + } + } + } + + throw new IllegalArgumentException("Cannot find width for transform: " + transform.describe()); + } + + private static String leafName(String[] fieldNames) { + Preconditions.checkArgument( + fieldNames.length > 0, "Invalid field name: at least one name is required"); + return fieldNames[fieldNames.length - 1]; + } + + private static String peerName(String[] fieldNames, String fieldName) { + if (fieldNames.length > 1) { + String[] peerNames = Arrays.copyOf(fieldNames, fieldNames.length); + peerNames[fieldNames.length - 1] = fieldName; + return DOT.join(peerNames); + } + return fieldName; + } + + private static String parentName(String[] fieldNames) { + if (fieldNames.length > 1) { + return DOT.join(Arrays.copyOfRange(fieldNames, 0, fieldNames.length - 1)); + } + return null; + } + + public static String describe(List exprs) { + return exprs.stream().map(Spark3Util::describe).collect(Collectors.joining(", ")); + } + + public static String describe(org.apache.iceberg.expressions.Expression expr) { + return ExpressionVisitors.visit(expr, DescribeExpressionVisitor.INSTANCE); + } + + public static String describe(Schema schema) { + return TypeUtil.visit(schema, DescribeSchemaVisitor.INSTANCE); + } + + public static String describe(Type type) { + return TypeUtil.visit(type, DescribeSchemaVisitor.INSTANCE); + } + + public static String describe(org.apache.iceberg.SortOrder order) { + return Joiner.on(", ").join(SortOrderVisitor.visit(order, DescribeSortOrderVisitor.INSTANCE)); + } + + public static boolean extensionsEnabled(SparkSession spark) { + String extensions = spark.conf().get("spark.sql.extensions", ""); + return extensions.contains("IcebergSparkSessionExtensions"); + } + + public static boolean containsIncrementalOptions(CaseInsensitiveStringMap options) { + return options.containsKey(SparkReadOptions.START_SNAPSHOT_ID) + || options.containsKey(SparkReadOptions.END_SNAPSHOT_ID); + } + + public static void validateNoLegacyTimeTravel(CaseInsensitiveStringMap options) { + Preconditions.checkArgument( + !options.containsKey(SparkReadOptions.LEGACY_SNAPSHOT_ID), + "Time travel option `%s` is no longer supported, use Spark built-in `%s` instead", + SparkReadOptions.LEGACY_SNAPSHOT_ID, + SparkReadOptions.VERSION_AS_OF); + + Preconditions.checkArgument( + !options.containsKey(SparkReadOptions.LEGACY_AS_OF_TIMESTAMP), + "Time travel option `%s` (in millis) is no longer supported, use Spark built-in `%s` instead (properly formatted timestamp)", + SparkReadOptions.LEGACY_AS_OF_TIMESTAMP, + SparkReadOptions.TIMESTAMP_AS_OF); + + Preconditions.checkArgument( + !options.containsKey(SparkReadOptions.LEGACY_TAG), + "Time travel option `%s` is no longer supported, use Spark built-in `%s` instead", + SparkReadOptions.LEGACY_TAG, + SparkReadOptions.VERSION_AS_OF); + } + + public static class DescribeSchemaVisitor extends TypeUtil.SchemaVisitor { + private static final Joiner COMMA = Joiner.on(','); + private static final DescribeSchemaVisitor INSTANCE = new DescribeSchemaVisitor(); + + private DescribeSchemaVisitor() {} + + @Override + public String schema(Schema schema, String structResult) { + return structResult; + } + + @Override + public String struct(Types.StructType struct, List fieldResults) { + return "struct<" + COMMA.join(fieldResults) + ">"; + } + + @Override + public String field(Types.NestedField field, String fieldResult) { + return field.name() + ": " + fieldResult + (field.isRequired() ? " not null" : ""); + } + + @Override + public String list(Types.ListType list, String elementResult) { + return "list<" + elementResult + ">"; + } + + @Override + public String map(Types.MapType map, String keyResult, String valueResult) { + return "map<" + keyResult + ", " + valueResult + ">"; + } + + @Override + public String variant(Types.VariantType variant) { + return "variant"; + } + + @Override + public String primitive(Type.PrimitiveType primitive) { + switch (primitive.typeId()) { + case BOOLEAN: + return "boolean"; + case INTEGER: + return "int"; + case LONG: + return "bigint"; + case FLOAT: + return "float"; + case DOUBLE: + return "double"; + case DATE: + return "date"; + case TIME: + return "time"; + case TIMESTAMP: + return "timestamp"; + case STRING: + case UUID: + return "string"; + case FIXED: + case BINARY: + return "binary"; + case DECIMAL: + Types.DecimalType decimal = (Types.DecimalType) primitive; + return "decimal(" + decimal.precision() + "," + decimal.scale() + ")"; + } + throw new UnsupportedOperationException("Cannot convert type to SQL: " + primitive); + } + } + + private static class DescribeExpressionVisitor + extends ExpressionVisitors.ExpressionVisitor { + private static final DescribeExpressionVisitor INSTANCE = new DescribeExpressionVisitor(); + + private DescribeExpressionVisitor() {} + + @Override + public String alwaysTrue() { + return "true"; + } + + @Override + public String alwaysFalse() { + return "false"; + } + + @Override + public String not(String result) { + return "NOT (" + result + ")"; + } + + @Override + public String and(String leftResult, String rightResult) { + return "(" + leftResult + " AND " + rightResult + ")"; + } + + @Override + public String or(String leftResult, String rightResult) { + return "(" + leftResult + " OR " + rightResult + ")"; + } + + @Override + public String predicate(BoundPredicate pred) { + throw new UnsupportedOperationException("Cannot convert bound predicates to SQL"); + } + + @Override + public String predicate(UnboundPredicate pred) { + switch (pred.op()) { + case IS_NULL: + return sqlString(pred.term()) + " IS NULL"; + case NOT_NULL: + return sqlString(pred.term()) + " IS NOT NULL"; + case IS_NAN: + return "is_nan(" + sqlString(pred.term()) + ")"; + case NOT_NAN: + return "not_nan(" + sqlString(pred.term()) + ")"; + case LT: + return sqlString(pred.term()) + " < " + sqlString(pred.literal()); + case LT_EQ: + return sqlString(pred.term()) + " <= " + sqlString(pred.literal()); + case GT: + return sqlString(pred.term()) + " > " + sqlString(pred.literal()); + case GT_EQ: + return sqlString(pred.term()) + " >= " + sqlString(pred.literal()); + case EQ: + return sqlString(pred.term()) + " = " + sqlString(pred.literal()); + case NOT_EQ: + return sqlString(pred.term()) + " != " + sqlString(pred.literal()); + case STARTS_WITH: + return sqlString(pred.term()) + " LIKE '" + pred.literal().value() + "%'"; + case NOT_STARTS_WITH: + return sqlString(pred.term()) + " NOT LIKE '" + pred.literal().value() + "%'"; + case IN: + return sqlString(pred.term()) + " IN (" + sqlString(pred.literals()) + ")"; + case NOT_IN: + return sqlString(pred.term()) + " NOT IN (" + sqlString(pred.literals()) + ")"; + default: + throw new UnsupportedOperationException("Cannot convert predicate to SQL: " + pred); + } + } + + private static String sqlString(UnboundTerm term) { + if (term instanceof org.apache.iceberg.expressions.NamedReference) { + return term.ref().name(); + } else if (term instanceof UnboundTransform) { + UnboundTransform transform = (UnboundTransform) term; + return transform.transform().toString() + "(" + transform.ref().name() + ")"; + } else { + throw new UnsupportedOperationException("Cannot convert term to SQL: " + term); + } + } + + private static String sqlString(List> literals) { + return literals.stream() + .map(DescribeExpressionVisitor::sqlString) + .collect(Collectors.joining(", ")); + } + + private static String sqlString(org.apache.iceberg.expressions.Literal lit) { + if (lit.value() instanceof String) { + return "'" + lit.value() + "'"; + } else if (lit.value() instanceof ByteBuffer) { + byte[] bytes = ByteBuffers.toByteArray((ByteBuffer) lit.value()); + return "X'" + BaseEncoding.base16().encode(bytes) + "'"; + } else { + return lit.value().toString(); + } + } + } + + /** + * Returns an Iceberg Table by its name from a Spark V2 Catalog. If cache is enabled in {@link + * SparkCatalog}, the {@link TableOperations} of the table may be stale, please refresh the table + * to get the latest one. + * + * @param spark SparkSession used for looking up catalog references and tables + * @param name The multipart identifier of the Iceberg table + * @return an Iceberg table + */ + public static org.apache.iceberg.Table loadIcebergTable(SparkSession spark, String name) + throws ParseException, NoSuchTableException { + CatalogAndIdentifier catalogAndIdentifier = catalogAndIdentifier(spark, name); + + TableCatalog catalog = asTableCatalog(catalogAndIdentifier.catalog); + Table sparkTable = catalog.loadTable(catalogAndIdentifier.identifier); + return toIcebergTable(sparkTable); + } + + /** + * Returns the underlying Iceberg Catalog object represented by a Spark Catalog + * + * @param spark SparkSession used for looking up catalog reference + * @param catalogName The name of the Spark Catalog being referenced + * @return the Iceberg catalog class being wrapped by the Spark Catalog + */ + public static Catalog loadIcebergCatalog(SparkSession spark, String catalogName) { + CatalogPlugin catalogPlugin = spark.sessionState().catalogManager().catalog(catalogName); + Preconditions.checkArgument( + catalogPlugin instanceof HasIcebergCatalog, + String.format( + "Cannot load Iceberg catalog from catalog %s because it does not contain an Iceberg Catalog. " + + "Actual Class: %s", + catalogName, catalogPlugin.getClass().getName())); + return ((HasIcebergCatalog) catalogPlugin).icebergCatalog(); + } + + public static CatalogAndIdentifier catalogAndIdentifier(SparkSession spark, String name) + throws ParseException { + return catalogAndIdentifier( + spark, name, spark.sessionState().catalogManager().currentCatalog()); + } + + public static CatalogAndIdentifier catalogAndIdentifier( + SparkSession spark, String name, CatalogPlugin defaultCatalog) throws ParseException { + ParserInterface parser = spark.sessionState().sqlParser(); + Seq multiPartIdentifier = parser.parseMultipartIdentifier(name).toIndexedSeq(); + List javaMultiPartIdentifier = JavaConverters.seqAsJavaList(multiPartIdentifier); + return catalogAndIdentifier(spark, javaMultiPartIdentifier, defaultCatalog); + } + + public static CatalogAndIdentifier catalogAndIdentifier( + String description, SparkSession spark, String name) { + return catalogAndIdentifier( + description, spark, name, spark.sessionState().catalogManager().currentCatalog()); + } + + public static CatalogAndIdentifier catalogAndIdentifier( + String description, SparkSession spark, String name, CatalogPlugin defaultCatalog) { + try { + return catalogAndIdentifier(spark, name, defaultCatalog); + } catch (ParseException e) { + throw new IllegalArgumentException("Cannot parse " + description + ": " + name, e); + } + } + + public static CatalogAndIdentifier catalogAndIdentifier( + SparkSession spark, List nameParts) { + return catalogAndIdentifier( + spark, nameParts, spark.sessionState().catalogManager().currentCatalog()); + } + + /** + * A modified version of Spark's LookupCatalog.CatalogAndIdentifier.unapply Attempts to find the + * catalog and identifier a multipart identifier represents + * + * @param spark Spark session to use for resolution + * @param nameParts Multipart identifier representing a table + * @param defaultCatalog Catalog to use if none is specified + * @return The CatalogPlugin and Identifier for the table + */ + public static CatalogAndIdentifier catalogAndIdentifier( + SparkSession spark, List nameParts, CatalogPlugin defaultCatalog) { + CatalogManager catalogManager = spark.sessionState().catalogManager(); + + String[] currentNamespace; + if (defaultCatalog.equals(catalogManager.currentCatalog())) { + currentNamespace = catalogManager.currentNamespace(); + } else { + currentNamespace = defaultCatalog.defaultNamespace(); + } + + Pair catalogIdentifier = + SparkUtil.catalogAndIdentifier( + nameParts, + catalogName -> { + try { + return catalogManager.catalog(catalogName); + } catch (Exception e) { + LOG.warn("Failed to load catalog: {}", catalogName, e); + return null; + } + }, + Identifier::of, + defaultCatalog, + currentNamespace); + return new CatalogAndIdentifier(catalogIdentifier); + } + + private static TableCatalog asTableCatalog(CatalogPlugin catalog) { + if (catalog instanceof TableCatalog) { + return (TableCatalog) catalog; + } + + throw new IllegalArgumentException( + String.format( + "Cannot use catalog %s(%s): not a TableCatalog", + catalog.name(), catalog.getClass().getName())); + } + + /** This mimics a class inside of Spark which is private inside of LookupCatalog. */ + public static class CatalogAndIdentifier { + private final CatalogPlugin catalog; + private final Identifier identifier; + + public CatalogAndIdentifier(CatalogPlugin catalog, Identifier identifier) { + this.catalog = catalog; + this.identifier = identifier; + } + + public CatalogAndIdentifier(Pair identifier) { + this.catalog = identifier.first(); + this.identifier = identifier.second(); + } + + public TableCatalog tableCatalog() { + Preconditions.checkArgument( + catalog instanceof TableCatalog, + "%s is not a valid table catalog: %s", + catalog.name(), + catalog.getClass().getName()); + return (TableCatalog) catalog; + } + + public CatalogPlugin catalog() { + return catalog; + } + + public Identifier identifier() { + return identifier; + } + } + + public static TableIdentifier identifierToTableIdentifier(Identifier identifier) { + return TableIdentifier.of(Namespace.of(identifier.namespace()), identifier.name()); + } + + public static String quotedFullIdentifier(String catalogName, Identifier identifier) { + List parts = + ImmutableList.builder() + .add(catalogName) + .addAll(Arrays.asList(identifier.namespace())) + .add(identifier.name()) + .build(); + + return CatalogV2Implicits.MultipartIdentifierHelper( + JavaConverters.asScalaIteratorConverter(parts.iterator()).asScala().toSeq()) + .quoted(); + } + + public static org.apache.spark.sql.execution.datasources.PartitionSpec getInferredSpec( + SparkSession spark, Path rootPath) { + FileStatusCache fileStatusCache = FileStatusCache.getOrCreate(spark); + InMemoryFileIndex fileIndex = + new InMemoryFileIndex( + spark, + JavaConverters.collectionAsScalaIterableConverter(ImmutableList.of(rootPath)) + .asScala() + .toSeq(), + scala.collection.immutable.Map$.MODULE$.empty(), + Option.empty(), // Pass empty so that automatic schema inference is used + fileStatusCache, + Option.empty(), + Option.empty()); + return fileIndex.partitionSpec(); + } + + /** + * Use Spark to list all partitions in the table. + * + * @param spark a Spark session + * @param rootPath a table identifier + * @param format format of the file + * @param partitionFilter partitionFilter of the file + * @param partitionSpec partitionSpec of the table + * @return all table's partitions + */ + public static List getPartitions( + SparkSession spark, + Path rootPath, + String format, + Map partitionFilter, + PartitionSpec partitionSpec) { + FileStatusCache fileStatusCache = FileStatusCache.getOrCreate(spark); + + Option userSpecifiedSchema = + partitionSpec == null + ? Option.empty() + : Option.apply( + SparkSchemaUtil.convert(new Schema(partitionSpec.partitionType().fields()))); + + InMemoryFileIndex fileIndex = + new InMemoryFileIndex( + spark, + JavaConverters.collectionAsScalaIterableConverter(ImmutableList.of(rootPath)) + .asScala() + .toSeq(), + scala.collection.immutable.Map$.MODULE$.empty(), + userSpecifiedSchema, + fileStatusCache, + Option.empty(), + Option.empty()); + + org.apache.spark.sql.execution.datasources.PartitionSpec spec = fileIndex.partitionSpec(); + StructType schema = spec.partitionColumns(); + if (schema.isEmpty()) { + return Lists.newArrayList(); + } + + List filterExpressions = + SparkUtil.partitionMapToExpression(schema, partitionFilter); + Seq scalaPartitionFilters = + JavaConverters.asScalaBufferConverter(filterExpressions).asScala().toIndexedSeq(); + + List dataFilters = Lists.newArrayList(); + Seq scalaDataFilters = + JavaConverters.asScalaBufferConverter(dataFilters).asScala().toIndexedSeq(); + + Seq filteredPartitions = + fileIndex.listFiles(scalaPartitionFilters, scalaDataFilters).toIndexedSeq(); + + return JavaConverters.seqAsJavaListConverter(filteredPartitions).asJava().stream() + .map( + partition -> { + Map values = Maps.newHashMap(); + JavaConverters.asJavaIterableConverter(schema) + .asJava() + .forEach( + field -> { + int fieldIndex = schema.fieldIndex(field.name()); + Object catalystValue = partition.values().get(fieldIndex, field.dataType()); + Object value = + CatalystTypeConverters.convertToScala(catalystValue, field.dataType()); + values.put(field.name(), (value == null) ? HIVE_NULL : value.toString()); + }); + + FileStatusWithMetadata fileStatus = + JavaConverters.seqAsJavaListConverter(partition.files()).asJava().get(0); + + return new SparkPartition( + values, fileStatus.getPath().getParent().toString(), format); + }) + .collect(Collectors.toList()); + } + + public static org.apache.spark.sql.catalyst.TableIdentifier toV1TableIdentifier( + Identifier identifier) { + String[] namespace = identifier.namespace(); + + Preconditions.checkArgument( + namespace.length <= 1, + "Cannot convert %s to a Spark v1 identifier, namespace contains more than 1 part", + identifier); + + String table = identifier.name(); + Option database = namespace.length == 1 ? Option.apply(namespace[0]) : Option.empty(); + return org.apache.spark.sql.catalyst.TableIdentifier.apply(table, database); + } + + public static String baseTableUUID(org.apache.iceberg.Table table) { + if (table instanceof HasTableOperations) { + TableOperations ops = ((HasTableOperations) table).operations(); + return ops.current().uuid(); + } else if (table instanceof BaseMetadataTable) { + return ((BaseMetadataTable) table).table().operations().current().uuid(); + } else { + throw new UnsupportedOperationException("Cannot retrieve UUID for table " + table.name()); + } + } + + private static class DescribeSortOrderVisitor implements SortOrderVisitor { + private static final DescribeSortOrderVisitor INSTANCE = new DescribeSortOrderVisitor(); + + private DescribeSortOrderVisitor() {} + + @Override + public String field( + String sourceName, + int sourceId, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("%s %s %s", sourceName, direction, nullOrder); + } + + @Override + public String bucket( + String sourceName, + int sourceId, + int numBuckets, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("bucket(%s, %s) %s %s", numBuckets, sourceName, direction, nullOrder); + } + + @Override + public String truncate( + String sourceName, + int sourceId, + int width, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("truncate(%s, %s) %s %s", sourceName, width, direction, nullOrder); + } + + @Override + public String year( + String sourceName, + int sourceId, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("years(%s) %s %s", sourceName, direction, nullOrder); + } + + @Override + public String month( + String sourceName, + int sourceId, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("months(%s) %s %s", sourceName, direction, nullOrder); + } + + @Override + public String day( + String sourceName, + int sourceId, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("days(%s) %s %s", sourceName, direction, nullOrder); + } + + @Override + public String hour( + String sourceName, + int sourceId, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("hours(%s) %s %s", sourceName, direction, nullOrder); + } + + @Override + public String unknown( + String sourceName, + int sourceId, + String transform, + org.apache.iceberg.SortDirection direction, + NullOrder nullOrder) { + return String.format("%s(%s) %s %s", transform, sourceName, direction, nullOrder); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkAggregates.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkAggregates.java new file mode 100644 index 000000000000..153ef11a9eb6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkAggregates.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expression.Operation; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.aggregate.AggregateFunc; +import org.apache.spark.sql.connector.expressions.aggregate.Count; +import org.apache.spark.sql.connector.expressions.aggregate.CountStar; +import org.apache.spark.sql.connector.expressions.aggregate.Max; +import org.apache.spark.sql.connector.expressions.aggregate.Min; + +public class SparkAggregates { + private SparkAggregates() {} + + private static final Map, Operation> AGGREGATES = + ImmutableMap., Operation>builder() + .put(Count.class, Operation.COUNT) + .put(CountStar.class, Operation.COUNT_STAR) + .put(Max.class, Operation.MAX) + .put(Min.class, Operation.MIN) + .buildOrThrow(); + + public static Expression convert(AggregateFunc aggregate) { + Operation op = AGGREGATES.get(aggregate.getClass()); + if (op != null) { + switch (op) { + case COUNT: + Count countAgg = (Count) aggregate; + if (countAgg.isDistinct()) { + // manifest file doesn't have count distinct so this can't be pushed down + return null; + } + + if (countAgg.column() instanceof NamedReference) { + return Expressions.count(SparkUtil.toColumnName((NamedReference) countAgg.column())); + } else { + return null; + } + + case COUNT_STAR: + return Expressions.countStar(); + + case MAX: + Max maxAgg = (Max) aggregate; + if (maxAgg.column() instanceof NamedReference) { + return Expressions.max(SparkUtil.toColumnName((NamedReference) maxAgg.column())); + } else { + return null; + } + + case MIN: + Min minAgg = (Min) aggregate; + if (minAgg.column() instanceof NamedReference) { + return Expressions.min(SparkUtil.toColumnName((NamedReference) minAgg.column())); + } else { + return null; + } + } + } + + return null; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java new file mode 100644 index 000000000000..12eefe697348 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java @@ -0,0 +1,963 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.TableProperties.GC_ENABLED; +import static org.apache.iceberg.TableProperties.GC_ENABLED_DEFAULT; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CachingCatalog; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.EnvironmentContext; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.catalog.ViewCatalog; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.hadoop.HadoopCatalog; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.source.SparkChangelogTable; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.iceberg.spark.source.SparkView; +import org.apache.iceberg.spark.source.StagedSparkTable; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.view.UpdateViewProperties; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NamespaceAlreadyExistsException; +import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.analysis.NoSuchViewException; +import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException; +import org.apache.spark.sql.catalyst.analysis.ViewAlreadyExistsException; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.NamespaceChange; +import org.apache.spark.sql.connector.catalog.StagedTable; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.TableChange; +import org.apache.spark.sql.connector.catalog.TableChange.ColumnChange; +import org.apache.spark.sql.connector.catalog.TableChange.RemoveProperty; +import org.apache.spark.sql.connector.catalog.TableChange.SetProperty; +import org.apache.spark.sql.connector.catalog.View; +import org.apache.spark.sql.connector.catalog.ViewChange; +import org.apache.spark.sql.connector.catalog.ViewInfo; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +/** + * A Spark TableCatalog implementation that wraps an Iceberg {@link Catalog}. + * + *

    This supports the following catalog configuration options: + * + *

      + *
    • type - catalog type, "hive" or "hadoop" or "rest". To specify a non-hive or + * hadoop catalog, use the catalog-impl option. + *
    • uri - the Hive Metastore URI for Hive catalog or REST URI for REST catalog + *
    • warehouse - the warehouse path (Hadoop catalog only) + *
    • catalog-impl - a custom {@link Catalog} implementation to use + *
    • io-impl - a custom {@link org.apache.iceberg.io.FileIO} implementation to use + *
    • metrics-reporter-impl - a custom {@link + * org.apache.iceberg.metrics.MetricsReporter} implementation to use + *
    • default-namespace - a namespace to use as the default + *
    • cache-enabled - whether to enable catalog cache + *
    • cache.case-sensitive - whether the catalog cache should compare table + * identifiers in a case sensitive way + *
    • cache.expiration-interval-ms - interval in millis before expiring tables from + * catalog cache. Refer to {@link CatalogProperties#CACHE_EXPIRATION_INTERVAL_MS} for further + * details and significant values. + *
    • table-default.$tablePropertyKey - table property $tablePropertyKey default at + * catalog level + *
    • table-override.$tablePropertyKey - table property $tablePropertyKey enforced + * at catalog level + *
    + * + *

    + */ +public class SparkCatalog extends BaseCatalog { + private static final Set DEFAULT_NS_KEYS = ImmutableSet.of(TableCatalog.PROP_OWNER); + private static final Splitter COMMA = Splitter.on(","); + private static final Joiner COMMA_JOINER = Joiner.on(","); + private static final Pattern AT_TIMESTAMP = Pattern.compile("at_timestamp_(\\d+)"); + private static final Pattern SNAPSHOT_ID = Pattern.compile("snapshot_id_(\\d+)"); + private static final Pattern BRANCH = Pattern.compile("branch_(.*)"); + private static final Pattern TAG = Pattern.compile("tag_(.*)"); + + private String catalogName = null; + private Catalog icebergCatalog = null; + private SupportsNamespaces asNamespaceCatalog = null; + private ViewCatalog asViewCatalog = null; + private String[] defaultNamespace = null; + private HadoopTables tables; + + /** + * Build an Iceberg {@link Catalog} to be used by this Spark catalog adapter. + * + * @param name Spark's catalog name + * @param options Spark's catalog options + * @return an Iceberg catalog + */ + protected Catalog buildIcebergCatalog(String name, CaseInsensitiveStringMap options) { + Configuration conf = SparkUtil.hadoopConfCatalogOverrides(SparkSession.active(), name); + Map optionsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + optionsMap.putAll(options.asCaseSensitiveMap()); + optionsMap.put(CatalogProperties.APP_ID, SparkSession.active().sparkContext().applicationId()); + optionsMap.put(CatalogProperties.USER, SparkSession.active().sparkContext().sparkUser()); + return CatalogUtil.buildIcebergCatalog(name, optionsMap, conf); + } + + /** + * Build an Iceberg {@link TableIdentifier} for the given Spark identifier. + * + * @param identifier Spark's identifier + * @return an Iceberg identifier + */ + protected TableIdentifier buildIdentifier(Identifier identifier) { + return Spark3Util.identifierToTableIdentifier(identifier); + } + + @Override + public Table loadTable(Identifier ident) throws NoSuchTableException { + return load(ident, null /* no time travel */); + } + + @Override + public Table loadTable(Identifier ident, String version) throws NoSuchTableException { + return load(ident, TimeTravel.version(version)); + } + + @Override + public Table loadTable(Identifier ident, long timestampMicros) throws NoSuchTableException { + return load(ident, TimeTravel.timestampMicros(timestampMicros)); + } + + @Override + public boolean tableExists(Identifier ident) { + if (isPathIdentifier(ident)) { + return tables.exists(((PathIdentifier) ident).location()); + } else { + return icebergCatalog.tableExists(buildIdentifier(ident)); + } + } + + @Override + public Table createTable( + Identifier ident, StructType schema, Transform[] transforms, Map properties) + throws TableAlreadyExistsException { + Schema icebergSchema = SparkSchemaUtil.convert(schema); + try { + Catalog.TableBuilder builder = newBuilder(ident, icebergSchema); + org.apache.iceberg.Table icebergTable = + builder + .withPartitionSpec(Spark3Util.toPartitionSpec(icebergSchema, transforms)) + .withLocation(properties.get("location")) + .withProperties(Spark3Util.rebuildCreateProperties(properties)) + .create(); + return new SparkTable(icebergTable); + } catch (AlreadyExistsException e) { + throw new TableAlreadyExistsException(ident); + } + } + + @Override + public StagedTable stageCreate( + Identifier ident, StructType schema, Transform[] transforms, Map properties) + throws TableAlreadyExistsException { + Schema icebergSchema = SparkSchemaUtil.convert(schema); + try { + Catalog.TableBuilder builder = newBuilder(ident, icebergSchema); + Transaction transaction = + builder + .withPartitionSpec(Spark3Util.toPartitionSpec(icebergSchema, transforms)) + .withLocation(properties.get("location")) + .withProperties(Spark3Util.rebuildCreateProperties(properties)) + .createTransaction(); + return new StagedSparkTable(transaction); + } catch (AlreadyExistsException e) { + throw new TableAlreadyExistsException(ident); + } + } + + @Override + public StagedTable stageReplace( + Identifier ident, StructType schema, Transform[] transforms, Map properties) + throws NoSuchTableException { + Schema icebergSchema = SparkSchemaUtil.convert(schema); + try { + Catalog.TableBuilder builder = newBuilder(ident, icebergSchema); + Transaction transaction = + builder + .withPartitionSpec(Spark3Util.toPartitionSpec(icebergSchema, transforms)) + .withLocation(properties.get("location")) + .withProperties(Spark3Util.rebuildCreateProperties(properties)) + .replaceTransaction(); + return new StagedSparkTable(transaction); + } catch (org.apache.iceberg.exceptions.NoSuchTableException e) { + throw new NoSuchTableException(ident); + } + } + + @Override + public StagedTable stageCreateOrReplace( + Identifier ident, StructType schema, Transform[] transforms, Map properties) { + Schema icebergSchema = SparkSchemaUtil.convert(schema); + Catalog.TableBuilder builder = newBuilder(ident, icebergSchema); + Transaction transaction = + builder + .withPartitionSpec(Spark3Util.toPartitionSpec(icebergSchema, transforms)) + .withLocation(properties.get("location")) + .withProperties(Spark3Util.rebuildCreateProperties(properties)) + .createOrReplaceTransaction(); + return new StagedSparkTable(transaction); + } + + @Override + public Table alterTable(Identifier ident, TableChange... changes) throws NoSuchTableException { + SetProperty setLocation = null; + SetProperty setSnapshotId = null; + SetProperty pickSnapshotId = null; + List propertyChanges = Lists.newArrayList(); + List schemaChanges = Lists.newArrayList(); + + for (TableChange change : changes) { + if (change instanceof SetProperty) { + SetProperty set = (SetProperty) change; + if (TableCatalog.PROP_LOCATION.equalsIgnoreCase(set.property())) { + setLocation = set; + } else if ("current-snapshot-id".equalsIgnoreCase(set.property())) { + setSnapshotId = set; + } else if ("cherry-pick-snapshot-id".equalsIgnoreCase(set.property())) { + pickSnapshotId = set; + } else if ("sort-order".equalsIgnoreCase(set.property())) { + throw new UnsupportedOperationException( + "Cannot specify the 'sort-order' because it's a reserved table " + + "property. Please use the command 'ALTER TABLE ... WRITE ORDERED BY' to specify write sort-orders."); + } else if ("identifier-fields".equalsIgnoreCase(set.property())) { + throw new UnsupportedOperationException( + "Cannot specify the 'identifier-fields' because it's a reserved table property. " + + "Please use the command 'ALTER TABLE ... SET IDENTIFIER FIELDS' to specify identifier fields."); + } else { + propertyChanges.add(set); + } + } else if (change instanceof RemoveProperty) { + propertyChanges.add(change); + } else if (change instanceof ColumnChange) { + schemaChanges.add(change); + } else { + throw new UnsupportedOperationException("Cannot apply unknown table change: " + change); + } + } + + try { + org.apache.iceberg.Table table = icebergCatalog.loadTable(buildIdentifier(ident)); + commitChanges( + table, setLocation, setSnapshotId, pickSnapshotId, propertyChanges, schemaChanges); + return new SparkTable(table); + } catch (org.apache.iceberg.exceptions.NoSuchTableException e) { + throw new NoSuchTableException(ident); + } + } + + @Override + public boolean dropTable(Identifier ident) { + return dropTableWithoutPurging(ident); + } + + @Override + public boolean purgeTable(Identifier ident) { + try { + org.apache.iceberg.Table table = icebergCatalog.loadTable(buildIdentifier(ident)); + ValidationException.check( + PropertyUtil.propertyAsBoolean(table.properties(), GC_ENABLED, GC_ENABLED_DEFAULT), + "Cannot purge table: GC is disabled (deleting files may corrupt other tables)"); + String metadataFileLocation = + ((HasTableOperations) table).operations().current().metadataFileLocation(); + + boolean dropped = dropTableWithoutPurging(ident); + + if (dropped) { + // check whether the metadata file exists because HadoopCatalog/HadoopTables + // will drop the warehouse directly and ignore the `purge` argument + boolean metadataFileExists = table.io().newInputFile(metadataFileLocation).exists(); + + if (metadataFileExists) { + SparkActions.get().deleteReachableFiles(metadataFileLocation).io(table.io()).execute(); + } + } + + return dropped; + } catch (org.apache.iceberg.exceptions.NoSuchTableException e) { + return false; + } + } + + private boolean dropTableWithoutPurging(Identifier ident) { + if (isPathIdentifier(ident)) { + return tables.dropTable(((PathIdentifier) ident).location(), false /* don't purge data */); + } else { + return icebergCatalog.dropTable(buildIdentifier(ident), false /* don't purge data */); + } + } + + @Override + public void renameTable(Identifier from, Identifier to) + throws NoSuchTableException, TableAlreadyExistsException { + try { + checkNotPathIdentifier(from, "renameTable"); + checkNotPathIdentifier(to, "renameTable"); + icebergCatalog.renameTable(buildIdentifier(from), buildIdentifier(to)); + } catch (org.apache.iceberg.exceptions.NoSuchTableException e) { + throw new NoSuchTableException(from); + } catch (AlreadyExistsException e) { + throw new TableAlreadyExistsException(to); + } + } + + @Override + public void invalidateTable(Identifier ident) { + if (!isPathIdentifier(ident)) { + icebergCatalog.invalidateTable(buildIdentifier(ident)); + } + } + + @Override + public Identifier[] listTables(String[] namespace) { + return icebergCatalog.listTables(Namespace.of(namespace)).stream() + .map(ident -> Identifier.of(ident.namespace().levels(), ident.name())) + .toArray(Identifier[]::new); + } + + @Override + public String[] defaultNamespace() { + if (defaultNamespace != null) { + return defaultNamespace; + } + + return new String[0]; + } + + @Override + public String[][] listNamespaces() { + if (asNamespaceCatalog != null) { + return asNamespaceCatalog.listNamespaces().stream() + .map(Namespace::levels) + .toArray(String[][]::new); + } + + return new String[0][]; + } + + @Override + public String[][] listNamespaces(String[] namespace) throws NoSuchNamespaceException { + if (asNamespaceCatalog != null) { + try { + return asNamespaceCatalog.listNamespaces(Namespace.of(namespace)).stream() + .map(Namespace::levels) + .toArray(String[][]::new); + } catch (org.apache.iceberg.exceptions.NoSuchNamespaceException e) { + throw new NoSuchNamespaceException(namespace); + } + } + + throw new NoSuchNamespaceException(namespace); + } + + @Override + public boolean namespaceExists(String[] namespace) { + return asNamespaceCatalog != null + && asNamespaceCatalog.namespaceExists(Namespace.of(namespace)); + } + + @Override + public Map loadNamespaceMetadata(String[] namespace) + throws NoSuchNamespaceException { + if (asNamespaceCatalog != null) { + try { + return asNamespaceCatalog.loadNamespaceMetadata(Namespace.of(namespace)); + } catch (org.apache.iceberg.exceptions.NoSuchNamespaceException e) { + throw new NoSuchNamespaceException(namespace); + } + } + + throw new NoSuchNamespaceException(namespace); + } + + @Override + public void createNamespace(String[] namespace, Map metadata) + throws NamespaceAlreadyExistsException { + if (asNamespaceCatalog != null) { + try { + if (asNamespaceCatalog instanceof HadoopCatalog + && DEFAULT_NS_KEYS.equals(metadata.keySet())) { + // Hadoop catalog will reject metadata properties, but Spark automatically adds "owner". + // If only the automatic properties are present, replace metadata with an empty map. + asNamespaceCatalog.createNamespace(Namespace.of(namespace), ImmutableMap.of()); + } else { + asNamespaceCatalog.createNamespace(Namespace.of(namespace), metadata); + } + } catch (AlreadyExistsException e) { + throw new NamespaceAlreadyExistsException(namespace); + } + } else { + throw new UnsupportedOperationException( + "Namespaces are not supported by catalog: " + catalogName); + } + } + + @Override + public void alterNamespace(String[] namespace, NamespaceChange... changes) + throws NoSuchNamespaceException { + if (asNamespaceCatalog != null) { + Map updates = Maps.newHashMap(); + Set removals = Sets.newHashSet(); + for (NamespaceChange change : changes) { + if (change instanceof NamespaceChange.SetProperty) { + NamespaceChange.SetProperty set = (NamespaceChange.SetProperty) change; + updates.put(set.property(), set.value()); + } else if (change instanceof NamespaceChange.RemoveProperty) { + removals.add(((NamespaceChange.RemoveProperty) change).property()); + } else { + throw new UnsupportedOperationException( + "Cannot apply unknown namespace change: " + change); + } + } + + try { + if (!updates.isEmpty()) { + asNamespaceCatalog.setProperties(Namespace.of(namespace), updates); + } + + if (!removals.isEmpty()) { + asNamespaceCatalog.removeProperties(Namespace.of(namespace), removals); + } + + } catch (org.apache.iceberg.exceptions.NoSuchNamespaceException e) { + throw new NoSuchNamespaceException(namespace); + } + } else { + throw new NoSuchNamespaceException(namespace); + } + } + + @Override + public boolean dropNamespace(String[] namespace, boolean cascade) + throws NoSuchNamespaceException { + if (asNamespaceCatalog != null) { + try { + return asNamespaceCatalog.dropNamespace(Namespace.of(namespace)); + } catch (org.apache.iceberg.exceptions.NoSuchNamespaceException e) { + throw new NoSuchNamespaceException(namespace); + } + } + + return false; + } + + @Override + public Identifier[] listViews(String... namespace) { + if (null != asViewCatalog) { + return asViewCatalog.listViews(Namespace.of(namespace)).stream() + .map(ident -> Identifier.of(ident.namespace().levels(), ident.name())) + .toArray(Identifier[]::new); + } + + return new Identifier[0]; + } + + @Override + public boolean viewExists(Identifier ident) { + return asViewCatalog != null && asViewCatalog.viewExists(buildIdentifier(ident)); + } + + @Override + public View loadView(Identifier ident) throws NoSuchViewException { + if (null != asViewCatalog) { + try { + org.apache.iceberg.view.View view = asViewCatalog.loadView(buildIdentifier(ident)); + return new SparkView(catalogName, view); + } catch (org.apache.iceberg.exceptions.NoSuchViewException e) { + throw new NoSuchViewException(ident); + } + } + + throw new NoSuchViewException(ident); + } + + @Override + public View createView(ViewInfo viewInfo) + throws ViewAlreadyExistsException, NoSuchNamespaceException { + if (null != asViewCatalog && viewInfo != null) { + Identifier ident = viewInfo.ident(); + String sql = viewInfo.sql(); + String currentCatalog = viewInfo.currentCatalog(); + String[] currentNamespace = viewInfo.currentNamespace(); + StructType schema = viewInfo.schema(); + String[] queryColumnNames = viewInfo.queryColumnNames(); + Map properties = viewInfo.properties(); + Schema icebergSchema = SparkSchemaUtil.convert(schema); + + try { + Map props = + ImmutableMap.builder() + .putAll(Spark3Util.rebuildCreateProperties(properties)) + .put(SparkView.QUERY_COLUMN_NAMES, COMMA_JOINER.join(queryColumnNames)) + .buildKeepingLast(); + + org.apache.iceberg.view.View view = + asViewCatalog + .buildView(buildIdentifier(ident)) + .withDefaultCatalog(currentCatalog) + .withDefaultNamespace(Namespace.of(currentNamespace)) + .withQuery("spark", sql) + .withSchema(icebergSchema) + .withLocation(properties.get("location")) + .withProperties(props) + .create(); + return new SparkView(catalogName, view); + } catch (org.apache.iceberg.exceptions.NoSuchNamespaceException e) { + throw new NoSuchNamespaceException(currentNamespace); + } catch (AlreadyExistsException e) { + throw new ViewAlreadyExistsException(ident); + } + } + + throw new UnsupportedOperationException( + "Creating a view is not supported by catalog: " + catalogName); + } + + @Override + public View replaceView( + Identifier ident, + String sql, + String currentCatalog, + String[] currentNamespace, + StructType schema, + String[] queryColumnNames, + String[] columnAliases, + String[] columnComments, + Map properties) + throws NoSuchNamespaceException, NoSuchViewException { + if (null != asViewCatalog) { + Schema icebergSchema = SparkSchemaUtil.convert(schema); + + try { + Map props = + ImmutableMap.builder() + .putAll(Spark3Util.rebuildCreateProperties(properties)) + .put(SparkView.QUERY_COLUMN_NAMES, COMMA_JOINER.join(queryColumnNames)) + .buildKeepingLast(); + + org.apache.iceberg.view.View view = + asViewCatalog + .buildView(buildIdentifier(ident)) + .withDefaultCatalog(currentCatalog) + .withDefaultNamespace(Namespace.of(currentNamespace)) + .withQuery("spark", sql) + .withSchema(icebergSchema) + .withLocation(properties.get("location")) + .withProperties(props) + .createOrReplace(); + return new SparkView(catalogName, view); + } catch (org.apache.iceberg.exceptions.NoSuchNamespaceException e) { + throw new NoSuchNamespaceException(currentNamespace); + } catch (org.apache.iceberg.exceptions.NoSuchViewException e) { + throw new NoSuchViewException(ident); + } + } + + throw new UnsupportedOperationException( + "Replacing a view is not supported by catalog: " + catalogName); + } + + @Override + public View alterView(Identifier ident, ViewChange... changes) + throws NoSuchViewException, IllegalArgumentException { + if (null != asViewCatalog) { + try { + org.apache.iceberg.view.View view = asViewCatalog.loadView(buildIdentifier(ident)); + UpdateViewProperties updateViewProperties = view.updateProperties(); + + for (ViewChange change : changes) { + if (change instanceof ViewChange.SetProperty) { + ViewChange.SetProperty property = (ViewChange.SetProperty) change; + verifyNonReservedPropertyIsSet(property.property()); + updateViewProperties.set(property.property(), property.value()); + } else if (change instanceof ViewChange.RemoveProperty) { + ViewChange.RemoveProperty remove = (ViewChange.RemoveProperty) change; + verifyNonReservedPropertyIsUnset(remove.property()); + updateViewProperties.remove(remove.property()); + } + } + + updateViewProperties.commit(); + + return new SparkView(catalogName, view); + } catch (org.apache.iceberg.exceptions.NoSuchViewException e) { + throw new NoSuchViewException(ident); + } + } + + throw new UnsupportedOperationException( + "Altering a view is not supported by catalog: " + catalogName); + } + + private static void verifyNonReservedProperty(String property, String errorMsg) { + if (SparkView.RESERVED_PROPERTIES.contains(property)) { + throw new UnsupportedOperationException(String.format(errorMsg, property)); + } + } + + private static void verifyNonReservedPropertyIsUnset(String property) { + verifyNonReservedProperty(property, "Cannot unset reserved property: '%s'"); + } + + private static void verifyNonReservedPropertyIsSet(String property) { + verifyNonReservedProperty(property, "Cannot set reserved property: '%s'"); + } + + @Override + public boolean dropView(Identifier ident) { + if (null != asViewCatalog) { + return asViewCatalog.dropView(buildIdentifier(ident)); + } + + return false; + } + + @Override + public void renameView(Identifier fromIdentifier, Identifier toIdentifier) + throws NoSuchViewException, ViewAlreadyExistsException { + if (null != asViewCatalog) { + try { + asViewCatalog.renameView(buildIdentifier(fromIdentifier), buildIdentifier(toIdentifier)); + } catch (org.apache.iceberg.exceptions.NoSuchViewException e) { + throw new NoSuchViewException(fromIdentifier); + } catch (org.apache.iceberg.exceptions.AlreadyExistsException e) { + throw new ViewAlreadyExistsException(toIdentifier); + } + } else { + throw new UnsupportedOperationException( + "Renaming a view is not supported by catalog: " + catalogName); + } + } + + @Override + public final void initialize(String name, CaseInsensitiveStringMap options) { + super.initialize(name, options); + + boolean cacheEnabled = + PropertyUtil.propertyAsBoolean( + options, CatalogProperties.CACHE_ENABLED, CatalogProperties.CACHE_ENABLED_DEFAULT); + + boolean cacheCaseSensitive = + PropertyUtil.propertyAsBoolean( + options, + CatalogProperties.CACHE_CASE_SENSITIVE, + CatalogProperties.CACHE_CASE_SENSITIVE_DEFAULT); + + long cacheExpirationIntervalMs = + PropertyUtil.propertyAsLong( + options, + CatalogProperties.CACHE_EXPIRATION_INTERVAL_MS, + CatalogProperties.CACHE_EXPIRATION_INTERVAL_MS_DEFAULT); + + // An expiration interval of 0ms effectively disables caching. + // Do not wrap with CachingCatalog. + if (cacheExpirationIntervalMs == 0) { + cacheEnabled = false; + } + + Catalog catalog = buildIcebergCatalog(name, options); + + this.catalogName = name; + SparkSession sparkSession = SparkSession.getActiveSession().get(); + this.tables = + new HadoopTables( + SparkUtil.hadoopConfCatalogOverrides(SparkSession.getActiveSession().get(), name)); + this.icebergCatalog = + cacheEnabled + ? CachingCatalog.wrap(catalog, cacheCaseSensitive, cacheExpirationIntervalMs) + : catalog; + if (catalog instanceof SupportsNamespaces) { + this.asNamespaceCatalog = (SupportsNamespaces) catalog; + if (options.containsKey("default-namespace")) { + this.defaultNamespace = + Splitter.on('.').splitToList(options.get("default-namespace")).toArray(new String[0]); + } + } + + if (catalog instanceof ViewCatalog) { + this.asViewCatalog = (ViewCatalog) catalog; + } + + EnvironmentContext.put(EnvironmentContext.ENGINE_NAME, "spark"); + EnvironmentContext.put( + EnvironmentContext.ENGINE_VERSION, sparkSession.sparkContext().version()); + EnvironmentContext.put(CatalogProperties.APP_ID, sparkSession.sparkContext().applicationId()); + EnvironmentContext.put(CatalogProperties.APP_NAME, sparkSession.sparkContext().appName()); + } + + @Override + public String name() { + return catalogName; + } + + private static void commitChanges( + org.apache.iceberg.Table table, + SetProperty setLocation, + SetProperty setSnapshotId, + SetProperty pickSnapshotId, + List propertyChanges, + List schemaChanges) { + // don't allow setting the snapshot and picking a commit at the same time because order is + // ambiguous and choosing one order leads to different results + Preconditions.checkArgument( + setSnapshotId == null || pickSnapshotId == null, + "Cannot set the current the current snapshot ID and cherry-pick snapshot changes"); + + if (setSnapshotId != null) { + long newSnapshotId = Long.parseLong(setSnapshotId.value()); + table.manageSnapshots().setCurrentSnapshot(newSnapshotId).commit(); + } + + // if updating the table snapshot, perform that update first in case it fails + if (pickSnapshotId != null) { + long newSnapshotId = Long.parseLong(pickSnapshotId.value()); + table.manageSnapshots().cherrypick(newSnapshotId).commit(); + } + + Transaction transaction = table.newTransaction(); + + if (setLocation != null) { + transaction.updateLocation().setLocation(setLocation.value()).commit(); + } + + if (!propertyChanges.isEmpty()) { + Spark3Util.applyPropertyChanges(transaction.updateProperties(), propertyChanges).commit(); + } + + if (!schemaChanges.isEmpty()) { + Spark3Util.applySchemaChanges(transaction.updateSchema(), schemaChanges).commit(); + } + + transaction.commitTransaction(); + } + + private static boolean isPathIdentifier(Identifier ident) { + return ident instanceof PathIdentifier; + } + + private static void checkNotPathIdentifier(Identifier identifier, String method) { + if (identifier instanceof PathIdentifier) { + throw new IllegalArgumentException( + String.format( + "Cannot pass path based identifier to %s method. %s is a path.", method, identifier)); + } + } + + private Table load(Identifier ident, TimeTravel timeTravel) throws NoSuchTableException { + if (isPathIdentifier(ident)) { + return loadPath((PathIdentifier) ident, timeTravel); + } + + try { + org.apache.iceberg.Table table = icebergCatalog.loadTable(buildIdentifier(ident)); + return SparkTable.create(table, timeTravel); + + } catch (org.apache.iceberg.exceptions.NoSuchTableException e) { + if (ident.namespace().length == 0) { + throw new NoSuchTableException(ident); + } + + // if the original load didn't work, try using the namespace as an identifier because + // the original identifier may include a snapshot selector or may point to the changelog + TableIdentifier namespaceAsIdent = buildIdentifier(namespaceToIdentifier(ident.namespace())); + org.apache.iceberg.Table table; + try { + table = icebergCatalog.loadTable(namespaceAsIdent); + } catch (Exception ignored) { + // the namespace does not identify a table, so it cannot be a table with a snapshot selector + // throw an exception for the original identifier + throw new NoSuchTableException(ident); + } + + // loading the namespace as a table worked, check the name to see if it is a valid selector + // or if the name points to the changelog + + if (ident.name().equalsIgnoreCase(SparkChangelogTable.TABLE_NAME)) { + Preconditions.checkArgument(timeTravel == null, "Can't time travel in changelog"); + return new SparkChangelogTable(table); + } + + Matcher branch = BRANCH.matcher(ident.name()); + if (branch.matches()) { + Preconditions.checkArgument(timeTravel == null, "Can't time travel in branch"); + return SparkTable.create(table, branch.group(1)); + } + + TimeTravel timeTravelSelector = parseTimeTravelSelector(ident.name()); + if (timeTravelSelector != null) { + Preconditions.checkArgument( + timeTravel == null, + "Can't time travel using selector and Spark time travel spec at the same time"); + return SparkTable.create(table, timeTravelSelector); + } + + // the name wasn't a valid snapshot selector and did not point to the changelog + // throw an exception for the original identifier + throw new NoSuchTableException(ident); + } + } + + private Pair> parseLocationString(String location) { + int hashIndex = location.lastIndexOf('#'); + if (hashIndex != -1 && !location.endsWith("#")) { + String baseLocation = location.substring(0, hashIndex); + List metadata = COMMA.splitToList(location.substring(hashIndex + 1)); + return Pair.of(baseLocation, metadata); + } else { + return Pair.of(location, ImmutableList.of()); + } + } + + @SuppressWarnings("CyclomaticComplexity") + private Table loadPath(PathIdentifier ident, TimeTravel timeTravel) throws NoSuchTableException { + Pair> parsed = parseLocationString(ident.location()); + + String metadataTableName = null; + String branch = null; + TimeTravel effectiveTimeTravel = timeTravel; + boolean isChangelog = false; + + for (String meta : parsed.second()) { + if (meta.equalsIgnoreCase(SparkChangelogTable.TABLE_NAME)) { + isChangelog = true; + continue; + } + + if (MetadataTableType.from(meta) != null) { + metadataTableName = meta; + continue; + } + + Matcher branchSelector = BRANCH.matcher(meta); + if (branchSelector.matches()) { + branch = branchSelector.group(1); + continue; + } + + TimeTravel timeTravelSelector = parseTimeTravelSelector(meta); + if (timeTravelSelector != null) { + Preconditions.checkArgument( + timeTravel == null, + "Can't time travel using selector and Spark time travel spec at the same time"); + Preconditions.checkArgument( + effectiveTimeTravel == null, + "Can't time travel using multiple time travel selectors: (%s, %s)", + effectiveTimeTravel, + timeTravelSelector); + effectiveTimeTravel = timeTravelSelector; + } + } + + org.apache.iceberg.Table table; + try { + String qualifier = metadataTableName != null ? "#" + metadataTableName : ""; + table = tables.load(parsed.first() + qualifier); + } catch (org.apache.iceberg.exceptions.NoSuchTableException e) { + throw new NoSuchTableException(ident); + } + + if (isChangelog) { + Preconditions.checkArgument(branch == null, "Cannot specify branch for changelogs"); + Preconditions.checkArgument(effectiveTimeTravel == null, "Cannot time travel in changelogs"); + return new SparkChangelogTable(table); + + } else if (branch != null) { + Preconditions.checkArgument(effectiveTimeTravel == null, "Cannot time travel in branch"); + return SparkTable.create(table, branch); + + } else { + return SparkTable.create(table, effectiveTimeTravel); + } + } + + private TimeTravel parseTimeTravelSelector(String selector) { + Matcher at = AT_TIMESTAMP.matcher(selector); + if (at.matches()) { + return TimeTravel.timestampMillis(Long.parseLong(at.group(1))); + } + + Matcher id = SNAPSHOT_ID.matcher(selector); + if (id.matches()) { + return TimeTravel.version(id.group(1)); + } + + Matcher tag = TAG.matcher(selector); + if (tag.matches()) { + return TimeTravel.version(tag.group(1)); + } + + return null; + } + + private Identifier namespaceToIdentifier(String[] namespace) { + Preconditions.checkArgument( + namespace.length > 0, "Cannot convert empty namespace to identifier"); + String[] ns = Arrays.copyOf(namespace, namespace.length - 1); + String name = namespace[ns.length]; + return Identifier.of(ns, name); + } + + private Catalog.TableBuilder newBuilder(Identifier ident, Schema schema) { + return isPathIdentifier(ident) + ? tables.buildTable(((PathIdentifier) ident).location(), schema) + : icebergCatalog.buildTable(buildIdentifier(ident), schema); + } + + @Override + public Catalog icebergCatalog() { + return icebergCatalog; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkCompressionUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkCompressionUtil.java new file mode 100644 index 000000000000..8f00b7f8301d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkCompressionUtil.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Locale; +import java.util.Map; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.Pair; +import org.apache.spark.SparkConf; +import org.apache.spark.sql.SparkSession; + +class SparkCompressionUtil { + + private static final String LZ4 = "lz4"; + private static final String ZSTD = "zstd"; + private static final String GZIP = "gzip"; + private static final String ZLIB = "zlib"; + private static final String SNAPPY = "snappy"; + private static final String NONE = "none"; + + // an internal Spark config that controls whether shuffle data is compressed + private static final String SHUFFLE_COMPRESSION_ENABLED = "spark.shuffle.compress"; + private static final boolean SHUFFLE_COMPRESSION_ENABLED_DEFAULT = true; + + // an internal Spark config that controls what compression codec is used + private static final String SPARK_COMPRESSION_CODEC = "spark.io.compression.codec"; + private static final String SPARK_COMPRESSION_CODEC_DEFAULT = "lz4"; + + private static final double DEFAULT_COLUMNAR_COMPRESSION = 2; + private static final Map, Double> COLUMNAR_COMPRESSIONS = + initColumnarCompressions(); + + private static final double DEFAULT_ROW_BASED_COMPRESSION = 1; + private static final Map, Double> ROW_BASED_COMPRESSIONS = + initRowBasedCompressions(); + + private SparkCompressionUtil() {} + + /** + * Estimates how much the data in shuffle map files will compress once it is written to disk using + * a particular file format and codec. + */ + public static double shuffleCompressionRatio( + SparkSession spark, FileFormat outputFileFormat, String outputCodec) { + if (outputFileFormat == FileFormat.ORC || outputFileFormat == FileFormat.PARQUET) { + return columnarCompression(shuffleCodec(spark), outputCodec); + } else if (outputFileFormat == FileFormat.AVRO) { + return rowBasedCompression(shuffleCodec(spark), outputCodec); + } else { + return 1.0; + } + } + + private static String shuffleCodec(SparkSession spark) { + SparkConf sparkConf = spark.sparkContext().conf(); + return shuffleCompressionEnabled(sparkConf) ? sparkCodec(sparkConf) : NONE; + } + + private static boolean shuffleCompressionEnabled(SparkConf sparkConf) { + return sparkConf.getBoolean(SHUFFLE_COMPRESSION_ENABLED, SHUFFLE_COMPRESSION_ENABLED_DEFAULT); + } + + private static String sparkCodec(SparkConf sparkConf) { + return sparkConf.get(SPARK_COMPRESSION_CODEC, SPARK_COMPRESSION_CODEC_DEFAULT); + } + + private static double columnarCompression(String shuffleCodec, String outputCodec) { + Pair key = Pair.of(normalize(shuffleCodec), normalize(outputCodec)); + return COLUMNAR_COMPRESSIONS.getOrDefault(key, DEFAULT_COLUMNAR_COMPRESSION); + } + + private static double rowBasedCompression(String shuffleCodec, String outputCodec) { + Pair key = Pair.of(normalize(shuffleCodec), normalize(outputCodec)); + return ROW_BASED_COMPRESSIONS.getOrDefault(key, DEFAULT_ROW_BASED_COMPRESSION); + } + + private static String normalize(String value) { + return value != null ? value.toLowerCase(Locale.ROOT) : null; + } + + private static Map, Double> initColumnarCompressions() { + Map, Double> compressions = Maps.newHashMap(); + + compressions.put(Pair.of(NONE, ZSTD), 4.0); + compressions.put(Pair.of(NONE, GZIP), 4.0); + compressions.put(Pair.of(NONE, ZLIB), 4.0); + compressions.put(Pair.of(NONE, SNAPPY), 3.0); + compressions.put(Pair.of(NONE, LZ4), 3.0); + + compressions.put(Pair.of(ZSTD, ZSTD), 2.0); + compressions.put(Pair.of(ZSTD, GZIP), 2.0); + compressions.put(Pair.of(ZSTD, ZLIB), 2.0); + compressions.put(Pair.of(ZSTD, SNAPPY), 1.5); + compressions.put(Pair.of(ZSTD, LZ4), 1.5); + + compressions.put(Pair.of(SNAPPY, ZSTD), 3.0); + compressions.put(Pair.of(SNAPPY, GZIP), 3.0); + compressions.put(Pair.of(SNAPPY, ZLIB), 3.0); + compressions.put(Pair.of(SNAPPY, SNAPPY), 2.0); + compressions.put(Pair.of(SNAPPY, LZ4), 2.); + + compressions.put(Pair.of(LZ4, ZSTD), 3.0); + compressions.put(Pair.of(LZ4, GZIP), 3.0); + compressions.put(Pair.of(LZ4, ZLIB), 3.0); + compressions.put(Pair.of(LZ4, SNAPPY), 2.0); + compressions.put(Pair.of(LZ4, LZ4), 2.0); + + return compressions; + } + + private static Map, Double> initRowBasedCompressions() { + Map, Double> compressions = Maps.newHashMap(); + + compressions.put(Pair.of(NONE, ZSTD), 2.0); + compressions.put(Pair.of(NONE, GZIP), 2.0); + compressions.put(Pair.of(NONE, ZLIB), 2.0); + + compressions.put(Pair.of(ZSTD, SNAPPY), 0.5); + compressions.put(Pair.of(ZSTD, LZ4), 0.5); + + compressions.put(Pair.of(SNAPPY, ZSTD), 1.5); + compressions.put(Pair.of(SNAPPY, GZIP), 1.5); + compressions.put(Pair.of(SNAPPY, ZLIB), 1.5); + + compressions.put(Pair.of(LZ4, ZSTD), 1.5); + compressions.put(Pair.of(LZ4, GZIP), 1.5); + compressions.put(Pair.of(LZ4, ZLIB), 1.5); + + return compressions; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkConfParser.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkConfParser.java new file mode 100644 index 000000000000..923e84a161f7 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkConfParser.java @@ -0,0 +1,320 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.network.util.JavaUtils; +import org.apache.spark.sql.RuntimeConfig; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +class SparkConfParser { + + private final Map properties; + private final RuntimeConfig sessionConf; + private final CaseInsensitiveStringMap options; + + SparkConfParser() { + this.properties = ImmutableMap.of(); + this.sessionConf = new org.apache.spark.sql.classic.RuntimeConfig(SQLConf.get()); + this.options = CaseInsensitiveStringMap.empty(); + } + + SparkConfParser(SparkSession spark, Table table, Map options) { + this.properties = table.properties(); + this.sessionConf = spark.conf(); + this.options = asCaseInsensitiveStringMap(options); + } + + public BooleanConfParser booleanConf() { + return new BooleanConfParser(); + } + + public IntConfParser intConf() { + return new IntConfParser(); + } + + public LongConfParser longConf() { + return new LongConfParser(); + } + + public StringConfParser stringConf() { + return new StringConfParser(); + } + + public DurationConfParser durationConf() { + return new DurationConfParser(); + } + + public > EnumConfParser enumConf(Function toEnum) { + return new EnumConfParser<>(toEnum); + } + + private static CaseInsensitiveStringMap asCaseInsensitiveStringMap(Map map) { + if (map instanceof CaseInsensitiveStringMap) { + return (CaseInsensitiveStringMap) map; + } else { + return new CaseInsensitiveStringMap(map); + } + } + + class BooleanConfParser extends ConfParser { + private Boolean defaultValue; + private boolean negate = false; + + @Override + protected BooleanConfParser self() { + return this; + } + + public BooleanConfParser defaultValue(boolean value) { + this.defaultValue = value; + return self(); + } + + public BooleanConfParser defaultValue(String value) { + this.defaultValue = Boolean.parseBoolean(value); + return self(); + } + + public BooleanConfParser negate() { + this.negate = true; + return self(); + } + + public boolean parse() { + Preconditions.checkArgument(defaultValue != null, "Default value cannot be null"); + boolean value = parse(Boolean::parseBoolean, defaultValue); + return negate ? !value : value; + } + } + + class IntConfParser extends ConfParser { + private Integer defaultValue; + + @Override + protected IntConfParser self() { + return this; + } + + public IntConfParser defaultValue(int value) { + this.defaultValue = value; + return self(); + } + + public int parse() { + Preconditions.checkArgument(defaultValue != null, "Default value cannot be null"); + return parse(Integer::parseInt, defaultValue); + } + + public Integer parseOptional() { + return parse(Integer::parseInt, defaultValue); + } + } + + class LongConfParser extends ConfParser { + private Long defaultValue; + + @Override + protected LongConfParser self() { + return this; + } + + public LongConfParser defaultValue(long value) { + this.defaultValue = value; + return self(); + } + + public long parse() { + Preconditions.checkArgument(defaultValue != null, "Default value cannot be null"); + return parse(Long::parseLong, defaultValue); + } + + public Long parseOptional() { + return parse(Long::parseLong, defaultValue); + } + } + + class StringConfParser extends ConfParser { + private String defaultValue; + + @Override + protected StringConfParser self() { + return this; + } + + public StringConfParser defaultValue(String value) { + this.defaultValue = value; + return self(); + } + + public String parse() { + Preconditions.checkArgument(defaultValue != null, "Default value cannot be null"); + return parse(Function.identity(), defaultValue); + } + + public String parseOptional() { + return parse(Function.identity(), defaultValue); + } + } + + class DurationConfParser extends ConfParser { + private Duration defaultValue; + + @Override + protected DurationConfParser self() { + return this; + } + + public DurationConfParser defaultValue(Duration value) { + this.defaultValue = value; + return self(); + } + + public Duration parse() { + Preconditions.checkArgument(defaultValue != null, "Default value cannot be null"); + return parse(this::toDuration, defaultValue); + } + + public Duration parseOptional() { + return parse(this::toDuration, defaultValue); + } + + private Duration toDuration(String time) { + return Duration.ofSeconds(JavaUtils.timeStringAsSec(time)); + } + } + + class EnumConfParser> extends ConfParser, T> { + private final Function toEnum; + private T defaultValue; + + EnumConfParser(Function toEnum) { + this.toEnum = toEnum; + } + + @Override + protected EnumConfParser self() { + return this; + } + + public EnumConfParser defaultValue(T value) { + this.defaultValue = value; + return self(); + } + + public EnumConfParser defaultValue(String value) { + this.defaultValue = toEnum.apply(value); + return self(); + } + + public T parse() { + Preconditions.checkArgument(defaultValue != null, "Default value cannot be null"); + return parse(toEnum, defaultValue); + } + + public T parseOptional() { + return parse(toEnum, defaultValue); + } + } + + abstract class ConfParser { + private final List optionNames = Lists.newArrayList(); + private String sessionConfName; + private String tablePropertyName; + + protected abstract ThisT self(); + + public ThisT option(String name) { + this.optionNames.add(name); + return self(); + } + + public ThisT sessionConf(String name) { + this.sessionConfName = name; + return self(); + } + + public ThisT tableProperty(String name) { + this.tablePropertyName = name; + return self(); + } + + protected T parse(Function conversion, T defaultValue) { + for (String optionName : optionNames) { + String optionValue = options.get(optionName); + if (optionValue != null) { + return conversion.apply(optionValue); + } + + String sparkOptionValue = options.get(toCamelCase(optionName)); + if (sparkOptionValue != null) { + return conversion.apply(sparkOptionValue); + } + } + + if (sessionConfName != null) { + String sessionConfValue = sessionConf.get(sessionConfName, null); + if (sessionConfValue != null) { + return conversion.apply(sessionConfValue); + } + + String sparkSessionConfValue = sessionConf.get(toCamelCase(sessionConfName), null); + if (sparkSessionConfValue != null) { + return conversion.apply(sparkSessionConfValue); + } + } + + if (tablePropertyName != null) { + String propertyValue = properties.get(tablePropertyName); + if (propertyValue != null) { + return conversion.apply(propertyValue); + } + } + + return defaultValue; + } + + private String toCamelCase(String key) { + StringBuilder transformedKey = new StringBuilder(); + boolean capitalizeNext = false; + + for (char character : key.toCharArray()) { + if (character == '-') { + capitalizeNext = true; + } else if (capitalizeNext) { + transformedKey.append(Character.toUpperCase(character)); + capitalizeNext = false; + } else { + transformedKey.append(character); + } + } + + return transformedKey.toString(); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java new file mode 100644 index 000000000000..7b44d93e53b5 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkContentFile.java @@ -0,0 +1,286 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.StructProjection; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.types.StructType; + +public abstract class SparkContentFile implements ContentFile { + + private final int fileContentPosition; + private final int filePathPosition; + private final int fileFormatPosition; + private final int partitionPosition; + private final int recordCountPosition; + private final int fileSizeInBytesPosition; + private final int columnSizesPosition; + private final int valueCountsPosition; + private final int nullValueCountsPosition; + private final int nanValueCountsPosition; + private final int lowerBoundsPosition; + private final int upperBoundsPosition; + private final int keyMetadataPosition; + private final int splitOffsetsPosition; + private final int sortOrderIdPosition; + private final int fileSpecIdPosition; + private final int equalityIdsPosition; + private final int firstRowIdPosition; + private final int referencedDataFilePosition; + private final int contentOffsetPosition; + private final int contentSizePosition; + private final Type lowerBoundsType; + private final Type upperBoundsType; + private final Type keyMetadataType; + + private final SparkStructLike wrappedPartition; + private final StructLike projectedPartition; + private Row wrapped; + + SparkContentFile(Types.StructType type, Types.StructType projectedType, StructType sparkType) { + this.lowerBoundsType = type.fieldType(DataFile.LOWER_BOUNDS.name()); + this.upperBoundsType = type.fieldType(DataFile.UPPER_BOUNDS.name()); + this.keyMetadataType = type.fieldType(DataFile.KEY_METADATA.name()); + + Types.StructType partitionType = type.fieldType(DataFile.PARTITION_NAME).asStructType(); + this.wrappedPartition = new SparkStructLike(partitionType); + + if (projectedType != null) { + Types.StructType projectedPartitionType = + projectedType.fieldType(DataFile.PARTITION_NAME).asStructType(); + StructProjection partitionProjection = + StructProjection.create(partitionType, projectedPartitionType); + this.projectedPartition = partitionProjection.wrap(wrappedPartition); + } else { + this.projectedPartition = wrappedPartition; + } + + Map positions = Maps.newHashMap(); + for (Types.NestedField field : type.fields()) { + String fieldName = field.name(); + positions.put(fieldName, fieldPosition(fieldName, sparkType)); + } + + this.fileContentPosition = positions.get(DataFile.CONTENT.name()); + this.filePathPosition = positions.get(DataFile.FILE_PATH.name()); + this.fileFormatPosition = positions.get(DataFile.FILE_FORMAT.name()); + this.partitionPosition = positions.get(DataFile.PARTITION_NAME); + this.recordCountPosition = positions.get(DataFile.RECORD_COUNT.name()); + this.fileSizeInBytesPosition = positions.get(DataFile.FILE_SIZE.name()); + this.columnSizesPosition = positions.get(DataFile.COLUMN_SIZES.name()); + this.valueCountsPosition = positions.get(DataFile.VALUE_COUNTS.name()); + this.nullValueCountsPosition = positions.get(DataFile.NULL_VALUE_COUNTS.name()); + this.nanValueCountsPosition = positions.get(DataFile.NAN_VALUE_COUNTS.name()); + this.lowerBoundsPosition = positions.get(DataFile.LOWER_BOUNDS.name()); + this.upperBoundsPosition = positions.get(DataFile.UPPER_BOUNDS.name()); + this.keyMetadataPosition = positions.get(DataFile.KEY_METADATA.name()); + this.splitOffsetsPosition = positions.get(DataFile.SPLIT_OFFSETS.name()); + this.sortOrderIdPosition = positions.get(DataFile.SORT_ORDER_ID.name()); + this.fileSpecIdPosition = positions.get(DataFile.SPEC_ID.name()); + this.equalityIdsPosition = positions.get(DataFile.EQUALITY_IDS.name()); + this.firstRowIdPosition = positions.get(DataFile.FIRST_ROW_ID.name()); + this.referencedDataFilePosition = positions.get(DataFile.REFERENCED_DATA_FILE.name()); + this.contentOffsetPosition = positions.get(DataFile.CONTENT_OFFSET.name()); + this.contentSizePosition = positions.get(DataFile.CONTENT_SIZE.name()); + } + + public F wrap(Row row) { + this.wrapped = row; + if (wrappedPartition.size() > 0) { + wrappedPartition.wrap(row.getAs(partitionPosition)); + } + return asFile(); + } + + protected abstract F asFile(); + + @Override + public Long pos() { + return null; + } + + @Override + public int specId() { + if (wrapped.isNullAt(fileSpecIdPosition)) { + return -1; + } + return wrapped.getAs(fileSpecIdPosition); + } + + @Override + public FileContent content() { + if (wrapped.isNullAt(fileContentPosition)) { + return null; + } + return FileContent.fromId(wrapped.getInt(fileContentPosition)); + } + + @Override + public CharSequence path() { + return wrapped.getAs(filePathPosition); + } + + @Override + public FileFormat format() { + return FileFormat.fromString(wrapped.getString(fileFormatPosition)); + } + + @Override + public StructLike partition() { + return projectedPartition; + } + + @Override + public long recordCount() { + return wrapped.getAs(recordCountPosition); + } + + @Override + public long fileSizeInBytes() { + return wrapped.getAs(fileSizeInBytesPosition); + } + + @Override + public Map columnSizes() { + return wrapped.isNullAt(columnSizesPosition) ? null : wrapped.getJavaMap(columnSizesPosition); + } + + @Override + public Map valueCounts() { + return wrapped.isNullAt(valueCountsPosition) ? null : wrapped.getJavaMap(valueCountsPosition); + } + + @Override + public Map nullValueCounts() { + if (wrapped.isNullAt(nullValueCountsPosition)) { + return null; + } + return wrapped.getJavaMap(nullValueCountsPosition); + } + + @Override + public Map nanValueCounts() { + if (wrapped.isNullAt(nanValueCountsPosition)) { + return null; + } + return wrapped.getJavaMap(nanValueCountsPosition); + } + + @Override + public Map lowerBounds() { + Map lowerBounds = + wrapped.isNullAt(lowerBoundsPosition) ? null : wrapped.getJavaMap(lowerBoundsPosition); + return convert(lowerBoundsType, lowerBounds); + } + + @Override + public Map upperBounds() { + Map upperBounds = + wrapped.isNullAt(upperBoundsPosition) ? null : wrapped.getJavaMap(upperBoundsPosition); + return convert(upperBoundsType, upperBounds); + } + + @Override + public ByteBuffer keyMetadata() { + return convert(keyMetadataType, wrapped.get(keyMetadataPosition)); + } + + @Override + public F copy() { + throw new UnsupportedOperationException("Not implemented: copy"); + } + + @Override + public F copyWithoutStats() { + throw new UnsupportedOperationException("Not implemented: copyWithoutStats"); + } + + @Override + public List splitOffsets() { + return wrapped.isNullAt(splitOffsetsPosition) ? null : wrapped.getList(splitOffsetsPosition); + } + + @Override + public Integer sortOrderId() { + return wrapped.getAs(sortOrderIdPosition); + } + + @Override + public List equalityFieldIds() { + return wrapped.isNullAt(equalityIdsPosition) ? null : wrapped.getList(equalityIdsPosition); + } + + public String referencedDataFile() { + if (wrapped.isNullAt(referencedDataFilePosition)) { + return null; + } + return wrapped.getString(referencedDataFilePosition); + } + + public Long contentOffset() { + if (wrapped.isNullAt(contentOffsetPosition)) { + return null; + } + return wrapped.getLong(contentOffsetPosition); + } + + public Long contentSizeInBytes() { + if (wrapped.isNullAt(contentSizePosition)) { + return null; + } + return wrapped.getLong(contentSizePosition); + } + + @Override + public Long firstRowId() { + if (wrapped.isNullAt(firstRowIdPosition)) { + return null; + } + + return wrapped.getLong(firstRowIdPosition); + } + + private int fieldPosition(String name, StructType sparkType) { + try { + return sparkType.fieldIndex(name); + } catch (IllegalArgumentException e) { + // the partition field is absent for unpartitioned tables + if (name.equals(DataFile.PARTITION_NAME) && wrappedPartition.size() == 0) { + return -1; + } + throw e; + } + } + + @SuppressWarnings("unchecked") + private T convert(Type valueType, Object value) { + return (T) SparkValueConverter.convert(valueType, value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkDataFile.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkDataFile.java new file mode 100644 index 000000000000..543ebf3f9ea7 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkDataFile.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.List; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.types.StructType; + +public class SparkDataFile extends SparkContentFile implements DataFile { + + public SparkDataFile(Types.StructType type, StructType sparkType) { + super(type, null, sparkType); + } + + public SparkDataFile( + Types.StructType type, Types.StructType projectedType, StructType sparkType) { + super(type, projectedType, sparkType); + } + + @Override + protected DataFile asFile() { + return this; + } + + @Override + public List equalityFieldIds() { + return null; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkDeleteFile.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkDeleteFile.java new file mode 100644 index 000000000000..6250a1630683 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkDeleteFile.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.types.StructType; + +public class SparkDeleteFile extends SparkContentFile implements DeleteFile { + + public SparkDeleteFile(Types.StructType type, StructType sparkType) { + super(type, null, sparkType); + } + + public SparkDeleteFile( + Types.StructType type, Types.StructType projectedType, StructType sparkType) { + super(type, projectedType, sparkType); + } + + @Override + protected DeleteFile asFile() { + return this; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkExceptionUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkExceptionUtil.java new file mode 100644 index 000000000000..5c6fe3e0ff96 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkExceptionUtil.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import com.google.errorprone.annotations.FormatMethod; +import java.io.IOException; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.spark.sql.AnalysisException; + +public class SparkExceptionUtil { + + private SparkExceptionUtil() {} + + /** + * Converts checked exceptions to unchecked exceptions. + * + * @param cause a checked exception object which is to be converted to its unchecked equivalent. + * @param message exception message as a format string + * @param args format specifiers + * @return unchecked exception. + */ + @FormatMethod + public static RuntimeException toUncheckedException( + final Throwable cause, final String message, final Object... args) { + // Parameters are required to be final to help @FormatMethod do static analysis + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + + } else if (cause instanceof org.apache.spark.sql.catalyst.analysis.NoSuchDatabaseException) { + return new NoSuchNamespaceException(cause, message, args); + + } else if (cause instanceof org.apache.spark.sql.catalyst.analysis.NoSuchTableException) { + return new NoSuchTableException(cause, message, args); + + } else if (cause instanceof AnalysisException) { + return new ValidationException(cause, message, args); + + } else if (cause instanceof IOException) { + return new RuntimeIOException((IOException) cause, message, args); + + } else { + return new RuntimeException(String.format(message, args), cause); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkExecutorCache.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkExecutorCache.java new file mode 100644 index 000000000000..6490d6678b46 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkExecutorCache.java @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An executor cache for reducing the computation and IO overhead in tasks. + * + *

    The cache is configured and controlled through Spark SQL properties. It supports both limits + * on the total cache size and maximum size for individual entries. Additionally, it implements + * automatic eviction of entries after a specified duration of inactivity. The cache will respect + * the SQL configuration valid at the time of initialization. All subsequent changes to the + * configuration will have no effect. + * + *

    The cache is accessed and populated via {@link #getOrLoad(String, String, Supplier, long)}. If + * the value is not present in the cache, it is computed using the provided supplier and stored in + * the cache, subject to the defined size constraints. When a key is added, it must be associated + * with a particular group ID. Once the group is no longer needed, it is recommended to explicitly + * invalidate its state by calling {@link #invalidate(String)} instead of relying on automatic + * eviction. + * + *

    Note that this class employs the singleton pattern to ensure only one cache exists per JVM. + */ +public class SparkExecutorCache { + + private static final Logger LOG = LoggerFactory.getLogger(SparkExecutorCache.class); + + private static volatile SparkExecutorCache instance = null; + + private final Duration timeout; + private final long maxEntrySize; + private final long maxTotalSize; + private volatile Cache state; + + private SparkExecutorCache(Conf conf) { + this.timeout = conf.timeout(); + this.maxEntrySize = conf.maxEntrySize(); + this.maxTotalSize = conf.maxTotalSize(); + } + + /** + * Returns the cache if created or creates and returns it. + * + *

    Note this method returns null if caching is disabled. + */ + public static SparkExecutorCache getOrCreate() { + if (instance == null) { + Conf conf = new Conf(); + if (conf.cacheEnabled()) { + synchronized (SparkExecutorCache.class) { + if (instance == null) { + SparkExecutorCache.instance = new SparkExecutorCache(conf); + } + } + } + } + + return instance; + } + + /** Returns the cache if already created or null otherwise. */ + public static SparkExecutorCache get() { + return instance; + } + + /** Returns the max entry size in bytes that will be considered for caching. */ + public long maxEntrySize() { + return maxEntrySize; + } + + /** + * Gets the cached value for the key or populates the cache with a new mapping. + * + * @param group a group ID + * @param key a cache key + * @param valueSupplier a supplier to compute the value + * @param valueSize an estimated memory size of the value in bytes + * @return the cached or computed value + */ + public V getOrLoad(String group, String key, Supplier valueSupplier, long valueSize) { + if (valueSize > maxEntrySize) { + LOG.debug("{} exceeds max entry size: {} > {}", key, valueSize, maxEntrySize); + return valueSupplier.get(); + } + + String internalKey = group + "_" + key; + CacheValue value = state().get(internalKey, loadFunc(valueSupplier, valueSize)); + Preconditions.checkNotNull(value, "Loaded value must not be null"); + return value.get(); + } + + private Function loadFunc(Supplier valueSupplier, long valueSize) { + return key -> { + long start = System.currentTimeMillis(); + V value = valueSupplier.get(); + long end = System.currentTimeMillis(); + LOG.debug("Loaded {} with size {} in {} ms", key, valueSize, (end - start)); + return new CacheValue(value, valueSize); + }; + } + + /** + * Invalidates all keys associated with the given group ID. + * + * @param group a group ID + */ + public void invalidate(String group) { + if (state != null) { + List internalKeys = findInternalKeys(group); + LOG.info("Invalidating {} keys associated with {}", internalKeys.size(), group); + internalKeys.forEach(internalKey -> state.invalidate(internalKey)); + LOG.info("Current cache stats {}", state.stats()); + } + } + + private List findInternalKeys(String group) { + return state.asMap().keySet().stream() + .filter(internalKey -> internalKey.startsWith(group)) + .collect(Collectors.toList()); + } + + private Cache state() { + if (state == null) { + synchronized (this) { + if (state == null) { + LOG.info("Initializing cache state"); + this.state = initState(); + } + } + } + + return state; + } + + private Cache initState() { + return Caffeine.newBuilder() + .expireAfterAccess(timeout) + .maximumWeight(maxTotalSize) + .weigher((key, value) -> ((CacheValue) value).weight()) + .recordStats() + .removalListener((key, value, cause) -> LOG.debug("Evicted {} ({})", key, cause)) + .build(); + } + + @VisibleForTesting + static class CacheValue { + private final Object value; + private final long size; + + CacheValue(Object value, long size) { + this.value = value; + this.size = size; + } + + @SuppressWarnings("unchecked") + public V get() { + return (V) value; + } + + public int weight() { + return (int) Math.min(size, Integer.MAX_VALUE); + } + } + + @VisibleForTesting + static class Conf { + private final SparkConfParser confParser = new SparkConfParser(); + + public boolean cacheEnabled() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.EXECUTOR_CACHE_ENABLED) + .defaultValue(SparkSQLProperties.EXECUTOR_CACHE_ENABLED_DEFAULT) + .parse(); + } + + public Duration timeout() { + return confParser + .durationConf() + .sessionConf(SparkSQLProperties.EXECUTOR_CACHE_TIMEOUT) + .defaultValue(SparkSQLProperties.EXECUTOR_CACHE_TIMEOUT_DEFAULT) + .parse(); + } + + public long maxEntrySize() { + return confParser + .longConf() + .sessionConf(SparkSQLProperties.EXECUTOR_CACHE_MAX_ENTRY_SIZE) + .defaultValue(SparkSQLProperties.EXECUTOR_CACHE_MAX_ENTRY_SIZE_DEFAULT) + .parse(); + } + + public long maxTotalSize() { + return confParser + .longConf() + .sessionConf(SparkSQLProperties.EXECUTOR_CACHE_MAX_TOTAL_SIZE) + .defaultValue(SparkSQLProperties.EXECUTOR_CACHE_MAX_TOTAL_SIZE_DEFAULT) + .parse(); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFilters.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFilters.java new file mode 100644 index 000000000000..49b73c7b01af --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFilters.java @@ -0,0 +1,280 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.expressions.Expressions.and; +import static org.apache.iceberg.expressions.Expressions.equal; +import static org.apache.iceberg.expressions.Expressions.greaterThan; +import static org.apache.iceberg.expressions.Expressions.greaterThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.in; +import static org.apache.iceberg.expressions.Expressions.isNaN; +import static org.apache.iceberg.expressions.Expressions.isNull; +import static org.apache.iceberg.expressions.Expressions.lessThan; +import static org.apache.iceberg.expressions.Expressions.lessThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.not; +import static org.apache.iceberg.expressions.Expressions.notIn; +import static org.apache.iceberg.expressions.Expressions.notNull; +import static org.apache.iceberg.expressions.Expressions.or; +import static org.apache.iceberg.expressions.Expressions.startsWith; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expression.Operation; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.util.NaNUtil; +import org.apache.spark.sql.catalyst.util.DateTimeUtils; +import org.apache.spark.sql.sources.AlwaysFalse; +import org.apache.spark.sql.sources.AlwaysFalse$; +import org.apache.spark.sql.sources.AlwaysTrue; +import org.apache.spark.sql.sources.AlwaysTrue$; +import org.apache.spark.sql.sources.And; +import org.apache.spark.sql.sources.EqualNullSafe; +import org.apache.spark.sql.sources.EqualTo; +import org.apache.spark.sql.sources.Filter; +import org.apache.spark.sql.sources.GreaterThan; +import org.apache.spark.sql.sources.GreaterThanOrEqual; +import org.apache.spark.sql.sources.In; +import org.apache.spark.sql.sources.IsNotNull; +import org.apache.spark.sql.sources.IsNull; +import org.apache.spark.sql.sources.LessThan; +import org.apache.spark.sql.sources.LessThanOrEqual; +import org.apache.spark.sql.sources.Not; +import org.apache.spark.sql.sources.Or; +import org.apache.spark.sql.sources.StringStartsWith; + +public class SparkFilters { + + private static final Pattern BACKTICKS_PATTERN = Pattern.compile("([`])(.|$)"); + + private SparkFilters() {} + + private static final Map, Operation> FILTERS = + ImmutableMap., Operation>builder() + .put(AlwaysTrue.class, Operation.TRUE) + .put(AlwaysTrue$.class, Operation.TRUE) + .put(AlwaysFalse$.class, Operation.FALSE) + .put(AlwaysFalse.class, Operation.FALSE) + .put(EqualTo.class, Operation.EQ) + .put(EqualNullSafe.class, Operation.EQ) + .put(GreaterThan.class, Operation.GT) + .put(GreaterThanOrEqual.class, Operation.GT_EQ) + .put(LessThan.class, Operation.LT) + .put(LessThanOrEqual.class, Operation.LT_EQ) + .put(In.class, Operation.IN) + .put(IsNull.class, Operation.IS_NULL) + .put(IsNotNull.class, Operation.NOT_NULL) + .put(And.class, Operation.AND) + .put(Or.class, Operation.OR) + .put(Not.class, Operation.NOT) + .put(StringStartsWith.class, Operation.STARTS_WITH) + .buildOrThrow(); + + public static Expression convert(Filter[] filters) { + Expression expression = Expressions.alwaysTrue(); + for (Filter filter : filters) { + Expression converted = convert(filter); + Preconditions.checkArgument( + converted != null, "Cannot convert filter to Iceberg: %s", filter); + expression = Expressions.and(expression, converted); + } + return expression; + } + + public static Expression convert(Filter filter) { + // avoid using a chain of if instanceof statements by mapping to the expression enum. + Operation op = FILTERS.get(filter.getClass()); + if (op != null) { + switch (op) { + case TRUE: + return Expressions.alwaysTrue(); + + case FALSE: + return Expressions.alwaysFalse(); + + case IS_NULL: + IsNull isNullFilter = (IsNull) filter; + return isNull(unquote(isNullFilter.attribute())); + + case NOT_NULL: + IsNotNull notNullFilter = (IsNotNull) filter; + return notNull(unquote(notNullFilter.attribute())); + + case LT: + LessThan lt = (LessThan) filter; + return lessThan(unquote(lt.attribute()), convertLiteral(lt.value())); + + case LT_EQ: + LessThanOrEqual ltEq = (LessThanOrEqual) filter; + return lessThanOrEqual(unquote(ltEq.attribute()), convertLiteral(ltEq.value())); + + case GT: + GreaterThan gt = (GreaterThan) filter; + return greaterThan(unquote(gt.attribute()), convertLiteral(gt.value())); + + case GT_EQ: + GreaterThanOrEqual gtEq = (GreaterThanOrEqual) filter; + return greaterThanOrEqual(unquote(gtEq.attribute()), convertLiteral(gtEq.value())); + + case EQ: // used for both eq and null-safe-eq + if (filter instanceof EqualTo) { + EqualTo eq = (EqualTo) filter; + // comparison with null in normal equality is always null. this is probably a mistake. + Preconditions.checkNotNull( + eq.value(), "Expression is always false (eq is not null-safe): %s", filter); + return handleEqual(unquote(eq.attribute()), eq.value()); + } else { + EqualNullSafe eq = (EqualNullSafe) filter; + if (eq.value() == null) { + return isNull(unquote(eq.attribute())); + } else { + return handleEqual(unquote(eq.attribute()), eq.value()); + } + } + + case IN: + In inFilter = (In) filter; + return in( + unquote(inFilter.attribute()), + Stream.of(inFilter.values()) + .filter(Objects::nonNull) + .map(SparkFilters::convertLiteral) + .collect(Collectors.toList())); + + case NOT: + Not notFilter = (Not) filter; + Filter childFilter = notFilter.child(); + Operation childOp = FILTERS.get(childFilter.getClass()); + if (childOp == Operation.IN) { + // infer an extra notNull predicate for Spark NOT IN filters + // as Iceberg expressions don't follow the 3-value SQL boolean logic + // col NOT IN (1, 2) in Spark is equivalent to notNull(col) && notIn(col, 1, 2) in + // Iceberg + In childInFilter = (In) childFilter; + Expression notIn = + notIn( + unquote(childInFilter.attribute()), + Stream.of(childInFilter.values()) + .map(SparkFilters::convertLiteral) + .collect(Collectors.toList())); + return and(notNull(childInFilter.attribute()), notIn); + } else if (hasNoInFilter(childFilter)) { + Expression child = convert(childFilter); + if (child != null) { + return not(child); + } + } + return null; + + case AND: + { + And andFilter = (And) filter; + Expression left = convert(andFilter.left()); + Expression right = convert(andFilter.right()); + if (left != null && right != null) { + return and(left, right); + } + return null; + } + + case OR: + { + Or orFilter = (Or) filter; + Expression left = convert(orFilter.left()); + Expression right = convert(orFilter.right()); + if (left != null && right != null) { + return or(left, right); + } + return null; + } + + case STARTS_WITH: + { + StringStartsWith stringStartsWith = (StringStartsWith) filter; + return startsWith(unquote(stringStartsWith.attribute()), stringStartsWith.value()); + } + } + } + + return null; + } + + private static Object convertLiteral(Object value) { + if (value instanceof Timestamp) { + return DateTimeUtils.fromJavaTimestamp((Timestamp) value); + } else if (value instanceof Date) { + return DateTimeUtils.fromJavaDate((Date) value); + } else if (value instanceof Instant) { + return DateTimeUtils.instantToMicros((Instant) value); + } else if (value instanceof LocalDateTime) { + return DateTimeUtils.localDateTimeToMicros((LocalDateTime) value); + } else if (value instanceof LocalDate) { + return DateTimeUtils.localDateToDays((LocalDate) value); + } + return value; + } + + private static Expression handleEqual(String attribute, Object value) { + if (NaNUtil.isNaN(value)) { + return isNaN(attribute); + } else { + return equal(attribute, convertLiteral(value)); + } + } + + private static String unquote(String attributeName) { + Matcher matcher = BACKTICKS_PATTERN.matcher(attributeName); + return matcher.replaceAll("$2"); + } + + private static boolean hasNoInFilter(Filter filter) { + Operation op = FILTERS.get(filter.getClass()); + + if (op != null) { + switch (op) { + case AND: + And andFilter = (And) filter; + return hasNoInFilter(andFilter.left()) && hasNoInFilter(andFilter.right()); + case OR: + Or orFilter = (Or) filter; + return hasNoInFilter(orFilter.left()) && hasNoInFilter(orFilter.right()); + case NOT: + Not notFilter = (Not) filter; + return hasNoInFilter(notFilter.child()); + case IN: + return false; + default: + return true; + } + } + + return false; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFixupTypes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFixupTypes.java new file mode 100644 index 000000000000..6c4ec39b20f1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFixupTypes.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.types.FixupTypes; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; + +/** + * Some types, like binary and fixed, are converted to the same Spark type. Conversion back can + * produce only one, which may not be correct. + */ +class SparkFixupTypes extends FixupTypes { + + private SparkFixupTypes(Schema referenceSchema) { + super(referenceSchema); + } + + static Schema fixup(Schema schema, Schema referenceSchema) { + return new Schema( + TypeUtil.visit(schema, new SparkFixupTypes(referenceSchema)).asStructType().fields()); + } + + @Override + protected boolean fixupPrimitive(Type.PrimitiveType type, Type source) { + switch (type.typeId()) { + case STRING: + if (source.typeId() == Type.TypeID.UUID) { + return true; + } + break; + case BINARY: + if (source.typeId() == Type.TypeID.FIXED) { + return true; + } + break; + case TIMESTAMP: + if (source.typeId() == Type.TypeID.TIMESTAMP) { + return true; + } + break; + default: + } + return false; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFunctionCatalog.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFunctionCatalog.java new file mode 100644 index 000000000000..2183b9e5df4d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkFunctionCatalog.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +/** + * A function catalog that can be used to resolve Iceberg functions without a metastore connection. + */ +public class SparkFunctionCatalog implements SupportsFunctions { + + private static final SparkFunctionCatalog INSTANCE = new SparkFunctionCatalog(); + + private String name = "iceberg-function-catalog"; + + public static SparkFunctionCatalog get() { + return INSTANCE; + } + + @Override + public void initialize(String catalogName, CaseInsensitiveStringMap options) { + this.name = catalogName; + } + + @Override + public String name() { + return name; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java new file mode 100644 index 000000000000..8128babfa340 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkReadConf.java @@ -0,0 +1,415 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.PlanningMode.LOCAL; + +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.SupportsDistributedScanPlanning; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.Util; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.Pair; +import org.apache.spark.SparkConf; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +/** + * A class for common Iceberg configs for Spark reads. + * + *

    If a config is set at multiple levels, the following order of precedence is used (top to + * bottom): + * + *

      + *
    1. Read options + *
    2. Session configuration + *
    3. Table metadata + *
    + * + * The most specific value is set in read options and takes precedence over all other configs. If no + * read option is provided, this class checks the session configuration for any overrides. If no + * applicable value is found in the session configuration, this class uses the table metadata. + * + *

    Note this class is NOT meant to be serialized and sent to executors. + */ +public class SparkReadConf { + + private static final String DRIVER_MAX_RESULT_SIZE = "spark.driver.maxResultSize"; + private static final String DRIVER_MAX_RESULT_SIZE_DEFAULT = "1G"; + private static final long DISTRIBUTED_PLANNING_MIN_RESULT_SIZE = 256L * 1024 * 1024; // 256 MB + + private final SparkSession spark; + private final Table table; + private final SparkConfParser confParser; + + public SparkReadConf(SparkSession spark, Table table) { + this(spark, table, CaseInsensitiveStringMap.empty()); + } + + public SparkReadConf(SparkSession spark, Table table, CaseInsensitiveStringMap options) { + this(spark, table, null, options); + } + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0. Use {@link #SparkReadConf(SparkSession, + * Table, CaseInsensitiveStringMap)} instead. + */ + @Deprecated + public SparkReadConf( + SparkSession spark, Table table, String branch, CaseInsensitiveStringMap options) { + this.spark = spark; + this.table = table; + this.confParser = new SparkConfParser(spark, table, options); + } + + public boolean caseSensitive() { + return SparkUtil.caseSensitive(spark); + } + + public boolean localityEnabled() { + boolean defaultValue = Util.mayHaveBlockLocations(table.io(), table.location()); + return confParser + .booleanConf() + .option(SparkReadOptions.LOCALITY) + .sessionConf(SparkSQLProperties.LOCALITY) + .defaultValue(defaultValue) + .parse(); + } + + public Long startSnapshotId() { + return confParser.longConf().option(SparkReadOptions.START_SNAPSHOT_ID).parseOptional(); + } + + public Long endSnapshotId() { + return confParser.longConf().option(SparkReadOptions.END_SNAPSHOT_ID).parseOptional(); + } + + public boolean streamingSkipDeleteSnapshots() { + return confParser + .booleanConf() + .option(SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS) + .defaultValue(SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS_DEFAULT) + .parse(); + } + + public boolean streamingSkipOverwriteSnapshots() { + return confParser + .booleanConf() + .option(SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS) + .defaultValue(SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS_DEFAULT) + .parse(); + } + + public boolean parquetVectorizationEnabled() { + return confParser + .booleanConf() + .option(SparkReadOptions.VECTORIZATION_ENABLED) + .sessionConf(SparkSQLProperties.VECTORIZATION_ENABLED) + .tableProperty(TableProperties.PARQUET_VECTORIZATION_ENABLED) + .defaultValue(TableProperties.PARQUET_VECTORIZATION_ENABLED_DEFAULT) + .parse(); + } + + public int parquetBatchSize() { + return confParser + .intConf() + .option(SparkReadOptions.VECTORIZATION_BATCH_SIZE) + .tableProperty(TableProperties.PARQUET_BATCH_SIZE) + .defaultValue(TableProperties.PARQUET_BATCH_SIZE_DEFAULT) + .parse(); + } + + public boolean orcVectorizationEnabled() { + return confParser + .booleanConf() + .option(SparkReadOptions.VECTORIZATION_ENABLED) + .sessionConf(SparkSQLProperties.VECTORIZATION_ENABLED) + .tableProperty(TableProperties.ORC_VECTORIZATION_ENABLED) + .defaultValue(TableProperties.ORC_VECTORIZATION_ENABLED_DEFAULT) + .parse(); + } + + public int orcBatchSize() { + return confParser + .intConf() + .option(SparkReadOptions.VECTORIZATION_BATCH_SIZE) + .tableProperty(TableProperties.ORC_BATCH_SIZE) + .defaultValue(TableProperties.ORC_BATCH_SIZE_DEFAULT) + .parse(); + } + + public Long splitSizeOption() { + return confParser.longConf().option(SparkReadOptions.SPLIT_SIZE).parseOptional(); + } + + public long splitSize() { + return confParser + .longConf() + .option(SparkReadOptions.SPLIT_SIZE) + .tableProperty(TableProperties.SPLIT_SIZE) + .defaultValue(TableProperties.SPLIT_SIZE_DEFAULT) + .parse(); + } + + public Integer splitLookbackOption() { + return confParser.intConf().option(SparkReadOptions.LOOKBACK).parseOptional(); + } + + public int splitLookback() { + return confParser + .intConf() + .option(SparkReadOptions.LOOKBACK) + .tableProperty(TableProperties.SPLIT_LOOKBACK) + .defaultValue(TableProperties.SPLIT_LOOKBACK_DEFAULT) + .parse(); + } + + public Long splitOpenFileCostOption() { + return confParser.longConf().option(SparkReadOptions.FILE_OPEN_COST).parseOptional(); + } + + public long splitOpenFileCost() { + return confParser + .longConf() + .option(SparkReadOptions.FILE_OPEN_COST) + .tableProperty(TableProperties.SPLIT_OPEN_FILE_COST) + .defaultValue(TableProperties.SPLIT_OPEN_FILE_COST_DEFAULT) + .parse(); + } + + public long streamFromTimestamp() { + return confParser + .longConf() + .option(SparkReadOptions.STREAM_FROM_TIMESTAMP) + .defaultValue(Long.MIN_VALUE) + .parse(); + } + + public Long startTimestamp() { + return confParser.longConf().option(SparkReadOptions.START_TIMESTAMP).parseOptional(); + } + + public Long endTimestamp() { + return confParser.longConf().option(SparkReadOptions.END_TIMESTAMP).parseOptional(); + } + + public int maxFilesPerMicroBatch() { + return confParser + .intConf() + .option(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH) + .defaultValue(Integer.MAX_VALUE) + .parse(); + } + + public int maxRecordsPerMicroBatch() { + return confParser + .intConf() + .option(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH) + .defaultValue(Integer.MAX_VALUE) + .parse(); + } + + public boolean asyncMicroBatchPlanningEnabled() { + return confParser + .booleanConf() + .option(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .sessionConf(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED) + .defaultValue(SparkSQLProperties.ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT) + .parse(); + } + + public long streamingSnapshotPollingIntervalMs() { + return confParser + .longConf() + .option(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS) + .defaultValue(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadFileLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT) + .parse(); + } + + public long asyncQueuePreloadRowLimit() { + return confParser + .longConf() + .option(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT) + .defaultValue(SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT) + .parse(); + } + + public boolean preserveDataGrouping() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.PRESERVE_DATA_GROUPING) + .defaultValue(SparkSQLProperties.PRESERVE_DATA_GROUPING_DEFAULT) + .parse(); + } + + public boolean aggregatePushDownEnabled() { + return confParser + .booleanConf() + .option(SparkReadOptions.AGGREGATE_PUSH_DOWN_ENABLED) + .sessionConf(SparkSQLProperties.AGGREGATE_PUSH_DOWN_ENABLED) + .defaultValue(SparkSQLProperties.AGGREGATE_PUSH_DOWN_ENABLED_DEFAULT) + .parse(); + } + + public boolean adaptiveSplitSizeEnabled() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_ENABLED) + .tableProperty(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED) + .defaultValue(TableProperties.ADAPTIVE_SPLIT_SIZE_ENABLED_DEFAULT) + .parse(); + } + + public int parallelism() { + int defaultParallelism = spark.sparkContext().defaultParallelism(); + int numShufflePartitions = spark.sessionState().conf().numShufflePartitions(); + return Math.max(defaultParallelism, numShufflePartitions); + } + + public int splitParallelism() { + int parallelism = + confParser + .intConf() + .sessionConf(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM) + .defaultValue(parallelism()) + .parse(); + Preconditions.checkArgument(parallelism > 0, "Split parallelism must be > 0: %s", parallelism); + return parallelism; + } + + public boolean distributedPlanningEnabled() { + return table instanceof SupportsDistributedScanPlanning distributed + && distributed.allowDistributedPlanning() + && (dataPlanningMode() != LOCAL || deletePlanningMode() != LOCAL); + } + + public PlanningMode dataPlanningMode() { + if (driverMaxResultSize() < DISTRIBUTED_PLANNING_MIN_RESULT_SIZE) { + return LOCAL; + } + + return confParser + .enumConf(PlanningMode::fromName) + .sessionConf(SparkSQLProperties.DATA_PLANNING_MODE) + .tableProperty(TableProperties.DATA_PLANNING_MODE) + .defaultValue(TableProperties.PLANNING_MODE_DEFAULT) + .parse(); + } + + public PlanningMode deletePlanningMode() { + if (driverMaxResultSize() < DISTRIBUTED_PLANNING_MIN_RESULT_SIZE) { + return LOCAL; + } + + String modeName = + confParser + .stringConf() + .sessionConf(SparkSQLProperties.DELETE_PLANNING_MODE) + .tableProperty(TableProperties.DELETE_PLANNING_MODE) + .defaultValue(TableProperties.PLANNING_MODE_DEFAULT) + .parse(); + return PlanningMode.fromName(modeName); + } + + private long driverMaxResultSize() { + SparkConf sparkConf = spark.sparkContext().conf(); + return sparkConf.getSizeAsBytes(DRIVER_MAX_RESULT_SIZE, DRIVER_MAX_RESULT_SIZE_DEFAULT); + } + + public boolean executorCacheLocalityEnabled() { + return executorCacheEnabled() && executorCacheLocalityEnabledInternal(); + } + + private boolean executorCacheEnabled() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.EXECUTOR_CACHE_ENABLED) + .defaultValue(SparkSQLProperties.EXECUTOR_CACHE_ENABLED_DEFAULT) + .parse(); + } + + public boolean cacheDeleteFilesOnExecutors() { + return executorCacheEnabled() && cacheDeleteFilesOnExecutorsInternal(); + } + + private boolean cacheDeleteFilesOnExecutorsInternal() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED) + .defaultValue(SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED_DEFAULT) + .parse(); + } + + private boolean executorCacheLocalityEnabledInternal() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.EXECUTOR_CACHE_LOCALITY_ENABLED) + .defaultValue(SparkSQLProperties.EXECUTOR_CACHE_LOCALITY_ENABLED_DEFAULT) + .parse(); + } + + public boolean reportColumnStats() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.REPORT_COLUMN_STATS) + .defaultValue(SparkSQLProperties.REPORT_COLUMN_STATS_DEFAULT) + .parse(); + } + + public boolean identifierFieldsRely() { + return confParser + .booleanConf() + .sessionConf(SparkSQLProperties.IDENTIFIER_FIELDS_RELY) + .tableProperty(TableProperties.IDENTIFIER_FIELDS_RELY) + .defaultValue(TableProperties.IDENTIFIER_FIELDS_RELY_DEFAULT) + .parse(); + } + + public Pair incrementalAppendScanBoundaries() { + Long startSnapshotId = startSnapshotId(); + Long endSnapshotId = endSnapshotId(); + Long startTimestamp = startTimestamp(); + Long endTimestamp = endTimestamp(); + + Preconditions.checkArgument( + startTimestamp == null && endTimestamp == null, + "Only changelog scans support `%s` and `%s`. Use `%s` and `%s` for incremental scans.", + SparkReadOptions.START_TIMESTAMP, + SparkReadOptions.END_TIMESTAMP, + SparkReadOptions.START_SNAPSHOT_ID, + SparkReadOptions.END_SNAPSHOT_ID); + + Preconditions.checkArgument( + startSnapshotId != null, + "Cannot set only `%s` for incremental scans. Please, set `%s` too.", + SparkReadOptions.END_SNAPSHOT_ID, + SparkReadOptions.START_SNAPSHOT_ID); + + return Pair.of(startSnapshotId, endSnapshotId); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java new file mode 100644 index 000000000000..e6d02d104766 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkReadOptions.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +/** Spark DF read options */ +public class SparkReadOptions { + + private SparkReadOptions() {} + + // legacy time travel option that is no longer supported + public static final String LEGACY_SNAPSHOT_ID = "snapshot-id"; + + // legacy time travel option that is no longer supported + public static final String LEGACY_AS_OF_TIMESTAMP = "as-of-timestamp"; + + // legacy time travel option that is no longer supported + public static final String LEGACY_TAG = "tag"; + + // Start snapshot ID used in incremental scans (exclusive) + public static final String START_SNAPSHOT_ID = "start-snapshot-id"; + + // End snapshot ID used in incremental scans (inclusive) + public static final String END_SNAPSHOT_ID = "end-snapshot-id"; + + // Start timestamp used in multi-snapshot scans (exclusive) + public static final String START_TIMESTAMP = "start-timestamp"; + + // End timestamp used in multi-snapshot scans (inclusive) + public static final String END_TIMESTAMP = "end-timestamp"; + + // Branch to read from + public static final String BRANCH = "branch"; + + // Overrides the table's read.split.target-size and read.split.metadata-target-size + public static final String SPLIT_SIZE = "split-size"; + + // Overrides the table's read.split.planning-lookback + public static final String LOOKBACK = "lookback"; + + // Overrides the table's read.split.open-file-cost + public static final String FILE_OPEN_COST = "file-open-cost"; + + // Overrides table's vectorization enabled properties + public static final String VECTORIZATION_ENABLED = "vectorization-enabled"; + + // Overrides the table's read.parquet.vectorization.batch-size + public static final String VECTORIZATION_BATCH_SIZE = "batch-size"; + + // skip snapshots of type delete while reading stream out of iceberg table + public static final String STREAMING_SKIP_DELETE_SNAPSHOTS = "streaming-skip-delete-snapshots"; + public static final boolean STREAMING_SKIP_DELETE_SNAPSHOTS_DEFAULT = false; + + // skip snapshots of type overwrite while reading stream out of iceberg table + public static final String STREAMING_SKIP_OVERWRITE_SNAPSHOTS = + "streaming-skip-overwrite-snapshots"; + public static final boolean STREAMING_SKIP_OVERWRITE_SNAPSHOTS_DEFAULT = false; + + // Controls whether to report locality information to Spark while allocating input partitions + public static final String LOCALITY = "locality"; + + // Timestamp in milliseconds; start a stream from the snapshot that occurs after this timestamp + public static final String STREAM_FROM_TIMESTAMP = "stream-from-timestamp"; + + // maximum file per micro_batch + public static final String STREAMING_MAX_FILES_PER_MICRO_BATCH = + "streaming-max-files-per-micro-batch"; + // maximum rows per micro_batch + public static final String STREAMING_MAX_ROWS_PER_MICRO_BATCH = + "streaming-max-rows-per-micro-batch"; + + // Enable async micro batch planning + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "async-micro-batch-planning-enabled"; + // Polling interval for async planner to refresh table metadata (ms) + public static final String STREAMING_SNAPSHOT_POLLING_INTERVAL_MS = + "streaming-snapshot-polling-interval-ms"; + public static final long STREAMING_SNAPSHOT_POLLING_INTERVAL_MS_DEFAULT = 30000L; + // Initial queue preload limits for async micro batch planner + public static final String ASYNC_QUEUE_PRELOAD_FILE_LIMIT = "async-queue-preload-file-limit"; + public static final long ASYNC_QUEUE_PRELOAD_FILE_LIMIT_DEFAULT = 100L; + public static final String ASYNC_QUEUE_PRELOAD_ROW_LIMIT = "async-queue-preload-row-limit"; + public static final long ASYNC_QUEUE_PRELOAD_ROW_LIMIT_DEFAULT = 100000L; + + // Table path + public static final String PATH = "path"; + + public static final String VERSION_AS_OF = "versionAsOf"; + + public static final String TIMESTAMP_AS_OF = "timestampAsOf"; + + public static final String AGGREGATE_PUSH_DOWN_ENABLED = "aggregate-push-down-enabled"; +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkRewriteTableCatalog.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkRewriteTableCatalog.java new file mode 100644 index 000000000000..a1016beb1886 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkRewriteTableCatalog.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.source.SparkRewriteTable; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.TableChange; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +public class SparkRewriteTableCatalog implements TableCatalog, SupportsFunctions { + + private static final String CLASS_NAME = SparkRewriteTableCatalog.class.getName(); + private static final SparkTableCache TABLE_CACHE = SparkTableCache.get(); + + private String name = null; + + @Override + public Identifier[] listTables(String[] namespace) { + throw new UnsupportedOperationException(CLASS_NAME + " does not support listing tables"); + } + + @Override + public SparkRewriteTable loadTable(Identifier ident) throws NoSuchTableException { + validateNoNamespace(ident); + + String groupId = ident.name(); + Table table = TABLE_CACHE.get(groupId); + + if (table == null) { + throw new NoSuchTableException(ident); + } + + return new SparkRewriteTable(table, groupId); + } + + @Override + public SparkTable loadTable(Identifier ident, String version) throws NoSuchTableException { + throw new UnsupportedOperationException(CLASS_NAME + " does not support time travel"); + } + + @Override + public SparkTable loadTable(Identifier ident, long timestampMicros) throws NoSuchTableException { + throw new UnsupportedOperationException(CLASS_NAME + " does not support time travel"); + } + + @Override + public void invalidateTable(Identifier ident) { + throw new UnsupportedOperationException(CLASS_NAME + " does not support table invalidation"); + } + + @Override + public SparkTable createTable( + Identifier ident, StructType schema, Transform[] partitions, Map properties) + throws TableAlreadyExistsException { + throw new UnsupportedOperationException(CLASS_NAME + " does not support creating tables"); + } + + @Override + public SparkTable alterTable(Identifier ident, TableChange... changes) { + throw new UnsupportedOperationException(CLASS_NAME + " does not support altering tables"); + } + + @Override + public boolean dropTable(Identifier ident) { + throw new UnsupportedOperationException(CLASS_NAME + " does not support dropping tables"); + } + + @Override + public boolean purgeTable(Identifier ident) throws UnsupportedOperationException { + throw new UnsupportedOperationException(CLASS_NAME + " does not support purging tables"); + } + + @Override + public void renameTable(Identifier oldIdent, Identifier newIdent) { + throw new UnsupportedOperationException(CLASS_NAME + " does not support renaming tables"); + } + + @Override + public void initialize(String catalogName, CaseInsensitiveStringMap options) { + this.name = catalogName; + } + + @Override + public String name() { + return name; + } + + private void validateNoNamespace(Identifier ident) { + Preconditions.checkArgument( + ident.namespace().length == 0, + "%s does not support namespaces, but got: %s", + CLASS_NAME, + String.join(".", ident.namespace())); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java new file mode 100644 index 000000000000..ddedc36c7126 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.time.Duration; + +public class SparkSQLProperties { + + private SparkSQLProperties() {} + + // Controls whether vectorized reads are enabled + public static final String VECTORIZATION_ENABLED = "spark.sql.iceberg.vectorization.enabled"; + + // Controls whether to perform the nullability check during writes + public static final String CHECK_NULLABILITY = "spark.sql.iceberg.check-nullability"; + public static final boolean CHECK_NULLABILITY_DEFAULT = true; + + // Controls whether to check the order of fields during writes + public static final String CHECK_ORDERING = "spark.sql.iceberg.check-ordering"; + public static final boolean CHECK_ORDERING_DEFAULT = true; + + // Controls whether to preserve the existing grouping of data while planning splits + public static final String PRESERVE_DATA_GROUPING = + "spark.sql.iceberg.planning.preserve-data-grouping"; + public static final boolean PRESERVE_DATA_GROUPING_DEFAULT = false; + + // Controls whether to push down aggregate (MAX/MIN/COUNT) to Iceberg + public static final String AGGREGATE_PUSH_DOWN_ENABLED = + "spark.sql.iceberg.aggregate-push-down.enabled"; + public static final boolean AGGREGATE_PUSH_DOWN_ENABLED_DEFAULT = true; + + // Controls write distribution mode + public static final String DISTRIBUTION_MODE = "spark.sql.iceberg.distribution-mode"; + + // Controls the WAP ID used for write-audit-publish workflow. + // When set, new snapshots will be staged with this ID in snapshot summary. + public static final String WAP_ID = "spark.wap.id"; + + // Controls the WAP branch used for write-audit-publish workflow. + // When set, new snapshots will be committed to this branch. + public static final String WAP_BRANCH = "spark.wap.branch"; + + // Controls write compress options + public static final String COMPRESSION_CODEC = "spark.sql.iceberg.compression-codec"; + public static final String COMPRESSION_LEVEL = "spark.sql.iceberg.compression-level"; + public static final String COMPRESSION_STRATEGY = "spark.sql.iceberg.compression-strategy"; + + // Overrides the data planning mode + public static final String DATA_PLANNING_MODE = "spark.sql.iceberg.data-planning-mode"; + + // Overrides the delete planning mode + public static final String DELETE_PLANNING_MODE = "spark.sql.iceberg.delete-planning-mode"; + + // Overrides the advisory partition size + public static final String ADVISORY_PARTITION_SIZE = "spark.sql.iceberg.advisory-partition-size"; + + // Controls whether to report locality information to Spark while allocating input partitions + public static final String LOCALITY = "spark.sql.iceberg.locality.enabled"; + + public static final String EXECUTOR_CACHE_ENABLED = "spark.sql.iceberg.executor-cache.enabled"; + public static final boolean EXECUTOR_CACHE_ENABLED_DEFAULT = true; + + // Controls whether to enable executor cache for delete files + public static final String EXECUTOR_CACHE_DELETE_FILES_ENABLED = + "spark.sql.iceberg.executor-cache.delete-files.enabled"; + public static final boolean EXECUTOR_CACHE_DELETE_FILES_ENABLED_DEFAULT = true; + + public static final String EXECUTOR_CACHE_TIMEOUT = "spark.sql.iceberg.executor-cache.timeout"; + public static final Duration EXECUTOR_CACHE_TIMEOUT_DEFAULT = Duration.ofMinutes(10); + + public static final String EXECUTOR_CACHE_MAX_ENTRY_SIZE = + "spark.sql.iceberg.executor-cache.max-entry-size"; + public static final long EXECUTOR_CACHE_MAX_ENTRY_SIZE_DEFAULT = 64 * 1024 * 1024; // 64 MB + + public static final String EXECUTOR_CACHE_MAX_TOTAL_SIZE = + "spark.sql.iceberg.executor-cache.max-total-size"; + public static final long EXECUTOR_CACHE_MAX_TOTAL_SIZE_DEFAULT = 128 * 1024 * 1024; // 128 MB + + // Controls whether to merge schema during write operation + public static final String MERGE_SCHEMA = "spark.sql.iceberg.merge-schema"; + public static final boolean MERGE_SCHEMA_DEFAULT = false; + + public static final String EXECUTOR_CACHE_LOCALITY_ENABLED = + "spark.sql.iceberg.executor-cache.locality.enabled"; + public static final boolean EXECUTOR_CACHE_LOCALITY_ENABLED_DEFAULT = false; + + // Controls whether to report available column statistics to Spark for query optimization. + public static final String REPORT_COLUMN_STATS = "spark.sql.iceberg.report-column-stats"; + public static final boolean REPORT_COLUMN_STATS_DEFAULT = true; + + // Rely on identifier fields as a PRIMARY KEY constraint for query optimization (not enforced) + public static final String IDENTIFIER_FIELDS_RELY = "spark.sql.iceberg.identifier-fields-rely"; + + // Prefix for custom snapshot properties + public static final String SNAPSHOT_PROPERTY_PREFIX = "spark.sql.iceberg.snapshot-property."; + + // Controls whether adaptive split sizing is enabled + public static final String READ_ADAPTIVE_SPLIT_SIZE_ENABLED = + "spark.sql.iceberg.read.adaptive-split-size.enabled"; + + // Overrides the parallelism used for adaptive split sizing. When unset, the parallelism + // defaults to max(spark.default.parallelism, spark.sql.shuffle.partitions). + public static final String READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM = + "spark.sql.iceberg.read.adaptive-split-size.parallelism"; + + // Controls whether to enable async micro batch planning for session + public static final String ASYNC_MICRO_BATCH_PLANNING_ENABLED = + "spark.sql.iceberg.async-micro-batch-planning-enabled"; + public static final boolean ASYNC_MICRO_BATCH_PLANNING_ENABLED_DEFAULT = false; + + // Controls whether to shred variant columns during write operations + public static final String SHRED_VARIANTS = "spark.sql.iceberg.shred-variants"; + + // Controls the buffer size for variant schema inference during writes + // This determines how many rows are buffered before inferring shredded schema + public static final String VARIANT_INFERENCE_BUFFER_SIZE = + "spark.sql.iceberg.variant-inference-buffer-size"; +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSchemaUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSchemaUtil.java new file mode 100644 index 000000000000..4c3713d3fff3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSchemaUtil.java @@ -0,0 +1,377 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Binder; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.math.LongMath; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalog.Column; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** Helper methods for working with Spark/Hive metadata. */ +public class SparkSchemaUtil { + private SparkSchemaUtil() {} + + /** + * Returns a {@link Schema} for the given table with fresh field ids. + * + *

    This creates a Schema for an existing table by looking up the table's schema with Spark and + * converting that schema. Spark/Hive partition columns are included in the schema. + * + * @param spark a Spark session + * @param name a table name and (optional) database + * @return a Schema for the table, if found + */ + public static Schema schemaForTable(SparkSession spark, String name) { + return convert(spark.table(name).schema()); + } + + /** + * Returns a {@link PartitionSpec} for the given table. + * + *

    This creates a partition spec for an existing table by looking up the table's schema and + * creating a spec with identity partitions for each partition column. + * + * @param spark a Spark session + * @param name a table name and (optional) database + * @return a PartitionSpec for the table + * @throws AnalysisException if thrown by the Spark catalog + */ + public static PartitionSpec specForTable(SparkSession spark, String name) + throws AnalysisException { + List parts = Lists.newArrayList(Splitter.on('.').limit(2).split(name)); + String db = parts.size() == 1 ? "default" : parts.get(0); + String table = parts.get(parts.size() == 1 ? 0 : 1); + + PartitionSpec spec = + identitySpec( + schemaForTable(spark, name), spark.catalog().listColumns(db, table).collectAsList()); + return spec == null ? PartitionSpec.unpartitioned() : spec; + } + + /** + * Convert a {@link Schema} to a {@link DataType Spark type}. + * + * @param schema a Schema + * @return the equivalent Spark type + * @throws IllegalArgumentException if the type cannot be converted to Spark + */ + public static StructType convert(Schema schema) { + return (StructType) TypeUtil.visit(schema, new TypeToSparkType()); + } + + /** + * Convert a {@link Type} to a {@link DataType Spark type}. + * + * @param type a Type + * @return the equivalent Spark type + * @throws IllegalArgumentException if the type cannot be converted to Spark + */ + public static DataType convert(Type type) { + return TypeUtil.visit(type, new TypeToSparkType()); + } + + /** + * Convert a Spark {@link StructType struct} to a {@link Schema} with new field ids. + * + *

    This conversion assigns fresh ids. + * + *

    Some data types are represented as the same Spark type. These are converted to a default + * type. + * + *

    To convert using a reference schema for field ids and ambiguous types, use {@link + * #convert(Schema, StructType)}. + * + * @param sparkType a Spark StructType + * @return the equivalent Schema + * @throws IllegalArgumentException if the type cannot be converted + */ + public static Schema convert(StructType sparkType) { + Type converted = SparkTypeVisitor.visit(sparkType, new SparkTypeToType(sparkType)); + return new Schema(converted.asNestedType().asStructType().fields()); + } + + /** + * Convert a Spark {@link DataType struct} to a {@link Type} with new field ids. + * + *

    This conversion assigns fresh ids. + * + *

    Some data types are represented as the same Spark type. These are converted to a default + * type. + * + *

    To convert using a reference schema for field ids and ambiguous types, use {@link + * #convert(Schema, StructType)}. + * + * @param sparkType a Spark DataType + * @return the equivalent Type + * @throws IllegalArgumentException if the type cannot be converted + */ + public static Type convert(DataType sparkType) { + return SparkTypeVisitor.visit(sparkType, new SparkTypeToType()); + } + + /** + * Convert a Spark {@link StructType struct} to a {@link Schema} based on the given schema. + * + *

    This conversion does not assign new ids; it uses ids from the base schema. + * + *

    Data types, field order, and nullability will match the spark type. This conversion may + * return a schema that is not compatible with base schema. + * + * @param baseSchema a Schema on which conversion is based + * @param sparkType a Spark StructType + * @return the equivalent Schema + * @throws IllegalArgumentException if the type cannot be converted or there are missing ids + */ + public static Schema convert(Schema baseSchema, StructType sparkType) { + return convert(baseSchema, sparkType, true); + } + + /** + * Convert a Spark {@link StructType struct} to a {@link Schema} based on the given schema. + * + *

    This conversion does not assign new ids; it uses ids from the base schema. + * + *

    Data types, field order, and nullability will match the spark type. This conversion may + * return a schema that is not compatible with base schema. + * + * @param baseSchema a Schema on which conversion is based + * @param sparkType a Spark StructType + * @param caseSensitive when false, the case of schema fields is ignored + * @return the equivalent Schema + * @throws IllegalArgumentException if the type cannot be converted or there are missing ids + */ + public static Schema convert(Schema baseSchema, StructType sparkType, boolean caseSensitive) { + // convert to a type with fresh ids + Types.StructType struct = + SparkTypeVisitor.visit(sparkType, new SparkTypeToType(sparkType)).asStructType(); + // reassign ids to match the base schema + Schema schema = TypeUtil.reassignIds(new Schema(struct.fields()), baseSchema, caseSensitive); + // fix types that can't be represented in Spark (UUID and Fixed) + return SparkFixupTypes.fixup(schema, baseSchema); + } + + /** + * Convert a Spark {@link StructType struct} to a {@link Schema} based on the given schema. + * + *

    This conversion will assign new ids for fields that are not found in the base schema. + * + *

    Data types, field order, and nullability will match the spark type. This conversion may + * return a schema that is not compatible with base schema. + * + * @param baseSchema a Schema on which conversion is based + * @param sparkType a Spark StructType + * @return the equivalent Schema + * @throws IllegalArgumentException if the type cannot be converted or there are missing ids + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static Schema convertWithFreshIds(Schema baseSchema, StructType sparkType) { + return convertWithFreshIds(baseSchema, sparkType, true); + } + + /** + * Convert a Spark {@link StructType struct} to a {@link Schema} based on the given schema. + * + *

    This conversion will assign new ids for fields that are not found in the base schema. + * + *

    Data types, field order, and nullability will match the spark type. This conversion may + * return a schema that is not compatible with base schema. + * + * @param baseSchema a Schema on which conversion is based + * @param sparkType a Spark StructType + * @param caseSensitive when false, case of field names in schema is ignored + * @return the equivalent Schema + * @throws IllegalArgumentException if the type cannot be converted or there are missing ids + */ + public static Schema convertWithFreshIds( + Schema baseSchema, StructType sparkType, boolean caseSensitive) { + // convert to a type with fresh ids + Types.StructType struct = + SparkTypeVisitor.visit(sparkType, new SparkTypeToType(sparkType)).asStructType(); + // reassign ids to match the base schema + Schema schema = + TypeUtil.reassignOrRefreshIds(new Schema(struct.fields()), baseSchema, caseSensitive); + // fix types that can't be represented in Spark (UUID and Fixed) + return SparkFixupTypes.fixup(schema, baseSchema); + } + + /** + * Prune columns from a {@link Schema} using a {@link StructType Spark type} projection. + * + *

    This requires that the Spark type is a projection of the Schema. Nullability and types must + * match. + * + * @param schema a Schema + * @param requestedType a projection of the Spark representation of the Schema + * @return a Schema corresponding to the Spark projection + * @throws IllegalArgumentException if the Spark type does not match the Schema + */ + public static Schema prune(Schema schema, StructType requestedType) { + return new Schema( + TypeUtil.visit(schema, new PruneColumnsWithoutReordering(requestedType, ImmutableSet.of())) + .asNestedType() + .asStructType() + .fields()); + } + + /** + * Prune columns from a {@link Schema} using a {@link StructType Spark type} projection. + * + *

    This requires that the Spark type is a projection of the Schema. Nullability and types must + * match. + * + *

    The filters list of {@link Expression} is used to ensure that columns referenced by filters + * are projected. + * + * @param schema a Schema + * @param requestedType a projection of the Spark representation of the Schema + * @param filters a list of filters + * @return a Schema corresponding to the Spark projection + * @throws IllegalArgumentException if the Spark type does not match the Schema + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static Schema prune(Schema schema, StructType requestedType, List filters) { + Set filterRefs = Binder.boundReferences(schema.asStruct(), filters, true); + return new Schema( + TypeUtil.visit(schema, new PruneColumnsWithoutReordering(requestedType, filterRefs)) + .asNestedType() + .asStructType() + .fields()); + } + + /** + * Prune columns from a {@link Schema} using a {@link StructType Spark type} projection. + * + *

    This requires that the Spark type is a projection of the Schema. Nullability and types must + * match. + * + *

    The filters list of {@link Expression} is used to ensure that columns referenced by filters + * are projected. + * + * @param schema a Schema + * @param requestedType a projection of the Spark representation of the Schema + * @param filter a filters + * @return a Schema corresponding to the Spark projection + * @throws IllegalArgumentException if the Spark type does not match the Schema + */ + public static Schema prune( + Schema schema, StructType requestedType, Expression filter, boolean caseSensitive) { + Set filterRefs = + Binder.boundReferences(schema.asStruct(), Collections.singletonList(filter), caseSensitive); + + return new Schema( + TypeUtil.visit(schema, new PruneColumnsWithoutReordering(requestedType, filterRefs)) + .asNestedType() + .asStructType() + .fields()); + } + + private static PartitionSpec identitySpec(Schema schema, Collection columns) { + List names = Lists.newArrayList(); + for (Column column : columns) { + if (column.isPartition()) { + names.add(column.name()); + } + } + + return identitySpec(schema, names); + } + + private static PartitionSpec identitySpec(Schema schema, List partitionNames) { + if (partitionNames == null || partitionNames.isEmpty()) { + return null; + } + + PartitionSpec.Builder builder = PartitionSpec.builderFor(schema); + for (String partitionName : partitionNames) { + builder.identity(partitionName); + } + + return builder.build(); + } + + /** + * Estimate approximate table size based on Spark schema and total records. + * + * @param tableSchema Spark schema + * @param totalRecords total records in the table + * @return approximate size based on table schema + */ + public static long estimateSize(StructType tableSchema, long totalRecords) { + if (totalRecords == Long.MAX_VALUE) { + return totalRecords; + } + + long result; + try { + result = LongMath.checkedMultiply(tableSchema.defaultSize(), totalRecords); + } catch (ArithmeticException e) { + result = Long.MAX_VALUE; + } + return result; + } + + public static void validateMetadataColumnReferences(Schema tableSchema, Schema readSchema) { + List conflictingColumnNames = + readSchema.columns().stream() + .map(Types.NestedField::name) + .filter( + name -> + MetadataColumns.isMetadataColumn(name) && tableSchema.findField(name) != null) + .collect(Collectors.toList()); + + ValidationException.check( + conflictingColumnNames.isEmpty(), + "Table column names conflict with names reserved for Iceberg metadata columns: %s.\n" + + "Please, use ALTER TABLE statements to rename the conflicting table columns.", + conflictingColumnNames); + } + + public static Map indexQuotedNameById(Schema schema) { + Function quotingFunc = name -> String.format("`%s`", name.replace("`", "``")); + return TypeUtil.indexQuotedNameById(schema.asStruct(), quotingFunc); + } + + public static StructType toStructType(List fields) { + return new StructType(fields.toArray(new StructField[0])); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java new file mode 100644 index 000000000000..942134eed623 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java @@ -0,0 +1,542 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.source.HasIcebergCatalog; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NamespaceAlreadyExistsException; +import org.apache.spark.sql.catalyst.analysis.NoSuchFunctionException; +import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.analysis.NoSuchViewException; +import org.apache.spark.sql.catalyst.analysis.NonEmptyNamespaceException; +import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException; +import org.apache.spark.sql.catalyst.analysis.ViewAlreadyExistsException; +import org.apache.spark.sql.connector.catalog.CatalogExtension; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.FunctionCatalog; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.NamespaceChange; +import org.apache.spark.sql.connector.catalog.StagedTable; +import org.apache.spark.sql.connector.catalog.StagingTableCatalog; +import org.apache.spark.sql.connector.catalog.SupportsNamespaces; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.TableChange; +import org.apache.spark.sql.connector.catalog.View; +import org.apache.spark.sql.connector.catalog.ViewCatalog; +import org.apache.spark.sql.connector.catalog.ViewChange; +import org.apache.spark.sql.connector.catalog.ViewInfo; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +/** + * A Spark catalog that can also load non-Iceberg tables. + * + * @param CatalogPlugin class to avoid casting to TableCatalog, FunctionCatalog and + * SupportsNamespaces. + */ +public class SparkSessionCatalog< + T extends TableCatalog & FunctionCatalog & SupportsNamespaces & ViewCatalog> + extends BaseCatalog implements CatalogExtension { + private static final String[] DEFAULT_NAMESPACE = new String[] {"default"}; + + private String catalogName = null; + private TableCatalog icebergCatalog = null; + private StagingTableCatalog asStagingCatalog = null; + private ViewCatalog asViewCatalog = null; + private T sessionCatalog = null; + private boolean createParquetAsIceberg = false; + private boolean createAvroAsIceberg = false; + private boolean createOrcAsIceberg = false; + + /** + * Build a {@link SparkCatalog} to be used for Iceberg operations. + * + *

    The default implementation creates a new SparkCatalog with the session catalog's name and + * options. + * + * @param name catalog name + * @param options catalog options + * @return a SparkCatalog to be used for Iceberg tables + */ + protected TableCatalog buildSparkCatalog(String name, CaseInsensitiveStringMap options) { + SparkCatalog newCatalog = new SparkCatalog(); + newCatalog.initialize(name, options); + return newCatalog; + } + + @Override + public String[] defaultNamespace() { + return DEFAULT_NAMESPACE; + } + + @Override + public String[][] listNamespaces() throws NoSuchNamespaceException { + return getSessionCatalog().listNamespaces(); + } + + @Override + public String[][] listNamespaces(String[] namespace) throws NoSuchNamespaceException { + return getSessionCatalog().listNamespaces(namespace); + } + + @Override + public boolean namespaceExists(String[] namespace) { + return getSessionCatalog().namespaceExists(namespace); + } + + @Override + public Map loadNamespaceMetadata(String[] namespace) + throws NoSuchNamespaceException { + return getSessionCatalog().loadNamespaceMetadata(namespace); + } + + @Override + public void createNamespace(String[] namespace, Map metadata) + throws NamespaceAlreadyExistsException { + getSessionCatalog().createNamespace(namespace, metadata); + } + + @Override + public void alterNamespace(String[] namespace, NamespaceChange... changes) + throws NoSuchNamespaceException { + getSessionCatalog().alterNamespace(namespace, changes); + } + + @Override + public boolean dropNamespace(String[] namespace, boolean cascade) + throws NoSuchNamespaceException, NonEmptyNamespaceException { + return getSessionCatalog().dropNamespace(namespace, cascade); + } + + @Override + public Identifier[] listTables(String[] namespace) throws NoSuchNamespaceException { + // delegate to the session catalog because all tables share the same namespace + return getSessionCatalog().listTables(namespace); + } + + @Override + public Table loadTable(Identifier ident) throws NoSuchTableException { + try { + return icebergCatalog.loadTable(ident); + } catch (NoSuchTableException e) { + return getSessionCatalog().loadTable(ident); + } + } + + @Override + public Table loadTable(Identifier ident, String version) throws NoSuchTableException { + try { + return icebergCatalog.loadTable(ident, version); + } catch (NoSuchTableException e) { + return getSessionCatalog().loadTable(ident, version); + } + } + + @Override + public Table loadTable(Identifier ident, long timestamp) throws NoSuchTableException { + try { + return icebergCatalog.loadTable(ident, timestamp); + } catch (NoSuchTableException e) { + return getSessionCatalog().loadTable(ident, timestamp); + } + } + + @Override + public void invalidateTable(Identifier ident) { + // We do not need to check whether the table exists and whether + // it is an Iceberg table to reduce remote service requests. + icebergCatalog.invalidateTable(ident); + getSessionCatalog().invalidateTable(ident); + } + + @Override + public boolean tableExists(Identifier ident) { + return icebergCatalog.tableExists(ident) || getSessionCatalog().tableExists(ident); + } + + @Override + public Table createTable( + Identifier ident, StructType schema, Transform[] partitions, Map properties) + throws TableAlreadyExistsException, NoSuchNamespaceException { + String provider = properties.get("provider"); + if (useIceberg(provider)) { + return icebergCatalog.createTable(ident, schema, partitions, properties); + } else { + // delegate to the session catalog + return getSessionCatalog().createTable(ident, schema, partitions, properties); + } + } + + @Override + public StagedTable stageCreate( + Identifier ident, StructType schema, Transform[] partitions, Map properties) + throws TableAlreadyExistsException, NoSuchNamespaceException { + String provider = properties.get("provider"); + TableCatalog catalog; + if (useIceberg(provider)) { + if (asStagingCatalog != null) { + return asStagingCatalog.stageCreate(ident, schema, partitions, properties); + } + catalog = icebergCatalog; + } else { + catalog = getSessionCatalog(); + } + + // create the table with the session catalog, then wrap it in a staged table that will delete to + // roll back + Table table = catalog.createTable(ident, schema, partitions, properties); + return new RollbackStagedTable(catalog, ident, table); + } + + @Override + public StagedTable stageReplace( + Identifier ident, StructType schema, Transform[] partitions, Map properties) + throws NoSuchNamespaceException, NoSuchTableException { + String provider = properties.get("provider"); + TableCatalog catalog; + if (useIceberg(provider)) { + if (asStagingCatalog != null) { + return asStagingCatalog.stageReplace(ident, schema, partitions, properties); + } + catalog = icebergCatalog; + } else { + catalog = getSessionCatalog(); + } + + // attempt to drop the table and fail if it doesn't exist + if (!catalog.dropTable(ident)) { + throw new NoSuchTableException(ident); + } + + try { + // create the table with the session catalog, then wrap it in a staged table that will delete + // to roll back + Table table = catalog.createTable(ident, schema, partitions, properties); + return new RollbackStagedTable(catalog, ident, table); + + } catch (TableAlreadyExistsException e) { + // the table was deleted, but now already exists again. retry the replace. + return stageReplace(ident, schema, partitions, properties); + } + } + + @Override + public StagedTable stageCreateOrReplace( + Identifier ident, StructType schema, Transform[] partitions, Map properties) + throws NoSuchNamespaceException { + String provider = properties.get("provider"); + TableCatalog catalog; + if (useIceberg(provider)) { + if (asStagingCatalog != null) { + return asStagingCatalog.stageCreateOrReplace(ident, schema, partitions, properties); + } + catalog = icebergCatalog; + } else { + catalog = getSessionCatalog(); + } + + // drop the table if it exists + catalog.dropTable(ident); + + try { + // create the table with the session catalog, then wrap it in a staged table that will delete + // to roll back + Table sessionCatalogTable = catalog.createTable(ident, schema, partitions, properties); + return new RollbackStagedTable(catalog, ident, sessionCatalogTable); + + } catch (TableAlreadyExistsException e) { + // the table was deleted, but now already exists again. retry the replace. + return stageCreateOrReplace(ident, schema, partitions, properties); + } + } + + @Override + public Table alterTable(Identifier ident, TableChange... changes) throws NoSuchTableException { + if (icebergCatalog.tableExists(ident)) { + return icebergCatalog.alterTable(ident, changes); + } else { + return getSessionCatalog().alterTable(ident, changes); + } + } + + @Override + public boolean dropTable(Identifier ident) { + // no need to check table existence to determine which catalog to use. if a table doesn't exist + // then both are + // required to return false. + return icebergCatalog.dropTable(ident) || getSessionCatalog().dropTable(ident); + } + + @Override + public boolean purgeTable(Identifier ident) { + // no need to check table existence to determine which catalog to use. if a table doesn't exist + // then both are + // required to return false. + return icebergCatalog.purgeTable(ident) || getSessionCatalog().purgeTable(ident); + } + + @Override + public void renameTable(Identifier from, Identifier to) + throws NoSuchTableException, TableAlreadyExistsException { + // rename is not supported by HadoopCatalog. to avoid UnsupportedOperationException for session + // catalog tables, + // check table existence first to ensure that the table belongs to the Iceberg catalog. + if (icebergCatalog.tableExists(from)) { + icebergCatalog.renameTable(from, to); + } else { + getSessionCatalog().renameTable(from, to); + } + } + + @Override + public final void initialize(String name, CaseInsensitiveStringMap options) { + super.initialize(name, options); + + if (options.containsKey(CatalogUtil.ICEBERG_CATALOG_TYPE) + && options + .get(CatalogUtil.ICEBERG_CATALOG_TYPE) + .equalsIgnoreCase(CatalogUtil.ICEBERG_CATALOG_TYPE_HIVE)) { + validateHmsUri(options.get(CatalogProperties.URI)); + } + + this.catalogName = name; + this.icebergCatalog = buildSparkCatalog(name, options); + if (icebergCatalog instanceof StagingTableCatalog) { + this.asStagingCatalog = (StagingTableCatalog) icebergCatalog; + } + + if (icebergCatalog instanceof ViewCatalog) { + this.asViewCatalog = (ViewCatalog) icebergCatalog; + } + + this.createParquetAsIceberg = options.getBoolean("parquet-enabled", createParquetAsIceberg); + this.createAvroAsIceberg = options.getBoolean("avro-enabled", createAvroAsIceberg); + this.createOrcAsIceberg = options.getBoolean("orc-enabled", createOrcAsIceberg); + } + + private void validateHmsUri(String catalogHmsUri) { + if (catalogHmsUri == null) { + return; + } + + Configuration conf = SparkSession.active().sessionState().newHadoopConf(); + String envHmsUri = conf.get(HiveConf.ConfVars.METASTOREURIS.varname, null); + if (envHmsUri == null) { + return; + } + + Preconditions.checkArgument( + catalogHmsUri.equals(envHmsUri), + "Inconsistent Hive metastore URIs: %s (Spark session) != %s (spark_catalog)", + envHmsUri, + catalogHmsUri); + } + + @Override + @SuppressWarnings("unchecked") + public void setDelegateCatalog(CatalogPlugin sparkSessionCatalog) { + if (sparkSessionCatalog instanceof TableCatalog + && sparkSessionCatalog instanceof FunctionCatalog + && sparkSessionCatalog instanceof SupportsNamespaces) { + this.sessionCatalog = (T) sparkSessionCatalog; + } else { + throw new IllegalArgumentException("Invalid session catalog: " + sparkSessionCatalog); + } + } + + @Override + public String name() { + return catalogName; + } + + private boolean useIceberg(String provider) { + if (provider == null || "iceberg".equalsIgnoreCase(provider)) { + return true; + } else if (createParquetAsIceberg && "parquet".equalsIgnoreCase(provider)) { + return true; + } else if (createAvroAsIceberg && "avro".equalsIgnoreCase(provider)) { + return true; + } else if (createOrcAsIceberg && "orc".equalsIgnoreCase(provider)) { + return true; + } + + return false; + } + + private T getSessionCatalog() { + Preconditions.checkNotNull( + sessionCatalog, + "Delegated SessionCatalog is missing. " + + "Please make sure your are replacing Spark's default catalog, named 'spark_catalog'."); + return sessionCatalog; + } + + @Override + public Catalog icebergCatalog() { + Preconditions.checkArgument( + icebergCatalog instanceof HasIcebergCatalog, + "Cannot return underlying Iceberg Catalog, wrapped catalog does not contain an Iceberg Catalog"); + return ((HasIcebergCatalog) icebergCatalog).icebergCatalog(); + } + + private boolean isViewCatalog() { + return getSessionCatalog() instanceof ViewCatalog; + } + + @Override + public boolean functionExists(Identifier ident) { + return super.functionExists(ident) || getSessionCatalog().functionExists(ident); + } + + @Override + public UnboundFunction loadFunction(Identifier ident) throws NoSuchFunctionException { + try { + return super.loadFunction(ident); + } catch (NoSuchFunctionException e) { + return getSessionCatalog().loadFunction(ident); + } + } + + @Override + public Identifier[] listViews(String... namespace) { + try { + if (null != asViewCatalog) { + return asViewCatalog.listViews(namespace); + } else if (isViewCatalog()) { + getSessionCatalog().listViews(namespace); + } + } catch (NoSuchNamespaceException e) { + throw new RuntimeException(e); + } + + return new Identifier[0]; + } + + @Override + public boolean viewExists(Identifier ident) { + return (asViewCatalog != null && asViewCatalog.viewExists(ident)) + || (isViewCatalog() && getSessionCatalog().viewExists(ident)); + } + + @Override + public View loadView(Identifier ident) throws NoSuchViewException { + if (null != asViewCatalog && asViewCatalog.viewExists(ident)) { + return asViewCatalog.loadView(ident); + } else if (isViewCatalog() && getSessionCatalog().viewExists(ident)) { + return getSessionCatalog().loadView(ident); + } + + throw new NoSuchViewException(ident); + } + + @Override + public View createView(ViewInfo viewInfo) + throws ViewAlreadyExistsException, NoSuchNamespaceException { + if (viewInfo == null) { + return null; + } + + if (null != asViewCatalog) { + return asViewCatalog.createView(viewInfo); + } else if (isViewCatalog()) { + return getSessionCatalog().createView(viewInfo); + } + + throw new UnsupportedOperationException( + "Creating a view is not supported by catalog: " + catalogName); + } + + @Override + public View replaceView( + Identifier ident, + String sql, + String currentCatalog, + String[] currentNamespace, + StructType schema, + String[] queryColumnNames, + String[] columnAliases, + String[] columnComments, + Map properties) + throws NoSuchNamespaceException, NoSuchViewException { + if (asViewCatalog instanceof SupportsReplaceView) { + return ((SupportsReplaceView) asViewCatalog) + .replaceView( + ident, + sql, + currentCatalog, + currentNamespace, + schema, + queryColumnNames, + columnAliases, + columnComments, + properties); + } + + throw new UnsupportedOperationException( + "Replacing a view is not supported by catalog: " + catalogName); + } + + @Override + public View alterView(Identifier ident, ViewChange... changes) + throws NoSuchViewException, IllegalArgumentException { + if (null != asViewCatalog && asViewCatalog.viewExists(ident)) { + return asViewCatalog.alterView(ident, changes); + } else if (isViewCatalog()) { + return getSessionCatalog().alterView(ident, changes); + } + + throw new UnsupportedOperationException( + "Altering a view is not supported by catalog: " + catalogName); + } + + @Override + public boolean dropView(Identifier ident) { + if (null != asViewCatalog && asViewCatalog.viewExists(ident)) { + return asViewCatalog.dropView(ident); + } else if (isViewCatalog()) { + return getSessionCatalog().dropView(ident); + } + + return false; + } + + @Override + public void renameView(Identifier fromIdentifier, Identifier toIdentifier) + throws NoSuchViewException, ViewAlreadyExistsException { + if (null != asViewCatalog && asViewCatalog.viewExists(fromIdentifier)) { + asViewCatalog.renameView(fromIdentifier, toIdentifier); + } else if (isViewCatalog()) { + getSessionCatalog().renameView(fromIdentifier, toIdentifier); + } else { + throw new UnsupportedOperationException( + "Renaming a view is not supported by catalog: " + catalogName); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkStructLike.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkStructLike.java new file mode 100644 index 000000000000..77cfa0f34c63 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkStructLike.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.StructLike; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Row; + +public class SparkStructLike implements StructLike { + + private final Types.StructType type; + private Row wrapped; + + public SparkStructLike(Types.StructType type) { + this.type = type; + } + + public SparkStructLike wrap(Row row) { + this.wrapped = row; + return this; + } + + @Override + public int size() { + return type.fields().size(); + } + + @Override + public T get(int pos, Class javaClass) { + Types.NestedField field = type.fields().get(pos); + return javaClass.cast(SparkValueConverter.convert(field.type(), wrapped.get(pos))); + } + + @Override + public void set(int pos, T value) { + throw new UnsupportedOperationException("Not implemented: set"); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTableCache.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTableCache.java new file mode 100644 index 000000000000..83c6303d0fa4 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTableCache.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Collection; +import java.util.Map; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; + +public class SparkTableCache { + + private static final SparkTableCache INSTANCE = new SparkTableCache(); + + private final Map cache = Maps.newConcurrentMap(); + + public static SparkTableCache get() { + return INSTANCE; + } + + public int size() { + return cache.size(); + } + + public void add(String key, Table table) { + cache.put(key, table); + } + + public boolean contains(String key) { + return cache.containsKey(key); + } + + public Table get(String key) { + return cache.get(key); + } + + public Table remove(String key) { + return cache.remove(key); + } + + public Collection

    tables() { + return cache.values(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java new file mode 100644 index 000000000000..96499184cab3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTableUtil.java @@ -0,0 +1,1413 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.spark.sql.functions.col; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestWriter; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.TableMigrationUtil; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.hadoop.SerializableConfiguration; +import org.apache.iceberg.hadoop.Util; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; +import org.apache.iceberg.relocated.com.google.common.base.Objects; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.spark.TaskContext; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.api.java.function.FlatMapFunction; +import org.apache.spark.api.java.function.MapFunction; +import org.apache.spark.api.java.function.MapPartitionsFunction; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.TableIdentifier; +import org.apache.spark.sql.catalyst.analysis.NoSuchDatabaseException; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute; +import org.apache.spark.sql.catalyst.catalog.CatalogTable; +import org.apache.spark.sql.catalyst.catalog.CatalogTablePartition; +import org.apache.spark.sql.catalyst.catalog.SessionCatalog; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.catalyst.expressions.NamedExpression; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; +import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.Function2; +import scala.Option; +import scala.Some; +import scala.Tuple2; +import scala.collection.JavaConverters; +import scala.collection.immutable.Map$; +import scala.collection.immutable.Seq; +import scala.collection.mutable.Builder; +import scala.runtime.AbstractPartialFunction; + +/** + * Java version of the original SparkTableUtil.scala + * https://github.com/apache/iceberg/blob/apache-iceberg-0.8.0-incubating/spark/src/main/scala/org/apache/iceberg/spark/SparkTableUtil.scala + */ +public class SparkTableUtil { + private static final Logger LOG = LoggerFactory.getLogger(SparkTableUtil.class); + + private static final String DUPLICATE_FILE_MESSAGE = + "Cannot complete import because data files " + + "to be imported already exist within the target table: %s. " + + "This is disabled by default as Iceberg is not designed for multiple references to the same file" + + " within the same table. If you are sure, you may set 'check_duplicate_files' to false to force the import."; + + private SparkTableUtil() {} + + /** + * Returns a DataFrame with a row for each partition in the table. + * + *

    The DataFrame has 3 columns, partition key (a=1/b=2), partition location, and format (avro + * or parquet). + * + * @param spark a Spark session + * @param table a table name and (optional) database + * @return a DataFrame of the table's partitions + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static Dataset partitionDF(SparkSession spark, String table) { + List partitions = getPartitions(spark, table); + return spark + .createDataFrame(partitions, SparkPartition.class) + .toDF("partition", "uri", "format"); + } + + /** + * Returns a DataFrame with a row for each partition that matches the specified 'expression'. + * + * @param spark a Spark session. + * @param table name of the table. + * @param expression The expression whose matching partitions are returned. + * @return a DataFrame of the table partitions. + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static Dataset partitionDFByFilter( + SparkSession spark, String table, String expression) { + List partitions = getPartitionsByFilter(spark, table, expression); + return spark + .createDataFrame(partitions, SparkPartition.class) + .toDF("partition", "uri", "format"); + } + + /** + * Returns all partitions in the table. + * + * @param spark a Spark session + * @param table a table name and (optional) database + * @return all table's partitions + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static List getPartitions(SparkSession spark, String table) { + try { + TableIdentifier tableIdent = spark.sessionState().sqlParser().parseTableIdentifier(table); + return getPartitions(spark, tableIdent, null); + } catch (ParseException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unable to parse table identifier: %s", table); + } + } + + /** + * Returns all partitions in the table. + * + * @param spark a Spark session + * @param tableIdent a table identifier + * @param partitionFilter partition filter, or null if no filter + * @return all table's partitions + */ + public static List getPartitions( + SparkSession spark, TableIdentifier tableIdent, Map partitionFilter) { + try { + SessionCatalog catalog = spark.sessionState().catalog(); + CatalogTable catalogTable = catalog.getTableMetadata(tableIdent); + + Option> scalaPartitionFilter; + if (partitionFilter != null && !partitionFilter.isEmpty()) { + Builder, scala.collection.immutable.Map> builder = + Map$.MODULE$.newBuilder(); + partitionFilter.forEach((key, value) -> builder.$plus$eq(Tuple2.apply(key, value))); + scalaPartitionFilter = Option.apply(builder.result()); + } else { + scalaPartitionFilter = Option.empty(); + } + Seq partitions = + catalog.listPartitions(tableIdent, scalaPartitionFilter).toIndexedSeq(); + return JavaConverters.seqAsJavaListConverter(partitions).asJava().stream() + .map(catalogPartition -> toSparkPartition(catalogPartition, catalogTable)) + .collect(Collectors.toList()); + } catch (NoSuchDatabaseException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unknown table: %s. Database not found in catalog.", tableIdent); + } catch (NoSuchTableException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unknown table: %s. Table not found in catalog.", tableIdent); + } + } + + /** + * Returns partitions that match the specified 'predicate'. + * + * @param spark a Spark session + * @param table a table name and (optional) database + * @param predicate a predicate on partition columns + * @return matching table's partitions + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static List getPartitionsByFilter( + SparkSession spark, String table, String predicate) { + TableIdentifier tableIdent; + try { + tableIdent = spark.sessionState().sqlParser().parseTableIdentifier(table); + } catch (ParseException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unable to parse the table identifier: %s", table); + } + + Expression unresolvedPredicateExpr; + try { + unresolvedPredicateExpr = spark.sessionState().sqlParser().parseExpression(predicate); + } catch (ParseException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unable to parse the predicate expression: %s", predicate); + } + + Expression resolvedPredicateExpr = resolveAttrs(spark, table, unresolvedPredicateExpr); + return getPartitionsByFilter(spark, tableIdent, resolvedPredicateExpr); + } + + /** + * Returns partitions that match the specified 'predicate'. + * + * @param spark a Spark session + * @param tableIdent a table identifier + * @param predicateExpr a predicate expression on partition columns + * @return matching table's partitions + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static List getPartitionsByFilter( + SparkSession spark, TableIdentifier tableIdent, Expression predicateExpr) { + try { + SessionCatalog catalog = spark.sessionState().catalog(); + CatalogTable catalogTable = catalog.getTableMetadata(tableIdent); + + Expression resolvedPredicateExpr; + if (!predicateExpr.resolved()) { + resolvedPredicateExpr = resolveAttrs(spark, tableIdent.quotedString(), predicateExpr); + } else { + resolvedPredicateExpr = predicateExpr; + } + Seq predicates = + JavaConverters.collectionAsScalaIterableConverter(ImmutableList.of(resolvedPredicateExpr)) + .asScala() + .toIndexedSeq(); + + Seq partitions = + catalog.listPartitionsByFilter(tableIdent, predicates).toIndexedSeq(); + + return JavaConverters.seqAsJavaListConverter(partitions).asJava().stream() + .map(catalogPartition -> toSparkPartition(catalogPartition, catalogTable)) + .collect(Collectors.toList()); + } catch (NoSuchDatabaseException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unknown table: %s. Database not found in catalog.", tableIdent); + } catch (NoSuchTableException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unknown table: %s. Table not found in catalog.", tableIdent); + } + } + + private static List listPartition( + SparkPartition partition, + PartitionSpec spec, + SerializableConfiguration conf, + MetricsConfig metricsConfig, + NameMapping mapping, + boolean ignoreMissingFiles, + ExecutorService service) { + try { + return TableMigrationUtil.listPartition( + partition.values, + partition.uri, + partition.format, + spec, + conf.get(), + metricsConfig, + mapping, + service); + } catch (RuntimeException e) { + if (ignoreMissingFiles && e.getCause() instanceof FileNotFoundException) { + LOG.warn("Ignoring FileNotFoundException when listing partition of {}", partition, e); + return Collections.emptyList(); + } else { + throw e; + } + } + } + + private static SparkPartition toSparkPartition( + CatalogTablePartition partition, CatalogTable table) { + Option locationUri = partition.storage().locationUri(); + Option partitionSerde = partition.storage().serde(); + + Preconditions.checkArgument(locationUri.nonEmpty(), "Partition URI should be defined"); + Preconditions.checkArgument( + partitionSerde.nonEmpty() || table.provider().nonEmpty(), + "Partition format should be defined"); + + String uri = Util.uriToString(locationUri.get()); + String format = resolveFileFormat(partitionSerde.getOrElse(() -> null), table); + Map partitionSpec = + JavaConverters.mapAsJavaMapConverter(partition.spec()).asJava(); + return new SparkPartition(partitionSpec, uri, format); + } + + private static Expression resolveAttrs(SparkSession spark, String table, Expression expr) { + Function2 resolver = spark.sessionState().analyzer().resolver(); + LogicalPlan plan = spark.table(table).queryExecution().analyzed(); + return expr.transform( + new AbstractPartialFunction() { + @Override + public Expression apply(Expression attr) { + UnresolvedAttribute unresolvedAttribute = (UnresolvedAttribute) attr; + Option namedExpressionOption = + plan.resolve(unresolvedAttribute.nameParts(), resolver); + if (namedExpressionOption.isDefined()) { + return (Expression) namedExpressionOption.get(); + } else { + throw new IllegalArgumentException( + String.format("Could not resolve %s using columns: %s", attr, plan.output())); + } + } + + @Override + public boolean isDefinedAt(Expression attr) { + return attr instanceof UnresolvedAttribute; + } + }); + } + + private static Iterator buildManifest( + int formatVersion, + Long snapshotId, + SerializableConfiguration conf, + PartitionSpec spec, + String basePath, + Iterator> fileTuples) { + if (fileTuples.hasNext()) { + FileIO io = new HadoopFileIO(conf.get()); + TaskContext ctx = TaskContext.get(); + String suffix = + String.format( + Locale.ROOT, + "stage-%d-task-%d-manifest-%s", + ctx.stageId(), + ctx.taskAttemptId(), + UUID.randomUUID()); + Path location = new Path(basePath, suffix); + String outputPath = FileFormat.AVRO.addExtension(location.toString()); + OutputFile outputFile = io.newOutputFile(outputPath); + ManifestWriter writer = + ManifestFiles.write(formatVersion, spec, outputFile, snapshotId); + + try (ManifestWriter writerRef = writer) { + fileTuples.forEachRemaining(fileTuple -> writerRef.add(fileTuple._2)); + } catch (IOException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unable to close the manifest writer: %s", outputPath); + } + + ManifestFile manifestFile = writer.toManifestFile(); + return ImmutableList.of(manifestFile).iterator(); + } else { + return Collections.emptyIterator(); + } + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + * @param partitionFilter only import partitions whose values match those in the map, can be + * partially defined + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static void importSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + String stagingDir, + Map partitionFilter, + boolean checkDuplicateFiles) { + importSparkTable( + spark, sourceTableIdent, targetTable, stagingDir, partitionFilter, checkDuplicateFiles, 1); + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + * @param parallelism number of threads to use for file reading + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static void importSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + String stagingDir, + int parallelism) { + importSparkTable( + spark, + sourceTableIdent, + targetTable, + stagingDir, + TableMigrationUtil.migrationService(parallelism)); + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + * @param service executor service to use for file reading. If null, file reading will be + * performed on the current thread. * If non-null, the provided ExecutorService will be + * shutdown within this method after file reading is complete. + */ + public static void importSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + String stagingDir, + ExecutorService service) { + importSparkTable( + spark, sourceTableIdent, targetTable, stagingDir, Collections.emptyMap(), false, service); + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + * @param partitionFilter only import partitions whose values match those in the map, can be + * partially defined + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @param parallelism number of threads to use for file reading + */ + public static void importSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + String stagingDir, + Map partitionFilter, + boolean checkDuplicateFiles, + int parallelism) { + importSparkTable( + spark, + sourceTableIdent, + targetTable, + stagingDir, + partitionFilter, + checkDuplicateFiles, + migrationService(parallelism)); + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + * @param partitionFilter only import partitions whose values match those in the map, can be + * partially defined + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @param service executor service to use for file reading. If null, file reading will be + * performed on the current thread. If non-null, the provided ExecutorService will be shutdown + * within this method after file reading is complete. + */ + public static void importSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + String stagingDir, + Map partitionFilter, + boolean checkDuplicateFiles, + ExecutorService service) { + importSparkTable( + spark, + sourceTableIdent, + targetTable, + stagingDir, + partitionFilter, + checkDuplicateFiles, + false, + service); + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + * @param partitionFilter only import partitions whose values match those in the map, can be + * partially defined + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @param ignoreMissingFiles if true, ignore {@link FileNotFoundException} when running {@link + * #listPartition} for the Spark partitions + * @param service executor service to use for file reading. If null, file reading will be + * performed on the current thread. If non-null, the provided ExecutorService will be shutdown + * within this method after file reading is complete. + */ + public static void importSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + String stagingDir, + Map partitionFilter, + boolean checkDuplicateFiles, + boolean ignoreMissingFiles, + ExecutorService service) { + SessionCatalog catalog = spark.sessionState().catalog(); + + String db = + sourceTableIdent.database().nonEmpty() + ? sourceTableIdent.database().get() + : catalog.getCurrentDatabase(); + TableIdentifier sourceTableIdentWithDB = + new TableIdentifier(sourceTableIdent.table(), Some.apply(db)); + + if (!catalog.tableExists(sourceTableIdentWithDB)) { + throw new org.apache.iceberg.exceptions.NoSuchTableException( + "Table %s does not exist", sourceTableIdentWithDB); + } + + try { + PartitionSpec spec = + findCompatibleSpec(targetTable, spark, sourceTableIdentWithDB.unquotedString()); + + validatePartitionFilter(spec, partitionFilter, targetTable.name()); + + if (Objects.equal(spec, PartitionSpec.unpartitioned())) { + importUnpartitionedSparkTable( + spark, sourceTableIdentWithDB, targetTable, checkDuplicateFiles, service); + } else { + List sourceTablePartitions = + getPartitions(spark, sourceTableIdent, partitionFilter); + if (sourceTablePartitions.isEmpty()) { + targetTable.newAppend().commit(); + } else { + importSparkPartitions( + spark, + sourceTablePartitions, + targetTable, + spec, + stagingDir, + checkDuplicateFiles, + ignoreMissingFiles, + service); + } + } + } catch (AnalysisException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unable to get partition spec for table: %s", sourceTableIdentWithDB); + } + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static void importSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + String stagingDir, + boolean checkDuplicateFiles) { + importSparkTable( + spark, + sourceTableIdent, + targetTable, + stagingDir, + Collections.emptyMap(), + checkDuplicateFiles, + 1); + } + + /** + * Import files from an existing Spark table to an Iceberg table. + * + *

    The import uses the Spark session to get table metadata. It assumes no operation is going on + * the original and target table and thus is not thread-safe. + * + * @param spark a Spark session + * @param sourceTableIdent an identifier of the source Spark table + * @param targetTable an Iceberg table where to import the data + * @param stagingDir a staging directory to store temporary manifest files + */ + public static void importSparkTable( + SparkSession spark, TableIdentifier sourceTableIdent, Table targetTable, String stagingDir) { + importSparkTable( + spark, sourceTableIdent, targetTable, stagingDir, Collections.emptyMap(), false, 1); + } + + private static void importUnpartitionedSparkTable( + SparkSession spark, + TableIdentifier sourceTableIdent, + Table targetTable, + boolean checkDuplicateFiles, + ExecutorService service) { + try { + CatalogTable sourceTable = spark.sessionState().catalog().getTableMetadata(sourceTableIdent); + String format = resolveFileFormat(null, sourceTable); + + Map partition = Collections.emptyMap(); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Configuration conf = spark.sessionState().newHadoopConf(); + MetricsConfig metricsConfig = MetricsConfig.forTable(targetTable); + String nameMappingString = targetTable.properties().get(TableProperties.DEFAULT_NAME_MAPPING); + NameMapping nameMapping = + nameMappingString != null ? NameMappingParser.fromJson(nameMappingString) : null; + + List files = + TableMigrationUtil.listPartition( + partition, + Util.uriToString(sourceTable.location()), + format, + spec, + conf, + metricsConfig, + nameMapping, + service); + + if (checkDuplicateFiles) { + Dataset importedFiles = + spark + .createDataset(Lists.transform(files, ContentFile::location), Encoders.STRING()) + .toDF("file_path"); + Dataset existingFiles = + loadMetadataTable(spark, targetTable, MetadataTableType.ENTRIES).filter("status != 2"); + Column joinCond = + existingFiles.col("data_file.file_path").equalTo(importedFiles.col("file_path")); + Dataset duplicates = + importedFiles.join(existingFiles, joinCond).select("file_path").as(Encoders.STRING()); + Preconditions.checkState( + duplicates.isEmpty(), + String.format( + DUPLICATE_FILE_MESSAGE, Joiner.on(",").join((String[]) duplicates.take(10)))); + } + + AppendFiles append = targetTable.newAppend(); + files.forEach(append::appendFile); + append.commit(); + } catch (NoSuchDatabaseException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unknown table: %s. Database not found in catalog.", sourceTableIdent); + } catch (NoSuchTableException e) { + throw SparkExceptionUtil.toUncheckedException( + e, "Unknown table: %s. Table not found in catalog.", sourceTableIdent); + } + } + + /** + * Import files from given partitions to an Iceberg table. + * + * @param spark a Spark session + * @param partitions partitions to import + * @param targetTable an Iceberg table where to import the data + * @param spec a partition spec + * @param stagingDir a staging directory to store temporary manifest files + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static void importSparkPartitions( + SparkSession spark, + List partitions, + Table targetTable, + PartitionSpec spec, + String stagingDir, + boolean checkDuplicateFiles) { + importSparkPartitions(spark, partitions, targetTable, spec, stagingDir, checkDuplicateFiles, 1); + } + + /** + * Import files from given partitions to an Iceberg table. + * + * @param spark a Spark session + * @param partitions partitions to import + * @param targetTable an Iceberg table where to import the data + * @param spec a partition spec + * @param stagingDir a staging directory to store temporary manifest files + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @param parallelism number of threads to use for file reading + */ + public static void importSparkPartitions( + SparkSession spark, + List partitions, + Table targetTable, + PartitionSpec spec, + String stagingDir, + boolean checkDuplicateFiles, + int parallelism) { + importSparkPartitions( + spark, + partitions, + targetTable, + spec, + stagingDir, + checkDuplicateFiles, + migrationService(parallelism)); + } + + /** + * Import files from given partitions to an Iceberg table. + * + * @param spark a Spark session + * @param partitions partitions to import + * @param targetTable an Iceberg table where to import the data + * @param spec a partition spec + * @param stagingDir a staging directory to store temporary manifest files + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @param service executor service to use for file reading. If null, file reading will be + * performed on the current thread. If non-null, the provided ExecutorService will be shutdown + * within this method after file reading is complete. + */ + public static void importSparkPartitions( + SparkSession spark, + List partitions, + Table targetTable, + PartitionSpec spec, + String stagingDir, + boolean checkDuplicateFiles, + ExecutorService service) { + importSparkPartitions( + spark, partitions, targetTable, spec, stagingDir, checkDuplicateFiles, false, service); + } + + /** + * Import files from given partitions to an Iceberg table. + * + * @param spark a Spark session + * @param partitions partitions to import + * @param targetTable an Iceberg table where to import the data + * @param spec a partition spec + * @param stagingDir a staging directory to store temporary manifest files + * @param checkDuplicateFiles if true, throw exception if import results in a duplicate data file + * @param ignoreMissingFiles if true, ignore {@link FileNotFoundException} when running {@link + * #listPartition} for the Spark partitions + * @param service executor service to use for file reading. If null, file reading will be + * performed on the current thread. If non-null, the provided ExecutorService will be shutdown + * within this method after file reading is complete. + */ + public static void importSparkPartitions( + SparkSession spark, + List partitions, + Table targetTable, + PartitionSpec spec, + String stagingDir, + boolean checkDuplicateFiles, + boolean ignoreMissingFiles, + ExecutorService service) { + Configuration conf = spark.sessionState().newHadoopConf(); + SerializableConfiguration serializableConf = new SerializableConfiguration(conf); + int listingParallelism = + Math.min( + partitions.size(), spark.sessionState().conf().parallelPartitionDiscoveryParallelism()); + int numShufflePartitions = spark.sessionState().conf().numShufflePartitions(); + MetricsConfig metricsConfig = MetricsConfig.forTable(targetTable); + String nameMappingString = targetTable.properties().get(TableProperties.DEFAULT_NAME_MAPPING); + NameMapping nameMapping = + nameMappingString != null ? NameMappingParser.fromJson(nameMappingString) : null; + + JavaSparkContext sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + JavaRDD partitionRDD = sparkContext.parallelize(partitions, listingParallelism); + + Dataset partitionDS = + spark.createDataset(partitionRDD.rdd(), Encoders.javaSerialization(SparkPartition.class)); + + Dataset filesToImport = + partitionDS.flatMap( + (FlatMapFunction) + sparkPartition -> + listPartition( + sparkPartition, + spec, + serializableConf, + metricsConfig, + nameMapping, + ignoreMissingFiles, + service) + .iterator(), + Encoders.javaSerialization(DataFile.class)); + + if (checkDuplicateFiles) { + Dataset importedFiles = + filesToImport + .map((MapFunction) ContentFile::location, Encoders.STRING()) + .toDF("file_path"); + Dataset existingFiles = + loadMetadataTable(spark, targetTable, MetadataTableType.ENTRIES).filter("status != 2"); + Column joinCond = + existingFiles.col("data_file.file_path").equalTo(importedFiles.col("file_path")); + Dataset duplicates = + importedFiles.join(existingFiles, joinCond).select("file_path").as(Encoders.STRING()); + Preconditions.checkState( + duplicates.isEmpty(), + String.format( + DUPLICATE_FILE_MESSAGE, Joiner.on(",").join((String[]) duplicates.take(10)))); + } + + TableOperations ops = ((HasTableOperations) targetTable).operations(); + int formatVersion = ops.current().formatVersion(); + boolean snapshotIdInheritanceEnabled = + PropertyUtil.propertyAsBoolean( + targetTable.properties(), + TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, + TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED_DEFAULT); + + final Long snapshotId; + if (formatVersion == 1 && !snapshotIdInheritanceEnabled) { + snapshotId = -1L; + } else { + snapshotId = null; + } + + List manifests = + filesToImport + .repartition(numShufflePartitions) + .map( + (MapFunction>) + file -> Tuple2.apply(file.location(), file), + Encoders.tuple(Encoders.STRING(), Encoders.javaSerialization(DataFile.class))) + .orderBy(col("_1")) + .mapPartitions( + (MapPartitionsFunction, ManifestFile>) + fileTuple -> + buildManifest( + formatVersion, + snapshotId, + serializableConf, + spec, + stagingDir, + fileTuple), + Encoders.javaSerialization(ManifestFile.class)) + .collectAsList(); + + try { + + AppendFiles append = targetTable.newAppend(); + manifests.forEach(append::appendManifest); + append.commit(); + + if (formatVersion == 1 && !snapshotIdInheritanceEnabled) { + // delete original manifests as they were rewritten before the commit + deleteManifests(targetTable.io(), manifests); + } + } catch (Throwable e) { + deleteManifests(targetTable.io(), manifests); + throw e; + } + } + + /** + * Import files from given partitions to an Iceberg table. + * + * @param spark a Spark session + * @param partitions partitions to import + * @param targetTable an Iceberg table where to import the data + * @param spec a partition spec + * @param stagingDir a staging directory to store temporary manifest files + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static void importSparkPartitions( + SparkSession spark, + List partitions, + Table targetTable, + PartitionSpec spec, + String stagingDir) { + importSparkPartitions(spark, partitions, targetTable, spec, stagingDir, false, 1); + } + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0 + */ + @Deprecated + public static List filterPartitions( + List partitions, Map partitionFilter) { + if (partitionFilter.isEmpty()) { + return partitions; + } else { + return partitions.stream() + .filter(p -> p.getValues().entrySet().containsAll(partitionFilter.entrySet())) + .collect(Collectors.toList()); + } + } + + private static void deleteManifests(FileIO io, List manifests) { + CatalogUtil.deleteFiles(io, Lists.transform(manifests, ManifestFile::path), "manifests"); + } + + public static Dataset loadTable(SparkSession spark, Table table, long snapshotId) { + TimeTravel timeTravel = TimeTravel.version(String.valueOf(snapshotId)); + SparkTable sparkTable = SparkTable.create(table, timeTravel); + DataSourceV2Relation relation = createRelation(sparkTable, ImmutableMap.of()); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + return org.apache.spark.sql.classic.Dataset.ofRows( + (org.apache.spark.sql.classic.SparkSession) spark, relation); + } + + public static Dataset loadMetadataTable( + SparkSession spark, Table table, MetadataTableType type) { + return loadMetadataTable(spark, table, type, ImmutableMap.of()); + } + + public static Dataset loadMetadataTable( + SparkSession spark, Table table, MetadataTableType type, Map extraOptions) { + Table metadataTable = MetadataTableUtils.createMetadataTableInstance(table, type); + SparkTable sparkMetadataTable = new SparkTable(metadataTable); + DataSourceV2Relation relation = createRelation(sparkMetadataTable, extraOptions); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + return org.apache.spark.sql.classic.Dataset.ofRows( + (org.apache.spark.sql.classic.SparkSession) spark, relation); + } + + private static DataSourceV2Relation createRelation( + SparkTable sparkTable, Map extraOptions) { + CaseInsensitiveStringMap options = new CaseInsensitiveStringMap(extraOptions); + return DataSourceV2Relation.create( + sparkTable, Option.empty(), Option.empty(), options, Option.empty()); + } + + public static void validateWriteBranch( + SparkSession spark, Table table, String branch, CaseInsensitiveStringMap options) { + validateBranch(spark, branch, determineWriteBranch(spark, table, branch, options)); + } + + public static void validateReadBranch( + SparkSession spark, Table table, String branch, CaseInsensitiveStringMap options) { + validateBranch(spark, branch, determineReadBranch(spark, table, branch, options)); + } + + private static void validateBranch(SparkSession spark, String branch, String targetBranch) { + Preconditions.checkArgument( + Objects.equal(branch, targetBranch) || Spark3Util.extensionsEnabled(spark), + "Must enable Iceberg extensions to use branching via options or SQL: operation targets branch `%s`", + targetBranch); + } + + public static String determineWriteBranch( + SparkSession spark, SparkTable sparkTable, CaseInsensitiveStringMap options) { + return determineWriteBranch(spark, sparkTable.table(), sparkTable.branch(), options); + } + + /** + * Determine the write branch. + * + *

    The target branch can be specified via table identifier, write option, or in SQL: + * + *

      + *
    • The identifier and option branches can't conflict. If both are set, they must match. + *
    • Identifier and option branches take priority over the session WAP branch. + *
    • If neither the option nor the identifier branch is set and WAP is enabled for this table, + * use the WAP branch from the session SQL config. + *
    + * + *

    Note: WAP ID and WAP branch cannot be set at the same time. + * + *

    Note: The target branch may be created during the write operation if it does not exist. + * + * @param spark a Spark Session + * @param table the table being written to + * @param branch write branch configured via table identifier, or null + * @param options write options + * @return branch for write operation, or null for main branch + */ + public static String determineWriteBranch( + SparkSession spark, Table table, String branch, CaseInsensitiveStringMap options) { + String optionBranch = options.get(SparkWriteOptions.BRANCH); + if (optionBranch != null) { + Preconditions.checkArgument( + branch == null || optionBranch.equals(branch), + "Explicitly configured branch [%s] and write option [%s] are in conflict", + branch, + optionBranch); + return optionBranch; + } + + if (branch == null && wapEnabled(table)) { + return wapSessionBranch(spark); + } + + return branch; + } + + public static String determineReadBranch( + SparkSession spark, SparkTable sparkTable, CaseInsensitiveStringMap options) { + return determineReadBranch(spark, sparkTable.table(), sparkTable.branch(), options); + } + + /** + * Determine the read branch. + * + *

    The target branch can be specified via table identifier, read option, or in SQL: + * + *

      + *
    • The identifier and option branches can't conflict. If both are set, they must match. + *
    • Identifier and option branches take priority over the session WAP branch. + *
    • If neither the option nor the identifier branch is set and WAP is enabled for this table, + * use the WAP branch from the session SQL config (only if the branch already exists). + *
    + * + *

    Note: WAP ID and WAP branch cannot be set at the same time. + * + * @param spark a Spark Session + * @param table the table being read from + * @param branch read branch configured via table identifier, or null + * @param options read options + * @return branch for read operation, or null for main branch + */ + public static String determineReadBranch( + SparkSession spark, Table table, String branch, CaseInsensitiveStringMap options) { + String optionBranch = options.get(SparkReadOptions.BRANCH); + if (optionBranch != null) { + Preconditions.checkArgument( + branch == null || optionBranch.equals(branch), + "Explicitly configured branch [%s] and read option [%s] are in conflict", + branch, + optionBranch); + return optionBranch; + } + + if (branch == null && wapEnabled(table)) { + String wapBranch = wapSessionBranch(spark); + if (wapBranch != null && table.refs().containsKey(wapBranch)) { + return wapBranch; + } + } + + return branch; + } + + private static String wapSessionBranch(SparkSession spark) { + String wapId = spark.conf().get(SparkSQLProperties.WAP_ID, null); + String wapBranch = spark.conf().get(SparkSQLProperties.WAP_BRANCH, null); + Preconditions.checkArgument( + wapId == null || wapBranch == null, + "Cannot set both WAP ID and branch, but got ID [%s] and branch [%s]", + wapId, + wapBranch); + return wapBranch; + } + + private static boolean wapEnabled(Table table) { + return PropertyUtil.propertyAsBoolean( + table.properties(), + TableProperties.WRITE_AUDIT_PUBLISH_ENABLED, + Boolean.parseBoolean(TableProperties.WRITE_AUDIT_PUBLISH_ENABLED_DEFAULT)); + } + + private static String resolveFileFormat(String partitionSerde, CatalogTable table) { + if (partitionSerde != null && isKnownFileFormat(partitionSerde)) { + return partitionSerde; + } + + Option serde = table.storage().serde(); + if (serde.nonEmpty() && isKnownFileFormat(serde.get())) { + return serde.get(); + } + + Preconditions.checkArgument( + table.provider().nonEmpty(), + "Could not determine table format from serde %s and no provider set", + serde.getOrElse(() -> "unknown")); + return table.provider().get(); + } + + private static boolean isKnownFileFormat(String serde) { + String lowerSerde = serde.toLowerCase(Locale.ROOT); + return lowerSerde.contains("parquet") + || lowerSerde.contains("avro") + || lowerSerde.contains("orc"); + } + + /** Class representing a table partition. */ + public static class SparkPartition implements Serializable { + private final Map values; + private final String uri; + private final String format; + + public SparkPartition(Map values, String uri, String format) { + this.values = Maps.newHashMap(values); + this.uri = uri; + this.format = format; + } + + public Map getValues() { + return values; + } + + public String getUri() { + return uri; + } + + public String getFormat() { + return format; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("values", values) + .add("uri", uri) + .add("format", format) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SparkPartition that = (SparkPartition) o; + return Objects.equal(values, that.values) + && Objects.equal(uri, that.uri) + && Objects.equal(format, that.format); + } + + @Override + public int hashCode() { + return Objects.hashCode(values, uri, format); + } + } + + @Nullable + public static ExecutorService migrationService(int parallelism) { + return parallelism == 1 ? null : new LazyExecutorService(parallelism); + } + + private static class LazyExecutorService implements ExecutorService, Serializable { + + private final int parallelism; + private volatile ExecutorService service; + + LazyExecutorService(int parallelism) { + this.parallelism = parallelism; + } + + @Override + public void shutdown() { + getService().shutdown(); + } + + @Nonnull + @Override + public List shutdownNow() { + return getService().shutdownNow(); + } + + @Override + public boolean isShutdown() { + return getService().isShutdown(); + } + + @Override + public boolean isTerminated() { + return getService().isTerminated(); + } + + @Override + public boolean awaitTermination(long timeout, @Nonnull TimeUnit unit) + throws InterruptedException { + return getService().awaitTermination(timeout, unit); + } + + @Nonnull + @Override + public Future submit(@Nonnull Callable task) { + return getService().submit(task); + } + + @Nonnull + @Override + public Future submit(@Nonnull Runnable task, T result) { + return getService().submit(task, result); + } + + @Nonnull + @Override + public Future submit(@Nonnull Runnable task) { + return getService().submit(task); + } + + @Nonnull + @Override + public List> invokeAll(@Nonnull Collection> tasks) + throws InterruptedException { + return getService().invokeAll(tasks); + } + + @Nonnull + @Override + public List> invokeAll( + @Nonnull Collection> tasks, long timeout, @Nonnull TimeUnit unit) + throws InterruptedException { + return getService().invokeAll(tasks, timeout, unit); + } + + @Nonnull + @Override + public T invokeAny(@Nonnull Collection> tasks) + throws InterruptedException, ExecutionException { + return getService().invokeAny(tasks); + } + + @Override + public T invokeAny( + @Nonnull Collection> tasks, long timeout, @Nonnull TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return getService().invokeAny(tasks, timeout, unit); + } + + @Override + public void execute(@Nonnull Runnable command) { + getService().execute(command); + } + + private ExecutorService getService() { + if (service == null) { + synchronized (this) { + if (service == null) { + service = TableMigrationUtil.migrationService(parallelism); + } + } + } + return service; + } + } + + /** + * Returns the first partition spec in an IcebergTable that shares the same names and ordering as + * the partition columns provided. Throws an error if not found + */ + public static PartitionSpec findCompatibleSpec(List partitionNames, Table icebergTable) { + List partitionNamesLower = + partitionNames.stream() + .map(name -> name.toLowerCase(Locale.ROOT)) + .collect(Collectors.toList()); + for (PartitionSpec icebergSpec : icebergTable.specs().values()) { + boolean allIdentity = + icebergSpec.fields().stream().allMatch(field -> field.transform().isIdentity()); + if (allIdentity) { + List icebergPartNames = + icebergSpec.fields().stream() + .map(PartitionField::name) + .map(name -> name.toLowerCase(Locale.ROOT)) + .collect(Collectors.toList()); + if (icebergPartNames.equals(partitionNamesLower)) { + return icebergSpec; + } + } + } + + throw new IllegalArgumentException( + String.format( + "Cannot find a partition spec in Iceberg table %s that matches the partition" + + " columns (%s) in input table", + icebergTable, partitionNames)); + } + + /** + * Returns the first partition spec in an IcebergTable that shares the same names and ordering as + * the partition columns in a given Spark Table. Throws an error if not found + */ + private static PartitionSpec findCompatibleSpec( + Table icebergTable, SparkSession spark, String sparkTable) throws AnalysisException { + List parts = Lists.newArrayList(Splitter.on('.').limit(2).split(sparkTable)); + String db = parts.size() == 1 ? "default" : parts.get(0); + String table = parts.get(parts.size() == 1 ? 0 : 1); + + List sparkPartNames = + spark.catalog().listColumns(db, table).collectAsList().stream() + .filter(org.apache.spark.sql.catalog.Column::isPartition) + .map(org.apache.spark.sql.catalog.Column::name) + .collect(Collectors.toList()); + return findCompatibleSpec(sparkPartNames, icebergTable); + } + + public static void validatePartitionFilter( + PartitionSpec spec, Map partitionFilter, String tableName) { + List partitionFields = spec.fields(); + Set partitionNames = + spec.fields().stream().map(PartitionField::name).collect(Collectors.toSet()); + + boolean tablePartitioned = !partitionFields.isEmpty(); + boolean partitionFilterPassed = !partitionFilter.isEmpty(); + + if (tablePartitioned && partitionFilterPassed) { + // Check to see there are sufficient partition columns to satisfy the filter + Preconditions.checkArgument( + partitionFields.size() >= partitionFilter.size(), + "Cannot add data files to target table %s because that table is partitioned, " + + "but the number of columns in the provided partition filter (%s) " + + "is greater than the number of partitioned columns in table (%s)", + tableName, + partitionFilter.size(), + partitionFields.size()); + + // Check for any filters of non-existent columns + List unMatchedFilters = + partitionFilter.keySet().stream() + .filter(filterName -> !partitionNames.contains(filterName)) + .collect(Collectors.toList()); + Preconditions.checkArgument( + unMatchedFilters.isEmpty(), + "Cannot add files to target table %s. %s is partitioned but the specified partition filter " + + "refers to columns that are not partitioned: %s . Valid partition columns: [%s]", + tableName, + tableName, + unMatchedFilters, + String.join(",", partitionNames)); + } else { + Preconditions.checkArgument( + !partitionFilterPassed, + "Cannot use partition filter with an unpartitioned table %s", + tableName); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java new file mode 100644 index 000000000000..54ad899ade77 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTypeToType.java @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.List; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.BinaryType; +import org.apache.spark.sql.types.BooleanType; +import org.apache.spark.sql.types.ByteType; +import org.apache.spark.sql.types.CharType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DateType; +import org.apache.spark.sql.types.DecimalType; +import org.apache.spark.sql.types.DoubleType; +import org.apache.spark.sql.types.FloatType; +import org.apache.spark.sql.types.IntegerType; +import org.apache.spark.sql.types.LongType; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType; +import org.apache.spark.sql.types.ShortType; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.TimestampNTZType; +import org.apache.spark.sql.types.TimestampType; +import org.apache.spark.sql.types.VarcharType; +import org.apache.spark.sql.types.VariantType; + +class SparkTypeToType extends SparkTypeVisitor { + private final StructType root; + private int nextId = 0; + + SparkTypeToType() { + this.root = null; + } + + SparkTypeToType(StructType root) { + this.root = root; + // the root struct's fields use the first ids + this.nextId = root.fields().length; + } + + private int getNextId() { + int next = nextId; + nextId += 1; + return next; + } + + @Override + @SuppressWarnings("ReferenceEquality") + public Type struct(StructType struct, List types) { + StructField[] fields = struct.fields(); + List newFields = Lists.newArrayListWithExpectedSize(fields.length); + boolean isRoot = root == struct; + for (int i = 0; i < fields.length; i += 1) { + StructField field = fields[i]; + Type type = types.get(i); + + int id; + if (isRoot) { + // for new conversions, use ordinals for ids in the root struct + id = i; + } else { + id = getNextId(); + } + + String doc = field.getComment().isDefined() ? field.getComment().get() : null; + + if (field.nullable()) { + newFields.add(Types.NestedField.optional(id, field.name(), type, doc)); + } else { + newFields.add(Types.NestedField.required(id, field.name(), type, doc)); + } + } + + return Types.StructType.of(newFields); + } + + @Override + public Type field(StructField field, Type typeResult) { + return typeResult; + } + + @Override + public Type array(ArrayType array, Type elementType) { + if (array.containsNull()) { + return Types.ListType.ofOptional(getNextId(), elementType); + } else { + return Types.ListType.ofRequired(getNextId(), elementType); + } + } + + @Override + public Type map(MapType map, Type keyType, Type valueType) { + if (map.valueContainsNull()) { + return Types.MapType.ofOptional(getNextId(), getNextId(), keyType, valueType); + } else { + return Types.MapType.ofRequired(getNextId(), getNextId(), keyType, valueType); + } + } + + @Override + public Type variant(VariantType variant) { + return Types.VariantType.get(); + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + @Override + public Type atomic(DataType atomic) { + if (atomic instanceof BooleanType) { + return Types.BooleanType.get(); + + } else if (atomic instanceof IntegerType + || atomic instanceof ShortType + || atomic instanceof ByteType) { + return Types.IntegerType.get(); + + } else if (atomic instanceof LongType) { + return Types.LongType.get(); + + } else if (atomic instanceof FloatType) { + return Types.FloatType.get(); + + } else if (atomic instanceof DoubleType) { + return Types.DoubleType.get(); + + } else if (atomic instanceof StringType + || atomic instanceof CharType + || atomic instanceof VarcharType) { + return Types.StringType.get(); + + } else if (atomic instanceof DateType) { + return Types.DateType.get(); + + } else if (atomic instanceof TimestampType) { + return Types.TimestampType.withZone(); + + } else if (atomic instanceof TimestampNTZType) { + return Types.TimestampType.withoutZone(); + + } else if (atomic instanceof DecimalType) { + return Types.DecimalType.of( + ((DecimalType) atomic).precision(), ((DecimalType) atomic).scale()); + } else if (atomic instanceof BinaryType) { + return Types.BinaryType.get(); + } else if (atomic instanceof NullType) { + return Types.UnknownType.get(); + } + + throw new UnsupportedOperationException("Not a supported type: " + atomic.catalogString()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTypeVisitor.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTypeVisitor.java new file mode 100644 index 000000000000..7b35f6183559 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkTypeVisitor.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.List; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.UserDefinedType; +import org.apache.spark.sql.types.VariantType; + +class SparkTypeVisitor { + static T visit(DataType type, SparkTypeVisitor visitor) { + if (type instanceof StructType) { + StructField[] fields = ((StructType) type).fields(); + List fieldResults = Lists.newArrayListWithExpectedSize(fields.length); + + for (StructField field : fields) { + fieldResults.add(visitor.field(field, visit(field.dataType(), visitor))); + } + + return visitor.struct((StructType) type, fieldResults); + + } else if (type instanceof MapType) { + return visitor.map( + (MapType) type, + visit(((MapType) type).keyType(), visitor), + visit(((MapType) type).valueType(), visitor)); + + } else if (type instanceof ArrayType) { + return visitor.array((ArrayType) type, visit(((ArrayType) type).elementType(), visitor)); + + } else if (type instanceof VariantType) { + return visitor.variant((VariantType) type); + + } else if (type instanceof UserDefinedType) { + throw new UnsupportedOperationException("User-defined types are not supported"); + + } else { + return visitor.atomic(type); + } + } + + public T variant(VariantType variant) { + throw new UnsupportedOperationException("Not implemented for variant"); + } + + public T struct(StructType struct, List fieldResults) { + return null; + } + + public T field(StructField field, T typeResult) { + return null; + } + + public T array(ArrayType array, T elementResult) { + return null; + } + + public T map(MapType map, T keyResult, T valueResult) { + return null; + } + + public T atomic(DataType atomic) { + return null; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkUtil.java new file mode 100644 index 000000000000..ef9990c7bd58 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkUtil.java @@ -0,0 +1,339 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.sql.Date; +import java.sql.Timestamp; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.avro.generic.GenericData; +import org.apache.avro.util.Utf8; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.transforms.Transform; +import org.apache.iceberg.transforms.UnknownTransform; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.Pair; +import org.apache.spark.SparkEnv; +import org.apache.spark.scheduler.ExecutorCacheTaskLocation; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.expressions.BoundReference; +import org.apache.spark.sql.catalyst.expressions.EqualTo; +import org.apache.spark.sql.catalyst.expressions.Expression; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.expressions.Literal; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.storage.BlockManager; +import org.apache.spark.storage.BlockManagerId; +import org.apache.spark.storage.BlockManagerMaster; +import org.apache.spark.unsafe.types.UTF8String; +import org.joda.time.DateTime; +import scala.collection.JavaConverters; +import scala.collection.Seq; + +public class SparkUtil { + private static final String SPARK_CATALOG_CONF_PREFIX = "spark.sql.catalog"; + // Format string used as the prefix for Spark configuration keys to override Hadoop configuration + // values for Iceberg tables from a given catalog. These keys can be specified as + // `spark.sql.catalog.$catalogName.hadoop.*`, similar to using `spark.hadoop.*` to override + // Hadoop configurations globally for a given Spark session. + private static final String SPARK_CATALOG_HADOOP_CONF_OVERRIDE_FMT_STR = + SPARK_CATALOG_CONF_PREFIX + ".%s.hadoop."; + + private static final Joiner DOT = Joiner.on("."); + + private SparkUtil() {} + + /** + * Check whether the partition transforms in a spec can be used to write data. + * + * @param spec a PartitionSpec + * @throws UnsupportedOperationException if the spec contains unknown partition transforms + */ + public static void validatePartitionTransforms(PartitionSpec spec) { + if (spec.fields().stream().anyMatch(field -> field.transform() instanceof UnknownTransform)) { + String unsupported = + spec.fields().stream() + .map(PartitionField::transform) + .filter(transform -> transform instanceof UnknownTransform) + .map(Transform::toString) + .collect(Collectors.joining(", ")); + + throw new UnsupportedOperationException( + String.format("Cannot write using unsupported transforms: %s", unsupported)); + } + } + + /** + * A modified version of Spark's LookupCatalog.CatalogAndIdentifier.unapply Attempts to find the + * catalog and identifier a multipart identifier represents + * + * @param nameParts Multipart identifier representing a table + * @return The CatalogPlugin and Identifier for the table + */ + public static Pair catalogAndIdentifier( + List nameParts, + Function catalogProvider, + BiFunction identiferProvider, + C currentCatalog, + String[] currentNamespace) { + Preconditions.checkArgument( + !nameParts.isEmpty(), "Cannot determine catalog and identifier from empty name"); + + int lastElementIndex = nameParts.size() - 1; + String name = nameParts.get(lastElementIndex); + + if (nameParts.size() == 1) { + // Only a single element, use current catalog and namespace + return Pair.of(currentCatalog, identiferProvider.apply(currentNamespace, name)); + } else { + C catalog = catalogProvider.apply(nameParts.get(0)); + if (catalog == null) { + // The first element was not a valid catalog, treat it like part of the namespace + String[] namespace = nameParts.subList(0, lastElementIndex).toArray(new String[0]); + return Pair.of(currentCatalog, identiferProvider.apply(namespace, name)); + } else { + // Assume the first element is a valid catalog + String[] namespace = nameParts.subList(1, lastElementIndex).toArray(new String[0]); + return Pair.of(catalog, identiferProvider.apply(namespace, name)); + } + } + } + + /** + * Pulls any Catalog specific overrides for the Hadoop conf from the current SparkSession, which + * can be set via `spark.sql.catalog.$catalogName.hadoop.*` + * + *

    Mirrors the override of hadoop configurations for a given spark session using + * `spark.hadoop.*`. + * + *

    The SparkCatalog allows for hadoop configurations to be overridden per catalog, by setting + * them on the SQLConf, where the following will add the property "fs.default.name" with value + * "hdfs://hanksnamenode:8020" to the catalog's hadoop configuration. SparkSession.builder() + * .config(s"spark.sql.catalog.$catalogName.hadoop.fs.default.name", "hdfs://hanksnamenode:8020") + * .getOrCreate() + * + * @param spark The current Spark session + * @param catalogName Name of the catalog to find overrides for. + * @return the Hadoop Configuration that should be used for this catalog, with catalog specific + * overrides applied. + */ + public static Configuration hadoopConfCatalogOverrides(SparkSession spark, String catalogName) { + // Find keys for the catalog intended to be hadoop configurations + final String hadoopConfCatalogPrefix = hadoopConfPrefixForCatalog(catalogName); + final Configuration conf = spark.sessionState().newHadoopConf(); + + spark + .sessionState() + .conf() + .settings() + .forEach( + (k, v) -> { + // these checks are copied from `spark.sessionState().newHadoopConfWithOptions()` + // to avoid converting back and forth between Scala / Java map types + if (v != null && k != null && k.startsWith(hadoopConfCatalogPrefix)) { + conf.set(k.substring(hadoopConfCatalogPrefix.length()), v); + } + }); + return conf; + } + + private static String hadoopConfPrefixForCatalog(String catalogName) { + return String.format(SPARK_CATALOG_HADOOP_CONF_OVERRIDE_FMT_STR, catalogName); + } + + /** + * Get a List of Spark filter Expression. + * + * @param schema table schema + * @param filters filters in the format of a Map, where key is one of the table column name, and + * value is the specific value to be filtered on the column. + * @return a List of filters in the format of Spark Expression. + */ + public static List partitionMapToExpression( + StructType schema, Map filters) { + List filterExpressions = Lists.newArrayList(); + for (Map.Entry entry : filters.entrySet()) { + try { + int index = schema.fieldIndex(entry.getKey()); + DataType dataType = schema.fields()[index].dataType(); + BoundReference ref = new BoundReference(index, dataType, true); + switch (dataType.typeName()) { + case "integer": + filterExpressions.add( + new EqualTo( + ref, + Literal.create(Integer.parseInt(entry.getValue()), DataTypes.IntegerType))); + break; + case "string": + filterExpressions.add( + new EqualTo(ref, Literal.create(entry.getValue(), DataTypes.StringType))); + break; + case "short": + filterExpressions.add( + new EqualTo( + ref, Literal.create(Short.parseShort(entry.getValue()), DataTypes.ShortType))); + break; + case "long": + filterExpressions.add( + new EqualTo( + ref, Literal.create(Long.parseLong(entry.getValue()), DataTypes.LongType))); + break; + case "float": + filterExpressions.add( + new EqualTo( + ref, Literal.create(Float.parseFloat(entry.getValue()), DataTypes.FloatType))); + break; + case "double": + filterExpressions.add( + new EqualTo( + ref, + Literal.create(Double.parseDouble(entry.getValue()), DataTypes.DoubleType))); + break; + case "date": + filterExpressions.add( + new EqualTo( + ref, + Literal.create( + new Date(DateTime.parse(entry.getValue()).getMillis()), + DataTypes.DateType))); + break; + case "timestamp": + filterExpressions.add( + new EqualTo( + ref, + Literal.create( + new Timestamp(DateTime.parse(entry.getValue()).getMillis()), + DataTypes.TimestampType))); + break; + default: + throw new IllegalStateException( + "Unexpected data type in partition filters: " + dataType); + } + } catch (IllegalArgumentException e) { + // ignore if filter is not on table columns + } + } + + return filterExpressions; + } + + public static String toColumnName(NamedReference ref) { + return DOT.join(ref.fieldNames()); + } + + public static boolean caseSensitive(SparkSession spark) { + return Boolean.parseBoolean(spark.conf().get("spark.sql.caseSensitive")); + } + + public static List executorLocations() { + BlockManager driverBlockManager = SparkEnv.get().blockManager(); + List executorBlockManagerIds = fetchPeers(driverBlockManager); + return executorBlockManagerIds.stream() + .map(SparkUtil::toExecutorLocation) + .sorted() + .collect(Collectors.toList()); + } + + private static List fetchPeers(BlockManager blockManager) { + BlockManagerMaster master = blockManager.master(); + BlockManagerId id = blockManager.blockManagerId(); + return toJavaList(master.getPeers(id)); + } + + private static List toJavaList(Seq seq) { + return JavaConverters.seqAsJavaListConverter(seq).asJava(); + } + + private static String toExecutorLocation(BlockManagerId id) { + return ExecutorCacheTaskLocation.apply(id.host(), id.executorId()).toString(); + } + + /** + * Converts a value to pass into Spark from Iceberg's internal object model. + * + * @param type an Iceberg type + * @param value a value that is an instance of {@link Type.TypeID#javaClass()} + * @return the value converted for Spark + */ + public static Object internalToSpark(Type type, Object value) { + if (value == null) { + return null; + } + + switch (type.typeId()) { + case DECIMAL: + return Decimal.apply((BigDecimal) value); + case UUID: + case STRING: + if (value instanceof Utf8) { + Utf8 utf8 = (Utf8) value; + return UTF8String.fromBytes(utf8.getBytes(), 0, utf8.getByteLength()); + } + return UTF8String.fromString(value.toString()); + case FIXED: + if (value instanceof byte[]) { + return value; + } else if (value instanceof GenericData.Fixed) { + return ((GenericData.Fixed) value).bytes(); + } + return ByteBuffers.toByteArray((ByteBuffer) value); + case BINARY: + return ByteBuffers.toByteArray((ByteBuffer) value); + case STRUCT: + Types.StructType structType = (Types.StructType) type; + + if (structType.fields().isEmpty()) { + return new GenericInternalRow(); + } + + List fields = structType.fields(); + Object[] values = new Object[fields.size()]; + StructLike struct = (StructLike) value; + + for (int index = 0; index < fields.size(); index++) { + Types.NestedField field = fields.get(index); + Type fieldType = field.type(); + values[index] = + internalToSpark(fieldType, struct.get(index, fieldType.typeId().javaClass())); + } + + return new GenericInternalRow(values); + default: + } + + return value; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkV2Filters.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkV2Filters.java new file mode 100644 index 000000000000..57b9d61e38bd --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkV2Filters.java @@ -0,0 +1,484 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.expressions.Expressions.and; +import static org.apache.iceberg.expressions.Expressions.bucket; +import static org.apache.iceberg.expressions.Expressions.day; +import static org.apache.iceberg.expressions.Expressions.equal; +import static org.apache.iceberg.expressions.Expressions.greaterThan; +import static org.apache.iceberg.expressions.Expressions.greaterThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.hour; +import static org.apache.iceberg.expressions.Expressions.in; +import static org.apache.iceberg.expressions.Expressions.isNaN; +import static org.apache.iceberg.expressions.Expressions.isNull; +import static org.apache.iceberg.expressions.Expressions.lessThan; +import static org.apache.iceberg.expressions.Expressions.lessThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.month; +import static org.apache.iceberg.expressions.Expressions.not; +import static org.apache.iceberg.expressions.Expressions.notEqual; +import static org.apache.iceberg.expressions.Expressions.notIn; +import static org.apache.iceberg.expressions.Expressions.notNaN; +import static org.apache.iceberg.expressions.Expressions.notNull; +import static org.apache.iceberg.expressions.Expressions.or; +import static org.apache.iceberg.expressions.Expressions.startsWith; +import static org.apache.iceberg.expressions.Expressions.truncate; +import static org.apache.iceberg.expressions.Expressions.year; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expression.Operation; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.UnboundPredicate; +import org.apache.iceberg.expressions.UnboundTerm; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.util.NaNUtil; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.connector.expressions.Literal; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.UserDefinedScalarFunc; +import org.apache.spark.sql.connector.expressions.filter.And; +import org.apache.spark.sql.connector.expressions.filter.Not; +import org.apache.spark.sql.connector.expressions.filter.Or; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; + +public class SparkV2Filters { + + public static final Set SUPPORTED_FUNCTIONS = + ImmutableSet.of("years", "months", "days", "hours", "bucket", "truncate"); + + private static final String TRUE = "ALWAYS_TRUE"; + private static final String FALSE = "ALWAYS_FALSE"; + private static final String EQ = "="; + private static final String EQ_NULL_SAFE = "<=>"; + private static final String NOT_EQ = "<>"; + private static final String GT = ">"; + private static final String GT_EQ = ">="; + private static final String LT = "<"; + private static final String LT_EQ = "<="; + private static final String IN = "IN"; + private static final String IS_NULL = "IS_NULL"; + private static final String NOT_NULL = "IS_NOT_NULL"; + private static final String AND = "AND"; + private static final String OR = "OR"; + private static final String NOT = "NOT"; + private static final String STARTS_WITH = "STARTS_WITH"; + + private static final Map FILTERS = + ImmutableMap.builder() + .put(TRUE, Operation.TRUE) + .put(FALSE, Operation.FALSE) + .put(EQ, Operation.EQ) + .put(EQ_NULL_SAFE, Operation.EQ) + .put(NOT_EQ, Operation.NOT_EQ) + .put(GT, Operation.GT) + .put(GT_EQ, Operation.GT_EQ) + .put(LT, Operation.LT) + .put(LT_EQ, Operation.LT_EQ) + .put(IN, Operation.IN) + .put(IS_NULL, Operation.IS_NULL) + .put(NOT_NULL, Operation.NOT_NULL) + .put(AND, Operation.AND) + .put(OR, Operation.OR) + .put(NOT, Operation.NOT) + .put(STARTS_WITH, Operation.STARTS_WITH) + .buildOrThrow(); + + private SparkV2Filters() {} + + public static Expression convert(Predicate[] predicates) { + Expression expression = Expressions.alwaysTrue(); + for (Predicate predicate : predicates) { + Expression converted = convert(predicate); + Preconditions.checkArgument( + converted != null, "Cannot convert Spark predicate to Iceberg expression: %s", predicate); + expression = Expressions.and(expression, converted); + } + + return expression; + } + + @SuppressWarnings({"checkstyle:CyclomaticComplexity", "checkstyle:MethodLength"}) + public static Expression convert(Predicate predicate) { + Operation op = FILTERS.get(predicate.name()); + if (op != null) { + switch (op) { + case TRUE: + return Expressions.alwaysTrue(); + + case FALSE: + return Expressions.alwaysFalse(); + + case IS_NULL: + if (canConvertToTerm(child(predicate))) { + UnboundTerm term = toTerm(child(predicate)); + return term != null ? isNull(term) : null; + } + + return null; + + case NOT_NULL: + if (canConvertToTerm(child(predicate))) { + UnboundTerm term = toTerm(child(predicate)); + return term != null ? notNull(term) : null; + } + + return null; + + case LT: + if (canConvertToTerm(leftChild(predicate)) && isLiteral(rightChild(predicate))) { + UnboundTerm term = toTerm(leftChild(predicate)); + return term != null ? lessThan(term, convertLiteral(rightChild(predicate))) : null; + } else if (canConvertToTerm(rightChild(predicate)) && isLiteral(leftChild(predicate))) { + UnboundTerm term = toTerm(rightChild(predicate)); + return term != null ? greaterThan(term, convertLiteral(leftChild(predicate))) : null; + } else { + return null; + } + + case LT_EQ: + if (canConvertToTerm(leftChild(predicate)) && isLiteral(rightChild(predicate))) { + UnboundTerm term = toTerm(leftChild(predicate)); + return term != null + ? lessThanOrEqual(term, convertLiteral(rightChild(predicate))) + : null; + } else if (canConvertToTerm(rightChild(predicate)) && isLiteral(leftChild(predicate))) { + UnboundTerm term = toTerm(rightChild(predicate)); + return term != null + ? greaterThanOrEqual(term, convertLiteral(leftChild(predicate))) + : null; + } else { + return null; + } + + case GT: + if (canConvertToTerm(leftChild(predicate)) && isLiteral(rightChild(predicate))) { + UnboundTerm term = toTerm(leftChild(predicate)); + return term != null ? greaterThan(term, convertLiteral(rightChild(predicate))) : null; + } else if (canConvertToTerm(rightChild(predicate)) && isLiteral(leftChild(predicate))) { + UnboundTerm term = toTerm(rightChild(predicate)); + return term != null ? lessThan(term, convertLiteral(leftChild(predicate))) : null; + } else { + return null; + } + + case GT_EQ: + if (canConvertToTerm(leftChild(predicate)) && isLiteral(rightChild(predicate))) { + UnboundTerm term = toTerm(leftChild(predicate)); + return term != null + ? greaterThanOrEqual(term, convertLiteral(rightChild(predicate))) + : null; + } else if (canConvertToTerm(rightChild(predicate)) && isLiteral(leftChild(predicate))) { + UnboundTerm term = toTerm(rightChild(predicate)); + return term != null + ? lessThanOrEqual(term, convertLiteral(leftChild(predicate))) + : null; + } else { + return null; + } + + case EQ: // used for both eq and null-safe-eq + Pair, Object> eqChildren = predicateChildren(predicate); + if (eqChildren == null) { + return null; + } + + if (predicate.name().equals(EQ)) { + // comparison with null in normal equality is always null. this is probably a mistake. + Preconditions.checkNotNull( + eqChildren.second(), + "Expression is always false (eq is not null-safe): %s", + predicate); + } + + return handleEqual(eqChildren.first(), eqChildren.second()); + + case NOT_EQ: + Pair, Object> notEqChildren = predicateChildren(predicate); + if (notEqChildren == null) { + return null; + } + + // comparison with null in normal equality is always null. this is probably a mistake. + Preconditions.checkNotNull( + notEqChildren.second(), + "Expression is always false (notEq is not null-safe): %s", + predicate); + + return handleNotEqual(notEqChildren.first(), notEqChildren.second()); + + case IN: + if (isSupportedInPredicate(predicate)) { + UnboundTerm term = toTerm(childAtIndex(predicate, 0)); + + return term != null + ? in( + term, + Arrays.stream(predicate.children()) + .skip(1) + .map(val -> convertLiteral(((Literal) val))) + .filter(Objects::nonNull) + .collect(Collectors.toList())) + : null; + } else { + return null; + } + + case NOT: + Not notPredicate = (Not) predicate; + Predicate childPredicate = notPredicate.child(); + if (childPredicate.name().equals(IN) && isSupportedInPredicate(childPredicate)) { + UnboundTerm term = toTerm(childAtIndex(childPredicate, 0)); + if (term == null) { + return null; + } + + // infer an extra notNull predicate for Spark NOT IN filters + // as Iceberg expressions don't follow the 3-value SQL boolean logic + // col NOT IN (1, 2) in Spark is equal to notNull(col) && notIn(col, 1, 2) in Iceberg + Expression notIn = + notIn( + term, + Arrays.stream(childPredicate.children()) + .skip(1) + .map(val -> convertLiteral(((Literal) val))) + .filter(Objects::nonNull) + .collect(Collectors.toList())); + return and(notNull(term), notIn); + } else if (hasNoInFilter(childPredicate)) { + Expression child = convert(childPredicate); + if (child != null) { + return not(child); + } + } + return null; + + case AND: + { + And andPredicate = (And) predicate; + Expression left = convert(andPredicate.left()); + Expression right = convert(andPredicate.right()); + if (left != null && right != null) { + return and(left, right); + } + return null; + } + + case OR: + { + Or orPredicate = (Or) predicate; + Expression left = convert(orPredicate.left()); + Expression right = convert(orPredicate.right()); + if (left != null && right != null) { + return or(left, right); + } + return null; + } + + case STARTS_WITH: + String colName = SparkUtil.toColumnName(leftChild(predicate)); + return startsWith(colName, convertLiteral(rightChild(predicate)).toString()); + } + } + + return null; + } + + private static Pair, Object> predicateChildren(Predicate predicate) { + if (canConvertToTerm(leftChild(predicate)) && isLiteral(rightChild(predicate))) { + UnboundTerm term = toTerm(leftChild(predicate)); + return term != null ? Pair.of(term, convertLiteral(rightChild(predicate))) : null; + + } else if (canConvertToTerm(rightChild(predicate)) && isLiteral(leftChild(predicate))) { + UnboundTerm term = toTerm(rightChild(predicate)); + return term != null ? Pair.of(term, convertLiteral(leftChild(predicate))) : null; + + } else { + return null; + } + } + + @SuppressWarnings("unchecked") + private static T child(Predicate predicate) { + org.apache.spark.sql.connector.expressions.Expression[] children = predicate.children(); + Preconditions.checkArgument( + children.length == 1, "Predicate should have one child: %s", predicate); + return (T) children[0]; + } + + @SuppressWarnings("unchecked") + private static T leftChild(Predicate predicate) { + org.apache.spark.sql.connector.expressions.Expression[] children = predicate.children(); + Preconditions.checkArgument( + children.length == 2, "Predicate should have two children: %s", predicate); + return (T) children[0]; + } + + @SuppressWarnings("unchecked") + private static T rightChild(Predicate predicate) { + org.apache.spark.sql.connector.expressions.Expression[] children = predicate.children(); + Preconditions.checkArgument( + children.length == 2, "Predicate should have two children: %s", predicate); + return (T) children[1]; + } + + @SuppressWarnings("unchecked") + private static T childAtIndex(Predicate predicate, int index) { + return (T) predicate.children()[index]; + } + + private static boolean canConvertToTerm( + org.apache.spark.sql.connector.expressions.Expression expr) { + return isRef(expr) || isSystemFunc(expr); + } + + private static boolean isRef(org.apache.spark.sql.connector.expressions.Expression expr) { + return expr instanceof NamedReference; + } + + private static boolean isSystemFunc(org.apache.spark.sql.connector.expressions.Expression expr) { + if (expr instanceof UserDefinedScalarFunc) { + UserDefinedScalarFunc udf = (UserDefinedScalarFunc) expr; + return udf.canonicalName().startsWith("iceberg") + && SUPPORTED_FUNCTIONS.contains(udf.name()) + && Arrays.stream(udf.children()).allMatch(child -> isLiteral(child) || isRef(child)); + } + + return false; + } + + private static boolean isLiteral(org.apache.spark.sql.connector.expressions.Expression expr) { + return expr instanceof Literal; + } + + private static Object convertLiteral(Literal literal) { + if (literal.value() instanceof UTF8String) { + return ((UTF8String) literal.value()).toString(); + } else if (literal.value() instanceof Decimal) { + return ((Decimal) literal.value()).toJavaBigDecimal(); + } + return literal.value(); + } + + private static UnboundPredicate handleEqual(UnboundTerm term, Object value) { + if (value == null) { + return isNull(term); + } else if (NaNUtil.isNaN(value)) { + return isNaN(term); + } else { + return equal(term, value); + } + } + + private static UnboundPredicate handleNotEqual(UnboundTerm term, Object value) { + if (NaNUtil.isNaN(value)) { + return notNaN(term); + } else { + return notEqual(term, value); + } + } + + private static boolean hasNoInFilter(Predicate predicate) { + Operation op = FILTERS.get(predicate.name()); + + if (op != null) { + switch (op) { + case AND: + And andPredicate = (And) predicate; + return hasNoInFilter(andPredicate.left()) && hasNoInFilter(andPredicate.right()); + case OR: + Or orPredicate = (Or) predicate; + return hasNoInFilter(orPredicate.left()) && hasNoInFilter(orPredicate.right()); + case NOT: + Not notPredicate = (Not) predicate; + return hasNoInFilter(notPredicate.child()); + case IN: + return false; + default: + return true; + } + } + + return false; + } + + private static boolean isSupportedInPredicate(Predicate predicate) { + if (!canConvertToTerm(childAtIndex(predicate, 0))) { + return false; + } else { + return Arrays.stream(predicate.children()).skip(1).allMatch(SparkV2Filters::isLiteral); + } + } + + /** Should be called after {@link #canConvertToTerm} passed */ + private static UnboundTerm toTerm(T input) { + if (input instanceof NamedReference) { + return Expressions.ref(SparkUtil.toColumnName((NamedReference) input)); + } else if (input instanceof UserDefinedScalarFunc) { + return udfToTerm((UserDefinedScalarFunc) input); + } else { + return null; + } + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + private static UnboundTerm udfToTerm(UserDefinedScalarFunc udf) { + org.apache.spark.sql.connector.expressions.Expression[] children = udf.children(); + String udfName = udf.name().toLowerCase(Locale.ROOT); + if (children.length == 1) { + org.apache.spark.sql.connector.expressions.Expression child = children[0]; + if (isRef(child)) { + String column = SparkUtil.toColumnName((NamedReference) child); + switch (udfName) { + case "years": + return year(column); + case "months": + return month(column); + case "days": + return day(column); + case "hours": + return hour(column); + } + } + } else if (children.length == 2) { + if (isLiteral(children[0]) && isRef(children[1])) { + String column = SparkUtil.toColumnName((NamedReference) children[1]); + switch (udfName) { + case "bucket": + int numBuckets = (Integer) convertLiteral((Literal) children[0]); + return bucket(column, numBuckets); + case "truncate": + int width = (Integer) convertLiteral((Literal) children[0]); + return truncate(column, width); + } + } + } + + return null; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkValueConverter.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkValueConverter.java new file mode 100644 index 000000000000..28b717ac090e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkValueConverter.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.util.DateTimeUtils; + +/** A utility class that converts Spark values to Iceberg's internal representation. */ +public class SparkValueConverter { + + private SparkValueConverter() {} + + public static Record convert(Schema schema, Row row) { + return convert(schema.asStruct(), row); + } + + public static Object convert(Type type, Object object) { + if (object == null) { + return null; + } + + switch (type.typeId()) { + case STRUCT: + return convert(type.asStructType(), (Row) object); + + case LIST: + List convertedList = Lists.newArrayList(); + List list = (List) object; + for (Object element : list) { + convertedList.add(convert(type.asListType().elementType(), element)); + } + return convertedList; + + case MAP: + Map convertedMap = Maps.newLinkedHashMap(); + Map map = (Map) object; + for (Map.Entry entry : map.entrySet()) { + convertedMap.put( + convert(type.asMapType().keyType(), entry.getKey()), + convert(type.asMapType().valueType(), entry.getValue())); + } + return convertedMap; + + case DATE: + // if spark.sql.datetime.java8API.enabled is set to true, java.time.LocalDate + // for Spark SQL DATE type otherwise java.sql.Date is returned. + return DateTimeUtils.anyToDays(object); + case TIMESTAMP: + return DateTimeUtils.anyToMicros(object); + case BINARY: + return ByteBuffer.wrap((byte[]) object); + case INTEGER: + return ((Number) object).intValue(); + case BOOLEAN: + case LONG: + case FLOAT: + case DOUBLE: + case DECIMAL: + case STRING: + case FIXED: + return object; + default: + throw new UnsupportedOperationException("Not a supported type: " + type); + } + } + + private static Record convert(Types.StructType struct, Row row) { + if (row == null) { + return null; + } + + Record record = GenericRecord.create(struct); + List fields = struct.fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + + Type fieldType = field.type(); + + switch (fieldType.typeId()) { + case STRUCT: + record.set(i, convert(fieldType.asStructType(), row.getStruct(i))); + break; + case LIST: + record.set(i, convert(fieldType.asListType(), row.getList(i))); + break; + case MAP: + record.set(i, convert(fieldType.asMapType(), row.getJavaMap(i))); + break; + default: + record.set(i, convert(fieldType, row.get(i))); + } + } + return record; + } + + public static Object convertToSpark(Type type, Object object) { + if (object == null) { + return null; + } + + switch (type.typeId()) { + case STRUCT: + case LIST: + case MAP: + return new UnsupportedOperationException("Complex types currently not supported"); + case DATE: + return DateTimeUtils.daysToLocalDate((int) object); + case TIMESTAMP: + Types.TimestampType ts = (Types.TimestampType) type.asPrimitiveType(); + if (ts.shouldAdjustToUTC()) { + return DateTimeUtils.microsToInstant((long) object); + } else { + return DateTimeUtils.microsToLocalDateTime((long) object); + } + case BINARY: + return ByteBuffers.toByteArray((ByteBuffer) object); + case INTEGER: + case BOOLEAN: + case LONG: + case FLOAT: + case DOUBLE: + case DECIMAL: + case STRING: + case FIXED: + return object; + default: + throw new UnsupportedOperationException("Not a supported type: " + type); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java new file mode 100644 index 000000000000..80f93427805a --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteConf.java @@ -0,0 +1,757 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.DistributionMode.HASH; +import static org.apache.iceberg.DistributionMode.NONE; +import static org.apache.iceberg.DistributionMode.RANGE; +import static org.apache.iceberg.TableProperties.AVRO_COMPRESSION; +import static org.apache.iceberg.TableProperties.AVRO_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.DELETE_AVRO_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_AVRO_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.DELETE_ORC_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_ORC_COMPRESSION_STRATEGY; +import static org.apache.iceberg.TableProperties.DELETE_PARQUET_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_PARQUET_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.ORC_COMPRESSION; +import static org.apache.iceberg.TableProperties.ORC_COMPRESSION_STRATEGY; +import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION; +import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.PARQUET_SHRED_VARIANTS; +import static org.apache.iceberg.TableProperties.PARQUET_VARIANT_BUFFER_SIZE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE; + +import java.util.Locale; +import java.util.Map; +import org.apache.iceberg.BaseMetadataTable; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.spark.sql.RuntimeConfig; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.write.RowLevelOperation.Command; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.collection.JavaConverters; + +/** + * A class for common Iceberg configs for Spark writes. + * + *

    If a config is set at multiple levels, the following order of precedence is used (top to + * bottom): + * + *

      + *
    1. Write options + *
    2. Session configuration + *
    3. Table metadata + *
    + * + * The most specific value is set in write options and takes precedence over all other configs. If + * no write option is provided, this class checks the session configuration for any overrides. If no + * applicable value is found in the session configuration, this class uses the table metadata. + * + *

    Note this class is NOT meant to be serialized and sent to executors. + */ +public class SparkWriteConf { + + private static final Logger LOG = LoggerFactory.getLogger(SparkWriteConf.class); + + private static final long DATA_FILE_SIZE = 128 * 1024 * 1024; // 128 MB + private static final long DELETE_FILE_SIZE = 32 * 1024 * 1024; // 32 MB + + private final SparkSession spark; + private final Table table; + private final RuntimeConfig sessionConf; + private final CaseInsensitiveStringMap options; + private final SparkConfParser confParser; + + public SparkWriteConf(SparkSession spark, Table table) { + this(spark, table, null, CaseInsensitiveStringMap.empty()); + } + + public SparkWriteConf(SparkSession spark, Table table, CaseInsensitiveStringMap options) { + this(spark, table, null, options); + } + + /** + * @deprecated since 1.11.0, will be removed in 1.12.0. Use {@link #SparkWriteConf(SparkSession, + * Table, CaseInsensitiveStringMap)} instead. + */ + @Deprecated + public SparkWriteConf( + SparkSession spark, Table table, String branch, CaseInsensitiveStringMap options) { + this.spark = spark; + this.table = table; + this.sessionConf = spark.conf(); + this.options = options; + this.confParser = new SparkConfParser(spark, table, options); + } + + public boolean checkNullability() { + return confParser + .booleanConf() + .option(SparkWriteOptions.CHECK_NULLABILITY) + .sessionConf(SparkSQLProperties.CHECK_NULLABILITY) + .defaultValue(SparkSQLProperties.CHECK_NULLABILITY_DEFAULT) + .parse(); + } + + public boolean checkOrdering() { + return confParser + .booleanConf() + .option(SparkWriteOptions.CHECK_ORDERING) + .sessionConf(SparkSQLProperties.CHECK_ORDERING) + .defaultValue(SparkSQLProperties.CHECK_ORDERING_DEFAULT) + .parse(); + } + + public String overwriteMode() { + String overwriteMode = options.get(SparkWriteOptions.OVERWRITE_MODE); + return overwriteMode != null ? overwriteMode.toLowerCase(Locale.ROOT) : null; + } + + public boolean wapEnabled() { + return confParser + .booleanConf() + .tableProperty(TableProperties.WRITE_AUDIT_PUBLISH_ENABLED) + .defaultValue(TableProperties.WRITE_AUDIT_PUBLISH_ENABLED_DEFAULT) + .parse(); + } + + public String wapId() { + return sessionConf.get(SparkSQLProperties.WAP_ID, null); + } + + public boolean mergeSchema() { + return confParser + .booleanConf() + .option(SparkWriteOptions.MERGE_SCHEMA) + .option(SparkWriteOptions.SPARK_MERGE_SCHEMA) + .sessionConf(SparkSQLProperties.MERGE_SCHEMA) + .defaultValue(SparkSQLProperties.MERGE_SCHEMA_DEFAULT) + .parse(); + } + + public int outputSpecId() { + int outputSpecId = + confParser + .intConf() + .option(SparkWriteOptions.OUTPUT_SPEC_ID) + .defaultValue(table.spec().specId()) + .parse(); + Preconditions.checkArgument( + table.specs().containsKey(outputSpecId), + "Output spec id %s is not a valid spec id for table", + outputSpecId); + return outputSpecId; + } + + public int outputSortOrderId(SparkWriteRequirements writeRequirements) { + Integer explicitId = + confParser.intConf().option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID).parseOptional(); + + if (explicitId != null) { + Preconditions.checkArgument( + table.sortOrders().containsKey(explicitId), + "Cannot use output sort order id %s because the table does not contain a sort order with that id", + explicitId); + return explicitId; + } + + if (writeRequirements.hasOrdering()) { + return table.sortOrder().orderId(); + } + + return SortOrder.unsorted().orderId(); + } + + public FileFormat dataFileFormat() { + String valueAsString = + confParser + .stringConf() + .option(SparkWriteOptions.WRITE_FORMAT) + .tableProperty(TableProperties.DEFAULT_FILE_FORMAT) + .defaultValue(TableProperties.DEFAULT_FILE_FORMAT_DEFAULT) + .parse(); + return FileFormat.fromString(valueAsString); + } + + private String dataCompressionCodec() { + switch (dataFileFormat()) { + case PARQUET: + return parquetCompressionCodec(); + case AVRO: + return avroCompressionCodec(); + case ORC: + return orcCompressionCodec(); + default: + return null; + } + } + + public long targetDataFileSize() { + return confParser + .longConf() + .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES) + .tableProperty(TableProperties.WRITE_TARGET_FILE_SIZE_BYTES) + .defaultValue(TableProperties.WRITE_TARGET_FILE_SIZE_BYTES_DEFAULT) + .parse(); + } + + public boolean useFanoutWriter(SparkWriteRequirements writeRequirements) { + boolean defaultValue = !writeRequirements.hasOrdering(); + return fanoutWriterEnabled(defaultValue); + } + + private boolean fanoutWriterEnabled() { + return fanoutWriterEnabled(true /* enabled by default */); + } + + private boolean fanoutWriterEnabled(boolean defaultValue) { + return confParser + .booleanConf() + .option(SparkWriteOptions.FANOUT_ENABLED) + .tableProperty(TableProperties.SPARK_WRITE_PARTITIONED_FANOUT_ENABLED) + .defaultValue(defaultValue) + .parse(); + } + + public FileFormat deleteFileFormat() { + if (!(table instanceof BaseMetadataTable) && TableUtil.formatVersion(table) >= 3) { + return FileFormat.PUFFIN; + } + + String valueAsString = + confParser + .stringConf() + .option(SparkWriteOptions.DELETE_FORMAT) + .tableProperty(TableProperties.DELETE_DEFAULT_FILE_FORMAT) + .parseOptional(); + return valueAsString != null ? FileFormat.fromString(valueAsString) : dataFileFormat(); + } + + private String deleteCompressionCodec() { + switch (deleteFileFormat()) { + case PARQUET: + return deleteParquetCompressionCodec(); + case AVRO: + return deleteAvroCompressionCodec(); + case ORC: + return deleteOrcCompressionCodec(); + default: + return null; + } + } + + public long targetDeleteFileSize() { + return confParser + .longConf() + .option(SparkWriteOptions.TARGET_DELETE_FILE_SIZE_BYTES) + .tableProperty(TableProperties.DELETE_TARGET_FILE_SIZE_BYTES) + .defaultValue(TableProperties.DELETE_TARGET_FILE_SIZE_BYTES_DEFAULT) + .parse(); + } + + public Map extraSnapshotMetadata() { + Map extraSnapshotMetadata = Maps.newHashMap(); + + // Add session configuration properties with SNAPSHOT_PROPERTY_PREFIX if necessary + extraSnapshotMetadata.putAll( + PropertyUtil.propertiesWithPrefix( + JavaConverters.mapAsJavaMap(sessionConf.getAll()), + SparkSQLProperties.SNAPSHOT_PROPERTY_PREFIX)); + + // Add write options, overriding session configuration if necessary + extraSnapshotMetadata.putAll( + PropertyUtil.propertiesWithPrefix(options, SnapshotSummary.EXTRA_METADATA_PREFIX)); + + return extraSnapshotMetadata; + } + + public SparkWriteRequirements writeRequirements() { + if (ignoreTableDistributionAndOrdering()) { + LOG.info("Skipping distribution/ordering: disabled per job configuration"); + return SparkWriteRequirements.EMPTY; + } + + return SparkWriteUtil.writeRequirements( + table, distributionMode(), fanoutWriterEnabled(), dataAdvisoryPartitionSize()); + } + + @VisibleForTesting + DistributionMode distributionMode() { + String modeName = + confParser + .stringConf() + .option(SparkWriteOptions.DISTRIBUTION_MODE) + .sessionConf(SparkSQLProperties.DISTRIBUTION_MODE) + .tableProperty(TableProperties.WRITE_DISTRIBUTION_MODE) + .parseOptional(); + + if (modeName != null) { + DistributionMode mode = DistributionMode.fromName(modeName); + return adjustWriteDistributionMode(mode); + } else { + return defaultWriteDistributionMode(); + } + } + + private DistributionMode adjustWriteDistributionMode(DistributionMode mode) { + if (mode == RANGE && table.spec().isUnpartitioned() && table.sortOrder().isUnsorted()) { + return NONE; + } else if (mode == HASH && table.spec().isUnpartitioned()) { + return NONE; + } else { + return mode; + } + } + + private DistributionMode defaultWriteDistributionMode() { + if (table.sortOrder().isSorted()) { + return RANGE; + } else if (table.spec().isPartitioned()) { + return HASH; + } else { + return NONE; + } + } + + public SparkWriteRequirements copyOnWriteRequirements(Command command) { + if (ignoreTableDistributionAndOrdering()) { + LOG.info("Skipping distribution/ordering: disabled per job configuration"); + return SparkWriteRequirements.EMPTY; + } + + return SparkWriteUtil.copyOnWriteRequirements( + table, + command, + copyOnWriteDistributionMode(command), + fanoutWriterEnabled(), + dataAdvisoryPartitionSize()); + } + + @VisibleForTesting + DistributionMode copyOnWriteDistributionMode(Command command) { + switch (command) { + case DELETE: + return deleteDistributionMode(); + case UPDATE: + return updateDistributionMode(); + case MERGE: + return copyOnWriteMergeDistributionMode(); + default: + throw new IllegalArgumentException("Unexpected command: " + command); + } + } + + public SparkWriteRequirements positionDeltaRequirements(Command command) { + if (ignoreTableDistributionAndOrdering()) { + LOG.info("Skipping distribution/ordering: disabled per job configuration"); + return SparkWriteRequirements.EMPTY; + } + + return SparkWriteUtil.positionDeltaRequirements( + table, + command, + positionDeltaDistributionMode(command), + fanoutWriterEnabled(), + command == DELETE ? deleteAdvisoryPartitionSize() : dataAdvisoryPartitionSize()); + } + + @VisibleForTesting + DistributionMode positionDeltaDistributionMode(Command command) { + switch (command) { + case DELETE: + return deleteDistributionMode(); + case UPDATE: + return updateDistributionMode(); + case MERGE: + return positionDeltaMergeDistributionMode(); + default: + throw new IllegalArgumentException("Unexpected command: " + command); + } + } + + private DistributionMode deleteDistributionMode() { + String deleteModeName = + confParser + .stringConf() + .option(SparkWriteOptions.DISTRIBUTION_MODE) + .sessionConf(SparkSQLProperties.DISTRIBUTION_MODE) + .tableProperty(TableProperties.DELETE_DISTRIBUTION_MODE) + .defaultValue(TableProperties.WRITE_DISTRIBUTION_MODE_HASH) + .parse(); + return DistributionMode.fromName(deleteModeName); + } + + private DistributionMode updateDistributionMode() { + String updateModeName = + confParser + .stringConf() + .option(SparkWriteOptions.DISTRIBUTION_MODE) + .sessionConf(SparkSQLProperties.DISTRIBUTION_MODE) + .tableProperty(TableProperties.UPDATE_DISTRIBUTION_MODE) + .defaultValue(TableProperties.WRITE_DISTRIBUTION_MODE_HASH) + .parse(); + return DistributionMode.fromName(updateModeName); + } + + private DistributionMode copyOnWriteMergeDistributionMode() { + String mergeModeName = + confParser + .stringConf() + .option(SparkWriteOptions.DISTRIBUTION_MODE) + .sessionConf(SparkSQLProperties.DISTRIBUTION_MODE) + .tableProperty(TableProperties.MERGE_DISTRIBUTION_MODE) + .parseOptional(); + + if (mergeModeName != null) { + DistributionMode mergeMode = DistributionMode.fromName(mergeModeName); + return adjustWriteDistributionMode(mergeMode); + + } else if (table.spec().isPartitioned()) { + return HASH; + + } else { + return distributionMode(); + } + } + + private DistributionMode positionDeltaMergeDistributionMode() { + String mergeModeName = + confParser + .stringConf() + .option(SparkWriteOptions.DISTRIBUTION_MODE) + .sessionConf(SparkSQLProperties.DISTRIBUTION_MODE) + .tableProperty(TableProperties.MERGE_DISTRIBUTION_MODE) + .defaultValue(TableProperties.WRITE_DISTRIBUTION_MODE_HASH) + .parse(); + return DistributionMode.fromName(mergeModeName); + } + + private boolean ignoreTableDistributionAndOrdering() { + return confParser + .booleanConf() + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING) + .defaultValue(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING_DEFAULT) + .negate() + .parse(); + } + + public Long validateFromSnapshotId() { + return confParser + .longConf() + .option(SparkWriteOptions.VALIDATE_FROM_SNAPSHOT_ID) + .parseOptional(); + } + + public IsolationLevel isolationLevel() { + return confParser + .enumConf(IsolationLevel::fromName) + .option(SparkWriteOptions.ISOLATION_LEVEL) + .parseOptional(); + } + + public boolean caseSensitive() { + return confParser + .booleanConf() + .sessionConf(SQLConf.CASE_SENSITIVE().key()) + .defaultValue(SQLConf.CASE_SENSITIVE().defaultValueString()) + .parse(); + } + + public Map writeProperties() { + Map writeProperties = Maps.newHashMap(); + writeProperties.putAll(dataWriteProperties()); + writeProperties.putAll(deleteWriteProperties()); + return writeProperties; + } + + private Map dataWriteProperties() { + Map writeProperties = Maps.newHashMap(); + FileFormat dataFormat = dataFileFormat(); + + switch (dataFormat) { + case PARQUET: + writeProperties.put(PARQUET_COMPRESSION, parquetCompressionCodec()); + String parquetCompressionLevel = parquetCompressionLevel(); + if (parquetCompressionLevel != null) { + writeProperties.put(PARQUET_COMPRESSION_LEVEL, parquetCompressionLevel); + } + boolean shouldShredVariants = shredVariants(); + writeProperties.put(PARQUET_SHRED_VARIANTS, String.valueOf(shouldShredVariants)); + + // Add variant shredding configuration properties + if (shouldShredVariants) { + writeProperties.put( + PARQUET_VARIANT_BUFFER_SIZE, String.valueOf(variantInferenceBufferSize())); + } + break; + + case AVRO: + writeProperties.put(AVRO_COMPRESSION, avroCompressionCodec()); + String avroCompressionLevel = avroCompressionLevel(); + if (avroCompressionLevel != null) { + writeProperties.put(AVRO_COMPRESSION_LEVEL, avroCompressionLevel); + } + break; + + case ORC: + writeProperties.put(ORC_COMPRESSION, orcCompressionCodec()); + writeProperties.put(ORC_COMPRESSION_STRATEGY, orcCompressionStrategy()); + break; + + default: + // skip + } + + return writeProperties; + } + + private Map deleteWriteProperties() { + Map writeProperties = Maps.newHashMap(); + FileFormat deleteFormat = deleteFileFormat(); + + switch (deleteFormat) { + case PARQUET: + writeProperties.put(DELETE_PARQUET_COMPRESSION, deleteParquetCompressionCodec()); + String deleteParquetCompressionLevel = deleteParquetCompressionLevel(); + if (deleteParquetCompressionLevel != null) { + writeProperties.put(DELETE_PARQUET_COMPRESSION_LEVEL, deleteParquetCompressionLevel); + } + break; + + case AVRO: + writeProperties.put(DELETE_AVRO_COMPRESSION, deleteAvroCompressionCodec()); + String deleteAvroCompressionLevel = deleteAvroCompressionLevel(); + if (deleteAvroCompressionLevel != null) { + writeProperties.put(DELETE_AVRO_COMPRESSION_LEVEL, deleteAvroCompressionLevel); + } + break; + + case ORC: + writeProperties.put(DELETE_ORC_COMPRESSION, deleteOrcCompressionCodec()); + writeProperties.put(DELETE_ORC_COMPRESSION_STRATEGY, deleteOrcCompressionStrategy()); + break; + + default: + // skip + } + + return writeProperties; + } + + private String parquetCompressionCodec() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_CODEC) + .sessionConf(SparkSQLProperties.COMPRESSION_CODEC) + .tableProperty(TableProperties.PARQUET_COMPRESSION) + .defaultValue(TableProperties.PARQUET_COMPRESSION_DEFAULT) + .parse(); + } + + private String deleteParquetCompressionCodec() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_CODEC) + .sessionConf(SparkSQLProperties.COMPRESSION_CODEC) + .tableProperty(DELETE_PARQUET_COMPRESSION) + .defaultValue(parquetCompressionCodec()) + .parse(); + } + + private String parquetCompressionLevel() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_LEVEL) + .sessionConf(SparkSQLProperties.COMPRESSION_LEVEL) + .tableProperty(TableProperties.PARQUET_COMPRESSION_LEVEL) + .defaultValue(TableProperties.PARQUET_COMPRESSION_LEVEL_DEFAULT) + .parseOptional(); + } + + private String deleteParquetCompressionLevel() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_LEVEL) + .sessionConf(SparkSQLProperties.COMPRESSION_LEVEL) + .tableProperty(DELETE_PARQUET_COMPRESSION_LEVEL) + .defaultValue(parquetCompressionLevel()) + .parseOptional(); + } + + private String avroCompressionCodec() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_CODEC) + .sessionConf(SparkSQLProperties.COMPRESSION_CODEC) + .tableProperty(TableProperties.AVRO_COMPRESSION) + .defaultValue(TableProperties.AVRO_COMPRESSION_DEFAULT) + .parse(); + } + + private String deleteAvroCompressionCodec() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_CODEC) + .sessionConf(SparkSQLProperties.COMPRESSION_CODEC) + .tableProperty(DELETE_AVRO_COMPRESSION) + .defaultValue(avroCompressionCodec()) + .parse(); + } + + private String avroCompressionLevel() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_LEVEL) + .sessionConf(SparkSQLProperties.COMPRESSION_LEVEL) + .tableProperty(TableProperties.AVRO_COMPRESSION_LEVEL) + .defaultValue(TableProperties.AVRO_COMPRESSION_LEVEL_DEFAULT) + .parseOptional(); + } + + private String deleteAvroCompressionLevel() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_LEVEL) + .sessionConf(SparkSQLProperties.COMPRESSION_LEVEL) + .tableProperty(DELETE_AVRO_COMPRESSION_LEVEL) + .defaultValue(avroCompressionLevel()) + .parseOptional(); + } + + private String orcCompressionCodec() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_CODEC) + .sessionConf(SparkSQLProperties.COMPRESSION_CODEC) + .tableProperty(TableProperties.ORC_COMPRESSION) + .defaultValue(TableProperties.ORC_COMPRESSION_DEFAULT) + .parse(); + } + + private String deleteOrcCompressionCodec() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_CODEC) + .sessionConf(SparkSQLProperties.COMPRESSION_CODEC) + .tableProperty(DELETE_ORC_COMPRESSION) + .defaultValue(orcCompressionCodec()) + .parse(); + } + + private String orcCompressionStrategy() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_STRATEGY) + .sessionConf(SparkSQLProperties.COMPRESSION_STRATEGY) + .tableProperty(TableProperties.ORC_COMPRESSION_STRATEGY) + .defaultValue(TableProperties.ORC_COMPRESSION_STRATEGY_DEFAULT) + .parse(); + } + + private String deleteOrcCompressionStrategy() { + return confParser + .stringConf() + .option(SparkWriteOptions.COMPRESSION_STRATEGY) + .sessionConf(SparkSQLProperties.COMPRESSION_STRATEGY) + .tableProperty(DELETE_ORC_COMPRESSION_STRATEGY) + .defaultValue(orcCompressionStrategy()) + .parse(); + } + + private long dataAdvisoryPartitionSize() { + long defaultValue = + advisoryPartitionSize(DATA_FILE_SIZE, dataFileFormat(), dataCompressionCodec()); + return advisoryPartitionSize(defaultValue); + } + + private long deleteAdvisoryPartitionSize() { + long defaultValue = + advisoryPartitionSize(DELETE_FILE_SIZE, deleteFileFormat(), deleteCompressionCodec()); + return advisoryPartitionSize(defaultValue); + } + + private long advisoryPartitionSize(long defaultValue) { + return confParser + .longConf() + .option(SparkWriteOptions.ADVISORY_PARTITION_SIZE) + .sessionConf(SparkSQLProperties.ADVISORY_PARTITION_SIZE) + .tableProperty(TableProperties.SPARK_WRITE_ADVISORY_PARTITION_SIZE_BYTES) + .defaultValue(defaultValue) + .parse(); + } + + private long advisoryPartitionSize( + long expectedFileSize, FileFormat outputFileFormat, String outputCodec) { + double shuffleCompressionRatio = shuffleCompressionRatio(outputFileFormat, outputCodec); + long suggestedAdvisoryPartitionSize = (long) (expectedFileSize * shuffleCompressionRatio); + return Math.max(suggestedAdvisoryPartitionSize, sparkAdvisoryPartitionSize()); + } + + private long sparkAdvisoryPartitionSize() { + return (long) spark.sessionState().conf().getConf(SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES()); + } + + private double shuffleCompressionRatio(FileFormat outputFileFormat, String outputCodec) { + return SparkCompressionUtil.shuffleCompressionRatio(spark, outputFileFormat, outputCodec); + } + + public DeleteGranularity deleteGranularity() { + return confParser + .enumConf(DeleteGranularity::fromString) + .option(SparkWriteOptions.DELETE_GRANULARITY) + .tableProperty(TableProperties.DELETE_GRANULARITY) + .defaultValue(DeleteGranularity.FILE) + .parse(); + } + + public boolean shredVariants() { + return confParser + .booleanConf() + .option(SparkWriteOptions.SHRED_VARIANTS) + .sessionConf(SparkSQLProperties.SHRED_VARIANTS) + .tableProperty(TableProperties.PARQUET_SHRED_VARIANTS) + .defaultValue(TableProperties.PARQUET_SHRED_VARIANTS_DEFAULT) + .parse(); + } + + public int variantInferenceBufferSize() { + return confParser + .intConf() + .option(SparkWriteOptions.VARIANT_INFERENCE_BUFFER_SIZE) + .sessionConf(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE) + .tableProperty(TableProperties.PARQUET_VARIANT_BUFFER_SIZE) + .defaultValue(TableProperties.PARQUET_VARIANT_BUFFER_SIZE_DEFAULT) + .parse(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java new file mode 100644 index 000000000000..621db891d46c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteOptions.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +/** Spark DF write options */ +public class SparkWriteOptions { + + private SparkWriteOptions() {} + + // Overrides the target branch for write operations + public static final String BRANCH = "branch"; + + // Fileformat for write operations(default: Table write.format.default ) + public static final String WRITE_FORMAT = "write-format"; + + // Overrides this table's write.target-file-size-bytes + public static final String TARGET_FILE_SIZE_BYTES = "target-file-size-bytes"; + + // Overrides the default file format for delete files + public static final String DELETE_FORMAT = "delete-format"; + + // Overrides the default size for delete files + public static final String TARGET_DELETE_FILE_SIZE_BYTES = "target-delete-file-size-bytes"; + + // Sets the nullable check on fields(default: true) + public static final String CHECK_NULLABILITY = "check-nullability"; + + // Adds an entry with custom-key and corresponding value in the snapshot summary + // ex: df.write().format(iceberg) + // .option(SparkWriteOptions.SNAPSHOT_PROPERTY_PREFIX."key1", "value1") + // .save(location) + public static final String SNAPSHOT_PROPERTY_PREFIX = "snapshot-property"; + + // Overrides table property write.spark.fanout.enabled(default: false) + public static final String FANOUT_ENABLED = "fanout-enabled"; + + // Checks if input schema and table schema are same(default: true) + public static final String CHECK_ORDERING = "check-ordering"; + + public static final String OUTPUT_SPEC_ID = "output-spec-id"; + public static final String OUTPUT_SORT_ORDER_ID = "output-sort-order-id"; + + public static final String OVERWRITE_MODE = "overwrite-mode"; + + // Overrides the default distribution mode for a write operation + public static final String DISTRIBUTION_MODE = "distribution-mode"; + + // Controls whether to take into account the table distribution and sort order during a write + // operation + public static final String USE_TABLE_DISTRIBUTION_AND_ORDERING = + "use-table-distribution-and-ordering"; + public static final boolean USE_TABLE_DISTRIBUTION_AND_ORDERING_DEFAULT = true; + + public static final String MERGE_SCHEMA = "merge-schema"; + public static final String SPARK_MERGE_SCHEMA = "mergeSchema"; + + // Identifies snapshot from which to start validating conflicting changes + public static final String VALIDATE_FROM_SNAPSHOT_ID = "validate-from-snapshot-id"; + + // Isolation Level for DataFrame calls. Currently supported by overwritePartitions + public static final String ISOLATION_LEVEL = "isolation-level"; + + // Controls write compress options + public static final String COMPRESSION_CODEC = "compression-codec"; + public static final String COMPRESSION_LEVEL = "compression-level"; + public static final String COMPRESSION_STRATEGY = "compression-strategy"; + + // Overrides the advisory partition size + public static final String ADVISORY_PARTITION_SIZE = "advisory-partition-size"; + + // Overrides the delete granularity + public static final String DELETE_GRANULARITY = "delete-granularity"; + + // Controls whether to shred variant columns during write operations + public static final String SHRED_VARIANTS = "shred-variants"; + + // Controls the buffer size for variant schema inference during writes + public static final String VARIANT_INFERENCE_BUFFER_SIZE = "variant-inference-buffer-size"; +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java new file mode 100644 index 000000000000..072727ac4bb0 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteRequirements.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.spark.sql.connector.distributions.Distribution; +import org.apache.spark.sql.connector.distributions.Distributions; +import org.apache.spark.sql.connector.distributions.UnspecifiedDistribution; +import org.apache.spark.sql.connector.expressions.SortOrder; + +/** A set of requirements such as distribution and ordering reported to Spark during writes. */ +public class SparkWriteRequirements { + + private static final long NO_ADVISORY_PARTITION_SIZE = 0; + public static final SparkWriteRequirements EMPTY = + new SparkWriteRequirements( + Distributions.unspecified(), new SortOrder[0], NO_ADVISORY_PARTITION_SIZE); + + private final Distribution distribution; + private final SortOrder[] ordering; + private final long advisoryPartitionSize; + + SparkWriteRequirements( + Distribution distribution, SortOrder[] ordering, long advisoryPartitionSize) { + this.distribution = distribution; + this.ordering = ordering; + // Spark prohibits requesting a particular advisory partition size without distribution + this.advisoryPartitionSize = + distribution instanceof UnspecifiedDistribution + ? NO_ADVISORY_PARTITION_SIZE + : advisoryPartitionSize; + } + + public Distribution distribution() { + return distribution; + } + + public SortOrder[] ordering() { + return ordering; + } + + public boolean hasOrdering() { + return ordering.length != 0; + } + + public long advisoryPartitionSize() { + return advisoryPartitionSize; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteUtil.java new file mode 100644 index 000000000000..86902c15e139 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SparkWriteUtil.java @@ -0,0 +1,368 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; + +import java.util.Arrays; +import java.util.List; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Table; +import org.apache.iceberg.metrics.CommitMetricsResult; +import org.apache.iceberg.metrics.CommitReport; +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.ObjectArrays; +import org.apache.iceberg.spark.source.metrics.AddedDataFiles; +import org.apache.iceberg.spark.source.metrics.AddedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.AddedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.AddedRecords; +import org.apache.iceberg.spark.source.metrics.RemovedDataFiles; +import org.apache.iceberg.spark.source.metrics.RemovedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedRecords; +import org.apache.iceberg.spark.source.metrics.TotalDataFiles; +import org.apache.iceberg.spark.source.metrics.TotalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TotalEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.TotalFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.TotalPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.TotalRecords; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.SortOrderUtil; +import org.apache.spark.sql.connector.distributions.Distribution; +import org.apache.spark.sql.connector.distributions.Distributions; +import org.apache.spark.sql.connector.expressions.Expression; +import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.SortDirection; +import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; +import org.apache.spark.sql.connector.write.RowLevelOperation.Command; + +/** + * A utility that contains helper methods for working with Spark writes. + * + *

    Note it is an evolving internal API that is subject to change even in minor releases. + */ +public class SparkWriteUtil { + + private static final NamedReference SPEC_ID = ref(MetadataColumns.SPEC_ID); + private static final NamedReference PARTITION = ref(MetadataColumns.PARTITION_COLUMN_NAME); + private static final NamedReference FILE_PATH = ref(MetadataColumns.FILE_PATH); + private static final NamedReference ROW_POSITION = ref(MetadataColumns.ROW_POSITION); + + private static final Expression[] FILE_CLUSTERING = clusterBy(FILE_PATH); + private static final Expression[] PARTITION_CLUSTERING = clusterBy(SPEC_ID, PARTITION); + private static final Expression[] PARTITION_FILE_CLUSTERING = + clusterBy(SPEC_ID, PARTITION, FILE_PATH); + + private static final SortOrder[] EMPTY_ORDERING = new SortOrder[0]; + private static final SortOrder[] EXISTING_ROW_ORDERING = orderBy(FILE_PATH, ROW_POSITION); + private static final SortOrder[] PARTITION_ORDERING = orderBy(SPEC_ID, PARTITION); + private static final SortOrder[] PARTITION_FILE_ORDERING = orderBy(SPEC_ID, PARTITION, FILE_PATH); + private static final SortOrder[] POSITION_DELETE_ORDERING = + orderBy(SPEC_ID, PARTITION, FILE_PATH, ROW_POSITION); + + private SparkWriteUtil() {} + + /** Builds requirements for batch and micro-batch writes such as append or overwrite. */ + public static SparkWriteRequirements writeRequirements( + Table table, DistributionMode mode, boolean fanoutEnabled, long advisoryPartitionSize) { + + Distribution distribution = writeDistribution(table, mode); + SortOrder[] ordering = writeOrdering(table, fanoutEnabled); + return new SparkWriteRequirements(distribution, ordering, advisoryPartitionSize); + } + + private static Distribution writeDistribution(Table table, DistributionMode mode) { + switch (mode) { + case NONE: + return Distributions.unspecified(); + + case HASH: + return Distributions.clustered(clustering(table)); + + case RANGE: + return Distributions.ordered(ordering(table)); + + default: + throw new IllegalArgumentException("Unsupported distribution mode: " + mode); + } + } + + /** Builds requirements for copy-on-write DELETE, UPDATE, MERGE operations. */ + public static SparkWriteRequirements copyOnWriteRequirements( + Table table, + Command command, + DistributionMode mode, + boolean fanoutEnabled, + long advisoryPartitionSize) { + + if (command == DELETE || command == UPDATE) { + Distribution distribution = copyOnWriteDeleteUpdateDistribution(table, mode); + SortOrder[] ordering = writeOrdering(table, fanoutEnabled); + return new SparkWriteRequirements(distribution, ordering, advisoryPartitionSize); + } else { + return writeRequirements(table, mode, fanoutEnabled, advisoryPartitionSize); + } + } + + private static Distribution copyOnWriteDeleteUpdateDistribution( + Table table, DistributionMode mode) { + + switch (mode) { + case NONE: + return Distributions.unspecified(); + + case HASH: + if (table.spec().isPartitioned()) { + return Distributions.clustered(clustering(table)); + } else { + return Distributions.clustered(FILE_CLUSTERING); + } + + case RANGE: + if (table.spec().isPartitioned() || table.sortOrder().isSorted()) { + return Distributions.ordered(ordering(table)); + } else { + return Distributions.ordered(EXISTING_ROW_ORDERING); + } + + default: + throw new IllegalArgumentException("Unexpected distribution mode: " + mode); + } + } + + /** Builds requirements for merge-on-read DELETE, UPDATE, MERGE operations. */ + public static SparkWriteRequirements positionDeltaRequirements( + Table table, + Command command, + DistributionMode mode, + boolean fanoutEnabled, + long advisoryPartitionSize) { + + if (command == UPDATE || command == MERGE) { + Distribution distribution = positionDeltaUpdateMergeDistribution(table, mode); + SortOrder[] ordering = positionDeltaUpdateMergeOrdering(table, fanoutEnabled); + return new SparkWriteRequirements(distribution, ordering, advisoryPartitionSize); + } else { + Distribution distribution = positionDeltaDeleteDistribution(table, mode); + SortOrder[] ordering = fanoutEnabled ? EMPTY_ORDERING : POSITION_DELETE_ORDERING; + return new SparkWriteRequirements(distribution, ordering, advisoryPartitionSize); + } + } + + private static Distribution positionDeltaUpdateMergeDistribution( + Table table, DistributionMode mode) { + + switch (mode) { + case NONE: + return Distributions.unspecified(); + + case HASH: + if (table.spec().isUnpartitioned()) { + return Distributions.clustered(concat(PARTITION_FILE_CLUSTERING, clustering(table))); + } else { + return Distributions.clustered(concat(PARTITION_CLUSTERING, clustering(table))); + } + + case RANGE: + if (table.spec().isUnpartitioned()) { + return Distributions.ordered(concat(PARTITION_FILE_ORDERING, ordering(table))); + } else { + return Distributions.ordered(concat(PARTITION_ORDERING, ordering(table))); + } + + default: + throw new IllegalArgumentException("Unsupported distribution mode: " + mode); + } + } + + private static SortOrder[] positionDeltaUpdateMergeOrdering(Table table, boolean fanoutEnabled) { + if (fanoutEnabled && table.sortOrder().isUnsorted()) { + return EMPTY_ORDERING; + } else { + return concat(POSITION_DELETE_ORDERING, ordering(table)); + } + } + + private static Distribution positionDeltaDeleteDistribution(Table table, DistributionMode mode) { + switch (mode) { + case NONE: + return Distributions.unspecified(); + + case HASH: + if (table.spec().isUnpartitioned()) { + return Distributions.clustered(PARTITION_FILE_CLUSTERING); + } else { + return Distributions.clustered(PARTITION_CLUSTERING); + } + + case RANGE: + if (table.spec().isUnpartitioned()) { + return Distributions.ordered(PARTITION_FILE_ORDERING); + } else { + return Distributions.ordered(PARTITION_ORDERING); + } + + default: + throw new IllegalArgumentException("Unsupported distribution mode: " + mode); + } + } + + // a local ordering within a task is beneficial in two cases: + // - there is a defined table sort order, so it is clear how the data should be ordered + // - the table is partitioned and fanout writers are disabled, + // so records for one partition must be co-located within a task + private static SortOrder[] writeOrdering(Table table, boolean fanoutEnabled) { + if (fanoutEnabled && table.sortOrder().isUnsorted()) { + return EMPTY_ORDERING; + } else { + return ordering(table); + } + } + + private static Expression[] clustering(Table table) { + return Spark3Util.toTransforms(table.spec()); + } + + private static SortOrder[] ordering(Table table) { + return Spark3Util.toOrdering(SortOrderUtil.buildSortOrder(table)); + } + + private static Expression[] concat(Expression[] clustering, Expression... otherClustering) { + return ObjectArrays.concat(clustering, otherClustering, Expression.class); + } + + private static SortOrder[] concat(SortOrder[] ordering, SortOrder... otherOrdering) { + return ObjectArrays.concat(ordering, otherOrdering, SortOrder.class); + } + + private static NamedReference ref(Types.NestedField field) { + return Expressions.column(field.name()); + } + + private static NamedReference ref(String name) { + return Expressions.column(name); + } + + private static Expression[] clusterBy(Expression... exprs) { + return exprs; + } + + private static SortOrder[] orderBy(Expression... exprs) { + return Arrays.stream(exprs).map(SparkWriteUtil::sort).toArray(SortOrder[]::new); + } + + private static SortOrder sort(Expression expr) { + return Expressions.sort(expr, SortDirection.ASCENDING); + } + + public static CustomMetric[] supportedCustomMetrics() { + return new CustomMetric[] { + new AddedDataFiles(), + new AddedDeleteFiles(), + new AddedEqualityDeletes(), + new AddedEqualityDeleteFiles(), + new AddedFileSizeInBytes(), + new AddedPositionalDeletes(), + new AddedPositionalDeleteFiles(), + new AddedRecords(), + new RemovedDataFiles(), + new RemovedDeleteFiles(), + new RemovedRecords(), + new RemovedEqualityDeleteFiles(), + new RemovedEqualityDeletes(), + new RemovedFileSizeInBytes(), + new RemovedPositionalDeleteFiles(), + new RemovedPositionalDeletes(), + new TotalDataFiles(), + new TotalDeleteFiles(), + new TotalEqualityDeletes(), + new TotalFileSizeInBytes(), + new TotalPositionalDeletes(), + new TotalRecords() + }; + } + + public static CustomTaskMetric[] customTaskMetrics(InMemoryMetricsReporter metricsReporter) { + List metrics = Lists.newArrayList(); + if (metricsReporter != null) { + CommitReport commitReport = metricsReporter.commitReport(); + if (commitReport != null) { + CommitMetricsResult result = commitReport.commitMetrics(); + addValue(new AddedDataFiles(), result.addedDataFiles(), metrics); + addValue(new AddedDeleteFiles(), result.addedDeleteFiles(), metrics); + addValue(new AddedEqualityDeletes(), result.addedEqualityDeletes(), metrics); + addValue(new AddedEqualityDeleteFiles(), result.addedEqualityDeleteFiles(), metrics); + addValue(new AddedFileSizeInBytes(), result.addedFilesSizeInBytes(), metrics); + addValue(new AddedPositionalDeletes(), result.addedPositionalDeletes(), metrics); + addValue(new AddedPositionalDeleteFiles(), result.addedPositionalDeleteFiles(), metrics); + addValue(new AddedRecords(), result.addedRecords(), metrics); + addValue(new RemovedDataFiles(), result.removedDataFiles(), metrics); + addValue(new RemovedDeleteFiles(), result.removedDeleteFiles(), metrics); + addValue(new RemovedRecords(), result.removedRecords(), metrics); + addValue(new RemovedEqualityDeleteFiles(), result.removedEqualityDeleteFiles(), metrics); + addValue(new RemovedEqualityDeletes(), result.removedEqualityDeletes(), metrics); + addValue(new RemovedFileSizeInBytes(), result.removedFilesSizeInBytes(), metrics); + addValue( + new RemovedPositionalDeleteFiles(), result.removedPositionalDeleteFiles(), metrics); + addValue(new RemovedPositionalDeletes(), result.removedPositionalDeletes(), metrics); + addValue(new TotalDataFiles(), result.totalDataFiles(), metrics); + addValue(new TotalDeleteFiles(), result.totalDeleteFiles(), metrics); + addValue(new TotalEqualityDeletes(), result.totalEqualityDeletes(), metrics); + addValue(new TotalFileSizeInBytes(), result.totalFilesSizeInBytes(), metrics); + addValue(new TotalPositionalDeletes(), result.totalPositionalDeletes(), metrics); + addValue(new TotalRecords(), result.totalRecords(), metrics); + } + } + return metrics.toArray(new CustomTaskMetric[0]); + } + + private static void addValue( + CustomMetric metric, CounterResult result, List taskMetrics) { + if (result != null) { + taskMetrics.add( + new CustomTaskMetric() { + @Override + public String name() { + return metric.name(); + } + + @Override + public long value() { + return result.value(); + } + }); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SupportsFunctions.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SupportsFunctions.java new file mode 100644 index 000000000000..83e56d1bd9b5 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SupportsFunctions.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.spark.functions.SparkFunctions; +import org.apache.spark.sql.catalyst.analysis.NoSuchFunctionException; +import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException; +import org.apache.spark.sql.connector.catalog.FunctionCatalog; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; + +interface SupportsFunctions extends FunctionCatalog { + + default boolean isFunctionNamespace(String[] namespace) { + return namespace.length == 0; + } + + default boolean isExistingNamespace(String[] namespace) { + return namespace.length == 0; + } + + @Override + default Identifier[] listFunctions(String[] namespace) throws NoSuchNamespaceException { + if (isFunctionNamespace(namespace)) { + return SparkFunctions.list().stream() + .map(name -> Identifier.of(namespace, name)) + .toArray(Identifier[]::new); + } else if (isExistingNamespace(namespace)) { + return new Identifier[0]; + } + + throw new NoSuchNamespaceException(namespace); + } + + @Override + default UnboundFunction loadFunction(Identifier ident) throws NoSuchFunctionException { + String[] namespace = ident.namespace(); + String name = ident.name(); + + if (isFunctionNamespace(namespace)) { + UnboundFunction func = SparkFunctions.load(name); + if (func != null) { + return func; + } + } + + throw new NoSuchFunctionException(ident); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SupportsReplaceView.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SupportsReplaceView.java new file mode 100644 index 000000000000..8bdb7b13861c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/SupportsReplaceView.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException; +import org.apache.spark.sql.catalyst.analysis.NoSuchViewException; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.View; +import org.apache.spark.sql.connector.catalog.ViewCatalog; +import org.apache.spark.sql.types.StructType; + +public interface SupportsReplaceView extends ViewCatalog { + /** + * Replace a view in the catalog + * + * @param ident a view identifier + * @param sql the SQL text that defines the view + * @param currentCatalog the current catalog + * @param currentNamespace the current namespace + * @param schema the view query output schema + * @param queryColumnNames the query column names + * @param columnAliases the column aliases + * @param columnComments the column comments + * @param properties the view properties + * @throws NoSuchViewException If the view doesn't exist or is a table + * @throws NoSuchNamespaceException If the identifier namespace does not exist (optional) + */ + View replaceView( + Identifier ident, + String sql, + String currentCatalog, + String[] currentNamespace, + StructType schema, + String[] queryColumnNames, + String[] columnAliases, + String[] columnComments, + Map properties) + throws NoSuchViewException, NoSuchNamespaceException; +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/TimeTravel.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/TimeTravel.java new file mode 100644 index 000000000000..2926aee8cc9f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/TimeTravel.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.relocated.com.google.common.primitives.Longs; + +/** Represents a time-travel specification for loading tables at a specific point in time. */ +public sealed interface TimeTravel permits TimeTravel.AsOfVersion, TimeTravel.AsOfTimestamp { + + static TimeTravel version(String version) { + return new AsOfVersion(version); + } + + static TimeTravel timestampMicros(long timestamp) { + return new AsOfTimestamp(timestamp); + } + + static TimeTravel timestampMillis(long timestamp) { + return new AsOfTimestamp(TimeUnit.MILLISECONDS.toMicros(timestamp)); + } + + /** Time-travel specification using a version (snapshot ID, branch, or tag). */ + record AsOfVersion(String version) implements TimeTravel { + + public boolean isSnapshotId() { + return Longs.tryParse(version) != null; + } + + @Override + public String toString() { + return "VERSION AS OF '" + version + "'"; + } + } + + /** Time-travel specification using a timestamp in microseconds. */ + record AsOfTimestamp(long timestampMicros) implements TimeTravel { + + public long timestampMillis() { + return TimeUnit.MICROSECONDS.toMillis(timestampMicros); + } + + @Override + public String toString() { + return "TIMESTAMP AS OF " + timestampMicros; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java new file mode 100644 index 000000000000..09c89bbba813 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/TypeToSparkType.java @@ -0,0 +1,166 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.List; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.expressions.Literal$; +import org.apache.spark.sql.types.ArrayType$; +import org.apache.spark.sql.types.BinaryType$; +import org.apache.spark.sql.types.BooleanType$; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DateType$; +import org.apache.spark.sql.types.DecimalType$; +import org.apache.spark.sql.types.DoubleType$; +import org.apache.spark.sql.types.FloatType$; +import org.apache.spark.sql.types.IntegerType$; +import org.apache.spark.sql.types.LongType$; +import org.apache.spark.sql.types.MapType$; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.MetadataBuilder; +import org.apache.spark.sql.types.NullType$; +import org.apache.spark.sql.types.StringType$; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType$; +import org.apache.spark.sql.types.TimestampNTZType$; +import org.apache.spark.sql.types.TimestampType$; +import org.apache.spark.sql.types.VariantType$; + +class TypeToSparkType extends TypeUtil.SchemaVisitor { + TypeToSparkType() {} + + public static final String METADATA_COL_ATTR_KEY = "__metadata_col"; + + @Override + public DataType schema(Schema schema, DataType structType) { + return structType; + } + + @Override + public DataType struct(Types.StructType struct, List fieldResults) { + List fields = struct.fields(); + + List sparkFields = Lists.newArrayListWithExpectedSize(fieldResults.size()); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + DataType type = fieldResults.get(i); + Metadata metadata = fieldMetadata(field.fieldId()); + StructField sparkField = StructField.apply(field.name(), type, field.isOptional(), metadata); + if (field.doc() != null) { + sparkField = sparkField.withComment(field.doc()); + } + + // Convert both write and initial default values to Spark SQL string literal representations + // on the StructField metadata + if (field.writeDefault() != null) { + Object writeDefault = SparkUtil.internalToSpark(field.type(), field.writeDefault()); + sparkField = + sparkField.withCurrentDefaultValue(Literal$.MODULE$.create(writeDefault, type).sql()); + } + + if (field.initialDefault() != null) { + Object initialDefault = SparkUtil.internalToSpark(field.type(), field.initialDefault()); + sparkField = + sparkField.withExistenceDefaultValue( + Literal$.MODULE$.create(initialDefault, type).sql()); + } + + sparkFields.add(sparkField); + } + + return StructType$.MODULE$.apply(sparkFields); + } + + @Override + public DataType field(Types.NestedField field, DataType fieldResult) { + return fieldResult; + } + + @Override + public DataType list(Types.ListType list, DataType elementResult) { + return ArrayType$.MODULE$.apply(elementResult, list.isElementOptional()); + } + + @Override + public DataType map(Types.MapType map, DataType keyResult, DataType valueResult) { + return MapType$.MODULE$.apply(keyResult, valueResult, map.isValueOptional()); + } + + @Override + public DataType variant(Types.VariantType variant) { + return VariantType$.MODULE$; + } + + @Override + public DataType primitive(Type.PrimitiveType primitive) { + switch (primitive.typeId()) { + case BOOLEAN: + return BooleanType$.MODULE$; + case INTEGER: + return IntegerType$.MODULE$; + case LONG: + return LongType$.MODULE$; + case FLOAT: + return FloatType$.MODULE$; + case DOUBLE: + return DoubleType$.MODULE$; + case DATE: + return DateType$.MODULE$; + case TIME: + throw new UnsupportedOperationException("Spark does not support time fields"); + case TIMESTAMP: + Types.TimestampType ts = (Types.TimestampType) primitive; + if (ts.shouldAdjustToUTC()) { + return TimestampType$.MODULE$; + } else { + return TimestampNTZType$.MODULE$; + } + case STRING: + return StringType$.MODULE$; + case UUID: + // use String + return StringType$.MODULE$; + case FIXED: + return BinaryType$.MODULE$; + case BINARY: + return BinaryType$.MODULE$; + case DECIMAL: + Types.DecimalType decimal = (Types.DecimalType) primitive; + return DecimalType$.MODULE$.apply(decimal.precision(), decimal.scale()); + case UNKNOWN: + return NullType$.MODULE$; + default: + throw new UnsupportedOperationException( + "Cannot convert unsupported type to Spark: " + primitive); + } + } + + private Metadata fieldMetadata(int fieldId) { + if (MetadataColumns.metadataFieldIds().contains(fieldId)) { + return new MetadataBuilder().putBoolean(METADATA_COL_ATTR_KEY, true).build(); + } + + return Metadata.empty(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSnapshotUpdateSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSnapshotUpdateSparkAction.java new file mode 100644 index 000000000000..b69b80a8d3a6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSnapshotUpdateSparkAction.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.spark.sql.SparkSession; + +abstract class BaseSnapshotUpdateSparkAction extends BaseSparkAction { + + private final Map summary = Maps.newHashMap(); + + protected BaseSnapshotUpdateSparkAction(SparkSession spark) { + super(spark); + } + + public ThisT snapshotProperty(String property, String value) { + summary.put(property, value); + return self(); + } + + protected void commit(org.apache.iceberg.SnapshotUpdate update) { + summary.forEach(update::set); + update.commit(); + } + + protected Map commitSummary() { + return ImmutableMap.copyOf(summary); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java new file mode 100644 index 000000000000..f53859ef97d6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseSparkAction.java @@ -0,0 +1,444 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.MetadataTableType.ALL_MANIFESTS; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.lit; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.apache.iceberg.AllManifestsTable; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.ManifestContent; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ReachableFileUtil; +import org.apache.iceberg.StaticTableOperations; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.io.BulkDeletionFailureException; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.ClosingIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.Splitter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterators; +import org.apache.iceberg.relocated.com.google.common.collect.ListMultimap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Multimaps; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.spark.JobGroupUtils; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.util.Tasks; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.api.java.function.FlatMapFunction; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class BaseSparkAction { + + protected static final String MANIFEST = "Manifest"; + protected static final String MANIFEST_LIST = "Manifest List"; + protected static final String STATISTICS_FILES = "Statistics Files"; + protected static final String OTHERS = "Others"; + + protected static final String FILE_PATH = "file_path"; + protected static final String LAST_MODIFIED = "last_modified"; + + protected static final Splitter COMMA_SPLITTER = Splitter.on(","); + protected static final Joiner COMMA_JOINER = Joiner.on(','); + + private static final Logger LOG = LoggerFactory.getLogger(BaseSparkAction.class); + private static final AtomicInteger JOB_COUNTER = new AtomicInteger(); + private static final int DELETE_NUM_RETRIES = 3; + private static final int DELETE_GROUP_SIZE = 100000; + + private final SparkSession spark; + private final JavaSparkContext sparkContext; + private final Map options = Maps.newHashMap(); + + protected BaseSparkAction(SparkSession spark) { + this.spark = spark; + this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + } + + protected SparkSession spark() { + return spark; + } + + protected JavaSparkContext sparkContext() { + return sparkContext; + } + + protected abstract ThisT self(); + + public ThisT option(String name, String value) { + options.put(name, value); + return self(); + } + + public ThisT options(Map newOptions) { + options.putAll(newOptions); + return self(); + } + + protected Map options() { + return options; + } + + protected T withJobGroupInfo(JobGroupInfo info, Supplier supplier) { + return JobGroupUtils.withJobGroupInfo(sparkContext, info, supplier); + } + + protected JobGroupInfo newJobGroupInfo(String groupId, String desc) { + return new JobGroupInfo(groupId + "-" + JOB_COUNTER.incrementAndGet(), desc); + } + + protected Table newStaticTable(TableMetadata metadata, FileIO io) { + StaticTableOperations ops = new StaticTableOperations(metadata, io); + return new BaseTable(ops, metadata.metadataFileLocation()); + } + + protected Table newStaticTable(String metadataFileLocation, FileIO io) { + StaticTableOperations ops = new StaticTableOperations(metadataFileLocation, io); + return new BaseTable(ops, metadataFileLocation); + } + + protected Dataset contentFileDS(Table table) { + return contentFileDS(table, null); + } + + protected Dataset contentFileDS(Table table, Set snapshotIds) { + Table serializableTable = SerializableTableWithSize.copyOf(table); + Broadcast

    tableBroadcast = sparkContext.broadcast(serializableTable); + int numShufflePartitions = spark.sessionState().conf().numShufflePartitions(); + + Dataset manifestBeanDS = + manifestDF(table, snapshotIds) + .selectExpr( + "content", + "path", + "length", + "0 as sequenceNumber", + "partition_spec_id as partitionSpecId", + "added_snapshot_id as addedSnapshotId", + "key_metadata as keyMetadata") + .dropDuplicates("path") + .repartition(numShufflePartitions) // avoid adaptive execution combining tasks + .as(ManifestFileBean.ENCODER); + + return manifestBeanDS.flatMap(new ReadManifest(tableBroadcast), FileInfo.ENCODER); + } + + protected Dataset manifestDS(Table table) { + return manifestDS(table, null); + } + + protected Dataset manifestDS(Table table, Set snapshotIds) { + return manifestDF(table, snapshotIds) + .select(col("path"), lit(MANIFEST).as("type")) + .as(FileInfo.ENCODER); + } + + private Dataset manifestDF(Table table, Set snapshotIds) { + Dataset manifestDF = loadMetadataTable(table, ALL_MANIFESTS); + if (snapshotIds != null) { + Column filterCond = col(AllManifestsTable.REF_SNAPSHOT_ID.name()).isInCollection(snapshotIds); + return manifestDF.filter(filterCond); + } else { + return manifestDF; + } + } + + protected Dataset manifestListDS(Table table) { + return manifestListDS(table, null); + } + + protected Dataset manifestListDS(Table table, Set snapshotIds) { + List manifestLists = ReachableFileUtil.manifestListLocations(table, snapshotIds); + return toFileInfoDS(manifestLists, MANIFEST_LIST); + } + + protected Dataset statisticsFileDS(Table table, Set snapshotIds) { + List statisticsFiles = + ReachableFileUtil.statisticsFilesLocationsForSnapshots(table, snapshotIds); + return toFileInfoDS(statisticsFiles, STATISTICS_FILES); + } + + protected Dataset otherMetadataFileDS(Table table) { + return otherMetadataFileDS(table, false /* include all reachable old metadata locations */); + } + + protected Dataset allReachableOtherMetadataFileDS(Table table) { + return otherMetadataFileDS(table, true /* include all reachable old metadata locations */); + } + + private Dataset otherMetadataFileDS(Table table, boolean recursive) { + List otherMetadataFiles = Lists.newArrayList(); + otherMetadataFiles.addAll(ReachableFileUtil.metadataFileLocations(table, recursive)); + otherMetadataFiles.add(ReachableFileUtil.versionHintLocation(table)); + otherMetadataFiles.addAll(ReachableFileUtil.statisticsFilesLocations(table)); + return toFileInfoDS(otherMetadataFiles, OTHERS); + } + + protected Dataset loadMetadataTable(Table table, MetadataTableType type) { + return SparkTableUtil.loadMetadataTable(spark, table, type); + } + + private Dataset toFileInfoDS(List paths, String type) { + List fileInfoList = Lists.transform(paths, path -> new FileInfo(path, type)); + return spark.createDataset(fileInfoList, FileInfo.ENCODER); + } + + /** + * Deletes files and keeps track of how many files were removed for each file type. + * + * @param executorService an executor service to use for parallel deletes + * @param deleteFunc a delete func + * @param files an iterator of Spark rows of the structure (path: String, type: String) + * @return stats on which files were deleted + */ + protected DeleteSummary deleteFiles( + ExecutorService executorService, Consumer deleteFunc, Iterator files) { + + DeleteSummary summary = new DeleteSummary(); + + Tasks.foreach(files) + .retry(DELETE_NUM_RETRIES) + .stopRetryOn(NotFoundException.class) + .suppressFailureWhenFinished() + .executeWith(executorService) + .onFailure( + (fileInfo, exc) -> { + String path = fileInfo.getPath(); + String type = fileInfo.getType(); + LOG.warn("Delete failed for {}: {}", type, path, exc); + }) + .run( + fileInfo -> { + String path = fileInfo.getPath(); + String type = fileInfo.getType(); + deleteFunc.accept(path); + summary.deletedFile(path, type); + }); + + return summary; + } + + protected DeleteSummary deleteFiles(SupportsBulkOperations io, Iterator files) { + DeleteSummary summary = new DeleteSummary(); + Iterator> fileGroups = Iterators.partition(files, DELETE_GROUP_SIZE); + + Tasks.foreach(fileGroups) + .suppressFailureWhenFinished() + .run(fileGroup -> deleteFileGroup(fileGroup, io, summary)); + + return summary; + } + + private static void deleteFileGroup( + List fileGroup, SupportsBulkOperations io, DeleteSummary summary) { + + ListMultimap filesByType = Multimaps.index(fileGroup, FileInfo::getType); + ListMultimap pathsByType = + Multimaps.transformValues(filesByType, FileInfo::getPath); + + for (Map.Entry> entry : pathsByType.asMap().entrySet()) { + String type = entry.getKey(); + Collection paths = entry.getValue(); + int failures = 0; + try { + io.deleteFiles(paths); + } catch (BulkDeletionFailureException e) { + failures = e.numberFailedObjects(); + } + summary.deletedFiles(type, paths.size() - failures); + } + } + + static class DeleteSummary { + private final AtomicLong dataFilesCount = new AtomicLong(0L); + private final AtomicLong positionDeleteFilesCount = new AtomicLong(0L); + private final AtomicLong equalityDeleteFilesCount = new AtomicLong(0L); + private final AtomicLong manifestsCount = new AtomicLong(0L); + private final AtomicLong manifestListsCount = new AtomicLong(0L); + private final AtomicLong statisticsFilesCount = new AtomicLong(0L); + private final AtomicLong otherFilesCount = new AtomicLong(0L); + + public void deletedFiles(String type, int numFiles) { + if (FileContent.DATA.name().equalsIgnoreCase(type)) { + dataFilesCount.addAndGet(numFiles); + + } else if (FileContent.POSITION_DELETES.name().equalsIgnoreCase(type)) { + positionDeleteFilesCount.addAndGet(numFiles); + + } else if (FileContent.EQUALITY_DELETES.name().equalsIgnoreCase(type)) { + equalityDeleteFilesCount.addAndGet(numFiles); + + } else if (MANIFEST.equalsIgnoreCase(type)) { + manifestsCount.addAndGet(numFiles); + + } else if (MANIFEST_LIST.equalsIgnoreCase(type)) { + manifestListsCount.addAndGet(numFiles); + + } else if (STATISTICS_FILES.equalsIgnoreCase(type)) { + statisticsFilesCount.addAndGet(numFiles); + + } else if (OTHERS.equalsIgnoreCase(type)) { + otherFilesCount.addAndGet(numFiles); + + } else { + throw new ValidationException("Illegal file type: %s", type); + } + } + + public void deletedFile(String path, String type) { + if (FileContent.DATA.name().equalsIgnoreCase(type)) { + dataFilesCount.incrementAndGet(); + LOG.trace("Deleted data file: {}", path); + + } else if (FileContent.POSITION_DELETES.name().equalsIgnoreCase(type)) { + positionDeleteFilesCount.incrementAndGet(); + LOG.trace("Deleted positional delete file: {}", path); + + } else if (FileContent.EQUALITY_DELETES.name().equalsIgnoreCase(type)) { + equalityDeleteFilesCount.incrementAndGet(); + LOG.trace("Deleted equality delete file: {}", path); + + } else if (MANIFEST.equalsIgnoreCase(type)) { + manifestsCount.incrementAndGet(); + LOG.debug("Deleted manifest: {}", path); + + } else if (MANIFEST_LIST.equalsIgnoreCase(type)) { + manifestListsCount.incrementAndGet(); + LOG.debug("Deleted manifest list: {}", path); + + } else if (STATISTICS_FILES.equalsIgnoreCase(type)) { + statisticsFilesCount.incrementAndGet(); + LOG.debug("Deleted statistics file: {}", path); + + } else if (OTHERS.equalsIgnoreCase(type)) { + otherFilesCount.incrementAndGet(); + LOG.debug("Deleted other metadata file: {}", path); + + } else { + throw new ValidationException("Illegal file type: %s", type); + } + } + + public long dataFilesCount() { + return dataFilesCount.get(); + } + + public long positionDeleteFilesCount() { + return positionDeleteFilesCount.get(); + } + + public long equalityDeleteFilesCount() { + return equalityDeleteFilesCount.get(); + } + + public long manifestsCount() { + return manifestsCount.get(); + } + + public long manifestListsCount() { + return manifestListsCount.get(); + } + + public long statisticsFilesCount() { + return statisticsFilesCount.get(); + } + + public long otherFilesCount() { + return otherFilesCount.get(); + } + + public long totalFilesCount() { + return dataFilesCount() + + positionDeleteFilesCount() + + equalityDeleteFilesCount() + + manifestsCount() + + manifestListsCount() + + statisticsFilesCount() + + otherFilesCount(); + } + } + + private static class ReadManifest implements FlatMapFunction { + private final Broadcast
    table; + + ReadManifest(Broadcast
    table) { + this.table = table; + } + + @Override + public Iterator call(ManifestFileBean manifest) { + return new ClosingIterator<>(entries(manifest)); + } + + public CloseableIterator entries(ManifestFileBean manifest) { + ManifestContent content = manifest.content(); + FileIO io = table.getValue().io(); + Map specs = table.getValue().specs(); + List proj = ImmutableList.of(DataFile.FILE_PATH.name(), DataFile.CONTENT.name()); + + switch (content) { + case DATA: + return CloseableIterator.transform( + ManifestFiles.read(manifest, io, specs).select(proj).iterator(), + ReadManifest::toFileInfo); + case DELETES: + return CloseableIterator.transform( + ManifestFiles.readDeleteManifest(manifest, io, specs).select(proj).iterator(), + ReadManifest::toFileInfo); + default: + throw new IllegalArgumentException("Unsupported manifest content type:" + content); + } + } + + static FileInfo toFileInfo(ContentFile file) { + return new FileInfo(file.location(), file.content().toString()); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java new file mode 100644 index 000000000000..37e1ec4ce788 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/BaseTableCreationSparkAction.java @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.mapping.MappingUtil; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.source.StagedSparkTable; +import org.apache.iceberg.util.LocationUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.catalog.CatalogTable; +import org.apache.spark.sql.catalyst.catalog.CatalogUtils; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.StagingTableCatalog; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.V1Table; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.StructType; + +abstract class BaseTableCreationSparkAction extends BaseSparkAction { + private static final Set ALLOWED_SOURCES = + ImmutableSet.of("parquet", "avro", "orc", "hive"); + protected static final String LOCATION = "location"; + protected static final String ICEBERG_METADATA_FOLDER = "metadata"; + protected static final List EXCLUDED_PROPERTIES = + ImmutableList.of("path", "transient_lastDdlTime", "serialization.format"); + + // Source Fields + private final V1Table sourceTable; + private final CatalogTable sourceCatalogTable; + private final String sourceTableLocation; + private final TableCatalog sourceCatalog; + private final Identifier sourceTableIdent; + + // Optional Parameters for destination + private final Map additionalProperties = Maps.newHashMap(); + + BaseTableCreationSparkAction( + SparkSession spark, CatalogPlugin sourceCatalog, Identifier sourceTableIdent) { + super(spark); + + this.sourceCatalog = checkSourceCatalog(sourceCatalog); + this.sourceTableIdent = sourceTableIdent; + + try { + this.sourceTable = (V1Table) this.sourceCatalog.loadTable(sourceTableIdent); + this.sourceCatalogTable = sourceTable.v1Table(); + } catch (org.apache.spark.sql.catalyst.analysis.NoSuchTableException e) { + throw new NoSuchTableException("Cannot find source table '%s'", sourceTableIdent); + } catch (ClassCastException e) { + throw new IllegalArgumentException( + String.format("Cannot use non-v1 table '%s' as a source", sourceTableIdent), e); + } + validateSourceTable(); + + this.sourceTableLocation = + CatalogUtils.URIToString(sourceCatalogTable.storage().locationUri().get()); + } + + protected abstract TableCatalog checkSourceCatalog(CatalogPlugin catalog); + + protected abstract StagingTableCatalog destCatalog(); + + protected abstract Identifier destTableIdent(); + + protected abstract Map destTableProps(); + + protected String sourceTableLocation() { + return sourceTableLocation; + } + + protected CatalogTable v1SourceTable() { + return sourceCatalogTable; + } + + protected TableCatalog sourceCatalog() { + return sourceCatalog; + } + + protected Identifier sourceTableIdent() { + return sourceTableIdent; + } + + protected void setProperties(Map properties) { + additionalProperties.putAll(properties); + } + + protected void setProperty(String key, String value) { + additionalProperties.put(key, value); + } + + protected Map additionalProperties() { + return additionalProperties; + } + + private void validateSourceTable() { + String sourceTableProvider = sourceCatalogTable.provider().get().toLowerCase(Locale.ROOT); + Preconditions.checkArgument( + ALLOWED_SOURCES.contains(sourceTableProvider), + "Cannot create an Iceberg table from source provider: '%s'", + sourceTableProvider); + Preconditions.checkArgument( + !sourceCatalogTable.storage().locationUri().isEmpty(), + "Cannot create an Iceberg table from a source without an explicit location"); + Preconditions.checkArgument( + sourceCatalogTable.bucketSpec().isEmpty(), + "Cannot create an Iceberg table from a bucketed source table: %s", + (Object) sourceCatalogTable.bucketSpec().getOrElse(() -> null)); + } + + protected StagingTableCatalog checkDestinationCatalog(CatalogPlugin catalog) { + Preconditions.checkArgument( + catalog instanceof SparkSessionCatalog || catalog instanceof SparkCatalog, + "Cannot create Iceberg table in non-Iceberg Catalog. " + + "Catalog '%s' was of class '%s' but '%s' or '%s' are required", + catalog.name(), + catalog.getClass().getName(), + SparkSessionCatalog.class.getName(), + SparkCatalog.class.getName()); + + return (StagingTableCatalog) catalog; + } + + protected StagedSparkTable stageDestTable() { + try { + Map props = destTableProps(); + StructType schema = sourceTable.schema(); + Transform[] partitioning = sourceTable.partitioning(); + return (StagedSparkTable) + destCatalog().stageCreate(destTableIdent(), schema, partitioning, props); + } catch (org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException e) { + throw new NoSuchNamespaceException( + "Cannot create table %s as the namespace does not exist", destTableIdent()); + } catch (org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException e) { + throw new AlreadyExistsException( + "Cannot create table %s as it already exists", destTableIdent()); + } + } + + protected void ensureNameMappingPresent(Table table) { + if (!table.properties().containsKey(TableProperties.DEFAULT_NAME_MAPPING)) { + NameMapping nameMapping = MappingUtil.create(table.schema()); + String nameMappingJson = NameMappingParser.toJson(nameMapping); + table.updateProperties().set(TableProperties.DEFAULT_NAME_MAPPING, nameMappingJson).commit(); + } + } + + protected String getMetadataLocation(Table table) { + String defaultValue = + LocationUtil.stripTrailingSlash(table.location()) + "/" + ICEBERG_METADATA_FOLDER; + return LocationUtil.stripTrailingSlash( + table.properties().getOrDefault(TableProperties.WRITE_METADATA_LOCATION, defaultValue)); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ComputePartitionStatsSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ComputePartitionStatsSparkAction.java new file mode 100644 index 000000000000..d46fa86c125f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ComputePartitionStatsSparkAction.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.io.IOException; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.PartitionStatsHandler; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ComputePartitionStats; +import org.apache.iceberg.actions.ImmutableComputePartitionStats; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.spark.sql.SparkSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Computes the stats incrementally after the snapshot that has partition stats file till the given + * snapshot (uses current snapshot if not specified) and writes the combined result into a {@link + * PartitionStatisticsFile} after merging the stats for a given snapshot. Does a full compute if + * previous statistics file does not exist. Also registers the {@link PartitionStatisticsFile} to + * table metadata. + */ +public class ComputePartitionStatsSparkAction + extends BaseSparkAction implements ComputePartitionStats { + + private static final Logger LOG = LoggerFactory.getLogger(ComputePartitionStatsSparkAction.class); + private static final Result EMPTY_RESULT = + ImmutableComputePartitionStats.Result.builder().build(); + + private final Table table; + private Snapshot snapshot; + + ComputePartitionStatsSparkAction(SparkSession spark, Table table) { + super(spark); + this.table = table; + this.snapshot = table.currentSnapshot(); + } + + @Override + protected ComputePartitionStatsSparkAction self() { + return this; + } + + @Override + public ComputePartitionStats snapshot(long newSnapshotId) { + Snapshot newSnapshot = table.snapshot(newSnapshotId); + Preconditions.checkArgument(newSnapshot != null, "Snapshot not found: %s", newSnapshotId); + this.snapshot = newSnapshot; + return this; + } + + @Override + public Result execute() { + if (snapshot == null) { + LOG.info("No snapshot to compute partition stats for table {}", table.name()); + return EMPTY_RESULT; + } + + JobGroupInfo info = newJobGroupInfo("COMPUTE-PARTITION-STATS", jobDesc()); + return withJobGroupInfo(info, this::doExecute); + } + + private Result doExecute() { + LOG.info("Computing partition stats for {} (snapshot {})", table.name(), snapshot.snapshotId()); + PartitionStatisticsFile statisticsFile; + try { + statisticsFile = PartitionStatsHandler.computeAndWriteStatsFile(table, snapshot.snapshotId()); + } catch (IOException e) { + throw new RuntimeIOException(e); + } + + if (statisticsFile == null) { + return EMPTY_RESULT; + } + + table.updatePartitionStatistics().setPartitionStatistics(statisticsFile).commit(); + return ImmutableComputePartitionStats.Result.builder().statisticsFile(statisticsFile).build(); + } + + private String jobDesc() { + return String.format( + "Computing partition stats for %s (snapshot=%s)", table.name(), snapshot.snapshotId()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ComputeTableStatsSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ComputeTableStatsSparkAction.java new file mode 100644 index 000000000000..1a0c022ad210 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ComputeTableStatsSparkAction.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.iceberg.GenericBlobMetadata; +import org.apache.iceberg.GenericStatisticsFile; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.IcebergBuild; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.actions.ComputeTableStats; +import org.apache.iceberg.actions.ImmutableComputeTableStats; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.puffin.Blob; +import org.apache.iceberg.puffin.Puffin; +import org.apache.iceberg.puffin.PuffinWriter; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.SparkSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Computes the statistics of the given columns and stores it as Puffin files. */ +public class ComputeTableStatsSparkAction extends BaseSparkAction + implements ComputeTableStats { + + private static final Logger LOG = LoggerFactory.getLogger(ComputeTableStatsSparkAction.class); + private static final Result EMPTY_RESULT = ImmutableComputeTableStats.Result.builder().build(); + + private final Table table; + private List columns; + private Snapshot snapshot; + + ComputeTableStatsSparkAction(SparkSession spark, Table table) { + super(spark); + this.table = table; + this.snapshot = table.currentSnapshot(); + } + + @Override + protected ComputeTableStatsSparkAction self() { + return this; + } + + @Override + public ComputeTableStats columns(String... newColumns) { + Preconditions.checkArgument( + newColumns != null && newColumns.length > 0, "Columns cannot be null/empty"); + this.columns = ImmutableList.copyOf(ImmutableSet.copyOf(newColumns)); + return this; + } + + @Override + public ComputeTableStats snapshot(long newSnapshotId) { + Snapshot newSnapshot = table.snapshot(newSnapshotId); + Preconditions.checkArgument(newSnapshot != null, "Snapshot not found: %s", newSnapshotId); + this.snapshot = newSnapshot; + return this; + } + + @Override + public Result execute() { + if (snapshot == null) { + LOG.info("No snapshot to compute stats for table {}", table.name()); + return EMPTY_RESULT; + } + validateColumns(); + JobGroupInfo info = newJobGroupInfo("COMPUTE-TABLE-STATS", jobDesc()); + return withJobGroupInfo(info, this::doExecute); + } + + private Result doExecute() { + LOG.info( + "Computing stats for columns {} in {} (snapshot {})", + columns(), + table.name(), + snapshotId()); + List blobs = generateNDVBlobs(); + StatisticsFile statisticsFile = writeStatsFile(blobs); + table.updateStatistics().setStatistics(statisticsFile).commit(); + return ImmutableComputeTableStats.Result.builder().statisticsFile(statisticsFile).build(); + } + + private StatisticsFile writeStatsFile(List blobs) { + LOG.info("Writing stats for table {} for snapshot {}", table.name(), snapshotId()); + OutputFile outputFile = table.io().newOutputFile(outputPath()); + try (PuffinWriter writer = Puffin.write(outputFile).createdBy(appIdentifier()).build()) { + blobs.forEach(writer::add); + writer.finish(); + return new GenericStatisticsFile( + snapshotId(), + outputFile.location(), + writer.fileSize(), + writer.footerSize(), + GenericBlobMetadata.from(writer.writtenBlobsMetadata())); + } catch (IOException e) { + throw new RuntimeIOException(e); + } + } + + private List generateNDVBlobs() { + return NDVSketchUtil.generateBlobs(spark(), table, snapshot, columns()); + } + + private List columns() { + if (columns == null) { + Schema schema = table.schemas().get(snapshot.schemaId()); + this.columns = + schema.columns().stream() + .filter(nestedField -> nestedField.type().isPrimitiveType()) + .map(Types.NestedField::name) + .collect(Collectors.toList()); + } + return columns; + } + + private void validateColumns() { + Schema schema = table.schemas().get(snapshot.schemaId()); + Preconditions.checkArgument(!columns().isEmpty(), "No columns found to compute stats"); + for (String columnName : columns()) { + Types.NestedField field = schema.findField(columnName); + Preconditions.checkArgument(field != null, "Can't find column %s in %s", columnName, schema); + Preconditions.checkArgument( + field.type().isPrimitiveType(), + "Can't compute stats on non-primitive type column: %s (%s)", + columnName, + field.type()); + } + } + + private String appIdentifier() { + String icebergVersion = IcebergBuild.fullVersion(); + String sparkVersion = spark().version(); + return String.format("Iceberg %s Spark %s", icebergVersion, sparkVersion); + } + + private long snapshotId() { + return snapshot.snapshotId(); + } + + private String jobDesc() { + return String.format( + "Computing table stats for %s (snapshot_id=%s, columns=%s)", + table.name(), snapshotId(), columns()); + } + + private String outputPath() { + TableOperations operations = ((HasTableOperations) table).operations(); + String fileName = String.format("%s-%s.stats", snapshotId(), UUID.randomUUID()); + return operations.metadataFileLocation(fileName); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java new file mode 100644 index 000000000000..92bfc880ad7f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteOrphanFilesSparkAction.java @@ -0,0 +1,623 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.TableProperties.GC_ENABLED; +import static org.apache.iceberg.TableProperties.GC_ENABLED_DEFAULT; + +import java.net.URI; +import java.sql.Timestamp; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Predicate; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.actions.FileURI; +import org.apache.iceberg.actions.ImmutableDeleteOrphanFiles; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.io.BulkDeletionFailureException; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.io.SupportsPrefixOperations; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterators; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.util.FileSystemWalker; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.Tasks; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.function.FlatMapFunction; +import org.apache.spark.api.java.function.MapPartitionsFunction; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.util.SerializableConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.Tuple2; + +/** + * An action that removes orphan metadata, data and delete files by listing a given location and + * comparing the actual files in that location with content and metadata files referenced by all + * valid snapshots. The location must be accessible for listing via the Hadoop {@link FileSystem}. + * + *

    By default, this action cleans up the table location returned by {@link Table#location()} and + * removes unreachable files that are older than 3 days using {@link Table#io()}. The behavior can + * be modified by passing a custom location to {@link #location} and a custom timestamp to {@link + * #olderThan(long)}. For example, someone might point this action to the data folder to clean up + * only orphan data files. + * + *

    Configure an alternative delete method using {@link #deleteWith(Consumer)}. + * + *

    For full control of the set of files being evaluated, use the {@link + * #compareToFileList(Dataset)} argument. This skips the directory listing - any files in the + * dataset provided which are not found in table metadata will be deleted, using the same {@link + * Table#location()} and {@link #olderThan(long)} filtering as above. + * + *

    Streaming mode can be enabled via the {@value #STREAM_RESULTS} option to avoid loading all + * orphan file paths into driver memory. When enabled, the result will contain only a sample of file + * paths (up to {@value #MAX_ORPHAN_FILE_SAMPLE_SIZE_DEFAULT}). The total count of deleted files is + * logged but not included in the result. + * + *

    Note: It is dangerous to call this action with a short retention interval as it might + * corrupt the state of the table if another operation is writing at the same time. + */ +public class DeleteOrphanFilesSparkAction extends BaseSparkAction + implements DeleteOrphanFiles { + + public static final String STREAM_RESULTS = "stream-results"; + public static final boolean STREAM_RESULTS_DEFAULT = false; + + // Test-only option to configure the max sample size for streaming mode + @VisibleForTesting + static final String MAX_ORPHAN_FILE_SAMPLE_SIZE = "max-orphan-file-sample-size"; + + private static final int MAX_ORPHAN_FILE_SAMPLE_SIZE_DEFAULT = 20000; + + private static final Logger LOG = LoggerFactory.getLogger(DeleteOrphanFilesSparkAction.class); + private static final Map EQUAL_SCHEMES_DEFAULT = ImmutableMap.of("s3n,s3a", "s3"); + private static final int MAX_DRIVER_LISTING_DEPTH = 3; + private static final int MAX_DRIVER_LISTING_DIRECT_SUB_DIRS = 10; + private static final int MAX_EXECUTOR_LISTING_DEPTH = 2000; + private static final int MAX_EXECUTOR_LISTING_DIRECT_SUB_DIRS = Integer.MAX_VALUE; + private static final int DELETE_GROUP_SIZE = 100000; + + private final SerializableConfiguration hadoopConf; + private final int listingParallelism; + private final Table table; + private Map equalSchemes = flattenMap(EQUAL_SCHEMES_DEFAULT); + private Map equalAuthorities = Collections.emptyMap(); + private PrefixMismatchMode prefixMismatchMode = PrefixMismatchMode.ERROR; + private String location; + private long olderThanTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(3); + private Dataset compareToFileList; + private Consumer deleteFunc = null; + private ExecutorService deleteExecutorService = null; + private boolean usePrefixListing = false; + private static final Encoder FILE_URI_ENCODER = Encoders.bean(FileURI.class); + + DeleteOrphanFilesSparkAction(SparkSession spark, Table table) { + super(spark); + + this.hadoopConf = new SerializableConfiguration(spark.sessionState().newHadoopConf()); + this.listingParallelism = spark.sessionState().conf().parallelPartitionDiscoveryParallelism(); + this.table = table; + this.location = table.location(); + + ValidationException.check( + PropertyUtil.propertyAsBoolean(table.properties(), GC_ENABLED, GC_ENABLED_DEFAULT), + "Cannot delete orphan files: GC is disabled (deleting files may corrupt other tables)"); + } + + @Override + protected DeleteOrphanFilesSparkAction self() { + return this; + } + + @Override + public DeleteOrphanFilesSparkAction executeDeleteWith(ExecutorService executorService) { + this.deleteExecutorService = executorService; + return this; + } + + @Override + public DeleteOrphanFilesSparkAction prefixMismatchMode(PrefixMismatchMode newPrefixMismatchMode) { + this.prefixMismatchMode = newPrefixMismatchMode; + return this; + } + + @Override + public DeleteOrphanFilesSparkAction equalSchemes(Map newEqualSchemes) { + this.equalSchemes = Maps.newHashMap(); + equalSchemes.putAll(flattenMap(EQUAL_SCHEMES_DEFAULT)); + equalSchemes.putAll(flattenMap(newEqualSchemes)); + return this; + } + + @Override + public DeleteOrphanFilesSparkAction equalAuthorities(Map newEqualAuthorities) { + this.equalAuthorities = Maps.newHashMap(); + equalAuthorities.putAll(flattenMap(newEqualAuthorities)); + return this; + } + + @Override + public DeleteOrphanFilesSparkAction location(String newLocation) { + this.location = newLocation; + return this; + } + + @Override + public DeleteOrphanFilesSparkAction olderThan(long newOlderThanTimestamp) { + this.olderThanTimestamp = newOlderThanTimestamp; + return this; + } + + @Override + public DeleteOrphanFilesSparkAction deleteWith(Consumer newDeleteFunc) { + this.deleteFunc = newDeleteFunc; + return this; + } + + public DeleteOrphanFilesSparkAction compareToFileList(Dataset files) { + StructType schema = files.schema(); + + StructField filePathField = schema.apply(FILE_PATH); + Preconditions.checkArgument( + filePathField.dataType() == DataTypes.StringType, + "Invalid %s column: %s is not a string", + FILE_PATH, + filePathField.dataType()); + + StructField lastModifiedField = schema.apply(LAST_MODIFIED); + Preconditions.checkArgument( + lastModifiedField.dataType() == DataTypes.TimestampType, + "Invalid %s column: %s is not a timestamp", + LAST_MODIFIED, + lastModifiedField.dataType()); + + this.compareToFileList = files; + return this; + } + + public DeleteOrphanFilesSparkAction usePrefixListing(boolean newUsePrefixListing) { + this.usePrefixListing = newUsePrefixListing; + return this; + } + + private Dataset filteredCompareToFileList() { + Dataset files = compareToFileList; + if (location != null) { + files = files.filter(files.col(FILE_PATH).startsWith(location)); + } + return files + .filter(files.col(LAST_MODIFIED).lt(new Timestamp(olderThanTimestamp))) + .select(files.col(FILE_PATH)) + .as(Encoders.STRING()); + } + + @Override + public DeleteOrphanFiles.Result execute() { + JobGroupInfo info = newJobGroupInfo("DELETE-ORPHAN-FILES", jobDesc()); + return withJobGroupInfo(info, this::doExecute); + } + + private String jobDesc() { + List options = Lists.newArrayList(); + options.add("older_than=" + olderThanTimestamp); + if (location != null) { + options.add("location=" + location); + } + String optionsAsString = COMMA_JOINER.join(options); + return String.format("Deleting orphan files (%s) from %s", optionsAsString, table.name()); + } + + private boolean streamResults() { + return PropertyUtil.propertyAsBoolean(options(), STREAM_RESULTS, STREAM_RESULTS_DEFAULT); + } + + private DeleteOrphanFiles.Result doExecute() { + Dataset actualFileIdentDS = actualFileIdentDS(); + Dataset validFileIdentDS = validFileIdentDS(); + + Dataset orphanFileDS = + findOrphanFiles(actualFileIdentDS, validFileIdentDS, prefixMismatchMode); + try { + return deleteFiles(orphanFileDS); + } finally { + orphanFileDS.unpersist(); + } + } + + /** + * Deletes orphan files from the cached dataset. + * + * @param orphanFileDS the cached dataset of orphan files + * @return result with orphan file paths + */ + private DeleteOrphanFiles.Result deleteFiles(Dataset orphanFileDS) { + int maxSampleSize = + PropertyUtil.propertyAsInt( + options(), MAX_ORPHAN_FILE_SAMPLE_SIZE, MAX_ORPHAN_FILE_SAMPLE_SIZE_DEFAULT); + List orphanFileList = Lists.newArrayListWithCapacity(maxSampleSize); + long filesCount = 0; + + Iterator orphanFiles = + streamResults() ? orphanFileDS.toLocalIterator() : orphanFileDS.collectAsList().iterator(); + + Iterator> fileGroups = Iterators.partition(orphanFiles, DELETE_GROUP_SIZE); + + while (fileGroups.hasNext()) { + List fileGroup = fileGroups.next(); + + collectPathsForOutput(fileGroup, orphanFileList, maxSampleSize); + + if (deleteFunc == null && table.io() instanceof SupportsBulkOperations) { + deleteBulk((SupportsBulkOperations) table.io(), fileGroup); + } else { + deleteNonBulk(fileGroup); + } + + filesCount += fileGroup.size(); + } + + LOG.info("Deleted {} orphan files", filesCount); + + return ImmutableDeleteOrphanFiles.Result.builder() + .orphanFileLocations(orphanFileList) + .orphanFilesCount(filesCount) + .build(); + } + + private void collectPathsForOutput( + List paths, List orphanFileList, int maxSampleSize) { + if (streamResults()) { + int lengthToAdd = Math.min(maxSampleSize - orphanFileList.size(), paths.size()); + orphanFileList.addAll(paths.subList(0, lengthToAdd)); + } else { + orphanFileList.addAll(paths); + } + } + + private void deleteBulk(SupportsBulkOperations io, List paths) { + try { + io.deleteFiles(paths); + LOG.info("Deleted {} files using bulk deletes", paths.size()); + } catch (BulkDeletionFailureException e) { + int deletedFilesCount = paths.size() - e.numberFailedObjects(); + LOG.warn( + "Deleted only {} of {} files using bulk deletes", deletedFilesCount, paths.size(), e); + } + } + + private void deleteNonBulk(List paths) { + Tasks.Builder deleteTasks = + Tasks.foreach(paths) + .noRetry() + .executeWith(deleteExecutorService) + .suppressFailureWhenFinished() + .onFailure((file, exc) -> LOG.warn("Failed to delete file: {}", file, exc)); + + if (deleteFunc == null) { + LOG.info( + "Table IO {} does not support bulk operations. Using non-bulk deletes.", + table.io().getClass().getName()); + deleteTasks.run(table.io()::deleteFile); + } else { + LOG.info("Custom delete function provided. Using non-bulk deletes"); + deleteTasks.run(deleteFunc::accept); + } + } + + @VisibleForTesting + static Dataset findOrphanFiles( + Dataset actualFileIdentDS, + Dataset validFileIdentDS, + PrefixMismatchMode prefixMismatchMode) { + + SetAccumulator> conflicts = new SetAccumulator<>(); + actualFileIdentDS.sparkSession().sparkContext().register(conflicts); + + Column joinCond = actualFileIdentDS.col("path").equalTo(validFileIdentDS.col("path")); + + Dataset orphanFileDS = + actualFileIdentDS + .joinWith(validFileIdentDS, joinCond, "leftouter") + .mapPartitions(new FindOrphanFiles(prefixMismatchMode, conflicts), Encoders.STRING()); + + // Cache and force computation to populate conflicts accumulator + orphanFileDS = orphanFileDS.cache(); + + try { + orphanFileDS.count(); + + if (prefixMismatchMode == PrefixMismatchMode.ERROR && !conflicts.value().isEmpty()) { + throw new ValidationException( + "Unable to determine whether certain files are orphan. Metadata references files that" + + " match listed/provided files except for authority/scheme. Please, inspect the" + + " conflicting authorities/schemes and provide which of them are equal by further" + + " configuring the action via equalSchemes() and equalAuthorities() methods. Set the" + + " prefix mismatch mode to 'NONE' to ignore remaining locations with conflicting" + + " authorities/schemes or to 'DELETE' iff you are ABSOLUTELY confident that" + + " remaining conflicting authorities/schemes are different. It will be impossible to" + + " recover deleted files. Conflicting authorities/schemes: %s.", + conflicts.value()); + } + + return orphanFileDS; + } catch (Exception e) { + orphanFileDS.unpersist(); + throw e; + } + } + + private Dataset validFileIdentDS() { + // transform before union to avoid extra serialization/deserialization + FileInfoToFileURI toFileURI = new FileInfoToFileURI(equalSchemes, equalAuthorities); + + Dataset contentFileIdentDS = toFileURI.apply(contentFileDS(table)); + Dataset manifestFileIdentDS = toFileURI.apply(manifestDS(table)); + Dataset manifestListIdentDS = toFileURI.apply(manifestListDS(table)); + Dataset otherMetadataFileIdentDS = toFileURI.apply(otherMetadataFileDS(table)); + + return contentFileIdentDS + .union(manifestFileIdentDS) + .union(manifestListIdentDS) + .union(otherMetadataFileIdentDS); + } + + private Dataset actualFileIdentDS() { + StringToFileURI toFileURI = new StringToFileURI(equalSchemes, equalAuthorities); + if (compareToFileList == null) { + return toFileURI.apply(listedFileDS()); + } else { + return toFileURI.apply(filteredCompareToFileList()); + } + } + + private Dataset listedFileDS() { + List subDirs = Lists.newArrayList(); + List matchingFiles = Lists.newArrayList(); + + if (usePrefixListing) { + Preconditions.checkArgument( + table.io() instanceof SupportsPrefixOperations, + "Cannot use prefix listing with FileIO {} which does not support prefix operations.", + table.io()); + + Predicate predicate = + fileInfo -> fileInfo.createdAtMillis() < olderThanTimestamp; + FileSystemWalker.listDirRecursivelyWithFileIO( + (SupportsPrefixOperations) table.io(), + location, + table.specs(), + predicate, + matchingFiles::add); + + JavaRDD matchingFileRDD = sparkContext().parallelize(matchingFiles, 1); + return spark().createDataset(matchingFileRDD.rdd(), Encoders.STRING()); + } else { + Predicate predicate = file -> file.getModificationTime() < olderThanTimestamp; + // list at most MAX_DRIVER_LISTING_DEPTH levels and only dirs that have + // less than MAX_DRIVER_LISTING_DIRECT_SUB_DIRS direct sub dirs on the driver + FileSystemWalker.listDirRecursivelyWithHadoop( + location, + table.specs(), + predicate, + hadoopConf.value(), + MAX_DRIVER_LISTING_DEPTH, + MAX_DRIVER_LISTING_DIRECT_SUB_DIRS, + subDirs::add, + matchingFiles::add); + + JavaRDD matchingFileRDD = sparkContext().parallelize(matchingFiles, 1); + + if (subDirs.isEmpty()) { + return spark().createDataset(matchingFileRDD.rdd(), Encoders.STRING()); + } + + int parallelism = Math.min(subDirs.size(), listingParallelism); + JavaRDD subDirRDD = sparkContext().parallelize(subDirs, parallelism); + + Broadcast conf = sparkContext().broadcast(hadoopConf); + ListDirsRecursively listDirs = + new ListDirsRecursively(conf, olderThanTimestamp, table.specs()); + JavaRDD matchingLeafFileRDD = subDirRDD.mapPartitions(listDirs); + + JavaRDD completeMatchingFileRDD = matchingFileRDD.union(matchingLeafFileRDD); + return spark().createDataset(completeMatchingFileRDD.rdd(), Encoders.STRING()); + } + } + + private static Map flattenMap(Map map) { + Map flattenedMap = Maps.newHashMap(); + if (map != null) { + for (String key : map.keySet()) { + String value = map.get(key); + for (String splitKey : COMMA_SPLITTER.split(key)) { + flattenedMap.put(splitKey.trim(), value.trim()); + } + } + } + return flattenedMap; + } + + private static class ListDirsRecursively implements FlatMapFunction, String> { + + private final Broadcast hadoopConf; + private final long olderThanTimestamp; + private final Map specs; + + ListDirsRecursively( + Broadcast hadoopConf, + long olderThanTimestamp, + Map specs) { + + this.hadoopConf = hadoopConf; + this.olderThanTimestamp = olderThanTimestamp; + this.specs = specs; + } + + @Override + public Iterator call(Iterator dirs) throws Exception { + List subDirs = Lists.newArrayList(); + List files = Lists.newArrayList(); + + Predicate predicate = file -> file.getModificationTime() < olderThanTimestamp; + + while (dirs.hasNext()) { + FileSystemWalker.listDirRecursivelyWithHadoop( + dirs.next(), + specs, + predicate, + hadoopConf.value().value(), + MAX_EXECUTOR_LISTING_DEPTH, + MAX_EXECUTOR_LISTING_DIRECT_SUB_DIRS, + subDirs::add, + files::add); + } + + if (!subDirs.isEmpty()) { + throw new RuntimeException( + "Could not list sub directories, reached maximum depth: " + MAX_EXECUTOR_LISTING_DEPTH); + } + + return files.iterator(); + } + } + + private static class FindOrphanFiles + implements MapPartitionsFunction, String> { + + private final PrefixMismatchMode mode; + private final SetAccumulator> conflicts; + + FindOrphanFiles(PrefixMismatchMode mode, SetAccumulator> conflicts) { + this.mode = mode; + this.conflicts = conflicts; + } + + @Override + public Iterator call(Iterator> rows) throws Exception { + Iterator orphanFiles = Iterators.transform(rows, this::toOrphanFile); + return Iterators.filter(orphanFiles, Objects::nonNull); + } + + private String toOrphanFile(Tuple2 row) { + FileURI actual = row._1; + FileURI valid = row._2; + + if (valid == null) { + return actual.getUriAsString(); + } + + boolean schemeMatch = valid.schemeMatch(actual); + boolean authorityMatch = valid.authorityMatch(actual); + + if ((!schemeMatch || !authorityMatch) && mode == PrefixMismatchMode.DELETE) { + return actual.getUriAsString(); + } else { + if (!schemeMatch) { + conflicts.add(Pair.of(valid.getScheme(), actual.getScheme())); + } + + if (!authorityMatch) { + conflicts.add(Pair.of(valid.getAuthority(), actual.getAuthority())); + } + + return null; + } + } + } + + @VisibleForTesting + static class StringToFileURI extends ToFileURI { + StringToFileURI(Map equalSchemes, Map equalAuthorities) { + super(equalSchemes, equalAuthorities); + } + + @Override + protected String uriAsString(String input) { + return input; + } + } + + @VisibleForTesting + static class FileInfoToFileURI extends ToFileURI { + FileInfoToFileURI(Map equalSchemes, Map equalAuthorities) { + super(equalSchemes, equalAuthorities); + } + + @Override + protected String uriAsString(FileInfo fileInfo) { + return fileInfo.getPath(); + } + } + + private abstract static class ToFileURI implements MapPartitionsFunction { + + private final Map equalSchemes; + private final Map equalAuthorities; + + ToFileURI(Map equalSchemes, Map equalAuthorities) { + this.equalSchemes = equalSchemes; + this.equalAuthorities = equalAuthorities; + } + + protected abstract String uriAsString(I input); + + Dataset apply(Dataset ds) { + return ds.mapPartitions(this, FILE_URI_ENCODER); + } + + @Override + public Iterator call(Iterator rows) throws Exception { + return Iterators.transform(rows, this::toFileURI); + } + + private FileURI toFileURI(I input) { + String uriAsString = uriAsString(input); + URI uri = new Path(uriAsString).toUri(); + String scheme = equalSchemes.getOrDefault(uri.getScheme(), uri.getScheme()); + String authority = equalAuthorities.getOrDefault(uri.getAuthority(), uri.getAuthority()); + return new FileURI(scheme, authority, uri.getPath(), uriAsString); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteReachableFilesSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteReachableFilesSparkAction.java new file mode 100644 index 000000000000..ea6ac9f3dbf5 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/DeleteReachableFilesSparkAction.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.TableProperties.GC_ENABLED; +import static org.apache.iceberg.TableProperties.GC_ENABLED_DEFAULT; + +import java.util.Iterator; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableMetadataParser; +import org.apache.iceberg.actions.DeleteReachableFiles; +import org.apache.iceberg.actions.ImmutableDeleteReachableFiles; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.SparkSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An implementation of {@link DeleteReachableFiles} that uses metadata tables in Spark to determine + * which files should be deleted. + */ +@SuppressWarnings("UnnecessaryAnonymousClass") +public class DeleteReachableFilesSparkAction + extends BaseSparkAction implements DeleteReachableFiles { + + public static final String STREAM_RESULTS = "stream-results"; + public static final boolean STREAM_RESULTS_DEFAULT = false; + + private static final Logger LOG = LoggerFactory.getLogger(DeleteReachableFilesSparkAction.class); + + private final String metadataFileLocation; + + private Consumer deleteFunc = null; + private ExecutorService deleteExecutorService = null; + private FileIO io = new HadoopFileIO(spark().sessionState().newHadoopConf()); + + DeleteReachableFilesSparkAction(SparkSession spark, String metadataFileLocation) { + super(spark); + this.metadataFileLocation = metadataFileLocation; + } + + @Override + protected DeleteReachableFilesSparkAction self() { + return this; + } + + @Override + public DeleteReachableFilesSparkAction io(FileIO fileIO) { + this.io = fileIO; + return this; + } + + @Override + public DeleteReachableFilesSparkAction deleteWith(Consumer newDeleteFunc) { + this.deleteFunc = newDeleteFunc; + return this; + } + + @Override + public DeleteReachableFilesSparkAction executeDeleteWith(ExecutorService executorService) { + this.deleteExecutorService = executorService; + return this; + } + + @Override + public Result execute() { + Preconditions.checkArgument(io != null, "File IO cannot be null"); + String jobDesc = String.format("Deleting files reachable from %s", metadataFileLocation); + JobGroupInfo info = newJobGroupInfo("DELETE-REACHABLE-FILES", jobDesc); + return withJobGroupInfo(info, this::doExecute); + } + + private Result doExecute() { + TableMetadata metadata = TableMetadataParser.read(io, metadataFileLocation); + + ValidationException.check( + PropertyUtil.propertyAsBoolean(metadata.properties(), GC_ENABLED, GC_ENABLED_DEFAULT), + "Cannot delete files: GC is disabled (deleting files may corrupt other tables)"); + + Dataset reachableFileDS = reachableFileDS(metadata); + + if (streamResults()) { + return deleteFiles(reachableFileDS.toLocalIterator()); + } else { + return deleteFiles(reachableFileDS.collectAsList().iterator()); + } + } + + private boolean streamResults() { + return PropertyUtil.propertyAsBoolean(options(), STREAM_RESULTS, STREAM_RESULTS_DEFAULT); + } + + private Dataset reachableFileDS(TableMetadata metadata) { + Table staticTable = newStaticTable(metadata, io); + return contentFileDS(staticTable) + .union(manifestDS(staticTable)) + .union(manifestListDS(staticTable)) + .union(allReachableOtherMetadataFileDS(staticTable)) + .distinct(); + } + + private DeleteReachableFiles.Result deleteFiles(Iterator files) { + DeleteSummary summary; + if (deleteFunc == null && io instanceof SupportsBulkOperations) { + summary = deleteFiles((SupportsBulkOperations) io, files); + } else { + + if (deleteFunc == null) { + LOG.info( + "Table IO {} does not support bulk operations. Using non-bulk deletes.", + io.getClass().getName()); + summary = deleteFiles(deleteExecutorService, io::deleteFile, files); + } else { + LOG.info("Custom delete function provided. Using non-bulk deletes"); + summary = deleteFiles(deleteExecutorService, deleteFunc, files); + } + } + + LOG.info("Deleted {} total files", summary.totalFilesCount()); + + return ImmutableDeleteReachableFiles.Result.builder() + .deletedDataFilesCount(summary.dataFilesCount()) + .deletedPositionDeleteFilesCount(summary.positionDeleteFilesCount()) + .deletedEqualityDeleteFilesCount(summary.equalityDeleteFilesCount()) + .deletedManifestsCount(summary.manifestsCount()) + .deletedManifestListsCount(summary.manifestListsCount()) + .deletedOtherFilesCount(summary.otherFilesCount()) + .build(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java new file mode 100644 index 000000000000..e49e7326736f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ExpireSnapshotsSparkAction.java @@ -0,0 +1,286 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.TableProperties.GC_ENABLED; +import static org.apache.iceberg.TableProperties.GC_ENABLED_DEFAULT; + +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.apache.iceberg.ExpireSnapshots.CleanupLevel; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.actions.ExpireSnapshots; +import org.apache.iceberg.actions.ImmutableExpireSnapshots; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.SparkSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An action that performs the same operation as {@link org.apache.iceberg.ExpireSnapshots} but uses + * Spark to determine the delta in files between the pre and post-expiration table metadata. All of + * the same restrictions of {@link org.apache.iceberg.ExpireSnapshots} also apply to this action. + * + *

    This action first leverages {@link org.apache.iceberg.ExpireSnapshots} to expire snapshots and + * then uses metadata tables to find files that can be safely deleted. This is done by anti-joining + * two Datasets that contain all manifest and content files before and after the expiration. The + * snapshot expiration will be fully committed before any deletes are issued. + * + *

    This operation performs a shuffle so the parallelism can be controlled through + * 'spark.sql.shuffle.partitions'. + * + *

    Deletes are still performed locally after retrieving the results from the Spark executors. + */ +@SuppressWarnings("UnnecessaryAnonymousClass") +public class ExpireSnapshotsSparkAction extends BaseSparkAction + implements ExpireSnapshots { + + public static final String STREAM_RESULTS = "stream-results"; + public static final boolean STREAM_RESULTS_DEFAULT = false; + + private static final Logger LOG = LoggerFactory.getLogger(ExpireSnapshotsSparkAction.class); + + private final Table table; + private final TableOperations ops; + + private final Set expiredSnapshotIds = Sets.newHashSet(); + private Long expireOlderThanValue = null; + private Integer retainLastValue = null; + private Consumer deleteFunc = null; + private ExecutorService deleteExecutorService = null; + private Dataset expiredFileDS = null; + private Boolean cleanExpiredMetadata = null; + + ExpireSnapshotsSparkAction(SparkSession spark, Table table) { + super(spark); + this.table = table; + this.ops = ((HasTableOperations) table).operations(); + + ValidationException.check( + PropertyUtil.propertyAsBoolean(table.properties(), GC_ENABLED, GC_ENABLED_DEFAULT), + "Cannot expire snapshots: GC is disabled (deleting files may corrupt other tables)"); + } + + @Override + protected ExpireSnapshotsSparkAction self() { + return this; + } + + @Override + public ExpireSnapshotsSparkAction executeDeleteWith(ExecutorService executorService) { + this.deleteExecutorService = executorService; + return this; + } + + @Override + public ExpireSnapshotsSparkAction expireSnapshotId(long snapshotId) { + expiredSnapshotIds.add(snapshotId); + return this; + } + + @Override + public ExpireSnapshotsSparkAction expireOlderThan(long timestampMillis) { + this.expireOlderThanValue = timestampMillis; + return this; + } + + @Override + public ExpireSnapshotsSparkAction retainLast(int numSnapshots) { + Preconditions.checkArgument( + 1 <= numSnapshots, + "Number of snapshots to retain must be at least 1, cannot be: %s", + numSnapshots); + this.retainLastValue = numSnapshots; + return this; + } + + @Override + public ExpireSnapshotsSparkAction deleteWith(Consumer newDeleteFunc) { + this.deleteFunc = newDeleteFunc; + return this; + } + + @Override + public ExpireSnapshotsSparkAction cleanExpiredMetadata(boolean clean) { + this.cleanExpiredMetadata = clean; + return this; + } + + /** + * Expires snapshots and commits the changes to the table, returning a Dataset of files to delete. + * + *

    This does not delete data files. To delete data files, run {@link #execute()}. + * + *

    This may be called before or after {@link #execute()} to return the expired files. + * + * @return a Dataset of files that are no longer referenced by the table + */ + public Dataset expireFiles() { + if (expiredFileDS == null) { + // fetch metadata before expiration + TableMetadata originalMetadata = ops.current(); + + // perform expiration + org.apache.iceberg.ExpireSnapshots expireSnapshots = table.expireSnapshots(); + + for (long id : expiredSnapshotIds) { + expireSnapshots = expireSnapshots.expireSnapshotId(id); + } + + if (expireOlderThanValue != null) { + expireSnapshots = expireSnapshots.expireOlderThan(expireOlderThanValue); + } + + if (retainLastValue != null) { + expireSnapshots = expireSnapshots.retainLast(retainLastValue); + } + + if (cleanExpiredMetadata != null) { + expireSnapshots.cleanExpiredMetadata(cleanExpiredMetadata); + } + + expireSnapshots.cleanupLevel(CleanupLevel.NONE).commit(); + + // fetch valid files after expiration + TableMetadata updatedMetadata = ops.refresh(); + Dataset validFileDS = fileDS(updatedMetadata); + + // fetch files referenced by expired snapshots + Set deletedSnapshotIds = findExpiredSnapshotIds(originalMetadata, updatedMetadata); + Dataset deleteCandidateFileDS = fileDS(originalMetadata, deletedSnapshotIds); + + // determine expired files + this.expiredFileDS = deleteCandidateFileDS.except(validFileDS); + } + + return expiredFileDS; + } + + @Override + public ExpireSnapshots.Result execute() { + JobGroupInfo info = newJobGroupInfo("EXPIRE-SNAPSHOTS", jobDesc()); + return withJobGroupInfo(info, this::doExecute); + } + + private String jobDesc() { + List options = Lists.newArrayList(); + + if (expireOlderThanValue != null) { + options.add("older_than=" + expireOlderThanValue); + } + + if (retainLastValue != null) { + options.add("retain_last=" + retainLastValue); + } + + if (!expiredSnapshotIds.isEmpty()) { + Long first = expiredSnapshotIds.stream().findFirst().get(); + if (expiredSnapshotIds.size() > 1) { + options.add( + String.format("snapshot_ids: %s (%s more...)", first, expiredSnapshotIds.size() - 1)); + } else { + options.add(String.format("snapshot_id: %s", first)); + } + } + + if (cleanExpiredMetadata != null) { + options.add("clean_expired_metadata=" + cleanExpiredMetadata); + } + + return String.format("Expiring snapshots (%s) in %s", COMMA_JOINER.join(options), table.name()); + } + + private ExpireSnapshots.Result doExecute() { + if (streamResults()) { + return deleteFiles(expireFiles().toLocalIterator()); + } else { + return deleteFiles(expireFiles().collectAsList().iterator()); + } + } + + private boolean streamResults() { + return PropertyUtil.propertyAsBoolean(options(), STREAM_RESULTS, STREAM_RESULTS_DEFAULT); + } + + private Dataset fileDS(TableMetadata metadata) { + return fileDS(metadata, null); + } + + private Dataset fileDS(TableMetadata metadata, Set snapshotIds) { + Table staticTable = newStaticTable(metadata, table.io()); + return contentFileDS(staticTable, snapshotIds) + .union(manifestDS(staticTable, snapshotIds)) + .union(manifestListDS(staticTable, snapshotIds)) + .union(statisticsFileDS(staticTable, snapshotIds)); + } + + private Set findExpiredSnapshotIds( + TableMetadata originalMetadata, TableMetadata updatedMetadata) { + Set retainedSnapshots = + updatedMetadata.snapshots().stream().map(Snapshot::snapshotId).collect(Collectors.toSet()); + return originalMetadata.snapshots().stream() + .map(Snapshot::snapshotId) + .filter(id -> !retainedSnapshots.contains(id)) + .collect(Collectors.toSet()); + } + + private ExpireSnapshots.Result deleteFiles(Iterator files) { + DeleteSummary summary; + if (deleteFunc == null && table.io() instanceof SupportsBulkOperations) { + summary = deleteFiles((SupportsBulkOperations) table.io(), files); + } else { + + if (deleteFunc == null) { + LOG.info( + "Table IO {} does not support bulk operations. Using non-bulk deletes.", + table.io().getClass().getName()); + summary = deleteFiles(deleteExecutorService, table.io()::deleteFile, files); + } else { + LOG.info("Custom delete function provided. Using non-bulk deletes"); + summary = deleteFiles(deleteExecutorService, deleteFunc, files); + } + } + + LOG.info("Deleted {} total files", summary.totalFilesCount()); + + return ImmutableExpireSnapshots.Result.builder() + .deletedDataFilesCount(summary.dataFilesCount()) + .deletedPositionDeleteFilesCount(summary.positionDeleteFilesCount()) + .deletedEqualityDeleteFilesCount(summary.equalityDeleteFilesCount()) + .deletedManifestsCount(summary.manifestsCount()) + .deletedManifestListsCount(summary.manifestListsCount()) + .deletedStatisticsFilesCount(summary.statisticsFilesCount()) + .build(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/FileInfo.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/FileInfo.java new file mode 100644 index 000000000000..51ff7c80fd18 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/FileInfo.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; + +public class FileInfo { + public static final Encoder ENCODER = Encoders.bean(FileInfo.class); + + private String path; + private String type; + + public FileInfo(String path, String type) { + this.path = path; + this.type = type; + } + + public FileInfo() {} + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java new file mode 100644 index 000000000000..599e27b71c45 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/ManifestFileBean.java @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.util.List; +import org.apache.iceberg.ManifestContent; +import org.apache.iceberg.ManifestFile; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; + +/** A serializable bean that contains a bare minimum to read a manifest. */ +public class ManifestFileBean implements ManifestFile, Serializable { + public static final Encoder ENCODER = Encoders.bean(ManifestFileBean.class); + + private String path = null; + private Long length = null; + private Integer partitionSpecId = null; + private Long addedSnapshotId = null; + private Integer content = null; + private Long sequenceNumber = null; + private Long firstRowId = null; + private byte[] keyMetadata = null; + + public static ManifestFileBean fromManifest(ManifestFile manifest) { + ManifestFileBean bean = new ManifestFileBean(); + + bean.setPath(manifest.path()); + bean.setLength(manifest.length()); + bean.setPartitionSpecId(manifest.partitionSpecId()); + bean.setAddedSnapshotId(manifest.snapshotId()); + bean.setContent(manifest.content().id()); + bean.setSequenceNumber(manifest.sequenceNumber()); + bean.setFirstRowId(manifest.firstRowId()); + bean.setKeyMetadata(manifest.keyMetadata() == null ? null : manifest.keyMetadata().array()); + + return bean; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Long getLength() { + return length; + } + + public void setLength(Long length) { + this.length = length; + } + + public Integer getPartitionSpecId() { + return partitionSpecId; + } + + public void setPartitionSpecId(Integer partitionSpecId) { + this.partitionSpecId = partitionSpecId; + } + + public Long getAddedSnapshotId() { + return addedSnapshotId; + } + + public void setAddedSnapshotId(Long addedSnapshotId) { + this.addedSnapshotId = addedSnapshotId; + } + + public Integer getContent() { + return content; + } + + public void setContent(Integer content) { + this.content = content; + } + + public Long getSequenceNumber() { + return sequenceNumber; + } + + public void setSequenceNumber(Long sequenceNumber) { + this.sequenceNumber = sequenceNumber; + } + + public void setFirstRowId(Long firstRowId) { + this.firstRowId = firstRowId; + } + + public byte[] getKeyMetadata() { + return keyMetadata; + } + + public void setKeyMetadata(byte[] keyMetadata) { + this.keyMetadata = keyMetadata; + } + + @Override + public String path() { + return path; + } + + @Override + public long length() { + return length; + } + + @Override + public int partitionSpecId() { + return partitionSpecId; + } + + @Override + public ManifestContent content() { + return ManifestContent.fromId(content); + } + + @Override + public long sequenceNumber() { + return sequenceNumber; + } + + @Override + public long minSequenceNumber() { + return 0; + } + + @Override + public Long snapshotId() { + return addedSnapshotId; + } + + @Override + public Integer addedFilesCount() { + return null; + } + + @Override + public Long addedRowsCount() { + return null; + } + + @Override + public Integer existingFilesCount() { + return null; + } + + @Override + public Long existingRowsCount() { + return null; + } + + @Override + public Integer deletedFilesCount() { + return null; + } + + @Override + public Long deletedRowsCount() { + return null; + } + + @Override + public List partitions() { + return null; + } + + @Override + public ByteBuffer keyMetadata() { + return keyMetadata == null ? null : ByteBuffer.wrap(keyMetadata); + } + + @Override + public Long firstRowId() { + return firstRowId; + } + + @Override + public ManifestFile copy() { + throw new UnsupportedOperationException("Cannot copy"); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/MigrateTableSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/MigrateTableSparkAction.java new file mode 100644 index 000000000000..bdffeb465405 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/MigrateTableSparkAction.java @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.Map; +import java.util.concurrent.ExecutorService; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ImmutableMigrateTable; +import org.apache.iceberg.actions.MigrateTable; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.source.StagedSparkTable; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.TableIdentifier; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.StagingTableCatalog; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.Some; +import scala.collection.JavaConverters; + +/** + * Takes a Spark table in the source catalog and attempts to transform it into an Iceberg table in + * the same location with the same identifier. Once complete the identifier which previously + * referred to a non-Iceberg table will refer to the newly migrated Iceberg table. + */ +public class MigrateTableSparkAction extends BaseTableCreationSparkAction + implements MigrateTable { + + private static final Logger LOG = LoggerFactory.getLogger(MigrateTableSparkAction.class); + private static final String BACKUP_SUFFIX = "_BACKUP_"; + + private final StagingTableCatalog destCatalog; + private final Identifier destTableIdent; + + private Identifier backupIdent; + private boolean dropBackup = false; + private ExecutorService executorService; + + MigrateTableSparkAction( + SparkSession spark, CatalogPlugin sourceCatalog, Identifier sourceTableIdent) { + super(spark, sourceCatalog, sourceTableIdent); + this.destCatalog = checkDestinationCatalog(sourceCatalog); + this.destTableIdent = sourceTableIdent; + String backupName = sourceTableIdent.name() + BACKUP_SUFFIX; + this.backupIdent = Identifier.of(sourceTableIdent.namespace(), backupName); + } + + @Override + protected MigrateTableSparkAction self() { + return this; + } + + @Override + protected StagingTableCatalog destCatalog() { + return destCatalog; + } + + @Override + protected Identifier destTableIdent() { + return destTableIdent; + } + + @Override + public MigrateTableSparkAction tableProperties(Map properties) { + setProperties(properties); + return this; + } + + @Override + public MigrateTableSparkAction tableProperty(String property, String value) { + setProperty(property, value); + return this; + } + + @Override + public MigrateTableSparkAction dropBackup() { + this.dropBackup = true; + return this; + } + + @Override + public MigrateTableSparkAction backupTableName(String tableName) { + this.backupIdent = Identifier.of(sourceTableIdent().namespace(), tableName); + return this; + } + + @Override + public MigrateTableSparkAction executeWith(ExecutorService service) { + this.executorService = service; + return this; + } + + @Override + public MigrateTable.Result execute() { + String desc = String.format("Migrating table %s", destTableIdent().toString()); + JobGroupInfo info = newJobGroupInfo("MIGRATE-TABLE", desc); + return withJobGroupInfo(info, this::doExecute); + } + + private MigrateTable.Result doExecute() { + LOG.info("Starting the migration of {} to Iceberg", sourceTableIdent()); + + // move the source table to a new name, halting all modifications and allowing us to stage + // the creation of a new Iceberg table in its place + renameAndBackupSourceTable(); + + StagedSparkTable stagedTable = null; + Table icebergTable; + boolean threw = true; + try { + LOG.info("Staging a new Iceberg table {}", destTableIdent()); + stagedTable = stageDestTable(); + icebergTable = stagedTable.table(); + + LOG.info("Ensuring {} has a valid name mapping", destTableIdent()); + ensureNameMappingPresent(icebergTable); + + Some backupNamespace = Some.apply(backupIdent.namespace()[0]); + TableIdentifier v1BackupIdent = new TableIdentifier(backupIdent.name(), backupNamespace); + String stagingLocation = getMetadataLocation(icebergTable); + LOG.info("Generating Iceberg metadata for {} in {}", destTableIdent(), stagingLocation); + SparkTableUtil.importSparkTable( + spark(), v1BackupIdent, icebergTable, stagingLocation, executorService); + + LOG.info("Committing staged changes to {}", destTableIdent()); + stagedTable.commitStagedChanges(); + threw = false; + } finally { + if (threw) { + LOG.error( + "Failed to perform the migration, aborting table creation and restoring the original table"); + + restoreSourceTable(); + + if (stagedTable != null) { + try { + stagedTable.abortStagedChanges(); + } catch (Exception abortException) { + LOG.error("Cannot abort staged changes", abortException); + } + } + } else if (dropBackup) { + dropBackupTable(); + } + } + + Snapshot snapshot = icebergTable.currentSnapshot(); + long migratedDataFilesCount = + Long.parseLong(snapshot.summary().get(SnapshotSummary.TOTAL_DATA_FILES_PROP)); + LOG.info( + "Successfully loaded Iceberg metadata for {} files to {}", + migratedDataFilesCount, + destTableIdent()); + return ImmutableMigrateTable.Result.builder() + .migratedDataFilesCount(migratedDataFilesCount) + .build(); + } + + @Override + protected Map destTableProps() { + Map properties = Maps.newHashMap(); + + // copy over relevant source table props + properties.putAll(JavaConverters.mapAsJavaMapConverter(v1SourceTable().properties()).asJava()); + EXCLUDED_PROPERTIES.forEach(properties::remove); + + // set default and user-provided props + properties.put(TableCatalog.PROP_PROVIDER, "iceberg"); + properties.putAll(additionalProperties()); + + // make sure we mark this table as migrated + properties.put("migrated", "true"); + + // inherit the source table location + properties.putIfAbsent(LOCATION, sourceTableLocation()); + + return properties; + } + + @Override + protected TableCatalog checkSourceCatalog(CatalogPlugin catalog) { + // currently the import code relies on being able to look up the table in the session catalog + Preconditions.checkArgument( + catalog instanceof SparkSessionCatalog, + "Cannot migrate a table from a non-Iceberg Spark Session Catalog. Found %s of class %s as the source catalog.", + catalog.name(), + catalog.getClass().getName()); + + return (TableCatalog) catalog; + } + + private void renameAndBackupSourceTable() { + try { + LOG.info("Renaming {} as {} for backup", sourceTableIdent(), backupIdent); + destCatalog().renameTable(sourceTableIdent(), backupIdent); + + } catch (org.apache.spark.sql.catalyst.analysis.NoSuchTableException e) { + throw new NoSuchTableException("Cannot find source table %s", sourceTableIdent()); + + } catch (org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException e) { + throw new AlreadyExistsException( + "Cannot rename %s as %s for backup. The backup table already exists.", + sourceTableIdent(), backupIdent); + } + } + + private void restoreSourceTable() { + try { + LOG.info("Restoring {} from {}", sourceTableIdent(), backupIdent); + destCatalog().renameTable(backupIdent, sourceTableIdent()); + + } catch (org.apache.spark.sql.catalyst.analysis.NoSuchTableException e) { + LOG.error( + "Cannot restore the original table, the backup table {} cannot be found", backupIdent, e); + + } catch (org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException e) { + LOG.error( + "Cannot restore the original table, a table with the original name exists. " + + "Use the backup table {} to restore the original table manually.", + backupIdent, + e); + } + } + + private void dropBackupTable() { + try { + destCatalog().dropTable(backupIdent); + } catch (Exception e) { + LOG.error( + "Cannot drop the backup table {}, after the migration is completed.", backupIdent, e); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/NDVSketchUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/NDVSketchUtil.java new file mode 100644 index 000000000000..11b14fafd916 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/NDVSketchUtil.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.nio.ByteBuffer; +import java.util.List; +import org.apache.datasketches.memory.Memory; +import org.apache.datasketches.theta.CompactSketch; +import org.apache.datasketches.theta.Sketch; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.puffin.Blob; +import org.apache.iceberg.puffin.PuffinCompressionCodec; +import org.apache.iceberg.puffin.StandardBlobTypes; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.classic.ExpressionColumnNode; +import org.apache.spark.sql.stats.ThetaSketchAgg; + +public class NDVSketchUtil { + + private NDVSketchUtil() {} + + public static final String APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY = "ndv"; + + static List generateBlobs( + SparkSession spark, Table table, Snapshot snapshot, List columns) { + Row sketches = computeNDVSketches(spark, table, snapshot, columns); + Schema schema = table.schemas().get(snapshot.schemaId()); + List blobs = Lists.newArrayListWithExpectedSize(columns.size()); + + for (int i = 0; i < columns.size(); i++) { + Types.NestedField field = schema.findField(columns.get(i)); + Sketch sketch = CompactSketch.wrap(Memory.wrap((byte[]) sketches.get(i))); + blobs.add(toBlob(field, sketch, snapshot)); + } + return blobs; + } + + private static Blob toBlob(Types.NestedField field, Sketch sketch, Snapshot snapshot) { + return new Blob( + StandardBlobTypes.APACHE_DATASKETCHES_THETA_V1, + ImmutableList.of(field.fieldId()), + snapshot.snapshotId(), + snapshot.sequenceNumber(), + ByteBuffer.wrap(sketch.toByteArray()), + PuffinCompressionCodec.ZSTD, + ImmutableMap.of( + APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY, + String.valueOf((long) sketch.getEstimate()))); + } + + private static Row computeNDVSketches( + SparkSession spark, Table table, Snapshot snapshot, List colNames) { + Dataset inputDF = SparkTableUtil.loadTable(spark, table, snapshot.snapshotId()); + return inputDF.select(toAggColumns(colNames)).first(); + } + + private static Column[] toAggColumns(List colNames) { + return colNames.stream().map(NDVSketchUtil::toAggColumn).toArray(Column[]::new); + } + + private static Column toAggColumn(String colName) { + ThetaSketchAgg agg = new ThetaSketchAgg(colName); + return new Column(ExpressionColumnNode.apply(agg.toAggregateExpression())); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RemoveDanglingDeletesSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RemoveDanglingDeletesSparkAction.java new file mode 100644 index 000000000000..bbe2847a9216 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RemoveDanglingDeletesSparkAction.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.min; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ImmutableRemoveDanglingDeleteFiles; +import org.apache.iceberg.actions.RemoveDanglingDeleteFiles; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.spark.SparkDeleteFile; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.StructType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An action that removes dangling delete files from the current snapshot. A delete file is dangling + * if its deletes no longer applies to any live data files. + * + *

    The following dangling delete files are removed: + * + *

      + *
    • Position delete files with a data sequence number less than that of any data file in the + * same partition + *
    • Equality delete files with a data sequence number less than or equal to that of any data + * file in the same partition + *
    + */ +class RemoveDanglingDeletesSparkAction + extends BaseSnapshotUpdateSparkAction + implements RemoveDanglingDeleteFiles { + + private static final Logger LOG = LoggerFactory.getLogger(RemoveDanglingDeletesSparkAction.class); + private final Table table; + + protected RemoveDanglingDeletesSparkAction(SparkSession spark, Table table) { + super(spark); + this.table = table; + } + + @Override + protected RemoveDanglingDeletesSparkAction self() { + return this; + } + + @Override + public Result execute() { + if (table.specs().size() == 1 && table.spec().isUnpartitioned()) { + // ManifestFilterManager already performs this table-wide delete on each commit + return ImmutableRemoveDanglingDeleteFiles.Result.builder() + .removedDeleteFiles(Collections.emptyList()) + .build(); + } + + String desc = String.format("Removing dangling delete files in %s", table.name()); + JobGroupInfo info = newJobGroupInfo("REMOVE-DELETES", desc); + return withJobGroupInfo(info, this::doExecute); + } + + Result doExecute() { + RewriteFiles rewriteFiles = table.newRewrite(); + DeleteFileSet danglingDeletes = DeleteFileSet.create(); + danglingDeletes.addAll(findDanglingDeletes()); + danglingDeletes.addAll(findDanglingDvs()); + + for (DeleteFile deleteFile : danglingDeletes) { + LOG.debug("Removing dangling delete file {}", deleteFile.location()); + rewriteFiles.deleteFile(deleteFile); + } + + if (!danglingDeletes.isEmpty()) { + commit(rewriteFiles); + } + + return ImmutableRemoveDanglingDeleteFiles.Result.builder() + .removedDeleteFiles(danglingDeletes) + .build(); + } + + /** + * Dangling delete files can be identified with following steps + * + *
      + *
    1. Group data files by partition keys and find the minimum data sequence number in each + * group. + *
    2. Left outer join delete files with partition-grouped data files on partition keys. + *
    3. Find dangling deletes by comparing each delete file's sequence number to its partition's + * minimum data sequence number. + *
    4. Collect results row to driver and use {@link SparkDeleteFile SparkDeleteFile} to wrap + * rows to valid delete files + *
    + */ + private List findDanglingDeletes() { + Dataset minSequenceNumberByPartition = + loadMetadataTable(table, MetadataTableType.ENTRIES) + // find live data files + .filter("data_file.content == 0 AND status < 2") + .selectExpr( + "data_file.partition as partition", + "data_file.spec_id as spec_id", + "sequence_number") + .groupBy("partition", "spec_id") + .agg(min("sequence_number")) + .toDF("grouped_partition", "grouped_spec_id", "min_data_sequence_number"); + + Dataset deleteEntries = + loadMetadataTable(table, MetadataTableType.ENTRIES) + // find live delete files + .filter("data_file.content != 0 AND status < 2"); + + Column joinOnPartition = + deleteEntries + .col("data_file.spec_id") + .equalTo(minSequenceNumberByPartition.col("grouped_spec_id")) + .and( + deleteEntries + .col("data_file.partition") + .equalTo(minSequenceNumberByPartition.col("grouped_partition"))); + + Column filterOnDanglingDeletes = + col("min_data_sequence_number") + // delete files without any data files in partition + .isNull() + // position delete files without any applicable data files in partition + .or( + col("data_file.content") + .equalTo("1") + .and(col("sequence_number").$less(col("min_data_sequence_number")))) + // equality delete files without any applicable data files in the partition + .or( + col("data_file.content") + .equalTo("2") + .and(col("sequence_number").$less$eq(col("min_data_sequence_number")))); + + Dataset danglingDeletes = + deleteEntries + .join(minSequenceNumberByPartition, joinOnPartition, "left") + .filter(filterOnDanglingDeletes) + .select("data_file.*"); + return danglingDeletes.collectAsList().stream() + // map on driver because SparkDeleteFile is not serializable + .map(row -> deleteFileWrapper(danglingDeletes.schema(), row)) + .collect(Collectors.toList()); + } + + private List findDanglingDvs() { + Dataset dvs = + loadMetadataTable(table, MetadataTableType.DELETE_FILES) + .where(col("file_format").equalTo(FileFormat.PUFFIN.name())); + Dataset dataFiles = loadMetadataTable(table, MetadataTableType.DATA_FILES); + + // a DV not pointing to a valid data file path is implicitly a dangling delete + List danglingDvs = + dvs.join( + dataFiles, + dvs.col("referenced_data_file").equalTo(dataFiles.col("file_path")), + "leftouter") + .filter(dataFiles.col("file_path").isNull()) + .select(dvs.col("*")) + .collectAsList(); + return danglingDvs.stream() + // map on driver because SparkDeleteFile is not serializable + .map(row -> deleteFileWrapper(dvs.schema(), row)) + .collect(Collectors.toList()); + } + + private DeleteFile deleteFileWrapper(StructType sparkFileType, Row row) { + int specId = row.getInt(row.fieldIndex("spec_id")); + Types.StructType combinedFileType = DataFile.getType(Partitioning.partitionType(table)); + // Set correct spec id + Types.StructType projection = DataFile.getType(table.specs().get(specId).partitionType()); + return new SparkDeleteFile(combinedFileType, projection, sparkFileType).wrap(row); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java new file mode 100644 index 000000000000..3415b6a551ae --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteDataFilesSparkAction.java @@ -0,0 +1,461 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.math.RoundingMode; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.BinPackRewriteFilePlanner; +import org.apache.iceberg.actions.FileRewritePlan; +import org.apache.iceberg.actions.FileRewriteRunner; +import org.apache.iceberg.actions.ImmutableRewriteDataFiles; +import org.apache.iceberg.actions.ImmutableRewriteDataFiles.Result.Builder; +import org.apache.iceberg.actions.RewriteDataFiles; +import org.apache.iceberg.actions.RewriteDataFilesCommitManager; +import org.apache.iceberg.actions.RewriteFileGroup; +import org.apache.iceberg.exceptions.CommitFailedException; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Queues; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.math.IntMath; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.MoreExecutors; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.ThreadFactoryBuilder; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.Tasks; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RewriteDataFilesSparkAction + extends BaseSnapshotUpdateSparkAction implements RewriteDataFiles { + + private static final Logger LOG = LoggerFactory.getLogger(RewriteDataFilesSparkAction.class); + private static final Set VALID_OPTIONS = + ImmutableSet.of( + MAX_CONCURRENT_FILE_GROUP_REWRITES, + MAX_FILE_GROUP_SIZE_BYTES, + PARTIAL_PROGRESS_ENABLED, + PARTIAL_PROGRESS_MAX_COMMITS, + PARTIAL_PROGRESS_MAX_FAILED_COMMITS, + TARGET_FILE_SIZE_BYTES, + USE_STARTING_SEQUENCE_NUMBER, + REWRITE_JOB_ORDER, + OUTPUT_SPEC_ID, + REMOVE_DANGLING_DELETES, + BinPackRewriteFilePlanner.MAX_FILES_TO_REWRITE); + + private static final RewriteDataFilesSparkAction.Result EMPTY_RESULT = + ImmutableRewriteDataFiles.Result.builder().rewriteResults(ImmutableList.of()).build(); + + private final Table table; + + private Expression filter = Expressions.alwaysTrue(); + private int maxConcurrentFileGroupRewrites; + private int maxCommits; + private int maxFailedCommits; + private boolean partialProgressEnabled; + private boolean removeDanglingDeletes; + private boolean useStartingSequenceNumber; + private boolean caseSensitive; + private String branch = SnapshotRef.MAIN_BRANCH; + private BinPackRewriteFilePlanner planner = null; + private FileRewriteRunner runner = null; + + RewriteDataFilesSparkAction(SparkSession spark, Table table) { + super(((org.apache.spark.sql.classic.SparkSession) spark).cloneSession()); + // Disable Adaptive Query Execution as this may change the output partitioning of our write + spark().conf().set(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), false); + // Disable executor cache for delete files as each partition is rewritten separately. + // Note: when compacting to a different target spec, data from multiple partitions + // may be grouped together, but caching is still disabled to avoid connection pool issues. + spark().conf().set(SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED, "false"); + this.caseSensitive = SparkUtil.caseSensitive(spark); + this.table = table; + } + + @Override + protected RewriteDataFilesSparkAction self() { + return this; + } + + @Override + public RewriteDataFilesSparkAction binPack() { + ensureRunnerNotSet(); + this.runner = new SparkBinPackFileRewriteRunner(spark(), table); + return this; + } + + @Override + public RewriteDataFilesSparkAction sort(SortOrder sortOrder) { + ensureRunnerNotSet(); + this.runner = new SparkSortFileRewriteRunner(spark(), table, sortOrder); + return this; + } + + @Override + public RewriteDataFilesSparkAction sort() { + ensureRunnerNotSet(); + this.runner = new SparkSortFileRewriteRunner(spark(), table); + return this; + } + + @Override + public RewriteDataFilesSparkAction zOrder(String... columnNames) { + ensureRunnerNotSet(); + this.runner = new SparkZOrderFileRewriteRunner(spark(), table, Arrays.asList(columnNames)); + return this; + } + + private void ensureRunnerNotSet() { + Preconditions.checkArgument( + runner == null, + "Cannot set rewrite mode, it has already been set to %s", + runner == null ? null : runner.description()); + } + + @Override + public RewriteDataFilesSparkAction filter(Expression expression) { + filter = Expressions.and(filter, expression); + return this; + } + + public RewriteDataFilesSparkAction toBranch(String targetBranch) { + Preconditions.checkArgument(targetBranch != null, "Invalid branch name: null"); + this.branch = targetBranch; + return this; + } + + @Override + public RewriteDataFiles.Result execute() { + if (table.currentSnapshot() == null) { + return EMPTY_RESULT; + } + + Preconditions.checkArgument( + table.snapshot(branch) != null, + "Cannot rewrite data files for branch %s: branch does not exist", + branch); + + long startingSnapshotId = table.snapshot(branch).snapshotId(); + + init(startingSnapshotId); + + FileRewritePlan plan = planner.plan(); + + if (plan.totalGroupCount() == 0) { + LOG.info("Nothing found to rewrite in {}", table.name()); + return EMPTY_RESULT; + } + + Builder resultBuilder = + partialProgressEnabled + ? doExecuteWithPartialProgress(plan, commitManager(startingSnapshotId)) + : doExecute(plan, commitManager(startingSnapshotId)); + ImmutableRewriteDataFiles.Result result = resultBuilder.build(); + + if (removeDanglingDeletes) { + RemoveDanglingDeletesSparkAction action = + new RemoveDanglingDeletesSparkAction(spark(), table); + int removedDeleteFiles = Iterables.size(action.execute().removedDeleteFiles()); + return result.withRemovedDeleteFilesCount( + result.removedDeleteFilesCount() + removedDeleteFiles); + } + + return result; + } + + private void init(long startingSnapshotId) { + this.planner = + runner instanceof SparkShufflingFileRewriteRunner + ? new SparkShufflingDataRewritePlanner(table, filter, startingSnapshotId, caseSensitive) + : new BinPackRewriteFilePlanner(table, filter, startingSnapshotId, caseSensitive); + + // Default to BinPack if no strategy selected + if (this.runner == null) { + this.runner = new SparkBinPackFileRewriteRunner(spark(), table); + } + + validateAndInitOptions(); + } + + @VisibleForTesting + RewriteFileGroup rewriteFiles( + FileRewritePlan plan, + RewriteFileGroup fileGroup) { + String desc = jobDesc(fileGroup, plan); + Set addedFiles = + withJobGroupInfo( + newJobGroupInfo("REWRITE-DATA-FILES", desc), () -> runner.rewrite(fileGroup)); + + fileGroup.setOutputFiles(addedFiles); + LOG.info("Rewrite Files Ready to be Committed - {}", desc); + return fileGroup; + } + + private ExecutorService rewriteService() { + return MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) + Executors.newFixedThreadPool( + maxConcurrentFileGroupRewrites, + new ThreadFactoryBuilder().setNameFormat("Rewrite-Service-%d").build())); + } + + @VisibleForTesting + RewriteDataFilesCommitManager commitManager(long startingSnapshotId) { + return new RewriteDataFilesCommitManager( + table, startingSnapshotId, useStartingSequenceNumber, commitSummary(), branch); + } + + private Builder doExecute( + FileRewritePlan plan, + RewriteDataFilesCommitManager commitManager) { + ExecutorService rewriteService = rewriteService(); + + ConcurrentLinkedQueue rewrittenGroups = Queues.newConcurrentLinkedQueue(); + + Tasks.Builder rewriteTaskBuilder = + Tasks.foreach(plan.groups()) + .executeWith(rewriteService) + .stopOnFailure() + .noRetry() + .onFailure( + (fileGroup, exception) -> { + LOG.warn( + "Failure during rewrite process for group {}", fileGroup.info(), exception); + }); + + try { + rewriteTaskBuilder.run( + fileGroup -> { + rewrittenGroups.add(rewriteFiles(plan, fileGroup)); + }); + } catch (Exception e) { + // At least one rewrite group failed, clean up all completed rewrites + LOG.error( + "Cannot complete rewrite, {} is not enabled and one of the file set groups failed to " + + "be rewritten. This error occurred during the writing of new files, not during the commit process. This " + + "indicates something is wrong that doesn't involve conflicts with other Iceberg operations. Enabling " + + "{} may help in this case but the root cause should be investigated. Cleaning up {} groups which finished " + + "being written.", + PARTIAL_PROGRESS_ENABLED, + PARTIAL_PROGRESS_ENABLED, + rewrittenGroups.size(), + e); + + Tasks.foreach(rewrittenGroups) + .suppressFailureWhenFinished() + .run(commitManager::abortFileGroup); + throw e; + } finally { + rewriteService.shutdown(); + } + + try { + commitManager.commitOrClean(Sets.newHashSet(rewrittenGroups)); + } catch (ValidationException | CommitFailedException e) { + String errorMessage = + String.format( + "Cannot commit rewrite because of a ValidationException or CommitFailedException. This usually means that " + + "this rewrite has conflicted with another concurrent Iceberg operation. To reduce the likelihood of " + + "conflicts, set %s which will break up the rewrite into multiple smaller commits controlled by %s. " + + "Separate smaller rewrite commits can succeed independently while any commits that conflict with " + + "another Iceberg operation will be ignored. This mode will create additional snapshots in the table " + + "history, one for each commit.", + PARTIAL_PROGRESS_ENABLED, PARTIAL_PROGRESS_MAX_COMMITS); + throw new RuntimeException(errorMessage, e); + } + + List rewriteResults = + rewrittenGroups.stream().map(RewriteFileGroup::asResult).collect(Collectors.toList()); + return ImmutableRewriteDataFiles.Result.builder().rewriteResults(rewriteResults); + } + + private Builder doExecuteWithPartialProgress( + FileRewritePlan plan, + RewriteDataFilesCommitManager commitManager) { + ExecutorService rewriteService = rewriteService(); + + // start commit service + int groupsPerCommit = IntMath.divide(plan.totalGroupCount(), maxCommits, RoundingMode.CEILING); + RewriteDataFilesCommitManager.CommitService commitService = + commitManager.service(groupsPerCommit); + commitService.start(); + + Collection rewriteFailures = new ConcurrentLinkedQueue<>(); + // start rewrite tasks + Tasks.foreach(plan.groups()) + .suppressFailureWhenFinished() + .executeWith(rewriteService) + .noRetry() + .onFailure( + (fileGroup, exception) -> { + LOG.error("Failure during rewrite group {}", fileGroup.info(), exception); + rewriteFailures.add( + ImmutableRewriteDataFiles.FileGroupFailureResult.builder() + .info(fileGroup.info()) + .dataFilesCount(fileGroup.inputFileNum()) + .build()); + }) + .run(fileGroup -> commitService.offer(rewriteFiles(plan, fileGroup))); + rewriteService.shutdown(); + + // stop commit service + commitService.close(); + + int totalCommits = Math.min(plan.totalGroupCount(), maxCommits); + int failedCommits = totalCommits - commitService.succeededCommits(); + if (failedCommits > 0 && failedCommits <= maxFailedCommits) { + LOG.warn( + "{} is true but {} rewrite commits failed. Check the logs to determine why the individual " + + "commits failed. If this is persistent it may help to increase {} which will split the rewrite operation " + + "into smaller commits.", + PARTIAL_PROGRESS_ENABLED, + failedCommits, + PARTIAL_PROGRESS_MAX_COMMITS); + } else if (failedCommits > maxFailedCommits) { + String errorMessage = + String.format( + Locale.ROOT, + "%s is true but %d rewrite commits failed. This is more than the maximum allowed failures of %d. " + + "Check the logs to determine why the individual commits failed. If this is persistent it may help to " + + "increase %s which will split the rewrite operation into smaller commits.", + PARTIAL_PROGRESS_ENABLED, + failedCommits, + maxFailedCommits, + PARTIAL_PROGRESS_MAX_COMMITS); + throw new RuntimeException(errorMessage); + } + + return ImmutableRewriteDataFiles.Result.builder() + .rewriteResults(toRewriteResults(commitService.results())) + .rewriteFailures(rewriteFailures); + } + + private Iterable toRewriteResults(List commitResults) { + return commitResults.stream().map(RewriteFileGroup::asResult).collect(Collectors.toList()); + } + + void validateAndInitOptions() { + Set validOptions = Sets.newHashSet(runner.validOptions()); + validOptions.addAll(VALID_OPTIONS); + validOptions.addAll(planner.validOptions()); + + Set invalidKeys = Sets.newHashSet(options().keySet()); + invalidKeys.removeAll(validOptions); + + Preconditions.checkArgument( + invalidKeys.isEmpty(), + "Cannot use options %s, they are not supported by the action or the rewriter %s", + invalidKeys, + runner.description()); + + planner.init(options()); + runner.init(options()); + + maxConcurrentFileGroupRewrites = + PropertyUtil.propertyAsInt( + options(), + MAX_CONCURRENT_FILE_GROUP_REWRITES, + MAX_CONCURRENT_FILE_GROUP_REWRITES_DEFAULT); + + maxCommits = + PropertyUtil.propertyAsInt( + options(), PARTIAL_PROGRESS_MAX_COMMITS, PARTIAL_PROGRESS_MAX_COMMITS_DEFAULT); + + maxFailedCommits = + PropertyUtil.propertyAsInt(options(), PARTIAL_PROGRESS_MAX_FAILED_COMMITS, maxCommits); + + partialProgressEnabled = + PropertyUtil.propertyAsBoolean( + options(), PARTIAL_PROGRESS_ENABLED, PARTIAL_PROGRESS_ENABLED_DEFAULT); + + useStartingSequenceNumber = + PropertyUtil.propertyAsBoolean( + options(), USE_STARTING_SEQUENCE_NUMBER, USE_STARTING_SEQUENCE_NUMBER_DEFAULT); + + removeDanglingDeletes = + PropertyUtil.propertyAsBoolean( + options(), REMOVE_DANGLING_DELETES, REMOVE_DANGLING_DELETES_DEFAULT); + + Preconditions.checkArgument( + maxConcurrentFileGroupRewrites >= 1, + "Cannot set %s to %s, the value must be positive.", + MAX_CONCURRENT_FILE_GROUP_REWRITES, + maxConcurrentFileGroupRewrites); + + Preconditions.checkArgument( + !partialProgressEnabled || maxCommits > 0, + "Cannot set %s to %s, the value must be positive when %s is true", + PARTIAL_PROGRESS_MAX_COMMITS, + maxCommits, + PARTIAL_PROGRESS_ENABLED); + } + + private String jobDesc( + RewriteFileGroup group, + FileRewritePlan plan) { + StructLike partition = group.info().partition(); + if (partition.size() > 0) { + return String.format( + Locale.ROOT, + "Rewriting %d files (%s, file group %d/%d, %s (%d/%d)) in %s", + group.rewrittenFiles().size(), + runner.description(), + group.info().globalIndex(), + plan.totalGroupCount(), + partition, + group.info().partitionIndex(), + plan.groupsInPartition(partition), + table.name()); + } else { + return String.format( + Locale.ROOT, + "Rewriting %d files (%s, file group %d/%d) in %s", + group.rewrittenFiles().size(), + runner.description(), + group.info().globalIndex(), + plan.totalGroupCount(), + table.name()); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteManifestsSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteManifestsSparkAction.java new file mode 100644 index 000000000000..668360c06730 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteManifestsSparkAction.java @@ -0,0 +1,606 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.MetadataTableType.ENTRIES; +import static org.apache.spark.sql.functions.col; + +import java.io.Serializable; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.ManifestContent; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestWriter; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.RollingManifestWriter; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.actions.ImmutableRewriteManifests; +import org.apache.iceberg.actions.RewriteManifests; +import org.apache.iceberg.exceptions.CleanableFailure; +import org.apache.iceberg.exceptions.CommitStateUnknownException; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.spark.SparkContentFile; +import org.apache.iceberg.spark.SparkDataFile; +import org.apache.iceberg.spark.SparkDeleteFile; +import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.ThreadPools; +import org.apache.spark.api.java.function.MapPartitionsFunction; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.functions; +import org.apache.spark.sql.types.StructType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An action that rewrites manifests in a distributed manner and co-locates metadata for partitions. + * + *

    By default, this action rewrites all manifests for the current partition spec and writes the + * result to the metadata folder. The behavior can be modified by passing a custom predicate to + * {@link #rewriteIf(Predicate)} and a custom spec ID to {@link #specId(int)}. In addition, there is + * a way to configure a custom location for staged manifests via {@link #stagingLocation(String)}. + * The provided staging location will be ignored if snapshot ID inheritance is enabled. In such + * cases, the manifests are always written to the metadata folder and committed without staging. + */ +public class RewriteManifestsSparkAction + extends BaseSnapshotUpdateSparkAction implements RewriteManifests { + + public static final String USE_CACHING = "use-caching"; + public static final boolean USE_CACHING_DEFAULT = false; + + private static final Logger LOG = LoggerFactory.getLogger(RewriteManifestsSparkAction.class); + private static final RewriteManifests.Result EMPTY_RESULT = + ImmutableRewriteManifests.Result.builder() + .rewrittenManifests(ImmutableList.of()) + .addedManifests(ImmutableList.of()) + .build(); + + private static final String DATA_FILE_PARTITION_COLUMN_NAME = "data_file.partition"; + + private final Table table; + private final int formatVersion; + private final long targetManifestSizeBytes; + private final boolean shouldStageManifests; + + private PartitionSpec spec; + private Predicate predicate = manifest -> true; + private String outputLocation; + + private List partitionFieldClustering = null; + + RewriteManifestsSparkAction(SparkSession spark, Table table) { + super(spark); + this.table = table; + this.spec = table.spec(); + this.targetManifestSizeBytes = + PropertyUtil.propertyAsLong( + table.properties(), + TableProperties.MANIFEST_TARGET_SIZE_BYTES, + TableProperties.MANIFEST_TARGET_SIZE_BYTES_DEFAULT); + + // default the output location to the metadata location + TableOperations ops = ((HasTableOperations) table).operations(); + Path metadataFilePath = new Path(ops.metadataFileLocation("file")); + this.outputLocation = metadataFilePath.getParent().toString(); + + // use the current table format version for new manifests + this.formatVersion = ops.current().formatVersion(); + + boolean snapshotIdInheritanceEnabled = + PropertyUtil.propertyAsBoolean( + table.properties(), + TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, + TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED_DEFAULT); + this.shouldStageManifests = formatVersion == 1 && !snapshotIdInheritanceEnabled; + } + + @Override + protected RewriteManifestsSparkAction self() { + return this; + } + + @Override + public RewriteManifestsSparkAction specId(int specId) { + Preconditions.checkArgument(table.specs().containsKey(specId), "Invalid spec id %s", specId); + this.spec = table.specs().get(specId); + return this; + } + + @Override + public RewriteManifestsSparkAction rewriteIf(Predicate newPredicate) { + this.predicate = newPredicate; + return this; + } + + @Override + public RewriteManifestsSparkAction stagingLocation(String newStagingLocation) { + if (shouldStageManifests) { + this.outputLocation = newStagingLocation; + } else { + LOG.warn("Ignoring provided staging location as new manifests will be committed directly"); + } + return this; + } + + @Override + public RewriteManifests.Result execute() { + String desc = String.format("Rewriting manifests in %s", table.name()); + JobGroupInfo info = newJobGroupInfo("REWRITE-MANIFESTS", desc); + return withJobGroupInfo(info, this::doExecute); + } + + @Override + public RewriteManifestsSparkAction sortBy(List partitionFields) { + // Collect set of available partition columns to cluster on + Set availablePartitionNames = + spec.fields().stream().map(PartitionField::name).collect(Collectors.toSet()); + + // Identify specified partition fields that are not available in the spec + List missingFields = + partitionFields.stream() + .filter(field -> !availablePartitionNames.contains(field)) + .collect(Collectors.toList()); + + // Check if these partition fields are included in the spec + Preconditions.checkArgument( + missingFields.isEmpty(), + "Cannot set manifest sorting because specified field(s) %s were not found in current partition spec %s. Spec ID %s", + missingFields, + this.spec, + this.spec.specId()); + + this.partitionFieldClustering = partitionFields; + return this; + } + + private RewriteManifests.Result doExecute() { + List rewrittenManifests = Lists.newArrayList(); + List addedManifests = Lists.newArrayList(); + + RewriteManifests.Result dataResult = rewriteManifests(ManifestContent.DATA); + Iterables.addAll(rewrittenManifests, dataResult.rewrittenManifests()); + Iterables.addAll(addedManifests, dataResult.addedManifests()); + + RewriteManifests.Result deletesResult = rewriteManifests(ManifestContent.DELETES); + Iterables.addAll(rewrittenManifests, deletesResult.rewrittenManifests()); + Iterables.addAll(addedManifests, deletesResult.addedManifests()); + + if (rewrittenManifests.isEmpty()) { + return EMPTY_RESULT; + } + + replaceManifests(rewrittenManifests, addedManifests); + + return ImmutableRewriteManifests.Result.builder() + .rewrittenManifests(rewrittenManifests) + .addedManifests(addedManifests) + .build(); + } + + private RewriteManifests.Result rewriteManifests(ManifestContent content) { + List matchingManifests = findMatchingManifests(content); + if (matchingManifests.isEmpty()) { + return EMPTY_RESULT; + } + + int targetNumManifests = targetNumManifests(totalSizeBytes(matchingManifests)); + if (targetNumManifests == 1 && matchingManifests.size() == 1) { + return EMPTY_RESULT; + } + + Dataset manifestEntryDF = buildManifestEntryDF(matchingManifests); + + List newManifests; + if (spec.isUnpartitioned()) { + newManifests = writeUnpartitionedManifests(content, manifestEntryDF, targetNumManifests); + } else { + newManifests = writePartitionedManifests(content, manifestEntryDF, targetNumManifests); + } + + return ImmutableRewriteManifests.Result.builder() + .rewrittenManifests(matchingManifests) + .addedManifests(newManifests) + .build(); + } + + private Dataset buildManifestEntryDF(List manifests) { + Dataset manifestDF = + spark() + .createDataset(Lists.transform(manifests, ManifestFile::path), Encoders.STRING()) + .toDF("manifest"); + + Dataset manifestEntryDF = + loadMetadataTable(table, ENTRIES) + .filter("status < 2") // select only live entries + .selectExpr( + "input_file_name() as manifest", + "snapshot_id", + "sequence_number", + "file_sequence_number", + "data_file"); + + Column joinCond = manifestDF.col("manifest").equalTo(manifestEntryDF.col("manifest")); + return manifestEntryDF + .join(manifestDF, joinCond, "left_semi") + .select("snapshot_id", "sequence_number", "file_sequence_number", "data_file"); + } + + private List writeUnpartitionedManifests( + ManifestContent content, Dataset manifestEntryDF, int numManifests) { + + WriteManifests writeFunc = newWriteManifestsFunc(content, manifestEntryDF.schema()); + Dataset transformedManifestEntryDF = manifestEntryDF.repartition(numManifests); + return writeFunc.apply(transformedManifestEntryDF).collectAsList(); + } + + private List writePartitionedManifests( + ManifestContent content, Dataset manifestEntryDF, int numManifests) { + + return withReusableDS( + manifestEntryDF, + df -> { + WriteManifests writeFunc = newWriteManifestsFunc(content, df.schema()); + Dataset transformedDF = repartitionAndSort(df, sortColumn(), numManifests); + return writeFunc.apply(transformedDF).collectAsList(); + }); + } + + private WriteManifests newWriteManifestsFunc(ManifestContent content, StructType sparkType) { + ManifestWriterFactory writers = manifestWriters(); + + StructType sparkFileType = (StructType) sparkType.apply("data_file").dataType(); + Types.StructType combinedFileType = DataFile.getType(Partitioning.partitionType(table)); + Types.StructType fileType = DataFile.getType(spec.partitionType()); + + if (content == ManifestContent.DATA) { + return new WriteDataManifests(writers, combinedFileType, fileType, sparkFileType); + } else { + return new WriteDeleteManifests(writers, combinedFileType, fileType, sparkFileType); + } + } + + private Column sortColumn() { + if (partitionFieldClustering != null) { + LOG.info( + "Clustering manifests for specId {} by partition columns by {} ", + spec.specId(), + partitionFieldClustering); + + // Map the top level partition column names to the column name referenced within the manifest + // entry dataframe + Column[] partitionColumns = + partitionFieldClustering.stream() + .map(p -> col(DATA_FILE_PARTITION_COLUMN_NAME + "." + p)) + .toArray(Column[]::new); + + // Form a new temporary column to cluster manifests on, based on the custom clustering columns + // order provided + return functions.struct(partitionColumns); + } else { + return new Column(DATA_FILE_PARTITION_COLUMN_NAME); + } + } + + private Dataset repartitionAndSort(Dataset df, Column col, int numPartitions) { + return df.repartitionByRange(numPartitions, col).sortWithinPartitions(col); + } + + private U withReusableDS(Dataset ds, Function, U> func) { + boolean useCaching = + PropertyUtil.propertyAsBoolean(options(), USE_CACHING, USE_CACHING_DEFAULT); + Dataset reusableDS = useCaching ? ds.cache() : ds; + + try { + return func.apply(reusableDS); + } finally { + if (useCaching) { + reusableDS.unpersist(false); + } + } + } + + private List findMatchingManifests(ManifestContent content) { + Snapshot currentSnapshot = table.currentSnapshot(); + + if (currentSnapshot == null) { + return ImmutableList.of(); + } + + List manifests = loadManifests(content, currentSnapshot); + + return manifests.stream() + .filter(manifest -> manifest.partitionSpecId() == spec.specId() && predicate.test(manifest)) + .collect(Collectors.toList()); + } + + private List loadManifests(ManifestContent content, Snapshot snapshot) { + switch (content) { + case DATA: + return snapshot.dataManifests(table.io()); + case DELETES: + return snapshot.deleteManifests(table.io()); + default: + throw new IllegalArgumentException("Unknown manifest content: " + content); + } + } + + private int targetNumManifests(long totalSizeBytes) { + return (int) ((totalSizeBytes + targetManifestSizeBytes - 1) / targetManifestSizeBytes); + } + + private long totalSizeBytes(Iterable manifests) { + long totalSizeBytes = 0L; + + for (ManifestFile manifest : manifests) { + ValidationException.check( + hasFileCounts(manifest), "No file counts in manifest: %s", manifest.path()); + totalSizeBytes += manifest.length(); + } + + return totalSizeBytes; + } + + private boolean hasFileCounts(ManifestFile manifest) { + return manifest.addedFilesCount() != null + && manifest.existingFilesCount() != null + && manifest.deletedFilesCount() != null; + } + + private void replaceManifests( + Iterable deletedManifests, Iterable addedManifests) { + try { + org.apache.iceberg.RewriteManifests rewriteManifests = table.rewriteManifests(); + deletedManifests.forEach(rewriteManifests::deleteManifest); + addedManifests.forEach(rewriteManifests::addManifest); + commit(rewriteManifests); + + if (shouldStageManifests) { + // delete new manifests as they were rewritten before the commit + deleteFiles(Iterables.transform(addedManifests, ManifestFile::path)); + } + } catch (CommitStateUnknownException commitStateUnknownException) { + // don't clean up added manifest files, because they may have been successfully committed. + throw commitStateUnknownException; + } catch (Exception e) { + if (e instanceof CleanableFailure) { + // delete all new manifests because the rewrite failed + deleteFiles(Iterables.transform(addedManifests, ManifestFile::path)); + } + + throw e; + } + } + + private void deleteFiles(Iterable locations) { + Iterable files = + Iterables.transform(locations, location -> new FileInfo(location, MANIFEST)); + if (table.io() instanceof SupportsBulkOperations) { + deleteFiles((SupportsBulkOperations) table.io(), files.iterator()); + } else { + deleteFiles( + ThreadPools.getWorkerPool(), file -> table.io().deleteFile(file), files.iterator()); + } + } + + private ManifestWriterFactory manifestWriters() { + return new ManifestWriterFactory( + sparkContext().broadcast(SerializableTableWithSize.copyOf(table)), + formatVersion, + spec.specId(), + outputLocation, + // allow the actual size of manifests to be 20% higher as the estimation is not precise + (long) (1.2 * targetManifestSizeBytes)); + } + + private static class WriteDataManifests extends WriteManifests { + + WriteDataManifests( + ManifestWriterFactory manifestWriters, + Types.StructType combinedPartitionType, + Types.StructType partitionType, + StructType sparkFileType) { + super(manifestWriters, combinedPartitionType, partitionType, sparkFileType); + } + + @Override + protected SparkDataFile newFileWrapper() { + return new SparkDataFile(combinedFileType(), fileType(), sparkFileType()); + } + + @Override + protected RollingManifestWriter newManifestWriter() { + return writers().newRollingManifestWriter(); + } + } + + private static class WriteDeleteManifests extends WriteManifests { + + WriteDeleteManifests( + ManifestWriterFactory manifestWriters, + Types.StructType combinedFileType, + Types.StructType fileType, + StructType sparkFileType) { + super(manifestWriters, combinedFileType, fileType, sparkFileType); + } + + @Override + protected SparkDeleteFile newFileWrapper() { + return new SparkDeleteFile(combinedFileType(), fileType(), sparkFileType()); + } + + @Override + protected RollingManifestWriter newManifestWriter() { + return writers().newRollingDeleteManifestWriter(); + } + } + + private abstract static class WriteManifests> + implements MapPartitionsFunction { + + private static final Encoder MANIFEST_ENCODER = + Encoders.javaSerialization(ManifestFile.class); + + private final ManifestWriterFactory writers; + private final Types.StructType combinedFileType; + private final Types.StructType fileType; + private final StructType sparkFileType; + + WriteManifests( + ManifestWriterFactory writers, + Types.StructType combinedFileType, + Types.StructType fileType, + StructType sparkFileType) { + this.writers = writers; + this.combinedFileType = combinedFileType; + this.fileType = fileType; + this.sparkFileType = sparkFileType; + } + + protected abstract SparkContentFile newFileWrapper(); + + protected abstract RollingManifestWriter newManifestWriter(); + + public Dataset apply(Dataset input) { + return input.mapPartitions(this, MANIFEST_ENCODER); + } + + @Override + public Iterator call(Iterator rows) throws Exception { + SparkContentFile fileWrapper = newFileWrapper(); + RollingManifestWriter writer = newManifestWriter(); + + try { + while (rows.hasNext()) { + Row row = rows.next(); + long snapshotId = row.getLong(0); + long sequenceNumber = row.getLong(1); + Long fileSequenceNumber = row.isNullAt(2) ? null : row.getLong(2); + Row file = row.getStruct(3); + writer.existing(fileWrapper.wrap(file), snapshotId, sequenceNumber, fileSequenceNumber); + } + } finally { + writer.close(); + } + + return writer.toManifestFiles().iterator(); + } + + protected ManifestWriterFactory writers() { + return writers; + } + + protected Types.StructType combinedFileType() { + return combinedFileType; + } + + protected Types.StructType fileType() { + return fileType; + } + + protected StructType sparkFileType() { + return sparkFileType; + } + } + + private static class ManifestWriterFactory implements Serializable { + private final Broadcast

    tableBroadcast; + private final int formatVersion; + private final int specId; + private final String outputLocation; + private final long maxManifestSizeBytes; + + ManifestWriterFactory( + Broadcast
    tableBroadcast, + int formatVersion, + int specId, + String outputLocation, + long maxManifestSizeBytes) { + this.tableBroadcast = tableBroadcast; + this.formatVersion = formatVersion; + this.specId = specId; + this.outputLocation = outputLocation; + this.maxManifestSizeBytes = maxManifestSizeBytes; + } + + public RollingManifestWriter newRollingManifestWriter() { + return new RollingManifestWriter<>(this::newManifestWriter, maxManifestSizeBytes); + } + + private ManifestWriter newManifestWriter() { + return ManifestFiles.write(formatVersion, spec(), newOutputFile(), null); + } + + public RollingManifestWriter newRollingDeleteManifestWriter() { + return new RollingManifestWriter<>(this::newDeleteManifestWriter, maxManifestSizeBytes); + } + + private ManifestWriter newDeleteManifestWriter() { + return ManifestFiles.writeDeleteManifest(formatVersion, spec(), newOutputFile(), null); + } + + private PartitionSpec spec() { + return table().specs().get(specId); + } + + private OutputFile newOutputFile() { + return table().io().newOutputFile(newManifestLocation()); + } + + private String newManifestLocation() { + String fileName = FileFormat.AVRO.addExtension("optimized-m-" + UUID.randomUUID()); + Path filePath = new Path(outputLocation, fileName); + return filePath.toString(); + } + + private Table table() { + return tableBroadcast.value(); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java new file mode 100644 index 000000000000..77aadc1d5da8 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewritePositionDeleteFilesSparkAction.java @@ -0,0 +1,380 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.math.RoundingMode; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.stream.Collectors; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.PositionDeletesTable; +import org.apache.iceberg.PositionDeletesTable.PositionDeletesBatchScan; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.actions.BinPackRewritePositionDeletePlanner; +import org.apache.iceberg.actions.FileRewritePlan; +import org.apache.iceberg.actions.ImmutableRewritePositionDeleteFiles; +import org.apache.iceberg.actions.RewritePositionDeleteFiles; +import org.apache.iceberg.actions.RewritePositionDeletesCommitManager; +import org.apache.iceberg.actions.RewritePositionDeletesCommitManager.CommitService; +import org.apache.iceberg.actions.RewritePositionDeletesGroup; +import org.apache.iceberg.exceptions.CommitFailedException; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Queues; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.math.IntMath; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.MoreExecutors; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.ThreadFactoryBuilder; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.Tasks; +import org.apache.spark.sql.SparkSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Spark implementation of {@link RewritePositionDeleteFiles}. */ +public class RewritePositionDeleteFilesSparkAction + extends BaseSnapshotUpdateSparkAction + implements RewritePositionDeleteFiles { + + private static final Logger LOG = + LoggerFactory.getLogger(RewritePositionDeleteFilesSparkAction.class); + private static final Set VALID_OPTIONS = + ImmutableSet.of( + MAX_CONCURRENT_FILE_GROUP_REWRITES, + PARTIAL_PROGRESS_ENABLED, + PARTIAL_PROGRESS_MAX_COMMITS, + REWRITE_JOB_ORDER); + private static final Result EMPTY_RESULT = + ImmutableRewritePositionDeleteFiles.Result.builder().build(); + + private final Table table; + private BinPackRewritePositionDeletePlanner planner; + private final SparkRewritePositionDeleteRunner runner; + private Expression filter = Expressions.alwaysTrue(); + + private int maxConcurrentFileGroupRewrites; + private int maxCommits; + private boolean partialProgressEnabled; + private boolean caseSensitive; + + RewritePositionDeleteFilesSparkAction(SparkSession spark, Table table) { + super(spark); + this.table = table; + this.runner = new SparkRewritePositionDeleteRunner(spark(), table); + this.caseSensitive = SparkUtil.caseSensitive(spark); + } + + @Override + protected RewritePositionDeleteFilesSparkAction self() { + return this; + } + + @Override + public RewritePositionDeleteFilesSparkAction filter(Expression expression) { + filter = Expressions.and(filter, expression); + return this; + } + + @Override + public RewritePositionDeleteFiles.Result execute() { + if (table.currentSnapshot() == null) { + LOG.info("Nothing found to rewrite in empty table {}", table.name()); + return EMPTY_RESULT; + } + + this.planner = new BinPackRewritePositionDeletePlanner(table, filter, caseSensitive); + + validateAndInitOptions(); + + if (TableUtil.formatVersion(table) >= 3 && !requiresRewriteToDVs()) { + LOG.info("v2 deletes in {} have already been rewritten to v3 DVs", table.name()); + return EMPTY_RESULT; + } + + FileRewritePlan + plan = planner.plan(); + + if (plan.totalGroupCount() == 0) { + LOG.info("Nothing found to rewrite in {}", table.name()); + return EMPTY_RESULT; + } + + if (partialProgressEnabled) { + return doExecuteWithPartialProgress(plan, commitManager()); + } else { + return doExecute(plan, commitManager()); + } + } + + private boolean requiresRewriteToDVs() { + PositionDeletesBatchScan scan = + (PositionDeletesBatchScan) + MetadataTableUtils.createMetadataTableInstance( + table, MetadataTableType.POSITION_DELETES) + .newBatchScan(); + try (CloseableIterator it = + CloseableIterable.filter( + CloseableIterable.transform( + scan.baseTableFilter(filter) + .caseSensitive(caseSensitive) + .select(PositionDeletesTable.DELETE_FILE_PATH) + .ignoreResiduals() + .planFiles(), + task -> (PositionDeletesScanTask) task), + t -> t.file().format() != FileFormat.PUFFIN) + .iterator()) { + return it.hasNext(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private RewritePositionDeletesGroup rewriteDeleteFiles( + FileRewritePlan< + FileGroupInfo, PositionDeletesScanTask, DeleteFile, RewritePositionDeletesGroup> + plan, + RewritePositionDeletesGroup fileGroup) { + String desc = jobDesc(fileGroup, plan); + Set addedFiles = + withJobGroupInfo( + newJobGroupInfo("REWRITE-POSITION-DELETES", desc), () -> runner.rewrite(fileGroup)); + + fileGroup.setOutputFiles(addedFiles); + LOG.info("Rewrite position deletes ready to be committed - {}", desc); + return fileGroup; + } + + private ExecutorService rewriteService() { + return MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) + Executors.newFixedThreadPool( + maxConcurrentFileGroupRewrites, + new ThreadFactoryBuilder() + .setNameFormat("Rewrite-Position-Delete-Service-%d") + .build())); + } + + private RewritePositionDeletesCommitManager commitManager() { + return new RewritePositionDeletesCommitManager(table, commitSummary()); + } + + private Result doExecute( + FileRewritePlan< + FileGroupInfo, PositionDeletesScanTask, DeleteFile, RewritePositionDeletesGroup> + plan, + RewritePositionDeletesCommitManager commitManager) { + ExecutorService rewriteService = rewriteService(); + + ConcurrentLinkedQueue rewrittenGroups = + Queues.newConcurrentLinkedQueue(); + + Tasks.Builder rewriteTaskBuilder = + Tasks.foreach(plan.groups()) + .executeWith(rewriteService) + .stopOnFailure() + .noRetry() + .onFailure( + (fileGroup, exception) -> + LOG.warn( + "Failure during rewrite process for group {}", + fileGroup.info(), + exception)); + + try { + rewriteTaskBuilder.run(fileGroup -> rewrittenGroups.add(rewriteDeleteFiles(plan, fileGroup))); + } catch (Exception e) { + // At least one rewrite group failed, clean up all completed rewrites + LOG.error( + "Cannot complete rewrite, {} is not enabled and one of the file set groups failed to " + + "be rewritten. This error occurred during the writing of new files, not during the commit process. This " + + "indicates something is wrong that doesn't involve conflicts with other Iceberg operations. Enabling " + + "{} may help in this case but the root cause should be investigated. Cleaning up {} groups which finished " + + "being written.", + PARTIAL_PROGRESS_ENABLED, + PARTIAL_PROGRESS_ENABLED, + rewrittenGroups.size(), + e); + + Tasks.foreach(rewrittenGroups).suppressFailureWhenFinished().run(commitManager::abort); + throw e; + } finally { + rewriteService.shutdown(); + } + + try { + commitManager.commitOrClean(Sets.newHashSet(rewrittenGroups)); + } catch (ValidationException | CommitFailedException e) { + String errorMessage = + String.format( + "Cannot commit rewrite because of a ValidationException or CommitFailedException. This usually means that " + + "this rewrite has conflicted with another concurrent Iceberg operation. To reduce the likelihood of " + + "conflicts, set %s which will break up the rewrite into multiple smaller commits controlled by %s. " + + "Separate smaller rewrite commits can succeed independently while any commits that conflict with " + + "another Iceberg operation will be ignored. This mode will create additional snapshots in the table " + + "history, one for each commit.", + PARTIAL_PROGRESS_ENABLED, PARTIAL_PROGRESS_MAX_COMMITS); + throw new RuntimeException(errorMessage, e); + } + + List rewriteResults = + rewrittenGroups.stream() + .map(RewritePositionDeletesGroup::asResult) + .collect(Collectors.toList()); + + return ImmutableRewritePositionDeleteFiles.Result.builder() + .rewriteResults(rewriteResults) + .build(); + } + + private Result doExecuteWithPartialProgress( + FileRewritePlan< + FileGroupInfo, PositionDeletesScanTask, DeleteFile, RewritePositionDeletesGroup> + plan, + RewritePositionDeletesCommitManager commitManager) { + ExecutorService rewriteService = rewriteService(); + + // start commit service + int groupsPerCommit = IntMath.divide(plan.totalGroupCount(), maxCommits, RoundingMode.CEILING); + CommitService commitService = commitManager.service(groupsPerCommit); + commitService.start(); + + // start rewrite tasks + Tasks.foreach(plan.groups()) + .suppressFailureWhenFinished() + .executeWith(rewriteService) + .noRetry() + .onFailure( + (fileGroup, exception) -> + LOG.error("Failure during rewrite group {}", fileGroup.info(), exception)) + .run(fileGroup -> commitService.offer(rewriteDeleteFiles(plan, fileGroup))); + rewriteService.shutdown(); + + // stop commit service + commitService.close(); + List commitResults = commitService.results(); + if (commitResults.isEmpty()) { + LOG.error( + "{} is true but no rewrite commits succeeded. Check the logs to determine why the individual " + + "commits failed. If this is persistent it may help to increase {} which will break the rewrite operation " + + "into smaller commits.", + PARTIAL_PROGRESS_ENABLED, + PARTIAL_PROGRESS_MAX_COMMITS); + } + + List rewriteResults = + commitResults.stream() + .map(RewritePositionDeletesGroup::asResult) + .collect(Collectors.toList()); + return ImmutableRewritePositionDeleteFiles.Result.builder() + .rewriteResults(rewriteResults) + .build(); + } + + private void validateAndInitOptions() { + Set validOptions = Sets.newHashSet(runner.validOptions()); + validOptions.addAll(VALID_OPTIONS); + validOptions.addAll(planner.validOptions()); + + Set invalidKeys = Sets.newHashSet(options().keySet()); + invalidKeys.removeAll(validOptions); + + Preconditions.checkArgument( + invalidKeys.isEmpty(), + "Cannot use options %s, they are not supported by the action or the rewriter %s", + invalidKeys, + runner.description()); + + planner.init(options()); + runner.init(options()); + + this.maxConcurrentFileGroupRewrites = + PropertyUtil.propertyAsInt( + options(), + MAX_CONCURRENT_FILE_GROUP_REWRITES, + MAX_CONCURRENT_FILE_GROUP_REWRITES_DEFAULT); + + this.maxCommits = + PropertyUtil.propertyAsInt( + options(), PARTIAL_PROGRESS_MAX_COMMITS, PARTIAL_PROGRESS_MAX_COMMITS_DEFAULT); + + this.partialProgressEnabled = + PropertyUtil.propertyAsBoolean( + options(), PARTIAL_PROGRESS_ENABLED, PARTIAL_PROGRESS_ENABLED_DEFAULT); + + Preconditions.checkArgument( + maxConcurrentFileGroupRewrites >= 1, + "Cannot set %s to %s, the value must be positive.", + MAX_CONCURRENT_FILE_GROUP_REWRITES, + maxConcurrentFileGroupRewrites); + + Preconditions.checkArgument( + !partialProgressEnabled || maxCommits > 0, + "Cannot set %s to %s, the value must be positive when %s is true", + PARTIAL_PROGRESS_MAX_COMMITS, + maxCommits, + PARTIAL_PROGRESS_ENABLED); + } + + private String jobDesc( + RewritePositionDeletesGroup group, + FileRewritePlan< + FileGroupInfo, PositionDeletesScanTask, DeleteFile, RewritePositionDeletesGroup> + plan) { + StructLike partition = group.info().partition(); + if (partition.size() > 0) { + return String.format( + Locale.ROOT, + "Rewriting %d position delete files (%s, file group %d/%d, %s (%d/%d)) in %s", + group.rewrittenDeleteFiles().size(), + runner.description(), + group.info().globalIndex(), + plan.totalGroupCount(), + partition, + group.info().partitionIndex(), + plan.groupsInPartition(partition), + table.name()); + } else { + return String.format( + Locale.ROOT, + "Rewriting %d position files (%s, file group %d/%d) in %s", + group.rewrittenDeleteFiles().size(), + runner.description(), + group.info().globalIndex(), + plan.totalGroupCount(), + table.name()); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java new file mode 100644 index 000000000000..aedb25e4a4a6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/RewriteTablePathSparkAction.java @@ -0,0 +1,837 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.RewriteTablePathUtil; +import org.apache.iceberg.RewriteTablePathUtil.PositionDeleteReaderWriter; +import org.apache.iceberg.RewriteTablePathUtil.RewriteResult; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StaticTableOperations; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableMetadata.MetadataLogEntry; +import org.apache.iceberg.TableMetadataParser; +import org.apache.iceberg.actions.ImmutableRewriteTablePath; +import org.apache.iceberg.actions.RewriteTablePath; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.avro.DataWriter; +import org.apache.iceberg.data.orc.GenericOrcWriter; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedFiles; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.DeleteSchemaUtil; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.orc.ORC; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.Tasks; +import org.apache.spark.api.java.function.ForeachFunction; +import org.apache.spark.api.java.function.MapFunction; +import org.apache.spark.api.java.function.ReduceFunction; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.functions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RewriteTablePathSparkAction extends BaseSparkAction + implements RewriteTablePath { + + private static final Logger LOG = LoggerFactory.getLogger(RewriteTablePathSparkAction.class); + private static final String RESULT_LOCATION = "file-list"; + static final String NOT_APPLICABLE = "N/A"; + + private String sourcePrefix; + private String targetPrefix; + private String startVersionName; + private String endVersionName; + private String stagingDir; + private boolean createFileList = true; + private ExecutorService executorService; + + private final Table table; + private Broadcast
    tableBroadcast = null; + + RewriteTablePathSparkAction(SparkSession spark, Table table) { + super(spark); + this.table = table; + } + + @Override + protected RewriteTablePath self() { + return this; + } + + @Override + public RewriteTablePath rewriteLocationPrefix(String sPrefix, String tPrefix) { + Preconditions.checkArgument( + sPrefix != null && !sPrefix.isEmpty(), "Source prefix('%s') cannot be empty.", sPrefix); + this.sourcePrefix = sPrefix; + this.targetPrefix = tPrefix; + return this; + } + + @Override + public RewriteTablePath startVersion(String sVersion) { + Preconditions.checkArgument( + sVersion != null && !sVersion.trim().isEmpty(), + "Start version('%s') cannot be empty.", + sVersion); + this.startVersionName = sVersion; + return this; + } + + @Override + public RewriteTablePath endVersion(String eVersion) { + Preconditions.checkArgument( + eVersion != null && !eVersion.trim().isEmpty(), + "End version('%s') cannot be empty.", + eVersion); + this.endVersionName = eVersion; + return this; + } + + @Override + public RewriteTablePath stagingLocation(String stagingLocation) { + Preconditions.checkArgument( + stagingLocation != null && !stagingLocation.isEmpty(), + "Staging location('%s') cannot be empty.", + stagingLocation); + this.stagingDir = stagingLocation; + return this; + } + + @Override + public RewriteTablePath createFileList(boolean createFileListFlag) { + this.createFileList = createFileListFlag; + return this; + } + + @Override + public RewriteTablePath executeWith(ExecutorService service) { + this.executorService = service; + return this; + } + + @Override + public Result execute() { + validateInputs(); + JobGroupInfo info = newJobGroupInfo("REWRITE-TABLE-PATH", jobDesc()); + return withJobGroupInfo(info, this::doExecute); + } + + private Result doExecute() { + return rebuildMetadata(); + } + + private void validateInputs() { + Preconditions.checkArgument( + sourcePrefix != null && !sourcePrefix.isEmpty(), + "Source prefix('%s') cannot be empty.", + sourcePrefix); + Preconditions.checkArgument( + targetPrefix != null && !targetPrefix.isEmpty(), + "Target prefix('%s') cannot be empty.", + targetPrefix); + Preconditions.checkArgument( + !sourcePrefix.equals(targetPrefix), + "Source prefix cannot be the same as target prefix (%s)", + sourcePrefix); + + validateAndSetEndVersion(); + validateAndSetStartVersion(); + + if (stagingDir == null) { + stagingDir = + getMetadataLocation(table) + + "copy-table-staging-" + + UUID.randomUUID() + + RewriteTablePathUtil.FILE_SEPARATOR; + } else { + stagingDir = RewriteTablePathUtil.maybeAppendFileSeparator(stagingDir); + } + } + + private void validateAndSetEndVersion() { + TableMetadata tableMetadata = ((HasTableOperations) table).operations().current(); + + if (endVersionName == null) { + LOG.info("No end version specified. Will stage all files to the latest table version."); + Preconditions.checkNotNull( + tableMetadata.metadataFileLocation(), "Metadata file location should not be null"); + this.endVersionName = tableMetadata.metadataFileLocation(); + } else { + this.endVersionName = validateVersion(tableMetadata, endVersionName); + } + } + + private void validateAndSetStartVersion() { + TableMetadata tableMetadata = ((HasTableOperations) table).operations().current(); + + if (startVersionName != null) { + this.startVersionName = validateVersion(tableMetadata, startVersionName); + } + } + + private String validateVersion(TableMetadata tableMetadata, String versionFileName) { + String versionFile = null; + if (versionInFilePath(tableMetadata.metadataFileLocation(), versionFileName)) { + versionFile = tableMetadata.metadataFileLocation(); + } + + for (MetadataLogEntry log : tableMetadata.previousFiles()) { + if (versionInFilePath(log.file(), versionFileName)) { + versionFile = log.file(); + } + } + + Preconditions.checkArgument( + versionFile != null, + "Cannot find provided version file %s in metadata log.", + versionFileName); + Preconditions.checkArgument( + fileExist(versionFile), "Version file %s does not exist.", versionFile); + return versionFile; + } + + private boolean versionInFilePath(String path, String version) { + return RewriteTablePathUtil.fileName(path).equals(version); + } + + private String jobDesc() { + if (startVersionName == null) { + return String.format( + "Replacing path prefixes '%s' with '%s' in the metadata files of table %s," + + "up to version '%s'.", + sourcePrefix, targetPrefix, table.name(), endVersionName); + } else { + return String.format( + "Replacing path prefixes '%s' with '%s' in the metadata files of table %s," + + "from version '%s' to '%s'.", + sourcePrefix, targetPrefix, table.name(), startVersionName, endVersionName); + } + } + + /** + * Rebuild metadata in a staging location, with paths rewritten. + * + *
      + *
    • Rebuild version files to staging + *
    • Rebuild manifest list files to staging + *
    • Rebuild manifest to staging + *
    • Get all files needed to move + *
    + */ + private Result rebuildMetadata() { + TableMetadata startMetadata = + startVersionName != null + ? ((HasTableOperations) newStaticTable(startVersionName, table.io())) + .operations() + .current() + : null; + TableMetadata endMetadata = + ((HasTableOperations) newStaticTable(endVersionName, table.io())).operations().current(); + + // rebuild version files + RewriteResult rewriteVersionResult = rewriteVersionFiles(endMetadata); + Set deltaSnapshots = deltaSnapshots(startMetadata, rewriteVersionResult.toRewrite()); + + Set manifestsToRewrite = manifestsToRewrite(deltaSnapshots, startMetadata); + Set validSnapshots = + Sets.difference(snapshotSet(endMetadata), snapshotSet(startMetadata)); + + // rebuild manifest-list files + Set> manifestListResults = Sets.newConcurrentHashSet(); + Tasks.foreach(validSnapshots) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + snapshot -> + manifestListResults.add( + rewriteManifestList(snapshot, endMetadata, manifestsToRewrite))); + + RewriteResult rewriteManifestListResult = new RewriteResult<>(); + manifestListResults.forEach(rewriteManifestListResult::append); + + // rebuild manifest files + Set metaFiles = rewriteManifestListResult.toRewrite(); + RewriteContentFileResult rewriteManifestResult = + rewriteManifests(deltaSnapshots, endMetadata, metaFiles); + + // rebuild position delete files + Set deleteFiles = + rewriteManifestResult.toRewrite().stream() + .filter(e -> e instanceof DeleteFile) + .map(e -> (DeleteFile) e) + .collect(Collectors.toCollection(DeleteFileSet::create)); + rewritePositionDeletes(deleteFiles); + + ImmutableRewriteTablePath.Result.Builder builder = + ImmutableRewriteTablePath.Result.builder() + .stagingLocation(stagingDir) + .rewrittenDeleteFilePathsCount(deleteFiles.size()) + .rewrittenManifestFilePathsCount(metaFiles.size()) + .latestVersion(RewriteTablePathUtil.fileName(endVersionName)); + + if (!createFileList) { + return builder.fileListLocation(NOT_APPLICABLE).build(); + } + + Set> copyPlan = Sets.newHashSet(); + copyPlan.addAll(rewriteVersionResult.copyPlan()); + copyPlan.addAll(rewriteManifestListResult.copyPlan()); + copyPlan.addAll(rewriteManifestResult.copyPlan()); + String fileListLocation = saveFileList(copyPlan); + + return builder.fileListLocation(fileListLocation).build(); + } + + private String saveFileList(Set> filesToMove) { + String fileListPath = stagingDir + RESULT_LOCATION; + OutputFile fileList = table.io().newOutputFile(fileListPath); + writeAsCsv(filesToMove, fileList); + return fileListPath; + } + + private void writeAsCsv(Set> rows, OutputFile outputFile) { + try (BufferedWriter writer = + new BufferedWriter( + new OutputStreamWriter(outputFile.createOrOverwrite(), StandardCharsets.UTF_8))) { + for (Pair pair : rows) { + writer.write(String.join(",", pair.first(), pair.second())); + writer.newLine(); + } + } catch (IOException e) { + throw new RuntimeIOException(e); + } + } + + private Set deltaSnapshots(TableMetadata startMetadata, Set allSnapshots) { + if (startMetadata == null) { + return allSnapshots; + } else { + Set startSnapshotIds = + startMetadata.snapshots().stream().map(Snapshot::snapshotId).collect(Collectors.toSet()); + return allSnapshots.stream() + .filter(s -> !startSnapshotIds.contains(s.snapshotId())) + .collect(Collectors.toSet()); + } + } + + private RewriteResult rewriteVersionFiles(TableMetadata endMetadata) { + RewriteResult result = new RewriteResult<>(); + result.toRewrite().addAll(endMetadata.snapshots()); + result.copyPlan().addAll(rewriteVersionFile(endMetadata, endVersionName)); + + List versions = endMetadata.previousFiles(); + List versionFilePaths = Lists.newArrayList(); + for (int i = versions.size() - 1; i >= 0; i--) { + String versionFilePath = versions.get(i).file(); + if (versionFilePath.equals(startVersionName)) { + break; + } + + Preconditions.checkArgument( + fileExist(versionFilePath), + String.format("Version file %s doesn't exist", versionFilePath)); + versionFilePaths.add(versionFilePath); + } + + Set allSnapshots = Sets.newConcurrentHashSet(); + Set> allCopyPlan = Sets.newConcurrentHashSet(); + Tasks.foreach(versionFilePaths) + .noRetry() + .throwFailureWhenFinished() + .executeWith(executorService) + .run( + versionFilePath -> { + TableMetadata tableMetadata = + new StaticTableOperations(versionFilePath, table.io()).current(); + allSnapshots.addAll(tableMetadata.snapshots()); + allCopyPlan.addAll(rewriteVersionFile(tableMetadata, versionFilePath)); + }); + + result.toRewrite().addAll(allSnapshots); + result.copyPlan().addAll(allCopyPlan); + + return result; + } + + private Set> rewriteVersionFile( + TableMetadata metadata, String versionFilePath) { + Set> result = Sets.newHashSet(); + String stagingPath = + RewriteTablePathUtil.stagingPath(versionFilePath, sourcePrefix, stagingDir); + TableMetadata newTableMetadata = + RewriteTablePathUtil.replacePaths(metadata, sourcePrefix, targetPrefix); + TableMetadataParser.overwrite(newTableMetadata, table.io().newOutputFile(stagingPath)); + result.add( + Pair.of( + stagingPath, + RewriteTablePathUtil.newPath(versionFilePath, sourcePrefix, targetPrefix))); + + // include statistics files in copy plan + result.addAll( + statsFileCopyPlan(metadata.statisticsFiles(), newTableMetadata.statisticsFiles())); + result.addAll( + partitionStatsFileCopyPlan( + metadata.partitionStatisticsFiles(), newTableMetadata.partitionStatisticsFiles())); + return result; + } + + private Set> statsFileCopyPlan( + List beforeStats, List afterStats) { + Set> result = Sets.newHashSet(); + if (beforeStats.isEmpty()) { + return result; + } + + Preconditions.checkArgument( + beforeStats.size() == afterStats.size(), + "Before and after path rewrite, statistic files count should be same"); + for (int i = 0; i < beforeStats.size(); i++) { + StatisticsFile before = beforeStats.get(i); + StatisticsFile after = afterStats.get(i); + Preconditions.checkArgument( + before.fileSizeInBytes() == after.fileSizeInBytes(), + "Before and after path rewrite, statistic file size should be same"); + result.add(Pair.of(before.path(), after.path())); + } + return result; + } + + private Set> partitionStatsFileCopyPlan( + List beforeStats, List afterStats) { + Set> result = Sets.newHashSet(); + if (beforeStats.isEmpty()) { + return result; + } + + Preconditions.checkArgument( + beforeStats.size() == afterStats.size(), + "Before and after path rewrite, partition statistic files count should be same"); + for (int i = 0; i < beforeStats.size(); i++) { + PartitionStatisticsFile before = beforeStats.get(i); + PartitionStatisticsFile after = afterStats.get(i); + Preconditions.checkArgument( + before.fileSizeInBytes() == after.fileSizeInBytes(), + "Before and after path rewrite, partition statistic file size should be same"); + result.add(Pair.of(before.path(), after.path())); + } + return result; + } + + /** + * Rewrite a manifest list representing a snapshot. + * + * @param snapshot snapshot represented by the manifest list + * @param tableMetadata metadata of table + * @param manifestsToRewrite filter of manifests to rewrite. + * @return a result including a copy plan for the manifests contained in the manifest list, as + * well as for the manifest list itself + */ + private RewriteResult rewriteManifestList( + Snapshot snapshot, TableMetadata tableMetadata, Set manifestsToRewrite) { + RewriteResult result = new RewriteResult<>(); + + String path = snapshot.manifestListLocation(); + String outputPath = RewriteTablePathUtil.stagingPath(path, sourcePrefix, stagingDir); + RewriteResult rewriteResult = + RewriteTablePathUtil.rewriteManifestList( + snapshot, + table.io(), + tableMetadata, + manifestsToRewrite, + sourcePrefix, + targetPrefix, + stagingDir, + outputPath); + + result.append(rewriteResult); + // add the manifest list copy plan itself to the result + result + .copyPlan() + .add(Pair.of(outputPath, RewriteTablePathUtil.newPath(path, sourcePrefix, targetPrefix))); + return result; + } + + private Set manifestsToRewrite( + Set deltaSnapshots, TableMetadata startMetadata) { + try { + Table endStaticTable = newStaticTable(endVersionName, table.io()); + Dataset lastVersionFiles = manifestDS(endStaticTable).select("path"); + if (startMetadata == null) { + return Sets.newHashSet(lastVersionFiles.distinct().as(Encoders.STRING()).collectAsList()); + } else { + Set deltaSnapshotIds = + deltaSnapshots.stream().map(Snapshot::snapshotId).collect(Collectors.toSet()); + return Sets.newHashSet( + lastVersionFiles + .distinct() + .filter( + functions + .column(ManifestFile.SNAPSHOT_ID.name()) + .isInCollection(deltaSnapshotIds)) + .as(Encoders.STRING()) + .collectAsList()); + } + } catch (Exception e) { + throw new UnsupportedOperationException( + "Unable to build the manifest files dataframe. The end version in use may contain invalid snapshots. " + + "Please choose an earlier version without invalid snapshots.", + e); + } + } + + public static class RewriteContentFileResult extends RewriteResult> { + @Override + public RewriteContentFileResult append(RewriteResult> r1) { + this.copyPlan().addAll(r1.copyPlan()); + this.toRewrite().addAll(r1.toRewrite()); + return this; + } + + public RewriteContentFileResult appendDataFile(RewriteResult r1) { + this.copyPlan().addAll(r1.copyPlan()); + this.toRewrite().addAll(r1.toRewrite()); + return this; + } + + public RewriteContentFileResult appendDeleteFile(RewriteResult r1) { + this.copyPlan().addAll(r1.copyPlan()); + this.toRewrite().addAll(r1.toRewrite()); + return this; + } + } + + /** Rewrite manifest files in a distributed manner and return rewritten data files path pairs. */ + private RewriteContentFileResult rewriteManifests( + Set deltaSnapshots, TableMetadata tableMetadata, Set toRewrite) { + if (toRewrite.isEmpty()) { + return new RewriteContentFileResult(); + } + + Encoder manifestFileEncoder = Encoders.javaSerialization(ManifestFile.class); + Dataset manifestDS = + spark().createDataset(Lists.newArrayList(toRewrite), manifestFileEncoder); + Set deltaSnapshotIds = + deltaSnapshots.stream().map(Snapshot::snapshotId).collect(Collectors.toSet()); + + return manifestDS + .repartition(toRewrite.size()) + .map( + toManifests( + tableBroadcast(), + sparkContext().broadcast(deltaSnapshotIds), + stagingDir, + tableMetadata.formatVersion(), + sourcePrefix, + targetPrefix), + Encoders.bean(RewriteContentFileResult.class)) + // duplicates are expected here as the same data file can have different statuses + // (e.g. added and deleted) + .reduce((ReduceFunction) RewriteContentFileResult::append); + } + + private static MapFunction toManifests( + Broadcast
    table, + Broadcast> deltaSnapshotIds, + String stagingLocation, + int format, + String sourcePrefix, + String targetPrefix) { + + return manifestFile -> { + RewriteContentFileResult result = new RewriteContentFileResult(); + switch (manifestFile.content()) { + case DATA: + result.appendDataFile( + writeDataManifest( + manifestFile, + table, + deltaSnapshotIds, + stagingLocation, + format, + sourcePrefix, + targetPrefix)); + break; + case DELETES: + result.appendDeleteFile( + writeDeleteManifest( + manifestFile, + table, + deltaSnapshotIds, + stagingLocation, + format, + sourcePrefix, + targetPrefix)); + break; + default: + throw new UnsupportedOperationException( + "Unsupported manifest type: " + manifestFile.content()); + } + return result; + }; + } + + private static RewriteResult writeDataManifest( + ManifestFile manifestFile, + Broadcast
    table, + Broadcast> snapshotIds, + String stagingLocation, + int format, + String sourcePrefix, + String targetPrefix) { + try { + String stagingPath = + RewriteTablePathUtil.stagingPath(manifestFile.path(), sourcePrefix, stagingLocation); + FileIO io = table.getValue().io(); + OutputFile outputFile = io.newOutputFile(stagingPath); + Map specsById = table.getValue().specs(); + Set deltaSnapshotIds = snapshotIds.value(); + return RewriteTablePathUtil.rewriteDataManifest( + manifestFile, + deltaSnapshotIds, + outputFile, + io, + format, + specsById, + sourcePrefix, + targetPrefix); + } catch (IOException e) { + throw new RuntimeIOException(e); + } + } + + private static RewriteResult writeDeleteManifest( + ManifestFile manifestFile, + Broadcast
    table, + Broadcast> snapshotIds, + String stagingLocation, + int format, + String sourcePrefix, + String targetPrefix) { + try { + String stagingPath = + RewriteTablePathUtil.stagingPath(manifestFile.path(), sourcePrefix, stagingLocation); + FileIO io = table.getValue().io(); + OutputFile outputFile = io.newOutputFile(stagingPath); + Map specsById = table.getValue().specs(); + Set deltaSnapshotIds = snapshotIds.value(); + return RewriteTablePathUtil.rewriteDeleteManifest( + manifestFile, + deltaSnapshotIds, + outputFile, + io, + format, + specsById, + sourcePrefix, + targetPrefix, + stagingLocation); + } catch (IOException e) { + throw new RuntimeIOException(e); + } + } + + private void rewritePositionDeletes(Set toRewrite) { + if (toRewrite.isEmpty()) { + return; + } + + Encoder deleteFileEncoder = Encoders.javaSerialization(DeleteFile.class); + Dataset deleteFileDs = + spark().createDataset(Lists.newArrayList(toRewrite), deleteFileEncoder); + + PositionDeleteReaderWriter posDeleteReaderWriter = new SparkPositionDeleteReaderWriter(); + deleteFileDs + .repartition(toRewrite.size()) + .foreach( + rewritePositionDelete( + tableBroadcast(), sourcePrefix, targetPrefix, stagingDir, posDeleteReaderWriter)); + } + + private static class SparkPositionDeleteReaderWriter implements PositionDeleteReaderWriter { + @Override + public CloseableIterable reader( + InputFile inputFile, FileFormat format, PartitionSpec spec) { + return positionDeletesReader(inputFile, format, spec); + } + + @Override + public PositionDeleteWriter writer( + OutputFile outputFile, + FileFormat format, + PartitionSpec spec, + StructLike partition, + Schema rowSchema) + throws IOException { + return positionDeletesWriter(outputFile, format, spec, partition, rowSchema); + } + } + + private ForeachFunction rewritePositionDelete( + Broadcast
    tableArg, + String sourcePrefixArg, + String targetPrefixArg, + String stagingLocationArg, + PositionDeleteReaderWriter posDeleteReaderWriter) { + return deleteFile -> { + FileIO io = tableArg.getValue().io(); + String newPath = + RewriteTablePathUtil.stagingPath( + deleteFile.location(), sourcePrefixArg, stagingLocationArg); + OutputFile outputFile = io.newOutputFile(newPath); + PartitionSpec spec = tableArg.getValue().specs().get(deleteFile.specId()); + RewriteTablePathUtil.rewritePositionDeleteFile( + deleteFile, + outputFile, + io, + spec, + sourcePrefixArg, + targetPrefixArg, + posDeleteReaderWriter); + }; + } + + private static CloseableIterable positionDeletesReader( + InputFile inputFile, FileFormat format, PartitionSpec spec) { + return FormatModelRegistry.readBuilder(format, Record.class, inputFile) + .project(DeleteSchemaUtil.posDeleteReadSchema(spec.schema())) + .reuseContainers() + .build(); + } + + private static PositionDeleteWriter positionDeletesWriter( + OutputFile outputFile, + FileFormat format, + PartitionSpec spec, + StructLike partition, + Schema rowSchema) + throws IOException { + if (rowSchema == null) { + return FormatModelRegistry.positionDeleteWriteBuilder( + format, EncryptedFiles.plainAsEncryptedOutput(outputFile)) + .partition(partition) + .spec(spec) + .build(); + } else { + return switch (format) { + case AVRO -> + Avro.writeDeletes(outputFile) + .createWriterFunc(DataWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(outputFile) + .createWriterFunc(GenericParquetWriter::create) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(outputFile) + .createWriterFunc(GenericOrcWriter::buildWriter) + .withPartition(partition) + .rowSchema(rowSchema) + .withSpec(spec) + .buildPositionWriter(); + default -> throw new UnsupportedOperationException("Unsupported file format: " + format); + }; + } + } + + private Set snapshotSet(TableMetadata metadata) { + if (metadata == null) { + return Sets.newHashSet(); + } else { + return Sets.newHashSet(metadata.snapshots()); + } + } + + private boolean fileExist(String path) { + if (path == null || path.trim().isEmpty()) { + return false; + } + return table.io().newInputFile(path).exists(); + } + + private String getMetadataLocation(Table tbl) { + String currentMetadataPath = + ((HasTableOperations) tbl).operations().current().metadataFileLocation(); + int lastIndex = currentMetadataPath.lastIndexOf(RewriteTablePathUtil.FILE_SEPARATOR); + String metadataDir = ""; + if (lastIndex != -1) { + metadataDir = currentMetadataPath.substring(0, lastIndex + 1); + } + + Preconditions.checkArgument( + !metadataDir.isEmpty(), "Failed to get the metadata file root directory"); + return metadataDir; + } + + @VisibleForTesting + Broadcast
    tableBroadcast() { + if (tableBroadcast == null) { + this.tableBroadcast = sparkContext().broadcast(SerializableTableWithSize.copyOf(table)); + } + + return tableBroadcast; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SetAccumulator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SetAccumulator.java new file mode 100644 index 000000000000..745169fc1efd --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SetAccumulator.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.Collections; +import java.util.Set; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.spark.util.AccumulatorV2; + +public class SetAccumulator extends AccumulatorV2> { + + private final Set set = Collections.synchronizedSet(Sets.newHashSet()); + + @Override + public boolean isZero() { + return set.isEmpty(); + } + + @Override + public AccumulatorV2> copy() { + SetAccumulator newAccumulator = new SetAccumulator<>(); + newAccumulator.set.addAll(set); + return newAccumulator; + } + + @Override + public void reset() { + set.clear(); + } + + @Override + public void add(T v) { + set.add(v); + } + + @Override + public void merge(AccumulatorV2> other) { + set.addAll(other.value()); + } + + @Override + public Set value() { + return set; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java new file mode 100644 index 000000000000..043b63870ca9 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SnapshotTableSparkAction.java @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.Map; +import java.util.concurrent.ExecutorService; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.actions.ImmutableSnapshotTable; +import org.apache.iceberg.actions.SnapshotTable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.JobGroupInfo; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.Spark3Util.CatalogAndIdentifier; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.source.StagedSparkTable; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.TableIdentifier; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.StagingTableCatalog; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.collection.JavaConverters; + +/** + * Creates a new Iceberg table based on a source Spark table. The new Iceberg table will have a + * different data and metadata directory allowing it to exist independently of the source table. + */ +public class SnapshotTableSparkAction extends BaseTableCreationSparkAction + implements SnapshotTable { + + private static final Logger LOG = LoggerFactory.getLogger(SnapshotTableSparkAction.class); + + private StagingTableCatalog destCatalog; + private Identifier destTableIdent; + private String destTableLocation = null; + private ExecutorService executorService; + + SnapshotTableSparkAction( + SparkSession spark, CatalogPlugin sourceCatalog, Identifier sourceTableIdent) { + super(spark, sourceCatalog, sourceTableIdent); + } + + @Override + protected SnapshotTableSparkAction self() { + return this; + } + + @Override + protected StagingTableCatalog destCatalog() { + return destCatalog; + } + + @Override + protected Identifier destTableIdent() { + return destTableIdent; + } + + @Override + public SnapshotTableSparkAction as(String ident) { + String ctx = "snapshot destination"; + CatalogPlugin defaultCatalog = spark().sessionState().catalogManager().currentCatalog(); + CatalogAndIdentifier catalogAndIdent = + Spark3Util.catalogAndIdentifier(ctx, spark(), ident, defaultCatalog); + this.destCatalog = checkDestinationCatalog(catalogAndIdent.catalog()); + this.destTableIdent = catalogAndIdent.identifier(); + return this; + } + + @Override + public SnapshotTableSparkAction tableProperties(Map properties) { + setProperties(properties); + return this; + } + + @Override + public SnapshotTableSparkAction tableProperty(String property, String value) { + setProperty(property, value); + return this; + } + + @Override + public SnapshotTableSparkAction executeWith(ExecutorService service) { + this.executorService = service; + return this; + } + + @Override + public SnapshotTable.Result execute() { + String desc = String.format("Snapshotting table %s as %s", sourceTableIdent(), destTableIdent); + JobGroupInfo info = newJobGroupInfo("SNAPSHOT-TABLE", desc); + return withJobGroupInfo(info, this::doExecute); + } + + private SnapshotTable.Result doExecute() { + Preconditions.checkArgument( + destCatalog() != null && destTableIdent() != null, + "The destination catalog and identifier cannot be null. " + + "Make sure to configure the action with a valid destination table identifier via the `as` method."); + + LOG.info( + "Staging a new Iceberg table {} as a snapshot of {}", destTableIdent(), sourceTableIdent()); + StagedSparkTable stagedTable = stageDestTable(); + Table icebergTable = stagedTable.table(); + + String sourceTableLocation = sourceTableLocation(); + String stagedTableLocation = icebergTable.location(); + Preconditions.checkArgument( + !sourceTableLocation.equals(stagedTableLocation) + && !stagedTableLocation.startsWith(sourceTableLocation + "/") + && !sourceTableLocation.startsWith(stagedTableLocation + "/"), + "Cannot create a snapshot at location %s because it would overlap with source table location %s. " + + "Overlapping snapshot and source would mix table files.", + stagedTableLocation, + sourceTableLocation); + + boolean threw = true; + try { + LOG.info("Ensuring {} has a valid name mapping", destTableIdent()); + ensureNameMappingPresent(icebergTable); + + TableIdentifier v1TableIdent = v1SourceTable().identifier(); + String stagingLocation = getMetadataLocation(icebergTable); + LOG.info("Generating Iceberg metadata for {} in {}", destTableIdent(), stagingLocation); + SparkTableUtil.importSparkTable( + spark(), v1TableIdent, icebergTable, stagingLocation, executorService); + + LOG.info("Committing staged changes to {}", destTableIdent()); + stagedTable.commitStagedChanges(); + threw = false; + } finally { + if (threw) { + LOG.error("Error when populating the staged table with metadata, aborting changes"); + + try { + stagedTable.abortStagedChanges(); + } catch (Exception abortException) { + LOG.error("Cannot abort staged changes", abortException); + } + } + } + + Snapshot snapshot = icebergTable.currentSnapshot(); + long importedDataFilesCount = + Long.parseLong(snapshot.summary().get(SnapshotSummary.TOTAL_DATA_FILES_PROP)); + LOG.info( + "Successfully loaded Iceberg metadata for {} files to {}", + importedDataFilesCount, + destTableIdent()); + return ImmutableSnapshotTable.Result.builder() + .importedDataFilesCount(importedDataFilesCount) + .build(); + } + + @Override + protected Map destTableProps() { + Map properties = Maps.newHashMap(); + + // copy over relevant source table props + properties.putAll(JavaConverters.mapAsJavaMapConverter(v1SourceTable().properties()).asJava()); + EXCLUDED_PROPERTIES.forEach(properties::remove); + + // remove any possible location properties from origin properties + properties.remove(LOCATION); + properties.remove(TableProperties.WRITE_METADATA_LOCATION); + properties.remove(TableProperties.WRITE_FOLDER_STORAGE_LOCATION); + properties.remove(TableProperties.OBJECT_STORE_PATH); + properties.remove(TableProperties.WRITE_DATA_LOCATION); + + // set default and user-provided props + properties.put(TableCatalog.PROP_PROVIDER, "iceberg"); + properties.putAll(additionalProperties()); + + // make sure we mark this table as a snapshot table + properties.put(TableProperties.GC_ENABLED, "false"); + properties.put("snapshot", "true"); + + // set the destination table location if provided + if (destTableLocation != null) { + properties.put(LOCATION, destTableLocation); + } + + return properties; + } + + @Override + protected TableCatalog checkSourceCatalog(CatalogPlugin catalog) { + // currently the import code relies on being able to look up the table in the session catalog + Preconditions.checkArgument( + catalog.name().equalsIgnoreCase("spark_catalog"), + "Cannot snapshot a table that isn't in the session catalog (i.e. spark_catalog). " + + "Found source catalog: %s.", + catalog.name()); + + Preconditions.checkArgument( + catalog instanceof TableCatalog, + "Cannot snapshot as catalog %s of class %s in not a table catalog", + catalog.name(), + catalog.getClass().getName()); + + return (TableCatalog) catalog; + } + + @Override + public SnapshotTableSparkAction tableLocation(String location) { + Preconditions.checkArgument( + !sourceTableLocation().equals(location), + "The snapshot table location cannot be same as the source table location. " + + "This would mix snapshot table files with original table files."); + this.destTableLocation = location; + return this; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkActions.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkActions.java new file mode 100644 index 000000000000..b7361c336a69 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkActions.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ActionsProvider; +import org.apache.iceberg.actions.ComputePartitionStats; +import org.apache.iceberg.actions.ComputeTableStats; +import org.apache.iceberg.actions.RemoveDanglingDeleteFiles; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.Spark3Util.CatalogAndIdentifier; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; + +/** + * An implementation of {@link ActionsProvider} for Spark. + * + *

    This class is the primary API for interacting with actions in Spark that users should use to + * instantiate particular actions. + */ +public class SparkActions implements ActionsProvider { + + private final SparkSession spark; + + private SparkActions(SparkSession spark) { + this.spark = spark; + } + + public static SparkActions get(SparkSession spark) { + return new SparkActions(spark); + } + + public static SparkActions get() { + return new SparkActions(SparkSession.active()); + } + + @Override + public SnapshotTableSparkAction snapshotTable(String tableIdent) { + String ctx = "snapshot source"; + CatalogPlugin defaultCatalog = spark.sessionState().catalogManager().currentCatalog(); + CatalogAndIdentifier catalogAndIdent = + Spark3Util.catalogAndIdentifier(ctx, spark, tableIdent, defaultCatalog); + return new SnapshotTableSparkAction( + spark, catalogAndIdent.catalog(), catalogAndIdent.identifier()); + } + + @Override + public MigrateTableSparkAction migrateTable(String tableIdent) { + String ctx = "migrate target"; + CatalogPlugin defaultCatalog = spark.sessionState().catalogManager().currentCatalog(); + CatalogAndIdentifier catalogAndIdent = + Spark3Util.catalogAndIdentifier(ctx, spark, tableIdent, defaultCatalog); + return new MigrateTableSparkAction( + spark, catalogAndIdent.catalog(), catalogAndIdent.identifier()); + } + + @Override + public RewriteDataFilesSparkAction rewriteDataFiles(Table table) { + return new RewriteDataFilesSparkAction(spark, table); + } + + @Override + public DeleteOrphanFilesSparkAction deleteOrphanFiles(Table table) { + return new DeleteOrphanFilesSparkAction(spark, table); + } + + @Override + public RewriteManifestsSparkAction rewriteManifests(Table table) { + return new RewriteManifestsSparkAction(spark, table); + } + + @Override + public ExpireSnapshotsSparkAction expireSnapshots(Table table) { + return new ExpireSnapshotsSparkAction(spark, table); + } + + @Override + public DeleteReachableFilesSparkAction deleteReachableFiles(String metadataLocation) { + return new DeleteReachableFilesSparkAction(spark, metadataLocation); + } + + @Override + public RewritePositionDeleteFilesSparkAction rewritePositionDeletes(Table table) { + return new RewritePositionDeleteFilesSparkAction(spark, table); + } + + @Override + public ComputeTableStats computeTableStats(Table table) { + return new ComputeTableStatsSparkAction(spark, table); + } + + @Override + public ComputePartitionStats computePartitionStats(Table table) { + return new ComputePartitionStatsSparkAction(spark, table); + } + + @Override + public RemoveDanglingDeleteFiles removeDanglingDeleteFiles(Table table) { + return new RemoveDanglingDeletesSparkAction(spark, table); + } + + @Override + public RewriteTablePathSparkAction rewriteTablePath(Table table) { + return new RewriteTablePathSparkAction(spark, table); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkBinPackFileRewriteRunner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkBinPackFileRewriteRunner.java new file mode 100644 index 000000000000..084e21b1bd21 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkBinPackFileRewriteRunner.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteFileGroup; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; + +class SparkBinPackFileRewriteRunner extends SparkDataFileRewriteRunner { + + SparkBinPackFileRewriteRunner(SparkSession spark, Table table) { + super(spark, table); + } + + @Override + public String description() { + return "BIN-PACK"; + } + + @Override + protected void doRewrite(String groupId, RewriteFileGroup group) { + // read the files packing them into splits of the required size + Dataset scanDF = + spark() + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, group.inputSplitSize()) + .option(SparkReadOptions.FILE_OPEN_COST, "0") + .load(groupId); + + // write the packed data into new files where each split becomes a new file + scanDF + .write() + .format("iceberg") + .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, group.maxOutputFileSize()) + .option(SparkWriteOptions.DISTRIBUTION_MODE, distributionMode(group).modeName()) + .option(SparkWriteOptions.OUTPUT_SPEC_ID, group.outputSpecId()) + .mode("append") + .save(groupId); + } + + // invoke a shuffle if the original spec does not match the output spec + private DistributionMode distributionMode(RewriteFileGroup group) { + boolean requiresRepartition = + !group.fileScanTasks().get(0).spec().equals(spec(group.outputSpecId())); + return requiresRepartition ? DistributionMode.RANGE : DistributionMode.NONE; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkDataFileRewriteRunner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkDataFileRewriteRunner.java new file mode 100644 index 000000000000..b37011b3dd76 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkDataFileRewriteRunner.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.Set; +import java.util.UUID; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteDataFiles.FileGroupInfo; +import org.apache.iceberg.actions.RewriteFileGroup; +import org.apache.iceberg.spark.FileRewriteCoordinator; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkTableCache; +import org.apache.spark.sql.SparkSession; + +abstract class SparkDataFileRewriteRunner + extends SparkRewriteRunner { + private final SparkTableCache tableCache = SparkTableCache.get(); + private final ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + private final FileRewriteCoordinator coordinator = FileRewriteCoordinator.get(); + + SparkDataFileRewriteRunner(SparkSession spark, Table table) { + super(spark, table); + } + + abstract void doRewrite(String groupId, RewriteFileGroup fileGroup); + + @Override + public Set rewrite(RewriteFileGroup group) { + String groupId = UUID.randomUUID().toString(); + try { + tableCache.add(groupId, table()); + taskSetManager.stageTasks(table(), groupId, group.fileScanTasks()); + + doRewrite(groupId, group); + + return coordinator.fetchNewFiles(table(), groupId); + } finally { + tableCache.remove(groupId); + taskSetManager.removeTasks(table(), groupId); + coordinator.clearRewrite(table(), groupId); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkRewritePositionDeleteRunner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkRewritePositionDeleteRunner.java new file mode 100644 index 000000000000..4bcf20811014 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkRewritePositionDeleteRunner.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.MetadataTableType.POSITION_DELETES; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.lit; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.IntStream; +import org.apache.iceberg.DataFilesTable; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewritePositionDeleteFiles; +import org.apache.iceberg.actions.RewritePositionDeletesGroup; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.PositionDeletesRewriteCoordinator; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkTableCache; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; + +class SparkRewritePositionDeleteRunner + extends SparkRewriteRunner< + RewritePositionDeleteFiles.FileGroupInfo, + PositionDeletesScanTask, + DeleteFile, + RewritePositionDeletesGroup> { + + private final SparkTableCache tableCache = SparkTableCache.get(); + private final ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + private final PositionDeletesRewriteCoordinator coordinator = + PositionDeletesRewriteCoordinator.get(); + + SparkRewritePositionDeleteRunner(SparkSession spark, Table table) { + // Disable Adaptive Query Execution as this may change the output partitioning of our write + super(((org.apache.spark.sql.classic.SparkSession) spark).cloneSession(), table); + this.spark().conf().set(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), false); + } + + @Override + public String description() { + return "BIN-PACK"; + } + + @Override + public Set rewrite(RewritePositionDeletesGroup group) { + String groupId = UUID.randomUUID().toString(); + Table deletesTable = MetadataTableUtils.createMetadataTableInstance(table(), POSITION_DELETES); + try { + tableCache.add(groupId, deletesTable); + taskSetManager.stageTasks(deletesTable, groupId, group.fileScanTasks()); + + doRewrite(groupId, group); + + return coordinator.fetchNewFiles(deletesTable, groupId); + } finally { + tableCache.remove(groupId); + taskSetManager.removeTasks(deletesTable, groupId); + coordinator.clearRewrite(deletesTable, groupId); + } + } + + protected void doRewrite(String groupId, RewritePositionDeletesGroup group) { + // all position deletes are of the same partition, because they are in same file group + Preconditions.checkArgument(!group.rewrittenDeleteFiles().isEmpty(), "Empty group"); + DeleteFile deleteFile = group.rewrittenDeleteFiles().iterator().next(); + Types.StructType partitionType = table().specs().get(deleteFile.specId()).partitionType(); + StructLike partition = deleteFile.partition(); + + // read the deletes packing them into splits of the required size + Dataset posDeletes = + spark() + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, group.inputSplitSize()) + .option(SparkReadOptions.FILE_OPEN_COST, "0") + .load(groupId); + + // keep only valid position deletes + Dataset dataFiles = dataFiles(partitionType, partition); + Column joinCond = posDeletes.col("file_path").equalTo(dataFiles.col("file_path")); + Dataset validDeletes = posDeletes.join(dataFiles, joinCond, "leftsemi"); + + // write the packed deletes into new files where each split becomes a new file + validDeletes + .sortWithinPartitions("file_path", "pos") + .write() + .format("iceberg") + .option(SparkWriteOptions.TARGET_DELETE_FILE_SIZE_BYTES, group.maxOutputFileSize()) + .mode("append") + .save(groupId); + } + + /** Returns entries of {@link DataFilesTable} of specified partition */ + private Dataset dataFiles(Types.StructType partitionType, StructLike partition) { + List fields = partitionType.fields(); + Optional condition = + IntStream.range(0, fields.size()) + .mapToObj( + i -> { + Type type = fields.get(i).type(); + Object value = partition.get(i, type.typeId().javaClass()); + Object convertedValue = SparkValueConverter.convertToSpark(type, value); + Column col = col("partition.`" + fields.get(i).name() + "`"); + return col.eqNullSafe(lit(convertedValue)); + }) + .reduce(Column::and); + if (condition.isPresent()) { + return SparkTableUtil.loadMetadataTable(spark(), table(), MetadataTableType.DATA_FILES) + .filter(condition.get()); + } else { + return SparkTableUtil.loadMetadataTable(spark(), table(), MetadataTableType.DATA_FILES); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkRewriteRunner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkRewriteRunner.java new file mode 100644 index 000000000000..cb65177061b6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkRewriteRunner.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.Map; +import java.util.Set; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.ContentScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.FileRewriteRunner; +import org.apache.iceberg.actions.RewriteDataFiles; +import org.apache.iceberg.actions.RewriteFileGroup; +import org.apache.iceberg.actions.RewriteGroupBase; +import org.apache.iceberg.actions.RewritePositionDeleteFiles; +import org.apache.iceberg.actions.RewritePositionDeletesGroup; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.spark.sql.SparkSession; + +/** + * Base class for Spark file rewrite runners which implement the {@link FileRewriteRunner} API for + * Spark based file rewrites. This class encapsulates the common interface and attributes for Spark + * file rewrite runners. The actual implementation of the file rewrite logic is implemented by the + * subclasses. + * + * @param the Java type of the plan info like {@link RewriteDataFiles.FileGroupInfo} or {@link + * RewritePositionDeleteFiles.FileGroupInfo} + * @param the Java type of the input scan tasks (input) + * @param the Java type of the content files (input and output) + * @param the Java type of the rewrite file group like {@link RewriteFileGroup} or {@link + * RewritePositionDeletesGroup} + */ +abstract class SparkRewriteRunner< + I, + T extends ContentScanTask, + F extends ContentFile, + G extends RewriteGroupBase> + implements FileRewriteRunner { + private final SparkSession spark; + private final Table table; + + SparkRewriteRunner(SparkSession spark, Table table) { + this.spark = spark; + this.table = table; + } + + SparkSession spark() { + return spark; + } + + Table table() { + return table; + } + + @Override + public Set validOptions() { + return ImmutableSet.of(); + } + + @Override + public void init(Map options) {} + + PartitionSpec spec(int specId) { + return table().specs().get(specId); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingDataRewritePlanner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingDataRewritePlanner.java new file mode 100644 index 000000000000..736114937eff --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingDataRewritePlanner.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.Map; +import java.util.Set; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.BinPackRewriteFilePlanner; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.util.PropertyUtil; + +/** + * Produces plans for shuffling rewrites. Since shuffle and sort could considerably improve the + * compression ratio, the planner introduces an additional {@link #COMPRESSION_FACTOR} option which + * is used when calculating the {@link #expectedOutputFiles(long)}. + */ +class SparkShufflingDataRewritePlanner extends BinPackRewriteFilePlanner { + /** + * The number of shuffle partitions and consequently the number of output files created by the + * Spark sort is based on the size of the input data files used in this file rewriter. Due to + * compression, the disk file sizes may not accurately represent the size of files in the output. + * This parameter lets the user adjust the file size used for estimating actual output data size. + * A factor greater than 1.0 would generate more files than we would expect based on the on-disk + * file size. A value less than 1.0 would create fewer files than we would expect based on the + * on-disk size. + */ + public static final String COMPRESSION_FACTOR = "compression-factor"; + + public static final double COMPRESSION_FACTOR_DEFAULT = 1.0; + + private double compressionFactor; + + SparkShufflingDataRewritePlanner( + Table table, Expression filter, Long snapshotId, boolean caseSensitive) { + super(table, filter, snapshotId, caseSensitive); + } + + @Override + public Set validOptions() { + return ImmutableSet.builder() + .addAll(super.validOptions()) + .add(COMPRESSION_FACTOR) + .build(); + } + + @Override + public void init(Map options) { + super.init(options); + this.compressionFactor = compressionFactor(options); + } + + @Override + protected int expectedOutputFiles(long inputSize) { + return Math.max(1, super.expectedOutputFiles((long) (inputSize * compressionFactor))); + } + + private double compressionFactor(Map options) { + double value = + PropertyUtil.propertyAsDouble(options, COMPRESSION_FACTOR, COMPRESSION_FACTOR_DEFAULT); + Preconditions.checkArgument( + value > 0, "'%s' is set to %s but must be > 0", COMPRESSION_FACTOR, value); + return value; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java new file mode 100644 index 000000000000..f20017648dde --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkShufflingFileRewriteRunner.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteFileGroup; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkFunctionCatalog; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.SortOrderUtil; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan; +import org.apache.spark.sql.catalyst.plans.logical.OrderAwareCoalesce; +import org.apache.spark.sql.catalyst.plans.logical.OrderAwareCoalescer; +import org.apache.spark.sql.connector.distributions.Distribution; +import org.apache.spark.sql.connector.distributions.Distributions; +import org.apache.spark.sql.connector.distributions.OrderedDistribution; +import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.write.RequiresDistributionAndOrdering; +import org.apache.spark.sql.execution.datasources.v2.DistributionAndOrderingUtils$; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import scala.Option; + +abstract class SparkShufflingFileRewriteRunner extends SparkDataFileRewriteRunner { + + private static final Logger LOG = LoggerFactory.getLogger(SparkShufflingFileRewriteRunner.class); + + /** + * The number of shuffle partitions to use for each output file. By default, this file rewriter + * assumes each shuffle partition would become a separate output file. Attempting to generate + * large output files of 512 MB or higher may strain the memory resources of the cluster as such + * rewrites would require lots of Spark memory. This parameter can be used to further divide up + * the data which will end up in a single file. For example, if the target file size is 2 GB, but + * the cluster can only handle shuffles of 512 MB, this parameter could be set to 4. Iceberg will + * use a custom coalesce operation to stitch these sorted partitions back together into a single + * sorted file. + * + *

    Note using this parameter requires enabling Iceberg Spark session extensions. + */ + public static final String SHUFFLE_PARTITIONS_PER_FILE = "shuffle-partitions-per-file"; + + public static final int SHUFFLE_PARTITIONS_PER_FILE_DEFAULT = 1; + + private int numShufflePartitionsPerFile; + + protected SparkShufflingFileRewriteRunner(SparkSession spark, Table table) { + super(spark, table); + } + + protected abstract org.apache.iceberg.SortOrder sortOrder(); + + /** + * Retrieves and returns the schema for the rewrite using the current table schema. + * + *

    The schema with all columns required for correctly sorting the table. This may include + * additional computed columns which are not written to the table but are used for sorting. + */ + protected Schema sortSchema() { + return table().schema(); + } + + protected abstract Dataset sortedDF( + Dataset df, Function, Dataset> sortFunc); + + @Override + public Set validOptions() { + return ImmutableSet.builder() + .addAll(super.validOptions()) + .add(SHUFFLE_PARTITIONS_PER_FILE) + .build(); + } + + @Override + public void init(Map options) { + super.init(options); + this.numShufflePartitionsPerFile = numShufflePartitionsPerFile(options); + } + + @Override + public void doRewrite(String groupId, RewriteFileGroup fileGroup) { + Dataset scanDF = spark().read().format("iceberg").load(groupId); + + Dataset sortedDF = + sortedDF( + scanDF, + sortFunction( + fileGroup.fileScanTasks(), + spec(fileGroup.outputSpecId()), + fileGroup.expectedOutputFiles())); + + org.apache.iceberg.SortOrder sortOrderInJobSpec = sortOrder(); + + org.apache.iceberg.SortOrder maybeMatchingTableSortOrder = + SortOrderUtil.findTableSortOrder(table(), sortOrder()); + + if (sortOrderInJobSpec.isSorted() && maybeMatchingTableSortOrder.isUnsorted()) { + LOG.warn( + "Sort order specified for job {} doesn't match any table sort orders, rewritten files will not be marked as sorted in the manifest files", + Spark3Util.describe(sortOrderInJobSpec)); + } + + sortedDF + .write() + .format("iceberg") + .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, fileGroup.maxOutputFileSize()) + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") + .option(SparkWriteOptions.OUTPUT_SPEC_ID, fileGroup.outputSpecId()) + .option(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, maybeMatchingTableSortOrder.orderId()) + .mode("append") + .save(groupId); + } + + private Function, Dataset> sortFunction( + List group, PartitionSpec outputSpec, int expectedOutputFiles) { + SortOrder[] ordering = Spark3Util.toOrdering(outputSortOrder(group, outputSpec)); + int numShufflePartitions = Math.max(1, expectedOutputFiles * numShufflePartitionsPerFile); + return df -> transformPlan(df, plan -> sortPlan(plan, ordering, numShufflePartitions)); + } + + private LogicalPlan sortPlan(LogicalPlan plan, SortOrder[] ordering, int numShufflePartitions) { + SparkFunctionCatalog catalog = SparkFunctionCatalog.get(); + OrderedWrite write = new OrderedWrite(ordering, numShufflePartitions); + LogicalPlan sortPlan = + DistributionAndOrderingUtils$.MODULE$.prepareQuery(write, plan, Option.apply(catalog)); + + if (numShufflePartitionsPerFile == 1) { + return sortPlan; + } else { + OrderAwareCoalescer coalescer = new OrderAwareCoalescer(numShufflePartitionsPerFile); + int numOutputPartitions = numShufflePartitions / numShufflePartitionsPerFile; + return new OrderAwareCoalesce(numOutputPartitions, coalescer, sortPlan); + } + } + + private Dataset transformPlan(Dataset df, Function func) { + Preconditions.checkArgument( + spark() instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark().getClass().getName()); + + Preconditions.checkArgument( + df instanceof org.apache.spark.sql.classic.Dataset, + "df is supposed to be org.apache.spark.sql.classic.Dataset, but got: %s", + df.getClass().getName()); + + return new org.apache.spark.sql.classic.Dataset<>( + ((org.apache.spark.sql.classic.SparkSession) spark()), + func.apply(((org.apache.spark.sql.classic.Dataset) df).logicalPlan()), + df.encoder()); + } + + private org.apache.iceberg.SortOrder outputSortOrder( + List group, PartitionSpec outputSpec) { + boolean requiresRepartitioning = !group.get(0).spec().equals(outputSpec); + if (requiresRepartitioning) { + // build in the requirement for partition sorting into our sort order + // as the original spec for this group does not match the output spec + return SortOrderUtil.buildSortOrder(sortSchema(), outputSpec, sortOrder()); + } else { + return sortOrder(); + } + } + + private int numShufflePartitionsPerFile(Map options) { + int value = + PropertyUtil.propertyAsInt( + options, SHUFFLE_PARTITIONS_PER_FILE, SHUFFLE_PARTITIONS_PER_FILE_DEFAULT); + Preconditions.checkArgument( + value > 0, "'%s' is set to %s but must be > 0", SHUFFLE_PARTITIONS_PER_FILE, value); + Preconditions.checkArgument( + value == 1 || Spark3Util.extensionsEnabled(spark()), + "Using '%s' requires enabling Iceberg Spark session extensions", + SHUFFLE_PARTITIONS_PER_FILE); + return value; + } + + private static class OrderedWrite implements RequiresDistributionAndOrdering { + private final OrderedDistribution distribution; + private final SortOrder[] ordering; + private final int numShufflePartitions; + + OrderedWrite(SortOrder[] ordering, int numShufflePartitions) { + this.distribution = Distributions.ordered(ordering); + this.ordering = ordering; + this.numShufflePartitions = numShufflePartitions; + } + + @Override + public Distribution requiredDistribution() { + return distribution; + } + + @Override + public boolean distributionStrictlyRequired() { + return true; + } + + @Override + public int requiredNumPartitions() { + return numShufflePartitions; + } + + @Override + public SortOrder[] requiredOrdering() { + return ordering; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkSortFileRewriteRunner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkSortFileRewriteRunner.java new file mode 100644 index 000000000000..9ec36c9463f5 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkSortFileRewriteRunner.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.util.function.Function; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; + +class SparkSortFileRewriteRunner extends SparkShufflingFileRewriteRunner { + + private final SortOrder sortOrder; + + SparkSortFileRewriteRunner(SparkSession spark, Table table) { + super(spark, table); + Preconditions.checkArgument( + table.sortOrder().isSorted(), + "Cannot sort data without a valid sort order, table '%s' is unsorted and no sort order is provided", + table.name()); + this.sortOrder = table.sortOrder(); + } + + SparkSortFileRewriteRunner(SparkSession spark, Table table, SortOrder sortOrder) { + super(spark, table); + Preconditions.checkArgument( + sortOrder != null && sortOrder.isSorted(), + "Cannot sort data without a valid sort order, the provided sort order is null or empty"); + this.sortOrder = sortOrder; + } + + @Override + public String description() { + return "SORT"; + } + + @Override + protected SortOrder sortOrder() { + return sortOrder; + } + + @Override + protected Dataset sortedDF(Dataset df, Function, Dataset> sortFunc) { + return sortFunc.apply(df); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java new file mode 100644 index 000000000000..fa8d765541da --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderFileRewriteRunner.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.spark.sql.functions.array; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.apache.iceberg.NullOrder; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortDirection; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.ZOrderByteUtils; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SparkZOrderFileRewriteRunner extends SparkShufflingFileRewriteRunner { + private static final Logger LOG = LoggerFactory.getLogger(SparkZOrderFileRewriteRunner.class); + + private static final String Z_COLUMN = "ICEZVALUE"; + private static final Schema Z_SCHEMA = + new Schema(Types.NestedField.required(0, Z_COLUMN, Types.BinaryType.get())); + private static final SortOrder Z_SORT_ORDER = + SortOrder.builderFor(Z_SCHEMA) + .sortBy(Z_COLUMN, SortDirection.ASC, NullOrder.NULLS_LAST) + .build(); + + /** + * Controls the amount of bytes interleaved in the ZOrder algorithm. Default is all bytes being + * interleaved. + */ + public static final String MAX_OUTPUT_SIZE = "max-output-size"; + + public static final int MAX_OUTPUT_SIZE_DEFAULT = Integer.MAX_VALUE; + + /** + * Controls the number of bytes considered from an input column of a type with variable length + * (String, Binary). + * + *

    Default is to use the same size as primitives {@link ZOrderByteUtils#PRIMITIVE_BUFFER_SIZE}. + */ + public static final String VAR_LENGTH_CONTRIBUTION = "var-length-contribution"; + + public static final int VAR_LENGTH_CONTRIBUTION_DEFAULT = ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE; + + private final List zOrderColNames; + private int maxOutputSize; + private int varLengthContribution; + + SparkZOrderFileRewriteRunner(SparkSession spark, Table table, List zOrderColNames) { + super(spark, table); + this.zOrderColNames = validZOrderColNames(spark, table, zOrderColNames); + } + + @Override + public String description() { + return "Z-ORDER"; + } + + @Override + public Set validOptions() { + return ImmutableSet.builder() + .addAll(super.validOptions()) + .add(MAX_OUTPUT_SIZE) + .add(VAR_LENGTH_CONTRIBUTION) + .build(); + } + + @Override + public void init(Map options) { + super.init(options); + this.maxOutputSize = maxOutputSize(options); + this.varLengthContribution = varLengthContribution(options); + } + + @Override + protected SortOrder sortOrder() { + return Z_SORT_ORDER; + } + + /** + * Overrides the sortSchema method to include columns from Z_SCHEMA. + * + *

    This method generates a new Schema object which consists of columns from the original table + * schema and Z_SCHEMA. + */ + @Override + protected Schema sortSchema() { + return new Schema( + new ImmutableList.Builder() + .addAll(table().schema().columns()) + .addAll(Z_SCHEMA.columns()) + .build()); + } + + @Override + protected Dataset sortedDF(Dataset df, Function, Dataset> sortFunc) { + Dataset zValueDF = df.withColumn(Z_COLUMN, zValue(df)); + Dataset sortedDF = sortFunc.apply(zValueDF); + return sortedDF.drop(Z_COLUMN); + } + + private Column zValue(Dataset df) { + SparkZOrderUDF zOrderUDF = + new SparkZOrderUDF(zOrderColNames.size(), varLengthContribution, maxOutputSize); + + Column[] zOrderCols = + zOrderColNames.stream() + .map(df.schema()::apply) + .map(col -> zOrderUDF.sortedLexicographically(df.col(col.name()), col.dataType())) + .toArray(Column[]::new); + + return zOrderUDF.interleaveBytes(array(zOrderCols)); + } + + private int varLengthContribution(Map options) { + int value = + PropertyUtil.propertyAsInt( + options, VAR_LENGTH_CONTRIBUTION, VAR_LENGTH_CONTRIBUTION_DEFAULT); + Preconditions.checkArgument( + value > 0, + "Cannot use less than 1 byte for variable length types with ZOrder, '%s' was set to %s", + VAR_LENGTH_CONTRIBUTION, + value); + return value; + } + + private int maxOutputSize(Map options) { + int value = PropertyUtil.propertyAsInt(options, MAX_OUTPUT_SIZE, MAX_OUTPUT_SIZE_DEFAULT); + Preconditions.checkArgument( + value > 0, + "Cannot have the interleaved ZOrder value use less than 1 byte, '%s' was set to %s", + MAX_OUTPUT_SIZE, + value); + return value; + } + + private List validZOrderColNames( + SparkSession spark, Table table, List inputZOrderColNames) { + + Preconditions.checkArgument( + inputZOrderColNames != null && !inputZOrderColNames.isEmpty(), + "Cannot ZOrder when no columns are specified"); + + Schema schema = table.schema(); + Set identityPartitionFieldIds = table.spec().identitySourceIds(); + boolean caseSensitive = SparkUtil.caseSensitive(spark); + + Preconditions.checkArgument( + caseSensitive + ? schema.findField(Z_COLUMN) == null + : schema.caseInsensitiveFindField(Z_COLUMN) == null, + "Cannot zorder because the table has a column named '%s', which conflicts with Iceberg's internal Z-order column name", + Z_COLUMN); + + List validZOrderColNames = Lists.newArrayList(); + + for (String colName : inputZOrderColNames) { + Types.NestedField field = + caseSensitive ? schema.findField(colName) : schema.caseInsensitiveFindField(colName); + Preconditions.checkArgument( + field != null, + "Cannot find column '%s' in table schema (case sensitive = %s): %s", + colName, + caseSensitive, + schema.asStruct()); + + if (identityPartitionFieldIds.contains(field.fieldId())) { + LOG.warn("Ignoring '{}' as such values are constant within a partition", colName); + } else { + validZOrderColNames.add(colName); + } + } + + Preconditions.checkArgument( + !validZOrderColNames.isEmpty(), + "Cannot ZOrder, all columns provided were identity partition columns and cannot be used"); + + return validZOrderColNames; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java new file mode 100644 index 000000000000..cf9cc8fd511a --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/actions/SparkZOrderUDF.java @@ -0,0 +1,353 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.nio.ByteBuffer; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.iceberg.util.ZOrderByteUtils; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.expressions.UserDefinedFunction; +import org.apache.spark.sql.functions; +import org.apache.spark.sql.types.BinaryType; +import org.apache.spark.sql.types.BooleanType; +import org.apache.spark.sql.types.ByteType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.DateType; +import org.apache.spark.sql.types.DoubleType; +import org.apache.spark.sql.types.FloatType; +import org.apache.spark.sql.types.IntegerType; +import org.apache.spark.sql.types.LongType; +import org.apache.spark.sql.types.ShortType; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.TimestampNTZType; +import org.apache.spark.sql.types.TimestampType; +import scala.collection.JavaConverters; +import scala.collection.Seq; + +class SparkZOrderUDF implements Serializable { + private static final byte[] PRIMITIVE_EMPTY = new byte[ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE]; + + /** + * Every Spark task runs iteratively on a rows in a single thread so ThreadLocal should protect + * from concurrent access to any of these structures. + */ + private transient ThreadLocal outputBuffer; + + private transient ThreadLocal inputHolder; + private transient ThreadLocal inputBuffers; + private transient ThreadLocal encoder; + + private final int numCols; + + private int inputCol = 0; + private int totalOutputBytes = 0; + private final int varTypeSize; + private final int maxOutputSize; + + SparkZOrderUDF(int numCols, int varTypeSize, int maxOutputSize) { + this.numCols = numCols; + this.varTypeSize = varTypeSize; + this.maxOutputSize = maxOutputSize; + } + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + in.defaultReadObject(); + inputBuffers = ThreadLocal.withInitial(() -> new ByteBuffer[numCols]); + inputHolder = ThreadLocal.withInitial(() -> new byte[numCols][]); + outputBuffer = ThreadLocal.withInitial(() -> ByteBuffer.allocate(totalOutputBytes)); + encoder = ThreadLocal.withInitial(() -> StandardCharsets.UTF_8.newEncoder()); + } + + private ByteBuffer inputBuffer(int position, int size) { + ByteBuffer buffer = inputBuffers.get()[position]; + if (buffer == null) { + buffer = ByteBuffer.allocate(size); + inputBuffers.get()[position] = buffer; + } + return buffer; + } + + byte[] interleaveBits(Seq scalaBinary) { + byte[][] columnsBinary = JavaConverters.seqAsJavaList(scalaBinary).toArray(inputHolder.get()); + return ZOrderByteUtils.interleaveBits(columnsBinary, totalOutputBytes, outputBuffer.get()); + } + + private UserDefinedFunction tinyToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (Byte value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + return ZOrderByteUtils.tinyintToOrderedBytes( + value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("TINY_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + + private UserDefinedFunction shortToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (Short value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + return ZOrderByteUtils.shortToOrderedBytes( + value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("SHORT_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + + private UserDefinedFunction intToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (Integer value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + return ZOrderByteUtils.intToOrderedBytes( + value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("INT_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + + private UserDefinedFunction longToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (Long value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + return ZOrderByteUtils.longToOrderedBytes( + value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("LONG_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + + private UserDefinedFunction timestampNtzToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (LocalDateTime value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + long micros = DateTimeUtil.microsFromTimestamp(value); + return ZOrderByteUtils.longToOrderedBytes( + micros, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("TIMESTAMP_NTZ_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + + private UserDefinedFunction floatToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (Float value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + return ZOrderByteUtils.floatToOrderedBytes( + value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("FLOAT_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + + private UserDefinedFunction doubleToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (Double value) -> { + if (value == null) { + return PRIMITIVE_EMPTY; + } + return ZOrderByteUtils.doubleToOrderedBytes( + value, inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE)) + .array(); + }, + DataTypes.BinaryType) + .withName("DOUBLE_ORDERED_BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + + return udf; + } + + private UserDefinedFunction booleanToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (Boolean value) -> { + ByteBuffer buffer = inputBuffer(position, ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + buffer.put(0, (byte) (value ? -127 : 0)); + return buffer.array(); + }, + DataTypes.BinaryType) + .withName("BOOLEAN-LEXICAL-BYTES"); + + this.inputCol++; + increaseOutputSize(ZOrderByteUtils.PRIMITIVE_BUFFER_SIZE); + return udf; + } + + private UserDefinedFunction stringToOrderedBytesUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (String value) -> + ZOrderByteUtils.stringToOrderedBytes( + value, varTypeSize, inputBuffer(position, varTypeSize), encoder.get()) + .array(), + DataTypes.BinaryType) + .withName("STRING-LEXICAL-BYTES"); + + this.inputCol++; + increaseOutputSize(varTypeSize); + + return udf; + } + + private UserDefinedFunction bytesTruncateUDF() { + int position = inputCol; + UserDefinedFunction udf = + functions + .udf( + (byte[] value) -> + ZOrderByteUtils.byteTruncateOrFill( + value, varTypeSize, inputBuffer(position, varTypeSize)) + .array(), + DataTypes.BinaryType) + .withName("BYTE-TRUNCATE"); + + this.inputCol++; + increaseOutputSize(varTypeSize); + + return udf; + } + + private final UserDefinedFunction interleaveUDF = + functions + .udf((Seq arrayBinary) -> interleaveBits(arrayBinary), DataTypes.BinaryType) + .withName("INTERLEAVE_BYTES"); + + Column interleaveBytes(Column arrayBinary) { + return interleaveUDF.apply(arrayBinary); + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + Column sortedLexicographically(Column column, DataType type) { + if (type instanceof ByteType) { + return tinyToOrderedBytesUDF().apply(column); + } else if (type instanceof ShortType) { + return shortToOrderedBytesUDF().apply(column); + } else if (type instanceof IntegerType) { + return intToOrderedBytesUDF().apply(column); + } else if (type instanceof LongType) { + return longToOrderedBytesUDF().apply(column); + } else if (type instanceof FloatType) { + return floatToOrderedBytesUDF().apply(column); + } else if (type instanceof DoubleType) { + return doubleToOrderedBytesUDF().apply(column); + } else if (type instanceof StringType) { + return stringToOrderedBytesUDF().apply(column); + } else if (type instanceof BinaryType) { + return bytesTruncateUDF().apply(column); + } else if (type instanceof BooleanType) { + return booleanToOrderedBytesUDF().apply(column); + } else if (type instanceof TimestampType) { + return longToOrderedBytesUDF().apply(column.cast(DataTypes.LongType)); + } else if (type instanceof TimestampNTZType) { + return timestampNtzToOrderedBytesUDF().apply(column); + } else if (type instanceof DateType) { + return longToOrderedBytesUDF().apply(functions.unix_date(column).cast(DataTypes.LongType)); + } else { + throw new IllegalArgumentException( + String.format( + "Cannot use column %s of type %s in ZOrdering, the type is unsupported", + column, type)); + } + } + + private void increaseOutputSize(int bytes) { + totalOutputBytes = Math.min(totalOutputBytes + bytes, maxOutputSize); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/AvroWithSparkSchemaVisitor.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/AvroWithSparkSchemaVisitor.java new file mode 100644 index 000000000000..4e8edb7e11ca --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/AvroWithSparkSchemaVisitor.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import org.apache.iceberg.avro.AvroWithPartnerByStructureVisitor; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.VariantType; + +public abstract class AvroWithSparkSchemaVisitor + extends AvroWithPartnerByStructureVisitor { + @Override + protected boolean isVariantType(DataType type) { + return type instanceof VariantType; + } + + @Override + protected boolean isStringType(DataType dataType) { + return dataType instanceof StringType; + } + + @Override + protected boolean isMapType(DataType dataType) { + return dataType instanceof MapType; + } + + @Override + protected DataType arrayElementType(DataType arrayType) { + Preconditions.checkArgument( + arrayType instanceof ArrayType, "Invalid array: %s is not an array", arrayType); + return ((ArrayType) arrayType).elementType(); + } + + @Override + protected DataType mapKeyType(DataType mapType) { + Preconditions.checkArgument(isMapType(mapType), "Invalid map: %s is not a map", mapType); + return ((MapType) mapType).keyType(); + } + + @Override + protected DataType mapValueType(DataType mapType) { + Preconditions.checkArgument(isMapType(mapType), "Invalid map: %s is not a map", mapType); + return ((MapType) mapType).valueType(); + } + + @Override + protected Pair fieldNameAndType(DataType structType, int pos) { + Preconditions.checkArgument( + structType instanceof StructType, "Invalid struct: %s is not a struct", structType); + StructField field = ((StructType) structType).apply(pos); + return Pair.of(field.name(), field.dataType()); + } + + @Override + protected DataType nullType() { + return DataTypes.NullType; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java new file mode 100644 index 000000000000..54a658bfad8e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/ParquetWithSparkSchemaVisitor.java @@ -0,0 +1,248 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.util.Deque; +import java.util.List; +import org.apache.iceberg.avro.AvroSchemaUtil; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.variants.Variant; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.parquet.schema.Type.Repetition; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.VariantType; + +/** + * Visitor for traversing a Parquet type with a companion Spark type. + * + * @param the Java class returned by the visitor + */ +public class ParquetWithSparkSchemaVisitor { + private final Deque fieldNames = Lists.newLinkedList(); + + public static T visit(DataType sType, Type type, ParquetWithSparkSchemaVisitor visitor) { + Preconditions.checkArgument(sType != null, "Invalid DataType: null"); + if (type instanceof MessageType) { + Preconditions.checkArgument( + sType instanceof StructType, "Invalid struct: %s is not a struct", sType); + StructType struct = (StructType) sType; + return visitor.message( + struct, (MessageType) type, visitFields(struct, type.asGroupType(), visitor)); + + } else if (type.isPrimitive()) { + return visitor.primitive(sType, type.asPrimitiveType()); + + } else { + // if not a primitive, the typeId must be a group + GroupType group = type.asGroupType(); + LogicalTypeAnnotation annotation = group.getLogicalTypeAnnotation(); + if (LogicalTypeAnnotation.listType().equals(annotation)) { + Preconditions.checkArgument( + !group.isRepetition(Repetition.REPEATED), + "Invalid list: top-level group is repeated: %s", + group); + Preconditions.checkArgument( + group.getFieldCount() == 1, + "Invalid list: does not contain single repeated field: %s", + group); + + GroupType repeatedElement = group.getFields().get(0).asGroupType(); + Preconditions.checkArgument( + repeatedElement.isRepetition(Repetition.REPEATED), + "Invalid list: inner group is not repeated"); + Preconditions.checkArgument( + repeatedElement.getFieldCount() <= 1, + "Invalid list: repeated group is not a single field: %s", + group); + + Preconditions.checkArgument( + sType instanceof ArrayType, "Invalid list: %s is not an array", sType); + ArrayType array = (ArrayType) sType; + StructField element = + new StructField("element", array.elementType(), array.containsNull(), Metadata.empty()); + + visitor.fieldNames.push(repeatedElement.getName()); + try { + T elementResult = null; + if (repeatedElement.getFieldCount() > 0) { + elementResult = visitField(element, repeatedElement.getType(0), visitor); + } + + return visitor.list(array, group, elementResult); + + } finally { + visitor.fieldNames.pop(); + } + } else if (LogicalTypeAnnotation.mapType().equals(annotation)) { + Preconditions.checkArgument( + !group.isRepetition(Repetition.REPEATED), + "Invalid map: top-level group is repeated: %s", + group); + Preconditions.checkArgument( + group.getFieldCount() == 1, + "Invalid map: does not contain single repeated field: %s", + group); + + GroupType repeatedKeyValue = group.getType(0).asGroupType(); + Preconditions.checkArgument( + repeatedKeyValue.isRepetition(Repetition.REPEATED), + "Invalid map: inner group is not repeated"); + Preconditions.checkArgument( + repeatedKeyValue.getFieldCount() <= 2, + "Invalid map: repeated group does not have 2 fields"); + + Preconditions.checkArgument( + sType instanceof MapType, "Invalid map: %s is not a map", sType); + MapType map = (MapType) sType; + StructField keyField = new StructField("key", map.keyType(), false, Metadata.empty()); + StructField valueField = + new StructField("value", map.valueType(), map.valueContainsNull(), Metadata.empty()); + + visitor.fieldNames.push(repeatedKeyValue.getName()); + try { + T keyResult = null; + T valueResult = null; + switch (repeatedKeyValue.getFieldCount()) { + case 2: + // if there are 2 fields, both key and value are projected + keyResult = visitField(keyField, repeatedKeyValue.getType(0), visitor); + valueResult = visitField(valueField, repeatedKeyValue.getType(1), visitor); + break; + case 1: + // if there is just one, use the name to determine what it is + Type keyOrValue = repeatedKeyValue.getType(0); + if (keyOrValue.getName().equalsIgnoreCase("key")) { + keyResult = visitField(keyField, keyOrValue, visitor); + // value result remains null + } else { + valueResult = visitField(valueField, keyOrValue, visitor); + // key result remains null + } + break; + default: + // both results will remain null + } + + return visitor.map(map, group, keyResult, valueResult); + + } finally { + visitor.fieldNames.pop(); + } + } else if (LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION).equals(annotation) + || sType instanceof VariantType) { + // For the Variant we both check the Parquet LogicalTypeAnnotation, and we rely on the + // Iceberg schema, since there are engines like Spark that produce VariantTypes without the + // annotation. + Preconditions.checkArgument( + sType instanceof VariantType, + "Invalid variant: Spark type %s is not a variant type", + sType); + VariantType variant = (VariantType) sType; + + return visitor.variant(variant, group); + } + + Preconditions.checkArgument( + sType instanceof StructType, "Invalid struct: %s is not a struct", sType); + StructType struct = (StructType) sType; + return visitor.struct(struct, group, visitFields(struct, group, visitor)); + } + } + + private static T visitField( + StructField sField, Type field, ParquetWithSparkSchemaVisitor visitor) { + visitor.fieldNames.push(field.getName()); + try { + return visit(sField.dataType(), field, visitor); + } finally { + visitor.fieldNames.pop(); + } + } + + private static List visitFields( + StructType struct, GroupType group, ParquetWithSparkSchemaVisitor visitor) { + List results = Lists.newArrayListWithExpectedSize(group.getFieldCount()); + + int fieldIndex = 0; + for (StructField sField : struct.fields()) { + if (sField.dataType() != DataTypes.NullType) { + Type field = group.getFields().get(fieldIndex); + Preconditions.checkArgument( + field.getName().equals(AvroSchemaUtil.makeCompatibleName(sField.name())), + "Structs do not match: field %s != %s", + field.getName(), + sField.name()); + results.add(visitField(sField, field, visitor)); + + fieldIndex += 1; + } + } + + // All the group fields should have been visited + Preconditions.checkArgument( + fieldIndex == group.getFieldCount(), "Structs do not match: %s and %s", struct, group); + + return results; + } + + public T message(StructType sStruct, MessageType message, List fields) { + return null; + } + + public T struct(StructType sStruct, GroupType struct, List fields) { + return null; + } + + public T list(ArrayType sArray, GroupType array, T element) { + return null; + } + + public T map(MapType sMap, GroupType map, T key, T value) { + return null; + } + + public T primitive(DataType sPrimitive, PrimitiveType primitive) { + return null; + } + + public T variant(VariantType sVariant, GroupType variant) { + throw new UnsupportedOperationException("Not implemented for variant"); + } + + protected String[] currentPath() { + return Lists.newArrayList(fieldNames.descendingIterator()).toArray(new String[0]); + } + + protected String[] path(String name) { + List list = Lists.newArrayList(fieldNames.descendingIterator()); + list.add(name); + return list.toArray(new String[0]); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java new file mode 100644 index 000000000000..fc7d64743d0c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkAvroWriter.java @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.avro.LogicalType; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.io.Encoder; +import org.apache.iceberg.FieldMetrics; +import org.apache.iceberg.avro.MetricsAwareDatumWriter; +import org.apache.iceberg.avro.ValueWriter; +import org.apache.iceberg.avro.ValueWriters; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.ByteType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.ShortType; +import org.apache.spark.sql.types.StructType; + +public class SparkAvroWriter implements MetricsAwareDatumWriter { + private final StructType dsSchema; + private ValueWriter writer = null; + + public SparkAvroWriter(StructType dsSchema) { + this.dsSchema = dsSchema; + } + + public SparkAvroWriter(org.apache.iceberg.Schema icebergSchema, StructType dsSchema) { + this(dsSchema != null ? dsSchema : SparkSchemaUtil.convert(icebergSchema)); + } + + @Override + @SuppressWarnings("unchecked") + public void setSchema(Schema schema) { + this.writer = + (ValueWriter) + AvroWithSparkSchemaVisitor.visit(dsSchema, schema, new WriteBuilder()); + } + + @Override + public void write(InternalRow datum, Encoder out) throws IOException { + writer.write(datum, out); + } + + @Override + public Stream metrics() { + return writer.metrics(); + } + + private static class WriteBuilder extends AvroWithSparkSchemaVisitor> { + @Override + public ValueWriter record( + DataType struct, Schema record, List names, List> fields) { + return SparkValueWriters.struct( + fields, + IntStream.range(0, names.size()) + .mapToObj(i -> fieldNameAndType(struct, i).second()) + .collect(Collectors.toList())); + } + + @Override + public ValueWriter union(DataType type, Schema union, List> options) { + Preconditions.checkArgument( + options.contains(ValueWriters.nulls()), + "Cannot create writer for non-option union: %s", + union); + Preconditions.checkArgument( + options.size() == 2, "Cannot create writer for non-option union: %s", union); + if (union.getTypes().get(0).getType() == Schema.Type.NULL) { + return ValueWriters.option(0, options.get(1)); + } else { + return ValueWriters.option(1, options.get(0)); + } + } + + @Override + public ValueWriter array(DataType sArray, Schema array, ValueWriter elementWriter) { + return SparkValueWriters.array(elementWriter, arrayElementType(sArray)); + } + + @Override + public ValueWriter map(DataType sMap, Schema map, ValueWriter valueReader) { + return SparkValueWriters.map( + SparkValueWriters.strings(), mapKeyType(sMap), valueReader, mapValueType(sMap)); + } + + @Override + public ValueWriter map( + DataType sMap, Schema map, ValueWriter keyWriter, ValueWriter valueWriter) { + return SparkValueWriters.arrayMap( + keyWriter, mapKeyType(sMap), valueWriter, mapValueType(sMap)); + } + + @Override + public ValueWriter variant(DataType partner, ValueWriter metadata, ValueWriter value) { + return SparkValueWriters.variants(); + } + + @Override + public ValueWriter primitive(DataType type, Schema primitive) { + LogicalType logicalType = primitive.getLogicalType(); + if (logicalType != null) { + switch (logicalType.getName()) { + case "date": + // Spark uses the same representation + return ValueWriters.ints(); + + case "timestamp-micros": + // Spark uses the same representation + return ValueWriters.longs(); + + case "decimal": + LogicalTypes.Decimal decimal = (LogicalTypes.Decimal) logicalType; + return SparkValueWriters.decimal(decimal.getPrecision(), decimal.getScale()); + + case "uuid": + return SparkValueWriters.uuids(); + + default: + throw new IllegalArgumentException("Unsupported logical type: " + logicalType); + } + } + + switch (primitive.getType()) { + case NULL: + return ValueWriters.nulls(); + case BOOLEAN: + return ValueWriters.booleans(); + case INT: + if (type instanceof ByteType) { + return ValueWriters.tinyints(); + } else if (type instanceof ShortType) { + return ValueWriters.shorts(); + } + return ValueWriters.ints(); + case LONG: + return ValueWriters.longs(); + case FLOAT: + return ValueWriters.floats(); + case DOUBLE: + return ValueWriters.doubles(); + case STRING: + return SparkValueWriters.strings(); + case FIXED: + return ValueWriters.fixed(primitive.getFixedSize()); + case BYTES: + return ValueWriters.bytes(); + default: + throw new IllegalArgumentException("Unsupported type: " + primitive); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcReader.java new file mode 100644 index 000000000000..c0d3d3efe026 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcReader.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.orc.OrcRowReader; +import org.apache.iceberg.orc.OrcSchemaWithTypeVisitor; +import org.apache.iceberg.orc.OrcValueReader; +import org.apache.iceberg.orc.OrcValueReaders; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.orc.TypeDescription; +import org.apache.orc.storage.ql.exec.vector.StructColumnVector; +import org.apache.orc.storage.ql.exec.vector.VectorizedRowBatch; +import org.apache.spark.sql.catalyst.InternalRow; + +/** + * Converts the OrcIterator, which returns ORC's VectorizedRowBatch to a set of Spark's UnsafeRows. + * + *

    It minimizes allocations by reusing most of the objects in the implementation. + */ +public class SparkOrcReader implements OrcRowReader { + private final OrcValueReader reader; + + public SparkOrcReader(org.apache.iceberg.Schema expectedSchema, TypeDescription readSchema) { + this(expectedSchema, readSchema, ImmutableMap.of()); + } + + @SuppressWarnings("unchecked") + public SparkOrcReader( + org.apache.iceberg.Schema expectedSchema, + TypeDescription readOrcSchema, + Map idToConstant) { + this.reader = + OrcSchemaWithTypeVisitor.visit( + expectedSchema, readOrcSchema, new ReadBuilder(idToConstant)); + } + + @Override + public InternalRow read(VectorizedRowBatch batch, int row) { + return (InternalRow) reader.read(new StructColumnVector(batch.size, batch.cols), row); + } + + @Override + public void setBatchContext(long batchOffsetInFile) { + reader.setBatchContext(batchOffsetInFile); + } + + private static class ReadBuilder extends OrcSchemaWithTypeVisitor> { + private final Map idToConstant; + + private ReadBuilder(Map idToConstant) { + this.idToConstant = idToConstant; + } + + @Override + public OrcValueReader record( + Types.StructType expected, + TypeDescription record, + List names, + List> fields) { + return SparkOrcValueReaders.struct(record, fields, expected, idToConstant); + } + + @Override + public OrcValueReader list( + Types.ListType iList, TypeDescription array, OrcValueReader elementReader) { + return SparkOrcValueReaders.array(elementReader); + } + + @Override + public OrcValueReader map( + Types.MapType iMap, + TypeDescription map, + OrcValueReader keyReader, + OrcValueReader valueReader) { + return SparkOrcValueReaders.map(keyReader, valueReader); + } + + @Override + public OrcValueReader primitive(Type.PrimitiveType iPrimitive, TypeDescription primitive) { + switch (primitive.getCategory()) { + case BOOLEAN: + return OrcValueReaders.booleans(); + case BYTE: + // Iceberg does not have a byte type. Use int + case SHORT: + // Iceberg does not have a short type. Use int + case DATE: + case INT: + return OrcValueReaders.ints(); + case LONG: + return OrcValueReaders.longs(); + case FLOAT: + return OrcValueReaders.floats(); + case DOUBLE: + return OrcValueReaders.doubles(); + case TIMESTAMP_INSTANT: + case TIMESTAMP: + return SparkOrcValueReaders.timestampTzs(); + case DECIMAL: + return SparkOrcValueReaders.decimals(primitive.getPrecision(), primitive.getScale()); + case CHAR: + case VARCHAR: + case STRING: + return SparkOrcValueReaders.utf8String(); + case BINARY: + if (Type.TypeID.UUID == iPrimitive.typeId()) { + return SparkOrcValueReaders.uuids(); + } + return OrcValueReaders.bytes(); + default: + throw new IllegalArgumentException("Unhandled type " + primitive); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueReaders.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueReaders.java new file mode 100644 index 000000000000..67664ac6c753 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueReaders.java @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.orc.OrcValueReader; +import org.apache.iceberg.orc.OrcValueReaders; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.UUIDUtil; +import org.apache.orc.TypeDescription; +import org.apache.orc.storage.ql.exec.vector.BytesColumnVector; +import org.apache.orc.storage.ql.exec.vector.ColumnVector; +import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector; +import org.apache.orc.storage.ql.exec.vector.ListColumnVector; +import org.apache.orc.storage.ql.exec.vector.MapColumnVector; +import org.apache.orc.storage.ql.exec.vector.TimestampColumnVector; +import org.apache.orc.storage.serde2.io.HiveDecimalWritable; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; + +public class SparkOrcValueReaders { + private SparkOrcValueReaders() {} + + public static OrcValueReader utf8String() { + return StringReader.INSTANCE; + } + + public static OrcValueReader uuids() { + return UUIDReader.INSTANCE; + } + + public static OrcValueReader timestampTzs() { + return TimestampTzReader.INSTANCE; + } + + public static OrcValueReader decimals(int precision, int scale) { + if (precision <= Decimal.MAX_LONG_DIGITS()) { + return new SparkOrcValueReaders.Decimal18Reader(precision, scale); + } else if (precision <= 38) { + return new SparkOrcValueReaders.Decimal38Reader(precision, scale); + } else { + throw new IllegalArgumentException("Invalid precision: " + precision); + } + } + + static OrcValueReader struct( + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + return new StructReader(record, readers, struct, idToConstant); + } + + static OrcValueReader array(OrcValueReader elementReader) { + return new ArrayReader(elementReader); + } + + static OrcValueReader map(OrcValueReader keyReader, OrcValueReader valueReader) { + return new MapReader(keyReader, valueReader); + } + + private static class ArrayReader implements OrcValueReader { + private final OrcValueReader elementReader; + + private ArrayReader(OrcValueReader elementReader) { + this.elementReader = elementReader; + } + + @Override + public ArrayData nonNullRead(ColumnVector vector, int row) { + ListColumnVector listVector = (ListColumnVector) vector; + int offset = (int) listVector.offsets[row]; + int length = (int) listVector.lengths[row]; + List elements = Lists.newArrayListWithExpectedSize(length); + for (int c = 0; c < length; ++c) { + elements.add(elementReader.read(listVector.child, offset + c)); + } + return new GenericArrayData(elements.toArray()); + } + + @Override + public void setBatchContext(long batchOffsetInFile) { + elementReader.setBatchContext(batchOffsetInFile); + } + } + + private static class MapReader implements OrcValueReader { + private final OrcValueReader keyReader; + private final OrcValueReader valueReader; + + private MapReader(OrcValueReader keyReader, OrcValueReader valueReader) { + this.keyReader = keyReader; + this.valueReader = valueReader; + } + + @Override + public MapData nonNullRead(ColumnVector vector, int row) { + MapColumnVector mapVector = (MapColumnVector) vector; + int offset = (int) mapVector.offsets[row]; + long length = mapVector.lengths[row]; + List keys = Lists.newArrayListWithExpectedSize((int) length); + List values = Lists.newArrayListWithExpectedSize((int) length); + for (int c = 0; c < length; c++) { + keys.add(keyReader.read(mapVector.keys, offset + c)); + values.add(valueReader.read(mapVector.values, offset + c)); + } + + return new ArrayBasedMapData( + new GenericArrayData(keys.toArray()), new GenericArrayData(values.toArray())); + } + + @Override + public void setBatchContext(long batchOffsetInFile) { + keyReader.setBatchContext(batchOffsetInFile); + valueReader.setBatchContext(batchOffsetInFile); + } + } + + static class StructReader extends OrcValueReaders.StructReader { + private final int numFields; + + protected StructReader( + TypeDescription record, + List> readers, + Types.StructType struct, + Map idToConstant) { + super(record, readers, struct, idToConstant); + this.numFields = struct.fields().size(); + } + + @Override + protected InternalRow create() { + return new GenericInternalRow(numFields); + } + + @Override + protected void set(InternalRow struct, int pos, Object value) { + if (value != null) { + struct.update(pos, value); + } else { + struct.setNullAt(pos); + } + } + } + + private static class StringReader implements OrcValueReader { + private static final StringReader INSTANCE = new StringReader(); + + private StringReader() {} + + @Override + public UTF8String nonNullRead(ColumnVector vector, int row) { + BytesColumnVector bytesVector = (BytesColumnVector) vector; + return UTF8String.fromBytes( + bytesVector.vector[row], bytesVector.start[row], bytesVector.length[row]); + } + } + + private static class UUIDReader implements OrcValueReader { + private static final UUIDReader INSTANCE = new UUIDReader(); + + private UUIDReader() {} + + @Override + public UTF8String nonNullRead(ColumnVector vector, int row) { + BytesColumnVector bytesVector = (BytesColumnVector) vector; + ByteBuffer buffer = + ByteBuffer.wrap(bytesVector.vector[row], bytesVector.start[row], bytesVector.length[row]); + return UTF8String.fromString(UUIDUtil.convert(buffer).toString()); + } + } + + private static class TimestampTzReader implements OrcValueReader { + private static final TimestampTzReader INSTANCE = new TimestampTzReader(); + + private TimestampTzReader() {} + + @Override + public Long nonNullRead(ColumnVector vector, int row) { + TimestampColumnVector tcv = (TimestampColumnVector) vector; + return Math.floorDiv(tcv.time[row], 1_000) * 1_000_000 + Math.floorDiv(tcv.nanos[row], 1000); + } + } + + private static class Decimal18Reader implements OrcValueReader { + private final int precision; + private final int scale; + + Decimal18Reader(int precision, int scale) { + this.precision = precision; + this.scale = scale; + } + + @Override + public Decimal nonNullRead(ColumnVector vector, int row) { + HiveDecimalWritable value = ((DecimalColumnVector) vector).vector[row]; + + // The scale of decimal read from hive ORC file may be not equals to the expected scale. For + // data type + // decimal(10,3) and the value 10.100, the hive ORC writer will remove its trailing zero and + // store it + // as 101*10^(-1), its scale will adjust from 3 to 1. So here we could not assert that + // value.scale() == scale. + // we also need to convert the hive orc decimal to a decimal with expected precision and + // scale. + Preconditions.checkArgument( + value.precision() <= precision, + "Cannot read value as decimal(%s,%s), too large: %s", + precision, + scale, + value); + + return new Decimal().set(value.serialize64(scale), precision, scale); + } + } + + private static class Decimal38Reader implements OrcValueReader { + private final int precision; + private final int scale; + + Decimal38Reader(int precision, int scale) { + this.precision = precision; + this.scale = scale; + } + + @Override + public Decimal nonNullRead(ColumnVector vector, int row) { + BigDecimal value = + ((DecimalColumnVector) vector).vector[row].getHiveDecimal().bigDecimalValue(); + + Preconditions.checkArgument( + value.precision() <= precision, + "Cannot read value as decimal(%s,%s), too large: %s", + precision, + scale, + value); + + return new Decimal().set(new scala.math.BigDecimal(value), precision, scale); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueWriters.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueWriters.java new file mode 100644 index 000000000000..7f9810e4c60c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueWriters.java @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; +import org.apache.iceberg.FieldMetrics; +import org.apache.iceberg.orc.OrcValueWriter; +import org.apache.iceberg.util.UUIDUtil; +import org.apache.orc.TypeDescription; +import org.apache.orc.storage.common.type.HiveDecimal; +import org.apache.orc.storage.ql.exec.vector.BytesColumnVector; +import org.apache.orc.storage.ql.exec.vector.ColumnVector; +import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector; +import org.apache.orc.storage.ql.exec.vector.ListColumnVector; +import org.apache.orc.storage.ql.exec.vector.MapColumnVector; +import org.apache.orc.storage.ql.exec.vector.TimestampColumnVector; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; + +class SparkOrcValueWriters { + private SparkOrcValueWriters() {} + + static OrcValueWriter strings() { + return StringWriter.INSTANCE; + } + + static OrcValueWriter uuids() { + return UUIDWriter.INSTANCE; + } + + static OrcValueWriter timestampTz() { + return TimestampTzWriter.INSTANCE; + } + + static OrcValueWriter decimal(int precision, int scale) { + if (precision <= 18) { + return new Decimal18Writer(scale); + } else { + return new Decimal38Writer(); + } + } + + static OrcValueWriter list(OrcValueWriter element, List orcType) { + return new ListWriter<>(element, orcType); + } + + static OrcValueWriter map( + OrcValueWriter keyWriter, OrcValueWriter valueWriter, List orcTypes) { + return new MapWriter<>(keyWriter, valueWriter, orcTypes); + } + + private static class StringWriter implements OrcValueWriter { + private static final StringWriter INSTANCE = new StringWriter(); + + @Override + public void nonNullWrite(int rowId, UTF8String data, ColumnVector output) { + byte[] value = data.getBytes(); + ((BytesColumnVector) output).setRef(rowId, value, 0, value.length); + } + } + + private static class UUIDWriter implements OrcValueWriter { + private static final UUIDWriter INSTANCE = new UUIDWriter(); + + @Override + public void nonNullWrite(int rowId, UTF8String data, ColumnVector output) { + // ((BytesColumnVector) output).setRef(..) just stores a reference to the passed byte[], so + // can't use a ThreadLocal ByteBuffer here like in other places because subsequent writes + // would then overwrite previous values + ByteBuffer buffer = UUIDUtil.convertToByteBuffer(UUID.fromString(data.toString())); + ((BytesColumnVector) output).setRef(rowId, buffer.array(), 0, buffer.array().length); + } + } + + private static class TimestampTzWriter implements OrcValueWriter { + private static final TimestampTzWriter INSTANCE = new TimestampTzWriter(); + + @Override + public void nonNullWrite(int rowId, Long micros, ColumnVector output) { + TimestampColumnVector cv = (TimestampColumnVector) output; + cv.time[rowId] = Math.floorDiv(micros, 1_000); // millis + cv.nanos[rowId] = (int) Math.floorMod(micros, 1_000_000) * 1_000; // nanos + } + } + + private static class Decimal18Writer implements OrcValueWriter { + private final int scale; + + Decimal18Writer(int scale) { + this.scale = scale; + } + + @Override + public void nonNullWrite(int rowId, Decimal decimal, ColumnVector output) { + ((DecimalColumnVector) output) + .vector[rowId].setFromLongAndScale(decimal.toUnscaledLong(), scale); + } + } + + private static class Decimal38Writer implements OrcValueWriter { + + @Override + public void nonNullWrite(int rowId, Decimal decimal, ColumnVector output) { + ((DecimalColumnVector) output) + .vector[rowId].set(HiveDecimal.create(decimal.toJavaBigDecimal())); + } + } + + private static class ListWriter implements OrcValueWriter { + private final OrcValueWriter writer; + private final SparkOrcWriter.FieldGetter fieldGetter; + + @SuppressWarnings("unchecked") + ListWriter(OrcValueWriter writer, List orcTypes) { + if (orcTypes.size() != 1) { + throw new IllegalArgumentException( + "Expected one (and same) ORC type for list elements, got: " + orcTypes); + } + this.writer = writer; + this.fieldGetter = + (SparkOrcWriter.FieldGetter) SparkOrcWriter.createFieldGetter(orcTypes.get(0)); + } + + @Override + public void nonNullWrite(int rowId, ArrayData value, ColumnVector output) { + ListColumnVector cv = (ListColumnVector) output; + // record the length and start of the list elements + cv.lengths[rowId] = value.numElements(); + cv.offsets[rowId] = cv.childCount; + cv.childCount = (int) (cv.childCount + cv.lengths[rowId]); + // make sure the child is big enough + growColumnVector(cv.child, cv.childCount); + // Add each element + for (int e = 0; e < cv.lengths[rowId]; ++e) { + writer.write((int) (e + cv.offsets[rowId]), fieldGetter.getFieldOrNull(value, e), cv.child); + } + } + + @Override + public Stream> metrics() { + return writer.metrics(); + } + } + + private static class MapWriter implements OrcValueWriter { + private final OrcValueWriter keyWriter; + private final OrcValueWriter valueWriter; + private final SparkOrcWriter.FieldGetter keyFieldGetter; + private final SparkOrcWriter.FieldGetter valueFieldGetter; + + @SuppressWarnings("unchecked") + MapWriter( + OrcValueWriter keyWriter, + OrcValueWriter valueWriter, + List orcTypes) { + if (orcTypes.size() != 2) { + throw new IllegalArgumentException( + "Expected two ORC type descriptions for a map, got: " + orcTypes); + } + this.keyWriter = keyWriter; + this.valueWriter = valueWriter; + this.keyFieldGetter = + (SparkOrcWriter.FieldGetter) SparkOrcWriter.createFieldGetter(orcTypes.get(0)); + this.valueFieldGetter = + (SparkOrcWriter.FieldGetter) SparkOrcWriter.createFieldGetter(orcTypes.get(1)); + } + + @Override + public void nonNullWrite(int rowId, MapData map, ColumnVector output) { + ArrayData key = map.keyArray(); + ArrayData value = map.valueArray(); + MapColumnVector cv = (MapColumnVector) output; + // record the length and start of the list elements + cv.lengths[rowId] = value.numElements(); + cv.offsets[rowId] = cv.childCount; + cv.childCount = (int) (cv.childCount + cv.lengths[rowId]); + // make sure the child is big enough + growColumnVector(cv.keys, cv.childCount); + growColumnVector(cv.values, cv.childCount); + // Add each element + for (int e = 0; e < cv.lengths[rowId]; ++e) { + int pos = (int) (e + cv.offsets[rowId]); + keyWriter.write(pos, keyFieldGetter.getFieldOrNull(key, e), cv.keys); + valueWriter.write(pos, valueFieldGetter.getFieldOrNull(value, e), cv.values); + } + } + + @Override + public Stream> metrics() { + return Stream.concat(keyWriter.metrics(), valueWriter.metrics()); + } + } + + private static void growColumnVector(ColumnVector cv, int requestedSize) { + if (cv.isNull.length < requestedSize) { + // Use growth factor of 3 to avoid frequent array allocations + cv.ensureSize(requestedSize * 3, true); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java new file mode 100644 index 000000000000..6fc8849c82b2 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcWriter.java @@ -0,0 +1,247 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.io.Serializable; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.apache.iceberg.FieldMetrics; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.orc.GenericOrcWriters; +import org.apache.iceberg.orc.ORCSchemaUtil; +import org.apache.iceberg.orc.OrcRowWriter; +import org.apache.iceberg.orc.OrcSchemaWithTypeVisitor; +import org.apache.iceberg.orc.OrcValueWriter; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.orc.TypeDescription; +import org.apache.orc.storage.ql.exec.vector.VectorizedRowBatch; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.SpecializedGetters; + +/** This class acts as an adaptor from an OrcFileAppender to a FileAppender<InternalRow>. */ +public class SparkOrcWriter implements OrcRowWriter { + + private final InternalRowWriter writer; + + public SparkOrcWriter(Schema iSchema, TypeDescription orcSchema) { + Preconditions.checkArgument( + orcSchema.getCategory() == TypeDescription.Category.STRUCT, + "Top level must be a struct " + orcSchema); + + writer = + (InternalRowWriter) OrcSchemaWithTypeVisitor.visit(iSchema, orcSchema, new WriteBuilder()); + } + + @Override + public void write(InternalRow value, VectorizedRowBatch output) { + Preconditions.checkArgument(value != null, "value must not be null"); + writer.writeRow(value, output); + } + + @Override + public List> writers() { + return writer.writers(); + } + + @Override + public Stream> metrics() { + return writer.metrics(); + } + + private static class WriteBuilder extends OrcSchemaWithTypeVisitor> { + private WriteBuilder() {} + + @Override + public OrcValueWriter record( + Types.StructType iStruct, + TypeDescription record, + List names, + List> fields) { + return new InternalRowWriter(fields, iStruct, record.getChildren()); + } + + @Override + public OrcValueWriter list( + Types.ListType iList, TypeDescription array, OrcValueWriter element) { + return SparkOrcValueWriters.list(element, array.getChildren()); + } + + @Override + public OrcValueWriter map( + Types.MapType iMap, TypeDescription map, OrcValueWriter key, OrcValueWriter value) { + return SparkOrcValueWriters.map(key, value, map.getChildren()); + } + + @Override + public OrcValueWriter primitive(Type.PrimitiveType iPrimitive, TypeDescription primitive) { + switch (primitive.getCategory()) { + case BOOLEAN: + return GenericOrcWriters.booleans(); + case BYTE: + return GenericOrcWriters.bytes(); + case SHORT: + return GenericOrcWriters.shorts(); + case DATE: + case INT: + return GenericOrcWriters.ints(); + case LONG: + return GenericOrcWriters.longs(); + case FLOAT: + return GenericOrcWriters.floats(ORCSchemaUtil.fieldId(primitive)); + case DOUBLE: + return GenericOrcWriters.doubles(ORCSchemaUtil.fieldId(primitive)); + case BINARY: + if (Type.TypeID.UUID == iPrimitive.typeId()) { + return SparkOrcValueWriters.uuids(); + } + return GenericOrcWriters.byteArrays(); + case STRING: + case CHAR: + case VARCHAR: + return SparkOrcValueWriters.strings(); + case DECIMAL: + return SparkOrcValueWriters.decimal(primitive.getPrecision(), primitive.getScale()); + case TIMESTAMP_INSTANT: + case TIMESTAMP: + return SparkOrcValueWriters.timestampTz(); + default: + throw new IllegalArgumentException("Unhandled type " + primitive); + } + } + } + + private static class InternalRowWriter extends GenericOrcWriters.StructWriter { + private final List> fieldGetters; + + InternalRowWriter( + List> writers, Types.StructType iStruct, List orcTypes) { + super(iStruct, writers); + this.fieldGetters = Lists.newArrayListWithExpectedSize(orcTypes.size()); + + Map idToType = + orcTypes.stream().collect(Collectors.toMap(ORCSchemaUtil::fieldId, s -> s)); + + for (Types.NestedField iField : iStruct.fields()) { + fieldGetters.add(createFieldGetter(idToType.get(iField.fieldId()))); + } + } + + @Override + protected Object get(InternalRow struct, int index) { + return fieldGetters.get(index).getFieldOrNull(struct, index); + } + } + + static FieldGetter createFieldGetter(TypeDescription fieldType) { + // In the case of an UnknownType + if (fieldType == null) { + return (row, ordinal) -> null; + } + + final FieldGetter fieldGetter; + switch (fieldType.getCategory()) { + case BOOLEAN: + fieldGetter = SpecializedGetters::getBoolean; + break; + case BYTE: + fieldGetter = SpecializedGetters::getByte; + break; + case SHORT: + fieldGetter = SpecializedGetters::getShort; + break; + case DATE: + case INT: + fieldGetter = SpecializedGetters::getInt; + break; + case LONG: + case TIMESTAMP: + case TIMESTAMP_INSTANT: + fieldGetter = SpecializedGetters::getLong; + break; + case FLOAT: + fieldGetter = SpecializedGetters::getFloat; + break; + case DOUBLE: + fieldGetter = SpecializedGetters::getDouble; + break; + case BINARY: + if (ORCSchemaUtil.BinaryType.UUID + .toString() + .equalsIgnoreCase( + fieldType.getAttributeValue(ORCSchemaUtil.ICEBERG_BINARY_TYPE_ATTRIBUTE))) { + fieldGetter = SpecializedGetters::getUTF8String; + } else { + fieldGetter = SpecializedGetters::getBinary; + } + // getBinary always makes a copy, so we don't need to worry about it + // being changed behind our back. + break; + case DECIMAL: + fieldGetter = + (row, ordinal) -> + row.getDecimal(ordinal, fieldType.getPrecision(), fieldType.getScale()); + break; + case STRING: + case CHAR: + case VARCHAR: + fieldGetter = SpecializedGetters::getUTF8String; + break; + case STRUCT: + fieldGetter = (row, ordinal) -> row.getStruct(ordinal, fieldType.getChildren().size()); + break; + case LIST: + fieldGetter = SpecializedGetters::getArray; + break; + case MAP: + fieldGetter = SpecializedGetters::getMap; + break; + default: + throw new IllegalArgumentException( + "Encountered an unsupported ORC type during a write from Spark."); + } + + return (row, ordinal) -> { + if (row.isNullAt(ordinal)) { + return null; + } + return fieldGetter.getFieldOrNull(row, ordinal); + }; + } + + interface FieldGetter extends Serializable { + + /** + * Returns a value from a complex Spark data holder such ArrayData, InternalRow, etc... Calls + * the appropriate getter for the expected data type. + * + * @param row Spark's data representation + * @param ordinal index in the data structure (e.g. column index for InterRow, list index in + * ArrayData, etc..) + * @return field value at ordinal + */ + @Nullable + T getFieldOrNull(SpecializedGetters row, int ordinal); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetReaders.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetReaders.java new file mode 100644 index 000000000000..a19ed8060737 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetReaders.java @@ -0,0 +1,794 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.parquet.ParquetSchemaUtil; +import org.apache.iceberg.parquet.ParquetValueReader; +import org.apache.iceberg.parquet.ParquetValueReaders; +import org.apache.iceberg.parquet.ParquetValueReaders.FloatAsDoubleReader; +import org.apache.iceberg.parquet.ParquetValueReaders.IntAsLongReader; +import org.apache.iceberg.parquet.ParquetValueReaders.PrimitiveReader; +import org.apache.iceberg.parquet.ParquetValueReaders.RepeatedKeyValueReader; +import org.apache.iceberg.parquet.ParquetValueReaders.RepeatedReader; +import org.apache.iceberg.parquet.ParquetValueReaders.ReusableEntry; +import org.apache.iceberg.parquet.ParquetValueReaders.StructReader; +import org.apache.iceberg.parquet.ParquetValueReaders.UnboxedReader; +import org.apache.iceberg.parquet.ParquetVariantReaders.DelegatingValueReader; +import org.apache.iceberg.parquet.ParquetVariantVisitor; +import org.apache.iceberg.parquet.TypeWithSchemaVisitor; +import org.apache.iceberg.parquet.VariantReaderBuilder; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Type.TypeID; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.UUIDUtil; +import org.apache.iceberg.variants.Variant; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.LogicalTypeAnnotation.DecimalLogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.CalendarInterval; +import org.apache.spark.unsafe.types.GeographyVal; +import org.apache.spark.unsafe.types.GeometryVal; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; + +public class SparkParquetReaders { + private SparkParquetReaders() {} + + public static ParquetValueReader buildReader( + Schema expectedSchema, MessageType fileSchema) { + return buildReader(expectedSchema, fileSchema, ImmutableMap.of()); + } + + @SuppressWarnings("unchecked") + public static ParquetValueReader buildReader( + Schema expectedSchema, MessageType fileSchema, Map idToConstant) { + if (ParquetSchemaUtil.hasIds(fileSchema)) { + return (ParquetValueReader) + TypeWithSchemaVisitor.visit( + expectedSchema.asStruct(), fileSchema, new ReadBuilder(fileSchema, idToConstant)); + } else { + return (ParquetValueReader) + TypeWithSchemaVisitor.visit( + expectedSchema.asStruct(), + fileSchema, + new FallbackReadBuilder(fileSchema, idToConstant)); + } + } + + private static class FallbackReadBuilder extends ReadBuilder { + FallbackReadBuilder(MessageType type, Map idToConstant) { + super(type, idToConstant); + } + + @Override + public ParquetValueReader message( + Types.StructType expected, MessageType message, List> fieldReaders) { + // the top level matches by ID, but the remaining IDs are missing + return super.struct(expected, message, fieldReaders); + } + + @Override + public ParquetValueReader struct( + Types.StructType ignored, GroupType struct, List> fieldReaders) { + // the expected struct is ignored because nested fields are never found when the + List> newFields = + Lists.newArrayListWithExpectedSize(fieldReaders.size()); + List fields = struct.getFields(); + for (int i = 0; i < fields.size(); i += 1) { + Type fieldType = fields.get(i); + int fieldD = type().getMaxDefinitionLevel(path(fieldType.getName())) - 1; + newFields.add(ParquetValueReaders.option(fieldType, fieldD, fieldReaders.get(i))); + } + + return new InternalRowReader(newFields); + } + } + + private static class ReadBuilder extends TypeWithSchemaVisitor> { + private final MessageType type; + private final Map idToConstant; + + ReadBuilder(MessageType type, Map idToConstant) { + this.type = type; + this.idToConstant = idToConstant; + } + + @Override + public ParquetValueReader message( + Types.StructType expected, MessageType message, List> fieldReaders) { + return struct(expected, message.asGroupType(), fieldReaders); + } + + @Override + public ParquetValueReader struct( + Types.StructType expected, GroupType struct, List> fieldReaders) { + if (null == expected) { + return new InternalRowReader(ImmutableList.of()); + } + + // match the expected struct's order + Map> readersById = Maps.newHashMap(); + List fields = struct.getFields(); + for (int i = 0; i < fields.size(); i += 1) { + Type fieldType = fields.get(i); + int fieldD = type.getMaxDefinitionLevel(path(fieldType.getName())) - 1; + if (fieldType.getId() != null) { + int id = fieldType.getId().intValue(); + readersById.put(id, ParquetValueReaders.option(fieldType, fieldD, fieldReaders.get(i))); + } + } + + int constantDefinitionLevel = type.getMaxDefinitionLevel(currentPath()); + List expectedFields = expected.fields(); + List> reorderedFields = + Lists.newArrayListWithExpectedSize(expectedFields.size()); + + for (Types.NestedField field : expectedFields) { + int id = field.fieldId(); + ParquetValueReader reader = + ParquetValueReaders.replaceWithMetadataReader( + id, readersById.get(id), idToConstant, constantDefinitionLevel); + reorderedFields.add(defaultReader(field, reader, constantDefinitionLevel)); + } + + return new InternalRowReader(reorderedFields); + } + + private ParquetValueReader defaultReader( + Types.NestedField field, ParquetValueReader reader, int constantDL) { + if (reader != null) { + return reader; + } else if (field.initialDefault() != null) { + return ParquetValueReaders.constant( + SparkUtil.internalToSpark(field.type(), field.initialDefault()), constantDL); + } else if (field.isOptional()) { + return ParquetValueReaders.nulls(); + } + + throw new IllegalArgumentException(String.format("Missing required field: %s", field.name())); + } + + @Override + public ParquetValueReader list( + Types.ListType expectedList, GroupType array, ParquetValueReader elementReader) { + String[] repeatedPath = currentPath(); + + int repeatedD = type.getMaxDefinitionLevel(repeatedPath) - 1; + int repeatedR = type.getMaxRepetitionLevel(repeatedPath) - 1; + + Type elementType = ParquetSchemaUtil.determineListElementType(array); + int elementD = type.getMaxDefinitionLevel(path(elementType.getName())) - 1; + + return new ArrayReader<>( + repeatedD, repeatedR, ParquetValueReaders.option(elementType, elementD, elementReader)); + } + + @Override + public ParquetValueReader map( + Types.MapType expectedMap, + GroupType map, + ParquetValueReader keyReader, + ParquetValueReader valueReader) { + GroupType repeatedKeyValue = map.getFields().get(0).asGroupType(); + String[] repeatedPath = currentPath(); + + int repeatedD = type.getMaxDefinitionLevel(repeatedPath) - 1; + int repeatedR = type.getMaxRepetitionLevel(repeatedPath) - 1; + + Type keyType = repeatedKeyValue.getType(0); + int keyD = type.getMaxDefinitionLevel(path(keyType.getName())) - 1; + Type valueType = repeatedKeyValue.getType(1); + int valueD = type.getMaxDefinitionLevel(path(valueType.getName())) - 1; + + return new MapReader<>( + repeatedD, + repeatedR, + ParquetValueReaders.option(keyType, keyD, keyReader), + ParquetValueReaders.option(valueType, valueD, valueReader)); + } + + @Override + public ParquetVariantVisitor> variantVisitor() { + return new VariantReaderBuilder(type, Arrays.asList(currentPath())); + } + + @Override + public ParquetValueReader variant( + Types.VariantType iVariant, GroupType variant, ParquetValueReader variantReader) { + return new VariantReader(variantReader); + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public ParquetValueReader primitive( + org.apache.iceberg.types.Type.PrimitiveType expected, PrimitiveType primitive) { + ColumnDescriptor desc = type.getColumnDescription(currentPath()); + + if (primitive.getOriginalType() != null) { + switch (primitive.getOriginalType()) { + case ENUM: + case JSON: + case UTF8: + return new StringReader(desc); + case INT_8: + case INT_16: + case INT_32: + if (expected != null && expected.typeId() == Types.LongType.get().typeId()) { + return new IntAsLongReader(desc); + } else { + return new UnboxedReader<>(desc); + } + case DATE: + case INT_64: + return new UnboxedReader<>(desc); + case TIMESTAMP_MICROS: + case TIMESTAMP_MILLIS: + return ParquetValueReaders.timestamps(desc); + case DECIMAL: + DecimalLogicalTypeAnnotation decimal = + (DecimalLogicalTypeAnnotation) primitive.getLogicalTypeAnnotation(); + switch (primitive.getPrimitiveTypeName()) { + case BINARY: + case FIXED_LEN_BYTE_ARRAY: + return new BinaryDecimalReader(desc, decimal.getScale()); + case INT64: + return new LongDecimalReader(desc, decimal.getPrecision(), decimal.getScale()); + case INT32: + return new IntegerDecimalReader(desc, decimal.getPrecision(), decimal.getScale()); + default: + throw new UnsupportedOperationException( + "Unsupported base type for decimal: " + primitive.getPrimitiveTypeName()); + } + case BSON: + return new ParquetValueReaders.ByteArrayReader(desc); + default: + throw new UnsupportedOperationException( + "Unsupported logical type: " + primitive.getOriginalType()); + } + } + + switch (primitive.getPrimitiveTypeName()) { + case FIXED_LEN_BYTE_ARRAY: + case BINARY: + if (expected != null && expected.typeId() == TypeID.UUID) { + return new UUIDReader(desc); + } + return new ParquetValueReaders.ByteArrayReader(desc); + case INT32: + if (expected != null && expected.typeId() == TypeID.LONG) { + return new IntAsLongReader(desc); + } else { + return new UnboxedReader<>(desc); + } + case FLOAT: + if (expected != null && expected.typeId() == TypeID.DOUBLE) { + return new FloatAsDoubleReader(desc); + } else { + return new UnboxedReader<>(desc); + } + case BOOLEAN: + case INT64: + case DOUBLE: + return new UnboxedReader<>(desc); + case INT96: + // Impala & Spark used to write timestamps as INT96 without a logical type. For backwards + // compatibility we try to read INT96 as timestamps. + return ParquetValueReaders.int96Timestamps(desc); + default: + throw new UnsupportedOperationException("Unsupported type: " + primitive); + } + } + + protected MessageType type() { + return type; + } + } + + private static class BinaryDecimalReader extends PrimitiveReader { + private final int scale; + + BinaryDecimalReader(ColumnDescriptor desc, int scale) { + super(desc); + this.scale = scale; + } + + @Override + public Decimal read(Decimal ignored) { + Binary binary = column.nextBinary(); + return Decimal.fromDecimal(new BigDecimal(new BigInteger(binary.getBytes()), scale)); + } + } + + private static class IntegerDecimalReader extends PrimitiveReader { + private final int precision; + private final int scale; + + IntegerDecimalReader(ColumnDescriptor desc, int precision, int scale) { + super(desc); + this.precision = precision; + this.scale = scale; + } + + @Override + public Decimal read(Decimal ignored) { + return Decimal.apply(column.nextInteger(), precision, scale); + } + } + + private static class LongDecimalReader extends PrimitiveReader { + private final int precision; + private final int scale; + + LongDecimalReader(ColumnDescriptor desc, int precision, int scale) { + super(desc); + this.precision = precision; + this.scale = scale; + } + + @Override + public Decimal read(Decimal ignored) { + return Decimal.apply(column.nextLong(), precision, scale); + } + } + + private static class StringReader extends PrimitiveReader { + StringReader(ColumnDescriptor desc) { + super(desc); + } + + @Override + public UTF8String read(UTF8String ignored) { + Binary binary = column.nextBinary(); + ByteBuffer buffer = binary.toByteBuffer(); + if (buffer.hasArray()) { + return UTF8String.fromBytes( + buffer.array(), buffer.arrayOffset() + buffer.position(), buffer.remaining()); + } else { + return UTF8String.fromBytes(binary.getBytes()); + } + } + } + + private static class UUIDReader extends PrimitiveReader { + UUIDReader(ColumnDescriptor desc) { + super(desc); + } + + @Override + @SuppressWarnings("ByteBufferBackingArray") + public UTF8String read(UTF8String ignored) { + return UTF8String.fromString(UUIDUtil.convert(column.nextBinary().toByteBuffer()).toString()); + } + } + + private static class ArrayReader extends RepeatedReader { + private int readPos = 0; + private int writePos = 0; + + ArrayReader(int definitionLevel, int repetitionLevel, ParquetValueReader reader) { + super(definitionLevel, repetitionLevel, reader); + } + + @Override + @SuppressWarnings("unchecked") + protected ReusableArrayData newListData(ArrayData reuse) { + this.readPos = 0; + this.writePos = 0; + + if (reuse instanceof ReusableArrayData) { + return (ReusableArrayData) reuse; + } else { + return new ReusableArrayData(); + } + } + + @Override + @SuppressWarnings("unchecked") + protected E getElement(ReusableArrayData list) { + E value = null; + if (readPos < list.capacity()) { + value = (E) list.values[readPos]; + } + + readPos += 1; + + return value; + } + + @Override + protected void addElement(ReusableArrayData reused, E element) { + if (writePos >= reused.capacity()) { + reused.grow(); + } + + reused.values[writePos] = element; + + writePos += 1; + } + + @Override + protected ArrayData buildList(ReusableArrayData list) { + list.setNumElements(writePos); + return list; + } + } + + private static class MapReader + extends RepeatedKeyValueReader { + private int readPos = 0; + private int writePos = 0; + + private final ReusableEntry entry = new ReusableEntry<>(); + private final ReusableEntry nullEntry = new ReusableEntry<>(); + + MapReader( + int definitionLevel, + int repetitionLevel, + ParquetValueReader keyReader, + ParquetValueReader valueReader) { + super(definitionLevel, repetitionLevel, keyReader, valueReader); + } + + @Override + @SuppressWarnings("unchecked") + protected ReusableMapData newMapData(MapData reuse) { + this.readPos = 0; + this.writePos = 0; + + if (reuse instanceof ReusableMapData) { + return (ReusableMapData) reuse; + } else { + return new ReusableMapData(); + } + } + + @Override + @SuppressWarnings("unchecked") + protected Map.Entry getPair(ReusableMapData map) { + Map.Entry kv = nullEntry; + if (readPos < map.capacity()) { + entry.set((K) map.keys.values[readPos], (V) map.values.values[readPos]); + kv = entry; + } + + readPos += 1; + + return kv; + } + + @Override + protected void addPair(ReusableMapData map, K key, V value) { + if (writePos >= map.capacity()) { + map.grow(); + } + + map.keys.values[writePos] = key; + map.values.values[writePos] = value; + + writePos += 1; + } + + @Override + protected MapData buildMap(ReusableMapData map) { + map.setNumElements(writePos); + return map; + } + } + + /** Variant reader to convert from Variant to Spark VariantVal */ + private static class VariantReader extends DelegatingValueReader { + @SuppressWarnings("unchecked") + private VariantReader(ParquetValueReader reader) { + super((ParquetValueReader) reader); + } + + @Override + public VariantVal read(VariantVal reuse) { + Variant variant = super.readFromDelegate(null); + byte[] metadataBytes = new byte[variant.metadata().sizeInBytes()]; + ByteBuffer metadataBuffer = ByteBuffer.wrap(metadataBytes).order(ByteOrder.LITTLE_ENDIAN); + variant.metadata().writeTo(metadataBuffer, 0); + + byte[] valueBytes = new byte[variant.value().sizeInBytes()]; + ByteBuffer valueBuffer = ByteBuffer.wrap(valueBytes).order(ByteOrder.LITTLE_ENDIAN); + variant.value().writeTo(valueBuffer, 0); + + return new VariantVal(valueBytes, metadataBytes); + } + } + + private static class InternalRowReader extends StructReader { + private final int numFields; + + InternalRowReader(List> readers) { + super(readers); + this.numFields = readers.size(); + } + + @Override + protected GenericInternalRow newStructData(InternalRow reuse) { + if (reuse instanceof GenericInternalRow) { + return (GenericInternalRow) reuse; + } else { + return new GenericInternalRow(numFields); + } + } + + @Override + protected Object getField(GenericInternalRow intermediate, int pos) { + return intermediate.genericGet(pos); + } + + @Override + protected InternalRow buildStruct(GenericInternalRow struct) { + return struct; + } + + @Override + protected void set(GenericInternalRow row, int pos, Object value) { + row.update(pos, value); + } + + @Override + protected void setNull(GenericInternalRow row, int pos) { + row.setNullAt(pos); + } + + @Override + protected void setBoolean(GenericInternalRow row, int pos, boolean value) { + row.setBoolean(pos, value); + } + + @Override + protected void setInteger(GenericInternalRow row, int pos, int value) { + row.setInt(pos, value); + } + + @Override + protected void setLong(GenericInternalRow row, int pos, long value) { + row.setLong(pos, value); + } + + @Override + protected void setFloat(GenericInternalRow row, int pos, float value) { + row.setFloat(pos, value); + } + + @Override + protected void setDouble(GenericInternalRow row, int pos, double value) { + row.setDouble(pos, value); + } + } + + private static class ReusableMapData extends MapData { + private final ReusableArrayData keys; + private final ReusableArrayData values; + private int numElements; + + private ReusableMapData() { + this.keys = new ReusableArrayData(); + this.values = new ReusableArrayData(); + } + + private void grow() { + keys.grow(); + values.grow(); + } + + private int capacity() { + return keys.capacity(); + } + + public void setNumElements(int numElements) { + this.numElements = numElements; + keys.setNumElements(numElements); + values.setNumElements(numElements); + } + + @Override + public int numElements() { + return numElements; + } + + @Override + public MapData copy() { + return new ArrayBasedMapData(keyArray().copy(), valueArray().copy()); + } + + @Override + public ReusableArrayData keyArray() { + return keys; + } + + @Override + public ReusableArrayData valueArray() { + return values; + } + } + + private static class ReusableArrayData extends ArrayData { + private static final Object[] EMPTY = new Object[0]; + + private Object[] values = EMPTY; + private int numElements = 0; + + private void grow() { + if (values.length == 0) { + this.values = new Object[20]; + } else { + Object[] old = values; + this.values = new Object[old.length << 2]; + // copy the old array in case it has values that can be reused + System.arraycopy(old, 0, values, 0, old.length); + } + } + + private int capacity() { + return values.length; + } + + public void setNumElements(int numElements) { + this.numElements = numElements; + } + + @Override + public Object get(int ordinal, DataType dataType) { + return values[ordinal]; + } + + @Override + public int numElements() { + return numElements; + } + + @Override + public ArrayData copy() { + return new GenericArrayData(array()); + } + + @Override + public Object[] array() { + return Arrays.copyOfRange(values, 0, numElements); + } + + @Override + public void setNullAt(int i) { + values[i] = null; + } + + @Override + public void update(int ordinal, Object value) { + values[ordinal] = value; + } + + @Override + public boolean isNullAt(int ordinal) { + return null == values[ordinal]; + } + + @Override + public boolean getBoolean(int ordinal) { + return (boolean) values[ordinal]; + } + + @Override + public byte getByte(int ordinal) { + return (byte) values[ordinal]; + } + + @Override + public short getShort(int ordinal) { + return (short) values[ordinal]; + } + + @Override + public int getInt(int ordinal) { + return (int) values[ordinal]; + } + + @Override + public long getLong(int ordinal) { + return (long) values[ordinal]; + } + + @Override + public float getFloat(int ordinal) { + return (float) values[ordinal]; + } + + @Override + public double getDouble(int ordinal) { + return (double) values[ordinal]; + } + + @Override + public Decimal getDecimal(int ordinal, int precision, int scale) { + return (Decimal) values[ordinal]; + } + + @Override + public UTF8String getUTF8String(int ordinal) { + return (UTF8String) values[ordinal]; + } + + @Override + public byte[] getBinary(int ordinal) { + return (byte[]) values[ordinal]; + } + + @Override + public CalendarInterval getInterval(int ordinal) { + return (CalendarInterval) values[ordinal]; + } + + @Override + public InternalRow getStruct(int ordinal, int numFields) { + return (InternalRow) values[ordinal]; + } + + @Override + public ArrayData getArray(int ordinal) { + return (ArrayData) values[ordinal]; + } + + @Override + public MapData getMap(int ordinal) { + return (MapData) values[ordinal]; + } + + @Override + public VariantVal getVariant(int ordinal) { + return (VariantVal) values[ordinal]; + } + + @Override + public GeographyVal getGeography(int ordinal) { + return (GeographyVal) values[ordinal]; + } + + @Override + public GeometryVal getGeometry(int ordinal) { + return (GeometryVal) values[ordinal]; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java new file mode 100644 index 000000000000..ba816efc0ac8 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkParquetWriters.java @@ -0,0 +1,660 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.iceberg.FieldMetrics; +import org.apache.iceberg.Schema; +import org.apache.iceberg.parquet.ParquetValueReaders.ReusableEntry; +import org.apache.iceberg.parquet.ParquetValueWriter; +import org.apache.iceberg.parquet.ParquetValueWriters; +import org.apache.iceberg.parquet.ParquetValueWriters.PrimitiveWriter; +import org.apache.iceberg.parquet.ParquetValueWriters.RepeatedKeyValueWriter; +import org.apache.iceberg.parquet.ParquetValueWriters.RepeatedWriter; +import org.apache.iceberg.parquet.ParquetVariantVisitor; +import org.apache.iceberg.parquet.TripleWriter; +import org.apache.iceberg.parquet.VariantWriterBuilder; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.util.DecimalUtil; +import org.apache.iceberg.util.UUIDUtil; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.ColumnWriteStore; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.LogicalTypeAnnotation.DecimalLogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.ByteType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.NullType; +import org.apache.spark.sql.types.ShortType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.VariantType; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; + +public class SparkParquetWriters { + private SparkParquetWriters() {} + + public static ParquetValueWriter buildWriter(StructType dfSchema, MessageType type) { + return buildWriter(null, type, dfSchema); + } + + @SuppressWarnings("unchecked") + public static ParquetValueWriter buildWriter( + Schema icebergSchema, MessageType type, StructType dfSchema) { + return (ParquetValueWriter) + ParquetWithSparkSchemaVisitor.visit( + dfSchema != null ? dfSchema : SparkSchemaUtil.convert(icebergSchema), + type, + new WriteBuilder(type)); + } + + private static class WriteBuilder extends ParquetWithSparkSchemaVisitor> { + private final MessageType type; + + WriteBuilder(MessageType type) { + this.type = type; + } + + @Override + public ParquetValueWriter message( + StructType sStruct, MessageType message, List> fieldWriters) { + return struct(sStruct, message.asGroupType(), fieldWriters); + } + + @Override + public ParquetValueWriter struct( + StructType sStruct, GroupType struct, List> fieldWriters) { + List fields = struct.getFields(); + List> writers = Lists.newArrayListWithExpectedSize(fieldWriters.size()); + for (int i = 0; i < fields.size(); i += 1) { + writers.add(newOption(struct.getType(i), fieldWriters.get(i))); + } + + StructField[] sFields = sStruct.fields(); + DataType[] types = new DataType[sFields.length]; + for (int i = 0; i < sFields.length; i += 1) { + types[i] = sFields[i].dataType(); + } + + return new InternalRowWriter(writers, types); + } + + @Override + public ParquetValueWriter list( + ArrayType sArray, GroupType array, ParquetValueWriter elementWriter) { + GroupType repeated = array.getFields().get(0).asGroupType(); + String[] repeatedPath = currentPath(); + + int repeatedD = type.getMaxDefinitionLevel(repeatedPath); + int repeatedR = type.getMaxRepetitionLevel(repeatedPath); + + return new ArrayDataWriter<>( + repeatedD, + repeatedR, + newOption(repeated.getType(0), elementWriter), + sArray.elementType()); + } + + @Override + public ParquetValueWriter map( + MapType sMap, + GroupType map, + ParquetValueWriter keyWriter, + ParquetValueWriter valueWriter) { + GroupType repeatedKeyValue = map.getFields().get(0).asGroupType(); + String[] repeatedPath = currentPath(); + + int repeatedD = type.getMaxDefinitionLevel(repeatedPath); + int repeatedR = type.getMaxRepetitionLevel(repeatedPath); + + return new MapDataWriter<>( + repeatedD, + repeatedR, + newOption(repeatedKeyValue.getType(0), keyWriter), + newOption(repeatedKeyValue.getType(1), valueWriter), + sMap.keyType(), + sMap.valueType()); + } + + @Override + public ParquetValueWriter variant(VariantType sVariant, GroupType variant) { + ParquetValueWriter writer = + ParquetVariantVisitor.visit( + variant, new VariantWriterBuilder(type, Arrays.asList(currentPath()))); + return new VariantWriter(writer); + } + + private ParquetValueWriter newOption(Type fieldType, ParquetValueWriter writer) { + int maxD = type.getMaxDefinitionLevel(path(fieldType.getName())); + return ParquetValueWriters.option(fieldType, maxD, writer); + } + + private static class LogicalTypeAnnotationParquetValueWriterVisitor + implements LogicalTypeAnnotation.LogicalTypeAnnotationVisitor> { + + private final ColumnDescriptor desc; + private final PrimitiveType primitive; + + LogicalTypeAnnotationParquetValueWriterVisitor( + ColumnDescriptor desc, PrimitiveType primitive) { + this.desc = desc; + this.primitive = primitive; + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.StringLogicalTypeAnnotation stringLogicalType) { + return Optional.of(utf8Strings(desc)); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.EnumLogicalTypeAnnotation enumLogicalType) { + return Optional.of(utf8Strings(desc)); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.JsonLogicalTypeAnnotation jsonLogicalType) { + return Optional.of(utf8Strings(desc)); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.UUIDLogicalTypeAnnotation uuidLogicalType) { + return Optional.of(uuids(desc)); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.MapLogicalTypeAnnotation mapLogicalType) { + return LogicalTypeAnnotation.LogicalTypeAnnotationVisitor.super.visit(mapLogicalType); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.ListLogicalTypeAnnotation listLogicalType) { + return LogicalTypeAnnotation.LogicalTypeAnnotationVisitor.super.visit(listLogicalType); + } + + @Override + public Optional> visit(DecimalLogicalTypeAnnotation decimal) { + switch (primitive.getPrimitiveTypeName()) { + case INT32: + return Optional.of(decimalAsInteger(desc, decimal.getPrecision(), decimal.getScale())); + case INT64: + return Optional.of(decimalAsLong(desc, decimal.getPrecision(), decimal.getScale())); + case BINARY: + case FIXED_LEN_BYTE_ARRAY: + return Optional.of(decimalAsFixed(desc, decimal.getPrecision(), decimal.getScale())); + } + return Optional.empty(); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.DateLogicalTypeAnnotation dateLogicalType) { + return Optional.of(ParquetValueWriters.ints(desc)); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.TimeLogicalTypeAnnotation timeLogicalType) { + if (timeLogicalType.getUnit() == LogicalTypeAnnotation.TimeUnit.MICROS) { + return Optional.of(ParquetValueWriters.longs(desc)); + } + return Optional.empty(); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.TimestampLogicalTypeAnnotation timestampLogicalType) { + if (timestampLogicalType.getUnit() == LogicalTypeAnnotation.TimeUnit.MICROS) { + return Optional.of(ParquetValueWriters.longs(desc)); + } + return Optional.empty(); + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.IntLogicalTypeAnnotation intLogicalType) { + int bitWidth = intLogicalType.getBitWidth(); + if (bitWidth <= 8) { + return Optional.of(ParquetValueWriters.tinyints(desc)); + } else if (bitWidth <= 16) { + return Optional.of(ParquetValueWriters.shorts(desc)); + } else if (bitWidth <= 32) { + return Optional.of(ParquetValueWriters.ints(desc)); + } else { + return Optional.of(ParquetValueWriters.longs(desc)); + } + } + + @Override + public Optional> visit( + LogicalTypeAnnotation.BsonLogicalTypeAnnotation bsonLogicalType) { + return Optional.of(byteArrays(desc)); + } + } + + @Override + public ParquetValueWriter primitive(DataType sType, PrimitiveType primitive) { + ColumnDescriptor desc = type.getColumnDescription(currentPath()); + LogicalTypeAnnotation logicalTypeAnnotation = primitive.getLogicalTypeAnnotation(); + + if (logicalTypeAnnotation != null) { + return logicalTypeAnnotation + .accept(new LogicalTypeAnnotationParquetValueWriterVisitor(desc, primitive)) + .orElseThrow( + () -> + new UnsupportedOperationException( + "Unsupported logical type: " + primitive.getLogicalTypeAnnotation())); + } + + switch (primitive.getPrimitiveTypeName()) { + case FIXED_LEN_BYTE_ARRAY: + case BINARY: + return byteArrays(desc); + case BOOLEAN: + return ParquetValueWriters.booleans(desc); + case INT32: + return ints(sType, desc); + case INT64: + return ParquetValueWriters.longs(desc); + case FLOAT: + return ParquetValueWriters.floats(desc); + case DOUBLE: + return ParquetValueWriters.doubles(desc); + default: + throw new UnsupportedOperationException("Unsupported type: " + primitive); + } + } + } + + private static PrimitiveWriter ints(DataType type, ColumnDescriptor desc) { + if (type instanceof ByteType) { + return ParquetValueWriters.tinyints(desc); + } else if (type instanceof ShortType) { + return ParquetValueWriters.shorts(desc); + } + return ParquetValueWriters.ints(desc); + } + + private static PrimitiveWriter utf8Strings(ColumnDescriptor desc) { + return new UTF8StringWriter(desc); + } + + private static PrimitiveWriter uuids(ColumnDescriptor desc) { + return new UUIDWriter(desc); + } + + private static PrimitiveWriter decimalAsInteger( + ColumnDescriptor desc, int precision, int scale) { + return new IntegerDecimalWriter(desc, precision, scale); + } + + private static PrimitiveWriter decimalAsLong( + ColumnDescriptor desc, int precision, int scale) { + return new LongDecimalWriter(desc, precision, scale); + } + + private static PrimitiveWriter decimalAsFixed( + ColumnDescriptor desc, int precision, int scale) { + return new FixedDecimalWriter(desc, precision, scale); + } + + private static PrimitiveWriter byteArrays(ColumnDescriptor desc) { + return new ByteArrayWriter(desc); + } + + private static class UTF8StringWriter extends PrimitiveWriter { + private UTF8StringWriter(ColumnDescriptor desc) { + super(desc); + } + + @Override + public void write(int repetitionLevel, UTF8String value) { + column.writeBinary(repetitionLevel, Binary.fromReusedByteArray(value.getBytes())); + } + } + + private static class IntegerDecimalWriter extends PrimitiveWriter { + private final int precision; + private final int scale; + + private IntegerDecimalWriter(ColumnDescriptor desc, int precision, int scale) { + super(desc); + this.precision = precision; + this.scale = scale; + } + + @Override + public void write(int repetitionLevel, Decimal decimal) { + Preconditions.checkArgument( + decimal.scale() == scale, + "Cannot write value as decimal(%s,%s), wrong scale: %s", + precision, + scale, + decimal); + Preconditions.checkArgument( + decimal.precision() <= precision, + "Cannot write value as decimal(%s,%s), too large: %s", + precision, + scale, + decimal); + + column.writeInteger(repetitionLevel, (int) decimal.toUnscaledLong()); + } + } + + private static class LongDecimalWriter extends PrimitiveWriter { + private final int precision; + private final int scale; + + private LongDecimalWriter(ColumnDescriptor desc, int precision, int scale) { + super(desc); + this.precision = precision; + this.scale = scale; + } + + @Override + public void write(int repetitionLevel, Decimal decimal) { + Preconditions.checkArgument( + decimal.scale() == scale, + "Cannot write value as decimal(%s,%s), wrong scale: %s", + precision, + scale, + decimal); + Preconditions.checkArgument( + decimal.precision() <= precision, + "Cannot write value as decimal(%s,%s), too large: %s", + precision, + scale, + decimal); + + column.writeLong(repetitionLevel, decimal.toUnscaledLong()); + } + } + + private static class FixedDecimalWriter extends PrimitiveWriter { + private final int precision; + private final int scale; + private final ThreadLocal bytes; + + private FixedDecimalWriter(ColumnDescriptor desc, int precision, int scale) { + super(desc); + this.precision = precision; + this.scale = scale; + this.bytes = + ThreadLocal.withInitial(() -> new byte[TypeUtil.decimalRequiredBytes(precision)]); + } + + @Override + public void write(int repetitionLevel, Decimal decimal) { + byte[] binary = + DecimalUtil.toReusedFixLengthBytes( + precision, scale, decimal.toJavaBigDecimal(), bytes.get()); + column.writeBinary(repetitionLevel, Binary.fromReusedByteArray(binary)); + } + } + + private static class UUIDWriter extends PrimitiveWriter { + private static final ThreadLocal BUFFER = + ThreadLocal.withInitial( + () -> { + ByteBuffer buffer = ByteBuffer.allocate(16); + buffer.order(ByteOrder.BIG_ENDIAN); + return buffer; + }); + + private UUIDWriter(ColumnDescriptor desc) { + super(desc); + } + + @Override + public void write(int repetitionLevel, UTF8String string) { + UUID uuid = UUID.fromString(string.toString()); + ByteBuffer buffer = UUIDUtil.convertToByteBuffer(uuid, BUFFER.get()); + column.writeBinary(repetitionLevel, Binary.fromReusedByteBuffer(buffer)); + } + } + + private static class ByteArrayWriter extends PrimitiveWriter { + private ByteArrayWriter(ColumnDescriptor desc) { + super(desc); + } + + @Override + public void write(int repetitionLevel, byte[] bytes) { + column.writeBinary(repetitionLevel, Binary.fromReusedByteArray(bytes)); + } + } + + private static class ArrayDataWriter extends RepeatedWriter { + private final DataType elementType; + + private ArrayDataWriter( + int definitionLevel, + int repetitionLevel, + ParquetValueWriter writer, + DataType elementType) { + super(definitionLevel, repetitionLevel, writer); + this.elementType = elementType; + } + + @Override + protected Iterator elements(ArrayData list) { + return new ElementIterator<>(list); + } + + private class ElementIterator implements Iterator { + private final int size; + private final ArrayData list; + private int index; + + private ElementIterator(ArrayData list) { + this.list = list; + size = list.numElements(); + index = 0; + } + + @Override + public boolean hasNext() { + return index != size; + } + + @Override + @SuppressWarnings("unchecked") + public E next() { + if (index >= size) { + throw new NoSuchElementException(); + } + + E element; + if (list.isNullAt(index)) { + element = null; + } else { + element = (E) list.get(index, elementType); + } + + index += 1; + + return element; + } + } + } + + private static class MapDataWriter extends RepeatedKeyValueWriter { + private final DataType keyType; + private final DataType valueType; + + private MapDataWriter( + int definitionLevel, + int repetitionLevel, + ParquetValueWriter keyWriter, + ParquetValueWriter valueWriter, + DataType keyType, + DataType valueType) { + super(definitionLevel, repetitionLevel, keyWriter, valueWriter); + this.keyType = keyType; + this.valueType = valueType; + } + + @Override + protected Iterator> pairs(MapData map) { + return new EntryIterator<>(map); + } + + private class EntryIterator implements Iterator> { + private final int size; + private final ArrayData keys; + private final ArrayData values; + private final ReusableEntry entry; + private int index; + + private EntryIterator(MapData map) { + size = map.numElements(); + keys = map.keyArray(); + values = map.valueArray(); + entry = new ReusableEntry<>(); + index = 0; + } + + @Override + public boolean hasNext() { + return index != size; + } + + @Override + @SuppressWarnings("unchecked") + public Map.Entry next() { + if (index >= size) { + throw new NoSuchElementException(); + } + + if (values.isNullAt(index)) { + entry.set((K) keys.get(index, keyType), null); + } else { + entry.set((K) keys.get(index, keyType), (V) values.get(index, valueType)); + } + + index += 1; + + return entry; + } + } + } + + /** Variant writer converts from VariantVal to Variant */ + public static class VariantWriter implements ParquetValueWriter { + private final ParquetValueWriter writer; + + @SuppressWarnings("unchecked") + private VariantWriter(ParquetValueWriter writer) { + this.writer = (ParquetValueWriter) writer; + } + + @Override + public void write(int repetitionLevel, VariantVal variantVal) { + VariantMetadata metadata = + VariantMetadata.from( + ByteBuffer.wrap(variantVal.getMetadata()).order(ByteOrder.LITTLE_ENDIAN)); + VariantValue value = + VariantValue.from( + metadata, ByteBuffer.wrap(variantVal.getValue()).order(ByteOrder.LITTLE_ENDIAN)); + + writer.write(repetitionLevel, Variant.of(metadata, value)); + } + + @Override + public List> columns() { + return writer.columns(); + } + + @Override + public void setColumnStore(ColumnWriteStore columnStore) { + writer.setColumnStore(columnStore); + } + + @Override + public Stream> metrics() { + return writer.metrics(); + } + } + + private static class InternalRowWriter extends ParquetValueWriters.StructWriter { + private final DataType[] types; + + private InternalRowWriter(List> writers, DataType[] types) { + super(writerToFieldIndex(types, writers.size()), writers); + this.types = types; + } + + @Override + protected Object get(InternalRow struct, int index) { + return struct.get(index, types[index]); + } + + /** Returns a mapping from writer index to field index, skipping Unknown columns. */ + private static int[] writerToFieldIndex(DataType[] types, int numWriters) { + if (null == types) { + return IntStream.rangeClosed(0, numWriters).toArray(); + } + + // value writer index to record field index + int[] indexes = new int[numWriters]; + int writerIndex = 0; + for (int pos = 0; pos < types.length; pos += 1) { + if (!(types[pos] instanceof NullType)) { + indexes[writerIndex] = pos; + writerIndex += 1; + } + } + + return indexes; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkPlannedAvroReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkPlannedAvroReader.java new file mode 100644 index 000000000000..596f94cd053f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkPlannedAvroReader.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import org.apache.avro.LogicalType; +import org.apache.avro.LogicalTypes; +import org.apache.avro.Schema; +import org.apache.avro.io.DatumReader; +import org.apache.avro.io.Decoder; +import org.apache.iceberg.avro.AvroWithPartnerVisitor; +import org.apache.iceberg.avro.SupportsRowPosition; +import org.apache.iceberg.avro.ValueReader; +import org.apache.iceberg.avro.ValueReaders; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.catalyst.InternalRow; + +public class SparkPlannedAvroReader implements DatumReader, SupportsRowPosition { + + private final Types.StructType expectedType; + private final Map idToConstant; + private ValueReader reader; + + public static SparkPlannedAvroReader create(org.apache.iceberg.Schema schema) { + return create(schema, ImmutableMap.of()); + } + + public static SparkPlannedAvroReader create( + org.apache.iceberg.Schema schema, Map constants) { + return new SparkPlannedAvroReader(schema, constants); + } + + private SparkPlannedAvroReader( + org.apache.iceberg.Schema expectedSchema, Map constants) { + this.expectedType = expectedSchema.asStruct(); + this.idToConstant = constants; + } + + @Override + @SuppressWarnings("unchecked") + public void setSchema(Schema fileSchema) { + this.reader = + (ValueReader) + AvroWithPartnerVisitor.visit( + expectedType, + fileSchema, + new ReadBuilder(idToConstant), + AvroWithPartnerVisitor.FieldIDAccessors.get()); + } + + @Override + public InternalRow read(InternalRow reuse, Decoder decoder) throws IOException { + return reader.read(decoder, reuse); + } + + @Override + public void setRowPositionSupplier(Supplier posSupplier) { + if (reader instanceof SupportsRowPosition) { + ((SupportsRowPosition) reader).setRowPositionSupplier(posSupplier); + } + } + + private static class ReadBuilder extends AvroWithPartnerVisitor> { + private final Map idToConstant; + + private ReadBuilder(Map idToConstant) { + this.idToConstant = idToConstant; + } + + @Override + public ValueReader record(Type partner, Schema record, List> fieldReaders) { + if (partner == null) { + return ValueReaders.skipStruct(fieldReaders); + } + + Types.StructType expected = partner.asStructType(); + List>> readPlan = + ValueReaders.buildReadPlan( + expected, record, fieldReaders, idToConstant, SparkUtil::internalToSpark); + + // TODO: should this pass expected so that struct.get can reuse containers? + return SparkValueReaders.struct(readPlan, expected.fields().size()); + } + + @Override + public ValueReader union(Type partner, Schema union, List> options) { + return ValueReaders.union(options); + } + + @Override + public ValueReader array(Type partner, Schema array, ValueReader elementReader) { + return SparkValueReaders.array(elementReader); + } + + @Override + public ValueReader arrayMap( + Type partner, Schema map, ValueReader keyReader, ValueReader valueReader) { + return SparkValueReaders.arrayMap(keyReader, valueReader); + } + + @Override + public ValueReader map(Type partner, Schema map, ValueReader valueReader) { + return SparkValueReaders.map(SparkValueReaders.strings(), valueReader); + } + + @Override + public ValueReader variant( + Type partner, ValueReader metadataReader, ValueReader valueReader) { + return SparkValueReaders.variants(); + } + + @Override + public ValueReader primitive(Type partner, Schema primitive) { + LogicalType logicalType = primitive.getLogicalType(); + if (logicalType != null) { + switch (logicalType.getName()) { + case "date": + // Spark uses the same representation + return ValueReaders.ints(); + + case "timestamp-millis": + // adjust to microseconds + ValueReader longs = ValueReaders.longs(); + return (ValueReader) (decoder, ignored) -> longs.read(decoder, null) * 1000L; + + case "timestamp-micros": + // Spark uses the same representation + return ValueReaders.longs(); + + case "decimal": + return SparkValueReaders.decimal( + ValueReaders.decimalBytesReader(primitive), + ((LogicalTypes.Decimal) logicalType).getScale()); + + case "uuid": + return SparkValueReaders.uuids(); + + default: + throw new IllegalArgumentException("Unknown logical type: " + logicalType); + } + } + + switch (primitive.getType()) { + case NULL: + return ValueReaders.nulls(); + case BOOLEAN: + return ValueReaders.booleans(); + case INT: + if (partner != null && partner.typeId() == Type.TypeID.LONG) { + return ValueReaders.intsAsLongs(); + } + return ValueReaders.ints(); + case LONG: + return ValueReaders.longs(); + case FLOAT: + if (partner != null && partner.typeId() == Type.TypeID.DOUBLE) { + return ValueReaders.floatsAsDoubles(); + } + return ValueReaders.floats(); + case DOUBLE: + return ValueReaders.doubles(); + case STRING: + return SparkValueReaders.strings(); + case FIXED: + return ValueReaders.fixed(primitive.getFixedSize()); + case BYTES: + return ValueReaders.bytes(); + case ENUM: + return SparkValueReaders.enums(primitive.getEnumSymbols()); + default: + throw new IllegalArgumentException("Unsupported type: " + primitive); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkValueReaders.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkValueReaders.java new file mode 100644 index 000000000000..7bcb3e3fae01 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkValueReaders.java @@ -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. + */ +package org.apache.iceberg.spark.data; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.apache.avro.io.Decoder; +import org.apache.avro.util.Utf8; +import org.apache.iceberg.avro.ValueReader; +import org.apache.iceberg.avro.ValueReaders; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.UUIDUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; + +public class SparkValueReaders { + + private SparkValueReaders() {} + + static ValueReader strings() { + return StringReader.INSTANCE; + } + + static ValueReader enums(List symbols) { + return new EnumReader(symbols); + } + + static ValueReader uuids() { + return UUIDReader.INSTANCE; + } + + static ValueReader variants() { + return VariantReader.INSTANCE; + } + + static ValueReader decimal(ValueReader unscaledReader, int scale) { + return new DecimalReader(unscaledReader, scale); + } + + static ValueReader array(ValueReader elementReader) { + return new ArrayReader(elementReader); + } + + static ValueReader arrayMap( + ValueReader keyReader, ValueReader valueReader) { + return new ArrayMapReader(keyReader, valueReader); + } + + static ValueReader map(ValueReader keyReader, ValueReader valueReader) { + return new MapReader(keyReader, valueReader); + } + + static ValueReader struct( + List>> readPlan, int numFields) { + return new PlannedStructReader(readPlan, numFields); + } + + static ValueReader struct( + List> readers, Types.StructType struct, Map idToConstant) { + return new StructReader(readers, struct, idToConstant); + } + + private static class StringReader implements ValueReader { + private static final StringReader INSTANCE = new StringReader(); + + private StringReader() {} + + @Override + public UTF8String read(Decoder decoder, Object reuse) throws IOException { + // use the decoder's readString(Utf8) method because it may be a resolving decoder + Utf8 utf8 = null; + if (reuse instanceof UTF8String) { + utf8 = new Utf8(((UTF8String) reuse).getBytes()); + } + + Utf8 string = decoder.readString(utf8); + return UTF8String.fromBytes(string.getBytes(), 0, string.getByteLength()); + } + } + + private static class EnumReader implements ValueReader { + private final UTF8String[] symbols; + + private EnumReader(List symbols) { + this.symbols = new UTF8String[symbols.size()]; + for (int i = 0; i < this.symbols.length; i += 1) { + this.symbols[i] = UTF8String.fromBytes(symbols.get(i).getBytes(StandardCharsets.UTF_8)); + } + } + + @Override + public UTF8String read(Decoder decoder, Object ignore) throws IOException { + int index = decoder.readEnum(); + return symbols[index]; + } + } + + private static class UUIDReader implements ValueReader { + private static final ThreadLocal BUFFER = + ThreadLocal.withInitial( + () -> { + ByteBuffer buffer = ByteBuffer.allocate(16); + buffer.order(ByteOrder.BIG_ENDIAN); + return buffer; + }); + + private static final UUIDReader INSTANCE = new UUIDReader(); + + private UUIDReader() {} + + @Override + @SuppressWarnings("ByteBufferBackingArray") + public UTF8String read(Decoder decoder, Object reuse) throws IOException { + ByteBuffer buffer = BUFFER.get(); + buffer.rewind(); + + decoder.readFixed(buffer.array(), 0, 16); + + return UTF8String.fromString(UUIDUtil.convert(buffer).toString()); + } + } + + private static class DecimalReader implements ValueReader { + private final ValueReader bytesReader; + private final int scale; + + private DecimalReader(ValueReader bytesReader, int scale) { + this.bytesReader = bytesReader; + this.scale = scale; + } + + @Override + public Decimal read(Decoder decoder, Object reuse) throws IOException { + byte[] bytes = bytesReader.read(decoder, null); + return Decimal.apply(new BigDecimal(new BigInteger(bytes), scale)); + } + } + + private static class VariantReader implements ValueReader { + private static final VariantReader INSTANCE = new VariantReader(); + + private final ValueReader metadataReader; + private final ValueReader valueReader; + + private VariantReader() { + this.metadataReader = ValueReaders.bytes(); + this.valueReader = ValueReaders.bytes(); + } + + @Override + public VariantVal read(Decoder decoder, Object reuse) throws IOException { + byte[] metadata = metadataReader.read(decoder, null); + byte[] value = valueReader.read(decoder, null); + return new VariantVal(value, metadata); + } + + @Override + public void skip(Decoder decoder) throws IOException { + metadataReader.skip(decoder); + valueReader.skip(decoder); + } + } + + private static class ArrayReader implements ValueReader { + private final ValueReader elementReader; + private final List reusedList = Lists.newArrayList(); + + private ArrayReader(ValueReader elementReader) { + this.elementReader = elementReader; + } + + @Override + public GenericArrayData read(Decoder decoder, Object reuse) throws IOException { + reusedList.clear(); + long chunkLength = decoder.readArrayStart(); + + while (chunkLength > 0) { + for (int i = 0; i < chunkLength; i += 1) { + reusedList.add(elementReader.read(decoder, null)); + } + + chunkLength = decoder.arrayNext(); + } + + // this will convert the list to an array so it is okay to reuse the list + return new GenericArrayData(reusedList.toArray()); + } + } + + private static class ArrayMapReader implements ValueReader { + private final ValueReader keyReader; + private final ValueReader valueReader; + + private final List reusedKeyList = Lists.newArrayList(); + private final List reusedValueList = Lists.newArrayList(); + + private ArrayMapReader(ValueReader keyReader, ValueReader valueReader) { + this.keyReader = keyReader; + this.valueReader = valueReader; + } + + @Override + public ArrayBasedMapData read(Decoder decoder, Object reuse) throws IOException { + reusedKeyList.clear(); + reusedValueList.clear(); + + long chunkLength = decoder.readArrayStart(); + + while (chunkLength > 0) { + for (int i = 0; i < chunkLength; i += 1) { + reusedKeyList.add(keyReader.read(decoder, null)); + reusedValueList.add(valueReader.read(decoder, null)); + } + + chunkLength = decoder.arrayNext(); + } + + return new ArrayBasedMapData( + new GenericArrayData(reusedKeyList.toArray()), + new GenericArrayData(reusedValueList.toArray())); + } + } + + private static class MapReader implements ValueReader { + private final ValueReader keyReader; + private final ValueReader valueReader; + + private final List reusedKeyList = Lists.newArrayList(); + private final List reusedValueList = Lists.newArrayList(); + + private MapReader(ValueReader keyReader, ValueReader valueReader) { + this.keyReader = keyReader; + this.valueReader = valueReader; + } + + @Override + public ArrayBasedMapData read(Decoder decoder, Object reuse) throws IOException { + reusedKeyList.clear(); + reusedValueList.clear(); + + long chunkLength = decoder.readMapStart(); + + while (chunkLength > 0) { + for (int i = 0; i < chunkLength; i += 1) { + reusedKeyList.add(keyReader.read(decoder, null)); + reusedValueList.add(valueReader.read(decoder, null)); + } + + chunkLength = decoder.mapNext(); + } + + return new ArrayBasedMapData( + new GenericArrayData(reusedKeyList.toArray()), + new GenericArrayData(reusedValueList.toArray())); + } + } + + static class PlannedStructReader extends ValueReaders.PlannedStructReader { + private final int numFields; + + protected PlannedStructReader(List>> readPlan, int numFields) { + super(readPlan); + this.numFields = numFields; + } + + @Override + protected InternalRow reuseOrCreate(Object reuse) { + if (reuse instanceof GenericInternalRow + && ((GenericInternalRow) reuse).numFields() == numFields) { + return (InternalRow) reuse; + } + return new GenericInternalRow(numFields); + } + + @Override + protected Object get(InternalRow struct, int pos) { + return null; + } + + @Override + protected void set(InternalRow struct, int pos, Object value) { + if (value != null) { + struct.update(pos, value); + } else { + struct.setNullAt(pos); + } + } + } + + static class StructReader extends ValueReaders.StructReader { + private final int numFields; + + protected StructReader( + List> readers, Types.StructType struct, Map idToConstant) { + super(readers, struct, idToConstant); + this.numFields = readers.size(); + } + + @Override + protected InternalRow reuseOrCreate(Object reuse) { + if (reuse instanceof GenericInternalRow + && ((GenericInternalRow) reuse).numFields() == numFields) { + return (InternalRow) reuse; + } + return new GenericInternalRow(numFields); + } + + @Override + protected Object get(InternalRow struct, int pos) { + return null; + } + + @Override + protected void set(InternalRow struct, int pos, Object value) { + if (value != null) { + struct.update(pos, value); + } else { + struct.setNullAt(pos); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkValueWriters.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkValueWriters.java new file mode 100644 index 000000000000..d0d3483a7690 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkValueWriters.java @@ -0,0 +1,280 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.io.IOException; +import java.lang.reflect.Array; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.List; +import java.util.UUID; +import org.apache.avro.io.Encoder; +import org.apache.avro.util.Utf8; +import org.apache.iceberg.avro.ValueWriter; +import org.apache.iceberg.avro.ValueWriters; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.util.DecimalUtil; +import org.apache.iceberg.util.UUIDUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; + +public class SparkValueWriters { + + private SparkValueWriters() {} + + static ValueWriter strings() { + return StringWriter.INSTANCE; + } + + static ValueWriter uuids() { + return UUIDWriter.INSTANCE; + } + + static ValueWriter variants() { + return VariantWriter.INSTANCE; + } + + static ValueWriter decimal(int precision, int scale) { + return new DecimalWriter(precision, scale); + } + + static ValueWriter array(ValueWriter elementWriter, DataType elementType) { + return new ArrayWriter<>(elementWriter, elementType); + } + + static ValueWriter arrayMap( + ValueWriter keyWriter, DataType keyType, ValueWriter valueWriter, DataType valueType) { + return new ArrayMapWriter<>(keyWriter, keyType, valueWriter, valueType); + } + + static ValueWriter map( + ValueWriter keyWriter, DataType keyType, ValueWriter valueWriter, DataType valueType) { + return new MapWriter<>(keyWriter, keyType, valueWriter, valueType); + } + + static ValueWriter struct(List> writers, List types) { + return new StructWriter(writers, types); + } + + private static class StringWriter implements ValueWriter { + private static final StringWriter INSTANCE = new StringWriter(); + + private StringWriter() {} + + @Override + public void write(UTF8String s, Encoder encoder) throws IOException { + // use getBytes because it may return the backing byte array if available. + // otherwise, it copies to a new byte array, which is still cheaper than Avro + // calling toString, which incurs encoding costs + encoder.writeString(new Utf8(s.getBytes())); + } + } + + private static class UUIDWriter implements ValueWriter { + private static final ThreadLocal BUFFER = + ThreadLocal.withInitial( + () -> { + ByteBuffer buffer = ByteBuffer.allocate(16); + buffer.order(ByteOrder.BIG_ENDIAN); + return buffer; + }); + + private static final UUIDWriter INSTANCE = new UUIDWriter(); + + private UUIDWriter() {} + + @Override + @SuppressWarnings("ByteBufferBackingArray") + public void write(UTF8String s, Encoder encoder) throws IOException { + // TODO: direct conversion from string to byte buffer + UUID uuid = UUID.fromString(s.toString()); + // calling array() is safe because the buffer is always allocated by the thread-local + encoder.writeFixed(UUIDUtil.convertToByteBuffer(uuid, BUFFER.get()).array()); + } + } + + private static class VariantWriter implements ValueWriter { + private static final VariantWriter INSTANCE = new VariantWriter(); + + private final ValueWriter metadataWriter; + private final ValueWriter valueWriter; + + private VariantWriter() { + this.metadataWriter = ValueWriters.bytes(); + this.valueWriter = ValueWriters.bytes(); + } + + @Override + public void write(VariantVal variant, Encoder encoder) throws IOException { + metadataWriter.write(variant.getMetadata(), encoder); + valueWriter.write(variant.getValue(), encoder); + } + } + + private static class DecimalWriter implements ValueWriter { + private final int precision; + private final int scale; + private final ThreadLocal bytes; + + private DecimalWriter(int precision, int scale) { + this.precision = precision; + this.scale = scale; + this.bytes = + ThreadLocal.withInitial(() -> new byte[TypeUtil.decimalRequiredBytes(precision)]); + } + + @Override + public void write(Decimal d, Encoder encoder) throws IOException { + encoder.writeFixed( + DecimalUtil.toReusedFixLengthBytes(precision, scale, d.toJavaBigDecimal(), bytes.get())); + } + } + + private static class ArrayWriter implements ValueWriter { + private final ValueWriter elementWriter; + private final DataType elementType; + + private ArrayWriter(ValueWriter elementWriter, DataType elementType) { + this.elementWriter = elementWriter; + this.elementType = elementType; + } + + @Override + @SuppressWarnings("unchecked") + public void write(ArrayData array, Encoder encoder) throws IOException { + encoder.writeArrayStart(); + int numElements = array.numElements(); + encoder.setItemCount(numElements); + for (int i = 0; i < numElements; i += 1) { + encoder.startItem(); + elementWriter.write((T) array.get(i, elementType), encoder); + } + encoder.writeArrayEnd(); + } + } + + private static class ArrayMapWriter implements ValueWriter { + private final ValueWriter keyWriter; + private final ValueWriter valueWriter; + private final DataType keyType; + private final DataType valueType; + + private ArrayMapWriter( + ValueWriter keyWriter, + DataType keyType, + ValueWriter valueWriter, + DataType valueType) { + this.keyWriter = keyWriter; + this.keyType = keyType; + this.valueWriter = valueWriter; + this.valueType = valueType; + } + + @Override + @SuppressWarnings("unchecked") + public void write(MapData map, Encoder encoder) throws IOException { + encoder.writeArrayStart(); + int numElements = map.numElements(); + encoder.setItemCount(numElements); + ArrayData keyArray = map.keyArray(); + ArrayData valueArray = map.valueArray(); + for (int i = 0; i < numElements; i += 1) { + encoder.startItem(); + keyWriter.write((K) keyArray.get(i, keyType), encoder); + valueWriter.write((V) valueArray.get(i, valueType), encoder); + } + encoder.writeArrayEnd(); + } + } + + private static class MapWriter implements ValueWriter { + private final ValueWriter keyWriter; + private final ValueWriter valueWriter; + private final DataType keyType; + private final DataType valueType; + + private MapWriter( + ValueWriter keyWriter, + DataType keyType, + ValueWriter valueWriter, + DataType valueType) { + this.keyWriter = keyWriter; + this.keyType = keyType; + this.valueWriter = valueWriter; + this.valueType = valueType; + } + + @Override + @SuppressWarnings("unchecked") + public void write(MapData map, Encoder encoder) throws IOException { + encoder.writeMapStart(); + int numElements = map.numElements(); + encoder.setItemCount(numElements); + ArrayData keyArray = map.keyArray(); + ArrayData valueArray = map.valueArray(); + for (int i = 0; i < numElements; i += 1) { + encoder.startItem(); + keyWriter.write((K) keyArray.get(i, keyType), encoder); + valueWriter.write((V) valueArray.get(i, valueType), encoder); + } + encoder.writeMapEnd(); + } + } + + static class StructWriter implements ValueWriter { + private final ValueWriter[] writers; + private final DataType[] types; + + @SuppressWarnings("unchecked") + private StructWriter(List> writers, List types) { + this.writers = (ValueWriter[]) Array.newInstance(ValueWriter.class, writers.size()); + this.types = new DataType[writers.size()]; + for (int i = 0; i < writers.size(); i += 1) { + this.writers[i] = writers.get(i); + this.types[i] = types.get(i); + } + } + + ValueWriter[] writers() { + return writers; + } + + @Override + public void write(InternalRow row, Encoder encoder) throws IOException { + for (int i = 0; i < types.length; i += 1) { + if (row.isNullAt(i)) { + writers[i].write(null, encoder); + } else { + write(row, i, writers[i], encoder); + } + } + } + + @SuppressWarnings("unchecked") + private void write(InternalRow row, int pos, ValueWriter writer, Encoder encoder) + throws IOException { + writer.write((T) row.get(pos, types[pos]), encoder); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ArrowVectorAccessorFactory.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ArrowVectorAccessorFactory.java new file mode 100644 index 000000000000..b4bb9a918732 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ArrowVectorAccessorFactory.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import org.apache.arrow.memory.ArrowBuf; +import org.apache.arrow.vector.FixedSizeBinaryVector; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.ValueVector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.complex.ListVector; +import org.apache.iceberg.arrow.vectorized.GenericArrowVectorAccessorFactory; +import org.apache.iceberg.util.UUIDUtil; +import org.apache.parquet.column.Dictionary; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.vectorized.ArrowColumnVector; +import org.apache.spark.sql.vectorized.ColumnarArray; +import org.apache.spark.unsafe.types.UTF8String; + +final class ArrowVectorAccessorFactory + extends GenericArrowVectorAccessorFactory< + Decimal, UTF8String, ColumnarArray, ArrowColumnVector> { + + ArrowVectorAccessorFactory() { + super( + DecimalFactoryImpl::new, + StringFactoryImpl::new, + StructChildFactoryImpl::new, + ArrayFactoryImpl::new); + } + + private static final class DecimalFactoryImpl implements DecimalFactory { + @Override + public Class getGenericClass() { + return Decimal.class; + } + + @Override + public Decimal ofLong(long value, int precision, int scale) { + return Decimal.apply(value, precision, scale); + } + + @Override + public Decimal ofBigDecimal(BigDecimal value, int precision, int scale) { + return Decimal.apply(value, precision, scale); + } + } + + private static final class StringFactoryImpl implements StringFactory { + @Override + public Class getGenericClass() { + return UTF8String.class; + } + + @Override + public UTF8String ofRow(VarCharVector vector, int rowId) { + int start = vector.getStartOffset(rowId); + int end = vector.getEndOffset(rowId); + + return UTF8String.fromAddress( + null, vector.getDataBuffer().memoryAddress() + start, end - start); + } + + @Override + public UTF8String ofRow(FixedSizeBinaryVector vector, int rowId) { + return UTF8String.fromString(UUIDUtil.convert(vector.get(rowId)).toString()); + } + + @Override + public UTF8String ofRow(IntVector offsetVector, Dictionary dictionary, int rowId) { + byte[] bytes = dictionary.decodeToBinary(offsetVector.get(rowId)).getBytes(); + return UTF8String.fromString(UUIDUtil.convert(bytes).toString()); + } + + @Override + public UTF8String ofBytes(byte[] bytes) { + return UTF8String.fromBytes(bytes); + } + + @Override + public UTF8String ofByteBuffer(ByteBuffer byteBuffer) { + if (byteBuffer.hasArray()) { + return UTF8String.fromBytes( + byteBuffer.array(), + byteBuffer.arrayOffset() + byteBuffer.position(), + byteBuffer.remaining()); + } + byte[] bytes = new byte[byteBuffer.remaining()]; + byteBuffer.get(bytes); + return UTF8String.fromBytes(bytes); + } + } + + private static final class ArrayFactoryImpl + implements ArrayFactory { + @Override + public ArrowColumnVector ofChild(ValueVector childVector) { + return new ArrowColumnVector(childVector); + } + + @Override + public ColumnarArray ofRow(ValueVector vector, ArrowColumnVector childData, int rowId) { + ArrowBuf offsets = vector.getOffsetBuffer(); + int index = rowId * ListVector.OFFSET_WIDTH; + int start = offsets.getInt(index); + int end = offsets.getInt(index + ListVector.OFFSET_WIDTH); + return new ColumnarArray(childData, start, end - start); + } + } + + private static final class StructChildFactoryImpl + implements StructChildFactory { + @Override + public Class getGenericClass() { + return ArrowColumnVector.class; + } + + @Override + public ArrowColumnVector of(ValueVector childVector) { + return new ArrowColumnVector(childVector); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ArrowVectorAccessors.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ArrowVectorAccessors.java new file mode 100644 index 000000000000..4e02dafb3c13 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ArrowVectorAccessors.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import org.apache.iceberg.arrow.vectorized.ArrowVectorAccessor; +import org.apache.iceberg.arrow.vectorized.VectorHolder; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.vectorized.ArrowColumnVector; +import org.apache.spark.sql.vectorized.ColumnarArray; +import org.apache.spark.unsafe.types.UTF8String; + +public class ArrowVectorAccessors { + + private static final ArrowVectorAccessorFactory FACTORY = new ArrowVectorAccessorFactory(); + + static ArrowVectorAccessor + getVectorAccessor(VectorHolder holder) { + return FACTORY.getVectorAccessor(holder); + } + + private ArrowVectorAccessors() {} +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnVectorBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnVectorBuilder.java new file mode 100644 index 000000000000..61616a9f233c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnVectorBuilder.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import org.apache.iceberg.arrow.vectorized.VectorHolder; +import org.apache.iceberg.arrow.vectorized.VectorHolder.ConstantVectorHolder; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.vectorized.ColumnVector; + +class ColumnVectorBuilder { + + public ColumnVector build(VectorHolder holder, int numRows) { + if (holder.isDummy()) { + if (holder instanceof VectorHolder.DeletedVectorHolder) { + return new DeletedColumnVector(Types.BooleanType.get()); + } else if (holder instanceof ConstantVectorHolder) { + ConstantVectorHolder constantHolder = (ConstantVectorHolder) holder; + Type icebergType = constantHolder.icebergType(); + Object value = constantHolder.getConstant(); + return new ConstantColumnVector(icebergType, numRows, value); + } else { + throw new IllegalStateException("Unknown dummy vector holder: " + holder); + } + } else { + return new IcebergArrowColumnVector(holder); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnVectorWithFilter.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnVectorWithFilter.java new file mode 100644 index 000000000000..edaaaeda2515 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnVectorWithFilter.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnVector; +import org.apache.spark.sql.vectorized.ColumnarArray; +import org.apache.spark.sql.vectorized.ColumnarMap; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * A column vector implementation that applies row-level filtering. + * + *

    This class wraps an existing column vector and uses a row ID mapping array to remap row + * indices during data access. Each method that retrieves data for a specific row translates the + * provided row index using the mapping array, effectively filtering the original data to only + * expose the live subset of rows. This approach allows efficient row-level filtering without + * modifying the underlying data. + */ +public class ColumnVectorWithFilter extends ColumnVector { + private final ColumnVector delegate; + private final int[] rowIdMapping; + private volatile ColumnVectorWithFilter[] children = null; + + public ColumnVectorWithFilter(ColumnVector delegate, int[] rowIdMapping) { + super(delegate.dataType()); + this.delegate = delegate; + this.rowIdMapping = rowIdMapping; + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public void closeIfFreeable() { + delegate.closeIfFreeable(); + } + + @Override + public boolean hasNull() { + return delegate.hasNull(); + } + + @Override + public int numNulls() { + // computing the actual number of nulls with rowIdMapping is expensive + // it is OK to overestimate and return the number of nulls in the original vector + return delegate.numNulls(); + } + + @Override + public boolean isNullAt(int rowId) { + return delegate.isNullAt(rowIdMapping[rowId]); + } + + @Override + public boolean getBoolean(int rowId) { + return delegate.getBoolean(rowIdMapping[rowId]); + } + + @Override + public byte getByte(int rowId) { + return delegate.getByte(rowIdMapping[rowId]); + } + + @Override + public short getShort(int rowId) { + return delegate.getShort(rowIdMapping[rowId]); + } + + @Override + public int getInt(int rowId) { + return delegate.getInt(rowIdMapping[rowId]); + } + + @Override + public long getLong(int rowId) { + return delegate.getLong(rowIdMapping[rowId]); + } + + @Override + public float getFloat(int rowId) { + return delegate.getFloat(rowIdMapping[rowId]); + } + + @Override + public double getDouble(int rowId) { + return delegate.getDouble(rowIdMapping[rowId]); + } + + @Override + public ColumnarArray getArray(int rowId) { + return delegate.getArray(rowIdMapping[rowId]); + } + + @Override + public ColumnarMap getMap(int rowId) { + return delegate.getMap(rowIdMapping[rowId]); + } + + @Override + public Decimal getDecimal(int rowId, int precision, int scale) { + return delegate.getDecimal(rowIdMapping[rowId], precision, scale); + } + + @Override + public UTF8String getUTF8String(int rowId) { + return delegate.getUTF8String(rowIdMapping[rowId]); + } + + @Override + public byte[] getBinary(int rowId) { + return delegate.getBinary(rowIdMapping[rowId]); + } + + @Override + public ColumnVector getChild(int ordinal) { + if (children == null) { + synchronized (this) { + if (children == null) { + if (dataType() instanceof StructType) { + StructType structType = (StructType) dataType(); + this.children = new ColumnVectorWithFilter[structType.length()]; + for (int index = 0; index < structType.length(); index++) { + children[index] = new ColumnVectorWithFilter(delegate.getChild(index), rowIdMapping); + } + } else { + throw new UnsupportedOperationException("Unsupported nested type: " + dataType()); + } + } + } + } + + return children[ordinal]; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnarBatchReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnarBatchReader.java new file mode 100644 index 000000000000..38d505d250d0 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnarBatchReader.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.arrow.vectorized.BaseBatchReader; +import org.apache.iceberg.arrow.vectorized.VectorizedArrowReader; +import org.apache.iceberg.parquet.VectorizedReader; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.parquet.column.page.PageReadStore; +import org.apache.parquet.hadoop.metadata.ColumnChunkMetaData; +import org.apache.parquet.hadoop.metadata.ColumnPath; +import org.apache.spark.sql.vectorized.ColumnVector; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +/** + * {@link VectorizedReader} that returns Spark's {@link ColumnarBatch} to support Spark's vectorized + * read path. The {@link ColumnarBatch} returned is created by passing in the Arrow vectors + * populated via delegated read calls to {@linkplain VectorizedArrowReader VectorReader(s)}. + */ +public class ColumnarBatchReader extends BaseBatchReader { + + public ColumnarBatchReader(List> readers) { + super(readers); + } + + @Override + public void setRowGroupInfo( + PageReadStore pageStore, Map metaData) { + super.setRowGroupInfo(pageStore, metaData); + } + + @Override + public final ColumnarBatch read(ColumnarBatch reuse, int numRowsToRead) { + if (reuse == null) { + closeVectors(); + } + + return new ColumnBatchLoader(numRowsToRead).loadDataToColumnBatch(); + } + + private class ColumnBatchLoader { + private final int batchSize; + + ColumnBatchLoader(int numRowsToRead) { + Preconditions.checkArgument( + numRowsToRead > 0, "Invalid number of rows to read: %s", numRowsToRead); + this.batchSize = numRowsToRead; + } + + ColumnarBatch loadDataToColumnBatch() { + ColumnVector[] vectors = readDataToColumnVectors(); + + ColumnarBatch batch = new ColumnarBatch(vectors); + batch.setNumRows(batchSize); + return batch; + } + + ColumnVector[] readDataToColumnVectors() { + ColumnVector[] arrowColumnVectors = new ColumnVector[readers.length]; + + ColumnVectorBuilder columnVectorBuilder = new ColumnVectorBuilder(); + for (int i = 0; i < readers.length; i += 1) { + vectorHolders[i] = readers[i].read(vectorHolders[i], batchSize); + int numRowsInVector = vectorHolders[i].numValues(); + Preconditions.checkState( + numRowsInVector == batchSize, + "Number of rows in the vector %s didn't match expected %s ", + numRowsInVector, + batchSize); + + arrowColumnVectors[i] = columnVectorBuilder.build(vectorHolders[i], numRowsInVector); + } + return arrowColumnVectors; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnarBatchUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnarBatchUtil.java new file mode 100644 index 000000000000..89fe4538bdc2 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ColumnarBatchUtil.java @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import java.util.Arrays; +import java.util.function.Predicate; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.vectorized.ColumnVector; +import org.apache.spark.sql.vectorized.ColumnarBatchRow; + +public class ColumnarBatchUtil { + + private ColumnarBatchUtil() {} + + /** + * Builds a row ID mapping inside a batch to skip deleted rows. + * + *

    +   * Initial state
    +   * Data values: [v0, v1, v2, v3, v4, v5, v6, v7]
    +   * Row ID mapping: [0, 1, 2, 3, 4, 5, 6, 7]
    +   *
    +   * Apply position deletes
    +   * Position deletes: 2, 6
    +   * Row ID mapping: [0, 1, 3, 4, 5, 7, -, -] (6 live records)
    +   *
    +   * Apply equality deletes
    +   * Equality deletes: v1, v2, v3
    +   * Row ID mapping: [0, 4, 5, 7, -, -, -, -] (4 live records)
    +   * 
    + * + * @param columnVectors the array of column vectors for the batch + * @param deletes the delete filter containing delete information + * @param rowStartPosInBatch the starting position of the row in the batch + * @param batchSize the size of the batch + * @return the mapping array and the number of live rows, or {@code null} if nothing is deleted + */ + public static Pair buildRowIdMapping( + ColumnVector[] columnVectors, + DeleteFilter deletes, + long rowStartPosInBatch, + int batchSize) { + if (deletes == null) { + return null; + } + + PositionDeleteIndex deletedPositions = deletes.deletedRowPositions(); + Predicate eqDeleteFilter = deletes.eqDeletedRowFilter(); + ColumnarBatchRow row = new ColumnarBatchRow(columnVectors); + int[] rowIdMapping = new int[batchSize]; + int liveRowId = 0; + + for (int rowId = 0; rowId < batchSize; rowId++) { + long pos = rowStartPosInBatch + rowId; + row.rowId = rowId; + if (isDeleted(pos, row, deletedPositions, eqDeleteFilter)) { + deletes.incrementDeleteCount(); + } else { + rowIdMapping[liveRowId] = rowId; + liveRowId++; + } + } + + return liveRowId == batchSize ? null : Pair.of(rowIdMapping, liveRowId); + } + + /** + * Builds a boolean array to indicate if a row is deleted or not. + * + *
    +   * Initial state
    +   * Data values: [v0, v1, v2, v3, v4, v5, v6, v7]
    +   * Is deleted array: [F, F, F, F, F, F, F, F]
    +   *
    +   * Apply position deletes
    +   * Position deletes: 2, 6
    +   * Is deleted array: [F, F, T, F, F, F, T, F] (6 live records)
    +   *
    +   * Apply equality deletes
    +   * Equality deletes: v1, v2, v3
    +   * Is deleted array: [F, T, T, T, F, F, T, F] (4 live records)
    +   * 
    + * + * @param columnVectors the array of column vectors for the batch. + * @param deletes the delete filter containing information about which rows should be deleted. + * @param rowStartPosInBatch the starting position of the row in the batch, used to calculate the + * absolute position of the rows in the context of the entire dataset. + * @param batchSize the number of rows in the current batch. + * @return an array of boolean values to indicate if a row is deleted or not + */ + public static boolean[] buildIsDeleted( + ColumnVector[] columnVectors, + DeleteFilter deletes, + long rowStartPosInBatch, + int batchSize) { + boolean[] isDeleted = new boolean[batchSize]; + + if (deletes == null) { + return isDeleted; + } + + PositionDeleteIndex deletedPositions = deletes.deletedRowPositions(); + Predicate eqDeleteFilter = deletes.eqDeletedRowFilter(); + ColumnarBatchRow row = new ColumnarBatchRow(columnVectors); + + for (int rowId = 0; rowId < batchSize; rowId++) { + long pos = rowStartPosInBatch + rowId; + row.rowId = rowId; + if (isDeleted(pos, row, deletedPositions, eqDeleteFilter)) { + deletes.incrementDeleteCount(); + isDeleted[rowId] = true; + } + } + + return isDeleted; + } + + private static boolean isDeleted( + long pos, + InternalRow row, + PositionDeleteIndex deletedPositions, + Predicate eqDeleteFilter) { + // use separate if statements to reduce the chance of speculative execution for equality tests + if (deletedPositions != null && deletedPositions.isDeleted(pos)) { + return true; + } + + if (eqDeleteFilter != null && !eqDeleteFilter.test(row)) { + return true; + } + + return false; + } + + /** + * Removes extra column vectors added for processing equality delete filters that are not part of + * the final query output. + * + *

    During query execution, additional columns may be included in the schema to evaluate + * equality delete filters. For example, if the table schema contains columns C1, C2, C3, C4, and + * C5, and the query is 'SELECT C5 FROM table'. While equality delete filters are applied on C3 + * and C4, the processing schema includes C5, C3, and C4. These extra columns (C3 and C4) are + * needed to identify rows to delete but are not included in the final result. + * + *

    This method removes the extra column vectors from the end of column vectors array, ensuring + * only the expected column vectors remain. + * + * @param deletes the delete filter containing delete information. + * @param columnVectors the array of column vectors representing query result data + * @return a new column vectors array with extra column vectors removed, or the original column + * vectors array if no extra column vectors are found + */ + public static ColumnVector[] removeExtraColumns( + DeleteFilter deletes, ColumnVector[] columnVectors) { + int expectedColumnSize = deletes.expectedSchema().columns().size(); + if (columnVectors.length > expectedColumnSize) { + return Arrays.copyOf(columnVectors, expectedColumnSize); + } else { + return columnVectors; + } + } +} diff --git a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ConstantColumnVector.java similarity index 54% rename from spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java rename to spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ConstantColumnVector.java index 5817f2c20a4f..1398a137c1c0 100644 --- a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/ConstantColumnVector.java @@ -18,56 +18,30 @@ */ package org.apache.iceberg.spark.data.vectorized; -import org.apache.comet.shaded.arrow.vector.ValueVector; -import org.apache.comet.vector.CometVector; import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.types.Type; import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.DataType; import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.StructType; import org.apache.spark.sql.vectorized.ColumnVector; import org.apache.spark.sql.vectorized.ColumnarArray; import org.apache.spark.sql.vectorized.ColumnarMap; import org.apache.spark.unsafe.types.UTF8String; -public class CometDeletedColumnVector extends CometVector implements UpdatableDeletedColumnVector { - private boolean[] isDeleted; +class ConstantColumnVector extends ColumnVector { - public CometDeletedColumnVector(boolean[] isDeleted) { - super(SparkSchemaUtil.convert(Types.BooleanType.get()), false); - this.isDeleted = isDeleted; - } - - @Override - public void setValue(boolean[] deleted) { - this.isDeleted = deleted; - } - - boolean[] isDeleted() { - return isDeleted; - } + private final Type icebergType; + private final Object constant; + private final int batchSize; - @Override - public void setNumNulls(int numNulls) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public void setNumValues(int numValues) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public int numValues() { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public ValueVector getValueVector() { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public CometVector slice(int offset, int length) { - throw new UnsupportedOperationException("Not implemented"); + ConstantColumnVector(Type icebergType, int batchSize, Object constant) { + // the type may be unknown for NULL vectors + super(icebergType != null ? SparkSchemaUtil.convert(icebergType) : null); + this.icebergType = icebergType; + this.constant = constant; + this.batchSize = batchSize; } @Override @@ -75,81 +49,93 @@ public void close() {} @Override public boolean hasNull() { - return false; + return constant == null; } @Override public int numNulls() { - return 0; + return constant == null ? batchSize : 0; } @Override public boolean isNullAt(int rowId) { - return false; + return constant == null; } @Override public boolean getBoolean(int rowId) { - return isDeleted[rowId]; + return (boolean) constant; } @Override public byte getByte(int rowId) { - throw new UnsupportedOperationException(); + return (byte) constant; } @Override public short getShort(int rowId) { - throw new UnsupportedOperationException(); + return (short) constant; } @Override public int getInt(int rowId) { - throw new UnsupportedOperationException(); + return (int) constant; } @Override public long getLong(int rowId) { - throw new UnsupportedOperationException(); + return (long) constant; } @Override public float getFloat(int rowId) { - throw new UnsupportedOperationException(); + return (float) constant; } @Override public double getDouble(int rowId) { - throw new UnsupportedOperationException(); + return (double) constant; } @Override public ColumnarArray getArray(int rowId) { - throw new UnsupportedOperationException(); + throw new UnsupportedOperationException(this.getClass() + " does not implement getArray"); } @Override public ColumnarMap getMap(int ordinal) { - throw new UnsupportedOperationException(); + throw new UnsupportedOperationException(this.getClass() + " does not implement getMap"); } @Override public Decimal getDecimal(int rowId, int precision, int scale) { - throw new UnsupportedOperationException(); + return (Decimal) constant; } @Override public UTF8String getUTF8String(int rowId) { - throw new UnsupportedOperationException(); + return (UTF8String) constant; } @Override public byte[] getBinary(int rowId) { - throw new UnsupportedOperationException(); + return (byte[]) constant; } @Override public ColumnVector getChild(int ordinal) { - throw new UnsupportedOperationException(); + InternalRow constantAsRow = (InternalRow) constant; + Object childConstant = constantAsRow.get(ordinal, childType(ordinal)); + return new ConstantColumnVector(childIcebergType(ordinal), batchSize, childConstant); + } + + private Type childIcebergType(int ordinal) { + Types.StructType icebergTypeAsStruct = (Types.StructType) icebergType; + return icebergTypeAsStruct.fields().get(ordinal).type(); + } + + private DataType childType(int ordinal) { + StructType typeAsStruct = (StructType) type; + return typeAsStruct.fields()[ordinal].dataType(); } } diff --git a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/DeletedColumnVector.java similarity index 73% rename from spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java rename to spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/DeletedColumnVector.java index 5817f2c20a4f..fa3bcfdd004e 100644 --- a/spark/v3.4/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/DeletedColumnVector.java @@ -18,22 +18,19 @@ */ package org.apache.iceberg.spark.data.vectorized; -import org.apache.comet.shaded.arrow.vector.ValueVector; -import org.apache.comet.vector.CometVector; import org.apache.iceberg.spark.SparkSchemaUtil; -import org.apache.iceberg.types.Types; +import org.apache.iceberg.types.Type; import org.apache.spark.sql.types.Decimal; import org.apache.spark.sql.vectorized.ColumnVector; import org.apache.spark.sql.vectorized.ColumnarArray; import org.apache.spark.sql.vectorized.ColumnarMap; import org.apache.spark.unsafe.types.UTF8String; -public class CometDeletedColumnVector extends CometVector implements UpdatableDeletedColumnVector { +public class DeletedColumnVector extends ColumnVector implements UpdatableDeletedColumnVector { private boolean[] isDeleted; - public CometDeletedColumnVector(boolean[] isDeleted) { - super(SparkSchemaUtil.convert(Types.BooleanType.get()), false); - this.isDeleted = isDeleted; + public DeletedColumnVector(Type type) { + super(SparkSchemaUtil.convert(type)); } @Override @@ -41,35 +38,6 @@ public void setValue(boolean[] deleted) { this.isDeleted = deleted; } - boolean[] isDeleted() { - return isDeleted; - } - - @Override - public void setNumNulls(int numNulls) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public void setNumValues(int numValues) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public int numValues() { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public ValueVector getValueVector() { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public CometVector slice(int offset, int length) { - throw new UnsupportedOperationException("Not implemented"); - } - @Override public void close() {} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/IcebergArrowColumnVector.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/IcebergArrowColumnVector.java new file mode 100644 index 000000000000..bf6de0e1391f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/IcebergArrowColumnVector.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import org.apache.iceberg.arrow.vectorized.ArrowVectorAccessor; +import org.apache.iceberg.arrow.vectorized.NullabilityHolder; +import org.apache.iceberg.arrow.vectorized.VectorHolder; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.vectorized.ArrowColumnVector; +import org.apache.spark.sql.vectorized.ColumnVector; +import org.apache.spark.sql.vectorized.ColumnarArray; +import org.apache.spark.sql.vectorized.ColumnarMap; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * Implementation of Spark's {@link ColumnVector} interface. The code for this class is heavily + * inspired from Spark's {@link ArrowColumnVector} The main difference is in how nullability checks + * are made in this class by relying on {@link NullabilityHolder} instead of the validity vector in + * the Arrow vector. + */ +public class IcebergArrowColumnVector extends ColumnVector { + + private final ArrowVectorAccessor accessor; + private final NullabilityHolder nullabilityHolder; + + public IcebergArrowColumnVector(VectorHolder holder) { + super(SparkSchemaUtil.convert(holder.icebergType())); + this.nullabilityHolder = holder.nullabilityHolder(); + this.accessor = ArrowVectorAccessors.getVectorAccessor(holder); + } + + protected ArrowVectorAccessor accessor() { + return accessor; + } + + protected NullabilityHolder nullabilityHolder() { + return nullabilityHolder; + } + + @Override + public void close() { + accessor.close(); + } + + @Override + public void closeIfFreeable() { + // If a column vector is writable or constant, it should override this method and do nothing. + // See more details at SPARK-50235, SPARK-50463 (Fixed in Spark 3.5.4) + } + + @Override + public boolean hasNull() { + return nullabilityHolder.hasNulls(); + } + + @Override + public int numNulls() { + return nullabilityHolder.numNulls(); + } + + @Override + public boolean isNullAt(int rowId) { + return nullabilityHolder.isNullAt(rowId) == 1; + } + + @Override + public boolean getBoolean(int rowId) { + return accessor.getBoolean(rowId); + } + + @Override + public byte getByte(int rowId) { + throw new UnsupportedOperationException("Unsupported type - byte"); + } + + @Override + public short getShort(int rowId) { + throw new UnsupportedOperationException("Unsupported type - short"); + } + + @Override + public int getInt(int rowId) { + return accessor.getInt(rowId); + } + + @Override + public long getLong(int rowId) { + return accessor.getLong(rowId); + } + + @Override + public float getFloat(int rowId) { + return accessor.getFloat(rowId); + } + + @Override + public double getDouble(int rowId) { + return accessor.getDouble(rowId); + } + + @Override + public ColumnarArray getArray(int rowId) { + if (isNullAt(rowId)) { + return null; + } + return accessor.getArray(rowId); + } + + @Override + public ColumnarMap getMap(int rowId) { + throw new UnsupportedOperationException("Unsupported type - map"); + } + + @Override + public Decimal getDecimal(int rowId, int precision, int scale) { + if (isNullAt(rowId)) { + return null; + } + return accessor.getDecimal(rowId, precision, scale); + } + + @Override + public UTF8String getUTF8String(int rowId) { + if (isNullAt(rowId)) { + return null; + } + return accessor.getUTF8String(rowId); + } + + @Override + public byte[] getBinary(int rowId) { + if (isNullAt(rowId)) { + return null; + } + return accessor.getBinary(rowId); + } + + @Override + public ArrowColumnVector getChild(int ordinal) { + return accessor.childColumn(ordinal); + } + + public ArrowVectorAccessor + vectorAccessor() { + return accessor; + } +} diff --git a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/RowPositionColumnVector.java similarity index 70% rename from spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java rename to spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/RowPositionColumnVector.java index 5817f2c20a4f..a389cd8286e5 100644 --- a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/CometDeletedColumnVector.java +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/RowPositionColumnVector.java @@ -18,8 +18,6 @@ */ package org.apache.iceberg.spark.data.vectorized; -import org.apache.comet.shaded.arrow.vector.ValueVector; -import org.apache.comet.vector.CometVector; import org.apache.iceberg.spark.SparkSchemaUtil; import org.apache.iceberg.types.Types; import org.apache.spark.sql.types.Decimal; @@ -28,46 +26,13 @@ import org.apache.spark.sql.vectorized.ColumnarMap; import org.apache.spark.unsafe.types.UTF8String; -public class CometDeletedColumnVector extends CometVector implements UpdatableDeletedColumnVector { - private boolean[] isDeleted; +public class RowPositionColumnVector extends ColumnVector { - public CometDeletedColumnVector(boolean[] isDeleted) { - super(SparkSchemaUtil.convert(Types.BooleanType.get()), false); - this.isDeleted = isDeleted; - } - - @Override - public void setValue(boolean[] deleted) { - this.isDeleted = deleted; - } + private final long batchOffsetInFile; - boolean[] isDeleted() { - return isDeleted; - } - - @Override - public void setNumNulls(int numNulls) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public void setNumValues(int numValues) { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public int numValues() { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public ValueVector getValueVector() { - throw new UnsupportedOperationException("Not implemented"); - } - - @Override - public CometVector slice(int offset, int length) { - throw new UnsupportedOperationException("Not implemented"); + RowPositionColumnVector(long batchOffsetInFile) { + super(SparkSchemaUtil.convert(Types.LongType.get())); + this.batchOffsetInFile = batchOffsetInFile; } @Override @@ -90,7 +55,7 @@ public boolean isNullAt(int rowId) { @Override public boolean getBoolean(int rowId) { - return isDeleted[rowId]; + throw new UnsupportedOperationException(); } @Override @@ -110,7 +75,7 @@ public int getInt(int rowId) { @Override public long getLong(int rowId) { - throw new UnsupportedOperationException(); + return batchOffsetInFile + rowId; } @Override diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/UpdatableDeletedColumnVector.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/UpdatableDeletedColumnVector.java new file mode 100644 index 000000000000..99bedc42bf3c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/UpdatableDeletedColumnVector.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +public interface UpdatableDeletedColumnVector { + void setValue(boolean[] isDeleted); +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java new file mode 100644 index 000000000000..4f324239881e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkOrcReaders.java @@ -0,0 +1,492 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.orc.OrcBatchReader; +import org.apache.iceberg.orc.OrcSchemaWithTypeVisitor; +import org.apache.iceberg.orc.OrcValueReader; +import org.apache.iceberg.orc.OrcValueReaders; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.SparkOrcValueReaders; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.orc.TypeDescription; +import org.apache.orc.storage.ql.exec.vector.ListColumnVector; +import org.apache.orc.storage.ql.exec.vector.MapColumnVector; +import org.apache.orc.storage.ql.exec.vector.StructColumnVector; +import org.apache.orc.storage.ql.exec.vector.VectorizedRowBatch; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.vectorized.ColumnVector; +import org.apache.spark.sql.vectorized.ColumnarArray; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.apache.spark.sql.vectorized.ColumnarMap; +import org.apache.spark.unsafe.types.UTF8String; + +public class VectorizedSparkOrcReaders { + + private VectorizedSparkOrcReaders() {} + + public static OrcBatchReader buildReader( + Schema expectedSchema, TypeDescription fileSchema, Map idToConstant) { + Converter converter = + OrcSchemaWithTypeVisitor.visit(expectedSchema, fileSchema, new ReadBuilder(idToConstant)); + + return new OrcBatchReader() { + private long batchOffsetInFile; + + @Override + public ColumnarBatch read(VectorizedRowBatch batch) { + BaseOrcColumnVector cv = + (BaseOrcColumnVector) + converter.convert( + new StructColumnVector(batch.size, batch.cols), + batch.size, + batchOffsetInFile, + batch.selectedInUse, + batch.selected); + ColumnarBatch columnarBatch = + new ColumnarBatch( + IntStream.range(0, expectedSchema.columns().size()) + .mapToObj(cv::getChild) + .toArray(ColumnVector[]::new)); + columnarBatch.setNumRows(batch.size); + return columnarBatch; + } + + @Override + public void setBatchContext(long batchOffsetInFile) { + this.batchOffsetInFile = batchOffsetInFile; + } + }; + } + + private interface Converter { + ColumnVector convert( + org.apache.orc.storage.ql.exec.vector.ColumnVector columnVector, + int batchSize, + long batchOffsetInFile, + boolean isSelectedInUse, + int[] selected); + } + + private static class ReadBuilder extends OrcSchemaWithTypeVisitor { + private final Map idToConstant; + + private ReadBuilder(Map idToConstant) { + this.idToConstant = idToConstant; + } + + @Override + public Converter record( + Types.StructType iStruct, + TypeDescription record, + List names, + List fields) { + return new StructConverter(iStruct, fields, idToConstant); + } + + @Override + public Converter list(Types.ListType iList, TypeDescription array, Converter element) { + return new ArrayConverter(iList, element); + } + + @Override + public Converter map(Types.MapType iMap, TypeDescription map, Converter key, Converter value) { + return new MapConverter(iMap, key, value); + } + + @Override + public Converter primitive(Type.PrimitiveType iPrimitive, TypeDescription primitive) { + final OrcValueReader primitiveValueReader; + switch (primitive.getCategory()) { + case BOOLEAN: + primitiveValueReader = OrcValueReaders.booleans(); + break; + case BYTE: + // Iceberg does not have a byte type. Use int + case SHORT: + // Iceberg does not have a short type. Use int + case DATE: + case INT: + primitiveValueReader = OrcValueReaders.ints(); + break; + case LONG: + primitiveValueReader = OrcValueReaders.longs(); + break; + case FLOAT: + primitiveValueReader = OrcValueReaders.floats(); + break; + case DOUBLE: + primitiveValueReader = OrcValueReaders.doubles(); + break; + case TIMESTAMP_INSTANT: + case TIMESTAMP: + primitiveValueReader = SparkOrcValueReaders.timestampTzs(); + break; + case DECIMAL: + primitiveValueReader = + SparkOrcValueReaders.decimals(primitive.getPrecision(), primitive.getScale()); + break; + case CHAR: + case VARCHAR: + case STRING: + primitiveValueReader = SparkOrcValueReaders.utf8String(); + break; + case BINARY: + primitiveValueReader = + Type.TypeID.UUID == iPrimitive.typeId() + ? SparkOrcValueReaders.uuids() + : OrcValueReaders.bytes(); + break; + default: + throw new IllegalArgumentException("Unhandled type " + primitive); + } + return (columnVector, batchSize, batchOffsetInFile, isSelectedInUse, selected) -> + new PrimitiveOrcColumnVector( + iPrimitive, batchSize, columnVector, primitiveValueReader, isSelectedInUse, selected); + } + } + + private abstract static class BaseOrcColumnVector extends ColumnVector { + private final org.apache.orc.storage.ql.exec.vector.ColumnVector vector; + private final int batchSize; + private final boolean isSelectedInUse; + private final int[] selected; + private Integer numNulls; + + BaseOrcColumnVector( + Type type, + int batchSize, + org.apache.orc.storage.ql.exec.vector.ColumnVector vector, + boolean isSelectedInUse, + int[] selected) { + super(SparkSchemaUtil.convert(type)); + this.vector = vector; + this.batchSize = batchSize; + this.isSelectedInUse = isSelectedInUse; + this.selected = selected; + } + + @Override + public void close() {} + + @Override + public boolean hasNull() { + return !vector.noNulls; + } + + @Override + public int numNulls() { + if (numNulls == null) { + numNulls = numNullsHelper(); + } + return numNulls; + } + + private int numNullsHelper() { + if (vector.isRepeating) { + if (vector.isNull[0]) { + return batchSize; + } else { + return 0; + } + } else if (vector.noNulls) { + return 0; + } else { + int count = 0; + for (int i = 0; i < batchSize; i++) { + if (vector.isNull[i]) { + count++; + } + } + return count; + } + } + + protected int getRowIndex(int rowId) { + int row = isSelectedInUse ? selected[rowId] : rowId; + return vector.isRepeating ? 0 : row; + } + + @Override + public boolean isNullAt(int rowId) { + return vector.isNull[getRowIndex(rowId)]; + } + + @Override + public boolean getBoolean(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public byte getByte(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public short getShort(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public int getInt(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public long getLong(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public float getFloat(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public double getDouble(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public Decimal getDecimal(int rowId, int precision, int scale) { + throw new UnsupportedOperationException(); + } + + @Override + public UTF8String getUTF8String(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] getBinary(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public ColumnarArray getArray(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public ColumnarMap getMap(int rowId) { + throw new UnsupportedOperationException(); + } + + @Override + public ColumnVector getChild(int ordinal) { + throw new UnsupportedOperationException(); + } + } + + private static class PrimitiveOrcColumnVector extends BaseOrcColumnVector { + private final org.apache.orc.storage.ql.exec.vector.ColumnVector vector; + private final OrcValueReader primitiveValueReader; + + PrimitiveOrcColumnVector( + Type type, + int batchSize, + org.apache.orc.storage.ql.exec.vector.ColumnVector vector, + OrcValueReader primitiveValueReader, + boolean isSelectedInUse, + int[] selected) { + super(type, batchSize, vector, isSelectedInUse, selected); + this.vector = vector; + this.primitiveValueReader = primitiveValueReader; + } + + @Override + public boolean getBoolean(int rowId) { + return (Boolean) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + + @Override + public int getInt(int rowId) { + return (Integer) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + + @Override + public long getLong(int rowId) { + return (Long) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + + @Override + public float getFloat(int rowId) { + return (Float) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + + @Override + public double getDouble(int rowId) { + return (Double) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + + @Override + public Decimal getDecimal(int rowId, int precision, int scale) { + // TODO: Is it okay to assume that (precision,scale) parameters == (precision,scale) of the + // decimal type + // and return a Decimal with (precision,scale) of the decimal type? + return (Decimal) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + + @Override + public UTF8String getUTF8String(int rowId) { + return (UTF8String) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + + @Override + public byte[] getBinary(int rowId) { + return (byte[]) primitiveValueReader.read(vector, getRowIndex(rowId)); + } + } + + private static class ArrayConverter implements Converter { + private final Types.ListType listType; + private final Converter elementConverter; + + private ArrayConverter(Types.ListType listType, Converter elementConverter) { + this.listType = listType; + this.elementConverter = elementConverter; + } + + @Override + public ColumnVector convert( + org.apache.orc.storage.ql.exec.vector.ColumnVector vector, + int batchSize, + long batchOffsetInFile, + boolean isSelectedInUse, + int[] selected) { + ListColumnVector listVector = (ListColumnVector) vector; + ColumnVector elementVector = + elementConverter.convert(listVector.child, batchSize, batchOffsetInFile, false, null); + + return new BaseOrcColumnVector(listType, batchSize, vector, isSelectedInUse, selected) { + @Override + public ColumnarArray getArray(int rowId) { + int index = getRowIndex(rowId); + return new ColumnarArray( + elementVector, (int) listVector.offsets[index], (int) listVector.lengths[index]); + } + }; + } + } + + private static class MapConverter implements Converter { + private final Types.MapType mapType; + private final Converter keyConverter; + private final Converter valueConverter; + + private MapConverter(Types.MapType mapType, Converter keyConverter, Converter valueConverter) { + this.mapType = mapType; + this.keyConverter = keyConverter; + this.valueConverter = valueConverter; + } + + @Override + public ColumnVector convert( + org.apache.orc.storage.ql.exec.vector.ColumnVector vector, + int batchSize, + long batchOffsetInFile, + boolean isSelectedInUse, + int[] selected) { + MapColumnVector mapVector = (MapColumnVector) vector; + ColumnVector keyVector = + keyConverter.convert(mapVector.keys, batchSize, batchOffsetInFile, false, null); + ColumnVector valueVector = + valueConverter.convert(mapVector.values, batchSize, batchOffsetInFile, false, null); + + return new BaseOrcColumnVector(mapType, batchSize, vector, isSelectedInUse, selected) { + @Override + public ColumnarMap getMap(int rowId) { + int index = getRowIndex(rowId); + return new ColumnarMap( + keyVector, + valueVector, + (int) mapVector.offsets[index], + (int) mapVector.lengths[index]); + } + }; + } + } + + private static class StructConverter implements Converter { + private final Types.StructType structType; + private final List fieldConverters; + private final Map idToConstant; + + private StructConverter( + Types.StructType structType, + List fieldConverters, + Map idToConstant) { + this.structType = structType; + this.fieldConverters = fieldConverters; + this.idToConstant = idToConstant; + } + + @Override + public ColumnVector convert( + org.apache.orc.storage.ql.exec.vector.ColumnVector vector, + int batchSize, + long batchOffsetInFile, + boolean isSelectedInUse, + int[] selected) { + StructColumnVector structVector = (StructColumnVector) vector; + List fields = structType.fields(); + List fieldVectors = Lists.newArrayListWithExpectedSize(fields.size()); + for (int pos = 0, vectorIndex = 0; pos < fields.size(); pos += 1) { + Types.NestedField field = fields.get(pos); + if (idToConstant.containsKey(field.fieldId())) { + fieldVectors.add( + new ConstantColumnVector(field.type(), batchSize, idToConstant.get(field.fieldId()))); + } else if (field.equals(MetadataColumns.ROW_POSITION)) { + fieldVectors.add(new RowPositionColumnVector(batchOffsetInFile)); + } else if (field.equals(MetadataColumns.IS_DELETED)) { + DeletedColumnVector deletedVector = new DeletedColumnVector(field.type()); + deletedVector.setValue(new boolean[batchSize]); + fieldVectors.add(deletedVector); + } else if (field.type().equals(Types.UnknownType.get())) { + fieldVectors.add(new ConstantColumnVector(field.type(), batchSize, null)); + } else { + fieldVectors.add( + fieldConverters + .get(vectorIndex) + .convert( + structVector.fields[vectorIndex], + batchSize, + batchOffsetInFile, + isSelectedInUse, + selected)); + vectorIndex++; + } + } + + return new BaseOrcColumnVector(structType, batchSize, vector, isSelectedInUse, selected) { + @Override + public ColumnVector getChild(int ordinal) { + return fieldVectors.get(ordinal); + } + }; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java new file mode 100644 index 000000000000..a80249ee7c36 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/data/vectorized/VectorizedSparkParquetReaders.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.NullCheckingForGet; +import org.apache.iceberg.Schema; +import org.apache.iceberg.arrow.ArrowAllocation; +import org.apache.iceberg.arrow.vectorized.VectorizedReaderBuilder; +import org.apache.iceberg.parquet.TypeWithSchemaVisitor; +import org.apache.iceberg.parquet.VectorizedReader; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.parquet.schema.MessageType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class VectorizedSparkParquetReaders { + + private static final Logger LOG = LoggerFactory.getLogger(VectorizedSparkParquetReaders.class); + private static final String ENABLE_UNSAFE_MEMORY_ACCESS = "arrow.enable_unsafe_memory_access"; + private static final String ENABLE_UNSAFE_MEMORY_ACCESS_ENV = "ARROW_ENABLE_UNSAFE_MEMORY_ACCESS"; + private static final String ENABLE_NULL_CHECK_FOR_GET = "arrow.enable_null_check_for_get"; + private static final String ENABLE_NULL_CHECK_FOR_GET_ENV = "ARROW_ENABLE_NULL_CHECK_FOR_GET"; + + static { + try { + enableUnsafeMemoryAccess(); + disableNullCheckForGet(); + } catch (Exception e) { + LOG.warn("Couldn't set Arrow properties, which may impact read performance", e); + } + } + + private VectorizedSparkParquetReaders() {} + + public static ColumnarBatchReader buildReader( + Schema expectedSchema, + MessageType fileSchema, + Map idToConstant, + BufferAllocator bufferAllocator) { + return (ColumnarBatchReader) + TypeWithSchemaVisitor.visit( + expectedSchema.asStruct(), + fileSchema, + new ReaderBuilder( + expectedSchema, + fileSchema, + NullCheckingForGet.NULL_CHECKING_ENABLED, + idToConstant, + ColumnarBatchReader::new, + bufferAllocator)); + } + + public static ColumnarBatchReader buildReader( + Schema expectedSchema, MessageType fileSchema, Map idToConstant) { + return buildReader(expectedSchema, fileSchema, idToConstant, ArrowAllocation.rootAllocator()); + } + + // enables unsafe memory access to avoid costly checks to see if index is within bounds + // as long as it is not configured explicitly (see BoundsChecking in Arrow) + private static void enableUnsafeMemoryAccess() { + String value = confValue(ENABLE_UNSAFE_MEMORY_ACCESS, ENABLE_UNSAFE_MEMORY_ACCESS_ENV); + if (value == null) { + LOG.info("Enabling {}", ENABLE_UNSAFE_MEMORY_ACCESS); + System.setProperty(ENABLE_UNSAFE_MEMORY_ACCESS, "true"); + } else { + LOG.info("Unsafe memory access was configured explicitly: {}", value); + } + } + + // disables expensive null checks for every get call in favor of Iceberg nullability + // as long as it is not configured explicitly (see NullCheckingForGet in Arrow) + private static void disableNullCheckForGet() { + String value = confValue(ENABLE_NULL_CHECK_FOR_GET, ENABLE_NULL_CHECK_FOR_GET_ENV); + if (value == null) { + LOG.info("Disabling {}", ENABLE_NULL_CHECK_FOR_GET); + System.setProperty(ENABLE_NULL_CHECK_FOR_GET, "false"); + } else { + LOG.info("Null checking for get calls was configured explicitly: {}", value); + } + } + + private static String confValue(String propName, String envName) { + String propValue = System.getProperty(propName); + if (propValue != null) { + return propValue; + } + + return System.getenv(envName); + } + + private static class ReaderBuilder extends VectorizedReaderBuilder { + + ReaderBuilder( + Schema expectedSchema, + MessageType parquetSchema, + boolean setArrowValidityVector, + Map idToConstant, + Function>, VectorizedReader> readerFactory, + BufferAllocator bufferAllocator) { + super( + expectedSchema, + parquetSchema, + setArrowValidityVector, + idToConstant, + readerFactory, + SparkUtil::internalToSpark, + bufferAllocator); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/BaseScalarFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/BaseScalarFunction.java new file mode 100644 index 000000000000..5ec44f314180 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/BaseScalarFunction.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import org.apache.spark.sql.connector.catalog.functions.ScalarFunction; + +abstract class BaseScalarFunction implements ScalarFunction { + @Override + public int hashCode() { + return canonicalName().hashCode(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } else if (!(other instanceof ScalarFunction)) { + return false; + } + + ScalarFunction that = (ScalarFunction) other; + return canonicalName().equals(that.canonicalName()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/BucketFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/BucketFunction.java new file mode 100644 index 000000000000..f31de7370533 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/BucketFunction.java @@ -0,0 +1,366 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.Set; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.util.BucketUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.connector.catalog.functions.Reducer; +import org.apache.spark.sql.connector.catalog.functions.ReducibleFunction; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; +import org.apache.spark.sql.types.BinaryType; +import org.apache.spark.sql.types.ByteType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.DateType; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.DecimalType; +import org.apache.spark.sql.types.IntegerType; +import org.apache.spark.sql.types.LongType; +import org.apache.spark.sql.types.ShortType; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.TimestampNTZType; +import org.apache.spark.sql.types.TimestampType; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * A Spark function implementation for the Iceberg bucket transform. + * + *

    Example usage: {@code SELECT system.bucket(128, 'abc')}, which returns the bucket 122. + * + *

    Note that for performance reasons, the given input number of buckets is not validated in the + * implementations used in code-gen. The number of buckets must be positive to give meaningful + * results. + */ +public class BucketFunction implements UnboundFunction { + + private static final int NUM_BUCKETS_ORDINAL = 0; + private static final int VALUE_ORDINAL = 1; + + private static final Set SUPPORTED_NUM_BUCKETS_TYPES = + ImmutableSet.of(DataTypes.ByteType, DataTypes.ShortType, DataTypes.IntegerType); + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public BoundFunction bind(StructType inputType) { + if (inputType.size() != 2) { + throw new UnsupportedOperationException( + "Wrong number of inputs (expected numBuckets and value)"); + } + + StructField numBucketsField = inputType.fields()[NUM_BUCKETS_ORDINAL]; + StructField valueField = inputType.fields()[VALUE_ORDINAL]; + + if (!SUPPORTED_NUM_BUCKETS_TYPES.contains(numBucketsField.dataType())) { + throw new UnsupportedOperationException( + "Expected number of buckets to be tinyint, shortint or int"); + } + + DataType type = valueField.dataType(); + if (type instanceof DateType) { + return new BucketInt(type); + } else if (type instanceof ByteType + || type instanceof ShortType + || type instanceof IntegerType) { + return new BucketInt(DataTypes.IntegerType); + } else if (type instanceof LongType) { + return new BucketLong(type); + } else if (type instanceof TimestampType) { + return new BucketLong(type); + } else if (type instanceof TimestampNTZType) { + return new BucketLong(type); + } else if (type instanceof DecimalType) { + return new BucketDecimal(type); + } else if (type instanceof StringType) { + return new BucketString(); + } else if (type instanceof BinaryType) { + return new BucketBinary(); + } else { + throw new UnsupportedOperationException( + "Expected column to be date, tinyint, smallint, int, bigint, decimal, timestamp, string, or binary"); + } + } + + @Override + public String description() { + return name() + + "(numBuckets, col) - Call Iceberg's bucket transform\n" + + " numBuckets :: number of buckets to divide the rows into, e.g. bucket(100, 34) -> 79 (must be a tinyint, smallint, or int)\n" + + " col :: column to bucket (must be a date, integer, long, timestamp, decimal, string, or binary)"; + } + + @Override + public String name() { + return "bucket"; + } + + public abstract static class BucketBase extends BaseScalarFunction + implements ReducibleFunction { + public static int apply(int numBuckets, int hashedValue) { + return (hashedValue & Integer.MAX_VALUE) % numBuckets; + } + + @Override + public String name() { + return "bucket"; + } + + @Override + public DataType resultType() { + return DataTypes.IntegerType; + } + + protected int gcd(int num1, int num2) { + return BigInteger.valueOf(num1).gcd(BigInteger.valueOf(num2)).intValue(); + } + + @Override + public Reducer reducer( + int thisNumBuckets, ReducibleFunction otherBucketFunction, int otherNumBuckets) { + + if (otherBucketFunction instanceof BucketBase) { + int commonDivisor = gcd(thisNumBuckets, otherNumBuckets); + if (commonDivisor > 1 && commonDivisor != thisNumBuckets) { + return new BucketReducer(commonDivisor); + } + } + + return null; + } + } + + // Used for both int and date - tinyint and smallint are upcasted to int by Spark. + public static class BucketInt extends BucketBase { + private final DataType sqlType; + + // magic method used in codegen + public static int invoke(int numBuckets, int value) { + return apply(numBuckets, hash(value)); + } + + // Visible for testing + public static int hash(int value) { + return BucketUtil.hash(value); + } + + public BucketInt(DataType sqlType) { + this.sqlType = sqlType; + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, sqlType}; + } + + @Override + public String canonicalName() { + return String.format("iceberg.bucket(%s)", sqlType.catalogString()); + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in the code-generated versions. + if (input.isNullAt(NUM_BUCKETS_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(NUM_BUCKETS_ORDINAL), input.getInt(VALUE_ORDINAL)); + } + } + } + + // Used for both BigInt and Timestamp + public static class BucketLong extends BucketBase { + private final DataType sqlType; + + // magic function for usage with codegen - needs to be static + public static int invoke(int numBuckets, long value) { + return apply(numBuckets, hash(value)); + } + + // Visible for testing + public static int hash(long value) { + return BucketUtil.hash(value); + } + + public BucketLong(DataType sqlType) { + this.sqlType = sqlType; + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, sqlType}; + } + + @Override + public String canonicalName() { + return String.format("iceberg.bucket(%s)", sqlType.catalogString()); + } + + @Override + public Integer produceResult(InternalRow input) { + if (input.isNullAt(NUM_BUCKETS_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(NUM_BUCKETS_ORDINAL), input.getLong(VALUE_ORDINAL)); + } + } + } + + public static class BucketString extends BucketBase { + // magic function for usage with codegen + public static Integer invoke(int numBuckets, UTF8String value) { + if (value == null) { + return null; + } + + return apply(numBuckets, hash(value.getBytes())); + } + + // Visible for testing + public static int hash(byte[] value) { + return BucketUtil.hash(value); + } + + // Visible for testing + public static int hash(String value) { + return BucketUtil.hash(value); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.StringType}; + } + + @Override + public String canonicalName() { + return "iceberg.bucket(string)"; + } + + @Override + public Integer produceResult(InternalRow input) { + if (input.isNullAt(NUM_BUCKETS_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(NUM_BUCKETS_ORDINAL), input.getUTF8String(VALUE_ORDINAL)); + } + } + } + + public static class BucketBinary extends BucketBase { + public static Integer invoke(int numBuckets, byte[] value) { + if (value == null) { + return null; + } + + return apply(numBuckets, hash(ByteBuffer.wrap(value))); + } + + // Visible for testing + public static int hash(ByteBuffer value) { + return BucketUtil.hash(value); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.BinaryType}; + } + + @Override + public Integer produceResult(InternalRow input) { + if (input.isNullAt(NUM_BUCKETS_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(NUM_BUCKETS_ORDINAL), input.getBinary(VALUE_ORDINAL)); + } + } + + @Override + public String canonicalName() { + return "iceberg.bucket(binary)"; + } + } + + public static class BucketDecimal extends BucketBase { + private final DataType sqlType; + private final int precision; + private final int scale; + + // magic method used in codegen + public static Integer invoke(int numBuckets, Decimal value) { + if (value == null) { + return null; + } + + return apply(numBuckets, hash(value.toJavaBigDecimal())); + } + + // Visible for testing + public static int hash(BigDecimal value) { + return BucketUtil.hash(value); + } + + public BucketDecimal(DataType sqlType) { + this.sqlType = sqlType; + this.precision = ((DecimalType) sqlType).precision(); + this.scale = ((DecimalType) sqlType).scale(); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, sqlType}; + } + + @Override + public Integer produceResult(InternalRow input) { + if (input.isNullAt(NUM_BUCKETS_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + int numBuckets = input.getInt(NUM_BUCKETS_ORDINAL); + Decimal value = input.getDecimal(VALUE_ORDINAL, precision, scale); + return invoke(numBuckets, value); + } + } + + @Override + public String canonicalName() { + return "iceberg.bucket(decimal)"; + } + } + + static class BucketReducer implements Reducer, Serializable { + private int commonDivisor; + + BucketReducer(int commonDivisor) { + this.commonDivisor = commonDivisor; + } + + @Override + public Integer reduce(Integer bucketNo) { + return bucketNo % this.commonDivisor; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/DaysFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/DaysFunction.java new file mode 100644 index 000000000000..55d4a3755a9d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/DaysFunction.java @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.connector.catalog.functions.Reducer; +import org.apache.spark.sql.connector.catalog.functions.ReducibleFunction; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.DateType; +import org.apache.spark.sql.types.TimestampNTZType; +import org.apache.spark.sql.types.TimestampType; + +/** + * A Spark function implementation for the Iceberg day transform. + * + *

    Example usage: {@code SELECT system.days('source_col')}. + */ +public class DaysFunction extends UnaryUnboundFunction { + + @Override + protected BoundFunction doBind(DataType valueType) { + if (valueType instanceof DateType) { + return new DateToDaysFunction(); + } else if (valueType instanceof TimestampType) { + return new TimestampToDaysFunction(); + } else if (valueType instanceof TimestampNTZType) { + return new TimestampNtzToDaysFunction(); + } else { + throw new UnsupportedOperationException( + "Expected value to be date or timestamp: " + valueType.catalogString()); + } + } + + @Override + public String description() { + return name() + + "(col) - Call Iceberg's day transform\n" + + " col :: source column (must be date or timestamp)"; + } + + @Override + public String name() { + return "days"; + } + + protected abstract static class BaseToDaysFunction extends BaseScalarFunction + implements ReducibleFunction { + @Override + public String name() { + return "days"; + } + + @Override + public DataType resultType() { + return DataTypes.DateType; + } + + @Override + public Reducer reducer(ReducibleFunction otherFunction) { + return null; + } + } + + // Spark and Iceberg internal representations of dates match so no transformation is required + public static class DateToDaysFunction extends BaseToDaysFunction { + // magic method used in codegen + public static int invoke(int days) { + return days; + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.DateType}; + } + + @Override + public String canonicalName() { + return "iceberg.days(date)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : input.getInt(0); + } + } + + public static class TimestampToDaysFunction extends BaseToDaysFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToDays(micros); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampType}; + } + + @Override + public String canonicalName() { + return "iceberg.days(timestamp)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } + + public static class TimestampNtzToDaysFunction extends BaseToDaysFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToDays(micros); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampNTZType}; + } + + @Override + public String canonicalName() { + return "iceberg.days(timestamp_ntz)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/HoursFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/HoursFunction.java new file mode 100644 index 000000000000..3261a908ec95 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/HoursFunction.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import java.io.Serializable; +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.connector.catalog.functions.Reducer; +import org.apache.spark.sql.connector.catalog.functions.ReducibleFunction; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.TimestampNTZType; +import org.apache.spark.sql.types.TimestampType; + +/** + * A Spark function implementation for the Iceberg hour transform. + * + *

    Example usage: {@code SELECT system.hours('source_col')}. + */ +public class HoursFunction extends UnaryUnboundFunction { + + @Override + protected BoundFunction doBind(DataType valueType) { + if (valueType instanceof TimestampType) { + return new TimestampToHoursFunction(); + } else if (valueType instanceof TimestampNTZType) { + return new TimestampNtzToHoursFunction(); + } else { + throw new UnsupportedOperationException( + "Expected value to be timestamp: " + valueType.catalogString()); + } + } + + @Override + public String description() { + return name() + + "(col) - Call Iceberg's hour transform\n" + + " col :: source column (must be timestamp)"; + } + + @Override + public String name() { + return "hours"; + } + + public abstract static class BaseToHourFunction extends BaseScalarFunction + implements ReducibleFunction { + @Override + public Reducer reducer(ReducibleFunction otherBucketFunction) { + if (otherBucketFunction instanceof DaysFunction.BaseToDaysFunction) { + return new HourToDaysReducer(); + } + return null; + } + } + + public static class TimestampToHoursFunction extends BaseToHourFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToHours(micros); + } + + @Override + public String name() { + return "hours"; + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampType}; + } + + @Override + public DataType resultType() { + return DataTypes.IntegerType; + } + + @Override + public String canonicalName() { + return "iceberg.hours(timestamp)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } + + public static class TimestampNtzToHoursFunction extends BaseToHourFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToHours(micros); + } + + @Override + public String name() { + return "hours"; + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampNTZType}; + } + + @Override + public DataType resultType() { + return DataTypes.IntegerType; + } + + @Override + public String canonicalName() { + return "iceberg.hours(timestamp_ntz)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } + + public static class HourToDaysReducer implements Reducer, Serializable { + @Override + public Integer reduce(Integer hour) { + return DateTimeUtil.hoursToDays(hour); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/IcebergVersionFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/IcebergVersionFunction.java new file mode 100644 index 000000000000..689a0f4cb4df --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/IcebergVersionFunction.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import org.apache.iceberg.IcebergBuild; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * A function for use in SQL that returns the current Iceberg version, e.g. {@code SELECT + * system.iceberg_version()} will return a String such as "0.14.0" or "0.15.0-SNAPSHOT" + */ +public class IcebergVersionFunction implements UnboundFunction { + @Override + public BoundFunction bind(StructType inputType) { + if (inputType.fields().length > 0) { + throw new UnsupportedOperationException( + String.format("Cannot bind: %s does not accept arguments", name())); + } + + return new IcebergVersionFunctionImpl(); + } + + @Override + public String description() { + return name() + " - Returns the runtime Iceberg version"; + } + + @Override + public String name() { + return "iceberg_version"; + } + + // Implementing class cannot be private, otherwise Spark is unable to access the static invoke + // function during code-gen and calling the function fails + static class IcebergVersionFunctionImpl extends BaseScalarFunction { + private static final UTF8String VERSION = UTF8String.fromString(IcebergBuild.version()); + + // magic function used in code-gen. must be named `invoke`. + public static UTF8String invoke() { + return VERSION; + } + + @Override + public DataType[] inputTypes() { + return new DataType[0]; + } + + @Override + public DataType resultType() { + return DataTypes.StringType; + } + + @Override + public boolean isResultNullable() { + return false; + } + + @Override + public String canonicalName() { + return "iceberg." + name(); + } + + @Override + public String name() { + return "iceberg_version"; + } + + @Override + public UTF8String produceResult(InternalRow input) { + return invoke(); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/MonthsFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/MonthsFunction.java new file mode 100644 index 000000000000..353d850f86e2 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/MonthsFunction.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.DateType; +import org.apache.spark.sql.types.TimestampNTZType; +import org.apache.spark.sql.types.TimestampType; + +/** + * A Spark function implementation for the Iceberg month transform. + * + *

    Example usage: {@code SELECT system.months('source_col')}. + */ +public class MonthsFunction extends UnaryUnboundFunction { + + @Override + protected BoundFunction doBind(DataType valueType) { + if (valueType instanceof DateType) { + return new DateToMonthsFunction(); + } else if (valueType instanceof TimestampType) { + return new TimestampToMonthsFunction(); + } else if (valueType instanceof TimestampNTZType) { + return new TimestampNtzToMonthsFunction(); + } else { + throw new UnsupportedOperationException( + "Expected value to be date or timestamp: " + valueType.catalogString()); + } + } + + @Override + public String description() { + return name() + + "(col) - Call Iceberg's month transform\n" + + " col :: source column (must be date or timestamp)"; + } + + @Override + public String name() { + return "months"; + } + + private abstract static class BaseToMonthsFunction extends BaseScalarFunction { + @Override + public String name() { + return "months"; + } + + @Override + public DataType resultType() { + return DataTypes.IntegerType; + } + } + + public static class DateToMonthsFunction extends BaseToMonthsFunction { + // magic method used in codegen + public static int invoke(int days) { + return DateTimeUtil.daysToMonths(days); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.DateType}; + } + + @Override + public String canonicalName() { + return "iceberg.months(date)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getInt(0)); + } + } + + public static class TimestampToMonthsFunction extends BaseToMonthsFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToMonths(micros); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampType}; + } + + @Override + public String canonicalName() { + return "iceberg.months(timestamp)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } + + public static class TimestampNtzToMonthsFunction extends BaseToMonthsFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToMonths(micros); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampNTZType}; + } + + @Override + public String canonicalName() { + return "iceberg.months(timestamp_ntz)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/SparkFunctions.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/SparkFunctions.java new file mode 100644 index 000000000000..6d9cadec576d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/SparkFunctions.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; + +public class SparkFunctions { + + private SparkFunctions() {} + + private static final Map FUNCTIONS = + ImmutableMap.of( + "iceberg_version", new IcebergVersionFunction(), + "years", new YearsFunction(), + "months", new MonthsFunction(), + "days", new DaysFunction(), + "hours", new HoursFunction(), + "bucket", new BucketFunction(), + "truncate", new TruncateFunction()); + + private static final Map, UnboundFunction> CLASS_TO_FUNCTIONS = + ImmutableMap.of( + YearsFunction.class, new YearsFunction(), + MonthsFunction.class, new MonthsFunction(), + DaysFunction.class, new DaysFunction(), + HoursFunction.class, new HoursFunction(), + BucketFunction.class, new BucketFunction(), + TruncateFunction.class, new TruncateFunction()); + + private static final List FUNCTION_NAMES = ImmutableList.copyOf(FUNCTIONS.keySet()); + + // Functions that are added to all Iceberg catalogs should be accessed with the `system` + // namespace. They can also be accessed with no namespace at all if qualified with the + // catalog name, e.g. my_hadoop_catalog.iceberg_version(). + // As namespace resolution is handled by those rules in BaseCatalog, a list of names + // alone is returned. + public static List list() { + return FUNCTION_NAMES; + } + + public static UnboundFunction load(String name) { + // function resolution is case-insensitive to match the existing Spark behavior for functions + return FUNCTIONS.get(name.toLowerCase(Locale.ROOT)); + } + + public static UnboundFunction loadFunctionByClass(Class functionClass) { + Class declaringClass = functionClass.getDeclaringClass(); + if (declaringClass == null) { + return null; + } + + return CLASS_TO_FUNCTIONS.get(declaringClass); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/TruncateFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/TruncateFunction.java new file mode 100644 index 000000000000..662033718fb1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/TruncateFunction.java @@ -0,0 +1,357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.Locale; +import java.util.Set; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.util.BinaryUtil; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.TruncateUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; +import org.apache.spark.sql.types.BinaryType; +import org.apache.spark.sql.types.ByteType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.DecimalType; +import org.apache.spark.sql.types.IntegerType; +import org.apache.spark.sql.types.LongType; +import org.apache.spark.sql.types.ShortType; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * A Spark function implementation for the Iceberg truncate transform. + * + *

    Example usage: {@code SELECT system.truncate(1, 'abc')}, which returns the String 'a'. + * + *

    Note that for performance reasons, the given input width is not validated in the + * implementations used in code-gen. The width must remain non-negative to give meaningful results. + */ +public class TruncateFunction implements UnboundFunction { + + private static final int WIDTH_ORDINAL = 0; + private static final int VALUE_ORDINAL = 1; + + private static final Set SUPPORTED_WIDTH_TYPES = + ImmutableSet.of(DataTypes.ByteType, DataTypes.ShortType, DataTypes.IntegerType); + + @Override + public BoundFunction bind(StructType inputType) { + if (inputType.size() != 2) { + throw new UnsupportedOperationException("Wrong number of inputs (expected width and value)"); + } + + StructField widthField = inputType.fields()[WIDTH_ORDINAL]; + StructField valueField = inputType.fields()[VALUE_ORDINAL]; + + if (!SUPPORTED_WIDTH_TYPES.contains(widthField.dataType())) { + throw new UnsupportedOperationException( + "Expected truncation width to be tinyint, shortint or int"); + } + + DataType valueType = valueField.dataType(); + if (valueType instanceof ByteType) { + return new TruncateTinyInt(); + } else if (valueType instanceof ShortType) { + return new TruncateSmallInt(); + } else if (valueType instanceof IntegerType) { + return new TruncateInt(); + } else if (valueType instanceof LongType) { + return new TruncateBigInt(); + } else if (valueType instanceof DecimalType) { + return new TruncateDecimal( + ((DecimalType) valueType).precision(), ((DecimalType) valueType).scale()); + } else if (valueType instanceof StringType) { + return new TruncateString(); + } else if (valueType instanceof BinaryType) { + return new TruncateBinary(); + } else { + throw new UnsupportedOperationException( + "Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + } + } + + @Override + public String description() { + return name() + + "(width, col) - Call Iceberg's truncate transform\n" + + " width :: width for truncation, e.g. truncate(10, 255) -> 250 (must be an integer)\n" + + " col :: column to truncate (must be an integer, decimal, string, or binary)"; + } + + @Override + public String name() { + return "truncate"; + } + + public abstract static class TruncateBase extends BaseScalarFunction { + @Override + public String name() { + return "truncate"; + } + } + + public static class TruncateTinyInt extends TruncateBase { + public static byte invoke(int width, byte value) { + return TruncateUtil.truncateByte(width, value); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.ByteType}; + } + + @Override + public DataType resultType() { + return DataTypes.ByteType; + } + + @Override + public String canonicalName() { + return "iceberg.truncate(tinyint)"; + } + + @Override + public Byte produceResult(InternalRow input) { + if (input.isNullAt(WIDTH_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(WIDTH_ORDINAL), input.getByte(VALUE_ORDINAL)); + } + } + } + + public static class TruncateSmallInt extends TruncateBase { + // magic method used in codegen + public static short invoke(int width, short value) { + return TruncateUtil.truncateShort(width, value); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.ShortType}; + } + + @Override + public DataType resultType() { + return DataTypes.ShortType; + } + + @Override + public String canonicalName() { + return "iceberg.truncate(smallint)"; + } + + @Override + public Short produceResult(InternalRow input) { + if (input.isNullAt(WIDTH_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(WIDTH_ORDINAL), input.getShort(VALUE_ORDINAL)); + } + } + } + + public static class TruncateInt extends TruncateBase { + // magic method used in codegen + public static int invoke(int width, int value) { + return TruncateUtil.truncateInt(width, value); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.IntegerType}; + } + + @Override + public DataType resultType() { + return DataTypes.IntegerType; + } + + @Override + public String canonicalName() { + return "iceberg.truncate(int)"; + } + + @Override + public Integer produceResult(InternalRow input) { + if (input.isNullAt(WIDTH_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(WIDTH_ORDINAL), input.getInt(VALUE_ORDINAL)); + } + } + } + + public static class TruncateBigInt extends TruncateBase { + // magic function for usage with codegen + public static long invoke(int width, long value) { + return TruncateUtil.truncateLong(width, value); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.LongType}; + } + + @Override + public DataType resultType() { + return DataTypes.LongType; + } + + @Override + public String canonicalName() { + return "iceberg.truncate(bigint)"; + } + + @Override + public Long produceResult(InternalRow input) { + if (input.isNullAt(WIDTH_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(WIDTH_ORDINAL), input.getLong(VALUE_ORDINAL)); + } + } + } + + public static class TruncateString extends TruncateBase { + // magic function for usage with codegen + public static UTF8String invoke(int width, UTF8String value) { + if (value == null) { + return null; + } + + return value.substring(0, width); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.StringType}; + } + + @Override + public DataType resultType() { + return DataTypes.StringType; + } + + @Override + public String canonicalName() { + return "iceberg.truncate(string)"; + } + + @Override + public UTF8String produceResult(InternalRow input) { + if (input.isNullAt(WIDTH_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(WIDTH_ORDINAL), input.getUTF8String(VALUE_ORDINAL)); + } + } + } + + public static class TruncateBinary extends TruncateBase { + // magic method used in codegen + public static byte[] invoke(int width, byte[] value) { + if (value == null) { + return null; + } + + return ByteBuffers.toByteArray( + BinaryUtil.truncateBinaryUnsafe(ByteBuffer.wrap(value), width)); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.BinaryType}; + } + + @Override + public DataType resultType() { + return DataTypes.BinaryType; + } + + @Override + public String canonicalName() { + return "iceberg.truncate(binary)"; + } + + @Override + public byte[] produceResult(InternalRow input) { + if (input.isNullAt(WIDTH_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + return invoke(input.getInt(WIDTH_ORDINAL), input.getBinary(VALUE_ORDINAL)); + } + } + } + + public static class TruncateDecimal extends TruncateBase { + private final int precision; + private final int scale; + + public TruncateDecimal(int precision, int scale) { + this.precision = precision; + this.scale = scale; + } + + // magic method used in codegen + public static Decimal invoke(int width, Decimal value) { + if (value == null) { + return null; + } + + return Decimal.apply( + TruncateUtil.truncateDecimal(BigInteger.valueOf(width), value.toJavaBigDecimal())); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.IntegerType, DataTypes.createDecimalType(precision, scale)}; + } + + @Override + public DataType resultType() { + return DataTypes.createDecimalType(precision, scale); + } + + @Override + public String canonicalName() { + return String.format(Locale.ROOT, "iceberg.truncate(decimal(%d,%d))", precision, scale); + } + + @Override + public Decimal produceResult(InternalRow input) { + if (input.isNullAt(WIDTH_ORDINAL) || input.isNullAt(VALUE_ORDINAL)) { + return null; + } else { + int width = input.getInt(WIDTH_ORDINAL); + Decimal value = input.getDecimal(VALUE_ORDINAL, precision, scale); + return invoke(width, value); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/UnaryUnboundFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/UnaryUnboundFunction.java new file mode 100644 index 000000000000..9003c68919dc --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/UnaryUnboundFunction.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.StructType; + +/** An unbound function that accepts only one argument */ +abstract class UnaryUnboundFunction implements UnboundFunction { + + @Override + public BoundFunction bind(StructType inputType) { + DataType valueType = valueType(inputType); + return doBind(valueType); + } + + protected abstract BoundFunction doBind(DataType valueType); + + private DataType valueType(StructType inputType) { + if (inputType.size() != 1) { + throw new UnsupportedOperationException("Wrong number of inputs (expected value)"); + } + + return inputType.fields()[0].dataType(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/YearsFunction.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/YearsFunction.java new file mode 100644 index 000000000000..cfd1b0e8d002 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/functions/YearsFunction.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.DateType; +import org.apache.spark.sql.types.TimestampNTZType; +import org.apache.spark.sql.types.TimestampType; + +/** + * A Spark function implementation for the Iceberg year transform. + * + *

    Example usage: {@code SELECT system.years('source_col')}. + */ +public class YearsFunction extends UnaryUnboundFunction { + + @Override + protected BoundFunction doBind(DataType valueType) { + if (valueType instanceof DateType) { + return new DateToYearsFunction(); + } else if (valueType instanceof TimestampType) { + return new TimestampToYearsFunction(); + } else if (valueType instanceof TimestampNTZType) { + return new TimestampNtzToYearsFunction(); + } else { + throw new UnsupportedOperationException( + "Expected value to be date or timestamp: " + valueType.catalogString()); + } + } + + @Override + public String description() { + return name() + + "(col) - Call Iceberg's year transform\n" + + " col :: source column (must be date or timestamp)"; + } + + @Override + public String name() { + return "years"; + } + + private abstract static class BaseToYearsFunction extends BaseScalarFunction { + @Override + public String name() { + return "years"; + } + + @Override + public DataType resultType() { + return DataTypes.IntegerType; + } + } + + public static class DateToYearsFunction extends BaseToYearsFunction { + // magic method used in codegen + public static int invoke(int days) { + return DateTimeUtil.daysToYears(days); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.DateType}; + } + + @Override + public String canonicalName() { + return "iceberg.years(date)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getInt(0)); + } + } + + public static class TimestampToYearsFunction extends BaseToYearsFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToYears(micros); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampType}; + } + + @Override + public String canonicalName() { + return "iceberg.years(timestamp)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } + + public static class TimestampNtzToYearsFunction extends BaseToYearsFunction { + // magic method used in codegen + public static int invoke(long micros) { + return DateTimeUtil.microsToYears(micros); + } + + @Override + public DataType[] inputTypes() { + return new DataType[] {DataTypes.TimestampNTZType}; + } + + @Override + public String canonicalName() { + return "iceberg.years(timestamp_ntz)"; + } + + @Override + public Integer produceResult(InternalRow input) { + // return null for null input to match what Spark does in codegen + return input.isNullAt(0) ? null : invoke(input.getLong(0)); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/AddFilesProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/AddFilesProcedure.java new file mode 100644 index 000000000000..e5e369f32701 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/AddFilesProcedure.java @@ -0,0 +1,278 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.mapping.MappingUtil; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkTableUtil.SparkPartition; +import org.apache.iceberg.util.LocationUtil; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.TableIdentifier; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import scala.collection.JavaConverters; + +class AddFilesProcedure extends BaseProcedure { + + static final String NAME = "add_files"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SOURCE_TABLE_PARAM = + requiredInParameter("source_table", DataTypes.StringType); + private static final ProcedureParameter PARTITION_FILTER_PARAM = + optionalInParameter("partition_filter", STRING_MAP); + private static final ProcedureParameter CHECK_DUPLICATE_FILES_PARAM = + optionalInParameter("check_duplicate_files", DataTypes.BooleanType); + private static final ProcedureParameter PARALLELISM = + optionalInParameter("parallelism", DataTypes.IntegerType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + TABLE_PARAM, + SOURCE_TABLE_PARAM, + PARTITION_FILTER_PARAM, + CHECK_DUPLICATE_FILES_PARAM, + PARALLELISM + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("added_files_count", DataTypes.LongType, false, Metadata.empty()), + new StructField("changed_partition_count", DataTypes.LongType, true, Metadata.empty()), + }); + + private AddFilesProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + public static SparkProcedures.ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected AddFilesProcedure doBuild() { + return new AddFilesProcedure(tableCatalog()); + } + }; + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + + Identifier tableIdent = input.ident(TABLE_PARAM); + + CatalogPlugin sessionCat = spark().sessionState().catalogManager().v2SessionCatalog(); + Identifier sourceIdent = input.ident(SOURCE_TABLE_PARAM, sessionCat); + + Map partitionFilter = + input.asStringMap(PARTITION_FILTER_PARAM, ImmutableMap.of()); + + boolean checkDuplicateFiles = input.asBoolean(CHECK_DUPLICATE_FILES_PARAM, true); + + int parallelism = input.asInt(PARALLELISM, 1); + Preconditions.checkArgument(parallelism > 0, "Parallelism should be larger than 0"); + + return asScanIterator( + OUTPUT_TYPE, + importToIceberg( + tableIdent, sourceIdent, partitionFilter, checkDuplicateFiles, parallelism)); + } + + private InternalRow[] toOutputRows(Snapshot snapshot) { + Map summary = snapshot.summary(); + return new InternalRow[] { + newInternalRow(addedFilesCount(summary), changedPartitionCount(summary)) + }; + } + + private long addedFilesCount(Map stats) { + return PropertyUtil.propertyAsLong(stats, SnapshotSummary.ADDED_FILES_PROP, 0L); + } + + private Long changedPartitionCount(Map stats) { + return PropertyUtil.propertyAsNullableLong(stats, SnapshotSummary.CHANGED_PARTITION_COUNT_PROP); + } + + private boolean isFileIdentifier(Identifier ident) { + String[] namespace = ident.namespace(); + return namespace.length == 1 + && (namespace[0].equalsIgnoreCase("orc") + || namespace[0].equalsIgnoreCase("parquet") + || namespace[0].equalsIgnoreCase("avro")); + } + + private InternalRow[] importToIceberg( + Identifier destIdent, + Identifier sourceIdent, + Map partitionFilter, + boolean checkDuplicateFiles, + int parallelism) { + return modifyIcebergTable( + destIdent, + table -> { + ensureNameMappingPresent(table); + + if (isFileIdentifier(sourceIdent)) { + Path sourcePath = new Path(sourceIdent.name()); + String format = sourceIdent.namespace()[0]; + importFileTable( + table, sourcePath, format, partitionFilter, checkDuplicateFiles, parallelism); + } else { + importCatalogTable( + table, sourceIdent, partitionFilter, checkDuplicateFiles, parallelism); + } + + Snapshot snapshot = table.currentSnapshot(); + return toOutputRows(snapshot); + }); + } + + private static void ensureNameMappingPresent(Table table) { + if (table.properties().get(TableProperties.DEFAULT_NAME_MAPPING) == null) { + // Forces Name based resolution instead of position based resolution + NameMapping mapping = MappingUtil.create(table.schema()); + String mappingJson = NameMappingParser.toJson(mapping); + table.updateProperties().set(TableProperties.DEFAULT_NAME_MAPPING, mappingJson).commit(); + } + } + + private void importFileTable( + Table table, + Path tableLocation, + String format, + Map partitionFilter, + boolean checkDuplicateFiles, + int parallelism) { + + org.apache.spark.sql.execution.datasources.PartitionSpec inferredSpec = + Spark3Util.getInferredSpec(spark(), tableLocation); + + List sparkPartNames = + JavaConverters.seqAsJavaList(inferredSpec.partitionColumns()).stream() + .map(StructField::name) + .collect(Collectors.toList()); + PartitionSpec compatibleSpec = SparkTableUtil.findCompatibleSpec(sparkPartNames, table); + + SparkTableUtil.validatePartitionFilter(compatibleSpec, partitionFilter, table.name()); + + // List Partitions via Spark InMemory file search interface + List partitions = + Spark3Util.getPartitions(spark(), tableLocation, format, partitionFilter, compatibleSpec); + + if (table.spec().isUnpartitioned()) { + Preconditions.checkArgument( + partitions.isEmpty(), "Cannot add partitioned files to an unpartitioned table"); + Preconditions.checkArgument( + partitionFilter.isEmpty(), + "Cannot use a partition filter when importing" + "to an unpartitioned table"); + + // Build a Global Partition for the source + SparkPartition partition = + new SparkPartition(Collections.emptyMap(), tableLocation.toString(), format); + importPartitions( + table, ImmutableList.of(partition), checkDuplicateFiles, compatibleSpec, parallelism); + } else { + Preconditions.checkArgument( + !partitions.isEmpty(), "Cannot find any matching partitions in table %s", table.name()); + importPartitions(table, partitions, checkDuplicateFiles, compatibleSpec, parallelism); + } + } + + private void importCatalogTable( + Table table, + Identifier sourceIdent, + Map partitionFilter, + boolean checkDuplicateFiles, + int parallelism) { + String stagingLocation = getMetadataLocation(table); + TableIdentifier sourceTableIdentifier = Spark3Util.toV1TableIdentifier(sourceIdent); + SparkTableUtil.importSparkTable( + spark(), + sourceTableIdentifier, + table, + stagingLocation, + partitionFilter, + checkDuplicateFiles, + parallelism); + } + + private void importPartitions( + Table table, + List partitions, + boolean checkDuplicateFiles, + PartitionSpec spec, + int parallelism) { + String stagingLocation = getMetadataLocation(table); + SparkTableUtil.importSparkPartitions( + spark(), partitions, table, spec, stagingLocation, checkDuplicateFiles, parallelism); + } + + private String getMetadataLocation(Table table) { + String defaultValue = LocationUtil.stripTrailingSlash(table.location()) + "/metadata"; + return LocationUtil.stripTrailingSlash( + table.properties().getOrDefault(TableProperties.WRITE_METADATA_LOCATION, defaultValue)); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "AddFiles"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/AncestorsOfProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/AncestorsOfProcedure.java new file mode 100644 index 000000000000..bdbe727bffad --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/AncestorsOfProcedure.java @@ -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. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import java.util.List; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +public class AncestorsOfProcedure extends BaseProcedure { + + static final String NAME = "ancestors_of"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SNAPSHOT_ID_PARAM = + optionalInParameter("snapshot_id", DataTypes.LongType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, SNAPSHOT_ID_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("snapshot_id", DataTypes.LongType, true, Metadata.empty()), + new StructField("timestamp", DataTypes.LongType, true, Metadata.empty()) + }); + + private AncestorsOfProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + public static SparkProcedures.ProcedureBuilder builder() { + return new Builder() { + @Override + protected AncestorsOfProcedure doBuild() { + return new AncestorsOfProcedure(tableCatalog()); + } + }; + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + + Identifier tableIdent = input.ident(TABLE_PARAM); + Long toSnapshotId = input.asLong(SNAPSHOT_ID_PARAM, null); + + SparkTable sparkTable = loadSparkTable(tableIdent); + Table icebergTable = sparkTable.table(); + + if (toSnapshotId == null) { + toSnapshotId = + icebergTable.currentSnapshot() != null ? icebergTable.currentSnapshot().snapshotId() : -1; + } + + List snapshotIds = + Lists.newArrayList( + SnapshotUtil.ancestorIdsBetween(toSnapshotId, null, icebergTable::snapshot)); + + return asScanIterator(OUTPUT_TYPE, toOutputRow(icebergTable, snapshotIds)); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "AncestorsOf"; + } + + private InternalRow[] toOutputRow(Table table, List snapshotIds) { + if (snapshotIds.isEmpty()) { + return new InternalRow[0]; + } + + InternalRow[] internalRows = new InternalRow[snapshotIds.size()]; + for (int i = 0; i < snapshotIds.size(); i++) { + Long snapshotId = snapshotIds.get(i); + internalRows[i] = newInternalRow(snapshotId, table.snapshot(snapshotId).timestampMillis()); + } + + return internalRows; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/BaseProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/BaseProcedure.java new file mode 100644 index 000000000000..858579e5dc3f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/BaseProcedure.java @@ -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. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.function.Function; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.MoreExecutors; +import org.apache.iceberg.relocated.com.google.common.util.concurrent.ThreadFactoryBuilder; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.Spark3Util.CatalogAndIdentifier; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.classic.SparkSession; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.catalog.procedures.UnboundProcedure; +import org.apache.spark.sql.connector.read.LocalScan; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.execution.CacheManager; +import org.apache.spark.sql.execution.datasources.SparkExpressionConverter; +import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructType; +import scala.Option; + +abstract class BaseProcedure implements BoundProcedure, UnboundProcedure { + protected static final DataType STRING_MAP = + DataTypes.createMapType(DataTypes.StringType, DataTypes.StringType); + protected static final DataType STRING_ARRAY = DataTypes.createArrayType(DataTypes.StringType); + + protected static ProcedureParameter requiredInParameter(String name, DataType dataType) { + return ProcedureParameter.in(name, dataType).build(); + } + + protected static ProcedureParameter optionalInParameter(String name, DataType dataType) { + return optionalInParameter(name, dataType, "NULL"); + } + + protected static ProcedureParameter optionalInParameter( + String name, DataType dataType, String defaultValue) { + return ProcedureParameter.in(name, dataType).defaultValue(defaultValue).build(); + } + + private final SparkSession spark; + private final TableCatalog tableCatalog; + + private SparkActions actions; + private ExecutorService executorService = null; + + protected BaseProcedure(TableCatalog tableCatalog) { + this.spark = SparkSession.active(); + this.tableCatalog = tableCatalog; + } + + @Override + public boolean isDeterministic() { + return false; + } + + protected SparkSession spark() { + return this.spark; + } + + protected SparkActions actions() { + if (actions == null) { + this.actions = SparkActions.get(spark); + } + return actions; + } + + protected TableCatalog tableCatalog() { + return this.tableCatalog; + } + + protected T modifyIcebergTable(Identifier ident, Function func) { + try { + return execute(ident, true, func); + } finally { + closeService(); + } + } + + protected T withIcebergTable(Identifier ident, Function func) { + try { + return execute(ident, false, func); + } finally { + closeService(); + } + } + + private T execute( + Identifier ident, boolean refreshSparkCache, Function func) { + SparkTable sparkTable = loadSparkTable(ident); + org.apache.iceberg.Table icebergTable = sparkTable.table(); + + T result = func.apply(icebergTable); + + if (refreshSparkCache) { + refreshSparkCache(ident, sparkTable); + } + + return result; + } + + protected Identifier toIdentifier(String identifierAsString, String argName) { + CatalogAndIdentifier catalogAndIdentifier = + toCatalogAndIdentifier(identifierAsString, argName, tableCatalog); + + Preconditions.checkArgument( + catalogAndIdentifier.catalog().equals(tableCatalog), + "Cannot run procedure in catalog '%s': '%s' is a table in catalog '%s'", + tableCatalog.name(), + identifierAsString, + catalogAndIdentifier.catalog().name()); + + return catalogAndIdentifier.identifier(); + } + + protected CatalogAndIdentifier toCatalogAndIdentifier( + String identifierAsString, String argName, CatalogPlugin catalog) { + Preconditions.checkArgument( + identifierAsString != null && !identifierAsString.isEmpty(), + "Cannot handle an empty identifier for argument %s", + argName); + + return Spark3Util.catalogAndIdentifier( + "identifier for arg " + argName, spark, identifierAsString, catalog); + } + + protected SparkTable loadSparkTable(Identifier ident) { + try { + Table table = tableCatalog.loadTable(ident); + ValidationException.check( + table instanceof SparkTable, "%s is not %s", ident, SparkTable.class.getName()); + return (SparkTable) table; + } catch (NoSuchTableException e) { + String errMsg = + String.format("Couldn't load table '%s' in catalog '%s'", ident, tableCatalog.name()); + throw new RuntimeException(errMsg, e); + } + } + + protected Dataset loadRows(Identifier tableIdent, Map options) { + String tableName = Spark3Util.quotedFullIdentifier(tableCatalog().name(), tableIdent); + return spark().read().options(options).table(tableName); + } + + protected void refreshSparkCache(Identifier ident, Table table) { + CacheManager cacheManager = spark.sharedState().cacheManager(); + DataSourceV2Relation relation = + DataSourceV2Relation.create(table, Option.apply(tableCatalog), Option.apply(ident)); + cacheManager.recacheByPlan(spark, relation); + } + + protected Expression filterExpression(Identifier ident, String where) { + try { + String name = Spark3Util.quotedFullIdentifier(tableCatalog.name(), ident); + org.apache.spark.sql.catalyst.expressions.Expression expression = + SparkExpressionConverter.collectResolvedSparkExpression(spark, name, where); + return SparkExpressionConverter.convertToIcebergExpression(expression); + } catch (AnalysisException e) { + throw new IllegalArgumentException("Cannot parse predicates in where option: " + where, e); + } + } + + protected InternalRow newInternalRow(Object... values) { + return new GenericInternalRow(values); + } + + protected static class Result implements LocalScan { + private final StructType readSchema; + private final InternalRow[] rows; + + public Result(StructType readSchema, InternalRow[] rows) { + this.readSchema = readSchema; + this.rows = rows; + } + + @Override + public StructType readSchema() { + return this.readSchema; + } + + @Override + public InternalRow[] rows() { + return this.rows; + } + } + + protected Iterator asScanIterator(StructType readSchema, InternalRow... rows) { + return Collections.singleton(new Result(readSchema, rows)).iterator(); + } + + protected abstract static class Builder implements ProcedureBuilder { + private TableCatalog tableCatalog; + + @Override + public Builder withTableCatalog(TableCatalog newTableCatalog) { + this.tableCatalog = newTableCatalog; + return this; + } + + @Override + public T build() { + return doBuild(); + } + + protected abstract T doBuild(); + + TableCatalog tableCatalog() { + return tableCatalog; + } + } + + /** + * Closes this procedure's executor service if a new one was created with {@link + * BaseProcedure#executorService(int, String)}. Does not block for any remaining tasks. + */ + protected void closeService() { + if (executorService != null) { + executorService.shutdown(); + } + } + + /** + * Starts a new executor service which can be used by this procedure in its work. The pool will be + * automatically shut down if {@link #withIcebergTable(Identifier, Function)} or {@link + * #modifyIcebergTable(Identifier, Function)} are called. If these methods are not used then the + * service can be shut down with {@link #closeService()} or left to be closed when this class is + * finalized. + * + * @param threadPoolSize number of threads in the service + * @param nameFormat name prefix for threads created in this service + * @return the new executor service owned by this procedure + */ + protected ExecutorService executorService(int threadPoolSize, String nameFormat) { + Preconditions.checkArgument( + executorService == null, "Cannot create a new executor service, one already exists."); + Preconditions.checkArgument( + nameFormat != null, "Cannot create a service with null nameFormat arg"); + this.executorService = + MoreExecutors.getExitingExecutorService( + (ThreadPoolExecutor) + Executors.newFixedThreadPool( + threadPoolSize, + new ThreadFactoryBuilder() + .setDaemon(true) + .setNameFormat(nameFormat + "-%d") + .build())); + + return executorService; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/CherrypickSnapshotProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/CherrypickSnapshotProcedure.java new file mode 100644 index 000000000000..31043b953b75 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/CherrypickSnapshotProcedure.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that applies changes in a given snapshot and creates a new snapshot which will be set + * as the current snapshot in a table. + * + *

    Note: this procedure invalidates all cached Spark plans that reference the affected + * table. + * + * @see org.apache.iceberg.ManageSnapshots#cherrypick(long) + */ +class CherrypickSnapshotProcedure extends BaseProcedure { + + static final String NAME = "cherrypick_snapshot"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SNAPSHOT_ID_PARAM = + requiredInParameter("snapshot_id", DataTypes.LongType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, SNAPSHOT_ID_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("source_snapshot_id", DataTypes.LongType, false, Metadata.empty()), + new StructField("current_snapshot_id", DataTypes.LongType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected CherrypickSnapshotProcedure doBuild() { + return new CherrypickSnapshotProcedure(tableCatalog()); + } + }; + } + + private CherrypickSnapshotProcedure(TableCatalog catalog) { + super(catalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + + Identifier tableIdent = input.ident(TABLE_PARAM); + long snapshotId = input.asLong(SNAPSHOT_ID_PARAM); + + return asScanIterator( + OUTPUT_TYPE, + modifyIcebergTable( + tableIdent, + table -> { + table.manageSnapshots().cherrypick(snapshotId).commit(); + + Snapshot currentSnapshot = table.currentSnapshot(); + + InternalRow outputRow = newInternalRow(snapshotId, currentSnapshot.snapshotId()); + return new InternalRow[] {outputRow}; + })); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "CherrypickSnapshotProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ComputePartitionStatsProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ComputePartitionStatsProcedure.java new file mode 100644 index 000000000000..c81157496e5f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ComputePartitionStatsProcedure.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ComputePartitionStats; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * A procedure that computes the stats incrementally from the last snapshot that has partition stats + * file until the given snapshot (uses current snapshot if not specified) and writes the combined + * result into a {@link PartitionStatisticsFile} after merging the partition stats. Does a full + * compute if previous statistics file does not exist. Also registers the {@link + * PartitionStatisticsFile} to table metadata. + * + * @see SparkActions#computePartitionStats(Table) + */ +public class ComputePartitionStatsProcedure extends BaseProcedure { + + static final String NAME = "compute_partition_stats"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SNAPSHOT_ID_PARAM = + optionalInParameter("snapshot_id", DataTypes.LongType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, SNAPSHOT_ID_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField( + "partition_statistics_file", DataTypes.StringType, true, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new Builder() { + @Override + protected ComputePartitionStatsProcedure doBuild() { + return new ComputePartitionStatsProcedure(tableCatalog()); + } + }; + } + + private ComputePartitionStatsProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + Long snapshotId = input.asLong(SNAPSHOT_ID_PARAM, null); + + return modifyIcebergTable( + tableIdent, + table -> { + ComputePartitionStats action = actions().computePartitionStats(table); + if (snapshotId != null) { + action.snapshot(snapshotId); + } + + return asScanIterator(OUTPUT_TYPE, toOutputRows(action.execute())); + }); + } + + private InternalRow[] toOutputRows(ComputePartitionStats.Result result) { + PartitionStatisticsFile statisticsFile = result.statisticsFile(); + if (statisticsFile != null) { + InternalRow row = newInternalRow(UTF8String.fromString(statisticsFile.path())); + return new InternalRow[] {row}; + } else { + return new InternalRow[0]; + } + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "ComputePartitionStatsProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ComputeTableStatsProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ComputeTableStatsProcedure.java new file mode 100644 index 000000000000..382fda706b79 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ComputeTableStatsProcedure.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ComputeTableStats; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * A procedure that computes statistics of a table. + * + * @see SparkActions#computeTableStats(Table) + */ +public class ComputeTableStatsProcedure extends BaseProcedure { + + static final String NAME = "compute_table_stats"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SNAPSHOT_ID_PARAM = + optionalInParameter("snapshot_id", DataTypes.LongType); + private static final ProcedureParameter COLUMNS_PARAM = + optionalInParameter("columns", STRING_ARRAY); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, SNAPSHOT_ID_PARAM, COLUMNS_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("statistics_file", DataTypes.StringType, true, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new Builder() { + @Override + protected ComputeTableStatsProcedure doBuild() { + return new ComputeTableStatsProcedure(tableCatalog()); + } + }; + } + + private ComputeTableStatsProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + Long snapshotId = input.asLong(SNAPSHOT_ID_PARAM, null); + String[] columns = input.asStringArray(COLUMNS_PARAM, null); + + return modifyIcebergTable( + tableIdent, + table -> { + ComputeTableStats action = actions().computeTableStats(table); + + if (snapshotId != null) { + action.snapshot(snapshotId); + } + + if (columns != null) { + action.columns(columns); + } + + ComputeTableStats.Result result = action.execute(); + return asScanIterator(OUTPUT_TYPE, toOutputRows(result)); + }); + } + + private InternalRow[] toOutputRows(ComputeTableStats.Result result) { + StatisticsFile statisticsFile = result.statisticsFile(); + if (statisticsFile != null) { + InternalRow row = newInternalRow(UTF8String.fromString(statisticsFile.path())); + return new InternalRow[] {row}; + } else { + return new InternalRow[0]; + } + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "ComputeTableStatsProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/CreateChangelogViewProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/CreateChangelogViewProcedure.java new file mode 100644 index 000000000000..73c94f778510 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/CreateChangelogViewProcedure.java @@ -0,0 +1,329 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.ChangelogIterator; +import org.apache.iceberg.spark.source.SparkChangelogTable; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.spark.api.java.function.MapPartitionsFunction; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.OrderUtils; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +/** + * A procedure that creates a view for changed rows. + * + *

    The procedure always removes the carry-over rows. Please query {@link SparkChangelogTable} + * instead when carry-over rows are required. + * + *

    The procedure doesn't compute the pre/post update images by default. If you want to compute + * them, you can set "compute_updates" to be true in the options. + * + *

    Carry-over rows are the result of a removal and insertion of the same row within an operation + * because of the copy-on-write mechanism. For example, given a file which contains row1 (id=1, + * data='a') and row2 (id=2, data='b'). A copy-on-write delete of row2 would require erasing this + * file and preserving row1 in a new file. The changelog table would report this as (id=1, data='a', + * op='DELETE') and (id=1, data='a', op='INSERT'), despite it not being an actual change to the + * table. The procedure finds the carry-over rows and removes them from the result. + * + *

    Pre/post update images are converted from a pair of a delete row and an insert row. Identifier + * columns are used for determining whether an insert and a delete record refer to the same row. If + * the two records share the same values for the identity columns they are considered to be before + * and after states of the same row. You can either set identifier fields in the table schema or + * input them as the procedure parameters. Here is an example of pre/post update images with an + * identifier column(id). A pair of a delete row and an insert row with the same id: + * + *

      + *
    • (id=1, data='a', op='DELETE') + *
    • (id=1, data='b', op='INSERT') + *
    + * + *

    will be marked as pre/post update images: + * + *

      + *
    • (id=1, data='a', op='UPDATE_BEFORE') + *
    • (id=1, data='b', op='UPDATE_AFTER') + *
    + */ +public class CreateChangelogViewProcedure extends BaseProcedure { + + static final String NAME = "create_changelog_view"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter CHANGELOG_VIEW_PARAM = + optionalInParameter("changelog_view", DataTypes.StringType); + private static final ProcedureParameter OPTIONS_PARAM = + optionalInParameter("options", STRING_MAP); + private static final ProcedureParameter COMPUTE_UPDATES_PARAM = + optionalInParameter("compute_updates", DataTypes.BooleanType); + private static final ProcedureParameter IDENTIFIER_COLUMNS_PARAM = + optionalInParameter("identifier_columns", STRING_ARRAY); + private static final ProcedureParameter NET_CHANGES = + optionalInParameter("net_changes", DataTypes.BooleanType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + TABLE_PARAM, + CHANGELOG_VIEW_PARAM, + OPTIONS_PARAM, + COMPUTE_UPDATES_PARAM, + IDENTIFIER_COLUMNS_PARAM, + NET_CHANGES, + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("changelog_view", DataTypes.StringType, false, Metadata.empty()) + }); + + public static SparkProcedures.ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected CreateChangelogViewProcedure doBuild() { + return new CreateChangelogViewProcedure(tableCatalog()); + } + }; + } + + private CreateChangelogViewProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + + Identifier tableIdent = input.ident(TABLE_PARAM); + + // load insert and deletes from the changelog table + Identifier changelogTableIdent = changelogTableIdent(tableIdent); + Dataset df = loadRows(changelogTableIdent, options(input)); + + boolean netChanges = input.asBoolean(NET_CHANGES, false); + String[] identifierColumns = identifierColumns(input, tableIdent); + Set unorderableColumnNames = + Arrays.stream(df.schema().fields()) + .filter(field -> !OrderUtils.isOrderable(field.dataType())) + .map(StructField::name) + .collect(Collectors.toSet()); + + Preconditions.checkArgument( + identifierColumns.length > 0 || unorderableColumnNames.isEmpty(), + "Identifier field is required as table contains unorderable columns: %s", + unorderableColumnNames); + + if (shouldComputeUpdateImages(input)) { + Preconditions.checkArgument(!netChanges, "Not support net changes with update images"); + df = computeUpdateImages(identifierColumns, df); + } else { + df = removeCarryoverRows(df, netChanges); + } + + String viewName = viewName(input, tableIdent.name()); + + df.createOrReplaceTempView(viewName); + + return asScanIterator(OUTPUT_TYPE, toOutputRows(viewName)); + } + + private Dataset computeUpdateImages(String[] identifierColumns, Dataset df) { + Preconditions.checkArgument( + identifierColumns.length > 0, + "Cannot compute the update images because identifier columns are not set"); + + String[] identifierFields = + ArrayUtil.add(identifierColumns, MetadataColumns.CHANGE_ORDINAL.name()); + Column[] repartitionSpec = + Arrays.stream(identifierFields) + .map(CreateChangelogViewProcedure::delimitedName) + .map(df::col) + .toArray(Column[]::new); + return applyChangelogIterator(df, repartitionSpec, identifierFields); + } + + private boolean shouldComputeUpdateImages(ProcedureInput input) { + // If the identifier columns are set, we compute pre/post update images by default. + boolean defaultValue = input.isProvided(IDENTIFIER_COLUMNS_PARAM); + return input.asBoolean(COMPUTE_UPDATES_PARAM, defaultValue); + } + + private Dataset removeCarryoverRows(Dataset df, boolean netChanges) { + Predicate columnsToKeep; + if (netChanges) { + Set metadataColumn = + Sets.newHashSet( + MetadataColumns.CHANGE_TYPE.name(), + MetadataColumns.CHANGE_ORDINAL.name(), + MetadataColumns.COMMIT_SNAPSHOT_ID.name()); + + columnsToKeep = column -> !metadataColumn.contains(column); + } else { + columnsToKeep = column -> !column.equals(MetadataColumns.CHANGE_TYPE.name()); + } + + Column[] repartitionSpec = + Arrays.stream(df.columns()) + .filter(columnsToKeep) + .map(CreateChangelogViewProcedure::delimitedName) + .map(df::col) + .toArray(Column[]::new); + + return applyCarryoverRemoveIterator(df, repartitionSpec, netChanges); + } + + private String[] identifierColumns(ProcedureInput input, Identifier tableIdent) { + if (input.isProvided(IDENTIFIER_COLUMNS_PARAM)) { + return input.asStringArray(IDENTIFIER_COLUMNS_PARAM); + } else { + Table table = loadSparkTable(tableIdent).table(); + return table.schema().identifierFieldNames().stream().toArray(String[]::new); + } + } + + private Identifier changelogTableIdent(Identifier tableIdent) { + List namespace = Lists.newArrayList(); + namespace.addAll(Arrays.asList(tableIdent.namespace())); + namespace.add(tableIdent.name()); + return Identifier.of(namespace.toArray(new String[0]), SparkChangelogTable.TABLE_NAME); + } + + private Map options(ProcedureInput input) { + return input.asStringMap(OPTIONS_PARAM, ImmutableMap.of()); + } + + private String viewName(ProcedureInput input, String tableName) { + String defaultValue = String.format("`%s_changes`", tableName); + return input.asString(CHANGELOG_VIEW_PARAM, defaultValue); + } + + private Dataset applyChangelogIterator( + Dataset df, Column[] repartitionSpec, String[] identifierFields) { + Column[] sortSpec = sortSpec(df, repartitionSpec, false); + StructType schema = df.schema(); + + return df.repartition(repartitionSpec) + .sortWithinPartitions(sortSpec) + .mapPartitions( + (MapPartitionsFunction) + rowIterator -> + ChangelogIterator.computeUpdates(rowIterator, schema, identifierFields), + Encoders.row(schema)); + } + + private Dataset applyCarryoverRemoveIterator( + Dataset df, Column[] repartitionSpec, boolean netChanges) { + Column[] sortSpec = sortSpec(df, repartitionSpec, netChanges); + StructType schema = df.schema(); + + return df.repartition(repartitionSpec) + .sortWithinPartitions(sortSpec) + .mapPartitions( + (MapPartitionsFunction) + rowIterator -> + netChanges + ? ChangelogIterator.removeNetCarryovers(rowIterator, schema) + : ChangelogIterator.removeCarryovers(rowIterator, schema), + Encoders.row(schema)); + } + + /** + * Ensure that column can be referenced using this name. Issues may come from field names that + * contain non-standard characters. In Spark, this can be fixed by using backtick + * quotes. + * + * @param columnName Column name that potentially can contain non-standard characters. + * @return A name that can be safely used within Spark to reference a column by its name. + */ + private static String delimitedName(String columnName) { + boolean delimited = columnName.startsWith("`") && columnName.endsWith("`"); + if (delimited) { + return columnName; + } else { + return "`" + columnName.replaceAll("`", "``") + "`"; + } + } + + private static Column[] sortSpec(Dataset df, Column[] repartitionSpec, boolean netChanges) { + Column changeType = df.col(MetadataColumns.CHANGE_TYPE.name()); + Column changeOrdinal = df.col(MetadataColumns.CHANGE_ORDINAL.name()); + Column[] extraColumns = + netChanges ? new Column[] {changeOrdinal, changeType} : new Column[] {changeType}; + + Column[] sortSpec = new Column[repartitionSpec.length + extraColumns.length]; + + System.arraycopy(repartitionSpec, 0, sortSpec, 0, repartitionSpec.length); + System.arraycopy(extraColumns, 0, sortSpec, repartitionSpec.length, extraColumns.length); + + return sortSpec; + } + + private InternalRow[] toOutputRows(String viewName) { + InternalRow row = newInternalRow(UTF8String.fromString(viewName)); + return new InternalRow[] {row}; + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "CreateChangelogViewProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ExpireSnapshotsProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ExpireSnapshotsProcedure.java new file mode 100644 index 000000000000..2b771914c3fe --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ExpireSnapshotsProcedure.java @@ -0,0 +1,204 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ExpireSnapshots; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.actions.ExpireSnapshotsSparkAction; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A procedure that expires snapshots in a table. + * + * @see SparkActions#expireSnapshots(Table) + */ +public class ExpireSnapshotsProcedure extends BaseProcedure { + + static final String NAME = "expire_snapshots"; + + private static final Logger LOG = LoggerFactory.getLogger(ExpireSnapshotsProcedure.class); + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter OLDER_THAN_PARAM = + optionalInParameter("older_than", DataTypes.TimestampType); + private static final ProcedureParameter RETAIN_LAST_PARAM = + optionalInParameter("retain_last", DataTypes.IntegerType); + private static final ProcedureParameter MAX_CONCURRENT_DELETES_PARAM = + optionalInParameter("max_concurrent_deletes", DataTypes.IntegerType); + private static final ProcedureParameter STREAM_RESULTS_PARAM = + optionalInParameter("stream_results", DataTypes.BooleanType); + private static final ProcedureParameter SNAPSHOT_IDS_PARAM = + optionalInParameter("snapshot_ids", DataTypes.createArrayType(DataTypes.LongType)); + private static final ProcedureParameter CLEAN_EXPIRED_METADATA_PARAM = + optionalInParameter("clean_expired_metadata", DataTypes.BooleanType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + TABLE_PARAM, + OLDER_THAN_PARAM, + RETAIN_LAST_PARAM, + MAX_CONCURRENT_DELETES_PARAM, + STREAM_RESULTS_PARAM, + SNAPSHOT_IDS_PARAM, + CLEAN_EXPIRED_METADATA_PARAM + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("deleted_data_files_count", DataTypes.LongType, true, Metadata.empty()), + new StructField( + "deleted_position_delete_files_count", DataTypes.LongType, true, Metadata.empty()), + new StructField( + "deleted_equality_delete_files_count", DataTypes.LongType, true, Metadata.empty()), + new StructField( + "deleted_manifest_files_count", DataTypes.LongType, true, Metadata.empty()), + new StructField( + "deleted_manifest_lists_count", DataTypes.LongType, true, Metadata.empty()), + new StructField( + "deleted_statistics_files_count", DataTypes.LongType, true, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected ExpireSnapshotsProcedure doBuild() { + return new ExpireSnapshotsProcedure(tableCatalog()); + } + }; + } + + private ExpireSnapshotsProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + Long olderThanMillis = input.asTimestampMillis(OLDER_THAN_PARAM, null); + Integer retainLastNum = input.asInt(RETAIN_LAST_PARAM, null); + Integer maxConcurrentDeletes = input.asInt(MAX_CONCURRENT_DELETES_PARAM, null); + Boolean streamResult = input.asBoolean(STREAM_RESULTS_PARAM, null); + long[] snapshotIds = input.asLongArray(SNAPSHOT_IDS_PARAM, null); + Boolean cleanExpiredMetadata = input.asBoolean(CLEAN_EXPIRED_METADATA_PARAM, null); + + Preconditions.checkArgument( + maxConcurrentDeletes == null || maxConcurrentDeletes > 0, + "max_concurrent_deletes should have value > 0, value: %s", + maxConcurrentDeletes); + + return modifyIcebergTable( + tableIdent, + table -> { + ExpireSnapshots action = actions().expireSnapshots(table); + + if (olderThanMillis != null) { + action.expireOlderThan(olderThanMillis); + } + + if (retainLastNum != null) { + action.retainLast(retainLastNum); + } + + if (maxConcurrentDeletes != null) { + if (table.io() instanceof SupportsBulkOperations) { + LOG.warn( + "max_concurrent_deletes only works with FileIOs that do not support bulk deletes. This " + + "table is currently using {} which supports bulk deletes so the parameter will be ignored. " + + "See that IO's documentation to learn how to adjust parallelism for that particular " + + "IO's bulk delete.", + table.io().getClass().getName()); + } else { + + action.executeDeleteWith(executorService(maxConcurrentDeletes, "expire-snapshots")); + } + } + + if (snapshotIds != null) { + for (long snapshotId : snapshotIds) { + action.expireSnapshotId(snapshotId); + } + } + + if (streamResult != null) { + action.option( + ExpireSnapshotsSparkAction.STREAM_RESULTS, Boolean.toString(streamResult)); + } + + if (cleanExpiredMetadata != null) { + action.cleanExpiredMetadata(cleanExpiredMetadata); + } + + ExpireSnapshots.Result result = action.execute(); + + return asScanIterator(OUTPUT_TYPE, toOutputRows(result)); + }); + } + + private InternalRow[] toOutputRows(ExpireSnapshots.Result result) { + InternalRow row = + newInternalRow( + result.deletedDataFilesCount(), + result.deletedPositionDeleteFilesCount(), + result.deletedEqualityDeleteFilesCount(), + result.deletedManifestsCount(), + result.deletedManifestListsCount(), + result.deletedStatisticsFilesCount()); + return new InternalRow[] {row}; + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "ExpireSnapshotProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/FastForwardBranchProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/FastForwardBranchProcedure.java new file mode 100644 index 000000000000..d7531e759408 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/FastForwardBranchProcedure.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +public class FastForwardBranchProcedure extends BaseProcedure { + + static final String NAME = "fast_forward"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter BRANCH_PARAM = + requiredInParameter("branch", DataTypes.StringType); + private static final ProcedureParameter TO_PARAM = + requiredInParameter("to", DataTypes.StringType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, BRANCH_PARAM, TO_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("branch_updated", DataTypes.StringType, false, Metadata.empty()), + new StructField("previous_ref", DataTypes.LongType, true, Metadata.empty()), + new StructField("updated_ref", DataTypes.LongType, false, Metadata.empty()) + }); + + public static SparkProcedures.ProcedureBuilder builder() { + return new Builder() { + @Override + protected FastForwardBranchProcedure doBuild() { + return new FastForwardBranchProcedure(tableCatalog()); + } + }; + } + + private FastForwardBranchProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + + Identifier tableIdent = input.ident(TABLE_PARAM); + String from = input.asString(BRANCH_PARAM); + String to = input.asString(TO_PARAM); + + return modifyIcebergTable( + tableIdent, + table -> { + Long snapshotBefore = + table.snapshot(from) != null ? table.snapshot(from).snapshotId() : null; + table.manageSnapshots().fastForwardBranch(from, to).commit(); + long snapshotAfter = table.snapshot(from).snapshotId(); + InternalRow outputRow = + newInternalRow(UTF8String.fromString(from), snapshotBefore, snapshotAfter); + return asScanIterator(OUTPUT_TYPE, outputRow); + }); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "FastForwardBranchProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/MigrateTableProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/MigrateTableProcedure.java new file mode 100644 index 000000000000..1a01faf38a4f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/MigrateTableProcedure.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import java.util.Map; +import org.apache.iceberg.actions.MigrateTable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.actions.MigrateTableSparkAction; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +class MigrateTableProcedure extends BaseProcedure { + + static final String NAME = "migrate"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter PROPERTIES_PARAM = + optionalInParameter("properties", STRING_MAP); + private static final ProcedureParameter DROP_BACKUP_PARAM = + optionalInParameter("drop_backup", DataTypes.BooleanType); + private static final ProcedureParameter BACKUP_TABLE_NAME_PARAM = + optionalInParameter("backup_table_name", DataTypes.StringType); + private static final ProcedureParameter PARALLELISM_PARAM = + optionalInParameter("parallelism", DataTypes.IntegerType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + TABLE_PARAM, PROPERTIES_PARAM, DROP_BACKUP_PARAM, BACKUP_TABLE_NAME_PARAM, PARALLELISM_PARAM + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("migrated_files_count", DataTypes.LongType, false, Metadata.empty()) + }); + + private MigrateTableProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected MigrateTableProcedure doBuild() { + return new MigrateTableProcedure(tableCatalog()); + } + }; + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + String tableName = input.asString(TABLE_PARAM, null); + Preconditions.checkArgument( + tableName != null && !tableName.isEmpty(), + "Cannot handle an empty identifier for argument table"); + + Map properties = input.asStringMap(PROPERTIES_PARAM, ImmutableMap.of()); + + boolean dropBackup = input.asBoolean(DROP_BACKUP_PARAM, false); + String backupTableName = input.asString(BACKUP_TABLE_NAME_PARAM, null); + + MigrateTableSparkAction migrateTableSparkAction = + SparkActions.get().migrateTable(tableName).tableProperties(properties); + + if (dropBackup) { + migrateTableSparkAction = migrateTableSparkAction.dropBackup(); + } + + if (backupTableName != null) { + migrateTableSparkAction = migrateTableSparkAction.backupTableName(backupTableName); + } + + if (input.isProvided(PARALLELISM_PARAM)) { + int parallelism = input.asInt(PARALLELISM_PARAM); + Preconditions.checkArgument(parallelism > 0, "Parallelism should be larger than 0"); + migrateTableSparkAction = + migrateTableSparkAction.executeWith(SparkTableUtil.migrationService(parallelism)); + } + + MigrateTable.Result result = migrateTableSparkAction.execute(); + return asScanIterator(OUTPUT_TYPE, newInternalRow(result.migratedDataFilesCount())); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "MigrateTableProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ProcedureInput.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ProcedureInput.java new file mode 100644 index 000000000000..8f6dbdcf5a62 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/ProcedureInput.java @@ -0,0 +1,274 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.lang.reflect.Array; +import java.util.Map; +import java.util.function.BiFunction; +import org.apache.commons.lang3.StringUtils; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.Spark3Util.CatalogAndIdentifier; +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; + +/** A class that abstracts common logic for working with input to a procedure. */ +class ProcedureInput { + + private static final DataType STRING_ARRAY = DataTypes.createArrayType(DataTypes.StringType); + private static final DataType STRING_MAP = + DataTypes.createMapType(DataTypes.StringType, DataTypes.StringType); + + private final SparkSession spark; + private final TableCatalog catalog; + private final Map paramOrdinals; + private final InternalRow args; + + ProcedureInput( + SparkSession spark, TableCatalog catalog, ProcedureParameter[] params, InternalRow args) { + this.spark = spark; + this.catalog = catalog; + this.paramOrdinals = computeParamOrdinals(params); + this.args = args; + } + + public boolean isProvided(ProcedureParameter param) { + int ordinal = ordinal(param); + return !args.isNullAt(ordinal); + } + + public Boolean asBoolean(ProcedureParameter param, Boolean defaultValue) { + validateParamType(param, DataTypes.BooleanType); + int ordinal = ordinal(param); + return args.isNullAt(ordinal) ? defaultValue : (Boolean) args.getBoolean(ordinal); + } + + public Integer asInt(ProcedureParameter param) { + Integer value = asInt(param, null); + Preconditions.checkArgument(value != null, "Parameter '%s' is not set", param.name()); + return value; + } + + public Integer asInt(ProcedureParameter param, Integer defaultValue) { + validateParamType(param, DataTypes.IntegerType); + int ordinal = ordinal(param); + return args.isNullAt(ordinal) ? defaultValue : (Integer) args.getInt(ordinal); + } + + public long asTimestampMillis(ProcedureParameter param) { + Long value = asTimestampMillis(param, null); + Preconditions.checkArgument(value != null, "Parameter '%s' is not set", param.name()); + return value; + } + + public Long asTimestampMillis(ProcedureParameter param, Long defaultValue) { + validateParamType(param, DataTypes.TimestampType); + int ordinal = ordinal(param); + Long value = args.isNullAt(ordinal) ? defaultValue : (Long) args.getLong(ordinal); + if (value != null) { + value = DateTimeUtil.microsToMillis(value); + } + return value; + } + + public long asLong(ProcedureParameter param) { + Long value = asLong(param, null); + Preconditions.checkArgument(value != null, "Parameter '%s' is not set", param.name()); + return value; + } + + public Long asLong(ProcedureParameter param, Long defaultValue) { + validateParamType(param, DataTypes.LongType); + int ordinal = ordinal(param); + return args.isNullAt(ordinal) ? defaultValue : (Long) args.getLong(ordinal); + } + + public long[] asLongArray(ProcedureParameter param, Long[] defaultValue) { + validateParamType(param, DataTypes.createArrayType(DataTypes.LongType)); + Long[] source = + array(param, (array, ordinal) -> array.getLong(ordinal), Long.class, defaultValue); + + if (source == null) { + return null; + } + + long[] result = new long[source.length]; + for (int i = 0; i < source.length; i++) { + result[i] = source[i]; + } + return result; + } + + public String asString(ProcedureParameter param) { + String value = asString(param, null); + Preconditions.checkArgument(value != null, "Parameter '%s' is not set", param.name()); + return value; + } + + public String asString(ProcedureParameter param, String defaultValue) { + validateParamType(param, DataTypes.StringType); + int ordinal = ordinal(param); + return args.isNullAt(ordinal) ? defaultValue : args.getString(ordinal); + } + + public String[] asStringArray(ProcedureParameter param) { + String[] value = asStringArray(param, null); + Preconditions.checkArgument(value != null, "Parameter '%s' is not set", param.name()); + return value; + } + + public String[] asStringArray(ProcedureParameter param, String[] defaultValue) { + validateParamType(param, STRING_ARRAY); + return array( + param, + (array, ordinal) -> array.getUTF8String(ordinal).toString(), + String.class, + defaultValue); + } + + @SuppressWarnings("unchecked") + private T[] array( + ProcedureParameter param, + BiFunction convertElement, + Class elementClass, + T[] defaultValue) { + + int ordinal = ordinal(param); + + if (args.isNullAt(ordinal)) { + return defaultValue; + } + + ArrayData arrayData = args.getArray(ordinal); + + T[] convertedArray = (T[]) Array.newInstance(elementClass, arrayData.numElements()); + + for (int index = 0; index < arrayData.numElements(); index++) { + convertedArray[index] = convertElement.apply(arrayData, index); + } + + return convertedArray; + } + + public Map asStringMap( + ProcedureParameter param, Map defaultValue) { + validateParamType(param, STRING_MAP); + return map( + param, + (keys, ordinal) -> keys.getUTF8String(ordinal).toString(), + (values, ordinal) -> values.getUTF8String(ordinal).toString(), + defaultValue); + } + + private Map map( + ProcedureParameter param, + BiFunction convertKey, + BiFunction convertValue, + Map defaultValue) { + + int ordinal = ordinal(param); + + if (args.isNullAt(ordinal)) { + return defaultValue; + } + + MapData mapData = args.getMap(ordinal); + + Map convertedMap = Maps.newHashMap(); + + for (int index = 0; index < mapData.numElements(); index++) { + K convertedKey = convertKey.apply(mapData.keyArray(), index); + V convertedValue = convertValue.apply(mapData.valueArray(), index); + convertedMap.put(convertedKey, convertedValue); + } + + return convertedMap; + } + + public Identifier ident(ProcedureParameter param) { + CatalogAndIdentifier catalogAndIdent = catalogAndIdent(param, catalog); + + Preconditions.checkArgument( + catalogAndIdent.catalog().equals(catalog), + "Cannot run procedure in catalog '%s': '%s' is a table in catalog '%s'", + catalog.name(), + catalogAndIdent.identifier(), + catalogAndIdent.catalog().name()); + + return catalogAndIdent.identifier(); + } + + public Identifier ident(ProcedureParameter param, CatalogPlugin defaultCatalog) { + CatalogAndIdentifier catalogAndIdent = catalogAndIdent(param, defaultCatalog); + return catalogAndIdent.identifier(); + } + + private CatalogAndIdentifier catalogAndIdent( + ProcedureParameter param, CatalogPlugin defaultCatalog) { + + String identAsString = asString(param); + + Preconditions.checkArgument( + StringUtils.isNotBlank(identAsString), + "Cannot handle an empty identifier for parameter '%s'", + param.name()); + + String desc = String.format("identifier for parameter '%s'", param.name()); + return Spark3Util.catalogAndIdentifier(desc, spark, identAsString, defaultCatalog); + } + + private int ordinal(ProcedureParameter param) { + return paramOrdinals.get(param.name()); + } + + private Map computeParamOrdinals(ProcedureParameter[] params) { + Map ordinals = Maps.newHashMap(); + + for (int index = 0; index < params.length; index++) { + String paramName = params[index].name(); + + Preconditions.checkArgument( + !ordinals.containsKey(paramName), + "Detected multiple parameters named as '%s'", + paramName); + + ordinals.put(paramName, index); + } + + return ordinals; + } + + private void validateParamType(ProcedureParameter param, DataType expectedDataType) { + Preconditions.checkArgument( + expectedDataType.sameType(param.dataType()), + "Parameter '%s' must be of type %s", + param.name(), + expectedDataType.catalogString()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java new file mode 100644 index 000000000000..8cb0a2bfb759 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/PublishChangesProcedure.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.iceberg.util.WapUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that applies changes in a snapshot created within a Write-Audit-Publish workflow with + * a wap_id and creates a new snapshot which will be set as the current snapshot in a table. + * + *

    Note: this procedure invalidates all cached Spark plans that reference the affected + * table. + * + * @see org.apache.iceberg.ManageSnapshots#cherrypick(long) + */ +class PublishChangesProcedure extends BaseProcedure { + + static final String NAME = "publish_changes"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter WAP_ID_PARAM = + requiredInParameter("wap_id", DataTypes.StringType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, WAP_ID_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("source_snapshot_id", DataTypes.LongType, false, Metadata.empty()), + new StructField("current_snapshot_id", DataTypes.LongType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new Builder() { + @Override + protected PublishChangesProcedure doBuild() { + return new PublishChangesProcedure(tableCatalog()); + } + }; + } + + private PublishChangesProcedure(TableCatalog catalog) { + super(catalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + + Identifier tableIdent = input.ident(TABLE_PARAM); + String wapId = input.asString(WAP_ID_PARAM); + + return modifyIcebergTable( + tableIdent, + table -> { + Snapshot matchingSnap = null; + for (Snapshot snap : table.snapshots()) { + if (wapId.equals(WapUtil.stagedWapId(snap))) { + if (matchingSnap != null) { + throw new ValidationException( + "Cannot apply non-unique WAP ID. Found multiple snapshots with WAP ID '%s'", + wapId); + } else { + matchingSnap = snap; + } + } + } + + if (matchingSnap == null) { + throw new ValidationException("Cannot apply unknown WAP ID '%s'", wapId); + } + + long wapSnapshotId = matchingSnap.snapshotId(); + table.manageSnapshots().cherrypick(wapSnapshotId).commit(); + Snapshot currentSnapshot = table.currentSnapshot(); + InternalRow outputRow = newInternalRow(wapSnapshotId, currentSnapshot.snapshotId()); + return asScanIterator(OUTPUT_TYPE, outputRow); + }); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "ApplyWapChangesProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RegisterTableProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RegisterTableProcedure.java new file mode 100644 index 000000000000..9ba577ad7e24 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RegisterTableProcedure.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.iceberg.spark.source.HasIcebergCatalog; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +class RegisterTableProcedure extends BaseProcedure { + + static final String NAME = "register_table"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter METADATA_FILE_PARAM = + requiredInParameter("metadata_file", DataTypes.StringType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, METADATA_FILE_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("current_snapshot_id", DataTypes.LongType, true, Metadata.empty()), + new StructField("total_records_count", DataTypes.LongType, true, Metadata.empty()), + new StructField("total_data_files_count", DataTypes.LongType, true, Metadata.empty()) + }); + + private RegisterTableProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected RegisterTableProcedure doBuild() { + return new RegisterTableProcedure(tableCatalog()); + } + }; + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + TableIdentifier tableName = + Spark3Util.identifierToTableIdentifier( + toIdentifier(input.asString(TABLE_PARAM), TABLE_PARAM.name())); + String metadataFile = input.asString(METADATA_FILE_PARAM); + Preconditions.checkArgument( + tableCatalog() instanceof HasIcebergCatalog, + "Cannot use Register Table in a non-Iceberg catalog"); + Preconditions.checkArgument( + metadataFile != null && !metadataFile.isEmpty(), + "Cannot handle an empty argument metadata_file"); + + Catalog icebergCatalog = ((HasIcebergCatalog) tableCatalog()).icebergCatalog(); + Table table = icebergCatalog.registerTable(tableName, metadataFile); + Long currentSnapshotId = null; + Long totalDataFiles = null; + Long totalRecords = null; + + Snapshot currentSnapshot = table.currentSnapshot(); + if (currentSnapshot != null) { + currentSnapshotId = currentSnapshot.snapshotId(); + totalDataFiles = + Long.parseLong(currentSnapshot.summary().get(SnapshotSummary.TOTAL_DATA_FILES_PROP)); + totalRecords = + Long.parseLong(currentSnapshot.summary().get(SnapshotSummary.TOTAL_RECORDS_PROP)); + } + + return asScanIterator( + OUTPUT_TYPE, newInternalRow(currentSnapshotId, totalRecords, totalDataFiles)); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RegisterTableProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RemoveOrphanFilesProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RemoveOrphanFilesProcedure.java new file mode 100644 index 000000000000..89e218c10360 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RemoveOrphanFilesProcedure.java @@ -0,0 +1,252 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.actions.DeleteOrphanFiles.PrefixMismatchMode; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.spark.actions.DeleteOrphanFilesSparkAction; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A procedure that removes orphan files in a table. + * + * @see SparkActions#deleteOrphanFiles(Table) + */ +public class RemoveOrphanFilesProcedure extends BaseProcedure { + + static final String NAME = "remove_orphan_files"; + + private static final Logger LOG = LoggerFactory.getLogger(RemoveOrphanFilesProcedure.class); + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter OLDER_THAN_PARAM = + optionalInParameter("older_than", DataTypes.TimestampType); + private static final ProcedureParameter LOCATION_PARAM = + optionalInParameter("location", DataTypes.StringType); + private static final ProcedureParameter DRY_RUN_PARAM = + optionalInParameter("dry_run", DataTypes.BooleanType); + private static final ProcedureParameter MAX_CONCURRENT_DELETES_PARAM = + optionalInParameter("max_concurrent_deletes", DataTypes.IntegerType); + private static final ProcedureParameter FILE_LIST_VIEW_PARAM = + optionalInParameter("file_list_view", DataTypes.StringType); + private static final ProcedureParameter EQUAL_SCHEMES_PARAM = + optionalInParameter("equal_schemes", STRING_MAP); + private static final ProcedureParameter EQUAL_AUTHORITIES_PARAM = + optionalInParameter("equal_authorities", STRING_MAP); + private static final ProcedureParameter PREFIX_MISMATCH_MODE_PARAM = + optionalInParameter("prefix_mismatch_mode", DataTypes.StringType); + // List files with prefix operations. Default is false. + private static final ProcedureParameter PREFIX_LISTING_PARAM = + optionalInParameter("prefix_listing", DataTypes.BooleanType); + // Stream results to avoid loading all orphan files in driver memory. Default is false. + private static final ProcedureParameter STREAM_RESULTS_PARAM = + optionalInParameter("stream_results", DataTypes.BooleanType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + TABLE_PARAM, + OLDER_THAN_PARAM, + LOCATION_PARAM, + DRY_RUN_PARAM, + MAX_CONCURRENT_DELETES_PARAM, + FILE_LIST_VIEW_PARAM, + EQUAL_SCHEMES_PARAM, + EQUAL_AUTHORITIES_PARAM, + PREFIX_MISMATCH_MODE_PARAM, + PREFIX_LISTING_PARAM, + STREAM_RESULTS_PARAM + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("orphan_file_location", DataTypes.StringType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected RemoveOrphanFilesProcedure doBuild() { + return new RemoveOrphanFilesProcedure(tableCatalog()); + } + }; + } + + private RemoveOrphanFilesProcedure(TableCatalog catalog) { + super(catalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + Long olderThanMillis = input.asTimestampMillis(OLDER_THAN_PARAM, null); + String location = input.asString(LOCATION_PARAM, null); + boolean dryRun = input.asBoolean(DRY_RUN_PARAM, false); + Integer maxConcurrentDeletes = input.asInt(MAX_CONCURRENT_DELETES_PARAM, null); + String fileListView = input.asString(FILE_LIST_VIEW_PARAM, null); + + Preconditions.checkArgument( + maxConcurrentDeletes == null || maxConcurrentDeletes > 0, + "max_concurrent_deletes should have value > 0, value: %s", + maxConcurrentDeletes); + + Map equalSchemes = input.asStringMap(EQUAL_SCHEMES_PARAM, ImmutableMap.of()); + Map equalAuthorities = + input.asStringMap(EQUAL_AUTHORITIES_PARAM, ImmutableMap.of()); + + PrefixMismatchMode prefixMismatchMode = asPrefixMismatchMode(input, PREFIX_MISMATCH_MODE_PARAM); + + boolean prefixListing = input.asBoolean(PREFIX_LISTING_PARAM, false); + boolean streamResults = input.asBoolean(STREAM_RESULTS_PARAM, false); + + return withIcebergTable( + tableIdent, + table -> { + DeleteOrphanFilesSparkAction action = actions().deleteOrphanFiles(table); + + if (olderThanMillis != null) { + boolean isTesting = Boolean.parseBoolean(spark().conf().get("spark.testing", "false")); + if (!isTesting) { + validateInterval(olderThanMillis); + } + action.olderThan(olderThanMillis); + } + + if (location != null) { + action.location(location); + } + + if (dryRun) { + action.deleteWith(file -> {}); + } + + if (maxConcurrentDeletes != null) { + if (table.io() instanceof SupportsBulkOperations) { + LOG.warn( + "max_concurrent_deletes only works with FileIOs that do not support bulk deletes. This " + + "table is currently using {} which supports bulk deletes so the parameter will be ignored. " + + "See that IO's documentation to learn how to adjust parallelism for that particular " + + "IO's bulk delete.", + table.io().getClass().getName()); + } else { + + action.executeDeleteWith(executorService(maxConcurrentDeletes, "remove-orphans")); + } + } + + if (fileListView != null) { + action.compareToFileList(spark().table(fileListView)); + } + + action.equalSchemes(equalSchemes); + action.equalAuthorities(equalAuthorities); + + if (prefixMismatchMode != null) { + action.prefixMismatchMode(prefixMismatchMode); + } + + action.usePrefixListing(prefixListing); + + if (streamResults) { + action.option("stream-results", "true"); + } + + DeleteOrphanFiles.Result result = action.execute(); + + return asScanIterator(OUTPUT_TYPE, toOutputRows(result)); + }); + } + + private InternalRow[] toOutputRows(DeleteOrphanFiles.Result result) { + Iterable orphanFileLocations = result.orphanFileLocations(); + + int orphanFileLocationsCount = Iterables.size(orphanFileLocations); + InternalRow[] rows = new InternalRow[orphanFileLocationsCount]; + + int index = 0; + for (String fileLocation : orphanFileLocations) { + rows[index] = newInternalRow(UTF8String.fromString(fileLocation)); + index++; + } + + return rows; + } + + private void validateInterval(long olderThanMillis) { + long intervalMillis = System.currentTimeMillis() - olderThanMillis; + if (intervalMillis < TimeUnit.DAYS.toMillis(1)) { + throw new IllegalArgumentException( + "Cannot remove orphan files with an interval less than 24 hours. Executing this " + + "procedure with a short interval may corrupt the table if other operations are happening " + + "at the same time. If you are absolutely confident that no concurrent operations will be " + + "affected by removing orphan files with such a short interval, you can use the Action API " + + "to remove orphan files with an arbitrary interval."); + } + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RemoveOrphanFilesProcedure"; + } + + private PrefixMismatchMode asPrefixMismatchMode(ProcedureInput input, ProcedureParameter param) { + String modeAsString = input.asString(param, null); + return (modeAsString == null) ? null : PrefixMismatchMode.fromString(modeAsString); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java new file mode 100644 index 000000000000..5ab07f4d0a67 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteDataFilesProcedure.java @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteDataFiles; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.NamedReference; +import org.apache.iceberg.expressions.Zorder; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.ExtendedParser; +import org.apache.iceberg.spark.actions.RewriteDataFilesSparkAction; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that rewrites datafiles in a table. + * + * @see org.apache.iceberg.spark.actions.SparkActions#rewriteDataFiles(Table) + */ +class RewriteDataFilesProcedure extends BaseProcedure { + + static final String NAME = "rewrite_data_files"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter STRATEGY_PARAM = + optionalInParameter("strategy", DataTypes.StringType); + private static final ProcedureParameter SORT_ORDER_PARAM = + optionalInParameter("sort_order", DataTypes.StringType); + private static final ProcedureParameter OPTIONS_PARAM = + optionalInParameter("options", STRING_MAP); + private static final ProcedureParameter WHERE_PARAM = + optionalInParameter("where", DataTypes.StringType); + private static final ProcedureParameter BRANCH_PARAM = + optionalInParameter("branch", DataTypes.StringType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + TABLE_PARAM, STRATEGY_PARAM, SORT_ORDER_PARAM, OPTIONS_PARAM, WHERE_PARAM, BRANCH_PARAM + }; + + // counts are not nullable since the action result is never null + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField( + "rewritten_data_files_count", DataTypes.IntegerType, false, Metadata.empty()), + new StructField( + "added_data_files_count", DataTypes.IntegerType, false, Metadata.empty()), + new StructField("rewritten_bytes_count", DataTypes.LongType, false, Metadata.empty()), + new StructField( + "failed_data_files_count", DataTypes.IntegerType, false, Metadata.empty()), + new StructField( + "removed_delete_files_count", DataTypes.IntegerType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new Builder() { + @Override + protected RewriteDataFilesProcedure doBuild() { + return new RewriteDataFilesProcedure(tableCatalog()); + } + }; + } + + private RewriteDataFilesProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + String strategy = input.asString(STRATEGY_PARAM, null); + String sortOrderString = input.asString(SORT_ORDER_PARAM, null); + Map options = input.asStringMap(OPTIONS_PARAM, ImmutableMap.of()); + String where = input.asString(WHERE_PARAM, null); + // Determine target branch: explicit parameter > table branch > main branch + String branchParam = input.asString(BRANCH_PARAM, null); + if (branchParam == null) { + branchParam = loadSparkTable(tableIdent).branch(); + if (branchParam == null) { + branchParam = SnapshotRef.MAIN_BRANCH; + } + } + String branch = branchParam; + + return modifyIcebergTable( + tableIdent, + table -> { + RewriteDataFilesSparkAction action = + actions().rewriteDataFiles(table).options(options).toBranch(branch); + + if (strategy != null || sortOrderString != null) { + action = checkAndApplyStrategy(action, strategy, sortOrderString, table.schema()); + } + + action = checkAndApplyFilter(action, where, tableIdent); + + RewriteDataFiles.Result result = action.execute(); + + return asScanIterator(OUTPUT_TYPE, toOutputRows(result)); + }); + } + + private RewriteDataFilesSparkAction checkAndApplyFilter( + RewriteDataFilesSparkAction action, String where, Identifier ident) { + if (where != null) { + Expression expression = filterExpression(ident, where); + return action.filter(expression); + } + return action; + } + + private RewriteDataFilesSparkAction checkAndApplyStrategy( + RewriteDataFilesSparkAction action, String strategy, String sortOrderString, Schema schema) { + List zOrderTerms = Lists.newArrayList(); + List sortOrderFields = Lists.newArrayList(); + if (sortOrderString != null) { + ExtendedParser.parseSortOrder(spark(), sortOrderString) + .forEach( + field -> { + if (field.term() instanceof Zorder) { + zOrderTerms.add((Zorder) field.term()); + } else { + sortOrderFields.add(field); + } + }); + + if (!zOrderTerms.isEmpty() && !sortOrderFields.isEmpty()) { + // TODO: we need to allow this in future when SparkAction has handling for this. + throw new IllegalArgumentException( + "Cannot mix identity sort columns and a Zorder sort expression: " + sortOrderString); + } + } + + // caller of this function ensures that between strategy and sortOrder, at least one of them is + // not null. + if (strategy == null || strategy.equalsIgnoreCase("sort")) { + if (!zOrderTerms.isEmpty()) { + String[] columnNames = + zOrderTerms.stream() + .flatMap(zOrder -> zOrder.refs().stream().map(NamedReference::name)) + .toArray(String[]::new); + return action.zOrder(columnNames); + } else if (!sortOrderFields.isEmpty()) { + return action.sort(buildSortOrder(sortOrderFields, schema)); + } else { + return action.sort(); + } + } + if (strategy.equalsIgnoreCase("binpack")) { + RewriteDataFilesSparkAction binPackAction = action.binPack(); + if (sortOrderString != null) { + // calling below method to throw the error as user has set both binpack strategy and sort + // order + return binPackAction.sort(buildSortOrder(sortOrderFields, schema)); + } + return binPackAction; + } else { + throw new IllegalArgumentException( + "unsupported strategy: " + strategy + ". Only binpack or sort is supported"); + } + } + + private SortOrder buildSortOrder( + List rawOrderFields, Schema schema) { + SortOrder.Builder builder = SortOrder.builderFor(schema); + rawOrderFields.forEach( + rawField -> builder.sortBy(rawField.term(), rawField.direction(), rawField.nullOrder())); + return builder.build(); + } + + private InternalRow[] toOutputRows(RewriteDataFiles.Result result) { + int rewrittenDataFilesCount = result.rewrittenDataFilesCount(); + long rewrittenBytesCount = result.rewrittenBytesCount(); + int addedDataFilesCount = result.addedDataFilesCount(); + int failedDataFilesCount = result.failedDataFilesCount(); + int removedDeleteFilesCount = result.removedDeleteFilesCount(); + + InternalRow row = + newInternalRow( + rewrittenDataFilesCount, + addedDataFilesCount, + rewrittenBytesCount, + failedDataFilesCount, + removedDeleteFilesCount); + return new InternalRow[] {row}; + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RewriteDataFilesProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteManifestsProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteManifestsProcedure.java new file mode 100644 index 000000000000..865ae3d1b607 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteManifestsProcedure.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Arrays; +import java.util.Iterator; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteManifests; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.spark.actions.RewriteManifestsSparkAction; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that rewrites manifests in a table. + * + *

    Note: this procedure invalidates all cached Spark plans that reference the affected + * table. + * + * @see SparkActions#rewriteManifests(Table) () + */ +class RewriteManifestsProcedure extends BaseProcedure { + + static final String NAME = "rewrite_manifests"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter USE_CACHING_PARAM = + optionalInParameter("use_caching", DataTypes.BooleanType); + private static final ProcedureParameter SPEC_ID_PARAM = + optionalInParameter("spec_id", DataTypes.IntegerType); + private static final ProcedureParameter SORT_BY_PARAM = + optionalInParameter("sort_by", STRING_ARRAY); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, USE_CACHING_PARAM, SPEC_ID_PARAM, SORT_BY_PARAM}; + + // counts are not nullable since the action result is never null + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField( + "rewritten_manifests_count", DataTypes.IntegerType, false, Metadata.empty()), + new StructField("added_manifests_count", DataTypes.IntegerType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected RewriteManifestsProcedure doBuild() { + return new RewriteManifestsProcedure(tableCatalog()); + } + }; + } + + private RewriteManifestsProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + Boolean useCaching = input.asBoolean(USE_CACHING_PARAM, null); + Integer specId = input.asInt(SPEC_ID_PARAM, null); + String[] sortBy = input.asStringArray(SORT_BY_PARAM, null); + + return modifyIcebergTable( + tableIdent, + table -> { + RewriteManifestsSparkAction action = actions().rewriteManifests(table); + + if (useCaching != null) { + action.option(RewriteManifestsSparkAction.USE_CACHING, useCaching.toString()); + } + + if (specId != null) { + action.specId(specId); + } + + if (sortBy != null) { + Preconditions.checkArgument( + sortBy.length > 0, "sort_by must not be empty when provided"); + action.sortBy(Arrays.asList(sortBy)); + } + + RewriteManifests.Result result = action.execute(); + + return asScanIterator(OUTPUT_TYPE, toOutputRows(result)); + }); + } + + private InternalRow[] toOutputRows(RewriteManifests.Result result) { + int rewrittenManifestsCount = Iterables.size(result.rewrittenManifests()); + int addedManifestsCount = Iterables.size(result.addedManifests()); + InternalRow row = newInternalRow(rewrittenManifestsCount, addedManifestsCount); + return new InternalRow[] {row}; + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RewriteManifestsProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewritePositionDeleteFilesProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewritePositionDeleteFilesProcedure.java new file mode 100644 index 000000000000..ae130cfd5208 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewritePositionDeleteFilesProcedure.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import java.util.Map; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewritePositionDeleteFiles; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that rewrites position delete files in a table. + * + * @see org.apache.iceberg.spark.actions.SparkActions#rewritePositionDeletes(Table) + */ +public class RewritePositionDeleteFilesProcedure extends BaseProcedure { + + static final String NAME = "rewrite_position_delete_files"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter OPTIONS_PARAM = + optionalInParameter("options", STRING_MAP); + private static final ProcedureParameter WHERE_PARAM = + optionalInParameter("where", DataTypes.StringType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, OPTIONS_PARAM, WHERE_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField( + "rewritten_delete_files_count", DataTypes.IntegerType, false, Metadata.empty()), + new StructField( + "added_delete_files_count", DataTypes.IntegerType, false, Metadata.empty()), + new StructField("rewritten_bytes_count", DataTypes.LongType, false, Metadata.empty()), + new StructField("added_bytes_count", DataTypes.LongType, false, Metadata.empty()) + }); + + public static SparkProcedures.ProcedureBuilder builder() { + return new Builder() { + @Override + protected RewritePositionDeleteFilesProcedure doBuild() { + return new RewritePositionDeleteFilesProcedure(tableCatalog()); + } + }; + } + + private RewritePositionDeleteFilesProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + Map options = input.asStringMap(OPTIONS_PARAM, ImmutableMap.of()); + String where = input.asString(WHERE_PARAM, null); + + return modifyIcebergTable( + tableIdent, + table -> { + RewritePositionDeleteFiles action = + actions().rewritePositionDeletes(table).options(options); + + if (where != null) { + Expression whereExpression = filterExpression(tableIdent, where); + action = action.filter(whereExpression); + } + + RewritePositionDeleteFiles.Result result = action.execute(); + return asScanIterator(OUTPUT_TYPE, toOutputRow(result)); + }); + } + + private InternalRow toOutputRow(RewritePositionDeleteFiles.Result result) { + return newInternalRow( + result.rewrittenDeleteFilesCount(), + result.addedDeleteFilesCount(), + result.rewrittenBytesCount(), + result.addedBytesCount()); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RewritePositionDeleteFilesProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteTablePathProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteTablePathProcedure.java new file mode 100644 index 000000000000..f40ead013b64 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RewriteTablePathProcedure.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.actions.RewriteTablePath; +import org.apache.iceberg.spark.actions.RewriteTablePathSparkAction; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +public class RewriteTablePathProcedure extends BaseProcedure { + + static final String NAME = "rewrite_table_path"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SOURCE_PREFIX_PARAM = + requiredInParameter("source_prefix", DataTypes.StringType); + private static final ProcedureParameter TARGET_PREFIX_PARAM = + requiredInParameter("target_prefix", DataTypes.StringType); + private static final ProcedureParameter START_VERSION_PARAM = + optionalInParameter("start_version", DataTypes.StringType); + private static final ProcedureParameter END_VERSION_PARM = + optionalInParameter("end_version", DataTypes.StringType); + private static final ProcedureParameter STAGING_LOCATION_PARAM = + optionalInParameter("staging_location", DataTypes.StringType); + private static final ProcedureParameter CREATE_FILE_LIST_PARAM = + optionalInParameter("create_file_list", DataTypes.BooleanType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + TABLE_PARAM, + SOURCE_PREFIX_PARAM, + TARGET_PREFIX_PARAM, + START_VERSION_PARAM, + END_VERSION_PARM, + STAGING_LOCATION_PARAM, + CREATE_FILE_LIST_PARAM + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("latest_version", DataTypes.StringType, true, Metadata.empty()), + new StructField("file_list_location", DataTypes.StringType, true, Metadata.empty()), + new StructField( + "rewritten_manifest_file_paths_count", + DataTypes.IntegerType, + true, + Metadata.empty()), + new StructField( + "rewritten_delete_file_paths_count", DataTypes.IntegerType, true, Metadata.empty()) + }); + + public static SparkProcedures.ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected RewriteTablePathProcedure doBuild() { + return new RewriteTablePathProcedure(tableCatalog()); + } + }; + } + + private RewriteTablePathProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + String sourcePrefix = input.asString(SOURCE_PREFIX_PARAM); + String targetPrefix = input.asString(TARGET_PREFIX_PARAM); + String startVersion = input.asString(START_VERSION_PARAM, null); + String endVersion = input.asString(END_VERSION_PARM, null); + String stagingLocation = input.asString(STAGING_LOCATION_PARAM, null); + boolean createFileList = input.asBoolean(CREATE_FILE_LIST_PARAM, true); + + return withIcebergTable( + tableIdent, + table -> { + RewriteTablePathSparkAction action = SparkActions.get().rewriteTablePath(table); + + if (startVersion != null) { + action.startVersion(startVersion); + } + if (endVersion != null) { + action.endVersion(endVersion); + } + if (stagingLocation != null) { + action.stagingLocation(stagingLocation); + } + + action.createFileList(createFileList); + + return asScanIterator( + OUTPUT_TYPE, + toOutputRows(action.rewriteLocationPrefix(sourcePrefix, targetPrefix).execute())); + }); + } + + private InternalRow[] toOutputRows(RewriteTablePath.Result result) { + return new InternalRow[] { + newInternalRow( + UTF8String.fromString(result.latestVersion()), + UTF8String.fromString(result.fileListLocation()), + result.rewrittenManifestFilePathsCount(), + result.rewrittenDeleteFilePathsCount()) + }; + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RewriteTablePathProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RollbackToSnapshotProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RollbackToSnapshotProcedure.java new file mode 100644 index 000000000000..98e1e2b870f7 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RollbackToSnapshotProcedure.java @@ -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. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that rollbacks a table to a specific snapshot id. + * + *

    Note: this procedure invalidates all cached Spark plans that reference the affected + * table. + * + * @see org.apache.iceberg.ManageSnapshots#rollbackTo(long) + */ +class RollbackToSnapshotProcedure extends BaseProcedure { + + static final String NAME = "rollback_to_snapshot"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SNAPSHOT_ID_PARAM = + requiredInParameter("snapshot_id", DataTypes.LongType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, SNAPSHOT_ID_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("previous_snapshot_id", DataTypes.LongType, false, Metadata.empty()), + new StructField("current_snapshot_id", DataTypes.LongType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + public RollbackToSnapshotProcedure doBuild() { + return new RollbackToSnapshotProcedure(tableCatalog()); + } + }; + } + + private RollbackToSnapshotProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + long snapshotId = input.asLong(SNAPSHOT_ID_PARAM); + + return modifyIcebergTable( + tableIdent, + table -> { + Snapshot previousSnapshot = table.currentSnapshot(); + + table.manageSnapshots().rollbackTo(snapshotId).commit(); + + InternalRow outputRow = newInternalRow(previousSnapshot.snapshotId(), snapshotId); + return asScanIterator(OUTPUT_TYPE, outputRow); + }); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RollbackToSnapshotProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RollbackToTimestampProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RollbackToTimestampProcedure.java new file mode 100644 index 000000000000..fd6791df28de --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/RollbackToTimestampProcedure.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that rollbacks a table to a given point in time. + * + *

    Note: this procedure invalidates all cached Spark plans that reference the affected + * table. + * + * @see org.apache.iceberg.ManageSnapshots#rollbackToTime(long) + */ +class RollbackToTimestampProcedure extends BaseProcedure { + + static final String NAME = "rollback_to_timestamp"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter TIMESTAMP_PARAM = + requiredInParameter("timestamp", DataTypes.TimestampType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, TIMESTAMP_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("previous_snapshot_id", DataTypes.LongType, false, Metadata.empty()), + new StructField("current_snapshot_id", DataTypes.LongType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected RollbackToTimestampProcedure doBuild() { + return new RollbackToTimestampProcedure(tableCatalog()); + } + }; + } + + private RollbackToTimestampProcedure(TableCatalog catalog) { + super(catalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + // timestamps in Spark have microsecond precision so this conversion is lossy + long timestampMillis = input.asTimestampMillis(TIMESTAMP_PARAM); + + return modifyIcebergTable( + tableIdent, + table -> { + Snapshot previousSnapshot = table.currentSnapshot(); + + table.manageSnapshots().rollbackToTime(timestampMillis).commit(); + + Snapshot currentSnapshot = table.currentSnapshot(); + + InternalRow outputRow = + newInternalRow(previousSnapshot.snapshotId(), currentSnapshot.snapshotId()); + return asScanIterator(OUTPUT_TYPE, outputRow); + }); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "RollbackToTimestampProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SetCurrentSnapshotProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SetCurrentSnapshotProcedure.java new file mode 100644 index 000000000000..18e3646cf529 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SetCurrentSnapshotProcedure.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.procedures.SparkProcedures.ProcedureBuilder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * A procedure that sets the current snapshot in a table. + * + *

    Note: this procedure invalidates all cached Spark plans that reference the affected + * table. + * + * @see org.apache.iceberg.ManageSnapshots#setCurrentSnapshot(long) + */ +class SetCurrentSnapshotProcedure extends BaseProcedure { + + static final String NAME = "set_current_snapshot"; + + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter SNAPSHOT_ID_PARAM = + optionalInParameter("snapshot_id", DataTypes.LongType); + private static final ProcedureParameter REF_PARAM = + optionalInParameter("ref", DataTypes.StringType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] {TABLE_PARAM, SNAPSHOT_ID_PARAM, REF_PARAM}; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("previous_snapshot_id", DataTypes.LongType, true, Metadata.empty()), + new StructField("current_snapshot_id", DataTypes.LongType, false, Metadata.empty()) + }); + + public static ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected SetCurrentSnapshotProcedure doBuild() { + return new SetCurrentSnapshotProcedure(tableCatalog()); + } + }; + } + + private SetCurrentSnapshotProcedure(TableCatalog catalog) { + super(catalog); + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + Identifier tableIdent = input.ident(TABLE_PARAM); + Long snapshotId = input.asLong(SNAPSHOT_ID_PARAM, null); + String ref = input.asString(REF_PARAM, null); + Preconditions.checkArgument( + (snapshotId != null && ref == null) || (snapshotId == null && ref != null), + "Either snapshot_id or ref must be provided, not both"); + + return modifyIcebergTable( + tableIdent, + table -> { + Snapshot previousSnapshot = table.currentSnapshot(); + Long previousSnapshotId = previousSnapshot != null ? previousSnapshot.snapshotId() : null; + + long targetSnapshotId = snapshotId != null ? snapshotId : toSnapshotId(table, ref); + table.manageSnapshots().setCurrentSnapshot(targetSnapshotId).commit(); + + InternalRow outputRow = newInternalRow(previousSnapshotId, targetSnapshotId); + return asScanIterator(OUTPUT_TYPE, outputRow); + }); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "SetCurrentSnapshotProcedure"; + } + + private long toSnapshotId(Table table, String refName) { + SnapshotRef ref = table.refs().get(refName); + ValidationException.check(ref != null, "Cannot find matching snapshot ID for ref %s", refName); + return ref.snapshotId(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SnapshotTableProcedure.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SnapshotTableProcedure.java new file mode 100644 index 000000000000..1498e3b56864 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SnapshotTableProcedure.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Iterator; +import java.util.Map; +import org.apache.iceberg.actions.SnapshotTable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.BoundProcedure; +import org.apache.spark.sql.connector.catalog.procedures.ProcedureParameter; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +class SnapshotTableProcedure extends BaseProcedure { + + static final String NAME = "snapshot"; + + private static final ProcedureParameter SOURCE_TABLE_PARAM = + requiredInParameter("source_table", DataTypes.StringType); + private static final ProcedureParameter TABLE_PARAM = + requiredInParameter("table", DataTypes.StringType); + private static final ProcedureParameter LOCATION_PARAM = + optionalInParameter("location", DataTypes.StringType); + private static final ProcedureParameter PROPERTIES_PARAM = + optionalInParameter("properties", STRING_MAP); + private static final ProcedureParameter PARALLELISM_PARAM = + optionalInParameter("parallelism", DataTypes.IntegerType); + + private static final ProcedureParameter[] PARAMETERS = + new ProcedureParameter[] { + SOURCE_TABLE_PARAM, TABLE_PARAM, LOCATION_PARAM, PROPERTIES_PARAM, PARALLELISM_PARAM + }; + + private static final StructType OUTPUT_TYPE = + new StructType( + new StructField[] { + new StructField("imported_files_count", DataTypes.LongType, false, Metadata.empty()) + }); + + private SnapshotTableProcedure(TableCatalog tableCatalog) { + super(tableCatalog); + } + + public static SparkProcedures.ProcedureBuilder builder() { + return new BaseProcedure.Builder() { + @Override + protected SnapshotTableProcedure doBuild() { + return new SnapshotTableProcedure(tableCatalog()); + } + }; + } + + @Override + public BoundProcedure bind(StructType inputType) { + return this; + } + + @Override + public ProcedureParameter[] parameters() { + return PARAMETERS; + } + + @Override + public Iterator call(InternalRow args) { + ProcedureInput input = new ProcedureInput(spark(), tableCatalog(), PARAMETERS, args); + + String source = input.asString(SOURCE_TABLE_PARAM, null); + Preconditions.checkArgument( + source != null && !source.isEmpty(), + "Cannot handle an empty identifier for argument source_table"); + + String dest = input.asString(TABLE_PARAM, null); + Preconditions.checkArgument( + dest != null && !dest.isEmpty(), "Cannot handle an empty identifier for argument table"); + + String snapshotLocation = input.asString(LOCATION_PARAM, null); + + Map properties = input.asStringMap(PROPERTIES_PARAM, ImmutableMap.of()); + + Preconditions.checkArgument( + !source.equals(dest), + "Cannot create a snapshot with the same name as the source of the snapshot."); + SnapshotTable action = SparkActions.get().snapshotTable(source).as(dest); + + if (snapshotLocation != null) { + action.tableLocation(snapshotLocation); + } + + if (input.isProvided(PARALLELISM_PARAM)) { + int parallelism = input.asInt(PARALLELISM_PARAM); + Preconditions.checkArgument(parallelism > 0, "Parallelism should be larger than 0"); + action = action.executeWith(SparkTableUtil.migrationService(parallelism)); + } + + SnapshotTable.Result result = action.tableProperties(properties).execute(); + return asScanIterator(OUTPUT_TYPE, newInternalRow(result.importedDataFilesCount())); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "SnapshotTableProcedure"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SparkProcedures.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SparkProcedures.java new file mode 100644 index 000000000000..bad31a12c19a --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/procedures/SparkProcedures.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.procedures; + +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.procedures.UnboundProcedure; + +public class SparkProcedures { + + private static final Map> BUILDERS = initProcedureBuilders(); + + private SparkProcedures() {} + + public static ProcedureBuilder newBuilder(String name) { + // procedure resolution is case insensitive to match the existing Spark behavior for functions + Supplier builderSupplier = BUILDERS.get(name.toLowerCase(Locale.ROOT)); + return builderSupplier != null ? builderSupplier.get() : null; + } + + public static Set names() { + return BUILDERS.keySet(); + } + + private static Map> initProcedureBuilders() { + ImmutableMap.Builder> mapBuilder = ImmutableMap.builder(); + mapBuilder.put(RollbackToSnapshotProcedure.NAME, RollbackToSnapshotProcedure::builder); + mapBuilder.put(RollbackToTimestampProcedure.NAME, RollbackToTimestampProcedure::builder); + mapBuilder.put(SetCurrentSnapshotProcedure.NAME, SetCurrentSnapshotProcedure::builder); + mapBuilder.put(CherrypickSnapshotProcedure.NAME, CherrypickSnapshotProcedure::builder); + mapBuilder.put(RewriteDataFilesProcedure.NAME, RewriteDataFilesProcedure::builder); + mapBuilder.put(RewriteManifestsProcedure.NAME, RewriteManifestsProcedure::builder); + mapBuilder.put(RemoveOrphanFilesProcedure.NAME, RemoveOrphanFilesProcedure::builder); + mapBuilder.put(ExpireSnapshotsProcedure.NAME, ExpireSnapshotsProcedure::builder); + mapBuilder.put(MigrateTableProcedure.NAME, MigrateTableProcedure::builder); + mapBuilder.put(SnapshotTableProcedure.NAME, SnapshotTableProcedure::builder); + mapBuilder.put(AddFilesProcedure.NAME, AddFilesProcedure::builder); + mapBuilder.put(AncestorsOfProcedure.NAME, AncestorsOfProcedure::builder); + mapBuilder.put(RegisterTableProcedure.NAME, RegisterTableProcedure::builder); + mapBuilder.put(PublishChangesProcedure.NAME, PublishChangesProcedure::builder); + mapBuilder.put(CreateChangelogViewProcedure.NAME, CreateChangelogViewProcedure::builder); + mapBuilder.put( + RewritePositionDeleteFilesProcedure.NAME, RewritePositionDeleteFilesProcedure::builder); + mapBuilder.put(FastForwardBranchProcedure.NAME, FastForwardBranchProcedure::builder); + mapBuilder.put(ComputeTableStatsProcedure.NAME, ComputeTableStatsProcedure::builder); + mapBuilder.put(ComputePartitionStatsProcedure.NAME, ComputePartitionStatsProcedure::builder); + mapBuilder.put(RewriteTablePathProcedure.NAME, RewriteTablePathProcedure::builder); + return mapBuilder.build(); + } + + public interface ProcedureBuilder { + ProcedureBuilder withTableCatalog(TableCatalog tableCatalog); + + UnboundProcedure build(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..3e442f9917d4 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java @@ -0,0 +1,543 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.connector.read.streaming.ReadAllAvailable; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class AsyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(AsyncSparkMicroBatchPlanner.class); + private static final int PLAN_FILES_CACHE_MAX_SIZE = 10; + private static final long QUEUE_POLL_TIMEOUT_MS = 100L; // 100 ms + + private final long minQueuedFiles; + private final long minQueuedRows; + + // Cache for planFiles results to handle duplicate calls + private final Cache, List> planFilesCache; + + // Queue to buffer pre-fetched file scan tasks + private final LinkedBlockingDeque> queue; + + // Background executor for async operations + private final ScheduledExecutorService executor; + + // Error tracking + private volatile Throwable refreshFailedThrowable; + private volatile Throwable fillQueueFailedThrowable; + + // Tracking queue state + private final AtomicLong queuedFileCount = new AtomicLong(0); + private final AtomicLong queuedRowCount = new AtomicLong(0); + private Snapshot lastQueuedSnapshot; + private boolean stopped; + + // Cap for Trigger.AvailableNow - don't process beyond this offset + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + /** + * This class manages a queue of FileScanTask + StreamingOffset. On creation, it starts up an + * asynchronous polling process which populates the queue when a new snapshot arrives or the + * minimum amount of queued data is too low. + * + *

    Note: this will capture the state of the table when snapshots are added to the queue. If a + * snapshot is expired after being added to the queue, the job will still process it. + */ + AsyncSparkMicroBatchPlanner( + Table table, + SparkReadConf readConf, + StreamingOffset initialOffset, + StreamingOffset maybeEndOffset, + StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.minQueuedFiles = readConf().maxFilesPerMicroBatch(); + this.minQueuedRows = readConf().maxRecordsPerMicroBatch(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + this.planFilesCache = Caffeine.newBuilder().maximumSize(PLAN_FILES_CACHE_MAX_SIZE).build(); + this.queue = new LinkedBlockingDeque<>(); + + table().refresh(); + + // Synchronously add data to the queue to meet our initial constraints. + // For Trigger.AvailableNow, constructor-time preload is normally initialized from + // latestOffset(...) with no explicit end offset, so bounded preload must stop at + // Trigger.AvailableNow snapshot. + fillQueue(initialOffset, maybeEndOffset); + + this.executor = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread thread = new Thread(r, "iceberg-async-planner-" + table().name()); + thread.setDaemon(true); + return thread; + }); + // Schedule table refresh at configured interval + long pollingIntervalMs = readConf().streamingSnapshotPollingIntervalMs(); + this.executor.scheduleWithFixedDelay( + this::refreshAndTrapException, pollingIntervalMs, pollingIntervalMs, TimeUnit.MILLISECONDS); + // Schedule queue fill to run frequently (use polling interval for tests, cap at 100ms for + // production) + long queueFillIntervalMs = Math.min(QUEUE_POLL_TIMEOUT_MS, pollingIntervalMs); + executor.scheduleWithFixedDelay( + () -> fillQueueAndTrapException(lastQueuedSnapshot), + 0, + queueFillIntervalMs, + TimeUnit.MILLISECONDS); + + LOG.info( + "Started AsyncSparkMicroBatchPlanner for {} from initialOffset: {}", + table().name(), + initialOffset); + } + + @Override + public synchronized void stop() { + Preconditions.checkArgument( + !stopped, "AsyncSparkMicroBatchPlanner for {} was already stopped", table().name()); + stopped = true; + LOG.info("Stopping AsyncSparkMicroBatchPlanner for table: {}", table().name()); + executor.shutdownNow(); + boolean terminated = false; + try { + terminated = + executor.awaitTermination( + readConf().streamingSnapshotPollingIntervalMs() * 2, TimeUnit.MILLISECONDS); + } catch (InterruptedException ignored) { + // Restore interrupt status + Thread.currentThread().interrupt(); + } + LOG.info("AsyncSparkMicroBatchPlanner for table: {}, stopped: {}", table().name(), terminated); + } + + @Override + public void close() { + stop(); + } + + /** + * Spark can call this multiple times; it should produce the same answer every time. + * + * @param startOffset the starting offset of this microbatch, position is inclusive + * @param endOffset the end offset of this microbatch, position is exclusive + * @return the list of files to scan between these offsets + */ + @Override + public synchronized List planFiles( + StreamingOffset startOffset, StreamingOffset endOffset) { + return planFilesCache.get( + Pair.of(startOffset, endOffset), + key -> { + LOG.info( + "running planFiles for {}, startOffset: {}, endOffset: {}", + table().name(), + startOffset, + endOffset); + List result = new LinkedList<>(); + Pair elem; + StreamingOffset currentOffset; + boolean shouldTerminate = false; + long filesInPlan = 0; + long rowsInPlan = 0; + + do { + try { + elem = queue.pollFirst(QUEUE_POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while polling queue", e); + } + + if (elem != null) { + currentOffset = elem.first(); + LOG.debug("planFiles consumed: {}", currentOffset); + FileScanTask currentTask = elem.second(); + filesInPlan += 1; + long elemRows = currentTask.file().recordCount(); + rowsInPlan += elemRows; + queuedFileCount.decrementAndGet(); + queuedRowCount.addAndGet(-elemRows); + result.add(currentTask); + + // try to peek at the next entry of the queue and see if we should stop + Pair nextElem = queue.peekFirst(); + boolean endOffsetPeek = false; + if (nextElem != null) { + endOffsetPeek = endOffset.equals(nextElem.first()); + } + // end offset may be synthetic and not exist in the queue + boolean endOffsetSynthetic = + currentOffset.snapshotId() == endOffset.snapshotId() + && (currentOffset.position() + 1) == endOffset.position(); + shouldTerminate = endOffsetPeek || endOffsetSynthetic; + } else { + LOG.trace("planFiles hasn't reached {}, waiting", endOffset); + } + } while (!shouldTerminate + && refreshFailedThrowable == null + && fillQueueFailedThrowable == null); + + if (refreshFailedThrowable != null) { + throw new RuntimeException("Table refresh failed", refreshFailedThrowable); + } + + if (fillQueueFailedThrowable != null) { + throw new RuntimeException("Queue filling failed", fillQueueFailedThrowable); + } + + LOG.info( + "completed planFiles for {}, startOffset: {}, endOffset: {}, files: {}, rows: {}", + table().name(), + startOffset, + endOffset, + filesInPlan, + rowsInPlan); + return result; + }); + } + + /** + * This needs to be non destructive on the queue as spark could call this multiple times. Each + * time, depending on the table state it could return something different + * + * @param startOffset the starting offset of the next microbatch + * @param limit a limit for how many files/bytes/rows the next microbatch should include + * @return The end offset to use for the next microbatch, null signals that no data is available + */ + @Override + public synchronized StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + LOG.info( + "running latestOffset for {}, startOffset: {}, limit: {}", + table().name(), + startOffset, + limit); + + if (table().currentSnapshot() == null) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() is null"); + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < readConf().streamFromTimestamp()) { + LOG.info("latestOffset returning START_OFFSET, currentSnapshot() < fromTimestamp"); + return StreamingOffset.START_OFFSET; + } + + // if any exceptions were encountered in the background process, raise them here + if (refreshFailedThrowable != null) { + throw new RuntimeException(refreshFailedThrowable); + } + if (fillQueueFailedThrowable != null) { + throw new RuntimeException(fillQueueFailedThrowable); + } + + // if we want to read all available we don't need to scan files, just snapshots + if (limit instanceof ReadAllAvailable) { + // If Trigger.AvailableNow cap is set, return it directly + if (this.lastOffsetForTriggerAvailableNow != null) { + return this.lastOffsetForTriggerAvailableNow; + } + Snapshot lastValidSnapshot = table().snapshot(startOffset.snapshotId()); + Snapshot nextValidSnapshot; + do { + nextValidSnapshot = nextValidSnapshot(lastValidSnapshot); + if (nextValidSnapshot != null) { + lastValidSnapshot = nextValidSnapshot; + } + } while (nextValidSnapshot != null); + return new StreamingOffset( + lastValidSnapshot.snapshotId(), + MicroBatchUtils.addedFilesCount(table(), lastValidSnapshot), + false); + } + + return computeLimitedOffset(limit); + } + + private StreamingOffset computeLimitedOffset(ReadLimit limit) { + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long rowsSeen = 0; + long filesSeen = 0; + LOG.debug( + "latestOffset queue status, queuedFiles: {}, queuedRows: {}", + queuedFileCount.get(), + queuedRowCount.get()); + + List> queueSnapshot = Lists.newArrayList(queue); + Pair queueTail = + queueSnapshot.isEmpty() ? null : queueSnapshot.get(queueSnapshot.size() - 1); + + for (int i = 0; i < queueSnapshot.size(); i++) { + Pair elem = queueSnapshot.get(i); + long fileRows = elem.second().file().recordCount(); + + // Hard limit on files - stop BEFORE exceeding + if (filesSeen + 1 > unpackedLimits.getMaxFiles()) { + if (filesSeen == 0) { + return null; + } + LOG.debug( + "latestOffset hit file limit at {}, rows: {}, files: {}", + elem.first(), + rowsSeen, + filesSeen); + return elem.first(); + } + + // Soft limit on rows - include file FIRST, then check + rowsSeen += fileRows; + filesSeen += 1; + + // Check if we've hit the row limit after including this file + if (rowsSeen >= unpackedLimits.getMaxRows()) { + if (filesSeen == 1 && rowsSeen > unpackedLimits.getMaxRows()) { + LOG.warn( + "File {} at offset {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + elem.second().file().location(), + elem.first(), + fileRows, + unpackedLimits.getMaxRows()); + } + // Return the offset of the NEXT element (or synthesize tail+1) + if (i + 1 < queueSnapshot.size()) { + LOG.debug( + "latestOffset hit row limit at {}, rows: {}, files: {}", + queueSnapshot.get(i + 1).first(), + rowsSeen, + filesSeen); + return queueSnapshot.get(i + 1).first(); + } else { + // This is the last element - return tail+1 + StreamingOffset current = elem.first(); + StreamingOffset result = + new StreamingOffset( + current.snapshotId(), current.position() + 1, current.shouldScanAllFiles()); + LOG.debug( + "latestOffset hit row limit at tail {}, rows: {}, files: {}", + result, + rowsSeen, + filesSeen); + return result; + } + } + } + + // if we got here there aren't enough files to exceed our limits + if (queueTail != null) { + StreamingOffset tailOffset = queueTail.first(); + // we have to increment the position by 1 since we want to include the tail in the read and + // position is non-inclusive + StreamingOffset latestOffset = + new StreamingOffset( + tailOffset.snapshotId(), tailOffset.position() + 1, tailOffset.shouldScanAllFiles()); + LOG.debug("latestOffset returning all queued data {}", latestOffset); + return latestOffset; + } + + // if we got here the queue is empty + LOG.debug("latestOffset no data, returning null"); + return null; + } + + // Background task wrapper that traps exceptions + private void refreshAndTrapException() { + try { + table().refresh(); + } catch (Throwable t) { + LOG.error("Failed to refresh table {}", table().name(), t); + refreshFailedThrowable = t; + } + } + + // Background task wrapper that traps exceptions + private void fillQueueAndTrapException(Snapshot snapshot) { + try { + fillQueue(snapshot); + } catch (Throwable t) { + LOG.error("Failed to fill queue for table {}", table().name(), t); + fillQueueFailedThrowable = t; + } + } + + /** Generate a MicroBatch based on input parameters and add to the queue */ + private void addMicroBatchToQueue( + Snapshot snapshot, long startFileIndex, long endFileIndex, boolean shouldScanAllFile) { + LOG.info("Adding MicroBatch for snapshot: {} to the queue", snapshot.snapshotId()); + MicroBatches.MicroBatch microBatch = + MicroBatches.from(snapshot, table().io()) + .caseSensitive(readConf().caseSensitive()) + .specsById(table().specs()) + .generate(startFileIndex, endFileIndex, Long.MAX_VALUE, shouldScanAllFile); + + long position = startFileIndex; + for (FileScanTask task : microBatch.tasks()) { + Pair elem = + Pair.of(new StreamingOffset(microBatch.snapshotId(), position, shouldScanAllFile), task); + queuedFileCount.incrementAndGet(); + queuedRowCount.addAndGet(task.file().recordCount()); + queue.addLast(elem); + position += 1; + } + if (LOG.isDebugEnabled()) { + StringBuilder sb = new StringBuilder("\n"); + for (Pair elem : queue) { + sb.append(elem.first()).append("\n"); + } + LOG.debug(sb.toString()); + } + lastQueuedSnapshot = snapshot; + } + + private void fillQueue(StreamingOffset fromOffset, StreamingOffset toOffset) { + LOG.debug("filling queue from {}, to: {}", fromOffset, toOffset); + Snapshot currentSnapshot = table().snapshot(fromOffset.snapshotId()); + // this could be a partial snapshot so add it outside the loop + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + fromOffset.position(), + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + fromOffset.shouldScanAllFiles()); + } + if (toOffset != null) { + if (currentSnapshot != null) { + while (currentSnapshot.snapshotId() != toOffset.snapshotId()) { + currentSnapshot = nextValidSnapshot(currentSnapshot); + if (currentSnapshot != null) { + addMicroBatchToQueue( + currentSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), currentSnapshot), + false); + } else { + break; + } + } + } + // toOffset snapshot already added in loop when currentSnapshot == toOffset + } else { + fillQueueInitialBuffer(currentSnapshot); + } + } + + private void fillQueueInitialBuffer(Snapshot startSnapshot) { + // toOffset is null - fill initial buffer to prevent queue starvation before background + // thread starts. Use configured limits to avoid loading all snapshots + // (which could cause OOM on tables with thousands of snapshots). + long targetRows = readConf().asyncQueuePreloadRowLimit(); + long targetFiles = readConf().asyncQueuePreloadFileLimit(); + + Snapshot preloadEndSnapshot = initialPreloadEndSnapshot(); + if (preloadEndSnapshot == null) { + return; // Empty table + } + + // START_OFFSET case: initialize using nextValidSnapshot which respects timestamp filtering + Snapshot current = startSnapshot; + if (current == null) { + current = nextValidSnapshot(null); + if (current != null) { + addMicroBatchToQueue(current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } + } + + // Continue loading more snapshots within safety limits + if (current != null) { + while ((queuedRowCount.get() < targetRows || queuedFileCount.get() < targetFiles) + && current.snapshotId() != preloadEndSnapshot.snapshotId()) { + current = nextValidSnapshot(current); + if (current != null) { + addMicroBatchToQueue( + current, 0, MicroBatchUtils.addedFilesCount(table(), current), false); + } else { + break; + } + } + } + } + + private Snapshot initialPreloadEndSnapshot() { + if (lastOffsetForTriggerAvailableNow != null) { + return table().snapshot(lastOffsetForTriggerAvailableNow.snapshotId()); + } + + return table().currentSnapshot(); + } + + @VisibleForTesting + static boolean reachedAvailableNowCap( + Snapshot readFrom, StreamingOffset lastOffsetForTriggerAvailableNow) { + return lastOffsetForTriggerAvailableNow != null + && readFrom != null + && readFrom.snapshotId() == lastOffsetForTriggerAvailableNow.snapshotId(); + } + + /** Try to populate the queue with data from unread snapshots */ + private void fillQueue(Snapshot readFrom) { + // Don't add beyond cap for Trigger.AvailableNow + if (reachedAvailableNowCap(readFrom, lastOffsetForTriggerAvailableNow)) { + LOG.debug( + "Reached cap snapshot {}, not adding more", + this.lastOffsetForTriggerAvailableNow.snapshotId()); + return; + } + + if ((queuedRowCount.get() > minQueuedRows) || (queuedFileCount.get() > minQueuedFiles)) { + // we have enough data buffered, check back shortly + LOG.debug( + "Buffer is full, {} > {} or {} > {}", + queuedRowCount.get(), + minQueuedRows, + queuedFileCount.get(), + minQueuedFiles); + } else { + // add an entire snapshot to the queue + Snapshot nextValidSnapshot = nextValidSnapshot(readFrom); + if (nextValidSnapshot != null) { + addMicroBatchToQueue( + nextValidSnapshot, + 0, + MicroBatchUtils.addedFilesCount(table(), nextValidSnapshot), + false); + } else { + LOG.debug("No snapshots ready to be read"); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java new file mode 100644 index 000000000000..a2af0964c635 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseBatchReader.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import javax.annotation.Nonnull; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting; +import org.apache.iceberg.spark.OrcBatchReadConf; +import org.apache.iceberg.spark.ParquetBatchReadConf; +import org.apache.iceberg.spark.data.vectorized.ColumnVectorWithFilter; +import org.apache.iceberg.spark.data.vectorized.ColumnarBatchUtil; +import org.apache.iceberg.spark.data.vectorized.UpdatableDeletedColumnVector; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.vectorized.ColumnVector; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +abstract class BaseBatchReader extends BaseReader { + private final ParquetBatchReadConf parquetConf; + private final OrcBatchReadConf orcConf; + + BaseBatchReader( + Table table, + FileIO fileIO, + ScanTaskGroup taskGroup, + Schema expectedSchema, + boolean caseSensitive, + ParquetBatchReadConf parquetConf, + OrcBatchReadConf orcConf, + boolean cacheDeleteFilesOnExecutors) { + super(table, fileIO, taskGroup, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + this.parquetConf = parquetConf; + this.orcConf = orcConf; + } + + protected CloseableIterable newBatchIterable( + InputFile inputFile, + FileFormat format, + long start, + long length, + Expression residual, + Map idToConstant, + @Nonnull SparkDeleteFilter deleteFilter) { + ReadBuilder readBuilder = + FormatModelRegistry.readBuilder(format, ColumnarBatch.class, inputFile); + + if (parquetConf != null) { + readBuilder = readBuilder.recordsPerBatch(parquetConf.batchSize()); + } else if (orcConf != null) { + readBuilder = readBuilder.recordsPerBatch(orcConf.batchSize()); + } + + CloseableIterable iterable = + readBuilder + .project(deleteFilter.requiredSchema()) + .idToConstant(idToConstant) + .split(start, length) + .filter(residual) + .caseSensitive(caseSensitive()) + // Spark eagerly consumes the batches. So the underlying memory allocated could be + // reused without worrying about subsequent reads clobbering over each other. This + // improves read performance as every batch read doesn't have to pay the cost of + // allocating memory. + .reuseContainers() + .withNameMapping(nameMapping()) + .build(); + + return CloseableIterable.transform(iterable, new BatchDeleteFilter(deleteFilter)::filterBatch); + } + + @VisibleForTesting + static class BatchDeleteFilter { + private final DeleteFilter deletes; + private boolean hasIsDeletedColumn; + private int rowPositionColumnIndex = -1; + + BatchDeleteFilter(DeleteFilter deletes) { + this.deletes = deletes; + + Schema schema = deletes.requiredSchema(); + for (int i = 0; i < schema.columns().size(); i++) { + if (schema.columns().get(i).fieldId() == MetadataColumns.ROW_POSITION.fieldId()) { + this.rowPositionColumnIndex = i; + } else if (schema.columns().get(i).fieldId() == MetadataColumns.IS_DELETED.fieldId()) { + this.hasIsDeletedColumn = true; + } + } + } + + ColumnarBatch filterBatch(ColumnarBatch batch) { + if (!needDeletes()) { + return batch; + } + + ColumnVector[] vectors = new ColumnVector[batch.numCols()]; + for (int i = 0; i < batch.numCols(); i++) { + vectors[i] = batch.column(i); + } + + int numLiveRows = batch.numRows(); + long rowStartPosInBatch = + rowPositionColumnIndex == -1 ? -1 : vectors[rowPositionColumnIndex].getLong(0); + + if (hasIsDeletedColumn) { + boolean[] isDeleted = + ColumnarBatchUtil.buildIsDeleted(vectors, deletes, rowStartPosInBatch, numLiveRows); + for (ColumnVector vector : vectors) { + if (vector instanceof UpdatableDeletedColumnVector) { + ((UpdatableDeletedColumnVector) vector).setValue(isDeleted); + } + } + } else { + Pair pair = + ColumnarBatchUtil.buildRowIdMapping(vectors, deletes, rowStartPosInBatch, numLiveRows); + if (pair != null) { + int[] rowIdMapping = pair.first(); + numLiveRows = pair.second(); + for (int i = 0; i < vectors.length; i++) { + vectors[i] = new ColumnVectorWithFilter(vectors[i], rowIdMapping); + } + } + } + + if (deletes != null && deletes.hasEqDeletes()) { + vectors = ColumnarBatchUtil.removeExtraColumns(deletes, vectors); + } + + ColumnarBatch output = new ColumnarBatch(vectors); + output.setNumRows(numLiveRows); + return output; + } + + private boolean needDeletes() { + return hasIsDeletedColumn + || (deletes != null && (deletes.hasEqDeletes() || deletes.hasPosDeletes())); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java new file mode 100644 index 000000000000..a4d9766ae713 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseReader.java @@ -0,0 +1,291 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.ContentScanTask; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.data.DeleteLoader; +import org.apache.iceberg.deletes.DeleteCounter; +import org.apache.iceberg.encryption.EncryptingFileIO; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.spark.SparkExecutorCache; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.types.Types.StructType; +import org.apache.iceberg.util.PartitionUtil; +import org.apache.spark.rdd.InputFileBlockHolder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class of Spark readers. + * + * @param is the Java class returned by this reader whose objects contain one or more rows. + */ +abstract class BaseReader implements Closeable { + private static final Logger LOG = LoggerFactory.getLogger(BaseReader.class); + + private final Table table; + private final EncryptingFileIO fileIO; + private final Schema expectedSchema; + private final boolean caseSensitive; + private final NameMapping nameMapping; + private final ScanTaskGroup taskGroup; + private final Iterator tasks; + private final DeleteCounter counter; + private final boolean cacheDeleteFilesOnExecutors; + + private Map lazyInputFiles; + private CloseableIterator currentIterator; + private T current = null; + private TaskT currentTask = null; + + BaseReader( + Table table, + FileIO fileIO, + ScanTaskGroup taskGroup, + Schema expectedSchema, + boolean caseSensitive, + boolean cacheDeleteFilesOnExecutors) { + this.table = table; + this.fileIO = EncryptingFileIO.combine(fileIO, table().encryption()); + this.taskGroup = taskGroup; + this.tasks = taskGroup.tasks().iterator(); + this.currentIterator = CloseableIterator.empty(); + this.expectedSchema = expectedSchema; + this.caseSensitive = caseSensitive; + String nameMappingString = table.properties().get(TableProperties.DEFAULT_NAME_MAPPING); + this.nameMapping = + nameMappingString != null ? NameMappingParser.fromJson(nameMappingString) : null; + this.counter = new DeleteCounter(); + this.cacheDeleteFilesOnExecutors = cacheDeleteFilesOnExecutors; + } + + protected abstract CloseableIterator open(TaskT task); + + protected abstract Stream> referencedFiles(TaskT task); + + protected Schema expectedSchema() { + return expectedSchema; + } + + protected boolean caseSensitive() { + return caseSensitive; + } + + protected boolean cacheDeleteFilesOnExecutors() { + return cacheDeleteFilesOnExecutors; + } + + protected NameMapping nameMapping() { + return nameMapping; + } + + protected Table table() { + return table; + } + + protected DeleteCounter counter() { + return counter; + } + + public boolean next() throws IOException { + try { + while (true) { + if (currentIterator.hasNext()) { + this.current = currentIterator.next(); + return true; + } else if (tasks.hasNext()) { + this.currentIterator.close(); + this.currentTask = tasks.next(); + this.currentIterator = open(currentTask); + } else { + this.currentIterator.close(); + return false; + } + } + } catch (IOException | RuntimeException e) { + if (currentTask != null && !currentTask.isDataTask()) { + String filePaths = + referencedFiles(currentTask) + .map(ContentFile::location) + .collect(Collectors.joining(", ")); + LOG.error("Error reading file(s): {}", filePaths, e); + } + throw e; + } + } + + public T get() { + return current; + } + + @Override + public void close() throws IOException { + InputFileBlockHolder.unset(); + + // close the current iterator + this.currentIterator.close(); + + // exhaust the task iterator + while (tasks.hasNext()) { + tasks.next(); + } + } + + protected InputFile getInputFile(String location) { + return inputFiles().get(location); + } + + private Map inputFiles() { + if (lazyInputFiles == null) { + this.lazyInputFiles = + fileIO.bulkDecrypt( + () -> taskGroup.tasks().stream().flatMap(this::referencedFiles).iterator()); + } + + return lazyInputFiles; + } + + protected Map constantsMap(ContentScanTask task, Schema readSchema) { + if (readSchema.findField(MetadataColumns.PARTITION_COLUMN_ID) != null) { + StructType partitionType = Partitioning.partitionType(table); + return PartitionUtil.constantsMap(task, partitionType, SparkUtil::internalToSpark); + } else { + return PartitionUtil.constantsMap(task, SparkUtil::internalToSpark); + } + } + + protected class SparkDeleteFilter extends DeleteFilter { + private final InternalRowWrapper asStructLike; + + SparkDeleteFilter( + String filePath, List deletes, DeleteCounter counter, boolean needRowPosCol) { + super(filePath, deletes, new FieldLookup(table), expectedSchema, counter, needRowPosCol); + this.asStructLike = + new InternalRowWrapper( + SparkSchemaUtil.convert(requiredSchema()), requiredSchema().asStruct()); + } + + @Override + protected StructLike asStructLike(InternalRow row) { + return asStructLike.wrap(row); + } + + @Override + protected InputFile getInputFile(String location) { + return BaseReader.this.getInputFile(location); + } + + @Override + protected void markRowDeleted(InternalRow row) { + if (!row.getBoolean(columnIsDeletedPosition())) { + row.setBoolean(columnIsDeletedPosition(), true); + counter().increment(); + } + } + + @Override + protected DeleteLoader newDeleteLoader() { + if (cacheDeleteFilesOnExecutors) { + return new CachingDeleteLoader(this::loadInputFile); + } + return new BaseDeleteLoader(this::loadInputFile); + } + + private class CachingDeleteLoader extends BaseDeleteLoader { + private final SparkExecutorCache cache; + + CachingDeleteLoader(Function loadInputFile) { + super(loadInputFile); + this.cache = SparkExecutorCache.getOrCreate(); + } + + @Override + protected boolean canCache(long size) { + return cache != null && size < cache.maxEntrySize(); + } + + @Override + protected V getOrLoad(String key, Supplier valueSupplier, long valueSize) { + return cache.getOrLoad(table().name(), key, valueSupplier, valueSize); + } + } + + // field lookup for serializable tables that assumes fetching historic schemas is expensive + private static class FieldLookup implements Function { + private final Table table; + private volatile Map historicSchemaFields; + + private FieldLookup(Table table) { + this.table = table; + } + + @Override + public Types.NestedField apply(Integer id) { + Types.NestedField field = table.schema().findField(id); + return field != null ? field : historicSchemaFields().get(id); + } + + private Map historicSchemaFields() { + if (historicSchemaFields == null) { + synchronized (this) { + if (historicSchemaFields == null) { + this.historicSchemaFields = Schema.indexFields(historicSchemas(table)); + } + } + } + return historicSchemaFields; + } + + private static Collection historicSchemas(Table table) { + return table.schemas().values().stream() + .filter(schema -> schema.schemaId() != table.schema().schemaId()) + .collect(Collectors.toList()); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java new file mode 100644 index 000000000000..cbc40db5333c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseRowReader.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.formats.ReadBuilder; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.spark.sql.catalyst.InternalRow; + +abstract class BaseRowReader extends BaseReader { + BaseRowReader( + Table table, + FileIO fileIO, + ScanTaskGroup taskGroup, + Schema expectedSchema, + boolean caseSensitive, + boolean cacheDeleteFilesOnExecutors) { + super(table, fileIO, taskGroup, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + } + + protected CloseableIterable newIterable( + InputFile file, + FileFormat format, + long start, + long length, + Expression residual, + Schema projection, + Map idToConstant) { + ReadBuilder reader = + FormatModelRegistry.readBuilder(format, InternalRow.class, file); + return reader + .project(projection) + .idToConstant(idToConstant) + .reuseContainers() + .split(start, length) + .caseSensitive(caseSensitive()) + .filter(residual) + .withNameMapping(nameMapping()) + .build(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..9298c2bbdfcc --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Locale; +import org.apache.iceberg.DataOperations; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.CompositeReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.apache.spark.sql.connector.read.streaming.ReadMaxFiles; +import org.apache.spark.sql.connector.read.streaming.ReadMaxRows; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class BaseSparkMicroBatchPlanner implements SparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(BaseSparkMicroBatchPlanner.class); + private final Table table; + private final SparkReadConf readConf; + + BaseSparkMicroBatchPlanner(Table table, SparkReadConf readConf) { + this.table = table; + this.readConf = readConf; + } + + protected Table table() { + return table; + } + + protected SparkReadConf readConf() { + return readConf; + } + + protected boolean shouldProcess(Snapshot snapshot) { + String op = snapshot.operation(); + switch (op) { + case DataOperations.APPEND: + return true; + case DataOperations.REPLACE: + return false; + case DataOperations.DELETE: + Preconditions.checkState( + readConf.streamingSkipDeleteSnapshots(), + "Cannot process delete snapshot: %s, to ignore deletes, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS); + return false; + case DataOperations.OVERWRITE: + Preconditions.checkState( + readConf.streamingSkipOverwriteSnapshots(), + "Cannot process overwrite snapshot: %s, to ignore overwrites, set %s=true", + snapshot.snapshotId(), + SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS); + return false; + default: + throw new IllegalStateException( + String.format( + "Cannot process unknown snapshot operation: %s (snapshot id %s)", + op.toLowerCase(Locale.ROOT), snapshot.snapshotId())); + } + } + + /** + * Get the next snapshot skipping over rewrite and delete snapshots. Async must handle nulls. + * + * @param curSnapshot the current snapshot + * @return the next valid snapshot (not a rewrite or delete snapshot), returns null if all + * remaining snapshots should be skipped. + */ + protected Snapshot nextValidSnapshot(Snapshot curSnapshot) { + Snapshot nextSnapshot; + // if there were no valid snapshots, check for an initialOffset again + if (curSnapshot == null) { + StreamingOffset startingOffset = + MicroBatchUtils.determineStartingOffset(table, readConf.streamFromTimestamp()); + LOG.debug("determineStartingOffset picked startingOffset: {}", startingOffset); + if (StreamingOffset.START_OFFSET.equals(startingOffset)) { + return null; + } + nextSnapshot = table.snapshot(startingOffset.snapshotId()); + } else { + if (curSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, curSnapshot.snapshotId()); + } + // skip over rewrite and delete snapshots + while (!shouldProcess(nextSnapshot)) { + LOG.debug("Skipping snapshot: {}", nextSnapshot); + // if the currentSnapShot was also the mostRecentSnapshot then break + // avoids snapshotAfter throwing exception since there are no more snapshots to process + if (nextSnapshot.snapshotId() == table.currentSnapshot().snapshotId()) { + return null; + } + nextSnapshot = SnapshotUtil.snapshotAfter(table, nextSnapshot.snapshotId()); + } + return nextSnapshot; + } + + static class UnpackedLimits { + private long maxRows = Integer.MAX_VALUE; + private long maxFiles = Integer.MAX_VALUE; + + UnpackedLimits(ReadLimit limit) { + if (limit instanceof CompositeReadLimit) { + ReadLimit[] compositeLimits = ((CompositeReadLimit) limit).getReadLimits(); + for (ReadLimit individualLimit : compositeLimits) { + if (individualLimit instanceof ReadMaxRows) { + ReadMaxRows readMaxRows = (ReadMaxRows) individualLimit; + this.maxRows = Math.min(this.maxRows, readMaxRows.maxRows()); + } else if (individualLimit instanceof ReadMaxFiles) { + ReadMaxFiles readMaxFiles = (ReadMaxFiles) individualLimit; + this.maxFiles = Math.min(this.maxFiles, readMaxFiles.maxFiles()); + } + } + } else if (limit instanceof ReadMaxRows) { + this.maxRows = ((ReadMaxRows) limit).maxRows(); + } else if (limit instanceof ReadMaxFiles) { + this.maxFiles = ((ReadMaxFiles) limit).maxFiles(); + } + } + + public long getMaxRows() { + return maxRows; + } + + public long getMaxFiles() { + return maxFiles; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkScanBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkScanBuilder.java new file mode 100644 index 000000000000..be34eaeed5dc --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkScanBuilder.java @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.TableProperties.SPLIT_LOOKBACK; +import static org.apache.iceberg.TableProperties.SPLIT_OPEN_FILE_COST; +import static org.apache.iceberg.TableProperties.SPLIT_SIZE; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Scan; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Binder; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionUtil; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkV2Filters; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.TypeUtil.GetID; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A base Spark scan builder with common functionality like projection and predicate pushdown. + * + *

    Note that this class intentionally doesn't implement any optional mix-in Spark interfaces even + * if it contains necessary logic, allowing each concrete scan implementation to select what + * functionality is applicable to that scan. + */ +abstract class BaseSparkScanBuilder implements ScanBuilder { + + private static final Logger LOG = LoggerFactory.getLogger(BaseSparkScanBuilder.class); + private static final Predicate[] NO_PREDICATES = new Predicate[0]; + + private final SparkSession spark; + private final Table table; + private final Schema schema; + private final SparkReadConf readConf; + private final boolean caseSensitive; + private final Set metaFieldNames = Sets.newLinkedHashSet(); + private final InMemoryMetricsReporter metricsReporter = new InMemoryMetricsReporter(); + + private Schema projection; + private List filters = Lists.newArrayList(); + private Predicate[] pushedPredicates = NO_PREDICATES; + private Integer limit = null; + + protected BaseSparkScanBuilder( + SparkSession spark, Table table, Schema schema, CaseInsensitiveStringMap options) { + this.spark = spark; + this.table = table; + this.schema = schema; + this.readConf = new SparkReadConf(spark, table, options); + this.caseSensitive = readConf.caseSensitive(); + this.projection = schema; + } + + protected SparkSession spark() { + return spark; + } + + protected Table table() { + return table; + } + + protected Schema schema() { + return schema; + } + + protected Schema projection() { + return projection; + } + + protected SparkReadConf readConf() { + return readConf; + } + + protected boolean caseSensitive() { + return caseSensitive; + } + + protected List filters() { + return filters; + } + + protected Expression filter() { + return filters.stream().reduce(Expressions.alwaysTrue(), Expressions::and); + } + + protected InMemoryMetricsReporter metricsReporter() { + return metricsReporter; + } + + // logic necessary for SupportsPushDownRequiredColumns + public void pruneColumns(StructType requestedType) { + List dataFields = Lists.newArrayList(); + + for (StructField field : requestedType.fields()) { + if (MetadataColumns.isMetadataColumn(field.name())) { + metaFieldNames.add(field.name()); + } else { + dataFields.add(field); + } + } + + StructType requestedDataType = SparkSchemaUtil.toStructType(dataFields); + this.projection = SparkSchemaUtil.prune(projection, requestedDataType, filter(), caseSensitive); + } + + // logic necessary for SupportsPushDownV2Filters + public Predicate[] pushPredicates(Predicate[] predicates) { + // there are 3 kinds of filters: + // (1) filters that can be pushed down completely and don't have to evaluated by Spark + // (e.g. filters that select entire partitions) + // (2) filters that can be pushed down partially and require record-level filtering in Spark + // (e.g. filters that may select some but not necessarily all rows in a file) + // (3) filters that can't be pushed down at all and have to be evaluated by Spark + // (e.g. unsupported filters) + // filters (1) and (2) are used to prune files during job planning in Iceberg + // filters (2) and (3) form a set of post scan filters and must be evaluated by Spark + + List expressions = Lists.newArrayListWithExpectedSize(predicates.length); + List pushablePredicates = Lists.newArrayListWithExpectedSize(predicates.length); + List postScanPredicates = Lists.newArrayListWithExpectedSize(predicates.length); + + for (Predicate predicate : predicates) { + try { + Expression expr = SparkV2Filters.convert(predicate); + + if (expr != null) { + // try binding the expression to ensure it can be pushed down + Binder.bind(projection.asStruct(), expr, caseSensitive); + expressions.add(expr); + pushablePredicates.add(predicate); + } + + if (expr == null || !ExpressionUtil.selectsPartitions(expr, table, caseSensitive)) { + postScanPredicates.add(predicate); + } else { + LOG.info("Evaluating completely on Iceberg side: {}", predicate); + } + + } catch (Exception e) { + LOG.warn("Failed to check if {} can be pushed down: {}", predicate, e.getMessage()); + postScanPredicates.add(predicate); + } + } + + this.filters = expressions; + this.pushedPredicates = pushablePredicates.toArray(new Predicate[0]); + + return postScanPredicates.toArray(new Predicate[0]); + } + + // logic necessary for SupportsPushDownV2Filters + public Predicate[] pushedPredicates() { + return pushedPredicates; + } + + // logic necessary for SupportsPushDownLimit + public boolean pushLimit(int newLimit) { + this.limit = newLimit; + return true; + } + + // schema of rows that must be returned by readers + protected Schema projectionWithMetadataColumns() { + return TypeUtil.join(projection, calculateMetadataSchema()); + } + + // computes metadata schema avoiding conflicts between partition and data field IDs + private Schema calculateMetadataSchema() { + List metaFields = metaFields(); + Optional partitionField = findPartitionField(metaFields); + + if (partitionField.isEmpty()) { + return new Schema(metaFields); + } + + Types.StructType partitionType = partitionField.get().type().asStructType(); + Set partitionFieldIds = TypeUtil.getProjectedIds(partitionType); + GetID getId = TypeUtil.reassignConflictingIds(partitionFieldIds, allUsedFieldIds()); + return new Schema(metaFields, getId); + } + + private List metaFields() { + return metaFieldNames.stream() + .map(name -> MetadataColumns.metadataColumn(table, name)) + .collect(Collectors.toList()); + } + + private Optional findPartitionField(List fields) { + return fields.stream() + .filter(field -> MetadataColumns.PARTITION_COLUMN_ID == field.fieldId()) + .findFirst(); + } + + // collects used data field IDs across all known table schemas + private Set allUsedFieldIds() { + return table.schemas().values().stream() + .flatMap(tableSchema -> TypeUtil.indexById(tableSchema.asStruct()).keySet().stream()) + .collect(Collectors.toSet()); + } + + protected > T configureSplitPlanning(T scan) { + T newScan = scan; + + Long splitSize = readConf.splitSizeOption(); + if (splitSize != null) { + newScan = newScan.option(SPLIT_SIZE, String.valueOf(splitSize)); + } + + Integer splitLookback = readConf.splitLookbackOption(); + if (splitLookback != null) { + newScan = newScan.option(SPLIT_LOOKBACK, String.valueOf(splitLookback)); + } + + Long splitOpenFileCost = readConf.splitOpenFileCostOption(); + if (splitOpenFileCost != null) { + newScan = newScan.option(SPLIT_OPEN_FILE_COST, String.valueOf(splitOpenFileCost)); + } + + if (limit != null) { + newScan = newScan.minRowsRequested(limit.longValue()); + } + + return newScan; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkTable.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkTable.java new file mode 100644 index 000000000000..a5d9293a9fc1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkTable.java @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.TableProperties.CURRENT_SNAPSHOT_ID; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; +import static org.apache.iceberg.TableProperties.FORMAT_VERSION; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.catalog.MetadataColumn; +import org.apache.spark.sql.connector.catalog.SupportsMetadataColumns; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.StructType; + +abstract class BaseSparkTable + implements org.apache.spark.sql.connector.catalog.Table, SupportsMetadataColumns { + + private static final String PROVIDER = "provider"; + private static final String FORMAT = "format"; + private static final String LOCATION = "location"; + private static final String SORT_ORDER = "sort-order"; + private static final String IDENTIFIER_FIELDS = "identifier-fields"; + private static final Set RESERVED_PROPERTIES = + ImmutableSet.of( + PROVIDER, + FORMAT, + CURRENT_SNAPSHOT_ID, + LOCATION, + FORMAT_VERSION, + SORT_ORDER, + IDENTIFIER_FIELDS); + + private final Table table; + private final Schema schema; + + private SparkSession lazySpark = null; + private StructType lazySparkSchema = null; + + protected BaseSparkTable(Table table, Schema schema) { + this.table = table; + this.schema = schema; + } + + protected SparkSession spark() { + if (lazySpark == null) { + this.lazySpark = SparkSession.active(); + } + return lazySpark; + } + + public Table table() { + return table; + } + + @Override + public String name() { + return table.toString(); + } + + @Override + public StructType schema() { + if (lazySparkSchema == null) { + this.lazySparkSchema = SparkSchemaUtil.convert(schema); + } + return lazySparkSchema; + } + + @Override + public Transform[] partitioning() { + return Spark3Util.toTransforms(table.spec()); + } + + @Override + public Map properties() { + ImmutableMap.Builder propsBuilder = ImmutableMap.builder(); + + propsBuilder.put(FORMAT, "iceberg/" + fileFormat()); + propsBuilder.put(PROVIDER, "iceberg"); + propsBuilder.put(LOCATION, table.location()); + propsBuilder.put(CURRENT_SNAPSHOT_ID, currentSnapshotId()); + + if (table instanceof BaseTable) { + TableOperations ops = ((BaseTable) table).operations(); + propsBuilder.put(FORMAT_VERSION, String.valueOf(ops.current().formatVersion())); + } + + if (table.sortOrder().isSorted()) { + propsBuilder.put(SORT_ORDER, Spark3Util.describe(table.sortOrder())); + } + + Set identifierFields = table.schema().identifierFieldNames(); + if (!identifierFields.isEmpty()) { + propsBuilder.put(IDENTIFIER_FIELDS, "[" + String.join(",", identifierFields) + "]"); + } + + table.properties().entrySet().stream() + .filter(entry -> !RESERVED_PROPERTIES.contains(entry.getKey())) + .forEach(propsBuilder::put); + + return propsBuilder.build(); + } + + @Override + public MetadataColumn[] metadataColumns() { + List cols = Lists.newArrayList(); + + cols.add(SparkMetadataColumns.SPEC_ID); + cols.add(SparkMetadataColumns.partition(table)); + cols.add(SparkMetadataColumns.FILE_PATH); + cols.add(SparkMetadataColumns.ROW_POSITION); + cols.add(SparkMetadataColumns.IS_DELETED); + + if (TableUtil.supportsRowLineage(table)) { + cols.add(SparkMetadataColumns.ROW_ID); + cols.add(SparkMetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER); + } + + return cols.toArray(SparkMetadataColumn[]::new); + } + + private String fileFormat() { + return table.properties().getOrDefault(DEFAULT_FILE_FORMAT, DEFAULT_FILE_FORMAT_DEFAULT); + } + + private String currentSnapshotId() { + Snapshot currentSnapshot = table.currentSnapshot(); + return currentSnapshot != null ? String.valueOf(currentSnapshot.snapshotId()) : "none"; + } + + @Override + public String toString() { + return table.toString(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkWrite.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkWrite.java new file mode 100644 index 000000000000..3ba11f3bbdc3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkWrite.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.SnapshotUpdate; +import org.apache.spark.sql.connector.write.MergeSummary; + +/** Base class for Spark write implementations with shared utility methods. */ +abstract class BaseSparkWrite { + + protected void setMergeSummaryProperties(SnapshotUpdate operation, MergeSummary mergeSummary) { + setIfPositive( + operation, "spark.merge-into.num-target-rows-copied", mergeSummary.numTargetRowsCopied()); + setIfPositive( + operation, "spark.merge-into.num-target-rows-deleted", mergeSummary.numTargetRowsDeleted()); + setIfPositive( + operation, "spark.merge-into.num-target-rows-updated", mergeSummary.numTargetRowsUpdated()); + setIfPositive( + operation, + "spark.merge-into.num-target-rows-inserted", + mergeSummary.numTargetRowsInserted()); + setIfPositive( + operation, + "spark.merge-into.num-target-rows-matched-updated", + mergeSummary.numTargetRowsMatchedUpdated()); + setIfPositive( + operation, + "spark.merge-into.num-target-rows-matched-deleted", + mergeSummary.numTargetRowsMatchedDeleted()); + setIfPositive( + operation, + "spark.merge-into.num-target-rows-not-matched-by-source-updated", + mergeSummary.numTargetRowsNotMatchedBySourceUpdated()); + setIfPositive( + operation, + "spark.merge-into.num-target-rows-not-matched-by-source-deleted", + mergeSummary.numTargetRowsNotMatchedBySourceDeleted()); + } + + private void setIfPositive(SnapshotUpdate operation, String key, long value) { + if (value >= 0) { + operation.set(key, String.valueOf(value)); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java new file mode 100644 index 000000000000..237dfd5c6919 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/BatchDataReader.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.stream.Stream; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.OrcBatchReadConf; +import org.apache.iceberg.spark.ParquetBatchReadConf; +import org.apache.iceberg.spark.source.metrics.TaskNumDeletes; +import org.apache.iceberg.spark.source.metrics.TaskNumSplits; +import org.apache.spark.rdd.InputFileBlockHolder; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; +import org.apache.spark.sql.connector.read.PartitionReader; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class BatchDataReader extends BaseBatchReader + implements PartitionReader { + + private static final Logger LOG = LoggerFactory.getLogger(BatchDataReader.class); + + private final long numSplits; + + BatchDataReader( + SparkInputPartition partition, + ParquetBatchReadConf parquetBatchReadConf, + OrcBatchReadConf orcBatchReadConf) { + this( + partition.table(), + partition.io(), + partition.taskGroup(), + partition.projection(), + partition.isCaseSensitive(), + parquetBatchReadConf, + orcBatchReadConf, + partition.cacheDeleteFilesOnExecutors()); + } + + BatchDataReader( + Table table, + FileIO fileIO, + ScanTaskGroup taskGroup, + Schema expectedSchema, + boolean caseSensitive, + ParquetBatchReadConf parquetConf, + OrcBatchReadConf orcConf, + boolean cacheDeleteFilesOnExecutors) { + super( + table, + fileIO, + taskGroup, + expectedSchema, + caseSensitive, + parquetConf, + orcConf, + cacheDeleteFilesOnExecutors); + + numSplits = taskGroup.tasks().size(); + LOG.debug("Reading {} file split(s) for table {}", numSplits, table.name()); + } + + @Override + public CustomTaskMetric[] currentMetricsValues() { + return new CustomTaskMetric[] { + new TaskNumSplits(numSplits), new TaskNumDeletes(counter().get()) + }; + } + + @Override + protected Stream> referencedFiles(FileScanTask task) { + return Stream.concat(Stream.of(task.file()), task.deletes().stream()); + } + + @Override + protected CloseableIterator open(FileScanTask task) { + String filePath = task.file().location(); + LOG.debug("Opening data file {}", filePath); + + // update the current file for Spark's filename() function + InputFileBlockHolder.set(filePath, task.start(), task.length()); + + InputFile inputFile = getInputFile(filePath); + Preconditions.checkNotNull(inputFile, "Could not find InputFile associated with FileScanTask"); + + SparkDeleteFilter deleteFilter = + new SparkDeleteFilter(filePath, task.deletes(), counter(), true); + + Map idToConstant = constantsMap(task, deleteFilter.requiredSchema()); + + return newBatchIterable( + inputFile, + task.file().format(), + task.start(), + task.length(), + task.residual(), + idToConstant, + deleteFilter) + .iterator(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java new file mode 100644 index 000000000000..eb8e5e63f430 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/ChangelogRowReader.java @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.apache.iceberg.AddedRowsScanTask; +import org.apache.iceberg.ChangelogScanTask; +import org.apache.iceberg.ChangelogUtil; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.ContentScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.DeletedDataFileScanTask; +import org.apache.iceberg.DeletedRowsScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.spark.rdd.InputFileBlockHolder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.expressions.JoinedRow; +import org.apache.spark.sql.connector.read.PartitionReader; +import org.apache.spark.unsafe.types.UTF8String; + +class ChangelogRowReader extends BaseRowReader + implements PartitionReader { + + ChangelogRowReader(SparkInputPartition partition) { + this( + partition.table(), + partition.io(), + partition.taskGroup(), + partition.projection(), + partition.isCaseSensitive(), + partition.cacheDeleteFilesOnExecutors()); + } + + ChangelogRowReader( + Table table, + FileIO fileIO, + ScanTaskGroup taskGroup, + Schema expectedSchema, + boolean caseSensitive, + boolean cacheDeleteFilesOnExecutors) { + super( + table, + fileIO, + taskGroup, + ChangelogUtil.dropChangelogMetadata(expectedSchema), + caseSensitive, + cacheDeleteFilesOnExecutors); + } + + @Override + protected CloseableIterator open(ChangelogScanTask task) { + JoinedRow cdcRow = new JoinedRow(); + + cdcRow.withRight(changelogMetadata(task)); + + CloseableIterable rows = openChangelogScanTask(task); + CloseableIterable cdcRows = CloseableIterable.transform(rows, cdcRow::withLeft); + + return cdcRows.iterator(); + } + + private static InternalRow changelogMetadata(ChangelogScanTask task) { + InternalRow metadataRow = new GenericInternalRow(3); + + metadataRow.update(0, UTF8String.fromString(task.operation().name())); + metadataRow.update(1, task.changeOrdinal()); + metadataRow.update(2, task.commitSnapshotId()); + + return metadataRow; + } + + private CloseableIterable openChangelogScanTask(ChangelogScanTask task) { + if (task instanceof AddedRowsScanTask) { + return openAddedRowsScanTask((AddedRowsScanTask) task); + + } else if (task instanceof DeletedRowsScanTask) { + throw new UnsupportedOperationException("Deleted rows scan task is not supported yet"); + + } else if (task instanceof DeletedDataFileScanTask) { + return openDeletedDataFileScanTask((DeletedDataFileScanTask) task); + + } else { + throw new IllegalArgumentException( + "Unsupported changelog scan task type: " + task.getClass().getName()); + } + } + + CloseableIterable openAddedRowsScanTask(AddedRowsScanTask task) { + String filePath = task.file().location(); + SparkDeleteFilter deletes = new SparkDeleteFilter(filePath, task.deletes(), counter(), true); + return deletes.filter(rows(task, deletes.requiredSchema())); + } + + private CloseableIterable openDeletedDataFileScanTask(DeletedDataFileScanTask task) { + String filePath = task.file().location(); + SparkDeleteFilter deletes = + new SparkDeleteFilter(filePath, task.existingDeletes(), counter(), true); + return deletes.filter(rows(task, deletes.requiredSchema())); + } + + private CloseableIterable rows(ContentScanTask task, Schema readSchema) { + Map idToConstant = constantsMap(task, readSchema); + + String filePath = task.file().location(); + + // update the current file for Spark's filename() function + InputFileBlockHolder.set(filePath, task.start(), task.length()); + + InputFile location = getInputFile(filePath); + Preconditions.checkNotNull(location, "Could not find InputFile"); + return newIterable( + location, + task.file().format(), + task.start(), + task.length(), + task.residual(), + readSchema, + idToConstant); + } + + @Override + protected Stream> referencedFiles(ChangelogScanTask task) { + if (task instanceof AddedRowsScanTask) { + return addedRowsScanTaskFiles((AddedRowsScanTask) task); + + } else if (task instanceof DeletedRowsScanTask) { + throw new UnsupportedOperationException("Deleted rows scan task is not supported yet"); + + } else if (task instanceof DeletedDataFileScanTask) { + return deletedDataFileScanTaskFiles((DeletedDataFileScanTask) task); + + } else { + throw new IllegalArgumentException( + "Unsupported changelog scan task type: " + task.getClass().getName()); + } + } + + private static Stream> deletedDataFileScanTaskFiles(DeletedDataFileScanTask task) { + DataFile file = task.file(); + List existingDeletes = task.existingDeletes(); + return Stream.concat(Stream.of(file), existingDeletes.stream()); + } + + private static Stream> addedRowsScanTaskFiles(AddedRowsScanTask task) { + DataFile file = task.file(); + List deletes = task.deletes(); + return Stream.concat(Stream.of(file), deletes.stream()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/DVIterator.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/DVIterator.java new file mode 100644 index 000000000000..2942413a7543 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/DVIterator.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ScanTaskUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.unsafe.types.UTF8String; + +class DVIterator implements CloseableIterator { + private final DeleteFile deleteFile; + private final Schema projection; + private final Map idToConstant; + private final Iterator positions; + private Integer deletedPositionIndex; + private GenericInternalRow row; + + DVIterator( + InputFile inputFile, DeleteFile deleteFile, Schema projection, Map idToConstant) { + this.deleteFile = deleteFile; + this.projection = projection; + this.idToConstant = idToConstant; + List pos = Lists.newArrayList(); + new BaseDeleteLoader(ignored -> inputFile) + .loadPositionDeletes(ImmutableList.of(deleteFile), deleteFile.referencedDataFile()) + .forEach(pos::add); + this.positions = pos.iterator(); + } + + @Override + public boolean hasNext() { + return positions.hasNext(); + } + + @Override + public InternalRow next() { + long position = positions.next(); + + if (null == row) { + List rowValues = Lists.newArrayList(); + for (Types.NestedField column : projection.columns()) { + int fieldId = column.fieldId(); + if (fieldId == MetadataColumns.DELETE_FILE_PATH.fieldId()) { + rowValues.add(UTF8String.fromString(deleteFile.referencedDataFile())); + } else if (fieldId == MetadataColumns.DELETE_FILE_POS.fieldId()) { + rowValues.add(position); + // remember the index where the deleted position needs to be set + deletedPositionIndex = rowValues.size() - 1; + } else if (fieldId == MetadataColumns.PARTITION_COLUMN_ID) { + rowValues.add(idToConstant.get(MetadataColumns.PARTITION_COLUMN_ID)); + } else if (fieldId == MetadataColumns.SPEC_ID_COLUMN_ID) { + rowValues.add(idToConstant.get(MetadataColumns.SPEC_ID_COLUMN_ID)); + } else if (fieldId == MetadataColumns.FILE_PATH_COLUMN_ID) { + rowValues.add(idToConstant.get(MetadataColumns.FILE_PATH_COLUMN_ID)); + } else if (fieldId == MetadataColumns.CONTENT_OFFSET_COLUMN_ID) { + rowValues.add(deleteFile.contentOffset()); + } else if (fieldId == MetadataColumns.CONTENT_SIZE_IN_BYTES_COLUMN_ID) { + rowValues.add(ScanTaskUtil.contentSizeInBytes(deleteFile)); + } else if (fieldId == MetadataColumns.DELETE_FILE_ROW_FIELD_ID) { + // DVs don't track the row that was deleted + rowValues.add(null); + } + } + + this.row = new GenericInternalRow(rowValues.toArray()); + } else if (null != deletedPositionIndex) { + // only update the deleted position if necessary, everything else stays the same + row.update(deletedPositionIndex, position); + } + + return row; + } + + @Override + public void remove() { + throw new UnsupportedOperationException("Remove is not supported"); + } + + @Override + public void close() {} +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java new file mode 100644 index 000000000000..aae399c5f2c4 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/EqualityDeleteRowReader.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.spark.rdd.InputFileBlockHolder; +import org.apache.spark.sql.catalyst.InternalRow; + +public class EqualityDeleteRowReader extends RowDataReader { + public EqualityDeleteRowReader( + CombinedScanTask task, + Table table, + FileIO fileIO, + Schema expectedSchema, + boolean caseSensitive, + boolean cacheDeleteFilesOnExecutors) { + super(table, fileIO, task, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + } + + @Override + protected CloseableIterator open(FileScanTask task) { + SparkDeleteFilter matches = + new SparkDeleteFilter(task.file().location(), task.deletes(), counter(), true); + + // schema or rows returned by readers + Schema requiredSchema = matches.requiredSchema(); + Map idToConstant = constantsMap(task, expectedSchema()); + DataFile file = task.file(); + + // update the current file for Spark's filename() function + InputFileBlockHolder.set(file.location(), task.start(), task.length()); + + return matches.findEqualityDeleteRows(open(task, requiredSchema, idToConstant)).iterator(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/ExtractRowLineage.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/ExtractRowLineage.java new file mode 100644 index 000000000000..7a337cf22bee --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/ExtractRowLineage.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.function.Function; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.ProjectingInternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.LongType$; +import org.apache.spark.sql.types.StructType; +import scala.collection.JavaConverters; + +class ExtractRowLineage implements Function { + private static final StructType ROW_LINEAGE_SCHEMA = + new StructType() + .add(MetadataColumns.ROW_ID.name(), LongType$.MODULE$, true) + .add(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), LongType$.MODULE$, true); + private static final InternalRow EMPTY_LINEAGE_ROW = new GenericInternalRow(2); + + private final boolean rowLineageRequired; + + private ProjectingInternalRow cachedRowLineageProjection; + + ExtractRowLineage(Schema writeSchema) { + Preconditions.checkArgument(writeSchema != null, "Write schema cannot be null"); + this.rowLineageRequired = writeSchema.findField(MetadataColumns.ROW_ID.name()) != null; + } + + @Override + public InternalRow apply(InternalRow meta) { + // If row lineage is not required on write return a null row + if (!rowLineageRequired) { + return null; + } + + // If metadata row is null but the write schema requires lineage, return an empty lineage row + if (meta == null) { + return EMPTY_LINEAGE_ROW; + } + + ProjectingInternalRow metaProj = (ProjectingInternalRow) meta; + // Use cached ordinals if they exist + if (cachedRowLineageProjection == null) { + this.cachedRowLineageProjection = rowLineageProjection(metaProj); + } + + cachedRowLineageProjection.project(metaProj); + return cachedRowLineageProjection; + } + + private ProjectingInternalRow rowLineageProjection(ProjectingInternalRow metadataRow) { + Integer rowIdOrdinal = null; + Integer lastUpdatedOrdinal = null; + for (int i = 0; i < metadataRow.numFields(); i++) { + String fieldName = metadataRow.schema().fields()[i].name(); + if (fieldName.equals(MetadataColumns.ROW_ID.name())) { + rowIdOrdinal = i; + } else if (fieldName.equals(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name())) { + lastUpdatedOrdinal = i; + } + } + + Preconditions.checkArgument(rowIdOrdinal != null, "Expected to find row ID in metadata row"); + Preconditions.checkArgument( + lastUpdatedOrdinal != null, + "Expected to find last updated sequence number in metadata row"); + List rowLineageProjectionOrdinals = ImmutableList.of(rowIdOrdinal, lastUpdatedOrdinal); + return new ProjectingInternalRow( + ROW_LINEAGE_SCHEMA, JavaConverters.asScala(rowLineageProjectionOrdinals).toIndexedSeq()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/HasIcebergCatalog.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/HasIcebergCatalog.java new file mode 100644 index 000000000000..37e0c4dfcdb6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/HasIcebergCatalog.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.catalog.Catalog; +import org.apache.spark.sql.connector.catalog.TableCatalog; + +public interface HasIcebergCatalog extends TableCatalog { + + /** + * Returns the underlying {@link org.apache.iceberg.catalog.Catalog} backing this Spark Catalog + */ + Catalog icebergCatalog(); +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java new file mode 100644 index 000000000000..32190dd84c31 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/IcebergSource.java @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.PathIdentifier; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.Spark3Util.CatalogAndIdentifier; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkRewriteTableCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.SparkTableCache; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.connector.catalog.CatalogManager; +import org.apache.spark.sql.connector.catalog.CatalogPlugin; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.SessionConfigSupport; +import org.apache.spark.sql.connector.catalog.SupportsCatalogOptions; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.sources.DataSourceRegister; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +/** + * Data source for reading and writing Iceberg tables using the "iceberg" format. + * + *

    The `path` parameter provided by Spark is resolved in the following priority order: + * + *

      + *
    1. Rewrite key - If `path` is a rewrite key, load a table from the rewrite catalog + *
    2. Table location - If `path` contains "/", load a table at the specified location + *
    3. Catalog identifier - Otherwise resolve `path` as an identifier per Spark rules + *
    + */ +public class IcebergSource + implements DataSourceRegister, SupportsCatalogOptions, SessionConfigSupport { + private static final String DEFAULT_CATALOG_NAME = "default_iceberg"; + private static final String REWRITE_CATALOG_NAME = "default_rewrite_catalog"; + private static final String CATALOG_PREFIX = "spark.sql.catalog."; + private static final String DEFAULT_CATALOG = CATALOG_PREFIX + DEFAULT_CATALOG_NAME; + private static final String REWRITE_CATALOG = CATALOG_PREFIX + REWRITE_CATALOG_NAME; + private static final String BRANCH_PREFIX = "branch_"; + private static final String[] EMPTY_NAMESPACE = new String[0]; + + private static final SparkTableCache TABLE_CACHE = SparkTableCache.get(); + + @Override + public String shortName() { + return "iceberg"; + } + + @Override + public String keyPrefix() { + return shortName(); + } + + @Override + public StructType inferSchema(CaseInsensitiveStringMap options) { + return null; + } + + @Override + public Transform[] inferPartitioning(CaseInsensitiveStringMap options) { + return getTable(null, null, options).partitioning(); + } + + @Override + public boolean supportsExternalMetadata() { + return true; + } + + @Override + public Table getTable(StructType schema, Transform[] partitioning, Map options) { + return loadTable(new CaseInsensitiveStringMap(options)); + } + + private Table loadTable(CaseInsensitiveStringMap options) { + CatalogAndIdentifier catalogAndIdent = catalogAndIdentifier(options); + TableCatalog catalog = catalogAndIdent.tableCatalog(); + Identifier ident = catalogAndIdent.identifier(); + try { + return catalog.loadTable(ident); + } catch (NoSuchTableException e) { + // TableProvider doesn't permit typed exception while loading tables, + // so throw Iceberg NoSuchTableException because the Spark one is typed + throw new org.apache.iceberg.exceptions.NoSuchTableException( + e, + "Cannot find table %s in catalog %s (%s)", + ident, + catalog.name(), + catalog.getClass().getName()); + } + } + + private CatalogAndIdentifier catalogAndIdentifier(CaseInsensitiveStringMap options) { + Preconditions.checkArgument( + options.containsKey(SparkReadOptions.PATH), "Cannot open table: path is not set"); + Spark3Util.validateNoLegacyTimeTravel(options); + + SparkSession spark = SparkSession.active(); + CatalogManager catalogManager = spark.sessionState().catalogManager(); + + setupDefaultSparkCatalogs(spark); + + String path = options.get(SparkReadOptions.PATH); + String branch = options.get(SparkReadOptions.BRANCH); + String branchSelector = branch != null ? BRANCH_PREFIX + branch : null; + + // return rewrite catalog with path as group ID if table is staged for rewrite + if (TABLE_CACHE.contains(path)) { + CatalogPlugin rewriteCatalog = catalogManager.catalog(REWRITE_CATALOG_NAME); + return new CatalogAndIdentifier(rewriteCatalog, Identifier.of(EMPTY_NAMESPACE, path)); + } + + // return default catalog and PathIdentifier with branch selector for a path + if (path.contains("/")) { + CatalogPlugin defaultCatalog = catalogManager.catalog(DEFAULT_CATALOG_NAME); + PathIdentifier identWithBranch = new PathIdentifier(pathWithSelector(path, branchSelector)); + return new CatalogAndIdentifier(defaultCatalog, identWithBranch); + } + + // treat path as an identifier and resolve it against the session config + CatalogAndIdentifier catalogAndIdent = resolveIdentifier(spark, path); + CatalogPlugin catalog = catalogAndIdent.catalog(); + Identifier ident = catalogAndIdent.identifier(); + Identifier identWithBranch = identifierWithSelector(ident, branchSelector); + + // if the catalog resolves to an unknown session catalog, use the default Iceberg catalog + if (isSessionCatalog(catalog) && !isIcebergSessionCatalog(catalog)) { + CatalogPlugin defaultCatalog = catalogManager.catalog(DEFAULT_CATALOG_NAME); + return new CatalogAndIdentifier(defaultCatalog, identWithBranch); + } + + return new CatalogAndIdentifier(catalog, identWithBranch); + } + + private static CatalogAndIdentifier resolveIdentifier(SparkSession spark, String ident) { + return Spark3Util.catalogAndIdentifier("identifier", spark, ident); + } + + private static boolean isSessionCatalog(CatalogPlugin catalog) { + return CatalogManager.SESSION_CATALOG_NAME().equals(catalog.name()); + } + + private static boolean isIcebergSessionCatalog(CatalogPlugin catalog) { + return catalog instanceof SparkSessionCatalog; + } + + private String pathWithSelector(String path, String selector) { + return selector == null ? path : path + "#" + selector; + } + + private Identifier identifierWithSelector(Identifier ident, String selector) { + if (selector == null) { + return ident; + } else { + String[] namespace = ident.namespace(); + String[] ns = Arrays.copyOf(namespace, namespace.length + 1); + ns[namespace.length] = ident.name(); + return Identifier.of(ns, selector); + } + } + + @Override + public Identifier extractIdentifier(CaseInsensitiveStringMap options) { + return catalogAndIdentifier(options).identifier(); + } + + @Override + public String extractCatalog(CaseInsensitiveStringMap options) { + return catalogAndIdentifier(options).catalog().name(); + } + + @Override + public Optional extractTimeTravelVersion(CaseInsensitiveStringMap options) { + return Optional.ofNullable(options.get(SparkReadOptions.VERSION_AS_OF)); + } + + @Override + public Optional extractTimeTravelTimestamp(CaseInsensitiveStringMap options) { + return Optional.ofNullable(options.get(SparkReadOptions.TIMESTAMP_AS_OF)); + } + + private static void setupDefaultSparkCatalogs(SparkSession spark) { + if (spark.conf().getOption(DEFAULT_CATALOG).isEmpty()) { + ImmutableMap config = + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "cache-enabled", "false" // the source should not use a cache + ); + spark.conf().set(DEFAULT_CATALOG, SparkCatalog.class.getName()); + config.forEach((key, value) -> spark.conf().set(DEFAULT_CATALOG + "." + key, value)); + } + + if (spark.conf().getOption(REWRITE_CATALOG).isEmpty()) { + spark.conf().set(REWRITE_CATALOG, SparkRewriteTableCatalog.class.getName()); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/InternalRowWrapper.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/InternalRowWrapper.java new file mode 100644 index 000000000000..d1682b8c85c1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/InternalRowWrapper.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.nio.ByteBuffer; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.stream.Stream; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.BinaryType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DecimalType; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * Class to adapt a Spark {@code InternalRow} to Iceberg {@link StructLike} for uses like {@link + * org.apache.iceberg.PartitionKey#partition(StructLike)} + */ +class InternalRowWrapper implements StructLike { + private final DataType[] types; + private final BiFunction[] getters; + private InternalRow row = null; + + @SuppressWarnings("unchecked") + InternalRowWrapper(StructType rowType, Types.StructType icebergSchema) { + this.types = Stream.of(rowType.fields()).map(StructField::dataType).toArray(DataType[]::new); + Preconditions.checkArgument( + types.length == icebergSchema.fields().size(), + "Invalid length: Spark struct type (%s) != Iceberg struct type (%s)", + types.length, + icebergSchema.fields().size()); + this.getters = new BiFunction[types.length]; + for (int i = 0; i < types.length; i++) { + getters[i] = getter(icebergSchema.fields().get(i).type(), types[i]); + } + } + + InternalRowWrapper wrap(InternalRow internalRow) { + this.row = internalRow; + return this; + } + + @Override + public int size() { + return types.length; + } + + @Override + public T get(int pos, Class javaClass) { + if (row.isNullAt(pos)) { + return null; + } else if (getters[pos] != null) { + return javaClass.cast(getters[pos].apply(row, pos)); + } + + return javaClass.cast(row.get(pos, types[pos])); + } + + @Override + public void set(int pos, T value) { + row.update(pos, value); + } + + private static BiFunction getter(Type icebergType, DataType type) { + if (type instanceof StringType) { + // Spark represents UUIDs as strings + if (Type.TypeID.UUID == icebergType.typeId()) { + return (row, pos) -> UUID.fromString(row.getUTF8String(pos).toString()); + } + + return (row, pos) -> row.getUTF8String(pos).toString(); + } else if (type instanceof DecimalType) { + DecimalType decimal = (DecimalType) type; + return (row, pos) -> + row.getDecimal(pos, decimal.precision(), decimal.scale()).toJavaBigDecimal(); + } else if (type instanceof BinaryType) { + return (row, pos) -> ByteBuffer.wrap(row.getBinary(pos)); + } else if (type instanceof StructType) { + StructType structType = (StructType) type; + InternalRowWrapper nestedWrapper = + new InternalRowWrapper(structType, icebergType.asStructType()); + return (row, pos) -> nestedWrapper.wrap(row.getStruct(pos, structType.size())); + } + + return null; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/MicroBatchUtils.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/MicroBatchUtils.java new file mode 100644 index 000000000000..7c73e3f416e3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/MicroBatchUtils.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.SnapshotUtil; + +class MicroBatchUtils { + + private MicroBatchUtils() {} + + static StreamingOffset determineStartingOffset(Table table, long fromTimestamp) { + if (table.currentSnapshot() == null) { + return StreamingOffset.START_OFFSET; + } + + if (fromTimestamp == Long.MIN_VALUE) { + // start from the oldest snapshot, since default value is MIN_VALUE + // avoids looping to find first snapshot + return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); + } + + if (table.currentSnapshot().timestampMillis() < fromTimestamp) { + return StreamingOffset.START_OFFSET; + } + + try { + Snapshot snapshot = SnapshotUtil.oldestAncestorAfter(table, fromTimestamp); + if (snapshot != null) { + return new StreamingOffset(snapshot.snapshotId(), 0, false); + } else { + return StreamingOffset.START_OFFSET; + } + } catch (IllegalStateException e) { + // could not determine the first snapshot after the timestamp. use the oldest ancestor instead + return new StreamingOffset(SnapshotUtil.oldestAncestor(table).snapshotId(), 0, false); + } + } + + static long addedFilesCount(Table table, Snapshot snapshot) { + long addedFilesCount = + PropertyUtil.propertyAsLong(snapshot.summary(), SnapshotSummary.ADDED_FILES_PROP, -1); + return addedFilesCount == -1 + ? Iterables.size( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) + : addedFilesCount; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/PositionDeletesRowReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/PositionDeletesRowReader.java new file mode 100644 index 000000000000..c7abf219db02 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/PositionDeletesRowReader.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.stream.Stream; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionUtil; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.spark.rdd.InputFileBlockHolder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.read.PartitionReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class PositionDeletesRowReader extends BaseRowReader + implements PartitionReader { + + private static final Logger LOG = LoggerFactory.getLogger(PositionDeletesRowReader.class); + + PositionDeletesRowReader(SparkInputPartition partition) { + this( + partition.table(), + partition.io(), + partition.taskGroup(), + partition.projection(), + partition.isCaseSensitive(), + partition.cacheDeleteFilesOnExecutors()); + } + + PositionDeletesRowReader( + Table table, + FileIO fileIO, + ScanTaskGroup taskGroup, + Schema expectedSchema, + boolean caseSensitive, + boolean cacheDeleteFilesOnExecutors) { + super(table, fileIO, taskGroup, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + + int numSplits = taskGroup.tasks().size(); + LOG.debug("Reading {} position delete file split(s) for table {}", numSplits, table.name()); + } + + @Override + protected Stream> referencedFiles(PositionDeletesScanTask task) { + return Stream.of(task.file()); + } + + @SuppressWarnings("resource") // handled by BaseReader + @Override + protected CloseableIterator open(PositionDeletesScanTask task) { + String filePath = task.file().location(); + LOG.debug("Opening position delete file {}", filePath); + + // update the current file for Spark's filename() function + InputFileBlockHolder.set(filePath, task.start(), task.length()); + + InputFile inputFile = getInputFile(task.file().location()); + Preconditions.checkNotNull(inputFile, "Could not find InputFile associated with %s", task); + + // Retain predicates on non-constant fields for row reader filter + Map idToConstant = constantsMap(task, expectedSchema()); + int[] nonConstantFieldIds = + expectedSchema().idToName().keySet().stream() + .filter(id -> !idToConstant.containsKey(id)) + .mapToInt(Integer::intValue) + .toArray(); + Expression residualWithoutConstants = + ExpressionUtil.extractByIdInclusive( + task.residual(), expectedSchema(), caseSensitive(), nonConstantFieldIds); + + if (ContentFileUtil.isDV(task.file())) { + return new DVIterator(inputFile, task.file(), expectedSchema(), idToConstant); + } + + return newIterable( + inputFile, + task.file().format(), + task.start(), + task.length(), + residualWithoutConstants, + expectedSchema(), + idToConstant) + .iterator(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java new file mode 100644 index 000000000000..dbfb0b7614e4 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/RowDataReader.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.stream.Stream; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataTask; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.source.metrics.TaskNumDeletes; +import org.apache.iceberg.spark.source.metrics.TaskNumSplits; +import org.apache.spark.rdd.InputFileBlockHolder; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; +import org.apache.spark.sql.connector.read.PartitionReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class RowDataReader extends BaseRowReader implements PartitionReader { + private static final Logger LOG = LoggerFactory.getLogger(RowDataReader.class); + + private final long numSplits; + + RowDataReader(SparkInputPartition partition) { + this( + partition.table(), + partition.io(), + partition.taskGroup(), + partition.projection(), + partition.isCaseSensitive(), + partition.cacheDeleteFilesOnExecutors()); + } + + RowDataReader( + Table table, + FileIO fileIO, + ScanTaskGroup taskGroup, + Schema expectedSchema, + boolean caseSensitive, + boolean cacheDeleteFilesOnExecutors) { + + super(table, fileIO, taskGroup, expectedSchema, caseSensitive, cacheDeleteFilesOnExecutors); + + numSplits = taskGroup.tasks().size(); + LOG.debug("Reading {} file split(s) for table {}", numSplits, table.name()); + } + + @Override + public CustomTaskMetric[] currentMetricsValues() { + return new CustomTaskMetric[] { + new TaskNumSplits(numSplits), new TaskNumDeletes(counter().get()) + }; + } + + @Override + protected Stream> referencedFiles(FileScanTask task) { + return Stream.concat(Stream.of(task.file()), task.deletes().stream()); + } + + @Override + protected CloseableIterator open(FileScanTask task) { + String filePath = task.file().location(); + LOG.debug("Opening data file {}", filePath); + SparkDeleteFilter deleteFilter = + new SparkDeleteFilter(filePath, task.deletes(), counter(), true); + + // schema or rows returned by readers + Schema requiredSchema = deleteFilter.requiredSchema(); + Map idToConstant = constantsMap(task, requiredSchema); + + // update the current file for Spark's filename() function + InputFileBlockHolder.set(filePath, task.start(), task.length()); + + return deleteFilter.filter(open(task, requiredSchema, idToConstant)).iterator(); + } + + protected CloseableIterable open( + FileScanTask task, Schema readSchema, Map idToConstant) { + if (task.isDataTask()) { + return newDataIterable(task.asDataTask(), readSchema); + } else { + InputFile inputFile = getInputFile(task.file().location()); + Preconditions.checkNotNull( + inputFile, "Could not find InputFile associated with FileScanTask"); + return newIterable( + inputFile, + task.file().format(), + task.start(), + task.length(), + task.residual(), + readSchema, + idToConstant); + } + } + + private CloseableIterable newDataIterable(DataTask task, Schema readSchema) { + StructInternalRow row = new StructInternalRow(readSchema.asStruct()); + return CloseableIterable.transform(task.asDataTask().rows(), row::setStruct); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java new file mode 100644 index 000000000000..2a359110c810 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SerializableFileIOWithSize.java @@ -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. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.function.Function; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.hadoop.HadoopConfigurable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.util.SerializableSupplier; +import org.apache.spark.util.KnownSizeEstimation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class provides a serializable {@link FileIO} with a known size estimate. Spark calls its + * {@link org.apache.spark.util.SizeEstimator} class when broadcasting variables and this can be an + * expensive operation, so providing a known size estimate allows that operation to be skipped. + * + *

    This class also implements {@link AutoCloseable} to avoid leaking resources upon broadcasting. + * Broadcast variables are destroyed and cleaned up on the driver and executors once they are + * garbage collected on the driver. The implementation ensures only resources used by copies of the + * main {@link FileIO} are released. + */ +class SerializableFileIOWithSize + implements FileIO, HadoopConfigurable, KnownSizeEstimation, AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(SerializableFileIOWithSize.class); + private static final long SIZE_ESTIMATE = 32_768L; + private final transient Object serializationMarker; + private final FileIO fileIO; + + private SerializableFileIOWithSize(FileIO fileIO) { + this.fileIO = fileIO; + this.serializationMarker = new Object(); + } + + @Override + public long estimatedSize() { + return SIZE_ESTIMATE; + } + + public static FileIO wrap(FileIO fileIO) { + return new SerializableFileIOWithSize(fileIO); + } + + @Override + public void close() { + if (null == serializationMarker) { + LOG.debug("Closing FileIO"); + fileIO.close(); + } + } + + @Override + public InputFile newInputFile(String path) { + return fileIO.newInputFile(path); + } + + @Override + public InputFile newInputFile(String path, long length) { + return fileIO.newInputFile(path, length); + } + + @Override + public OutputFile newOutputFile(String path) { + return fileIO.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + fileIO.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + fileIO.initialize(properties); + } + + @Override + public Map properties() { + return fileIO.properties(); + } + + @Override + public void serializeConfWith( + Function> confSerializer) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.serializeConfWith(confSerializer); + } + } + + @Override + public void setConf(Configuration conf) { + if (fileIO instanceof HadoopConfigurable configurable) { + configurable.setConf(conf); + } + } + + @Override + public Configuration getConf() { + if (fileIO instanceof HadoopConfigurable hadoopConfigurable) { + return hadoopConfigurable.getConf(); + } + + return null; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SerializableTableWithSize.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SerializableTableWithSize.java new file mode 100644 index 000000000000..f6913fb9d00d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SerializableTableWithSize.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.BaseMetadataTable; +import org.apache.iceberg.SerializableTable; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.SparkExecutorCache; +import org.apache.spark.util.KnownSizeEstimation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class provides a serializable table with a known size estimate. Spark calls its + * SizeEstimator class when broadcasting variables and this can be an expensive operation, so + * providing a known size estimate allows that operation to be skipped. + * + *

    This class also implements AutoCloseable to avoid leaking resources upon broadcasting. + * Broadcast variables are destroyed and cleaned up on the driver and executors once they are + * garbage collected on the driver. The implementation ensures only resources used by copies of the + * main table are released. + */ +public class SerializableTableWithSize extends SerializableTable + implements KnownSizeEstimation, AutoCloseable { + + private static final Logger LOG = LoggerFactory.getLogger(SerializableTableWithSize.class); + private static final long SIZE_ESTIMATE = 32_768L; + + private final transient Object serializationMarker; + + protected SerializableTableWithSize(Table table) { + super(table); + this.serializationMarker = new Object(); + } + + @Override + public long estimatedSize() { + return SIZE_ESTIMATE; + } + + public static Table copyOf(Table table) { + if (table instanceof BaseMetadataTable) { + return new SerializableMetadataTableWithSize((BaseMetadataTable) table); + } else { + return new SerializableTableWithSize(table); + } + } + + @Override + public void close() throws Exception { + if (serializationMarker == null) { + LOG.info("Releasing resources"); + io().close(); + } + invalidateCache(name()); + } + + public static class SerializableMetadataTableWithSize extends SerializableMetadataTable + implements KnownSizeEstimation, AutoCloseable { + + private static final Logger LOG = + LoggerFactory.getLogger(SerializableMetadataTableWithSize.class); + + private final transient Object serializationMarker; + + protected SerializableMetadataTableWithSize(BaseMetadataTable metadataTable) { + super(metadataTable); + this.serializationMarker = new Object(); + } + + @Override + public long estimatedSize() { + return SIZE_ESTIMATE; + } + + @Override + public void close() throws Exception { + if (serializationMarker == null) { + LOG.info("Releasing resources"); + io().close(); + } + invalidateCache(name()); + } + } + + private static void invalidateCache(String name) { + SparkExecutorCache cache = SparkExecutorCache.get(); + if (cache != null) { + cache.invalidate(name); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java new file mode 100644 index 000000000000..22a4b171b331 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatch.java @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.spark.ImmutableOrcBatchReadConf; +import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; +import org.apache.iceberg.spark.OrcBatchReadConf; +import org.apache.iceberg.spark.ParquetBatchReadConf; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.PartitionReaderFactory; + +class SparkBatch implements Batch { + + private final JavaSparkContext sparkContext; + private final Table table; + private final Supplier fileIO; + private final SparkReadConf readConf; + private final Types.StructType groupingKeyType; + private final List> taskGroups; + private final Schema projection; + private final boolean caseSensitive; + private final boolean localityEnabled; + private final boolean executorCacheLocalityEnabled; + private final int scanHashCode; + private final boolean cacheDeleteFilesOnExecutors; + + SparkBatch( + JavaSparkContext sparkContext, + Table table, + Supplier fileIO, + SparkReadConf readConf, + Types.StructType groupingKeyType, + List> taskGroups, + Schema projection, + int scanHashCode) { + this.sparkContext = sparkContext; + this.table = table; + this.fileIO = fileIO; + this.readConf = readConf; + this.groupingKeyType = groupingKeyType; + this.taskGroups = taskGroups; + this.projection = projection; + this.caseSensitive = readConf.caseSensitive(); + this.localityEnabled = readConf.localityEnabled(); + this.executorCacheLocalityEnabled = readConf.executorCacheLocalityEnabled(); + this.scanHashCode = scanHashCode; + this.cacheDeleteFilesOnExecutors = readConf.cacheDeleteFilesOnExecutors(); + } + + @Override + public InputPartition[] planInputPartitions() { + // broadcast the table metadata as input partitions will be sent to executors + Broadcast

    tableBroadcast = + sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + Broadcast fileIOBroadcast = + sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); + String projectionString = SchemaParser.toJson(projection); + String[][] locations = computePreferredLocations(); + + InputPartition[] partitions = new InputPartition[taskGroups.size()]; + + for (int index = 0; index < taskGroups.size(); index++) { + partitions[index] = + new SparkInputPartition( + groupingKeyType, + taskGroups.get(index), + tableBroadcast, + fileIOBroadcast, + projectionString, + caseSensitive, + locations != null ? locations[index] : SparkPlanningUtil.NO_LOCATION_PREFERENCE, + cacheDeleteFilesOnExecutors); + } + + return partitions; + } + + private String[][] computePreferredLocations() { + if (localityEnabled) { + return SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups); + + } else if (executorCacheLocalityEnabled) { + List executorLocations = SparkUtil.executorLocations(); + if (!executorLocations.isEmpty()) { + return SparkPlanningUtil.assignExecutors(taskGroups, executorLocations); + } + } + + return null; + } + + @Override + public PartitionReaderFactory createReaderFactory() { + if (useParquetBatchReads()) { + return new SparkColumnarReaderFactory(parquetBatchReadConf()); + + } else if (useOrcBatchReads()) { + return new SparkColumnarReaderFactory(orcBatchReadConf()); + + } else { + return new SparkRowReaderFactory(); + } + } + + private ParquetBatchReadConf parquetBatchReadConf() { + return ImmutableParquetBatchReadConf.builder().batchSize(readConf.parquetBatchSize()).build(); + } + + private OrcBatchReadConf orcBatchReadConf() { + return ImmutableOrcBatchReadConf.builder().batchSize(readConf.orcBatchSize()).build(); + } + + // conditions for using Parquet batch reads: + // - Parquet vectorization is enabled + // - only primitives or metadata columns are projected + // - all tasks are of FileScanTask type and read only Parquet files + private boolean useParquetBatchReads() { + return readConf.parquetVectorizationEnabled() + && projection.columns().stream().allMatch(this::supportsParquetBatchReads) + && taskGroups.stream().allMatch(this::supportsParquetBatchReads); + } + + private boolean supportsParquetBatchReads(ScanTask task) { + if (task instanceof ScanTaskGroup) { + ScanTaskGroup taskGroup = (ScanTaskGroup) task; + return taskGroup.tasks().stream().allMatch(this::supportsParquetBatchReads); + + } else if (task.isFileScanTask() && !task.isDataTask()) { + FileScanTask fileScanTask = task.asFileScanTask(); + return fileScanTask.file().format() == FileFormat.PARQUET; + + } else { + return false; + } + } + + private boolean supportsParquetBatchReads(Types.NestedField field) { + return field.type().isPrimitiveType() || MetadataColumns.isMetadataColumn(field.fieldId()); + } + + // conditions for using ORC batch reads: + // - ORC vectorization is enabled + // - all tasks are of type FileScanTask and read only ORC files with no delete files + private boolean useOrcBatchReads() { + return readConf.orcVectorizationEnabled() + && taskGroups.stream().allMatch(this::supportsOrcBatchReads); + } + + private boolean supportsOrcBatchReads(ScanTask task) { + if (task instanceof ScanTaskGroup) { + ScanTaskGroup taskGroup = (ScanTaskGroup) task; + return taskGroup.tasks().stream().allMatch(this::supportsOrcBatchReads); + + } else if (task.isFileScanTask() && !task.isDataTask()) { + FileScanTask fileScanTask = task.asFileScanTask(); + return fileScanTask.file().format() == FileFormat.ORC && fileScanTask.deletes().isEmpty(); + + } else { + return false; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + SparkBatch that = (SparkBatch) o; + return table.name().equals(that.table.name()) && scanHashCode == that.scanHashCode; + } + + @Override + public int hashCode() { + return Objects.hash(table.name(), scanHashCode); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java new file mode 100644 index 000000000000..814ca410d147 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import org.apache.iceberg.Scan; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.read.Statistics; + +class SparkBatchQueryScan extends SparkRuntimeFilterableScan { + + private final Snapshot snapshot; + private final String branch; + + SparkBatchQueryScan( + SparkSession spark, + Table table, + Schema schema, + Snapshot snapshot, + String branch, + Scan> scan, + SparkReadConf readConf, + Schema projection, + List filters, + Supplier scanReportSupplier) { + super(spark, table, schema, scan, readConf, projection, filters, scanReportSupplier); + this.snapshot = snapshot; + this.branch = branch; + } + + Long snapshotId() { + return snapshot != null ? snapshot.snapshotId() : null; + } + + @Override + public Statistics estimateStatistics() { + return estimateStatistics(snapshot); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + SparkBatchQueryScan that = (SparkBatchQueryScan) o; + return table().name().equals(that.table().name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && Objects.equals(snapshot, that.snapshot) + && Objects.equals(branch, that.branch) + && readSchema().equals(that.readSchema()) // compare Spark schemas to ignore field ids + && filtersDesc().equals(that.filtersDesc()) + && runtimeFiltersDesc().equals(that.runtimeFiltersDesc()); + } + + @Override + public int hashCode() { + return Objects.hash( + table().name(), + table().uuid(), + snapshot, + branch, + readSchema(), + filtersDesc(), + runtimeFiltersDesc()); + } + + @Override + public String description() { + return String.format( + "IcebergScan(table=%s, schemaId=%s, snapshotId=%s, branch=%s, filters=%s, runtimeFilters=%s, groupedBy=%s)", + table(), + schema().schemaId(), + snapshotId(), + branch, + filtersDesc(), + runtimeFiltersDesc(), + groupingKeyDesc()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java new file mode 100644 index 000000000000..57ccf92b9651 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScan.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.apache.iceberg.ChangelogScanTask; +import org.apache.iceberg.IncrementalChangelogScan; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.Statistics; +import org.apache.spark.sql.connector.read.SupportsReportStatistics; +import org.apache.spark.sql.types.StructType; + +class SparkChangelogScan implements Scan, SupportsReportStatistics { + + private static final Types.StructType EMPTY_GROUPING_KEY_TYPE = Types.StructType.of(); + + private final JavaSparkContext sparkContext; + private final Table table; + private final IncrementalChangelogScan scan; + private final SparkReadConf readConf; + private final Schema projection; + private final List filters; + private final Long startSnapshotId; + private final Long endSnapshotId; + + // lazy variables + private List> taskGroups = null; + private StructType expectedSparkType = null; + + SparkChangelogScan( + SparkSession spark, + Table table, + IncrementalChangelogScan scan, + SparkReadConf readConf, + Schema projection, + List filters) { + + SparkSchemaUtil.validateMetadataColumnReferences(table.schema(), projection); + + this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + this.table = table; + this.scan = scan; + this.readConf = readConf; + this.projection = projection; + this.filters = filters != null ? filters : Collections.emptyList(); + this.startSnapshotId = readConf.startSnapshotId(); + this.endSnapshotId = readConf.endSnapshotId(); + if (scan == null) { + this.taskGroups = Collections.emptyList(); + } + } + + @Override + public Statistics estimateStatistics() { + long rowsCount = taskGroups().stream().mapToLong(ScanTaskGroup::estimatedRowsCount).sum(); + long sizeInBytes = SparkSchemaUtil.estimateSize(readSchema(), rowsCount); + return new Stats(sizeInBytes, rowsCount, Collections.emptyMap()); + } + + @Override + public StructType readSchema() { + if (expectedSparkType == null) { + this.expectedSparkType = SparkSchemaUtil.convert(projection); + } + + return expectedSparkType; + } + + @Override + public Batch toBatch() { + return new SparkBatch( + sparkContext, + table, + null != scan ? scan.fileIO() : table::io, + readConf, + EMPTY_GROUPING_KEY_TYPE, + taskGroups(), + projection, + hashCode()); + } + + private List> taskGroups() { + if (taskGroups == null) { + try (CloseableIterable> groups = scan.planTasks()) { + this.taskGroups = Lists.newArrayList(groups); + } catch (IOException e) { + throw new UncheckedIOException("Failed to close changelog scan: " + scan, e); + } + } + + return taskGroups; + } + + @Override + public String description() { + return String.format( + Locale.ROOT, + "IcebergChangelogScan(table=%s, fromSnapshotId=%d, toSnapshotId=%d, filters=%s)", + table, + startSnapshotId, + endSnapshotId, + filtersDesc()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + SparkChangelogScan that = (SparkChangelogScan) o; + return table.name().equals(that.table.name()) + && Objects.equals(table.uuid(), that.table.uuid()) + && readSchema().equals(that.readSchema()) // compare Spark schemas to ignore field IDs + && filtersDesc().equals(that.filtersDesc()) + && Objects.equals(startSnapshotId, that.startSnapshotId) + && Objects.equals(endSnapshotId, that.endSnapshotId); + } + + @Override + public int hashCode() { + return Objects.hash( + table.name(), table.uuid(), readSchema(), filtersDesc(), startSnapshotId, endSnapshotId); + } + + private String filtersDesc() { + return Spark3Util.describe(filters); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScanBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScanBuilder.java new file mode 100644 index 000000000000..43b8a36507db --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogScanBuilder.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.IncrementalChangelogScan; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.SupportsPushDownLimit; +import org.apache.spark.sql.connector.read.SupportsPushDownRequiredColumns; +import org.apache.spark.sql.connector.read.SupportsPushDownV2Filters; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +public class SparkChangelogScanBuilder extends BaseSparkScanBuilder + implements SupportsPushDownV2Filters, SupportsPushDownRequiredColumns, SupportsPushDownLimit { + + SparkChangelogScanBuilder( + SparkSession spark, Table table, Schema schema, CaseInsensitiveStringMap options) { + super(spark, table, schema, options); + } + + @Override + public Scan build() { + Long startSnapshotId = readConf().startSnapshotId(); + Long endSnapshotId = readConf().endSnapshotId(); + Long startTimestamp = readConf().startTimestamp(); + Long endTimestamp = readConf().endTimestamp(); + + Preconditions.checkArgument( + startSnapshotId == null || startTimestamp == null, + "Cannot set both %s and %s for changelogs", + SparkReadOptions.START_SNAPSHOT_ID, + SparkReadOptions.START_TIMESTAMP); + + Preconditions.checkArgument( + endSnapshotId == null || endTimestamp == null, + "Cannot set both %s and %s for changelogs", + SparkReadOptions.END_SNAPSHOT_ID, + SparkReadOptions.END_TIMESTAMP); + + Preconditions.checkArgument( + startTimestamp == null || endTimestamp == null || startTimestamp < endTimestamp, + "Cannot set %s to be greater than %s for changelogs", + SparkReadOptions.START_TIMESTAMP, + SparkReadOptions.END_TIMESTAMP); + + if (startTimestamp != null) { + if (noSnapshotsAfter(startTimestamp)) { + return emptyChangelogScan(); + } + startSnapshotId = getStartSnapshotId(startTimestamp); + } + + if (endTimestamp != null) { + endSnapshotId = getEndSnapshotId(endTimestamp); + if (noSnapshotsBetween(startSnapshotId, endSnapshotId)) { + return emptyChangelogScan(); + } + } + + Schema projection = projectionWithMetadataColumns(); + IncrementalChangelogScan scan = buildIcebergScan(projection, startSnapshotId, endSnapshotId); + return new SparkChangelogScan(spark(), table(), scan, readConf(), projection, filters()); + } + + private IncrementalChangelogScan buildIcebergScan( + Schema projection, Long startSnapshotId, Long endSnapshotId) { + IncrementalChangelogScan scan = + table() + .newIncrementalChangelogScan() + .caseSensitive(caseSensitive()) + .filter(filter()) + .project(projection) + .metricsReporter(metricsReporter()); + + if (startSnapshotId != null) { + scan = scan.fromSnapshotExclusive(startSnapshotId); + } + + if (endSnapshotId != null) { + scan = scan.toSnapshot(endSnapshotId); + } + + scan = configureSplitPlanning(scan); + + return scan; + } + + private SparkChangelogScan emptyChangelogScan() { + return new SparkChangelogScan( + spark(), + table(), + null /* no scan */, + readConf(), + projectionWithMetadataColumns(), + filters()); + } + + private boolean noSnapshotsAfter(long timestamp) { + Snapshot currentSnapshot = table().currentSnapshot(); + return currentSnapshot == null || timestamp > currentSnapshot.timestampMillis(); + } + + private boolean noSnapshotsBetween(Long startSnapshotId, Long endSnapshotId) { + return (startSnapshotId == null && endSnapshotId == null) + || (startSnapshotId != null && startSnapshotId.equals(endSnapshotId)); + } + + private Long getStartSnapshotId(Long startTimestamp) { + Snapshot oldestSnapshotAfter = SnapshotUtil.oldestAncestorAfter(table(), startTimestamp); + if (oldestSnapshotAfter == null) { + return null; + } else if (oldestSnapshotAfter.timestampMillis() == startTimestamp) { + return oldestSnapshotAfter.snapshotId(); + } else { + return oldestSnapshotAfter.parentId(); + } + } + + private Long getEndSnapshotId(Long endTimestamp) { + Long endSnapshotId = null; + for (Snapshot snapshot : SnapshotUtil.currentAncestors(table())) { + if (snapshot.timestampMillis() <= endTimestamp) { + endSnapshotId = snapshot.snapshotId(); + break; + } + } + return endSnapshotId; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogTable.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogTable.java new file mode 100644 index 000000000000..bdafca27fbb8 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkChangelogTable.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Set; +import org.apache.iceberg.ChangelogUtil; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.catalog.MetadataColumn; +import org.apache.spark.sql.connector.catalog.SupportsMetadataColumns; +import org.apache.spark.sql.connector.catalog.SupportsRead; +import org.apache.spark.sql.connector.catalog.TableCapability; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +public class SparkChangelogTable + implements org.apache.spark.sql.connector.catalog.Table, SupportsRead, SupportsMetadataColumns { + + public static final String TABLE_NAME = "changes"; + + private static final Set CAPABILITIES = + ImmutableSet.of(TableCapability.BATCH_READ); + + private final Table table; + private final Schema schema; + + private SparkSession lazySpark = null; + private StructType lazySparkSchema = null; + + public SparkChangelogTable(Table table) { + this.table = table; + this.schema = ChangelogUtil.changelogSchema(table.schema()); + } + + @Override + public String name() { + return table.name() + "." + TABLE_NAME; + } + + @Override + public StructType schema() { + if (lazySparkSchema == null) { + this.lazySparkSchema = SparkSchemaUtil.convert(schema); + } + + return lazySparkSchema; + } + + @Override + public Set capabilities() { + return CAPABILITIES; + } + + @Override + public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) { + return new SparkChangelogScanBuilder(spark(), table, schema, options); + } + + private SparkSession spark() { + if (lazySpark == null) { + this.lazySpark = SparkSession.active(); + } + + return lazySpark; + } + + @Override + public MetadataColumn[] metadataColumns() { + return new MetadataColumn[] { + SparkMetadataColumns.SPEC_ID, + SparkMetadataColumns.partition(table), + SparkMetadataColumns.FILE_PATH, + SparkMetadataColumns.ROW_POSITION, + SparkMetadataColumns.IS_DELETED, + }; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java new file mode 100644 index 000000000000..8d5972ac7acf --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCleanupUtil.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.SupportsBulkOperations; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.util.Tasks; +import org.apache.iceberg.util.ThreadPools; +import org.apache.spark.TaskContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** A utility for cleaning up written but not committed files. */ +class SparkCleanupUtil { + + private static final Logger LOG = LoggerFactory.getLogger(SparkCleanupUtil.class); + + private static final int DELETE_NUM_RETRIES = 3; + private static final int DELETE_MIN_RETRY_WAIT_MS = 100; // 100 ms + private static final int DELETE_MAX_RETRY_WAIT_MS = 30 * 1000; // 30 seconds + private static final int DELETE_TOTAL_RETRY_TIME_MS = 2 * 60 * 1000; // 2 minutes + + private SparkCleanupUtil() {} + + /** + * Attempts to delete as many files produced by a task as possible. + * + *

    Note this method will log Spark task info and is supposed to be called only on executors. + * Use {@link #deleteFiles(String, FileIO, List)} to delete files on the driver. + * + * @param io a {@link FileIO} instance used for deleting files + * @param files a list of files to delete + */ + public static void deleteTaskFiles(FileIO io, List> files) { + deleteFiles(taskInfo(), io, files); + } + + // the format matches what Spark uses for internal logging + private static String taskInfo() { + TaskContext taskContext = TaskContext.get(); + if (taskContext == null) { + return "unknown task"; + } else { + return String.format( + Locale.ROOT, + "partition %d (task %d, attempt %d, stage %d.%d)", + taskContext.partitionId(), + taskContext.taskAttemptId(), + taskContext.attemptNumber(), + taskContext.stageId(), + taskContext.stageAttemptNumber()); + } + } + + /** + * Attempts to delete as many given files as possible. + * + * @param context a helpful description of the operation invoking this method + * @param io a {@link FileIO} instance used for deleting files + * @param files a list of files to delete + */ + public static void deleteFiles(String context, FileIO io, List> files) { + List paths = Lists.transform(files, ContentFile::location); + if (io instanceof SupportsBulkOperations) { + CatalogUtil.deleteFiles(io, paths, ""); + } else { + delete(context, io, paths); + } + } + + private static void delete(String context, FileIO io, List paths) { + AtomicInteger deletedFilesCount = new AtomicInteger(0); + + Tasks.foreach(paths) + .executeWith(ThreadPools.getWorkerPool()) + .stopRetryOn(NotFoundException.class) + .suppressFailureWhenFinished() + .onFailure((path, exc) -> LOG.warn("Failed to delete {} ({})", path, context, exc)) + .retry(DELETE_NUM_RETRIES) + .exponentialBackoff( + DELETE_MIN_RETRY_WAIT_MS, + DELETE_MAX_RETRY_WAIT_MS, + DELETE_TOTAL_RETRY_TIME_MS, + 2 /* exponential */) + .run( + path -> { + io.deleteFile(path); + deletedFilesCount.incrementAndGet(); + }); + + if (deletedFilesCount.get() < paths.size()) { + LOG.warn("Deleted only {} of {} file(s) ({})", deletedFilesCount, paths.size(), context); + } else { + LOG.info("Deleted {} file(s) ({})", paths.size(), context); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkColumnStatistics.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkColumnStatistics.java new file mode 100644 index 000000000000..faaff3631d7c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkColumnStatistics.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Optional; +import java.util.OptionalLong; +import org.apache.spark.sql.connector.read.colstats.ColumnStatistics; +import org.apache.spark.sql.connector.read.colstats.Histogram; + +class SparkColumnStatistics implements ColumnStatistics { + + private final OptionalLong distinctCount; + private final Optional min; + private final Optional max; + private final OptionalLong nullCount; + private final OptionalLong avgLen; + private final OptionalLong maxLen; + private final Optional histogram; + + SparkColumnStatistics( + Long distinctCount, + Object min, + Object max, + Long nullCount, + Long avgLen, + Long maxLen, + Histogram histogram) { + this.distinctCount = + (distinctCount == null) ? OptionalLong.empty() : OptionalLong.of(distinctCount); + this.min = Optional.ofNullable(min); + this.max = Optional.ofNullable(max); + this.nullCount = (nullCount == null) ? OptionalLong.empty() : OptionalLong.of(nullCount); + this.avgLen = (avgLen == null) ? OptionalLong.empty() : OptionalLong.of(avgLen); + this.maxLen = (maxLen == null) ? OptionalLong.empty() : OptionalLong.of(maxLen); + this.histogram = Optional.ofNullable(histogram); + } + + @Override + public OptionalLong distinctCount() { + return distinctCount; + } + + @Override + public Optional min() { + return min; + } + + @Override + public Optional max() { + return max; + } + + @Override + public OptionalLong nullCount() { + return nullCount; + } + + @Override + public OptionalLong avgLen() { + return avgLen; + } + + @Override + public OptionalLong maxLen() { + return maxLen; + } + + @Override + public Optional histogram() { + return histogram; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkColumnarReaderFactory.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkColumnarReaderFactory.java new file mode 100644 index 000000000000..887b84fb617a --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkColumnarReaderFactory.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.OrcBatchReadConf; +import org.apache.iceberg.spark.ParquetBatchReadConf; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.PartitionReader; +import org.apache.spark.sql.connector.read.PartitionReaderFactory; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +class SparkColumnarReaderFactory implements PartitionReaderFactory { + private final ParquetBatchReadConf parquetConf; + private final OrcBatchReadConf orcConf; + + SparkColumnarReaderFactory(ParquetBatchReadConf conf) { + this.parquetConf = conf; + this.orcConf = null; + } + + SparkColumnarReaderFactory(OrcBatchReadConf conf) { + this.orcConf = conf; + this.parquetConf = null; + } + + @Override + public PartitionReader createReader(InputPartition inputPartition) { + throw new UnsupportedOperationException("Row-based reads are not supported"); + } + + @Override + public PartitionReader createColumnarReader(InputPartition inputPartition) { + Preconditions.checkArgument( + inputPartition instanceof SparkInputPartition, + "Unknown input partition type: %s", + inputPartition.getClass().getName()); + + SparkInputPartition partition = (SparkInputPartition) inputPartition; + + if (partition.allTasksOfType(FileScanTask.class)) { + return new BatchDataReader(partition, parquetConf, orcConf); + } else { + throw new UnsupportedOperationException( + "Unsupported task group for columnar reads: " + partition.taskGroup()); + } + } + + @Override + public boolean supportColumnarReads(InputPartition inputPartition) { + return true; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteOperation.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteOperation.java new file mode 100644 index 000000000000..113098256ec1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteOperation.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; + +import java.util.List; +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.RowLevelOperation; +import org.apache.spark.sql.connector.write.RowLevelOperationInfo; +import org.apache.spark.sql.connector.write.WriteBuilder; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +class SparkCopyOnWriteOperation implements RowLevelOperation { + + private final SparkSession spark; + private final Table table; + private final Snapshot snapshot; + private final String branch; + private final Command command; + private final IsolationLevel isolationLevel; + + // lazy vars + private ScanBuilder lazyScanBuilder; + private Scan configuredScan; + private WriteBuilder lazyWriteBuilder; + + SparkCopyOnWriteOperation( + SparkSession spark, + Table table, + Snapshot snapshot, + String branch, + RowLevelOperationInfo info, + IsolationLevel isolationLevel) { + this.spark = spark; + this.table = table; + this.snapshot = snapshot; + this.branch = branch; + this.command = info.command(); + this.isolationLevel = isolationLevel; + } + + @Override + public Command command() { + return command; + } + + @Override + public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) { + if (lazyScanBuilder == null) { + lazyScanBuilder = + new SparkScanBuilder(spark, table, table.schema(), snapshot, branch, options) { + @Override + public Scan build() { + Scan scan = super.buildCopyOnWriteScan(); + SparkCopyOnWriteOperation.this.configuredScan = scan; + return scan; + } + }; + } + + return lazyScanBuilder; + } + + @Override + public WriteBuilder newWriteBuilder(LogicalWriteInfo info) { + if (lazyWriteBuilder == null) { + SparkWriteBuilder writeBuilder = new SparkWriteBuilder(spark, table, branch, info); + lazyWriteBuilder = writeBuilder.overwriteFiles(configuredScan, command, isolationLevel); + } + + return lazyWriteBuilder; + } + + @Override + public NamedReference[] requiredMetadataAttributes() { + List metaAttrs = Lists.newArrayList(); + metaAttrs.add(SparkMetadataColumns.FILE_PATH.asRef()); + + if (command == DELETE || command == UPDATE) { + metaAttrs.add(SparkMetadataColumns.ROW_POSITION.asRef()); + } + + if (TableUtil.supportsRowLineage(table)) { + metaAttrs.add(SparkMetadataColumns.ROW_ID.asRef()); + metaAttrs.add(SparkMetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.asRef()); + } + + return metaAttrs.toArray(NamedReference[]::new); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java new file mode 100644 index 000000000000..b8b3a0edeab0 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.iceberg.BatchScan; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.Literal; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.Statistics; +import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SparkCopyOnWriteScan extends SparkPartitioningAwareScan + implements SupportsRuntimeV2Filtering { + + private static final Logger LOG = LoggerFactory.getLogger(SparkCopyOnWriteScan.class); + + private final Snapshot snapshot; + private final String branch; + + private Set filteredLocations = null; + + SparkCopyOnWriteScan( + SparkSession spark, + Table table, + Schema schema, + Snapshot snapshot, + String branch, + BatchScan scan, + SparkReadConf readConf, + Schema projection, + List filters, + Supplier scanReportSupplier) { + super(spark, table, schema, scan, readConf, projection, filters, scanReportSupplier); + this.snapshot = snapshot; + this.branch = branch; + if (scan == null) { + this.filteredLocations = Collections.emptySet(); + } + } + + Long snapshotId() { + return snapshot != null ? snapshot.snapshotId() : null; + } + + @Override + protected Class taskJavaClass() { + return FileScanTask.class; + } + + @Override + public Statistics estimateStatistics() { + return estimateStatistics(snapshot); + } + + @Override + public NamedReference[] filterAttributes() { + return new NamedReference[] {SparkMetadataColumns.FILE_PATH.asRef()}; + } + + @Override + public void filter(Predicate[] predicates) { + for (Predicate predicate : predicates) { + // Spark can only pass IN predicates at the moment + if (isFilePathInPredicate(predicate)) { + Set fileLocations = extractStringLiterals(predicate); + + // Spark may call this multiple times for UPDATEs with subqueries + // as such cases are rewritten using UNION and the same scan on both sides + // so filter files only if it is beneficial + if (filteredLocations == null || fileLocations.size() < filteredLocations.size()) { + this.filteredLocations = fileLocations; + List filteredTasks = + tasks().stream() + .filter(file -> fileLocations.contains(file.file().location())) + .collect(Collectors.toList()); + + LOG.info( + "{} of {} task(s) for table {} matched runtime file filter with {} location(s)", + filteredTasks.size(), + tasks().size(), + table().name(), + fileLocations.size()); + + resetTasks(filteredTasks); + } + } else { + LOG.warn("Unsupported runtime filter {}", predicate); + } + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + SparkCopyOnWriteScan that = (SparkCopyOnWriteScan) o; + return table().name().equals(that.table().name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && Objects.equals(snapshot, that.snapshot) + && Objects.equals(branch, that.branch) + && readSchema().equals(that.readSchema()) // compare Spark schemas to ignore field ids + && filtersDesc().equals(that.filtersDesc()) + && Objects.equals(filteredLocations, that.filteredLocations); + } + + @Override + public int hashCode() { + return Objects.hash( + table().name(), + table().uuid(), + snapshot, + branch, + readSchema(), + filtersDesc(), + filteredLocations); + } + + @Override + public String description() { + return String.format( + "IcebergCopyOnWriteScan(table=%s, schemaId=%s, snapshotId=%s, branch=%s, filters=%s, groupedBy=%s)", + table(), schema().schemaId(), snapshotId(), branch, filtersDesc(), groupingKeyDesc()); + } + + private static boolean isFilePathInPredicate(Predicate predicate) { + if (!"IN".equals(predicate.name()) || predicate.children().length < 1) { + return false; + } + + if (!(predicate.children()[0] instanceof NamedReference)) { + return false; + } + + String[] fieldNames = ((NamedReference) predicate.children()[0]).fieldNames(); + + return fieldNames.length == 1 + && fieldNames[0].equalsIgnoreCase(MetadataColumns.FILE_PATH.name()); + } + + private static Set extractStringLiterals(Predicate predicate) { + Set values = Sets.newHashSet(); + for (int i = 1; i < predicate.children().length; i++) { + if (predicate.children()[i] instanceof Literal) { + Object value = ((Literal) predicate.children()[i]).value(); + // V2 string literals come through as UTF8String; toString() materializes the Java String + values.add(value.toString()); + } + } + + return values; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java new file mode 100644 index 000000000000..39110f0b0597 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkFileWriterFactory.java @@ -0,0 +1,364 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.MetadataColumns.DELETE_FILE_ROW_FIELD_NAME; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; +import static org.apache.iceberg.TableProperties.DELETE_DEFAULT_FILE_FORMAT; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Map; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.data.RegistryBasedFileWriterFactory; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.io.DeleteSchemaUtil; +import org.apache.iceberg.orc.ORC; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.SparkAvroWriter; +import org.apache.iceberg.spark.data.SparkOrcWriter; +import org.apache.iceberg.spark.data.SparkParquetWriters; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SparkFileWriterFactory extends RegistryBasedFileWriterFactory { + private static final Logger LOG = LoggerFactory.getLogger(SparkFileWriterFactory.class); + // We need to use old writers to write position deletes with row data, which is a deprecated + // feature. + private final boolean useDeprecatedPositionDeleteWriter; + private StructType positionDeleteSparkType; + private final Schema positionDeleteRowSchema; + private final Table table; + private final FileFormat deleteFormat; + private final Map writeProperties; + + /** + * @deprecated This constructor is deprecated as of version 1.11.0 and will be removed in 1.12.0. + * Position deletes that include row data are no longer supported. Use {@link + * #SparkFileWriterFactory(Table, FileFormat, Schema, StructType, SortOrder, FileFormat, + * int[], Schema, StructType, SortOrder, Map)} instead. + */ + @Deprecated + SparkFileWriterFactory( + Table table, + FileFormat dataFileFormat, + Schema dataSchema, + StructType dataSparkType, + SortOrder dataSortOrder, + FileFormat deleteFileFormat, + int[] equalityFieldIds, + Schema equalityDeleteRowSchema, + StructType equalityDeleteSparkType, + SortOrder equalityDeleteSortOrder, + Schema positionDeleteRowSchema, + StructType positionDeleteSparkType, + Map writeProperties) { + + super( + table, + dataFileFormat, + InternalRow.class, + dataSchema, + dataSortOrder, + deleteFileFormat, + equalityFieldIds, + equalityDeleteRowSchema, + equalityDeleteSortOrder, + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); + + this.table = table; + this.deleteFormat = deleteFileFormat; + this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = positionDeleteRowSchema; + this.positionDeleteSparkType = positionDeleteSparkType; + this.useDeprecatedPositionDeleteWriter = + positionDeleteRowSchema != null + || (positionDeleteSparkType != null + && positionDeleteSparkType.getFieldIndex(DELETE_FILE_ROW_FIELD_NAME).isDefined()); + } + + SparkFileWriterFactory( + Table table, + FileFormat dataFileFormat, + Schema dataSchema, + StructType dataSparkType, + SortOrder dataSortOrder, + FileFormat deleteFileFormat, + int[] equalityFieldIds, + Schema equalityDeleteRowSchema, + StructType equalityDeleteSparkType, + SortOrder equalityDeleteSortOrder, + Map writeProperties) { + + super( + table, + dataFileFormat, + InternalRow.class, + dataSchema, + dataSortOrder, + deleteFileFormat, + equalityFieldIds, + equalityDeleteRowSchema, + equalityDeleteSortOrder, + writeProperties, + useOrConvert(dataSparkType, dataSchema), + useOrConvert(equalityDeleteSparkType, equalityDeleteRowSchema)); + + this.table = table; + this.deleteFormat = deleteFileFormat; + this.writeProperties = writeProperties != null ? writeProperties : ImmutableMap.of(); + this.positionDeleteRowSchema = null; + this.useDeprecatedPositionDeleteWriter = false; + } + + static Builder builderFor(Table table) { + return new Builder(table); + } + + private StructType positionDeleteSparkType() { + if (positionDeleteSparkType == null) { + // wrap the optional row schema into the position delete schema containing path and position + Schema positionDeleteSchema = DeleteSchemaUtil.posDeleteSchema(positionDeleteRowSchema); + this.positionDeleteSparkType = SparkSchemaUtil.convert(positionDeleteSchema); + } + + return positionDeleteSparkType; + } + + @Override + public PositionDeleteWriter newPositionDeleteWriter( + EncryptedOutputFile file, PartitionSpec spec, StructLike partition) { + if (!useDeprecatedPositionDeleteWriter) { + return super.newPositionDeleteWriter(file, spec, partition); + } else { + LOG.warn("Position deletes with deleted rows are deprecated and will be removed in 1.12.0."); + Map properties = table == null ? ImmutableMap.of() : table.properties(); + MetricsConfig metricsConfig = + table == null + ? MetricsConfig.forPositionDelete() + : MetricsConfig.forPositionDelete(table); + + try { + return switch (deleteFormat) { + case AVRO -> + Avro.writeDeletes(file) + .createWriterFunc( + ignored -> + new SparkAvroWriter( + (StructType) + positionDeleteSparkType() + .apply(DELETE_FILE_ROW_FIELD_NAME) + .dataType())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case ORC -> + ORC.writeDeletes(file) + .createWriterFunc(SparkOrcWriter::new) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + case PARQUET -> + Parquet.writeDeletes(file) + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter(positionDeleteSparkType(), msgType)) + .transformPaths(path -> UTF8String.fromString(path.toString())) + .setAll(properties) + .setAll(writeProperties) + .metricsConfig(metricsConfig) + .withPartition(partition) + .overwrite() + .rowSchema(positionDeleteRowSchema) + .withSpec(spec) + .withKeyMetadata(file.keyMetadata()) + .buildPositionWriter(); + default -> + throw new UnsupportedOperationException( + "Cannot write pos-deletes for unsupported file format: " + deleteFormat); + }; + } catch (IOException e) { + throw new UncheckedIOException("Failed to create new position delete writer", e); + } + } + } + + static class Builder { + private final Table table; + private FileFormat dataFileFormat; + private Schema dataSchema; + private StructType dataSparkType; + private SortOrder dataSortOrder; + private FileFormat deleteFileFormat; + private int[] equalityFieldIds; + private Schema equalityDeleteRowSchema; + private StructType equalityDeleteSparkType; + private SortOrder equalityDeleteSortOrder; + private Schema positionDeleteRowSchema; + private StructType positionDeleteSparkType; + private Map writeProperties; + + Builder(Table table) { + this.table = table; + + Map properties = table.properties(); + + String dataFileFormatName = + properties.getOrDefault(DEFAULT_FILE_FORMAT, DEFAULT_FILE_FORMAT_DEFAULT); + this.dataFileFormat = FileFormat.fromString(dataFileFormatName); + + String deleteFileFormatName = + properties.getOrDefault(DELETE_DEFAULT_FILE_FORMAT, dataFileFormatName); + this.deleteFileFormat = FileFormat.fromString(deleteFileFormatName); + } + + Builder dataFileFormat(FileFormat newDataFileFormat) { + this.dataFileFormat = newDataFileFormat; + return this; + } + + Builder dataSchema(Schema newDataSchema) { + this.dataSchema = newDataSchema; + return this; + } + + Builder dataSparkType(StructType newDataSparkType) { + this.dataSparkType = newDataSparkType; + return this; + } + + Builder dataSortOrder(SortOrder newDataSortOrder) { + this.dataSortOrder = newDataSortOrder; + return this; + } + + Builder deleteFileFormat(FileFormat newDeleteFileFormat) { + this.deleteFileFormat = newDeleteFileFormat; + return this; + } + + Builder equalityFieldIds(int[] newEqualityFieldIds) { + this.equalityFieldIds = newEqualityFieldIds; + return this; + } + + Builder equalityDeleteRowSchema(Schema newEqualityDeleteRowSchema) { + this.equalityDeleteRowSchema = newEqualityDeleteRowSchema; + return this; + } + + Builder equalityDeleteSparkType(StructType newEqualityDeleteSparkType) { + this.equalityDeleteSparkType = newEqualityDeleteSparkType; + return this; + } + + Builder equalityDeleteSortOrder(SortOrder newEqualityDeleteSortOrder) { + this.equalityDeleteSortOrder = newEqualityDeleteSortOrder; + return this; + } + + /** + * @deprecated This method is deprecated as of version 1.11.0 and will be removed in 1.12.0. + * Position deletes that include row data are no longer supported. + */ + @Deprecated + Builder positionDeleteRowSchema(Schema newPositionDeleteRowSchema) { + this.positionDeleteRowSchema = newPositionDeleteRowSchema; + return this; + } + + /** + * @deprecated This method is deprecated as of version 1.11.0 and will be removed in 1.12.0. + * Position deletes that include row data are no longer supported. + */ + @Deprecated + Builder positionDeleteSparkType(StructType newPositionDeleteSparkType) { + this.positionDeleteSparkType = newPositionDeleteSparkType; + return this; + } + + Builder writeProperties(Map properties) { + this.writeProperties = properties; + return this; + } + + SparkFileWriterFactory build() { + boolean noEqualityDeleteConf = equalityFieldIds == null && equalityDeleteRowSchema == null; + boolean fullEqualityDeleteConf = equalityFieldIds != null && equalityDeleteRowSchema != null; + Preconditions.checkArgument( + noEqualityDeleteConf || fullEqualityDeleteConf, + "Equality field IDs and equality delete row schema must be set together"); + + return new SparkFileWriterFactory( + table, + dataFileFormat, + dataSchema, + dataSparkType, + dataSortOrder, + deleteFileFormat, + equalityFieldIds, + equalityDeleteRowSchema, + equalityDeleteSparkType, + equalityDeleteSortOrder, + positionDeleteRowSchema, + positionDeleteSparkType, + writeProperties); + } + } + + private static StructType useOrConvert(StructType sparkType, Schema schema) { + if (sparkType != null) { + return sparkType; + } else if (schema != null) { + return SparkSchemaUtil.convert(schema); + } else { + return null; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java new file mode 100644 index 000000000000..5b7862116aea --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkFormatModels.java @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.avro.AvroFormatModel; +import org.apache.iceberg.formats.FormatModelRegistry; +import org.apache.iceberg.orc.ORCFormatModel; +import org.apache.iceberg.parquet.ParquetFormatModel; +import org.apache.iceberg.spark.data.SparkAvroWriter; +import org.apache.iceberg.spark.data.SparkOrcReader; +import org.apache.iceberg.spark.data.SparkOrcWriter; +import org.apache.iceberg.spark.data.SparkParquetReaders; +import org.apache.iceberg.spark.data.SparkParquetWriters; +import org.apache.iceberg.spark.data.SparkPlannedAvroReader; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +public class SparkFormatModels { + public static void register() { + FormatModelRegistry.register( + AvroFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkAvroWriter(icebergSchema, engineSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkPlannedAvroReader.create(icebergSchema, idToConstant))); + + FormatModelRegistry.register( + ParquetFormatModel.create( + InternalRow.class, + StructType.class, + SparkParquetWriters::buildWriter, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + SparkParquetReaders.buildReader(icebergSchema, fileSchema, idToConstant), + new SparkVariantShreddingAnalyzer(), + InternalRow::copy)); + + FormatModelRegistry.register( + ParquetFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkParquetReaders.buildReader( + icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + InternalRow.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema) -> + new SparkOrcWriter(icebergSchema, fileSchema), + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + new SparkOrcReader(icebergSchema, fileSchema, idToConstant))); + + FormatModelRegistry.register( + ORCFormatModel.create( + ColumnarBatch.class, + StructType.class, + (icebergSchema, fileSchema, engineSchema, idToConstant) -> + VectorizedSparkOrcReaders.buildReader(icebergSchema, fileSchema, idToConstant))); + } + + private SparkFormatModels() {} +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkIncrementalAppendScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkIncrementalAppendScan.java new file mode 100644 index 000000000000..ecfe782a53f5 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkIncrementalAppendScan.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; +import org.apache.iceberg.IncrementalAppendScan; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.spark.sql.SparkSession; + +class SparkIncrementalAppendScan extends SparkRuntimeFilterableScan { + + private final long startSnapshotId; + private final Long endSnapshotId; + + SparkIncrementalAppendScan( + SparkSession spark, + Table table, + long startSnapshotId, + Long endSnapshotId, + IncrementalAppendScan scan, + SparkReadConf readConf, + Schema projection, + List filters, + Supplier scanReportSupplier) { + super(spark, table, table.schema(), scan, readConf, projection, filters, scanReportSupplier); + this.startSnapshotId = startSnapshotId; + this.endSnapshotId = endSnapshotId; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other == null || getClass() != other.getClass()) { + return false; + } + + SparkIncrementalAppendScan that = (SparkIncrementalAppendScan) other; + return table().name().equals(that.table().name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && startSnapshotId == that.startSnapshotId + && Objects.equals(endSnapshotId, that.endSnapshotId) + && readSchema().equals(that.readSchema()) // compare Spark schemas to ignore field ids + && filtersDesc().equals(that.filtersDesc()) + && runtimeFiltersDesc().equals(that.runtimeFiltersDesc()); + } + + @Override + public int hashCode() { + return Objects.hash( + table().name(), + table().uuid(), + startSnapshotId, + endSnapshotId, + readSchema(), + filtersDesc(), + runtimeFiltersDesc()); + } + + @Override + public String description() { + return String.format( + "IcebergIncrementalScan(table=%s, startSnapshotId=%s, endSnapshotId=%s, filters=%s, runtimeFilters=%s, groupedBy=%s)", + table(), + startSnapshotId, + endSnapshotId, + filtersDesc(), + runtimeFiltersDesc(), + groupingKeyDesc()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java new file mode 100644 index 000000000000..a3d78b43a919 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkInputPartition.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.Serializable; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.types.Types; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.read.HasPartitionKey; +import org.apache.spark.sql.connector.read.InputPartition; + +class SparkInputPartition implements InputPartition, HasPartitionKey, Serializable { + private final Types.StructType groupingKeyType; + private final ScanTaskGroup taskGroup; + private final Broadcast

    tableBroadcast; + private final Broadcast fileIOBroadcast; + private final String projectionString; + private final boolean caseSensitive; + private final transient String[] preferredLocations; + private final boolean cacheDeleteFilesOnExecutors; + + private transient Schema projection = null; + + SparkInputPartition( + Types.StructType groupingKeyType, + ScanTaskGroup taskGroup, + Broadcast
    tableBroadcast, + Broadcast fileIOBroadcast, + String projectionString, + boolean caseSensitive, + String[] preferredLocations, + boolean cacheDeleteFilesOnExecutors) { + this.groupingKeyType = groupingKeyType; + this.taskGroup = taskGroup; + this.tableBroadcast = tableBroadcast; + this.fileIOBroadcast = fileIOBroadcast; + this.projectionString = projectionString; + this.caseSensitive = caseSensitive; + this.preferredLocations = preferredLocations; + this.cacheDeleteFilesOnExecutors = cacheDeleteFilesOnExecutors; + } + + @Override + public String[] preferredLocations() { + return preferredLocations; + } + + @Override + public InternalRow partitionKey() { + return new StructInternalRow(groupingKeyType).setStruct(taskGroup.groupingKey()); + } + + @SuppressWarnings("unchecked") + public ScanTaskGroup taskGroup() { + return (ScanTaskGroup) taskGroup; + } + + public boolean allTasksOfType(Class javaClass) { + return taskGroup.tasks().stream().allMatch(javaClass::isInstance); + } + + public Table table() { + return tableBroadcast.value(); + } + + public FileIO io() { + return fileIOBroadcast.value(); + } + + public boolean isCaseSensitive() { + return caseSensitive; + } + + public boolean cacheDeleteFilesOnExecutors() { + return cacheDeleteFilesOnExecutors; + } + + public Schema projection() { + if (projection == null) { + this.projection = SchemaParser.fromJson(projectionString); + } + + return projection; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkLocalScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkLocalScan.java new file mode 100644 index 000000000000..13806297f7b9 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkLocalScan.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.read.LocalScan; +import org.apache.spark.sql.types.StructType; + +class SparkLocalScan implements LocalScan { + + private final Table table; + private final StructType readSchema; + private final InternalRow[] rows; + private final List filterExpressions; + + SparkLocalScan( + Table table, StructType readSchema, InternalRow[] rows, List filterExpressions) { + this.table = table; + this.readSchema = readSchema; + this.rows = rows; + this.filterExpressions = filterExpressions; + } + + @Override + public InternalRow[] rows() { + return rows; + } + + @Override + public StructType readSchema() { + return readSchema; + } + + @Override + public String description() { + String filtersDesc = Spark3Util.describe(filterExpressions); + return String.format("IcebergLocalScan(table=%s, filters=%s)", table, filtersDesc); + } + + @Override + public String toString() { + return description(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMetadataColumn.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMetadataColumn.java new file mode 100644 index 000000000000..8f2ae73460a3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMetadataColumn.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Strings; +import org.apache.spark.sql.connector.catalog.MetadataColumn; +import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.MetadataBuilder; + +public class SparkMetadataColumn implements MetadataColumn { + + private final String name; + private final DataType dataType; + private final boolean isNullable; + private final boolean preserveOnReinsert; + private final boolean preserveOnUpdate; + private final boolean preserveOnDelete; + + public static class Builder { + private String name; + private DataType dataType; + private boolean isNullable; + private boolean preserveOnReinsert = MetadataColumn.PRESERVE_ON_REINSERT_DEFAULT; + private boolean preserveOnUpdate = MetadataColumn.PRESERVE_ON_UPDATE_DEFAULT; + private boolean preserveOnDelete = MetadataColumn.PRESERVE_ON_DELETE_DEFAULT; + + public Builder name(String fieldName) { + Preconditions.checkArgument( + !Strings.isNullOrEmpty(fieldName), "Cannot have a null or empty name"); + this.name = fieldName; + return this; + } + + public Builder dataType(DataType type) { + Preconditions.checkArgument(type != null, "Cannot have a null datatype"); + this.dataType = type; + return this; + } + + public Builder withNullability(boolean nullable) { + this.isNullable = nullable; + return this; + } + + public Builder preserveOnReinsert(boolean shouldPreserveOnReinsert) { + this.preserveOnReinsert = shouldPreserveOnReinsert; + return this; + } + + public Builder preserveOnUpdate(boolean shouldPreserveOnUpdate) { + this.preserveOnUpdate = shouldPreserveOnUpdate; + return this; + } + + public Builder preserveOnDelete(boolean shouldPreserveOnDelete) { + this.preserveOnDelete = shouldPreserveOnDelete; + return this; + } + + public SparkMetadataColumn build() { + Preconditions.checkArgument( + name != null, "Cannot build a SparkMetadataColumn with a null name"); + Preconditions.checkArgument( + dataType != null, "Cannot build a SparkMetadataColumn with a null data type"); + return new SparkMetadataColumn( + name, dataType, isNullable, preserveOnReinsert, preserveOnUpdate, preserveOnDelete); + } + } + + public static Builder builder() { + return new Builder(); + } + + private SparkMetadataColumn( + String name, + DataType dataType, + boolean isNullable, + boolean preserveOnReinsert, + boolean preserveOnUpdate, + boolean preserveOnDelete) { + this.name = name; + this.dataType = dataType; + this.isNullable = isNullable; + this.preserveOnReinsert = preserveOnReinsert; + this.preserveOnUpdate = preserveOnUpdate; + this.preserveOnDelete = preserveOnDelete; + } + + @Override + public String name() { + return name; + } + + @Override + public DataType dataType() { + return dataType; + } + + @Override + public boolean isNullable() { + return isNullable; + } + + @Override + public String metadataInJSON() { + return new MetadataBuilder() + .putBoolean(MetadataColumn.PRESERVE_ON_REINSERT, preserveOnReinsert) + .putBoolean(MetadataColumn.PRESERVE_ON_UPDATE, preserveOnUpdate) + .putBoolean(MetadataColumn.PRESERVE_ON_DELETE, preserveOnDelete) + .build() + .json(); + } + + public NamedReference asRef() { + return Expressions.column(name()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMetadataColumns.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMetadataColumns.java new file mode 100644 index 000000000000..65d79272fe8a --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMetadataColumns.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.spark.sql.types.DataTypes; + +public class SparkMetadataColumns { + + private SparkMetadataColumns() {} + + public static final SparkMetadataColumn SPEC_ID = + SparkMetadataColumn.builder() + .name(MetadataColumns.SPEC_ID.name()) + .dataType(DataTypes.IntegerType) + .withNullability(true) + .build(); + + public static final SparkMetadataColumn FILE_PATH = + SparkMetadataColumn.builder() + .name(MetadataColumns.FILE_PATH.name()) + .dataType(DataTypes.StringType) + .withNullability(false) + .build(); + + public static final SparkMetadataColumn ROW_POSITION = + SparkMetadataColumn.builder() + .name(MetadataColumns.ROW_POSITION.name()) + .dataType(DataTypes.LongType) + .withNullability(false) + .build(); + + public static final SparkMetadataColumn IS_DELETED = + SparkMetadataColumn.builder() + .name(MetadataColumns.IS_DELETED.name()) + .dataType(DataTypes.BooleanType) + .withNullability(false) + .build(); + + public static final SparkMetadataColumn ROW_ID = + SparkMetadataColumn.builder() + .name(MetadataColumns.ROW_ID.name()) + .dataType(DataTypes.LongType) + .withNullability(true) + .preserveOnReinsert(true) + .preserveOnUpdate(true) + .preserveOnDelete(false) + .build(); + + public static final SparkMetadataColumn LAST_UPDATED_SEQUENCE_NUMBER = + SparkMetadataColumn.builder() + .name(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()) + .dataType(DataTypes.LongType) + .withNullability(true) + .preserveOnReinsert(false) + .preserveOnUpdate(false) + .preserveOnDelete(false) + .build(); + + public static SparkMetadataColumn partition(Table table) { + return SparkMetadataColumn.builder() + .name(MetadataColumns.PARTITION_COLUMN_NAME) + .dataType(SparkSchemaUtil.convert(Partitioning.partitionType(table))) + .withNullability(true) + .build(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java new file mode 100644 index 000000000000..1986ddac5d8e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.FileScanTask; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; + +interface SparkMicroBatchPlanner { + /** + * Return the {@link FileScanTask}s for data added between the start and end offsets. + * + * @param startOffset the offset to start planning from + * @param endOffset the offset to plan up to + * @return file scan tasks for data in the offset range + */ + List planFiles(StreamingOffset startOffset, StreamingOffset endOffset); + + /** + * Return the latest offset the stream can advance to from {@code startOffset}, respecting the + * given {@link ReadLimit}. + * + * @param startOffset the current offset of the stream + * @param limit the read limit bounding how far ahead to advance + * @return the latest available offset, or {@code null} if no new data is available + */ + StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit); + + /** Stop the planner and release any resources. */ + void stop(); +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java new file mode 100644 index 000000000000..7adf3c633cd0 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java @@ -0,0 +1,317 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.function.Supplier; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SchemaParser; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.TableScanUtil; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.PartitionReaderFactory; +import org.apache.spark.sql.connector.read.streaming.MicroBatchStream; +import org.apache.spark.sql.connector.read.streaming.Offset; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.apache.spark.sql.connector.read.streaming.SupportsTriggerAvailableNow; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SparkMicroBatchStream implements MicroBatchStream, SupportsTriggerAvailableNow { + private static final Joiner SLASH = Joiner.on("/"); + private static final Logger LOG = LoggerFactory.getLogger(SparkMicroBatchStream.class); + private static final Types.StructType EMPTY_GROUPING_KEY_TYPE = Types.StructType.of(); + + private final Table table; + private final Supplier fileIO; + private final SparkReadConf readConf; + private final boolean caseSensitive; + private final String projection; + private final Broadcast
    tableBroadcast; + private final Broadcast fileIOBroadcast; + private final long splitSize; + private final int splitLookback; + private final long splitOpenFileCost; + private final boolean localityPreferred; + private final StreamingOffset initialOffset; + private final long fromTimestamp; + private final int maxFilesPerMicroBatch; + private final int maxRecordsPerMicroBatch; + private final boolean cacheDeleteFilesOnExecutors; + private SparkMicroBatchPlanner planner; + private StreamingOffset lastOffsetForTriggerAvailableNow; + + SparkMicroBatchStream( + JavaSparkContext sparkContext, + Table table, + Supplier fileIO, + SparkReadConf readConf, + Schema projection, + String checkpointLocation) { + this.table = table; + this.fileIO = fileIO; + this.readConf = readConf; + this.caseSensitive = readConf.caseSensitive(); + this.projection = SchemaParser.toJson(projection); + this.localityPreferred = readConf.localityEnabled(); + this.tableBroadcast = sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + this.fileIOBroadcast = sparkContext.broadcast(SerializableFileIOWithSize.wrap(fileIO.get())); + this.splitSize = readConf.splitSize(); + this.splitLookback = readConf.splitLookback(); + this.splitOpenFileCost = readConf.splitOpenFileCost(); + this.fromTimestamp = readConf.streamFromTimestamp(); + this.maxFilesPerMicroBatch = readConf.maxFilesPerMicroBatch(); + this.maxRecordsPerMicroBatch = readConf.maxRecordsPerMicroBatch(); + this.cacheDeleteFilesOnExecutors = readConf.cacheDeleteFilesOnExecutors(); + + InitialOffsetStore initialOffsetStore = + new InitialOffsetStore( + table, checkpointLocation, fromTimestamp, sparkContext.hadoopConfiguration()); + this.initialOffset = initialOffsetStore.initialOffset(); + } + + @Override + public Offset latestOffset() { + table.refresh(); + if (table.currentSnapshot() == null) { + return StreamingOffset.START_OFFSET; + } + + if (table.currentSnapshot().timestampMillis() < fromTimestamp) { + return StreamingOffset.START_OFFSET; + } + + Snapshot latestSnapshot = table.currentSnapshot(); + + return new StreamingOffset( + latestSnapshot.snapshotId(), MicroBatchUtils.addedFilesCount(table, latestSnapshot), false); + } + + @Override + public InputPartition[] planInputPartitions(Offset start, Offset end) { + Preconditions.checkArgument( + end instanceof StreamingOffset, "Invalid end offset: %s is not a StreamingOffset", end); + Preconditions.checkArgument( + start instanceof StreamingOffset, + "Invalid start offset: %s is not a StreamingOffset", + start); + + if (end.equals(StreamingOffset.START_OFFSET)) { + return new InputPartition[0]; + } + + StreamingOffset endOffset = (StreamingOffset) end; + StreamingOffset startOffset = (StreamingOffset) start; + + // Initialize planner if not already done (for resume scenarios) + if (planner == null) { + initializePlanner(startOffset, endOffset); + } + + List fileScanTasks = planner.planFiles(startOffset, endOffset); + + CloseableIterable splitTasks = + TableScanUtil.splitFiles(CloseableIterable.withNoopClose(fileScanTasks), splitSize); + List combinedScanTasks = + Lists.newArrayList( + TableScanUtil.planTasks(splitTasks, splitSize, splitLookback, splitOpenFileCost)); + String[][] locations = computePreferredLocations(combinedScanTasks); + + InputPartition[] partitions = new InputPartition[combinedScanTasks.size()]; + + for (int index = 0; index < combinedScanTasks.size(); index++) { + partitions[index] = + new SparkInputPartition( + EMPTY_GROUPING_KEY_TYPE, + combinedScanTasks.get(index), + tableBroadcast, + fileIOBroadcast, + projection, + caseSensitive, + locations != null ? locations[index] : SparkPlanningUtil.NO_LOCATION_PREFERENCE, + cacheDeleteFilesOnExecutors); + } + + return partitions; + } + + private String[][] computePreferredLocations(List taskGroups) { + return localityPreferred + ? SparkPlanningUtil.fetchBlockLocations(fileIO.get(), taskGroups) + : null; + } + + @Override + public PartitionReaderFactory createReaderFactory() { + return new SparkRowReaderFactory(); + } + + @Override + public Offset initialOffset() { + return initialOffset; + } + + @Override + public Offset deserializeOffset(String json) { + return StreamingOffset.fromJson(json); + } + + @Override + public void commit(Offset end) {} + + @Override + public void stop() { + if (planner != null) { + planner.stop(); + } + } + + private void initializePlanner(StreamingOffset startOffset, StreamingOffset endOffset) { + if (readConf.asyncMicroBatchPlanningEnabled()) { + this.planner = + new AsyncSparkMicroBatchPlanner( + table, readConf, startOffset, endOffset, lastOffsetForTriggerAvailableNow); + } else { + this.planner = + new SyncSparkMicroBatchPlanner(table, readConf, lastOffsetForTriggerAvailableNow); + } + } + + @Override + public Offset latestOffset(Offset startOffset, ReadLimit limit) { + Preconditions.checkArgument( + startOffset instanceof StreamingOffset, + "Invalid start offset: %s is not a StreamingOffset", + startOffset); + + // Initialize planner if not already done + if (planner == null) { + initializePlanner((StreamingOffset) startOffset, null); + } + + return planner.latestOffset((StreamingOffset) startOffset, limit); + } + + @Override + public ReadLimit getDefaultReadLimit() { + if (maxFilesPerMicroBatch != Integer.MAX_VALUE + && maxRecordsPerMicroBatch != Integer.MAX_VALUE) { + ReadLimit[] readLimits = new ReadLimit[2]; + readLimits[0] = ReadLimit.maxFiles(maxFilesPerMicroBatch); + readLimits[1] = ReadLimit.maxRows(maxRecordsPerMicroBatch); + return ReadLimit.compositeLimit(readLimits); + } else if (maxFilesPerMicroBatch != Integer.MAX_VALUE) { + return ReadLimit.maxFiles(maxFilesPerMicroBatch); + } else if (maxRecordsPerMicroBatch != Integer.MAX_VALUE) { + return ReadLimit.maxRows(maxRecordsPerMicroBatch); + } else { + return ReadLimit.allAvailable(); + } + } + + @Override + public void prepareForTriggerAvailableNow() { + LOG.info("The streaming query reports to use Trigger.AvailableNow"); + + lastOffsetForTriggerAvailableNow = + (StreamingOffset) latestOffset(initialOffset, ReadLimit.allAvailable()); + + LOG.info("lastOffset for Trigger.AvailableNow is {}", lastOffsetForTriggerAvailableNow.json()); + + // Reset planner so it gets recreated with the cap on next call + if (planner != null) { + planner.stop(); + planner = null; + } + } + + private static class InitialOffsetStore { + private final Table table; + private final FileIO io; + private final String initialOffsetLocation; + private final long fromTimestamp; + + InitialOffsetStore( + Table table, String checkpointLocation, long fromTimestamp, Configuration conf) { + this.table = table; + this.io = new HadoopFileIO(conf); + this.initialOffsetLocation = SLASH.join(checkpointLocation, "offsets/0"); + this.fromTimestamp = fromTimestamp; + } + + public StreamingOffset initialOffset() { + InputFile inputFile = io.newInputFile(initialOffsetLocation); + if (inputFile.exists()) { + return readOffset(inputFile); + } + + table.refresh(); + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, fromTimestamp); + + OutputFile outputFile = io.newOutputFile(initialOffsetLocation); + writeOffset(offset, outputFile); + + return offset; + } + + private void writeOffset(StreamingOffset offset, OutputFile file) { + try (OutputStream outputStream = file.create()) { + BufferedWriter writer = + new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)); + writer.write(offset.json()); + writer.flush(); + } catch (IOException ioException) { + throw new UncheckedIOException( + String.format("Failed writing offset to: %s", initialOffsetLocation), ioException); + } + } + + private StreamingOffset readOffset(InputFile file) { + try (InputStream in = file.newStream()) { + return StreamingOffset.fromJson(in); + } catch (IOException ioException) { + throw new UncheckedIOException( + String.format("Failed reading offset from: %s", initialOffsetLocation), ioException); + } + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java new file mode 100644 index 000000000000..fe5eeee8fb10 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java @@ -0,0 +1,267 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Scan; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.types.Types.StructType; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.iceberg.util.TableScanUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.connector.read.SupportsReportPartitioning; +import org.apache.spark.sql.connector.read.partitioning.KeyGroupedPartitioning; +import org.apache.spark.sql.connector.read.partitioning.Partitioning; +import org.apache.spark.sql.connector.read.partitioning.UnknownPartitioning; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class SparkPartitioningAwareScan extends SparkScan + implements SupportsReportPartitioning { + + private static final Logger LOG = LoggerFactory.getLogger(SparkPartitioningAwareScan.class); + + private final Scan> scan; + private final boolean preserveDataGrouping; + + private Set specs = null; // lazy cache of scanned specs + private List tasks = null; // lazy cache of uncombined tasks + private List> taskGroups = null; // lazy cache of task groups + private StructType groupingKeyType = null; // lazy cache of the grouping key type + private Transform[] groupingKeyTransforms = null; // lazy cache of grouping key transforms + + SparkPartitioningAwareScan( + SparkSession spark, + Table table, + Schema schema, + Scan> scan, + SparkReadConf readConf, + Schema projection, + List filters, + Supplier scanReportSupplier) { + super( + spark, + table, + null != scan ? scan.fileIO() : table::io, + schema, + readConf, + projection, + filters, + scanReportSupplier); + + this.scan = scan; + this.preserveDataGrouping = readConf.preserveDataGrouping(); + + if (scan == null) { + this.specs = Collections.emptySet(); + this.tasks = Collections.emptyList(); + this.taskGroups = Collections.emptyList(); + } + } + + protected abstract Class taskJavaClass(); + + protected Scan> scan() { + return scan; + } + + @Override + public Partitioning outputPartitioning() { + if (groupingKeyType().fields().isEmpty()) { + LOG.info( + "Reporting UnknownPartitioning with {} partition(s) for table {}", + taskGroups().size(), + table().name()); + return new UnknownPartitioning(taskGroups().size()); + } else { + LOG.info( + "Reporting KeyGroupedPartitioning by {} with {} partition(s) for table {}", + groupingKeyTransforms(), + taskGroups().size(), + table().name()); + return new KeyGroupedPartitioning(groupingKeyTransforms(), taskGroups().size()); + } + } + + @Override + protected StructType groupingKeyType() { + if (groupingKeyType == null) { + if (preserveDataGrouping) { + this.groupingKeyType = computeGroupingKeyType(); + } else { + this.groupingKeyType = StructType.of(); + } + } + + return groupingKeyType; + } + + private StructType computeGroupingKeyType() { + return org.apache.iceberg.Partitioning.groupingKeyType(projection(), specs()); + } + + private Transform[] groupingKeyTransforms() { + if (groupingKeyTransforms == null) { + Map fieldsById = indexFieldsById(specs()); + + List groupingKeyFields = + groupingKeyType().fields().stream() + .map(field -> fieldsById.get(field.fieldId())) + .collect(Collectors.toList()); + + this.groupingKeyTransforms = Spark3Util.toTransforms(schema(), groupingKeyFields); + } + + return groupingKeyTransforms; + } + + private Map indexFieldsById(Iterable specIterable) { + Map fieldsById = Maps.newHashMap(); + + for (PartitionSpec spec : specIterable) { + for (PartitionField field : spec.fields()) { + fieldsById.putIfAbsent(field.fieldId(), field); + } + } + + return fieldsById; + } + + protected Set specs() { + if (specs == null) { + // avoid calling equals/hashCode on specs as those methods are relatively expensive + IntStream specIds = tasks().stream().mapToInt(task -> task.spec().specId()).distinct(); + this.specs = specIds.mapToObj(id -> table().specs().get(id)).collect(Collectors.toSet()); + } + + return specs; + } + + protected synchronized List tasks() { + if (tasks == null) { + try (CloseableIterable taskIterable = scan.planFiles()) { + List plannedTasks = Lists.newArrayList(); + + for (ScanTask task : taskIterable) { + ValidationException.check( + taskJavaClass().isInstance(task), + "Unsupported task type, expected a subtype of %s: %s", + taskJavaClass().getName(), + task.getClass().getName()); + + plannedTasks.add(taskJavaClass().cast(task)); + } + + this.tasks = plannedTasks; + } catch (IOException e) { + throw new UncheckedIOException("Failed to close scan: " + scan, e); + } + } + + return tasks; + } + + @Override + protected synchronized List> taskGroups() { + if (taskGroups == null) { + if (groupingKeyType().fields().isEmpty()) { + CloseableIterable> plannedTaskGroups = + TableScanUtil.planTaskGroups( + CloseableIterable.withNoopClose(tasks()), + adjustSplitSize(tasks(), scan.targetSplitSize()), + scan.splitLookback(), + scan.splitOpenFileCost()); + this.taskGroups = Lists.newArrayList(plannedTaskGroups); + + LOG.debug( + "Planned {} task group(s) without data grouping for table {}", + taskGroups.size(), + table().name()); + + } else { + List> plannedTaskGroups = + TableScanUtil.planTaskGroups( + tasks(), + adjustSplitSize(tasks(), scan.targetSplitSize()), + scan.splitLookback(), + scan.splitOpenFileCost(), + groupingKeyType()); + StructLikeSet plannedGroupingKeys = collectGroupingKeys(plannedTaskGroups); + + LOG.debug( + "Planned {} task group(s) with {} grouping key type and {} unique grouping key(s) for table {}", + plannedTaskGroups.size(), + groupingKeyType(), + plannedGroupingKeys.size(), + table().name()); + + this.taskGroups = plannedTaskGroups; + } + } + + return taskGroups; + } + + // only task groups can be reset while resetting tasks + // the set of scanned specs and grouping key type must never change + protected void resetTasks(List filteredTasks) { + this.taskGroups = null; + this.tasks = filteredTasks; + } + + private StructLikeSet collectGroupingKeys(Iterable> taskGroupIterable) { + StructLikeSet keys = StructLikeSet.create(groupingKeyType()); + + for (ScanTaskGroup taskGroup : taskGroupIterable) { + keys.add(taskGroup.groupingKey()); + } + + return keys; + } + + protected String groupingKeyDesc() { + return groupingKeyType().fields().stream() + .map(NestedField::name) + .collect(Collectors.joining(", ")); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPlanningUtil.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPlanningUtil.java new file mode 100644 index 000000000000..9cdec2c8f463 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPlanningUtil.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.hadoop.Util; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.JavaHash; +import org.apache.iceberg.util.Tasks; +import org.apache.iceberg.util.ThreadPools; + +class SparkPlanningUtil { + + public static final String[] NO_LOCATION_PREFERENCE = new String[0]; + + private SparkPlanningUtil() {} + + public static String[][] fetchBlockLocations( + FileIO io, List> taskGroups) { + String[][] locations = new String[taskGroups.size()][]; + + Tasks.range(taskGroups.size()) + .stopOnFailure() + .executeWith(ThreadPools.getWorkerPool()) + .run(index -> locations[index] = Util.blockLocations(io, taskGroups.get(index))); + + return locations; + } + + public static String[][] assignExecutors( + List> taskGroups, List executorLocations) { + Map> partitionHashes = Maps.newHashMap(); + String[][] locations = new String[taskGroups.size()][]; + + for (int index = 0; index < taskGroups.size(); index++) { + locations[index] = assign(taskGroups.get(index), executorLocations, partitionHashes); + } + + return locations; + } + + private static String[] assign( + ScanTaskGroup taskGroup, + List executorLocations, + Map> partitionHashes) { + List locations = Lists.newArrayList(); + + for (ScanTask task : taskGroup.tasks()) { + if (task.isFileScanTask()) { + FileScanTask fileTask = task.asFileScanTask(); + PartitionSpec spec = fileTask.spec(); + if (spec.isPartitioned() && !fileTask.deletes().isEmpty()) { + JavaHash partitionHash = + partitionHashes.computeIfAbsent(spec.specId(), key -> partitionHash(spec)); + int partitionHashCode = partitionHash.hash(fileTask.partition()); + int index = Math.floorMod(partitionHashCode, executorLocations.size()); + String executorLocation = executorLocations.get(index); + locations.add(executorLocation); + } + } + } + + return locations.toArray(NO_LOCATION_PREFERENCE); + } + + private static JavaHash partitionHash(PartitionSpec spec) { + return JavaHash.forType(spec.partitionType()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewrite.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewrite.java new file mode 100644 index 000000000000..6b8be68c62b9 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewrite.java @@ -0,0 +1,545 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PositionDeletesTable; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.io.ClusteredPositionDeleteWriter; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningDVWriter; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.PositionDeletesRewriteCoordinator; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.spark.SparkWriteUtil; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; +import org.apache.spark.sql.connector.write.BatchWrite; +import org.apache.spark.sql.connector.write.DataWriter; +import org.apache.spark.sql.connector.write.DataWriterFactory; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.PhysicalWriteInfo; +import org.apache.spark.sql.connector.write.Write; +import org.apache.spark.sql.connector.write.WriterCommitMessage; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +/** + * {@link Write} class for rewriting position delete files from Spark. Responsible for creating + * {@link SparkPositionDeletesRewrite.PositionDeleteBatchWrite} + * + *

    This class is meant to be used for an action to rewrite position delete files. Hence, it + * assumes all position deletes to rewrite have come from {@link ScanTaskSetManager} and that all + * have the same partition spec id and partition values. + */ +public class SparkPositionDeletesRewrite implements Write { + + private final JavaSparkContext sparkContext; + private final Table table; + private final String fileSetId; + private final String queryId; + private final FileFormat format; + private final long targetFileSize; + private final DeleteGranularity deleteGranularity; + private final Schema writeSchema; + private final StructType dsSchema; + private final int specId; + private final StructLike partition; + private final Map writeProperties; + private InMemoryMetricsReporter metricsReporter; + + /** + * Constructs a {@link SparkPositionDeletesRewrite}. + * + * @param spark Spark session + * @param table instance of {@link PositionDeletesTable} + * @param fileSetId file set ID + * @param writeConf Spark write config + * @param writeInfo Spark write info + * @param writeSchema Iceberg output schema + * @param dsSchema schema of original incoming position deletes dataset + * @param specId spec id of position deletes + * @param partition partition value of position deletes + */ + SparkPositionDeletesRewrite( + SparkSession spark, + Table table, + String fileSetId, + SparkWriteConf writeConf, + LogicalWriteInfo writeInfo, + Schema writeSchema, + StructType dsSchema, + int specId, + StructLike partition) { + this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + this.table = table; + this.fileSetId = fileSetId; + this.queryId = writeInfo.queryId(); + this.format = writeConf.deleteFileFormat(); + this.targetFileSize = writeConf.targetDeleteFileSize(); + this.deleteGranularity = writeConf.deleteGranularity(); + this.writeSchema = writeSchema; + this.dsSchema = dsSchema; + this.specId = specId; + this.partition = partition; + this.writeProperties = writeConf.writeProperties(); + + if (this.table instanceof BaseTable) { + this.metricsReporter = new InMemoryMetricsReporter(); + ((BaseTable) this.table).combineMetricsReporter(metricsReporter); + } + } + + @Override + public BatchWrite toBatch() { + return new PositionDeleteBatchWrite(); + } + + @Override + public CustomTaskMetric[] reportDriverMetrics() { + return SparkWriteUtil.customTaskMetrics(metricsReporter); + } + + @Override + public CustomMetric[] supportedCustomMetrics() { + return SparkWriteUtil.supportedCustomMetrics(); + } + + /** {@link BatchWrite} class for rewriting position deletes files from Spark */ + class PositionDeleteBatchWrite implements BatchWrite { + + @Override + public DataWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) { + // broadcast the table metadata as the writer factory will be sent to executors + Broadcast

    tableBroadcast = + sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + return new PositionDeletesWriterFactory( + tableBroadcast, + queryId, + format, + targetFileSize, + deleteGranularity, + writeSchema, + dsSchema, + specId, + partition, + writeProperties); + } + + @Override + public boolean useCommitCoordinator() { + return false; + } + + @Override + public void commit(WriterCommitMessage[] messages) { + PositionDeletesRewriteCoordinator coordinator = PositionDeletesRewriteCoordinator.get(); + coordinator.stageRewrite(table, fileSetId, DeleteFileSet.of(files(messages))); + } + + @Override + public void abort(WriterCommitMessage[] messages) { + SparkCleanupUtil.deleteFiles("job abort", table.io(), files(messages)); + } + + private List files(WriterCommitMessage[] messages) { + List files = Lists.newArrayList(); + + for (WriterCommitMessage message : messages) { + if (message != null) { + DeleteTaskCommit taskCommit = (DeleteTaskCommit) message; + files.addAll(Arrays.asList(taskCommit.files())); + } + } + + return files; + } + } + + /** + * Writer factory for position deletes metadata table. Responsible for creating {@link + * DeleteWriter}. + * + *

    This writer is meant to be used for an action to rewrite delete files. Hence, it makes an + * assumption that all incoming deletes belong to the same partition, and that incoming dataset is + * from {@link ScanTaskSetManager}. + */ + static class PositionDeletesWriterFactory implements DataWriterFactory { + private final Broadcast

    tableBroadcast; + private final String queryId; + private final FileFormat format; + private final Long targetFileSize; + private final DeleteGranularity deleteGranularity; + private final Schema writeSchema; + private final StructType dsSchema; + private final int specId; + private final StructLike partition; + private final Map writeProperties; + + PositionDeletesWriterFactory( + Broadcast
    tableBroadcast, + String queryId, + FileFormat format, + long targetFileSize, + DeleteGranularity deleteGranularity, + Schema writeSchema, + StructType dsSchema, + int specId, + StructLike partition, + Map writeProperties) { + this.tableBroadcast = tableBroadcast; + this.queryId = queryId; + this.format = format; + this.targetFileSize = targetFileSize; + this.deleteGranularity = deleteGranularity; + this.writeSchema = writeSchema; + this.dsSchema = dsSchema; + this.specId = specId; + this.partition = partition; + this.writeProperties = writeProperties; + } + + @Override + public DataWriter createWriter(int partitionId, long taskId) { + Table table = tableBroadcast.value(); + int formatVersion = TableUtil.formatVersion(table); + + OutputFileFactory deleteFileFactory = + OutputFileFactory.builderFor(table, partitionId, taskId) + .format(formatVersion >= 3 ? FileFormat.PUFFIN : format) + .operationId(queryId) + .suffix("deletes") + .build(); + + if (formatVersion >= 3) { + return new DVWriter(table, deleteFileFactory, dsSchema, specId, partition); + } else { + Schema positionDeleteRowSchema = positionDeleteRowSchema(); + StructType deleteSparkType = deleteSparkType(); + StructType deleteSparkTypeWithoutRow = deleteSparkTypeWithoutRow(); + + SparkFileWriterFactory writerFactoryWithRow = + SparkFileWriterFactory.builderFor(table) + .deleteFileFormat(format) + .positionDeleteRowSchema(positionDeleteRowSchema) + .positionDeleteSparkType(deleteSparkType) + .writeProperties(writeProperties) + .build(); + SparkFileWriterFactory writerFactoryWithoutRow = + SparkFileWriterFactory.builderFor(table) + .deleteFileFormat(format) + .positionDeleteSparkType(deleteSparkTypeWithoutRow) + .writeProperties(writeProperties) + .build(); + + return new DeleteWriter( + table, + writerFactoryWithRow, + writerFactoryWithoutRow, + deleteFileFactory, + targetFileSize, + deleteGranularity, + dsSchema, + specId, + partition); + } + } + + private Schema positionDeleteRowSchema() { + return new Schema( + writeSchema + .findField(MetadataColumns.DELETE_FILE_ROW_FIELD_NAME) + .type() + .asStructType() + .fields()); + } + + private StructType deleteSparkType() { + return new StructType( + new StructField[] { + dsSchema.apply(MetadataColumns.DELETE_FILE_PATH.name()), + dsSchema.apply(MetadataColumns.DELETE_FILE_POS.name()), + dsSchema.apply(MetadataColumns.DELETE_FILE_ROW_FIELD_NAME) + }); + } + + private StructType deleteSparkTypeWithoutRow() { + return new StructType( + new StructField[] { + dsSchema.apply(MetadataColumns.DELETE_FILE_PATH.name()), + dsSchema.apply(MetadataColumns.DELETE_FILE_POS.name()), + }); + } + } + + /** + * Writer for position deletes metadata table. + * + *

    Iceberg specifies delete files schema as having either 'row' as a required field, or omits + * 'row' altogether. This is to ensure accuracy of delete file statistics on 'row' column. Hence, + * this writer, if receiving source position deletes with null and non-null rows, redirects rows + * with null 'row' to one file writer, and non-null 'row' to another file writer. + * + *

    This writer is meant to be used for an action to rewrite delete files. Hence, it makes an + * assumption that all incoming deletes belong to the same partition. + */ + private static class DeleteWriter implements DataWriter { + private final SparkFileWriterFactory writerFactoryWithRow; + private final SparkFileWriterFactory writerFactoryWithoutRow; + private final OutputFileFactory deleteFileFactory; + private final long targetFileSize; + private final DeleteGranularity deleteGranularity; + private final PositionDelete positionDelete; + private final FileIO io; + private final PartitionSpec spec; + private final int fileOrdinal; + private final int positionOrdinal; + private final int rowOrdinal; + private final int rowSize; + private final StructLike partition; + + private ClusteredPositionDeleteWriter writerWithRow; + private ClusteredPositionDeleteWriter writerWithoutRow; + private boolean closed = false; + + /** + * Constructs a {@link DeleteWriter}. + * + * @param table position deletes metadata table + * @param writerFactoryWithRow writer factory for deletes with non-null 'row' + * @param writerFactoryWithoutRow writer factory for deletes with null 'row' + * @param deleteFileFactory delete file factory + * @param targetFileSize target file size + * @param dsSchema schema of incoming dataset of position deletes + * @param specId partition spec id of incoming position deletes. All incoming partition deletes + * are required to have the same spec id. + * @param partition partition value of incoming position delete. All incoming partition deletes + * are required to have the same partition. + */ + DeleteWriter( + Table table, + SparkFileWriterFactory writerFactoryWithRow, + SparkFileWriterFactory writerFactoryWithoutRow, + OutputFileFactory deleteFileFactory, + long targetFileSize, + DeleteGranularity deleteGranularity, + StructType dsSchema, + int specId, + StructLike partition) { + this.deleteFileFactory = deleteFileFactory; + this.targetFileSize = targetFileSize; + this.deleteGranularity = deleteGranularity; + this.writerFactoryWithRow = writerFactoryWithRow; + this.writerFactoryWithoutRow = writerFactoryWithoutRow; + this.positionDelete = PositionDelete.create(); + this.io = table.io(); + this.spec = table.specs().get(specId); + this.partition = partition; + + this.fileOrdinal = dsSchema.fieldIndex(MetadataColumns.DELETE_FILE_PATH.name()); + this.positionOrdinal = dsSchema.fieldIndex(MetadataColumns.DELETE_FILE_POS.name()); + + this.rowOrdinal = dsSchema.fieldIndex(MetadataColumns.DELETE_FILE_ROW_FIELD_NAME); + DataType type = dsSchema.apply(MetadataColumns.DELETE_FILE_ROW_FIELD_NAME).dataType(); + Preconditions.checkArgument( + type instanceof StructType, "Expected row as struct type but was %s", type); + this.rowSize = ((StructType) type).size(); + } + + @Override + public void write(InternalRow record) { + String file = record.getString(fileOrdinal); + long position = record.getLong(positionOrdinal); + InternalRow row = record.getStruct(rowOrdinal, rowSize); + if (row != null) { + positionDelete.set(file, position, row); + lazyWriterWithRow().write(positionDelete, spec, partition); + } else { + positionDelete.set(file, position, null); + lazyWriterWithoutRow().write(positionDelete, spec, partition); + } + } + + @Override + public WriterCommitMessage commit() throws IOException { + close(); + return new DeleteTaskCommit(allDeleteFiles()); + } + + @Override + public void abort() throws IOException { + close(); + SparkCleanupUtil.deleteTaskFiles(io, allDeleteFiles()); + } + + @Override + public void close() throws IOException { + if (!closed) { + if (writerWithRow != null) { + writerWithRow.close(); + } + if (writerWithoutRow != null) { + writerWithoutRow.close(); + } + this.closed = true; + } + } + + private ClusteredPositionDeleteWriter lazyWriterWithRow() { + if (writerWithRow == null) { + this.writerWithRow = + new ClusteredPositionDeleteWriter<>( + writerFactoryWithRow, deleteFileFactory, io, targetFileSize, deleteGranularity); + } + return writerWithRow; + } + + private ClusteredPositionDeleteWriter lazyWriterWithoutRow() { + if (writerWithoutRow == null) { + this.writerWithoutRow = + new ClusteredPositionDeleteWriter<>( + writerFactoryWithoutRow, deleteFileFactory, io, targetFileSize, deleteGranularity); + } + return writerWithoutRow; + } + + private List allDeleteFiles() { + List allDeleteFiles = Lists.newArrayList(); + if (writerWithRow != null) { + allDeleteFiles.addAll(writerWithRow.result().deleteFiles()); + } + if (writerWithoutRow != null) { + allDeleteFiles.addAll(writerWithoutRow.result().deleteFiles()); + } + return allDeleteFiles; + } + } + + /** + * DV Writer for position deletes metadata table. + * + *

    This writer is meant to be used for an action to rewrite delete files when the table + * supports DVs. + */ + private static class DVWriter implements DataWriter { + private final PositionDelete positionDelete; + private final FileIO io; + private final PartitionSpec spec; + private final int fileOrdinal; + private final int positionOrdinal; + private final StructLike partition; + private final PartitioningDVWriter dvWriter; + private boolean closed = false; + + /** + * Constructs a {@link DeleteWriter}. + * + * @param table position deletes metadata table + * @param deleteFileFactory delete file factory + * @param dsSchema schema of incoming dataset of position deletes + * @param specId partition spec id of incoming position deletes. All incoming partition deletes + * are required to have the same spec id. + * @param partition partition value of incoming position delete. All incoming partition deletes + * are required to have the same partition. + */ + DVWriter( + Table table, + OutputFileFactory deleteFileFactory, + StructType dsSchema, + int specId, + StructLike partition) { + this.positionDelete = PositionDelete.create(); + this.io = table.io(); + this.spec = table.specs().get(specId); + this.partition = partition; + this.fileOrdinal = dsSchema.fieldIndex(MetadataColumns.DELETE_FILE_PATH.name()); + this.positionOrdinal = dsSchema.fieldIndex(MetadataColumns.DELETE_FILE_POS.name()); + this.dvWriter = new PartitioningDVWriter<>(deleteFileFactory, p -> null); + } + + @Override + public void write(InternalRow record) { + String file = record.getString(fileOrdinal); + long position = record.getLong(positionOrdinal); + positionDelete.set(file, position, null); + dvWriter.write(positionDelete, spec, partition); + } + + @Override + public WriterCommitMessage commit() throws IOException { + close(); + return new DeleteTaskCommit(allDeleteFiles()); + } + + @Override + public void abort() throws IOException { + close(); + SparkCleanupUtil.deleteTaskFiles(io, allDeleteFiles()); + } + + @Override + public void close() throws IOException { + if (!closed) { + if (null != dvWriter) { + dvWriter.close(); + } + this.closed = true; + } + } + + private List allDeleteFiles() { + return dvWriter.result().deleteFiles(); + } + } + + public static class DeleteTaskCommit implements WriterCommitMessage { + private final DeleteFile[] taskFiles; + + DeleteTaskCommit(List deleteFiles) { + this.taskFiles = deleteFiles.toArray(new DeleteFile[0]); + } + + DeleteFile[] files() { + return taskFiles; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewriteBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewriteBuilder.java new file mode 100644 index 000000000000..5e5d268ab942 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeletesRewriteBuilder.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.ContentScanTask; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.Write; +import org.apache.spark.sql.connector.write.WriteBuilder; +import org.apache.spark.sql.types.StructType; + +/** + * Builder class for rewrites of position delete files from Spark. Responsible for creating {@link + * SparkPositionDeletesRewrite}. + * + *

    This class is meant to be used for an action to rewrite delete files. Hence, it makes an + * assumption that all incoming deletes belong to the same partition, and that incoming dataset is + * from {@link ScanTaskSetManager}. + */ +public class SparkPositionDeletesRewriteBuilder implements WriteBuilder { + + private final SparkSession spark; + private final Table table; + private final String fileSetId; + private final SparkWriteConf writeConf; + private final LogicalWriteInfo info; + private final StructType dsSchema; + private final Schema writeSchema; + + SparkPositionDeletesRewriteBuilder( + SparkSession spark, Table table, String fileSetId, LogicalWriteInfo info) { + this.spark = spark; + this.table = table; + this.fileSetId = fileSetId; + this.writeConf = new SparkWriteConf(spark, table, info.options()); + this.info = info; + this.dsSchema = info.schema(); + this.writeSchema = SparkSchemaUtil.convert(table.schema(), dsSchema, writeConf.caseSensitive()); + } + + @Override + public Write build() { + // all files of rewrite group have same partition and spec id + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + List tasks = taskSetManager.fetchTasks(table, fileSetId); + Preconditions.checkArgument( + tasks != null && !tasks.isEmpty(), "No scan tasks found for %s", fileSetId); + + int specId = specId(fileSetId, tasks); + StructLike partition = partition(fileSetId, tasks); + + return new SparkPositionDeletesRewrite( + spark, table, fileSetId, writeConf, info, writeSchema, dsSchema, specId, partition); + } + + private static int specId(String fileSetId, List tasks) { + Set specIds = tasks.stream().map(t -> t.spec().specId()).collect(Collectors.toSet()); + Preconditions.checkArgument( + specIds.size() == 1, + "All scan tasks of %s are expected to have same spec id, but got %s", + fileSetId, + Joiner.on(",").join(specIds)); + return tasks.get(0).spec().specId(); + } + + private static StructLike partition(String fileSetId, List tasks) { + StructLikeSet partitions = StructLikeSet.create(tasks.get(0).spec().partitionType()); + tasks.stream().map(ContentScanTask::partition).forEach(partitions::add); + Preconditions.checkArgument( + partitions.size() == 1, + "All scan tasks of %s are expected to have the same partition, but got %s", + fileSetId, + Joiner.on(",").join(partitions)); + return tasks.get(0).partition(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaOperation.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaOperation.java new file mode 100644 index 000000000000..6bac223c877c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaOperation.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.connector.write.DeltaWriteBuilder; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.RowLevelOperation; +import org.apache.spark.sql.connector.write.RowLevelOperationInfo; +import org.apache.spark.sql.connector.write.SupportsDelta; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +class SparkPositionDeltaOperation implements RowLevelOperation, SupportsDelta { + + private final SparkSession spark; + private final Table table; + private final Snapshot snapshot; + private final String branch; + private final Command command; + private final IsolationLevel isolationLevel; + + // lazy vars + private ScanBuilder lazyScanBuilder; + private Scan configuredScan; + private DeltaWriteBuilder lazyWriteBuilder; + + SparkPositionDeltaOperation( + SparkSession spark, + Table table, + Snapshot snapshot, + String branch, + RowLevelOperationInfo info, + IsolationLevel isolationLevel) { + this.spark = spark; + this.table = table; + this.snapshot = snapshot; + this.branch = branch; + this.command = info.command(); + this.isolationLevel = isolationLevel; + } + + @Override + public Command command() { + return command; + } + + @Override + public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) { + if (lazyScanBuilder == null) { + this.lazyScanBuilder = + new SparkScanBuilder(spark, table, table.schema(), snapshot, branch, options) { + @Override + public Scan build() { + Scan scan = super.build(); + SparkPositionDeltaOperation.this.configuredScan = scan; + return scan; + } + }; + } + + return lazyScanBuilder; + } + + @Override + public DeltaWriteBuilder newWriteBuilder(LogicalWriteInfo info) { + if (lazyWriteBuilder == null) { + // don't validate the scan is not null as if the condition evaluates to false, + // the optimizer replaces the original scan relation with a local relation + lazyWriteBuilder = + new SparkPositionDeltaWriteBuilder( + spark, table, branch, command, configuredScan, isolationLevel, info); + } + + return lazyWriteBuilder; + } + + @Override + public NamedReference[] requiredMetadataAttributes() { + List metaAttrs = Lists.newArrayList(); + metaAttrs.add(SparkMetadataColumns.SPEC_ID.asRef()); + metaAttrs.add(SparkMetadataColumns.partition(table).asRef()); + + if (TableUtil.supportsRowLineage(table)) { + metaAttrs.add(SparkMetadataColumns.ROW_ID.asRef()); + metaAttrs.add(SparkMetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.asRef()); + } + + return metaAttrs.toArray(new NamedReference[0]); + } + + @Override + public NamedReference[] rowId() { + return new NamedReference[] { + SparkMetadataColumns.FILE_PATH.asRef(), SparkMetadataColumns.ROW_POSITION.asRef() + }; + } + + @Override + public boolean representUpdateAsDeleteAndInsert() { + return true; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java new file mode 100644 index 000000000000..89f6e1c350fc --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWrite.java @@ -0,0 +1,968 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.IsolationLevel.SERIALIZABLE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; + +import java.io.IOException; +import java.io.Serializable; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.PartitionKey; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.SnapshotUpdate; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.BaseDeleteLoader; +import org.apache.iceberg.data.DeleteLoader; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.encryption.EncryptingFileIO; +import org.apache.iceberg.exceptions.CleanableFailure; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.BasePositionDeltaWriter; +import org.apache.iceberg.io.ClusteredDataWriter; +import org.apache.iceberg.io.ClusteredPositionDeleteWriter; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.DeleteWriteResult; +import org.apache.iceberg.io.FanoutDataWriter; +import org.apache.iceberg.io.FanoutPositionOnlyDeleteWriter; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningDVWriter; +import org.apache.iceberg.io.PartitioningWriter; +import org.apache.iceberg.io.PositionDeltaWriter; +import org.apache.iceberg.io.WriteResult; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.CommitMetadata; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.spark.SparkWriteRequirements; +import org.apache.iceberg.spark.SparkWriteUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.CharSequenceSet; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.iceberg.util.StructProjection; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.JoinedRow; +import org.apache.spark.sql.connector.distributions.Distribution; +import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; +import org.apache.spark.sql.connector.write.DeltaBatchWrite; +import org.apache.spark.sql.connector.write.DeltaWrite; +import org.apache.spark.sql.connector.write.DeltaWriter; +import org.apache.spark.sql.connector.write.DeltaWriterFactory; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.MergeSummary; +import org.apache.spark.sql.connector.write.PhysicalWriteInfo; +import org.apache.spark.sql.connector.write.RequiresDistributionAndOrdering; +import org.apache.spark.sql.connector.write.RowLevelOperation.Command; +import org.apache.spark.sql.connector.write.WriteSummary; +import org.apache.spark.sql.connector.write.WriterCommitMessage; +import org.apache.spark.sql.types.LongType$; +import org.apache.spark.sql.types.StructType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SparkPositionDeltaWrite extends BaseSparkWrite + implements DeltaWrite, RequiresDistributionAndOrdering { + + private static final Logger LOG = LoggerFactory.getLogger(SparkPositionDeltaWrite.class); + + private final JavaSparkContext sparkContext; + private final Table table; + private final String branch; + private final Command command; + private final SparkBatchQueryScan scan; + private final IsolationLevel isolationLevel; + private final String applicationId; + private final boolean wapEnabled; + private final String wapId; + private final Map extraSnapshotMetadata; + private final SparkWriteRequirements writeRequirements; + private final int sortOrderId; + private final Context context; + private final Map writeProperties; + + private boolean cleanupOnAbort = false; + private InMemoryMetricsReporter metricsReporter; + + SparkPositionDeltaWrite( + SparkSession spark, + Table table, + String branch, + Command command, + SparkBatchQueryScan scan, + IsolationLevel isolationLevel, + SparkWriteConf writeConf, + LogicalWriteInfo info, + Schema dataSchema) { + this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + this.table = table; + this.branch = branch; + this.command = command; + this.scan = scan; + this.isolationLevel = isolationLevel; + this.applicationId = spark.sparkContext().applicationId(); + this.wapEnabled = writeConf.wapEnabled(); + this.wapId = writeConf.wapId(); + this.extraSnapshotMetadata = writeConf.extraSnapshotMetadata(); + this.writeRequirements = writeConf.positionDeltaRequirements(command); + this.sortOrderId = writeConf.outputSortOrderId(writeRequirements); + this.context = new Context(dataSchema, writeConf, info, writeRequirements); + this.writeProperties = writeConf.writeProperties(); + + if (this.table instanceof BaseTable) { + this.metricsReporter = new InMemoryMetricsReporter(); + ((BaseTable) this.table).combineMetricsReporter(metricsReporter); + } + } + + @Override + public Distribution requiredDistribution() { + Distribution distribution = writeRequirements.distribution(); + LOG.debug("Requesting {} as write distribution for table {}", distribution, table.name()); + return distribution; + } + + @Override + public boolean distributionStrictlyRequired() { + return false; + } + + @Override + public SortOrder[] requiredOrdering() { + SortOrder[] ordering = writeRequirements.ordering(); + LOG.debug("Requesting {} as write ordering for table {}", ordering, table.name()); + return ordering; + } + + @Override + public long advisoryPartitionSizeInBytes() { + long size = writeRequirements.advisoryPartitionSize(); + LOG.debug("Requesting {} bytes advisory partition size for table {}", size, table.name()); + return size; + } + + @Override + public DeltaBatchWrite toBatch() { + return new PositionDeltaBatchWrite(); + } + + @Override + public CustomMetric[] supportedCustomMetrics() { + return SparkWriteUtil.supportedCustomMetrics(); + } + + @Override + public CustomTaskMetric[] reportDriverMetrics() { + return SparkWriteUtil.customTaskMetrics(metricsReporter); + } + + private class PositionDeltaBatchWrite implements DeltaBatchWrite { + + @Override + public DeltaWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) { + // broadcast large objects since the writer factory will be sent to executors + return new PositionDeltaWriteFactory( + sparkContext.broadcast(SerializableTableWithSize.copyOf(table)), + broadcastRewritableDeletes(), + command, + context, + writeProperties, + sortOrderId); + } + + private Broadcast> broadcastRewritableDeletes() { + if (scan != null && shouldRewriteDeletes()) { + Map rewritableDeletes = scan.rewritableDeletes(context.useDVs()); + if (rewritableDeletes != null && !rewritableDeletes.isEmpty()) { + return sparkContext.broadcast(rewritableDeletes); + } + } + return null; + } + + private boolean shouldRewriteDeletes() { + // deletes must be rewritten when there are DVs and file-scoped deletes + return context.useDVs() || context.deleteGranularity() == DeleteGranularity.FILE; + } + + @Override + public boolean useCommitCoordinator() { + return false; + } + + @Override + public void commit(WriterCommitMessage[] messages) { + commit(messages, null); + } + + @Override + public void commit(WriterCommitMessage[] messages, WriteSummary summary) { + RowDelta rowDelta = table.newRowDelta(); + + CharSequenceSet referencedDataFiles = CharSequenceSet.empty(); + + int addedDataFilesCount = 0; + int addedDeleteFilesCount = 0; + int removedDeleteFilesCount = 0; + + for (WriterCommitMessage message : messages) { + DeltaTaskCommit taskCommit = (DeltaTaskCommit) message; + + for (DataFile dataFile : taskCommit.dataFiles()) { + rowDelta.addRows(dataFile); + addedDataFilesCount += 1; + } + + for (DeleteFile deleteFile : taskCommit.deleteFiles()) { + rowDelta.addDeletes(deleteFile); + addedDeleteFilesCount += 1; + } + + for (DeleteFile deleteFile : taskCommit.rewrittenDeleteFiles()) { + rowDelta.removeDeletes(deleteFile); + removedDeleteFilesCount += 1; + } + + referencedDataFiles.addAll(Arrays.asList(taskCommit.referencedDataFiles())); + } + + // the scan may be null if the optimizer replaces it with an empty relation + // no validation is needed in this case as the command is independent of the table state + if (scan != null) { + Expression conflictDetectionFilter = scan.filter(); + rowDelta.conflictDetectionFilter(conflictDetectionFilter); + + rowDelta.validateDataFilesExist(referencedDataFiles); + + if (scan.snapshotId() != null) { + // set the read snapshot ID to check only snapshots that happened after the table was read + // otherwise, the validation will go through all snapshots present in the table + rowDelta.validateFromSnapshot(scan.snapshotId()); + } + + if (command == UPDATE || command == MERGE) { + rowDelta.validateDeletedFiles(); + rowDelta.validateNoConflictingDeleteFiles(); + } + + if (isolationLevel == SERIALIZABLE) { + rowDelta.validateNoConflictingDataFiles(); + } + + String commitMsg = + String.format( + Locale.ROOT, + "position delta with %d data files, %d delete files and %d rewritten delete files" + + "(scanSnapshotId: %d, conflictDetectionFilter: %s, isolationLevel: %s)", + addedDataFilesCount, + addedDeleteFilesCount, + removedDeleteFilesCount, + scan.snapshotId(), + conflictDetectionFilter, + isolationLevel); + commitOperation(rowDelta, commitMsg, summary); + + } else { + String commitMsg = + String.format( + Locale.ROOT, + "position delta with %d data files and %d delete files (no validation required)", + addedDataFilesCount, + addedDeleteFilesCount); + commitOperation(rowDelta, commitMsg, summary); + } + } + + @Override + public void abort(WriterCommitMessage[] messages) { + if (cleanupOnAbort) { + SparkCleanupUtil.deleteFiles("job abort", table.io(), files(messages)); + } else { + LOG.warn("Skipping cleanup of written files"); + } + } + + private List> files(WriterCommitMessage[] messages) { + List> files = Lists.newArrayList(); + + for (WriterCommitMessage message : messages) { + if (message != null) { + DeltaTaskCommit taskCommit = (DeltaTaskCommit) message; + files.addAll(Arrays.asList(taskCommit.dataFiles())); + files.addAll(Arrays.asList(taskCommit.deleteFiles())); + } + } + + return files; + } + + private void commitOperation( + SnapshotUpdate operation, String description, WriteSummary summary) { + LOG.info("Committing {} to table {}", description, table); + if (applicationId != null) { + operation.set("spark.app.id", applicationId); + } + + extraSnapshotMetadata.forEach(operation::set); + + CommitMetadata.commitProperties().forEach(operation::set); + + if (summary instanceof MergeSummary) { + setMergeSummaryProperties(operation, (MergeSummary) summary); + } + + if (wapEnabled && wapId != null) { + // write-audit-publish is enabled for this table and job + // stage the changes without changing the current snapshot + operation.set(SnapshotSummary.STAGED_WAP_ID_PROP, wapId); + operation.stageOnly(); + } + + if (branch != null) { + operation.toBranch(branch); + } + + try { + long start = System.currentTimeMillis(); + operation.commit(); // abort is automatically called if this fails + long duration = System.currentTimeMillis() - start; + LOG.info("Committed in {} ms", duration); + } catch (Exception e) { + cleanupOnAbort = e instanceof CleanableFailure; + throw e; + } + } + } + + public static class DeltaTaskCommit implements WriterCommitMessage { + private final DataFile[] dataFiles; + private final DeleteFile[] deleteFiles; + private final DeleteFile[] rewrittenDeleteFiles; + private final CharSequence[] referencedDataFiles; + + DeltaTaskCommit(WriteResult result) { + this.dataFiles = result.dataFiles(); + this.deleteFiles = result.deleteFiles(); + this.referencedDataFiles = result.referencedDataFiles(); + this.rewrittenDeleteFiles = result.rewrittenDeleteFiles(); + } + + DeltaTaskCommit(DeleteWriteResult result) { + this.dataFiles = new DataFile[0]; + this.deleteFiles = result.deleteFiles().toArray(new DeleteFile[0]); + this.referencedDataFiles = result.referencedDataFiles().toArray(new CharSequence[0]); + this.rewrittenDeleteFiles = result.rewrittenDeleteFiles().toArray(new DeleteFile[0]); + } + + DataFile[] dataFiles() { + return dataFiles; + } + + DeleteFile[] deleteFiles() { + return deleteFiles; + } + + DeleteFile[] rewrittenDeleteFiles() { + return rewrittenDeleteFiles; + } + + CharSequence[] referencedDataFiles() { + return referencedDataFiles; + } + } + + private static class PositionDeltaWriteFactory implements DeltaWriterFactory { + private final Broadcast

    tableBroadcast; + private final Broadcast> rewritableDeletesBroadcast; + private final Command command; + private final Context context; + private final Map writeProperties; + private final int sortOrderId; + + PositionDeltaWriteFactory( + Broadcast
    tableBroadcast, + Broadcast> rewritableDeletesBroadcast, + Command command, + Context context, + Map writeProperties, + int sortOrderId) { + this.tableBroadcast = tableBroadcast; + this.rewritableDeletesBroadcast = rewritableDeletesBroadcast; + this.command = command; + this.context = context; + this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; + } + + @Override + public DeltaWriter createWriter(int partitionId, long taskId) { + Table table = tableBroadcast.value(); + + OutputFileFactory dataFileFactory = + OutputFileFactory.builderFor(table, partitionId, taskId) + .format(context.dataFileFormat()) + .operationId(context.queryId()) + .build(); + OutputFileFactory deleteFileFactory = + OutputFileFactory.builderFor(table, partitionId, taskId) + .format(context.deleteFileFormat()) + .operationId(context.queryId()) + .suffix("deletes") + .build(); + + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table) + .dataFileFormat(context.dataFileFormat()) + .dataSchema(context.dataSchema()) + .dataSparkType(context.dataSparkType()) + .deleteFileFormat(context.deleteFileFormat()) + .positionDeleteSparkType(context.deleteSparkType()) + .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) + .build(); + + if (command == DELETE) { + return new DeleteOnlyDeltaWriter( + table, rewritableDeletes(), writerFactory, deleteFileFactory, context); + + } else if (table.spec().isUnpartitioned()) { + return new UnpartitionedDeltaWriter( + table, + rewritableDeletes(), + writerFactory, + dataFileFactory, + deleteFileFactory, + new ExtractRowLineage(context.dataSchema()), + context); + + } else { + return new PartitionedDeltaWriter( + table, + rewritableDeletes(), + writerFactory, + dataFileFactory, + deleteFileFactory, + new ExtractRowLineage(context.dataSchema()), + context); + } + } + + private Map rewritableDeletes() { + return rewritableDeletesBroadcast != null ? rewritableDeletesBroadcast.getValue() : null; + } + } + + private abstract static class BaseDeltaWriter implements DeltaWriter { + + protected InternalRowWrapper initPartitionRowWrapper(Types.StructType partitionType) { + StructType sparkPartitionType = (StructType) SparkSchemaUtil.convert(partitionType); + return new InternalRowWrapper(sparkPartitionType, partitionType); + } + + protected Map buildPartitionProjections( + Types.StructType partitionType, Map specs) { + Map partitionProjections = Maps.newHashMap(); + + for (int specId : specs.keySet()) { + PartitionSpec spec = specs.get(specId); + StructProjection projection = StructProjection.create(partitionType, spec.partitionType()); + partitionProjections.put(specId, projection); + } + + return partitionProjections; + } + + // use a fanout writer only if enabled and the input is unordered and the table is partitioned + protected PartitioningWriter newDataWriter( + Table table, SparkFileWriterFactory writers, OutputFileFactory files, Context context) { + + FileIO io = table.io(); + boolean useFanoutWriter = context.useFanoutWriter(); + long targetFileSize = context.targetDataFileSize(); + + if (table.spec().isPartitioned() && useFanoutWriter) { + return new FanoutDataWriter<>(writers, files, io, targetFileSize); + } else { + return new ClusteredDataWriter<>(writers, files, io, targetFileSize); + } + } + + // Use the DV writer for V3+ tables + // The spec requires position deletes to be ordered by file and position for V2 tables + // use a fanout writer if the input is unordered no matter whether fanout writers are enabled + // clustered writers assume that the position deletes are already ordered by file and position + protected PartitioningWriter, DeleteWriteResult> newDeleteWriter( + Table table, + Map rewritableDeletes, + SparkFileWriterFactory writers, + OutputFileFactory files, + Context context) { + Function previousDeleteLoader = + PreviousDeleteLoader.create(table, rewritableDeletes); + FileIO io = table.io(); + boolean inputOrdered = context.inputOrdered(); + long targetFileSize = context.targetDeleteFileSize(); + DeleteGranularity deleteGranularity = context.deleteGranularity(); + + if (context.useDVs()) { + return new PartitioningDVWriter<>(files, previousDeleteLoader); + } else if (inputOrdered && rewritableDeletes == null) { + return new ClusteredPositionDeleteWriter<>( + writers, files, io, targetFileSize, deleteGranularity); + } else { + return new FanoutPositionOnlyDeleteWriter<>( + writers, files, io, targetFileSize, deleteGranularity, previousDeleteLoader); + } + } + } + + private static class PreviousDeleteLoader implements Function { + private final Map deleteFiles; + private final DeleteLoader deleteLoader; + + private PreviousDeleteLoader(Table table, Map deleteFiles) { + this.deleteFiles = deleteFiles; + this.deleteLoader = + new BaseDeleteLoader( + deleteFile -> + EncryptingFileIO.combine(table.io(), table.encryption()) + .newInputFile(deleteFile)); + } + + @Override + public PositionDeleteIndex apply(CharSequence path) { + DeleteFileSet deleteFileSet = deleteFiles.get(path.toString()); + if (deleteFileSet == null) { + return null; + } + + return deleteLoader.loadPositionDeletes(deleteFileSet, path); + } + + public static Function create( + Table table, Map deleteFiles) { + if (deleteFiles == null) { + return path -> null; + } + + return new PreviousDeleteLoader(table, deleteFiles); + } + } + + private static class DeleteOnlyDeltaWriter extends BaseDeltaWriter { + private final PartitioningWriter, DeleteWriteResult> delegate; + private final PositionDelete positionDelete; + private final FileIO io; + private final Map specs; + private final InternalRowWrapper partitionRowWrapper; + private final Map partitionProjections; + private final int specIdOrdinal; + private final int partitionOrdinal; + private final int fileOrdinal; + private final int positionOrdinal; + + private boolean closed = false; + + DeleteOnlyDeltaWriter( + Table table, + Map rewritableDeletes, + SparkFileWriterFactory writerFactory, + OutputFileFactory deleteFileFactory, + Context context) { + + this.delegate = + newDeleteWriter(table, rewritableDeletes, writerFactory, deleteFileFactory, context); + this.positionDelete = PositionDelete.create(); + this.io = table.io(); + this.specs = table.specs(); + + Types.StructType partitionType = Partitioning.partitionType(table); + this.partitionRowWrapper = initPartitionRowWrapper(partitionType); + this.partitionProjections = buildPartitionProjections(partitionType, specs); + + this.specIdOrdinal = context.specIdOrdinal(); + this.partitionOrdinal = context.partitionOrdinal(); + this.fileOrdinal = context.fileOrdinal(); + this.positionOrdinal = context.positionOrdinal(); + } + + @Override + public void delete(InternalRow metadata, InternalRow id) throws IOException { + int specId = metadata.getInt(specIdOrdinal); + PartitionSpec spec = specs.get(specId); + + InternalRow partition = metadata.getStruct(partitionOrdinal, partitionRowWrapper.size()); + StructProjection partitionProjection = partitionProjections.get(specId); + partitionProjection.wrap(partitionRowWrapper.wrap(partition)); + + String file = id.getString(fileOrdinal); + long position = id.getLong(positionOrdinal); + positionDelete.set(file, position); + delegate.write(positionDelete, spec, partitionProjection); + } + + @Override + public void update(InternalRow metadata, InternalRow id, InternalRow row) { + throw new UnsupportedOperationException( + this.getClass().getName() + " does not implement update"); + } + + @Override + public void insert(InternalRow row) throws IOException { + throw new UnsupportedOperationException( + this.getClass().getName() + " does not implement insert"); + } + + @Override + public WriterCommitMessage commit() throws IOException { + close(); + + DeleteWriteResult result = delegate.result(); + return new DeltaTaskCommit(result); + } + + @Override + public void abort() throws IOException { + close(); + + DeleteWriteResult result = delegate.result(); + SparkCleanupUtil.deleteTaskFiles(io, result.deleteFiles()); + } + + @Override + public void close() throws IOException { + if (!closed) { + delegate.close(); + this.closed = true; + } + } + } + + @SuppressWarnings("checkstyle:VisibilityModifier") + private abstract static class DeleteAndDataDeltaWriter extends BaseDeltaWriter { + protected final PositionDeltaWriter delegate; + protected final Function rowLineageExtractor; + + private final FileIO io; + private final Map specs; + private final InternalRowWrapper deletePartitionRowWrapper; + private final Map deletePartitionProjections; + private final int specIdOrdinal; + private final int partitionOrdinal; + private final int fileOrdinal; + private final int positionOrdinal; + private boolean closed = false; + + DeleteAndDataDeltaWriter( + Table table, + Map rewritableDeletes, + SparkFileWriterFactory writerFactory, + OutputFileFactory dataFileFactory, + OutputFileFactory deleteFileFactory, + Function rowLineageExtractor, + Context context) { + + this.delegate = + new BasePositionDeltaWriter<>( + newDataWriter(table, writerFactory, dataFileFactory, context), + newDeleteWriter(table, rewritableDeletes, writerFactory, deleteFileFactory, context)); + this.io = table.io(); + this.specs = table.specs(); + + Types.StructType partitionType = Partitioning.partitionType(table); + this.deletePartitionRowWrapper = initPartitionRowWrapper(partitionType); + this.deletePartitionProjections = buildPartitionProjections(partitionType, specs); + + this.rowLineageExtractor = rowLineageExtractor; + + this.specIdOrdinal = context.specIdOrdinal(); + this.partitionOrdinal = context.partitionOrdinal(); + this.fileOrdinal = context.fileOrdinal(); + this.positionOrdinal = context.positionOrdinal(); + } + + @Override + public void delete(InternalRow meta, InternalRow id) throws IOException { + int specId = meta.getInt(specIdOrdinal); + PartitionSpec spec = specs.get(specId); + + InternalRow partition = meta.getStruct(partitionOrdinal, deletePartitionRowWrapper.size()); + StructProjection partitionProjection = deletePartitionProjections.get(specId); + partitionProjection.wrap(deletePartitionRowWrapper.wrap(partition)); + + String file = id.getString(fileOrdinal); + long position = id.getLong(positionOrdinal); + delegate.delete(file, position, spec, partitionProjection); + } + + @Override + public WriterCommitMessage commit() throws IOException { + close(); + + WriteResult result = delegate.result(); + return new DeltaTaskCommit(result); + } + + @Override + public void abort() throws IOException { + close(); + + WriteResult result = delegate.result(); + SparkCleanupUtil.deleteTaskFiles(io, files(result)); + } + + private List> files(WriteResult result) { + List> files = Lists.newArrayList(); + files.addAll(Arrays.asList(result.dataFiles())); + files.addAll(Arrays.asList(result.deleteFiles())); + return files; + } + + @Override + public void close() throws IOException { + if (!closed) { + delegate.close(); + this.closed = true; + } + } + + protected InternalRow decorateWithRowLineage(InternalRow meta, InternalRow data) { + InternalRow rowLineage = rowLineageExtractor.apply(meta); + return rowLineage == null ? data : new JoinedRow(data, rowLineage); + } + } + + private static class UnpartitionedDeltaWriter extends DeleteAndDataDeltaWriter { + private final PartitionSpec dataSpec; + + UnpartitionedDeltaWriter( + Table table, + Map rewritableDeletes, + SparkFileWriterFactory writerFactory, + OutputFileFactory dataFileFactory, + OutputFileFactory deleteFileFactory, + Function rowLineageFromMetadata, + Context context) { + super( + table, + rewritableDeletes, + writerFactory, + dataFileFactory, + deleteFileFactory, + rowLineageFromMetadata, + context); + this.dataSpec = table.spec(); + } + + @Override + public void update(InternalRow meta, InternalRow id, InternalRow row) throws IOException { + throw new UnsupportedOperationException("Update must be represented as delete and insert"); + } + + @Override + public void insert(InternalRow row) throws IOException { + reinsert(null, row); + } + + @Override + public void reinsert(InternalRow meta, InternalRow row) throws IOException { + delegate.insert(decorateWithRowLineage(meta, row), dataSpec, null); + } + } + + private static class PartitionedDeltaWriter extends DeleteAndDataDeltaWriter { + private final PartitionSpec dataSpec; + private final PartitionKey dataPartitionKey; + private final InternalRowWrapper internalRowDataWrapper; + + PartitionedDeltaWriter( + Table table, + Map rewritableDeletes, + SparkFileWriterFactory writerFactory, + OutputFileFactory dataFileFactory, + OutputFileFactory deleteFileFactory, + Function rowLineageFromMetadata, + Context context) { + super( + table, + rewritableDeletes, + writerFactory, + dataFileFactory, + deleteFileFactory, + rowLineageFromMetadata, + context); + + this.dataSpec = table.spec(); + this.dataPartitionKey = new PartitionKey(dataSpec, context.dataSchema()); + this.internalRowDataWrapper = + new InternalRowWrapper(context.dataSparkType(), context.dataSchema().asStruct()); + } + + @Override + public void update(InternalRow meta, InternalRow id, InternalRow row) throws IOException { + throw new UnsupportedOperationException("Update must be represented as delete and insert"); + } + + @Override + public void insert(InternalRow row) throws IOException { + reinsert(null, row); + } + + @Override + public void reinsert(InternalRow meta, InternalRow row) throws IOException { + dataPartitionKey.partition(internalRowDataWrapper.wrap(row)); + delegate.insert(decorateWithRowLineage(meta, row), dataSpec, dataPartitionKey); + } + } + + // a serializable helper class for common parameters required to configure writers + private static class Context implements Serializable { + private final Schema dataSchema; + private StructType dataSparkType; + private final FileFormat dataFileFormat; + private final long targetDataFileSize; + private final StructType deleteSparkType; + private final StructType metadataSparkType; + private final FileFormat deleteFileFormat; + private final long targetDeleteFileSize; + private final DeleteGranularity deleteGranularity; + private final String queryId; + private final boolean useFanoutWriter; + private final boolean inputOrdered; + + Context( + Schema dataSchema, + SparkWriteConf writeConf, + LogicalWriteInfo info, + SparkWriteRequirements writeRequirements) { + this.dataSchema = dataSchema; + this.dataSparkType = info.schema(); + if (dataSchema != null && dataSchema.findField(MetadataColumns.ROW_ID.fieldId()) != null) { + dataSparkType = dataSparkType.add(MetadataColumns.ROW_ID.name(), LongType$.MODULE$); + dataSparkType = + dataSparkType.add( + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), LongType$.MODULE$); + } + + this.dataFileFormat = writeConf.dataFileFormat(); + this.targetDataFileSize = writeConf.targetDataFileSize(); + this.deleteSparkType = info.rowIdSchema().get(); + this.deleteFileFormat = writeConf.deleteFileFormat(); + this.targetDeleteFileSize = writeConf.targetDeleteFileSize(); + this.deleteGranularity = writeConf.deleteGranularity(); + this.metadataSparkType = info.metadataSchema().get(); + this.queryId = info.queryId(); + this.useFanoutWriter = writeConf.useFanoutWriter(writeRequirements); + this.inputOrdered = writeRequirements.hasOrdering(); + } + + Schema dataSchema() { + return dataSchema; + } + + StructType dataSparkType() { + return dataSparkType; + } + + FileFormat dataFileFormat() { + return dataFileFormat; + } + + long targetDataFileSize() { + return targetDataFileSize; + } + + /* @deprecated This method is deprecated as of version 1.11.0 and will be removed in 1.12.0. + * Position deletes that include row data are no longer supported. + */ + StructType deleteSparkType() { + return deleteSparkType; + } + + FileFormat deleteFileFormat() { + return deleteFileFormat; + } + + long targetDeleteFileSize() { + return targetDeleteFileSize; + } + + DeleteGranularity deleteGranularity() { + return deleteGranularity; + } + + String queryId() { + return queryId; + } + + boolean useFanoutWriter() { + return useFanoutWriter; + } + + boolean inputOrdered() { + return inputOrdered; + } + + boolean useDVs() { + return deleteFileFormat == FileFormat.PUFFIN; + } + + int specIdOrdinal() { + return metadataSparkType.fieldIndex(MetadataColumns.SPEC_ID.name()); + } + + int partitionOrdinal() { + return metadataSparkType.fieldIndex(MetadataColumns.PARTITION_COLUMN_NAME); + } + + int fileOrdinal() { + return deleteSparkType.fieldIndex(MetadataColumns.FILE_PATH.name()); + } + + int positionOrdinal() { + return deleteSparkType.fieldIndex(MetadataColumns.ROW_POSITION.name()); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWriteBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWriteBuilder.java new file mode 100644 index 000000000000..87379126a646 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPositionDeltaWriteBuilder.java @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.MetadataColumns.SPEC_ID_COLUMN_DOC; +import static org.apache.iceberg.MetadataColumns.SPEC_ID_COLUMN_ID; +import static org.apache.iceberg.MetadataColumns.schemaWithRowLineage; + +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.DeltaWrite; +import org.apache.spark.sql.connector.write.DeltaWriteBuilder; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.RowLevelOperation.Command; +import org.apache.spark.sql.types.StructType; + +class SparkPositionDeltaWriteBuilder implements DeltaWriteBuilder { + + private static final Schema EXPECTED_ROW_ID_SCHEMA = + new Schema(MetadataColumns.FILE_PATH, MetadataColumns.ROW_POSITION); + + private final SparkSession spark; + private final Table table; + private final String branch; + private final Command command; + private final SparkBatchQueryScan scan; + private final IsolationLevel isolationLevel; + private final SparkWriteConf writeConf; + private final LogicalWriteInfo info; + private final boolean checkNullability; + private final boolean checkOrdering; + + SparkPositionDeltaWriteBuilder( + SparkSession spark, + Table table, + String branch, + Command command, + Scan scan, + IsolationLevel isolationLevel, + LogicalWriteInfo info) { + this.spark = spark; + this.table = table; + this.branch = branch; + this.command = command; + this.scan = (SparkBatchQueryScan) scan; + this.isolationLevel = isolationLevel; + this.writeConf = new SparkWriteConf(spark, table, info.options()); + this.info = info; + this.checkNullability = writeConf.checkNullability(); + this.checkOrdering = writeConf.checkOrdering(); + SparkTableUtil.validateWriteBranch(spark, table, branch, info.options()); + } + + @Override + public DeltaWrite build() { + Schema dataSchema = dataSchema(); + + validateRowIdSchema(); + validateMetadataSchema(); + if (dataSchema != null + && info.metadataSchema().isPresent() + && info.metadataSchema() + .get() + .find(f -> f.name().equals(MetadataColumns.ROW_ID.name())) + .isDefined()) { + dataSchema = MetadataColumns.schemaWithRowLineage(dataSchema); + } + + SparkUtil.validatePartitionTransforms(table.spec()); + + return new SparkPositionDeltaWrite( + spark, table, branch, command, scan, isolationLevel, writeConf, info, dataSchema); + } + + private Schema dataSchema() { + if (info.schema() == null || info.schema().isEmpty()) { + return null; + } else { + Schema dataSchema = SparkSchemaUtil.convert(table.schema(), info.schema()); + validateSchema("data", table.schema(), dataSchema); + return dataSchema; + } + } + + private void validateRowIdSchema() { + Preconditions.checkArgument(info.rowIdSchema().isPresent(), "Row ID schema must be set"); + StructType rowIdSparkType = info.rowIdSchema().get(); + Schema rowIdSchema = SparkSchemaUtil.convert(EXPECTED_ROW_ID_SCHEMA, rowIdSparkType); + validateSchema("row ID", EXPECTED_ROW_ID_SCHEMA, rowIdSchema); + } + + private void validateMetadataSchema() { + Preconditions.checkArgument(info.metadataSchema().isPresent(), "Metadata schema must be set"); + Schema expectedMetadataSchema = + new Schema( + Types.NestedField.optional( + SPEC_ID_COLUMN_ID, "_spec_id", Types.IntegerType.get(), SPEC_ID_COLUMN_DOC), + MetadataColumns.metadataColumn(table, MetadataColumns.PARTITION_COLUMN_NAME)); + if (TableUtil.supportsRowLineage(table)) { + expectedMetadataSchema = schemaWithRowLineage(expectedMetadataSchema); + } + + StructType metadataSparkType = info.metadataSchema().get(); + Schema metadataSchema = SparkSchemaUtil.convert(expectedMetadataSchema, metadataSparkType); + validateSchema("metadata", expectedMetadataSchema, metadataSchema); + } + + private void validateSchema(String context, Schema expected, Schema actual) { + TypeUtil.validateSchema(context, expected, actual, checkNullability, checkOrdering); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRewriteTable.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRewriteTable.java new file mode 100644 index 000000000000..73d5b34f1c2d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRewriteTable.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Set; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.PositionDeletesTable; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.spark.sql.connector.catalog.SupportsRead; +import org.apache.spark.sql.connector.catalog.SupportsWrite; +import org.apache.spark.sql.connector.catalog.TableCapability; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.WriteBuilder; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +public class SparkRewriteTable extends BaseSparkTable implements SupportsRead, SupportsWrite { + + private static final Set CAPABILITIES = + ImmutableSet.of(TableCapability.BATCH_READ, TableCapability.BATCH_WRITE); + + private final String groupId; + + public SparkRewriteTable(Table table, String groupId) { + super(table, rewriteSchema(table)); + this.groupId = groupId; + } + + @Override + public Set capabilities() { + return CAPABILITIES; + } + + @Override + public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) { + return new SparkStagedScanBuilder(spark(), table(), groupId, options); + } + + @Override + public WriteBuilder newWriteBuilder(LogicalWriteInfo info) { + if (table() instanceof PositionDeletesTable) { + return new SparkPositionDeletesRewriteBuilder(spark(), table(), groupId, info); + } else { + return new SparkRewriteWriteBuilder(spark(), table(), rewriteSchema(table()), groupId, info); + } + } + + private static Schema rewriteSchema(Table table) { + if (TableUtil.supportsRowLineage(table)) { + return MetadataColumns.schemaWithRowLineage(table.schema()); + } else { + return table.schema(); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRewriteWriteBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRewriteWriteBuilder.java new file mode 100644 index 000000000000..c605b2ce91d8 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRewriteWriteBuilder.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.types.TypeUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.write.BatchWrite; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.Write; +import org.apache.spark.sql.connector.write.WriteBuilder; +import org.apache.spark.sql.connector.write.streaming.StreamingWrite; + +class SparkRewriteWriteBuilder implements WriteBuilder { + + private final SparkSession spark; + private final Table table; + private final Schema schema; + private final String groupId; + private final SparkWriteConf writeConf; + private final LogicalWriteInfo info; + private final boolean caseSensitive; + private final boolean checkNullability; + private final boolean checkOrdering; + + SparkRewriteWriteBuilder( + SparkSession spark, Table table, Schema schema, String groupId, LogicalWriteInfo info) { + this.spark = spark; + this.table = table; + this.schema = schema; + this.groupId = groupId; + this.writeConf = new SparkWriteConf(spark, table, info.options()); + this.info = info; + this.caseSensitive = writeConf.caseSensitive(); + this.checkNullability = writeConf.checkNullability(); + this.checkOrdering = writeConf.checkOrdering(); + } + + @Override + public Write build() { + Schema writeSchema = validateWriteSchema(); + SparkUtil.validatePartitionTransforms(table.spec()); + String appId = spark.sparkContext().applicationId(); + return new SparkWrite( + spark, + table, + null /* main branch */, + writeConf, + info, + appId, + writeSchema, + info.schema(), + writeConf.writeRequirements()) { + + @Override + public BatchWrite toBatch() { + return asRewrite(groupId); + } + + @Override + public StreamingWrite toStreaming() { + throw new UnsupportedOperationException("Streaming writes are not supported for rewrites"); + } + }; + } + + private Schema validateWriteSchema() { + Schema writeSchema = SparkSchemaUtil.convert(schema, info.schema(), caseSensitive); + TypeUtil.validateWriteSchema(schema, writeSchema, checkNullability, checkOrdering); + return writeSchema; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRowLevelOperationBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRowLevelOperationBuilder.java new file mode 100644 index 000000000000..8d763c7c6a84 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRowLevelOperationBuilder.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.TableProperties.DELETE_ISOLATION_LEVEL; +import static org.apache.iceberg.TableProperties.DELETE_ISOLATION_LEVEL_DEFAULT; +import static org.apache.iceberg.TableProperties.DELETE_MODE; +import static org.apache.iceberg.TableProperties.DELETE_MODE_DEFAULT; +import static org.apache.iceberg.TableProperties.MERGE_ISOLATION_LEVEL; +import static org.apache.iceberg.TableProperties.MERGE_ISOLATION_LEVEL_DEFAULT; +import static org.apache.iceberg.TableProperties.MERGE_MODE; +import static org.apache.iceberg.TableProperties.MERGE_MODE_DEFAULT; +import static org.apache.iceberg.TableProperties.UPDATE_ISOLATION_LEVEL; +import static org.apache.iceberg.TableProperties.UPDATE_ISOLATION_LEVEL_DEFAULT; +import static org.apache.iceberg.TableProperties.UPDATE_MODE; +import static org.apache.iceberg.TableProperties.UPDATE_MODE_DEFAULT; + +import java.util.Map; +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.write.RowLevelOperation; +import org.apache.spark.sql.connector.write.RowLevelOperation.Command; +import org.apache.spark.sql.connector.write.RowLevelOperationBuilder; +import org.apache.spark.sql.connector.write.RowLevelOperationInfo; + +class SparkRowLevelOperationBuilder implements RowLevelOperationBuilder { + + private final SparkSession spark; + private final Table table; + private final Snapshot snapshot; + private final String branch; + private final RowLevelOperationInfo info; + private final RowLevelOperationMode mode; + private final IsolationLevel isolationLevel; + + SparkRowLevelOperationBuilder( + SparkSession spark, + Table table, + Snapshot snapshot, + String branch, + RowLevelOperationInfo info) { + this.spark = spark; + this.table = table; + this.snapshot = snapshot; + this.branch = branch; + this.info = info; + this.mode = mode(table.properties(), info.command()); + this.isolationLevel = isolationLevel(table.properties(), info.command()); + } + + @Override + public RowLevelOperation build() { + return switch (mode) { + case COPY_ON_WRITE -> + new SparkCopyOnWriteOperation(spark, table, snapshot, branch, info, isolationLevel); + case MERGE_ON_READ -> + new SparkPositionDeltaOperation(spark, table, snapshot, branch, info, isolationLevel); + }; + } + + private RowLevelOperationMode mode(Map properties, Command command) { + String modeName; + + switch (command) { + case DELETE: + modeName = properties.getOrDefault(DELETE_MODE, DELETE_MODE_DEFAULT); + break; + case UPDATE: + modeName = properties.getOrDefault(UPDATE_MODE, UPDATE_MODE_DEFAULT); + break; + case MERGE: + modeName = properties.getOrDefault(MERGE_MODE, MERGE_MODE_DEFAULT); + break; + default: + throw new IllegalArgumentException("Unsupported command: " + command); + } + + return RowLevelOperationMode.fromName(modeName); + } + + private IsolationLevel isolationLevel(Map properties, Command command) { + String levelName; + + switch (command) { + case DELETE: + levelName = properties.getOrDefault(DELETE_ISOLATION_LEVEL, DELETE_ISOLATION_LEVEL_DEFAULT); + break; + case UPDATE: + levelName = properties.getOrDefault(UPDATE_ISOLATION_LEVEL, UPDATE_ISOLATION_LEVEL_DEFAULT); + break; + case MERGE: + levelName = properties.getOrDefault(MERGE_ISOLATION_LEVEL, MERGE_ISOLATION_LEVEL_DEFAULT); + break; + default: + throw new IllegalArgumentException("Unsupported command: " + command); + } + + return IsolationLevel.fromName(levelName); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRowReaderFactory.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRowReaderFactory.java new file mode 100644 index 000000000000..23699aeb167c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRowReaderFactory.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.ChangelogScanTask; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.PartitionReader; +import org.apache.spark.sql.connector.read.PartitionReaderFactory; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +class SparkRowReaderFactory implements PartitionReaderFactory { + + SparkRowReaderFactory() {} + + @Override + public PartitionReader createReader(InputPartition inputPartition) { + Preconditions.checkArgument( + inputPartition instanceof SparkInputPartition, + "Unknown input partition type: %s", + inputPartition.getClass().getName()); + + SparkInputPartition partition = (SparkInputPartition) inputPartition; + + if (partition.allTasksOfType(FileScanTask.class)) { + return new RowDataReader(partition); + + } else if (partition.allTasksOfType(ChangelogScanTask.class)) { + return new ChangelogRowReader(partition); + + } else if (partition.allTasksOfType(PositionDeletesScanTask.class)) { + return new PositionDeletesRowReader(partition); + + } else { + throw new UnsupportedOperationException( + "Unsupported task group for row-based reads: " + partition.taskGroup()); + } + } + + @Override + public PartitionReader createColumnarReader(InputPartition inputPartition) { + throw new UnsupportedOperationException("Columnar reads are not supported"); + } + + @Override + public boolean supportColumnarReads(InputPartition inputPartition) { + return false; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java new file mode 100644 index 000000000000..dfd38a154811 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java @@ -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. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Scan; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Binder; +import org.apache.iceberg.expressions.Evaluator; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionUtil; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.Projections; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkV2Filters; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.SupportsRuntimeV2Filtering; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class SparkRuntimeFilterableScan extends SparkPartitioningAwareScan + implements SupportsRuntimeV2Filtering { + + private static final Logger LOG = LoggerFactory.getLogger(SparkRuntimeFilterableScan.class); + + private final List runtimeFilters; + + protected SparkRuntimeFilterableScan( + SparkSession spark, + Table table, + Schema schema, + Scan> scan, + SparkReadConf readConf, + Schema projection, + List filters, + Supplier scanReportSupplier) { + super(spark, table, schema, scan, readConf, projection, filters, scanReportSupplier); + this.runtimeFilters = Lists.newArrayList(); + } + + @Override + protected Class taskJavaClass() { + return PartitionScanTask.class; + } + + @Override + public NamedReference[] filterAttributes() { + Set partitionFieldSourceIds = Sets.newHashSet(); + + for (PartitionSpec spec : specs()) { + for (PartitionField field : spec.fields()) { + partitionFieldSourceIds.add(field.sourceId()); + } + } + + Map quotedNameById = SparkSchemaUtil.indexQuotedNameById(projection()); + + // the optimizer will look for an equality condition with filter attributes in a join + // as the scan has been already planned, filtering can only be done on projected attributes + // that's why only partition source fields that are part of the read schema can be reported + + return partitionFieldSourceIds.stream() + .filter(fieldId -> projection().findField(fieldId) != null) + .map(fieldId -> Spark3Util.toNamedReference(quotedNameById.get(fieldId))) + .toArray(NamedReference[]::new); + } + + @Override + public void filter(Predicate[] predicates) { + Expression runtimeFilter = convertRuntimePredicates(predicates); + + if (runtimeFilter != Expressions.alwaysTrue()) { + Map evaluatorsBySpecId = Maps.newHashMap(); + + for (PartitionSpec spec : specs()) { + Expression inclusiveExpr = + Projections.inclusive(spec, caseSensitive()).project(runtimeFilter); + Evaluator inclusive = new Evaluator(spec.partitionType(), inclusiveExpr); + evaluatorsBySpecId.put(spec.specId(), inclusive); + } + + List filteredTasks = + tasks().stream() + .filter( + task -> { + Evaluator evaluator = evaluatorsBySpecId.get(task.spec().specId()); + return evaluator.eval(task.partition()); + }) + .collect(Collectors.toList()); + + LOG.info( + "{} of {} task(s) for table {} matched runtime filter `{}`", + filteredTasks.size(), + tasks().size(), + table().name(), + ExpressionUtil.toSanitizedString(runtimeFilter)); + + // don't invalidate tasks if the runtime filter had no effect to avoid planning splits again + if (filteredTasks.size() < tasks().size()) { + resetTasks(filteredTasks); + } + + // save the evaluated filter for equals/hashCode + runtimeFilters.add(runtimeFilter); + } + } + + protected Map rewritableDeletes(boolean forDVs) { + Map rewritableDeletes = Maps.newHashMap(); + + for (ScanTask task : tasks()) { + FileScanTask fileScanTask = task.asFileScanTask(); + for (DeleteFile deleteFile : fileScanTask.deletes()) { + if (shouldRewrite(deleteFile, forDVs)) { + rewritableDeletes + .computeIfAbsent(fileScanTask.file().location(), ignored -> DeleteFileSet.create()) + .add(deleteFile); + } + } + } + + return rewritableDeletes; + } + + // for DVs all position deletes must be rewritten + // for position deletes, only file-scoped deletes must be rewritten + private boolean shouldRewrite(DeleteFile deleteFile, boolean forDVs) { + if (forDVs) { + return deleteFile.content() != FileContent.EQUALITY_DELETES; + } + + return ContentFileUtil.isFileScoped(deleteFile); + } + + // at this moment, Spark can only pass IN filters for a single attribute + // if there are multiple filter attributes, Spark will pass two separate IN filters + private Expression convertRuntimePredicates(Predicate[] predicates) { + Expression filter = Expressions.alwaysTrue(); + + for (Predicate predicate : predicates) { + Expression expr = SparkV2Filters.convert(predicate); + if (expr != null) { + try { + Binder.bind(projection().asStruct(), expr, caseSensitive()); + filter = Expressions.and(filter, expr); + } catch (ValidationException e) { + LOG.warn("Failed to bind {} to expected schema, skipping runtime filter", expr, e); + } + } else { + LOG.warn("Unsupported runtime filter {}", predicate); + } + } + + return filter; + } + + protected String runtimeFiltersDesc() { + return Spark3Util.describe(runtimeFilters); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java new file mode 100644 index 000000000000..ee61523d8028 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScan.java @@ -0,0 +1,387 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.apache.iceberg.BlobMetadata; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.relocated.com.google.common.base.Strings; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.source.metrics.EqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.IndexedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.NumDeletes; +import org.apache.iceberg.spark.source.metrics.NumSplits; +import org.apache.iceberg.spark.source.metrics.PositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.ResultDataFiles; +import org.apache.iceberg.spark.source.metrics.ResultDeleteFiles; +import org.apache.iceberg.spark.source.metrics.ScannedDataManifests; +import org.apache.iceberg.spark.source.metrics.ScannedDeleteManifests; +import org.apache.iceberg.spark.source.metrics.SkippedDataFiles; +import org.apache.iceberg.spark.source.metrics.SkippedDataManifests; +import org.apache.iceberg.spark.source.metrics.SkippedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.SkippedDeleteManifests; +import org.apache.iceberg.spark.source.metrics.TaskEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TaskIndexedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TaskPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TaskResultDataFiles; +import org.apache.iceberg.spark.source.metrics.TaskResultDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TaskScannedDataManifests; +import org.apache.iceberg.spark.source.metrics.TaskScannedDeleteManifests; +import org.apache.iceberg.spark.source.metrics.TaskSkippedDataFiles; +import org.apache.iceberg.spark.source.metrics.TaskSkippedDataManifests; +import org.apache.iceberg.spark.source.metrics.TaskSkippedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TaskSkippedDeleteManifests; +import org.apache.iceberg.spark.source.metrics.TaskTotalDataFileSize; +import org.apache.iceberg.spark.source.metrics.TaskTotalDataManifests; +import org.apache.iceberg.spark.source.metrics.TaskTotalDeleteFileSize; +import org.apache.iceberg.spark.source.metrics.TaskTotalDeleteManifests; +import org.apache.iceberg.spark.source.metrics.TaskTotalPlanningDuration; +import org.apache.iceberg.spark.source.metrics.TotalDataFileSize; +import org.apache.iceberg.spark.source.metrics.TotalDataManifests; +import org.apache.iceberg.spark.source.metrics.TotalDeleteFileSize; +import org.apache.iceberg.spark.source.metrics.TotalDeleteManifests; +import org.apache.iceberg.spark.source.metrics.TotalPlanningDuration; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.TableScanUtil; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.FieldReference; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.Statistics; +import org.apache.spark.sql.connector.read.SupportsReportStatistics; +import org.apache.spark.sql.connector.read.colstats.ColumnStatistics; +import org.apache.spark.sql.connector.read.streaming.MicroBatchStream; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class SparkScan implements Scan, SupportsReportStatistics { + private static final Logger LOG = LoggerFactory.getLogger(SparkScan.class); + private static final String NDV_KEY = "ndv"; + + private final JavaSparkContext sparkContext; + private final Table table; + private final Supplier fileIO; + private final Schema schema; + private final SparkSession spark; + private final SparkReadConf readConf; + private final boolean caseSensitive; + private final Schema projection; + private final List filters; + private final Supplier scanReportSupplier; + + // lazy variables + private StructType readSchema; + + SparkScan( + SparkSession spark, + Table table, + Supplier fileIO, + Schema schema, + SparkReadConf readConf, + Schema projection, + List filters, + Supplier scanReportSupplier) { + this.spark = spark; + this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + this.table = table; + this.fileIO = fileIO; + this.schema = schema; + this.readConf = readConf; + this.caseSensitive = readConf.caseSensitive(); + this.projection = projection; + this.filters = filters != null ? filters : Collections.emptyList(); + this.scanReportSupplier = scanReportSupplier; + SparkSchemaUtil.validateMetadataColumnReferences(schema, projection); + } + + protected Table table() { + return table; + } + + protected boolean caseSensitive() { + return caseSensitive; + } + + protected Schema schema() { + return schema; + } + + protected Schema projection() { + return projection; + } + + protected List filters() { + return filters; + } + + protected Expression filter() { + return filters.stream().reduce(Expressions.alwaysTrue(), Expressions::and); + } + + protected String filtersDesc() { + return Spark3Util.describe(filters); + } + + protected Types.StructType groupingKeyType() { + return Types.StructType.of(); + } + + protected abstract List> taskGroups(); + + @Override + public Batch toBatch() { + return new SparkBatch( + sparkContext, + table, + fileIO, + readConf, + groupingKeyType(), + taskGroups(), + projection, + hashCode()); + } + + @Override + public MicroBatchStream toMicroBatchStream(String checkpointLocation) { + return new SparkMicroBatchStream( + sparkContext, table, fileIO, readConf, projection, checkpointLocation); + } + + @Override + public StructType readSchema() { + if (readSchema == null) { + this.readSchema = SparkSchemaUtil.convert(projection); + } + return readSchema; + } + + /** + * Reports the default stats. + * + *

    Note that the default implementation is based on task groups and read schema and is good + * enough when the scan doesn't apply to a particular snapshot (e.g. incremental scan). Regular + * batch scans are expected to override this behavior to leverage snapshot information. + * + * @return the default stats estimates + * @see SparkBatchQueryScan + * @see SparkCopyOnWriteScan + */ + @Override + public Statistics estimateStatistics() { + long rowsCount = taskGroups().stream().mapToLong(ScanTaskGroup::estimatedRowsCount).sum(); + long sizeInBytes = SparkSchemaUtil.estimateSize(readSchema(), rowsCount); + return new Stats(sizeInBytes, rowsCount, Collections.emptyMap()); + } + + protected Statistics estimateStatistics(Snapshot snapshot) { + // its a fresh table, no data + if (snapshot == null) { + return new Stats(0L, 0L, Collections.emptyMap()); + } + + boolean cboEnabled = + Boolean.parseBoolean(spark.conf().get(SQLConf.CBO_ENABLED().key(), "false")); + Map colStatsMap = Collections.emptyMap(); + if (readConf.reportColumnStats() && cboEnabled) { + colStatsMap = Maps.newHashMap(); + List files = table.statisticsFiles(); + Optional file = + files.stream().filter(f -> f.snapshotId() == snapshot.snapshotId()).findFirst(); + if (file.isPresent()) { + List metadataList = file.get().blobMetadata(); + + Map> groupedByField = + metadataList.stream() + .collect( + Collectors.groupingBy( + metadata -> metadata.fields().get(0), Collectors.toList())); + + for (Map.Entry> entry : groupedByField.entrySet()) { + String colName = table.schema().findColumnName(entry.getKey()); + NamedReference ref = FieldReference.column(colName); + Long ndv = null; + + for (BlobMetadata blobMetadata : entry.getValue()) { + if (blobMetadata + .type() + .equals(org.apache.iceberg.puffin.StandardBlobTypes.APACHE_DATASKETCHES_THETA_V1)) { + String ndvStr = blobMetadata.properties().get(NDV_KEY); + if (!Strings.isNullOrEmpty(ndvStr)) { + ndv = Long.parseLong(ndvStr); + } else { + LOG.debug("{} is not set in BlobMetadata for column {}", NDV_KEY, colName); + } + } else { + LOG.debug("Blob type {} is not supported yet", blobMetadata.type()); + } + } + ColumnStatistics colStats = + new SparkColumnStatistics(ndv, null, null, null, null, null, null); + + colStatsMap.put(ref, colStats); + } + } + } + + // estimate stats using snapshot summary only for partitioned tables + // (metadata tables are unpartitioned) + if (!table.spec().isUnpartitioned() && filters.isEmpty()) { + LOG.debug( + "Using snapshot {} metadata to estimate statistics for table {}", + snapshot.snapshotId(), + table.name()); + long totalRecords = totalRecords(snapshot); + return new Stats( + SparkSchemaUtil.estimateSize(readSchema(), totalRecords), totalRecords, colStatsMap); + } + + long rowsCount = taskGroups().stream().mapToLong(ScanTaskGroup::estimatedRowsCount).sum(); + long sizeInBytes = SparkSchemaUtil.estimateSize(readSchema(), rowsCount); + return new Stats(sizeInBytes, rowsCount, colStatsMap); + } + + private long totalRecords(Snapshot snapshot) { + Map summary = snapshot.summary(); + return PropertyUtil.propertyAsLong(summary, SnapshotSummary.TOTAL_RECORDS_PROP, Long.MAX_VALUE); + } + + @Override + public String toString() { + return description(); + } + + @Override + public CustomTaskMetric[] reportDriverMetrics() { + ScanReport scanReport = scanReportSupplier != null ? scanReportSupplier.get() : null; + + if (scanReport == null) { + return new CustomTaskMetric[0]; + } + + List driverMetrics = Lists.newArrayList(); + + // common + driverMetrics.add(TaskTotalPlanningDuration.from(scanReport)); + + // data manifests + driverMetrics.add(TaskTotalDataManifests.from(scanReport)); + driverMetrics.add(TaskScannedDataManifests.from(scanReport)); + driverMetrics.add(TaskSkippedDataManifests.from(scanReport)); + + // data files + driverMetrics.add(TaskResultDataFiles.from(scanReport)); + driverMetrics.add(TaskSkippedDataFiles.from(scanReport)); + driverMetrics.add(TaskTotalDataFileSize.from(scanReport)); + + // delete manifests + driverMetrics.add(TaskTotalDeleteManifests.from(scanReport)); + driverMetrics.add(TaskScannedDeleteManifests.from(scanReport)); + driverMetrics.add(TaskSkippedDeleteManifests.from(scanReport)); + + // delete files + driverMetrics.add(TaskTotalDeleteFileSize.from(scanReport)); + driverMetrics.add(TaskResultDeleteFiles.from(scanReport)); + driverMetrics.add(TaskEqualityDeleteFiles.from(scanReport)); + driverMetrics.add(TaskIndexedDeleteFiles.from(scanReport)); + driverMetrics.add(TaskPositionalDeleteFiles.from(scanReport)); + driverMetrics.add(TaskSkippedDeleteFiles.from(scanReport)); + + return driverMetrics.toArray(new CustomTaskMetric[0]); + } + + @Override + public CustomMetric[] supportedCustomMetrics() { + return new CustomMetric[] { + // task metrics + new NumSplits(), + new NumDeletes(), + + // common + new TotalPlanningDuration(), + + // data manifests + new TotalDataManifests(), + new ScannedDataManifests(), + new SkippedDataManifests(), + + // data files + new ResultDataFiles(), + new SkippedDataFiles(), + new TotalDataFileSize(), + + // delete manifests + new TotalDeleteManifests(), + new ScannedDeleteManifests(), + new SkippedDeleteManifests(), + + // delete files + new TotalDeleteFileSize(), + new ResultDeleteFiles(), + new EqualityDeleteFiles(), + new IndexedDeleteFiles(), + new PositionalDeleteFiles(), + new SkippedDeleteFiles() + }; + } + + protected long adjustSplitSize(List tasks, long splitSize) { + if (readConf.splitSizeOption() == null && readConf.adaptiveSplitSizeEnabled()) { + long scanSize = tasks.stream().mapToLong(ScanTask::sizeBytes).sum(); + int parallelism = readConf.splitParallelism(); + long adjustedSplitSize = TableScanUtil.adjustSplitSize(scanSize, parallelism, splitSize); + if (adjustedSplitSize != splitSize) { + LOG.debug( + "Adjusted split size from {} to {} for table {} with parallelism {}", + splitSize, + adjustedSplitSize, + table().name(), + parallelism); + } + return adjustedSplitSize; + } else { + return splitSize; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java new file mode 100644 index 000000000000..69b6314a7f2b --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkScanBuilder.java @@ -0,0 +1,396 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.apache.iceberg.BaseMetadataTable; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.BatchScan; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.IncrementalAppendScan; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.MetricsModes; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SparkDistributedDataScan; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.AggregateEvaluator; +import org.apache.iceberg.expressions.Binder; +import org.apache.iceberg.expressions.BoundAggregate; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkAggregates; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.TimeTravel; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.connector.expressions.aggregate.AggregateFunc; +import org.apache.spark.sql.connector.expressions.aggregate.Aggregation; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.SupportsPushDownAggregates; +import org.apache.spark.sql.connector.read.SupportsPushDownLimit; +import org.apache.spark.sql.connector.read.SupportsPushDownRequiredColumns; +import org.apache.spark.sql.connector.read.SupportsPushDownV2Filters; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SparkScanBuilder extends BaseSparkScanBuilder + implements SupportsPushDownV2Filters, + SupportsPushDownRequiredColumns, + SupportsPushDownLimit, + SupportsPushDownAggregates { + + private static final Logger LOG = LoggerFactory.getLogger(SparkScanBuilder.class); + + private final Snapshot snapshot; + private final String branch; + private final TimeTravel timeTravel; + private final Long startSnapshotId; + private final Long endSnapshotId; + private Scan localScan; + + SparkScanBuilder(SparkSession spark, Table table, CaseInsensitiveStringMap options) { + this( + spark, + table, + table.schema(), + table.currentSnapshot(), + null /* no branch */, + null /* no time travel */, + options); + } + + SparkScanBuilder( + SparkSession spark, + Table table, + Schema schema, + Snapshot snapshot, + String branch, + CaseInsensitiveStringMap options) { + this(spark, table, schema, snapshot, branch, null /* no time travel */, options); + } + + SparkScanBuilder( + SparkSession spark, + Table table, + Schema schema, + Snapshot snapshot, + String branch, + TimeTravel timeTravel, + CaseInsensitiveStringMap options) { + super(spark, table, schema, options); + this.snapshot = snapshot; + this.branch = branch; + this.timeTravel = timeTravel; + if (Spark3Util.containsIncrementalOptions(options)) { + Preconditions.checkArgument(timeTravel == null, "Cannot use time travel in incremental scan"); + Pair boundaries = readConf().incrementalAppendScanBoundaries(); + this.startSnapshotId = boundaries.first(); + this.endSnapshotId = boundaries.second(); + } else { + this.startSnapshotId = null; + this.endSnapshotId = null; + } + Spark3Util.validateNoLegacyTimeTravel(options); + SparkTableUtil.validateReadBranch(spark, table, branch, options); + } + + @Override + public boolean pushAggregation(Aggregation aggregation) { + if (!canPushDownAggregation(aggregation)) { + return false; + } + + AggregateEvaluator aggregateEvaluator; + List> expressions = + Lists.newArrayListWithExpectedSize(aggregation.aggregateExpressions().length); + + for (AggregateFunc aggregateFunc : aggregation.aggregateExpressions()) { + try { + Expression expr = SparkAggregates.convert(aggregateFunc); + if (expr != null) { + Expression bound = Binder.bind(projection().asStruct(), expr, caseSensitive()); + expressions.add((BoundAggregate) bound); + } else { + LOG.info( + "Skipping aggregate pushdown: AggregateFunc {} can't be converted to iceberg expression", + aggregateFunc); + return false; + } + } catch (IllegalArgumentException e) { + LOG.info("Skipping aggregate pushdown: Bind failed for AggregateFunc {}", aggregateFunc, e); + return false; + } + } + + aggregateEvaluator = AggregateEvaluator.create(expressions); + + if (!metricsModeSupportsAggregatePushDown(aggregateEvaluator.aggregates())) { + return false; + } + + try (CloseableIterable fileScanTasks = planFilesWithStats()) { + for (FileScanTask task : fileScanTasks) { + if (!task.deletes().isEmpty()) { + LOG.info("Skipping aggregate pushdown: detected row level deletes"); + return false; + } + + aggregateEvaluator.update(task.file()); + } + } catch (IOException e) { + LOG.info("Skipping aggregate pushdown: ", e); + return false; + } + + if (!aggregateEvaluator.allAggregatorsValid()) { + return false; + } + + StructType pushedAggregateSchema = + SparkSchemaUtil.convert(new Schema(aggregateEvaluator.resultType().fields())); + InternalRow[] pushedAggregateRows = new InternalRow[1]; + StructLike structLike = aggregateEvaluator.result(); + pushedAggregateRows[0] = + new StructInternalRow(aggregateEvaluator.resultType()).setStruct(structLike); + localScan = new SparkLocalScan(table(), pushedAggregateSchema, pushedAggregateRows, filters()); + + return true; + } + + private boolean canPushDownAggregation(Aggregation aggregation) { + if (!isMainTable()) { + return false; + } + + if (!readConf().aggregatePushDownEnabled()) { + return false; + } + + // If group by expression is the same as the partition, the statistics information can still + // be used to calculate min/max/count, will enable aggregate push down in next phase. + // TODO: enable aggregate push down for partition col group by expression + if (aggregation.groupByExpressions().length > 0) { + LOG.info("Skipping aggregate pushdown: group by aggregation push down is not supported"); + return false; + } + + return true; + } + + private boolean metricsModeSupportsAggregatePushDown(List> aggregates) { + MetricsConfig config = MetricsConfig.forTable(table()); + for (BoundAggregate aggregate : aggregates) { + String colName = aggregate.columnName(); + if (!colName.equals("*")) { + MetricsModes.MetricsMode mode = config.columnMode(colName); + if (mode instanceof MetricsModes.None) { + LOG.info("Skipping aggregate pushdown: No metrics for column {}", colName); + return false; + } else if (mode instanceof MetricsModes.Counts) { + if (aggregate.op() == Expression.Operation.MAX + || aggregate.op() == Expression.Operation.MIN) { + LOG.info( + "Skipping aggregate pushdown: Cannot produce min or max from count for column {}", + colName); + return false; + } + } else if (aggregate.type().typeId() == Type.TypeID.STRING + || aggregate.type().typeId() == Type.TypeID.BINARY) { + // lower_bounds and upper_bounds may have been truncated before, so disable push down + // regardless of the current mode + if (aggregate.op() == Expression.Operation.MAX + || aggregate.op() == Expression.Operation.MIN) { + LOG.info( + "Skipping aggregate pushdown: Cannot produce min or max from truncated values for column {}", + colName); + return false; + } + } + } + } + + return true; + } + + @Override + public Scan build() { + if (localScan != null) { + return localScan; + } else if (startSnapshotId != null) { + return buildIncrementalAppendScan(); + } else { + return buildBatchScan(); + } + } + + private Scan buildBatchScan() { + Schema projection = projectionWithMetadataColumns(); + return new SparkBatchQueryScan( + spark(), + table(), + schema(), + snapshot, + branch, + buildIcebergBatchScan(projection, false /* use residuals */, false /* no stats */), + readConf(), + projection, + filters(), + metricsReporter()::scanReport); + } + + private Scan buildIncrementalAppendScan() { + Schema projection = projectionWithMetadataColumns(); + return new SparkIncrementalAppendScan( + spark(), + table(), + startSnapshotId, + endSnapshotId, + buildIcebergIncrementalAppendScan(projection, false /* no stats */), + readConf(), + projection, + filters(), + metricsReporter()::scanReport); + } + + public Scan buildCopyOnWriteScan() { + Schema projection = projectionWithMetadataColumns(); + return new SparkCopyOnWriteScan( + spark(), + table(), + schema(), + snapshot, + branch, + buildIcebergBatchScan(projection, true /* ignore residuals */, false /* no stats */), + readConf(), + projection, + filters(), + metricsReporter()::scanReport); + } + + private CloseableIterable planFilesWithStats() { + Schema projection = projectionWithMetadataColumns(); + org.apache.iceberg.Scan scan = buildIcebergScanWithStats(projection); + if (scan != null) { + return CloseableIterable.transform(scan.planFiles(), ScanTask::asFileScanTask); + } else { + return CloseableIterable.empty(); + } + } + + private org.apache.iceberg.Scan buildIcebergScanWithStats(Schema projection) { + if (startSnapshotId != null) { + return buildIcebergIncrementalAppendScan(projection, true /* with stats */); + } else { + return buildIcebergBatchScan(projection, false /* use residuals */, true /* with stats */); + } + } + + private IncrementalAppendScan buildIcebergIncrementalAppendScan( + Schema projection, boolean withStats) { + IncrementalAppendScan scan = + table() + .newIncrementalAppendScan() + .fromSnapshotExclusive(startSnapshotId) + .caseSensitive(caseSensitive()) + .filter(filter()) + .project(projection) + .metricsReporter(metricsReporter()); + + if (withStats) { + scan = scan.includeColumnStats(); + } + + if (endSnapshotId != null) { + scan = scan.toSnapshot(endSnapshotId); + } + + return configureSplitPlanning(scan); + } + + private BatchScan buildIcebergBatchScan( + Schema projection, boolean ignoreResiduals, boolean withStats) { + if (shouldPinSnapshot() && snapshot == null) { + return null; + } + + BatchScan scan = + newIcebergBatchScan() + .caseSensitive(caseSensitive()) + .filter(filter()) + .project(projection) + .metricsReporter(metricsReporter()); + + if (shouldPinSnapshot() || timeTravel != null) { + scan = scan.useSnapshot(snapshot.snapshotId()); + } + + Preconditions.checkState( + Objects.equals(snapshot, scan.snapshot()), + "Failed to enforce scan consistency: resolved Spark table snapshot (%s) vs scan snapshot (%s)", + snapshot, + scan.snapshot()); + + if (ignoreResiduals) { + scan = scan.ignoreResiduals(); + } + + if (withStats) { + scan = scan.includeColumnStats(); + } + + return configureSplitPlanning(scan); + } + + private BatchScan newIcebergBatchScan() { + if (readConf().distributedPlanningEnabled()) { + return new SparkDistributedDataScan(spark(), table(), readConf()); + } else { + return table().newBatchScan(); + } + } + + private boolean shouldPinSnapshot() { + return isMainTable() || isMetadataTableWithTimeTravel(); + } + + private boolean isMainTable() { + return table() instanceof BaseTable; + } + + private boolean isMetadataTableWithTimeTravel() { + if (table() instanceof BaseMetadataTable metadataTable) { + return metadataTable.supportsTimeTravel(); + } else { + return false; + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java new file mode 100644 index 000000000000..47481ec51cad --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScan.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import java.util.Objects; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.TableScanUtil; +import org.apache.spark.sql.SparkSession; + +class SparkStagedScan extends SparkScan { + + private final String taskSetId; + private final long splitSize; + private final int splitLookback; + private final long openFileCost; + + private List> taskGroups = null; // lazy cache of tasks + + SparkStagedScan( + SparkSession spark, + Table table, + Schema schema, + Schema projection, + String taskSetId, + SparkReadConf readConf) { + super(spark, table, table::io, schema, readConf, projection, ImmutableList.of(), null); + this.taskSetId = taskSetId; + this.splitSize = readConf.splitSize(); + this.splitLookback = readConf.splitLookback(); + this.openFileCost = readConf.splitOpenFileCost(); + } + + @Override + protected List> taskGroups() { + if (taskGroups == null) { + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + List tasks = taskSetManager.fetchTasks(table(), taskSetId); + ValidationException.check( + tasks != null, + "Task set manager has no tasks for table %s with task set ID %s", + table(), + taskSetId); + + this.taskGroups = TableScanUtil.planTaskGroups(tasks, splitSize, splitLookback, openFileCost); + } + return taskGroups; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other == null || getClass() != other.getClass()) { + return false; + } + + SparkStagedScan that = (SparkStagedScan) other; + return table().name().equals(that.table().name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && Objects.equals(taskSetId, that.taskSetId) + && readSchema().equals(that.readSchema()) + && splitSize == that.splitSize + && splitLookback == that.splitLookback + && openFileCost == that.openFileCost; + } + + @Override + public int hashCode() { + return Objects.hash( + table().name(), + table().uuid(), + taskSetId, + readSchema(), + splitSize, + splitLookback, + openFileCost); + } + + @Override + public String description() { + return String.format("IcebergStagedScan(table=%s, taskSetID=%s)", table(), taskSetId); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScanBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScanBuilder.java new file mode 100644 index 000000000000..54ea35b8fd84 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkStagedScanBuilder.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.SupportsPushDownRequiredColumns; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +class SparkStagedScanBuilder extends BaseSparkScanBuilder + implements SupportsPushDownRequiredColumns { + + private final String taskSetId; + + SparkStagedScanBuilder( + SparkSession spark, Table table, String taskSetId, CaseInsensitiveStringMap options) { + super(spark, table, table.schema(), options); + this.taskSetId = taskSetId; + } + + @Override + public Scan build() { + Schema projection = projectionWithMetadataColumns(); + return new SparkStagedScan(spark(), table(), schema(), projection, taskSetId, readConf()); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java new file mode 100644 index 000000000000..80a40d72c8d1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkTable.java @@ -0,0 +1,397 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFiles; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Evaluator; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionUtil; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.Projections; +import org.apache.iceberg.expressions.StrictMetricsEvaluator; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.CommitMetadata; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.spark.SparkV2Filters; +import org.apache.iceberg.spark.TimeTravel; +import org.apache.iceberg.spark.TimeTravel.AsOfTimestamp; +import org.apache.iceberg.spark.TimeTravel.AsOfVersion; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.catalog.SupportsDeleteV2; +import org.apache.spark.sql.connector.catalog.SupportsRead; +import org.apache.spark.sql.connector.catalog.SupportsRowLevelOperations; +import org.apache.spark.sql.connector.catalog.SupportsWrite; +import org.apache.spark.sql.connector.catalog.TableCapability; +import org.apache.spark.sql.connector.catalog.constraints.Constraint; +import org.apache.spark.sql.connector.catalog.constraints.Constraint.ValidationStatus; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.RowLevelOperationBuilder; +import org.apache.spark.sql.connector.write.RowLevelOperationInfo; +import org.apache.spark.sql.connector.write.WriteBuilder; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The main Spark table implementation that supports reads, writes, and row-level operations. + * + *

    Note the table state (e.g. schema, snapshot) is pinned upon loading and must not change. + */ +public class SparkTable extends BaseSparkTable + implements SupportsRead, SupportsWrite, SupportsDeleteV2, SupportsRowLevelOperations { + + private static final Logger LOG = LoggerFactory.getLogger(SparkTable.class); + + private static final Set BASE_CAPABILITIES = + ImmutableSet.of( + TableCapability.BATCH_READ, + TableCapability.BATCH_WRITE, + TableCapability.MICRO_BATCH_READ, + TableCapability.STREAMING_WRITE, + TableCapability.OVERWRITE_BY_FILTER, + TableCapability.OVERWRITE_DYNAMIC); + + private final Schema schema; // effective schema (not necessarily current table schema) + private final Snapshot snapshot; // always set unless table is empty + private final String branch; // set if table is loaded for specific branch + private final TimeTravel timeTravel; // set if table is loaded for time travel + private final Set capabilities; + + public SparkTable(Table table) { + this(table, null /* main branch */); + } + + private SparkTable(Table table, String branch) { + this( + table, + table.schema(), + determineLatestSnapshot(table, branch), + branch, + null /* no time travel */); + } + + private SparkTable(Table table, long snapshotId, TimeTravel timeTravel) { + this( + table, + SnapshotUtil.schemaFor(table, snapshotId), + table.snapshot(snapshotId), + null /* main branch */, + timeTravel); + } + + private SparkTable( + Table table, Schema schema, Snapshot snapshot, String branch, TimeTravel timeTravel) { + super(table, schema); + this.schema = schema; + this.snapshot = snapshot; + this.branch = branch; + this.timeTravel = timeTravel; + this.capabilities = computeCapabilities(table); + } + + public SparkTable copyWithBranch(String newBranch) { + return new SparkTable(table(), newBranch); + } + + public Long snapshotId() { + return snapshot != null ? snapshot.snapshotId() : null; + } + + public String branch() { + return branch; + } + + @Override + public String id() { + return Spark3Util.baseTableUUID(table()); + } + + @Override + public String version() { + return String.format("branch_%s_snapshot_%s", branch, snapshotId()); + } + + @Override + public Set capabilities() { + return capabilities; + } + + @Override + public Constraint[] constraints() { + List constraints = Lists.newArrayList(); + + SparkReadConf readConf = new SparkReadConf(spark(), table()); + Set identifierFieldNames = schema.identifierFieldNames(); + + if (readConf.identifierFieldsRely() && !identifierFieldNames.isEmpty()) { + constraints.add( + Constraint.primaryKey("iceberg_pk", Spark3Util.toNamedReferences(identifierFieldNames)) + .enforced(false) + .validationStatus(ValidationStatus.UNVALIDATED) + .rely(true) + .build()); + } + + return constraints.toArray(new Constraint[0]); + } + + @Override + public ScanBuilder newScanBuilder(CaseInsensitiveStringMap options) { + return new SparkScanBuilder(spark(), table(), schema, snapshot, branch, timeTravel, options); + } + + @Override + public WriteBuilder newWriteBuilder(LogicalWriteInfo info) { + Preconditions.checkArgument(timeTravel == null, "Cannot write to table with time travel"); + return new SparkWriteBuilder(spark(), table(), branch, info); + } + + @Override + public RowLevelOperationBuilder newRowLevelOperationBuilder(RowLevelOperationInfo info) { + Preconditions.checkArgument(timeTravel == null, "Cannot modify table with time travel"); + return new SparkRowLevelOperationBuilder(spark(), table(), snapshot, branch, info); + } + + @Override + public boolean canDeleteWhere(Predicate[] predicates) { + Preconditions.checkArgument(timeTravel == null, "Cannot delete from table with time travel"); + + Expression deleteExpr = Expressions.alwaysTrue(); + + for (Predicate predicate : predicates) { + Expression expr = SparkV2Filters.convert(predicate); + if (expr != null) { + deleteExpr = Expressions.and(deleteExpr, expr); + } else { + return false; + } + } + + String scanBranch = + SparkTableUtil.determineReadBranch( + spark(), table(), branch, CaseInsensitiveStringMap.empty()); + return canDeleteUsingMetadata(deleteExpr, scanBranch); + } + + // a metadata delete is possible iff matching files can be deleted entirely + private boolean canDeleteUsingMetadata(Expression deleteExpr, String scanBranch) { + boolean caseSensitive = SparkUtil.caseSensitive(spark()); + + if (ExpressionUtil.selectsPartitions(deleteExpr, table(), caseSensitive)) { + return true; + } + + TableScan scan = + table() + .newScan() + .filter(deleteExpr) + .caseSensitive(caseSensitive) + .includeColumnStats() + .ignoreResiduals(); + + if (scanBranch != null) { + scan = scan.useRef(scanBranch); + } else if (snapshot != null) { + scan = scan.useSnapshot(snapshot.snapshotId()); + } + + try (CloseableIterable tasks = scan.planFiles()) { + Map evaluators = Maps.newHashMap(); + StrictMetricsEvaluator metricsEvaluator = new StrictMetricsEvaluator(schema, deleteExpr); + return Iterables.all( + tasks, + task -> { + DataFile file = task.file(); + PartitionSpec spec = task.spec(); + Evaluator evaluator = + evaluators.computeIfAbsent( + spec.specId(), + specId -> + new Evaluator( + spec.partitionType(), Projections.strict(spec).project(deleteExpr))); + return evaluator.eval(file.partition()) || metricsEvaluator.eval(file); + }); + + } catch (IOException ioe) { + LOG.warn("Failed to close task iterable", ioe); + return false; + } + } + + @Override + public void deleteWhere(Predicate[] predicates) { + Expression deleteExpr = SparkV2Filters.convert(predicates); + + if (deleteExpr == Expressions.alwaysFalse()) { + LOG.info("Skipping the delete operation as the condition is always false"); + return; + } + + DeleteFiles deleteFiles = + table() + .newDelete() + .set("spark.app.id", spark().sparkContext().applicationId()) + .deleteFromRowFilter(deleteExpr); + + String writeBranch = + SparkTableUtil.determineWriteBranch( + spark(), table(), branch, CaseInsensitiveStringMap.empty()); + + if (writeBranch != null) { + deleteFiles.toBranch(writeBranch); + } + + if (!CommitMetadata.commitProperties().isEmpty()) { + CommitMetadata.commitProperties().forEach(deleteFiles::set); + } + + deleteFiles.commit(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } else if (other == null || getClass() != other.getClass()) { + return false; + } + + SparkTable that = (SparkTable) other; + return table().name().equals(that.table().name()) + && Objects.equals(table().uuid(), that.table().uuid()) + && schema.schemaId() == that.schema.schemaId() + && Objects.equals(snapshotId(), that.snapshotId()) + && Objects.equals(branch, that.branch) + && Objects.equals(timeTravel, that.timeTravel); + } + + @Override + public int hashCode() { + return Objects.hash( + table().name(), table().uuid(), schema.schemaId(), snapshotId(), branch, timeTravel); + } + + public static SparkTable create(Table table, String branch) { + ValidationException.check( + branch == null || SnapshotRef.MAIN_BRANCH.equals(branch) || table.snapshot(branch) != null, + "Cannot use branch (does not exist): %s", + branch); + return new SparkTable(table, branch); + } + + public static SparkTable create(Table table, TimeTravel timeTravel) { + if (timeTravel == null) { + return new SparkTable(table); + } else if (timeTravel instanceof AsOfVersion asOfVersion) { + return createWithVersion(table, asOfVersion); + } else if (timeTravel instanceof AsOfTimestamp asOfTimestamp) { + return createWithTimestamp(table, asOfTimestamp); + } else { + throw new IllegalArgumentException("Unknown time travel: " + timeTravel); + } + } + + private static SparkTable createWithVersion(Table table, AsOfVersion timeTravel) { + if (timeTravel.isSnapshotId()) { + return new SparkTable(table, Long.parseLong(timeTravel.version()), timeTravel); + } else { + SnapshotRef ref = table.refs().get(timeTravel.version()); + Preconditions.checkArgument( + ref != null, + "Cannot find matching snapshot ID or reference name for version %s", + timeTravel.version()); + if (ref.isBranch()) { + return new SparkTable(table, timeTravel.version()); + } else { + return new SparkTable(table, ref.snapshotId(), timeTravel); + } + } + } + + // Iceberg uses milliseconds for snapshot timestamps + private static SparkTable createWithTimestamp(Table table, AsOfTimestamp timeTravel) { + long timestampMillis = timeTravel.timestampMillis(); + long snapshotId = SnapshotUtil.snapshotIdAsOfTime(table, timestampMillis); + return new SparkTable(table, snapshotId, timeTravel); + } + + private static Set computeCapabilities(Table table) { + ImmutableSet.Builder tableCapabilities = ImmutableSet.builder(); + tableCapabilities.addAll(BASE_CAPABILITIES); + + if (autoSchemaEvolution(table)) { + tableCapabilities.add(TableCapability.AUTOMATIC_SCHEMA_EVOLUTION); + } + + if (acceptAnySchema(table)) { + tableCapabilities.add(TableCapability.ACCEPT_ANY_SCHEMA); + } + + return tableCapabilities.build(); + } + + private static boolean acceptAnySchema(Table table) { + return PropertyUtil.propertyAsBoolean( + table.properties(), + TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA, + TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA_DEFAULT); + } + + private static boolean autoSchemaEvolution(Table table) { + return PropertyUtil.propertyAsBoolean( + table.properties(), + TableProperties.SPARK_WRITE_AUTO_SCHEMA_EVOLUTION, + TableProperties.SPARK_WRITE_AUTO_SCHEMA_EVOLUTION_DEFAULT); + } + + // returns latest snapshot for branch or current snapshot if branch is yet to be created + private static Snapshot determineLatestSnapshot(Table table, String branch) { + if (branch != null && table.refs().containsKey(branch)) { + return SnapshotUtil.latestSnapshot(table, branch); + } else { + return table.currentSnapshot(); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkVariantShreddingAnalyzer.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkVariantShreddingAnalyzer.java new file mode 100644 index 000000000000..2c08c662c9da --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkVariantShreddingAnalyzer.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.List; +import org.apache.iceberg.parquet.VariantShreddingAnalyzer; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.VariantVal; + +/** + * Spark-specific implementation that extracts variant values from {@link InternalRow} instances. + */ +class SparkVariantShreddingAnalyzer extends VariantShreddingAnalyzer { + + SparkVariantShreddingAnalyzer() {} + + @Override + protected int resolveColumnIndex(StructType sparkSchema, String columnName) { + try { + return sparkSchema.fieldIndex(columnName); + } catch (IllegalArgumentException e) { + return -1; + } + } + + @Override + protected List extractVariantValues( + List bufferedRows, int variantFieldIndex) { + List values = Lists.newArrayList(); + + for (InternalRow row : bufferedRows) { + if (!row.isNullAt(variantFieldIndex)) { + VariantVal variantVal = row.getVariant(variantFieldIndex); + if (variantVal != null) { + VariantValue variantValue = + VariantValue.from( + VariantMetadata.from( + ByteBuffer.wrap(variantVal.getMetadata()).order(ByteOrder.LITTLE_ENDIAN)), + ByteBuffer.wrap(variantVal.getValue()).order(ByteOrder.LITTLE_ENDIAN)); + values.add(variantValue); + } + } + } + + return values; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkView.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkView.java new file mode 100644 index 000000000000..47e57295363d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkView.java @@ -0,0 +1,151 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.TableProperties.FORMAT_VERSION; + +import java.util.Map; +import java.util.Set; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.view.BaseView; +import org.apache.iceberg.view.SQLViewRepresentation; +import org.apache.iceberg.view.View; +import org.apache.iceberg.view.ViewOperations; +import org.apache.spark.sql.types.StructType; + +public class SparkView implements org.apache.spark.sql.connector.catalog.View { + + public static final String QUERY_COLUMN_NAMES = "spark.query-column-names"; + public static final Set RESERVED_PROPERTIES = + ImmutableSet.of("provider", "location", FORMAT_VERSION, QUERY_COLUMN_NAMES); + + private final View icebergView; + private final String catalogName; + private StructType lazySchema = null; + + public SparkView(String catalogName, View icebergView) { + this.catalogName = catalogName; + this.icebergView = icebergView; + } + + public View view() { + return icebergView; + } + + @Override + public String name() { + return icebergView.name(); + } + + @Override + public String query() { + SQLViewRepresentation sqlRepr = icebergView.sqlFor("spark"); + Preconditions.checkState(sqlRepr != null, "Cannot load SQL for view %s", name()); + return sqlRepr.sql(); + } + + @Override + public String currentCatalog() { + return icebergView.currentVersion().defaultCatalog() != null + ? icebergView.currentVersion().defaultCatalog() + : catalogName; + } + + @Override + public String[] currentNamespace() { + return icebergView.currentVersion().defaultNamespace().levels(); + } + + @Override + public StructType schema() { + if (null == lazySchema) { + this.lazySchema = SparkSchemaUtil.convert(icebergView.schema()); + } + + return lazySchema; + } + + @Override + public String[] queryColumnNames() { + return icebergView.properties().containsKey(QUERY_COLUMN_NAMES) + ? icebergView.properties().get(QUERY_COLUMN_NAMES).split(",") + : new String[0]; + } + + @Override + public String[] columnAliases() { + return icebergView.schema().columns().stream() + .map(Types.NestedField::name) + .toArray(String[]::new); + } + + @Override + public String[] columnComments() { + return icebergView.schema().columns().stream() + .map(Types.NestedField::doc) + .toArray(String[]::new); + } + + @Override + public Map properties() { + ImmutableMap.Builder propsBuilder = ImmutableMap.builder(); + + propsBuilder.put("provider", "iceberg"); + propsBuilder.put("location", icebergView.location()); + + if (icebergView instanceof BaseView) { + ViewOperations ops = ((BaseView) icebergView).operations(); + propsBuilder.put(FORMAT_VERSION, String.valueOf(ops.current().formatVersion())); + } + + icebergView.properties().entrySet().stream() + .filter(entry -> !RESERVED_PROPERTIES.contains(entry.getKey())) + .forEach(propsBuilder::put); + + return propsBuilder.build(); + } + + @Override + public String toString() { + return icebergView.toString(); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } else if (other == null || getClass() != other.getClass()) { + return false; + } + + // use only name in order to correctly invalidate Spark cache + SparkView that = (SparkView) other; + return icebergView.name().equals(that.icebergView.name()); + } + + @Override + public int hashCode() { + // use only name in order to correctly invalidate Spark cache + return icebergView.name().hashCode(); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java new file mode 100644 index 000000000000..7caa24772e97 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkWrite.java @@ -0,0 +1,913 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.IsolationLevel.SERIALIZABLE; +import static org.apache.iceberg.IsolationLevel.SNAPSHOT; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.OverwriteFiles; +import org.apache.iceberg.PartitionKey; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ReplacePartitions; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.SnapshotUpdate; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.CleanableFailure; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.ClusteredDataWriter; +import org.apache.iceberg.io.DataWriteResult; +import org.apache.iceberg.io.FanoutDataWriter; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.FileWriter; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.io.PartitioningWriter; +import org.apache.iceberg.io.RollingDataWriter; +import org.apache.iceberg.metrics.InMemoryMetricsReporter; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.CommitMetadata; +import org.apache.iceberg.spark.FileRewriteCoordinator; +import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.spark.SparkWriteRequirements; +import org.apache.iceberg.spark.SparkWriteUtil; +import org.apache.iceberg.util.ContentFileUtil; +import org.apache.iceberg.util.DataFileSet; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.spark.TaskContext; +import org.apache.spark.TaskContext$; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.executor.OutputMetrics; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.JoinedRow; +import org.apache.spark.sql.connector.distributions.Distribution; +import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.metric.CustomMetric; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; +import org.apache.spark.sql.connector.write.BatchWrite; +import org.apache.spark.sql.connector.write.DataWriter; +import org.apache.spark.sql.connector.write.DataWriterFactory; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.MergeSummary; +import org.apache.spark.sql.connector.write.PhysicalWriteInfo; +import org.apache.spark.sql.connector.write.RequiresDistributionAndOrdering; +import org.apache.spark.sql.connector.write.Write; +import org.apache.spark.sql.connector.write.WriteSummary; +import org.apache.spark.sql.connector.write.WriterCommitMessage; +import org.apache.spark.sql.connector.write.streaming.StreamingDataWriterFactory; +import org.apache.spark.sql.connector.write.streaming.StreamingWrite; +import org.apache.spark.sql.types.StructType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +abstract class SparkWrite extends BaseSparkWrite implements Write, RequiresDistributionAndOrdering { + private static final Logger LOG = LoggerFactory.getLogger(SparkWrite.class); + + private final JavaSparkContext sparkContext; + private final SparkWriteConf writeConf; + private final Table table; + private final String queryId; + private final FileFormat format; + private final String applicationId; + private final boolean wapEnabled; + private final String wapId; + private final int outputSpecId; + private final String branch; + private final long targetFileSize; + private final Schema writeSchema; + private final StructType dsSchema; + private final Map extraSnapshotMetadata; + private final boolean useFanoutWriter; + private final SparkWriteRequirements writeRequirements; + private final Map writeProperties; + + private boolean cleanupOnAbort = false; + private InMemoryMetricsReporter metricsReporter; + + SparkWrite( + SparkSession spark, + Table table, + String branch, + SparkWriteConf writeConf, + LogicalWriteInfo writeInfo, + String applicationId, + Schema writeSchema, + StructType dsSchema, + SparkWriteRequirements writeRequirements) { + this.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + this.table = table; + this.writeConf = writeConf; + this.queryId = writeInfo.queryId(); + this.format = writeConf.dataFileFormat(); + this.applicationId = applicationId; + this.wapEnabled = writeConf.wapEnabled(); + this.wapId = writeConf.wapId(); + this.branch = branch; + this.targetFileSize = writeConf.targetDataFileSize(); + this.writeSchema = writeSchema; + this.dsSchema = dsSchema; + this.extraSnapshotMetadata = writeConf.extraSnapshotMetadata(); + this.useFanoutWriter = writeConf.useFanoutWriter(writeRequirements); + this.writeRequirements = writeRequirements; + this.outputSpecId = writeConf.outputSpecId(); + this.writeProperties = writeConf.writeProperties(); + + if (this.table instanceof BaseTable) { + this.metricsReporter = new InMemoryMetricsReporter(); + ((BaseTable) this.table).combineMetricsReporter(metricsReporter); + } + } + + @Override + public Distribution requiredDistribution() { + Distribution distribution = writeRequirements.distribution(); + LOG.debug("Requesting {} as write distribution for table {}", distribution, table.name()); + return distribution; + } + + @Override + public boolean distributionStrictlyRequired() { + return false; + } + + @Override + public SortOrder[] requiredOrdering() { + SortOrder[] ordering = writeRequirements.ordering(); + LOG.debug("Requesting {} as write ordering for table {}", ordering, table.name()); + return ordering; + } + + @Override + public long advisoryPartitionSizeInBytes() { + long size = writeRequirements.advisoryPartitionSize(); + LOG.debug("Requesting {} bytes advisory partition size for table {}", size, table.name()); + return size; + } + + @Override + public CustomMetric[] supportedCustomMetrics() { + return SparkWriteUtil.supportedCustomMetrics(); + } + + BatchWrite asBatchAppend() { + return new BatchAppend(); + } + + BatchWrite asDynamicOverwrite() { + return new DynamicOverwrite(); + } + + BatchWrite asOverwriteByFilter(Expression overwriteExpr) { + return new OverwriteByFilter(overwriteExpr); + } + + BatchWrite asCopyOnWriteOperation(SparkCopyOnWriteScan scan, IsolationLevel isolationLevel) { + return new CopyOnWriteOperation(scan, isolationLevel); + } + + BatchWrite asRewrite(String fileSetID) { + return new RewriteFiles(fileSetID); + } + + StreamingWrite asStreamingAppend() { + return new StreamingAppend(); + } + + StreamingWrite asStreamingOverwrite() { + return new StreamingOverwrite(); + } + + // the writer factory works for both batch and streaming + private WriterFactory createWriterFactory() { + // broadcast the table metadata as the writer factory will be sent to executors + Broadcast

    tableBroadcast = + sparkContext.broadcast(SerializableTableWithSize.copyOf(table)); + int sortOrderId = writeConf.outputSortOrderId(writeRequirements); + return new WriterFactory( + tableBroadcast, + queryId, + format, + outputSpecId, + targetFileSize, + writeSchema, + dsSchema, + useFanoutWriter, + writeProperties, + sortOrderId); + } + + private void commitOperation(SnapshotUpdate operation, String description) { + commitOperation(operation, description, null); + } + + private void commitOperation( + SnapshotUpdate operation, String description, WriteSummary summary) { + LOG.info("Committing {} to table {}", description, table); + if (applicationId != null) { + operation.set("spark.app.id", applicationId); + } + + if (!extraSnapshotMetadata.isEmpty()) { + extraSnapshotMetadata.forEach(operation::set); + } + + if (!CommitMetadata.commitProperties().isEmpty()) { + CommitMetadata.commitProperties().forEach(operation::set); + } + + if (summary instanceof MergeSummary) { + setMergeSummaryProperties(operation, (MergeSummary) summary); + } + + if (wapEnabled && wapId != null) { + // write-audit-publish is enabled for this table and job + // stage the changes without changing the current snapshot + operation.set(SnapshotSummary.STAGED_WAP_ID_PROP, wapId); + operation.stageOnly(); + } + + if (branch != null) { + operation.toBranch(branch); + } + + try { + long start = System.currentTimeMillis(); + operation.commit(); // abort is automatically called if this fails + long duration = System.currentTimeMillis() - start; + LOG.info("Committed in {} ms", duration); + } catch (Exception e) { + cleanupOnAbort = e instanceof CleanableFailure; + throw e; + } + } + + private void abort(WriterCommitMessage[] messages) { + if (cleanupOnAbort) { + SparkCleanupUtil.deleteFiles("job abort", table.io(), Lists.newArrayList(files(messages))); + } else { + LOG.warn("Skipping cleanup of written files"); + } + } + + private DataFileSet files(WriterCommitMessage[] messages) { + DataFileSet files = DataFileSet.create(); + + for (WriterCommitMessage message : messages) { + if (message != null) { + TaskCommit taskCommit = (TaskCommit) message; + files.addAll(Arrays.asList(taskCommit.files())); + } + } + + return files; + } + + @Override + public CustomTaskMetric[] reportDriverMetrics() { + return SparkWriteUtil.customTaskMetrics(metricsReporter); + } + + @Override + public String toString() { + return String.format("IcebergWrite(table=%s, format=%s)", table, format); + } + + private abstract class BaseBatchWrite implements BatchWrite { + @Override + public DataWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) { + return createWriterFactory(); + } + + @Override + public boolean useCommitCoordinator() { + return false; + } + + @Override + public void abort(WriterCommitMessage[] messages) { + SparkWrite.this.abort(messages); + } + + @Override + public String toString() { + return String.format("IcebergBatchWrite(table=%s, format=%s)", table, format); + } + } + + private class BatchAppend extends BaseBatchWrite { + @Override + public void commit(WriterCommitMessage[] messages) { + AppendFiles append = table.newAppend(); + + int numFiles = 0; + for (DataFile file : files(messages)) { + numFiles += 1; + append.appendFile(file); + } + + commitOperation( + append, String.format(Locale.ROOT, "append with %d new data files", numFiles)); + } + } + + private class DynamicOverwrite extends BaseBatchWrite { + @Override + public void commit(WriterCommitMessage[] messages) { + DataFileSet files = files(messages); + + if (files.isEmpty()) { + LOG.info("Dynamic overwrite is empty, skipping commit"); + return; + } + + ReplacePartitions dynamicOverwrite = table.newReplacePartitions(); + IsolationLevel isolationLevel = writeConf.isolationLevel(); + Long validateFromSnapshotId = writeConf.validateFromSnapshotId(); + + if (isolationLevel != null && validateFromSnapshotId != null) { + dynamicOverwrite.validateFromSnapshot(validateFromSnapshotId); + } + + if (isolationLevel == SERIALIZABLE) { + dynamicOverwrite.validateNoConflictingData(); + dynamicOverwrite.validateNoConflictingDeletes(); + } else if (isolationLevel == SNAPSHOT) { + dynamicOverwrite.validateNoConflictingDeletes(); + } + + int numFiles = 0; + for (DataFile file : files) { + numFiles += 1; + dynamicOverwrite.addFile(file); + } + + commitOperation( + dynamicOverwrite, + String.format( + Locale.ROOT, "dynamic partition overwrite with %d new data files", numFiles)); + } + } + + private class OverwriteByFilter extends BaseBatchWrite { + private final Expression overwriteExpr; + + private OverwriteByFilter(Expression overwriteExpr) { + this.overwriteExpr = overwriteExpr; + } + + @Override + public void commit(WriterCommitMessage[] messages) { + OverwriteFiles overwriteFiles = table.newOverwrite(); + overwriteFiles.overwriteByRowFilter(overwriteExpr); + + int numFiles = 0; + for (DataFile file : files(messages)) { + numFiles += 1; + overwriteFiles.addFile(file); + } + + IsolationLevel isolationLevel = writeConf.isolationLevel(); + Long validateFromSnapshotId = writeConf.validateFromSnapshotId(); + + if (isolationLevel != null && validateFromSnapshotId != null) { + overwriteFiles.validateFromSnapshot(validateFromSnapshotId); + } + + if (isolationLevel == SERIALIZABLE) { + overwriteFiles.validateNoConflictingDeletes(); + overwriteFiles.validateNoConflictingData(); + } else if (isolationLevel == SNAPSHOT) { + overwriteFiles.validateNoConflictingDeletes(); + } + + String commitMsg = + String.format( + Locale.ROOT, + "overwrite by filter %s with %d new data files", + overwriteExpr, + numFiles); + commitOperation(overwriteFiles, commitMsg); + } + } + + private class CopyOnWriteOperation extends BaseBatchWrite { + private final SparkCopyOnWriteScan scan; + private final IsolationLevel isolationLevel; + + private CopyOnWriteOperation(SparkCopyOnWriteScan scan, IsolationLevel isolationLevel) { + this.scan = scan; + this.isolationLevel = isolationLevel; + } + + private DataFileSet overwrittenFiles() { + if (scan == null) { + return DataFileSet.create(); + } else { + return scan.tasks().stream() + .map(FileScanTask::file) + .collect(Collectors.toCollection(DataFileSet::create)); + } + } + + private DeleteFileSet danglingDVs() { + if (scan == null) { + return DeleteFileSet.create(); + } else { + return scan.tasks().stream() + .flatMap(task -> task.deletes().stream().filter(ContentFileUtil::isDV)) + .collect(Collectors.toCollection(DeleteFileSet::create)); + } + } + + @Override + public void commit(WriterCommitMessage[] messages) { + commit(messages, null); + } + + @Override + public void commit(WriterCommitMessage[] messages, WriteSummary summary) { + OverwriteFiles overwriteFiles = table.newOverwrite(); + + DataFileSet overwrittenFiles = overwrittenFiles(); + int numOverwrittenFiles = overwrittenFiles.size(); + DeleteFileSet danglingDVs = danglingDVs(); + overwriteFiles.deleteFiles(overwrittenFiles, danglingDVs); + + int numAddedFiles = 0; + for (DataFile file : files(messages)) { + numAddedFiles += 1; + overwriteFiles.addFile(file); + } + + // the scan may be null if the optimizer replaces it with an empty relation (e.g. false cond) + // no validation is needed in this case as the command does not depend on the table state + if (scan != null) { + switch (isolationLevel) { + case SERIALIZABLE: + commitWithSerializableIsolation( + overwriteFiles, numOverwrittenFiles, numAddedFiles, summary); + break; + case SNAPSHOT: + commitWithSnapshotIsolation( + overwriteFiles, numOverwrittenFiles, numAddedFiles, summary); + break; + default: + throw new IllegalArgumentException("Unsupported isolation level: " + isolationLevel); + } + + } else { + commitOperation( + overwriteFiles, + String.format( + Locale.ROOT, "overwrite with %d new data files (no validation)", numAddedFiles), + summary); + } + } + + private void commitWithSerializableIsolation( + OverwriteFiles overwriteFiles, + int numOverwrittenFiles, + int numAddedFiles, + WriteSummary summary) { + Long scanSnapshotId = scan.snapshotId(); + if (scanSnapshotId != null) { + overwriteFiles.validateFromSnapshot(scanSnapshotId); + } + + Expression conflictDetectionFilter = scan.filter(); + overwriteFiles.conflictDetectionFilter(conflictDetectionFilter); + overwriteFiles.validateNoConflictingData(); + overwriteFiles.validateNoConflictingDeletes(); + + String commitMsg = + String.format( + Locale.ROOT, + "overwrite of %d data files with %d new data files, scanSnapshotId: %d, conflictDetectionFilter: %s", + numOverwrittenFiles, + numAddedFiles, + scanSnapshotId, + conflictDetectionFilter); + commitOperation(overwriteFiles, commitMsg, summary); + } + + private void commitWithSnapshotIsolation( + OverwriteFiles overwriteFiles, + int numOverwrittenFiles, + int numAddedFiles, + WriteSummary summary) { + Long scanSnapshotId = scan.snapshotId(); + if (scanSnapshotId != null) { + overwriteFiles.validateFromSnapshot(scanSnapshotId); + } + + Expression conflictDetectionFilter = scan.filter(); + overwriteFiles.conflictDetectionFilter(conflictDetectionFilter); + overwriteFiles.validateNoConflictingDeletes(); + + String commitMsg = + String.format( + Locale.ROOT, + "overwrite of %d data files with %d new data files", + numOverwrittenFiles, + numAddedFiles); + commitOperation(overwriteFiles, commitMsg, summary); + } + } + + private class RewriteFiles extends BaseBatchWrite { + private final String fileSetID; + + private RewriteFiles(String fileSetID) { + this.fileSetID = fileSetID; + } + + @Override + public void commit(WriterCommitMessage[] messages) { + FileRewriteCoordinator coordinator = FileRewriteCoordinator.get(); + coordinator.stageRewrite(table, fileSetID, files(messages)); + } + } + + private abstract class BaseStreamingWrite implements StreamingWrite { + private static final String QUERY_ID_PROPERTY = "spark.sql.streaming.queryId"; + private static final String EPOCH_ID_PROPERTY = "spark.sql.streaming.epochId"; + + protected abstract String mode(); + + @Override + public StreamingDataWriterFactory createStreamingWriterFactory(PhysicalWriteInfo info) { + return createWriterFactory(); + } + + @Override + public boolean useCommitCoordinator() { + return false; + } + + @Override + public final void commit(long epochId, WriterCommitMessage[] messages) { + LOG.info("Committing epoch {} for query {} in {} mode", epochId, queryId, mode()); + + table.refresh(); + + Long lastCommittedEpochId = findLastCommittedEpochId(); + if (lastCommittedEpochId != null && epochId <= lastCommittedEpochId) { + LOG.info("Skipping epoch {} for query {} as it was already committed", epochId, queryId); + return; + } + + doCommit(epochId, messages); + } + + protected abstract void doCommit(long epochId, WriterCommitMessage[] messages); + + protected void commit(SnapshotUpdate snapshotUpdate, long epochId, String description) { + snapshotUpdate.set(QUERY_ID_PROPERTY, queryId); + snapshotUpdate.set(EPOCH_ID_PROPERTY, Long.toString(epochId)); + commitOperation(snapshotUpdate, description); + } + + private Long findLastCommittedEpochId() { + Snapshot snapshot = table.currentSnapshot(); + Long lastCommittedEpochId = null; + while (snapshot != null) { + Map summary = snapshot.summary(); + String snapshotQueryId = summary.get(QUERY_ID_PROPERTY); + if (queryId.equals(snapshotQueryId)) { + lastCommittedEpochId = Long.valueOf(summary.get(EPOCH_ID_PROPERTY)); + break; + } + Long parentSnapshotId = snapshot.parentId(); + snapshot = parentSnapshotId != null ? table.snapshot(parentSnapshotId) : null; + } + return lastCommittedEpochId; + } + + @Override + public void abort(long epochId, WriterCommitMessage[] messages) { + SparkWrite.this.abort(messages); + } + + @Override + public String toString() { + return String.format("IcebergStreamingWrite(table=%s, format=%s)", table, format); + } + } + + private class StreamingAppend extends BaseStreamingWrite { + @Override + protected String mode() { + return "append"; + } + + @Override + protected void doCommit(long epochId, WriterCommitMessage[] messages) { + AppendFiles append = table.newFastAppend(); + int numFiles = 0; + for (DataFile file : files(messages)) { + append.appendFile(file); + numFiles++; + } + commit( + append, + epochId, + String.format(Locale.ROOT, "streaming append with %d new data files", numFiles)); + } + } + + private class StreamingOverwrite extends BaseStreamingWrite { + @Override + protected String mode() { + return "complete"; + } + + @Override + public void doCommit(long epochId, WriterCommitMessage[] messages) { + OverwriteFiles overwriteFiles = table.newOverwrite(); + overwriteFiles.overwriteByRowFilter(Expressions.alwaysTrue()); + int numFiles = 0; + for (DataFile file : files(messages)) { + overwriteFiles.addFile(file); + numFiles++; + } + commit( + overwriteFiles, + epochId, + String.format( + Locale.ROOT, "streaming complete overwrite with %d new data files", numFiles)); + } + } + + public static class TaskCommit implements WriterCommitMessage { + private final DataFile[] taskFiles; + + TaskCommit(DataFile[] taskFiles) { + this.taskFiles = taskFiles; + } + + // Reports bytesWritten and recordsWritten to the Spark output metrics. + // Can only be called in executor. + void reportOutputMetrics() { + long bytesWritten = 0L; + long recordsWritten = 0L; + for (DataFile dataFile : taskFiles) { + bytesWritten += dataFile.fileSizeInBytes(); + recordsWritten += dataFile.recordCount(); + } + + TaskContext taskContext = TaskContext$.MODULE$.get(); + if (taskContext != null) { + OutputMetrics outputMetrics = taskContext.taskMetrics().outputMetrics(); + outputMetrics.setBytesWritten(bytesWritten); + outputMetrics.setRecordsWritten(recordsWritten); + } + } + + DataFile[] files() { + return taskFiles; + } + } + + private static class WriterFactory implements DataWriterFactory, StreamingDataWriterFactory { + private final Broadcast
    tableBroadcast; + private final FileFormat format; + private final int outputSpecId; + private final long targetFileSize; + private final Schema writeSchema; + private final StructType dsSchema; + private final boolean useFanoutWriter; + private final String queryId; + private final Map writeProperties; + private final int sortOrderId; + + protected WriterFactory( + Broadcast
    tableBroadcast, + String queryId, + FileFormat format, + int outputSpecId, + long targetFileSize, + Schema writeSchema, + StructType dsSchema, + boolean useFanoutWriter, + Map writeProperties, + int sortOrderId) { + this.tableBroadcast = tableBroadcast; + this.format = format; + this.outputSpecId = outputSpecId; + this.targetFileSize = targetFileSize; + this.writeSchema = writeSchema; + this.dsSchema = dsSchema; + this.useFanoutWriter = useFanoutWriter; + this.queryId = queryId; + this.writeProperties = writeProperties; + this.sortOrderId = sortOrderId; + } + + @Override + public DataWriter createWriter(int partitionId, long taskId) { + return createWriter(partitionId, taskId, 0); + } + + @Override + public DataWriter createWriter(int partitionId, long taskId, long epochId) { + Table table = tableBroadcast.value(); + PartitionSpec spec = table.specs().get(outputSpecId); + FileIO io = table.io(); + String operationId = queryId + "-" + epochId; + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, partitionId, taskId) + .format(format) + .operationId(operationId) + .build(); + SparkFileWriterFactory writerFactory = + SparkFileWriterFactory.builderFor(table) + .dataFileFormat(format) + .dataSchema(writeSchema) + .dataSparkType(dsSchema) + .writeProperties(writeProperties) + .dataSortOrder(table.sortOrders().get(sortOrderId)) + .build(); + + Function rowLineageExtractor = new ExtractRowLineage(writeSchema); + + if (spec.isUnpartitioned()) { + return new UnpartitionedDataWriter( + writerFactory, fileFactory, io, spec, targetFileSize, rowLineageExtractor); + + } else { + return new PartitionedDataWriter( + writerFactory, + fileFactory, + io, + spec, + writeSchema, + dsSchema, + targetFileSize, + useFanoutWriter, + rowLineageExtractor); + } + } + } + + private static class UnpartitionedDataWriter extends DataWriterWithLineage { + private final FileWriter delegate; + private final FileIO io; + + private UnpartitionedDataWriter( + SparkFileWriterFactory writerFactory, + OutputFileFactory fileFactory, + FileIO io, + PartitionSpec spec, + long targetFileSize, + Function rowLineageExtractor) { + super(rowLineageExtractor); + this.delegate = + new RollingDataWriter<>(writerFactory, fileFactory, io, targetFileSize, spec, null); + this.io = io; + } + + @Override + public void write(InternalRow record) throws IOException { + write(null, record); + } + + @Override + public void write(InternalRow meta, InternalRow record) throws IOException { + delegate.write(decorateWithRowLineage(meta, record)); + } + + @Override + public WriterCommitMessage commit() throws IOException { + close(); + + DataWriteResult result = delegate.result(); + TaskCommit taskCommit = new TaskCommit(result.dataFiles().toArray(new DataFile[0])); + taskCommit.reportOutputMetrics(); + return taskCommit; + } + + @Override + public void abort() throws IOException { + close(); + + DataWriteResult result = delegate.result(); + SparkCleanupUtil.deleteTaskFiles(io, result.dataFiles()); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + private static class PartitionedDataWriter extends DataWriterWithLineage { + private final PartitioningWriter delegate; + private final FileIO io; + private final PartitionSpec spec; + private final PartitionKey partitionKey; + private final InternalRowWrapper internalRowWrapper; + + private PartitionedDataWriter( + SparkFileWriterFactory writerFactory, + OutputFileFactory fileFactory, + FileIO io, + PartitionSpec spec, + Schema dataSchema, + StructType dataSparkType, + long targetFileSize, + boolean fanoutEnabled, + Function rowLineageExtractor) { + super(rowLineageExtractor); + if (fanoutEnabled) { + this.delegate = new FanoutDataWriter<>(writerFactory, fileFactory, io, targetFileSize); + } else { + this.delegate = new ClusteredDataWriter<>(writerFactory, fileFactory, io, targetFileSize); + } + this.io = io; + this.spec = spec; + this.partitionKey = new PartitionKey(spec, dataSchema); + this.internalRowWrapper = new InternalRowWrapper(dataSparkType, dataSchema.asStruct()); + } + + @Override + public void write(InternalRow row) throws IOException { + write(null, row); + } + + @Override + public void write(InternalRow meta, InternalRow record) throws IOException { + partitionKey.partition(internalRowWrapper.wrap(record)); + delegate.write(decorateWithRowLineage(meta, record), spec, partitionKey); + } + + @Override + public WriterCommitMessage commit() throws IOException { + close(); + + DataWriteResult result = delegate.result(); + TaskCommit taskCommit = new TaskCommit(result.dataFiles().toArray(new DataFile[0])); + taskCommit.reportOutputMetrics(); + return taskCommit; + } + + @Override + public void abort() throws IOException { + close(); + + DataWriteResult result = delegate.result(); + SparkCleanupUtil.deleteTaskFiles(io, result.dataFiles()); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + private abstract static class DataWriterWithLineage implements DataWriter { + private final Function rowLineageExtractor; + + DataWriterWithLineage(Function rowLineageExtractor) { + Preconditions.checkArgument( + rowLineageExtractor != null, "Row lineage extractor cannot be null"); + this.rowLineageExtractor = rowLineageExtractor; + } + + protected InternalRow decorateWithRowLineage(InternalRow meta, InternalRow record) { + InternalRow rowLineage = rowLineageExtractor.apply(meta); + return rowLineage == null ? record : new JoinedRow(record, rowLineage); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java new file mode 100644 index 000000000000..79730cd63d4b --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkWriteBuilder.java @@ -0,0 +1,233 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.IsolationLevel; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableUtil; +import org.apache.iceberg.UpdateSchema; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.spark.SparkV2Filters; +import org.apache.iceberg.spark.SparkWriteConf; +import org.apache.iceberg.spark.SparkWriteRequirements; +import org.apache.iceberg.spark.source.SparkWriteBuilder.Mode.Append; +import org.apache.iceberg.spark.source.SparkWriteBuilder.Mode.CopyOnWriteOperation; +import org.apache.iceberg.spark.source.SparkWriteBuilder.Mode.DynamicOverwrite; +import org.apache.iceberg.spark.source.SparkWriteBuilder.Mode.OverwriteByFilter; +import org.apache.iceberg.types.TypeUtil; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.write.BatchWrite; +import org.apache.spark.sql.connector.write.LogicalWriteInfo; +import org.apache.spark.sql.connector.write.RowLevelOperation.Command; +import org.apache.spark.sql.connector.write.SupportsDynamicOverwrite; +import org.apache.spark.sql.connector.write.SupportsOverwriteV2; +import org.apache.spark.sql.connector.write.Write; +import org.apache.spark.sql.connector.write.WriteBuilder; +import org.apache.spark.sql.connector.write.streaming.StreamingWrite; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; + +class SparkWriteBuilder implements WriteBuilder, SupportsDynamicOverwrite, SupportsOverwriteV2 { + private final SparkSession spark; + private final Table table; + private final String branch; + private final SparkWriteConf writeConf; + private final LogicalWriteInfo info; + private final boolean caseSensitive; + private final boolean checkNullability; + private final boolean checkOrdering; + private final boolean mergeSchema; + private Mode mode = null; + + SparkWriteBuilder(SparkSession spark, Table table, String branch, LogicalWriteInfo info) { + this.spark = spark; + this.table = table; + this.branch = branch; + this.writeConf = new SparkWriteConf(spark, table, info.options()); + this.info = info; + this.caseSensitive = writeConf.caseSensitive(); + this.checkNullability = writeConf.checkNullability(); + this.checkOrdering = writeConf.checkOrdering(); + this.mergeSchema = writeConf.mergeSchema(); + SparkTableUtil.validateWriteBranch(spark, table, branch, info.options()); + } + + public WriteBuilder overwriteFiles(Scan scan, Command command, IsolationLevel isolationLevel) { + Preconditions.checkState(mode == null, "Cannot use copy-on-write with other modes"); + this.mode = new CopyOnWriteOperation((SparkCopyOnWriteScan) scan, command, isolationLevel); + return this; + } + + @Override + public WriteBuilder overwriteDynamicPartitions() { + Preconditions.checkState(mode == null, "Cannot use dynamic overwrite with other modes"); + this.mode = new DynamicOverwrite(); + return this; + } + + @Override + public WriteBuilder overwrite(Predicate[] predicates) { + Preconditions.checkState(mode == null, "Cannot use overwrite by filter with other modes"); + Expression expr = SparkV2Filters.convert(predicates); + this.mode = useDynamicOverwrite(expr) ? new DynamicOverwrite() : new OverwriteByFilter(expr); + return this; + } + + private boolean useDynamicOverwrite(Expression expr) { + return expr == Expressions.alwaysTrue() && "dynamic".equals(writeConf.overwriteMode()); + } + + private boolean writeNeedsRowLineage() { + return TableUtil.supportsRowLineage(table) && mode instanceof CopyOnWriteOperation; + } + + private boolean writeIncludesRowLineage() { + return info.metadataSchema() + .map(schema -> schema.exists(field -> field.name().equals(MetadataColumns.ROW_ID.name()))) + .orElse(false); + } + + private StructType sparkWriteSchema() { + if (writeIncludesRowLineage()) { + StructType writeSchema = info.schema(); + StructType metaSchema = info.metadataSchema().get(); + StructField rowId = metaSchema.apply(MetadataColumns.ROW_ID.name()); + writeSchema = writeSchema.add(rowId); + StructField rowSeq = metaSchema.apply(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name()); + writeSchema = writeSchema.add(rowSeq); + return writeSchema; + } else { + return info.schema(); + } + } + + @Override + public Write build() { + validateRowLineage(); + Schema writeSchema = mergeSchema ? mergeAndValidateWriteSchema() : validateWriteSchema(); + SparkUtil.validatePartitionTransforms(table.spec()); + String appId = spark.sparkContext().applicationId(); + + return new SparkWrite( + spark, + table, + branch, + writeConf, + info, + appId, + writeSchema, + sparkWriteSchema(), + writeRequirements()) { + + @Override + public BatchWrite toBatch() { + if (mode instanceof OverwriteByFilter overwrite) { + return asOverwriteByFilter(overwrite.expr()); + } else if (mode instanceof DynamicOverwrite) { + return asDynamicOverwrite(); + } else if (mode instanceof CopyOnWriteOperation cow) { + return asCopyOnWriteOperation(cow.scan(), cow.isolationLevel()); + } else { + return asBatchAppend(); + } + } + + @Override + public StreamingWrite toStreaming() { + if (mode instanceof OverwriteByFilter overwrite) { + Preconditions.checkState( + overwrite.expr() == Expressions.alwaysTrue(), + "Unsupported streaming overwrite filter: " + overwrite.expr()); + return asStreamingOverwrite(); + } else if (mode == null || mode instanceof Append) { + return asStreamingAppend(); + } else { + throw new IllegalStateException("Unsupported streaming write mode: " + mode); + } + } + }; + } + + private SparkWriteRequirements writeRequirements() { + if (mode instanceof CopyOnWriteOperation cow) { + return writeConf.copyOnWriteRequirements(cow.command()); + } else { + return writeConf.writeRequirements(); + } + } + + private void validateRowLineage() { + Preconditions.checkArgument( + writeIncludesRowLineage() || !writeNeedsRowLineage(), + "Row lineage information is missing for write in mode: %s", + mode); + } + + private Schema validateWriteSchema() { + Schema writeSchema = SparkSchemaUtil.convert(table.schema(), info.schema(), caseSensitive); + TypeUtil.validateWriteSchema(table.schema(), writeSchema, checkNullability, checkOrdering); + return addRowLineageIfNeeded(writeSchema); + } + + // merge schema flow: + // - convert Spark schema and assign fresh IDs for new fields + // - update table to get final ID assignments and validate changes + // - reconvert Spark schema without assignment to use IDs assigned by UpdateSchema + // - if validation passed, update table schema + private Schema mergeAndValidateWriteSchema() { + Schema newSchema = + SparkSchemaUtil.convertWithFreshIds(table.schema(), info.schema(), caseSensitive); + UpdateSchema update = + table.updateSchema().caseSensitive(caseSensitive).unionByNameWith(newSchema); + Schema mergedSchema = update.apply(); + Schema writeSchema = SparkSchemaUtil.convert(mergedSchema, info.schema(), caseSensitive); + TypeUtil.validateWriteSchema(mergedSchema, writeSchema, checkNullability, checkOrdering); + update.commit(); + return addRowLineageIfNeeded(writeSchema); + } + + private Schema addRowLineageIfNeeded(Schema schema) { + return writeNeedsRowLineage() ? MetadataColumns.schemaWithRowLineage(schema) : schema; + } + + sealed interface Mode { + // add new data + record Append() implements Mode {} + + // overwrite partitions that receive new data (determined at runtime) + record DynamicOverwrite() implements Mode {} + + // overwrite data files matching filter expression (a.k.a static overwrite) + record OverwriteByFilter(Expression expr) implements Mode {} + + // copy-on-write operation (UPDATE/DELETE/MERGE) that completely rewrites affected files + record CopyOnWriteOperation( + SparkCopyOnWriteScan scan, Command command, IsolationLevel isolationLevel) + implements Mode {} + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StagedSparkTable.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StagedSparkTable.java new file mode 100644 index 000000000000..d78f83a51140 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StagedSparkTable.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.Transaction; +import org.apache.spark.sql.connector.catalog.StagedTable; + +public class StagedSparkTable extends SparkTable implements StagedTable { + private final Transaction transaction; + + public StagedSparkTable(Transaction transaction) { + super(transaction.table()); + this.transaction = transaction; + } + + @Override + public void commitStagedChanges() { + transaction.commitTransaction(); + } + + @Override + public void abortStagedChanges() { + // TODO: clean up + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/Stats.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/Stats.java new file mode 100644 index 000000000000..ccf523cb4b05 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/Stats.java @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import java.util.OptionalLong; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.read.Statistics; +import org.apache.spark.sql.connector.read.colstats.ColumnStatistics; + +class Stats implements Statistics { + private final OptionalLong sizeInBytes; + private final OptionalLong numRows; + private final Map colstats; + + Stats(long sizeInBytes, long numRows, Map colstats) { + this.sizeInBytes = OptionalLong.of(sizeInBytes); + this.numRows = OptionalLong.of(numRows); + this.colstats = colstats; + } + + @Override + public OptionalLong sizeInBytes() { + return sizeInBytes; + } + + @Override + public OptionalLong numRows() { + return numRows; + } + + @Override + public Map columnStats() { + return colstats; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StreamingOffset.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StreamingOffset.java new file mode 100644 index 000000000000..8869fedc84ee --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StreamingOffset.java @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.util.Locale; +import org.apache.iceberg.relocated.com.google.common.base.Objects; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.util.JsonUtil; +import org.apache.spark.sql.connector.read.streaming.Offset; + +class StreamingOffset extends Offset { + static final StreamingOffset START_OFFSET = new StreamingOffset(-1L, -1, false); + + private static final int CURR_VERSION = 1; + private static final String VERSION = "version"; + private static final String SNAPSHOT_ID = "snapshot_id"; + private static final String POSITION = "position"; + private static final String SCAN_ALL_FILES = "scan_all_files"; + + private final long snapshotId; + private final long position; + private final boolean scanAllFiles; + + /** + * An implementation of Spark Structured Streaming Offset, to track the current processed files of + * Iceberg table. + * + * @param snapshotId The current processed snapshot id. + * @param position The position of last scanned file in snapshot. + * @param scanAllFiles whether to scan all files in a snapshot; for example, to read all data when + * starting a stream. + */ + StreamingOffset(long snapshotId, long position, boolean scanAllFiles) { + this.snapshotId = snapshotId; + this.position = position; + this.scanAllFiles = scanAllFiles; + } + + static StreamingOffset fromJson(String json) { + Preconditions.checkNotNull(json, "Cannot parse StreamingOffset JSON: null"); + + try { + JsonNode node = JsonUtil.mapper().readValue(json, JsonNode.class); + return fromJsonNode(node); + } catch (IOException e) { + throw new UncheckedIOException( + String.format("Failed to parse StreamingOffset from JSON string %s", json), e); + } + } + + static StreamingOffset fromJson(InputStream inputStream) { + Preconditions.checkNotNull(inputStream, "Cannot parse StreamingOffset from inputStream: null"); + + JsonNode node; + try { + node = JsonUtil.mapper().readValue(inputStream, JsonNode.class); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read StreamingOffset from json", e); + } + + return fromJsonNode(node); + } + + @Override + public String json() { + StringWriter writer = new StringWriter(); + try { + JsonGenerator generator = JsonUtil.factory().createGenerator(writer); + generator.writeStartObject(); + generator.writeNumberField(VERSION, CURR_VERSION); + generator.writeNumberField(SNAPSHOT_ID, snapshotId); + generator.writeNumberField(POSITION, position); + generator.writeBooleanField(SCAN_ALL_FILES, scanAllFiles); + generator.writeEndObject(); + generator.flush(); + + } catch (IOException e) { + throw new UncheckedIOException("Failed to write StreamingOffset to json", e); + } + + return writer.toString(); + } + + long snapshotId() { + return snapshotId; + } + + long position() { + return position; + } + + boolean shouldScanAllFiles() { + return scanAllFiles; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof StreamingOffset) { + StreamingOffset offset = (StreamingOffset) obj; + return offset.snapshotId == snapshotId + && offset.position == position + && offset.scanAllFiles == scanAllFiles; + } else { + return false; + } + } + + @Override + public int hashCode() { + return Objects.hashCode(snapshotId, position, scanAllFiles); + } + + @Override + public String toString() { + return String.format( + Locale.ROOT, + "Streaming Offset[%d: position (%d) scan_all_files (%b)]", + snapshotId, + position, + scanAllFiles); + } + + private static StreamingOffset fromJsonNode(JsonNode node) { + // The version of StreamingOffset. The offset was created with a version number + // used to validate when deserializing from json string. + int version = JsonUtil.getInt(VERSION, node); + Preconditions.checkArgument( + version == CURR_VERSION, + "This version of Iceberg source only supports version %s. Version %s is not supported.", + CURR_VERSION, + version); + + long snapshotId = JsonUtil.getLong(SNAPSHOT_ID, node); + int position = JsonUtil.getInt(POSITION, node); + boolean shouldScanAllFiles = JsonUtil.getBool(SCAN_ALL_FILES, node); + + return new StreamingOffset(snapshotId, position, shouldScanAllFiles); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StructInternalRow.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StructInternalRow.java new file mode 100644 index 000000000000..074f04d03468 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/StructInternalRow.java @@ -0,0 +1,428 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.Function; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.variants.Variant; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.BinaryType; +import org.apache.spark.sql.types.BooleanType; +import org.apache.spark.sql.types.ByteType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DateType; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.DecimalType; +import org.apache.spark.sql.types.DoubleType; +import org.apache.spark.sql.types.FloatType; +import org.apache.spark.sql.types.IntegerType; +import org.apache.spark.sql.types.LongType; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.ShortType; +import org.apache.spark.sql.types.StringType; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.types.TimestampType; +import org.apache.spark.sql.types.VariantType; +import org.apache.spark.unsafe.types.CalendarInterval; +import org.apache.spark.unsafe.types.GeographyVal; +import org.apache.spark.unsafe.types.GeometryVal; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; + +class StructInternalRow extends InternalRow { + private final Types.StructType type; + private StructLike struct; + + StructInternalRow(Types.StructType type) { + this.type = type; + } + + private StructInternalRow(Types.StructType type, StructLike struct) { + this.type = type; + this.struct = struct; + } + + public StructInternalRow setStruct(StructLike newStruct) { + this.struct = newStruct; + return this; + } + + @Override + public int numFields() { + return struct.size(); + } + + @Override + public void setNullAt(int i) { + throw new UnsupportedOperationException("StructInternalRow is read-only"); + } + + @Override + public void update(int i, Object value) { + throw new UnsupportedOperationException("StructInternalRow is read-only"); + } + + @Override + public InternalRow copy() { + return this; + } + + @Override + public boolean isNullAt(int ordinal) { + return struct.get(ordinal, Object.class) == null; + } + + @Override + public boolean getBoolean(int ordinal) { + return struct.get(ordinal, Boolean.class); + } + + @Override + public byte getByte(int ordinal) { + return (byte) (int) struct.get(ordinal, Integer.class); + } + + @Override + public short getShort(int ordinal) { + return (short) (int) struct.get(ordinal, Integer.class); + } + + @Override + public int getInt(int ordinal) { + Object integer = struct.get(ordinal, Object.class); + + if (integer instanceof Integer) { + return (int) integer; + } else if (integer instanceof LocalDate) { + return (int) ((LocalDate) integer).toEpochDay(); + } else { + throw new IllegalStateException( + "Unknown type for int field. Type name: " + integer.getClass().getName()); + } + } + + @Override + public long getLong(int ordinal) { + Object longVal = struct.get(ordinal, Object.class); + + if (longVal instanceof Long) { + return (long) longVal; + } else if (longVal instanceof OffsetDateTime) { + return Duration.between(Instant.EPOCH, (OffsetDateTime) longVal).toNanos() / 1000; + } else if (longVal instanceof LocalDate) { + return ((LocalDate) longVal).toEpochDay(); + } else { + throw new IllegalStateException( + "Unknown type for long field. Type name: " + longVal.getClass().getName()); + } + } + + @Override + public float getFloat(int ordinal) { + return struct.get(ordinal, Float.class); + } + + @Override + public double getDouble(int ordinal) { + return struct.get(ordinal, Double.class); + } + + @Override + public Decimal getDecimal(int ordinal, int precision, int scale) { + return isNullAt(ordinal) ? null : getDecimalInternal(ordinal); + } + + private Decimal getDecimalInternal(int ordinal) { + return Decimal.apply(struct.get(ordinal, BigDecimal.class)); + } + + @Override + public UTF8String getUTF8String(int ordinal) { + return isNullAt(ordinal) ? null : getUTF8StringInternal(ordinal); + } + + private UTF8String getUTF8StringInternal(int ordinal) { + CharSequence seq = struct.get(ordinal, CharSequence.class); + return UTF8String.fromString(seq.toString()); + } + + @Override + public byte[] getBinary(int ordinal) { + return isNullAt(ordinal) ? null : getBinaryInternal(ordinal); + } + + private byte[] getBinaryInternal(int ordinal) { + Object bytes = struct.get(ordinal, Object.class); + + // should only be either ByteBuffer or byte[] + if (bytes instanceof ByteBuffer) { + return ByteBuffers.toByteArray((ByteBuffer) bytes); + } else if (bytes instanceof byte[]) { + return (byte[]) bytes; + } else { + throw new IllegalStateException( + "Unknown type for binary field. Type name: " + bytes.getClass().getName()); + } + } + + @Override + public CalendarInterval getInterval(int ordinal) { + throw new UnsupportedOperationException("Unsupported type: interval"); + } + + @Override + public InternalRow getStruct(int ordinal, int numFields) { + return isNullAt(ordinal) ? null : getStructInternal(ordinal); + } + + private InternalRow getStructInternal(int ordinal) { + return new StructInternalRow( + type.fields().get(ordinal).type().asStructType(), struct.get(ordinal, StructLike.class)); + } + + @Override + public ArrayData getArray(int ordinal) { + return isNullAt(ordinal) ? null : getArrayInternal(ordinal); + } + + private ArrayData getArrayInternal(int ordinal) { + return collectionToArrayData( + type.fields().get(ordinal).type().asListType().elementType(), + struct.get(ordinal, Collection.class)); + } + + @Override + public MapData getMap(int ordinal) { + return isNullAt(ordinal) ? null : getMapInternal(ordinal); + } + + private MapData getMapInternal(int ordinal) { + return mapToMapData( + type.fields().get(ordinal).type().asMapType(), struct.get(ordinal, Map.class)); + } + + @Override + public VariantVal getVariant(int ordinal) { + return isNullAt(ordinal) ? null : getVariantInternal(ordinal); + } + + private VariantVal getVariantInternal(int ordinal) { + Object value = struct.get(ordinal, Object.class); + return toVariantVal(value); + } + + @Override + public GeographyVal getGeography(int ordinal) { + return isNullAt(ordinal) ? null : GeographyVal.fromBytes(getBinaryInternal(ordinal)); + } + + @Override + public GeometryVal getGeometry(int ordinal) { + return isNullAt(ordinal) ? null : GeometryVal.fromBytes(getBinaryInternal(ordinal)); + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public Object get(int ordinal, DataType dataType) { + if (isNullAt(ordinal)) { + return null; + } + + if (dataType instanceof IntegerType) { + return getInt(ordinal); + } else if (dataType instanceof LongType) { + return getLong(ordinal); + } else if (dataType instanceof StringType) { + return getUTF8StringInternal(ordinal); + } else if (dataType instanceof FloatType) { + return getFloat(ordinal); + } else if (dataType instanceof DoubleType) { + return getDouble(ordinal); + } else if (dataType instanceof DecimalType) { + return getDecimalInternal(ordinal); + } else if (dataType instanceof BinaryType) { + return getBinaryInternal(ordinal); + } else if (dataType instanceof StructType) { + return getStructInternal(ordinal); + } else if (dataType instanceof ArrayType) { + return getArrayInternal(ordinal); + } else if (dataType instanceof MapType) { + return getMapInternal(ordinal); + } else if (dataType instanceof BooleanType) { + return getBoolean(ordinal); + } else if (dataType instanceof ByteType) { + return getByte(ordinal); + } else if (dataType instanceof ShortType) { + return getShort(ordinal); + } else if (dataType instanceof DateType) { + return getInt(ordinal); + } else if (dataType instanceof TimestampType) { + return getLong(ordinal); + } else if (dataType instanceof VariantType) { + return getVariantInternal(ordinal); + } + return null; + } + + private MapData mapToMapData(Types.MapType mapType, Map map) { + // make a defensive copy to ensure entries do not change + List> entries = ImmutableList.copyOf(map.entrySet()); + return new ArrayBasedMapData( + collectionToArrayData(mapType.keyType(), Lists.transform(entries, Map.Entry::getKey)), + collectionToArrayData(mapType.valueType(), Lists.transform(entries, Map.Entry::getValue))); + } + + private ArrayData collectionToArrayData(Type elementType, Collection values) { + switch (elementType.typeId()) { + case BOOLEAN: + case INTEGER: + case DATE: + case TIME: + case LONG: + case TIMESTAMP: + case FLOAT: + case DOUBLE: + return fillArray(values, array -> (pos, value) -> array[pos] = value); + case STRING: + return fillArray( + values, + array -> + (BiConsumer) + (pos, seq) -> array[pos] = UTF8String.fromString(seq.toString())); + case FIXED: + case BINARY: + return fillArray( + values, + array -> + (BiConsumer) + (pos, buf) -> array[pos] = ByteBuffers.toByteArray(buf)); + case DECIMAL: + return fillArray( + values, + array -> + (BiConsumer) (pos, dec) -> array[pos] = Decimal.apply(dec)); + case STRUCT: + return fillArray( + values, + array -> + (BiConsumer) + (pos, tuple) -> + array[pos] = new StructInternalRow(elementType.asStructType(), tuple)); + case LIST: + return fillArray( + values, + array -> + (BiConsumer>) + (pos, list) -> + array[pos] = + collectionToArrayData(elementType.asListType().elementType(), list)); + case MAP: + return fillArray( + values, + array -> + (BiConsumer>) + (pos, map) -> array[pos] = mapToMapData(elementType.asMapType(), map)); + case VARIANT: + return fillArray( + values, + array -> (BiConsumer) (pos, v) -> array[pos] = toVariantVal(v)); + default: + throw new UnsupportedOperationException("Unsupported array element type: " + elementType); + } + } + + private static VariantVal toVariantVal(Object value) { + if (value instanceof Variant) { + Variant variant = (Variant) value; + byte[] metadataBytes = new byte[variant.metadata().sizeInBytes()]; + ByteBuffer metadataBuffer = ByteBuffer.wrap(metadataBytes).order(ByteOrder.LITTLE_ENDIAN); + variant.metadata().writeTo(metadataBuffer, 0); + + byte[] valueBytes = new byte[variant.value().sizeInBytes()]; + ByteBuffer valueBuffer = ByteBuffer.wrap(valueBytes).order(ByteOrder.LITTLE_ENDIAN); + variant.value().writeTo(valueBuffer, 0); + + return new VariantVal(valueBytes, metadataBytes); + } + + throw new UnsupportedOperationException( + "Unsupported value for VARIANT in StructInternalRow: " + value.getClass()); + } + + @SuppressWarnings("unchecked") + private GenericArrayData fillArray( + Collection values, Function> makeSetter) { + Object[] array = new Object[values.size()]; + BiConsumer setter = makeSetter.apply(array); + + int index = 0; + for (Object value : values) { + if (value == null) { + array[index] = null; + } else { + setter.accept(index, (T) value); + } + + index += 1; + } + + return new GenericArrayData(array); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other == null || getClass() != other.getClass()) { + return false; + } + + StructInternalRow that = (StructInternalRow) other; + return type.equals(that.type) && struct.equals(that.struct); + } + + @Override + public int hashCode() { + return Objects.hash(type, struct); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..f1b0029c5432 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.MicroBatches; +import org.apache.iceberg.MicroBatches.MicroBatch; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class SyncSparkMicroBatchPlanner extends BaseSparkMicroBatchPlanner { + private static final Logger LOG = LoggerFactory.getLogger(SyncSparkMicroBatchPlanner.class); + + private final boolean caseSensitive; + private final long fromTimestamp; + private final StreamingOffset lastOffsetForTriggerAvailableNow; + + SyncSparkMicroBatchPlanner( + Table table, SparkReadConf readConf, StreamingOffset lastOffsetForTriggerAvailableNow) { + super(table, readConf); + this.caseSensitive = readConf().caseSensitive(); + this.fromTimestamp = readConf().streamFromTimestamp(); + this.lastOffsetForTriggerAvailableNow = lastOffsetForTriggerAvailableNow; + } + + @Override + public List planFiles(StreamingOffset startOffset, StreamingOffset endOffset) { + List fileScanTasks = Lists.newArrayList(); + StreamingOffset batchStartOffset = + StreamingOffset.START_OFFSET.equals(startOffset) + ? MicroBatchUtils.determineStartingOffset(table(), fromTimestamp) + : startOffset; + + StreamingOffset currentOffset = null; + + // [(startOffset : startFileIndex), (endOffset : endFileIndex) ) + do { + long endFileIndex; + if (currentOffset == null) { + currentOffset = batchStartOffset; + } else { + Snapshot snapshotAfter = SnapshotUtil.snapshotAfter(table(), currentOffset.snapshotId()); + // it may happen that we need to read this snapshot partially in case it's equal to + // endOffset. + if (currentOffset.snapshotId() != endOffset.snapshotId()) { + currentOffset = new StreamingOffset(snapshotAfter.snapshotId(), 0L, false); + } else { + currentOffset = endOffset; + } + } + + Snapshot snapshot = table().snapshot(currentOffset.snapshotId()); + + validateCurrentSnapshotExists(snapshot, currentOffset); + + if (!shouldProcess(snapshot)) { + LOG.debug("Skipping snapshot: {} of table {}", currentOffset.snapshotId(), table().name()); + continue; + } + + Snapshot currentSnapshot = table().snapshot(currentOffset.snapshotId()); + if (currentOffset.snapshotId() == endOffset.snapshotId()) { + endFileIndex = endOffset.position(); + } else { + endFileIndex = MicroBatchUtils.addedFilesCount(table(), currentSnapshot); + } + + MicroBatch latestMicroBatch = + MicroBatches.from(currentSnapshot, table().io()) + .caseSensitive(caseSensitive) + .specsById(table().specs()) + .generate( + currentOffset.position(), + endFileIndex, + Long.MAX_VALUE, + currentOffset.shouldScanAllFiles()); + + fileScanTasks.addAll(latestMicroBatch.tasks()); + } while (currentOffset.snapshotId() != endOffset.snapshotId()); + + return fileScanTasks; + } + + @Override + @SuppressWarnings("checkstyle:CyclomaticComplexity") + public StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit) { + table().refresh(); + if (table().currentSnapshot() == null) { + return StreamingOffset.START_OFFSET; + } + + if (table().currentSnapshot().timestampMillis() < fromTimestamp) { + return StreamingOffset.START_OFFSET; + } + + // end offset can expand to multiple snapshots + StreamingOffset startingOffset = startOffset; + + if (startOffset.equals(StreamingOffset.START_OFFSET)) { + startingOffset = MicroBatchUtils.determineStartingOffset(table(), fromTimestamp); + } + + Snapshot curSnapshot = table().snapshot(startingOffset.snapshotId()); + validateCurrentSnapshotExists(curSnapshot, startingOffset); + + // Use the pre-computed snapshotId when Trigger.AvailableNow is enabled. + long latestSnapshotId = + lastOffsetForTriggerAvailableNow != null + ? lastOffsetForTriggerAvailableNow.snapshotId() + : table().currentSnapshot().snapshotId(); + + int startPosOfSnapOffset = (int) startingOffset.position(); + + boolean scanAllFiles = startingOffset.shouldScanAllFiles(); + + boolean shouldContinueReading = true; + int curFilesAdded = 0; + long curRecordCount = 0; + int curPos = 0; + + // Extract limits once to avoid repeated calls in tight loop + UnpackedLimits unpackedLimits = new UnpackedLimits(limit); + long maxFiles = unpackedLimits.getMaxFiles(); + long maxRows = unpackedLimits.getMaxRows(); + + // Note : we produce nextOffset with pos as non-inclusive + while (shouldContinueReading) { + // generate manifest index for the curSnapshot + List> indexedManifests = + MicroBatches.skippedManifestIndexesFromSnapshot( + table().io(), curSnapshot, startPosOfSnapOffset, scanAllFiles); + // this is under assumption we will be able to add at-least 1 file in the new offset + for (int idx = 0; idx < indexedManifests.size() && shouldContinueReading; idx++) { + // be rest assured curPos >= startFileIndex + curPos = indexedManifests.get(idx).second(); + try (CloseableIterable taskIterable = + MicroBatches.openManifestFile( + table().io(), + table().specs(), + caseSensitive, + curSnapshot, + indexedManifests.get(idx).first(), + scanAllFiles); + CloseableIterator taskIter = taskIterable.iterator()) { + while (taskIter.hasNext()) { + FileScanTask task = taskIter.next(); + if (curPos >= startPosOfSnapOffset) { + if ((curFilesAdded + 1) > maxFiles) { + // On including the file it might happen that we might exceed, the configured + // soft limit on the number of records, since this is a soft limit its acceptable. + shouldContinueReading = false; + break; + } + + curFilesAdded += 1; + curRecordCount += task.file().recordCount(); + + if (curRecordCount >= maxRows) { + // we included the file, so increment the number of files + // read in the current snapshot. + if (curFilesAdded == 1 && curRecordCount > maxRows) { + LOG.warn( + "File {} contains {} records, exceeding maxRecordsPerMicroBatch limit of {}. " + + "This file will be processed entirely to guarantee forward progress. " + + "Consider increasing the limit or writing smaller files to avoid unexpected memory usage.", + task.file().location(), + task.file().recordCount(), + maxRows); + } + ++curPos; + shouldContinueReading = false; + break; + } + } + ++curPos; + } + } catch (IOException ioe) { + LOG.warn("Failed to close task iterable", ioe); + } + } + // if the currentSnapShot was also the latestSnapshot then break + if (curSnapshot.snapshotId() == latestSnapshotId) { + break; + } + + // if everything was OK and we consumed complete snapshot then move to next snapshot + if (shouldContinueReading) { + Snapshot nextValid = nextValidSnapshot(curSnapshot); + if (nextValid == null) { + // nextValid implies all the remaining snapshots should be skipped. + break; + } + // we found the next available snapshot, continue from there. + curSnapshot = nextValid; + startPosOfSnapOffset = -1; + // if anyhow we are moving to next snapshot we should only scan addedFiles + scanAllFiles = false; + } + } + + StreamingOffset latestStreamingOffset = + new StreamingOffset(curSnapshot.snapshotId(), curPos, scanAllFiles); + + // if no new data arrived, then return null. + return latestStreamingOffset.equals(startingOffset) ? null : latestStreamingOffset; + } + + @Override + public void stop() {} + + private void validateCurrentSnapshotExists(Snapshot snapshot, StreamingOffset currentOffset) { + if (snapshot == null) { + throw new IllegalStateException( + String.format( + Locale.ROOT, + "Cannot load current offset at snapshot %d, the snapshot was expired or removed", + currentOffset.snapshotId())); + } + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDataFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDataFiles.java new file mode 100644 index 000000000000..70df1ca2ca22 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedDataFiles extends CustomSumMetric { + + public static final String NAME = "addedDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added data files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDeleteFiles.java new file mode 100644 index 000000000000..381e912407a5 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedDeleteFiles extends CustomSumMetric { + + public static final String NAME = "addedDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeleteFiles.java new file mode 100644 index 000000000000..bd653803fa47 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedEqualityDeleteFiles extends CustomSumMetric { + + public static final String NAME = "addedEqualityDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added equality delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeletes.java new file mode 100644 index 000000000000..c5ee8c952dd9 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedEqualityDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedEqualityDeletes extends CustomSumMetric { + + public static final String NAME = "addedEqualityDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added equality deletes records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedFileSizeInBytes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedFileSizeInBytes.java new file mode 100644 index 000000000000..8e5a16dbf2a9 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedFileSizeInBytes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedFileSizeInBytes extends CustomSumMetric { + + public static final String NAME = "addedFileSizeInBytes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total size of added files (bytes)"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeleteFiles.java new file mode 100644 index 000000000000..d9d5501a80e3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedPositionalDeleteFiles extends CustomSumMetric { + + public static final String NAME = "addedPositionalDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added positional delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeletes.java new file mode 100644 index 000000000000..bd8995eac604 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedPositionalDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedPositionalDeletes extends CustomSumMetric { + + public static final String NAME = "addedPositionalDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added positional deletes records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedRecords.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedRecords.java new file mode 100644 index 000000000000..240ec5b34bd2 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/AddedRecords.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class AddedRecords extends CustomSumMetric { + + public static final String NAME = "addedRecords"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of added records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/EqualityDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/EqualityDeleteFiles.java new file mode 100644 index 000000000000..4c258c01c907 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/EqualityDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class EqualityDeleteFiles extends CustomSumMetric { + + public static final String NAME = "equalityDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of equality delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/IndexedDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/IndexedDeleteFiles.java new file mode 100644 index 000000000000..93dd410b2ae0 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/IndexedDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class IndexedDeleteFiles extends CustomSumMetric { + + public static final String NAME = "indexedDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of indexed delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/NumDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/NumDeletes.java new file mode 100644 index 000000000000..eeff4286d0ce --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/NumDeletes.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import java.text.NumberFormat; +import java.util.Locale; +import org.apache.spark.sql.connector.metric.CustomMetric; + +public class NumDeletes implements CustomMetric { + + public static final String DISPLAY_STRING = "number of row deletes applied"; + + @Override + public String name() { + return "numDeletes"; + } + + @Override + public String description() { + return DISPLAY_STRING; + } + + @Override + public String aggregateTaskMetrics(long[] taskMetrics) { + long sum = initialValue; + for (long taskMetric : taskMetrics) { + sum += taskMetric; + } + + return NumberFormat.getIntegerInstance(Locale.ROOT).format(sum); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/NumSplits.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/NumSplits.java new file mode 100644 index 000000000000..f5c7a1f78cff --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/NumSplits.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import java.text.NumberFormat; +import java.util.Locale; +import org.apache.spark.sql.connector.metric.CustomMetric; + +public class NumSplits implements CustomMetric { + + @Override + public String name() { + return "numSplits"; + } + + @Override + public String description() { + return "number of file splits read"; + } + + @Override + public String aggregateTaskMetrics(long[] taskMetrics) { + long sum = initialValue; + for (long taskMetric : taskMetrics) { + sum += taskMetric; + } + + return NumberFormat.getIntegerInstance(Locale.ROOT).format(sum); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/PositionalDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/PositionalDeleteFiles.java new file mode 100644 index 000000000000..f362d3353505 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/PositionalDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class PositionalDeleteFiles extends CustomSumMetric { + + public static final String NAME = "positionalDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of positional delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDataFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDataFiles.java new file mode 100644 index 000000000000..96e21e96c20e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedDataFiles extends CustomSumMetric { + + public static final String NAME = "removedDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed data files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDeleteFiles.java new file mode 100644 index 000000000000..9e1267592fc1 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedDeleteFiles extends CustomSumMetric { + + public static final String NAME = "removedDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeleteFiles.java new file mode 100644 index 000000000000..07c0de3325d2 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedEqualityDeleteFiles extends CustomSumMetric { + + public static final String NAME = "removedEqualityDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed equality delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeletes.java new file mode 100644 index 000000000000..2ad7f5a34419 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedEqualityDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedEqualityDeletes extends CustomSumMetric { + + public static final String NAME = "removedEqualityDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed equality deletes records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedFileSizeInBytes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedFileSizeInBytes.java new file mode 100644 index 000000000000..b7ec8b519564 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedFileSizeInBytes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedFileSizeInBytes extends CustomSumMetric { + + public static final String NAME = "removedFileSizeInBytes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total size of removed files (bytes)"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeleteFiles.java new file mode 100644 index 000000000000..d01529753b64 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedPositionalDeleteFiles extends CustomSumMetric { + + public static final String NAME = "removedPositionalDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed positional delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeletes.java new file mode 100644 index 000000000000..b99892b2116e --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedPositionalDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedPositionalDeletes extends CustomSumMetric { + + public static final String NAME = "removedPositionalDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed positional deletes records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedRecords.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedRecords.java new file mode 100644 index 000000000000..b186aff8e075 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/RemovedRecords.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class RemovedRecords extends CustomSumMetric { + + public static final String NAME = "removedRecords"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of removed records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDataFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDataFiles.java new file mode 100644 index 000000000000..af75b746e437 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class ResultDataFiles extends CustomSumMetric { + + public static final String NAME = "resultDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of result data files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDeleteFiles.java new file mode 100644 index 000000000000..54d7afac81bc --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ResultDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class ResultDeleteFiles extends CustomSumMetric { + + public static final String NAME = "resultDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of result delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDataManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDataManifests.java new file mode 100644 index 000000000000..f52efec55b3b --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDataManifests.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class ScannedDataManifests extends CustomSumMetric { + + public static final String NAME = "scannedDataManifests"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of scanned data manifests"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDeleteManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDeleteManifests.java new file mode 100644 index 000000000000..8f11eac2f286 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/ScannedDeleteManifests.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class ScannedDeleteManifests extends CustomSumMetric { + + public static final String NAME = "scannedDeleteManifests"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of scanned delete manifests"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataFiles.java new file mode 100644 index 000000000000..0e57eb31ea72 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class SkippedDataFiles extends CustomSumMetric { + + public static final String NAME = "skippedDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of skipped data files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataManifests.java new file mode 100644 index 000000000000..a02644643bd4 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDataManifests.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class SkippedDataManifests extends CustomSumMetric { + + public static final String NAME = "skippedDataManifests"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of skipped data manifests"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteFiles.java new file mode 100644 index 000000000000..517415a51945 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class SkippedDeleteFiles extends CustomSumMetric { + + public static final String NAME = "skippedDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of skipped delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteManifests.java new file mode 100644 index 000000000000..c76aa28834cd --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/SkippedDeleteManifests.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class SkippedDeleteManifests extends CustomSumMetric { + + public static final String NAME = "skippedDeleteManifests"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "number of skipped delete manifest"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskEqualityDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskEqualityDeleteFiles.java new file mode 100644 index 000000000000..ecd14bcca31d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskEqualityDeleteFiles.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskEqualityDeleteFiles implements CustomTaskMetric { + private final long value; + + private TaskEqualityDeleteFiles(long value) { + this.value = value; + } + + @Override + public String name() { + return EqualityDeleteFiles.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskEqualityDeleteFiles from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().equalityDeleteFiles(); + long value = counter != null ? counter.value() : 0L; + return new TaskEqualityDeleteFiles(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskIndexedDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskIndexedDeleteFiles.java new file mode 100644 index 000000000000..63b6767e955d --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskIndexedDeleteFiles.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskIndexedDeleteFiles implements CustomTaskMetric { + private final long value; + + private TaskIndexedDeleteFiles(long value) { + this.value = value; + } + + @Override + public String name() { + return IndexedDeleteFiles.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskIndexedDeleteFiles from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().indexedDeleteFiles(); + long value = counter != null ? counter.value() : 0L; + return new TaskIndexedDeleteFiles(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskNumDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskNumDeletes.java new file mode 100644 index 000000000000..8c734ba9f022 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskNumDeletes.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskNumDeletes implements CustomTaskMetric { + private final long value; + + public TaskNumDeletes(long value) { + this.value = value; + } + + @Override + public String name() { + return "numDeletes"; + } + + @Override + public long value() { + return value; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskNumSplits.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskNumSplits.java new file mode 100644 index 000000000000..d8cbc4db05bb --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskNumSplits.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskNumSplits implements CustomTaskMetric { + private final long value; + + public TaskNumSplits(long value) { + this.value = value; + } + + @Override + public String name() { + return "numSplits"; + } + + @Override + public long value() { + return value; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskPositionalDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskPositionalDeleteFiles.java new file mode 100644 index 000000000000..805f22bf0d7c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskPositionalDeleteFiles.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskPositionalDeleteFiles implements CustomTaskMetric { + private final long value; + + private TaskPositionalDeleteFiles(long value) { + this.value = value; + } + + @Override + public String name() { + return PositionalDeleteFiles.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskPositionalDeleteFiles from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().positionalDeleteFiles(); + long value = counter != null ? counter.value() : 0L; + return new TaskPositionalDeleteFiles(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskResultDataFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskResultDataFiles.java new file mode 100644 index 000000000000..a27142131403 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskResultDataFiles.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskResultDataFiles implements CustomTaskMetric { + private final long value; + + private TaskResultDataFiles(long value) { + this.value = value; + } + + @Override + public String name() { + return ResultDataFiles.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskResultDataFiles from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().resultDataFiles(); + long value = counter != null ? counter.value() : 0L; + return new TaskResultDataFiles(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskResultDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskResultDeleteFiles.java new file mode 100644 index 000000000000..aea8ca07dd05 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskResultDeleteFiles.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskResultDeleteFiles implements CustomTaskMetric { + private final long value; + + private TaskResultDeleteFiles(long value) { + this.value = value; + } + + @Override + public String name() { + return ResultDeleteFiles.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskResultDeleteFiles from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().resultDeleteFiles(); + long value = counter != null ? counter.value() : 0L; + return new TaskResultDeleteFiles(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScannedDataManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScannedDataManifests.java new file mode 100644 index 000000000000..09dd0339910c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScannedDataManifests.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskScannedDataManifests implements CustomTaskMetric { + private final long value; + + private TaskScannedDataManifests(long value) { + this.value = value; + } + + @Override + public String name() { + return ScannedDataManifests.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskScannedDataManifests from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().scannedDataManifests(); + long value = counter != null ? counter.value() : 0L; + return new TaskScannedDataManifests(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScannedDeleteManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScannedDeleteManifests.java new file mode 100644 index 000000000000..1766cf2f6835 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskScannedDeleteManifests.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskScannedDeleteManifests implements CustomTaskMetric { + private final long value; + + private TaskScannedDeleteManifests(long value) { + this.value = value; + } + + @Override + public String name() { + return ScannedDeleteManifests.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskScannedDeleteManifests from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().scannedDeleteManifests(); + long value = counter != null ? counter.value() : 0L; + return new TaskScannedDeleteManifests(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDataFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDataFiles.java new file mode 100644 index 000000000000..5165f9a3116c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDataFiles.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskSkippedDataFiles implements CustomTaskMetric { + private final long value; + + private TaskSkippedDataFiles(long value) { + this.value = value; + } + + @Override + public String name() { + return SkippedDataFiles.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskSkippedDataFiles from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().skippedDataFiles(); + long value = counter != null ? counter.value() : 0L; + return new TaskSkippedDataFiles(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDataManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDataManifests.java new file mode 100644 index 000000000000..86fef8c4118b --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDataManifests.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskSkippedDataManifests implements CustomTaskMetric { + private final long value; + + private TaskSkippedDataManifests(long value) { + this.value = value; + } + + @Override + public String name() { + return SkippedDataManifests.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskSkippedDataManifests from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().skippedDataManifests(); + long value = counter != null ? counter.value() : 0L; + return new TaskSkippedDataManifests(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDeleteFiles.java new file mode 100644 index 000000000000..87579751742c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDeleteFiles.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskSkippedDeleteFiles implements CustomTaskMetric { + private final long value; + + private TaskSkippedDeleteFiles(long value) { + this.value = value; + } + + @Override + public String name() { + return SkippedDeleteFiles.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskSkippedDeleteFiles from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().skippedDeleteFiles(); + long value = counter != null ? counter.value() : 0L; + return new TaskSkippedDeleteFiles(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDeleteManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDeleteManifests.java new file mode 100644 index 000000000000..4a9c71e0c1e4 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskSkippedDeleteManifests.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskSkippedDeleteManifests implements CustomTaskMetric { + private final long value; + + private TaskSkippedDeleteManifests(long value) { + this.value = value; + } + + @Override + public String name() { + return SkippedDeleteManifests.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskSkippedDeleteManifests from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().skippedDeleteManifests(); + long value = counter != null ? counter.value() : 0L; + return new TaskSkippedDeleteManifests(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDataFileSize.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDataFileSize.java new file mode 100644 index 000000000000..3f5a224425d8 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDataFileSize.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskTotalDataFileSize implements CustomTaskMetric { + + private final long value; + + private TaskTotalDataFileSize(long value) { + this.value = value; + } + + @Override + public String name() { + return TotalDataFileSize.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskTotalDataFileSize from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().totalFileSizeInBytes(); + long value = counter != null ? counter.value() : 0L; + return new TaskTotalDataFileSize(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDataManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDataManifests.java new file mode 100644 index 000000000000..6d8c3c24e460 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDataManifests.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskTotalDataManifests implements CustomTaskMetric { + private final long value; + + private TaskTotalDataManifests(long value) { + this.value = value; + } + + @Override + public String name() { + return TotalDataManifests.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskTotalDataManifests from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().totalDataManifests(); + long value = counter != null ? counter.value() : 0L; + return new TaskTotalDataManifests(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDeleteFileSize.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDeleteFileSize.java new file mode 100644 index 000000000000..17ecec78da3f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDeleteFileSize.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskTotalDeleteFileSize implements CustomTaskMetric { + + private final long value; + + private TaskTotalDeleteFileSize(long value) { + this.value = value; + } + + @Override + public String name() { + return TotalDeleteFileSize.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskTotalDeleteFileSize from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().totalDeleteFileSizeInBytes(); + long value = counter != null ? counter.value() : 0L; + return new TaskTotalDeleteFileSize(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDeleteManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDeleteManifests.java new file mode 100644 index 000000000000..ff55c1be89e3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalDeleteManifests.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.CounterResult; +import org.apache.iceberg.metrics.ScanReport; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskTotalDeleteManifests implements CustomTaskMetric { + private final long value; + + private TaskTotalDeleteManifests(long value) { + this.value = value; + } + + @Override + public String name() { + return TotalDeleteManifests.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskTotalDeleteManifests from(ScanReport scanReport) { + CounterResult counter = scanReport.scanMetrics().totalDeleteManifests(); + long value = counter != null ? counter.value() : 0L; + return new TaskTotalDeleteManifests(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalPlanningDuration.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalPlanningDuration.java new file mode 100644 index 000000000000..32ac6fde8bf3 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TaskTotalPlanningDuration.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.iceberg.metrics.ScanReport; +import org.apache.iceberg.metrics.TimerResult; +import org.apache.spark.sql.connector.metric.CustomTaskMetric; + +public class TaskTotalPlanningDuration implements CustomTaskMetric { + + private final long value; + + private TaskTotalPlanningDuration(long value) { + this.value = value; + } + + @Override + public String name() { + return TotalPlanningDuration.NAME; + } + + @Override + public long value() { + return value; + } + + public static TaskTotalPlanningDuration from(ScanReport scanReport) { + TimerResult timerResult = scanReport.scanMetrics().totalPlanningDuration(); + long value = timerResult != null ? timerResult.totalDuration().toMillis() : -1; + return new TaskTotalPlanningDuration(value); + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFileSize.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFileSize.java new file mode 100644 index 000000000000..2f93dcabb0ec --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFileSize.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDataFileSize extends CustomSumMetric { + + public static final String NAME = "totalDataFileSize"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total data file size (bytes)"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFiles.java new file mode 100644 index 000000000000..a18126db7a1f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDataFiles extends CustomSumMetric { + + public static final String NAME = "totalDataFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of data files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataManifests.java new file mode 100644 index 000000000000..33a0656dcfa6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDataManifests.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDataManifests extends CustomSumMetric { + + public static final String NAME = "totalDataManifest"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total data manifests"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFileSize.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFileSize.java new file mode 100644 index 000000000000..d374a23df1e6 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFileSize.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDeleteFileSize extends CustomSumMetric { + + public static final String NAME = "totalDeleteFileSize"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total delete file size (bytes)"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFiles.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFiles.java new file mode 100644 index 000000000000..66e94162530f --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteFiles.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDeleteFiles extends CustomSumMetric { + + public static final String NAME = "totalDeleteFiles"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of delete files"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteManifests.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteManifests.java new file mode 100644 index 000000000000..58ac739ea3ff --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalDeleteManifests.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalDeleteManifests extends CustomSumMetric { + + public static final String NAME = "totalDeleteManifests"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total delete manifests"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalEqualityDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalEqualityDeletes.java new file mode 100644 index 000000000000..fff664cc9bdd --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalEqualityDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalEqualityDeletes extends CustomSumMetric { + + public static final String NAME = "totalEqualityDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of equality deletes records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalFileSizeInBytes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalFileSizeInBytes.java new file mode 100644 index 000000000000..ec97bfec552c --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalFileSizeInBytes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalFileSizeInBytes extends CustomSumMetric { + + public static final String NAME = "totalFileSizeInBytes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total data file size (bytes)"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPlanningDuration.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPlanningDuration.java new file mode 100644 index 000000000000..f1051bd928a9 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPlanningDuration.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalPlanningDuration extends CustomSumMetric { + + public static final String NAME = "totalPlanningDuration"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total planning duration (ms)"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPositionalDeletes.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPositionalDeletes.java new file mode 100644 index 000000000000..da339688a3aa --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalPositionalDeletes.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalPositionalDeletes extends CustomSumMetric { + + public static final String NAME = "totalPositionalDeletes"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of positional deletes records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalRecords.java b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalRecords.java new file mode 100644 index 000000000000..3cc1123343ae --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/metrics/TotalRecords.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source.metrics; + +import org.apache.spark.sql.connector.metric.CustomSumMetric; + +public class TotalRecords extends CustomSumMetric { + + public static final String NAME = "totalRecords"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "total number of records"; + } +} diff --git a/spark/v4.1/spark/src/main/java/org/apache/spark/sql/catalyst/analysis/IcebergAnalysisException.java b/spark/v4.1/spark/src/main/java/org/apache/spark/sql/catalyst/analysis/IcebergAnalysisException.java new file mode 100644 index 000000000000..1953d7986632 --- /dev/null +++ b/spark/v4.1/spark/src/main/java/org/apache/spark/sql/catalyst/analysis/IcebergAnalysisException.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.analysis; + +import org.apache.spark.QueryContext; +import org.apache.spark.sql.AnalysisException; +import scala.Option; +import scala.collection.immutable.Map$; + +public class IcebergAnalysisException extends AnalysisException { + public IcebergAnalysisException(String message) { + super( + message, + Option.empty(), + Option.empty(), + Option.empty(), + Option.empty(), + Map$.MODULE$.empty(), + new QueryContext[0]); + } +} diff --git a/spark/v4.1/spark/src/main/resources/META-INF/services/org.apache.spark.sql.sources.DataSourceRegister b/spark/v4.1/spark/src/main/resources/META-INF/services/org.apache.spark.sql.sources.DataSourceRegister new file mode 100644 index 000000000000..01a6c4e0670d --- /dev/null +++ b/spark/v4.1/spark/src/main/resources/META-INF/services/org.apache.spark.sql.sources.DataSourceRegister @@ -0,0 +1,20 @@ +# +# 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. +# + +org.apache.iceberg.spark.source.IcebergSource diff --git a/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/OrderAwareCoalesce.scala b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/OrderAwareCoalesce.scala new file mode 100644 index 000000000000..5d5990c23b57 --- /dev/null +++ b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/OrderAwareCoalesce.scala @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.spark.rdd.PartitionCoalescer +import org.apache.spark.rdd.PartitionGroup +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.catalyst.expressions.Attribute + +// this node doesn't extend RepartitionOperation on purpose to keep this logic isolated +// and ignore it in optimizer rules such as CollapseRepartition +case class OrderAwareCoalesce(numPartitions: Int, coalescer: PartitionCoalescer, child: LogicalPlan) + extends OrderPreservingUnaryNode { + + override def output: Seq[Attribute] = child.output + + override protected def withNewChildInternal(newChild: LogicalPlan): LogicalPlan = { + copy(child = newChild) + } +} + +class OrderAwareCoalescer(val groupSize: Int) extends PartitionCoalescer with Serializable { + + override def coalesce(maxPartitions: Int, parent: RDD[_]): Array[PartitionGroup] = { + val partitionBins = parent.partitions.grouped(groupSize) + partitionBins.map { partitions => + val group = new PartitionGroup() + group.partitions ++= partitions + group + }.toArray + } +} diff --git a/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SetWriteDistributionAndOrdering.scala b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SetWriteDistributionAndOrdering.scala new file mode 100644 index 000000000000..3ce3d3e82ad8 --- /dev/null +++ b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SetWriteDistributionAndOrdering.scala @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.iceberg.DistributionMode +import org.apache.iceberg.NullOrder +import org.apache.iceberg.SortDirection +import org.apache.iceberg.expressions.Term +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.connector.catalog.CatalogV2Implicits + +case class SetWriteDistributionAndOrdering( + table: Seq[String], + distributionMode: Option[DistributionMode], + sortOrder: Seq[(Term, SortDirection, NullOrder)]) + extends LeafCommand { + + import CatalogV2Implicits._ + + override lazy val output: Seq[Attribute] = Nil + + override def simpleString(maxFields: Int): String = { + val order = sortOrder + .map { case (term, direction, nullOrder) => + s"$term $direction $nullOrder" + } + .mkString(", ") + s"SetWriteDistributionAndOrdering ${table.quoted} $distributionMode $order" + } +} diff --git a/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SortOrderParserUtil.scala b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SortOrderParserUtil.scala new file mode 100644 index 000000000000..71b6107d048e --- /dev/null +++ b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/SortOrderParserUtil.scala @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.plans.logical + +import org.apache.iceberg.NullOrder +import org.apache.iceberg.Schema +import org.apache.iceberg.SortDirection +import org.apache.iceberg.SortOrder +import org.apache.iceberg.expressions.Term + +class SortOrderParserUtil { + + def collectSortOrder( + tableSchema: Schema, + sortOrder: Seq[(Term, SortDirection, NullOrder)]): SortOrder = { + val orderBuilder = SortOrder.builderFor(tableSchema) + sortOrder.foreach { + case (term, SortDirection.ASC, nullOrder) => + orderBuilder.asc(term, nullOrder) + case (term, SortDirection.DESC, nullOrder) => + orderBuilder.desc(term, nullOrder) + } + orderBuilder.build(); + } +} diff --git a/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/utils/PlanUtils.scala b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/utils/PlanUtils.scala new file mode 100644 index 000000000000..f46127f818e5 --- /dev/null +++ b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/catalyst/utils/PlanUtils.scala @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.catalyst.utils + +import org.apache.iceberg.spark.source.SparkTable +import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan +import org.apache.spark.sql.catalyst.plans.logical.SubqueryAlias +import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation +import scala.annotation.tailrec + +object PlanUtils { + @tailrec + def isIcebergRelation(plan: LogicalPlan): Boolean = { + def isIcebergTable(relation: DataSourceV2Relation): Boolean = relation.table match { + case _: SparkTable => true + case _ => false + } + + plan match { + case s: SubqueryAlias => isIcebergRelation(s.child) + case r: DataSourceV2Relation => isIcebergTable(r) + case _ => false + } + } +} diff --git a/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/execution/OrderAwareCoalesceExec.scala b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/execution/OrderAwareCoalesceExec.scala new file mode 100644 index 000000000000..83dec98c0d9a --- /dev/null +++ b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/execution/OrderAwareCoalesceExec.scala @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.execution + +import org.apache.spark.rdd.PartitionCoalescer +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.expressions.SortOrder +import org.apache.spark.sql.catalyst.plans.physical.Partitioning +import org.apache.spark.sql.catalyst.plans.physical.SinglePartition +import org.apache.spark.sql.catalyst.plans.physical.UnknownPartitioning + +case class OrderAwareCoalesceExec( + numPartitions: Int, + coalescer: PartitionCoalescer, + child: SparkPlan) + extends UnaryExecNode { + + override def output: Seq[Attribute] = child.output + + override def outputOrdering: Seq[SortOrder] = child.outputOrdering + + override def outputPartitioning: Partitioning = { + if (numPartitions == 1) SinglePartition else UnknownPartitioning(numPartitions) + } + + protected override def doExecute(): RDD[InternalRow] = { + val result = child.execute() + if (numPartitions == 1 && result.getNumPartitions < 1) { + // make sure we don't output an RDD with 0 partitions, + // when claiming that we have a `SinglePartition` + // see CoalesceExec in Spark + new CoalesceExec.EmptyRDDWithPartitions(sparkContext, numPartitions) + } else { + result.coalesce(numPartitions, shuffle = false, Some(coalescer)) + } + } + + override protected def withNewChildInternal(newChild: SparkPlan): SparkPlan = { + copy(child = newChild) + } +} diff --git a/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/execution/datasources/SparkExpressionConverter.scala b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/execution/datasources/SparkExpressionConverter.scala new file mode 100644 index 000000000000..0be425f7d796 --- /dev/null +++ b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/execution/datasources/SparkExpressionConverter.scala @@ -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 org.apache.spark.sql.execution.datasources + +import org.apache.iceberg.spark.SparkV2Filters +import org.apache.spark.sql.catalyst.analysis.IcebergAnalysisException +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.expressions.Literal +import org.apache.spark.sql.catalyst.plans.logical.Filter +import org.apache.spark.sql.catalyst.plans.logical.LeafNode +import org.apache.spark.sql.catalyst.plans.logical.LocalRelation +import org.apache.spark.sql.classic.SparkSession +import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Strategy + +object SparkExpressionConverter { + + def convertToIcebergExpression( + sparkExpression: Expression): org.apache.iceberg.expressions.Expression = { + // Currently, it is a double conversion as we are converting Spark expression to Spark predicate + // and then converting Spark predicate to Iceberg expression. + // But these two conversions already exist and well tested. So, we are going with this approach. + DataSourceV2Strategy.translateFilterV2(sparkExpression) match { + case Some(filter) => + val converted = SparkV2Filters.convert(filter) + if (converted == null) { + throw new IllegalArgumentException( + s"Cannot convert Spark filter: $filter to Iceberg expression") + } + + converted + case _ => + throw new IllegalArgumentException( + s"Cannot translate Spark expression: $sparkExpression to data source filter") + } + } + + @throws[IcebergAnalysisException] + def collectResolvedSparkExpression( + session: SparkSession, + tableName: String, + where: String): Expression = { + val tableAttrs = session.table(tableName).queryExecution.analyzed.output + val unresolvedExpression = session.sessionState.sqlParser.parseExpression(where) + val filter = Filter(unresolvedExpression, DummyRelation(tableAttrs)) + val optimizedLogicalPlan = session.sessionState.executePlan(filter).optimizedPlan + optimizedLogicalPlan + .collectFirst { + case filter: Filter => filter.condition + case _: DummyRelation => Literal.TrueLiteral + case _: LocalRelation => Literal.FalseLiteral + } + .getOrElse(throw new IcebergAnalysisException("Failed to find filter expression")) + } + + case class DummyRelation(output: Seq[Attribute]) extends LeafNode +} diff --git a/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/stats/ThetaSketchAgg.scala b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/stats/ThetaSketchAgg.scala new file mode 100644 index 000000000000..c63f3c9c5cf6 --- /dev/null +++ b/spark/v4.1/spark/src/main/scala/org/apache/spark/sql/stats/ThetaSketchAgg.scala @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.spark.sql.stats + +import java.nio.ByteBuffer +import org.apache.datasketches.common.Family +import org.apache.datasketches.memory.Memory +import org.apache.datasketches.theta.CompactSketch +import org.apache.datasketches.theta.SetOperationBuilder +import org.apache.datasketches.theta.Sketch +import org.apache.datasketches.theta.UpdateSketch +import org.apache.iceberg.spark.SparkSchemaUtil +import org.apache.iceberg.types.Conversions +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.analysis +import org.apache.spark.sql.catalyst.expressions.Expression +import org.apache.spark.sql.catalyst.expressions.aggregate.ImperativeAggregate +import org.apache.spark.sql.catalyst.expressions.aggregate.TypedImperativeAggregate +import org.apache.spark.sql.catalyst.trees.UnaryLike +import org.apache.spark.sql.types.BinaryType +import org.apache.spark.sql.types.DataType +import org.apache.spark.sql.types.Decimal +import org.apache.spark.unsafe.types.UTF8String + +/** + * ThetaSketchAgg generates Alpha family sketch with default seed. + * The values fed to the sketch are converted to bytes using Iceberg's single value serialization. + * The result returned is an array of bytes of Compact Theta sketch of Datasketches library, + * which should be deserialized to Compact sketch before using. + * + * See [[https://iceberg.apache.org/puffin-spec/]] for more information. + */ +case class ThetaSketchAgg( + child: Expression, + mutableAggBufferOffset: Int = 0, + inputAggBufferOffset: Int = 0) + extends TypedImperativeAggregate[Sketch] + with UnaryLike[Expression] { + + private lazy val icebergType = SparkSchemaUtil.convert(child.dataType) + + def this(colName: String) = { + this(analysis.UnresolvedAttribute.quotedString(colName), 0, 0) + } + + override def dataType: DataType = BinaryType + + override def nullable: Boolean = false + + override def createAggregationBuffer(): Sketch = { + UpdateSketch.builder.setFamily(Family.ALPHA).build() + } + + override def update(buffer: Sketch, input: InternalRow): Sketch = { + val value = child.eval(input) + if (value != null) { + val icebergValue = toIcebergValue(value) + val byteBuffer = Conversions.toByteBuffer(icebergType, icebergValue) + buffer.asInstanceOf[UpdateSketch].update(byteBuffer) + } + buffer + } + + private def toIcebergValue(value: Any): Any = { + value match { + case s: UTF8String => s.toString + case d: Decimal => d.toJavaBigDecimal + case b: Array[Byte] => ByteBuffer.wrap(b) + case _ => value + } + } + + override def merge(buffer: Sketch, input: Sketch): Sketch = { + new SetOperationBuilder().buildUnion.union(buffer, input) + } + + override def eval(buffer: Sketch): Any = { + toBytes(buffer) + } + + override def serialize(buffer: Sketch): Array[Byte] = { + toBytes(buffer) + } + + override def deserialize(storageFormat: Array[Byte]): Sketch = { + CompactSketch.wrap(Memory.wrap(storageFormat)) + } + + override def withNewMutableAggBufferOffset( + newMutableAggBufferOffset: Int): ImperativeAggregate = { + copy(mutableAggBufferOffset = newMutableAggBufferOffset) + } + + override def withNewInputAggBufferOffset(newInputAggBufferOffset: Int): ImperativeAggregate = { + copy(inputAggBufferOffset = newInputAggBufferOffset) + } + + override protected def withNewChildInternal(newChild: Expression): Expression = { + copy(child = newChild) + } + + private def toBytes(sketch: Sketch): Array[Byte] = { + val compactSketch = sketch.compact() + compactSketch.toByteArray + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/KryoHelpers.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/KryoHelpers.java new file mode 100644 index 000000000000..6d88aaa11813 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/KryoHelpers.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import org.apache.spark.SparkConf; +import org.apache.spark.serializer.KryoSerializer; + +public class KryoHelpers { + + private KryoHelpers() {} + + @SuppressWarnings("unchecked") + public static T roundTripSerialize(T obj) throws IOException { + Kryo kryo = new KryoSerializer(new SparkConf()).newKryo(); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try (Output out = new Output(new ObjectOutputStream(bytes))) { + kryo.writeClassAndObject(out, obj); + } + + try (Input in = + new Input(new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray())))) { + return (T) kryo.readClassAndObject(in); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java new file mode 100644 index 000000000000..d1c724425c9f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/SparkDistributedDataScanTestBase.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; + +import java.util.Arrays; +import java.util.List; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class SparkDistributedDataScanTestBase + extends DataTableScanTestBase> { + + @Parameters(name = "formatVersion = {0}, dataMode = {1}, deleteMode = {2}") + public static List parameters() { + return Arrays.asList( + new Object[] {1, LOCAL, LOCAL}, + new Object[] {1, LOCAL, DISTRIBUTED}, + new Object[] {1, DISTRIBUTED, LOCAL}, + new Object[] {1, DISTRIBUTED, DISTRIBUTED}, + new Object[] {2, LOCAL, LOCAL}, + new Object[] {2, LOCAL, DISTRIBUTED}, + new Object[] {2, DISTRIBUTED, LOCAL}, + new Object[] {2, DISTRIBUTED, DISTRIBUTED}); + } + + protected static SparkSession spark = null; + + @Parameter(index = 1) + private PlanningMode dataMode; + + @Parameter(index = 2) + private PlanningMode deleteMode; + + @BeforeEach + public void configurePlanningModes() { + table + .updateProperties() + .set(TableProperties.DATA_PLANNING_MODE, dataMode.modeName()) + .set(TableProperties.DELETE_PLANNING_MODE, deleteMode.modeName()) + .commit(); + } + + @Override + protected BatchScan useRef(BatchScan scan, String ref) { + return scan.useRef(ref); + } + + @Override + protected BatchScan useSnapshot(BatchScan scan, long snapshotId) { + return scan.useSnapshot(snapshotId); + } + + @Override + protected BatchScan asOfTime(BatchScan scan, long timestampMillis) { + return scan.asOfTime(timestampMillis); + } + + @Override + protected BatchScan newScan() { + SparkReadConf readConf = new SparkReadConf(spark, table); + return new SparkDistributedDataScan(spark, table, readConf); + } + + protected static SparkSession initSpark(String serializer) { + return SparkSession.builder() + .master("local[2]") + .config("spark.serializer", serializer) + .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TaskCheckHelper.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TaskCheckHelper.java new file mode 100644 index 000000000000..e4979512a65f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TaskCheckHelper.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +public final class TaskCheckHelper { + private TaskCheckHelper() {} + + public static void assertEquals( + ScanTaskGroup expected, ScanTaskGroup actual) { + List expectedTasks = getFileScanTasksInFilePathOrder(expected); + List actualTasks = getFileScanTasksInFilePathOrder(actual); + + assertThat(actualTasks) + .as("The number of file scan tasks should match") + .hasSameSizeAs(expectedTasks); + + for (int i = 0; i < expectedTasks.size(); i++) { + FileScanTask expectedTask = expectedTasks.get(i); + FileScanTask actualTask = actualTasks.get(i); + assertEquals(expectedTask, actualTask); + } + } + + public static void assertEquals(FileScanTask expected, FileScanTask actual) { + assertEquals(expected.file(), actual.file()); + + // PartitionSpec implements its own equals method + assertThat(actual.spec()).as("PartitionSpec doesn't match").isEqualTo(expected.spec()); + + assertThat(actual.start()).as("starting position doesn't match").isEqualTo(expected.start()); + + assertThat(actual.start()) + .as("the number of bytes to scan doesn't match") + .isEqualTo(expected.start()); + + // simplify comparison on residual expression via comparing toString + assertThat(actual.residual()) + .asString() + .as("Residual expression doesn't match") + .isEqualTo(expected.residual().toString()); + } + + public static void assertEquals(DataFile expected, DataFile actual) { + assertThat(actual.location()) + .as("Should match the serialized record path") + .isEqualTo(expected.location()); + assertThat(actual.format()) + .as("Should match the serialized record format") + .isEqualTo(expected.format()); + assertThat(actual.partition().get(0, Object.class)) + .as("Should match the serialized record partition") + .isEqualTo(expected.partition().get(0, Object.class)); + assertThat(actual.recordCount()) + .as("Should match the serialized record count") + .isEqualTo(expected.recordCount()); + assertThat(actual.fileSizeInBytes()) + .as("Should match the serialized record size") + .isEqualTo(expected.fileSizeInBytes()); + assertThat(actual.valueCounts()) + .as("Should match the serialized record value counts") + .isEqualTo(expected.valueCounts()); + assertThat(actual.nullValueCounts()) + .as("Should match the serialized record null value counts") + .isEqualTo(expected.nullValueCounts()); + assertThat(actual.lowerBounds()) + .as("Should match the serialized record lower bounds") + .isEqualTo(expected.lowerBounds()); + assertThat(actual.upperBounds()) + .as("Should match the serialized record upper bounds") + .isEqualTo(expected.upperBounds()); + assertThat(actual.keyMetadata()) + .as("Should match the serialized record key metadata") + .isEqualTo(expected.keyMetadata()); + assertThat(actual.splitOffsets()) + .as("Should match the serialized record offsets") + .isEqualTo(expected.splitOffsets()); + } + + private static List getFileScanTasksInFilePathOrder( + ScanTaskGroup taskGroup) { + return taskGroup.tasks().stream() + // use file path + start position to differentiate the tasks + .sorted(Comparator.comparing(o -> o.file().location() + "##" + o.start())) + .collect(Collectors.toList()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestDataFileSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestDataFileSerialization.java new file mode 100644 index 000000000000..57c4dc7cdf23 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestDataFileSerialization.java @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.TaskCheckHelper.assertEquals; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.data.SparkParquetWriters; +import org.apache.iceberg.types.Types; +import org.apache.spark.SparkConf; +import org.apache.spark.serializer.KryoSerializer; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestDataFileSerialization { + + private static final Schema DATE_SCHEMA = + new Schema( + required(1, "id", Types.LongType.get()), + optional(2, "data", Types.StringType.get()), + required(3, "date", Types.StringType.get()), + optional(4, "double", Types.DoubleType.get())); + + private static final PartitionSpec PARTITION_SPEC = + PartitionSpec.builderFor(DATE_SCHEMA).identity("date").build(); + + private static final Map VALUE_COUNTS = Maps.newHashMap(); + private static final Map NULL_VALUE_COUNTS = Maps.newHashMap(); + private static final Map NAN_VALUE_COUNTS = Maps.newHashMap(); + private static final Map LOWER_BOUNDS = Maps.newHashMap(); + private static final Map UPPER_BOUNDS = Maps.newHashMap(); + + static { + VALUE_COUNTS.put(1, 5L); + VALUE_COUNTS.put(2, 3L); + VALUE_COUNTS.put(4, 2L); + NULL_VALUE_COUNTS.put(1, 0L); + NULL_VALUE_COUNTS.put(2, 2L); + NAN_VALUE_COUNTS.put(4, 1L); + LOWER_BOUNDS.put(1, longToBuffer(0L)); + UPPER_BOUNDS.put(1, longToBuffer(4L)); + } + + private static final DataFile DATA_FILE = + DataFiles.builder(PARTITION_SPEC) + .withPath("/path/to/data-1.parquet") + .withFileSizeInBytes(1234) + .withPartitionPath("date=2018-06-08") + .withMetrics( + new Metrics( + 5L, + null, + VALUE_COUNTS, + NULL_VALUE_COUNTS, + NAN_VALUE_COUNTS, + LOWER_BOUNDS, + UPPER_BOUNDS)) + .withSplitOffsets(ImmutableList.of(4L)) + .withEncryptionKeyMetadata(ByteBuffer.allocate(4).putInt(34)) + .withSortOrder(SortOrder.unsorted()) + .build(); + + @TempDir private Path temp; + + @Test + public void testDataFileKryoSerialization() throws Exception { + File data = File.createTempFile("junit", null, temp.toFile()); + assertThat(data.delete()).isTrue(); + Kryo kryo = new KryoSerializer(new SparkConf()).newKryo(); + + try (Output out = new Output(new FileOutputStream(data))) { + kryo.writeClassAndObject(out, DATA_FILE); + kryo.writeClassAndObject(out, DATA_FILE.copy()); + } + + try (Input in = new Input(new FileInputStream(data))) { + for (int i = 0; i < 2; i += 1) { + Object obj = kryo.readClassAndObject(in); + assertThat(obj).as("Should be a DataFile").isInstanceOf(DataFile.class); + assertEquals(DATA_FILE, (DataFile) obj); + } + } + } + + @Test + public void testDataFileJavaSerialization() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(DATA_FILE); + out.writeObject(DATA_FILE.copy()); + } + + try (ObjectInputStream in = + new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + for (int i = 0; i < 2; i += 1) { + Object obj = in.readObject(); + assertThat(obj).as("Should be a DataFile").isInstanceOf(DataFile.class); + assertEquals(DATA_FILE, (DataFile) obj); + } + } + } + + @Test + public void testParquetWriterSplitOffsets() throws IOException { + Iterable records = RandomData.generateSpark(DATE_SCHEMA, 1, 33L); + File parquetFile = + new File(temp.toFile(), FileFormat.PARQUET.addExtension(UUID.randomUUID().toString())); + FileAppender writer = + Parquet.write(Files.localOutput(parquetFile)) + .schema(DATE_SCHEMA) + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(DATE_SCHEMA), msgType)) + .build(); + try { + writer.addAll(records); + } finally { + writer.close(); + } + + Kryo kryo = new KryoSerializer(new SparkConf()).newKryo(); + File dataFile = File.createTempFile("junit", null, temp.toFile()); + try (Output out = new Output(new FileOutputStream(dataFile))) { + kryo.writeClassAndObject(out, writer.splitOffsets()); + } + try (Input in = new Input(new FileInputStream(dataFile))) { + kryo.readClassAndObject(in); + } + } + + private static ByteBuffer longToBuffer(long value) { + return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0, value); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestFileIOSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestFileIOSerialization.java new file mode 100644 index 000000000000..cdd2443cc0e0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestFileIOSerialization.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestFileIOSerialization { + + private static final Configuration CONF = new Configuration(); + private static final HadoopTables TABLES = new HadoopTables(CONF); + + private static final Schema SCHEMA = + new Schema( + required(1, "id", Types.LongType.get()), + optional(2, "data", Types.StringType.get()), + required(3, "date", Types.StringType.get()), + optional(4, "double", Types.DoubleType.get())); + + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).identity("date").build(); + + private static final SortOrder SORT_ORDER = SortOrder.builderFor(SCHEMA).asc("id").build(); + + static { + CONF.set("k1", "v1"); + CONF.set("k2", "v2"); + } + + @TempDir private Path temp; + private Table table; + + @BeforeEach + public void initTable() throws IOException { + Map props = ImmutableMap.of("k1", "v1", "k2", "v2"); + + File tableLocation = Files.createTempDirectory(temp, "junit").toFile(); + assertThat(tableLocation.delete()).isTrue(); + + this.table = TABLES.create(SCHEMA, SPEC, SORT_ORDER, props, tableLocation.toString()); + } + + @Test + public void testHadoopFileIOKryoSerialization() throws IOException { + FileIO io = table.io(); + Configuration expectedConf = ((HadoopFileIO) io).conf(); + + Table serializableTable = SerializableTableWithSize.copyOf(table); + FileIO deserializedIO = KryoHelpers.roundTripSerialize(serializableTable.io()); + Configuration actualConf = ((HadoopFileIO) deserializedIO).conf(); + + assertThat(actualConf).containsExactlyInAnyOrderElementsOf(expectedConf); + } + + @Test + public void testHadoopFileIOJavaSerialization() throws IOException, ClassNotFoundException { + FileIO io = table.io(); + Configuration expectedConf = ((HadoopFileIO) io).conf(); + + Table serializableTable = SerializableTableWithSize.copyOf(table); + FileIO deserializedIO = TestHelpers.roundTripSerialize(serializableTable.io()); + Configuration actualConf = ((HadoopFileIO) deserializedIO).conf(); + + assertThat(actualConf).containsExactlyInAnyOrderElementsOf(expectedConf); + } + + private Map toMap(Configuration conf) { + Map map = Maps.newHashMapWithExpectedSize(conf.size()); + conf.forEach(entry -> map.put(entry.getKey(), entry.getValue())); + return map; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestHadoopMetricsContextSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestHadoopMetricsContextSerialization.java new file mode 100644 index 000000000000..a4643d7a087b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestHadoopMetricsContextSerialization.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import java.io.IOException; +import org.apache.iceberg.hadoop.HadoopMetricsContext; +import org.apache.iceberg.io.FileIOMetricsContext; +import org.apache.iceberg.metrics.MetricsContext; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.junit.jupiter.api.Test; + +public class TestHadoopMetricsContextSerialization { + + @Test + public void testHadoopMetricsContextKryoSerialization() throws IOException { + MetricsContext metricsContext = new HadoopMetricsContext("s3"); + + metricsContext.initialize(Maps.newHashMap()); + + MetricsContext deserializedMetricContext = KryoHelpers.roundTripSerialize(metricsContext); + // statistics are properly re-initialized post de-serialization + deserializedMetricContext + .counter(FileIOMetricsContext.WRITE_BYTES, MetricsContext.Unit.BYTES) + .increment(); + } + + @Test + public void testHadoopMetricsContextJavaSerialization() + throws IOException, ClassNotFoundException { + MetricsContext metricsContext = new HadoopMetricsContext("s3"); + + metricsContext.initialize(Maps.newHashMap()); + + MetricsContext deserializedMetricContext = TestHelpers.roundTripSerialize(metricsContext); + // statistics are properly re-initialized post de-serialization + deserializedMetricContext + .counter(FileIOMetricsContext.WRITE_BYTES, MetricsContext.Unit.BYTES) + .increment(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestManifestFileSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestManifestFileSerialization.java new file mode 100644 index 000000000000..1e09917d0305 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestManifestFileSerialization.java @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Path; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.ManifestFile.PartitionFieldSummary; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.types.Types; +import org.apache.spark.SparkConf; +import org.apache.spark.serializer.KryoSerializer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestManifestFileSerialization { + + private static final Schema SCHEMA = + new Schema( + required(1, "id", Types.LongType.get()), + optional(2, "data", Types.StringType.get()), + required(3, "date", Types.StringType.get()), + required(4, "double", Types.DoubleType.get())); + + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).identity("double").build(); + + private static final DataFile FILE_A = + DataFiles.builder(SPEC) + .withPath("/path/to/data-1.parquet") + .withFileSizeInBytes(0) + .withPartition(TestHelpers.Row.of(1D)) + .withPartitionPath("double=1") + .withMetrics( + new Metrics( + 5L, + null, // no column sizes + ImmutableMap.of(1, 5L, 2, 3L), // value count + ImmutableMap.of(1, 0L, 2, 2L), // null count + ImmutableMap.of(), // nan count + ImmutableMap.of(1, longToBuffer(0L)), // lower bounds + ImmutableMap.of(1, longToBuffer(4L)) // upper bounds + )) + .build(); + + private static final DataFile FILE_B = + DataFiles.builder(SPEC) + .withPath("/path/to/data-2.parquet") + .withFileSizeInBytes(0) + .withPartition(TestHelpers.Row.of(Double.NaN)) + .withPartitionPath("double=NaN") + .withMetrics( + new Metrics( + 1L, + null, // no column sizes + ImmutableMap.of(1, 1L, 4, 1L), // value count + ImmutableMap.of(1, 0L, 2, 0L), // null count + ImmutableMap.of(4, 1L), // nan count + ImmutableMap.of(1, longToBuffer(0L)), // lower bounds + ImmutableMap.of(1, longToBuffer(1L)) // upper bounds + )) + .build(); + + private static final FileIO FILE_IO = new HadoopFileIO(new Configuration()); + + @TempDir private Path temp; + + @Test + public void testManifestFileKryoSerialization() throws IOException { + File data = File.createTempFile("junit", null, temp.toFile()); + assertThat(data.delete()).isTrue(); + + Kryo kryo = new KryoSerializer(new SparkConf()).newKryo(); + + ManifestFile manifest = writeManifest(FILE_A, FILE_B); + + try (Output out = new Output(new FileOutputStream(data))) { + kryo.writeClassAndObject(out, manifest); + kryo.writeClassAndObject(out, manifest.copy()); + kryo.writeClassAndObject(out, GenericManifestFile.copyOf(manifest).build()); + } + + try (Input in = new Input(new FileInputStream(data))) { + for (int i = 0; i < 3; i += 1) { + Object obj = kryo.readClassAndObject(in); + assertThat(obj).as("Should be a ManifestFile").isInstanceOf(ManifestFile.class); + checkManifestFile(manifest, (ManifestFile) obj); + } + } + } + + @Test + public void testManifestFileJavaSerialization() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + ManifestFile manifest = writeManifest(FILE_A, FILE_B); + + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(manifest); + out.writeObject(manifest.copy()); + out.writeObject(GenericManifestFile.copyOf(manifest).build()); + } + + try (ObjectInputStream in = + new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + for (int i = 0; i < 3; i += 1) { + Object obj = in.readObject(); + assertThat(obj).as("Should be a ManifestFile").isInstanceOf(ManifestFile.class); + checkManifestFile(manifest, (ManifestFile) obj); + } + } + } + + private void checkManifestFile(ManifestFile expected, ManifestFile actual) { + assertThat(actual.path()).as("Path must match").isEqualTo(expected.path()); + assertThat(actual.length()).as("Length must match").isEqualTo(expected.length()); + assertThat(actual.partitionSpecId()) + .as("Spec id must match") + .isEqualTo(expected.partitionSpecId()); + assertThat(actual.snapshotId()).as("Snapshot id must match").isEqualTo(expected.snapshotId()); + assertThat(actual.hasAddedFiles()) + .as("Added files flag must match") + .isEqualTo(expected.hasAddedFiles()); + assertThat(actual.addedFilesCount()) + .as("Added files count must match") + .isEqualTo(expected.addedFilesCount()); + assertThat(actual.addedRowsCount()) + .as("Added rows count must match") + .isEqualTo(expected.addedRowsCount()); + assertThat(actual.hasExistingFiles()) + .as("Existing files flag must match") + .isEqualTo(expected.hasExistingFiles()); + assertThat(actual.existingFilesCount()) + .as("Existing files count must match") + .isEqualTo(expected.existingFilesCount()); + assertThat(actual.existingRowsCount()) + .as("Existing rows count must match") + .isEqualTo(expected.existingRowsCount()); + assertThat(actual.hasDeletedFiles()) + .as("Deleted files flag must match") + .isEqualTo(expected.hasDeletedFiles()); + assertThat(actual.deletedFilesCount()) + .as("Deleted files count must match") + .isEqualTo(expected.deletedFilesCount()); + assertThat(actual.deletedRowsCount()) + .as("Deleted rows count must match") + .isEqualTo(expected.deletedRowsCount()); + + PartitionFieldSummary expectedPartition = expected.partitions().get(0); + PartitionFieldSummary actualPartition = actual.partitions().get(0); + + assertThat(actualPartition.containsNull()) + .as("Null flag in partition must match") + .isEqualTo(expectedPartition.containsNull()); + assertThat(actualPartition.containsNaN()) + .as("NaN flag in partition must match") + .isEqualTo(expectedPartition.containsNaN()); + assertThat(actualPartition.lowerBound()) + .as("Lower bounds in partition must match") + .isEqualTo(expectedPartition.lowerBound()); + assertThat(actualPartition.upperBound()) + .as("Upper bounds in partition must match") + .isEqualTo(expectedPartition.upperBound()); + } + + private ManifestFile writeManifest(DataFile... files) throws IOException { + File manifestFile = File.createTempFile("input.m0", ".avro", temp.toFile()); + assertThat(manifestFile.delete()).isTrue(); + OutputFile outputFile = FILE_IO.newOutputFile(manifestFile.getCanonicalPath()); + + ManifestWriter writer = ManifestFiles.write(SPEC, outputFile); + try { + for (DataFile file : files) { + writer.add(file); + } + } finally { + writer.close(); + } + + return writer.toManifestFile(); + } + + private static ByteBuffer longToBuffer(long value) { + return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(0, value); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestScanTaskSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestScanTaskSerialization.java new file mode 100644 index 000000000000..66ed837eafb5 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestScanTaskSerialization.java @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; + +import com.esotericsoftware.kryo.Kryo; +import com.esotericsoftware.kryo.io.Input; +import com.esotericsoftware.kryo.io.Output; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.iceberg.types.Types; +import org.apache.spark.SparkConf; +import org.apache.spark.serializer.KryoSerializer; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestScanTaskSerialization extends TestBase { + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + + @TempDir private Path temp; + @TempDir private File tableDir; + + private String tableLocation = null; + + @BeforeEach + public void setupTableLocation() { + this.tableLocation = tableDir.toURI().toString(); + } + + @Test + public void testBaseCombinedScanTaskKryoSerialization() throws Exception { + BaseCombinedScanTask scanTask = prepareBaseCombinedScanTaskForSerDeTest(); + + File data = File.createTempFile("junit", null, temp.toFile()); + assertThat(data.delete()).isTrue(); + Kryo kryo = new KryoSerializer(new SparkConf()).newKryo(); + + try (Output out = new Output(new FileOutputStream(data))) { + kryo.writeClassAndObject(out, scanTask); + } + + try (Input in = new Input(new FileInputStream(data))) { + Object obj = kryo.readClassAndObject(in); + assertThat(obj) + .as("Should be a BaseCombinedScanTask") + .isInstanceOf(BaseCombinedScanTask.class); + TaskCheckHelper.assertEquals(scanTask, (BaseCombinedScanTask) obj); + } + } + + @Test + public void testBaseCombinedScanTaskJavaSerialization() throws Exception { + BaseCombinedScanTask scanTask = prepareBaseCombinedScanTaskForSerDeTest(); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(scanTask); + } + + try (ObjectInputStream in = + new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + Object obj = in.readObject(); + assertThat(obj) + .as("Should be a BaseCombinedScanTask") + .isInstanceOf(BaseCombinedScanTask.class); + TaskCheckHelper.assertEquals(scanTask, (BaseCombinedScanTask) obj); + } + } + + @Test + @SuppressWarnings("unchecked") + public void testBaseScanTaskGroupKryoSerialization() throws Exception { + BaseScanTaskGroup taskGroup = prepareBaseScanTaskGroupForSerDeTest(); + + assertThat(taskGroup.tasks()).as("Task group can't be empty").isNotEmpty(); + + File data = File.createTempFile("junit", null, temp.toFile()); + assertThat(data.delete()).isTrue(); + Kryo kryo = new KryoSerializer(new SparkConf()).newKryo(); + + try (Output out = new Output(Files.newOutputStream(data.toPath()))) { + kryo.writeClassAndObject(out, taskGroup); + } + + try (Input in = new Input(Files.newInputStream(data.toPath()))) { + Object obj = kryo.readClassAndObject(in); + assertThat(obj).as("should be a BaseScanTaskGroup").isInstanceOf(BaseScanTaskGroup.class); + TaskCheckHelper.assertEquals(taskGroup, (BaseScanTaskGroup) obj); + } + } + + @Test + @SuppressWarnings("unchecked") + public void testBaseScanTaskGroupJavaSerialization() throws Exception { + BaseScanTaskGroup taskGroup = prepareBaseScanTaskGroupForSerDeTest(); + + assertThat(taskGroup.tasks()).as("Task group can't be empty").isNotEmpty(); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) { + out.writeObject(taskGroup); + } + + try (ObjectInputStream in = + new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) { + Object obj = in.readObject(); + assertThat(obj).as("should be a BaseScanTaskGroup").isInstanceOf(BaseScanTaskGroup.class); + TaskCheckHelper.assertEquals(taskGroup, (BaseScanTaskGroup) obj); + } + } + + private BaseCombinedScanTask prepareBaseCombinedScanTaskForSerDeTest() { + Table table = initTable(); + CloseableIterable tasks = table.newScan().planFiles(); + return new BaseCombinedScanTask(Lists.newArrayList(tasks)); + } + + private BaseScanTaskGroup prepareBaseScanTaskGroupForSerDeTest() { + Table table = initTable(); + CloseableIterable tasks = table.newScan().planFiles(); + return new BaseScanTaskGroup<>(ImmutableList.copyOf(tasks)); + } + + private Table initTable() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + List records1 = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), new ThreeColumnRecord(1, "BBBBBBBBBB", "BBBB")); + writeRecords(records1); + + List records2 = + Lists.newArrayList( + new ThreeColumnRecord(2, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(2, "DDDDDDDDDD", "DDDD")); + writeRecords(records2); + + table.refresh(); + + return table; + } + + private void writeRecords(List records) { + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + writeDF(df); + } + + private void writeDF(Dataset df) { + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java new file mode 100644 index 000000000000..a21c6a08ec3b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanDeletes.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; + +import java.util.Arrays; +import java.util.List; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkDistributedDataScanDeletes + extends DeleteFileIndexTestBase> { + + @Parameters(name = "formatVersion = {0}, dataMode = {1}, deleteMode = {2}") + public static List parameters() { + return Arrays.asList( + new Object[] {2, LOCAL, LOCAL}, + new Object[] {2, LOCAL, DISTRIBUTED}, + new Object[] {2, DISTRIBUTED, LOCAL}, + new Object[] {2, LOCAL, DISTRIBUTED}, + new Object[] {3, LOCAL, LOCAL}, + new Object[] {3, LOCAL, DISTRIBUTED}, + new Object[] {3, DISTRIBUTED, LOCAL}, + new Object[] {3, DISTRIBUTED, DISTRIBUTED}); + } + + private static SparkSession spark = null; + + @Parameter(index = 1) + private PlanningMode dataMode; + + @Parameter(index = 2) + private PlanningMode deleteMode; + + @BeforeEach + public void configurePlanningModes() { + table + .updateProperties() + .set(TableProperties.DATA_PLANNING_MODE, dataMode.modeName()) + .set(TableProperties.DELETE_PLANNING_MODE, deleteMode.modeName()) + .commit(); + } + + @BeforeAll + public static void startSpark() { + TestSparkDistributedDataScanDeletes.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestSparkDistributedDataScanDeletes.spark; + TestSparkDistributedDataScanDeletes.spark = null; + currentSpark.stop(); + } + + @Override + protected BatchScan newScan(Table table) { + SparkReadConf readConf = new SparkReadConf(spark, table); + return new SparkDistributedDataScan(spark, table, readConf); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java new file mode 100644 index 000000000000..5edf4828229a --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanFilterFiles.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; + +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkDistributedDataScanFilterFiles + extends FilterFilesTestBase> { + + @Parameters(name = "formatVersion = {0}, dataMode = {1}, deleteMode = {2}") + public static Object[] parameters() { + return new Object[][] { + new Object[] {1, LOCAL, LOCAL}, + new Object[] {1, LOCAL, DISTRIBUTED}, + new Object[] {1, DISTRIBUTED, LOCAL}, + new Object[] {1, DISTRIBUTED, DISTRIBUTED}, + new Object[] {2, LOCAL, LOCAL}, + new Object[] {2, LOCAL, DISTRIBUTED}, + new Object[] {2, DISTRIBUTED, LOCAL}, + new Object[] {2, DISTRIBUTED, DISTRIBUTED} + }; + } + + private static SparkSession spark = null; + + @Parameter(index = 1) + private PlanningMode dataMode; + + @Parameter(index = 2) + private PlanningMode deleteMode; + + @BeforeAll + public static void startSpark() { + TestSparkDistributedDataScanFilterFiles.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestSparkDistributedDataScanFilterFiles.spark; + TestSparkDistributedDataScanFilterFiles.spark = null; + currentSpark.stop(); + } + + @Override + protected BatchScan newScan(Table table) { + table + .updateProperties() + .set(TableProperties.DATA_PLANNING_MODE, dataMode.modeName()) + .set(TableProperties.DELETE_PLANNING_MODE, deleteMode.modeName()) + .commit(); + SparkReadConf readConf = new SparkReadConf(spark, table); + return new SparkDistributedDataScan(spark, table, readConf); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanJavaSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanJavaSerialization.java new file mode 100644 index 000000000000..b8bd6fb86747 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanJavaSerialization.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +public class TestSparkDistributedDataScanJavaSerialization + extends SparkDistributedDataScanTestBase { + + @BeforeAll + public static void startSpark() { + SparkDistributedDataScanTestBase.spark = + initSpark("org.apache.spark.serializer.JavaSerializer"); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = SparkDistributedDataScanTestBase.spark; + SparkDistributedDataScanTestBase.spark = null; + currentSpark.stop(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanKryoSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanKryoSerialization.java new file mode 100644 index 000000000000..08d66cccb627 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanKryoSerialization.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +public class TestSparkDistributedDataScanKryoSerialization + extends SparkDistributedDataScanTestBase { + + @BeforeAll + public static void startSpark() { + SparkDistributedDataScanTestBase.spark = + initSpark("org.apache.spark.serializer.KryoSerializer"); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = SparkDistributedDataScanTestBase.spark; + SparkDistributedDataScanTestBase.spark = null; + currentSpark.stop(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java new file mode 100644 index 000000000000..e6f3c75475d8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestSparkDistributedDataScanReporting.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; + +import java.util.Arrays; +import java.util.List; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkDistributedDataScanReporting + extends ScanPlanningAndReportingTestBase> { + + @Parameters(name = "formatVersion = {0}, dataMode = {1}, deleteMode = {2}") + public static List parameters() { + return Arrays.asList( + new Object[] {2, LOCAL, LOCAL}, + new Object[] {2, LOCAL, DISTRIBUTED}, + new Object[] {2, DISTRIBUTED, LOCAL}, + new Object[] {2, DISTRIBUTED, DISTRIBUTED}, + new Object[] {3, LOCAL, LOCAL}, + new Object[] {3, LOCAL, DISTRIBUTED}, + new Object[] {3, DISTRIBUTED, LOCAL}, + new Object[] {3, DISTRIBUTED, DISTRIBUTED}); + } + + private static SparkSession spark = null; + + @Parameter(index = 1) + private PlanningMode dataMode; + + @Parameter(index = 2) + private PlanningMode deleteMode; + + @BeforeAll + public static void startSpark() { + TestSparkDistributedDataScanReporting.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") + .config(SQLConf.SHUFFLE_PARTITIONS().key(), "4") + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestSparkDistributedDataScanReporting.spark; + TestSparkDistributedDataScanReporting.spark = null; + currentSpark.stop(); + } + + @Override + protected BatchScan newScan(Table table) { + table + .updateProperties() + .set(TableProperties.DATA_PLANNING_MODE, dataMode.modeName()) + .set(TableProperties.DELETE_PLANNING_MODE, deleteMode.modeName()) + .commit(); + SparkReadConf readConf = new SparkReadConf(spark, table); + return new SparkDistributedDataScan(spark, table, readConf); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestTableSerialization.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestTableSerialization.java new file mode 100644 index 000000000000..6e510299c632 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/TestTableSerialization.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.source.SerializableTableWithSize; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestTableSerialization { + + @Parameters(name = "isObjectStoreEnabled = {0}") + public static List parameters() { + return Arrays.asList("true", "false"); + } + + private static final HadoopTables TABLES = new HadoopTables(); + + @Parameter private String isObjectStoreEnabled; + + private static final Schema SCHEMA = + new Schema( + required(1, "id", Types.LongType.get()), + optional(2, "data", Types.StringType.get()), + required(3, "date", Types.StringType.get()), + optional(4, "double", Types.DoubleType.get())); + + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).identity("date").build(); + + private static final SortOrder SORT_ORDER = SortOrder.builderFor(SCHEMA).asc("id").build(); + + @TempDir private Path temp; + private Table table; + + @BeforeEach + public void initTable() throws IOException { + Map props = + ImmutableMap.of("k1", "v1", TableProperties.OBJECT_STORE_ENABLED, isObjectStoreEnabled); + + File tableLocation = Files.createTempDirectory(temp, "junit").toFile(); + assertThat(tableLocation.delete()).isTrue(); + + this.table = TABLES.create(SCHEMA, SPEC, SORT_ORDER, props, tableLocation.toString()); + } + + @TestTemplate + public void testCloseSerializableTableKryoSerialization() throws Exception { + for (Table tbl : tables()) { + Table spyTable = spy(tbl); + FileIO spyIO = spy(tbl.io()); + when(spyTable.io()).thenReturn(spyIO); + + Table serializableTable = SerializableTableWithSize.copyOf(spyTable); + + Table serializableTableCopy = spy(KryoHelpers.roundTripSerialize(serializableTable)); + FileIO spyFileIOCopy = spy(serializableTableCopy.io()); + when(serializableTableCopy.io()).thenReturn(spyFileIOCopy); + + ((AutoCloseable) serializableTable).close(); // mimics close on the driver + ((AutoCloseable) serializableTableCopy).close(); // mimics close on executors + + verify(spyIO, never()).close(); + verify(spyFileIOCopy, times(1)).close(); + } + } + + @TestTemplate + public void testCloseSerializableTableJavaSerialization() throws Exception { + for (Table tbl : tables()) { + Table spyTable = spy(tbl); + FileIO spyIO = spy(tbl.io()); + when(spyTable.io()).thenReturn(spyIO); + + Table serializableTable = SerializableTableWithSize.copyOf(spyTable); + + Table serializableTableCopy = spy(TestHelpers.roundTripSerialize(serializableTable)); + FileIO spyFileIOCopy = spy(serializableTableCopy.io()); + when(serializableTableCopy.io()).thenReturn(spyFileIOCopy); + + ((AutoCloseable) serializableTable).close(); // mimics close on the driver + ((AutoCloseable) serializableTableCopy).close(); // mimics close on executors + + verify(spyIO, never()).close(); + verify(spyFileIOCopy, times(1)).close(); + } + } + + @TestTemplate + public void testSerializableTableKryoSerialization() throws IOException { + Table serializableTable = SerializableTableWithSize.copyOf(table); + TestHelpers.assertSerializedAndLoadedMetadata( + table, KryoHelpers.roundTripSerialize(serializableTable)); + } + + @TestTemplate + public void testSerializableMetadataTableKryoSerialization() throws IOException { + for (MetadataTableType type : MetadataTableType.values()) { + TableOperations ops = ((HasTableOperations) table).operations(); + Table metadataTable = + MetadataTableUtils.createMetadataTableInstance(ops, table.name(), "meta", type); + Table serializableMetadataTable = SerializableTableWithSize.copyOf(metadataTable); + + TestHelpers.assertSerializedAndLoadedMetadata( + metadataTable, KryoHelpers.roundTripSerialize(serializableMetadataTable)); + } + } + + @TestTemplate + public void testSerializableTransactionTableKryoSerialization() throws IOException { + Transaction txn = table.newTransaction(); + + txn.updateProperties().set("k1", "v1").commit(); + + Table txnTable = txn.table(); + Table serializableTxnTable = SerializableTableWithSize.copyOf(txnTable); + + TestHelpers.assertSerializedMetadata( + txnTable, KryoHelpers.roundTripSerialize(serializableTxnTable)); + } + + @TestTemplate + public void testLocationProviderExceptionIsDeferred() { + Table spyTable = spy(table); + RuntimeException failure = new RuntimeException("location provider failure"); + when(spyTable.locationProvider()).thenThrow(failure); + + Table serializableTable = SerializableTableWithSize.copyOf(spyTable); + assertThat(serializableTable).isNotNull(); + + assertThatThrownBy(serializableTable::locationProvider).isSameAs(failure); + verify(spyTable, times(1)).locationProvider(); + } + + @TestTemplate + public void testLocationProviderExceptionJavaSerialization() + throws IOException, ClassNotFoundException { + Table spyTable = spy(table); + RuntimeException failure = new RuntimeException("location provider failure"); + when(spyTable.locationProvider()).thenThrow(failure); + + Table serializableTable = SerializableTableWithSize.copyOf(spyTable); + Table deserialized = TestHelpers.roundTripSerialize(serializableTable); + + assertThatThrownBy(deserialized::locationProvider) + .isInstanceOf(RuntimeException.class) + .hasMessage("location provider failure"); + } + + @TestTemplate + public void testLocationProviderExceptionKryoSerialization() throws IOException { + Table spyTable = spy(table); + RuntimeException failure = new RuntimeException("location provider failure"); + when(spyTable.locationProvider()).thenThrow(failure); + + Table serializableTable = SerializableTableWithSize.copyOf(spyTable); + Table deserialized = KryoHelpers.roundTripSerialize(serializableTable); + + assertThatThrownBy(deserialized::locationProvider) + .isInstanceOf(RuntimeException.class) + .hasMessage("location provider failure"); + } + + private List
    tables() { + List
    tables = Lists.newArrayList(); + + tables.add(table); + + for (MetadataTableType type : MetadataTableType.values()) { + Table metadataTable = MetadataTableUtils.createMetadataTableInstance(table, type); + tables.add(metadataTable); + } + + return tables; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java new file mode 100644 index 000000000000..1090245326fb --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/ValidationHelpers.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; + +public class ValidationHelpers { + + private ValidationHelpers() {} + + public static List dataSeqs(Long... seqs) { + return Arrays.asList(seqs); + } + + public static List fileSeqs(Long... seqs) { + return Arrays.asList(seqs); + } + + public static List snapshotIds(Long... ids) { + return Arrays.asList(ids); + } + + public static List files(ContentFile... files) { + return Arrays.stream(files).map(ContentFile::location).collect(Collectors.toList()); + } + + public static void validateDataManifest( + Table table, + ManifestFile manifest, + List dataSeqs, + List fileSeqs, + List snapshotIds, + List files) { + + List actualDataSeqs = Lists.newArrayList(); + List actualFileSeqs = Lists.newArrayList(); + List actualSnapshotIds = Lists.newArrayList(); + List actualFiles = Lists.newArrayList(); + + for (ManifestEntry entry : + ManifestFiles.read(manifest, table.io(), table.specs()).entries()) { + actualDataSeqs.add(entry.dataSequenceNumber()); + actualFileSeqs.add(entry.fileSequenceNumber()); + actualSnapshotIds.add(entry.snapshotId()); + actualFiles.add(entry.file().location()); + } + + assertSameElements("data seqs", actualDataSeqs, dataSeqs); + assertSameElements("file seqs", actualFileSeqs, fileSeqs); + assertSameElements("snapshot IDs", actualSnapshotIds, snapshotIds); + assertSameElements("files", actualFiles, files); + } + + private static void assertSameElements(String context, List actual, List expected) { + String errorMessage = String.format("%s must match", context); + assertThat(actual).as(errorMessage).hasSameElementsAs(expected); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/CatalogTestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/CatalogTestBase.java new file mode 100644 index 000000000000..fe4d7bc01ba4 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/CatalogTestBase.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class CatalogTestBase extends TestBaseWithCatalog { + + // these parameters are broken out to avoid changes that need to modify lots of test suites + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties() + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties() + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties() + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build() + } + }; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java new file mode 100644 index 000000000000..ee1f29e56fb3 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/DummyMetricsServlet.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import com.codahale.metrics.MetricRegistry; +import java.util.Properties; +import org.apache.spark.SparkConf; +import org.apache.spark.metrics.sink.MetricsServlet; +import org.sparkproject.jetty.servlet.ServletContextHandler; + +/** + * A dummy implementation of {@link MetricsServlet} that does not start a server or report metrics. + * This is used in tests to avoid conflicts with Spark's jetty dependencies. + */ +public class DummyMetricsServlet extends MetricsServlet { + + /** + * Constructor required by Spark's reflection-based instantiation. + * + * @param properties Metrics properties + * @param registry Metric registry + */ + public DummyMetricsServlet(Properties properties, MetricRegistry registry) { + super(properties, registry); + } + + @Override + public ServletContextHandler[] getHandlers(SparkConf conf) { + return new ServletContextHandler[] {}; + } + + @Override + public void start() { + // No-op for tests + } + + @Override + public void stop() { + // No-op for tests + } + + @Override + public void report() { + // No-op for tests + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/Employee.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/Employee.java new file mode 100644 index 000000000000..9c57936d989e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/Employee.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Objects; + +public class Employee { + private Integer id; + private String dep; + + public Employee() {} + + public Employee(Integer id, String dep) { + this.id = id; + this.dep = dep; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getDep() { + return dep; + } + + public void setDep(String dep) { + this.dep = dep; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } else if (other == null || getClass() != other.getClass()) { + return false; + } + + Employee employee = (Employee) other; + return Objects.equals(id, employee.id) && Objects.equals(dep, employee.dep); + } + + @Override + public int hashCode() { + return Objects.hash(id, dep); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java new file mode 100644 index 000000000000..b20c87619ed8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SparkCatalogConfig.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import java.util.Map; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; + +public enum SparkCatalogConfig { + HIVE( + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default")), + HADOOP( + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hadoop", "cache-enabled", "false")), + REST( + "testrest", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "rest", "cache-enabled", "false")), + SPARK_SESSION( + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "parquet-enabled", "true", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + )), + SPARK_WITH_VIEWS( + "spark_with_views", + SparkCatalog.class.getName(), + ImmutableMap.of( + CatalogProperties.CATALOG_IMPL, + InMemoryCatalog.class.getName(), + "default-namespace", + "default", + "cache-enabled", + "false")), + SPARK_SESSION_WITH_VIEWS( + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of("type", "rest", "default-namespace", "default", "cache-enabled", "false")), + SPARK_WITH_HIVE_VIEWS( + "spark_hive_with_views", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hive", "default-namespace", "default", "cache-enabled", "false")), + SPARK_SESSION_WITH_UNIQUE_LOCATION( + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "parquet-enabled", "true", + "unique-table-location", "true", + "cache-enabled", "false")), + HIVE_WITH_UNIQUE_LOCATION( + "hive_with_unique_location", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "unique-table-location", "true")); + + private final String catalogName; + private final String implementation; + private final Map properties; + + SparkCatalogConfig(String catalogName, String implementation, Map properties) { + this.catalogName = catalogName; + this.implementation = implementation; + this.properties = properties; + } + + public String catalogName() { + return catalogName; + } + + public String implementation() { + return implementation; + } + + public Map properties() { + return properties; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SparkTestHelperBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SparkTestHelperBase.java new file mode 100644 index 000000000000..2754e891a481 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SparkTestHelperBase.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.spark.sql.Row; +import org.apache.spark.unsafe.types.VariantVal; + +public class SparkTestHelperBase { + protected static final Object ANY = new Object(); + + protected List rowsToJava(List rows) { + return rows.stream().map(this::toJava).collect(Collectors.toList()); + } + + private Object[] toJava(Row row) { + return IntStream.range(0, row.size()) + .mapToObj( + pos -> { + if (row.isNullAt(pos)) { + return null; + } + + Object value = row.get(pos); + if (value instanceof Row) { + return toJava((Row) value); + } else if (value instanceof scala.collection.Seq) { + return row.getList(pos); + } else if (value instanceof scala.collection.Map) { + return row.getJavaMap(pos); + } else { + return value; + } + }) + .toArray(Object[]::new); + } + + protected void assertEquals( + String context, List expectedRows, List actualRows) { + assertThat(actualRows) + .as("%s: number of results should match", context) + .hasSameSizeAs(expectedRows); + for (int row = 0; row < expectedRows.size(); row += 1) { + Object[] expected = expectedRows.get(row); + Object[] actual = actualRows.get(row); + assertThat(actual).as("Number of columns should match").hasSameSizeAs(expected); + assertEquals(context + ": row " + (row + 1), expected, actual); + } + } + + protected void assertEquals(String context, Object[] expectedRow, Object[] actualRow) { + assertThat(actualRow).as("Number of columns should match").hasSameSizeAs(expectedRow); + for (int col = 0; col < actualRow.length; col += 1) { + Object expectedValue = expectedRow[col]; + Object actualValue = actualRow[col]; + if (expectedValue != null && expectedValue.getClass().isArray()) { + String newContext = String.format("%s (nested col %d)", context, col + 1); + if (expectedValue instanceof byte[]) { + assertThat(actualValue).as(newContext).isEqualTo(expectedValue); + } else { + assertEquals(newContext, (Object[]) expectedValue, (Object[]) actualValue); + } + } else if (expectedValue instanceof VariantVal && actualValue instanceof VariantVal) { + // Spark VariantVal comparison is based on raw byte[] comparison, which can fail + // if Spark uses trailing null bytes. so, we compare their JSON representation instead. + assertThat(actualValue) + .asString() + .as("%s contents should match (VariantVal JSON)", context) + .isEqualTo((expectedValue).toString()); + } else if (expectedValue != ANY) { + assertThat(actualValue).as("%s contents should match", context).isEqualTo(expectedValue); + } + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SystemFunctionPushDownHelper.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SystemFunctionPushDownHelper.java new file mode 100644 index 000000000000..059325e02a34 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/SystemFunctionPushDownHelper.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.spark.sql.SparkSession; + +public class SystemFunctionPushDownHelper { + public static final Types.StructType STRUCT = + Types.StructType.of( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "ts", Types.TimestampType.withZone()), + Types.NestedField.optional(3, "data", Types.StringType.get())); + + private SystemFunctionPushDownHelper() {} + + public static void createUnpartitionedTable(SparkSession spark, String tableName) { + sql(spark, "CREATE TABLE %s (id BIGINT, ts TIMESTAMP, data STRING) USING iceberg", tableName); + insertRecords(spark, tableName); + } + + public static void createPartitionedTable( + SparkSession spark, String tableName, String partitionCol) { + sql( + spark, + "CREATE TABLE %s (id BIGINT, ts TIMESTAMP, data STRING) USING iceberg PARTITIONED BY (%s)", + tableName, + partitionCol); + insertRecords(spark, tableName); + } + + private static void insertRecords(SparkSession spark, String tableName) { + sql( + spark, + "ALTER TABLE %s SET TBLPROPERTIES('%s' %s)", + tableName, + "read.split.target-size", + "10"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(0, CAST('2017-11-22T09:20:44.294658+00:00' AS TIMESTAMP), 'data-0')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(1, CAST('2017-11-22T07:15:34.582910+00:00' AS TIMESTAMP), 'data-1')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(2, CAST('2017-11-22T06:02:09.243857+00:00' AS TIMESTAMP), 'data-2')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(3, CAST('2017-11-22T03:10:11.134509+00:00' AS TIMESTAMP), 'data-3')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(4, CAST('2017-11-22T00:34:00.184671+00:00' AS TIMESTAMP), 'data-4')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(5, CAST('2018-12-21T22:20:08.935889+00:00' AS TIMESTAMP), 'material-5')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(6, CAST('2018-12-21T21:55:30.589712+00:00' AS TIMESTAMP), 'material-6')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(7, CAST('2018-12-21T17:31:14.532797+00:00' AS TIMESTAMP), 'material-7')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(8, CAST('2018-12-21T15:21:51.237521+00:00' AS TIMESTAMP), 'material-8')"); + sql( + spark, + "INSERT INTO TABLE %s VALUES %s", + tableName, + "(9, CAST('2018-12-21T15:02:15.230570+00:00' AS TIMESTAMP), 'material-9')"); + } + + public static int timestampStrToYearOrdinal(String timestamp) { + return DateTimeUtil.microsToYears(DateTimeUtil.isoTimestamptzToMicros(timestamp)); + } + + public static int timestampStrToMonthOrdinal(String timestamp) { + return DateTimeUtil.microsToMonths(DateTimeUtil.isoTimestamptzToMicros(timestamp)); + } + + public static int timestampStrToDayOrdinal(String timestamp) { + return DateTimeUtil.microsToDays(DateTimeUtil.isoTimestamptzToMicros(timestamp)); + } + + public static int timestampStrToHourOrdinal(String timestamp) { + return DateTimeUtil.microsToHours(DateTimeUtil.isoTimestamptzToMicros(timestamp)); + } + + private static void sql(SparkSession spark, String sqlFormat, Object... args) { + spark.sql(String.format(sqlFormat, args)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestBase.java new file mode 100644 index 000000000000..507d7b313b42 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestBase.java @@ -0,0 +1,298 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.hive.TestHiveMetastore; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.execution.QueryExecution; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanExec; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.QueryExecutionListener; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +public abstract class TestBase extends SparkTestHelperBase { + + protected static TestHiveMetastore metastore = null; + protected static HiveConf hiveConf = null; + protected static SparkSession spark = null; + protected static JavaSparkContext sparkContext = null; + protected static HiveCatalog catalog = null; + // disable Spark UI and use dummy servlet to avoid dependency conflicts with Spark's Jetty version + public static final Map DISABLE_UI = + ImmutableMap.of( + "spark.ui.enabled", + "false", + "spark.metrics.conf.*.sink.servlet.class", + "org.apache.iceberg.spark.DummyMetricsServlet"); + + @BeforeAll + public static void startMetastoreAndSpark() { + TestBase.metastore = new TestHiveMetastore(); + metastore.start(); + TestBase.hiveConf = metastore.hiveConf(); + + TestBase.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") + .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config("spark.ui.enabled", "false") + .config(DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + TestBase.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + + TestBase.catalog = + (HiveCatalog) + CatalogUtil.loadCatalog( + HiveCatalog.class.getName(), "hive", ImmutableMap.of(), hiveConf); + + try { + catalog.createNamespace(Namespace.of("default")); + } catch (AlreadyExistsException ignored) { + // the default namespace already exists. ignore the create error + } + } + + @AfterAll + public static void stopMetastoreAndSpark() throws Exception { + TestBase.catalog = null; + if (metastore != null) { + metastore.stop(); + TestBase.metastore = null; + } + if (spark != null) { + spark.stop(); + TestBase.spark = null; + TestBase.sparkContext = null; + } + } + + protected long waitUntilAfter(long timestampMillis) { + long current = System.currentTimeMillis(); + while (current <= timestampMillis) { + current = System.currentTimeMillis(); + } + return current; + } + + protected List sql(String query, Object... args) { + List rows = spark.sql(String.format(query, args)).collectAsList(); + if (rows.isEmpty()) { + return ImmutableList.of(); + } + + return rowsToJava(rows); + } + + protected Object scalarSql(String query, Object... args) { + List rows = sql(query, args); + assertThat(rows).as("Scalar SQL should return one row").hasSize(1); + Object[] row = Iterables.getOnlyElement(rows); + assertThat(row).as("Scalar SQL should return one value").hasSize(1); + return row[0]; + } + + protected Object[] row(Object... values) { + return values; + } + + protected static String dbPath(String dbName) { + return metastore.getDatabasePath(dbName); + } + + protected void withUnavailableFiles(Iterable> files, Action action) { + Iterable fileLocations = Iterables.transform(files, ContentFile::location); + withUnavailableLocations(fileLocations, action); + } + + private void move(String location, String newLocation) { + Path path = Paths.get(URI.create(location)); + Path tempPath = Paths.get(URI.create(newLocation)); + + try { + Files.move(path, tempPath); + } catch (IOException e) { + throw new UncheckedIOException("Failed to move: " + location, e); + } + } + + protected void withUnavailableLocations(Iterable locations, Action action) { + for (String location : locations) { + move(location, location + "_temp"); + } + + try { + action.invoke(); + } finally { + for (String location : locations) { + move(location + "_temp", location); + } + } + } + + protected void withDefaultTimeZone(String zoneId, Action action) { + TimeZone currentZone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + action.invoke(); + } finally { + TimeZone.setDefault(currentZone); + } + } + + protected void withSQLConf(Map conf, Action action) { + SQLConf sqlConf = SQLConf.get(); + + Map currentConfValues = Maps.newHashMap(); + conf.keySet() + .forEach( + confKey -> { + if (sqlConf.contains(confKey)) { + String currentConfValue = sqlConf.getConfString(confKey); + currentConfValues.put(confKey, currentConfValue); + } + }); + + conf.forEach( + (confKey, confValue) -> { + if (SQLConf.isStaticConfigKey(confKey)) { + throw new RuntimeException("Cannot modify the value of a static config: " + confKey); + } + sqlConf.setConfString(confKey, confValue); + }); + + try { + action.invoke(); + } finally { + conf.forEach( + (confKey, confValue) -> { + if (currentConfValues.containsKey(confKey)) { + sqlConf.setConfString(confKey, currentConfValues.get(confKey)); + } else { + sqlConf.unsetConf(confKey); + } + }); + } + } + + protected Dataset jsonToDF(String schema, String... records) { + Dataset jsonDF = spark.createDataset(ImmutableList.copyOf(records), Encoders.STRING()); + return spark.read().schema(schema).json(jsonDF); + } + + protected void append(String table, String... jsonRecords) { + try { + String schema = spark.table(table).schema().toDDL(); + Dataset df = jsonToDF(schema, jsonRecords); + df.coalesce(1).writeTo(table).append(); + } catch (NoSuchTableException e) { + throw new RuntimeException("Failed to write data", e); + } + } + + protected String tablePropsAsString(Map tableProps) { + StringBuilder stringBuilder = new StringBuilder(); + + for (Map.Entry property : tableProps.entrySet()) { + if (stringBuilder.length() > 0) { + stringBuilder.append(", "); + } + stringBuilder.append(String.format("'%s' '%s'", property.getKey(), property.getValue())); + } + + return stringBuilder.toString(); + } + + protected SparkPlan executeAndKeepPlan(String query, Object... args) { + return executeAndKeepPlan(() -> sql(query, args)); + } + + protected SparkPlan executeAndKeepPlan(Action action) { + AtomicReference executedPlanRef = new AtomicReference<>(); + + QueryExecutionListener listener = + new QueryExecutionListener() { + @Override + public void onSuccess(String funcName, QueryExecution qe, long durationNs) { + executedPlanRef.set(qe.executedPlan()); + } + + @Override + public void onFailure(String funcName, QueryExecution qe, Exception exception) {} + }; + + spark.listenerManager().register(listener); + + action.invoke(); + + try { + spark.sparkContext().listenerBus().waitUntilEmpty(); + } catch (TimeoutException e) { + throw new RuntimeException("Timeout while waiting for processing events", e); + } + + SparkPlan executedPlan = executedPlanRef.get(); + if (executedPlan instanceof AdaptiveSparkPlanExec) { + return ((AdaptiveSparkPlanExec) executedPlan).executedPlan(); + } else { + return executedPlan; + } + } + + @FunctionalInterface + protected interface Action { + void invoke(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java new file mode 100644 index 000000000000..1760143d2cb1 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestBaseWithCatalog.java @@ -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. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.CatalogProperties.CATALOG_IMPL; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_HADOOP; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_HIVE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_REST; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.util.Map; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.SupportsNamespaces; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.hadoop.HadoopCatalog; +import org.apache.iceberg.inmemory.InMemoryCatalog; +import org.apache.iceberg.rest.RESTCatalog; +import org.apache.iceberg.rest.RESTCatalogServer; +import org.apache.iceberg.rest.RESTServerExtension; +import org.apache.iceberg.util.PropertyUtil; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class TestBaseWithCatalog extends TestBase { + protected static File warehouse = null; + + @RegisterExtension + private static final RESTServerExtension REST_SERVER_EXTENSION = + new RESTServerExtension( + Map.of( + RESTCatalogServer.REST_PORT, + RESTServerExtension.FREE_PORT, + // In-memory sqlite database by default is private to the connection that created it. + // If more than 1 jdbc connection backed by in-memory sqlite is created behind one + // JdbcCatalog, then different jdbc connections could provide different views of table + // status even belonging to the same catalog. Reference: + // https://www.sqlite.org/inmemorydb.html + CatalogProperties.CLIENT_POOL_SIZE, + "1", + "include-credentials", + "true", + "gcs.oauth2.token", + "dummyToken")); + + protected static RESTCatalog restCatalog; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties() + }, + }; + } + + @BeforeAll + public static void setUpAll() throws IOException { + TestBaseWithCatalog.warehouse = File.createTempFile("warehouse", null); + assertThat(warehouse.delete()).isTrue(); + restCatalog = REST_SERVER_EXTENSION.client(); + } + + @AfterAll + public static void tearDownAll() throws IOException { + if (warehouse != null && warehouse.exists()) { + Path warehousePath = new Path(warehouse.getAbsolutePath()); + FileSystem fs = warehousePath.getFileSystem(hiveConf); + assertThat(fs.delete(warehousePath, true)).as("Failed to delete " + warehousePath).isTrue(); + } + } + + @TempDir protected java.nio.file.Path temp; + + @Parameter(index = 0) + protected String catalogName; + + @Parameter(index = 1) + protected String implementation; + + @Parameter(index = 2) + protected Map catalogConfig; + + protected Catalog validationCatalog; + protected SupportsNamespaces validationNamespaceCatalog; + protected TableIdentifier tableIdent = TableIdentifier.of(Namespace.of("default"), "table"); + protected String tableName; + + private void configureValidationCatalog() { + if (catalogConfig.containsKey(ICEBERG_CATALOG_TYPE)) { + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + switch (catalogType) { + case ICEBERG_CATALOG_TYPE_HADOOP: + this.validationCatalog = + new HadoopCatalog(spark.sessionState().newHadoopConf(), "file:" + warehouse); + break; + case ICEBERG_CATALOG_TYPE_REST: + this.validationCatalog = restCatalog; + break; + case ICEBERG_CATALOG_TYPE_HIVE: + this.validationCatalog = catalog; + break; + default: + throw new IllegalArgumentException("Unknown catalog type: " + catalogType); + } + } else if (catalogConfig.containsKey(CATALOG_IMPL)) { + switch (catalogConfig.get(CATALOG_IMPL)) { + case "org.apache.iceberg.inmemory.InMemoryCatalog": + this.validationCatalog = new InMemoryCatalog(); + break; + default: + throw new IllegalArgumentException("Unknown catalog impl"); + } + } + this.validationNamespaceCatalog = (SupportsNamespaces) validationCatalog; + } + + @BeforeEach + public void before() { + configureValidationCatalog(); + + spark.conf().set("spark.sql.catalog." + catalogName, implementation); + catalogConfig.forEach( + (key, value) -> spark.conf().set("spark.sql.catalog." + catalogName + "." + key, value)); + + if ("hadoop".equalsIgnoreCase(catalogConfig.get("type"))) { + spark.conf().set("spark.sql.catalog." + catalogName + ".warehouse", "file:" + warehouse); + } + + this.tableName = + (catalogName.equals("spark_catalog") ? "" : catalogName + ".") + "default.table"; + + sql("CREATE NAMESPACE IF NOT EXISTS default"); + } + + protected String tableName(String name) { + return (catalogName.equals("spark_catalog") ? "" : catalogName + ".") + "default." + name; + } + + protected String commitTarget() { + return tableName; + } + + protected String selectTarget() { + return tableName; + } + + protected boolean cachingCatalogEnabled() { + return PropertyUtil.propertyAsBoolean( + catalogConfig, CatalogProperties.CACHE_ENABLED, CatalogProperties.CACHE_ENABLED_DEFAULT); + } + + protected void configurePlanningMode(PlanningMode planningMode) { + configurePlanningMode(tableName, planningMode); + } + + protected void configurePlanningMode(String table, PlanningMode planningMode) { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s', '%s' '%s')", + table, + TableProperties.DATA_PLANNING_MODE, + planningMode.modeName(), + TableProperties.DELETE_PLANNING_MODE, + planningMode.modeName()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestChangelogIterator.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestChangelogIterator.java new file mode 100644 index 000000000000..bd9832f7d674 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestChangelogIterator.java @@ -0,0 +1,359 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import org.apache.iceberg.ChangelogOperation; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.Test; + +public class TestChangelogIterator extends SparkTestHelperBase { + private static final String DELETE = ChangelogOperation.DELETE.name(); + private static final String INSERT = ChangelogOperation.INSERT.name(); + private static final String UPDATE_BEFORE = ChangelogOperation.UPDATE_BEFORE.name(); + private static final String UPDATE_AFTER = ChangelogOperation.UPDATE_AFTER.name(); + + private static final StructType SCHEMA = + new StructType( + new StructField[] { + new StructField("id", DataTypes.IntegerType, false, Metadata.empty()), + new StructField("name", DataTypes.StringType, false, Metadata.empty()), + new StructField("data", DataTypes.StringType, true, Metadata.empty()), + new StructField( + MetadataColumns.CHANGE_TYPE.name(), DataTypes.StringType, false, Metadata.empty()), + new StructField( + MetadataColumns.CHANGE_ORDINAL.name(), + DataTypes.IntegerType, + false, + Metadata.empty()), + new StructField( + MetadataColumns.COMMIT_SNAPSHOT_ID.name(), + DataTypes.LongType, + false, + Metadata.empty()) + }); + private static final String[] IDENTIFIER_FIELDS = new String[] {"id", "name"}; + + private enum RowType { + DELETED, + INSERTED, + CARRY_OVER, + UPDATED + } + + @Test + public void testIterator() { + List permutations = Lists.newArrayList(); + // generate 24 permutations + permute( + Arrays.asList(RowType.DELETED, RowType.INSERTED, RowType.CARRY_OVER, RowType.UPDATED), + 0, + permutations); + assertThat(permutations).hasSize(24); + + for (Object[] permutation : permutations) { + validate(permutation); + } + } + + private void validate(Object[] permutation) { + List rows = Lists.newArrayList(); + List expectedRows = Lists.newArrayList(); + for (int i = 0; i < permutation.length; i++) { + rows.addAll(toOriginalRows((RowType) permutation[i], i)); + expectedRows.addAll(toExpectedRows((RowType) permutation[i], i)); + } + + Iterator iterator = + ChangelogIterator.computeUpdates(rows.iterator(), SCHEMA, IDENTIFIER_FIELDS); + List result = Lists.newArrayList(iterator); + assertEquals("Rows should match", expectedRows, rowsToJava(result)); + } + + private List toOriginalRows(RowType rowType, int index) { + switch (rowType) { + case DELETED: + return Lists.newArrayList( + new GenericRowWithSchema(new Object[] {index, "b", "data", DELETE, 0, 0}, null)); + case INSERTED: + return Lists.newArrayList( + new GenericRowWithSchema(new Object[] {index, "c", "data", INSERT, 0, 0}, null)); + case CARRY_OVER: + return Lists.newArrayList( + new GenericRowWithSchema(new Object[] {index, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {index, "d", "data", INSERT, 0, 0}, null)); + case UPDATED: + return Lists.newArrayList( + new GenericRowWithSchema(new Object[] {index, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {index, "a", "new_data", INSERT, 0, 0}, null)); + default: + throw new IllegalArgumentException("Unknown row type: " + rowType); + } + } + + private List toExpectedRows(RowType rowType, int order) { + switch (rowType) { + case DELETED: + List rows = Lists.newArrayList(); + rows.add(new Object[] {order, "b", "data", DELETE, 0, 0}); + return rows; + case INSERTED: + List insertedRows = Lists.newArrayList(); + insertedRows.add(new Object[] {order, "c", "data", INSERT, 0, 0}); + return insertedRows; + case CARRY_OVER: + return Lists.newArrayList(); + case UPDATED: + return Lists.newArrayList( + new Object[] {order, "a", "data", UPDATE_BEFORE, 0, 0}, + new Object[] {order, "a", "new_data", UPDATE_AFTER, 0, 0}); + default: + throw new IllegalArgumentException("Unknown row type: " + rowType); + } + } + + private void permute(List arr, int start, List pm) { + for (int i = start; i < arr.size(); i++) { + Collections.swap(arr, i, start); + permute(arr, start + 1, pm); + Collections.swap(arr, start, i); + } + if (start == arr.size() - 1) { + pm.add(arr.toArray()); + } + } + + @Test + public void testRowsWithNullValue() { + final List rowsWithNull = + Lists.newArrayList( + new GenericRowWithSchema(new Object[] {2, null, null, DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {3, null, null, INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {4, null, null, DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {4, null, null, INSERT, 0, 0}, null), + // mixed null and non-null value in non-identifier columns + new GenericRowWithSchema(new Object[] {5, null, null, DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {5, null, "data", INSERT, 0, 0}, null), + // mixed null and non-null value in identifier columns + new GenericRowWithSchema(new Object[] {6, null, null, DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {6, "name", null, INSERT, 0, 0}, null)); + + Iterator iterator = + ChangelogIterator.computeUpdates(rowsWithNull.iterator(), SCHEMA, IDENTIFIER_FIELDS); + List result = Lists.newArrayList(iterator); + + assertEquals( + "Rows should match", + Lists.newArrayList( + new Object[] {2, null, null, DELETE, 0, 0}, + new Object[] {3, null, null, INSERT, 0, 0}, + new Object[] {5, null, null, UPDATE_BEFORE, 0, 0}, + new Object[] {5, null, "data", UPDATE_AFTER, 0, 0}, + new Object[] {6, null, null, DELETE, 0, 0}, + new Object[] {6, "name", null, INSERT, 0, 0}), + rowsToJava(result)); + } + + @Test + public void testUpdatedRowsWithDuplication() { + List rowsWithDuplication = + Lists.newArrayList( + // two rows with same identifier fields(id, name) + new GenericRowWithSchema(new Object[] {1, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "a", "new_data", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "a", "new_data", INSERT, 0, 0}, null)); + + Iterator iterator = + ChangelogIterator.computeUpdates(rowsWithDuplication.iterator(), SCHEMA, IDENTIFIER_FIELDS); + + assertThatThrownBy(() -> Lists.newArrayList(iterator)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Cannot compute updates because there are multiple rows with the same identifier fields([id,name]). Please make sure the rows are unique."); + + // still allow extra insert rows + rowsWithDuplication = + Lists.newArrayList( + new GenericRowWithSchema(new Object[] {1, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "a", "new_data1", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "a", "new_data2", INSERT, 0, 0}, null)); + + Iterator iterator1 = + ChangelogIterator.computeUpdates(rowsWithDuplication.iterator(), SCHEMA, IDENTIFIER_FIELDS); + + assertEquals( + "Rows should match.", + Lists.newArrayList( + new Object[] {1, "a", "data", UPDATE_BEFORE, 0, 0}, + new Object[] {1, "a", "new_data1", UPDATE_AFTER, 0, 0}, + new Object[] {1, "a", "new_data2", INSERT, 0, 0}), + rowsToJava(Lists.newArrayList(iterator1))); + } + + @Test + public void testCarryRowsRemoveWithDuplicates() { + // assume rows are sorted by id and change type + List rowsWithDuplication = + Lists.newArrayList( + // keep all delete rows for id 0 and id 1 since there is no insert row for them + new GenericRowWithSchema(new Object[] {0, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {0, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {0, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "a", "old_data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "a", "old_data", DELETE, 0, 0}, null), + // the same number of delete and insert rows for id 2 + new GenericRowWithSchema(new Object[] {2, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {2, "a", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {2, "a", "data", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {2, "a", "data", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {3, "a", "new_data", INSERT, 0, 0}, null)); + + List expectedRows = + Lists.newArrayList( + new Object[] {0, "a", "data", DELETE, 0, 0}, + new Object[] {0, "a", "data", DELETE, 0, 0}, + new Object[] {0, "a", "data", DELETE, 0, 0}, + new Object[] {1, "a", "old_data", DELETE, 0, 0}, + new Object[] {1, "a", "old_data", DELETE, 0, 0}, + new Object[] {3, "a", "new_data", INSERT, 0, 0}); + + validateIterators(rowsWithDuplication, expectedRows); + } + + @Test + public void testCarryRowsRemoveLessInsertRows() { + // less insert rows than delete rows + List rowsWithDuplication = + Lists.newArrayList( + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {2, "d", "data", INSERT, 0, 0}, null)); + + List expectedRows = + Lists.newArrayList( + new Object[] {1, "d", "data", DELETE, 0, 0}, + new Object[] {2, "d", "data", INSERT, 0, 0}); + + validateIterators(rowsWithDuplication, expectedRows); + } + + @Test + public void testCarryRowsRemoveMoreInsertRows() { + List rowsWithDuplication = + Lists.newArrayList( + new GenericRowWithSchema(new Object[] {0, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null), + // more insert rows than delete rows, should keep extra insert rows + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 0, 0}, null)); + + List expectedRows = + Lists.newArrayList( + new Object[] {0, "d", "data", DELETE, 0, 0}, + new Object[] {1, "d", "data", INSERT, 0, 0}); + + validateIterators(rowsWithDuplication, expectedRows); + } + + @Test + public void testCarryRowsRemoveNoInsertRows() { + // no insert row + List rowsWithDuplication = + Lists.newArrayList( + // next two rows are identical + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null)); + + List expectedRows = + Lists.newArrayList( + new Object[] {1, "d", "data", DELETE, 0, 0}, + new Object[] {1, "d", "data", DELETE, 0, 0}); + + validateIterators(rowsWithDuplication, expectedRows); + } + + private void validateIterators(List rowsWithDuplication, List expectedRows) { + Iterator iterator = + ChangelogIterator.removeCarryovers(rowsWithDuplication.iterator(), SCHEMA); + List result = Lists.newArrayList(iterator); + + assertEquals("Rows should match.", expectedRows, rowsToJava(result)); + + iterator = ChangelogIterator.removeNetCarryovers(rowsWithDuplication.iterator(), SCHEMA); + result = Lists.newArrayList(iterator); + + assertEquals("Rows should match.", expectedRows, rowsToJava(result)); + } + + @Test + public void testRemoveNetCarryovers() { + List rowsWithDuplication = + Lists.newArrayList( + // this row are different from other rows, it is a net change, should be kept + new GenericRowWithSchema(new Object[] {0, "d", "data", DELETE, 0, 0}, null), + // a pair of delete and insert rows, should be removed + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 0, 0}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 0, 0}, null), + // 2 delete rows and 2 insert rows, should be removed + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 1, 1}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 1, 1}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 1, 1}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 1, 1}, null), + // a pair of insert and delete rows across snapshots, should be removed + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 2, 2}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", DELETE, 3, 3}, null), + // extra insert rows, they are net changes, should be kept + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 4, 4}, null), + new GenericRowWithSchema(new Object[] {1, "d", "data", INSERT, 4, 4}, null), + // different key, net changes, should be kept + new GenericRowWithSchema(new Object[] {2, "d", "data", DELETE, 4, 4}, null)); + + List expectedRows = + Lists.newArrayList( + new Object[] {0, "d", "data", DELETE, 0, 0}, + new Object[] {1, "d", "data", INSERT, 4, 4}, + new Object[] {1, "d", "data", INSERT, 4, 4}, + new Object[] {2, "d", "data", DELETE, 4, 4}); + + Iterator iterator = + ChangelogIterator.removeNetCarryovers(rowsWithDuplication.iterator(), SCHEMA); + List result = Lists.newArrayList(iterator); + + assertEquals("Rows should match.", expectedRows, rowsToJava(result)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestFileRewriteCoordinator.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestFileRewriteCoordinator.java new file mode 100644 index 000000000000..664c2019151f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestFileRewriteCoordinator.java @@ -0,0 +1,262 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.iceberg.util.DataFileSet; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestFileRewriteCoordinator extends CatalogTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testBinPackRewrite() throws NoSuchTableException, IOException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + + Dataset df = newDF(1000); + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should produce 4 snapshots").hasSize(4); + + Dataset fileDF = + spark.read().format("iceberg").load(tableName(tableIdent.name() + ".files")); + List fileSizes = fileDF.select("file_size_in_bytes").as(Encoders.LONG()).collectAsList(); + long avgFileSize = fileSizes.stream().mapToLong(i -> i).sum() / fileSizes.size(); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + String groupId = UUID.randomUUID().toString(); + + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + taskSetManager.stageTasks(table, groupId, Lists.newArrayList(fileScanTasks)); + SparkTableCache.get().add(groupId, table); + + // read and pack original 4 files into 2 splits + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, Long.toString(avgFileSize * 2)) + .option(SparkReadOptions.FILE_OPEN_COST, "0") + .load(groupId); + + // write the packed data into new files where each split becomes a new file + scanDF.write().format("iceberg").mode("append").save(groupId); + + // commit the rewrite + FileRewriteCoordinator rewriteCoordinator = FileRewriteCoordinator.get(); + Set rewrittenFiles = + taskSetManager.fetchTasks(table, groupId).stream() + .map(t -> t.asFileScanTask().file()) + .collect(Collectors.toCollection(DataFileSet::create)); + Set addedFiles = rewriteCoordinator.fetchNewFiles(table, groupId); + table.newRewrite().rewriteFiles(rewrittenFiles, addedFiles).commit(); + } + + table.refresh(); + + Map summary = table.currentSnapshot().summary(); + assertThat(summary) + .containsEntry("deleted-data-files", "4") + .containsEntry("added-data-files", "2"); + + Object rowCount = scalarSql("SELECT count(*) FROM %s", tableName); + assertThat(rowCount).as("Row count must match").isEqualTo(4000L); + } + + @TestTemplate + public void testSortRewrite() throws NoSuchTableException, IOException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + + Dataset df = newDF(1000); + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should produce 4 snapshots").hasSize(4); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + String groupId = UUID.randomUUID().toString(); + + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + taskSetManager.stageTasks(table, groupId, Lists.newArrayList(fileScanTasks)); + SparkTableCache.get().add(groupId, table); + + // read original 4 files as 4 splits + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, "134217728") + .option(SparkReadOptions.FILE_OPEN_COST, "134217728") + .load(groupId); + + // make sure we disable AQE and set the number of shuffle partitions as the target num files + ImmutableMap sqlConf = + ImmutableMap.of( + "spark.sql.shuffle.partitions", "2", + "spark.sql.adaptive.enabled", "false"); + + withSQLConf( + sqlConf, + () -> { + // write new files with sorted records + scanDF.sort("id").write().format("iceberg").mode("append").save(groupId); + }); + + // commit the rewrite + FileRewriteCoordinator rewriteCoordinator = FileRewriteCoordinator.get(); + Set rewrittenFiles = + taskSetManager.fetchTasks(table, groupId).stream() + .map(t -> t.asFileScanTask().file()) + .collect(Collectors.toCollection(DataFileSet::create)); + Set addedFiles = rewriteCoordinator.fetchNewFiles(table, groupId); + table.newRewrite().rewriteFiles(rewrittenFiles, addedFiles).commit(); + } + + table.refresh(); + + Map summary = table.currentSnapshot().summary(); + assertThat(summary) + .containsEntry("deleted-data-files", "4") + .containsEntry("added-data-files", "2"); + + Object rowCount = scalarSql("SELECT count(*) FROM %s", tableName); + assertThat(rowCount).as("Row count must match").isEqualTo(4000L); + } + + @TestTemplate + public void testCommitMultipleRewrites() throws NoSuchTableException, IOException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + + Dataset df = newDF(1000); + + // add first two files + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + + String firstGroupId = UUID.randomUUID().toString(); + long firstFileSetSnapshotId = table.currentSnapshot().snapshotId(); + + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + // stage first 2 files for compaction + taskSetManager.stageTasks(table, firstGroupId, Lists.newArrayList(tasks)); + SparkTableCache.get().add(firstGroupId, table); + } + + // add two more files + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + + table.refresh(); + + String secondGroupId = UUID.randomUUID().toString(); + + try (CloseableIterable tasks = + table.newScan().appendsAfter(firstFileSetSnapshotId).planFiles()) { + // stage 2 more files for compaction + taskSetManager.stageTasks(table, secondGroupId, Lists.newArrayList(tasks)); + SparkTableCache.get().add(secondGroupId, table); + } + + ImmutableSet groupIds = ImmutableSet.of(firstGroupId, secondGroupId); + + for (String groupId : groupIds) { + // read and pack 2 files into 1 split + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, Long.MAX_VALUE) + .load(groupId); + + // write the combined data as one file + scanDF.write().format("iceberg").mode("append").save(groupId); + } + + // commit both rewrites at the same time + FileRewriteCoordinator rewriteCoordinator = FileRewriteCoordinator.get(); + Set rewrittenFiles = + groupIds.stream() + .flatMap(groupId -> taskSetManager.fetchTasks(table, groupId).stream()) + .map(t -> t.asFileScanTask().file()) + .collect(Collectors.toSet()); + Set addedFiles = + groupIds.stream() + .flatMap(groupId -> rewriteCoordinator.fetchNewFiles(table, groupId).stream()) + .collect(Collectors.toCollection(DataFileSet::create)); + table.newRewrite().rewriteFiles(rewrittenFiles, addedFiles).commit(); + + table.refresh(); + + assertThat(table.snapshots()).as("Should produce 5 snapshots").hasSize(5); + + Map summary = table.currentSnapshot().summary(); + assertThat(summary) + .containsEntry("deleted-data-files", "4") + .containsEntry("added-data-files", "2"); + + Object rowCount = scalarSql("SELECT count(*) FROM %s", tableName); + assertThat(rowCount).as("Row count must match").isEqualTo(4000L); + } + + private Dataset newDF(int numRecords) { + List data = Lists.newArrayListWithExpectedSize(numRecords); + for (int index = 0; index < numRecords; index++) { + data.add(new SimpleRecord(index, Integer.toString(index))); + } + return spark.createDataFrame(data, SimpleRecord.class); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestFunctionCatalog.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestFunctionCatalog.java new file mode 100644 index 000000000000..5f160bcf10f8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestFunctionCatalog.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.IcebergBuild; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.functions.IcebergVersionFunction; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.catalyst.analysis.NoSuchFunctionException; +import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException; +import org.apache.spark.sql.connector.catalog.FunctionCatalog; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestFunctionCatalog extends TestBaseWithCatalog { + private static final String[] EMPTY_NAMESPACE = new String[] {}; + private static final String[] SYSTEM_NAMESPACE = new String[] {"system"}; + private static final String[] DEFAULT_NAMESPACE = new String[] {"default"}; + private static final String[] DB_NAMESPACE = new String[] {"db"}; + private FunctionCatalog asFunctionCatalog; + + @BeforeEach + public void before() { + super.before(); + this.asFunctionCatalog = castToFunctionCatalog(catalogName); + sql("CREATE NAMESPACE IF NOT EXISTS %s", catalogName + ".default"); + } + + @AfterEach + public void dropDefaultNamespace() { + sql("DROP NAMESPACE IF EXISTS %s", catalogName + ".default"); + } + + @TestTemplate + public void testListFunctionsViaCatalog() throws NoSuchNamespaceException { + assertThat(asFunctionCatalog.listFunctions(EMPTY_NAMESPACE)) + .anyMatch(func -> "iceberg_version".equals(func.name())); + + assertThat(asFunctionCatalog.listFunctions(SYSTEM_NAMESPACE)) + .anyMatch(func -> "iceberg_version".equals(func.name())); + + assertThat(asFunctionCatalog.listFunctions(DEFAULT_NAMESPACE)) + .as("Listing functions in an existing namespace that's not system should not throw") + .isEqualTo(new Identifier[0]); + + assertThatThrownBy(() -> asFunctionCatalog.listFunctions(DB_NAMESPACE)) + .isInstanceOf(NoSuchNamespaceException.class) + .hasMessageStartingWith("[SCHEMA_NOT_FOUND] The schema `db` cannot be found."); + } + + @TestTemplate + public void testLoadFunctions() throws NoSuchFunctionException { + for (String[] namespace : ImmutableList.of(EMPTY_NAMESPACE, SYSTEM_NAMESPACE)) { + Identifier identifier = Identifier.of(namespace, "iceberg_version"); + UnboundFunction func = asFunctionCatalog.loadFunction(identifier); + + assertThat(func) + .isNotNull() + .isInstanceOf(UnboundFunction.class) + .isExactlyInstanceOf(IcebergVersionFunction.class); + } + + assertThatThrownBy( + () -> + asFunctionCatalog.loadFunction(Identifier.of(DEFAULT_NAMESPACE, "iceberg_version"))) + .isInstanceOf(NoSuchFunctionException.class) + .hasMessageStartingWith( + String.format( + "[ROUTINE_NOT_FOUND] The routine default.iceberg_version cannot be found")); + + Identifier undefinedFunction = Identifier.of(SYSTEM_NAMESPACE, "undefined_function"); + assertThatThrownBy(() -> asFunctionCatalog.loadFunction(undefinedFunction)) + .isInstanceOf(NoSuchFunctionException.class) + .hasMessageStartingWith( + String.format( + "[ROUTINE_NOT_FOUND] The routine system.undefined_function cannot be found")); + + assertThatThrownBy(() -> sql("SELECT undefined_function(1, 2)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "[UNRESOLVED_ROUTINE] Cannot resolve routine `undefined_function` on search path"); + } + + @TestTemplate + public void testCallingFunctionInSQLEndToEnd() { + String buildVersion = IcebergBuild.version(); + + assertThat(scalarSql("SELECT %s.system.iceberg_version()", catalogName)) + .as( + "Should be able to use the Iceberg version function from the fully qualified system namespace") + .isEqualTo(buildVersion); + + assertThat(scalarSql("SELECT %s.iceberg_version()", catalogName)) + .as( + "Should be able to use the Iceberg version function when fully qualified without specifying a namespace") + .isEqualTo(buildVersion); + + sql("USE %s", catalogName); + + assertThat(scalarSql("SELECT system.iceberg_version()")) + .as( + "Should be able to call iceberg_version from system namespace without fully qualified name when using Iceberg catalog") + .isEqualTo(buildVersion); + + assertThat(scalarSql("SELECT iceberg_version()")) + .as( + "Should be able to call iceberg_version from empty namespace without fully qualified name when using Iceberg catalog") + .isEqualTo(buildVersion); + } + + private FunctionCatalog castToFunctionCatalog(String name) { + return (FunctionCatalog) spark.sessionState().catalogManager().catalog(name); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSpark3Util.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSpark3Util.java new file mode 100644 index 000000000000..e4e66abfefa0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSpark3Util.java @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.NullOrder.NULLS_FIRST; +import static org.apache.iceberg.NullOrder.NULLS_LAST; +import static org.apache.iceberg.expressions.Expressions.and; +import static org.apache.iceberg.expressions.Expressions.bucket; +import static org.apache.iceberg.expressions.Expressions.day; +import static org.apache.iceberg.expressions.Expressions.equal; +import static org.apache.iceberg.expressions.Expressions.greaterThan; +import static org.apache.iceberg.expressions.Expressions.greaterThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.hour; +import static org.apache.iceberg.expressions.Expressions.in; +import static org.apache.iceberg.expressions.Expressions.lessThan; +import static org.apache.iceberg.expressions.Expressions.lessThanOrEqual; +import static org.apache.iceberg.expressions.Expressions.month; +import static org.apache.iceberg.expressions.Expressions.notIn; +import static org.apache.iceberg.expressions.Expressions.truncate; +import static org.apache.iceberg.expressions.Expressions.year; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.CachingCatalog; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.SortOrderParser; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +public class TestSpark3Util extends TestBase { + @Test + public void testDescribeSortOrder() { + Schema schema = + new Schema( + required(1, "data", Types.StringType.get()), + required(2, "time", Types.TimestampType.withoutZone())); + + assertThat(Spark3Util.describe(buildSortOrder("Identity", schema, 1))) + .as("Sort order isn't correct.") + .isEqualTo("data DESC NULLS FIRST"); + + assertThat(Spark3Util.describe(buildSortOrder("bucket[1]", schema, 1))) + .as("Sort order isn't correct.") + .isEqualTo("bucket(1, data) DESC NULLS FIRST"); + + assertThat(Spark3Util.describe(buildSortOrder("truncate[3]", schema, 1))) + .as("Sort order isn't correct.") + .isEqualTo("truncate(data, 3) DESC NULLS FIRST"); + + assertThat(Spark3Util.describe(buildSortOrder("year", schema, 2))) + .as("Sort order isn't correct.") + .isEqualTo("years(time) DESC NULLS FIRST"); + + assertThat(Spark3Util.describe(buildSortOrder("month", schema, 2))) + .as("Sort order isn't correct.") + .isEqualTo("months(time) DESC NULLS FIRST"); + + assertThat(Spark3Util.describe(buildSortOrder("day", schema, 2))) + .as("Sort order isn't correct.") + .isEqualTo("days(time) DESC NULLS FIRST"); + + assertThat(Spark3Util.describe(buildSortOrder("hour", schema, 2))) + .as("Sort order isn't correct.") + .isEqualTo("hours(time) DESC NULLS FIRST"); + + assertThat(Spark3Util.describe(buildSortOrder("unknown", schema, 1))) + .as("Sort order isn't correct.") + .isEqualTo("unknown(data) DESC NULLS FIRST"); + + // multiple sort orders + SortOrder multiOrder = + SortOrder.builderFor(schema).asc("time", NULLS_FIRST).asc("data", NULLS_LAST).build(); + assertThat(Spark3Util.describe(multiOrder)) + .as("Sort order isn't correct.") + .isEqualTo("time ASC NULLS FIRST, data ASC NULLS LAST"); + } + + @Test + public void testDescribeSchema() { + Schema schema = + new Schema( + required(1, "data", Types.ListType.ofRequired(2, Types.StringType.get())), + optional( + 3, + "pairs", + Types.MapType.ofOptional(4, 5, Types.StringType.get(), Types.LongType.get())), + required(6, "time", Types.TimestampType.withoutZone()), + required(7, "v", Types.VariantType.get())); + + assertThat(Spark3Util.describe(schema)) + .as("Schema description isn't correct.") + .isEqualTo( + "struct not null,pairs: map,time: timestamp not null,v: variant not null>"); + } + + @Test + public void testLoadIcebergTable() throws Exception { + spark.conf().set("spark.sql.catalog.hive", SparkCatalog.class.getName()); + spark.conf().set("spark.sql.catalog.hive.type", "hive"); + spark.conf().set("spark.sql.catalog.hive.default-namespace", "default"); + + String tableFullName = "hive.default.tbl"; + sql("CREATE TABLE %s (c1 bigint, c2 string, c3 string) USING iceberg", tableFullName); + + Table table = Spark3Util.loadIcebergTable(spark, tableFullName); + assertThat(table.name()).isEqualTo(tableFullName); + } + + @Test + public void testLoadIcebergCatalog() throws Exception { + spark.conf().set("spark.sql.catalog.test_cat", SparkCatalog.class.getName()); + spark.conf().set("spark.sql.catalog.test_cat.type", "hive"); + Catalog catalog = Spark3Util.loadIcebergCatalog(spark, "test_cat"); + assertThat(catalog) + .as("Should retrieve underlying catalog class") + .isInstanceOf(CachingCatalog.class); + } + + @Test + public void testDescribeExpression() { + Expression refExpression = equal("id", 1); + assertThat(Spark3Util.describe(refExpression)).isEqualTo("id = 1"); + + Expression yearExpression = greaterThan(year("ts"), 10); + assertThat(Spark3Util.describe(yearExpression)).isEqualTo("year(ts) > 10"); + + Expression monthExpression = greaterThanOrEqual(month("ts"), 10); + assertThat(Spark3Util.describe(monthExpression)).isEqualTo("month(ts) >= 10"); + + Expression dayExpression = lessThan(day("ts"), 10); + assertThat(Spark3Util.describe(dayExpression)).isEqualTo("day(ts) < 10"); + + Expression hourExpression = lessThanOrEqual(hour("ts"), 10); + assertThat(Spark3Util.describe(hourExpression)).isEqualTo("hour(ts) <= 10"); + + Expression bucketExpression = in(bucket("id", 5), 3); + assertThat(Spark3Util.describe(bucketExpression)).isEqualTo("bucket[5](id) IN (3)"); + + Expression truncateExpression = notIn(truncate("name", 3), "abc"); + assertThat(Spark3Util.describe(truncateExpression)) + .isEqualTo("truncate[3](name) NOT IN ('abc')"); + + Expression andExpression = and(refExpression, yearExpression); + assertThat(Spark3Util.describe(andExpression)).isEqualTo("(id = 1 AND year(ts) > 10)"); + } + + private SortOrder buildSortOrder(String transform, Schema schema, int sourceId) { + String jsonString = + "{\n" + + " \"order-id\" : 10,\n" + + " \"fields\" : [ {\n" + + " \"transform\" : \"" + + transform + + "\",\n" + + " \"source-id\" : " + + sourceId + + ",\n" + + " \"direction\" : \"desc\",\n" + + " \"null-order\" : \"nulls-first\"\n" + + " } ]\n" + + "}"; + + return SortOrderParser.fromJson(schema, jsonString); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkCatalogOperations.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkCatalogOperations.java new file mode 100644 index 000000000000..c71fc5954cc8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkCatalogOperations.java @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.atIndex; + +import java.util.concurrent.ThreadLocalRandom; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.connector.catalog.Column; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableChange; +import org.apache.spark.sql.types.DataTypes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkCatalogOperations extends CatalogTestBase { + private static final boolean USE_NULLABLE_QUERY_SCHEMA = + ThreadLocalRandom.current().nextBoolean(); + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "use-nullable-query-schema", Boolean.toString(USE_NULLABLE_QUERY_SCHEMA)) + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + ImmutableMap.of( + "type", + "hadoop", + "cache-enabled", + "false", + "use-nullable-query-schema", + Boolean.toString(USE_NULLABLE_QUERY_SCHEMA)) + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + ImmutableMap.of( + "type", + "hive", + "default-namespace", + "default", + "parquet-enabled", + "true", + "cache-enabled", + "false", // Spark will delete tables using v1, leaving the cache out of sync + "use-nullable-query-schema", + Boolean.toString(USE_NULLABLE_QUERY_SCHEMA)), + } + }; + } + + @BeforeEach + public void createTable() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testAlterTable() throws NoSuchTableException { + BaseCatalog catalog = (BaseCatalog) spark.sessionState().catalogManager().catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + + String fieldName = "location"; + String propsKey = "note"; + String propsValue = "jazz"; + Table table = + catalog.alterTable( + identifier, + TableChange.addColumn(new String[] {fieldName}, DataTypes.StringType, true), + TableChange.setProperty(propsKey, propsValue)); + + assertThat(table).as("Should return updated table").isNotNull(); + + Column expectedField = Column.create(fieldName, DataTypes.StringType, true); + assertThat(table.columns()) + .as("Adding a column to a table should return the updated table with the new column") + .contains(expectedField, atIndex(2)); + + assertThat(table.properties()) + .as( + "Adding a property to a table should return the updated table with the new property with the new correct value") + .containsEntry(propsKey, propsValue); + } + + @TestTemplate + public void testInvalidateTable() { + // load table to CachingCatalog + sql("SELECT count(1) FROM %s", tableName); + + // recreate table from another catalog or program + Catalog anotherCatalog = validationCatalog; + Schema schema = anotherCatalog.loadTable(tableIdent).schema(); + anotherCatalog.dropTable(tableIdent); + anotherCatalog.createTable(tableIdent, schema); + + // invalidate and reload table + sql("REFRESH TABLE %s", tableName); + sql("SELECT count(1) FROM %s", tableName); + } + + @TestTemplate + public void testCTASUseNullableQuerySchema() { + sql("INSERT INTO %s VALUES(1, 'abc'), (2, null)", tableName); + + String ctasTableName = tableName("ctas_table"); + + sql("CREATE TABLE %s USING iceberg AS SELECT * FROM %s", ctasTableName, tableName); + + org.apache.iceberg.Table ctasTable = + validationCatalog.loadTable(TableIdentifier.parse("default.ctas_table")); + + Schema expectedSchema = + new Schema( + USE_NULLABLE_QUERY_SCHEMA + ? Types.NestedField.optional(1, "id", Types.LongType.get()) + : Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(ctasTable.schema().asStruct()) + .as("Should have expected schema") + .isEqualTo(expectedSchema.asStruct()); + + sql("DROP TABLE IF EXISTS %s", ctasTableName); + } + + @TestTemplate + public void testRTASUseNullableQuerySchema() { + sql("INSERT INTO %s VALUES(1, 'abc'), (2, null)", tableName); + + String rtasTableName = tableName("rtas_table"); + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", rtasTableName); + + sql("REPLACE TABLE %s USING iceberg AS SELECT * FROM %s", rtasTableName, tableName); + + org.apache.iceberg.Table rtasTable = + validationCatalog.loadTable(TableIdentifier.parse("default.rtas_table")); + + Schema expectedSchema = + new Schema( + USE_NULLABLE_QUERY_SCHEMA + ? Types.NestedField.optional(1, "id", Types.LongType.get()) + : Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(rtasTable.schema().asStruct()) + .as("Should have expected schema") + .isEqualTo(expectedSchema.asStruct()); + + assertEquals( + "Should have rows matching the source table", + sql("SELECT * FROM %s ORDER BY id", tableName), + sql("SELECT * FROM %s ORDER BY id", rtasTableName)); + + sql("DROP TABLE IF EXISTS %s", rtasTableName); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkCompressionUtil.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkCompressionUtil.java new file mode 100644 index 000000000000..aa329efbbad5 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkCompressionUtil.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.FileFormat.AVRO; +import static org.apache.iceberg.FileFormat.METADATA; +import static org.apache.iceberg.FileFormat.ORC; +import static org.apache.iceberg.FileFormat.PARQUET; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.iceberg.FileFormat; +import org.apache.spark.SparkConf; +import org.apache.spark.SparkContext; +import org.apache.spark.internal.config.package$; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestSparkCompressionUtil { + + private SparkSession spark; + private SparkConf sparkConf; + + @BeforeEach + public void initSpark() { + this.spark = mock(SparkSession.class); + this.sparkConf = mock(SparkConf.class); + + SparkContext sparkContext = mock(SparkContext.class); + + when(spark.sparkContext()).thenReturn(sparkContext); + when(sparkContext.conf()).thenReturn(sparkConf); + } + + @Test + public void testParquetCompressionRatios() { + configureShuffle("lz4", true); + + double ratio1 = shuffleCompressionRatio(PARQUET, "zstd"); + assertThat(ratio1).isEqualTo(3.0); + + double ratio2 = shuffleCompressionRatio(PARQUET, "gzip"); + assertThat(ratio2).isEqualTo(3.0); + + double ratio3 = shuffleCompressionRatio(PARQUET, "snappy"); + assertThat(ratio3).isEqualTo(2.0); + } + + @Test + public void testOrcCompressionRatios() { + configureShuffle("lz4", true); + + double ratio1 = shuffleCompressionRatio(ORC, "zlib"); + assertThat(ratio1).isEqualTo(3.0); + + double ratio2 = shuffleCompressionRatio(ORC, "lz4"); + assertThat(ratio2).isEqualTo(2.0); + } + + @Test + public void testAvroCompressionRatios() { + configureShuffle("lz4", true); + + double ratio1 = shuffleCompressionRatio(AVRO, "gzip"); + assertThat(ratio1).isEqualTo(1.5); + + double ratio2 = shuffleCompressionRatio(AVRO, "zstd"); + assertThat(ratio2).isEqualTo(1.5); + } + + @Test + public void testCodecNameNormalization() { + configureShuffle("zStD", true); + double ratio = shuffleCompressionRatio(PARQUET, "ZstD"); + assertThat(ratio).isEqualTo(2.0); + } + + @Test + public void testUnknownCodecNames() { + configureShuffle("SOME_SPARK_CODEC", true); + + double ratio1 = shuffleCompressionRatio(PARQUET, "SOME_PARQUET_CODEC"); + assertThat(ratio1).isEqualTo(2.0); + + double ratio2 = shuffleCompressionRatio(ORC, "SOME_ORC_CODEC"); + assertThat(ratio2).isEqualTo(2.0); + + double ratio3 = shuffleCompressionRatio(AVRO, "SOME_AVRO_CODEC"); + assertThat(ratio3).isEqualTo(1.0); + } + + @Test + public void testOtherFileFormats() { + configureShuffle("lz4", true); + double ratio = shuffleCompressionRatio(METADATA, "zstd"); + assertThat(ratio).isEqualTo(1.0); + } + + @Test + public void testNullFileCodec() { + configureShuffle("lz4", true); + + double ratio1 = shuffleCompressionRatio(PARQUET, null); + assertThat(ratio1).isEqualTo(2.0); + + double ratio2 = shuffleCompressionRatio(ORC, null); + assertThat(ratio2).isEqualTo(2.0); + + double ratio3 = shuffleCompressionRatio(AVRO, null); + assertThat(ratio3).isEqualTo(1.0); + } + + @Test + public void testUncompressedShuffles() { + configureShuffle("zstd", false); + + double ratio1 = shuffleCompressionRatio(PARQUET, "zstd"); + assertThat(ratio1).isEqualTo(4.0); + + double ratio2 = shuffleCompressionRatio(ORC, "zlib"); + assertThat(ratio2).isEqualTo(4.0); + + double ratio3 = shuffleCompressionRatio(AVRO, "gzip"); + assertThat(ratio3).isEqualTo(2.0); + } + + @Test + public void testSparkDefaults() { + assertThat(package$.MODULE$.SHUFFLE_COMPRESS().defaultValueString()).isEqualTo("true"); + assertThat(package$.MODULE$.IO_COMPRESSION_CODEC().defaultValueString()).isEqualTo("lz4"); + } + + private void configureShuffle(String codec, boolean compress) { + when(sparkConf.getBoolean(eq("spark.shuffle.compress"), anyBoolean())).thenReturn(compress); + when(sparkConf.get(eq("spark.io.compression.codec"), anyString())).thenReturn(codec); + } + + private double shuffleCompressionRatio(FileFormat fileFormat, String codec) { + return SparkCompressionUtil.shuffleCompressionRatio(spark, fileFormat, codec); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkDistributionAndOrderingUtil.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkDistributionAndOrderingUtil.java new file mode 100644 index 000000000000..21b67b89a99b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkDistributionAndOrderingUtil.java @@ -0,0 +1,3028 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.TableProperties.DELETE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.MERGE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.SPARK_WRITE_PARTITIONED_FANOUT_ENABLED; +import static org.apache.iceberg.TableProperties.UPDATE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_HASH; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_NONE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_RANGE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.spark.sql.connector.distributions.Distribution; +import org.apache.spark.sql.connector.distributions.Distributions; +import org.apache.spark.sql.connector.expressions.Expression; +import org.apache.spark.sql.connector.expressions.Expressions; +import org.apache.spark.sql.connector.expressions.SortDirection; +import org.apache.spark.sql.connector.expressions.SortOrder; +import org.apache.spark.sql.connector.write.RowLevelOperation.Command; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkDistributionAndOrderingUtil extends TestBaseWithCatalog { + + private static final Distribution UNSPECIFIED_DISTRIBUTION = Distributions.unspecified(); + private static final Distribution FILE_CLUSTERED_DISTRIBUTION = + Distributions.clustered( + new Expression[] {Expressions.column(MetadataColumns.FILE_PATH.name())}); + private static final Distribution SPEC_ID_PARTITION_CLUSTERED_DISTRIBUTION = + Distributions.clustered( + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME) + }); + private static final Distribution SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION = + Distributions.clustered( + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.column(MetadataColumns.FILE_PATH.name()) + }); + + private static final SortOrder[] EMPTY_ORDERING = new SortOrder[] {}; + private static final SortOrder[] FILE_POSITION_ORDERING = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING) + }; + private static final SortOrder[] SPEC_ID_PARTITION_ORDERING = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING) + }; + private static final SortOrder[] SPEC_ID_PARTITION_FILE_ORDERING = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING) + }; + private static final SortOrder[] SPEC_ID_PARTITION_FILE_POSITION_ORDERING = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING) + }; + + @AfterEach + public void dropTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + // ============================================================= + // Distribution and ordering for write operations such as APPEND + // ============================================================= + // + // UNPARTITIONED UNORDERED + // ------------------------------------------------------------------------- + // write mode is NOT SET -> unspecified distribution + empty ordering + // write mode is NONE -> unspecified distribution + empty ordering + // write mode is HASH -> unspecified distribution + empty ordering + // write mode is RANGE -> unspecified distribution + empty ordering + // + // UNPARTITIONED ORDERED BY id, data + // ------------------------------------------------------------------------- + // write mode is NOT SET -> ORDER BY id, data + // write mode is NONE -> unspecified distribution + LOCALLY ORDER BY id, data + // write mode is HASH -> unspecified distribution + LOCALLY ORDER BY id, data + // write mode is RANGE -> ORDER BY id, data + // + // PARTITIONED BY date, days(ts) UNORDERED + // ------------------------------------------------------------------------- + // write mode is NOT SET -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // write mode is NOT SET (fanout) -> CLUSTER BY date, days(ts) + empty ordering + // write mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, days(ts) + // write mode is NONE (fanout) -> unspecified distribution + empty ordering + // write mode is HASH -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // write mode is HASH (fanout) -> CLUSTER BY date, days(ts) + empty ordering + // write mode is RANGE -> ORDER BY date, days(ts) + // write mode is RANGE (fanout) -> RANGE DISTRIBUTE BY date, days(ts) + empty ordering + // + // PARTITIONED BY date ORDERED BY id + // ------------------------------------------------------------------------- + // write mode is NOT SET -> ORDER BY date, id + // write mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, id + // write mode is HASH -> CLUSTER BY date + LOCALLY ORDER BY date, id + // write mode is RANGE -> ORDER BY date, id + + @TestTemplate + public void testDefaultWriteUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + checkWriteDistributionAndOrdering(table, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashWriteUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + checkWriteDistributionAndOrdering(table, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeWriteUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + checkWriteDistributionAndOrdering(table, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultWriteUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testHashWriteUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkWriteDistributionAndOrdering(table, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testRangeWriteUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testDefaultWritePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkWriteDistributionAndOrdering(table, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashWritePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkWriteDistributionAndOrdering(table, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeWritePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkWriteDistributionAndOrdering(table, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultWritePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().desc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testHashWritePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").commit(); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.bucket(8, "data")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testRangeWritePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkWriteDistributionAndOrdering(table, expectedDistribution, expectedOrdering); + } + + // ============================================================= + // Distribution and ordering for copy-on-write DELETE operations + // ============================================================= + // + // UNPARTITIONED UNORDERED + // ------------------------------------------------------------------------- + // delete mode is NOT SET -> CLUSTER BY _file + empty ordering + // delete mode is NONE -> unspecified distribution + empty ordering + // delete mode is HASH -> CLUSTER BY _file + empty ordering + // delete mode is RANGE -> RANGE DISTRIBUTE BY _file, _pos + empty ordering + // + // UNPARTITIONED ORDERED BY id, data + // ------------------------------------------------------------------------- + // delete mode is NOT SET -> CLUSTER BY _file + LOCALLY ORDER BY id, data + // delete mode is NONE -> unspecified distribution + LOCALLY ORDER BY id, data + // delete mode is HASH -> CLUSTER BY _file + LOCALLY ORDER BY id, data + // delete mode is RANGE -> ORDER BY id, data + // + // PARTITIONED BY date, days(ts) UNORDERED + // ------------------------------------------------------------------------- + // delete mode is NOT SET -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // delete mode is NOT SET (fanout) -> CLUSTER BY date, days(ts) + empty ordering + // delete mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, days(ts) + // delete mode is NONE (fanout) -> unspecified distribution + empty ordering + // delete mode is HASH -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // delete mode is HASH (fanout) -> CLUSTER BY date, days(ts) + empty ordering + // delete mode is RANGE -> ORDER BY date, days(ts) + // delete mode is RANGE (fanout) -> RANGE DISTRIBUTE BY date, days(ts) + empty ordering + // + // PARTITIONED BY date ORDERED BY id + // ------------------------------------------------------------------------- + // delete mode is NOT SET -> CLUSTER BY date + LOCALLY ORDER BY date, id + // delete mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, id + // delete mode is HASH -> CLUSTER BY date + LOCALLY ORDER BY date, id + // delete mode is RANGE -> ORDER BY date, id + + @TestTemplate + public void testDefaultCopyOnWriteDeleteUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testNoneCopyOnWriteDeleteUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashCopyOnWriteDeleteUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeCopyOnWriteDeleteUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + Distribution expectedDistribution = Distributions.ordered(FILE_POSITION_ORDERING); + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultCopyOnWriteDeleteUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, FILE_CLUSTERED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testNoneCopyOnWriteDeleteUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashCopyOnWriteDeleteUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, FILE_CLUSTERED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testRangeCopyOnWriteDeleteUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testDefaultCopyOnWriteDeletePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testNoneCopyOnWriteDeletePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashCopyOnWriteDeletePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeCopyOnWriteDeletePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultCopyOnWriteDeletePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().desc("id").commit(); + + Expression[] expectedClustering = new Expression[] {Expressions.identity("date")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testNoneCopyOnWriteDeletePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().desc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashCopyOnWriteDeletePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").commit(); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.bucket(8, "data")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testRangeCopyOnWriteDeletePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, DELETE, expectedDistribution, expectedOrdering); + } + + // ============================================================= + // Distribution and ordering for copy-on-write UPDATE operations + // ============================================================= + // + // UNPARTITIONED UNORDERED + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY _file + empty ordering + // update mode is NONE -> unspecified distribution + empty ordering + // update mode is HASH -> CLUSTER BY _file + empty ordering + // update mode is RANGE -> RANGE DISTRIBUTE BY _file, _pos + empty ordering + // + // UNPARTITIONED ORDERED BY id, data + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY _file + LOCALLY ORDER BY id, data + // update mode is NONE -> unspecified distribution + LOCALLY ORDER BY id, data + // update mode is HASH -> CLUSTER BY _file + LOCALLY ORDER BY id, data + // update mode is RANGE -> ORDER BY id, data + // + // PARTITIONED BY date, days(ts) UNORDERED + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // update mode is NOT SET (fanout) -> CLUSTER BY _file + empty ordering + // update mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, days(ts) + // update mode is NONE (fanout) -> unspecified distribution + empty ordering + // update mode is HASH -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // update mode is HASH (fanout) -> CLUSTER BY date, days(ts) + empty ordering + // update mode is RANGE -> ORDER BY date, days(ts) + // update mode is RANGE (fanout) -> RANGE DISTRIBUTED BY date, days(ts) + empty ordering + // + // PARTITIONED BY date ORDERED BY id + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY date + LOCALLY ORDER BY date, id + // update mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, id + // update mode is HASH -> CLUSTER BY date + LOCALLY ORDER BY date, id + // update mode is RANGE -> ORDER BY date, id + + @TestTemplate + public void testDefaultCopyOnWriteUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testNoneCopyOnWriteUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashCopyOnWriteUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeCopyOnWriteUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + Distribution expectedDistribution = Distributions.ordered(FILE_POSITION_ORDERING); + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultCopyOnWriteUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, FILE_CLUSTERED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testNoneCopyOnWriteUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashCopyOnWriteUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, FILE_CLUSTERED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testRangeCopyOnWriteUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testDefaultCopyOnWriteUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testNoneCopyOnWriteUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashCopyOnWriteUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeCopyOnWriteUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultCopyOnWriteUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().desc("id").commit(); + + Expression[] expectedClustering = new Expression[] {Expressions.identity("date")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testNoneCopyOnWriteUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().desc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashCopyOnWriteUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").commit(); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.bucket(8, "data")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testRangeCopyOnWriteUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, UPDATE, expectedDistribution, expectedOrdering); + } + + // ============================================================= + // Distribution and ordering for copy-on-write MERGE operations + // ============================================================= + // + // UNPARTITIONED UNORDERED + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> use write distribution and ordering + // merge mode is NONE -> unspecified distribution + empty ordering + // merge mode is HASH -> unspecified distribution + empty ordering + // merge mode is RANGE -> unspecified distribution + empty ordering + // + // UNPARTITIONED ORDERED BY id, data + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> use write distribution and ordering + // merge mode is NONE -> unspecified distribution + LOCALLY ORDER BY id, data + // merge mode is HASH -> unspecified distribution + LOCALLY ORDER BY id, data + // merge mode is RANGE -> ORDER BY id, data + // + // PARTITIONED BY date, days(ts) UNORDERED + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // merge mode is NOT SET (fanout) -> CLUSTER BY date, days(ts) + empty ordering + // merge mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, days(ts) + // merge mode is NONE (fanout) -> unspecified distribution + empty ordering + // merge mode is HASH -> CLUSTER BY date, days(ts) + LOCALLY ORDER BY date, days(ts) + // merge mode is HASH (fanout) -> CLUSTER BY date, days(ts) + empty ordering + // merge mode is RANGE -> ORDER BY date, days(ts) + // merge mode is RANGE (fanout) -> RANGE DISTRIBUTE BY date, days(ts) + empty ordering + // + // PARTITIONED BY date ORDERED BY id + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> CLUSTER BY date + LOCALLY ORDER BY date, id + // merge mode is NONE -> unspecified distribution + LOCALLY ORDERED BY date, id + // merge mode is HASH -> CLUSTER BY date + LOCALLY ORDER BY date, id + // merge mode is RANGE -> ORDERED BY date, id + + @TestTemplate + public void testDefaultCopyOnWriteMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testNoneCopyOnWriteMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashCopyOnWriteMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeCopyOnWriteMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultCopyOnWriteMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testNoneCopyOnWriteMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashCopyOnWriteMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testRangeCopyOnWriteMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testDefaultCopyOnWriteMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testNoneCopyOnWriteMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashCopyOnWriteMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.days("ts")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangeCopyOnWriteMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultCopyOnWriteMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().desc("id").commit(); + + Expression[] expectedClustering = new Expression[] {Expressions.identity("date")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testNoneCopyOnWriteMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().desc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashCopyOnWriteMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").commit(); + + Expression[] expectedClustering = + new Expression[] {Expressions.identity("date"), Expressions.bucket(8, "data")}; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testRangeCopyOnWriteMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + Distribution expectedDistribution = Distributions.ordered(expectedOrdering); + + checkCopyOnWriteDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + // =================================================================================== + // Distribution and ordering for merge-on-read DELETE operations with position deletes + // =================================================================================== + // + // UNPARTITIONED (ORDERED & UNORDERED) + // ------------------------------------------------------------------------- + // delete mode is NOT SET -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is NOT SET (fanout) -> CLUSTER BY _spec_id, _partition, _file + empty ordering + // delete mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is NOT SET (fanout) -> CLUSTER BY _spec_id, _partition, _file + empty ordering + // delete mode is HASH -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is HASH (fanout) -> CLUSTER BY _spec_id, _partition, _file + empty ordering + // delete mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is RANGE (fanout) -> RANGE DISTRIBUTE BY _spec_id, _partition, _file + + // empty ordering + // + // PARTITIONED BY date, days(ts) (ORDERED & UNORDERED) + // ------------------------------------------------------------------------- + // delete mode is NOT SET -> CLUSTER BY _spec_id, _partition + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is NOT SET (fanout) -> CLUSTER BY _spec_id, _partition + empty ordering + // delete mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is NONE (fanout) -> unspecified distribution + empty ordering + // delete mode is HASH -> CLUSTER BY _spec_id, _partition + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is HASH (fanout) -> CLUSTER BY _spec_id, _partition + empty ordering + // delete mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // delete mode is RANGE (fanout) -> RANGE DISTRIBUTE BY _spec_id, _partition + empty ordering + + @TestTemplate + public void testDefaultPositionDeltaDeleteUnpartitionedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, + DELETE, + SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, + SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testNonePositionDeltaDeleteUnpartitionedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashPositionDeltaDeleteUnpartitionedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, + DELETE, + SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, + SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangePositionDeltaDeleteUnpartitionedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + Distribution expectedDistribution = Distributions.ordered(SPEC_ID_PARTITION_FILE_ORDERING); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, expectedDistribution, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, DELETE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultPositionDeltaDeletePartitionedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, + DELETE, + SPEC_ID_PARTITION_CLUSTERED_DISTRIBUTION, + SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, SPEC_ID_PARTITION_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testNonePositionDeltaDeletePartitionedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashPositionDeltaDeletePartitionedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, + DELETE, + SPEC_ID_PARTITION_CLUSTERED_DISTRIBUTION, + SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, SPEC_ID_PARTITION_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangePositionDeltaDeletePartitionedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + Distribution expectedDistribution = Distributions.ordered(SPEC_ID_PARTITION_ORDERING); + + checkPositionDeltaDistributionAndOrdering( + table, DELETE, expectedDistribution, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, DELETE, expectedDistribution, EMPTY_ORDERING); + } + + // =================================================================================== + // Distribution and ordering for merge-on-read UPDATE operations with position deletes + // =================================================================================== + // + // IMPORTANT: updates are represented as delete and insert + // IMPORTANT: metadata columns like _spec_id and _partition are NULL for new insert rows + // + // UNPARTITIONED UNORDERED + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // update mode is NOT SET (fanout) -> CLUSTER BY _spec_id, _partition, _file + empty ordering + // update mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // update mode is NONE (fanout) -> unspecified distribution + empty ordering + // update mode is HASH -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // update mode is HASH (fanout) -> CLUSTER BY _spec_id, _partition, _file + empty ordering + // update mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos + // update mode is RANGE (fanout) -> RANGE DISTRIBUTE BY _spec_id, _partition, _file + empty + // ordering + // + // UNPARTITIONED ORDERED BY id, data + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, id, data + // update mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, id, data + // update mode is HASH -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, id, data + // update mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, _file, id, data + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, id, data + // + // PARTITIONED BY date, days(ts) UNORDERED + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, days(ts) + // update mode is NOT SET (fanout) -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // empty ordering + // update mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, days(ts) + // update mode is NONE (fanout) -> unspecified distribution + empty ordering + // update mode is HASH -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, days(ts) + // update mode is HASH (fanout) -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // empty ordering + // update mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, date, days(ts) + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, days(ts) + // update mode is RANGE (fanout) -> RANGE DISTRIBUTE BY _spec_id, _partition, date, days(ts) + + // empty ordering + // + // PARTITIONED BY date ORDERED BY id + // ------------------------------------------------------------------------- + // update mode is NOT SET -> CLUSTER BY _spec_id, _partition, date + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, id + // update mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, id + // update mode is HASH -> CLUSTER BY _spec_id, _partition, date + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, id + // update mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, date, id + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, id + + @TestTemplate + public void testDefaultPositionDeltaUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, + UPDATE, + SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, + SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testNonePositionDeltaUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashPositionDeltaUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, + UPDATE, + SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, + SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangePositionDeltaUpdateUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + Distribution expectedDistribution = Distributions.ordered(SPEC_ID_PARTITION_FILE_ORDERING); + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultPositionDeltaUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testNonePositionDeltaUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashPositionDeltaUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, SPEC_ID_PARTITION_FILE_CLUSTERED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testRangePositionDeltaUpdateUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + SortOrder[] expectedDistributionOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + Distribution expectedDistribution = Distributions.ordered(expectedDistributionOrdering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testDefaultPositionDeltaUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.bucket(8, "data") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testNonePositionDeltaUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashPositionDeltaUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.bucket(8, "data") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangePositionDeltaUpdatePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedDistributionOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING) + }; + Distribution expectedDistribution = Distributions.ordered(expectedDistributionOrdering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, UPDATE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultPositionDeltaUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.bucket(8, "data") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testNonePositionDeltaUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashPositionDeltaUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.bucket(8, "data") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testRangePositionDeltaUpdatePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + table.updateProperties().set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + SortOrder[] expectedDistributionOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + Distribution expectedDistribution = Distributions.ordered(expectedDistributionOrdering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, UPDATE, expectedDistribution, expectedOrdering); + } + + // ================================================================================== + // Distribution and ordering for merge-on-read MERGE operations with position deletes + // ================================================================================== + // + // IMPORTANT: updates are represented as delete and insert + // IMPORTANT: metadata columns like _spec_id and _partition are NULL for new rows + // + // UNPARTITIONED UNORDERED + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos + // merge mode is NOT SET (fanout) -> CLUSTER BY _spec_id, _partition, _file + empty ordering + // merge mode is NONE -> unspecified distribution + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos + // merge mode is NONE (fanout) -> unspecified distribution + empty ordering + // merge mode is HASH -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos + // merge mode is HASH (fanout) -> CLUSTER BY _spec_id, _partition, _file + + // empty ordering + // merge mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, _file + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos + // merge mode is RANGE (fanout) -> RANGE DISTRIBUTE BY _spec_id, _partition, _file + + // empty ordering + // + // UNPARTITIONED ORDERED BY id, data + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, id, data + // merge mode is NONE -> unspecified distribution + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, id, data + // merge mode is HASH -> CLUSTER BY _spec_id, _partition, _file + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, id, data + // merge mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, _file, id, data + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, id, data + // + // PARTITIONED BY date, days(ts) UNORDERED + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, date, days(ts) + // merge mode is NOT SET (fanout) -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // empty ordering + // merge mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, days(ts) + // merge mode is NONE (fanout) -> unspecified distribution + empty ordering + // merge mode is HASH -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, date, days(ts) + // merge mode is HASH (fanout) -> CLUSTER BY _spec_id, _partition, date, days(ts) + + // empty ordering + // merge mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, date, days(ts) + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, date, days(ts) + // merge mode is RANGE (fanout) -> RANGE DISTRIBUTE BY _spec_id, _partition, date, days(ts) + + // empty ordering + // + // PARTITIONED BY date ORDERED BY id + // ------------------------------------------------------------------------- + // merge mode is NOT SET -> CLUSTER BY _spec_id, _partition, date + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, date, id + // merge mode is NONE -> unspecified distribution + + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, id + // merge mode is HASH -> CLUSTER BY _spec_id, _partition, date + + // LOCALLY ORDER BY _spec_id, _partition, _file, _pos, date, id + // merge mode is RANGE -> RANGE DISTRIBUTE BY _spec_id, _partition, date, id + // LOCALLY ORDERED BY _spec_id, _partition, _file, _pos, date, id + + @TestTemplate + public void testDefaultPositionDeltaMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.column(MetadataColumns.FILE_PATH.name()) + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, expectedDistribution, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testNonePositionDeltaMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashPositionDeltaMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.column(MetadataColumns.FILE_PATH.name()) + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, expectedDistribution, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangePositionDeltaMergeUnpartitionedUnsortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedDistributionOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING) + }; + Distribution expectedDistribution = Distributions.ordered(expectedDistributionOrdering); + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, expectedDistribution, SPEC_ID_PARTITION_FILE_POSITION_ORDERING); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testDefaultPositionDeltaMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.column(MetadataColumns.FILE_PATH.name()) + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testNonePositionDeltaMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testHashPositionDeltaMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.column(MetadataColumns.FILE_PATH.name()) + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testRangePositionDeltaMergeUnpartitionedSortedTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").asc("data").commit(); + + SortOrder[] expectedDistributionOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + Distribution expectedDistribution = Distributions.ordered(expectedDistributionOrdering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("data"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testDefaultPositionDeltaMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.days("ts") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testNonePositionDeltaMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, EMPTY_ORDERING); + } + + @TestTemplate + public void testHashPositionDeltaMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + disableFanoutWriters(table); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.days("ts") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testRangePositionDeltaMergePartitionedUnsortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + disableFanoutWriters(table); + + SortOrder[] expectedDistributionOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + Distribution expectedDistribution = Distributions.ordered(expectedDistributionOrdering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.days("ts"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + + enableFanoutWriters(table); + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, EMPTY_ORDERING); + } + + @TestTemplate + public void testNonePositionDeltaMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE).commit(); + + table.replaceSortOrder().desc("id").commit(); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.DESCENDING) + }; + + checkPositionDeltaDistributionAndOrdering( + table, MERGE, UNSPECIFIED_DISTRIBUTION, expectedOrdering); + } + + @TestTemplate + public void testDefaultPositionDeltaMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.bucket(8, "data") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testHashPositionDeltaMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, bucket(8, data))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH).commit(); + + table.replaceSortOrder().asc("id").commit(); + + Expression[] expectedClustering = + new Expression[] { + Expressions.column(MetadataColumns.SPEC_ID.name()), + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), + Expressions.identity("date"), + Expressions.bucket(8, "data") + }; + Distribution expectedDistribution = Distributions.clustered(expectedClustering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.bucket(8, "data"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + @TestTemplate + public void testRangePositionDeltaMergePartitionedSortedTable() { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE).commit(); + + table.replaceSortOrder().asc("id").commit(); + + SortOrder[] expectedDistributionOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + Distribution expectedDistribution = Distributions.ordered(expectedDistributionOrdering); + + SortOrder[] expectedOrdering = + new SortOrder[] { + Expressions.sort( + Expressions.column(MetadataColumns.SPEC_ID.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.PARTITION_COLUMN_NAME), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.FILE_PATH.name()), SortDirection.ASCENDING), + Expressions.sort( + Expressions.column(MetadataColumns.ROW_POSITION.name()), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("date"), SortDirection.ASCENDING), + Expressions.sort(Expressions.column("id"), SortDirection.ASCENDING) + }; + + checkPositionDeltaDistributionAndOrdering(table, MERGE, expectedDistribution, expectedOrdering); + } + + private void checkWriteDistributionAndOrdering( + Table table, Distribution expectedDistribution, SortOrder[] expectedOrdering) { + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + SparkWriteRequirements requirements = writeConf.writeRequirements(); + + Distribution distribution = requirements.distribution(); + assertThat(distribution).as("Distribution must match").isEqualTo(expectedDistribution); + + SortOrder[] ordering = requirements.ordering(); + assertThat(ordering).as("Ordering must match").isEqualTo(expectedOrdering); + } + + private void checkCopyOnWriteDistributionAndOrdering( + Table table, + Command command, + Distribution expectedDistribution, + SortOrder[] expectedOrdering) { + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + SparkWriteRequirements requirements = writeConf.copyOnWriteRequirements(command); + + Distribution distribution = requirements.distribution(); + assertThat(distribution).as("Distribution must match").isEqualTo(expectedDistribution); + + SortOrder[] ordering = requirements.ordering(); + assertThat(ordering).as("Ordering must match").isEqualTo(expectedOrdering); + } + + private void checkPositionDeltaDistributionAndOrdering( + Table table, + Command command, + Distribution expectedDistribution, + SortOrder[] expectedOrdering) { + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + SparkWriteRequirements requirements = writeConf.positionDeltaRequirements(command); + + Distribution distribution = requirements.distribution(); + assertThat(distribution).as("Distribution must match").isEqualTo(expectedDistribution); + + SortOrder[] ordering = requirements.ordering(); + assertThat(ordering).as("Ordering must match").isEqualTo(expectedOrdering); + } + + private void disableFanoutWriters(Table table) { + table.updateProperties().set(SPARK_WRITE_PARTITIONED_FANOUT_ENABLED, "false").commit(); + } + + private void enableFanoutWriters(Table table) { + table.updateProperties().set(SPARK_WRITE_PARTITIONED_FANOUT_ENABLED, "true").commit(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkExecutorCache.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkExecutorCache.java new file mode 100644 index 000000000000..607955971d4a --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkExecutorCache.java @@ -0,0 +1,677 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.RowLevelOperationMode.COPY_ON_WRITE; +import static org.apache.iceberg.RowLevelOperationMode.MERGE_ON_READ; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.benmanes.caffeine.cache.Cache; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ContentScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Files; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.io.SeekableInputStream; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.SparkExecutorCache.CacheValue; +import org.apache.iceberg.spark.SparkExecutorCache.Conf; +import org.apache.iceberg.util.CharSequenceSet; +import org.apache.iceberg.util.Pair; +import org.apache.spark.SparkEnv; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.storage.memory.MemoryStore; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestSparkExecutorCache extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hive", + CatalogProperties.FILE_IO_IMPL, + CustomFileIO.class.getName(), + "default-namespace", + "default") + }, + }; + } + + private static final String UPDATES_VIEW_NAME = "updates"; + private static final AtomicInteger JOB_COUNTER = new AtomicInteger(); + private static final Map INPUT_FILES = + Collections.synchronizedMap(Maps.newHashMap()); + + private String targetTableName; + private TableIdentifier targetTableIdent; + + @BeforeEach + public void configureTargetTableName() { + String name = "target_exec_cache_" + JOB_COUNTER.incrementAndGet(); + this.targetTableName = tableName(name); + this.targetTableIdent = TableIdentifier.of(Namespace.of("default"), name); + } + + @AfterEach + public void releaseResources() { + sql("DROP TABLE IF EXISTS %s", targetTableName); + sql("DROP TABLE IF EXISTS %s", UPDATES_VIEW_NAME); + INPUT_FILES.clear(); + } + + @TestTemplate + public void testCacheValueWeightOverflow() { + CacheValue cacheValue = new CacheValue("v", Integer.MAX_VALUE + 1L); + assertThat(cacheValue.weight()).isEqualTo(Integer.MAX_VALUE); + } + + @TestTemplate + public void testCacheEnabledConfig() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "true"), + () -> { + Conf conf = new Conf(); + assertThat(conf.cacheEnabled()).isTrue(); + }); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "false"), + () -> { + Conf conf = new Conf(); + assertThat(conf.cacheEnabled()).isFalse(); + }); + } + + @TestTemplate + public void testTimeoutConfig() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.EXECUTOR_CACHE_TIMEOUT, "10s"), + () -> { + Conf conf = new Conf(); + assertThat(conf.timeout()).hasSeconds(10); + }); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.EXECUTOR_CACHE_TIMEOUT, "2m"), + () -> { + Conf conf = new Conf(); + assertThat(conf.timeout()).hasMinutes(2); + }); + } + + @TestTemplate + public void testMaxEntrySizeConfig() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.EXECUTOR_CACHE_MAX_ENTRY_SIZE, "128"), + () -> { + Conf conf = new Conf(); + assertThat(conf.maxEntrySize()).isEqualTo(128L); + }); + } + + @TestTemplate + public void testMaxTotalSizeConfig() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.EXECUTOR_CACHE_MAX_TOTAL_SIZE, "512"), + () -> { + Conf conf = new Conf(); + assertThat(conf.maxTotalSize()).isEqualTo(512L); + }); + } + + @TestTemplate + public void testDeleteFilesCacheDisabledConfig() throws Exception { + createAndInitTable(TableProperties.DELETE_MODE, COPY_ON_WRITE); + Table table = validationCatalog.loadTable(targetTableIdent); + + withSQLConf( + ImmutableMap.of( + SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "true", + SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED, "false"), + () -> { + SparkReadConf readConf = new SparkReadConf(spark, table); + assertThat(readConf.cacheDeleteFilesOnExecutors()).isFalse(); + }); + + withSQLConf( + ImmutableMap.of( + SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "true", + SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED, "true"), + () -> { + SparkReadConf readConf = new SparkReadConf(spark, table); + assertThat(readConf.cacheDeleteFilesOnExecutors()).isTrue(); + }); + + withSQLConf( + ImmutableMap.of( + SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "false", + SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED, "true"), + () -> { + SparkReadConf readConf = new SparkReadConf(spark, table); + assertThat(readConf.cacheDeleteFilesOnExecutors()).isFalse(); + }); + } + + @TestTemplate + public void testConcurrentAccess() throws InterruptedException { + SparkExecutorCache cache = SparkExecutorCache.getOrCreate(); + + String table1 = "table1"; + String table2 = "table2"; + + Set loadedInternalKeys = Sets.newHashSet(); + + String key1 = "key1"; + String key2 = "key2"; + + long valueSize = 100L; + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + + for (int threadNumber = 0; threadNumber < threadCount; threadNumber++) { + String group = threadNumber % 2 == 0 ? table1 : table2; + executorService.submit( + () -> { + for (int batch = 0; batch < 3; batch++) { + cache.getOrLoad( + group, + key1, + () -> { + String internalKey = toInternalKey(group, key1); + synchronized (loadedInternalKeys) { + // verify only one load was done for this key + assertThat(loadedInternalKeys.contains(internalKey)).isFalse(); + loadedInternalKeys.add(internalKey); + } + return "value1"; + }, + valueSize); + + cache.getOrLoad( + group, + key2, + () -> { + String internalKey = toInternalKey(group, key2); + synchronized (loadedInternalKeys) { + // verify only one load was done for this key + assertThat(loadedInternalKeys.contains(internalKey)).isFalse(); + loadedInternalKeys.add(internalKey); + } + return "value2"; + }, + valueSize); + } + }); + } + + executorService.shutdown(); + assertThat(executorService.awaitTermination(1, TimeUnit.MINUTES)).isTrue(); + + cache.invalidate(table1); + cache.invalidate(table2); + + // all keys must be invalidated + Cache state = fetchInternalCacheState(); + Set liveKeys = state.asMap().keySet(); + assertThat(liveKeys).noneMatch(key -> key.startsWith(table1) || key.startsWith(table2)); + } + + @TestTemplate + public void testCopyOnWriteDelete() throws Exception { + checkDelete(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadDelete() throws Exception { + checkDelete(MERGE_ON_READ); + } + + private void checkDelete(RowLevelOperationMode mode) throws Exception { + List deleteFiles = createAndInitTable(TableProperties.DELETE_MODE, mode); + + sql("DELETE FROM %s WHERE id = 1 OR id = 4", targetTableName); + + // there are 2 data files and 2 delete files that apply to both of them + // in CoW, the target table will be scanned 2 times (main query + runtime filter) + // the runtime filter may invalidate the cache so check at least some requests were hits + // in MoR, the target table will be scanned only once + // so each delete file must be opened once + int maxRequestCount = mode == COPY_ON_WRITE ? 3 : 1; + assertThat(deleteFiles).allMatch(deleteFile -> streamCount(deleteFile) <= maxRequestCount); + + // verify the final set of records is correct + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id ASC", targetTableName)); + } + + @TestTemplate + public void testCopyOnWriteUpdate() throws Exception { + checkUpdate(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadUpdate() throws Exception { + checkUpdate(MERGE_ON_READ); + } + + private void checkUpdate(RowLevelOperationMode mode) throws Exception { + List deleteFiles = createAndInitTable(TableProperties.UPDATE_MODE, mode); + + Dataset updateDS = spark.createDataset(ImmutableList.of(1, 4), Encoders.INT()); + updateDS.createOrReplaceTempView(UPDATES_VIEW_NAME); + + sql("UPDATE %s SET id = -1 WHERE id IN (SELECT * FROM %s)", targetTableName, UPDATES_VIEW_NAME); + + // there are 2 data files and 2 delete files that apply to both of them + // in CoW, the target table will be scanned 3 times (2 in main query + runtime filter) + // the runtime filter may invalidate the cache so check at least some requests were hits + // in MoR, the target table will be scanned only once + // so each delete file must be opened once + int maxRequestCount = mode == COPY_ON_WRITE ? 5 : 1; + assertThat(deleteFiles).allMatch(deleteFile -> streamCount(deleteFile) <= maxRequestCount); + + // verify the final set of records is correct + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(-1, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC", targetTableName)); + } + + @TestTemplate + public void testCopyOnWriteMerge() throws Exception { + checkMerge(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadMerge() throws Exception { + checkMerge(MERGE_ON_READ); + } + + private void checkMerge(RowLevelOperationMode mode) throws Exception { + List deleteFiles = createAndInitTable(TableProperties.MERGE_MODE, mode); + + Dataset updateDS = spark.createDataset(ImmutableList.of(1, 4), Encoders.INT()); + updateDS.createOrReplaceTempView(UPDATES_VIEW_NAME); + + sql( + "MERGE INTO %s t USING %s s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET id = 100 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (-1, 'unknown')", + targetTableName, UPDATES_VIEW_NAME); + + // there are 2 data files and 2 delete files that apply to both of them + // in CoW, the target table will be scanned 2 times (main query + runtime filter) + // the runtime filter may invalidate the cache so check at least some requests were hits + // in MoR, the target table will be scanned only once + // so each delete file must be opened once + int maxRequestCount = mode == COPY_ON_WRITE ? 3 : 1; + assertThat(deleteFiles).allMatch(deleteFile -> streamCount(deleteFile) <= maxRequestCount); + + // verify the final set of records is correct + assertEquals( + "Should have expected rows", + ImmutableList.of(row(100, "hr"), row(100, "hr")), + sql("SELECT * FROM %s ORDER BY id ASC", targetTableName)); + } + + @TestTemplate + public void testCopyOnWriteDeleteWithDeleteFileCacheDisabled() { + checkDeleteWithDeleteFilesCacheDisabled(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadDeleteWithDeleteFileCacheDisabled() { + checkDeleteWithDeleteFilesCacheDisabled(MERGE_ON_READ); + } + + private void checkDeleteWithDeleteFilesCacheDisabled(RowLevelOperationMode mode) { + withSQLConf( + ImmutableMap.of( + SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "true", + SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED, "false"), + () -> { + try { + List deleteFiles = createAndInitTable(TableProperties.DELETE_MODE, mode); + + sql("DELETE FROM %s WHERE id = 1 OR id = 4", targetTableName); + + // When cache is disabled, delete files should be opened multiple times + // The cached CoW test has a maximum of 3 scans, so we expect more than that when + // disabled. + // The cached MoR test has a maximum of 1 scan, so we expect more than that when + // disabled. + int expectedMinStreamCount = mode == COPY_ON_WRITE ? 4 : 2; + assertThat(deleteFiles) + .allMatch(deleteFile -> streamCount(deleteFile) >= expectedMinStreamCount); + + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id ASC", targetTableName)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @TestTemplate + public void testCopyOnWriteUpdateWithDeleteFilesCacheDisabled() throws Exception { + checkUpdateWithDeleteFilesCacheDisabled(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadUpdateWithDeleteFilesCacheDisabled() throws Exception { + checkUpdateWithDeleteFilesCacheDisabled(MERGE_ON_READ); + } + + private void checkUpdateWithDeleteFilesCacheDisabled(RowLevelOperationMode mode) + throws Exception { + withSQLConf( + ImmutableMap.of( + SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "true", + SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED, "false"), + () -> { + try { + List deleteFiles = createAndInitTable(TableProperties.UPDATE_MODE, mode); + + Dataset updateDS = spark.createDataset(ImmutableList.of(1, 4), Encoders.INT()); + updateDS.createOrReplaceTempView(UPDATES_VIEW_NAME); + + sql( + "UPDATE %s SET id = -1 WHERE id IN (SELECT * FROM %s)", + targetTableName, UPDATES_VIEW_NAME); + + // When cache is disabled, delete files should be opened multiple times + // Both CoW and MoR should open delete files at least 2 times without caching + int expectedMinStreamCount = 2; + assertThat(deleteFiles) + .allMatch(deleteFile -> streamCount(deleteFile) >= expectedMinStreamCount); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(-1, "hr"), row(-1, "hr")), + sql("SELECT * FROM %s", targetTableName)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @TestTemplate + public void testCopyOnWriteMergeWithDeleteFilesCacheDisabled() throws Exception { + checkMergeWithDeleteFilesCacheDisabled(COPY_ON_WRITE); + } + + @TestTemplate + public void testMergeOnReadMergeWithDeleteFilesCacheDisabled() throws Exception { + checkMergeWithDeleteFilesCacheDisabled(MERGE_ON_READ); + } + + private void checkMergeWithDeleteFilesCacheDisabled(RowLevelOperationMode mode) throws Exception { + withSQLConf( + ImmutableMap.of( + SparkSQLProperties.EXECUTOR_CACHE_ENABLED, "true", + SparkSQLProperties.EXECUTOR_CACHE_DELETE_FILES_ENABLED, "false"), + () -> { + try { + List deleteFiles = createAndInitTable(TableProperties.MERGE_MODE, mode); + + Dataset updateDS = spark.createDataset(ImmutableList.of(1, 4), Encoders.INT()); + updateDS.createOrReplaceTempView(UPDATES_VIEW_NAME); + + sql( + "MERGE INTO %s t USING %s s " + + "ON t.id == s.value " + + "WHEN MATCHED THEN " + + " UPDATE SET id = 100 " + + "WHEN NOT MATCHED THEN " + + " INSERT (id, dep) VALUES (-1, 'unknown')", + targetTableName, UPDATES_VIEW_NAME); + + // When the cache is disabled, delete files are opened more often because each Spark + // task reads them. + // The cached CoW MERGE test allows up to 3 scans, so we require at least 4 to confirm + // the cache is disabled. + // For MoR MERGE, the cached test allows 1 scan, so we require at least 2 to confirm the + // cache is disabled. + int expectedMinStreamCount = mode == COPY_ON_WRITE ? 4 : 2; + assertThat(deleteFiles) + .allMatch(deleteFile -> streamCount(deleteFile) >= expectedMinStreamCount); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(100, "hr"), row(100, "hr")), + sql("SELECT * FROM %s", targetTableName)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + private int streamCount(DeleteFile deleteFile) { + CustomInputFile inputFile = INPUT_FILES.get(deleteFile.location()); + return inputFile.streamCount(); + } + + private List createAndInitTable(String operation, RowLevelOperationMode mode) + throws Exception { + sql( + "CREATE TABLE %s (id INT, dep STRING) " + + "USING iceberg " + + "TBLPROPERTIES ('%s' '%s', '%s' '%s', '%s' '%s')", + targetTableName, + TableProperties.WRITE_METADATA_LOCATION, + temp.toString().replaceFirst("file:", ""), + TableProperties.WRITE_DATA_LOCATION, + temp.toString().replaceFirst("file:", ""), + operation, + mode.modeName()); + + append(targetTableName, new Employee(0, "hr"), new Employee(1, "hr"), new Employee(2, "hr")); + append(targetTableName, new Employee(3, "hr"), new Employee(4, "hr"), new Employee(5, "hr")); + + Table table = validationCatalog.loadTable(targetTableIdent); + + List> posDeletes = + dataFiles(table).stream() + .map(dataFile -> Pair.of((CharSequence) dataFile.location(), 0L)) + .collect(Collectors.toList()); + Pair posDeleteResult = writePosDeletes(table, posDeletes); + DeleteFile posDeleteFile = posDeleteResult.first(); + CharSequenceSet referencedDataFiles = posDeleteResult.second(); + + DeleteFile eqDeleteFile = writeEqDeletes(table, "id", 2, 5); + + table + .newRowDelta() + .validateFromSnapshot(table.currentSnapshot().snapshotId()) + .validateDataFilesExist(referencedDataFiles) + .addDeletes(posDeleteFile) + .addDeletes(eqDeleteFile) + .commit(); + + sql("REFRESH TABLE %s", targetTableName); + + // invalidate the memory store to destroy all currently live table broadcasts + SparkEnv sparkEnv = SparkEnv.get(); + MemoryStore memoryStore = sparkEnv.blockManager().memoryStore(); + memoryStore.clear(); + + return ImmutableList.of(posDeleteFile, eqDeleteFile); + } + + private DeleteFile writeEqDeletes(Table table, String col, Object... values) throws IOException { + Schema deleteSchema = table.schema().select(col); + + Record delete = GenericRecord.create(deleteSchema); + List deletes = Lists.newArrayList(); + for (Object value : values) { + deletes.add(delete.copy(col, value)); + } + + OutputFile out = Files.localOutput(new File(temp.toFile(), "eq-deletes-" + UUID.randomUUID())); + return FileHelpers.writeDeleteFile(table, out, null, deletes, deleteSchema); + } + + private Pair writePosDeletes( + Table table, List> deletes) throws IOException { + OutputFile out = Files.localOutput(new File(temp.toFile(), "pos-deletes-" + UUID.randomUUID())); + return FileHelpers.writeDeleteFile(table, out, null, deletes); + } + + private void append(String target, Employee... employees) throws NoSuchTableException { + List input = Arrays.asList(employees); + Dataset inputDF = spark.createDataFrame(input, Employee.class); + inputDF.coalesce(1).writeTo(target).append(); + } + + private Collection dataFiles(Table table) { + try (CloseableIterable tasks = table.newScan().planFiles()) { + return ImmutableList.copyOf(Iterables.transform(tasks, ContentScanTask::file)); + } catch (IOException e) { + throw new RuntimeIOException(e); + } + } + + @SuppressWarnings("unchecked") + private static Cache fetchInternalCacheState() { + try { + Field stateField = SparkExecutorCache.class.getDeclaredField("state"); + stateField.setAccessible(true); + SparkExecutorCache cache = SparkExecutorCache.get(); + return (Cache) stateField.get(cache); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String toInternalKey(String group, String key) { + return group + "_" + key; + } + + public static class CustomFileIO implements FileIO { + + public CustomFileIO() {} + + @Override + public InputFile newInputFile(String path) { + return INPUT_FILES.computeIfAbsent(path, key -> new CustomInputFile(path)); + } + + @Override + public OutputFile newOutputFile(String path) { + return Files.localOutput(path); + } + + @Override + public void deleteFile(String path) { + File file = new File(path); + if (!file.delete()) { + throw new RuntimeIOException("Failed to delete file: " + path); + } + } + } + + public static class CustomInputFile implements InputFile { + private final InputFile delegate; + private final AtomicInteger streamCount; + + public CustomInputFile(String path) { + this.delegate = Files.localInput(path); + this.streamCount = new AtomicInteger(); + } + + @Override + public long getLength() { + return delegate.getLength(); + } + + @Override + public SeekableInputStream newStream() { + streamCount.incrementAndGet(); + return delegate.newStream(); + } + + public int streamCount() { + return streamCount.get(); + } + + @Override + public String location() { + return delegate.location(); + } + + @Override + public boolean exists() { + return delegate.exists(); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkFilters.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkFilters.java new file mode 100644 index 000000000000..49c38b34d34a --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkFilters.java @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.spark.sql.sources.And; +import org.apache.spark.sql.sources.EqualNullSafe; +import org.apache.spark.sql.sources.EqualTo; +import org.apache.spark.sql.sources.GreaterThan; +import org.apache.spark.sql.sources.GreaterThanOrEqual; +import org.apache.spark.sql.sources.In; +import org.apache.spark.sql.sources.IsNotNull; +import org.apache.spark.sql.sources.IsNull; +import org.apache.spark.sql.sources.LessThan; +import org.apache.spark.sql.sources.LessThanOrEqual; +import org.apache.spark.sql.sources.Not; +import org.junit.jupiter.api.Test; + +public class TestSparkFilters { + + @Test + public void testQuotedAttributes() { + Map attrMap = Maps.newHashMap(); + attrMap.put("id", "id"); + attrMap.put("`i.d`", "i.d"); + attrMap.put("`i``d`", "i`d"); + attrMap.put("`d`.b.`dd```", "d.b.dd`"); + attrMap.put("a.`aa```.c", "a.aa`.c"); + + attrMap.forEach( + (quoted, unquoted) -> { + IsNull isNull = IsNull.apply(quoted); + Expression expectedIsNull = Expressions.isNull(unquoted); + Expression actualIsNull = SparkFilters.convert(isNull); + assertThat(actualIsNull) + .asString() + .as("IsNull must match") + .isEqualTo(expectedIsNull.toString()); + + IsNotNull isNotNull = IsNotNull.apply(quoted); + Expression expectedIsNotNull = Expressions.notNull(unquoted); + Expression actualIsNotNull = SparkFilters.convert(isNotNull); + assertThat(actualIsNotNull) + .asString() + .as("IsNotNull must match") + .isEqualTo(expectedIsNotNull.toString()); + + LessThan lt = LessThan.apply(quoted, 1); + Expression expectedLt = Expressions.lessThan(unquoted, 1); + Expression actualLt = SparkFilters.convert(lt); + assertThat(actualLt) + .asString() + .as("LessThan must match") + .isEqualTo(expectedLt.toString()); + + LessThanOrEqual ltEq = LessThanOrEqual.apply(quoted, 1); + Expression expectedLtEq = Expressions.lessThanOrEqual(unquoted, 1); + Expression actualLtEq = SparkFilters.convert(ltEq); + assertThat(actualLtEq) + .asString() + .as("LessThanOrEqual must match") + .isEqualTo(expectedLtEq.toString()); + + GreaterThan gt = GreaterThan.apply(quoted, 1); + Expression expectedGt = Expressions.greaterThan(unquoted, 1); + Expression actualGt = SparkFilters.convert(gt); + assertThat(actualGt) + .asString() + .as("GreaterThan must match") + .isEqualTo(expectedGt.toString()); + + GreaterThanOrEqual gtEq = GreaterThanOrEqual.apply(quoted, 1); + Expression expectedGtEq = Expressions.greaterThanOrEqual(unquoted, 1); + Expression actualGtEq = SparkFilters.convert(gtEq); + assertThat(actualGtEq) + .asString() + .as("GreaterThanOrEqual must match") + .isEqualTo(expectedGtEq.toString()); + + EqualTo eq = EqualTo.apply(quoted, 1); + Expression expectedEq = Expressions.equal(unquoted, 1); + Expression actualEq = SparkFilters.convert(eq); + assertThat(actualEq).asString().as("EqualTo must match").isEqualTo(expectedEq.toString()); + + EqualNullSafe eqNullSafe = EqualNullSafe.apply(quoted, 1); + Expression expectedEqNullSafe = Expressions.equal(unquoted, 1); + Expression actualEqNullSafe = SparkFilters.convert(eqNullSafe); + assertThat(actualEqNullSafe) + .asString() + .as("EqualNullSafe must match") + .isEqualTo(expectedEqNullSafe.toString()); + + In in = In.apply(quoted, new Integer[] {1}); + Expression expectedIn = Expressions.in(unquoted, 1); + Expression actualIn = SparkFilters.convert(in); + assertThat(actualIn).asString().as("In must match").isEqualTo(expectedIn.toString()); + }); + } + + @Test + public void testTimestampFilterConversion() { + Instant instant = Instant.parse("2018-10-18T00:00:57.907Z"); + Timestamp timestamp = Timestamp.from(instant); + long epochMicros = ChronoUnit.MICROS.between(Instant.EPOCH, instant); + + Expression instantExpression = SparkFilters.convert(GreaterThan.apply("x", instant)); + Expression timestampExpression = SparkFilters.convert(GreaterThan.apply("x", timestamp)); + Expression rawExpression = Expressions.greaterThan("x", epochMicros); + + assertThat(timestampExpression) + .asString() + .as("Generated Timestamp expression should be correct") + .isEqualTo(rawExpression.toString()); + + assertThat(instantExpression) + .asString() + .as("Generated Instant expression should be correct") + .isEqualTo(rawExpression.toString()); + } + + @Test + public void testLocalDateTimeFilterConversion() { + LocalDateTime ldt = LocalDateTime.parse("2018-10-18T00:00:57"); + long epochMicros = + ChronoUnit.MICROS.between(LocalDateTime.ofInstant(Instant.EPOCH, ZoneId.of("UTC")), ldt); + + Expression instantExpression = SparkFilters.convert(GreaterThan.apply("x", ldt)); + Expression rawExpression = Expressions.greaterThan("x", epochMicros); + + assertThat(instantExpression) + .asString() + .as("Generated Instant expression should be correct") + .isEqualTo(rawExpression.toString()); + } + + @Test + public void testDateFilterConversion() { + LocalDate localDate = LocalDate.parse("2018-10-18"); + Date date = Date.valueOf(localDate); + long epochDay = localDate.toEpochDay(); + + Expression localDateExpression = SparkFilters.convert(GreaterThan.apply("x", localDate)); + Expression dateExpression = SparkFilters.convert(GreaterThan.apply("x", date)); + Expression rawExpression = Expressions.greaterThan("x", epochDay); + + assertThat(localDateExpression) + .asString() + .as("Generated localdate expression should be correct") + .isEqualTo(rawExpression.toString()); + + assertThat(dateExpression) + .asString() + .as("Generated date expression should be correct") + .isEqualTo(rawExpression.toString()); + } + + @Test + public void testNestedInInsideNot() { + Not filter = + Not.apply(And.apply(EqualTo.apply("col1", 1), In.apply("col2", new Integer[] {1, 2}))); + Expression converted = SparkFilters.convert(filter); + assertThat(converted).as("Expression should not be converted").isNull(); + } + + @Test + public void testNotIn() { + Not filter = Not.apply(In.apply("col", new Integer[] {1, 2})); + Expression actual = SparkFilters.convert(filter); + Expression expected = + Expressions.and(Expressions.notNull("col"), Expressions.notIn("col", 1, 2)); + assertThat(actual).asString().as("Expressions should match").isEqualTo(expected.toString()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java new file mode 100644 index 000000000000..c3fc69c8b25c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkReadConf.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReadConf extends TestBaseWithCatalog { + + @BeforeEach + public void before() { + super.before(); + sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName); + } + + @AfterEach + public void after() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSplitParallelismDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(conf.parallelism()); + } + + @TestTemplate + public void testSplitParallelismSessionConf() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of( + SQLConf.SHUFFLE_PARTITIONS().key(), + "999", + SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, + "42"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThat(conf.splitParallelism()).isEqualTo(42); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsZero() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "0"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } + + @TestTemplate + public void testSplitParallelismRejectsNegative() { + Table table = validationCatalog.loadTable(tableIdent); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.READ_ADAPTIVE_SPLIT_SIZE_PARALLELISM, "-5"), + () -> { + SparkReadConf conf = new SparkReadConf(spark, table, CaseInsensitiveStringMap.empty()); + assertThatIllegalArgumentException() + .isThrownBy(conf::splitParallelism) + .withMessageContaining("Split parallelism must be > 0"); + }); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java new file mode 100644 index 000000000000..d5f407a715ef --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkSchemaUtil.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.TimeZone; +import java.util.stream.Stream; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.expressions.AttributeReference; +import org.apache.spark.sql.catalyst.expressions.MetadataAttribute; +import org.apache.spark.sql.catalyst.types.DataTypeUtils; +import org.apache.spark.sql.catalyst.util.ResolveDefaultColumnsUtils$; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class TestSparkSchemaUtil { + private static final Schema TEST_SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + + private static final Schema TEST_SCHEMA_WITH_METADATA_COLS = + new Schema( + optional(1, "id", Types.IntegerType.get()), + optional(2, "data", Types.StringType.get()), + MetadataColumns.FILE_PATH, + MetadataColumns.ROW_POSITION); + + @Test + public void testEstimateSizeMaxValue() { + assertThat(SparkSchemaUtil.estimateSize(null, Long.MAX_VALUE)) + .as("estimateSize returns Long max value") + .isEqualTo(Long.MAX_VALUE); + } + + @Test + public void testEstimateSizeWithOverflow() { + long tableSize = + SparkSchemaUtil.estimateSize(SparkSchemaUtil.convert(TEST_SCHEMA), Long.MAX_VALUE - 1); + assertThat(tableSize).as("estimateSize handles overflow").isEqualTo(Long.MAX_VALUE); + } + + @Test + public void testEstimateSize() { + long tableSize = SparkSchemaUtil.estimateSize(SparkSchemaUtil.convert(TEST_SCHEMA), 1); + assertThat(tableSize).as("estimateSize matches with expected approximation").isEqualTo(24); + } + + @Test + public void testSchemaConversionWithMetaDataColumnSchema() { + StructType structType = SparkSchemaUtil.convert(TEST_SCHEMA_WITH_METADATA_COLS); + List attrRefs = + scala.collection.JavaConverters.seqAsJavaList(DataTypeUtils.toAttributes(structType)); + for (AttributeReference attrRef : attrRefs) { + if (MetadataColumns.isMetadataColumn(attrRef.name())) { + assertThat(MetadataAttribute.unapply(attrRef).isDefined()) + .as("metadata columns should have __metadata_col in attribute metadata") + .isTrue(); + } else { + assertThat(MetadataAttribute.unapply(attrRef).isDefined()) + .as("non metadata columns should not have __metadata_col in attribute metadata") + .isFalse(); + } + } + } + + @Test + public void testSchemaConversionWithOnlyWriteDefault() { + Schema schema = + new Schema( + Types.NestedField.optional("field") + .withId(1) + .ofType(Types.StringType.get()) + .withWriteDefault(Literal.of("write_only")) + .build()); + + StructType sparkSchema = SparkSchemaUtil.convert(schema); + Metadata metadata = sparkSchema.fields()[0].metadata(); + + assertThat( + metadata.contains( + ResolveDefaultColumnsUtils$.MODULE$.CURRENT_DEFAULT_COLUMN_METADATA_KEY())) + .as("Field with only write default should have CURRENT_DEFAULT metadata") + .isTrue(); + assertThat( + metadata.contains( + ResolveDefaultColumnsUtils$.MODULE$.EXISTS_DEFAULT_COLUMN_METADATA_KEY())) + .as("Field with only write default should not have EXISTS_DEFAULT metadata") + .isFalse(); + assertThat( + metadata.getString( + ResolveDefaultColumnsUtils$.MODULE$.CURRENT_DEFAULT_COLUMN_METADATA_KEY())) + .as("Spark metadata CURRENT_DEFAULT should contain correctly formatted literal") + .isEqualTo("'write_only'"); + } + + @Test + public void testSchemaConversionWithOnlyInitialDefault() { + Schema schema = + new Schema( + Types.NestedField.optional("field") + .withId(1) + .ofType(Types.IntegerType.get()) + .withInitialDefault(Literal.of(42)) + .build()); + + StructType sparkSchema = SparkSchemaUtil.convert(schema); + Metadata metadata = sparkSchema.fields()[0].metadata(); + + assertThat( + metadata.contains( + ResolveDefaultColumnsUtils$.MODULE$.CURRENT_DEFAULT_COLUMN_METADATA_KEY())) + .as("Field with only initial default should not have CURRENT_DEFAULT metadata") + .isFalse(); + assertThat( + metadata.contains( + ResolveDefaultColumnsUtils$.MODULE$.EXISTS_DEFAULT_COLUMN_METADATA_KEY())) + .as("Field with only initial default should have EXISTS_DEFAULT metadata") + .isTrue(); + assertThat( + metadata.getString( + ResolveDefaultColumnsUtils$.MODULE$.EXISTS_DEFAULT_COLUMN_METADATA_KEY())) + .as("Spark metadata EXISTS_DEFAULT should contain correctly formatted literal") + .isEqualTo("42"); + } + + @ParameterizedTest(name = "{0} with writeDefault={1}, initialDefault={2}") + @MethodSource("schemaConversionWithDefaultsTestCases") + public void testSchemaConversionWithDefaultsForPrimitiveTypes( + Type type, + Literal writeDefault, + Literal initialDefault, + String expectedCurrentDefaultValue, + String expectedExistsDefaultValue) { + TimeZone systemTimeZone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + Schema schema = + new Schema( + Types.NestedField.optional("field") + .withId(1) + .ofType(type) + .withWriteDefault(writeDefault) + .withInitialDefault(initialDefault) + .build()); + + StructType sparkSchema = SparkSchemaUtil.convert(schema); + StructField defaultField = sparkSchema.fields()[0]; + Metadata metadata = defaultField.metadata(); + + assertThat( + metadata.contains( + ResolveDefaultColumnsUtils$.MODULE$.CURRENT_DEFAULT_COLUMN_METADATA_KEY())) + .as("Field of type %s should have CURRENT_DEFAULT metadata", type) + .isTrue(); + assertThat( + metadata.contains( + ResolveDefaultColumnsUtils$.MODULE$.EXISTS_DEFAULT_COLUMN_METADATA_KEY())) + .as("Field of type %s should have EXISTS_DEFAULT metadata", type) + .isTrue(); + assertThat( + metadata.getString( + ResolveDefaultColumnsUtils$.MODULE$.CURRENT_DEFAULT_COLUMN_METADATA_KEY())) + .as( + "Spark metadata CURRENT_DEFAULT for type %s should contain correctly formatted literal", + type) + .isEqualTo(expectedCurrentDefaultValue); + assertThat( + metadata.getString( + ResolveDefaultColumnsUtils$.MODULE$.EXISTS_DEFAULT_COLUMN_METADATA_KEY())) + .as( + "Spark metadata EXISTS_DEFAULT for type %s should contain correctly formatted literal", + type) + .isEqualTo(expectedExistsDefaultValue); + } finally { + TimeZone.setDefault(systemTimeZone); + } + } + + private static Stream schemaConversionWithDefaultsTestCases() { + return Stream.of( + Arguments.of(Types.IntegerType.get(), Literal.of(1), Literal.of(2), "1", "2"), + Arguments.of( + Types.StringType.get(), + Literal.of("write_default"), + Literal.of("initial_default"), + "'write_default'", + "'initial_default'"), + Arguments.of( + Types.UUIDType.get(), + Literal.of("f79c3e09-677c-4bbd-a479-3f349cb785e7").to(Types.UUIDType.get()), + Literal.of("f79c3e09-677c-4bbd-a479-3f349cb685e7").to(Types.UUIDType.get()), + "'f79c3e09-677c-4bbd-a479-3f349cb785e7'", + "'f79c3e09-677c-4bbd-a479-3f349cb685e7'"), + Arguments.of(Types.BooleanType.get(), Literal.of(true), Literal.of(false), "true", "false"), + Arguments.of(Types.IntegerType.get(), Literal.of(42), Literal.of(10), "42", "10"), + Arguments.of(Types.LongType.get(), Literal.of(100L), Literal.of(50L), "100L", "50L"), + Arguments.of( + Types.FloatType.get(), + Literal.of(3.14f), + Literal.of(1.5f), + "CAST('3.14' AS FLOAT)", + "CAST('1.5' AS FLOAT)"), + Arguments.of( + Types.DoubleType.get(), Literal.of(2.718), Literal.of(1.414), "2.718D", "1.414D"), + Arguments.of( + Types.DecimalType.of(10, 2), + Literal.of(new BigDecimal("99.99")), + Literal.of(new BigDecimal("11.11")), + "99.99BD", + "11.11BD"), + Arguments.of( + Types.DateType.get(), + Literal.of("2024-01-01").to(Types.DateType.get()), + Literal.of("2023-01-01").to(Types.DateType.get()), + "DATE '2024-01-01'", + "DATE '2023-01-01'"), + Arguments.of( + Types.TimestampType.withZone(), + Literal.of("2017-11-30T10:30:07.123456+00:00").to(Types.TimestampType.withZone()), + Literal.of("2017-11-29T10:30:07.123456+00:00").to(Types.TimestampType.withZone()), + "TIMESTAMP '2017-11-30 10:30:07.123456'", + "TIMESTAMP '2017-11-29 10:30:07.123456'"), + Arguments.of( + Types.TimestampType.withoutZone(), + Literal.of("2017-11-30T10:30:07.123456").to(Types.TimestampType.withoutZone()), + Literal.of("2017-11-29T10:30:07.123456").to(Types.TimestampType.withoutZone()), + "TIMESTAMP_NTZ '2017-11-30 10:30:07.123456'", + "TIMESTAMP_NTZ '2017-11-29 10:30:07.123456'"), + Arguments.of( + Types.BinaryType.get(), + Literal.of(ByteBuffer.wrap(new byte[] {0x0a, 0x0b})), + Literal.of(ByteBuffer.wrap(new byte[] {0x01, 0x02})), + "X'0A0B'", + "X'0102'"), + Arguments.of( + Types.FixedType.ofLength(4), + Literal.of("test".getBytes()), + Literal.of("init".getBytes()), + "X'74657374'", + "X'696E6974'")); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java new file mode 100644 index 000000000000..85408ffff1d9 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestSparkSessionCatalog extends TestBase { + private final String envHmsUriKey = "spark.hadoop." + METASTOREURIS.varname; + private final String catalogHmsUriKey = "spark.sql.catalog.spark_catalog.uri"; + private final String hmsUri = hiveConf.get(METASTOREURIS.varname); + + @BeforeAll + public static void setUpCatalog() { + spark + .conf() + .set("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog"); + spark.conf().set("spark.sql.catalog.spark_catalog.type", "hive"); + } + + @BeforeEach + public void setupHmsUri() { + spark.sessionState().catalogManager().reset(); + spark.conf().set(envHmsUriKey, hmsUri); + spark.conf().set(catalogHmsUriKey, hmsUri); + } + + @Test + public void testValidateHmsUri() { + // HMS uris match + assertThat(spark.sessionState().catalogManager().v2SessionCatalog().defaultNamespace()) + .containsExactly("default"); + + // HMS uris doesn't match + spark.sessionState().catalogManager().reset(); + String catalogHmsUri = "RandomString"; + spark.conf().set(envHmsUriKey, hmsUri); + spark.conf().set(catalogHmsUriKey, catalogHmsUri); + + assertThatThrownBy(() -> spark.sessionState().catalogManager().v2SessionCatalog()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + String.format( + "Inconsistent Hive metastore URIs: %s (Spark session) != %s (spark_catalog)", + hmsUri, catalogHmsUri)); + + // no env HMS uri, only catalog HMS uri + spark.sessionState().catalogManager().reset(); + spark.conf().set(catalogHmsUriKey, hmsUri); + spark.conf().unset(envHmsUriKey); + assertThat(spark.sessionState().catalogManager().v2SessionCatalog().defaultNamespace()) + .containsExactly("default"); + + // no catalog HMS uri, only env HMS uri + spark.sessionState().catalogManager().reset(); + spark.conf().set(envHmsUriKey, hmsUri); + spark.conf().unset(catalogHmsUriKey); + assertThat(spark.sessionState().catalogManager().v2SessionCatalog().defaultNamespace()) + .containsExactly("default"); + } + + @Test + public void testLoadFunction() { + String functionClass = "org.apache.hadoop.hive.ql.udf.generic.GenericUDFUpper"; + + // load permanent UDF in Hive via FunctionCatalog + spark.sql(String.format("CREATE FUNCTION perm_upper AS '%s'", functionClass)); + assertThat(scalarSql("SELECT perm_upper('xyz')")) + .as("Load permanent UDF in Hive") + .isEqualTo("XYZ"); + + // load temporary UDF in Hive via FunctionCatalog + spark.sql(String.format("CREATE TEMPORARY FUNCTION temp_upper AS '%s'", functionClass)); + assertThat(scalarSql("SELECT temp_upper('xyz')")) + .as("Load temporary UDF in Hive") + .isEqualTo("XYZ"); + + // TODO: fix loading Iceberg built-in functions in SessionCatalog + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkTableUtil.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkTableUtil.java new file mode 100644 index 000000000000..93e4c8968715 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkTableUtil.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.Map; +import org.apache.iceberg.KryoHelpers; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.MetricsModes; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkTableUtil.SparkPartition; +import org.junit.jupiter.api.Test; + +public class TestSparkTableUtil { + @Test + public void testSparkPartitionOKryoSerialization() throws IOException { + Map values = ImmutableMap.of("id", "2"); + String uri = "s3://bucket/table/data/id=2"; + String format = "parquet"; + SparkPartition sparkPartition = new SparkPartition(values, uri, format); + + SparkPartition deserialized = KryoHelpers.roundTripSerialize(sparkPartition); + assertThat(sparkPartition).isEqualTo(deserialized); + } + + @Test + public void testSparkPartitionJavaSerialization() throws IOException, ClassNotFoundException { + Map values = ImmutableMap.of("id", "2"); + String uri = "s3://bucket/table/data/id=2"; + String format = "parquet"; + SparkPartition sparkPartition = new SparkPartition(values, uri, format); + + SparkPartition deserialized = TestHelpers.roundTripSerialize(sparkPartition); + assertThat(sparkPartition).isEqualTo(deserialized); + } + + @Test + public void testMetricsConfigKryoSerialization() throws Exception { + Map metricsConfig = + ImmutableMap.of( + TableProperties.DEFAULT_WRITE_METRICS_MODE, + "counts", + TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "col1", + "full", + TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "col2", + "truncate(16)"); + + MetricsConfig config = MetricsConfig.fromProperties(metricsConfig); + MetricsConfig deserialized = KryoHelpers.roundTripSerialize(config); + + assertThat(deserialized.columnMode("col1")) + .asString() + .isEqualTo(MetricsModes.Full.get().toString()); + assertThat(deserialized.columnMode("col2")) + .asString() + .isEqualTo(MetricsModes.Truncate.withLength(16).toString()); + assertThat(deserialized.columnMode("col3")) + .asString() + .isEqualTo(MetricsModes.Counts.get().toString()); + } + + @Test + public void testMetricsConfigJavaSerialization() throws Exception { + Map metricsConfig = + ImmutableMap.of( + TableProperties.DEFAULT_WRITE_METRICS_MODE, + "counts", + TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "col1", + "full", + TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "col2", + "truncate(16)"); + + MetricsConfig config = MetricsConfig.fromProperties(metricsConfig); + MetricsConfig deserialized = TestHelpers.roundTripSerialize(config); + + assertThat(deserialized.columnMode("col1")) + .asString() + .isEqualTo(MetricsModes.Full.get().toString()); + assertThat(deserialized.columnMode("col2")) + .asString() + .isEqualTo(MetricsModes.Truncate.withLength(16).toString()); + assertThat(deserialized.columnMode("col3")) + .asString() + .isEqualTo(MetricsModes.Counts.get().toString()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkV2Filters.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkV2Filters.java new file mode 100644 index 000000000000..e0b590e5a6e8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkV2Filters.java @@ -0,0 +1,853 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.ExpressionUtil; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.expressions.UnboundTerm; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.functions.BucketFunction; +import org.apache.iceberg.spark.functions.DaysFunction; +import org.apache.iceberg.spark.functions.HoursFunction; +import org.apache.iceberg.spark.functions.IcebergVersionFunction; +import org.apache.iceberg.spark.functions.MonthsFunction; +import org.apache.iceberg.spark.functions.TruncateFunction; +import org.apache.iceberg.spark.functions.YearsFunction; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.DateTimeUtil; +import org.apache.spark.sql.connector.catalog.functions.ScalarFunction; +import org.apache.spark.sql.connector.expressions.FieldReference; +import org.apache.spark.sql.connector.expressions.LiteralValue; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.UserDefinedScalarFunc; +import org.apache.spark.sql.connector.expressions.filter.And; +import org.apache.spark.sql.connector.expressions.filter.Not; +import org.apache.spark.sql.connector.expressions.filter.Or; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; +import org.junit.jupiter.api.Test; + +public class TestSparkV2Filters { + + private static final Types.StructType STRUCT = + Types.StructType.of( + Types.NestedField.optional(1, "dateCol", Types.DateType.get()), + Types.NestedField.optional(2, "tsCol", Types.TimestampType.withZone()), + Types.NestedField.optional(3, "tsNtzCol", Types.TimestampType.withoutZone()), + Types.NestedField.optional(4, "intCol", Types.IntegerType.get()), + Types.NestedField.optional(5, "strCol", Types.StringType.get())); + + @SuppressWarnings("checkstyle:MethodLength") + @Test + public void testV2Filters() { + Map attrMap = Maps.newHashMap(); + attrMap.put("id", "id"); + attrMap.put("`i.d`", "i.d"); + attrMap.put("`i``d`", "i`d"); + attrMap.put("`d`.b.`dd```", "d.b.dd`"); + attrMap.put("a.`aa```.c", "a.aa`.c"); + + attrMap.forEach( + (quoted, unquoted) -> { + NamedReference namedReference = FieldReference.apply(quoted); + org.apache.spark.sql.connector.expressions.Expression[] attrOnly = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference}; + + LiteralValue value = new LiteralValue(1, DataTypes.IntegerType); + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, value}; + org.apache.spark.sql.connector.expressions.Expression[] valueAndAttr = + new org.apache.spark.sql.connector.expressions.Expression[] {value, namedReference}; + + Predicate isNull = new Predicate("IS_NULL", attrOnly); + Expression expectedIsNull = Expressions.isNull(unquoted); + Expression actualIsNull = SparkV2Filters.convert(isNull); + assertThat(actualIsNull) + .asString() + .as("IsNull must match") + .isEqualTo(expectedIsNull.toString()); + + Predicate isNotNull = new Predicate("IS_NOT_NULL", attrOnly); + Expression expectedIsNotNull = Expressions.notNull(unquoted); + Expression actualIsNotNull = SparkV2Filters.convert(isNotNull); + assertThat(actualIsNotNull) + .asString() + .as("IsNotNull must match") + .isEqualTo(expectedIsNotNull.toString()); + + Predicate lt1 = new Predicate("<", attrAndValue); + Expression expectedLt1 = Expressions.lessThan(unquoted, 1); + Expression actualLt1 = SparkV2Filters.convert(lt1); + assertThat(actualLt1) + .asString() + .as("LessThan must match") + .isEqualTo(expectedLt1.toString()); + + Predicate lt2 = new Predicate("<", valueAndAttr); + Expression expectedLt2 = Expressions.greaterThan(unquoted, 1); + Expression actualLt2 = SparkV2Filters.convert(lt2); + assertThat(actualLt2) + .asString() + .as("LessThan must match") + .isEqualTo(expectedLt2.toString()); + + Predicate ltEq1 = new Predicate("<=", attrAndValue); + Expression expectedLtEq1 = Expressions.lessThanOrEqual(unquoted, 1); + Expression actualLtEq1 = SparkV2Filters.convert(ltEq1); + assertThat(actualLtEq1) + .asString() + .as("LessThanOrEqual must match") + .isEqualTo(expectedLtEq1.toString()); + + Predicate ltEq2 = new Predicate("<=", valueAndAttr); + Expression expectedLtEq2 = Expressions.greaterThanOrEqual(unquoted, 1); + Expression actualLtEq2 = SparkV2Filters.convert(ltEq2); + assertThat(actualLtEq2) + .asString() + .as("LessThanOrEqual must match") + .isEqualTo(expectedLtEq2.toString()); + + Predicate gt1 = new Predicate(">", attrAndValue); + Expression expectedGt1 = Expressions.greaterThan(unquoted, 1); + Expression actualGt1 = SparkV2Filters.convert(gt1); + assertThat(actualGt1) + .asString() + .as("GreaterThan must match") + .isEqualTo(expectedGt1.toString()); + + Predicate gt2 = new Predicate(">", valueAndAttr); + Expression expectedGt2 = Expressions.lessThan(unquoted, 1); + Expression actualGt2 = SparkV2Filters.convert(gt2); + assertThat(actualGt2) + .asString() + .as("GreaterThan must match") + .isEqualTo(expectedGt2.toString()); + + Predicate gtEq1 = new Predicate(">=", attrAndValue); + Expression expectedGtEq1 = Expressions.greaterThanOrEqual(unquoted, 1); + Expression actualGtEq1 = SparkV2Filters.convert(gtEq1); + assertThat(actualGtEq1) + .asString() + .as("GreaterThanOrEqual must match") + .isEqualTo(expectedGtEq1.toString()); + + Predicate gtEq2 = new Predicate(">=", valueAndAttr); + Expression expectedGtEq2 = Expressions.lessThanOrEqual(unquoted, 1); + Expression actualGtEq2 = SparkV2Filters.convert(gtEq2); + assertThat(actualGtEq2) + .asString() + .as("GreaterThanOrEqual must match") + .isEqualTo(expectedGtEq2.toString()); + + Predicate eq1 = new Predicate("=", attrAndValue); + Expression expectedEq1 = Expressions.equal(unquoted, 1); + Expression actualEq1 = SparkV2Filters.convert(eq1); + assertThat(actualEq1) + .asString() + .as("EqualTo must match") + .isEqualTo(expectedEq1.toString()); + + Predicate eq2 = new Predicate("=", valueAndAttr); + Expression expectedEq2 = Expressions.equal(unquoted, 1); + Expression actualEq2 = SparkV2Filters.convert(eq2); + assertThat(actualEq2) + .asString() + .as("EqualTo must match") + .isEqualTo(expectedEq2.toString()); + + Predicate notEq1 = new Predicate("<>", attrAndValue); + Expression expectedNotEq1 = Expressions.notEqual(unquoted, 1); + Expression actualNotEq1 = SparkV2Filters.convert(notEq1); + assertThat(actualNotEq1) + .asString() + .as("NotEqualTo must match") + .isEqualTo(expectedNotEq1.toString()); + + Predicate notEq2 = new Predicate("<>", valueAndAttr); + Expression expectedNotEq2 = Expressions.notEqual(unquoted, 1); + Expression actualNotEq2 = SparkV2Filters.convert(notEq2); + assertThat(actualNotEq2) + .asString() + .as("NotEqualTo must match") + .isEqualTo(expectedNotEq2.toString()); + + Predicate eqNullSafe1 = new Predicate("<=>", attrAndValue); + Expression expectedEqNullSafe1 = Expressions.equal(unquoted, 1); + Expression actualEqNullSafe1 = SparkV2Filters.convert(eqNullSafe1); + assertThat(actualEqNullSafe1) + .asString() + .as("EqualNullSafe must match") + .isEqualTo(expectedEqNullSafe1.toString()); + + Predicate eqNullSafe2 = new Predicate("<=>", valueAndAttr); + Expression expectedEqNullSafe2 = Expressions.equal(unquoted, 1); + Expression actualEqNullSafe2 = SparkV2Filters.convert(eqNullSafe2); + assertThat(actualEqNullSafe2) + .asString() + .as("EqualNullSafe must match") + .isEqualTo(expectedEqNullSafe2.toString()); + + LiteralValue str = + new LiteralValue(UTF8String.fromString("iceberg"), DataTypes.StringType); + org.apache.spark.sql.connector.expressions.Expression[] attrAndStr = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, str}; + Predicate startsWith = new Predicate("STARTS_WITH", attrAndStr); + Expression expectedStartsWith = Expressions.startsWith(unquoted, "iceberg"); + Expression actualStartsWith = SparkV2Filters.convert(startsWith); + assertThat(actualStartsWith) + .asString() + .as("StartsWith must match") + .isEqualTo(expectedStartsWith.toString()); + + Predicate in = new Predicate("IN", attrAndValue); + Expression expectedIn = Expressions.in(unquoted, 1); + Expression actualIn = SparkV2Filters.convert(in); + assertThat(actualIn).asString().as("In must match").isEqualTo(expectedIn.toString()); + + Predicate and = new And(lt1, eq1); + Expression expectedAnd = Expressions.and(expectedLt1, expectedEq1); + Expression actualAnd = SparkV2Filters.convert(and); + assertThat(actualAnd).asString().as("And must match").isEqualTo(expectedAnd.toString()); + + org.apache.spark.sql.connector.expressions.Expression[] attrAndAttr = + new org.apache.spark.sql.connector.expressions.Expression[] { + namedReference, namedReference + }; + Predicate invalid = new Predicate("<", attrAndAttr); + Predicate andWithInvalidLeft = new And(invalid, eq1); + Expression convertedAnd = SparkV2Filters.convert(andWithInvalidLeft); + assertThat(convertedAnd).as("And must match").isNull(); + + Predicate or = new Or(lt1, eq1); + Expression expectedOr = Expressions.or(expectedLt1, expectedEq1); + Expression actualOr = SparkV2Filters.convert(or); + assertThat(actualOr).asString().as("Or must match").isEqualTo(expectedOr.toString()); + + Predicate orWithInvalidLeft = new Or(invalid, eq1); + Expression convertedOr = SparkV2Filters.convert(orWithInvalidLeft); + assertThat(convertedOr).as("Or must match").isNull(); + + Predicate not = new Not(lt1); + Expression expectedNot = Expressions.not(expectedLt1); + Expression actualNot = SparkV2Filters.convert(not); + assertThat(actualNot).asString().as("Not must match").isEqualTo(expectedNot.toString()); + }); + } + + @Test + public void testEqualToNull() { + String col = "col"; + NamedReference namedReference = FieldReference.apply(col); + LiteralValue value = new LiteralValue(null, DataTypes.IntegerType); + + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, value}; + org.apache.spark.sql.connector.expressions.Expression[] valueAndAttr = + new org.apache.spark.sql.connector.expressions.Expression[] {value, namedReference}; + + Predicate eq1 = new Predicate("=", attrAndValue); + assertThatThrownBy(() -> SparkV2Filters.convert(eq1)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Expression is always false"); + + Predicate eq2 = new Predicate("=", valueAndAttr); + assertThatThrownBy(() -> SparkV2Filters.convert(eq2)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Expression is always false"); + + Predicate eqNullSafe1 = new Predicate("<=>", attrAndValue); + Expression expectedEqNullSafe = Expressions.isNull(col); + Expression actualEqNullSafe1 = SparkV2Filters.convert(eqNullSafe1); + assertThat(actualEqNullSafe1.toString()).isEqualTo(expectedEqNullSafe.toString()); + + Predicate eqNullSafe2 = new Predicate("<=>", valueAndAttr); + Expression actualEqNullSafe2 = SparkV2Filters.convert(eqNullSafe2); + assertThat(actualEqNullSafe2.toString()).isEqualTo(expectedEqNullSafe.toString()); + } + + @Test + public void testEqualToNaN() { + String col = "col"; + NamedReference namedReference = FieldReference.apply(col); + LiteralValue value = new LiteralValue(Float.NaN, DataTypes.FloatType); + + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, value}; + org.apache.spark.sql.connector.expressions.Expression[] valueAndAttr = + new org.apache.spark.sql.connector.expressions.Expression[] {value, namedReference}; + + Predicate eqNaN1 = new Predicate("=", attrAndValue); + Expression expectedEqNaN = Expressions.isNaN(col); + Expression actualEqNaN1 = SparkV2Filters.convert(eqNaN1); + assertThat(actualEqNaN1.toString()).isEqualTo(expectedEqNaN.toString()); + + Predicate eqNaN2 = new Predicate("=", valueAndAttr); + Expression actualEqNaN2 = SparkV2Filters.convert(eqNaN2); + assertThat(actualEqNaN2.toString()).isEqualTo(expectedEqNaN.toString()); + } + + @Test + public void testNotEqualToNull() { + String col = "col"; + NamedReference namedReference = FieldReference.apply(col); + LiteralValue value = new LiteralValue(null, DataTypes.IntegerType); + + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, value}; + org.apache.spark.sql.connector.expressions.Expression[] valueAndAttr = + new org.apache.spark.sql.connector.expressions.Expression[] {value, namedReference}; + + Predicate notEq1 = new Predicate("<>", attrAndValue); + assertThatThrownBy(() -> SparkV2Filters.convert(notEq1)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Expression is always false"); + + Predicate notEq2 = new Predicate("<>", valueAndAttr); + assertThatThrownBy(() -> SparkV2Filters.convert(notEq2)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("Expression is always false"); + } + + @Test + public void testNotEqualToNaN() { + String col = "col"; + NamedReference namedReference = FieldReference.apply(col); + LiteralValue value = new LiteralValue(Float.NaN, DataTypes.FloatType); + + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, value}; + org.apache.spark.sql.connector.expressions.Expression[] valueAndAttr = + new org.apache.spark.sql.connector.expressions.Expression[] {value, namedReference}; + + Predicate notEqNaN1 = new Predicate("<>", attrAndValue); + Expression expectedNotEqNaN = Expressions.notNaN(col); + Expression actualNotEqNaN1 = SparkV2Filters.convert(notEqNaN1); + assertThat(actualNotEqNaN1.toString()).isEqualTo(expectedNotEqNaN.toString()); + + Predicate notEqNaN2 = new Predicate("<>", valueAndAttr); + Expression actualNotEqNaN2 = SparkV2Filters.convert(notEqNaN2); + assertThat(actualNotEqNaN2.toString()).isEqualTo(expectedNotEqNaN.toString()); + } + + @Test + public void testInValuesContainNull() { + String col = "strCol"; + NamedReference namedReference = FieldReference.apply(col); + LiteralValue nullValue = new LiteralValue(null, DataTypes.StringType); + LiteralValue value1 = new LiteralValue("value1", DataTypes.StringType); + LiteralValue value2 = new LiteralValue("value2", DataTypes.StringType); + + // Values only contains null + Predicate inNull = new Predicate("IN", expressions(namedReference, nullValue)); + Expression expectedInNull = Expressions.in(col); + Expression actualInNull = SparkV2Filters.convert(inNull); + assertEquals(expectedInNull, actualInNull); + + Predicate in = new Predicate("IN", expressions(namedReference, nullValue, value1, value2)); + Expression expectedIn = Expressions.in(col, "value1", "value2"); + Expression actualIn = SparkV2Filters.convert(in); + assertEquals(expectedIn, actualIn); + } + + @Test + public void testNotInNull() { + String col = "strCol"; + NamedReference namedReference = FieldReference.apply(col); + LiteralValue nullValue = new LiteralValue(null, DataTypes.StringType); + LiteralValue value1 = new LiteralValue("value1", DataTypes.StringType); + LiteralValue value2 = new LiteralValue("value2", DataTypes.StringType); + + // Values only contains null + Predicate notInNull = new Not(new Predicate("IN", expressions(namedReference, nullValue))); + Expression expectedNotInNull = + Expressions.and(Expressions.notNull(col), Expressions.notIn(col)); + Expression actualNotInNull = SparkV2Filters.convert(notInNull); + assertEquals(expectedNotInNull, actualNotInNull); + + Predicate notIn = + new Not(new Predicate("IN", expressions(namedReference, nullValue, value1, value2))); + Expression expectedNotIn = + Expressions.and(Expressions.notNull(col), Expressions.notIn(col, "value1", "value2")); + Expression actualNotIn = SparkV2Filters.convert(notIn); + assertEquals(expectedNotIn, actualNotIn); + } + + @Test + public void testTimestampFilterConversion() { + Instant instant = Instant.parse("2018-10-18T00:00:57.907Z"); + long epochMicros = ChronoUnit.MICROS.between(Instant.EPOCH, instant); + + NamedReference namedReference = FieldReference.apply("x"); + LiteralValue ts = new LiteralValue(epochMicros, DataTypes.TimestampType); + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, ts}; + + Predicate predicate = new Predicate(">", attrAndValue); + Expression tsExpression = SparkV2Filters.convert(predicate); + Expression rawExpression = Expressions.greaterThan("x", epochMicros); + + assertThat(tsExpression) + .asString() + .as("Generated Timestamp expression should be correct") + .isEqualTo(rawExpression.toString()); + } + + @Test + public void testDateFilterConversion() { + LocalDate localDate = LocalDate.parse("2018-10-18"); + long epochDay = localDate.toEpochDay(); + + NamedReference namedReference = FieldReference.apply("x"); + LiteralValue ts = new LiteralValue(epochDay, DataTypes.DateType); + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, ts}; + + Predicate predicate = new Predicate(">", attrAndValue); + Expression dateExpression = SparkV2Filters.convert(predicate); + Expression rawExpression = Expressions.greaterThan("x", epochDay); + + assertThat(dateExpression) + .asString() + .as("Generated date expression should be correct") + .isEqualTo(rawExpression.toString()); + } + + @Test + public void testNestedInInsideNot() { + NamedReference namedReference1 = FieldReference.apply("col1"); + LiteralValue v1 = new LiteralValue(1, DataTypes.IntegerType); + LiteralValue v2 = new LiteralValue(2, DataTypes.IntegerType); + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue1 = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference1, v1}; + Predicate equal = new Predicate("=", attrAndValue1); + + NamedReference namedReference2 = FieldReference.apply("col2"); + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue2 = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference2, v1, v2}; + Predicate in = new Predicate("IN", attrAndValue2); + + Not filter = new Not(new And(equal, in)); + Expression converted = SparkV2Filters.convert(filter); + assertThat(converted).as("Expression should not be converted").isNull(); + } + + @Test + public void testNotIn() { + NamedReference namedReference = FieldReference.apply("col"); + LiteralValue v1 = new LiteralValue(1, DataTypes.IntegerType); + LiteralValue v2 = new LiteralValue(2, DataTypes.IntegerType); + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + new org.apache.spark.sql.connector.expressions.Expression[] {namedReference, v1, v2}; + + Predicate in = new Predicate("IN", attrAndValue); + Not not = new Not(in); + + Expression actual = SparkV2Filters.convert(not); + Expression expected = + Expressions.and(Expressions.notNull("col"), Expressions.notIn("col", 1, 2)); + assertThat(actual).asString().as("Expressions should match").isEqualTo(expected.toString()); + } + + @Test + public void testDateToYears() { + ScalarFunction dateToYearsFunc = new YearsFunction.DateToYearsFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + dateToYearsFunc.name(), + dateToYearsFunc.canonicalName(), + expressions(FieldReference.apply("dateCol"))); + testUDF(udf, Expressions.year("dateCol"), dateToYears("2023-06-25"), DataTypes.IntegerType); + } + + @Test + public void testTsToYears() { + ScalarFunction tsToYearsFunc = new YearsFunction.TimestampToYearsFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsToYearsFunc.name(), + tsToYearsFunc.canonicalName(), + expressions(FieldReference.apply("tsCol"))); + testUDF( + udf, + Expressions.year("tsCol"), + timestampToYears("2023-12-03T10:15:30+01:00"), + DataTypes.IntegerType); + } + + @Test + public void testTsNtzToYears() { + ScalarFunction tsNtzToYearsFunc = new YearsFunction.TimestampNtzToYearsFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsNtzToYearsFunc.name(), + tsNtzToYearsFunc.canonicalName(), + expressions(FieldReference.apply("tsNtzCol"))); + testUDF( + udf, + Expressions.year("tsNtzCol"), + timestampNtzToYears("2023-06-25T13:15:30"), + DataTypes.IntegerType); + } + + @Test + public void testDateToMonths() { + ScalarFunction dateToMonthsFunc = new MonthsFunction.DateToMonthsFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + dateToMonthsFunc.name(), + dateToMonthsFunc.canonicalName(), + expressions(FieldReference.apply("dateCol"))); + testUDF(udf, Expressions.month("dateCol"), dateToMonths("2023-06-25"), DataTypes.IntegerType); + } + + @Test + public void testTsToMonths() { + ScalarFunction tsToMonthsFunc = new MonthsFunction.TimestampToMonthsFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsToMonthsFunc.name(), + tsToMonthsFunc.canonicalName(), + expressions(FieldReference.apply("tsCol"))); + testUDF( + udf, + Expressions.month("tsCol"), + timestampToMonths("2023-12-03T10:15:30+01:00"), + DataTypes.IntegerType); + } + + @Test + public void testTsNtzToMonths() { + ScalarFunction tsNtzToMonthsFunc = new MonthsFunction.TimestampNtzToMonthsFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsNtzToMonthsFunc.name(), + tsNtzToMonthsFunc.canonicalName(), + expressions(FieldReference.apply("tsNtzCol"))); + testUDF( + udf, + Expressions.month("tsNtzCol"), + timestampNtzToMonths("2023-12-03T10:15:30"), + DataTypes.IntegerType); + } + + @Test + public void testDateToDays() { + ScalarFunction dateToDayFunc = new DaysFunction.DateToDaysFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + dateToDayFunc.name(), + dateToDayFunc.canonicalName(), + expressions(FieldReference.apply("dateCol"))); + testUDF(udf, Expressions.day("dateCol"), dateToDays("2023-06-25"), DataTypes.IntegerType); + } + + @Test + public void testTsToDays() { + ScalarFunction tsToDaysFunc = new DaysFunction.TimestampToDaysFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsToDaysFunc.name(), + tsToDaysFunc.canonicalName(), + expressions(FieldReference.apply("tsCol"))); + testUDF( + udf, + Expressions.day("tsCol"), + timestampToDays("2023-12-03T10:15:30+01:00"), + DataTypes.IntegerType); + } + + @Test + public void testTsNtzToDays() { + ScalarFunction tsNtzToDaysFunc = new DaysFunction.TimestampNtzToDaysFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsNtzToDaysFunc.name(), + tsNtzToDaysFunc.canonicalName(), + expressions(FieldReference.apply("tsNtzCol"))); + testUDF( + udf, + Expressions.day("tsNtzCol"), + timestampNtzToDays("2023-12-03T10:15:30"), + DataTypes.IntegerType); + } + + @Test + public void testTsToHours() { + ScalarFunction tsToHourFunc = new HoursFunction.TimestampToHoursFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsToHourFunc.name(), + tsToHourFunc.canonicalName(), + expressions(FieldReference.apply("tsCol"))); + testUDF( + udf, + Expressions.hour("tsCol"), + timestampToHours("2023-12-03T10:15:30+01:00"), + DataTypes.IntegerType); + } + + @Test + public void testTsNtzToHours() { + ScalarFunction tsNtzToHourFunc = new HoursFunction.TimestampNtzToHoursFunction(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + tsNtzToHourFunc.name(), + tsNtzToHourFunc.canonicalName(), + expressions(FieldReference.apply("tsNtzCol"))); + testUDF( + udf, + Expressions.hour("tsNtzCol"), + timestampNtzToHours("2023-12-03T10:15:30"), + DataTypes.IntegerType); + } + + @Test + public void testBucket() { + ScalarFunction bucketInt = new BucketFunction.BucketInt(DataTypes.IntegerType); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + bucketInt.name(), + bucketInt.canonicalName(), + expressions( + LiteralValue.apply(4, DataTypes.IntegerType), FieldReference.apply("intCol"))); + testUDF(udf, Expressions.bucket("intCol", 4), 2, DataTypes.IntegerType); + } + + @Test + public void testTruncate() { + ScalarFunction truncate = new TruncateFunction.TruncateString(); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + truncate.name(), + truncate.canonicalName(), + expressions( + LiteralValue.apply(6, DataTypes.IntegerType), FieldReference.apply("strCol"))); + testUDF(udf, Expressions.truncate("strCol", 6), "prefix", DataTypes.StringType); + } + + @Test + public void testUnsupportedUDFConvert() { + ScalarFunction icebergVersionFunc = + (ScalarFunction) new IcebergVersionFunction().bind(new StructType()); + UserDefinedScalarFunc udf = + new UserDefinedScalarFunc( + icebergVersionFunc.name(), + icebergVersionFunc.canonicalName(), + new org.apache.spark.sql.connector.expressions.Expression[] {}); + LiteralValue literalValue = new LiteralValue("1.3.0", DataTypes.StringType); + Predicate predicate = new Predicate("=", expressions(udf, literalValue)); + + Expression icebergExpr = SparkV2Filters.convert(predicate); + assertThat(icebergExpr).isNull(); + } + + private void testUDF( + org.apache.spark.sql.connector.expressions.Expression udf, + UnboundTerm expectedTerm, + T value, + DataType dataType) { + org.apache.spark.sql.connector.expressions.Expression[] attrOnly = expressions(udf); + + LiteralValue literalValue = new LiteralValue(value, dataType); + org.apache.spark.sql.connector.expressions.Expression[] attrAndValue = + expressions(udf, literalValue); + org.apache.spark.sql.connector.expressions.Expression[] valueAndAttr = + expressions(literalValue, udf); + + Predicate isNull = new Predicate("IS_NULL", attrOnly); + Expression expectedIsNull = Expressions.isNull(expectedTerm); + Expression actualIsNull = SparkV2Filters.convert(isNull); + assertEquals(expectedIsNull, actualIsNull); + + Predicate isNotNull = new Predicate("IS_NOT_NULL", attrOnly); + Expression expectedIsNotNull = Expressions.notNull(expectedTerm); + Expression actualIsNotNull = SparkV2Filters.convert(isNotNull); + assertEquals(expectedIsNotNull, actualIsNotNull); + + Predicate lt1 = new Predicate("<", attrAndValue); + Expression expectedLt1 = Expressions.lessThan(expectedTerm, value); + Expression actualLt1 = SparkV2Filters.convert(lt1); + assertEquals(expectedLt1, actualLt1); + + Predicate lt2 = new Predicate("<", valueAndAttr); + Expression expectedLt2 = Expressions.greaterThan(expectedTerm, value); + Expression actualLt2 = SparkV2Filters.convert(lt2); + assertEquals(expectedLt2, actualLt2); + + Predicate ltEq1 = new Predicate("<=", attrAndValue); + Expression expectedLtEq1 = Expressions.lessThanOrEqual(expectedTerm, value); + Expression actualLtEq1 = SparkV2Filters.convert(ltEq1); + assertEquals(expectedLtEq1, actualLtEq1); + + Predicate ltEq2 = new Predicate("<=", valueAndAttr); + Expression expectedLtEq2 = Expressions.greaterThanOrEqual(expectedTerm, value); + Expression actualLtEq2 = SparkV2Filters.convert(ltEq2); + assertEquals(expectedLtEq2, actualLtEq2); + + Predicate gt1 = new Predicate(">", attrAndValue); + Expression expectedGt1 = Expressions.greaterThan(expectedTerm, value); + Expression actualGt1 = SparkV2Filters.convert(gt1); + assertEquals(expectedGt1, actualGt1); + + Predicate gt2 = new Predicate(">", valueAndAttr); + Expression expectedGt2 = Expressions.lessThan(expectedTerm, value); + Expression actualGt2 = SparkV2Filters.convert(gt2); + assertEquals(expectedGt2, actualGt2); + + Predicate gtEq1 = new Predicate(">=", attrAndValue); + Expression expectedGtEq1 = Expressions.greaterThanOrEqual(expectedTerm, value); + Expression actualGtEq1 = SparkV2Filters.convert(gtEq1); + assertEquals(expectedGtEq1, actualGtEq1); + + Predicate gtEq2 = new Predicate(">=", valueAndAttr); + Expression expectedGtEq2 = Expressions.lessThanOrEqual(expectedTerm, value); + Expression actualGtEq2 = SparkV2Filters.convert(gtEq2); + assertEquals(expectedGtEq2, actualGtEq2); + + Predicate eq1 = new Predicate("=", attrAndValue); + Expression expectedEq1 = Expressions.equal(expectedTerm, value); + Expression actualEq1 = SparkV2Filters.convert(eq1); + assertEquals(expectedEq1, actualEq1); + + Predicate eq2 = new Predicate("=", valueAndAttr); + Expression expectedEq2 = Expressions.equal(expectedTerm, value); + Expression actualEq2 = SparkV2Filters.convert(eq2); + assertEquals(expectedEq2, actualEq2); + + Predicate notEq1 = new Predicate("<>", attrAndValue); + Expression expectedNotEq1 = Expressions.notEqual(expectedTerm, value); + Expression actualNotEq1 = SparkV2Filters.convert(notEq1); + assertEquals(expectedNotEq1, actualNotEq1); + + Predicate notEq2 = new Predicate("<>", valueAndAttr); + Expression expectedNotEq2 = Expressions.notEqual(expectedTerm, value); + Expression actualNotEq2 = SparkV2Filters.convert(notEq2); + assertEquals(expectedNotEq2, actualNotEq2); + + Predicate eqNullSafe1 = new Predicate("<=>", attrAndValue); + Expression expectedEqNullSafe1 = Expressions.equal(expectedTerm, value); + Expression actualEqNullSafe1 = SparkV2Filters.convert(eqNullSafe1); + assertEquals(expectedEqNullSafe1, actualEqNullSafe1); + + Predicate eqNullSafe2 = new Predicate("<=>", valueAndAttr); + Expression expectedEqNullSafe2 = Expressions.equal(expectedTerm, value); + Expression actualEqNullSafe2 = SparkV2Filters.convert(eqNullSafe2); + assertEquals(expectedEqNullSafe2, actualEqNullSafe2); + + Predicate in = new Predicate("IN", attrAndValue); + Expression expectedIn = Expressions.in(expectedTerm, value); + Expression actualIn = SparkV2Filters.convert(in); + assertEquals(expectedIn, actualIn); + + Predicate notIn = new Not(in); + Expression expectedNotIn = + Expressions.and(Expressions.notNull(expectedTerm), Expressions.notIn(expectedTerm, value)); + Expression actualNotIn = SparkV2Filters.convert(notIn); + assertEquals(expectedNotIn, actualNotIn); + + Predicate and = new And(lt1, eq1); + Expression expectedAnd = Expressions.and(expectedLt1, expectedEq1); + Expression actualAnd = SparkV2Filters.convert(and); + assertEquals(expectedAnd, actualAnd); + + org.apache.spark.sql.connector.expressions.Expression[] attrAndAttr = expressions(udf, udf); + Predicate invalid = new Predicate("<", attrAndAttr); + Predicate andWithInvalidLeft = new And(invalid, eq1); + Expression convertedAnd = SparkV2Filters.convert(andWithInvalidLeft); + assertThat(convertedAnd).isNull(); + + Predicate or = new Or(lt1, eq1); + Expression expectedOr = Expressions.or(expectedLt1, expectedEq1); + Expression actualOr = SparkV2Filters.convert(or); + assertEquals(expectedOr, actualOr); + + Predicate orWithInvalidLeft = new Or(invalid, eq1); + Expression convertedOr = SparkV2Filters.convert(orWithInvalidLeft); + assertThat(convertedOr).isNull(); + + Predicate not = new Not(lt1); + Expression expectedNot = Expressions.not(expectedLt1); + Expression actualNot = SparkV2Filters.convert(not); + assertEquals(expectedNot, actualNot); + } + + private static void assertEquals(Expression expected, Expression actual) { + assertThat(ExpressionUtil.equivalent(expected, actual, STRUCT, true)).isTrue(); + } + + private org.apache.spark.sql.connector.expressions.Expression[] expressions( + org.apache.spark.sql.connector.expressions.Expression... expressions) { + return expressions; + } + + private static int dateToYears(String dateString) { + return DateTimeUtil.daysToYears(DateTimeUtil.isoDateToDays(dateString)); + } + + private static int timestampToYears(String timestampString) { + return DateTimeUtil.microsToYears(DateTimeUtil.isoTimestamptzToMicros(timestampString)); + } + + private static int timestampNtzToYears(String timestampNtzString) { + return DateTimeUtil.microsToYears(DateTimeUtil.isoTimestampToMicros(timestampNtzString)); + } + + private static int dateToMonths(String dateString) { + return DateTimeUtil.daysToMonths(DateTimeUtil.isoDateToDays(dateString)); + } + + private static int timestampToMonths(String timestampString) { + return DateTimeUtil.microsToMonths(DateTimeUtil.isoTimestamptzToMicros(timestampString)); + } + + private static int timestampNtzToMonths(String timestampNtzString) { + return DateTimeUtil.microsToMonths(DateTimeUtil.isoTimestampToMicros(timestampNtzString)); + } + + private static int dateToDays(String dateString) { + return DateTimeUtil.isoDateToDays(dateString); + } + + private static int timestampToDays(String timestampString) { + return DateTimeUtil.microsToDays(DateTimeUtil.isoTimestamptzToMicros(timestampString)); + } + + private static int timestampNtzToDays(String timestampNtzString) { + return DateTimeUtil.microsToDays(DateTimeUtil.isoTimestampToMicros(timestampNtzString)); + } + + private static int timestampToHours(String timestampString) { + return DateTimeUtil.microsToHours(DateTimeUtil.isoTimestamptzToMicros(timestampString)); + } + + private static int timestampNtzToHours(String timestampNtzString) { + return DateTimeUtil.microsToHours(DateTimeUtil.isoTimestampToMicros(timestampNtzString)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkValueConverter.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkValueConverter.java new file mode 100644 index 000000000000..c7a2e6c18fca --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkValueConverter.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.junit.jupiter.api.Test; + +public class TestSparkValueConverter { + @Test + public void testSparkNullMapConvert() { + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 5, + "locations", + Types.MapType.ofOptional( + 6, + 7, + Types.StringType.get(), + Types.StructType.of( + Types.NestedField.required(1, "lat", Types.FloatType.get()), + Types.NestedField.required(2, "long", Types.FloatType.get()))))); + + assertCorrectNullConversion(schema); + } + + @Test + public void testSparkNullListConvert() { + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 5, "locations", Types.ListType.ofOptional(6, Types.StringType.get()))); + + assertCorrectNullConversion(schema); + } + + @Test + public void testSparkNullStructConvert() { + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 5, + "location", + Types.StructType.of( + Types.NestedField.required(1, "lat", Types.FloatType.get()), + Types.NestedField.required(2, "long", Types.FloatType.get())))); + + assertCorrectNullConversion(schema); + } + + @Test + public void testSparkNullPrimitiveConvert() { + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(5, "location", Types.StringType.get())); + assertCorrectNullConversion(schema); + } + + private void assertCorrectNullConversion(Schema schema) { + Row sparkRow = RowFactory.create(1, null); + Record record = GenericRecord.create(schema); + record.set(0, 1); + assertThat(SparkValueConverter.convert(schema, sparkRow)) + .as("Round-trip conversion should produce original value") + .isEqualTo(record); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java new file mode 100644 index 000000000000..336067c31235 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/TestSparkWriteConf.java @@ -0,0 +1,785 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark; + +import static org.apache.iceberg.TableProperties.AVRO_COMPRESSION; +import static org.apache.iceberg.TableProperties.AVRO_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DELETE_AVRO_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_AVRO_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.DELETE_DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DELETE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.DELETE_ORC_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_ORC_COMPRESSION_STRATEGY; +import static org.apache.iceberg.TableProperties.DELETE_PARQUET_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_PARQUET_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.MERGE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.ORC_COMPRESSION; +import static org.apache.iceberg.TableProperties.ORC_COMPRESSION_STRATEGY; +import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION; +import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION_LEVEL; +import static org.apache.iceberg.TableProperties.PARQUET_SHRED_VARIANTS; +import static org.apache.iceberg.TableProperties.UPDATE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_HASH; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_NONE; +import static org.apache.iceberg.TableProperties.WRITE_DISTRIBUTION_MODE_RANGE; +import static org.apache.iceberg.spark.SparkSQLProperties.COMPRESSION_CODEC; +import static org.apache.iceberg.spark.SparkSQLProperties.COMPRESSION_LEVEL; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.DELETE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.MERGE; +import static org.apache.spark.sql.connector.write.RowLevelOperation.Command.UPDATE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.DistributionMode; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.UpdateProperties; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkWriteConf extends TestBaseWithCatalog { + + @BeforeEach + public void before() { + super.before(); + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date, days(ts))", + tableName); + } + + @AfterEach + public void after() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testOptionCaseInsensitive() { + Table table = validationCatalog.loadTable(tableIdent); + Map options = ImmutableMap.of("option", "value"); + SparkConfParser parser = new SparkConfParser(spark, table, options); + String parsedValue = parser.stringConf().option("oPtIoN").parseOptional(); + assertThat(parsedValue).isEqualTo("value"); + } + + @TestTemplate + public void testCamelCaseSparkSessionConf() { + Table table = validationCatalog.loadTable(tableIdent); + String confName = "spark.sql.iceberg.some-int-conf"; + String sparkConfName = "spark.sql.iceberg.someIntConf"; + + withSQLConf( + ImmutableMap.of(sparkConfName, "1"), + () -> { + SparkConfParser parser = new SparkConfParser(spark, table, ImmutableMap.of()); + Integer value = parser.intConf().sessionConf(confName).parseOptional(); + assertThat(value).isEqualTo(1); + }); + } + + @TestTemplate + public void testCamelCaseSparkOption() { + Table table = validationCatalog.loadTable(tableIdent); + String option = "some-int-option"; + String sparkOption = "someIntOption"; + Map options = ImmutableMap.of(sparkOption, "1"); + SparkConfParser parser = new SparkConfParser(spark, table, options); + Integer value = parser.intConf().option(option).parseOptional(); + assertThat(value).isEqualTo(1); + } + + @TestTemplate + public void testDurationConf() { + Table table = validationCatalog.loadTable(tableIdent); + String confName = "spark.sql.iceberg.some-duration-conf"; + + withSQLConf( + ImmutableMap.of(confName, "10s"), + () -> { + SparkConfParser parser = new SparkConfParser(spark, table, ImmutableMap.of()); + Duration duration = parser.durationConf().sessionConf(confName).parseOptional(); + assertThat(duration).hasSeconds(10); + }); + + withSQLConf( + ImmutableMap.of(confName, "2m"), + () -> { + SparkConfParser parser = new SparkConfParser(spark, table, ImmutableMap.of()); + Duration duration = parser.durationConf().sessionConf(confName).parseOptional(); + assertThat(duration).hasMinutes(2); + }); + } + + @TestTemplate + public void testDeleteGranularityDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + DeleteGranularity value = writeConf.deleteGranularity(); + assertThat(value).isEqualTo(DeleteGranularity.FILE); + } + + @TestTemplate + public void testDeleteGranularityTableProperty() { + Table table = validationCatalog.loadTable(tableIdent); + + table + .updateProperties() + .set(TableProperties.DELETE_GRANULARITY, DeleteGranularity.PARTITION.toString()) + .commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + DeleteGranularity value = writeConf.deleteGranularity(); + assertThat(value).isEqualTo(DeleteGranularity.PARTITION); + } + + @TestTemplate + public void testDeleteGranularityWriteOption() { + Table table = validationCatalog.loadTable(tableIdent); + + table + .updateProperties() + .set(TableProperties.DELETE_GRANULARITY, DeleteGranularity.PARTITION.toString()) + .commit(); + + Map options = + ImmutableMap.of(SparkWriteOptions.DELETE_GRANULARITY, DeleteGranularity.FILE.toString()); + + SparkWriteConf writeConf = + new SparkWriteConf(spark, table, new CaseInsensitiveStringMap(options)); + + DeleteGranularity value = writeConf.deleteGranularity(); + assertThat(value).isEqualTo(DeleteGranularity.FILE); + } + + @TestTemplate + public void testDeleteGranularityInvalidValue() { + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(TableProperties.DELETE_GRANULARITY, "invalid").commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + assertThatThrownBy(writeConf::deleteGranularity) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown delete granularity"); + } + + @TestTemplate + public void testAdvisoryPartitionSize() { + Table table = validationCatalog.loadTable(tableIdent); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + long value1 = writeConf.writeRequirements().advisoryPartitionSize(); + assertThat(value1).isGreaterThan(64L * 1024 * 1024).isLessThan(2L * 1024 * 1024 * 1024); + + spark.conf().set(SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), "2GB"); + long value2 = writeConf.writeRequirements().advisoryPartitionSize(); + assertThat(value2).isEqualTo(2L * 1024 * 1024 * 1024); + + spark.conf().set(SQLConf.ADVISORY_PARTITION_SIZE_IN_BYTES().key(), "10MB"); + long value3 = writeConf.writeRequirements().advisoryPartitionSize(); + assertThat(value3).isGreaterThan(10L * 1024 * 1024); + } + + @TestTemplate + public void testSparkWriteConfDistributionDefault() { + Table table = validationCatalog.loadTable(tableIdent); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + checkMode(DistributionMode.HASH, writeConf); + } + + @TestTemplate + public void testSparkWriteConfDistributionModeWithWriteOption() { + Table table = validationCatalog.loadTable(tableIdent); + + CaseInsensitiveStringMap writeOptions = + new CaseInsensitiveStringMap( + ImmutableMap.of(SparkWriteOptions.DISTRIBUTION_MODE, DistributionMode.NONE.modeName())); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table, writeOptions); + checkMode(DistributionMode.NONE, writeConf); + } + + @TestTemplate + public void testSparkWriteConfDistributionModeWithSessionConfig() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.DISTRIBUTION_MODE, DistributionMode.NONE.modeName()), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + checkMode(DistributionMode.NONE, writeConf); + }); + } + + @TestTemplate + public void testSparkWriteConfDistributionModeWithTableProperties() { + Table table = validationCatalog.loadTable(tableIdent); + + table + .updateProperties() + .set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE) + .set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE) + .set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE) + .set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_NONE) + .commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + checkMode(DistributionMode.NONE, writeConf); + } + + @TestTemplate + public void testSparkWriteConfDistributionModeWithTblPropAndSessionConfig() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.DISTRIBUTION_MODE, DistributionMode.NONE.modeName()), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + + table + .updateProperties() + .set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE) + .set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE) + .set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE) + .set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_RANGE) + .commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + // session config overwrite the table properties + checkMode(DistributionMode.NONE, writeConf); + }); + } + + @TestTemplate + public void testSparkWriteConfDistributionModeWithWriteOptionAndSessionConfig() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.DISTRIBUTION_MODE, DistributionMode.RANGE.modeName()), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + + CaseInsensitiveStringMap writeOptions = + new CaseInsensitiveStringMap( + ImmutableMap.of( + SparkWriteOptions.DISTRIBUTION_MODE, DistributionMode.NONE.modeName())); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table, writeOptions); + // write options overwrite the session config + checkMode(DistributionMode.NONE, writeConf); + }); + } + + @TestTemplate + public void testSparkWriteConfDistributionModeWithEverything() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.DISTRIBUTION_MODE, DistributionMode.RANGE.modeName()), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + + CaseInsensitiveStringMap writeOptions = + new CaseInsensitiveStringMap( + ImmutableMap.of( + SparkWriteOptions.DISTRIBUTION_MODE, DistributionMode.NONE.modeName())); + + table + .updateProperties() + .set(WRITE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH) + .set(DELETE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH) + .set(UPDATE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH) + .set(MERGE_DISTRIBUTION_MODE, WRITE_DISTRIBUTION_MODE_HASH) + .commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table, writeOptions); + // write options take the highest priority + checkMode(DistributionMode.NONE, writeConf); + }); + } + + @TestTemplate + public void testSparkConfOverride() { + List>> propertiesSuites = + Lists.newArrayList( + Lists.newArrayList( + ImmutableMap.of(COMPRESSION_CODEC, "zstd", COMPRESSION_LEVEL, "3"), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "parquet", + DELETE_DEFAULT_FILE_FORMAT, + "parquet", + TableProperties.PARQUET_COMPRESSION, + "gzip", + TableProperties.DELETE_PARQUET_COMPRESSION, + "snappy"), + ImmutableMap.of( + PARQUET_SHRED_VARIANTS, + "false", + DELETE_PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION_LEVEL, + "3", + DELETE_PARQUET_COMPRESSION_LEVEL, + "3")), + Lists.newArrayList( + ImmutableMap.of( + COMPRESSION_CODEC, + "zstd", + SparkSQLProperties.COMPRESSION_STRATEGY, + "compression"), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "orc", + DELETE_DEFAULT_FILE_FORMAT, + "orc", + ORC_COMPRESSION, + "zlib", + DELETE_ORC_COMPRESSION, + "snappy"), + ImmutableMap.of( + DELETE_ORC_COMPRESSION, + "zstd", + ORC_COMPRESSION, + "zstd", + DELETE_ORC_COMPRESSION_STRATEGY, + "compression", + ORC_COMPRESSION_STRATEGY, + "compression")), + Lists.newArrayList( + ImmutableMap.of(COMPRESSION_CODEC, "zstd", COMPRESSION_LEVEL, "9"), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "avro", + DELETE_DEFAULT_FILE_FORMAT, + "avro", + AVRO_COMPRESSION, + "gzip", + DELETE_AVRO_COMPRESSION, + "snappy"), + ImmutableMap.of( + DELETE_AVRO_COMPRESSION, + "zstd", + AVRO_COMPRESSION, + "zstd", + AVRO_COMPRESSION_LEVEL, + "9", + DELETE_AVRO_COMPRESSION_LEVEL, + "9"))); + for (List> propertiesSuite : propertiesSuites) { + testWriteProperties(propertiesSuite); + } + } + + @TestTemplate + public void testExtraSnapshotMetadataReflectsSessionConfig() { + withSQLConf( + ImmutableMap.of("spark.sql.iceberg.snapshot-property.test-key", "session-value"), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + + Map metadata = writeConf.extraSnapshotMetadata(); + + assertThat(metadata).containsEntry("test-key", "session-value"); + }); + } + + @TestTemplate + public void testExtraSnapshotMetadataWriteOptionsOverrideSessionConfig() { + withSQLConf( + ImmutableMap.of("spark.sql.iceberg.snapshot-property.test-key", "session-value"), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + CaseInsensitiveStringMap writeOptions = + new CaseInsensitiveStringMap( + ImmutableMap.of("snapshot-property.test-key", "write-option-value")); + SparkWriteConf writeConf = new SparkWriteConf(spark, table, writeOptions); + + Map metadata = writeConf.extraSnapshotMetadata(); + + // Assert that writeOptions take precedence over session config + assertThat(metadata).containsEntry("test-key", "write-option-value"); + }); + } + + @TestTemplate + public void testExtraSnapshotMetadataPersistedOnWrite() { + String propertyKey = "test-key"; + String propertyValue = "session-value"; + + withSQLConf( + ImmutableMap.of("spark.sql.iceberg.snapshot-property." + propertyKey, propertyValue), + () -> { + spark.sql( + String.format( + "INSERT INTO %s VALUES (1, 'a', DATE '2021-01-01', TIMESTAMP '2021-01-01 00:00:00')", + tableName)); + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.currentSnapshot().summary()).containsEntry(propertyKey, propertyValue); + }); + } + + @TestTemplate + public void testDataPropsDefaultsAsDeleteProps() { + List>> propertiesSuites = + Lists.newArrayList( + Lists.newArrayList( + ImmutableMap.of(), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "parquet", + DELETE_DEFAULT_FILE_FORMAT, + "parquet", + PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION_LEVEL, + "5"), + ImmutableMap.of( + PARQUET_SHRED_VARIANTS, + "false", + DELETE_PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION_LEVEL, + "5", + DELETE_PARQUET_COMPRESSION_LEVEL, + "5")), + Lists.newArrayList( + ImmutableMap.of(), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "orc", + DELETE_DEFAULT_FILE_FORMAT, + "orc", + ORC_COMPRESSION, + "snappy", + ORC_COMPRESSION_STRATEGY, + "speed"), + ImmutableMap.of( + DELETE_ORC_COMPRESSION, + "snappy", + ORC_COMPRESSION, + "snappy", + ORC_COMPRESSION_STRATEGY, + "speed", + DELETE_ORC_COMPRESSION_STRATEGY, + "speed")), + Lists.newArrayList( + ImmutableMap.of(), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "avro", + DELETE_DEFAULT_FILE_FORMAT, + "avro", + AVRO_COMPRESSION, + "snappy", + AVRO_COMPRESSION_LEVEL, + "9"), + ImmutableMap.of( + DELETE_AVRO_COMPRESSION, + "snappy", + AVRO_COMPRESSION, + "snappy", + AVRO_COMPRESSION_LEVEL, + "9", + DELETE_AVRO_COMPRESSION_LEVEL, + "9"))); + for (List> propertiesSuite : propertiesSuites) { + testWriteProperties(propertiesSuite); + } + } + + @TestTemplate + public void testDeleteFileWriteConf() { + List>> propertiesSuites = + Lists.newArrayList( + Lists.newArrayList( + ImmutableMap.of(), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "parquet", + DELETE_DEFAULT_FILE_FORMAT, + "parquet", + TableProperties.PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION_LEVEL, + "5", + DELETE_PARQUET_COMPRESSION_LEVEL, + "6"), + ImmutableMap.of( + PARQUET_SHRED_VARIANTS, + "false", + DELETE_PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION, + "zstd", + PARQUET_COMPRESSION_LEVEL, + "5", + DELETE_PARQUET_COMPRESSION_LEVEL, + "6")), + Lists.newArrayList( + ImmutableMap.of(), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "orc", + DELETE_DEFAULT_FILE_FORMAT, + "orc", + ORC_COMPRESSION, + "snappy", + ORC_COMPRESSION_STRATEGY, + "speed", + DELETE_ORC_COMPRESSION, + "zstd", + DELETE_ORC_COMPRESSION_STRATEGY, + "compression"), + ImmutableMap.of( + DELETE_ORC_COMPRESSION, + "zstd", + ORC_COMPRESSION, + "snappy", + ORC_COMPRESSION_STRATEGY, + "speed", + DELETE_ORC_COMPRESSION_STRATEGY, + "compression")), + Lists.newArrayList( + ImmutableMap.of(), + ImmutableMap.of( + DEFAULT_FILE_FORMAT, + "avro", + DELETE_DEFAULT_FILE_FORMAT, + "avro", + AVRO_COMPRESSION, + "snappy", + AVRO_COMPRESSION_LEVEL, + "9", + DELETE_AVRO_COMPRESSION, + "zstd", + DELETE_AVRO_COMPRESSION_LEVEL, + "16"), + ImmutableMap.of( + DELETE_AVRO_COMPRESSION, + "zstd", + AVRO_COMPRESSION, + "snappy", + AVRO_COMPRESSION_LEVEL, + "9", + DELETE_AVRO_COMPRESSION_LEVEL, + "16"))); + for (List> propertiesSuite : propertiesSuites) { + testWriteProperties(propertiesSuite); + } + } + + @TestTemplate + public void testDVWriteConf() { + Table table = validationCatalog.loadTable(tableIdent); + table.updateProperties().set(TableProperties.FORMAT_VERSION, "3").commit(); + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + assertThat(writeConf.deleteFileFormat()).isEqualTo(FileFormat.PUFFIN); + } + + @TestTemplate + public void testSortOrderWriteConf() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConf = + new SparkWriteConf( + spark, + table, + new CaseInsensitiveStringMap( + ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "1"))); + + assertThat(writeConf.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .isEqualTo(table.sortOrder().orderId()); + } + + @TestTemplate + public void testSortOrderWriteConfWithInvalidId() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfForUnknownSortOrder = + new SparkWriteConf( + spark, + table, + new CaseInsensitiveStringMap( + ImmutableMap.of(SparkWriteOptions.OUTPUT_SORT_ORDER_ID, "999"))); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> writeConfForUnknownSortOrder.outputSortOrderId(SparkWriteRequirements.EMPTY)) + .withMessage( + "Cannot use output sort order id 999 because the table does not contain a sort order with that id"); + } + + @TestTemplate + public void testSortOrderWriteConfWithNoOption() { + Table table = validationCatalog.loadTable(tableIdent); + + table.replaceSortOrder().asc("id").commit(); + + SparkWriteConf writeConfNoOption = new SparkWriteConf(spark, table); + + assertThat(writeConfNoOption.outputSortOrderId(writeConfNoOption.writeRequirements())) + .isEqualTo(table.sortOrder().orderId()); + + assertThat(writeConfNoOption.outputSortOrderId(SparkWriteRequirements.EMPTY)).isEqualTo(0); + } + + private void testWriteProperties(List> propertiesSuite) { + withSQLConf( + propertiesSuite.get(0), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + Map tableProperties = propertiesSuite.get(1); + UpdateProperties updateProperties = table.updateProperties(); + for (Map.Entry entry : tableProperties.entrySet()) { + updateProperties.set(entry.getKey(), entry.getValue()); + } + + updateProperties.commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + Map writeProperties = writeConf.writeProperties(); + Map expectedProperties = propertiesSuite.get(2); + assertThat(writeConf.writeProperties()).hasSameSizeAs(expectedProperties); + for (Map.Entry entry : writeProperties.entrySet()) { + assertThat(expectedProperties).containsEntry(entry.getKey(), entry.getValue()); + } + + table.refresh(); + updateProperties = table.updateProperties(); + for (Map.Entry entry : tableProperties.entrySet()) { + updateProperties.remove(entry.getKey()); + } + + updateProperties.commit(); + }); + } + + private void checkMode(DistributionMode expectedMode, SparkWriteConf writeConf) { + assertThat(writeConf.distributionMode()).isEqualTo(expectedMode); + assertThat(writeConf.copyOnWriteDistributionMode(DELETE)).isEqualTo(expectedMode); + assertThat(writeConf.positionDeltaDistributionMode(DELETE)).isEqualTo(expectedMode); + assertThat(writeConf.copyOnWriteDistributionMode(UPDATE)).isEqualTo(expectedMode); + assertThat(writeConf.positionDeltaDistributionMode(UPDATE)).isEqualTo(expectedMode); + assertThat(writeConf.copyOnWriteDistributionMode(MERGE)).isEqualTo(expectedMode); + assertThat(writeConf.positionDeltaDistributionMode(MERGE)).isEqualTo(expectedMode); + } + + @TestTemplate + public void testShredVariantsDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + assertThat(writeConf.shredVariants()).isFalse(); + } + + @TestTemplate + public void testVariantInferenceBufferSizeDefault() { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + assertThat(writeConf.variantInferenceBufferSize()) + .isEqualTo(TableProperties.PARQUET_VARIANT_BUFFER_SIZE_DEFAULT); + } + + @TestTemplate + public void testVariantInferenceBufferSizeTableProperty() { + Table table = validationCatalog.loadTable(tableIdent); + + table.updateProperties().set(TableProperties.PARQUET_VARIANT_BUFFER_SIZE, "500").commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + assertThat(writeConf.variantInferenceBufferSize()).isEqualTo(500); + } + + @TestTemplate + public void testShredVariantsSessionOverridesTableProperty() { + Table table = validationCatalog.loadTable(tableIdent); + table.updateProperties().set(TableProperties.PARQUET_SHRED_VARIANTS, "false").commit(); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.SHRED_VARIANTS, "true"), + () -> { + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + assertThat(writeConf.shredVariants()).isTrue(); + }); + } + + @TestTemplate + public void testShredVariantsWriteOptionOverridesSessionConf() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.SHRED_VARIANTS, "false"), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = + new SparkWriteConf( + spark, + table, + new CaseInsensitiveStringMap( + ImmutableMap.of(SparkWriteOptions.SHRED_VARIANTS, "true"))); + assertThat(writeConf.shredVariants()).isTrue(); + }); + } + + @TestTemplate + public void testVariantInferenceBufferSizeSessionConf() { + withSQLConf( + ImmutableMap.of(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "250"), + () -> { + Table table = validationCatalog.loadTable(tableIdent); + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + assertThat(writeConf.variantInferenceBufferSize()).isEqualTo(250); + }); + } + + @TestTemplate + public void testWritePropertiesIncludeVariantShredding() { + Table table = validationCatalog.loadTable(tableIdent); + table.updateProperties().set(TableProperties.PARQUET_SHRED_VARIANTS, "true").commit(); + table.updateProperties().set(TableProperties.PARQUET_VARIANT_BUFFER_SIZE, "200").commit(); + + SparkWriteConf writeConf = new SparkWriteConf(spark, table); + Map writeProperties = writeConf.writeProperties(); + assertThat(writeProperties).containsEntry(PARQUET_SHRED_VARIANTS, "true"); + assertThat(writeProperties).containsEntry(TableProperties.PARQUET_VARIANT_BUFFER_SIZE, "200"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java new file mode 100644 index 000000000000..9ca6a9e57b8c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputePartitionStatsAction.java @@ -0,0 +1,301 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.PartitionStatistics; +import org.apache.iceberg.PartitionStatisticsFile; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.types.Types; +import org.assertj.core.groups.Tuple; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestComputePartitionStatsAction extends CatalogTestBase { + + private static final int DEFAULT_SPEC_ID = 0; + private static final long DEFAULT_POS_DEL_RECORD_COUNT = 0L; + private static final int DEFAULT_POS_DEL_FILE_COUNT = 0; + private static final long DEFAULT_EQ_DEL_RECORD_COUNT = 0L; + private static final int DEFAULT_EQ_DEL_FILE_COUNT = 0; + private static final Long DEFAULT_TOTAL_RECORD_COUNT = null; + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void emptyTable() { + createPartitionedTable(); + Table table = validationCatalog.loadTable(tableIdent); + ComputePartitionStatsSparkAction.Result result = + SparkActions.get().computePartitionStats(table).execute(); + assertThat(result.statisticsFile()).isNull(); + } + + @TestTemplate + public void emptyBranch() { + createPartitionedTable(); + Table table = validationCatalog.loadTable(tableIdent); + table.manageSnapshots().createBranch("b1").commit(); + ComputePartitionStatsSparkAction.Result result = + SparkActions.get() + .computePartitionStats(table) + .snapshot(table.refs().get("b1").snapshotId()) + .execute(); + assertThat(result.statisticsFile()).isNull(); + } + + @TestTemplate + public void invalidSnapshot() { + createPartitionedTable(); + Table table = validationCatalog.loadTable(tableIdent); + assertThatThrownBy( + () -> SparkActions.get().computePartitionStats(table).snapshot(42L).execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Snapshot not found: 42"); + } + + @TestTemplate + public void partitionStatsComputeOnLatestSnapshot() throws IOException { + createPartitionedTable(); + // foo, A -> 4 records, + // foo, B -> 2 records, + // bar, A -> 2 records, + // bar, B -> 1 record + sql( + "INSERT into %s values (0, 'foo', 'A'), (1, 'foo', 'A'), (2, 'foo', 'B'), (3, 'foo', 'B')", + tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot1 = table.currentSnapshot(); + sql("INSERT into %s values(4, 'bar', 'A'), (5, 'bar', 'A'), (6, 'bar', 'B')", tableName); + table.refresh(); + Snapshot snapshot2 = table.currentSnapshot(); + sql("INSERT into %s values(7, 'foo', 'A'), (8, 'foo', 'A')", tableName); + // snapshot3 is unused for partition stats as the same partition is modified by snapshot4 + + // delete one record of foo, A + sql("DELETE FROM %s WHERE c1=1", tableName); + table.refresh(); + Snapshot snapshot4 = table.currentSnapshot(); + + assertThat(table.partitionStatisticsFiles()).isEmpty(); + + PartitionStatisticsFile statisticsFile = + SparkActions.get().computePartitionStats(table).execute().statisticsFile(); + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.snapshotId()).isEqualTo(snapshot4.snapshotId()); + // check table metadata registration + assertThat(table.partitionStatisticsFiles()).containsExactly(statisticsFile); + + Types.StructType partitionType = Partitioning.partitionType(table); + validatePartitionStats( + table, + statisticsFile.snapshotId(), + Tuple.tuple( + partitionRecord(partitionType, "foo", "A"), + DEFAULT_SPEC_ID, + 4L, // dataRecordCount (total 4 records for this partition) + 2, // dataFileCount + totalDataFileSizeInBytes("foo", "A"), + 1L, // positionDeleteRecordCount (from delete operation) + 1, // positionDeleteFileCount (from delete operation) + DEFAULT_EQ_DEL_RECORD_COUNT, + DEFAULT_EQ_DEL_FILE_COUNT, + DEFAULT_TOTAL_RECORD_COUNT, + snapshot4.timestampMillis(), // lastUpdatedAt (last modified by snapshot4) + snapshot4.snapshotId() // lastUpdatedSnapshotId + ), + Tuple.tuple( + partitionRecord(partitionType, "foo", "B"), + DEFAULT_SPEC_ID, + 2L, // dataRecordCount + 1, // dataFileCount + totalDataFileSizeInBytes("foo", "B"), + DEFAULT_POS_DEL_RECORD_COUNT, + DEFAULT_POS_DEL_FILE_COUNT, + DEFAULT_EQ_DEL_RECORD_COUNT, + DEFAULT_EQ_DEL_FILE_COUNT, + DEFAULT_TOTAL_RECORD_COUNT, + snapshot1.timestampMillis(), // lastUpdatedAt (added by snapshot1) + snapshot1.snapshotId() // lastUpdatedSnapshotId + ), + Tuple.tuple( + partitionRecord(partitionType, "bar", "A"), + DEFAULT_SPEC_ID, + 2L, // dataRecordCount + 1, // dataFileCount + totalDataFileSizeInBytes("bar", "A"), + DEFAULT_POS_DEL_RECORD_COUNT, + DEFAULT_POS_DEL_FILE_COUNT, + DEFAULT_EQ_DEL_RECORD_COUNT, + DEFAULT_EQ_DEL_FILE_COUNT, + DEFAULT_TOTAL_RECORD_COUNT, + snapshot2.timestampMillis(), // lastUpdatedAt (added by snapshot2) + snapshot2.snapshotId() // lastUpdatedSnapshotId + ), + Tuple.tuple( + partitionRecord(partitionType, "bar", "B"), + DEFAULT_SPEC_ID, + 1L, // dataRecordCount + 1, // dataFileCount + totalDataFileSizeInBytes("bar", "B"), + DEFAULT_POS_DEL_RECORD_COUNT, + DEFAULT_POS_DEL_FILE_COUNT, + DEFAULT_EQ_DEL_RECORD_COUNT, + DEFAULT_EQ_DEL_FILE_COUNT, + DEFAULT_TOTAL_RECORD_COUNT, + snapshot2.timestampMillis(), // lastUpdatedAt + snapshot2.snapshotId() // lastUpdatedSnapshotId + )); + } + + @TestTemplate + public void partitionStatsComputeOnSnapshot() throws IOException { + createPartitionedTableV1(); + // foo, A -> 2 records, + // foo, B -> 1 record, + // bar, A -> 2 records, + sql("INSERT into %s values (0, 'foo', 'A'), (1, 'foo', 'A'), (2, 'foo', 'B')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + Snapshot snapshot1 = table.currentSnapshot(); + sql("INSERT into %s values(3, 'bar', 'A'), (4, 'bar', 'A')", tableName); + table.refresh(); + + assertThat(table.partitionStatisticsFiles()).isEmpty(); + + PartitionStatisticsFile statisticsFile = + SparkActions.get() + .computePartitionStats(table) + .snapshot(snapshot1.snapshotId()) + .execute() + .statisticsFile(); + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + // should be mapped to snapshot1 instead of latest snapshot + assertThat(statisticsFile.snapshotId()).isEqualTo(snapshot1.snapshotId()); + // check table metadata registration + assertThat(table.partitionStatisticsFiles()).containsExactly(statisticsFile); + + Types.StructType partitionType = Partitioning.partitionType(table); + // should contain stats for only partitions of snapshot1 (no entry for partition bar, A) + validatePartitionStats( + table, + statisticsFile.snapshotId(), + Tuple.tuple( + partitionRecord(partitionType, "foo", "A"), + DEFAULT_SPEC_ID, + 2L, // dataRecordCount + 1, // dataFileCount + totalDataFileSizeInBytes("foo", "A"), + DEFAULT_POS_DEL_RECORD_COUNT, + DEFAULT_POS_DEL_FILE_COUNT, + DEFAULT_EQ_DEL_RECORD_COUNT, + DEFAULT_EQ_DEL_FILE_COUNT, + DEFAULT_TOTAL_RECORD_COUNT, + snapshot1.timestampMillis(), // lastUpdatedAt + snapshot1.snapshotId()), // lastUpdatedSnapshotId + Tuple.tuple( + partitionRecord(partitionType, "foo", "B"), + DEFAULT_SPEC_ID, + 1L, // dataRecordCount + 1, // dataFileCount + totalDataFileSizeInBytes("foo", "B"), + DEFAULT_POS_DEL_RECORD_COUNT, + DEFAULT_POS_DEL_FILE_COUNT, + DEFAULT_EQ_DEL_RECORD_COUNT, + DEFAULT_EQ_DEL_FILE_COUNT, + DEFAULT_TOTAL_RECORD_COUNT, + snapshot1.timestampMillis(), // lastUpdatedAt + snapshot1.snapshotId() // lastUpdatedSnapshotId + )); + + // try again on same snapshot + PartitionStatisticsFile newStatsFile = + SparkActions.get() + .computePartitionStats(table) + .snapshot(snapshot1.snapshotId()) + .execute() + .statisticsFile(); + assertThat(newStatsFile).isEqualTo(statisticsFile); + } + + private long totalDataFileSizeInBytes(String col1, String col2) { + return (long) + sql( + "SELECT sum(file_size_in_bytes) FROM %s.data_files WHERE partition.c2 = '%s' AND partition.c3 = '%s'", + tableName, col1, col2) + .get(0)[0]; + } + + private void createPartitionedTable() { + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) USING iceberg PARTITIONED BY (c2, c3) TBLPROPERTIES('write.delete.mode'='merge-on-read')", + tableName); + } + + private void createPartitionedTableV1() { + sql( + "CREATE TABLE %s (c1 int, c2 string, c3 string) USING iceberg PARTITIONED BY (c2, c3) TBLPROPERTIES('format-version'='1')", + tableName); + } + + private void validatePartitionStats(Table table, long snapshotId, Tuple... expectedValues) + throws IOException { + List partitionStats; + try (CloseableIterable recordIterator = + table.newPartitionStatisticsScan().useSnapshot(snapshotId).scan()) { + partitionStats = Lists.newArrayList(recordIterator); + } + + assertThat(partitionStats) + .extracting( + PartitionStatistics::partition, + PartitionStatistics::specId, + PartitionStatistics::dataRecordCount, + PartitionStatistics::dataFileCount, + PartitionStatistics::totalDataFileSizeInBytes, + PartitionStatistics::positionDeleteRecordCount, + PartitionStatistics::positionDeleteFileCount, + PartitionStatistics::equalityDeleteRecordCount, + PartitionStatistics::equalityDeleteFileCount, + PartitionStatistics::totalRecords, + PartitionStatistics::lastUpdatedAt, + PartitionStatistics::lastUpdatedSnapshotId) + .containsExactlyInAnyOrder(expectedValues); + } + + private StructLike partitionRecord(Types.StructType partitionType, String val1, String val2) { + GenericRecord record = GenericRecord.create(partitionType); + record.set(0, val1); + record.set(1, val2); + return record; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputeTableStatsAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputeTableStatsAction.java new file mode 100644 index 000000000000..2252ccda62fe --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestComputeTableStatsAction.java @@ -0,0 +1,443 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.spark.actions.NDVSketchUtil.APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.Files; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.ComputeTableStats; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.iceberg.types.Types; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestComputeTableStatsAction extends CatalogTestBase { + private static final Types.StructType LEAF_STRUCT_TYPE = + Types.StructType.of( + optional(1, "leafLongCol", Types.LongType.get()), + optional(2, "leafDoubleCol", Types.DoubleType.get())); + + private static final Types.StructType NESTED_STRUCT_TYPE = + Types.StructType.of(required(3, "leafStructCol", LEAF_STRUCT_TYPE)); + + private static final Schema NESTED_SCHEMA = + new Schema(required(4, "nestedStructCol", NESTED_STRUCT_TYPE)); + + private static final Schema SCHEMA_WITH_NESTED_COLUMN = + new Schema( + required(4, "nestedStructCol", NESTED_STRUCT_TYPE), + required(5, "stringCol", Types.StringType.get())); + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testLoadingTableDirectly() { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + sql("INSERT into %s values(1, 'abcd')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + SparkActions actions = SparkActions.get(); + ComputeTableStats.Result results = actions.computeTableStats(table).execute(); + StatisticsFile statisticsFile = results.statisticsFile(); + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.blobMetadata()).hasSize(2); + } + + @TestTemplate + public void testComputeTableStatsAction() throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + + // To create multiple splits on the mapper + table + .updateProperties() + .set("read.split.target-size", "100") + .set("write.parquet.row-group-size-bytes", "100") + .commit(); + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark.createDataset(records, Encoders.bean(SimpleRecord.class)).writeTo(tableName).append(); + SparkActions actions = SparkActions.get(); + table.refresh(); + ComputeTableStats.Result results = + actions.computeTableStats(table).columns("id", "data").execute(); + assertThat(results).isNotNull(); + + List statisticsFiles = table.statisticsFiles(); + assertThat(statisticsFiles) + .singleElement() + .satisfies( + statisticsFile -> { + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.blobMetadata()) + .hasSize(2) + .element(0) + .satisfies( + blobMetadata -> { + assertThat(blobMetadata.properties()) + .containsEntry(APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY, "4"); + }); + }); + } + + @TestTemplate + public void testComputeTableStatsActionWithoutExplicitColumns() + throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + SparkActions actions = SparkActions.get(); + ComputeTableStats.Result results = actions.computeTableStats(table).execute(); + assertThat(results).isNotNull(); + + assertThat(table.statisticsFiles()) + .singleElement() + .satisfies( + statisticsFile -> { + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.blobMetadata()) + .hasSize(2) + .satisfiesExactly( + blobMetadata -> { + assertThat(blobMetadata.properties()) + .containsEntry(APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY, "4"); + }, + blobMetadata -> { + assertThat(blobMetadata.properties()) + .containsEntry(APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY, "4"); + }); + }); + } + + @TestTemplate + public void testComputeTableStatsForInvalidColumns() throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + // Append data to create snapshot + sql("INSERT into %s values(1, 'abcd')", tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + SparkActions actions = SparkActions.get(); + assertThatThrownBy(() -> actions.computeTableStats(table).columns("id1").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Can't find column id1 in table"); + } + + @TestTemplate + public void testComputeTableStatsWithNoSnapshots() throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + SparkActions actions = SparkActions.get(); + ComputeTableStats.Result result = actions.computeTableStats(table).columns("id").execute(); + assertThat(result.statisticsFile()).isNull(); + } + + @TestTemplate + public void testComputeTableStatsWithNullValues() throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + List records = + Lists.newArrayList( + new SimpleRecord(1, null), + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + SparkActions actions = SparkActions.get(); + ComputeTableStats.Result results = actions.computeTableStats(table).columns("data").execute(); + assertThat(results).isNotNull(); + + assertThat(table.statisticsFiles()) + .singleElement() + .satisfies( + statisticsFile -> { + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.blobMetadata()) + .singleElement() + .satisfies( + blobMetadata -> { + assertThat(blobMetadata.properties()) + .containsEntry(APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY, "4"); + }); + }); + } + + @TestTemplate + public void testComputeTableStatsWithSnapshotHavingDifferentSchemas() + throws NoSuchTableException, ParseException { + SparkActions actions = SparkActions.get(); + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + // Append data to create snapshot + sql("INSERT into %s values(1, 'abcd')", tableName); + long snapshotId1 = Spark3Util.loadIcebergTable(spark, tableName).currentSnapshot().snapshotId(); + // Snapshot id not specified + Table table = Spark3Util.loadIcebergTable(spark, tableName); + + assertThatNoException() + .isThrownBy(() -> actions.computeTableStats(table).columns("data").execute()); + + sql("ALTER TABLE %s DROP COLUMN %s", tableName, "data"); + // Append data to create snapshot + sql("INSERT into %s values(1)", tableName); + table.refresh(); + long snapshotId2 = Spark3Util.loadIcebergTable(spark, tableName).currentSnapshot().snapshotId(); + + // Snapshot id specified + assertThatNoException() + .isThrownBy( + () -> actions.computeTableStats(table).snapshot(snapshotId1).columns("data").execute()); + + assertThatThrownBy( + () -> actions.computeTableStats(table).snapshot(snapshotId2).columns("data").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Can't find column data in table"); + } + + @TestTemplate + public void testComputeTableStatsWhenSnapshotIdNotSpecified() + throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + // Append data to create snapshot + sql("INSERT into %s values(1, 'abcd')", tableName); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + SparkActions actions = SparkActions.get(); + ComputeTableStats.Result results = actions.computeTableStats(table).columns("data").execute(); + + assertThat(results).isNotNull(); + + assertThat(table.statisticsFiles()) + .singleElement() + .satisfies( + statisticsFile -> { + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.blobMetadata()) + .singleElement() + .satisfies( + blobMetadata -> { + assertThat(blobMetadata.properties()) + .containsEntry(APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY, "1"); + }); + }); + } + + @TestTemplate + public void testComputeTableStatsWithNestedSchema() + throws NoSuchTableException, ParseException, IOException { + List records = Lists.newArrayList(createNestedRecord()); + Table table = + validationCatalog.createTable( + tableIdent, + SCHEMA_WITH_NESTED_COLUMN, + PartitionSpec.unpartitioned(), + ImmutableMap.of()); + DataFile dataFile = FileHelpers.writeDataFile(table, Files.localOutput(temp.toFile()), records); + table.newAppend().appendFile(dataFile).commit(); + + Table tbl = Spark3Util.loadIcebergTable(spark, tableName); + SparkActions actions = SparkActions.get(); + actions.computeTableStats(tbl).execute(); + + tbl.refresh(); + assertThat(tbl.statisticsFiles()) + .singleElement() + .satisfies( + statisticsFile -> { + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.blobMetadata()).hasSize(1); + }); + } + + @TestTemplate + public void testComputeTableStatsWithNoComputableColumns() throws IOException { + List records = Lists.newArrayList(createNestedRecord()); + Table table = + validationCatalog.createTable( + tableIdent, NESTED_SCHEMA, PartitionSpec.unpartitioned(), ImmutableMap.of()); + DataFile dataFile = FileHelpers.writeDataFile(table, Files.localOutput(temp.toFile()), records); + table.newAppend().appendFile(dataFile).commit(); + + table.refresh(); + SparkActions actions = SparkActions.get(); + assertThatThrownBy(() -> actions.computeTableStats(table).execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("No columns found to compute stats"); + } + + @TestTemplate + public void testComputeTableStatsOnByteColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("byte_col", "TINYINT"); + } + + @TestTemplate + public void testComputeTableStatsOnShortColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("short_col", "SMALLINT"); + } + + @TestTemplate + public void testComputeTableStatsOnIntColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("int_col", "INT"); + } + + @TestTemplate + public void testComputeTableStatsOnLongColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("long_col", "BIGINT"); + } + + @TestTemplate + public void testComputeTableStatsOnTimestampColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("timestamp_col", "TIMESTAMP"); + } + + @TestTemplate + public void testComputeTableStatsOnTimestampNtzColumn() + throws NoSuchTableException, ParseException { + testComputeTableStats("timestamp_col", "TIMESTAMP_NTZ"); + } + + @TestTemplate + public void testComputeTableStatsOnDateColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("date_col", "DATE"); + } + + @TestTemplate + public void testComputeTableStatsOnDecimalColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("decimal_col", "DECIMAL(20, 2)"); + } + + @TestTemplate + public void testComputeTableStatsOnBinaryColumn() throws NoSuchTableException, ParseException { + testComputeTableStats("binary_col", "BINARY"); + } + + public void testComputeTableStats(String columnName, String type) + throws NoSuchTableException, ParseException { + sql("CREATE TABLE %s (id int, %s %s) USING iceberg", tableName, columnName, type); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + + Dataset dataDF = randomDataDF(table.schema()); + append(tableName, dataDF); + + SparkActions actions = SparkActions.get(); + table.refresh(); + ComputeTableStats.Result results = + actions.computeTableStats(table).columns(columnName).execute(); + assertThat(results).isNotNull(); + + assertThat(table.statisticsFiles()) + .singleElement() + .satisfies( + statisticsFile -> { + assertThat(statisticsFile.fileSizeInBytes()).isGreaterThan(0); + assertThat(statisticsFile.blobMetadata()) + .singleElement() + .satisfies( + blobMetadata -> { + assertThat(blobMetadata.properties()) + .containsKey(APACHE_DATASKETCHES_THETA_V1_NDV_PROPERTY); + }); + }); + } + + private GenericRecord createNestedRecord() { + GenericRecord record = GenericRecord.create(SCHEMA_WITH_NESTED_COLUMN); + GenericRecord nested = GenericRecord.create(NESTED_STRUCT_TYPE); + GenericRecord leaf = GenericRecord.create(LEAF_STRUCT_TYPE); + leaf.set(0, 0L); + leaf.set(1, 0.0); + nested.set(0, leaf); + record.set(0, nested); + record.set(1, "data"); + return record; + } + + private Dataset randomDataDF(Schema schema) { + Iterable rows = RandomData.generateSpark(schema, 10, 0); + JavaRDD rowRDD = sparkContext.parallelize(Lists.newArrayList(rows)); + StructType rowSparkType = SparkSchemaUtil.convert(schema); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + return ((org.apache.spark.sql.classic.SparkSession) spark) + .internalCreateDataFrame(JavaRDD.toRDD(rowRDD), rowSparkType, false); + } + + private void append(String table, Dataset df) throws NoSuchTableException { + // fanout writes are enabled as write-time clustering is not supported without Spark extensions + df.coalesce(1).writeTo(table).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java new file mode 100644 index 000000000000..50afb53e0539 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestCreateActions.java @@ -0,0 +1,1056 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.FilenameFilter; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.filefilter.TrueFileFilter; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.actions.MigrateTable; +import org.apache.iceberg.actions.SnapshotTable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.iceberg.types.Types; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.MessageTypeParser; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.catalyst.TableIdentifier; +import org.apache.spark.sql.catalyst.analysis.NoSuchDatabaseException; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.catalog.CatalogTable; +import org.apache.spark.sql.catalyst.catalog.CatalogTablePartition; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import scala.Option; +import scala.Some; +import scala.collection.JavaConverters; +import scala.collection.Seq; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCreateActions extends CatalogTestBase { + private static final String CREATE_PARTITIONED_PARQUET = + "CREATE TABLE %s (id INT, data STRING) " + "using parquet PARTITIONED BY (id) LOCATION '%s'"; + private static final String CREATE_PARQUET = + "CREATE TABLE %s (id INT, data STRING) " + "using parquet LOCATION '%s'"; + private static final String CREATE_HIVE_EXTERNAL_PARQUET = + "CREATE EXTERNAL TABLE %s (data STRING) " + + "PARTITIONED BY (id INT) STORED AS parquet LOCATION '%s'"; + private static final String CREATE_HIVE_PARQUET = + "CREATE TABLE %s (data STRING) " + "PARTITIONED BY (id INT) STORED AS parquet"; + + private static final String NAMESPACE = "default"; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, type = {3}") + public static Object[][] parameters() { + return new Object[][] { + new Object[] { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "parquet-enabled", "true", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + ), + "hive" + }, + new Object[] { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hadoop", + "default-namespace", "default", + "parquet-enabled", "true", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + ), + "hadoop" + }, + new Object[] { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default"), + "hive" + }, + new Object[] { + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hadoop", + "default-namespace", "default"), + "hadoop" + } + }; + } + + private final String baseTableName = "baseTable"; + @TempDir private File tableDir; + private String tableLocation; + + @Parameter(index = 3) + private String type; + + private TableCatalog catalog; + + @BeforeEach + @Override + public void before() { + super.before(); + this.tableLocation = tableDir.toURI().toString(); + this.catalog = (TableCatalog) spark.sessionState().catalogManager().catalog(catalogName); + + spark.conf().set("hive.exec.dynamic.partition", "true"); + spark.conf().set("hive.exec.dynamic.partition.mode", "nonstrict"); + spark.conf().set("spark.sql.parquet.writeLegacyFormat", false); + spark.conf().set("spark.sql.parquet.writeLegacyFormat", false); + spark.sql(String.format("DROP TABLE IF EXISTS %s", baseTableName)); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .orderBy("data") + .write() + .mode("append") + .option("path", tableLocation) + .saveAsTable(baseTableName); + } + + @AfterEach + public void after() throws IOException { + // Drop the hive table. + spark.sql(String.format("DROP TABLE IF EXISTS %s", baseTableName)); + spark.sessionState().catalogManager().reset(); + spark.conf().unset("spark.sql.catalog.spark_catalog.type"); + spark.conf().unset("spark.sql.catalog.spark_catalog.default-namespace"); + spark.conf().unset("spark.sql.catalog.spark_catalog.parquet-enabled"); + spark.conf().unset("spark.sql.catalog.spark_catalog.cache-enabled"); + } + + @TestTemplate + public void testMigratePartitioned() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_migrate_partitioned_table"); + String dest = source; + createSourceTable(CREATE_PARTITIONED_PARQUET, source); + assertMigratedFileCount(SparkActions.get().migrateTable(source), source, dest); + } + + @TestTemplate + public void testPartitionedTableWithUnRecoveredPartitions() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_unrecovered_partitions"); + String dest = source; + File location = Files.createTempDirectory(temp, "junit").toFile(); + sql(CREATE_PARTITIONED_PARQUET, source, location); + + // Data generation and partition addition + spark + .range(5) + .selectExpr("id", "cast(id as STRING) as data") + .write() + .partitionBy("id") + .mode(SaveMode.Overwrite) + .parquet(location.toURI().toString()); + sql("ALTER TABLE %s ADD PARTITION(id=0)", source); + + assertMigratedFileCount(SparkActions.get().migrateTable(source), source, dest); + } + + @TestTemplate + public void testPartitionedTableWithCustomPartitions() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_custom_parts"); + String dest = source; + File tblLocation = Files.createTempDirectory(temp, "junit").toFile(); + File partitionDataLoc = Files.createTempDirectory(temp, "junit").toFile(); + + // Data generation and partition addition + spark.sql(String.format(CREATE_PARTITIONED_PARQUET, source, tblLocation)); + spark + .range(10) + .selectExpr("cast(id as STRING) as data") + .write() + .mode(SaveMode.Overwrite) + .parquet(partitionDataLoc.toURI().toString()); + sql( + "ALTER TABLE %s ADD PARTITION(id=0) LOCATION '%s'", + source, partitionDataLoc.toURI().toString()); + assertMigratedFileCount(SparkActions.get().migrateTable(source), source, dest); + } + + @TestTemplate + public void testAddColumnOnMigratedTableAtEnd() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_add_column_migrated_table"); + String dest = source; + createSourceTable(CREATE_PARQUET, source); + List expected1 = sql("select *, null from %s order by id", source); + List expected2 = sql("select *, null, null from %s order by id", source); + + // migrate table + SparkActions.get().migrateTable(source).execute(); + SparkTable sparkTable = loadTable(dest); + Table table = sparkTable.table(); + + // test column addition on migrated table + Schema beforeSchema = table.schema(); + String newCol1 = "newCol1"; + sparkTable.table().updateSchema().addColumn(newCol1, Types.IntegerType.get()).commit(); + Schema afterSchema = table.schema(); + assertThat(beforeSchema.findField(newCol1)).isNull(); + assertThat(afterSchema.findField(newCol1)).isNotNull(); + + // reads should succeed without any exceptions + List results1 = sql("select * from %s order by id", dest); + assertThat(results1).isNotEmpty(); + assertEquals("Output must match", results1, expected1); + + String newCol2 = "newCol2"; + sql("ALTER TABLE %s ADD COLUMN %s INT", dest, newCol2); + StructType schema = spark.table(dest).schema(); + assertThat(schema.fieldNames()).contains(newCol2); + + // reads should succeed without any exceptions + List results2 = sql("select * from %s order by id", dest); + assertThat(results2).isNotEmpty(); + assertEquals("Output must match", results2, expected2); + } + + @TestTemplate + public void testAddColumnOnMigratedTableAtMiddle() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_add_column_migrated_table_middle"); + String dest = source; + createSourceTable(CREATE_PARQUET, source); + + // migrate table + SparkActions.get().migrateTable(source).execute(); + SparkTable sparkTable = loadTable(dest); + Table table = sparkTable.table(); + List expected = sql("select id, null, data from %s order by id", source); + + // test column addition on migrated table + Schema beforeSchema = table.schema(); + String newCol1 = "newCol"; + sparkTable + .table() + .updateSchema() + .addColumn("newCol", Types.IntegerType.get()) + .moveAfter(newCol1, "id") + .commit(); + Schema afterSchema = table.schema(); + assertThat(beforeSchema.findField(newCol1)).isNull(); + assertThat(afterSchema.findField(newCol1)).isNotNull(); + + // reads should succeed + List results = sql("select * from %s order by id", dest); + assertThat(results).isNotEmpty(); + assertEquals("Output must match", results, expected); + } + + @TestTemplate + public void removeColumnsAtEnd() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_remove_column_migrated_table"); + String dest = source; + + String colName1 = "newCol1"; + String colName2 = "newCol2"; + spark + .range(10) + .selectExpr("cast(id as INT)", "CAST(id as INT) " + colName1, "CAST(id as INT) " + colName2) + .write() + .mode(SaveMode.Overwrite) + .saveAsTable(dest); + List expected1 = sql("select id, %s from %s order by id", colName1, source); + List expected2 = sql("select id from %s order by id", source); + + // migrate table + SparkActions.get().migrateTable(source).execute(); + SparkTable sparkTable = loadTable(dest); + Table table = sparkTable.table(); + + // test column removal on migrated table + Schema beforeSchema = table.schema(); + sparkTable.table().updateSchema().deleteColumn(colName1).commit(); + Schema afterSchema = table.schema(); + assertThat(beforeSchema.findField(colName1)).isNotNull(); + assertThat(afterSchema.findField(colName1)).isNull(); + + // reads should succeed without any exceptions + List results1 = sql("select * from %s order by id", dest); + assertThat(results1).isNotEmpty(); + assertEquals("Output must match", expected1, results1); + + sql("ALTER TABLE %s DROP COLUMN %s", dest, colName2); + StructType schema = spark.table(dest).schema(); + assertThat(schema.fieldNames()).doesNotContain(colName2); + + // reads should succeed without any exceptions + List results2 = sql("select * from %s order by id", dest); + assertThat(results2).isNotEmpty(); + assertEquals("Output must match", expected2, results2); + } + + @TestTemplate + public void removeColumnFromMiddle() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_remove_column_migrated_table_from_middle"); + String dest = source; + String dropColumnName = "col1"; + + spark + .range(10) + .selectExpr( + "cast(id as INT)", "CAST(id as INT) as " + dropColumnName, "CAST(id as INT) as col2") + .write() + .mode(SaveMode.Overwrite) + .saveAsTable(dest); + List expected = sql("select id, col2 from %s order by id", source); + + // migrate table + SparkActions.get().migrateTable(source).execute(); + + // drop column + sql("ALTER TABLE %s DROP COLUMN %s", dest, "col1"); + StructType schema = spark.table(dest).schema(); + assertThat(schema.fieldNames()).doesNotContain(dropColumnName); + + // reads should return same output as that of non-iceberg table + List results = sql("select * from %s order by id", dest); + assertThat(results).isNotEmpty(); + assertEquals("Output must match", expected, results); + } + + @TestTemplate + public void testMigrateUnpartitioned() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String source = sourceName("test_migrate_unpartitioned_table"); + String dest = source; + createSourceTable(CREATE_PARQUET, source); + assertMigratedFileCount(SparkActions.get().migrateTable(source), source, dest); + } + + @TestTemplate + public void testSnapshotPartitioned() throws Exception { + assumeThat(type) + .as("Cannot snapshot with arbitrary location in a hadoop based catalog") + .isNotEqualTo("hadoop"); + File location = Files.createTempDirectory(temp, "junit").toFile(); + String source = sourceName("test_snapshot_partitioned_table"); + String dest = destName("iceberg_snapshot_partitioned"); + createSourceTable(CREATE_PARTITIONED_PARQUET, source); + assertSnapshotFileCount( + SparkActions.get().snapshotTable(source).as(dest).tableLocation(location.toString()), + source, + dest); + assertIsolatedSnapshot(source, dest); + } + + @TestTemplate + public void testSnapshotUnpartitioned() throws Exception { + assumeThat(type) + .as("Cannot snapshot with arbitrary location in a hadoop based catalog") + .isNotEqualTo("hadoop"); + File location = Files.createTempDirectory(temp, "junit").toFile(); + String source = sourceName("test_snapshot_unpartitioned_table"); + String dest = destName("iceberg_snapshot_unpartitioned"); + createSourceTable(CREATE_PARQUET, source); + assertSnapshotFileCount( + SparkActions.get().snapshotTable(source).as(dest).tableLocation(location.toString()), + source, + dest); + assertIsolatedSnapshot(source, dest); + } + + @TestTemplate + public void testSnapshotHiveTable() throws Exception { + assumeThat(type) + .as("Cannot snapshot with arbitrary location in a hadoop based catalog") + .isNotEqualTo("hadoop"); + File location = Files.createTempDirectory(temp, "junit").toFile(); + String source = sourceName("snapshot_hive_table"); + String dest = destName("iceberg_snapshot_hive_table"); + createSourceTable(CREATE_HIVE_EXTERNAL_PARQUET, source); + assertSnapshotFileCount( + SparkActions.get().snapshotTable(source).as(dest).tableLocation(location.toString()), + source, + dest); + assertIsolatedSnapshot(source, dest); + } + + @TestTemplate + public void testMigrateHiveTable() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + String source = sourceName("migrate_hive_table"); + String dest = source; + createSourceTable(CREATE_HIVE_EXTERNAL_PARQUET, source); + assertMigratedFileCount(SparkActions.get().migrateTable(source), source, dest); + } + + @TestTemplate + public void testSnapshotManagedHiveTable() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + File location = Files.createTempDirectory(temp, "junit").toFile(); + String source = sourceName("snapshot_managed_hive_table"); + String dest = destName("iceberg_snapshot_managed_hive_table"); + createSourceTable(CREATE_HIVE_PARQUET, source); + assertSnapshotFileCount( + SparkActions.get().snapshotTable(source).as(dest).tableLocation(location.toString()), + source, + dest); + assertIsolatedSnapshot(source, dest); + } + + @TestTemplate + public void testMigrateManagedHiveTable() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + File location = Files.createTempDirectory(temp, "junit").toFile(); + String source = sourceName("migrate_managed_hive_table"); + String dest = destName("iceberg_migrate_managed_hive_table"); + createSourceTable(CREATE_HIVE_PARQUET, source); + assertSnapshotFileCount( + SparkActions.get().snapshotTable(source).as(dest).tableLocation(location.toString()), + source, + dest); + } + + @TestTemplate + public void testProperties() throws Exception { + String source = sourceName("test_properties_table"); + String dest = destName("iceberg_properties"); + Map props = Maps.newHashMap(); + props.put("city", "New Orleans"); + props.put("note", "Jazz"); + createSourceTable(CREATE_PARQUET, source); + for (Map.Entry keyValue : props.entrySet()) { + spark.sql( + String.format( + "ALTER TABLE %s SET TBLPROPERTIES (\"%s\" = \"%s\")", + source, keyValue.getKey(), keyValue.getValue())); + } + assertSnapshotFileCount( + SparkActions.get().snapshotTable(source).as(dest).tableProperty("dogs", "sundance"), + source, + dest); + SparkTable table = loadTable(dest); + + Map expectedProps = Maps.newHashMap(); + expectedProps.putAll(props); + expectedProps.put("dogs", "sundance"); + + assertThat(table.properties()).containsAllEntriesOf(expectedProps); + } + + @TestTemplate + public void testSparkTableReservedProperties() throws Exception { + String destTableName = "iceberg_reserved_properties"; + String source = sourceName("test_reserved_properties_table"); + String dest = destName(destTableName); + createSourceTable(CREATE_PARQUET, source); + SnapshotTableSparkAction action = SparkActions.get().snapshotTable(source).as(dest); + action.tableProperty(TableProperties.FORMAT_VERSION, "1"); + assertSnapshotFileCount(action, source, dest); + SparkTable table = loadTable(dest); + // set sort orders + table.table().replaceSortOrder().asc("id").desc("data").commit(); + + String[] keys = {"provider", "format", "current-snapshot-id", "location", "sort-order"}; + + assertThat(table.properties()) + .as("Created table missing reserved properties") + .containsKeys(keys); + + assertThat(table.properties()) + .containsEntry("provider", "iceberg") + .containsEntry("format", "iceberg/parquet") + .hasEntrySatisfying("current-snapshot-id", id -> assertThat(id).isNotEqualTo("none")) + .hasEntrySatisfying("location", loc -> assertThat(loc).endsWith(destTableName)); + + assertThat(table.properties()).containsEntry("format-version", "1"); + table.table().updateProperties().set("format-version", "2").commit(); + assertThat(table.properties()).containsEntry("format-version", "2"); + + assertThat(table.properties()) + .containsEntry("sort-order", "id ASC NULLS FIRST, data DESC NULLS LAST") + .doesNotContainKey("identifier-fields"); + + table + .table() + .updateSchema() + .allowIncompatibleChanges() + .requireColumn("id") + .setIdentifierFields("id") + .commit(); + assertThat(table.properties()).containsEntry("identifier-fields", "[id]"); + } + + @TestTemplate + public void testSnapshotDefaultLocation() throws Exception { + String source = sourceName("test_snapshot_default"); + String dest = destName("iceberg_snapshot_default"); + createSourceTable(CREATE_PARTITIONED_PARQUET, source); + assertSnapshotFileCount(SparkActions.get().snapshotTable(source).as(dest), source, dest); + assertIsolatedSnapshot(source, dest); + } + + @TestTemplate + public void schemaEvolutionTestWithSparkAPI() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + + File location = Files.createTempDirectory(temp, "junit").toFile(); + String tblName = sourceName("schema_evolution_test"); + + // Data generation and partition addition + spark + .range(0, 5) + .selectExpr("CAST(id as INT) as col0", "CAST(id AS FLOAT) col2", "CAST(id AS LONG) col3") + .write() + .mode(SaveMode.Append) + .parquet(location.toURI().toString()); + Dataset rowDataset = + spark + .range(6, 10) + .selectExpr( + "CAST(id as INT) as col0", + "CAST(id AS STRING) col1", + "CAST(id AS FLOAT) col2", + "CAST(id AS LONG) col3"); + rowDataset.write().mode(SaveMode.Append).parquet(location.toURI().toString()); + spark + .read() + .schema(rowDataset.schema()) + .parquet(location.toURI().toString()) + .write() + .saveAsTable(tblName); + List expectedBeforeAddColumn = sql("SELECT * FROM %s ORDER BY col0", tblName); + List expectedAfterAddColumn = + sql("SELECT col0, null, col1, col2, col3 FROM %s ORDER BY col0", tblName); + + // Migrate table + SparkActions.get().migrateTable(tblName).execute(); + + // check if iceberg and non-iceberg output + List afterMigarteBeforeAddResults = sql("SELECT * FROM %s ORDER BY col0", tblName); + assertEquals("Output must match", expectedBeforeAddColumn, afterMigarteBeforeAddResults); + + // Update schema and check output correctness + SparkTable sparkTable = loadTable(tblName); + sparkTable + .table() + .updateSchema() + .addColumn("newCol", Types.IntegerType.get()) + .moveAfter("newCol", "col0") + .commit(); + List afterMigarteAfterAddResults = sql("SELECT * FROM %s ORDER BY col0", tblName); + assertEquals("Output must match", expectedAfterAddColumn, afterMigarteAfterAddResults); + } + + @TestTemplate + public void schemaEvolutionTestWithSparkSQL() throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + assumeThat(catalog.name()) + .as("Can only migrate from Spark Session Catalog") + .isEqualTo("spark_catalog"); + String tblName = sourceName("schema_evolution_test_sql"); + + // Data generation and partition addition + spark + .range(0, 5) + .selectExpr("CAST(id as INT) col0", "CAST(id AS FLOAT) col1", "CAST(id AS STRING) col2") + .write() + .mode(SaveMode.Append) + .saveAsTable(tblName); + sql("ALTER TABLE %s ADD COLUMN col3 INT", tblName); + spark + .range(6, 10) + .selectExpr( + "CAST(id AS INT) col0", + "CAST(id AS FLOAT) col1", + "CAST(id AS STRING) col2", + "CAST(id AS INT) col3") + .createOrReplaceTempView("tempdata"); + sql("INSERT INTO TABLE %s SELECT * FROM tempdata", tblName); + List expectedBeforeAddColumn = sql("SELECT * FROM %s ORDER BY col0", tblName); + List expectedAfterAddColumn = + sql("SELECT col0, null, col1, col2, col3 FROM %s ORDER BY col0", tblName); + + // Migrate table + SparkActions.get().migrateTable(tblName).execute(); + + // check if iceberg and non-iceberg output + List afterMigarteBeforeAddResults = sql("SELECT * FROM %s ORDER BY col0", tblName); + assertEquals("Output must match", expectedBeforeAddColumn, afterMigarteBeforeAddResults); + + // Update schema and check output correctness + SparkTable sparkTable = loadTable(tblName); + sparkTable + .table() + .updateSchema() + .addColumn("newCol", Types.IntegerType.get()) + .moveAfter("newCol", "col0") + .commit(); + List afterMigarteAfterAddResults = sql("SELECT * FROM %s ORDER BY col0", tblName); + assertEquals("Output must match", expectedAfterAddColumn, afterMigarteAfterAddResults); + } + + @TestTemplate + public void testHiveStyleThreeLevelList() throws Exception { + threeLevelList(true); + } + + @TestTemplate + public void testThreeLevelList() throws Exception { + threeLevelList(false); + } + + @TestTemplate + public void testHiveStyleThreeLevelListWithNestedStruct() throws Exception { + threeLevelListWithNestedStruct(true); + } + + @TestTemplate + public void testThreeLevelListWithNestedStruct() throws Exception { + threeLevelListWithNestedStruct(false); + } + + @TestTemplate + public void testHiveStyleThreeLevelLists() throws Exception { + threeLevelLists(true); + } + + @TestTemplate + public void testThreeLevelLists() throws Exception { + threeLevelLists(false); + } + + @TestTemplate + public void testHiveStyleStructOfThreeLevelLists() throws Exception { + structOfThreeLevelLists(true); + } + + @TestTemplate + public void testStructOfThreeLevelLists() throws Exception { + structOfThreeLevelLists(false); + } + + @TestTemplate + public void testTwoLevelList() throws IOException { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + + spark.conf().set("spark.sql.parquet.writeLegacyFormat", true); + + String tableName = sourceName("testTwoLevelList"); + File location = Files.createTempDirectory(temp, "junit").toFile(); + + StructType sparkSchema = + new StructType( + new StructField[] { + new StructField( + "col1", + new ArrayType( + new StructType( + new StructField[] { + new StructField("col2", DataTypes.IntegerType, false, Metadata.empty()) + }), + false), + true, + Metadata.empty()) + }); + + // even though this list looks like three level list, it is actually a 2-level list where the + // items are + // structs with 1 field. + String expectedParquetSchema = + "message spark_schema {\n" + + " optional group col1 (LIST) {\n" + + " repeated group array {\n" + + " required int32 col2;\n" + + " }\n" + + " }\n" + + "}\n"; + + // generate parquet file with required schema + List testData = Collections.singletonList("{\"col1\": [{\"col2\": 1}]}"); + spark + .read() + .schema(sparkSchema) + .json(JavaSparkContext.fromSparkContext(spark.sparkContext()).parallelize(testData)) + .coalesce(1) + .write() + .format("parquet") + .mode(SaveMode.Append) + .save(location.getPath()); + + File parquetFile = + Arrays.stream( + Preconditions.checkNotNull( + location.listFiles( + new FilenameFilter() { + @Override + public boolean accept(File dir, String name) { + return name.endsWith("parquet"); + } + }))) + .findAny() + .get(); + + // verify generated parquet file has expected schema + ParquetFileReader pqReader = + ParquetFileReader.open( + HadoopInputFile.fromPath( + new Path(parquetFile.getPath()), spark.sessionState().newHadoopConf())); + MessageType schema = pqReader.getFooter().getFileMetaData().getSchema(); + assertThat(schema).isEqualTo(MessageTypeParser.parseMessageType(expectedParquetSchema)); + + // create sql table on top of it + sql( + "CREATE EXTERNAL TABLE %s (col1 ARRAY>)" + + " STORED AS parquet" + + " LOCATION '%s'", + tableName, location); + List expected = sql("select array(struct(1))"); + + // migrate table + SparkActions.get().migrateTable(tableName).execute(); + + // check migrated table is returning expected result + List results = sql("SELECT * FROM %s", tableName); + assertThat(results).isNotEmpty(); + assertEquals("Output must match", expected, results); + } + + private void threeLevelList(boolean useLegacyMode) throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + + spark.conf().set("spark.sql.parquet.writeLegacyFormat", useLegacyMode); + + String tableName = sourceName(String.format("threeLevelList_%s", useLegacyMode)); + File location = Files.createTempDirectory(temp, "junit").toFile(); + sql( + "CREATE TABLE %s (col1 ARRAY>)" + " STORED AS parquet" + " LOCATION '%s'", + tableName, location); + + int testValue = 12345; + sql("INSERT INTO %s VALUES (ARRAY(STRUCT(%s)))", tableName, testValue); + List expected = sql(String.format("SELECT * FROM %s", tableName)); + + // migrate table + SparkActions.get().migrateTable(tableName).execute(); + + // check migrated table is returning expected result + List results = sql("SELECT * FROM %s", tableName); + assertThat(results).isNotEmpty(); + assertEquals("Output must match", expected, results); + } + + private void threeLevelListWithNestedStruct(boolean useLegacyMode) throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + + spark.conf().set("spark.sql.parquet.writeLegacyFormat", useLegacyMode); + + String tableName = + sourceName(String.format("threeLevelListWithNestedStruct_%s", useLegacyMode)); + File location = Files.createTempDirectory(temp, "junit").toFile(); + sql( + "CREATE TABLE %s (col1 ARRAY>>)" + + " STORED AS parquet" + + " LOCATION '%s'", + tableName, location); + + int testValue = 12345; + sql("INSERT INTO %s VALUES (ARRAY(STRUCT(STRUCT(%s))))", tableName, testValue); + List expected = sql(String.format("SELECT * FROM %s", tableName)); + + // migrate table + SparkActions.get().migrateTable(tableName).execute(); + + // check migrated table is returning expected result + List results = sql("SELECT * FROM %s", tableName); + assertThat(results).isNotEmpty(); + assertEquals("Output must match", expected, results); + } + + private void threeLevelLists(boolean useLegacyMode) throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + + spark.conf().set("spark.sql.parquet.writeLegacyFormat", useLegacyMode); + + String tableName = sourceName(String.format("threeLevelLists_%s", useLegacyMode)); + File location = Files.createTempDirectory(temp, "junit").toFile(); + sql( + "CREATE TABLE %s (col1 ARRAY>, col3 ARRAY>)" + + " STORED AS parquet" + + " LOCATION '%s'", + tableName, location); + + int testValue1 = 12345; + int testValue2 = 987654; + sql( + "INSERT INTO %s VALUES (ARRAY(STRUCT(%s)), ARRAY(STRUCT(%s)))", + tableName, testValue1, testValue2); + List expected = sql(String.format("SELECT * FROM %s", tableName)); + + // migrate table + SparkActions.get().migrateTable(tableName).execute(); + + // check migrated table is returning expected result + List results = sql("SELECT * FROM %s", tableName); + assertThat(results).isNotEmpty(); + assertEquals("Output must match", expected, results); + } + + private void structOfThreeLevelLists(boolean useLegacyMode) throws Exception { + assumeThat(type).as("Cannot migrate to a hadoop based catalog").isNotEqualTo("hadoop"); + + spark.conf().set("spark.sql.parquet.writeLegacyFormat", useLegacyMode); + + String tableName = sourceName(String.format("structOfThreeLevelLists_%s", useLegacyMode)); + File location = Files.createTempDirectory(temp, "junit").toFile(); + sql( + "CREATE TABLE %s (col1 STRUCT>>)" + + " STORED AS parquet" + + " LOCATION '%s'", + tableName, location); + + int testValue1 = 12345; + sql("INSERT INTO %s VALUES (STRUCT(STRUCT(ARRAY(STRUCT(%s)))))", tableName, testValue1); + List expected = sql(String.format("SELECT * FROM %s", tableName)); + + // migrate table + SparkActions.get().migrateTable(tableName).execute(); + + // check migrated table is returning expected result + List results = sql("SELECT * FROM %s", tableName); + assertThat(results).isNotEmpty(); + assertEquals("Output must match", expected, results); + } + + private SparkTable loadTable(String name) throws NoSuchTableException, ParseException { + return (SparkTable) + catalog.loadTable(Spark3Util.catalogAndIdentifier(spark, name).identifier()); + } + + private CatalogTable loadSessionTable(String name) + throws NoSuchTableException, NoSuchDatabaseException, ParseException { + Identifier identifier = Spark3Util.catalogAndIdentifier(spark, name).identifier(); + Some namespace = Some.apply(identifier.namespace()[0]); + return spark + .sessionState() + .catalog() + .getTableMetadata(new TableIdentifier(identifier.name(), namespace)); + } + + private void createSourceTable(String createStatement, String tableName) + throws IOException, NoSuchTableException, NoSuchDatabaseException, ParseException { + File location = Files.createTempDirectory(temp, "junit").toFile(); + spark.sql(String.format(createStatement, tableName, location)); + CatalogTable table = loadSessionTable(tableName); + String format = table.provider().get(); + spark + .table(baseTableName) + .selectExpr(table.schema().names()) + .write() + .mode(SaveMode.Append) + .format(format) + .insertInto(tableName); + } + + // Counts the number of files in the source table, makes sure the same files exist in the + // destination table + private void assertMigratedFileCount(MigrateTable migrateAction, String source, String dest) + throws NoSuchTableException, NoSuchDatabaseException, ParseException { + long expectedFiles = expectedFilesCount(source); + MigrateTable.Result migratedFiles = migrateAction.execute(); + validateTables(source, dest); + assertThat(migratedFiles.migratedDataFilesCount()) + .as("Expected number of migrated files") + .isEqualTo(expectedFiles); + } + + // Counts the number of files in the source table, makes sure the same files exist in the + // destination table + private void assertSnapshotFileCount(SnapshotTable snapshotTable, String source, String dest) + throws NoSuchTableException, NoSuchDatabaseException, ParseException { + long expectedFiles = expectedFilesCount(source); + SnapshotTable.Result snapshotTableResult = snapshotTable.execute(); + validateTables(source, dest); + assertThat(snapshotTableResult.importedDataFilesCount()) + .as("Expected number of imported snapshot files") + .isEqualTo(expectedFiles); + } + + private void validateTables(String source, String dest) + throws NoSuchTableException, ParseException { + List expected = spark.table(source).collectAsList(); + SparkTable destTable = loadTable(dest); + assertThat(destTable.properties()).containsEntry(TableCatalog.PROP_PROVIDER, "iceberg"); + List actual = spark.table(dest).collectAsList(); + assertThat(actual) + .as( + String.format( + "Rows in migrated table did not match\nExpected :%s rows \nFound :%s", + expected, actual)) + .containsAll(expected); + assertThat(expected) + .as( + String.format( + "Rows in migrated table did not match\nExpected :%s rows \nFound :%s", + expected, actual)) + .containsAll(actual); + } + + private long expectedFilesCount(String source) + throws NoSuchDatabaseException, NoSuchTableException, ParseException { + CatalogTable sourceTable = loadSessionTable(source); + List uris; + if (sourceTable.partitionColumnNames().isEmpty()) { + uris = Lists.newArrayList(); + uris.add(sourceTable.location()); + } else { + Seq catalogTablePartitionSeq = + spark + .sessionState() + .catalog() + .listPartitions(sourceTable.identifier(), Option.apply(null)); + uris = + JavaConverters.seqAsJavaList(catalogTablePartitionSeq).stream() + .map(CatalogTablePartition::location) + .collect(Collectors.toList()); + } + return uris.stream() + .flatMap( + uri -> + FileUtils.listFiles( + Paths.get(uri).toFile(), TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE) + .stream()) + .filter(file -> !file.toString().endsWith("crc") && !file.toString().contains("_SUCCESS")) + .count(); + } + + // Insert records into the destination, makes sure those records exist and source table is + // unchanged + private void assertIsolatedSnapshot(String source, String dest) { + List expected = spark.sql(String.format("SELECT * FROM %s", source)).collectAsList(); + + List extraData = Lists.newArrayList(new SimpleRecord(4, "d")); + Dataset df = spark.createDataFrame(extraData, SimpleRecord.class); + df.write().format("iceberg").mode("append").saveAsTable(dest); + + List result = spark.sql(String.format("SELECT * FROM %s", source)).collectAsList(); + assertThat(result) + .as("No additional rows should be added to the original table") + .hasSameSizeAs(expected); + + List snapshot = + spark + .sql(String.format("SELECT * FROM %s WHERE id = 4 AND data = 'd'", dest)) + .collectAsList(); + assertThat(snapshot).as("Added row not found in snapshot").hasSize(1); + } + + private String sourceName(String source) { + return NAMESPACE + "." + catalog.name() + "_" + type + "_" + source; + } + + private String destName(String dest) { + if (catalog.name().equals("spark_catalog")) { + return NAMESPACE + "." + catalog.name() + "_" + type + "_" + dest; + } else { + return catalog.name() + "." + NAMESPACE + "." + catalog.name() + "_" + type + "_" + dest; + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestDeleteReachableFilesAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestDeleteReachableFilesAction.java new file mode 100644 index 000000000000..1bd26f7aa1de --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestDeleteReachableFilesAction.java @@ -0,0 +1,403 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.StreamSupport; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.actions.ActionsProvider; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.actions.DeleteReachableFiles; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestDeleteReachableFilesAction extends TestBase { + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + private static final int SHUFFLE_PARTITIONS = 2; + + private static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + + static final DataFile FILE_A = + DataFiles.builder(SPEC) + .withPath("/path/to/data-a.parquet") + .withFileSizeInBytes(10) + .withPartition(TestHelpers.Row.of(0)) + .withRecordCount(1) + .build(); + static final DataFile FILE_B = + DataFiles.builder(SPEC) + .withPath("/path/to/data-b.parquet") + .withFileSizeInBytes(10) + .withPartition(TestHelpers.Row.of(1)) + .withRecordCount(1) + .build(); + static final DataFile FILE_C = + DataFiles.builder(SPEC) + .withPath("/path/to/data-c.parquet") + .withFileSizeInBytes(10) + .withPartition(TestHelpers.Row.of(2)) + .withRecordCount(1) + .build(); + static final DataFile FILE_D = + DataFiles.builder(SPEC) + .withPath("/path/to/data-d.parquet") + .withFileSizeInBytes(10) + .withPartition(TestHelpers.Row.of(3)) + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A_POS_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-a-pos-deletes.parquet") + .withFileSizeInBytes(10) + .withPartition(TestHelpers.Row.of(0)) + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A_EQ_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes() + .withPath("/path/to/data-a-eq-deletes.parquet") + .withFileSizeInBytes(10) + .withPartition(TestHelpers.Row.of(0)) + .withRecordCount(1) + .build(); + + @TempDir private File tableDir; + @Parameter private int formatVersion; + + @Parameters(name = "formatVersion = {0}") + protected static List parameters() { + return org.apache.iceberg.TestHelpers.V2_AND_ABOVE; + } + + private Table table; + + @BeforeEach + public void setupTableLocation() throws Exception { + String tableLocation = tableDir.toURI().toString(); + this.table = + TABLES.create( + SCHEMA, + SPEC, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + spark.conf().set("spark.sql.shuffle.partitions", SHUFFLE_PARTITIONS); + } + + private void checkRemoveFilesResults( + long expectedDatafiles, + long expectedPosDeleteFiles, + long expectedEqDeleteFiles, + long expectedManifestsDeleted, + long expectedManifestListsDeleted, + long expectedOtherFilesDeleted, + DeleteReachableFiles.Result results) { + assertThat(results.deletedManifestsCount()) + .as("Incorrect number of manifest files deleted") + .isEqualTo(expectedManifestsDeleted); + + assertThat(results.deletedDataFilesCount()) + .as("Incorrect number of datafiles deleted") + .isEqualTo(expectedDatafiles); + + assertThat(results.deletedPositionDeleteFilesCount()) + .as("Incorrect number of position delete files deleted") + .isEqualTo(expectedPosDeleteFiles); + + assertThat(results.deletedEqualityDeleteFilesCount()) + .as("Incorrect number of equality delete files deleted") + .isEqualTo(expectedEqDeleteFiles); + + assertThat(results.deletedManifestListsCount()) + .as("Incorrect number of manifest lists deleted") + .isEqualTo(expectedManifestListsDeleted); + + assertThat(results.deletedOtherFilesCount()) + .as("Incorrect number of other lists deleted") + .isEqualTo(expectedOtherFilesDeleted); + } + + @TestTemplate + public void dataFilesCleanupWithParallelTasks() { + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newFastAppend().appendFile(FILE_B).commit(); + + table.newRewrite().rewriteFiles(ImmutableSet.of(FILE_B), ImmutableSet.of(FILE_D)).commit(); + + table.newRewrite().rewriteFiles(ImmutableSet.of(FILE_A), ImmutableSet.of(FILE_C)).commit(); + + Set deletedFiles = ConcurrentHashMap.newKeySet(); + Set deleteThreads = ConcurrentHashMap.newKeySet(); + AtomicInteger deleteThreadsIndex = new AtomicInteger(0); + + DeleteReachableFiles.Result result = + sparkActions() + .deleteReachableFiles(metadataLocation(table)) + .io(table.io()) + .executeDeleteWith( + Executors.newFixedThreadPool( + 4, + runnable -> { + Thread thread = new Thread(runnable); + thread.setName("remove-files-" + deleteThreadsIndex.getAndIncrement()); + thread.setDaemon( + true); // daemon threads will be terminated abruptly when the JVM exits + return thread; + })) + .deleteWith( + s -> { + deleteThreads.add(Thread.currentThread().getName()); + deletedFiles.add(s); + }) + .execute(); + + // Verifies that the delete methods ran in the threads created by the provided ExecutorService + // ThreadFactory + assertThat(deleteThreads) + .containsExactlyInAnyOrder( + "remove-files-0", "remove-files-1", "remove-files-2", "remove-files-3"); + + Lists.newArrayList(FILE_A, FILE_B, FILE_C, FILE_D) + .forEach( + file -> + assertThat(deletedFiles) + .as("FILE_A should be deleted") + .contains(FILE_A.location())); + checkRemoveFilesResults(4L, 0, 0, 6L, 4L, 6, result); + } + + @TestTemplate + public void testWithExpiringDanglingStageCommit() { + table.location(); + // `A` commit + table.newAppend().appendFile(FILE_A).commit(); + + // `B` staged commit + table.newAppend().appendFile(FILE_B).stageOnly().commit(); + + // `C` commit + table.newAppend().appendFile(FILE_C).commit(); + + DeleteReachableFiles.Result result = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(table.io()).execute(); + + checkRemoveFilesResults(3L, 0, 0, 3L, 3L, 5, result); + } + + @TestTemplate + public void testRemoveFileActionOnEmptyTable() { + DeleteReachableFiles.Result result = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(table.io()).execute(); + + checkRemoveFilesResults(0, 0, 0, 0, 0, 2, result); + } + + @TestTemplate + public void testRemoveFilesActionWithReducedVersionsTable() { + table.updateProperties().set(TableProperties.METADATA_PREVIOUS_VERSIONS_MAX, "2").commit(); + table.newAppend().appendFile(FILE_A).commit(); + + table.newAppend().appendFile(FILE_B).commit(); + + table.newAppend().appendFile(FILE_B).commit(); + + table.newAppend().appendFile(FILE_C).commit(); + + table.newAppend().appendFile(FILE_D).commit(); + + DeleteReachableFiles baseRemoveFilesSparkAction = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(table.io()); + DeleteReachableFiles.Result result = baseRemoveFilesSparkAction.execute(); + + checkRemoveFilesResults(4, 0, 0, 5, 5, 8, result); + } + + @TestTemplate + public void testRemoveFilesAction() { + table.newAppend().appendFile(FILE_A).commit(); + + table.newAppend().appendFile(FILE_B).commit(); + + DeleteReachableFiles baseRemoveFilesSparkAction = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(table.io()); + checkRemoveFilesResults(2, 0, 0, 2, 2, 4, baseRemoveFilesSparkAction.execute()); + } + + @TestTemplate + public void testPositionDeleteFiles() { + table.newAppend().appendFile(FILE_A).commit(); + + table.newAppend().appendFile(FILE_B).commit(); + + table.newRowDelta().addDeletes(fileADeletes()).commit(); + + DeleteReachableFiles baseRemoveFilesSparkAction = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(table.io()); + checkRemoveFilesResults(2, 1, 0, 3, 3, 5, baseRemoveFilesSparkAction.execute()); + } + + @TestTemplate + public void testEqualityDeleteFiles() { + table.newAppend().appendFile(FILE_A).commit(); + + table.newAppend().appendFile(FILE_B).commit(); + + table.newRowDelta().addDeletes(FILE_A_EQ_DELETES).commit(); + + DeleteReachableFiles baseRemoveFilesSparkAction = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(table.io()); + checkRemoveFilesResults(2, 0, 1, 3, 3, 5, baseRemoveFilesSparkAction.execute()); + } + + @TestTemplate + public void testRemoveFilesActionWithDefaultIO() { + table.newAppend().appendFile(FILE_A).commit(); + + table.newAppend().appendFile(FILE_B).commit(); + + // IO not set explicitly on removeReachableFiles action + // IO defaults to HadoopFileIO + DeleteReachableFiles baseRemoveFilesSparkAction = + sparkActions().deleteReachableFiles(metadataLocation(table)); + checkRemoveFilesResults(2, 0, 0, 2, 2, 4, baseRemoveFilesSparkAction.execute()); + } + + @TestTemplate + public void testUseLocalIterator() { + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newOverwrite().deleteFile(FILE_A).addFile(FILE_B).commit(); + + table.newFastAppend().appendFile(FILE_C).commit(); + + int jobsBefore = spark.sparkContext().dagScheduler().nextJobId().get(); + + withSQLConf( + ImmutableMap.of("spark.sql.adaptive.enabled", "false"), + () -> { + DeleteReachableFiles.Result results = + sparkActions() + .deleteReachableFiles(metadataLocation(table)) + .io(table.io()) + .option("stream-results", "true") + .execute(); + + int jobsAfter = spark.sparkContext().dagScheduler().nextJobId().get(); + int totalJobsRun = jobsAfter - jobsBefore; + + checkRemoveFilesResults(3L, 0, 0, 4L, 3L, 5, results); + + assertThat(totalJobsRun) + .as("Expected total jobs to be equal to total number of shuffle partitions") + .isEqualTo(SHUFFLE_PARTITIONS); + }); + } + + @TestTemplate + public void testIgnoreMetadataFilesNotFound() { + table.updateProperties().set(TableProperties.METADATA_PREVIOUS_VERSIONS_MAX, "1").commit(); + + table.newAppend().appendFile(FILE_A).commit(); + // There are three metadata json files at this point + DeleteOrphanFiles.Result result = + sparkActions().deleteOrphanFiles(table).olderThan(System.currentTimeMillis()).execute(); + + assertThat(result.orphanFileLocations()).as("Should delete 1 file").hasSize(1); + assertThat(StreamSupport.stream(result.orphanFileLocations().spliterator(), false)) + .as("Should remove v1 file") + .anyMatch(file -> file.contains("v1.metadata.json")); + + DeleteReachableFiles baseRemoveFilesSparkAction = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(table.io()); + DeleteReachableFiles.Result res = baseRemoveFilesSparkAction.execute(); + + checkRemoveFilesResults(1, 0, 0, 1, 1, 4, res); + } + + @TestTemplate + public void testEmptyIOThrowsException() { + DeleteReachableFiles baseRemoveFilesSparkAction = + sparkActions().deleteReachableFiles(metadataLocation(table)).io(null); + + assertThatThrownBy(baseRemoveFilesSparkAction::execute) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("File IO cannot be null"); + } + + @TestTemplate + public void testRemoveFilesActionWhenGarbageCollectionDisabled() { + table.updateProperties().set(TableProperties.GC_ENABLED, "false").commit(); + + assertThatThrownBy(() -> sparkActions().deleteReachableFiles(metadataLocation(table)).execute()) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot delete files: GC is disabled (deleting files may corrupt other tables)"); + } + + private String metadataLocation(Table tbl) { + return ((HasTableOperations) tbl).operations().current().metadataFileLocation(); + } + + private ActionsProvider sparkActions() { + return SparkActions.get(); + } + + private DeleteFile fileADeletes() { + return formatVersion >= 3 ? FileGenerationUtil.generateDV(table, FILE_A) : FILE_A_POS_DELETES; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java new file mode 100644 index 000000000000..c89e4f7852d5 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestExpireSnapshotsAction.java @@ -0,0 +1,1419 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ReachableFileUtil; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.actions.ExpireSnapshots; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestExpireSnapshotsAction extends TestBase { + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + private static final int SHUFFLE_PARTITIONS = 2; + + private static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + + static final DataFile FILE_A = + DataFiles.builder(SPEC) + .withPath("/path/to/data-a.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=0") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_B = + DataFiles.builder(SPEC) + .withPath("/path/to/data-b.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=1") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_C = + DataFiles.builder(SPEC) + .withPath("/path/to/data-c.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=2") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_D = + DataFiles.builder(SPEC) + .withPath("/path/to/data-d.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=3") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A_POS_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-a-pos-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=0") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A_EQ_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes() + .withPath("/path/to/data-a-eq-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=0") // easy way to set partition data for now + .withRecordCount(1) + .build(); + + @TempDir private Path temp; + @Parameter private int formatVersion; + + @Parameters(name = "formatVersion = {0}") + protected static List parameters() { + return org.apache.iceberg.TestHelpers.V2_AND_ABOVE; + } + + @TempDir private File tableDir; + private String tableLocation; + private Table table; + + @BeforeEach + public void setupTableLocation() throws Exception { + this.tableLocation = tableDir.toURI().toString(); + this.table = + TABLES.create( + SCHEMA, + SPEC, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + spark.conf().set("spark.sql.shuffle.partitions", SHUFFLE_PARTITIONS); + } + + private Long rightAfterSnapshot() { + return rightAfterSnapshot(table.currentSnapshot().snapshotId()); + } + + private Long rightAfterSnapshot(long snapshotId) { + Long end = System.currentTimeMillis(); + while (end <= table.snapshot(snapshotId).timestampMillis()) { + end = System.currentTimeMillis(); + } + return end; + } + + private DeleteFile fileADeletes() { + return formatVersion >= 3 ? FileGenerationUtil.generateDV(table, FILE_A) : FILE_A_POS_DELETES; + } + + private void checkExpirationResults( + long expectedDatafiles, + long expectedPosDeleteFiles, + long expectedEqDeleteFiles, + long expectedManifestsDeleted, + long expectedManifestListsDeleted, + ExpireSnapshots.Result results) { + + assertThat(results.deletedManifestsCount()) + .as("Incorrect number of manifest files deleted") + .isEqualTo(expectedManifestsDeleted); + + assertThat(results.deletedDataFilesCount()) + .as("Incorrect number of datafiles deleted") + .isEqualTo(expectedDatafiles); + + assertThat(results.deletedPositionDeleteFilesCount()) + .as("Incorrect number of pos deletefiles deleted") + .isEqualTo(expectedPosDeleteFiles); + + assertThat(results.deletedEqualityDeleteFilesCount()) + .as("Incorrect number of eq deletefiles deleted") + .isEqualTo(expectedEqDeleteFiles); + + assertThat(results.deletedManifestListsCount()) + .as("Incorrect number of manifest lists deleted") + .isEqualTo(expectedManifestListsDeleted); + } + + @TestTemplate + public void testFilesCleaned() throws Exception { + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newOverwrite().deleteFile(FILE_A).addFile(FILE_B).commit(); + + table.newFastAppend().appendFile(FILE_C).commit(); + + long end = rightAfterSnapshot(); + + ExpireSnapshots.Result results = + SparkActions.get().expireSnapshots(table).expireOlderThan(end).execute(); + + assertThat(table.snapshots()).as("Table does not have 1 snapshot after expiration").hasSize(1); + + checkExpirationResults(1L, 0L, 0L, 1L, 2L, results); + } + + @TestTemplate + public void dataFilesCleanupWithParallelTasks() throws IOException { + + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newFastAppend().appendFile(FILE_B).commit(); + + table.newRewrite().rewriteFiles(ImmutableSet.of(FILE_B), ImmutableSet.of(FILE_D)).commit(); + + table.newRewrite().rewriteFiles(ImmutableSet.of(FILE_A), ImmutableSet.of(FILE_C)).commit(); + + long t4 = rightAfterSnapshot(); + + Set deletedFiles = ConcurrentHashMap.newKeySet(); + Set deleteThreads = ConcurrentHashMap.newKeySet(); + AtomicInteger deleteThreadsIndex = new AtomicInteger(0); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .executeDeleteWith( + Executors.newFixedThreadPool( + 4, + runnable -> { + Thread thread = new Thread(runnable); + thread.setName("remove-snapshot-" + deleteThreadsIndex.getAndIncrement()); + thread.setDaemon( + true); // daemon threads will be terminated abruptly when the JVM exits + return thread; + })) + .expireOlderThan(t4) + .deleteWith( + s -> { + deleteThreads.add(Thread.currentThread().getName()); + deletedFiles.add(s); + }) + .execute(); + + // Verifies that the delete methods ran in the threads created by the provided ExecutorService + // ThreadFactory + assertThat(deleteThreads) + .containsExactlyInAnyOrder( + "remove-snapshot-0", "remove-snapshot-1", "remove-snapshot-2", "remove-snapshot-3"); + + assertThat(deletedFiles).as("FILE_A should be deleted").contains(FILE_A.location()); + assertThat(deletedFiles).as("FILE_B should be deleted").contains(FILE_B.location()); + + checkExpirationResults(2L, 0L, 0L, 3L, 3L, result); + } + + @TestTemplate + public void testNoFilesDeletedWhenNoSnapshotsExpired() throws Exception { + table.newFastAppend().appendFile(FILE_A).commit(); + + ExpireSnapshots.Result results = SparkActions.get().expireSnapshots(table).execute(); + checkExpirationResults(0L, 0L, 0L, 0L, 0L, results); + } + + @TestTemplate + public void testCleanupRepeatedOverwrites() throws Exception { + table.newFastAppend().appendFile(FILE_A).commit(); + + for (int i = 0; i < 10; i++) { + table.newOverwrite().deleteFile(FILE_A).addFile(FILE_B).commit(); + + table.newOverwrite().deleteFile(FILE_B).addFile(FILE_A).commit(); + } + + long end = rightAfterSnapshot(); + ExpireSnapshots.Result results = + SparkActions.get().expireSnapshots(table).expireOlderThan(end).execute(); + checkExpirationResults(1L, 0L, 0L, 39L, 20L, results); + } + + @TestTemplate + public void testRetainLastWithExpireOlderThan() { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .commit(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + long t1 = System.currentTimeMillis(); + while (t1 <= table.currentSnapshot().timestampMillis()) { + t1 = System.currentTimeMillis(); + } + + table + .newAppend() + .appendFile(FILE_B) // data_bucket=1 + .commit(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + long t3 = rightAfterSnapshot(); + + // Retain last 2 snapshots + SparkActions.get().expireSnapshots(table).expireOlderThan(t3).retainLast(2).execute(); + + assertThat(table.snapshots()).as("Should have two snapshots.").hasSize(2); + assertThat(table.snapshot(firstSnapshotId)).as("First snapshot should not present.").isNull(); + } + + @TestTemplate + public void testExpireTwoSnapshotsById() throws Exception { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .commit(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + + table + .newAppend() + .appendFile(FILE_B) // data_bucket=1 + .commit(); + + long secondSnapshotID = table.currentSnapshot().snapshotId(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + // Retain last 2 snapshots + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireSnapshotId(firstSnapshotId) + .expireSnapshotId(secondSnapshotID) + .execute(); + + assertThat(table.snapshots()).as("Should have one snapshot.").hasSize(1); + assertThat(table.snapshot(firstSnapshotId)).as("First snapshot should not present.").isNull(); + assertThat(table.snapshot(secondSnapshotID)).as("Second snapshot should not present.").isNull(); + + checkExpirationResults(0L, 0L, 0L, 0L, 2L, result); + } + + @TestTemplate + public void testRetainLastWithExpireById() { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .commit(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + + table + .newAppend() + .appendFile(FILE_B) // data_bucket=1 + .commit(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + // Retain last 3 snapshots, but explicitly remove the first snapshot + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireSnapshotId(firstSnapshotId) + .retainLast(3) + .execute(); + + assertThat(table.snapshots()).as("Should have 2 snapshots.").hasSize(2); + assertThat(table.snapshot(firstSnapshotId)).as("First snapshot should not present.").isNull(); + checkExpirationResults(0L, 0L, 0L, 0L, 1L, result); + } + + @TestTemplate + public void testRetainLastWithTooFewSnapshots() { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .appendFile(FILE_B) // data_bucket=1 + .commit(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + long t2 = rightAfterSnapshot(); + + // Retain last 3 snapshots + ExpireSnapshots.Result result = + SparkActions.get().expireSnapshots(table).expireOlderThan(t2).retainLast(3).execute(); + + assertThat(table.snapshots()).as("Should have two snapshots.").hasSize(2); + assertThat(table.snapshot(firstSnapshotId)) + .as("First snapshot should still be present.") + .isNotNull(); + checkExpirationResults(0L, 0L, 0L, 0L, 0L, result); + } + + @TestTemplate + public void testRetainLastKeepsExpiringSnapshot() { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .commit(); + + table + .newAppend() + .appendFile(FILE_B) // data_bucket=1 + .commit(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + table + .newAppend() + .appendFile(FILE_D) // data_bucket=3 + .commit(); + + // Retain last 2 snapshots and expire older than t3 + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(secondSnapshot.timestampMillis()) + .retainLast(2) + .execute(); + + assertThat(table.snapshots()).as("Should have three snapshots.").hasSize(3); + assertThat(table.snapshot(secondSnapshot.snapshotId())) + .as("First snapshot should be present.") + .isNotNull(); + checkExpirationResults(0L, 0L, 0L, 0L, 1L, result); + } + + @TestTemplate + public void testExpireSnapshotsWithDisabledGarbageCollection() { + table.updateProperties().set(TableProperties.GC_ENABLED, "false").commit(); + + table.newAppend().appendFile(FILE_A).commit(); + + assertThatThrownBy(() -> SparkActions.get().expireSnapshots(table)) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot expire snapshots: GC is disabled (deleting files may corrupt other tables)"); + } + + @TestTemplate + public void testExpireOlderThanMultipleCalls() { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .commit(); + + table + .newAppend() + .appendFile(FILE_B) // data_bucket=1 + .commit(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + Snapshot thirdSnapshot = table.currentSnapshot(); + + // Retain last 2 snapshots and expire older than t3 + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(secondSnapshot.timestampMillis()) + .expireOlderThan(thirdSnapshot.timestampMillis()) + .execute(); + + assertThat(table.snapshots()).as("Should have one snapshot.").hasSize(1); + assertThat(table.snapshot(secondSnapshot.snapshotId())) + .as("Second snapshot should not present.") + .isNull(); + checkExpirationResults(0L, 0L, 0L, 0L, 2L, result); + } + + @TestTemplate + public void testRetainLastMultipleCalls() { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .commit(); + + table + .newAppend() + .appendFile(FILE_B) // data_bucket=1 + .commit(); + + Snapshot secondSnapshot = table.currentSnapshot(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + long t3 = rightAfterSnapshot(); + + // Retain last 2 snapshots and expire older than t3 + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(t3) + .retainLast(2) + .retainLast(1) + .execute(); + + assertThat(table.snapshots()).as("Should have one snapshot.").hasSize(1); + assertThat(table.snapshot(secondSnapshot.snapshotId())) + .as("Second snapshot should not present.") + .isNull(); + checkExpirationResults(0L, 0L, 0L, 0L, 2L, result); + } + + @TestTemplate + public void testRetainZeroSnapshots() { + assertThatThrownBy(() -> SparkActions.get().expireSnapshots(table).retainLast(0).execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Number of snapshots to retain must be at least 1, cannot be: 0"); + } + + @TestTemplate + public void testScanExpiredManifestInValidSnapshotAppend() { + table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + table.newOverwrite().addFile(FILE_C).deleteFile(FILE_A).commit(); + + table.newAppend().appendFile(FILE_D).commit(); + + long t3 = rightAfterSnapshot(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(t3) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(deletedFiles).as("FILE_A should be deleted").contains(FILE_A.location()); + checkExpirationResults(1L, 0L, 0L, 1L, 2L, result); + } + + @TestTemplate + public void testScanExpiredManifestInValidSnapshotFastAppend() { + table + .updateProperties() + .set(TableProperties.MANIFEST_MERGE_ENABLED, "true") + .set(TableProperties.MANIFEST_MIN_MERGE_COUNT, "1") + .commit(); + + table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + table.newOverwrite().addFile(FILE_C).deleteFile(FILE_A).commit(); + + table.newFastAppend().appendFile(FILE_D).commit(); + + long t3 = rightAfterSnapshot(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(t3) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(deletedFiles).as("FILE_A should be deleted").contains(FILE_A.location()); + checkExpirationResults(1L, 0L, 0L, 1L, 2L, result); + } + + /** + * Test on table below, and expiring the staged commit `B` using `expireOlderThan` API. Table: A - + * C ` B (staged) + */ + @TestTemplate + public void testWithExpiringDanglingStageCommit() { + // `A` commit + table.newAppend().appendFile(FILE_A).commit(); + + // `B` staged commit + table.newAppend().appendFile(FILE_B).stageOnly().commit(); + + TableMetadata base = ((BaseTable) table).operations().current(); + Snapshot snapshotA = base.snapshots().get(0); + Snapshot snapshotB = base.snapshots().get(1); + + // `C` commit + table.newAppend().appendFile(FILE_C).commit(); + + Set deletedFiles = Sets.newHashSet(); + + // Expire all commits including dangling staged snapshot. + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .deleteWith(deletedFiles::add) + .expireOlderThan(snapshotB.timestampMillis() + 1) + .execute(); + + checkExpirationResults(1L, 0L, 0L, 1L, 2L, result); + + Set expectedDeletes = Sets.newHashSet(); + expectedDeletes.add(snapshotA.manifestListLocation()); + + // Files should be deleted of dangling staged snapshot + SnapshotChanges.builderFor(table) + .snapshot(snapshotB) + .build() + .addedDataFiles() + .forEach( + i -> { + expectedDeletes.add(i.location()); + }); + + // ManifestList should be deleted too + expectedDeletes.add(snapshotB.manifestListLocation()); + snapshotB + .dataManifests(table.io()) + .forEach( + file -> { + // Only the manifest of B should be deleted. + if (file.snapshotId() == snapshotB.snapshotId()) { + expectedDeletes.add(file.path()); + } + }); + assertThat(expectedDeletes) + .as("Files deleted count should be expected") + .hasSameSizeAs(deletedFiles); + // Take the diff + expectedDeletes.removeAll(deletedFiles); + assertThat(expectedDeletes).as("Exactly same files should be deleted").isEmpty(); + } + + /** + * Expire cherry-pick the commit as shown below, when `B` is in table's current state Table: A - B + * - C <--current snapshot `- D (source=B) + */ + @TestTemplate + public void testWithCherryPickTableSnapshot() { + // `A` commit + table.newAppend().appendFile(FILE_A).commit(); + Snapshot snapshotA = table.currentSnapshot(); + + // `B` commit + Set deletedAFiles = Sets.newHashSet(); + table.newOverwrite().addFile(FILE_B).deleteFile(FILE_A).deleteWith(deletedAFiles::add).commit(); + assertThat(deletedAFiles).as("No files should be physically deleted").isEmpty(); + + // pick the snapshot 'B` + Snapshot snapshotB = table.currentSnapshot(); + + // `C` commit to let cherry-pick take effect, and avoid fast-forward of `B` with cherry-pick + table.newAppend().appendFile(FILE_C).commit(); + Snapshot snapshotC = table.currentSnapshot(); + + // Move the table back to `A` + table.manageSnapshots().setCurrentSnapshot(snapshotA.snapshotId()).commit(); + + // Generate A -> `D (B)` + table.manageSnapshots().cherrypick(snapshotB.snapshotId()).commit(); + Snapshot snapshotD = table.currentSnapshot(); + + // Move the table back to `C` + table.manageSnapshots().setCurrentSnapshot(snapshotC.snapshotId()).commit(); + List deletedFiles = Lists.newArrayList(); + + // Expire `C` + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .deleteWith(deletedFiles::add) + .expireOlderThan(snapshotC.timestampMillis() + 1) + .execute(); + + // Make sure no dataFiles are deleted for the B, C, D snapshot + Lists.newArrayList(snapshotB, snapshotC, snapshotD) + .forEach( + i -> { + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() + .forEach( + item -> { + assertThat(deletedFiles).doesNotContain(item.location()); + }); + }); + + checkExpirationResults(1L, 0L, 0L, 2L, 2L, result); + } + + /** + * Test on table below, and expiring `B` which is not in current table state. 1) Expire `B` 2) All + * commit Table: A - C - D (B) ` B (staged) + */ + @TestTemplate + public void testWithExpiringStagedThenCherrypick() { + // `A` commit + table.newAppend().appendFile(FILE_A).commit(); + + // `B` commit + table.newAppend().appendFile(FILE_B).stageOnly().commit(); + + // pick the snapshot that's staged but not committed + TableMetadata base = ((BaseTable) table).operations().current(); + Snapshot snapshotB = base.snapshots().get(1); + + // `C` commit to let cherry-pick take effect, and avoid fast-forward of `B` with cherry-pick + table.newAppend().appendFile(FILE_C).commit(); + + // `D (B)` cherry-pick commit + table.manageSnapshots().cherrypick(snapshotB.snapshotId()).commit(); + + base = ((BaseTable) table).operations().current(); + Snapshot snapshotD = base.snapshots().get(3); + + List deletedFiles = Lists.newArrayList(); + + // Expire `B` commit. + ExpireSnapshots.Result firstResult = + SparkActions.get() + .expireSnapshots(table) + .deleteWith(deletedFiles::add) + .expireSnapshotId(snapshotB.snapshotId()) + .execute(); + + // Make sure no dataFiles are deleted for the staged snapshot + Lists.newArrayList(snapshotB) + .forEach( + i -> { + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() + .forEach( + item -> { + assertThat(deletedFiles).doesNotContain(item.location()); + }); + }); + checkExpirationResults(0L, 0L, 0L, 1L, 1L, firstResult); + + // Expire all snapshots including cherry-pick + ExpireSnapshots.Result secondResult = + SparkActions.get() + .expireSnapshots(table) + .deleteWith(deletedFiles::add) + .expireOlderThan(table.currentSnapshot().timestampMillis() + 1) + .execute(); + + // Make sure no dataFiles are deleted for the staged and cherry-pick + Lists.newArrayList(snapshotB, snapshotD) + .forEach( + i -> { + SnapshotChanges.builderFor(table) + .snapshot(i) + .build() + .addedDataFiles() + .forEach( + item -> { + assertThat(deletedFiles).doesNotContain(item.location()); + }); + }); + checkExpirationResults(0L, 0L, 0L, 0L, 2L, secondResult); + } + + @TestTemplate + public void testExpireOlderThan() { + table.newAppend().appendFile(FILE_A).commit(); + + Snapshot firstSnapshot = table.currentSnapshot(); + + rightAfterSnapshot(); + + table.newAppend().appendFile(FILE_B).commit(); + + long snapshotId = table.currentSnapshot().snapshotId(); + + long tAfterCommits = rightAfterSnapshot(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(tAfterCommits) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(table.currentSnapshot().snapshotId()) + .as("Expire should not change current snapshot.") + .isEqualTo(snapshotId); + assertThat(table.snapshot(firstSnapshot.snapshotId())) + .as("Expire should remove the oldest snapshot.") + .isNull(); + assertThat(deletedFiles) + .as("Should remove only the expired manifest list location.") + .containsExactly(firstSnapshot.manifestListLocation()); + + checkExpirationResults(0, 0, 0, 0, 1, result); + } + + @TestTemplate + public void testExpireOlderThanWithDelete() { + table.newAppend().appendFile(FILE_A).commit(); + + Snapshot firstSnapshot = table.currentSnapshot(); + assertThat(firstSnapshot.allManifests(table.io())).as("Should create one manifest").hasSize(1); + + rightAfterSnapshot(); + + table.newDelete().deleteFile(FILE_A).commit(); + + Snapshot secondSnapshot = table.currentSnapshot(); + assertThat(secondSnapshot.allManifests(table.io())) + .as("Should create replace manifest with a rewritten manifest") + .hasSize(1); + + table.newAppend().appendFile(FILE_B).commit(); + + rightAfterSnapshot(); + + long snapshotId = table.currentSnapshot().snapshotId(); + + long tAfterCommits = rightAfterSnapshot(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(tAfterCommits) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(table.currentSnapshot().snapshotId()) + .as("Expire should not change current snapshot.") + .isEqualTo(snapshotId); + assertThat(table.snapshot(firstSnapshot.snapshotId())) + .as("Expire should remove the oldest snapshot.") + .isNull(); + assertThat(table.snapshot(secondSnapshot.snapshotId())) + .as("Expire should remove the second oldest snapshot.") + .isNull(); + assertThat(deletedFiles) + .as("Should remove expired manifest lists and deleted data file.") + .containsExactlyInAnyOrder( + firstSnapshot.manifestListLocation(), // snapshot expired + firstSnapshot + .allManifests(table.io()) + .get(0) + .path(), // manifest was rewritten for delete + secondSnapshot.manifestListLocation(), // snapshot expired + secondSnapshot + .allManifests(table.io()) + .get(0) + .path(), // manifest contained only deletes, was dropped + FILE_A.location()); + + checkExpirationResults(1, 0, 0, 2, 2, result); + } + + @TestTemplate + public void testExpireOlderThanWithDeleteInMergedManifests() { + // merge every commit + table.updateProperties().set(TableProperties.MANIFEST_MIN_MERGE_COUNT, "0").commit(); + + table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + Snapshot firstSnapshot = table.currentSnapshot(); + assertThat(firstSnapshot.allManifests(table.io())).as("Should create one manifest").hasSize(1); + + rightAfterSnapshot(); + + table + .newDelete() + .deleteFile(FILE_A) // FILE_B is still in the dataset + .commit(); + + Snapshot secondSnapshot = table.currentSnapshot(); + assertThat(secondSnapshot.allManifests(table.io())) + .as("Should replace manifest with a rewritten manifest") + .hasSize(1); + table + .newFastAppend() // do not merge to keep the last snapshot's manifest valid + .appendFile(FILE_C) + .commit(); + + rightAfterSnapshot(); + + long snapshotId = table.currentSnapshot().snapshotId(); + + long tAfterCommits = rightAfterSnapshot(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(tAfterCommits) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(table.currentSnapshot().snapshotId()) + .as("Expire should not change current snapshot.") + .isEqualTo(snapshotId); + assertThat(table.snapshot(firstSnapshot.snapshotId())) + .as("Expire should remove the oldest snapshot.") + .isNull(); + assertThat(table.snapshot(secondSnapshot.snapshotId())) + .as("Expire should remove the second oldest snapshot.") + .isNull(); + + assertThat(deletedFiles) + .as("Should remove expired manifest lists and deleted data file.") + .containsExactlyInAnyOrder( + firstSnapshot.manifestListLocation(), // snapshot expired + firstSnapshot + .allManifests(table.io()) + .get(0) + .path(), // manifest was rewritten for delete + secondSnapshot.manifestListLocation(), // snapshot expired + FILE_A.location()); + checkExpirationResults(1, 0, 0, 1, 2, result); + } + + @TestTemplate + public void testExpireOlderThanWithRollback() { + // merge every commit + table.updateProperties().set(TableProperties.MANIFEST_MIN_MERGE_COUNT, "0").commit(); + + table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit(); + + Snapshot firstSnapshot = table.currentSnapshot(); + assertThat(firstSnapshot.allManifests(table.io())).as("Should create one manifest").hasSize(1); + + rightAfterSnapshot(); + + table.newDelete().deleteFile(FILE_B).commit(); + + Snapshot secondSnapshot = table.currentSnapshot(); + Set secondSnapshotManifests = + Sets.newHashSet(secondSnapshot.allManifests(table.io())); + secondSnapshotManifests.removeAll(firstSnapshot.allManifests(table.io())); + assertThat(secondSnapshotManifests).as("Should add one new manifest for append").hasSize(1); + + table.manageSnapshots().rollbackTo(firstSnapshot.snapshotId()).commit(); + + long tAfterCommits = rightAfterSnapshot(secondSnapshot.snapshotId()); + + long snapshotId = table.currentSnapshot().snapshotId(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(tAfterCommits) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(table.currentSnapshot().snapshotId()) + .as("Expire should not change current snapshot.") + .isEqualTo(snapshotId); + + assertThat(table.snapshot(firstSnapshot.snapshotId())) + .as("Expire should keep the oldest snapshot, current.") + .isNotNull(); + assertThat(table.snapshot(secondSnapshot.snapshotId())) + .as("Expire should remove the orphaned snapshot.") + .isNull(); + + assertThat(deletedFiles) + .as("Should remove expired manifest lists and reverted appended data file") + .containsExactlyInAnyOrder( + secondSnapshot.manifestListLocation(), // snapshot expired + Iterables.getOnlyElement(secondSnapshotManifests).path()); + + checkExpirationResults(0, 0, 0, 1, 1, result); + } + + @TestTemplate + public void testExpireOlderThanWithRollbackAndMergedManifests() { + table.newAppend().appendFile(FILE_A).commit(); + + Snapshot firstSnapshot = table.currentSnapshot(); + assertThat(firstSnapshot.allManifests(table.io())).as("Should create one manifest").hasSize(1); + rightAfterSnapshot(); + + table.newAppend().appendFile(FILE_B).commit(); + + Snapshot secondSnapshot = table.currentSnapshot(); + Set secondSnapshotManifests = + Sets.newHashSet(secondSnapshot.allManifests(table.io())); + secondSnapshotManifests.removeAll(firstSnapshot.allManifests(table.io())); + assertThat(secondSnapshotManifests).as("Should add one new manifest for append").hasSize(1); + + table.manageSnapshots().rollbackTo(firstSnapshot.snapshotId()).commit(); + + long tAfterCommits = rightAfterSnapshot(secondSnapshot.snapshotId()); + + long snapshotId = table.currentSnapshot().snapshotId(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(tAfterCommits) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(table.currentSnapshot().snapshotId()) + .as("Expire should not change current snapshot.") + .isEqualTo(snapshotId); + + assertThat(table.snapshot(firstSnapshot.snapshotId())) + .as("Expire should keep the oldest snapshot, current.") + .isNotNull(); + assertThat(table.snapshot(secondSnapshot.snapshotId())) + .as("Expire should remove the orphaned snapshot.") + .isNull(); + + assertThat(deletedFiles) + .as("Should remove expired manifest lists and reverted appended data file") + .containsExactlyInAnyOrder( + secondSnapshot.manifestListLocation(), // snapshot expired + Iterables.getOnlyElement(secondSnapshotManifests) + .path(), // manifest is no longer referenced + FILE_B.location()); + + checkExpirationResults(1, 0, 0, 1, 1, result); + } + + @TestTemplate + public void testExpireOlderThanWithDeleteFile() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + table.updateProperties().set(TableProperties.MANIFEST_MERGE_ENABLED, "false").commit(); + + // Add Data File + table.newAppend().appendFile(FILE_A).commit(); + Snapshot firstSnapshot = table.currentSnapshot(); + + // Add POS Delete + DeleteFile fileADeletes = fileADeletes(); + table.newRowDelta().addDeletes(fileADeletes).commit(); + Snapshot secondSnapshot = table.currentSnapshot(); + + // Add EQ Delete + table.newRowDelta().addDeletes(FILE_A_EQ_DELETES).commit(); + Snapshot thirdSnapshot = table.currentSnapshot(); + + // Move files to DELETED + table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()).commit(); + Snapshot fourthSnapshot = table.currentSnapshot(); + + long afterAllDeleted = rightAfterSnapshot(); + + table.newAppend().appendFile(FILE_B).commit(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(afterAllDeleted) + .deleteWith(deletedFiles::add) + .execute(); + + Set expectedDeletes = + Sets.newHashSet( + firstSnapshot.manifestListLocation(), + secondSnapshot.manifestListLocation(), + thirdSnapshot.manifestListLocation(), + fourthSnapshot.manifestListLocation(), + FILE_A.location(), + fileADeletes.location(), + FILE_A_EQ_DELETES.location()); + + expectedDeletes.addAll( + thirdSnapshot.allManifests(table.io()).stream() + .map(ManifestFile::path) + .map(CharSequence::toString) + .collect(Collectors.toSet())); + // Delete operation (fourth snapshot) generates new manifest files + expectedDeletes.addAll( + fourthSnapshot.allManifests(table.io()).stream() + .map(ManifestFile::path) + .map(CharSequence::toString) + .collect(Collectors.toSet())); + + assertThat(deletedFiles) + .as("Should remove expired manifest lists and deleted data file") + .isEqualTo(expectedDeletes); + + checkExpirationResults(1, 1, 1, 6, 4, result); + } + + @TestTemplate + public void testExpireOnEmptyTable() { + Set deletedFiles = Sets.newHashSet(); + + // table has no data, testing ExpireSnapshots should not fail with no snapshot + ExpireSnapshots.Result result = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(System.currentTimeMillis()) + .deleteWith(deletedFiles::add) + .execute(); + + checkExpirationResults(0, 0, 0, 0, 0, result); + } + + @TestTemplate + public void testExpireAction() { + table.newAppend().appendFile(FILE_A).commit(); + + Snapshot firstSnapshot = table.currentSnapshot(); + + rightAfterSnapshot(); + + table.newAppend().appendFile(FILE_B).commit(); + + long snapshotId = table.currentSnapshot().snapshotId(); + + long tAfterCommits = rightAfterSnapshot(); + + Set deletedFiles = Sets.newHashSet(); + + ExpireSnapshotsSparkAction action = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(tAfterCommits) + .deleteWith(deletedFiles::add); + Dataset pendingDeletes = action.expireFiles(); + + List pending = pendingDeletes.collectAsList(); + + assertThat(table.currentSnapshot().snapshotId()) + .as("Should not change current snapshot.") + .isEqualTo(snapshotId); + + assertThat(table.snapshot(firstSnapshot.snapshotId())) + .as("Should remove the oldest snapshot") + .isNull(); + assertThat(pending).as("Pending deletes should contain one row").hasSize(1); + + assertThat(pending.get(0).getPath()) + .as("Pending delete should be the expired manifest list location") + .isEqualTo(firstSnapshot.manifestListLocation()); + + assertThat(pending.get(0).getType()) + .as("Pending delete should be a manifest list") + .isEqualTo("Manifest List"); + + assertThat(deletedFiles).as("Should not delete any files").isEmpty(); + + assertThat(action.expireFiles().count()) + .as("Multiple calls to expire should return the same count of deleted files") + .isEqualTo(pendingDeletes.count()); + } + + @TestTemplate + public void testUseLocalIterator() { + table.newFastAppend().appendFile(FILE_A).commit(); + + table.newOverwrite().deleteFile(FILE_A).addFile(FILE_B).commit(); + + table.newFastAppend().appendFile(FILE_C).commit(); + + long end = rightAfterSnapshot(); + + int jobsBeforeStreamResults = spark.sparkContext().dagScheduler().nextJobId().get(); + + withSQLConf( + ImmutableMap.of("spark.sql.adaptive.enabled", "false"), + () -> { + ExpireSnapshots.Result results = + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(end) + .option("stream-results", "true") + .execute(); + + int jobsAfterStreamResults = spark.sparkContext().dagScheduler().nextJobId().get(); + int jobsRunDuringStreamResults = jobsAfterStreamResults - jobsBeforeStreamResults; + + checkExpirationResults(1L, 0L, 0L, 1L, 2L, results); + + assertThat(jobsRunDuringStreamResults) + .as( + "Expected total number of jobs with stream-results should match the expected number") + .isEqualTo(4L); + }); + } + + @TestTemplate + public void testExpireAfterExecute() { + table + .newAppend() + .appendFile(FILE_A) // data_bucket=0 + .commit(); + + rightAfterSnapshot(); + + table + .newAppend() + .appendFile(FILE_B) // data_bucket=1 + .commit(); + + table + .newAppend() + .appendFile(FILE_C) // data_bucket=2 + .commit(); + + long t3 = rightAfterSnapshot(); + + ExpireSnapshotsSparkAction action = SparkActions.get().expireSnapshots(table); + + action.expireOlderThan(t3).retainLast(2); + + ExpireSnapshots.Result result = action.execute(); + checkExpirationResults(0L, 0L, 0L, 0L, 1L, result); + + List typedExpiredFiles = action.expireFiles().collectAsList(); + assertThat(typedExpiredFiles).as("Expired results must match").hasSize(1); + + List untypedExpiredFiles = action.expireFiles().collectAsList(); + assertThat(untypedExpiredFiles).as("Expired results must match").hasSize(1); + } + + @TestTemplate + public void testExpireFileDeletionMostExpired() { + textExpireAllCheckFilesDeleted(5, 2); + } + + @TestTemplate + public void testExpireFileDeletionMostRetained() { + textExpireAllCheckFilesDeleted(2, 5); + } + + public void textExpireAllCheckFilesDeleted(int dataFilesExpired, int dataFilesRetained) { + // Add data files to be expired + Set dataFiles = Sets.newHashSet(); + for (int i = 0; i < dataFilesExpired; i++) { + DataFile df = + DataFiles.builder(SPEC) + .withPath(String.format("/path/to/data-expired-%d.parquet", i)) + .withFileSizeInBytes(10) + .withPartitionPath("c1=1") + .withRecordCount(1) + .build(); + dataFiles.add(df.location()); + table.newFastAppend().appendFile(df).commit(); + } + + // Delete them all, these will be deleted on expire snapshot + table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()).commit(); + // Clears "DELETED" manifests + table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()).commit(); + + Set manifestsBefore = TestHelpers.reachableManifestPaths(table); + + // Add data files to be retained, which are not deleted. + for (int i = 0; i < dataFilesRetained; i++) { + DataFile df = + DataFiles.builder(SPEC) + .withPath(String.format("/path/to/data-retained-%d.parquet", i)) + .withFileSizeInBytes(10) + .withPartitionPath("c1=1") + .withRecordCount(1) + .build(); + table.newFastAppend().appendFile(df).commit(); + } + + long end = rightAfterSnapshot(); + + Set expectedDeletes = Sets.newHashSet(); + expectedDeletes.addAll(ReachableFileUtil.manifestListLocations(table)); + // all snapshot manifest lists except current will be deleted + expectedDeletes.remove(table.currentSnapshot().manifestListLocation()); + expectedDeletes.addAll( + manifestsBefore); // new manifests are reachable from current snapshot and not deleted + expectedDeletes.addAll( + dataFiles); // new data files are reachable from current snapshot and not deleted + + Set deletedFiles = Sets.newHashSet(); + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(end) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(deletedFiles) + .as("All reachable files before expiration should be deleted") + .isEqualTo(expectedDeletes); + } + + @TestTemplate + public void testExpireSomeCheckFilesDeleted() { + + table.newAppend().appendFile(FILE_A).commit(); + + table.newAppend().appendFile(FILE_B).commit(); + + table.newAppend().appendFile(FILE_C).commit(); + + table.newDelete().deleteFile(FILE_A).commit(); + + long after = rightAfterSnapshot(); + waitUntilAfter(after); + + table.newAppend().appendFile(FILE_D).commit(); + + table.newDelete().deleteFile(FILE_B).commit(); + + Set deletedFiles = Sets.newHashSet(); + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(after) + .deleteWith(deletedFiles::add) + .execute(); + + // C, D should be retained (live) + // B should be retained (previous snapshot points to it) + // A should be deleted + assertThat(deletedFiles) + .contains(FILE_A.location()) + .doesNotContain(FILE_B.location(), FILE_C.location(), FILE_D.location()); + } + + @TestTemplate + public void testNoExpiredMetadataCleanupByDefault() { + table.newAppend().appendFile(FILE_A).commit(); + table.newDelete().deleteFile(FILE_A).commit(); + + long after = rightAfterSnapshot(); + + table.updateSchema().addColumn("extra_col", Types.IntegerType.get()).commit(); + table.newAppend().appendFile(FILE_B).commit(); + + Set schemaIds = table.schemas().keySet(); + + Set deletedFiles = Sets.newHashSet(); + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(after) + .deleteWith(deletedFiles::add) + .execute(); + + assertThat(table.schemas().keySet()).containsExactlyInAnyOrderElementsOf(schemaIds); + assertThat(deletedFiles).contains(FILE_A.location()).doesNotContain(FILE_B.location()); + } + + @TestTemplate + public void testCleanExpiredMetadata() { + table.newAppend().appendFile(FILE_A).commit(); + table.newDelete().deleteFile(FILE_A).commit(); + + long after = rightAfterSnapshot(); + + table.updateSchema().addColumn("extra_col", Types.IntegerType.get()).commit(); + table.updateSpec().addField("extra_col").commit(); + + DataFile fileInNewSpec = + DataFiles.builder(table.spec()) + .withPath("/path/to/data-in-new-spec.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=1/extra_col=11") + .withRecordCount(1) + .build(); + + table.newAppend().appendFile(fileInNewSpec).commit(); + + Set deletedFiles = Sets.newHashSet(); + SparkActions.get() + .expireSnapshots(table) + .expireOlderThan(after) + .deleteWith(deletedFiles::add) + .cleanExpiredMetadata(true) + .execute(); + + assertThat(table.specs().keySet()) + .as("Should have only the latest spec") + .containsExactly(table.spec().specId()); + assertThat(table.schemas().keySet()) + .as("Should have only the latest schema") + .containsExactly(table.schema().schemaId()); + assertThat(deletedFiles) + .as("Should remove the file from first snapshot") + .contains(FILE_A.location()) + .doesNotContain(fileInNewSpec.location()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestMigrateTableAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestMigrateTableAction.java new file mode 100644 index 000000000000..94afa50cf4b8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestMigrateTableAction.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.CatalogTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMigrateTableAction extends CatalogTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s_BACKUP_", tableName); + } + + @TestTemplate + public void testMigrateWithParallelTasks() throws IOException { + assumeThat(catalogName).isEqualToIgnoringCase("spark_catalog"); + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + tableName, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", tableName); + + AtomicInteger migrationThreadsIndex = new AtomicInteger(0); + SparkActions.get() + .migrateTable(tableName) + .executeWith( + Executors.newFixedThreadPool( + 4, + runnable -> { + Thread thread = new Thread(runnable); + thread.setName("table-migration-" + migrationThreadsIndex.getAndIncrement()); + thread.setDaemon(true); + return thread; + })) + .execute(); + assertThat(migrationThreadsIndex.get()).isEqualTo(2); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java new file mode 100644 index 000000000000..4df99ca1998b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveDanglingDeleteAction.java @@ -0,0 +1,536 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.actions.RemoveDanglingDeleteFiles; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Encoders; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import scala.Tuple2; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRemoveDanglingDeleteAction extends TestBase { + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.StringType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + + private static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + + static final DataFile FILE_A = + DataFiles.builder(SPEC) + .withPath("/path/to/data-a.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=a") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_A2 = + DataFiles.builder(SPEC) + .withPath("/path/to/data-a2.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=a") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_B = + DataFiles.builder(SPEC) + .withPath("/path/to/data-b.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=b") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_B2 = + DataFiles.builder(SPEC) + .withPath("/path/to/data-b2.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=b") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_C = + DataFiles.builder(SPEC) + .withPath("/path/to/data-c.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=c") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_C2 = + DataFiles.builder(SPEC) + .withPath("/path/to/data-c2.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=c") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_D = + DataFiles.builder(SPEC) + .withPath("/path/to/data-d.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=d") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DataFile FILE_D2 = + DataFiles.builder(SPEC) + .withPath("/path/to/data-d2.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=d") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A_POS_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-a-pos-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=a") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A2_POS_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-a2-pos-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=a") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A_EQ_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes() + .withPath("/path/to/data-a-eq-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=a") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_A2_EQ_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes() + .withPath("/path/to/data-a2-eq-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=a") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_B_POS_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-b-pos-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=b") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_B2_POS_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofPositionDeletes() + .withPath("/path/to/data-b2-pos-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=b") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_B_EQ_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes() + .withPath("/path/to/data-b-eq-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=b") // easy way to set partition data for now + .withRecordCount(1) + .build(); + static final DeleteFile FILE_B2_EQ_DELETES = + FileMetadata.deleteFileBuilder(SPEC) + .ofEqualityDeletes() + .withPath("/path/to/data-b2-eq-deletes.parquet") + .withFileSizeInBytes(10) + .withPartitionPath("c1=b") // easy way to set partition data for now + .withRecordCount(1) + .build(); + + static final DataFile FILE_UNPARTITIONED = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath("/path/to/data-unpartitioned.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + static final DeleteFile FILE_UNPARTITIONED_POS_DELETE = + FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofEqualityDeletes() + .withPath("/path/to/data-unpartitioned-pos-deletes.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + static final DeleteFile FILE_UNPARTITIONED_EQ_DELETE = + FileMetadata.deleteFileBuilder(PartitionSpec.unpartitioned()) + .ofEqualityDeletes() + .withPath("/path/to/data-unpartitioned-eq-deletes.parquet") + .withFileSizeInBytes(10) + .withRecordCount(1) + .build(); + + @TempDir private File tableDir; + @Parameter private int formatVersion; + + @Parameters(name = "formatVersion = {0}") + protected static List parameters() { + return TestHelpers.V2_AND_ABOVE; + } + + private String tableLocation = null; + private Table table; + + @BeforeEach + public void before() throws Exception { + this.tableLocation = tableDir.toURI().toString(); + } + + @AfterEach + public void after() { + TABLES.dropTable(tableLocation); + } + + private void setupPartitionedTable() { + this.table = + TABLES.create( + SCHEMA, + SPEC, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + } + + private void setupUnpartitionedTable() { + this.table = + TABLES.create( + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + } + + private DeleteFile fileADeletes() { + return formatVersion >= 3 ? FileGenerationUtil.generateDV(table, FILE_A) : FILE_A_POS_DELETES; + } + + private DeleteFile fileA2Deletes() { + return formatVersion >= 3 ? FileGenerationUtil.generateDV(table, FILE_A2) : FILE_A2_POS_DELETES; + } + + private DeleteFile fileBDeletes() { + return formatVersion >= 3 ? FileGenerationUtil.generateDV(table, FILE_B) : FILE_B_POS_DELETES; + } + + private DeleteFile fileB2Deletes() { + return formatVersion >= 3 ? FileGenerationUtil.generateDV(table, FILE_B2) : FILE_B2_POS_DELETES; + } + + private DeleteFile fileUnpartitionedDeletes() { + return formatVersion >= 3 + ? FileGenerationUtil.generateDV(table, FILE_UNPARTITIONED) + : FILE_UNPARTITIONED_POS_DELETE; + } + + @TestTemplate + public void testPartitionedDeletesWithLesserSeqNo() { + setupPartitionedTable(); + + // Add Data Files + table.newAppend().appendFile(FILE_B).appendFile(FILE_C).appendFile(FILE_D).commit(); + + // Add Delete Files + DeleteFile fileADeletes = fileADeletes(); + DeleteFile fileA2Deletes = fileA2Deletes(); + DeleteFile fileBDeletes = fileBDeletes(); + DeleteFile fileB2Deletes = fileB2Deletes(); + table + .newRowDelta() + .addDeletes(fileADeletes) + .addDeletes(fileA2Deletes) + .addDeletes(fileBDeletes) + .addDeletes(fileB2Deletes) + .addDeletes(FILE_A_EQ_DELETES) + .addDeletes(FILE_A2_EQ_DELETES) + .addDeletes(FILE_B_EQ_DELETES) + .addDeletes(FILE_B2_EQ_DELETES) + .commit(); + + // Add More Data Files + table + .newAppend() + .appendFile(FILE_A2) + .appendFile(FILE_B2) + .appendFile(FILE_C2) + .appendFile(FILE_D2) + .commit(); + + List> actual = allEntries(); + List> expected = + ImmutableList.of( + Tuple2.apply(1L, FILE_B.location()), + Tuple2.apply(1L, FILE_C.location()), + Tuple2.apply(1L, FILE_D.location()), + Tuple2.apply(2L, FILE_A_EQ_DELETES.location()), + Tuple2.apply(2L, fileADeletes.location()), + Tuple2.apply(2L, FILE_A2_EQ_DELETES.location()), + Tuple2.apply(2L, fileA2Deletes.location()), + Tuple2.apply(2L, FILE_B_EQ_DELETES.location()), + Tuple2.apply(2L, fileBDeletes.location()), + Tuple2.apply(2L, FILE_B2_EQ_DELETES.location()), + Tuple2.apply(2L, fileB2Deletes.location()), + Tuple2.apply(3L, FILE_A2.location()), + Tuple2.apply(3L, FILE_B2.location()), + Tuple2.apply(3L, FILE_C2.location()), + Tuple2.apply(3L, FILE_D2.location())); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + + RemoveDanglingDeleteFiles.Result result = + SparkActions.get().removeDanglingDeleteFiles(table).execute(); + + // All Delete files of the FILE A partition should be removed + // because there are no data files in partition with a lesser sequence number + + Set removedDeleteFiles = + StreamSupport.stream(result.removedDeleteFiles().spliterator(), false) + .map(DeleteFile::location) + .collect(Collectors.toSet()); + assertThat(removedDeleteFiles) + .as("Expected 4 delete files removed") + .hasSize(4) + .containsExactlyInAnyOrder( + fileADeletes.location(), + fileA2Deletes.location(), + FILE_A_EQ_DELETES.location(), + FILE_A2_EQ_DELETES.location()); + + List> actualAfter = liveEntries(); + List> expectedAfter = + ImmutableList.of( + Tuple2.apply(1L, FILE_B.location()), + Tuple2.apply(1L, FILE_C.location()), + Tuple2.apply(1L, FILE_D.location()), + Tuple2.apply(2L, FILE_B_EQ_DELETES.location()), + Tuple2.apply(2L, fileBDeletes.location()), + Tuple2.apply(2L, FILE_B2_EQ_DELETES.location()), + Tuple2.apply(2L, fileB2Deletes.location()), + Tuple2.apply(3L, FILE_A2.location()), + Tuple2.apply(3L, FILE_B2.location()), + Tuple2.apply(3L, FILE_C2.location()), + Tuple2.apply(3L, FILE_D2.location())); + assertThat(actualAfter).containsExactlyInAnyOrderElementsOf(expectedAfter); + } + + @TestTemplate + public void testPartitionedDeletesWithEqSeqNo() { + setupPartitionedTable(); + + // Add Data Files + table.newAppend().appendFile(FILE_A).appendFile(FILE_C).appendFile(FILE_D).commit(); + + // Add Data Files with EQ and POS deletes + DeleteFile fileADeletes = fileADeletes(); + DeleteFile fileA2Deletes = fileA2Deletes(); + DeleteFile fileB2Deletes = fileB2Deletes(); + table + .newRowDelta() + .addRows(FILE_A2) + .addRows(FILE_B2) + .addRows(FILE_C2) + .addRows(FILE_D2) + .addDeletes(fileADeletes) + .addDeletes(fileA2Deletes) + .addDeletes(FILE_A_EQ_DELETES) + .addDeletes(FILE_A2_EQ_DELETES) + .addDeletes(fileB2Deletes) + .addDeletes(FILE_B_EQ_DELETES) + .addDeletes(FILE_B2_EQ_DELETES) + .commit(); + + List> actual = allEntries(); + List> expected = + ImmutableList.of( + Tuple2.apply(1L, FILE_A.location()), + Tuple2.apply(1L, FILE_C.location()), + Tuple2.apply(1L, FILE_D.location()), + Tuple2.apply(2L, FILE_A_EQ_DELETES.location()), + Tuple2.apply(2L, fileADeletes.location()), + Tuple2.apply(2L, FILE_A2.location()), + Tuple2.apply(2L, FILE_A2_EQ_DELETES.location()), + Tuple2.apply(2L, fileA2Deletes.location()), + Tuple2.apply(2L, FILE_B_EQ_DELETES.location()), + Tuple2.apply(2L, FILE_B2.location()), + Tuple2.apply(2L, FILE_B2_EQ_DELETES.location()), + Tuple2.apply(2L, fileB2Deletes.location()), + Tuple2.apply(2L, FILE_C2.location()), + Tuple2.apply(2L, FILE_D2.location())); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + + RemoveDanglingDeleteFiles.Result result = + SparkActions.get().removeDanglingDeleteFiles(table).execute(); + + // Eq Delete files of the FILE B partition should be removed + // because there are no data files in partition with a lesser sequence number + Set removedDeleteFiles = + StreamSupport.stream(result.removedDeleteFiles().spliterator(), false) + .map(DeleteFile::location) + .collect(Collectors.toSet()); + assertThat(removedDeleteFiles) + .as("Expected two delete files removed") + .hasSize(2) + .containsExactlyInAnyOrder(FILE_B_EQ_DELETES.location(), FILE_B2_EQ_DELETES.location()); + + List> actualAfter = liveEntries(); + List> expectedAfter = + ImmutableList.of( + Tuple2.apply(1L, FILE_A.location()), + Tuple2.apply(1L, FILE_C.location()), + Tuple2.apply(1L, FILE_D.location()), + Tuple2.apply(2L, FILE_A_EQ_DELETES.location()), + Tuple2.apply(2L, fileADeletes.location()), + Tuple2.apply(2L, FILE_A2.location()), + Tuple2.apply(2L, FILE_A2_EQ_DELETES.location()), + Tuple2.apply(2L, fileA2Deletes.location()), + Tuple2.apply(2L, FILE_B2.location()), + Tuple2.apply(2L, fileB2Deletes.location()), + Tuple2.apply(2L, FILE_C2.location()), + Tuple2.apply(2L, FILE_D2.location())); + assertThat(actualAfter).containsExactlyInAnyOrderElementsOf(expectedAfter); + } + + @TestTemplate + public void testUnpartitionedTable() { + setupUnpartitionedTable(); + + table + .newRowDelta() + .addDeletes(fileUnpartitionedDeletes()) + .addDeletes(FILE_UNPARTITIONED_EQ_DELETE) + .commit(); + table.newAppend().appendFile(FILE_UNPARTITIONED).commit(); + + RemoveDanglingDeleteFiles.Result result = + SparkActions.get().removeDanglingDeleteFiles(table).execute(); + assertThat(result.removedDeleteFiles()).as("No-op for unpartitioned tables").isEmpty(); + } + + @TestTemplate + public void testPartitionedDeletesWithDanglingDvs() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + setupPartitionedTable(); + + table.newAppend().appendFile(FILE_A).appendFile(FILE_C).appendFile(FILE_D).commit(); + + DeleteFile fileADeletes = fileADeletes(); + DeleteFile fileBDeletes = fileBDeletes(); + DeleteFile fileB2Deletes = fileB2Deletes(); + table + .newRowDelta() + .addRows(FILE_A) + .addRows(FILE_C) + .addDeletes(fileADeletes) + // since FILE B doesn't exist, these delete files will be dangling + .addDeletes(fileBDeletes) + .addDeletes(fileB2Deletes) + .commit(); + + List> actual = allEntries(); + List> expected = + ImmutableList.of( + Tuple2.apply(1L, FILE_A.location()), + Tuple2.apply(1L, FILE_C.location()), + Tuple2.apply(1L, FILE_D.location()), + Tuple2.apply(2L, FILE_A.location()), + Tuple2.apply(2L, FILE_C.location()), + Tuple2.apply(2L, fileB2Deletes.location()), + Tuple2.apply(2L, fileBDeletes.location()), + Tuple2.apply(2L, fileADeletes.location())); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + + RemoveDanglingDeleteFiles.Result result = + SparkActions.get().removeDanglingDeleteFiles(table).execute(); + + // DVs of FILE B should be removed because they don't point to valid data files + Set removedDeleteFiles = + Lists.newArrayList(result.removedDeleteFiles()).stream() + .map(DeleteFile::location) + .collect(Collectors.toSet()); + assertThat(removedDeleteFiles) + .as("Expected two delete files to be removed") + .hasSize(2) + .containsExactlyInAnyOrder(fileBDeletes.location(), fileB2Deletes.location()); + + List> actualAfter = liveEntries(); + List> expectedAfter = + ImmutableList.of( + Tuple2.apply(1L, FILE_A.location()), + Tuple2.apply(1L, FILE_C.location()), + Tuple2.apply(1L, FILE_D.location()), + Tuple2.apply(2L, FILE_A.location()), + Tuple2.apply(2L, FILE_C.location()), + Tuple2.apply(2L, fileADeletes.location())); + assertThat(actualAfter).containsExactlyInAnyOrderElementsOf(expectedAfter); + } + + private List> liveEntries() { + return spark + .read() + .format("iceberg") + .load(tableLocation + "#entries") + .filter("status < 2") // live files + .select("sequence_number", "data_file.file_path") + .sort("sequence_number", "data_file.file_path") + .as(Encoders.tuple(Encoders.LONG(), Encoders.STRING())) + .collectAsList(); + } + + private List> allEntries() { + return spark + .read() + .format("iceberg") + .load(tableLocation + "#entries") + .select("sequence_number", "data_file.file_path") + .sort("sequence_number", "data_file.file_path") + .as(Encoders.tuple(Encoders.LONG(), Encoders.STRING())) + .collectAsList(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java new file mode 100644 index 000000000000..5a33c710b2f6 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction.java @@ -0,0 +1,1354 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.spark.actions.DeleteOrphanFilesSparkAction.MAX_ORPHAN_FILE_SAMPLE_SIZE; +import static org.apache.iceberg.spark.actions.DeleteOrphanFilesSparkAction.STREAM_RESULTS; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.Files; +import org.apache.iceberg.GenericBlobMetadata; +import org.apache.iceberg.GenericStatisticsFile; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StatisticsFile; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.hadoop.HadoopCatalog; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.hadoop.HiddenPathFilter; +import org.apache.iceberg.puffin.Blob; +import org.apache.iceberg.puffin.Puffin; +import org.apache.iceberg.puffin.PuffinWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.actions.DeleteOrphanFilesSparkAction.StringToFileURI; +import org.apache.iceberg.spark.source.FilePathLastModifiedRecord; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.FileSystemWalker; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class TestRemoveOrphanFilesAction extends TestBase { + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + protected static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + protected static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).truncate("c2", 2).identity("c3").build(); + + @TempDir private File tableDir = null; + protected String tableLocation = null; + protected Map properties; + @Parameter private int formatVersion; + + @Parameter(index = 1) + private boolean usePrefixListing; + + @Parameters(name = "formatVersion = {0}, usePrefixListing = {1}") + protected static List parameters() { + return TestHelpers.ALL_VERSIONS.stream() + .filter(version -> version > 1) + .flatMap(version -> Stream.of(new Object[] {version, true}, new Object[] {version, false})) + .collect(Collectors.toList()); + } + + @BeforeEach + public void setupTableLocation() throws Exception { + this.tableLocation = tableDir.toURI().toString(); + properties = ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + } + + @TestTemplate + public void testDryRun() throws IOException { + Table table = TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), properties, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + List validFiles = + spark + .read() + .format("iceberg") + .load(tableLocation + "#files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + assertThat(validFiles).as("Should be 2 valid files").hasSize(2); + + df.write().mode("append").parquet(tableLocation + "/data"); + + Path dataPath = new Path(tableLocation + "/data"); + FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); + List allFiles = + Arrays.stream(fs.listStatus(dataPath, HiddenPathFilter.get())) + .filter(FileStatus::isFile) + .map(file -> file.getPath().toString()) + .collect(Collectors.toList()); + assertThat(allFiles).as("Should be 3 valid files").hasSize(3); + + List invalidFiles = Lists.newArrayList(allFiles); + invalidFiles.removeAll(validFiles); + assertThat(invalidFiles).as("Should be 1 invalid file").hasSize(1); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result1 = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .deleteWith(s -> {}) + .execute(); + assertThat(result1.orphanFileLocations()) + .as("Default olderThan interval should be safe") + .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); + + DeleteOrphanFiles.Result result2 = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .deleteWith(s -> {}) + .execute(); + assertThat(result2.orphanFileLocations()) + .as("Action should find 1 file") + .isEqualTo(invalidFiles); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); + assertThat(fs.exists(new Path(invalidFiles.get(0)))) + .as("Invalid file should be present") + .isTrue(); + + DeleteOrphanFiles.Result result3 = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .option(STREAM_RESULTS, "true") + .deleteWith(s -> {}) + .execute(); + assertThat(result3.orphanFileLocations()) + .as("Streaming dry run should find 1 file") + .isEqualTo(invalidFiles); + assertThat(result3.orphanFilesCount()) + .as("Streaming dry run should find 1 file") + .isEqualTo((long) invalidFiles.size()); + assertThat(fs.exists(new Path(invalidFiles.get(0)))) + .as("Invalid file should be present after streaming dry run") + .isTrue(); + + DeleteOrphanFiles.Result result4 = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + assertThat(result4.orphanFileLocations()) + .as("Action should delete 1 file") + .isEqualTo(invalidFiles); + assertThat(result4.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFiles.size()); + assertThat(fs.exists(new Path(invalidFiles.get(0)))) + .as("Invalid file should not be present") + .isFalse(); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(records); + expectedRecords.addAll(records); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + assertThat(actualRecords).isEqualTo(expectedRecords); + } + + @TestTemplate + public void testAllValidFilesAreKept() throws IOException { + Table table = TABLES.create(SCHEMA, SPEC, properties, tableLocation); + + List records1 = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df1 = spark.createDataFrame(records1, ThreeColumnRecord.class).coalesce(1); + + // original append + df1.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + List records2 = + Lists.newArrayList(new ThreeColumnRecord(2, "AAAAAAAAAA", "AAAA")); + Dataset df2 = spark.createDataFrame(records2, ThreeColumnRecord.class).coalesce(1); + + // dynamic partition overwrite + df2.select("c1", "c2", "c3").write().format("iceberg").mode("overwrite").save(tableLocation); + + // second append + df2.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + List snapshots = Lists.newArrayList(table.snapshots()); + + List snapshotFiles1 = snapshotFiles(snapshots.get(0).snapshotId()); + assertThat(snapshotFiles1).hasSize(1); + + List snapshotFiles2 = snapshotFiles(snapshots.get(1).snapshotId()); + assertThat(snapshotFiles2).hasSize(1); + + List snapshotFiles3 = snapshotFiles(snapshots.get(2).snapshotId()); + assertThat(snapshotFiles3).hasSize(2); + + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data"); + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data/c2_trunc=AA"); + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data/c2_trunc=AA/c3=AAAA"); + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data/invalid/invalid"); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should delete 4 files").hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); + + Path dataPath = new Path(tableLocation + "/data"); + FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); + + for (String fileLocation : snapshotFiles1) { + assertThat(fs.exists(new Path(fileLocation))).as("All snapshot files must remain").isTrue(); + } + + for (String fileLocation : snapshotFiles2) { + assertThat(fs.exists(new Path(fileLocation))).as("All snapshot files must remain").isTrue(); + } + + for (String fileLocation : snapshotFiles3) { + assertThat(fs.exists(new Path(fileLocation))).as("All snapshot files must remain").isTrue(); + } + } + + @TestTemplate + public void orphanedFileRemovedWithParallelTasks() { + assumeThat(usePrefixListing) + .as("Should not test both prefix listing and Hadoop file listing (redundant)") + .isEqualTo(false); + Table table = TABLES.create(SCHEMA, SPEC, properties, tableLocation); + + List records1 = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df1 = spark.createDataFrame(records1, ThreeColumnRecord.class).coalesce(1); + + // original append + df1.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + List records2 = + Lists.newArrayList(new ThreeColumnRecord(2, "AAAAAAAAAA", "AAAA")); + Dataset df2 = spark.createDataFrame(records2, ThreeColumnRecord.class).coalesce(1); + + // dynamic partition overwrite + df2.select("c1", "c2", "c3").write().format("iceberg").mode("overwrite").save(tableLocation); + + // second append + df2.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data"); + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data/c2_trunc=AA"); + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data/c2_trunc=AA/c3=AAAA"); + df2.coalesce(1).write().mode("append").parquet(tableLocation + "/data/invalid/invalid"); + + waitUntilAfter(System.currentTimeMillis()); + + Set deletedFiles = ConcurrentHashMap.newKeySet(); + Set deleteThreads = ConcurrentHashMap.newKeySet(); + AtomicInteger deleteThreadsIndex = new AtomicInteger(0); + + ExecutorService executorService = + Executors.newFixedThreadPool( + 4, + runnable -> { + Thread thread = new Thread(runnable); + thread.setName("remove-orphan-" + deleteThreadsIndex.getAndIncrement()); + thread.setDaemon(true); + return thread; + }); + + DeleteOrphanFiles.Result result = + SparkActions.get() + .deleteOrphanFiles(table) + .executeDeleteWith(executorService) + .olderThan(System.currentTimeMillis() + 5000) // Ensure all orphan files are selected + .deleteWith( + file -> { + deleteThreads.add(Thread.currentThread().getName()); + deletedFiles.add(file); + }) + .execute(); + + // Verifies that the delete methods ran in the threads created by the provided ExecutorService + // ThreadFactory + assertThat(deleteThreads) + .containsExactlyInAnyOrder( + "remove-orphan-0", "remove-orphan-1", "remove-orphan-2", "remove-orphan-3"); + assertThat(deletedFiles).hasSize(4); + assertThat(result.orphanFilesCount()).as("Should delete 4 files").isEqualTo(4L); + } + + @TestTemplate + public void testWapFilesAreKept() { + assumeThat(usePrefixListing) + .as("Should not test both prefix listing and Hadoop file listing (redundant)") + .isEqualTo(false); + assumeThat(formatVersion).as("currently fails with DVs").isEqualTo(2); + Map props = Maps.newHashMap(); + props.put(TableProperties.WRITE_AUDIT_PUBLISH_ENABLED, "true"); + props.putAll(properties); + Table table = TABLES.create(SCHEMA, SPEC, props, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + + // normal write + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + withSQLConf( + Map.of(SparkSQLProperties.WAP_ID, "1"), + () -> { + // wap write + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + + // TODO: currently fails because DVs delete stuff from WAP branch + assertThat(actualRecords) + .as("Should not return data from the staged snapshot") + .isEqualTo(records); + }); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions.deleteOrphanFiles(table).olderThan(System.currentTimeMillis()).execute(); + + assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); + } + + @TestTemplate + public void testMetadataFolderIsIntact() { + // write data directly to the table location + Map props = Maps.newHashMap(); + props.put(TableProperties.WRITE_DATA_LOCATION, tableLocation); + props.putAll(properties); + Table table = TABLES.create(SCHEMA, SPEC, props, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df.write().mode("append").parquet(tableLocation + "/c2_trunc=AA/c3=AAAA"); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should delete 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + assertThat(actualRecords).as("Rows must match").isEqualTo(records); + } + + @TestTemplate + public void testOlderThanTimestamp() { + Table table = TABLES.create(SCHEMA, SPEC, properties, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df.write().mode("append").parquet(tableLocation + "/data/c2_trunc=AA/c3=AAAA"); + df.write().mode("append").parquet(tableLocation + "/data/c2_trunc=AA/c3=AAAA"); + + waitUntilAfter(System.currentTimeMillis()); + + long timestamp = System.currentTimeMillis(); + + waitUntilAfter(System.currentTimeMillis() + 1000L); + + df.write().mode("append").parquet(tableLocation + "/data/c2_trunc=AA/c3=AAAA"); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(timestamp) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should delete only 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete only 2 files").isEqualTo(2L); + } + + @TestTemplate + public void testRemoveUnreachableMetadataVersionFiles() { + Map props = Maps.newHashMap(); + props.put(TableProperties.WRITE_DATA_LOCATION, tableLocation); + props.put(TableProperties.METADATA_PREVIOUS_VERSIONS_MAX, "1"); + props.putAll(properties); + Table table = TABLES.create(SCHEMA, SPEC, props, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()) + .containsExactly(tableLocation + "metadata/v1.metadata.json"); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(records); + expectedRecords.addAll(records); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + assertThat(actualRecords).as("Rows must match").isEqualTo(expectedRecords); + } + + @TestTemplate + public void testManyTopLevelPartitions() { + Table table = TABLES.create(SCHEMA, SPEC, properties, tableLocation); + + List records = Lists.newArrayList(); + for (int i = 0; i < 100; i++) { + records.add(new ThreeColumnRecord(i, String.valueOf(i), String.valueOf(i))); + } + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + assertThat(resultDF.count()).as("Rows count must match").isEqualTo(records.size()); + } + + @TestTemplate + public void testManyLeafPartitions() { + Table table = TABLES.create(SCHEMA, SPEC, properties, tableLocation); + + List records = Lists.newArrayList(); + for (int i = 0; i < 100; i++) { + records.add(new ThreeColumnRecord(i, String.valueOf(i % 3), String.valueOf(i))); + } + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should not delete any files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should not delete any files").isEqualTo(0L); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + assertThat(resultDF.count()).as("Row count must match").isEqualTo(records.size()); + } + + @TestTemplate + public void testHiddenPartitionPaths() { + Schema schema = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "_c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).truncate("_c2", 2).identity("c3").build(); + Table table = TABLES.create(schema, spec, properties, tableLocation); + + StructType structType = + new StructType() + .add("c1", DataTypes.IntegerType) + .add("_c2", DataTypes.StringType) + .add("c3", DataTypes.StringType); + List records = Lists.newArrayList(RowFactory.create(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, structType).coalesce(1); + + df.select("c1", "_c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df.write().mode("append").parquet(tableLocation + "/data/_c2_trunc=AA/c3=AAAA"); + df.write().mode("append").parquet(tableLocation + "/data/_c2_trunc=AA/c3=AAAA"); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); + } + + @TestTemplate + public void testHiddenPartitionPathsWithPartitionEvolution() { + Schema schema = + new Schema( + optional(1, "_c1", Types.IntegerType.get()), + optional(2, "_c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).truncate("_c2", 2).build(); + Table table = TABLES.create(schema, spec, properties, tableLocation); + + StructType structType = + new StructType() + .add("_c1", DataTypes.IntegerType) + .add("_c2", DataTypes.StringType) + .add("c3", DataTypes.StringType); + List records = Lists.newArrayList(RowFactory.create(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, structType).coalesce(1); + + df.select("_c1", "_c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df.write().mode("append").parquet(tableLocation + "/data/_c2_trunc=AA"); + + table.updateSpec().addField("_c1").commit(); + + df.write().mode("append").parquet(tableLocation + "/data/_c2_trunc=AA/_c1=1"); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should delete 2 files").hasSize(2); + assertThat(result.orphanFilesCount()).as("Should delete 2 files").isEqualTo(2L); + } + + @TestTemplate + public void testHiddenPathsStartingWithPartitionNamesAreIgnored() throws IOException { + Schema schema = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "_c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).truncate("_c2", 2).identity("c3").build(); + Table table = TABLES.create(schema, spec, properties, tableLocation); + + StructType structType = + new StructType() + .add("c1", DataTypes.IntegerType) + .add("_c2", DataTypes.StringType) + .add("c3", DataTypes.StringType); + List records = Lists.newArrayList(RowFactory.create(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, structType).coalesce(1); + + df.select("c1", "_c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + Path dataPath = new Path(tableLocation + "/data"); + FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); + Path pathToFileInHiddenFolder = new Path(dataPath, "_c2_trunc/file.txt"); + fs.createNewFile(pathToFileInHiddenFolder); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should delete 0 files").isEmpty(); + assertThat(result.orphanFilesCount()).as("Should delete 0 files").isEqualTo(0L); + assertThat(fs.exists(pathToFileInHiddenFolder)).isTrue(); + } + + private List snapshotFiles(long snapshotId) { + return spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotId) + .load(tableLocation + "#files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + } + + @TestTemplate + public void testRemoveOrphanFilesWithRelativeFilePath() throws IOException { + assumeThat(usePrefixListing) + .as("Should not test both prefix listing and Hadoop file listing (redundant)") + .isEqualTo(false); + Table table = + TABLES.create( + SCHEMA, PartitionSpec.unpartitioned(), properties, tableDir.getAbsolutePath()); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3") + .write() + .format("iceberg") + .mode("append") + .save(tableDir.getAbsolutePath()); + + List validFiles = + spark + .read() + .format("iceberg") + .load(tableLocation + "#files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + assertThat(validFiles).as("Should be 1 valid file").hasSize(1); + String validFile = validFiles.get(0); + + df.write().mode("append").parquet(tableLocation + "/data"); + + Path dataPath = new Path(tableLocation + "/data"); + FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); + List allFiles = + Arrays.stream(fs.listStatus(dataPath, HiddenPathFilter.get())) + .filter(FileStatus::isFile) + .map(file -> file.getPath().toString()) + .collect(Collectors.toList()); + assertThat(allFiles).as("Should be 2 files").hasSize(2); + + List invalidFiles = Lists.newArrayList(allFiles); + invalidFiles.removeIf(file -> file.contains(validFile)); + assertThat(invalidFiles).as("Should be 1 invalid file").hasSize(1); + + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + DeleteOrphanFiles.Result result = + actions + .deleteOrphanFiles(table) + .olderThan(System.currentTimeMillis()) + .deleteWith(s -> {}) + .execute(); + assertThat(result.orphanFileLocations()) + .as("Action should find 1 file") + .isEqualTo(invalidFiles); + assertThat(result.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFiles.size()); + assertThat(fs.exists(new Path(invalidFiles.get(0)))) + .as("Invalid file should be present") + .isTrue(); + } + + @TestTemplate + public void testRemoveOrphanFilesWithHadoopCatalog() throws InterruptedException { + HadoopCatalog catalog = new HadoopCatalog(new Configuration(), tableLocation); + String namespaceName = "testDb"; + String tableName = "testTb"; + + Namespace namespace = Namespace.of(namespaceName); + TableIdentifier tableIdentifier = TableIdentifier.of(namespace, tableName); + Table table = + catalog.createTable( + tableIdentifier, SCHEMA, PartitionSpec.unpartitioned(), Maps.newHashMap()); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(table.location()); + + df.write().mode("append").parquet(table.location() + "/data"); + + waitUntilAfter(System.currentTimeMillis()); + + table.refresh(); + + DeleteOrphanFiles.Result result = + SparkActions.get() + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .execute(); + + assertThat(result.orphanFileLocations()).as("Should delete only 1 file").hasSize(1); + assertThat(result.orphanFilesCount()).as("Should delete only 1 file").isEqualTo(1L); + + Dataset resultDF = spark.read().format("iceberg").load(table.location()); + List actualRecords = + resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + assertThat(actualRecords).as("Rows must match").isEqualTo(records); + } + + @TestTemplate + public void testHiveCatalogTable() throws IOException { + TableIdentifier identifier = TableIdentifier.of("default", randomName("hivetestorphan")); + Table table = catalog.createTable(identifier, SCHEMA, SPEC, tableLocation, properties); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3") + .write() + .format("iceberg") + .mode("append") + .save(identifier.toString()); + + String location = table.location().replaceFirst("file:", ""); + new File(location + "/data/trashfile").createNewFile(); + + DeleteOrphanFiles.Result result = + SparkActions.get() + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + assertThat(result.orphanFileLocations()) + .as("trash file should be removed") + .contains("file:" + location + "/data/trashfile"); + assertThat(result.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); + } + + @TestTemplate + public void testGarbageCollectionDisabled() { + assumeThat(usePrefixListing) + .as("Should not test both prefix listing and Hadoop file listing (redundant)") + .isEqualTo(false); + Table table = TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), properties, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + table.updateProperties().set(TableProperties.GC_ENABLED, "false").commit(); + + assertThatThrownBy(() -> SparkActions.get().deleteOrphanFiles(table).execute()) + .isInstanceOf(ValidationException.class) + .hasMessage( + "Cannot delete orphan files: GC is disabled (deleting files may corrupt other tables)"); + } + + @TestTemplate + public void testCompareToFileList() throws IOException { + assumeThat(usePrefixListing) + .as("Should not test both prefix listing and Hadoop file listing (redundant)") + .isEqualTo(false); + Table table = TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), properties, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + Path dataPath = new Path(tableLocation + "/data"); + FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); + List validFiles = + Arrays.stream(fs.listStatus(dataPath, HiddenPathFilter.get())) + .filter(FileStatus::isFile) + .map( + file -> + new FilePathLastModifiedRecord( + file.getPath().toString(), new Timestamp(file.getModificationTime()))) + .collect(Collectors.toList()); + + assertThat(validFiles).as("Should be 2 valid files").hasSize(2); + + df.write().mode("append").parquet(tableLocation + "/data"); + + List allFiles = + Arrays.stream(fs.listStatus(dataPath, HiddenPathFilter.get())) + .filter(FileStatus::isFile) + .map( + file -> + new FilePathLastModifiedRecord( + file.getPath().toString(), new Timestamp(file.getModificationTime()))) + .collect(Collectors.toList()); + + assertThat(allFiles).as("Should be 3 files").hasSize(3); + + List invalidFiles = Lists.newArrayList(allFiles); + invalidFiles.removeAll(validFiles); + List invalidFilePaths = + invalidFiles.stream() + .map(FilePathLastModifiedRecord::getFilePath) + .collect(Collectors.toList()); + assertThat(invalidFiles).as("Should be 1 invalid file").hasSize(1); + + // sleep for 1 second to ensure files will be old enough + waitUntilAfter(System.currentTimeMillis()); + + SparkActions actions = SparkActions.get(); + + Dataset compareToFileList = + spark + .createDataFrame(allFiles, FilePathLastModifiedRecord.class) + .withColumnRenamed("filePath", "file_path") + .withColumnRenamed("lastModified", "last_modified"); + + DeleteOrphanFiles.Result result1 = + actions + .deleteOrphanFiles(table) + .compareToFileList(compareToFileList) + .deleteWith(s -> {}) + .execute(); + assertThat(result1.orphanFileLocations()) + .as("Default olderThan interval should be safe") + .isEmpty(); + assertThat(result1.orphanFilesCount()) + .as("Should not find any orphan file using default olderThan interval") + .isEqualTo(0L); + + DeleteOrphanFiles.Result result2 = + actions + .deleteOrphanFiles(table) + .compareToFileList(compareToFileList) + .olderThan(System.currentTimeMillis()) + .deleteWith(s -> {}) + .execute(); + assertThat(result2.orphanFileLocations()) + .as("Action should find 1 file") + .isEqualTo(invalidFilePaths); + assertThat(result2.orphanFilesCount()) + .as("Action should find 1 file") + .isEqualTo((long) invalidFilePaths.size()); + assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) + .as("Invalid file should be present") + .isTrue(); + + DeleteOrphanFiles.Result result3 = + actions + .deleteOrphanFiles(table) + .compareToFileList(compareToFileList) + .olderThan(System.currentTimeMillis()) + .execute(); + assertThat(result3.orphanFileLocations()) + .as("Action should delete 1 file") + .isEqualTo(invalidFilePaths); + assertThat(result3.orphanFilesCount()) + .as("Action should delete 1 file") + .isEqualTo((long) invalidFilePaths.size()); + assertThat(fs.exists(new Path(invalidFilePaths.get(0)))) + .as("Invalid file should not be present") + .isFalse(); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(records); + expectedRecords.addAll(records); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + assertThat(actualRecords).as("Rows must match").isEqualTo(expectedRecords); + + List outsideLocationMockFiles = + Lists.newArrayList(new FilePathLastModifiedRecord("/tmp/mock1", new Timestamp(0L))); + + Dataset compareToFileListWithOutsideLocation = + spark + .createDataFrame(outsideLocationMockFiles, FilePathLastModifiedRecord.class) + .withColumnRenamed("filePath", "file_path") + .withColumnRenamed("lastModified", "last_modified"); + + DeleteOrphanFiles.Result result4 = + actions + .deleteOrphanFiles(table) + .compareToFileList(compareToFileListWithOutsideLocation) + .deleteWith(s -> {}) + .execute(); + assertThat(result4.orphanFileLocations()).as("Action should find nothing").isEmpty(); + assertThat(result4.orphanFilesCount()).as("Action should find nothing").isEqualTo(0L); + } + + protected long waitUntilAfter(long timestampMillis) { + long current = System.currentTimeMillis(); + while (current <= timestampMillis) { + current = System.currentTimeMillis(); + } + return current; + } + + @TestTemplate + public void testRemoveOrphanFilesWithStatisticFiles() throws Exception { + assumeThat(usePrefixListing) + .as("Should not test both prefix listing and Hadoop file listing (redundant)") + .isEqualTo(false); + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + Table table = TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), properties, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + table.refresh(); + long snapshotId = table.currentSnapshot().snapshotId(); + long snapshotSequenceNumber = table.currentSnapshot().sequenceNumber(); + + File statsLocation = + new File(new URI(tableLocation)) + .toPath() + .resolve("data") + .resolve("some-stats-file") + .toFile(); + StatisticsFile statisticsFile; + try (PuffinWriter puffinWriter = Puffin.write(Files.localOutput(statsLocation)).build()) { + puffinWriter.add( + new Blob( + "some-blob-type", + ImmutableList.of(1), + snapshotId, + snapshotSequenceNumber, + ByteBuffer.wrap("blob content".getBytes(StandardCharsets.UTF_8)))); + puffinWriter.finish(); + statisticsFile = + new GenericStatisticsFile( + snapshotId, + statsLocation.toString(), + puffinWriter.fileSize(), + puffinWriter.footerSize(), + puffinWriter.writtenBlobsMetadata().stream() + .map(GenericBlobMetadata::from) + .collect(ImmutableList.toImmutableList())); + } + + Transaction transaction = table.newTransaction(); + transaction.updateStatistics().setStatistics(statisticsFile).commit(); + transaction.commitTransaction(); + + SparkActions.get() + .deleteOrphanFiles(table) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + + assertThat(statsLocation).as("stats file should exist").exists(); + assertThat(statsLocation.length()) + .as("stats file length") + .isEqualTo(statisticsFile.fileSizeInBytes()); + + transaction = table.newTransaction(); + transaction.updateStatistics().removeStatistics(statisticsFile.snapshotId()).commit(); + transaction.commitTransaction(); + + DeleteOrphanFiles.Result result = + SparkActions.get() + .deleteOrphanFiles(table) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + Iterable orphanFileLocations = result.orphanFileLocations(); + assertThat(orphanFileLocations).hasSize(1).containsExactly(statsLocation.toURI().toString()); + assertThat(result.orphanFilesCount()).as("Should delete 1 file").isEqualTo(1L); + assertThat(statsLocation).as("stats file should be deleted").doesNotExist(); + } + + @TestTemplate + public void testPathsWithExtraSlashes() { + List validFiles = Lists.newArrayList("file:///dir1/dir2/file1"); + List actualFiles = Lists.newArrayList("file:///dir1/////dir2///file1"); + executeTest(validFiles, actualFiles, Lists.newArrayList()); + } + + @TestTemplate + public void testPathsWithValidFileHavingNoAuthority() { + List validFiles = Lists.newArrayList("hdfs:///dir1/dir2/file1"); + List actualFiles = Lists.newArrayList("hdfs://servicename/dir1/dir2/file1"); + executeTest(validFiles, actualFiles, Lists.newArrayList()); + } + + @TestTemplate + public void testPathsWithActualFileHavingNoAuthority() { + List validFiles = Lists.newArrayList("hdfs://servicename/dir1/dir2/file1"); + List actualFiles = Lists.newArrayList("hdfs:///dir1/dir2/file1"); + executeTest(validFiles, actualFiles, Lists.newArrayList()); + } + + @TestTemplate + public void testPathsWithEqualSchemes() { + List validFiles = Lists.newArrayList("scheme1://bucket1/dir1/dir2/file1"); + List actualFiles = Lists.newArrayList("scheme2://bucket1/dir1/dir2/file1"); + assertThatThrownBy( + () -> + executeTest( + validFiles, + actualFiles, + Lists.newArrayList(), + ImmutableMap.of(), + ImmutableMap.of(), + DeleteOrphanFiles.PrefixMismatchMode.ERROR)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith("Unable to determine whether certain files are orphan") + .hasMessageEndingWith("Conflicting authorities/schemes: [(scheme1, scheme2)]."); + + Map equalSchemes = Maps.newHashMap(); + equalSchemes.put("scheme1", "scheme"); + equalSchemes.put("scheme2", "scheme"); + executeTest( + validFiles, + actualFiles, + Lists.newArrayList(), + equalSchemes, + ImmutableMap.of(), + DeleteOrphanFiles.PrefixMismatchMode.ERROR); + } + + @TestTemplate + public void testPathsWithEqualAuthorities() { + List validFiles = Lists.newArrayList("hdfs://servicename1/dir1/dir2/file1"); + List actualFiles = Lists.newArrayList("hdfs://servicename2/dir1/dir2/file1"); + assertThatThrownBy( + () -> + executeTest( + validFiles, + actualFiles, + Lists.newArrayList(), + ImmutableMap.of(), + ImmutableMap.of(), + DeleteOrphanFiles.PrefixMismatchMode.ERROR)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith("Unable to determine whether certain files are orphan") + .hasMessageEndingWith("Conflicting authorities/schemes: [(servicename1, servicename2)]."); + + Map equalAuthorities = Maps.newHashMap(); + equalAuthorities.put("servicename1", "servicename"); + equalAuthorities.put("servicename2", "servicename"); + executeTest( + validFiles, + actualFiles, + Lists.newArrayList(), + ImmutableMap.of(), + equalAuthorities, + DeleteOrphanFiles.PrefixMismatchMode.ERROR); + } + + @TestTemplate + public void testRemoveOrphanFileActionWithDeleteMode() { + List validFiles = Lists.newArrayList("hdfs://servicename1/dir1/dir2/file1"); + List actualFiles = Lists.newArrayList("hdfs://servicename2/dir1/dir2/file1"); + + executeTest( + validFiles, + actualFiles, + Lists.newArrayList("hdfs://servicename2/dir1/dir2/file1"), + ImmutableMap.of(), + ImmutableMap.of(), + DeleteOrphanFiles.PrefixMismatchMode.DELETE); + } + + @TestTemplate + public void testDefaultToHadoopListing() { + assumeThat(usePrefixListing) + .as( + "This test verifies default listing behavior and does not require prefix listing to be enabled.") + .isEqualTo(false); + Table table = TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), properties, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + DeleteOrphanFilesSparkAction deleteOrphanFilesSparkAction = + SparkActions.get().deleteOrphanFiles(table); + DeleteOrphanFilesSparkAction spyAction = Mockito.spy(deleteOrphanFilesSparkAction); + try (MockedStatic mockedStatic = Mockito.mockStatic(FileSystemWalker.class)) { + spyAction.execute(); + mockedStatic.verify( + () -> + FileSystemWalker.listDirRecursivelyWithHadoop( + anyString(), + anyMap(), + any(Predicate.class), + any(Configuration.class), + anyInt(), + anyInt(), + any(), + any())); + } + } + + protected String randomName(String prefix) { + return prefix + UUID.randomUUID().toString().replace("-", ""); + } + + private void executeTest( + List validFiles, List actualFiles, List expectedOrphanFiles) { + executeTest( + validFiles, + actualFiles, + expectedOrphanFiles, + ImmutableMap.of(), + ImmutableMap.of(), + DeleteOrphanFiles.PrefixMismatchMode.IGNORE); + } + + private void executeTest( + List validFiles, + List actualFiles, + List expectedOrphanFiles, + Map equalSchemes, + Map equalAuthorities, + DeleteOrphanFiles.PrefixMismatchMode mode) { + + StringToFileURI toFileUri = new StringToFileURI(equalSchemes, equalAuthorities); + + Dataset validFileDS = spark.createDataset(validFiles, Encoders.STRING()); + Dataset actualFileDS = spark.createDataset(actualFiles, Encoders.STRING()); + + Dataset orphanFileDS = + DeleteOrphanFilesSparkAction.findOrphanFiles( + toFileUri.apply(actualFileDS), toFileUri.apply(validFileDS), mode); + + List orphanFiles = orphanFileDS.collectAsList(); + orphanFileDS.unpersist(); + + assertThat(orphanFiles).isEqualTo(expectedOrphanFiles); + } + + @TestTemplate + public void testStreamResultsDeletion() throws IOException { + Table table = TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), properties, tableLocation); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + + List validFiles = + spark + .read() + .format("iceberg") + .load(tableLocation + "#files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + assertThat(validFiles).as("Should be 1 valid file").hasSize(1); + + for (int i = 0; i < 10; i++) { + df.write().mode("append").parquet(tableLocation + "/data"); + } + + Path dataPath = new Path(tableLocation + "/data"); + FileSystem fs = dataPath.getFileSystem(spark.sessionState().newHadoopConf()); + List allFiles = + Arrays.stream(fs.listStatus(dataPath, HiddenPathFilter.get())) + .filter(FileStatus::isFile) + .map(file -> file.getPath().toString()) + .collect(Collectors.toList()); + assertThat(allFiles).as("Should be 11 files").hasSize(11); + + List invalidFiles = Lists.newArrayList(allFiles); + invalidFiles.removeAll(validFiles); + assertThat(invalidFiles).as("Should be 10 invalid files").hasSize(10); + + waitUntilAfter(System.currentTimeMillis()); + + DeleteOrphanFiles.Result nonStreamingResult = + SparkActions.get() + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .deleteWith(s -> {}) + .execute(); + + assertThat(nonStreamingResult.orphanFileLocations()) + .as("Non-streaming dry-run should return all 10 orphan files") + .hasSize(10) + .containsExactlyInAnyOrderElementsOf(invalidFiles); + assertThat(nonStreamingResult.orphanFilesCount()) + .as("Non-streaming dry-run should return all 10 orphan files") + .isEqualTo((long) invalidFiles.size()); + + DeleteOrphanFiles.Result streamingResult = + SparkActions.get() + .deleteOrphanFiles(table) + .usePrefixListing(usePrefixListing) + .olderThan(System.currentTimeMillis()) + .option(STREAM_RESULTS, "true") + .option(MAX_ORPHAN_FILE_SAMPLE_SIZE, "5") + .execute(); + + assertThat(streamingResult.orphanFileLocations()) + .as("Streaming with sample size 5 should return only 5 orphan files") + .hasSize(5); + assertThat(streamingResult.orphanFilesCount()) + .as("Deleted 10 files") + .isEqualTo((long) invalidFiles.size()); + + for (String invalidFile : invalidFiles) { + assertThat(fs.exists(new Path(invalidFile))).as("Orphan file should be deleted").isFalse(); + } + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + assertThat(actualRecords).isEqualTo(records); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java new file mode 100644 index 000000000000..88ac800b158f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRemoveOrphanFilesAction3.java @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.util.stream.StreamSupport; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.source.SparkTable; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.expressions.Transform; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestRemoveOrphanFilesAction3 extends TestRemoveOrphanFilesAction { + @TestTemplate + public void testSparkCatalogTable() throws Exception { + spark.conf().set("spark.sql.catalog.mycat", "org.apache.iceberg.spark.SparkCatalog"); + spark.conf().set("spark.sql.catalog.mycat.type", "hadoop"); + spark.conf().set("spark.sql.catalog.mycat.warehouse", tableLocation); + SparkCatalog cat = (SparkCatalog) spark.sessionState().catalogManager().catalog("mycat"); + + String[] database = {"default"}; + Identifier id = Identifier.of(database, randomName("table")); + Transform[] transforms = {}; + cat.createTable(id, SparkSchemaUtil.convert(SCHEMA), transforms, properties); + SparkTable table = (SparkTable) cat.loadTable(id); + + sql("INSERT INTO mycat.default.%s VALUES (1,1,1)", id.name()); + + String location = table.table().location().replaceFirst("file:", ""); + String trashFile = randomName("/data/trashfile"); + new File(location + trashFile).createNewFile(); + + DeleteOrphanFiles.Result results = + SparkActions.get() + .deleteOrphanFiles(table.table()) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + assertThat(results.orphanFileLocations()) + .as("trash file should be removed") + .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); + } + + @TestTemplate + public void testSparkCatalogNamedHadoopTable() throws Exception { + spark.conf().set("spark.sql.catalog.hadoop", "org.apache.iceberg.spark.SparkCatalog"); + spark.conf().set("spark.sql.catalog.hadoop.type", "hadoop"); + spark.conf().set("spark.sql.catalog.hadoop.warehouse", tableLocation); + SparkCatalog cat = (SparkCatalog) spark.sessionState().catalogManager().catalog("hadoop"); + + String[] database = {"default"}; + Identifier id = Identifier.of(database, randomName("table")); + Transform[] transforms = {}; + cat.createTable(id, SparkSchemaUtil.convert(SCHEMA), transforms, properties); + SparkTable table = (SparkTable) cat.loadTable(id); + + sql("INSERT INTO hadoop.default.%s VALUES (1,1,1)", id.name()); + + String location = table.table().location().replaceFirst("file:", ""); + String trashFile = randomName("/data/trashfile"); + new File(location + trashFile).createNewFile(); + + DeleteOrphanFiles.Result results = + SparkActions.get() + .deleteOrphanFiles(table.table()) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + assertThat(results.orphanFileLocations()) + .as("trash file should be removed") + .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); + } + + @TestTemplate + public void testSparkCatalogNamedHiveTable() throws Exception { + spark.conf().set("spark.sql.catalog.hive", "org.apache.iceberg.spark.SparkCatalog"); + spark.conf().set("spark.sql.catalog.hive.type", "hadoop"); + spark.conf().set("spark.sql.catalog.hive.warehouse", tableLocation); + SparkCatalog cat = (SparkCatalog) spark.sessionState().catalogManager().catalog("hive"); + + String[] database = {"default"}; + Identifier id = Identifier.of(database, randomName("table")); + Transform[] transforms = {}; + cat.createTable(id, SparkSchemaUtil.convert(SCHEMA), transforms, properties); + SparkTable table = (SparkTable) cat.loadTable(id); + + sql("INSERT INTO hive.default.%s VALUES (1,1,1)", id.name()); + + String location = table.table().location().replaceFirst("file:", ""); + String trashFile = randomName("/data/trashfile"); + new File(location + trashFile).createNewFile(); + + DeleteOrphanFiles.Result results = + SparkActions.get() + .deleteOrphanFiles(table.table()) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + + assertThat(StreamSupport.stream(results.orphanFileLocations().spliterator(), false)) + .as("trash file should be removed") + .anyMatch(file -> file.contains("file:" + location + trashFile)); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); + } + + @TestTemplate + public void testSparkSessionCatalogHadoopTable() throws Exception { + spark + .conf() + .set("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog"); + spark.conf().set("spark.sql.catalog.spark_catalog.type", "hadoop"); + spark.conf().set("spark.sql.catalog.spark_catalog.warehouse", tableLocation); + SparkSessionCatalog cat = + (SparkSessionCatalog) spark.sessionState().catalogManager().v2SessionCatalog(); + + String[] database = {"default"}; + Identifier id = Identifier.of(database, randomName("table")); + Transform[] transforms = {}; + cat.createTable(id, SparkSchemaUtil.convert(SCHEMA), transforms, properties); + SparkTable table = (SparkTable) cat.loadTable(id); + + sql("INSERT INTO default.%s VALUES (1,1,1)", id.name()); + + String location = table.table().location().replaceFirst("file:", ""); + String trashFile = randomName("/data/trashfile"); + new File(location + trashFile).createNewFile(); + + DeleteOrphanFiles.Result results = + SparkActions.get() + .deleteOrphanFiles(table.table()) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + assertThat(results.orphanFileLocations()) + .as("trash file should be removed") + .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); + } + + @TestTemplate + public void testSparkSessionCatalogHiveTable() throws Exception { + spark + .conf() + .set("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.SparkSessionCatalog"); + spark.conf().set("spark.sql.catalog.spark_catalog.type", "hive"); + SparkSessionCatalog cat = + (SparkSessionCatalog) spark.sessionState().catalogManager().v2SessionCatalog(); + + String[] database = {"default"}; + Identifier id = Identifier.of(database, "sessioncattest"); + Transform[] transforms = {}; + cat.dropTable(id); + cat.createTable(id, SparkSchemaUtil.convert(SCHEMA), transforms, properties); + SparkTable table = (SparkTable) cat.loadTable(id); + + sql("INSERT INTO default.sessioncattest VALUES (1,1,1)"); + + String location = table.table().location().replaceFirst("file:", ""); + String trashFile = randomName("/data/trashfile"); + new File(location + trashFile).createNewFile(); + + DeleteOrphanFiles.Result results = + SparkActions.get() + .deleteOrphanFiles(table.table()) + .olderThan(System.currentTimeMillis() + 1000) + .execute(); + assertThat(results.orphanFileLocations()) + .as("trash file should be removed") + .contains("file:" + location + trashFile); + assertThat(results.orphanFilesCount()).as("trash file should be removed").isEqualTo(1L); + } + + @AfterEach + public void resetSparkSessionCatalog() { + spark.conf().unset("spark.sql.catalog.spark_catalog"); + spark.conf().unset("spark.sql.catalog.spark_catalog.type"); + spark.conf().unset("spark.sql.catalog.spark_catalog.warehouse"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java new file mode 100644 index 000000000000..110e43ede1f9 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteDataFilesAction.java @@ -0,0 +1,2690 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.TableProperties.COMMIT_NUM_RETRIES; +import static org.apache.iceberg.data.FileHelpers.encrypt; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.current_date; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.min; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.LongStream; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.PartitionKey; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.actions.BinPackRewriteFilePlanner; +import org.apache.iceberg.actions.RewriteDataFiles; +import org.apache.iceberg.actions.RewriteDataFiles.Result; +import org.apache.iceberg.actions.RewriteDataFilesCommitManager; +import org.apache.iceberg.actions.RewriteFileGroup; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.data.GenericFileWriterFactory; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.deletes.BaseDVFileWriter; +import org.apache.iceberg.deletes.DVFileWriter; +import org.apache.iceberg.deletes.EqualityDeleteWriter; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.exceptions.CommitFailedException; +import org.apache.iceberg.exceptions.CommitStateUnknownException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.collect.Streams; +import org.apache.iceberg.spark.FileRewriteCoordinator; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.iceberg.types.Comparators; +import org.apache.iceberg.types.Conversions; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.DataTypes; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentMatcher; +import org.mockito.Mockito; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewriteDataFilesAction extends TestBase { + + @TempDir private File tableDir; + private static final int SCALE = 400000; + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + + private static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + + @Parameter private int formatVersion; + + @Parameters(name = "formatVersion = {0}") + protected static List parameters() { + return org.apache.iceberg.TestHelpers.V2_AND_ABOVE; + } + + private final FileRewriteCoordinator coordinator = FileRewriteCoordinator.get(); + private final ScanTaskSetManager manager = ScanTaskSetManager.get(); + private String tableLocation = null; + + @BeforeAll + public static void setupSpark() { + // disable AQE as tests assume that writes generate a particular number of files + spark.conf().set(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false"); + } + + @BeforeEach + public void setupTableLocation() { + this.tableLocation = tableDir.toURI().toString(); + } + + private RewriteDataFilesSparkAction basicRewrite(Table table) { + // Always compact regardless of input files + return actions() + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1"); + } + + @TestTemplate + public void testEmptyTable() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + assertThat(table.currentSnapshot()).as("Table must be empty").isNull(); + + basicRewrite(table).execute(); + + assertThat(table.currentSnapshot()).as("Table must stay empty").isNull(); + } + + @TestTemplate + public void testBinPackUnpartitionedTable() { + Table table = createTable(4); + shouldHaveFiles(table, 4); + List expectedRecords = currentData(); + long dataSizeBefore = testDataSize(table); + + Result result = basicRewrite(table).execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 4 data files") + .isEqualTo(4); + assertThat(result.addedDataFilesCount()).as("Action should add 1 data file").isOne(); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveFiles(table, 1); + List actual = currentData(); + + assertEquals("Rows must match", expectedRecords, actual); + } + + @TestTemplate + public void testBinPackPartitionedTable() { + Table table = createTablePartitioned(4, 2); + shouldHaveFiles(table, 8); + List expectedRecords = currentData(); + long dataSizeBefore = testDataSize(table); + + Result result = basicRewrite(table).execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 8 data files") + .isEqualTo(8); + assertThat(result.addedDataFilesCount()).as("Action should add 4 data file").isEqualTo(4); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveFiles(table, 4); + List actualRecords = currentData(); + + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testBinPackWithFilter() { + Table table = createTablePartitioned(4, 2); + shouldHaveFiles(table, 8); + List expectedRecords = currentData(); + long dataSizeBefore = testDataSize(table); + + Result result = + basicRewrite(table) + .filter(Expressions.equal("c1", 1)) + .filter(Expressions.startsWith("c2", "foo")) + .execute(); + + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 2 data files") + .isEqualTo(2); + assertThat(result.addedDataFilesCount()).as("Action should add 1 data file").isOne(); + assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); + + shouldHaveFiles(table, 7); + + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testBinPackWithFilterOnBucketExpression() { + Table table = createTablePartitioned(4, 2); + + shouldHaveFiles(table, 8); + List expectedRecords = currentData(); + long dataSizeBefore = testDataSize(table); + + Result result = + basicRewrite(table) + .filter(Expressions.equal("c1", 1)) + .filter(Expressions.equal(Expressions.bucket("c2", 2), 0)) + .execute(); + + assertThat(result) + .extracting(Result::rewrittenDataFilesCount, Result::addedDataFilesCount) + .as("Action should rewrite 2 data files into 1 data file") + .contains(2, 1); + assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); + + shouldHaveFiles(table, 7); + + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testBinPackAfterPartitionChange() { + Table table = createTable(); + + writeRecords(20, SCALE, 20); + table.refresh(); + shouldHaveFiles(table, 20); + table.updateSpec().addField(Expressions.ref("c1")).commit(); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFiles.Result result = + basicRewrite(table) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1") + .option( + SizeBasedFileRewritePlanner.MIN_FILE_SIZE_BYTES, + Integer.toString(averageFileSize(table) + 1000)) + .option( + RewriteDataFiles.TARGET_FILE_SIZE_BYTES, + // Increase max file size for V3 to account for additional row lineage fields + Integer.toString(averageFileSize(table) + (formatVersion >= 3 ? 12000 : 1100))) + .execute(); + + assertThat(result.rewriteResults()) + .as("Should have 1 fileGroup because all files were not correctly partitioned") + .hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + shouldHaveFiles(table, 20); + } + + @TestTemplate + public void testDataFilesRewrittenWithMaxDeleteRatio() throws Exception { + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + Table table = createTable(); + int numDataFiles = 5; + // 100 / 5 = 20 records per data file + writeRecords(numDataFiles, 100); + // delete > 100% of records for each data file + int numPositionsToDelete = 1000; + table.refresh(); + List dataFiles = TestHelpers.dataFiles(table); + assertThat(dataFiles).hasSize(numDataFiles); + + RowDelta rowDelta = table.newRowDelta(); + for (DataFile dataFile : dataFiles) { + if (formatVersion >= 3) { + writeDV(table, dataFile.partition(), dataFile.location(), numPositionsToDelete) + .forEach(rowDelta::addDeletes); + } else { + writePosDeletes(table, dataFile.partition(), dataFile.location(), 4, numPositionsToDelete) + .forEach(rowDelta::addDeletes); + } + } + + rowDelta.commit(); + + Set deleteFiles = TestHelpers.deleteFiles(table); + int expectedDataFiles = formatVersion >= 3 ? numDataFiles : numDataFiles * 4; + assertThat(deleteFiles).hasSize(expectedDataFiles); + + // there are 5 data files with a delete ratio of > 100% each, so all data files should be + // rewritten. Set MIN_INPUT_FILES > to the number of data files so that compaction is only + // triggered when the delete ratio of >= 30% is hit + RewriteDataFiles.Result result = + SparkActions.get(spark) + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "10") + .option(SizeBasedFileRewritePlanner.MIN_FILE_SIZE_BYTES, "0") + .execute(); + + assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); + + List newDataFiles = TestHelpers.dataFiles(table); + assertThat(newDataFiles).isEmpty(); + + Set newDeleteFiles = TestHelpers.deleteFiles(table); + assertThat(newDeleteFiles).isEmpty(); + } + + @TestTemplate + public void testDataFilesRewrittenWithHighDeleteRatio() throws Exception { + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + Table table = createTable(); + int numDataFiles = 5; + // 100 / 5 = 20 records per data file + writeRecords(numDataFiles, 100); + // delete 40% of records for each data file + int numPositionsToDelete = 8; + table.refresh(); + List dataFiles = TestHelpers.dataFiles(table); + assertThat(dataFiles).hasSize(numDataFiles); + + RowDelta rowDelta = table.newRowDelta(); + for (DataFile dataFile : dataFiles) { + if (formatVersion >= 3) { + writeDV(table, dataFile.partition(), dataFile.location(), numPositionsToDelete) + .forEach(rowDelta::addDeletes); + } else { + writePosDeletes(table, dataFile.partition(), dataFile.location(), 4, numPositionsToDelete) + .forEach(rowDelta::addDeletes); + } + } + + rowDelta.commit(); + + Set deleteFiles = TestHelpers.deleteFiles(table); + int expectedDataFiles = formatVersion >= 3 ? numDataFiles : numDataFiles * 4; + assertThat(deleteFiles).hasSize(expectedDataFiles); + + // there are 5 data files with a delete ratio of 40% each, so all data files should be + // rewritten. Set MIN_INPUT_FILES > to the number of data files so that compaction is only + // triggered when the delete ratio of >= 30% is hit + RewriteDataFiles.Result result = + SparkActions.get(spark) + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "10") + .option(SizeBasedFileRewritePlanner.MIN_FILE_SIZE_BYTES, "0") + .execute(); + + assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); + + List newDataFiles = TestHelpers.dataFiles(table); + assertThat(newDataFiles).hasSize(1); + + Set newDeleteFiles = TestHelpers.deleteFiles(table); + assertThat(newDeleteFiles).isEmpty(); + } + + @TestTemplate + public void testDataFilesNotRewrittenWithLowDeleteRatio() throws Exception { + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + Table table = createTable(); + int numDataFiles = 5; + // 100 / 5 = 20 records per data file + writeRecords(numDataFiles, 100); + // delete 25% of records for each data file + int numPositionsToDelete = 5; + table.refresh(); + List dataFiles = TestHelpers.dataFiles(table); + assertThat(dataFiles).hasSize(numDataFiles); + + RowDelta rowDelta = table.newRowDelta(); + for (DataFile dataFile : dataFiles) { + if (formatVersion >= 3) { + writeDV(table, dataFile.partition(), dataFile.location(), numPositionsToDelete) + .forEach(rowDelta::addDeletes); + } else { + writePosDeletes(table, dataFile.partition(), dataFile.location(), 5, numPositionsToDelete) + .forEach(rowDelta::addDeletes); + } + } + + rowDelta.commit(); + + Set deleteFiles = TestHelpers.deleteFiles(table); + int expectedDataFiles = formatVersion >= 3 ? numDataFiles : numDataFiles * 5; + assertThat(deleteFiles).hasSize(expectedDataFiles); + + // there are 5 data files with a delete ratio of 25% each, so data files should not be + // rewritten. Set MIN_INPUT_FILES > to the number of data files so that compaction is only + // triggered when the delete ratio of >= 30% is hit + RewriteDataFiles.Result result = + SparkActions.get(spark) + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "10") + .option(SizeBasedFileRewritePlanner.MIN_FILE_SIZE_BYTES, "0") + .execute(); + + assertThat(result.rewrittenDataFilesCount()).isEqualTo(0); + + List newDataFiles = TestHelpers.dataFiles(table); + assertThat(newDataFiles).hasSameSizeAs(dataFiles); + + Set newDeleteFiles = TestHelpers.deleteFiles(table); + assertThat(newDeleteFiles).hasSameSizeAs(deleteFiles); + } + + @TestTemplate + public void testBinPackWithV2PositionDeletes() throws IOException { + assumeThat(formatVersion).isEqualTo(2); + Table table = createTablePartitioned(4, 2); + shouldHaveFiles(table, 8); + + List dataFiles = TestHelpers.dataFiles(table); + int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); + + RowDelta rowDelta = table.newRowDelta(); + // add 1 delete file for data files 0, 1, 2 + for (int i = 0; i < 3; i++) { + writePosDeletesToFile(table, dataFiles.get(i), 1).forEach(rowDelta::addDeletes); + } + + // add 2 delete files for data files 3, 4 + for (int i = 3; i < 5; i++) { + writePosDeletesToFile(table, dataFiles.get(i), 2).forEach(rowDelta::addDeletes); + } + + rowDelta.commit(); + List expectedRecords = currentData(); + long dataSizeBefore = testDataSize(table); + Result result = + actions() + .rewriteDataFiles(table) + // do not include any file based on bin pack file size configs + .option(BinPackRewriteFilePlanner.MIN_FILE_SIZE_BYTES, "0") + .option(RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Long.toString(Long.MAX_VALUE - 1)) + .option(BinPackRewriteFilePlanner.MAX_FILE_SIZE_BYTES, Long.toString(Long.MAX_VALUE)) + .option(BinPackRewriteFilePlanner.DELETE_FILE_THRESHOLD, "2") + .execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 2 data files") + .isEqualTo(2); + assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); + + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertThat(actualRecords).as("7 rows are removed").hasSize(total - 7); + } + + @TestTemplate + public void testBinPackWithDVs() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + Table table = createTablePartitioned(4, 2); + shouldHaveFiles(table, 8); + List initialRecords = currentDataWithLineage(); + Set rowIds = + initialRecords.stream().map(record -> (Long) record[0]).collect(Collectors.toSet()); + Set lastUpdatedSequenceNumbers = + initialRecords.stream().map(record -> (Long) record[1]).collect(Collectors.toSet()); + assertThat(rowIds) + .isEqualTo(LongStream.range(0, initialRecords.size()).boxed().collect(Collectors.toSet())); + assertThat(lastUpdatedSequenceNumbers).allMatch(sequenceNumber -> sequenceNumber.equals(1L)); + List dataFiles = TestHelpers.dataFiles(table); + int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); + + RowDelta rowDelta = table.newRowDelta(); + Set rowIdsBeingRemoved = Sets.newHashSet(); + + // add 1 DV for data files 0, 1, 2 + for (int i = 0; i < 3; i++) { + writeDV(table, dataFiles.get(i).partition(), dataFiles.get(i).location(), 1) + .forEach(rowDelta::addDeletes); + rowIdsBeingRemoved.add(dataFiles.get(i).firstRowId()); + } + + // delete 2 positions for data files 3, 4 + for (int i = 3; i < 5; i++) { + writeDV(table, dataFiles.get(i).partition(), dataFiles.get(i).location(), 2) + .forEach(rowDelta::addDeletes); + long dataFileFirstRowId = dataFiles.get(i).firstRowId(); + rowIdsBeingRemoved.add(dataFileFirstRowId); + rowIdsBeingRemoved.add(dataFileFirstRowId + 1); + } + + rowDelta.commit(); + List recordsWithLineageAfterDelete = currentDataWithLineage(); + rowIds.removeAll(rowIdsBeingRemoved); + assertThat(rowIds) + .isEqualTo( + recordsWithLineageAfterDelete.stream() + .map(record -> (Long) record[0]) + .collect(Collectors.toSet())); + + long dataSizeBefore = testDataSize(table); + + Result result = + actions() + .rewriteDataFiles(table) + // do not include any file based on bin pack file size configs + .option(BinPackRewriteFilePlanner.MIN_FILE_SIZE_BYTES, "0") + .option(RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Long.toString(Long.MAX_VALUE - 1)) + .option(BinPackRewriteFilePlanner.MAX_FILE_SIZE_BYTES, Long.toString(Long.MAX_VALUE)) + // set DELETE_FILE_THRESHOLD to 1 since DVs only produce one delete file per data file + .option(BinPackRewriteFilePlanner.DELETE_FILE_THRESHOLD, "1") + .execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 5 data files") + .isEqualTo(5); + assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); + List actualRecordsWithLineage = currentDataWithLineage(); + assertEquals("Rows must match", recordsWithLineageAfterDelete, actualRecordsWithLineage); + assertThat(actualRecordsWithLineage).as("7 rows are removed").hasSize(total - 7); + } + + @TestTemplate + public void removeDanglingDVsFromDeleteManifest() throws Exception { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + Table table = createTable(); + int numDataFiles = 5; + // 100 / 5 = 20 records per data file + writeRecords(numDataFiles, 100); + int numPositionsToDelete = 10; + table.refresh(); + List dataFiles = TestHelpers.dataFiles(table); + assertThat(dataFiles).hasSize(numDataFiles); + + RowDelta rowDelta = table.newRowDelta(); + for (DataFile dataFile : dataFiles) { + writeDV(table, dataFile.partition(), dataFile.location(), numPositionsToDelete) + .forEach(rowDelta::addDeletes); + } + + rowDelta.commit(); + + Set deleteFiles = TestHelpers.deleteFiles(table); + assertThat(deleteFiles).hasSize(numDataFiles); + + Set validDataFilePaths = + TestHelpers.dataFiles(table).stream() + .map(ContentFile::location) + .collect(Collectors.toSet()); + for (ManifestFile manifestFile : table.currentSnapshot().deleteManifests(table.io())) { + ManifestReader reader = + ManifestFiles.readDeleteManifest( + manifestFile, table.io(), ((BaseTable) table).operations().current().specsById()); + for (DeleteFile deleteFile : reader) { + // make sure there are no orphaned DVs + assertThat(validDataFilePaths).contains(deleteFile.referencedDataFile()); + } + } + + // all data files should be rewritten. Set MIN_INPUT_FILES > to the number of data files so that + // compaction is only triggered when the delete ratio of >= 30% is hit + RewriteDataFiles.Result result = + SparkActions.get(spark) + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "10") + .option(SizeBasedFileRewritePlanner.MIN_FILE_SIZE_BYTES, "0") + .execute(); + + assertThat(result.rewrittenDataFilesCount()).isEqualTo(numDataFiles); + assertThat(result.removedDeleteFilesCount()).isEqualTo(numDataFiles); + + assertThat(TestHelpers.dataFiles(table)).hasSize(1); + assertThat(TestHelpers.deleteFiles(table)).isEmpty(); + + validDataFilePaths = + TestHelpers.dataFiles(table).stream() + .map(ContentFile::location) + .collect(Collectors.toSet()); + for (ManifestFile manifestFile : table.currentSnapshot().deleteManifests(table.io())) { + ManifestReader reader = + ManifestFiles.readDeleteManifest( + manifestFile, table.io(), ((BaseTable) table).operations().current().specsById()); + for (DeleteFile deleteFile : reader) { + // make sure there are no orphaned DVs + assertThat(validDataFilePaths).contains(deleteFile.referencedDataFile()); + } + } + } + + @TestTemplate + public void testRemoveDangledEqualityDeletesPartitionEvolution() { + Table table = + TABLES.create( + SCHEMA, + SPEC, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + // data seq = 1, write 4 files in 2 partitions + List records1 = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), new ThreeColumnRecord(1, "BBBBBBBBBB", "BBBB")); + writeRecords(records1); + List records2 = + Lists.newArrayList( + new ThreeColumnRecord(0, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(0, "DDDDDDDDDD", "DDDD")); + writeRecords(records2); + table.refresh(); + shouldHaveFiles(table, 4); + + // data seq = 2 & 3, write 2 equality deletes in both partitions + writeEqDeleteRecord(table, "c1", 1, "c3", "AAAA"); + writeEqDeleteRecord(table, "c1", 2, "c3", "CCCC"); + table.refresh(); + Set existingDeletes = TestHelpers.deleteFiles(table); + assertThat(existingDeletes) + .as("Only one equality delete c1=1 is used in query planning") + .hasSize(1); + + // partition evolution + table.updateSpec().addField(Expressions.ref("c3")).commit(); + + // data seq = 4, write 2 new data files in both partitions for evolved spec + List records3 = + Lists.newArrayList( + new ThreeColumnRecord(1, "A", "CCCC"), new ThreeColumnRecord(2, "D", "DDDD")); + writeRecords(records3); + + List originalData = currentData(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .filter(Expressions.equal("c1", 1)) + .option(RewriteDataFiles.REMOVE_DANGLING_DELETES, "true") + .execute(); + + existingDeletes = TestHelpers.deleteFiles(table); + assertThat(existingDeletes).as("Shall pruned dangling deletes after rewrite").hasSize(0); + + assertThat(result) + .extracting( + Result::addedDataFilesCount, + Result::rewrittenDataFilesCount, + Result::removedDeleteFilesCount) + .as("Should compact 3 data files into 2 and remove both dangled equality delete file") + .containsExactly(2, 3, 2); + shouldHaveMinSequenceNumberInPartition(table, "data_file.partition.c1 == 1", 5); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 7); + shouldHaveFiles(table, 5); + } + + @TestTemplate + public void testRemoveDangledPositionDeletesPartitionEvolution() throws IOException { + Table table = + TABLES.create( + SCHEMA, + SPEC, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + // data seq = 1, write 4 files in 2 partitions + writeRecords(2, 2, 2); + List dataFilesBefore = TestHelpers.dataFiles(table, null); + shouldHaveFiles(table, 4); + + DeleteFile deleteFile; + // data seq = 2, write 1 position deletes in c1=1 + DataFile dataFile = dataFilesBefore.get(3); + if (formatVersion >= 3) { + deleteFile = writeDV(table, dataFile.partition(), dataFile.location(), 1).get(0); + } else { + deleteFile = writePosDeletesToFile(table, dataFile, 1).get(0); + } + table.newRowDelta().addDeletes(deleteFile).commit(); + + // partition evolution + table.updateSpec().addField(Expressions.ref("c3")).commit(); + + // data seq = 3, write 1 new data files in c1=1 for evolved spec + writeRecords(1, 1, 1); + shouldHaveFiles(table, 5); + List expectedRecords = currentData(); + + Result result = + actions() + .rewriteDataFiles(table) + .filter(Expressions.equal("c1", 1)) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option(RewriteDataFiles.REMOVE_DANGLING_DELETES, "true") + .execute(); + + assertThat(result) + .extracting( + Result::addedDataFilesCount, + Result::rewrittenDataFilesCount, + Result::removedDeleteFilesCount) + .as("Should rewrite 2 data files into 1 and remove 1 dangled position delete file") + .containsExactly(1, 2, 1); + shouldHaveMinSequenceNumberInPartition(table, "data_file.partition.c1 == 1", 3); + + // v3 removes orphaned DVs during rewrite already, so there should be one snapshot less + int expectedSnapshots = formatVersion >= 3 ? 4 : 5; + shouldHaveSnapshots(table, expectedSnapshots); + assertThat(table.currentSnapshot().summary()).containsEntry("total-position-deletes", "0"); + assertEquals("Rows must match", expectedRecords, currentData()); + } + + @TestTemplate + public void testBinPackWithDeleteAllData() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + Table table = createTablePartitioned(1, 1, 1); + shouldHaveFiles(table, 1); + + List dataFiles = TestHelpers.dataFiles(table); + int total = (int) dataFiles.stream().mapToLong(ContentFile::recordCount).sum(); + + RowDelta rowDelta = table.newRowDelta(); + DataFile dataFile = dataFiles.get(0); + // remove all data + if (formatVersion >= 3) { + writeDV(table, dataFile.partition(), dataFile.location(), total) + .forEach(rowDelta::addDeletes); + } else { + writePosDeletesToFile(table, dataFile, total).forEach(rowDelta::addDeletes); + } + + rowDelta.commit(); + List expectedRecords = currentData(); + long dataSizeBefore = testDataSize(table); + + Result result = + actions() + .rewriteDataFiles(table) + .option(BinPackRewriteFilePlanner.DELETE_FILE_THRESHOLD, "1") + .execute(); + assertThat(result.rewrittenDataFilesCount()).as("Action should rewrite 1 data files").isOne(); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + if (formatVersion >= 3) { + assertThat(result.removedDeleteFilesCount()).isEqualTo(dataFiles.size()); + } + + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertThat(table.currentSnapshot().dataManifests(table.io()).get(0).existingFilesCount()) + .as("Data manifest should not have existing data file") + .isZero(); + + assertThat((long) table.currentSnapshot().dataManifests(table.io()).get(0).deletedFilesCount()) + .as("Data manifest should have 1 delete data file") + .isEqualTo(1L); + + assertThat(table.currentSnapshot().deleteManifests(table.io()).get(0).addedRowsCount()) + .as("Delete manifest added row count should equal total count") + // v3 removes orphaned DVs during rewrite + .isEqualTo(formatVersion >= 3 ? 0 : 1); + } + + @TestTemplate + public void testBinPackWithStartingSequenceNumber() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + Table table = createTablePartitioned(4, 2); + shouldHaveFiles(table, 8); + List expectedRecords = currentData(); + long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); + long dataSizeBefore = testDataSize(table); + + Result result = + basicRewrite(table).option(RewriteDataFiles.USE_STARTING_SEQUENCE_NUMBER, "true").execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 8 data files") + .isEqualTo(8); + assertThat(result.addedDataFilesCount()).as("Action should add 4 data files").isEqualTo(4); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveFiles(table, 4); + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + + assertThat(table.currentSnapshot().sequenceNumber()) + .as("Table sequence number should be incremented") + .isGreaterThan(oldSequenceNumber); + + Dataset rows = SparkTableUtil.loadMetadataTable(spark, table, MetadataTableType.ENTRIES); + for (Row row : rows.collectAsList()) { + if (row.getInt(0) == 1) { + assertThat(row.getLong(2)) + .as("Expect old sequence number for added entries") + .isEqualTo(oldSequenceNumber); + } + } + } + + @TestTemplate + public void testBinPackWithStartingSequenceNumberV1Compatibility() { + Map properties = ImmutableMap.of(TableProperties.FORMAT_VERSION, "1"); + Table table = createTablePartitioned(4, 2, SCALE, properties); + shouldHaveFiles(table, 8); + List expectedRecords = currentData(); + long oldSequenceNumber = table.currentSnapshot().sequenceNumber(); + assertThat(oldSequenceNumber).as("Table sequence number should be 0").isZero(); + long dataSizeBefore = testDataSize(table); + + Result result = + basicRewrite(table).option(RewriteDataFiles.USE_STARTING_SEQUENCE_NUMBER, "true").execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 8 data files") + .isEqualTo(8); + assertThat(result.addedDataFilesCount()).as("Action should add 4 data files").isEqualTo(4); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveFiles(table, 4); + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + + assertThat(table.currentSnapshot().sequenceNumber()) + .as("Table sequence number should still be 0") + .isEqualTo(oldSequenceNumber); + + Dataset rows = SparkTableUtil.loadMetadataTable(spark, table, MetadataTableType.ENTRIES); + for (Row row : rows.collectAsList()) { + assertThat(row.getLong(2)) + .as("Expect sequence number 0 for all entries") + .isEqualTo(oldSequenceNumber); + } + } + + @TestTemplate + public void testRewriteLargeTableHasResiduals() { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).build(); + Map options = + ImmutableMap.of( + TableProperties.FORMAT_VERSION, + String.valueOf(formatVersion), + TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES, + "100"); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + // all records belong to the same partition + List records = Lists.newArrayList(); + for (int i = 0; i < 100; i++) { + records.add(new ThreeColumnRecord(i, String.valueOf(i), String.valueOf(i % 4))); + } + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + writeDF(df); + + List expectedRecords = currentData(); + + table.refresh(); + + CloseableIterable tasks = + table.newScan().ignoreResiduals().filter(Expressions.equal("c3", "0")).planFiles(); + for (FileScanTask task : tasks) { + assertThat(task.residual()) + .as("Residuals must be ignored") + .isEqualTo(Expressions.alwaysTrue()); + } + + shouldHaveFiles(table, 2); + + long dataSizeBefore = testDataSize(table); + Result result = basicRewrite(table).filter(Expressions.equal("c3", "0")).execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 2 data files") + .isEqualTo(2); + assertThat(result.addedDataFilesCount()).as("Action should add 1 data file").isOne(); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List actualRecords = currentData(); + + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testBinPackSplitLargeFile() { + Table table = createTable(1); + shouldHaveFiles(table, 1); + + List expectedRecords = currentData(); + long targetSize = testDataSize(table) / 2; + + long dataSizeBefore = testDataSize(table); + Result result = + basicRewrite(table) + .option(RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Long.toString(targetSize)) + .option( + SizeBasedFileRewritePlanner.MAX_FILE_SIZE_BYTES, + Long.toString(targetSize * 2 - 2000)) + .execute(); + + assertThat(result.rewrittenDataFilesCount()).as("Action should delete 1 data files").isOne(); + assertThat(result.addedDataFilesCount()).as("Action should add 2 data files").isEqualTo(2); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveFiles(table, 2); + + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testBinPackCombineMixedFiles() { + Table table = createTable(1); // 400000 + shouldHaveFiles(table, 1); + + // Add one more small file, and one large file + writeRecords(1, SCALE); + writeRecords(1, SCALE * 3); + table.refresh(); + shouldHaveFiles(table, 3); + + List expectedRecords = currentData(); + + int targetSize = averageFileSize(table); + + long dataSizeBefore = testDataSize(table); + Result result = + basicRewrite(table) + .option(RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Integer.toString(targetSize + 1000)) + .option( + SizeBasedFileRewritePlanner.MAX_FILE_SIZE_BYTES, + // Increase max file size for V3 to account for additional row lineage fields + Integer.toString(targetSize + (formatVersion >= 3 ? 1850000 : 80000))) + .option( + SizeBasedFileRewritePlanner.MIN_FILE_SIZE_BYTES, + Integer.toString(targetSize - 1000)) + .execute(); + + assertThat(result.rewrittenDataFilesCount()) + .as("Action should delete 3 data files") + .isEqualTo(3); + // Should Split the big files into 3 pieces, one of which should be combined with the two + // smaller files + assertThat(result.addedDataFilesCount()).as("Action should add 3 data files").isEqualTo(3); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveFiles(table, 3); + + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testBinPackCombineMediumFiles() { + Table table = createTable(4); + shouldHaveFiles(table, 4); + + List expectedRecords = currentData(); + int targetSize = ((int) testDataSize(table) / 3); + // The test is to see if we can combine parts of files to make files of the correct size + + long dataSizeBefore = testDataSize(table); + Result result = + basicRewrite(table) + .option(RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Integer.toString(targetSize)) + .option( + SizeBasedFileRewritePlanner.MAX_FILE_SIZE_BYTES, + // Increase max file size for V3 to account for additional row lineage fields + Integer.toString((int) (targetSize * (formatVersion >= 3 ? 2 : 1.8)))) + .option( + SizeBasedFileRewritePlanner.MIN_FILE_SIZE_BYTES, + Integer.toString(targetSize - 100)) // All files too small + .execute(); + + assertThat(result.rewrittenDataFilesCount()) + .as("Action should delete 4 data files") + .isEqualTo(4); + assertThat(result.addedDataFilesCount()).as("Action should add 3 data files").isEqualTo(3); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveFiles(table, 3); + + List actualRecords = currentData(); + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testPartialProgressEnabled() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + table.updateProperties().set(COMMIT_NUM_RETRIES, "10").commit(); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + // Perform a rewrite but only allow 2 files to be compacted at a time + RewriteDataFiles.Result result = + basicRewrite(table) + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "10") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + shouldHaveSnapshots(table, 11); + shouldHaveACleanCache(table); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + } + + @TestTemplate + public void testMultipleGroups() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + // Perform a rewrite but only allow 2 files to be compacted at a time + RewriteDataFiles.Result result = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testPartialProgressMaxCommits() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + // Perform a rewrite but only allow 2 files to be compacted at a time + RewriteDataFiles.Result result = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "3") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 4); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testSingleCommitWithRewriteFailure() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)); + + RewriteDataFilesSparkAction spyRewrite = Mockito.spy(realRewrite); + + // Fail groups 1, 3, and 7 during rewrite + GroupInfoMatcher failGroup = new GroupInfoMatcher(1, 3, 7); + doThrow(new RuntimeException("Rewrite Failed")) + .when(spyRewrite) + .rewriteFiles(any(), argThat(failGroup)); + + assertThatThrownBy(spyRewrite::execute) + .isInstanceOf(RuntimeException.class) + .hasMessage("Rewrite Failed"); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 1); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testSingleCommitWithCommitFailure() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)); + + RewriteDataFilesSparkAction spyRewrite = spy(realRewrite); + RewriteDataFilesCommitManager util = spy(new RewriteDataFilesCommitManager(table)); + + // Fail to commit + doThrow(new CommitFailedException("Commit Failure")).when(util).commitFileGroups(any()); + + doReturn(util).when(spyRewrite).commitManager(table.currentSnapshot().snapshotId()); + + assertThatThrownBy(spyRewrite::execute) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Cannot commit rewrite"); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 1); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testCommitFailsWithUncleanableFailure() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)); + + RewriteDataFilesSparkAction spyRewrite = spy(realRewrite); + RewriteDataFilesCommitManager util = spy(new RewriteDataFilesCommitManager(table)); + + // Fail to commit with an arbitrary failure and validate that orphans are not cleaned up + doThrow(new RuntimeException("Arbitrary Failure")).when(util).commitFileGroups(any()); + + doReturn(util).when(spyRewrite).commitManager(table.currentSnapshot().snapshotId()); + + assertThatThrownBy(spyRewrite::execute) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Arbitrary Failure"); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 1); + shouldHaveOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testParallelSingleCommitWithRewriteFailure() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.MAX_CONCURRENT_FILE_GROUP_REWRITES, "3"); + + RewriteDataFilesSparkAction spyRewrite = Mockito.spy(realRewrite); + + // Fail groups 1, 3, and 7 during rewrite + GroupInfoMatcher failGroup = new GroupInfoMatcher(1, 3, 7); + doThrow(new CommitFailedException("Rewrite Failed")) + .when(spyRewrite) + .rewriteFiles(any(), argThat(failGroup)); + + assertThatThrownBy(spyRewrite::execute) + .isInstanceOf(CommitFailedException.class) + .hasMessage("Rewrite Failed"); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 1); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testPartialProgressWithRewriteFailure() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "3"); + + RewriteDataFilesSparkAction spyRewrite = Mockito.spy(realRewrite); + + // Fail groups 1, 3, and 7 during rewrite + GroupInfoMatcher failGroup = new GroupInfoMatcher(1, 3, 7); + doThrow(new RuntimeException("Rewrite Failed")) + .when(spyRewrite) + .rewriteFiles(any(), argThat(failGroup)); + + RewriteDataFiles.Result result = spyRewrite.execute(); + + assertThat(result.rewriteResults()).hasSize(7); + assertThat(result.rewriteFailures()).hasSize(3); + assertThat(result.failedDataFilesCount()).isEqualTo(6); + assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + // With 10 original groups and Max Commits of 3, we should have commits with 4, 4, and 2. + // removing 3 groups leaves us with only 2 new commits, 4 and 3 + shouldHaveSnapshots(table, 3); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testParallelPartialProgressWithRewriteFailure() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.MAX_CONCURRENT_FILE_GROUP_REWRITES, "3") + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "3"); + + RewriteDataFilesSparkAction spyRewrite = Mockito.spy(realRewrite); + + // Fail groups 1, 3, and 7 during rewrite + GroupInfoMatcher failGroup = new GroupInfoMatcher(1, 3, 7); + doThrow(new RuntimeException("Rewrite Failed")) + .when(spyRewrite) + .rewriteFiles(any(), argThat(failGroup)); + + RewriteDataFiles.Result result = spyRewrite.execute(); + + assertThat(result.rewriteResults()).hasSize(7); + assertThat(result.rewriteFailures()).hasSize(3); + assertThat(result.failedDataFilesCount()).isEqualTo(6); + assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + // With 10 original groups and max commits of 3, we have 4 groups per commit. + // Removing 3 groups, we are left with 4 groups and 3 groups in two commits. + shouldHaveSnapshots(table, 3); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testParallelPartialProgressWithCommitFailure() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.MAX_CONCURRENT_FILE_GROUP_REWRITES, "3") + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "3"); + + RewriteDataFilesSparkAction spyRewrite = spy(realRewrite); + RewriteDataFilesCommitManager util = spy(new RewriteDataFilesCommitManager(table)); + + // First and Third commits work, second does not + doCallRealMethod() + .doThrow(new CommitFailedException("Commit Failed")) + .doCallRealMethod() + .when(util) + .commitFileGroups(any()); + + doReturn(util).when(spyRewrite).commitManager(table.currentSnapshot().snapshotId()); + + RewriteDataFiles.Result result = spyRewrite.execute(); + + // Commit 1: 4/4 + Commit 2 failed 0/4 + Commit 3: 2/2 == 6 out of 10 total groups committed + assertThat(result.rewriteResults()).as("Should have 6 fileGroups").hasSize(6); + assertThat(result.rewrittenBytesCount()).isGreaterThan(0L).isLessThan(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + // Only 2 new commits because we broke one + shouldHaveSnapshots(table, 3); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testParallelPartialProgressWithMaxFailedCommits() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + + RewriteDataFilesSparkAction realRewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.MAX_CONCURRENT_FILE_GROUP_REWRITES, "3") + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "3") + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_FAILED_COMMITS, "0"); + + RewriteDataFilesSparkAction spyRewrite = Mockito.spy(realRewrite); + + // Fail groups 1, 3, and 7 during rewrite + GroupInfoMatcher failGroup = new GroupInfoMatcher(1, 3, 7); + doThrow(new RuntimeException("Rewrite Failed")) + .when(spyRewrite) + .rewriteFiles(any(), argThat(failGroup)); + + assertThatThrownBy(() -> spyRewrite.execute()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining( + "1 rewrite commits failed. This is more than the maximum allowed failures of 0"); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + // With 10 original groups and max commits of 3, we have 4 groups per commit. + // Removing 3 groups, we are left with 4 groups and 3 groups in two commits. + // Adding max allowed failed commits doesn't change the number of successful commits. + shouldHaveSnapshots(table, 3); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testParallelPartialProgressWithMaxCommitsLargerThanTotalGroupCount() { + Table table = createTable(20); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + + RewriteDataFilesSparkAction rewrite = + basicRewrite(table) + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .option(RewriteDataFiles.MAX_CONCURRENT_FILE_GROUP_REWRITES, "3") + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + // Since we can have at most one commit per file group and there are only 10 file + // groups, actual number of commits is 10 + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "20") + // Setting max-failed-commits to 1 to tolerate random commit failure + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_FAILED_COMMITS, "1"); + rewrite.execute(); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + assertThat(table.snapshots()) + .as("Table did not have the expected number of snapshots") + // To tolerate 1 random commit failure + .hasSizeGreaterThanOrEqualTo(10); + shouldHaveNoOrphans(table); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testInvalidOptions() { + Table table = createTable(20); + + assertThatThrownBy( + () -> + basicRewrite(table) + .option(RewriteDataFiles.PARTIAL_PROGRESS_ENABLED, "true") + .option(RewriteDataFiles.PARTIAL_PROGRESS_MAX_COMMITS, "-5") + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot set partial-progress.max-commits to -5, " + + "the value must be positive when partial-progress.enabled is true"); + + assertThatThrownBy( + () -> + basicRewrite(table) + .option(RewriteDataFiles.MAX_CONCURRENT_FILE_GROUP_REWRITES, "-5") + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot set max-concurrent-file-group-rewrites to -5, the value must be positive."); + + assertThatThrownBy(() -> basicRewrite(table).option("foobarity", "-5").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot use options [foobarity], they are not supported by the action or the rewriter BIN-PACK"); + + assertThatThrownBy( + () -> basicRewrite(table).option(RewriteDataFiles.REWRITE_JOB_ORDER, "foo").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid rewrite job order name: foo"); + + assertThatThrownBy( + () -> + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c2").build()) + .option(SparkShufflingFileRewriteRunner.SHUFFLE_PARTITIONS_PER_FILE, "5") + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires enabling Iceberg Spark session extensions"); + } + + @TestTemplate + public void testSortMultipleGroups() { + Table table = createTable(20); + shouldHaveFiles(table, 20); + table.replaceSortOrder().asc("c2").commit(); + shouldHaveLastCommitUnsorted(table, "c2"); + int fileSize = averageFileSize(table); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + // Perform a rewrite but only allow 2 files to be compacted at a time + RewriteDataFiles.Result result = + basicRewrite(table) + .sort() + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + RewriteDataFiles.MAX_FILE_GROUP_SIZE_BYTES, Integer.toString(fileSize * 2 + 1000)) + .execute(); + + assertThat(result.rewriteResults()).as("Should have 10 fileGroups").hasSize(10); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testSimpleSort() throws IOException { + Table table = createTable(20); + shouldHaveFiles(table, 20); + table.replaceSortOrder().asc("c2").commit(); + shouldHaveLastCommitUnsorted(table, "c2"); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort() + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1") + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Integer.toString(averageFileSize(table))) + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + shouldHaveMultipleFiles(table); + shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); + } + + @TestTemplate + public void testSortAfterPartitionChange() throws IOException { + Table table = createTable(20); + shouldHaveFiles(table, 20); + table.updateSpec().addField(Expressions.bucket("c1", 4)).commit(); + table.replaceSortOrder().asc("c2").commit(); + shouldHaveLastCommitUnsorted(table, "c2"); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort() + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1") + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Integer.toString(averageFileSize(table))) + .execute(); + + assertThat(result.rewriteResults()) + .as("Should have 1 fileGroups because all files were not correctly partitioned") + .hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + shouldHaveMultipleFiles(table); + shouldHaveLastCommitSorted(table, "c2"); + dataFilesSortOrderShouldMatchTableSortOrder(table); + } + + @TestTemplate + public void testSortCustomSortOrder() throws IOException { + Table table = createTable(20); + shouldHaveLastCommitUnsorted(table, "c2"); + shouldHaveFiles(table, 20); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c2").build()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + RewriteDataFiles.TARGET_FILE_SIZE_BYTES, Integer.toString(averageFileSize(table))) + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + shouldHaveMultipleFiles(table); + shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); + } + + @TestTemplate + public void testSortCustomSortOrderRequiresRepartition() throws IOException { + int partitions = 4; + Table table = createTable(); + writeRecords(20, SCALE, partitions); + table.refresh(); + shouldHaveLastCommitUnsorted(table, "c3"); + + // Add a partition column so this requires repartitioning + table.updateSpec().addField("c1").commit(); + // Add a sort order which our repartitioning needs to ignore + table.replaceSortOrder().asc("c2").apply(); + shouldHaveFiles(table, 20); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c3").build()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + RewriteDataFiles.TARGET_FILE_SIZE_BYTES, + Integer.toString(averageFileSize(table) / partitions)) + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + shouldHaveMultipleFiles(table); + shouldHaveLastCommitUnsorted(table, "c2"); + shouldHaveLastCommitSorted(table, "c3"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); + } + + @TestTemplate + public void testSortPastTableSortOrderGetsAppliedToFiles() throws IOException { + Table table = createTable(1); + + table.replaceSortOrder().asc("c3").commit(); + SortOrder c3SortOrder = table.sortOrder(); + + table.replaceSortOrder().asc("c2").commit(); + + List originalData = currentData(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c3").build()) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + + table.refresh(); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + dataFilesShouldHaveSortOrderIdMatching(table, c3SortOrder); + } + + @TestTemplate + public void testAutoSortShuffleOutput() throws IOException { + Table table = createTable(20); + shouldHaveLastCommitUnsorted(table, "c2"); + shouldHaveFiles(table, 20); + + List originalData = currentData(); + long dataSizeBefore = testDataSize(table); + + RewriteDataFiles.Result result = + basicRewrite(table) + .sort(SortOrder.builderFor(table.schema()).asc("c2").build()) + .option( + SizeBasedFileRewritePlanner.MAX_FILE_SIZE_BYTES, + Integer.toString((averageFileSize(table) / 2) + 2)) + // Divide files in 2 + .option( + RewriteDataFiles.TARGET_FILE_SIZE_BYTES, + Integer.toString(averageFileSize(table) / 2)) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) + .as("Should have written 40+ files") + .hasSizeGreaterThanOrEqualTo(40); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + shouldHaveMultipleFiles(table); + shouldHaveLastCommitSorted(table, "c2"); + dataFilesShouldHaveSortOrderIdMatching(table, SortOrder.unsorted()); + } + + @TestTemplate + public void testCommitStateUnknownException() { + Table table = createTable(20); + shouldHaveFiles(table, 20); + + List originalData = currentData(); + + RewriteDataFilesSparkAction action = basicRewrite(table); + RewriteDataFilesSparkAction spyAction = spy(action); + RewriteDataFilesCommitManager util = spy(new RewriteDataFilesCommitManager(table)); + + doAnswer( + invocationOnMock -> { + invocationOnMock.callRealMethod(); + throw new CommitStateUnknownException(new RuntimeException("Unknown State")); + }) + .when(util) + .commitFileGroups(any()); + + doReturn(util).when(spyAction).commitManager(table.currentSnapshot().snapshotId()); + + assertThatThrownBy(spyAction::execute) + .isInstanceOf(CommitStateUnknownException.class) + .hasMessageStartingWith( + "Unknown State\n" + "Cannot determine whether the commit was successful or not"); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); // Commit actually Succeeded + } + + @TestTemplate + public void testZOrderWithZColumnCollision() { + Schema schema = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "ICEZVALUE", Types.StringType.get())); + + Table table = + TABLES.create( + schema, + PartitionSpec.unpartitioned(), + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + assertThatThrownBy(() -> basicRewrite(table).zOrder("c1", "c2").execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot zorder because the table has a column named 'ICEZVALUE'"); + } + + @TestTemplate + public void testZOrderSort() { + int originalFiles = 20; + Table table = createTable(originalFiles); + shouldHaveLastCommitUnsorted(table, "c2"); + shouldHaveFiles(table, originalFiles); + + List originalData = currentData(); + double originalFilesC2 = percentFilesRequired(table, "c2", "foo23"); + double originalFilesC3 = percentFilesRequired(table, "c3", "bar21"); + double originalFilesC2C3 = + percentFilesRequired(table, new String[] {"c2", "c3"}, new String[] {"foo23", "bar23"}); + + assertThat(originalFilesC2).as("Should require all files to scan c2").isGreaterThan(0.99); + assertThat(originalFilesC3).as("Should require all files to scan c3").isGreaterThan(0.99); + + long dataSizeBefore = testDataSize(table); + RewriteDataFiles.Result result = + basicRewrite(table) + .zOrder("c2", "c3") + .option( + SizeBasedFileRewritePlanner.MAX_FILE_SIZE_BYTES, + Integer.toString((averageFileSize(table) / 2) + 2)) + // Divide files in 2 + .option( + RewriteDataFiles.TARGET_FILE_SIZE_BYTES, + Integer.toString(averageFileSize(table) / 2)) + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) + .as("Should have written 40+ files") + .hasSizeGreaterThanOrEqualTo(40); + + List postRewriteData = currentData(); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + + double filesScannedC2 = percentFilesRequired(table, "c2", "foo23"); + double filesScannedC3 = percentFilesRequired(table, "c3", "bar21"); + double filesScannedC2C3 = + percentFilesRequired(table, new String[] {"c2", "c3"}, new String[] {"foo23", "bar23"}); + + assertThat(originalFilesC2) + .as("Should have reduced the number of files required for c2") + .isGreaterThan(filesScannedC2); + assertThat(originalFilesC3) + .as("Should have reduced the number of files required for c3") + .isGreaterThan(filesScannedC3); + assertThat(originalFilesC2C3) + .as("Should have reduced the number of files required for c2,c3 predicate") + .isGreaterThan(filesScannedC2C3); + } + + @TestTemplate + public void testZOrderAllTypesSort() { + spark.conf().set("spark.sql.ansi.enabled", "false"); + Table table = createTypeTestTable(); + shouldHaveFiles(table, 10); + + List originalRaw = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .load(tableLocation) + .coalesce(1) + .sort("longCol") + .collectAsList(); + List originalData = rowsToJava(originalRaw); + long dataSizeBefore = testDataSize(table); + + // TODO add in UUID when it is supported in Spark + RewriteDataFiles.Result result = + basicRewrite(table) + .zOrder( + "longCol", + "intCol", + "floatCol", + "doubleCol", + "dateCol", + "timestampCol", + "stringCol", + "binaryCol", + "booleanCol") + .option(SizeBasedFileRewritePlanner.MIN_INPUT_FILES, "1") + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + assertThat(result.rewriteResults()).as("Should have 1 fileGroups").hasSize(1); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + assertThat(SnapshotChanges.builderFor(table).build().addedDataFiles()) + .as("Should have written 1 file") + .hasSize(1); + + List postRaw = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .load(tableLocation) + .coalesce(1) + .sort("longCol") + .collectAsList(); + List postRewriteData = rowsToJava(postRaw); + assertEquals("We shouldn't have changed the data", originalData, postRewriteData); + + shouldHaveSnapshots(table, 2); + shouldHaveACleanCache(table); + } + + @TestTemplate + public void testInvalidAPIUsage() { + Table table = createTable(1); + + SortOrder sortOrder = SortOrder.builderFor(table.schema()).asc("c2").build(); + + assertThatThrownBy(() -> actions().rewriteDataFiles(table).binPack().sort()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot set rewrite mode, it has already been set to "); + + assertThatThrownBy(() -> actions().rewriteDataFiles(table).sort(sortOrder).binPack()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot set rewrite mode, it has already been set to "); + + assertThatThrownBy(() -> actions().rewriteDataFiles(table).sort(sortOrder).binPack()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot set rewrite mode, it has already been set to "); + } + + @TestTemplate + public void testSnapshotProperty() { + Table table = createTable(4); + Result ignored = basicRewrite(table).snapshotProperty("key", "value").execute(); + assertThat(table.currentSnapshot().summary()) + .containsAllEntriesOf(ImmutableMap.of("key", "value")); + // make sure internal produced properties are not lost + String[] commitMetricsKeys = + new String[] { + SnapshotSummary.ADDED_FILES_PROP, + SnapshotSummary.DELETED_FILES_PROP, + SnapshotSummary.TOTAL_DATA_FILES_PROP, + SnapshotSummary.CHANGED_PARTITION_COUNT_PROP + }; + assertThat(table.currentSnapshot().summary()).containsKeys(commitMetricsKeys); + } + + @TestTemplate + public void testBinPackRewriterWithSpecificUnparitionedOutputSpec() { + Table table = createTable(10); + shouldHaveFiles(table, 10); + int outputSpecId = table.spec().specId(); + table.updateSpec().addField(Expressions.truncate("c2", 2)).commit(); + + long dataSizeBefore = testDataSize(table); + long count = currentData().size(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .option(RewriteDataFiles.OUTPUT_SPEC_ID, String.valueOf(outputSpecId)) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .binPack() + .execute(); + + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + assertThat(currentData()).hasSize((int) count); + shouldRewriteDataFilesWithPartitionSpec(table, outputSpecId); + } + + @TestTemplate + public void testBinPackRewriterWithSpecificOutputSpec() { + Table table = createTable(10); + shouldHaveFiles(table, 10); + table.updateSpec().addField(Expressions.truncate("c2", 2)).commit(); + int outputSpecId = table.spec().specId(); + table.updateSpec().addField(Expressions.bucket("c3", 2)).commit(); + + long dataSizeBefore = testDataSize(table); + long count = currentData().size(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .option(RewriteDataFiles.OUTPUT_SPEC_ID, String.valueOf(outputSpecId)) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .binPack() + .execute(); + + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + assertThat(currentData()).hasSize((int) count); + shouldRewriteDataFilesWithPartitionSpec(table, outputSpecId); + } + + @TestTemplate + public void testBinpackRewriteWithInvalidOutputSpecId() { + Table table = createTable(10); + shouldHaveFiles(table, 10); + assertThatThrownBy( + () -> + actions() + .rewriteDataFiles(table) + .option(RewriteDataFiles.OUTPUT_SPEC_ID, String.valueOf(1234)) + .binPack() + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot use output spec id 1234 because the table does not contain a reference to this spec-id."); + } + + @TestTemplate + public void testSortRewriterWithSpecificOutputSpecId() { + Table table = createTable(10); + shouldHaveFiles(table, 10); + table.updateSpec().addField(Expressions.truncate("c2", 2)).commit(); + int outputSpecId = table.spec().specId(); + table.updateSpec().addField(Expressions.bucket("c3", 2)).commit(); + + long dataSizeBefore = testDataSize(table); + long count = currentData().size(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .option(RewriteDataFiles.OUTPUT_SPEC_ID, String.valueOf(outputSpecId)) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .sort(SortOrder.builderFor(table.schema()).asc("c2").asc("c3").build()) + .execute(); + + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + assertThat(currentData()).hasSize((int) count); + shouldRewriteDataFilesWithPartitionSpec(table, outputSpecId); + } + + @TestTemplate + public void testZOrderRewriteWithSpecificOutputSpecId() { + Table table = createTable(10); + shouldHaveFiles(table, 10); + table.updateSpec().addField(Expressions.truncate("c2", 2)).commit(); + int outputSpecId = table.spec().specId(); + table.updateSpec().addField(Expressions.bucket("c3", 2)).commit(); + + long dataSizeBefore = testDataSize(table); + long count = currentData().size(); + + RewriteDataFiles.Result result = + basicRewrite(table) + .option(RewriteDataFiles.OUTPUT_SPEC_ID, String.valueOf(outputSpecId)) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .zOrder("c2", "c3") + .execute(); + + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + assertThat(currentData()).hasSize((int) count); + shouldRewriteDataFilesWithPartitionSpec(table, outputSpecId); + } + + @TestTemplate + public void testUnpartitionedRewriteDataFilesPreservesLineage() throws NoSuchTableException { + assumeThat(formatVersion).isGreaterThan(2); + + // Verify the initial row IDs and sequence numbers + Table table = createTable(4); + shouldHaveFiles(table, 4); + List expectedRecordsWithLineage = currentDataWithLineage(); + List rowIds = + expectedRecordsWithLineage.stream() + .map(record -> (Long) record[0]) + .collect(Collectors.toList()); + List lastUpdatedSequenceNumbers = + expectedRecordsWithLineage.stream() + .map(record -> (Long) record[1]) + .collect(Collectors.toList()); + assertThat(rowIds) + .isEqualTo( + LongStream.range(0, expectedRecordsWithLineage.size()) + .boxed() + .collect(Collectors.toList())); + assertThat(lastUpdatedSequenceNumbers).allMatch(sequenceNumber -> sequenceNumber.equals(1L)); + + // Perform and validate compaction + long dataSizeBefore = testDataSize(table); + Result result = basicRewrite(table).execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 4 data files") + .isEqualTo(4); + assertThat(result.addedDataFilesCount()).as("Action should add 1 data file").isOne(); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + shouldHaveFiles(table, 1); + List actualRecordsWithLineage = currentDataWithLineage(); + assertEquals("Rows must match", expectedRecordsWithLineage, actualRecordsWithLineage); + } + + @TestTemplate + public void testRewriteDataFilesPreservesLineage() throws NoSuchTableException { + assumeThat(formatVersion).isGreaterThan(2); + + Table table = createTablePartitioned(4 /* partitions */, 2 /* files per partition */); + shouldHaveFiles(table, 8); + + // Verify the initial row IDs and sequence numbers + List expectedRecords = currentDataWithLineage(); + List rowIds = + expectedRecords.stream().map(record -> (Long) record[0]).collect(Collectors.toList()); + List lastUpdatedSequenceNumbers = + expectedRecords.stream().map(record -> (Long) record[1]).collect(Collectors.toList()); + assertThat(rowIds) + .isEqualTo( + LongStream.range(0, expectedRecords.size()).boxed().collect(Collectors.toList())); + assertThat(lastUpdatedSequenceNumbers).allMatch(sequenceNumber -> sequenceNumber.equals(1L)); + + // Perform and validate compaction + long dataSizeBefore = testDataSize(table); + Result result = basicRewrite(table).execute(); + assertThat(result.rewrittenDataFilesCount()) + .as("Action should rewrite 8 data files") + .isEqualTo(8); + assertThat(result.addedDataFilesCount()).as("Action should add 4 data file").isEqualTo(4); + assertThat(result.rewrittenBytesCount()).isEqualTo(dataSizeBefore); + shouldHaveFiles(table, 4); + List actualRecordsWithLineage = currentDataWithLineage(); + assertEquals("Rows must match", expectedRecords, actualRecordsWithLineage); + } + + @TestTemplate + public void testExecutorCacheForDeleteFilesDisabled() { + Table table = createTablePartitioned(1, 1); + RewriteDataFilesSparkAction action = SparkActions.get(spark).rewriteDataFiles(table); + + // The constructor should have set the configuration to false + SparkReadConf readConf = new SparkReadConf(action.spark(), table); + assertThat(readConf.cacheDeleteFilesOnExecutors()) + .as("Executor cache for delete files should be disabled in RewriteDataFilesSparkAction") + .isFalse(); + } + + @TestTemplate + public void testZOrderUDFWithDateType() { + SparkZOrderUDF zorderUDF = new SparkZOrderUDF(1, 16, 1024); + Dataset result = + spark + .sql("SELECT DATE '2025-01-01' as test_col") + .withColumn( + "zorder_result", + zorderUDF.sortedLexicographically(col("test_col"), DataTypes.DateType)); + + assertThat(result.schema().apply("zorder_result").dataType()).isEqualTo(DataTypes.BinaryType); + List rows = result.collectAsList(); + Row row = rows.get(0); + byte[] zorderBytes = row.getAs("zorder_result"); + assertThat(zorderBytes).isNotNull().isNotEmpty(); + } + + @TestTemplate + public void testZOrderUDFWithTimestampNTZType() { + SparkZOrderUDF zorderUDF = new SparkZOrderUDF(1, 16, 1024); + Dataset result = + spark + .sql("SELECT timestamp_ntz '2025-01-01 12:00:00' as test_col") + .withColumn( + "zorder_result", + zorderUDF.sortedLexicographically(col("test_col"), DataTypes.TimestampNTZType)); + + assertThat(result.schema().apply("zorder_result").dataType()).isEqualTo(DataTypes.BinaryType); + List rows = result.collectAsList(); + Row row = rows.get(0); + byte[] zorderBytes = row.getAs("zorder_result"); + assertThat(zorderBytes).isNotNull().isNotEmpty(); + } + + protected void shouldRewriteDataFilesWithPartitionSpec(Table table, int outputSpecId) { + List rewrittenFiles = currentDataFiles(table); + assertThat(rewrittenFiles).allMatch(file -> file.specId() == outputSpecId); + assertThat(rewrittenFiles) + .allMatch( + file -> + ((PartitionData) file.partition()) + .getPartitionType() + .equals(table.specs().get(outputSpecId).partitionType())); + } + + protected List currentDataFiles(Table table) { + return Streams.stream(table.newScan().planFiles()) + .map(FileScanTask::file) + .collect(Collectors.toList()); + } + + protected List currentData() { + return rowsToJava( + spark + .read() + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .format("iceberg") + .load(tableLocation) + .coalesce(1) + .sort("c1", "c2", "c3") + .collectAsList()); + } + + protected List currentDataWithLineage() { + return rowsToJava( + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .load(tableLocation) + .coalesce(1) + .sort("_row_id") + .selectExpr("_row_id", "_last_updated_sequence_number", "*") + .collectAsList()); + } + + protected long testDataSize(Table table) { + return Streams.stream(table.newScan().planFiles()).mapToLong(FileScanTask::length).sum(); + } + + protected void shouldHaveMultipleFiles(Table table) { + int numFiles = Iterables.size(table.newScan().planFiles()); + assertThat(numFiles) + .as(String.format("Should have multiple files, had %d", numFiles)) + .isGreaterThan(1); + } + + protected void shouldHaveFiles(Table table, int numExpected) { + List files = + StreamSupport.stream(table.newScan().planFiles().spliterator(), false) + .collect(Collectors.toList()); + assertThat(files.size()).as("Did not have the expected number of files").isEqualTo(numExpected); + } + + protected long shouldHaveMinSequenceNumberInPartition( + Table table, String partitionFilter, long expected) { + long actual = + SparkTableUtil.loadMetadataTable(spark, table, MetadataTableType.ENTRIES) + .filter("status != 2") + .filter(partitionFilter) + .select("sequence_number") + .agg(min("sequence_number")) + .as(Encoders.LONG()) + .collectAsList() + .get(0); + assertThat(actual).as("Did not have the expected min sequence number").isEqualTo(expected); + return actual; + } + + protected void shouldHaveSnapshots(Table table, int expectedSnapshots) { + assertThat(table.snapshots()) + .as("Table did not have the expected number of snapshots") + .hasSize(expectedSnapshots); + } + + protected void shouldHaveNoOrphans(Table table) { + assertThat( + actions() + .deleteOrphanFiles(table) + .olderThan(System.currentTimeMillis()) + .execute() + .orphanFileLocations()) + .as("Should not have found any orphan files") + .isEmpty(); + } + + protected void shouldHaveOrphans(Table table) { + assertThat( + actions() + .deleteOrphanFiles(table) + .olderThan(System.currentTimeMillis()) + .execute() + .orphanFileLocations()) + .as("Should have found orphan files") + .isNotEmpty(); + } + + protected void shouldHaveACleanCache(Table table) { + assertThat(cacheContents(table)).as("Should not have any entries in cache").isEmpty(); + } + + protected void shouldHaveLastCommitSorted(Table table, String column) { + List, Pair>> overlappingFiles = checkForOverlappingFiles(table, column); + + assertThat(overlappingFiles).as("Found overlapping files").isEmpty(); + } + + protected void shouldHaveLastCommitUnsorted(Table table, String column) { + List, Pair>> overlappingFiles = checkForOverlappingFiles(table, column); + + assertThat(overlappingFiles).as("Found no overlapping files").isNotEmpty(); + } + + private Pair boundsOf(DataFile file, NestedField field, Class javaClass) { + int columnId = field.fieldId(); + return Pair.of( + javaClass.cast(Conversions.fromByteBuffer(field.type(), file.lowerBounds().get(columnId))), + javaClass.cast(Conversions.fromByteBuffer(field.type(), file.upperBounds().get(columnId)))); + } + + private List, Pair>> checkForOverlappingFiles( + Table table, String column) { + NestedField field = table.schema().caseInsensitiveFindField(column); + Class javaClass = (Class) field.type().typeId().javaClass(); + + Snapshot snapshot = table.currentSnapshot(); + Map> filesByPartition = + Streams.stream( + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles()) + .collect(Collectors.groupingBy(DataFile::partition)); + + Stream, Pair>> overlaps = + filesByPartition.entrySet().stream() + .flatMap( + entry -> { + List datafiles = entry.getValue(); + Preconditions.checkArgument( + datafiles.size() > 1, + "This test is checking for overlaps in a situation where no overlaps can actually occur because the " + + "partition %s does not contain multiple datafiles", + entry.getKey()); + + List, Pair>> boundComparisons = + Lists.cartesianProduct(datafiles, datafiles).stream() + .filter(tuple -> tuple.get(0) != tuple.get(1)) + .map( + tuple -> + Pair.of( + boundsOf(tuple.get(0), field, javaClass), + boundsOf(tuple.get(1), field, javaClass))) + .collect(Collectors.toList()); + + Comparator comparator = Comparators.forType(field.type().asPrimitiveType()); + + List, Pair>> overlappingFiles = + boundComparisons.stream() + .filter( + filePair -> { + Pair left = filePair.first(); + T lMin = left.first(); + T lMax = left.second(); + Pair right = filePair.second(); + T rMin = right.first(); + T rMax = right.second(); + boolean boundsDoNotOverlap = + // Min and Max of a range are greater than or equal to the max + // value of the other range + (comparator.compare(rMax, lMax) >= 0 + && comparator.compare(rMin, lMax) >= 0) + || (comparator.compare(lMax, rMax) >= 0 + && comparator.compare(lMin, rMax) >= 0); + + return !boundsDoNotOverlap; + }) + .collect(Collectors.toList()); + return overlappingFiles.stream(); + }); + + return overlaps.collect(Collectors.toList()); + } + + protected Table createTable() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + table + .updateProperties() + .set(TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES, Integer.toString(20 * 1024)) + .commit(); + assertThat(table.currentSnapshot()).as("Table must be empty").isNull(); + return table; + } + + /** + * Create a table with a certain number of files, returns the size of a file + * + * @param files number of files to create + * @return the created table + */ + protected Table createTable(int files) { + Table table = createTable(); + writeRecords(files, SCALE); + table.refresh(); + return table; + } + + protected Table createTablePartitioned( + int partitions, int files, int numRecords, Map options) { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").truncate("c2", 2).build(); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + assertThat(table.currentSnapshot()).as("Table must be empty").isNull(); + + writeRecords(files, numRecords, partitions); + table.refresh(); + return table; + } + + protected Table createTablePartitioned(int partitions, int files) { + return createTablePartitioned( + partitions, + files, + SCALE, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion))); + } + + protected Table createTablePartitioned(int partitions, int files, int numRecords) { + return createTablePartitioned( + partitions, + files, + numRecords, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion))); + } + + private Table createTypeTestTable() { + Schema schema = + new Schema( + required(1, "longCol", Types.LongType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "floatCol", Types.FloatType.get()), + optional(4, "doubleCol", Types.DoubleType.get()), + optional(5, "dateCol", Types.DateType.get()), + optional(6, "timestampCol", Types.TimestampType.withZone()), + optional(7, "stringCol", Types.StringType.get()), + optional(8, "booleanCol", Types.BooleanType.get()), + optional(9, "binaryCol", Types.BinaryType.get())); + + Map options = + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + Table table = TABLES.create(schema, PartitionSpec.unpartitioned(), options, tableLocation); + + spark + .range(0, 10, 1, 10) + .withColumnRenamed("id", "longCol") + .withColumn("intCol", expr("CAST(longCol AS INT)")) + .withColumn("floatCol", expr("CAST(longCol AS FLOAT)")) + .withColumn("doubleCol", expr("CAST(longCol AS DOUBLE)")) + .withColumn("dateCol", date_add(current_date(), 1)) + .withColumn("timestampCol", expr("TO_TIMESTAMP(dateCol)")) + .withColumn("stringCol", expr("CAST(dateCol AS STRING)")) + .withColumn("booleanCol", expr("longCol > 5")) + .withColumn("binaryCol", expr("CAST(longCol AS BINARY)")) + .write() + .format("iceberg") + .mode("append") + .save(tableLocation); + + table.refresh(); + return table; + } + + protected int averageFileSize(Table table) { + return (int) + Streams.stream(table.newScan().planFiles()) + .mapToLong(FileScanTask::length) + .average() + .getAsDouble(); + } + + private void writeRecords(List records) { + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + writeDF(df); + } + + private void writeRecords(int files, int numRecords) { + writeRecords(files, numRecords, 0); + } + + private void writeRecords(int files, int numRecords, int partitions) { + List records = Lists.newArrayList(); + int rowDimension = (int) Math.ceil(Math.sqrt(numRecords)); + List> data = + IntStream.range(0, rowDimension) + .boxed() + .flatMap(x -> IntStream.range(0, rowDimension).boxed().map(y -> Pair.of(x, y))) + .collect(Collectors.toList()); + Collections.shuffle(data, new Random(42)); + if (partitions > 0) { + data.forEach( + i -> + records.add( + new ThreeColumnRecord( + i.first() % partitions, "foo" + i.first(), "bar" + i.second()))); + } else { + data.forEach( + i -> + records.add(new ThreeColumnRecord(i.first(), "foo" + i.first(), "bar" + i.second()))); + } + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).repartition(files); + writeDF(df); + } + + private void writeDF(Dataset df) { + df.select("c1", "c2", "c3") + .sortWithinPartitions("c1", "c2") + .write() + .format("iceberg") + .mode("append") + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") + .save(tableLocation); + } + + private List writePosDeletesToFile( + Table table, DataFile dataFile, int outputDeleteFiles) { + return writePosDeletes(table, dataFile.partition(), dataFile.location(), outputDeleteFiles); + } + + private List writePosDeletes( + Table table, StructLike partition, String path, int outputDeleteFiles) { + List results = Lists.newArrayList(); + int rowPosition = 0; + for (int file = 0; file < outputDeleteFiles; file++) { + OutputFile outputFile = + table + .io() + .newOutputFile( + table + .locationProvider() + .newDataLocation( + FileFormat.PARQUET.addExtension(UUID.randomUUID().toString()))); + + PositionDeleteWriter posDeleteWriter = + new GenericFileWriterFactory.Builder(table) + .deleteFileFormat(FileFormat.PARQUET) + .writerProperties(ImmutableMap.of(TableProperties.DEFAULT_WRITE_METRICS_MODE, "full")) + .build() + .newPositionDeleteWriter(encrypt(outputFile), table.spec(), partition); + + PositionDelete posDelete = PositionDelete.create(); + posDeleteWriter.write(posDelete.set(path, rowPosition, null)); + try { + posDeleteWriter.close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + results.add(posDeleteWriter.toDeleteFile()); + rowPosition++; + } + + return results; + } + + private List writePosDeletes( + Table table, + StructLike partition, + String path, + int outputDeleteFiles, + int totalPositionsToDelete) { + List results = Lists.newArrayList(); + for (int file = 0; file < outputDeleteFiles; file++) { + OutputFile outputFile = + table + .io() + .newOutputFile( + table + .locationProvider() + .newDataLocation( + FileFormat.PARQUET.addExtension(UUID.randomUUID().toString()))); + + PositionDeleteWriter posDeleteWriter = + new GenericFileWriterFactory.Builder(table) + .deleteFileFormat(FileFormat.PARQUET) + .writerProperties(ImmutableMap.of(TableProperties.DEFAULT_WRITE_METRICS_MODE, "full")) + .build() + .newPositionDeleteWriter(encrypt(outputFile), table.spec(), partition); + + PositionDelete posDelete = PositionDelete.create(); + int positionsPerDeleteFile = totalPositionsToDelete / outputDeleteFiles; + + for (int position = file * positionsPerDeleteFile; + position < (file + 1) * positionsPerDeleteFile; + position++) { + posDeleteWriter.write(posDelete.set(path, position, null)); + } + + try { + posDeleteWriter.close(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + results.add(posDeleteWriter.toDeleteFile()); + } + + return results; + } + + private List writeDV( + Table table, StructLike partition, String path, int numPositionsToDelete) throws IOException { + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PUFFIN).build(); + DVFileWriter writer = new BaseDVFileWriter(fileFactory, p -> null); + try (DVFileWriter closeableWriter = writer) { + for (int row = 0; row < numPositionsToDelete; row++) { + closeableWriter.delete(path, row, table.spec(), partition); + } + } + + return writer.result().deleteFiles(); + } + + private void writeEqDeleteRecord( + Table table, String partCol, Object partVal, String delCol, Object delVal) { + List equalityFieldIds = Lists.newArrayList(table.schema().findField(delCol).fieldId()); + Schema eqDeleteRowSchema = table.schema().select(delCol); + Record partitionRecord = + GenericRecord.create(table.schema().select(partCol)) + .copy(ImmutableMap.of(partCol, partVal)); + Record record = GenericRecord.create(eqDeleteRowSchema).copy(ImmutableMap.of(delCol, delVal)); + writeEqDeleteRecord(table, equalityFieldIds, partitionRecord, eqDeleteRowSchema, record); + } + + private void writeEqDeleteRecord( + Table table, + List equalityFieldIds, + Record partitionRecord, + Schema eqDeleteRowSchema, + Record deleteRecord) { + OutputFileFactory fileFactory = + OutputFileFactory.builderFor(table, 1, 1).format(FileFormat.PARQUET).build(); + + EncryptedOutputFile file = + createEncryptedOutputFile(createPartitionKey(table, partitionRecord), fileFactory); + + EqualityDeleteWriter eqDeleteWriter = + new GenericFileWriterFactory.Builder(table) + .equalityFieldIds(ArrayUtil.toIntArray(equalityFieldIds)) + .equalityDeleteRowSchema(eqDeleteRowSchema) + .deleteFileFormat(FileFormat.PARQUET) + .build() + .newEqualityDeleteWriter( + file, table.spec(), createPartitionKey(table, partitionRecord)); + + try (EqualityDeleteWriter clsEqDeleteWriter = eqDeleteWriter) { + clsEqDeleteWriter.write(deleteRecord); + } catch (Exception e) { + throw new RuntimeException(e); + } + table.newRowDelta().addDeletes(eqDeleteWriter.toDeleteFile()).commit(); + } + + private PartitionKey createPartitionKey(Table table, Record record) { + if (table.spec().isUnpartitioned()) { + return null; + } + + PartitionKey partitionKey = new PartitionKey(table.spec(), table.schema()); + partitionKey.partition(record); + + return partitionKey; + } + + private EncryptedOutputFile createEncryptedOutputFile( + PartitionKey partition, OutputFileFactory fileFactory) { + if (partition == null) { + return fileFactory.newOutputFile(); + } else { + return fileFactory.newOutputFile(partition); + } + } + + private SparkActions actions() { + return SparkActions.get(); + } + + private Set cacheContents(Table table) { + return ImmutableSet.builder() + .addAll(manager.fetchSetIds(table)) + .addAll(coordinator.fetchSetIds(table)) + .build(); + } + + private double percentFilesRequired(Table table, String col, String value) { + return percentFilesRequired(table, new String[] {col}, new String[] {value}); + } + + private double percentFilesRequired(Table table, String[] cols, String[] values) { + Preconditions.checkArgument(cols.length == values.length); + Expression restriction = Expressions.alwaysTrue(); + for (int i = 0; i < cols.length; i++) { + restriction = Expressions.and(restriction, Expressions.equal(cols[i], values[i])); + } + int totalFiles = Iterables.size(table.newScan().planFiles()); + int filteredFiles = Iterables.size(table.newScan().filter(restriction).planFiles()); + return (double) filteredFiles / (double) totalFiles; + } + + class GroupInfoMatcher implements ArgumentMatcher { + private final Set groupIDs; + + GroupInfoMatcher(Integer... globalIndex) { + this.groupIDs = ImmutableSet.copyOf(globalIndex); + } + + @Override + public boolean matches(RewriteFileGroup argument) { + return groupIDs.contains(argument.info().globalIndex()); + } + } + + private void dataFilesSortOrderShouldMatchTableSortOrder(Table table) throws IOException { + dataFilesShouldHaveSortOrderIdMatching(table, table.sortOrder()); + } + + private void dataFilesShouldHaveSortOrderIdMatching(Table table, SortOrder sortOrder) + throws IOException { + try (CloseableIterable files = table.newScan().planFiles()) { + assertThat(files) + .extracting(fileScanTask -> fileScanTask.file().sortOrderId()) + .containsOnly(sortOrder.orderId()); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java new file mode 100644 index 000000000000..0120d4b5fe78 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteManifestsAction.java @@ -0,0 +1,1442 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.ValidationHelpers.dataSeqs; +import static org.apache.iceberg.ValidationHelpers.fileSeqs; +import static org.apache.iceberg.ValidationHelpers.files; +import static org.apache.iceberg.ValidationHelpers.snapshotIds; +import static org.apache.iceberg.ValidationHelpers.validateDataManifest; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileGenerationUtil; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Files; +import org.apache.iceberg.ManifestContent; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestWriter; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.actions.RewriteManifests; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.exceptions.CommitStateUnknownException; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.CharSequenceSet; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.TableIdentifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewriteManifestsAction extends TestBase { + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + + @Parameters( + name = + "snapshotIdInheritanceEnabled = {0}, useCaching = {1}, shouldStageManifests = {2}, formatVersion = {3}") + public static Object[] parameters() { + return new Object[][] { + new Object[] {"true", "true", false, 1}, + new Object[] {"false", "true", true, 1}, + new Object[] {"true", "false", false, 2}, + new Object[] {"false", "false", false, 2}, + new Object[] {"true", "false", false, 3}, + new Object[] {"false", "false", false, 3} + }; + } + + @Parameter private String snapshotIdInheritanceEnabled; + + @Parameter(index = 1) + private String useCaching; + + @Parameter(index = 2) + private boolean shouldStageManifests; + + @Parameter(index = 3) + private int formatVersion; + + private String tableLocation = null; + + @TempDir private Path temp; + @TempDir private File tableDir; + + @BeforeEach + public void setupTableLocation() throws Exception { + this.tableLocation = tableDir.toURI().toString(); + } + + @TestTemplate + public void testRewriteManifestsPreservesOptionalFields() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + DataFile dataFile1 = newDataFile(table, "c1=0"); + DataFile dataFile2 = newDataFile(table, "c1=0"); + DataFile dataFile3 = newDataFile(table, "c1=0"); + table + .newFastAppend() + .appendFile(dataFile1) + .appendFile(dataFile2) + .appendFile(dataFile3) + .commit(); + + DeleteFile deleteFile1 = newDeletes(table, dataFile1); + assertDeletes(dataFile1, deleteFile1); + table.newRowDelta().addDeletes(deleteFile1).commit(); + + DeleteFile deleteFile2 = newDeletes(table, dataFile2); + assertDeletes(dataFile2, deleteFile2); + table.newRowDelta().addDeletes(deleteFile2).commit(); + + DeleteFile deleteFile3 = newDeletes(table, dataFile3); + assertDeletes(dataFile3, deleteFile3); + table.newRowDelta().addDeletes(deleteFile3).commit(); + + SparkActions actions = SparkActions.get(); + + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + for (FileScanTask fileTask : tasks) { + DataFile dataFile = fileTask.file(); + DeleteFile deleteFile = Iterables.getOnlyElement(fileTask.deletes()); + if (dataFile.location().equals(dataFile1.location())) { + assertThat(deleteFile.referencedDataFile()).isEqualTo(deleteFile1.referencedDataFile()); + assertEqual(deleteFile, deleteFile1); + } else if (dataFile.location().equals(dataFile2.location())) { + assertThat(deleteFile.referencedDataFile()).isEqualTo(deleteFile2.referencedDataFile()); + assertEqual(deleteFile, deleteFile2); + } else { + assertThat(deleteFile.referencedDataFile()).isEqualTo(deleteFile3.referencedDataFile()); + assertEqual(deleteFile, deleteFile3); + } + } + } + } + + @TestTemplate + public void testRewriteV3ManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, null, "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteV3PartitionedManifestsPreservesFirstRowId() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Table table = + TABLES.create( + SCHEMA, + spec, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)), + tableLocation); + + writeRecords(Lists.newArrayList(new ThreeColumnRecord(1, "AAAA", "AAAA"))); + writeRecords(Lists.newArrayList(new ThreeColumnRecord(2, "BBBB", "BBBB"))); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + List rowsBefore = recordsWithLineage(); + assertThat(rowsBefore) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter).containsExactlyElementsOf(rowsBefore); + } + + @TestTemplate + public void testRewriteManifestsAfterV2ToV3Upgrade() { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = + TABLES.create( + SCHEMA, spec, ImmutableMap.of(TableProperties.FORMAT_VERSION, "2"), tableLocation); + + ThreeColumnRecord record1 = new ThreeColumnRecord(1, null, "AAAA"); + ThreeColumnRecord record2 = new ThreeColumnRecord(2, "CCCC", "CCCC"); + writeRecords(Lists.newArrayList(record1)); + writeRecords(Lists.newArrayList(record2)); + table.refresh(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(2); + + table + .updateProperties() + .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)) + .commit(); + + SparkActions.get() + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(table.currentSnapshot().dataManifests(table.io())).hasSize(1); + + List rowsAfter = recordsWithLineage(); + + assertThat(rowsAfter) + .extracting(r -> r.getAs(MetadataColumns.ROW_ID.name())) + .doesNotContainNull() + .doesNotHaveDuplicates(); + assertThat(rowsAfter) + .extracting(r -> new ThreeColumnRecord(r.getAs("c1"), r.getAs("c2"), r.getAs("c3"))) + .containsExactlyInAnyOrder(record1, record2); + } + + @TestTemplate + public void testRewriteManifestsEmptyTable() throws IOException { + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + assertThat(table.currentSnapshot()).as("Table must be empty").isNull(); + + SparkActions actions = SparkActions.get(); + + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .stagingLocation(java.nio.file.Files.createTempDirectory(temp, "junit").toString()) + .execute(); + + assertThat(table.currentSnapshot()).as("Table must stay empty").isNull(); + } + + @TestTemplate + public void testRewriteSmallManifestsNonPartitionedTable() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + List records1 = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), new ThreeColumnRecord(1, "BBBBBBBBBB", "BBBB")); + writeRecords(records1); + + List records2 = + Lists.newArrayList( + new ThreeColumnRecord(2, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(2, "DDDDDDDDDD", "DDDD")); + writeRecords(records2); + + table.refresh(); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).as("Should have 2 manifests before rewrite").hasSize(2); + + SparkActions actions = SparkActions.get(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(result.rewrittenManifests()).as("Action should rewrite 2 manifests").hasSize(2); + assertThat(result.addedManifests()).as("Action should add 1 manifests").hasSize(1); + assertManifestsLocation(result.addedManifests()); + + table.refresh(); + + List newManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(newManifests).as("Should have 1 manifests after rewrite").hasSize(1); + + assertThat(newManifests.get(0).existingFilesCount()).isEqualTo(4); + assertThat(newManifests.get(0).hasAddedFiles()).isFalse(); + assertThat(newManifests.get(0).hasDeletedFiles()).isFalse(); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(records1); + expectedRecords.addAll(records2); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.sort("c1", "c2").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + + assertThat(actualRecords).as("Rows must match").isEqualTo(expectedRecords); + } + + @TestTemplate + public void testRewriteManifestsWithCommitStateUnknownException() { + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + List records1 = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), new ThreeColumnRecord(1, "BBBBBBBBBB", "BBBB")); + writeRecords(records1); + + List records2 = + Lists.newArrayList( + new ThreeColumnRecord(2, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(2, "DDDDDDDDDD", "DDDD")); + writeRecords(records2); + + table.refresh(); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).as("Should have 2 manifests before rewrite").hasSize(2); + + SparkActions actions = SparkActions.get(); + + // create a spy which would throw a CommitStateUnknownException after successful commit. + org.apache.iceberg.RewriteManifests newRewriteManifests = table.rewriteManifests(); + org.apache.iceberg.RewriteManifests spyNewRewriteManifests = spy(newRewriteManifests); + doAnswer( + invocation -> { + newRewriteManifests.commit(); + throw new CommitStateUnknownException(new RuntimeException("Datacenter on Fire")); + }) + .when(spyNewRewriteManifests) + .commit(); + + Table spyTable = spy(table); + when(spyTable.rewriteManifests()).thenReturn(spyNewRewriteManifests); + + assertThatThrownBy( + () -> actions.rewriteManifests(spyTable).rewriteIf(manifest -> true).execute()) + .cause() + .isInstanceOf(RuntimeException.class) + .hasMessage("Datacenter on Fire"); + + table.refresh(); + + // table should reflect the changes, since the commit was successful + List newManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(newManifests).as("Should have 1 manifests after rewrite").hasSize(1); + + assertThat(newManifests.get(0).existingFilesCount()).isEqualTo(4); + assertThat(newManifests.get(0).hasAddedFiles()).isFalse(); + assertThat(newManifests.get(0).hasDeletedFiles()).isFalse(); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(records1); + expectedRecords.addAll(records2); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.sort("c1", "c2").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + + assertThat(actualRecords).as("Rows must match").isEqualTo(expectedRecords); + } + + @TestTemplate + public void testRewriteSmallManifestsPartitionedTable() { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").truncate("c2", 2).build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + List records1 = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), new ThreeColumnRecord(1, "BBBBBBBBBB", "BBBB")); + writeRecords(records1); + + List records2 = + Lists.newArrayList( + new ThreeColumnRecord(2, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(2, "DDDDDDDDDD", "DDDD")); + writeRecords(records2); + + List records3 = + Lists.newArrayList( + new ThreeColumnRecord(3, "EEEEEEEEEE", "EEEE"), + new ThreeColumnRecord(3, "FFFFFFFFFF", "FFFF")); + writeRecords(records3); + + List records4 = + Lists.newArrayList( + new ThreeColumnRecord(4, "GGGGGGGGGG", "GGGG"), + new ThreeColumnRecord(4, "HHHHHHHHHG", "HHHH")); + writeRecords(records4); + + table.refresh(); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).as("Should have 4 manifests before rewrite").hasSize(4); + + SparkActions actions = SparkActions.get(); + + // we will expect to have 2 manifests with 4 entries in each after rewrite + long manifestEntrySizeBytes = computeManifestEntrySizeBytes(manifests); + long targetManifestSizeBytes = (long) (1.05 * 4 * manifestEntrySizeBytes); + + table + .updateProperties() + .set(TableProperties.MANIFEST_TARGET_SIZE_BYTES, String.valueOf(targetManifestSizeBytes)) + .commit(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(result.rewrittenManifests()).as("Action should rewrite 4 manifests").hasSize(4); + assertThat(result.addedManifests()).as("Action should add 2 manifests").hasSize(2); + assertManifestsLocation(result.addedManifests()); + + table.refresh(); + + List newManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(newManifests).as("Should have 2 manifests after rewrite").hasSize(2); + + assertThat(newManifests.get(0).existingFilesCount()).isEqualTo(4); + assertThat(newManifests.get(0).hasAddedFiles()).isFalse(); + assertThat(newManifests.get(0).hasDeletedFiles()).isFalse(); + + assertThat(newManifests.get(1).existingFilesCount()).isEqualTo(4); + assertThat(newManifests.get(1).hasAddedFiles()).isFalse(); + assertThat(newManifests.get(1).hasDeletedFiles()).isFalse(); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(records1); + expectedRecords.addAll(records2); + expectedRecords.addAll(records3); + expectedRecords.addAll(records4); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.sort("c1", "c2").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + + assertThat(actualRecords).as("Rows must match").isEqualTo(expectedRecords); + } + + @TestTemplate + public void testRewriteImportedManifests() throws IOException { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c3").build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + List records = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), new ThreeColumnRecord(1, "BBBBBBBBBB", "BBBB")); + File parquetTableDir = temp.resolve("parquet_table").toFile(); + String parquetTableLocation = parquetTableDir.toURI().toString(); + + try { + Dataset inputDF = spark.createDataFrame(records, ThreeColumnRecord.class); + inputDF + .select("c1", "c2", "c3") + .write() + .format("parquet") + .mode("overwrite") + .option("path", parquetTableLocation) + .partitionBy("c3") + .saveAsTable("parquet_table"); + + File stagingDir = temp.resolve("staging-dir").toFile(); + SparkTableUtil.importSparkTable( + spark, new TableIdentifier("parquet_table"), table, stagingDir.toString()); + + // add some more data to create more than one manifest for the rewrite + inputDF.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(tableLocation); + table.refresh(); + + Snapshot snapshot = table.currentSnapshot(); + + SparkActions actions = SparkActions.get(); + + String rewriteStagingLocation = + java.nio.file.Files.createTempDirectory(temp, "junit").toString(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .stagingLocation(rewriteStagingLocation) + .execute(); + + assertThat(result.rewrittenManifests()) + .as("Action should rewrite all manifests") + .isEqualTo(snapshot.allManifests(table.io())); + assertThat(result.addedManifests()).as("Action should add 1 manifest").hasSize(1); + assertManifestsLocation(result.addedManifests(), rewriteStagingLocation); + + } finally { + spark.sql("DROP TABLE parquet_table"); + } + } + + @TestTemplate + public void testRewriteLargeManifestsPartitionedTable() throws IOException { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c3").build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + List dataFiles = Lists.newArrayList(); + for (int fileOrdinal = 0; fileOrdinal < 1000; fileOrdinal++) { + dataFiles.add(newDataFile(table, "c3=" + fileOrdinal)); + } + ManifestFile appendManifest = writeManifest(table, dataFiles); + table.newFastAppend().appendManifest(appendManifest).commit(); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).as("Should have 1 manifests before rewrite").hasSize(1); + + // set the target manifest size to a small value to force splitting records into multiple files + table + .updateProperties() + .set( + TableProperties.MANIFEST_TARGET_SIZE_BYTES, + String.valueOf(manifests.get(0).length() / 2)) + .commit(); + + SparkActions actions = SparkActions.get(); + + String stagingLocation = java.nio.file.Files.createTempDirectory(temp, "junit").toString(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .stagingLocation(stagingLocation) + .execute(); + + assertThat(result.rewrittenManifests()).hasSize(1); + assertThat(result.addedManifests()).hasSizeGreaterThanOrEqualTo(2); + assertManifestsLocation(result.addedManifests(), stagingLocation); + + table.refresh(); + + List newManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(newManifests).hasSizeGreaterThanOrEqualTo(2); + } + + @TestTemplate + public void testRewriteManifestsPartitionedTableWithInvalidSortingColumns() throws IOException { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").bucket("c3", 10).build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + SparkActions actions = org.apache.iceberg.spark.actions.SparkActions.get(); + + List nonexistentFields = ImmutableList.of("c1", "c2"); + assertThatThrownBy( + () -> + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .sortBy(nonexistentFields) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot set manifest sorting because specified field(s) [c2] were not found in " + + "current partition spec [\n" + + " 1000: c1: identity(1)\n" + + " 1001: c3_bucket: bucket[10](3)\n" + + "]. Spec ID 0"); + + // c3_bucket is the correct internal partition name to use, c3 is the untransformed column name, + // sortBy() expects the hidden partition column names + List hasIncorrectPartitionFieldNames = ImmutableList.of("c1", "c3"); + assertThatThrownBy( + () -> + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .sortBy(hasIncorrectPartitionFieldNames) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot set manifest sorting because specified field(s) [c3] were not found in " + + "current partition spec [\n" + + " 1000: c1: identity(1)\n" + + " 1001: c3_bucket: bucket[10](3)\n" + + "]. Spec ID 0"); + } + + @TestTemplate + public void testRewriteManifestsPartitionedTableWithCustomSorting() throws IOException { + Random random = new Random(4141912); + + PartitionSpec spec = + PartitionSpec.builderFor(SCHEMA).identity("c1").truncate("c2", 3).bucket("c3", 10).build(); + Table table = TABLES.create(SCHEMA, spec, tableLocation); + + // write a large number of random records so the rewrite will split into multiple manifests + List dataFiles = Lists.newArrayList(); + for (int i = 0; i < 1000; i++) { + dataFiles.add( + newDataFile( + table, + TestHelpers.Row.of(i, String.valueOf(random.nextInt() * 100), random.nextInt(10)))); + } + ManifestFile appendManifest = writeManifest(table, dataFiles); + table.newFastAppend().appendManifest(appendManifest).commit(); + + // force manifest splitting + table + .updateProperties() + .set( + TableProperties.MANIFEST_TARGET_SIZE_BYTES, String.valueOf(appendManifest.length() / 2)) + .commit(); + + List clusterKeys = ImmutableList.of("c3_bucket", "c2_trunc", "c1"); + SparkActions actions = SparkActions.get(); + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .sortBy(clusterKeys) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + table.refresh(); + + // Read the manifests metadata table. The partition_summaries column contains + // an array of structs with lower_bound and upper_bound as strings + Dataset manifestsDf = spark.read().format("iceberg").load(tableLocation + "#manifests"); + + List bounds = Lists.newArrayList(); + for (Row row : manifestsDf.select("partition_summaries").collectAsList()) { + // partition_summaries is an array of structs; + // index 2 corresponds to c3_bucket since it is the third ordinal field in the table + List summaries = row.getList(0); + Row c3Summary = summaries.get(2); + // lower_bound and upper_bound are at positions 2 and 3 of the summary struct + String lower = c3Summary.getString(2); + String upper = c3Summary.getString(3); + bounds.add(Integer.valueOf(lower)); + bounds.add(Integer.valueOf(upper)); + } + + // Ensure that the list of bounds is sorted; if custom sorting is working, + // the lower/upper bounds should form a non‑decreasing sequence. AKA [0, 4, 4, 9] + assertThat(bounds) + .as("Manifest boundaries should be sorted") + .isSortedAccordingTo(Integer::compareTo); + + // Make sure we have at least two manifests, otherwise the test misses the point + assertThat(manifestsDf.count()).as("There should be at least 2 manifests").isGreaterThan(1L); + } + + @TestTemplate + public void testRewriteManifestsWithPredicate() throws IOException { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").truncate("c2", 2).build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + List records1 = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), new ThreeColumnRecord(1, "BBBBBBBBBB", "BBBB")); + writeRecords(records1); + + writeRecords(records1); + + List records2 = + Lists.newArrayList( + new ThreeColumnRecord(2, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(2, "DDDDDDDDDD", "DDDD")); + writeRecords(records2); + + table.refresh(); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).as("Should have 3 manifests before rewrite").hasSize(3); + + SparkActions actions = SparkActions.get(); + + String stagingLocation = java.nio.file.Files.createTempDirectory(temp, "junit").toString(); + + // rewrite only the first manifest + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf( + manifest -> + (manifest.path().equals(manifests.get(0).path()) + || (manifest.path().equals(manifests.get(1).path())))) + .stagingLocation(stagingLocation) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(result.rewrittenManifests()).as("Action should rewrite 2 manifest").hasSize(2); + assertThat(result.addedManifests()).as("Action should add 1 manifests").hasSize(1); + assertManifestsLocation(result.addedManifests(), stagingLocation); + + table.refresh(); + + List newManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(newManifests) + .as("Should have 2 manifests after rewrite") + .hasSize(2) + .as("First manifest must be rewritten") + .doesNotContain(manifests.get(0)) + .as("Second manifest must be rewritten") + .doesNotContain(manifests.get(1)) + .as("Third manifest must not be rewritten") + .contains(manifests.get(2)); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.add(records1.get(0)); + expectedRecords.add(records1.get(0)); + expectedRecords.add(records1.get(1)); + expectedRecords.add(records1.get(1)); + expectedRecords.addAll(records2); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.sort("c1", "c2").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + + assertThat(actualRecords).as("Rows must match").isEqualTo(expectedRecords); + } + + @TestTemplate + public void testRewriteSmallManifestsNonPartitionedV2Table() { + assumeThat(formatVersion).isGreaterThan(1); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map properties = ImmutableMap.of(TableProperties.FORMAT_VERSION, "2"); + Table table = TABLES.create(SCHEMA, spec, properties, tableLocation); + + List records1 = Lists.newArrayList(new ThreeColumnRecord(1, null, "AAAA")); + writeRecords(records1); + + table.refresh(); + + Snapshot snapshot1 = table.currentSnapshot(); + DataFile file1 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot1).build().addedDataFiles()); + + List records2 = Lists.newArrayList(new ThreeColumnRecord(2, "CCCC", "CCCC")); + writeRecords(records2); + + table.refresh(); + + Snapshot snapshot2 = table.currentSnapshot(); + DataFile file2 = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(table).snapshot(snapshot2).build().addedDataFiles()); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).as("Should have 2 manifests before rewrite").hasSize(2); + + SparkActions actions = SparkActions.get(); + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + assertThat(result.rewrittenManifests()).as("Action should rewrite 2 manifests").hasSize(2); + assertThat(result.addedManifests()).as("Action should add 1 manifests").hasSize(1); + assertManifestsLocation(result.addedManifests()); + + table.refresh(); + + List newManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(newManifests).as("Should have 1 manifests after rewrite").hasSize(1); + + ManifestFile newManifest = Iterables.getOnlyElement(newManifests); + assertThat(newManifest.existingFilesCount()).isEqualTo(2); + assertThat(newManifest.hasAddedFiles()).isFalse(); + assertThat(newManifest.hasDeletedFiles()).isFalse(); + + validateDataManifest( + table, + newManifest, + dataSeqs(1L, 2L), + fileSeqs(1L, 2L), + snapshotIds(snapshot1.snapshotId(), snapshot2.snapshotId()), + files(file1, file2)); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(records1); + expectedRecords.addAll(records2); + + Dataset resultDF = spark.read().format("iceberg").load(tableLocation); + List actualRecords = + resultDF.sort("c1", "c2").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + + assertThat(actualRecords).as("Rows must match").isEqualTo(expectedRecords); + } + + @TestTemplate + public void testRewriteLargeManifestsEvolvedUnpartitionedV1Table() throws IOException { + assumeThat(formatVersion).isEqualTo(1); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c3").build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + table.updateSpec().removeField("c3").commit(); + + assertThat(table.spec().fields()).hasSize(1).allMatch(field -> field.transform().isVoid()); + + List dataFiles = Lists.newArrayList(); + for (int fileOrdinal = 0; fileOrdinal < 1000; fileOrdinal++) { + dataFiles.add(newDataFile(table, TestHelpers.Row.of(new Object[] {null}))); + } + ManifestFile appendManifest = writeManifest(table, dataFiles); + table.newFastAppend().appendManifest(appendManifest).commit(); + + List originalManifests = table.currentSnapshot().allManifests(table.io()); + ManifestFile originalManifest = Iterables.getOnlyElement(originalManifests); + + // set the target manifest size to a small value to force splitting records into multiple files + table + .updateProperties() + .set( + TableProperties.MANIFEST_TARGET_SIZE_BYTES, + String.valueOf(originalManifest.length() / 2)) + .commit(); + + SparkActions actions = SparkActions.get(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(result.rewrittenManifests()).hasSize(1); + assertThat(result.addedManifests()).hasSizeGreaterThanOrEqualTo(2); + assertManifestsLocation(result.addedManifests()); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).hasSizeGreaterThanOrEqualTo(2); + } + + @TestTemplate + public void testRewriteSmallDeleteManifestsNonPartitionedTable() throws IOException { + assumeThat(formatVersion).isEqualTo(2); + + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + // commit data records + List records = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "BBBB"), + new ThreeColumnRecord(3, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(4, "DDDDDDDDDD", "DDDD")); + writeRecords(records); + + // commit a position delete file to remove records where c1 = 1 OR c1 = 2 + List> posDeletes = generatePosDeletes("c1 = 1 OR c1 = 2"); + Pair posDeleteWriteResult = writePosDeletes(table, posDeletes); + table + .newRowDelta() + .addDeletes(posDeleteWriteResult.first()) + .validateDataFilesExist(posDeleteWriteResult.second()) + .commit(); + + // commit an equality delete file to remove all records where c1 = 3 + DeleteFile eqDeleteFile = writeEqDeletes(table, "c1", 3); + table.newRowDelta().addDeletes(eqDeleteFile).commit(); + + // the current snapshot should contain 1 data manifest and 2 delete manifests + List originalManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(originalManifests).hasSize(3); + + SparkActions actions = SparkActions.get(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + // the original delete manifests must be combined + assertThat(result.rewrittenManifests()) + .hasSize(2) + .allMatch(m -> m.content() == ManifestContent.DELETES); + assertThat(result.addedManifests()) + .hasSize(1) + .allMatch(m -> m.content() == ManifestContent.DELETES); + assertManifestsLocation(result.addedManifests()); + + // the new delete manifest must only contain files with status EXISTING + ManifestFile deleteManifest = + Iterables.getOnlyElement(table.currentSnapshot().deleteManifests(table.io())); + assertThat(deleteManifest.existingFilesCount()).isEqualTo(2); + assertThat(deleteManifest.hasAddedFiles()).isFalse(); + assertThat(deleteManifest.hasDeletedFiles()).isFalse(); + + // the preserved data manifest must only contain files with status ADDED + ManifestFile dataManifest = + Iterables.getOnlyElement(table.currentSnapshot().dataManifests(table.io())); + assertThat(dataManifest.hasExistingFiles()).isFalse(); + assertThat(dataManifest.hasAddedFiles()).isTrue(); + assertThat(dataManifest.hasDeletedFiles()).isFalse(); + + // the table must produce expected records after the rewrite + List expectedRecords = + Lists.newArrayList(new ThreeColumnRecord(4, "DDDDDDDDDD", "DDDD")); + assertThat(actualRecords()).isEqualTo(expectedRecords); + } + + @TestTemplate + public void testRewriteSmallDeleteManifestsPartitionedTable() throws IOException { + assumeThat(formatVersion).isEqualTo(2); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c3").build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + options.put(TableProperties.MANIFEST_MERGE_ENABLED, "false"); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + // commit data records + List records = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "AAAA"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "BBBB"), + new ThreeColumnRecord(3, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(4, "DDDDDDDDDD", "DDDD"), + new ThreeColumnRecord(5, "EEEEEEEEEE", "EEEE")); + writeRecords(records); + + // commit the first position delete file to remove records where c1 = 1 + List> posDeletes1 = generatePosDeletes("c1 = 1"); + Pair posDeleteWriteResult1 = + writePosDeletes(table, TestHelpers.Row.of("AAAA"), posDeletes1); + table + .newRowDelta() + .addDeletes(posDeleteWriteResult1.first()) + .validateDataFilesExist(posDeleteWriteResult1.second()) + .commit(); + + // commit the second position delete file to remove records where c1 = 2 + List> posDeletes2 = generatePosDeletes("c1 = 2"); + Pair positionDeleteWriteResult2 = + writePosDeletes(table, TestHelpers.Row.of("BBBB"), posDeletes2); + table + .newRowDelta() + .addDeletes(positionDeleteWriteResult2.first()) + .validateDataFilesExist(positionDeleteWriteResult2.second()) + .commit(); + + // commit the first equality delete file to remove records where c1 = 3 + DeleteFile eqDeleteFile1 = writeEqDeletes(table, TestHelpers.Row.of("CCCC"), "c1", 3); + table.newRowDelta().addDeletes(eqDeleteFile1).commit(); + + // commit the second equality delete file to remove records where c1 = 4 + DeleteFile eqDeleteFile2 = writeEqDeletes(table, TestHelpers.Row.of("DDDD"), "c1", 4); + table.newRowDelta().addDeletes(eqDeleteFile2).commit(); + + // the table must have 1 data manifest and 4 delete manifests + List originalManifests = table.currentSnapshot().allManifests(table.io()); + assertThat(originalManifests).hasSize(5); + + // set the target manifest size to have 2 manifests with 2 entries in each after the rewrite + List originalDeleteManifests = + table.currentSnapshot().deleteManifests(table.io()); + long manifestEntrySizeBytes = computeManifestEntrySizeBytes(originalDeleteManifests); + long targetManifestSizeBytes = (long) (1.05 * 2 * manifestEntrySizeBytes); + + table + .updateProperties() + .set(TableProperties.MANIFEST_TARGET_SIZE_BYTES, String.valueOf(targetManifestSizeBytes)) + .commit(); + + SparkActions actions = SparkActions.get(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> manifest.content() == ManifestContent.DELETES) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + // the original 4 delete manifests must be replaced with 2 new delete manifests + assertThat(result.rewrittenManifests()) + .hasSize(4) + .allMatch(m -> m.content() == ManifestContent.DELETES); + assertThat(result.addedManifests()) + .hasSize(2) + .allMatch(m -> m.content() == ManifestContent.DELETES); + assertManifestsLocation(result.addedManifests()); + + List deleteManifests = table.currentSnapshot().deleteManifests(table.io()); + assertThat(deleteManifests).hasSize(2); + + // the first new delete manifest must only contain files with status EXISTING + ManifestFile deleteManifest1 = deleteManifests.get(0); + assertThat(deleteManifest1.existingFilesCount()).isEqualTo(2); + assertThat(deleteManifest1.hasAddedFiles()).isFalse(); + assertThat(deleteManifest1.hasDeletedFiles()).isFalse(); + + // the second new delete manifest must only contain files with status EXISTING + ManifestFile deleteManifest2 = deleteManifests.get(1); + assertThat(deleteManifest2.existingFilesCount()).isEqualTo(2); + assertThat(deleteManifest2.hasAddedFiles()).isFalse(); + assertThat(deleteManifest2.hasDeletedFiles()).isFalse(); + + // the table must produce expected records after the rewrite + List expectedRecords = + Lists.newArrayList(new ThreeColumnRecord(5, "EEEEEEEEEE", "EEEE")); + assertThat(actualRecords()).isEqualTo(expectedRecords); + } + + @TestTemplate + public void testRewriteLargeDeleteManifestsPartitionedTable() throws IOException { + assumeThat(formatVersion).isEqualTo(2); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c3").build(); + Map options = Maps.newHashMap(); + options.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + options.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, snapshotIdInheritanceEnabled); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + // generate enough delete files to have a reasonably sized manifest + List deleteFiles = Lists.newArrayList(); + for (int fileOrdinal = 0; fileOrdinal < 1000; fileOrdinal++) { + DeleteFile deleteFile = newDeleteFile(table, "c3=" + fileOrdinal); + deleteFiles.add(deleteFile); + } + + // commit delete files + RowDelta rowDelta = table.newRowDelta(); + for (DeleteFile deleteFile : deleteFiles) { + rowDelta.addDeletes(deleteFile); + } + rowDelta.commit(); + + // the current snapshot should contain only 1 delete manifest + List originalDeleteManifests = + table.currentSnapshot().deleteManifests(table.io()); + ManifestFile originalDeleteManifest = Iterables.getOnlyElement(originalDeleteManifests); + + // set the target manifest size to a small value to force splitting records into multiple files + table + .updateProperties() + .set( + TableProperties.MANIFEST_TARGET_SIZE_BYTES, + String.valueOf(originalDeleteManifest.length() / 2)) + .commit(); + + SparkActions actions = SparkActions.get(); + + String stagingLocation = java.nio.file.Files.createTempDirectory(temp, "junit").toString(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .stagingLocation(stagingLocation) + .execute(); + + // the action must rewrite the original delete manifest and add at least 2 new ones + assertThat(result.rewrittenManifests()) + .hasSize(1) + .allMatch(m -> m.content() == ManifestContent.DELETES); + assertThat(result.addedManifests()) + .hasSizeGreaterThanOrEqualTo(2) + .allMatch(m -> m.content() == ManifestContent.DELETES); + assertManifestsLocation(result.addedManifests(), stagingLocation); + + // the current snapshot must return the correct number of delete manifests + List deleteManifests = table.currentSnapshot().deleteManifests(table.io()); + assertThat(deleteManifests).hasSizeGreaterThanOrEqualTo(2); + } + + @TestTemplate + public void testRewriteManifestsAfterUpgradeToV3() throws IOException { + assumeThat(formatVersion).isEqualTo(2); + + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Map options = ImmutableMap.of(TableProperties.FORMAT_VERSION, "2"); + Table table = TABLES.create(SCHEMA, spec, options, tableLocation); + + DataFile dataFile1 = newDataFile(table, "c1=1"); + DeleteFile deleteFile1 = newDeletes(table, dataFile1); + table.newRowDelta().addRows(dataFile1).addDeletes(deleteFile1).commit(); + + DataFile dataFile2 = newDataFile(table, "c1=1"); + DeleteFile deleteFile2 = newDeletes(table, dataFile2); + table.newRowDelta().addRows(dataFile2).addDeletes(deleteFile2).commit(); + + // upgrade the table to enable DVs + table.updateProperties().set(TableProperties.FORMAT_VERSION, "3").commit(); + + DataFile dataFile3 = newDataFile(table, "c1=1"); + DeleteFile dv3 = newDV(table, dataFile3); + table.newRowDelta().addRows(dataFile3).addDeletes(dv3).commit(); + + SparkActions actions = SparkActions.get(); + + RewriteManifests.Result result = + actions + .rewriteManifests(table) + .rewriteIf(manifest -> true) + .option(RewriteManifestsSparkAction.USE_CACHING, useCaching) + .execute(); + + assertThat(result.rewrittenManifests()).as("Action should rewrite 6 manifests").hasSize(6); + assertThat(result.addedManifests()).as("Action should add 2 manifests").hasSize(2); + assertManifestsLocation(result.addedManifests()); + + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + for (FileScanTask fileTask : tasks) { + DataFile dataFile = fileTask.file(); + DeleteFile deleteFile = Iterables.getOnlyElement(fileTask.deletes()); + if (dataFile.location().equals(dataFile1.location())) { + assertThat(deleteFile.referencedDataFile()).isEqualTo(deleteFile1.referencedDataFile()); + assertEqual(deleteFile, deleteFile1); + } else if (dataFile.location().equals(dataFile2.location())) { + assertThat(deleteFile.referencedDataFile()).isEqualTo(deleteFile2.referencedDataFile()); + assertEqual(deleteFile, deleteFile2); + } else { + assertThat(deleteFile.referencedDataFile()).isEqualTo(dv3.referencedDataFile()); + assertEqual(deleteFile, dv3); + } + } + } + } + + private List actualRecords() { + return spark + .read() + .format("iceberg") + .load(tableLocation) + .as(Encoders.bean(ThreeColumnRecord.class)) + .sort("c1", "c2", "c3") + .collectAsList(); + } + + private List recordsWithLineage() { + return spark + .read() + .format("iceberg") + .load(tableLocation) + .selectExpr( + MetadataColumns.ROW_ID.name(), MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.name(), "*") + .orderBy(MetadataColumns.ROW_ID.name()) + .collectAsList(); + } + + private void writeRecords(List records) { + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class); + writeDF(df); + } + + private void writeDF(Dataset df) { + df.select("c1", "c2", "c3") + .write() + .format("iceberg") + .option(SparkWriteOptions.DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_NONE) + .mode("append") + .save(tableLocation); + } + + private long computeManifestEntrySizeBytes(List manifests) { + long totalSize = 0L; + int numEntries = 0; + + for (ManifestFile manifest : manifests) { + totalSize += manifest.length(); + numEntries += + manifest.addedFilesCount() + manifest.existingFilesCount() + manifest.deletedFilesCount(); + } + + return totalSize / numEntries; + } + + private void assertManifestsLocation(Iterable manifests) { + assertManifestsLocation(manifests, null); + } + + private void assertManifestsLocation(Iterable manifests, String stagingLocation) { + if (shouldStageManifests && stagingLocation != null) { + assertThat(manifests).allMatch(manifest -> manifest.path().startsWith(stagingLocation)); + } else { + assertThat(manifests).allMatch(manifest -> manifest.path().startsWith(tableLocation)); + } + } + + private ManifestFile writeManifest(Table table, List files) throws IOException { + File manifestFile = File.createTempFile("generated-manifest", ".avro", temp.toFile()); + assertThat(manifestFile.delete()).isTrue(); + OutputFile outputFile = table.io().newOutputFile(manifestFile.getCanonicalPath()); + + ManifestWriter writer = + ManifestFiles.write(formatVersion, table.spec(), outputFile, null); + + try { + for (DataFile file : files) { + writer.add(file); + } + } finally { + writer.close(); + } + + return writer.toManifestFile(); + } + + private DataFile newDataFile(Table table, String partitionPath) { + return newDataFileBuilder(table).withPartitionPath(partitionPath).build(); + } + + private DataFile newDataFile(Table table, StructLike partition) { + return newDataFileBuilder(table).withPartition(partition).build(); + } + + private DataFiles.Builder newDataFileBuilder(Table table) { + return DataFiles.builder(table.spec()) + .withPath("/path/to/data-" + UUID.randomUUID() + ".parquet") + .withFileSizeInBytes(10) + .withRecordCount(1); + } + + private DeleteFile newDeletes(Table table, DataFile dataFile) { + return formatVersion >= 3 ? newDV(table, dataFile) : newDeleteFileWithRef(table, dataFile); + } + + private DeleteFile newDeleteFileWithRef(Table table, DataFile dataFile) { + return FileGenerationUtil.generatePositionDeleteFileWithRef(table, dataFile); + } + + private DeleteFile newDV(Table table, DataFile dataFile) { + return FileGenerationUtil.generateDV(table, dataFile); + } + + private DeleteFile newDeleteFile(Table table, String partitionPath) { + return formatVersion >= 3 + ? FileMetadata.deleteFileBuilder(table.spec()) + .ofPositionDeletes() + .withPath("/path/to/pos-deletes-" + UUID.randomUUID() + ".puffin") + .withFileSizeInBytes(5) + .withPartitionPath(partitionPath) + .withRecordCount(1) + .withContentOffset(ThreadLocalRandom.current().nextInt()) + .withContentSizeInBytes(ThreadLocalRandom.current().nextInt()) + .build() + : FileMetadata.deleteFileBuilder(table.spec()) + .ofPositionDeletes() + .withPath("/path/to/pos-deletes-" + UUID.randomUUID() + ".parquet") + .withFileSizeInBytes(5) + .withPartitionPath(partitionPath) + .withRecordCount(1) + .build(); + } + + private List> generatePosDeletes(String predicate) { + List rows = + spark + .read() + .format("iceberg") + .load(tableLocation) + .selectExpr("_file", "_pos") + .where(predicate) + .collectAsList(); + + List> deletes = Lists.newArrayList(); + + for (Row row : rows) { + deletes.add(Pair.of(row.getString(0), row.getLong(1))); + } + + return deletes; + } + + private Pair writePosDeletes( + Table table, List> deletes) throws IOException { + return writePosDeletes(table, null, deletes); + } + + private Pair writePosDeletes( + Table table, StructLike partition, List> deletes) + throws IOException { + OutputFile outputFile = Files.localOutput(File.createTempFile("junit", null, temp.toFile())); + return FileHelpers.writeDeleteFile(table, outputFile, partition, deletes, formatVersion); + } + + private DeleteFile writeEqDeletes(Table table, String key, Object... values) throws IOException { + return writeEqDeletes(table, null, key, values); + } + + private DeleteFile writeEqDeletes(Table table, StructLike partition, String key, Object... values) + throws IOException { + List deletes = Lists.newArrayList(); + Schema deleteSchema = table.schema().select(key); + Record delete = GenericRecord.create(deleteSchema); + + for (Object value : values) { + deletes.add(delete.copy(key, value)); + } + + OutputFile outputFile = Files.localOutput(File.createTempFile("junit", null, temp.toFile())); + return FileHelpers.writeDeleteFile(table, outputFile, partition, deletes, deleteSchema); + } + + private void assertDeletes(DataFile dataFile, DeleteFile deleteFile) { + assertThat(deleteFile.referencedDataFile()).isEqualTo(dataFile.location()); + if (formatVersion >= 3) { + assertThat(deleteFile.contentOffset()).isNotNull(); + assertThat(deleteFile.contentSizeInBytes()).isNotNull(); + } else { + assertThat(deleteFile.contentOffset()).isNull(); + assertThat(deleteFile.contentSizeInBytes()).isNull(); + } + } + + private void assertEqual(DeleteFile deleteFile1, DeleteFile deleteFile2) { + assertThat(deleteFile1.location()).isEqualTo(deleteFile2.location()); + assertThat(deleteFile1.content()).isEqualTo(deleteFile2.content()); + assertThat(deleteFile1.specId()).isEqualTo(deleteFile2.specId()); + assertThat(deleteFile1.partition()).isEqualTo(deleteFile2.partition()); + assertThat(deleteFile1.format()).isEqualTo(deleteFile2.format()); + assertThat(deleteFile1.referencedDataFile()).isEqualTo(deleteFile2.referencedDataFile()); + assertThat(deleteFile1.contentOffset()).isEqualTo(deleteFile2.contentOffset()); + assertThat(deleteFile1.contentSizeInBytes()).isEqualTo(deleteFile2.contentSizeInBytes()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java new file mode 100644 index 000000000000..7adbe760e763 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewritePositionDeleteFilesAction.java @@ -0,0 +1,1302 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.expr; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.fail; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionData; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.actions.BinPackRewriteFilePlanner; +import org.apache.iceberg.actions.RewriteDataFiles; +import org.apache.iceberg.actions.RewritePositionDeleteFiles.FileGroupRewriteResult; +import org.apache.iceberg.actions.RewritePositionDeleteFiles.Result; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.deletes.DeleteGranularity; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.source.FourColumnRecord; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.StructLikeMap; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewritePositionDeleteFilesAction extends CatalogTestBase { + + private static final String TABLE_NAME = "test_table"; + private static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + + private static final Map CATALOG_PROPS = + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "cache-enabled", "false"); + + private static final int SCALE = 4000; + private static final int DELETES_SCALE = 1000; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, fileFormat = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + CATALOG_PROPS, + FileFormat.PARQUET + } + }; + } + + @Parameter(index = 3) + private FileFormat format; + + @AfterEach + public void cleanup() { + validationCatalog.dropTable(TableIdentifier.of("default", TABLE_NAME)); + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testEmptyTable() { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Table table = + validationCatalog.createTable( + TableIdentifier.of("default", TABLE_NAME), SCHEMA, spec, tableProperties()); + + Result result = SparkActions.get(spark).rewritePositionDeletes(table).execute(); + assertThat(result.rewrittenDeleteFilesCount()).as("No rewritten delete files").isZero(); + assertThat(result.addedDeleteFilesCount()).as("No added delete files").isZero(); + } + + @TestTemplate + public void testFileGranularity() throws Exception { + checkDeleteGranularity(DeleteGranularity.FILE); + } + + @TestTemplate + public void testPartitionGranularity() throws Exception { + checkDeleteGranularity(DeleteGranularity.PARTITION); + } + + private void checkDeleteGranularity(DeleteGranularity deleteGranularity) throws Exception { + Table table = createTableUnpartitioned(2, SCALE); + + table + .updateProperties() + .set(TableProperties.DELETE_GRANULARITY, deleteGranularity.toString()) + .commit(); + + List dataFiles = TestHelpers.dataFiles(table); + assertThat(dataFiles).hasSize(2); + + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(2); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + int expectedDeleteFilesCount = deleteGranularity == DeleteGranularity.FILE ? 2 : 1; + assertThat(result.addedDeleteFilesCount()).isEqualTo(expectedDeleteFilesCount); + } + + @TestTemplate + public void testUnpartitioned() throws Exception { + Table table = createTableUnpartitioned(2, SCALE); + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(2); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(2); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(2000); + assertThat(expectedDeletes).hasSize(2000); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("New delete files").hasSize(1); + assertLocallySorted(newDeleteFiles); + assertNotContains(deleteFiles, newDeleteFiles); + checkResult(result, deleteFiles, newDeleteFiles, 1); + checkSequenceNumbers(table, deleteFiles, newDeleteFiles); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + } + + @TestTemplate + public void testRewriteAll() throws Exception { + Table table = createTablePartitioned(4, 2, SCALE); + + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(4); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(8); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(12000); + assertThat(expectedDeletes).hasSize(4000); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + SizeBasedFileRewritePlanner.TARGET_FILE_SIZE_BYTES, + Long.toString(Long.MAX_VALUE - 1)) + .execute(); + + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).hasSize(4); + assertNotContains(deleteFiles, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, deleteFiles, newDeleteFiles, 4); + checkSequenceNumbers(table, deleteFiles, newDeleteFiles); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + } + + @TestTemplate + public void testRewriteFilter() throws Exception { + Table table = createTablePartitioned(4, 2, SCALE); + + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(4); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(8); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(12000); + assertThat(expectedDeletes).hasSize(4000); + + Expression filter = + Expressions.and( + Expressions.greaterThan("c3", "0"), // should have no effect + // "C1" should work because Spark defaults case sensitivity to false. + Expressions.or(Expressions.equal("C1", 1), Expressions.equal("C1", 2))); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .filter(filter) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + SizeBasedFileRewritePlanner.TARGET_FILE_SIZE_BYTES, + Long.toString(Long.MAX_VALUE - 1)) + .execute(); + + List newDeleteFiles = except(deleteFiles(table), deleteFiles); + assertThat(newDeleteFiles).as("Delete files").hasSize(2); + + List expectedRewrittenFiles = + filterFiles(table, deleteFiles, ImmutableList.of(1), ImmutableList.of(2)); + assertLocallySorted(newDeleteFiles); + checkResult(result, expectedRewrittenFiles, newDeleteFiles, 2); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + + withSQLConf( + ImmutableMap.of(SQLConf.CASE_SENSITIVE().key(), "true"), + () -> { + assertThatThrownBy( + () -> + SparkActions.get(spark) + .rewritePositionDeletes(table) + .filter(filter) + .execute()) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Cannot find field 'C1' in struct"); + }); + } + + @TestTemplate + public void testRewriteToSmallerTarget() throws Exception { + Table table = createTablePartitioned(4, 2, SCALE); + + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(4); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(12000); + assertThat(expectedDeletes).hasSize(4000); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(8); + + long avgSize = size(deleteFiles) / deleteFiles.size(); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option(SizeBasedFileRewritePlanner.TARGET_FILE_SIZE_BYTES, String.valueOf(avgSize / 2)) + .execute(); + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("New delete files").hasSize(8); + assertNotContains(deleteFiles, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, deleteFiles, newDeleteFiles, 4); + checkSequenceNumbers(table, deleteFiles, newDeleteFiles); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + } + + @TestTemplate + public void testRemoveDanglingDeletes() throws Exception { + Table table = createTablePartitioned(4, 2, SCALE); + + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles( + table, + 2, + DELETES_SCALE, + dataFiles, + true /* Disable commit-time ManifestFilterManager removal of dangling deletes */); + + assertThat(dataFiles).hasSize(4); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(8); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(12000); + assertThat(expectedDeletes).hasSize(4000); + + SparkActions.get(spark) + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("New delete files").isEmpty(); + assertNotContains(deleteFiles, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, deleteFiles, newDeleteFiles, 4); + checkSequenceNumbers(table, deleteFiles, newDeleteFiles); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertThat(actualDeletes).as("New position deletes").isEmpty(); + } + + @TestTemplate + public void testRemoveDanglingDVsAfterCompaction() { + sql( + "create table %s (s string, id string) PARTITIONED BY (bucket(8, id)) " + + "tblproperties ('format-version'='3'," + + "'write.update.mode'='merge-on-read'," + + "'write.delete.mode'='merge-on-read'," + + "'write.merge.mode'='merge-on-read')", + tableName); + sql("insert into %s select * from (values ('foo', '1'), ('bar', '1')) order by 1", tableName); + sql("insert into %s select * from (values ('foo', '1'), ('bat', '1')) order by 1", tableName); + sql("insert into %s select * from (values ('bar', '1'), ('bat', '1')) order by 1", tableName); + + List objects = sql("select * from %s.files", tableName); + assertThat(objects).hasSize(3); + + sql("delete from %s where s = 'foo'", tableName); + assertThat(sql("select * from %s.files", tableName)).hasSize(5); + + sql("delete from %s where s = 'bar'", tableName); + assertThat(sql("select * from %s.files", tableName)).hasSize(6); + + assertThat(sql("select * from %s.data_files", tableName)).hasSize(3); + assertThat(sql("select * from %s.delete_files", tableName)).hasSize(3); + + Set deleteFilesBefore = + TestHelpers.deleteFiles(validationCatalog.loadTable(tableIdent)); + assertThat(deleteFilesBefore).hasSize(3); + + RewriteDataFiles.Result result = + SparkActions.get(spark) + .rewriteDataFiles(validationCatalog.loadTable(tableIdent)) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option(RewriteDataFiles.REMOVE_DANGLING_DELETES, "true") + .execute(); + + Set deleteFilesAfter = + TestHelpers.deleteFiles(validationCatalog.loadTable(tableIdent)); + assertThat(deleteFilesAfter).isEmpty(); + assertThat(result.addedDataFilesCount()).isEqualTo(1); + assertThat(result.rewrittenDataFilesCount()).isEqualTo(3); + assertThat(sql("select * from %s.delete_files", tableName)).hasSameSizeAs(deleteFilesAfter); + assertThat(result.removedDeleteFilesCount()).isEqualTo(deleteFilesBefore.size()); + } + + @TestTemplate + public void testValidDVsAreNotRemovedDuringDanglingDeletesRemoval() { + sql( + "create table %s (s string, id string) PARTITIONED BY (bucket(8, id)) " + + "tblproperties ('format-version'='3'," + + "'write.update.mode'='merge-on-read'," + + "'write.delete.mode'='merge-on-read'," + + "'write.merge.mode'='merge-on-read')", + tableName); + sql("insert into %s select * from (values ('foo', '1'), ('bar', '1')) order by 1", tableName); + sql("insert into %s select * from (values ('foo', '1'), ('bat', '1')) order by 1", tableName); + sql("insert into %s select * from (values ('bar', '1'), ('bat', '1')) order by 1", tableName); + + List objects = sql("select * from %s.files", tableName); + assertThat(objects).hasSize(3); + + sql("delete from %s where s = 'foo'", tableName); + assertThat(sql("select * from %s.files", tableName)).hasSize(5); + + assertThat(sql("select * from %s.data_files", tableName)).hasSize(3); + assertThat(sql("select * from %s.delete_files", tableName)).hasSize(2); + + Set deleteFilesBefore = + TestHelpers.deleteFiles(validationCatalog.loadTable(tableIdent)); + assertThat(deleteFilesBefore).hasSize(2); + + // data files are not compacted and removing dangling deletes should not remove valid DVs + RewriteDataFiles.Result result = + SparkActions.get(spark) + .rewriteDataFiles(validationCatalog.loadTable(tableIdent)) + .option(RewriteDataFiles.REMOVE_DANGLING_DELETES, "true") + .option(BinPackRewriteFilePlanner.MIN_FILE_SIZE_BYTES, "0") + .option(BinPackRewriteFilePlanner.DELETE_RATIO_THRESHOLD, "1.0") + .execute(); + + Set deleteFilesAfter = + TestHelpers.deleteFiles(validationCatalog.loadTable(tableIdent)); + assertThat(deleteFilesAfter).isEqualTo(deleteFilesBefore); + assertThat(result.addedDataFilesCount()).isEqualTo(0); + assertThat(result.rewrittenDataFilesCount()).isEqualTo(0); + assertThat(sql("select * from %s.delete_files", tableName)).hasSameSizeAs(deleteFilesAfter); + assertThat(result.removedDeleteFilesCount()).isEqualTo(0); + } + + @TestTemplate + public void testSomePartitionsDanglingDeletes() throws Exception { + Table table = createTablePartitioned(4, 2, SCALE); + + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(4); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(8); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(12000); + assertThat(expectedDeletes).hasSize(4000); + + // Rewrite half the data files + Expression filter = Expressions.or(Expressions.equal("c1", 0), Expressions.equal("c1", 1)); + SparkActions.get(spark) + .rewriteDataFiles(table) + .filter(filter) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("New delete files").hasSize(2); + assertNotContains(deleteFiles, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, deleteFiles, newDeleteFiles, 4); + checkSequenceNumbers(table, deleteFiles, newDeleteFiles); + + // As only half the files have been rewritten, + // we expect to retain position deletes only for those not rewritten + expectedDeletes = + expectedDeletes.stream() + .filter( + r -> { + Object[] partition = (Object[]) r[3]; + return partition[0] == (Integer) 2 || partition[0] == (Integer) 3; + }) + .collect(Collectors.toList()); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + } + + @TestTemplate + public void testRewriteFilterRemoveDangling() throws Exception { + Table table = createTablePartitioned(4, 2, SCALE); + + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles, true); + assertThat(dataFiles).hasSize(4); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(8); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(12000); // 16000 data - 4000 delete rows + assertThat(expectedDeletes).hasSize(4000); + + SparkActions.get(spark) + .rewriteDataFiles(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + Expression filter = Expressions.or(Expressions.equal("c1", 0), Expressions.equal("c1", 1)); + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .filter(filter) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + SizeBasedFileRewritePlanner.TARGET_FILE_SIZE_BYTES, + Long.toString(Long.MAX_VALUE - 1)) + .execute(); + + List newDeleteFiles = except(deleteFiles(table), deleteFiles); + assertThat(newDeleteFiles).as("New delete files").isEmpty(); + + List expectedRewrittenFiles = + filterFiles(table, deleteFiles, ImmutableList.of(0), ImmutableList.of(1)); + checkResult(result, expectedRewrittenFiles, newDeleteFiles, 2); + + List actualRecords = records(table); + List allDeletes = deleteRecords(table); + // Only non-compacted deletes remain + List expectedDeletesFiltered = + filterDeletes(expectedDeletes, ImmutableList.of(2), ImmutableList.of(3)); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletesFiltered, allDeletes); + } + + @TestTemplate + public void testPartitionEvolutionAdd() throws Exception { + Table table = createTableUnpartitioned(2, SCALE); + List unpartitionedDataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, unpartitionedDataFiles); + assertThat(unpartitionedDataFiles).hasSize(2); + + List unpartitionedDeleteFiles = deleteFiles(table); + assertThat(unpartitionedDeleteFiles).hasSize(2); + + List expectedUnpartitionedDeletes = deleteRecords(table); + List expectedUnpartitionedRecords = records(table); + assertThat(expectedUnpartitionedRecords).hasSize(2000); + assertThat(expectedUnpartitionedDeletes).hasSize(2000); + + table.updateSpec().addField("c1").commit(); + writeRecords(table, 2, SCALE, 2); + List partitionedDataFiles = + except(TestHelpers.dataFiles(table), unpartitionedDataFiles); + writePosDeletesForFiles(table, 2, DELETES_SCALE, partitionedDataFiles); + assertThat(partitionedDataFiles).hasSize(2); + + List partitionedDeleteFiles = except(deleteFiles(table), unpartitionedDeleteFiles); + assertThat(partitionedDeleteFiles).hasSize(4); + + List expectedDeletes = deleteRecords(table); + List expectedRecords = records(table); + assertThat(expectedDeletes).hasSize(4000); + assertThat(expectedRecords).hasSize(8000); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + List rewrittenDeleteFiles = + Stream.concat(unpartitionedDeleteFiles.stream(), partitionedDeleteFiles.stream()) + .collect(Collectors.toList()); + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("New delete files").hasSize(3); + assertNotContains(rewrittenDeleteFiles, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, rewrittenDeleteFiles, newDeleteFiles, 3); + checkSequenceNumbers(table, rewrittenDeleteFiles, newDeleteFiles); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + } + + @TestTemplate + public void testPartitionEvolutionRemove() throws Exception { + Table table = createTablePartitioned(2, 2, SCALE); + List dataFilesUnpartitioned = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFilesUnpartitioned); + assertThat(dataFilesUnpartitioned).hasSize(2); + + List deleteFilesUnpartitioned = deleteFiles(table); + assertThat(deleteFilesUnpartitioned).hasSize(4); + + table.updateSpec().removeField("c1").commit(); + + writeRecords(table, 2, SCALE); + List dataFilesPartitioned = + except(TestHelpers.dataFiles(table), dataFilesUnpartitioned); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFilesPartitioned); + assertThat(dataFilesPartitioned).hasSize(2); + + List deleteFilesPartitioned = except(deleteFiles(table), deleteFilesUnpartitioned); + assertThat(deleteFilesPartitioned).hasSize(2); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedDeletes).hasSize(4000); + assertThat(expectedRecords).hasSize(8000); + + List expectedRewritten = deleteFiles(table); + assertThat(expectedRewritten).hasSize(6); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("New delete files").hasSize(3); + assertNotContains(expectedRewritten, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, expectedRewritten, newDeleteFiles, 3); + checkSequenceNumbers(table, expectedRewritten, newDeleteFiles); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + } + + @TestTemplate + public void testSchemaEvolution() throws Exception { + Table table = createTablePartitioned(2, 2, SCALE); + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(2); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(4); + + table.updateSchema().addColumn("c4", Types.StringType.get()).commit(); + writeNewSchemaRecords(table, 2, SCALE, 2, 2); + + int newColId = table.schema().findField("c4").fieldId(); + List newSchemaDataFiles = + TestHelpers.dataFiles(table).stream() + .filter(f -> f.upperBounds().containsKey(newColId)) + .collect(Collectors.toList()); + writePosDeletesForFiles(table, 2, DELETES_SCALE, newSchemaDataFiles); + + List newSchemaDeleteFiles = except(deleteFiles(table), deleteFiles); + assertThat(newSchemaDeleteFiles).hasSize(4); + + List expectedDeletes = deleteRecords(table); + List expectedRecords = records(table); + assertThat(expectedDeletes).hasSize(4000); // 4 files * 1000 per file + assertThat(expectedRecords).hasSize(12000); // 4 * 4000 - 4000 + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + + List rewrittenDeleteFiles = + Stream.concat(deleteFiles.stream(), newSchemaDeleteFiles.stream()) + .collect(Collectors.toList()); + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).as("New delete files").hasSize(4); + assertNotContains(rewrittenDeleteFiles, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, rewrittenDeleteFiles, newDeleteFiles, 4); + checkSequenceNumbers(table, rewrittenDeleteFiles, newDeleteFiles); + + List actualRecords = records(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + } + + @TestTemplate + public void testSnapshotProperty() throws Exception { + Table table = createTableUnpartitioned(2, SCALE); + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(2); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(2); + + Result ignored = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .snapshotProperty("key", "value") + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + assertThat(table.currentSnapshot().summary()) + .containsAllEntriesOf(ImmutableMap.of("key", "value")); + + // make sure internal produced properties are not lost + String[] commitMetricsKeys = + new String[] { + SnapshotSummary.ADDED_DELETE_FILES_PROP, + SnapshotSummary.ADDED_POS_DELETES_PROP, + SnapshotSummary.CHANGED_PARTITION_COUNT_PROP, + SnapshotSummary.REMOVED_DELETE_FILES_PROP, + SnapshotSummary.REMOVED_POS_DELETES_PROP, + SnapshotSummary.TOTAL_DATA_FILES_PROP, + SnapshotSummary.TOTAL_DELETE_FILES_PROP, + }; + assertThat(table.currentSnapshot().summary()).containsKeys(commitMetricsKeys); + } + + @TestTemplate + public void testRewriteManyColumns() throws Exception { + List fields = + Lists.newArrayList(Types.NestedField.required(0, "id", Types.LongType.get())); + List additionalCols = + IntStream.range(1, 1010) + .mapToObj(i -> Types.NestedField.optional(i, "c" + i, Types.StringType.get())) + .collect(Collectors.toList()); + fields.addAll(additionalCols); + Schema schema = new Schema(fields); + PartitionSpec spec = PartitionSpec.builderFor(schema).bucket("id", 2).build(); + Table table = + validationCatalog.createTable( + TableIdentifier.of("default", TABLE_NAME), schema, spec, tableProperties()); + + Dataset df = + spark + .range(4) + .withColumns( + IntStream.range(1, 1010) + .boxed() + .collect(Collectors.toMap(i -> "c" + i, i -> expr("CAST(id as STRING)")))); + StructType sparkSchema = spark.table(name(table)).schema(); + spark + .createDataFrame(df.rdd(), sparkSchema) + .coalesce(1) + .write() + .format("iceberg") + .mode("append") + .save(name(table)); + + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 1, 1, dataFiles); + assertThat(dataFiles).hasSize(2); + + List deleteFiles = deleteFiles(table); + assertThat(deleteFiles).hasSize(2); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(2); + assertThat(expectedDeletes).hasSize(2); + + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .option( + SizeBasedFileRewritePlanner.TARGET_FILE_SIZE_BYTES, + Long.toString(Long.MAX_VALUE - 1)) + .execute(); + + List newDeleteFiles = deleteFiles(table); + assertThat(newDeleteFiles).hasSize(2); + assertNotContains(deleteFiles, newDeleteFiles); + assertLocallySorted(newDeleteFiles); + checkResult(result, deleteFiles, newDeleteFiles, 2); + checkSequenceNumbers(table, deleteFiles, newDeleteFiles); + + List actualRecords = records(table); + List actualDeletes = deleteRecords(table); + assertEquals("Rows must match", expectedRecords, actualRecords); + assertEquals("Position deletes must match", expectedDeletes, actualDeletes); + } + + @TestTemplate + public void testRewriteV2PositionDeletesToV3DVs() throws IOException { + Table table = createTableUnpartitioned(2, SCALE); + List dataFiles = TestHelpers.dataFiles(table); + writePosDeletesForFiles(table, 2, DELETES_SCALE, dataFiles); + assertThat(dataFiles).hasSize(2); + assertThat(deleteFiles(table)).hasSize(2).allMatch(file -> file.format() == FileFormat.PARQUET); + + List expectedRecords = records(table); + List expectedDeletes = deleteRecords(table); + assertThat(expectedRecords).hasSize(2000); + assertThat(expectedDeletes).hasSize(2000); + assertThat(dvRecords(table)).isEmpty(); + + // upgrade the table to V3 + table.updateProperties().set(TableProperties.FORMAT_VERSION, "3").commit(); + + // v2 position deletes should now be rewritten to DVs + Result result = + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + assertThat(result.rewrittenDeleteFilesCount()).isEqualTo(2); + assertThat(result.addedDeleteFilesCount()).isEqualTo(2); + assertThat(deleteFiles(table)).hasSize(2).allMatch(file -> file.format() == FileFormat.PUFFIN); + assertThat(dvRecords(table)).hasSize(2); + + // rewriting DVs via rewritePositionDeletes shouldn't be possible anymore + assertThat(SparkActions.get(spark).rewritePositionDeletes(table).execute().rewriteResults()) + .isEmpty(); + } + + private List dvRecords(Table table) { + return spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .load(name(table) + ".position_deletes") + .select("file_path", "delete_file_path") + .where(col("delete_file_path").endsWith(".puffin")) + .coalesce(1) + .distinct() + .collectAsList(); + } + + private Table createTablePartitioned(int partitions, int files, int numRecords) { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("c1").build(); + Table table = + validationCatalog.createTable( + TableIdentifier.of("default", TABLE_NAME), SCHEMA, spec, tableProperties()); + + writeRecords(table, files, numRecords, partitions); + return table; + } + + private Table createTableUnpartitioned(int files, int numRecords) { + Table table = + validationCatalog.createTable( + TableIdentifier.of("default", TABLE_NAME), + SCHEMA, + PartitionSpec.unpartitioned(), + tableProperties()); + + writeRecords(table, files, numRecords); + return table; + } + + private Map tableProperties() { + return tableProperties(2); + } + + private Map tableProperties(int formatVersion) { + return ImmutableMap.of( + TableProperties.DEFAULT_WRITE_METRICS_MODE, + "full", + TableProperties.FORMAT_VERSION, + String.valueOf(formatVersion), + TableProperties.DEFAULT_FILE_FORMAT, + format.toString(), + TableProperties.DELETE_GRANULARITY, + DeleteGranularity.PARTITION.toString()); + } + + private void writeRecords(Table table, int files, int numRecords) { + writeRecords(table, files, numRecords, 1); + } + + private void writeRecords(Table table, int files, int numRecords, int numPartitions) { + writeRecordsWithPartitions( + table, + files, + numRecords, + IntStream.range(0, numPartitions).mapToObj(ImmutableList::of).collect(Collectors.toList())); + } + + private void writeRecordsWithPartitions( + Table table, int files, int numRecords, List> partitions) { + int partitionTypeSize = table.spec().partitionType().fields().size(); + assertThat(partitionTypeSize) + .as("This method currently supports only two columns as partition columns") + .isLessThanOrEqualTo(2); + + BiFunction, ThreeColumnRecord> recordFunction = + (i, partValues) -> { + switch (partitionTypeSize) { + case (0): + return new ThreeColumnRecord(i, String.valueOf(i), String.valueOf(i)); + case (1): + return new ThreeColumnRecord(partValues.get(0), String.valueOf(i), String.valueOf(i)); + case (2): + return new ThreeColumnRecord( + partValues.get(0), String.valueOf(partValues.get(1)), String.valueOf(i)); + default: + throw new ValidationException( + "This method currently supports only two columns as partition columns"); + } + }; + List records = + partitions.stream() + .flatMap( + partition -> + IntStream.range(0, numRecords) + .mapToObj(i -> recordFunction.apply(i, partition))) + .collect(Collectors.toList()); + spark + .createDataFrame(records, ThreeColumnRecord.class) + .repartition(files) + .write() + .format("iceberg") + .mode("append") + .save(name(table)); + table.refresh(); + } + + private void writeNewSchemaRecords( + Table table, int files, int numRecords, int startingPartition, int partitions) { + List records = + IntStream.range(startingPartition, startingPartition + partitions) + .boxed() + .flatMap( + partition -> + IntStream.range(0, numRecords) + .mapToObj( + i -> + new FourColumnRecord( + partition, + String.valueOf(i), + String.valueOf(i), + String.valueOf(i)))) + .collect(Collectors.toList()); + spark + .createDataFrame(records, FourColumnRecord.class) + .repartition(files) + .write() + .format("iceberg") + .mode("append") + .save(name(table)); + } + + private List records(Table table) { + return rowsToJava( + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .load(name(table)) + .coalesce(1) + .sort("c1", "c2", "c3") + .collectAsList()); + } + + private List deleteRecords(Table table) { + String[] additionalFields; + // do not select delete_file_path for comparison + // as delete files have been rewritten + if (table.spec().isUnpartitioned()) { + additionalFields = new String[] {"pos", "row"}; + } else { + additionalFields = new String[] {"pos", "row", "partition", "spec_id"}; + } + return rowsToJava( + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .load(name(table) + ".position_deletes") + .select("file_path", additionalFields) + .coalesce(1) + .sort("file_path", "pos") + .collectAsList()); + } + + private void writePosDeletesForFiles( + Table table, int deleteFilesPerPartition, int deletesPerDataFile, List files) + throws IOException { + writePosDeletesForFiles(table, deleteFilesPerPartition, deletesPerDataFile, files, false); + } + + private void writePosDeletesForFiles( + Table table, + int deleteFilesPerPartition, + int deletesPerDataFile, + List files, + boolean transactional) + throws IOException { + + Map> filesByPartition = + files.stream().collect(Collectors.groupingBy(ContentFile::partition)); + List deleteFiles = + Lists.newArrayListWithCapacity(deleteFilesPerPartition * filesByPartition.size()); + String suffix = String.format(".%s", FileFormat.PARQUET.name().toLowerCase()); + + for (Map.Entry> filesByPartitionEntry : + filesByPartition.entrySet()) { + + StructLike partition = filesByPartitionEntry.getKey(); + List partitionFiles = filesByPartitionEntry.getValue(); + + int deletesForPartition = partitionFiles.size() * deletesPerDataFile; + assertThat(deletesForPartition % deleteFilesPerPartition) + .as( + "Number of delete files per partition should be " + + "evenly divisible by requested deletes per data file times number of data files in this partition") + .isZero(); + + int deleteFileSize = deletesForPartition / deleteFilesPerPartition; + int counter = 0; + List> deletes = Lists.newArrayList(); + for (DataFile partitionFile : partitionFiles) { + for (int deletePos = 0; deletePos < deletesPerDataFile; deletePos++) { + deletes.add(Pair.of(partitionFile.location(), (long) deletePos)); + counter++; + if (counter == deleteFileSize) { + // Dump to file and reset variables + OutputFile output = + Files.localOutput(File.createTempFile("junit", suffix, temp.toFile())); + deleteFiles.add(FileHelpers.writeDeleteFile(table, output, partition, deletes).first()); + counter = 0; + deletes.clear(); + } + } + } + } + + if (transactional) { + RowDelta rowDelta = table.newRowDelta(); + deleteFiles.forEach(rowDelta::addDeletes); + rowDelta.commit(); + } else { + deleteFiles.forEach( + deleteFile -> { + RowDelta rowDelta = table.newRowDelta(); + rowDelta.addDeletes(deleteFile); + rowDelta.commit(); + }); + } + } + + private List deleteFiles(Table table) { + Table deletesTable = + MetadataTableUtils.createMetadataTableInstance(table, MetadataTableType.POSITION_DELETES); + CloseableIterable tasks = deletesTable.newBatchScan().planFiles(); + return Lists.newArrayList( + CloseableIterable.transform(tasks, t -> ((PositionDeletesScanTask) t).file())); + } + + private > List except(List first, List second) { + Set secondPaths = + second.stream().map(ContentFile::location).collect(Collectors.toSet()); + return first.stream() + .filter(f -> !secondPaths.contains(f.location())) + .collect(Collectors.toList()); + } + + private void assertNotContains(List original, List rewritten) { + Set originalPaths = + original.stream().map(ContentFile::location).collect(Collectors.toSet()); + Set rewrittenPaths = + rewritten.stream().map(ContentFile::location).collect(Collectors.toSet()); + rewrittenPaths.retainAll(originalPaths); + assertThat(rewrittenPaths).isEmpty(); + } + + private void assertLocallySorted(List deleteFiles) { + for (DeleteFile deleteFile : deleteFiles) { + Dataset deletes = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, 0) + .option(SparkReadOptions.SPLIT_SIZE, 1024 * 1024 * 64) + .load("default." + TABLE_NAME + ".position_deletes"); + deletes.filter(deletes.col("delete_file_path").equalTo(deleteFile.location())); + List rows = deletes.collectAsList(); + assertThat(rows).as("Empty delete file found").isNotEmpty(); + int lastPos = 0; + String lastPath = ""; + for (Row row : rows) { + String path = row.getAs("file_path"); + long pos = row.getAs("pos"); + if (path.compareTo(lastPath) < 0) { + fail(String.format("File_path not sorted, Found %s after %s", path, lastPath)); + } else if (path.equals(lastPath)) { + assertThat(pos).as("Pos not sorted").isGreaterThanOrEqualTo(lastPos); + } + } + } + } + + private String name(Table table) { + String[] splits = table.name().split("\\."); + assertThat(splits).hasSize(3); + return String.format("%s.%s", splits[1], splits[2]); + } + + private long size(List deleteFiles) { + return deleteFiles.stream().mapToLong(DeleteFile::fileSizeInBytes).sum(); + } + + private List filterDeletes(List deletes, List... partitionValues) { + Stream matches = + deletes.stream() + .filter( + r -> { + Object[] partition = (Object[]) r[3]; + return Arrays.stream(partitionValues) + .map(partitionValue -> match(partition, partitionValue)) + .reduce((a, b) -> a || b) + .get(); + }); + return sorted(matches).collect(Collectors.toList()); + } + + private boolean match(Object[] partition, List expectedPartition) { + return IntStream.range(0, expectedPartition.size()) + .mapToObj(j -> partition[j] == expectedPartition.get(j)) + .reduce((a, b) -> a && b) + .get(); + } + + private Stream sorted(Stream deletes) { + return deletes.sorted( + (a, b) -> { + String aFilePath = (String) a[0]; + String bFilePath = (String) b[0]; + int filePathCompare = aFilePath.compareTo(bFilePath); + if (filePathCompare != 0) { + return filePathCompare; + } else { + long aPos = (long) a[1]; + long bPos = (long) b[1]; + return Long.compare(aPos, bPos); + } + }); + } + + private List filterFiles( + Table table, List files, List... partitionValues) { + List partitionTypes = + table.specs().values().stream() + .map(PartitionSpec::partitionType) + .collect(Collectors.toList()); + List partitionDatas = + Arrays.stream(partitionValues) + .map( + partitionValue -> { + Types.StructType thisType = + partitionTypes.stream() + .filter(f -> f.fields().size() == partitionValue.size()) + .findFirst() + .get(); + PartitionData partition = new PartitionData(thisType); + for (int i = 0; i < partitionValue.size(); i++) { + partition.set(i, partitionValue.get(i)); + } + return partition; + }) + .collect(Collectors.toList()); + + return files.stream() + .filter(f -> partitionDatas.stream().anyMatch(data -> f.partition().equals(data))) + .collect(Collectors.toList()); + } + + private void checkResult( + Result result, + List rewrittenDeletes, + List newDeletes, + int expectedGroups) { + assertThat(result.rewrittenDeleteFilesCount()) + .as("Rewritten delete file count") + .isEqualTo(rewrittenDeletes.size()); + + assertThat(result.addedDeleteFilesCount()) + .as("New delete file count") + .isEqualTo(newDeletes.size()); + + assertThat(result.rewrittenBytesCount()) + .as("Rewritten delete byte count") + .isEqualTo(size(rewrittenDeletes)); + + assertThat(result.addedBytesCount()).as("New delete byte count").isEqualTo(size(newDeletes)); + + assertThat(result.rewriteResults()).as("Rewrite group count").hasSize(expectedGroups); + + assertThat( + result.rewriteResults().stream() + .mapToInt(FileGroupRewriteResult::rewrittenDeleteFilesCount) + .sum()) + .as("Rewritten delete file count in all groups") + .isEqualTo(rewrittenDeletes.size()); + + assertThat( + result.rewriteResults().stream() + .mapToInt(FileGroupRewriteResult::addedDeleteFilesCount) + .sum()) + .as("Added delete file count in all groups") + .isEqualTo(newDeletes.size()); + + assertThat( + result.rewriteResults().stream() + .mapToLong(FileGroupRewriteResult::rewrittenBytesCount) + .sum()) + .as("Rewritten delete bytes in all groups") + .isEqualTo(size(rewrittenDeletes)); + + assertThat( + result.rewriteResults().stream() + .mapToLong(FileGroupRewriteResult::addedBytesCount) + .sum()) + .as("Added delete bytes in all groups") + .isEqualTo(size(newDeletes)); + } + + private void checkSequenceNumbers( + Table table, List rewrittenDeletes, List addedDeletes) { + StructLikeMap> rewrittenFilesPerPartition = + groupPerPartition(table, rewrittenDeletes); + StructLikeMap> addedFilesPerPartition = groupPerPartition(table, addedDeletes); + for (StructLike partition : rewrittenFilesPerPartition.keySet()) { + Long maxRewrittenSeq = + rewrittenFilesPerPartition.get(partition).stream() + .mapToLong(ContentFile::dataSequenceNumber) + .max() + .getAsLong(); + List addedPartitionFiles = addedFilesPerPartition.get(partition); + if (addedPartitionFiles != null) { + addedPartitionFiles.forEach( + d -> + assertThat(d.dataSequenceNumber()) + .as("Sequence number should be max of rewritten set") + .isEqualTo(maxRewrittenSeq)); + } + } + } + + private StructLikeMap> groupPerPartition( + Table table, List deleteFiles) { + StructLikeMap> result = + StructLikeMap.create(Partitioning.partitionType(table)); + for (DeleteFile deleteFile : deleteFiles) { + StructLike partition = deleteFile.partition(); + List partitionFiles = result.get(partition); + if (partitionFiles == null) { + partitionFiles = Lists.newArrayList(); + } + partitionFiles.add(deleteFile); + result.put(partition, partitionFiles); + } + return result; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java new file mode 100644 index 000000000000..dae721b1d73d --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestRewriteTablePathsAction.java @@ -0,0 +1,1696 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.spark.actions.RewriteTablePathSparkAction.NOT_APPLICABLE; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.apache.commons.io.FileUtils; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.StaticTableOperations; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.actions.ActionsProvider; +import org.apache.iceberg.actions.ExpireSnapshots; +import org.apache.iceberg.actions.RewriteManifests; +import org.apache.iceberg.actions.RewriteTablePath; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.source.ThreeColumnRecord; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.SparkEnv; +import org.apache.spark.broadcast.Broadcast; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.storage.BlockId; +import org.apache.spark.storage.BlockInfoManager; +import org.apache.spark.storage.BlockManager; +import org.apache.spark.storage.BroadcastBlockId; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import scala.Tuple2; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRewriteTablePathsAction extends TestBase { + + @TempDir private Path staging; + @TempDir private Path tableDir; + @TempDir private Path newTableDir; + @TempDir private Path targetTableDir; + + @Parameters(name = "formatVersion = {0}") + protected static List formatVersions() { + return TestHelpers.V2_AND_ABOVE; + } + + @Parameter private int formatVersion; + + protected ActionsProvider actions() { + return SparkActions.get(); + } + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + protected static final Schema SCHEMA = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + + protected String tableLocation = null; + private Table table = null; + + private final String ns = "testns"; + private final String backupNs = "backupns"; + + @BeforeEach + public void setupTableLocation() { + this.tableLocation = tableDir.toFile().toURI().toString(); + this.table = createATableWith2Snapshots(tableLocation); + createNameSpaces(); + } + + @AfterEach + public void cleanupTableSetup() { + dropNameSpaces(); + } + + private Table createATableWith2Snapshots(String location) { + return createTableWithSnapshots(location, 2); + } + + private Table createTableWithSnapshots(String location, int snapshotNumber) { + return createTableWithSnapshots(location, snapshotNumber, Maps.newHashMap()); + } + + protected Table createTableWithSnapshots( + String location, int snapshotNumber, Map properties) { + return createTableWithSnapshots(location, snapshotNumber, properties, "append"); + } + + private Table createTableWithSnapshots( + String location, int snapshotNumber, Map properties, String mode) { + Table newTable = + TABLES.create( + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.builder() + .put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)) + .putAll(properties) + .build(), + location); + + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + for (int i = 0; i < snapshotNumber; i++) { + df.select("c1", "c2", "c3").write().format("iceberg").mode(mode).save(location); + } + + return newTable; + } + + private void createNameSpaces() { + sql("CREATE DATABASE IF NOT EXISTS %s", ns); + sql("CREATE DATABASE IF NOT EXISTS %s", backupNs); + } + + private void dropNameSpaces() { + sql("DROP DATABASE IF EXISTS %s CASCADE", ns); + sql("DROP DATABASE IF EXISTS %s CASCADE", backupNs); + } + + @TestTemplate + public void testRewritePath() throws Exception { + String targetTableLocation = targetTableLocation(); + + // check the data file location before the rebuild + List validDataFiles = + spark + .read() + .format("iceberg") + .load(tableLocation + "#files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + assertThat(validDataFiles).hasSize(2); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(tableLocation, targetTableLocation) + .endVersion("v3.metadata.json") + .execute(); + + assertThat(result.latestVersion()).isEqualTo("v3.metadata.json"); + + checkFileNum(3, 2, 2, 9, result); + + // copy the metadata files and data files + copyTableFiles(result); + + // verify the data file path after the rebuild + List validDataFilesAfterRebuilt = + spark + .read() + .format("iceberg") + .load(targetTableLocation + "#files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + assertThat(validDataFilesAfterRebuilt) + .hasSize(2) + .allMatch(item -> item.startsWith(targetTableLocation)); + + // verify data rows + List actual = rows(targetTableLocation); + List expected = rows(tableLocation); + assertEquals("Rows should match after copy", expected, actual); + } + + @TestTemplate + public void testSameLocations() { + assertThatThrownBy( + () -> + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(tableLocation, tableLocation) + .endVersion("v1.metadata.json") + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Source prefix cannot be the same as target prefix"); + } + + @TestTemplate + public void testStartVersion() throws Exception { + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(tableLocation, targetTableLocation()) + .startVersion("v2.metadata.json") + .execute(); + + checkFileNum(1, 1, 1, 4, result); + + List> paths = readPathPairList(result.fileListLocation()); + + String currentSnapshotId = String.valueOf(table.currentSnapshot().snapshotId()); + assertThat(paths.stream().filter(c -> c._2().contains(currentSnapshotId))) + .as("Should have the current snapshot file") + .hasSize(1); + + String parentSnapshotId = String.valueOf(table.currentSnapshot().parentId()); + assertThat(paths.stream().filter(c -> c._2().contains(parentSnapshotId))) + .as("Should NOT have the parent snapshot file") + .isEmpty(); + } + + @TestTemplate + public void testIncrementalRewrite() throws Exception { + String location = newTableLocation(); + Table sourceTable = + TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), Maps.newHashMap(), location); + List recordsA = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset dfA = spark.createDataFrame(recordsA, ThreeColumnRecord.class).coalesce(1); + + // Write first increment to source table + dfA.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(location); + assertThat(spark.read().format("iceberg").load(location).collectAsList()).hasSize(1); + + // Replicate first increment to target table + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceTable.location(), targetTableLocation()) + .execute(); + copyTableFiles(result); + assertThat(spark.read().format("iceberg").load(targetTableLocation()).collectAsList()) + .hasSize(1); + + // Write second increment to source table + List recordsB = + Lists.newArrayList(new ThreeColumnRecord(2, "BBBBBBBBB", "BBB")); + Dataset dfB = spark.createDataFrame(recordsB, ThreeColumnRecord.class).coalesce(1); + dfB.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(location); + assertThat(spark.read().format("iceberg").load(location).collectAsList()).hasSize(2); + + // Replicate second increment to target table + sourceTable.refresh(); + Table targetTable = TABLES.load(targetTableLocation()); + String targetTableMetadata = currentMetadata(targetTable).metadataFileLocation(); + String startVersion = fileName(targetTableMetadata); + RewriteTablePath.Result incrementalRewriteResult = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceTable.location(), targetTableLocation()) + .startVersion(startVersion) + .execute(); + copyTableFiles(incrementalRewriteResult); + List actual = rowsSorted(targetTableLocation(), "c1"); + List expected = rowsSorted(location, "c1"); + assertEquals("Rows should match after copy", expected, actual); + } + + @TestTemplate + public void testTableWith3Snapshots(@TempDir Path location1, @TempDir Path location2) + throws Exception { + String location = newTableLocation(); + Table tableWith3Snaps = createTableWithSnapshots(location, 3); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(tableWith3Snaps) + .rewriteLocationPrefix(location, toAbsolute(location1)) + .startVersion("v2.metadata.json") + .execute(); + + checkFileNum(2, 2, 2, 8, result); + + // start from the first version + RewriteTablePath.Result result1 = + actions() + .rewriteTablePath(tableWith3Snaps) + .rewriteLocationPrefix(location, toAbsolute(location2)) + .startVersion("v1.metadata.json") + .execute(); + + checkFileNum(3, 3, 3, 12, result1); + } + + @TestTemplate + public void testFullTableRewritePath() throws Exception { + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(tableLocation, targetTableLocation()) + .execute(); + + checkFileNum(3, 2, 2, 9, result); + } + + @TestTemplate + public void testManifestRewriteAndIncrementalCopy() throws Exception { + RewriteTablePath.Result initialResult = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .stagingLocation(stagingLocation()) + .execute(); + checkFileNum(3, 2, 2, 9, initialResult); + + // rewrite manifest without change data files + RewriteManifests.Result rewriteManifestResult = actions().rewriteManifests(table).execute(); + int addedManifest = Iterables.size(rewriteManifestResult.addedManifests()); + + // only move version v4, which is the version generated by rewrite manifest + RewriteTablePath.Result postReweiteResult = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .stagingLocation(stagingLocation()) + .startVersion("v3.metadata.json") + .execute(); + + // no data files need to move + checkFileNum(1, 1, addedManifest, 3, postReweiteResult); + } + + @TestTemplate + public void testDeleteDataFile() throws Exception { + List validDataFiles = + spark + .read() + .format("iceberg") + .load(table.location() + "#files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + + table.newDelete().deleteFile(validDataFiles.stream().findFirst().get()).commit(); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .stagingLocation(stagingLocation()) + .execute(); + + checkFileNum(4, 3, 3, 12, result); + + // copy the metadata files and data files + copyTableFiles(result); + + // verify data rows + Dataset resultDF = spark.read().format("iceberg").load(targetTableLocation()); + assertThat(resultDF.as(Encoders.bean(ThreeColumnRecord.class)).collectAsList()) + .as("There are only one row left since we deleted a data file") + .hasSize(1); + } + + @TestTemplate + public void testPositionDeletesParquet() throws Exception { + runPositionDeletesTest("parquet"); + } + + @TestTemplate + public void testPositionDeletesAvro() throws Exception { + runPositionDeletesTest("avro"); + } + + @TestTemplate + public void testPositionDeletesOrc() throws Exception { + runPositionDeletesTest("orc"); + } + + private void runPositionDeletesTest(String fileFormat) throws Exception { + Table tableWithPosDeletes = + createTableWithSnapshots( + tableDir.toFile().toURI().toString().concat("tableWithPosDeletes").concat(fileFormat), + 2, + Map.of(TableProperties.DELETE_DEFAULT_FILE_FORMAT, fileFormat)); + + List> deletes = + Lists.newArrayList( + Pair.of( + SnapshotChanges.builderFor(tableWithPosDeletes) + .build() + .addedDataFiles() + .iterator() + .next() + .location(), + 0L)); + + File file = + new File( + removePrefix( + tableWithPosDeletes.location() + "/data/deeply/nested/deletes." + fileFormat)); + DeleteFile positionDeletes = + FileHelpers.writeDeleteFile( + tableWithPosDeletes, + tableWithPosDeletes.io().newOutputFile(file.toURI().toString()), + deletes, + formatVersion) + .first(); + + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + assertThat(spark.read().format("iceberg").load(tableWithPosDeletes.location()).collectAsList()) + .hasSize(1); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(tableWithPosDeletes) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(tableWithPosDeletes.location(), targetTableLocation()) + .execute(); + + // We have one more snapshot, an additional manifest list, and a new (delete) manifest, + // and an additional position delete + checkFileNum(4, 3, 3, 13, result); + + // copy the metadata files and data files + copyTableFiles(result); + + // Positional delete affects a single row, so only one row must remain + assertThat(spark.read().format("iceberg").load(targetTableLocation()).collectAsList()) + .hasSize(1); + } + + @TestTemplate + public void testPositionDeleteWithRow() throws Exception { + String dataFileLocation = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next().location(); + List> deletes = Lists.newArrayList(); + OutputFile deleteFile = + table + .io() + .newOutputFile( + new File(removePrefix(table.location() + "/data/deeply/nested/deletes.parquet")) + .toURI() + .toString()); + deletes.add(positionDelete(SCHEMA, dataFileLocation, 0L, 1, "AAAAAAAAAA", "AAAA")); + DeleteFile positionDeletes = + FileHelpers.writePosDeleteFile(table, deleteFile, null, deletes, formatVersion); + table.newRowDelta().addDeletes(positionDeletes).commit(); + + assertThat(spark.read().format("iceberg").load(table.location()).collectAsList()).hasSize(1); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .execute(); + + // We have one more snapshot, an additional manifest list, and a new (delete) manifest, + // and an additional position delete + checkFileNum(4, 3, 3, 13, result); + + // copy the metadata files and data files + copyTableFiles(result); + + // check copied position delete row - only v2 stores row data with position deletes + // v3+ uses Deletion Vectors (DV) which only store position information + if (formatVersion == 2) { + Object[] deletedRow = (Object[]) rows(targetTableLocation() + "#position_deletes").get(0)[2]; + assertEquals( + "Position deletes should be equal", new Object[] {1, "AAAAAAAAAA", "AAAA"}, deletedRow); + } + + // Positional delete affects a single row, so only one row must remain + assertThat(spark.read().format("iceberg").load(targetTableLocation()).collectAsList()) + .hasSize(1); + } + + @TestTemplate + public void testPositionDeletesAcrossFiles() throws Exception { + assumeThat(formatVersion) + .as("Can't write multiple deletes into a single v3 delete file") + .isEqualTo(2); + Stream allFiles = + StreamSupport.stream(table.snapshots().spliterator(), false) + .flatMap( + s -> + StreamSupport.stream( + SnapshotChanges.builderFor(table) + .snapshot(s) + .build() + .addedDataFiles() + .spliterator(), + false)); + List> deletes = + allFiles.map(f -> Pair.of((CharSequence) f.location(), 0L)).collect(Collectors.toList()); + + // a single position delete with two entries + assertThat(deletes).hasSize(2); + + File file = new File(removePrefix(table.location() + "/data/deeply/nested/file.parquet")); + DeleteFile positionDeletes = + FileHelpers.writeDeleteFile( + table, table.io().newOutputFile(file.toURI().toString()), deletes, formatVersion) + .first(); + + table.newRowDelta().addDeletes(positionDeletes).commit(); + + assertThat(spark.read().format("iceberg").load(table.location()).collectAsList()).isEmpty(); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .execute(); + + // We have one more snapshot, an additional manifest list, and a new (delete) manifest, + // and an additional position delete + checkFileNum(4, 3, 3, 13, result); + + // copy the metadata files and data files + copyTableFiles(result); + + assertThat(spark.read().format("iceberg").load(targetTableLocation()).collectAsList()) + .isEmpty(); + } + + /** + * Test for https://github.com/apache/iceberg/issues/14814 + * + *

    This test verifies that rewrite_table_path correctly deduplicates delete files when the same + * delete file appears in multiple manifests. Without the DeleteFileSet fix, this test would fail + * with AlreadyExistsException because DeleteFile objects don't override equals() and the same + * file would be processed multiple times. + * + *

    The test creates a scenario where the same delete file is added to multiple snapshots, + * causing it to appear in multiple manifest entries. When these manifests are processed, the same + * delete file is returned as different object instances which need proper deduplication. + */ + @TestTemplate + public void testPositionDeletesDeduplication() throws Exception { + // Format versions 3 and 4 use Deletion Vectors stored in Puffin files, which have different + // validation rules that prevent adding the same delete file multiple times + assumeThat(formatVersion) + .as("Format versions 3+ use DVs with different validation rules") + .isEqualTo(2); + + Table tableWithPosDeletes = + createTableWithSnapshots( + tableDir.toFile().toURI().toString().concat("tableWithDuplicateDeletes"), + 2, + Map.of(TableProperties.DELETE_DEFAULT_FILE_FORMAT, "parquet")); + + // Get a data file to create position deletes for + DataFile dataFile = + tableWithPosDeletes + .currentSnapshot() + .addedDataFiles(tableWithPosDeletes.io()) + .iterator() + .next(); + + // Create a position delete file + List> deletes = Lists.newArrayList(Pair.of(dataFile.location(), 0L)); + File deleteFile = + new File( + removePrefix(tableWithPosDeletes.location() + "/data/deeply/nested/deletes.parquet")); + DeleteFile positionDeletes = + FileHelpers.writeDeleteFile( + tableWithPosDeletes, + tableWithPosDeletes.io().newOutputFile(deleteFile.toURI().toString()), + deletes, + formatVersion) + .first(); + + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // Add the SAME delete file AGAIN in a second snapshot - this creates a duplicate entry + // in a new manifest, which will cause duplicate DeleteFile objects when processing + tableWithPosDeletes.newRowDelta().addDeletes(positionDeletes).commit(); + + // This should NOT throw AlreadyExistsException - the fix uses DeleteFileSet to deduplicate + // Without the fix (using Collectors.toSet()), this would fail because: + // 1. Both manifests contain entries for the same delete file + // 2. Processing returns two different DeleteFile objects for the same file + // 3. HashSet doesn't deduplicate them (DeleteFile doesn't override equals()) + // 4. rewritePositionDeletes tries to write the same file twice -> AlreadyExistsException + RewriteTablePath.Result result = + actions() + .rewriteTablePath(tableWithPosDeletes) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(tableWithPosDeletes.location(), targetTableLocation()) + .execute(); + + // Verify the rewrite completed successfully - should have rewritten exactly 1 delete file + // (the duplicate should be deduplicated by DeleteFileSet) + assertThat(result.rewrittenDeleteFilePathsCount()) + .as("Should have rewritten exactly 1 delete file after deduplication") + .isEqualTo(1); + } + + @TestTemplate + public void testEqualityDeletes() throws Exception { + Table sourceTable = createTableWithSnapshots(newTableLocation(), 1); + + // Add more varied data + List records = + Lists.newArrayList( + new ThreeColumnRecord(2, "AAAAAAAAAA", "AAAA"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "BBBB"), + new ThreeColumnRecord(4, "CCCCCCCCCC", "CCCC"), + new ThreeColumnRecord(5, "DDDDDDDDDD", "DDDD")); + spark + .createDataFrame(records, ThreeColumnRecord.class) + .coalesce(1) + .select("c1", "c2", "c3") + .write() + .format("iceberg") + .mode("append") + .save(newTableLocation()); + + Schema deleteRowSchema = sourceTable.schema().select("c2"); + Record dataDelete = GenericRecord.create(deleteRowSchema); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("c2", "AAAAAAAAAA"), dataDelete.copy("c2", "CCCCCCCCCC")); + File file = new File(removePrefix(sourceTable.location()) + "/data/deeply/nested/file.parquet"); + DeleteFile equalityDeletes = + FileHelpers.writeDeleteFile( + sourceTable, + sourceTable.io().newOutputFile(file.toURI().toString()), + TestHelpers.Row.of(0), + dataDeletes, + deleteRowSchema); + sourceTable.newRowDelta().addDeletes(equalityDeletes).commit(); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(newTableLocation(), targetTableLocation()) + .execute(); + + // We have four metadata files: for the table creation, for the initial snapshot, for the + // second append here, and for commit with equality deletes. Thus, we have three manifest lists. + // We have a data file for each snapshot (two with data, one with equality deletes) + checkFileNum(4, 3, 3, 13, result); + + // copy the metadata files and data files + copyTableFiles(result); + + // Equality deletes affect three rows, so just two rows must remain + assertThat(spark.read().format("iceberg").load(targetTableLocation()).collectAsList()) + .hasSize(2); + } + + @TestTemplate + public void testFullTableRewritePathWithDeletedVersionFiles() throws Exception { + String location = newTableLocation(); + Table sourceTable = createTableWithSnapshots(location, 2); + // expire the first snapshot + Table staticTable = newStaticTable(location + "metadata/v2.metadata.json", table.io()); + int expiredManifestListCount = 1; + ExpireSnapshots.Result expireResult = + actions() + .expireSnapshots(sourceTable) + .expireSnapshotId(staticTable.currentSnapshot().snapshotId()) + .execute(); + assertThat(expireResult.deletedManifestListsCount()).isEqualTo(expiredManifestListCount); + + // create 100 more snapshots + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + for (int i = 0; i < 100; i++) { + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(location); + } + sourceTable.refresh(); + + // each iteration generate 1 version file, 1 manifest list, 1 manifest and 1 data file + int totalIteration = 102; + // v1/v2/v3.metadata.json has been deleted in v104.metadata.json, and there is no way to find + // the first snapshot + // from the version file history + int missingVersionFile = 1; + // since first snapshot cannot be found, first data files will also be skipped + int missingDataFile = 1; + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(location, targetTableLocation()) + .execute(); + + checkFileNum( + totalIteration - missingVersionFile, + totalIteration - expiredManifestListCount, + totalIteration, + totalIteration * 4 - missingVersionFile - expiredManifestListCount - missingDataFile, + result); + } + + @TestTemplate + public void testRewritePathWithoutSnapshot() throws Exception { + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(tableLocation, newTableLocation()) + .endVersion("v1.metadata.json") + .execute(); + + // the only rebuilt file is v1.metadata.json since it contains no snapshot + checkFileNum(1, 0, 0, 1, result); + } + + @TestTemplate + public void testExpireSnapshotBeforeRewrite() throws Exception { + // expire one snapshot + actions().expireSnapshots(table).expireSnapshotId(table.currentSnapshot().parentId()).execute(); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .execute(); + + checkFileNum(4, 1, 2, 9, result); + } + + @TestTemplate + public void testRewritePathWithNonLiveEntry() throws Exception { + String location = newTableLocation(); + // first overwrite generate 1 manifest and 1 data file + // each subsequent overwrite on unpartitioned table generate 2 manifests and 1 data file + Table tableWith3Snaps = createTableWithSnapshots(location, 3, Maps.newHashMap(), "overwrite"); + + Snapshot oldest = SnapshotUtil.oldestAncestor(tableWith3Snaps); + String oldestDataFilePath = + Iterables.getOnlyElement( + SnapshotChanges.builderFor(tableWith3Snaps) + .snapshot(tableWith3Snaps.snapshot(oldest.snapshotId())) + .build() + .addedDataFiles()) + .location(); + String deletedDataFilePathInTargetLocation = + String.format("%sdata/%s", targetTableLocation(), fileName(oldestDataFilePath)); + + // expire the oldest snapshot and remove oldest DataFile + ExpireSnapshots.Result expireResult = + actions().expireSnapshots(tableWith3Snaps).expireSnapshotId(oldest.snapshotId()).execute(); + assertThat(expireResult) + .as("Should deleted 1 data files in root snapshot") + .extracting( + ExpireSnapshots.Result::deletedManifestListsCount, + ExpireSnapshots.Result::deletedManifestsCount, + ExpireSnapshots.Result::deletedDataFilesCount) + .contains(1L, 1L, 1L); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(tableWith3Snaps) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(tableWith3Snaps.location(), targetTableLocation()) + .execute(); + + // 5 version files include 1 table creation 3 overwrite and 1 snapshot expiration + // 3 overwrites generate 3 manifest list and 5 manifests with 3 data files + // snapshot expiration removed 1 of each + checkFileNum(5, 2, 4, 13, result); + + // copy the metadata files and data files + copyTableFiles(result); + + // expect deleted data file is excluded from rewrite and copy + List copiedDataFiles = + spark + .read() + .format("iceberg") + .load(targetTableLocation() + "#all_files") + .select("file_path") + .as(Encoders.STRING()) + .collectAsList(); + assertThat(copiedDataFiles).hasSize(2).doesNotContain(deletedDataFilePathInTargetLocation); + + // expect manifest entries still contain deleted entry + List copiedEntries = + spark + .read() + .format("iceberg") + .load(targetTableLocation() + "#all_entries") + .filter("status == 2") + .select("data_file.file_path") + .as(Encoders.STRING()) + .collectAsList(); + assertThat(copiedEntries).contains(deletedDataFilePathInTargetLocation); + } + + @TestTemplate + public void testStartSnapshotWithoutValidSnapshot() throws Exception { + // expire one snapshot + actions().expireSnapshots(table).expireSnapshotId(table.currentSnapshot().parentId()).execute(); + + assertThat(table.snapshots()).hasSize(1); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .stagingLocation(stagingLocation()) + .startVersion("v2.metadata.json") + .execute(); + + // 2 metadata.json, 1 manifest list file, 1 manifest files + checkFileNum(2, 1, 1, 5, result); + } + + @TestTemplate + public void testMoveTheVersionExpireSnapshot() throws Exception { + // expire one snapshot + actions().expireSnapshots(table).expireSnapshotId(table.currentSnapshot().parentId()).execute(); + + // only move version v4, which is the version generated by snapshot expiration + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), targetTableLocation()) + .stagingLocation(stagingLocation()) + .startVersion("v3.metadata.json") + .execute(); + + // only v4.metadata.json needs to move + checkFileNum(1, 0, 0, 1, result); + } + + @TestTemplate + public void testMoveVersionWithInvalidSnapshots() { + // expire one snapshot + actions().expireSnapshots(table).expireSnapshotId(table.currentSnapshot().parentId()).execute(); + + assertThatThrownBy( + () -> + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), newTableLocation()) + .stagingLocation(stagingLocation()) + .endVersion("v3.metadata.json") + .execute()) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining( + "Unable to build the manifest files dataframe. The end version in use may contain invalid snapshots. " + + "Please choose an earlier version without invalid snapshots."); + } + + @TestTemplate + public void testRollBack() throws Exception { + long secondSnapshotId = table.currentSnapshot().snapshotId(); + + // roll back to the first snapshot(v2) + table.manageSnapshots().setCurrentSnapshot(table.currentSnapshot().parentId()).commit(); + + // add a new snapshot + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(table.location()); + + table.refresh(); + + // roll back to the second snapshot(v3) + table.manageSnapshots().setCurrentSnapshot(secondSnapshotId).commit(); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), newTableLocation()) + .stagingLocation(stagingLocation()) + .execute(); + checkFileNum(6, 3, 3, 15, result); + } + + @TestTemplate + public void testWriteAuditPublish() throws Exception { + // enable WAP + table.updateProperties().set(TableProperties.WRITE_AUDIT_PUBLISH_ENABLED, "true").commit(); + spark.conf().set("spark.wap.id", "1"); + + // add a new snapshot without changing the current snapshot of the table + List records = + Lists.newArrayList(new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA")); + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + df.select("c1", "c2", "c3").write().format("iceberg").mode("append").save(table.location()); + + table.refresh(); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), newTableLocation()) + .stagingLocation(stagingLocation()) + .execute(); + + // There are 3 snapshots in total, although the current snapshot is the second one. + checkFileNum(5, 3, 3, 14, result); + } + + @TestTemplate + public void testSchemaChange() throws Exception { + // change the schema + table.updateSchema().addColumn("c4", Types.StringType.get()).commit(); + + // copy table + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(table.location(), newTableLocation()) + .stagingLocation(stagingLocation()) + .execute(); + + // check the result + checkFileNum(4, 2, 2, 10, result); + } + + @TestTemplate + public void testSnapshotIdInheritanceEnabled() throws Exception { + String sourceTableLocation = newTableLocation(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, "true"); + + Table sourceTable = createTableWithSnapshots(sourceTableLocation, 2, properties); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(sourceTableLocation, targetTableLocation()) + .execute(); + + checkFileNum(3, 2, 2, 9, result); + } + + @TestTemplate + public void testMetadataCompression() throws Exception { + String sourceTableLocation = newTableLocation(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + Table sourceTable = createTableWithSnapshots(sourceTableLocation, 2, properties); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceTableLocation, targetTableLocation()) + .endVersion("v2.gz.metadata.json") + .execute(); + + checkFileNum(2, 1, 1, 5, result); + + result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceTableLocation, targetTableLocation()) + .startVersion("v1.gz.metadata.json") + .execute(); + + checkFileNum(2, 2, 2, 8, result); + } + + @TestTemplate + public void testInvalidArgs() { + RewriteTablePath actions = actions().rewriteTablePath(table); + + assertThatThrownBy(() -> actions.rewriteLocationPrefix("", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Source prefix('') cannot be empty"); + + assertThatThrownBy(() -> actions.rewriteLocationPrefix(null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Source prefix('null') cannot be empty"); + + assertThatThrownBy(() -> actions.stagingLocation("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Staging location('') cannot be empty"); + + assertThatThrownBy(() -> actions.stagingLocation(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Staging location('null') cannot be empty"); + + assertThatThrownBy(() -> actions.startVersion(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Start version('null') cannot be empty"); + + assertThatThrownBy(() -> actions.endVersion(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End version(' ') cannot be empty"); + + assertThatThrownBy(() -> actions.endVersion(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End version('null') cannot be empty"); + } + + @TestTemplate + public void testTableWithManyPartitionStatisticFile() throws IOException { + String sourceTableLocation = newTableLocation(); + Map properties = + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + String tableName = String.format("v%stblwithPartStats", formatVersion); + Table sourceTable = + createMetastoreTable(sourceTableLocation, properties, "default", tableName, 0, "c1"); + + int iterations = 10; + for (int i = 0; i < iterations; i++) { + sql("insert into hive.default.%s values (%s, 'AAAAAAAAAA', 'AAAA')", tableName, i); + sourceTable.refresh(); + actions().computePartitionStats(sourceTable).execute(); + } + + sourceTable.refresh(); + assertThat(sourceTable.partitionStatisticsFiles()).hasSize(iterations); + + String targetTableLocation = targetTableLocation(); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceTableLocation, targetTableLocation) + .execute(); + checkFileNum( + iterations * 2 + 1, iterations, iterations, 0, iterations, iterations * 6 + 1, result); + + findAndAssertFileInFileList( + result, "partition-stats", sourceTableLocation, targetTableLocation); + } + + @TestTemplate + public void testTableWithManyStatisticFiles() throws IOException { + String sourceTableLocation = newTableLocation(); + Map properties = + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + String tableName = String.format("v%stblwithmanystats", formatVersion); + Table sourceTable = + createMetastoreTable(sourceTableLocation, properties, "default", tableName, 0); + + int iterations = 10; + for (int i = 0; i < iterations; i++) { + sql("insert into hive.default.%s values (%s, 'AAAAAAAAAA', 'AAAA')", tableName, i); + sourceTable.refresh(); + actions().computeTableStats(sourceTable).execute(); + } + + sourceTable.refresh(); + assertThat(sourceTable.statisticsFiles()).hasSize(iterations); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceTableLocation, targetTableLocation()) + .execute(); + + checkFileNum( + iterations * 2 + 1, iterations, iterations, iterations, iterations * 6 + 1, result); + } + + @TestTemplate + public void testStatisticsFileSourcePath() throws IOException { + String sourceTableLocation = newTableLocation(); + Map properties = + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + String tableName = String.format("v%stblwithstats", formatVersion); + Table sourceTable = + createMetastoreTable(sourceTableLocation, properties, "default", tableName, 1); + + // Compute table statistics to generate a .stats file + actions().computeTableStats(sourceTable).execute(); + + assertThat(sourceTable.statisticsFiles()) + .as("Should include 1 statistics file after compute stats") + .hasSize(1); + + String targetTableLocation = targetTableLocation(); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceTableLocation, targetTableLocation) + .execute(); + + checkFileNum(3, 1, 1, 1, 7, result); + + findAndAssertFileInFileList(result, ".stats", sourceTableLocation, targetTableLocation); + } + + @TestTemplate + public void testMetadataCompressionWithMetastoreTable() throws Exception { + Map properties = Maps.newHashMap(); + properties.put(TableProperties.METADATA_COMPRESSION, "gzip"); + Table sourceTable = + createMetastoreTable( + newTableLocation(), + properties, + "default", + String.format("v%sMetadataCompression", formatVersion), + 2); + + TableMetadata currentMetadata = currentMetadata(sourceTable); + + // set the second version as the endVersion + String endVersion = fileName(currentMetadata.previousFiles().get(1).file()); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(newTableLocation(), targetTableLocation()) + .endVersion(endVersion) + .execute(); + + checkFileNum(2, 1, 1, 5, result); + + // set the first version as the lastCopiedVersion + String firstVersion = fileName(currentMetadata.previousFiles().get(0).file()); + result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(newTableLocation(), targetTableLocation()) + .startVersion(firstVersion) + .execute(); + + checkFileNum(2, 2, 2, 8, result); + } + + // Metastore table tests + @TestTemplate + public void testMetadataLocationChange() throws Exception { + String tableName = String.format("v%stblWithLocation", formatVersion); + Table sourceTable = + createMetastoreTable(newTableLocation(), Maps.newHashMap(), "default", tableName, 1); + String metadataFilePath = currentMetadata(sourceTable).metadataFileLocation(); + + String newMetadataDir = "new-metadata-dir"; + sourceTable + .updateProperties() + .set(TableProperties.WRITE_METADATA_LOCATION, newTableLocation() + newMetadataDir) + .commit(); + + sql("insert into hive.default.%s values (1, 'AAAAAAAAAA', 'AAAA')", tableName); + sourceTable.refresh(); + + // copy table + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(newTableLocation(), targetTableLocation()) + .execute(); + + checkFileNum(4, 2, 2, 10, result); + + // pick up a version from the old metadata dir as the end version + RewriteTablePath.Result result1 = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(newTableLocation(), targetTableLocation()) + .endVersion(fileName(metadataFilePath)) + .execute(); + + checkFileNum(2, 1, 1, 5, result1); + + // pick up a version from the old metadata dir as the last copied version + RewriteTablePath.Result result2 = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(newTableLocation(), targetTableLocation()) + .startVersion(fileName(metadataFilePath)) + .execute(); + + checkFileNum(2, 1, 1, 5, result2); + } + + @TestTemplate + public void testDeleteFrom() throws Exception { + Map properties = + ImmutableMap.of( + TableProperties.FORMAT_VERSION, + String.valueOf(formatVersion), + "write.delete.mode", + "merge-on-read"); + String tableName = String.format("v%stbl", formatVersion); + Table sourceTable = + createMetastoreTable(newTableLocation(), properties, "default", tableName, 0); + // ingest data + List records = + Lists.newArrayList( + new ThreeColumnRecord(1, "AAAAAAAAAA", "AAAA"), + new ThreeColumnRecord(2, "AAAAAAAAAA", "AAAA"), + new ThreeColumnRecord(3, "AAAAAAAAAA", "AAAA")); + + Dataset df = spark.createDataFrame(records, ThreeColumnRecord.class).coalesce(1); + + df.select("c1", "c2", "c3") + .write() + .format("iceberg") + .mode("append") + .saveAsTable("hive.default." + tableName); + sourceTable.refresh(); + + // generate position delete files + spark.sql(String.format("delete from hive.default.%s where c1 = 1", tableName)); + sourceTable.refresh(); + + List originalData = + rowsToJava( + spark + .read() + .format("iceberg") + .load("hive.default." + tableName) + .sort("c1", "c2", "c3") + .collectAsList()); + // two rows + assertThat(originalData).hasSize(2); + + // copy table and check the results + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(newTableLocation(), targetTableLocation()) + .execute(); + + checkFileNum(3, 2, 2, 9, result); + // one data and one metadata file + copyTableFiles(result); + + // register table + String metadataLocation = currentMetadata(sourceTable).metadataFileLocation(); + String versionFile = fileName(metadataLocation); + String targetTableName = String.format("copiedV%sTable", formatVersion); + TableIdentifier tableIdentifier = TableIdentifier.of("default", targetTableName); + catalog.registerTable(tableIdentifier, targetTableLocation() + "/metadata/" + versionFile); + + List copiedData = + rowsToJava( + spark + .read() + .format("iceberg") + .load("hive.default." + targetTableName) + .sort("c1", "c2", "c3") + .collectAsList()); + + assertEquals("Rows must match", originalData, copiedData); + } + + @TestTemplate + public void testKryoDeserializeBroadcastValues() { + sparkContext.getConf().set("spark.serializer", "org.apache.spark.serializer.KryoSerializer"); + RewriteTablePathSparkAction action = + (RewriteTablePathSparkAction) actions().rewriteTablePath(table); + Broadcast

    tableBroadcast = action.tableBroadcast(); + // force deserializing broadcast values + removeBroadcastValuesFromLocalBlockManager(tableBroadcast.id()); + assertThat(tableBroadcast.getValue().uuid()).isEqualTo(table.uuid()); + } + + @TestTemplate + public void testNestedDirectoryStructurePreservation() throws Exception { + assumeThat(formatVersion) + .as("Can't add multiple DVs for the same data file in v3") + .isEqualTo(2); + String sourceTableLocation = newTableLocation(); + Table sourceTable = createTableWithSnapshots(sourceTableLocation, 1); + + // Create position delete files with same names in different nested directories + // This simulates the scenario tested in + // TestRewriteTablePathUtil.testStagingPathPreservesDirectoryStructure + SnapshotChanges sourceChanges = SnapshotChanges.builderFor(sourceTable).build(); + List> deletes1 = + Lists.newArrayList( + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); + + List> deletes2 = + Lists.newArrayList( + Pair.of(sourceChanges.addedDataFiles().iterator().next().location(), 0L)); + + // Create delete files with same name in different nested paths (hash1/ and hash2/) + File file1 = + new File(removePrefix(sourceTable.location() + "/data/hash1/delete_0_0_0.parquet")); + File file2 = + new File(removePrefix(sourceTable.location() + "/data/hash2/delete_0_0_0.parquet")); + + DeleteFile positionDeletes1 = + FileHelpers.writeDeleteFile( + sourceTable, + sourceTable.io().newOutputFile(file1.toURI().toString()), + deletes1, + formatVersion) + .first(); + + DeleteFile positionDeletes2 = + FileHelpers.writeDeleteFile( + sourceTable, + sourceTable.io().newOutputFile(file2.toURI().toString()), + deletes2, + formatVersion) + .first(); + + sourceTable.newRowDelta().addDeletes(positionDeletes1).commit(); + sourceTable.newRowDelta().addDeletes(positionDeletes2).commit(); + + // Perform rewrite with staging location to test directory structure preservation + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .stagingLocation(stagingLocation()) + .rewriteLocationPrefix(sourceTableLocation, targetTableLocation()) + .execute(); + + // Copy the files and verify structure is preserved + copyTableFiles(result); + + // Read the file paths from the rewritten result to verify directory structure + List> filePaths = readPathPairList(result.fileListLocation()); + + // Find the delete files in the result + List> deleteFilePaths = + filePaths.stream() + .filter(pair -> pair._2().contains("delete_0_0_0.parquet")) + .collect(Collectors.toList()); + + // Should have 2 delete files with different paths + assertThat(deleteFilePaths).hasSize(2); + + // Verify that the directory structure is preserved in target paths + assertThat(deleteFilePaths) + .anyMatch(pair -> pair._2().contains("/hash1/delete_0_0_0.parquet")) + .anyMatch(pair -> pair._2().contains("/hash2/delete_0_0_0.parquet")); + + // Verify that the files have different target paths (no conflicts) + String targetPath1 = deleteFilePaths.get(0)._2(); + String targetPath2 = deleteFilePaths.get(1)._2(); + assertThat(targetPath1).isNotEqualTo(targetPath2); + + // Verify both target paths start with the target table location + assertThat(targetPath1).startsWith(targetTableLocation()); + assertThat(targetPath2).startsWith(targetTableLocation()); + } + + @TestTemplate + public void testRewritePathWithoutCreateFileList() throws Exception { + String targetTableLocation = targetTableLocation(); + + RewriteTablePath.Result result = + actions() + .rewriteTablePath(table) + .rewriteLocationPrefix(tableLocation, targetTableLocation) + .createFileList(false) // Disable file list creation + .execute(); + + assertThat(result.latestVersion()).isEqualTo("v3.metadata.json"); + + assertThat(result.fileListLocation()) + .as("File list location should not be set when createFileList is false") + .isEqualTo(NOT_APPLICABLE); + } + + @TestTemplate + public void testRewritePathWithExecutorService() throws Exception { + String sourceLocation = newTableLocation(); + Table sourceTable = createTableWithSnapshots(sourceLocation, 50); + + ExecutorService service = Executors.newFixedThreadPool(4); + try { + sourceTable.refresh(); + RewriteTablePath.Result result = + actions() + .rewriteTablePath(sourceTable) + .rewriteLocationPrefix(sourceLocation, targetTableLocation()) + .startVersion("v1.metadata.json") + .executeWith(service) + .execute(); + + checkFileNum(50, 50, 50, 200, result); + } finally { + service.shutdown(); + } + } + + protected void checkFileNum( + int versionFileCount, + int manifestListCount, + int manifestFileCount, + int totalCount, + RewriteTablePath.Result result) { + checkFileNum(versionFileCount, manifestListCount, manifestFileCount, 0, 0, totalCount, result); + } + + protected void checkFileNum( + int versionFileCount, + int manifestListCount, + int manifestFileCount, + int statisticsFileCount, + int totalCount, + RewriteTablePath.Result result) { + checkFileNum( + versionFileCount, + manifestListCount, + manifestFileCount, + statisticsFileCount, + 0, + totalCount, + result); + } + + protected void checkFileNum( + int versionFileCount, + int manifestListCount, + int manifestFileCount, + int statisticsFileCount, + int partitionFileCount, + int totalCount, + RewriteTablePath.Result result) { + List filesToMove = + spark + .read() + .format("text") + .load(result.fileListLocation()) + .as(Encoders.STRING()) + .collectAsList(); + Predicate isManifest = + f -> + (f.contains("optimized-m-") && f.endsWith(".avro")) + || f.endsWith("-m0.avro") + || f.endsWith("-m1.avro"); + Predicate isManifestList = f -> f.contains("snap-") && f.endsWith(".avro"); + Predicate isMetadataJSON = f -> f.endsWith(".metadata.json"); + + assertThat(filesToMove.stream().filter(isMetadataJSON)) + .as("Wrong rebuilt version file count") + .hasSize(versionFileCount); + assertThat(filesToMove.stream().filter(isManifestList)) + .as("Wrong rebuilt Manifest list file count") + .hasSize(manifestListCount); + assertThat(filesToMove.stream().filter(isManifest)) + .as("Wrong rebuilt Manifest file file count") + .hasSize(manifestFileCount); + assertThat(filesToMove.stream().filter(f -> f.endsWith(".stats"))) + .as("Wrong rebuilt Statistic file count") + .hasSize(statisticsFileCount); + assertThat(filesToMove.stream().filter(f -> f.contains("partition-stats"))) + .as("Wrong rebuilt Partition Statistic file count") + .hasSize(partitionFileCount); + assertThat(filesToMove).as("Wrong total file count").hasSize(totalCount); + } + + protected String newTableLocation() throws IOException { + return toAbsolute(newTableDir); + } + + protected String targetTableLocation() throws IOException { + return toAbsolute(targetTableDir); + } + + protected String stagingLocation() throws IOException { + return toAbsolute(staging); + } + + protected String toAbsolute(Path relative) { + return relative.toFile().toURI().toString(); + } + + private void copyTableFiles(RewriteTablePath.Result result) throws Exception { + List> filesToMove = readPathPairList(result.fileListLocation()); + + for (Tuple2 pathPair : filesToMove) { + FileUtils.copyFile(new File(URI.create(pathPair._1())), new File(URI.create(pathPair._2()))); + } + } + + private String removePrefix(String path) { + return path.substring(path.lastIndexOf(":") + 1); + } + + protected Table newStaticTable(String metadataFileLocation, FileIO io) { + StaticTableOperations ops = new StaticTableOperations(metadataFileLocation, io); + return new BaseTable(ops, metadataFileLocation); + } + + private List> readPathPairList(String path) { + Encoder> encoder = Encoders.tuple(Encoders.STRING(), Encoders.STRING()); + return spark + .read() + .format("csv") + .schema(encoder.schema()) + .load(path) + .as(encoder) + .collectAsList(); + } + + private Table createMetastoreTable( + String location, + Map properties, + String namespace, + String tableName, + int snapshotNumber) { + return createMetastoreTable(location, properties, namespace, tableName, snapshotNumber, null); + } + + private Table createMetastoreTable( + String location, + Map properties, + String namespace, + String tableName, + int snapshotNumber, + String partitionColumn) { + spark.conf().set("spark.sql.catalog.hive", SparkCatalog.class.getName()); + spark.conf().set("spark.sql.catalog.hive.type", "hive"); + spark.conf().set("spark.sql.catalog.hive.default-namespace", "default"); + spark.conf().set("spark.sql.catalog.hive.cache-enabled", "false"); + + // Generate and execute CREATE TABLE SQL + String createTableSQL = + generateCreateTableSQL(location, properties, namespace, tableName, partitionColumn); + sql(createTableSQL); + + for (int i = 0; i < snapshotNumber; i++) { + sql("insert into hive.%s.%s values (%s, 'AAAAAAAAAA', 'AAAA')", namespace, tableName, i); + } + return catalog.loadTable(TableIdentifier.of(namespace, tableName)); + } + + /** + * Generates SQL statement for creating an Iceberg table + * + * @param location location the storage location path for the table, can be empty + * @param properties key-value pairs of table properties for setting table metadata + * @param namespace the namespace (database name) + * @param tableName the name of the table to be created + * @param partitionColumn the partition column name, must be one of c1, c2, or c3; can be null or + * empty string for non-partitioned table + * @return CREATE TABLE SQL statement string + */ + private String generateCreateTableSQL( + String location, + Map properties, + String namespace, + String tableName, + String partitionColumn) { + StringBuilder propertiesStr = new StringBuilder(); + properties.forEach((k, v) -> propertiesStr.append("'" + k + "'='" + v + "',")); + String tblProperties = + propertiesStr.substring(0, propertiesStr.length() > 0 ? propertiesStr.length() - 1 : 0); + + sql("DROP TABLE IF EXISTS hive.%s.%s", namespace, tableName); + + StringBuilder createTableSql = new StringBuilder(); + createTableSql + .append("CREATE TABLE hive.") + .append(namespace) + .append(".") + .append(tableName) + .append(" (c1 bigint, c2 string, c3 string)"); + + if (partitionColumn != null && !partitionColumn.isEmpty()) { + createTableSql.append(" USING iceberg PARTITIONED BY (").append(partitionColumn).append(")"); + } else { + createTableSql.append(" USING iceberg"); + } + + if (!location.isEmpty()) { + createTableSql.append(" LOCATION '").append(location).append("'"); + } + + if (!tblProperties.isEmpty()) { + createTableSql.append(" TBLPROPERTIES (").append(tblProperties).append(")"); + } + + return createTableSql.toString(); + } + + private static String fileName(String path) { + String filename = path; + int lastIndex = path.lastIndexOf(File.separator); + if (lastIndex != -1) { + filename = path.substring(lastIndex + 1); + } + return filename; + } + + private TableMetadata currentMetadata(Table tbl) { + return ((HasTableOperations) tbl).operations().current(); + } + + private List rows(String location) { + return rowsToJava(spark.read().format("iceberg").load(location).collectAsList()); + } + + private List rowsSorted(String location, String sortCol) { + return rowsToJava(spark.read().format("iceberg").load(location).sort(sortCol).collectAsList()); + } + + private PositionDelete positionDelete( + Schema tableSchema, CharSequence path, Long position, Object... values) { + PositionDelete posDelete = PositionDelete.create(); + GenericRecord nested = GenericRecord.create(tableSchema); + for (int i = 0; i < values.length; i++) { + nested.set(i, values[i]); + } + posDelete.set(path, position, nested); + return posDelete; + } + + private void removeBroadcastValuesFromLocalBlockManager(long id) { + BlockId blockId = new BroadcastBlockId(id, ""); + SparkEnv env = SparkEnv.get(); + env.broadcastManager().cachedValues().clear(); + BlockManager blockManager = env.blockManager(); + BlockInfoManager blockInfoManager = blockManager.blockInfoManager(); + blockInfoManager.lockForWriting(blockId, true); + blockInfoManager.removeBlock(blockId); + blockManager.memoryStore().remove(blockId); + } + + private void findAndAssertFileInFileList( + RewriteTablePath.Result result, + String fileIdentifier, + String sourceTableLocation, + String targetTableLocation) { + + List> filesToMove = readPathPairList(result.fileListLocation()); + + // Find the file path pair that contains the specified file identifier + Tuple2 filePathPair = + filesToMove.stream() + .filter(pair -> pair._1().contains(fileIdentifier)) + .findFirst() + .orElse(null); + + // Assert that the file was found in the list + assertThat(filePathPair).as("Should find " + fileIdentifier + " file in file list").isNotNull(); + + // Validate source path: should point to source table location, contain metadata, and not + // staging + assertThat(filePathPair._1()) + .as(fileIdentifier + " source should point to source table location and NOT staging") + .startsWith(sourceTableLocation) + .contains("/metadata/") + .doesNotContain("staging"); + + // Validate target path: should point to target table location + assertThat(filePathPair._2()) + .as(fileIdentifier + " target should point to target table location") + .startsWith(targetTableLocation); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java new file mode 100644 index 000000000000..9fac633e75ac --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSnapshotTableAction.java @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_HADOOP; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.CatalogTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSnapshotTableAction extends CatalogTestBase { + private static final String SOURCE_NAME = "spark_catalog.default.source"; + private static final String SOURCE = "source"; + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s PURGE", SOURCE_NAME); + } + + @TestTemplate + public void testSnapshotWithParallelTasks() throws IOException { + String location = Files.createTempDirectory(temp, "junit").toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, location); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + + AtomicInteger snapshotThreadsIndex = new AtomicInteger(0); + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .executeWith( + Executors.newFixedThreadPool( + 4, + runnable -> { + Thread thread = new Thread(runnable); + thread.setName("table-snapshot-" + snapshotThreadsIndex.getAndIncrement()); + thread.setDaemon(true); + return thread; + })) + .execute(); + assertThat(snapshotThreadsIndex.get()).isEqualTo(2); + } + + @TestTemplate + public void testSnapshotWithOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(actualSourceLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith( + "The snapshot table location cannot be same as the source table location."); + + String destAsSubdirectory = actualSourceLocation + "/nested"; + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(destAsSubdirectory) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + + String parentLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - ("/" + SOURCE).length()); + assertThatThrownBy( + () -> + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(parentLocation) + .execute()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create a snapshot at location"); + } + + @TestTemplate + public void testSnapshotWithNonOverlappingLocation() throws IOException { + // Hadoop Catalogs do not Support Custom Table Locations + String catalogType = catalogConfig.get(ICEBERG_CATALOG_TYPE); + assumeThat(catalogType).isNotEqualTo(ICEBERG_CATALOG_TYPE_HADOOP); + + String sourceLocation = + Files.createTempDirectory(temp, "junit").resolve(SOURCE).toFile().toString(); + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING parquet LOCATION '%s'", + SOURCE_NAME, sourceLocation); + sql("INSERT INTO TABLE %s VALUES (1, 'a')", SOURCE_NAME); + sql("INSERT INTO TABLE %s VALUES (2, 'b')", SOURCE_NAME); + String actualSourceLocation = + spark + .sql(String.format("DESCRIBE EXTENDED %s", SOURCE_NAME)) + .filter("col_name = 'Location'") + .select("data_type") + .first() + .getString(0); + + String validDestLocation = + actualSourceLocation.substring(0, actualSourceLocation.length() - SOURCE.length()) + + "newDestination"; + SparkActions.get() + .snapshotTable(SOURCE_NAME) + .as(tableName) + .tableLocation(validDestLocation) + .execute(); + assertThat(sql("SELECT * FROM %s", tableName)).hasSize(2); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSparkFileRewriteRunners.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSparkFileRewriteRunners.java new file mode 100644 index 000000000000..072b8e8a7eeb --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSparkFileRewriteRunners.java @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types.IntegerType; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.types.Types.StringType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class TestSparkFileRewriteRunners extends TestBase { + + private static final TableIdentifier TABLE_IDENT = TableIdentifier.of("default", "tbl"); + private static final Schema SCHEMA = + new Schema( + NestedField.required(1, "id", IntegerType.get()), + NestedField.required(2, "dep", StringType.get())); + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).identity("dep").build(); + private static final SortOrder SORT_ORDER = SortOrder.builderFor(SCHEMA).asc("id").build(); + + @AfterEach + public void removeTable() { + catalog.dropTable(TABLE_IDENT); + } + + @Test + public void testInvalidConstructorUsagesSortData() { + Table table = catalog.createTable(TABLE_IDENT, SCHEMA); + + assertThatThrownBy(() -> new SparkSortFileRewriteRunner(spark, table)) + .hasMessageContaining("Cannot sort data without a valid sort order") + .hasMessageContaining("is unsorted and no sort order is provided"); + + assertThatThrownBy(() -> new SparkSortFileRewriteRunner(spark, table, null)) + .hasMessageContaining("Cannot sort data without a valid sort order") + .hasMessageContaining("the provided sort order is null or empty"); + + assertThatThrownBy(() -> new SparkSortFileRewriteRunner(spark, table, SortOrder.unsorted())) + .hasMessageContaining("Cannot sort data without a valid sort order") + .hasMessageContaining("the provided sort order is null or empty"); + } + + @Test + public void testInvalidConstructorUsagesZOrderData() { + Table table = catalog.createTable(TABLE_IDENT, SCHEMA, SPEC); + + assertThatThrownBy(() -> new SparkZOrderFileRewriteRunner(spark, table, null)) + .hasMessageContaining("Cannot ZOrder when no columns are specified"); + + assertThatThrownBy(() -> new SparkZOrderFileRewriteRunner(spark, table, ImmutableList.of())) + .hasMessageContaining("Cannot ZOrder when no columns are specified"); + + assertThatThrownBy( + () -> new SparkZOrderFileRewriteRunner(spark, table, ImmutableList.of("dep"))) + .hasMessageContaining("Cannot ZOrder") + .hasMessageContaining("all columns provided were identity partition columns"); + + assertThatThrownBy( + () -> new SparkZOrderFileRewriteRunner(spark, table, ImmutableList.of("DeP"))) + .hasMessageContaining("Cannot ZOrder") + .hasMessageContaining("all columns provided were identity partition columns"); + } + + @Test + public void testSortDataValidOptions() { + Table table = catalog.createTable(TABLE_IDENT, SCHEMA); + SparkSortFileRewriteRunner rewriter = new SparkSortFileRewriteRunner(spark, table, SORT_ORDER); + + assertThat(rewriter.validOptions()) + .as("Rewriter must report all supported options") + .containsExactlyInAnyOrder(SparkShufflingFileRewriteRunner.SHUFFLE_PARTITIONS_PER_FILE); + } + + @Test + public void testZOrderDataValidOptions() { + Table table = catalog.createTable(TABLE_IDENT, SCHEMA); + ImmutableList zOrderCols = ImmutableList.of("id"); + SparkZOrderFileRewriteRunner rewriter = + new SparkZOrderFileRewriteRunner(spark, table, zOrderCols); + + assertThat(rewriter.validOptions()) + .as("Rewriter must report all supported options") + .containsExactlyInAnyOrder( + SparkZOrderFileRewriteRunner.SHUFFLE_PARTITIONS_PER_FILE, + SparkZOrderFileRewriteRunner.MAX_OUTPUT_SIZE, + SparkZOrderFileRewriteRunner.VAR_LENGTH_CONTRIBUTION); + } + + @Test + public void testInvalidValuesForZOrderDataOptions() { + Table table = catalog.createTable(TABLE_IDENT, SCHEMA); + ImmutableList zOrderCols = ImmutableList.of("id"); + SparkZOrderFileRewriteRunner rewriter = + new SparkZOrderFileRewriteRunner(spark, table, zOrderCols); + + Map invalidMaxOutputOptions = + ImmutableMap.of(SparkZOrderFileRewriteRunner.MAX_OUTPUT_SIZE, "0"); + assertThatThrownBy(() -> rewriter.init(invalidMaxOutputOptions)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot have the interleaved ZOrder value use less than 1 byte") + .hasMessageContaining("'max-output-size' was set to 0"); + + Map invalidVarLengthContributionOptions = + ImmutableMap.of(SparkZOrderFileRewriteRunner.VAR_LENGTH_CONTRIBUTION, "0"); + assertThatThrownBy(() -> rewriter.init(invalidVarLengthContributionOptions)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot use less than 1 byte for variable length types with ZOrder") + .hasMessageContaining("'var-length-contribution' was set to 0"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSparkShufflingDataRewritePlanner.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSparkShufflingDataRewritePlanner.java new file mode 100644 index 000000000000..9fcf9f5ec51a --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/actions/TestSparkShufflingDataRewritePlanner.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.actions; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.RewriteDataFiles; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types.IntegerType; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.types.Types.StringType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class TestSparkShufflingDataRewritePlanner extends TestBase { + + private static final TableIdentifier TABLE_IDENT = TableIdentifier.of("default", "tbl"); + private static final Schema SCHEMA = + new Schema( + NestedField.required(1, "id", IntegerType.get()), + NestedField.required(2, "dep", StringType.get())); + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).identity("dep").build(); + private static final SortOrder SORT_ORDER = SortOrder.builderFor(SCHEMA).asc("id").build(); + + @AfterEach + public void removeTable() { + catalog.dropTable(TABLE_IDENT); + } + + @Test + void testSparkShufflingDataRewritePlannerValidOptions() { + Table table = catalog.createTable(TABLE_IDENT, SCHEMA); + SparkShufflingDataRewritePlanner planner = + new SparkShufflingDataRewritePlanner( + table, Expressions.alwaysTrue(), null, false /* caseSensitive */); + + assertThat(planner.validOptions()) + .as("Planner must report all supported options") + .isEqualTo( + ImmutableSet.of( + SparkShufflingDataRewritePlanner.COMPRESSION_FACTOR, + SparkShufflingDataRewritePlanner.TARGET_FILE_SIZE_BYTES, + SparkShufflingDataRewritePlanner.MIN_FILE_SIZE_BYTES, + SparkShufflingDataRewritePlanner.MAX_FILE_SIZE_BYTES, + SparkShufflingDataRewritePlanner.MIN_INPUT_FILES, + SparkShufflingDataRewritePlanner.REWRITE_ALL, + SparkShufflingDataRewritePlanner.MAX_FILE_GROUP_SIZE_BYTES, + SparkShufflingDataRewritePlanner.DELETE_FILE_THRESHOLD, + SparkShufflingDataRewritePlanner.DELETE_RATIO_THRESHOLD, + RewriteDataFiles.REWRITE_JOB_ORDER, + SparkShufflingDataRewritePlanner.MAX_FILES_TO_REWRITE)); + } + + @Test + void testInvalidValuesSparkShufflingDataRewritePlannerOptions() { + Table table = catalog.createTable(TABLE_IDENT, SCHEMA); + SparkShufflingDataRewritePlanner planner = + new SparkShufflingDataRewritePlanner( + table, Expressions.alwaysTrue(), null, false /* caseSensitive */); + + Map invalidCompressionFactorOptions = + ImmutableMap.of(SparkShufflingDataRewritePlanner.COMPRESSION_FACTOR, "0"); + assertThatThrownBy(() -> planner.init(invalidCompressionFactorOptions)) + .hasMessageContaining("'compression-factor' is set to 0.0 but must be > 0"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java new file mode 100644 index 000000000000..8ce60f6275e5 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/AvroDataTestBase.java @@ -0,0 +1,728 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.types.Types.ListType; +import org.apache.iceberg.types.Types.LongType; +import org.apache.iceberg.types.Types.MapType; +import org.apache.iceberg.types.Types.StructType; +import org.apache.iceberg.util.DateTimeUtil; +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.FieldSource; +import org.junit.jupiter.params.provider.MethodSource; + +public abstract class AvroDataTestBase { + + private static final long FIRST_ROW_ID = 2_000L; + protected static final Map ID_TO_CONSTANT = + Map.of( + MetadataColumns.ROW_ID.fieldId(), + FIRST_ROW_ID, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId(), + 34L); + + protected abstract void writeAndValidate(Schema schema) throws IOException; + + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + throw new UnsupportedEncodingException( + "Cannot run test, writeAndValidate(Schema, Schema) is not implemented"); + } + + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema, List records) + throws IOException { + throw new UnsupportedEncodingException( + "Cannot run test, writeAndValidate(Schema, Schema, List) is not implemented"); + } + + protected boolean supportsDefaultValues() { + return false; + } + + protected boolean supportsNestedTypes() { + return true; + } + + protected boolean supportsRowLineage() { + return false; + } + + protected static final StructType SUPPORTED_PRIMITIVES = + StructType.of( + required(100, "id", LongType.get()), + optional(101, "data", Types.StringType.get()), + required(102, "b", Types.BooleanType.get()), + optional(103, "i", Types.IntegerType.get()), + required(104, "l", LongType.get()), + optional(105, "f", Types.FloatType.get()), + required(106, "d", Types.DoubleType.get()), + optional(107, "date", Types.DateType.get()), + required(108, "ts", Types.TimestampType.withZone()), + required(109, "ts_without_zone", Types.TimestampType.withoutZone()), + required(110, "s", Types.StringType.get()), + required(111, "uuid", Types.UUIDType.get()), + required(112, "fixed", Types.FixedType.ofLength(7)), + optional(113, "bytes", Types.BinaryType.get()), + required(114, "dec_9_0", Types.DecimalType.of(9, 0)), // int encoded + required(115, "dec_11_2", Types.DecimalType.of(11, 2)), // long encoded + required(116, "dec_20_5", Types.DecimalType.of(20, 5)), // requires padding + required(117, "dec_38_10", Types.DecimalType.of(38, 10)), // Spark's maximum precision + optional(118, "unk", Types.UnknownType.get())); + + @TempDir protected Path temp; + + private static final Type[] SIMPLE_TYPES = + new Type[] { + Types.UnknownType.get(), + Types.BooleanType.get(), + Types.IntegerType.get(), + LongType.get(), + Types.FloatType.get(), + Types.DoubleType.get(), + Types.DateType.get(), + Types.TimeType.get(), + Types.TimestampType.withZone(), + Types.TimestampType.withoutZone(), + Types.TimestampNanoType.withZone(), + Types.TimestampNanoType.withoutZone(), + Types.StringType.get(), + Types.FixedType.ofLength(7), + Types.BinaryType.get(), + Types.DecimalType.of(9, 0), + Types.DecimalType.of(11, 2), + Types.DecimalType.of(38, 10), + Types.VariantType.get(), + Types.GeometryType.crs84(), + Types.GeographyType.crs84(), + }; + + protected boolean supportsTime() { + return false; + } + + protected boolean supportsTimestampNanos() { + return false; + } + + protected boolean supportsVariant() { + return false; + } + + protected boolean supportsGeospatial() { + return false; + } + + @ParameterizedTest + @FieldSource("SIMPLE_TYPES") + public void testTypeSchema(Type type) throws IOException { + assumeThat(supportsTime() || TypeUtil.find(type, t -> t.typeId() == Type.TypeID.TIME) == null) + .as("Spark does not support time fields") + .isTrue(); + assumeThat( + supportsTimestampNanos() + || TypeUtil.find(type, t -> t.typeId() == Type.TypeID.TIMESTAMP_NANO) == null) + .as("timestamp_ns is not yet implemented") + .isTrue(); + assumeThat( + supportsVariant() + || TypeUtil.find(type, t -> t.typeId() == Type.TypeID.VARIANT) == null) + .as("variant is not yet implemented") + .isTrue(); + if (!supportsGeospatial()) { + assumeThat(TypeUtil.find(type, t -> t.typeId() == Type.TypeID.GEOMETRY) == null) + .as("geometry is not yet implemented") + .isTrue(); + assumeThat(TypeUtil.find(type, t -> t.typeId() == Type.TypeID.GEOGRAPHY) == null) + .as("geography is not yet implemented") + .isTrue(); + } + + writeAndValidate( + new Schema( + required(1, "id", LongType.get()), + optional(2, "test_type", type), + required(3, "trailing_data", Types.StringType.get()))); + } + + @Test + public void testSimpleStruct() throws IOException { + writeAndValidate(TypeUtil.assignIncreasingFreshIds(new Schema(SUPPORTED_PRIMITIVES.fields()))); + } + + @Test + public void testStructWithRequiredFields() throws IOException { + List supportedPrimitives = + SUPPORTED_PRIMITIVES.fields().stream() + .filter(f -> f.type().typeId() != Type.TypeID.UNKNOWN) + .collect(Collectors.toList()); + writeAndValidate( + TypeUtil.assignIncreasingFreshIds( + new Schema(Lists.transform(supportedPrimitives, Types.NestedField::asRequired)))); + } + + @Test + public void testStructWithOptionalFields() throws IOException { + writeAndValidate( + TypeUtil.assignIncreasingFreshIds( + new Schema( + Lists.transform(SUPPORTED_PRIMITIVES.fields(), Types.NestedField::asOptional)))); + } + + @Test + public void testNestedStruct() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + writeAndValidate( + TypeUtil.assignIncreasingFreshIds(new Schema(required(1, "struct", SUPPORTED_PRIMITIVES)))); + } + + @Test + public void testArray() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional(1, "data", ListType.ofOptional(2, Types.StringType.get()))); + + writeAndValidate(schema); + } + + @Test + public void testArrayOfStructs() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + TypeUtil.assignIncreasingFreshIds( + new Schema( + required(0, "id", LongType.get()), + optional(1, "data", ListType.ofOptional(2, SUPPORTED_PRIMITIVES)))); + + writeAndValidate(schema); + } + + @Test + public void testMap() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional( + 1, + "data", + MapType.ofOptional(2, 3, Types.StringType.get(), Types.StringType.get()))); + + writeAndValidate(schema); + } + + @Test + public void testNumericMapKey() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional( + 1, "data", MapType.ofOptional(2, 3, Types.LongType.get(), Types.StringType.get()))); + + writeAndValidate(schema); + } + + @Test + public void testComplexMapKey() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional( + 1, + "data", + MapType.ofOptional( + 2, + 3, + Types.StructType.of( + required(4, "i", Types.IntegerType.get()), + optional(5, "s", Types.StringType.get())), + Types.StringType.get()))); + + writeAndValidate(schema); + } + + @Test + public void testMapOfStructs() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + TypeUtil.assignIncreasingFreshIds( + new Schema( + required(0, "id", LongType.get()), + optional( + 1, + "data", + MapType.ofOptional(2, 3, Types.StringType.get(), SUPPORTED_PRIMITIVES)))); + + writeAndValidate(schema); + } + + @Test + public void testMixedTypes() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + StructType structType = + StructType.of( + required(0, "id", LongType.get()), + optional( + 1, + "list_of_maps", + ListType.ofOptional( + 2, MapType.ofOptional(3, 4, Types.StringType.get(), SUPPORTED_PRIMITIVES))), + optional( + 5, + "map_of_lists", + MapType.ofOptional( + 6, 7, Types.StringType.get(), ListType.ofOptional(8, SUPPORTED_PRIMITIVES))), + required( + 9, + "list_of_lists", + ListType.ofOptional(10, ListType.ofOptional(11, SUPPORTED_PRIMITIVES))), + required( + 12, + "map_of_maps", + MapType.ofOptional( + 13, + 14, + Types.StringType.get(), + MapType.ofOptional(15, 16, Types.StringType.get(), SUPPORTED_PRIMITIVES))), + required( + 17, + "list_of_struct_of_nested_types", + ListType.ofOptional( + 19, + StructType.of( + Types.NestedField.required( + 20, + "m1", + MapType.ofOptional( + 21, 22, Types.StringType.get(), SUPPORTED_PRIMITIVES)), + Types.NestedField.optional( + 23, "l1", ListType.ofRequired(24, SUPPORTED_PRIMITIVES)), + Types.NestedField.required( + 25, "l2", ListType.ofRequired(26, SUPPORTED_PRIMITIVES)), + Types.NestedField.optional( + 27, + "m2", + MapType.ofOptional( + 28, 29, Types.StringType.get(), SUPPORTED_PRIMITIVES)))))); + + Schema schema = + new Schema( + TypeUtil.assignFreshIds(structType, new AtomicInteger(0)::incrementAndGet) + .asStructType() + .fields()); + + writeAndValidate(schema); + } + + @Test + public void testMissingRequiredWithoutDefault() { + assumeThat(supportsDefaultValues()).isTrue(); + + Schema writeSchema = new Schema(required(1, "id", Types.LongType.get())); + + Schema expectedSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.required("missing_str") + .withId(6) + .ofType(Types.StringType.get()) + .withDoc("Missing required field with no default") + .build()); + + assertThatThrownBy(() -> writeAndValidate(writeSchema, expectedSchema)) + .has( + new Condition<>( + t -> + IllegalArgumentException.class.isInstance(t) + || IllegalArgumentException.class.isInstance(t.getCause()), + "Expecting a throwable or cause that is an instance of IllegalArgumentException")) + .hasMessageContaining("Missing required field: missing_str"); + } + + @Test + public void testDefaultValues() throws IOException { + assumeThat(supportsDefaultValues()).isTrue(); + + Schema writeSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .withDoc("Should not produce default value") + .build()); + + Schema expectedSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .build(), + Types.NestedField.required("missing_str") + .withId(6) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("orange")) + .build(), + Types.NestedField.optional("missing_int") + .withId(7) + .ofType(Types.IntegerType.get()) + .withInitialDefault(Literal.of(34)) + .build()); + + writeAndValidate(writeSchema, expectedSchema); + } + + @Test + public void testNullDefaultValue() throws IOException { + assumeThat(supportsDefaultValues()).isTrue(); + + Schema writeSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .withDoc("Should not produce default value") + .build()); + + Schema expectedSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .build(), + Types.NestedField.optional("missing_date") + .withId(3) + .ofType(Types.DateType.get()) + .build()); + + writeAndValidate(writeSchema, expectedSchema); + } + + @Test + public void testNestedDefaultValue() throws IOException { + assumeThat(supportsDefaultValues()).isTrue(); + assumeThat(supportsNestedTypes()).isTrue(); + + Schema writeSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .withDoc("Should not produce default value") + .build(), + Types.NestedField.optional("nested") + .withId(3) + .ofType(Types.StructType.of(required(4, "inner", Types.StringType.get()))) + .withDoc("Used to test nested field defaults") + .build()); + + Schema expectedSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .build(), + Types.NestedField.optional("nested") + .withId(3) + .ofType( + Types.StructType.of( + required(4, "inner", Types.StringType.get()), + Types.NestedField.optional("missing_inner_float") + .withId(5) + .ofType(Types.FloatType.get()) + .withInitialDefault(Literal.of(-0.0F)) + .build())) + .withDoc("Used to test nested field defaults") + .build()); + + writeAndValidate(writeSchema, expectedSchema); + } + + @Test + public void testMapNestedDefaultValue() throws IOException { + assumeThat(supportsDefaultValues()).isTrue(); + assumeThat(supportsNestedTypes()).isTrue(); + + Schema writeSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .withDoc("Should not produce default value") + .build(), + Types.NestedField.optional("nested_map") + .withId(3) + .ofType( + Types.MapType.ofOptional( + 4, + 5, + Types.StringType.get(), + Types.StructType.of(required(6, "value_str", Types.StringType.get())))) + .withDoc("Used to test nested map value field defaults") + .build()); + + Schema expectedSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .build(), + Types.NestedField.optional("nested_map") + .withId(3) + .ofType( + Types.MapType.ofOptional( + 4, + 5, + Types.StringType.get(), + Types.StructType.of( + required(6, "value_str", Types.StringType.get()), + Types.NestedField.optional("value_int") + .withId(7) + .ofType(Types.IntegerType.get()) + .withInitialDefault(Literal.of(34)) + .build()))) + .withDoc("Used to test nested field defaults") + .build()); + + writeAndValidate(writeSchema, expectedSchema); + } + + @Test + public void testListNestedDefaultValue() throws IOException { + assumeThat(supportsDefaultValues()).isTrue(); + assumeThat(supportsNestedTypes()).isTrue(); + + Schema writeSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .withDoc("Should not produce default value") + .build(), + Types.NestedField.optional("nested_list") + .withId(3) + .ofType( + Types.ListType.ofOptional( + 4, Types.StructType.of(required(5, "element_str", Types.StringType.get())))) + .withDoc("Used to test nested field defaults") + .build()); + + Schema expectedSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withInitialDefault(Literal.of("wrong!")) + .build(), + Types.NestedField.optional("nested_list") + .withId(3) + .ofType( + Types.ListType.ofOptional( + 4, + Types.StructType.of( + required(5, "element_str", Types.StringType.get()), + Types.NestedField.optional("element_int") + .withId(7) + .ofType(Types.IntegerType.get()) + .withInitialDefault(Literal.of(34)) + .build()))) + .withDoc("Used to test nested field defaults") + .build()); + + writeAndValidate(writeSchema, expectedSchema); + } + + private static Stream primitiveTypesAndDefaults() { + return Stream.of( + Arguments.of(Types.BooleanType.get(), Literal.of(false)), + Arguments.of(Types.IntegerType.get(), Literal.of(34)), + Arguments.of(Types.LongType.get(), Literal.of(4900000000L)), + Arguments.of(Types.FloatType.get(), Literal.of(12.21F)), + Arguments.of(Types.DoubleType.get(), Literal.of(-0.0D)), + Arguments.of(Types.DateType.get(), Literal.of(DateTimeUtil.isoDateToDays("2024-12-17"))), + // Arguments.of(Types.TimeType.get(), DateTimeUtil.isoTimeToMicros("23:59:59.999999")), + Arguments.of( + Types.TimestampType.withZone(), + Literal.of(DateTimeUtil.isoTimestamptzToMicros("2024-12-17T23:59:59.999999+00:00"))), + Arguments.of( + Types.TimestampType.withoutZone(), + Literal.of(DateTimeUtil.isoTimestampToMicros("2024-12-17T23:59:59.999999"))), + Arguments.of(Types.StringType.get(), Literal.of("iceberg")), + Arguments.of(Types.UUIDType.get(), Literal.of(UUID.randomUUID())), + Arguments.of( + Types.FixedType.ofLength(4), + Literal.of(ByteBuffer.wrap(new byte[] {0x0a, 0x0b, 0x0c, 0x0d}))), + Arguments.of(Types.BinaryType.get(), Literal.of(ByteBuffer.wrap(new byte[] {0x0a, 0x0b}))), + Arguments.of(Types.DecimalType.of(9, 2), Literal.of(new BigDecimal("12.34")))); + } + + @ParameterizedTest + @MethodSource("primitiveTypesAndDefaults") + public void testPrimitiveTypeDefaultValues(Type.PrimitiveType type, Literal defaultValue) + throws IOException { + assumeThat(supportsDefaultValues()).isTrue(); + + Schema writeSchema = new Schema(required(1, "id", Types.LongType.get())); + + Schema readSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.optional("col_with_default") + .withId(2) + .ofType(type) + .withInitialDefault(defaultValue) + .build()); + + writeAndValidate(writeSchema, readSchema); + } + + @Test + public void testRowLineage() throws Exception { + assumeThat(supportsRowLineage()).as("Row lineage support is not implemented").isTrue(); + + Schema schema = + new Schema( + required(1, "id", LongType.get()), + required(2, "data", Types.StringType.get()), + MetadataColumns.ROW_ID, + MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER); + + GenericRecord record = GenericRecord.create(schema); + + writeAndValidate( + schema, + schema, + List.of( + record.copy(Map.of("id", 1L, "data", "a")), + record.copy(Map.of("id", 2L, "data", "b")), + record.copy( + Map.of( + "id", + 3L, + "data", + "c", + "_row_id", + 1_000L, + "_last_updated_sequence_number", + 33L)), + record.copy(Map.of("id", 4L, "data", "d", "_row_id", 1_001L)), + record.copy(Map.of("id", 5L, "data", "e")))); + } + + @Test + public void testUnknownNestedLevel() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(1, "id", LongType.get()), + optional( + 2, + "nested", + Types.StructType.of( + required(20, "int", Types.IntegerType.get()), + optional(21, "unk", Types.UnknownType.get())))); + + writeAndValidate(schema); + } + + @Test + public void testUnknownListType() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional(1, "data", ListType.ofOptional(2, Types.UnknownType.get()))); + + writeAndValidate(schema); + } + + @Test + public void testUnknownMapType() throws IOException { + assumeThat(supportsNestedTypes()).isTrue(); + + Schema schema = + new Schema( + required(0, "id", LongType.get()), + optional( + 1, + "data", + MapType.ofOptional(2, 3, Types.StringType.get(), Types.UnknownType.get()))); + + writeAndValidate(schema); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/GenericsHelpers.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/GenericsHelpers.java new file mode 100644 index 000000000000..fecb8275c240 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/GenericsHelpers.java @@ -0,0 +1,441 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.spark.SparkSchemaUtil.convert; +import static org.assertj.core.api.Assertions.assertThat; +import static scala.collection.JavaConverters.mapAsJavaMapConverter; +import static scala.collection.JavaConverters.seqAsJavaListConverter; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.data.GenericDataUtil; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantTestUtil; +import org.apache.iceberg.variants.VariantValue; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; +import scala.collection.Seq; + +public class GenericsHelpers { + private GenericsHelpers() {} + + private static final OffsetDateTime EPOCH = Instant.ofEpochMilli(0L).atOffset(ZoneOffset.UTC); + private static final LocalDate EPOCH_DAY = EPOCH.toLocalDate(); + + public static void assertEqualsSafe(Types.StructType struct, Record expected, Row actual) { + Types.StructType expectedType = expected.struct(); + List fields = struct.fields(); + for (int readPos = 0; readPos < fields.size(); readPos += 1) { + Type fieldType = fields.get(readPos).type(); + Types.NestedField field = fields.get(readPos); + Types.NestedField expectedField = expectedType.field(field.fieldId()); + + Object actualValue = actual.get(readPos); + + Object expectedValue; + if (expectedField != null) { + expectedValue = expected.getField(expectedField.name()); + } else { + expectedValue = GenericDataUtil.internalToGeneric(field.type(), field.initialDefault()); + } + + assertEqualsSafe(fieldType, expectedValue, actualValue); + } + } + + public static void assertEqualsBatch( + Types.StructType struct, + Iterator expectedRecords, + ColumnarBatch batch, + Map idToConstant, + Integer batchFirstRowPos) { + for (int rowPos = 0; rowPos < batch.numRows(); rowPos++) { + InternalRow row = batch.getRow(rowPos); + Record expectedRecord = expectedRecords.next(); + assertEqualsUnsafe(struct, expectedRecord, row, idToConstant, batchFirstRowPos + rowPos); + } + } + + private static void assertEqualsSafe( + Types.ListType list, Collection expected, List actual) { + Type elementType = list.elementType(); + List expectedElements = Lists.newArrayList(expected); + for (int i = 0; i < expectedElements.size(); i += 1) { + Object expectedValue = expectedElements.get(i); + Object actualValue = actual.get(i); + + assertEqualsSafe(elementType, expectedValue, actualValue); + } + } + + private static void assertEqualsSafe(Types.MapType map, Map expected, Map actual) { + Type keyType = map.keyType(); + Type valueType = map.valueType(); + assertThat(actual.keySet()) + .as("Should have the same number of keys") + .hasSameSizeAs(expected.keySet()); + + for (Object expectedKey : expected.keySet()) { + Object matchingKey = null; + for (Object actualKey : actual.keySet()) { + try { + assertEqualsSafe(keyType, expectedKey, actualKey); + matchingKey = actualKey; + break; + } catch (AssertionError e) { + // failed + } + } + + assertThat(matchingKey).as("Should have a matching key").isNotNull(); + assertEqualsSafe(valueType, expected.get(expectedKey), actual.get(matchingKey)); + } + } + + @SuppressWarnings("unchecked") + private static void assertEqualsSafe(Type type, Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + + switch (type.typeId()) { + case BOOLEAN: + case INTEGER: + case LONG: + case FLOAT: + case DOUBLE: + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + break; + case DATE: + assertThat(expected).as("Should expect a LocalDate").isInstanceOf(LocalDate.class); + assertThat(actual) + .isInstanceOf(Date.class) + .as("ISO-8601 date should be equal") + .asString() + .isEqualTo(String.valueOf(expected)); + break; + case TIMESTAMP: + Types.TimestampType timestampType = (Types.TimestampType) type; + if (timestampType.shouldAdjustToUTC()) { + // Timestamptz + assertThat(actual).as("Should be a Timestamp").isInstanceOf(Timestamp.class); + Timestamp ts = (Timestamp) actual; + // milliseconds from nanos has already been added by getTime + OffsetDateTime actualTs = + EPOCH.plusNanos((ts.getTime() * 1_000_000) + (ts.getNanos() % 1_000_000)); + + assertThat(expected) + .as("Should expect an OffsetDateTime") + .isInstanceOf(OffsetDateTime.class); + + assertThat(actualTs).as("Timestamp should be equal").isEqualTo(expected); + } else { + // Timestamp + assertThat(actual).as("Should be a LocalDateTime").isInstanceOf(LocalDateTime.class); + + assertThat(expected) + .as("Should expect an LocalDateTime") + .isInstanceOf(LocalDateTime.class); + + assertThat(actual).as("Timestamp should be equal").isEqualTo(expected); + } + break; + case STRING: + assertThat(actual) + .isInstanceOf(String.class) + .asString() + .isEqualTo(String.valueOf(expected)); + break; + case UUID: + assertThat(expected).as("Should expect a UUID").isInstanceOf(UUID.class); + assertThat(actual) + .isInstanceOf(String.class) + .asString() + .isEqualTo(String.valueOf(expected)); + break; + case FIXED: + assertThat(expected).as("Should expect a byte[]").isInstanceOf(byte[].class); + assertThat(actual).isInstanceOf(byte[].class).isEqualTo(expected); + break; + case BINARY: + assertThat(expected).as("Should expect a ByteBuffer").isInstanceOf(ByteBuffer.class); + assertThat(actual).isInstanceOf(byte[].class).isEqualTo(((ByteBuffer) expected).array()); + break; + case DECIMAL: + assertThat(expected).as("Should expect a BigDecimal").isInstanceOf(BigDecimal.class); + assertThat(actual).isInstanceOf(BigDecimal.class).isEqualTo(expected); + break; + case STRUCT: + assertThat(expected).as("Should expect a Record").isInstanceOf(Record.class); + assertThat(actual).as("Should be a Row").isInstanceOf(Row.class); + assertEqualsSafe(type.asNestedType().asStructType(), (Record) expected, (Row) actual); + break; + case LIST: + assertThat(expected).as("Should expect a Collection").isInstanceOf(Collection.class); + assertThat(actual).as("Should be a Seq").isInstanceOf(Seq.class); + List asList = seqAsJavaListConverter((Seq) actual).asJava(); + assertEqualsSafe(type.asNestedType().asListType(), (Collection) expected, asList); + break; + case MAP: + assertThat(expected).as("Should expect a Collection").isInstanceOf(Map.class); + assertThat(actual).as("Should be a Map").isInstanceOf(scala.collection.Map.class); + Map asMap = + mapAsJavaMapConverter((scala.collection.Map) actual).asJava(); + assertEqualsSafe(type.asNestedType().asMapType(), (Map) expected, asMap); + break; + case VARIANT: + assertThat(expected).as("Should expect a Variant").isInstanceOf(Variant.class); + assertThat(actual).as("Should be a VariantVal").isInstanceOf(VariantVal.class); + assertEquals((Variant) expected, (VariantVal) actual); + break; + case TIME: + default: + throw new IllegalArgumentException("Not a supported type: " + type); + } + } + + public static void assertEqualsUnsafe( + Types.StructType struct, Record expected, InternalRow actual) { + assertEqualsUnsafe(struct, expected, actual, null, -1); + } + + public static void assertEqualsUnsafe( + Types.StructType struct, + Record expected, + InternalRow actual, + Map idToConstant, + int pos) { + Types.StructType expectedType = expected.struct(); + List fields = struct.fields(); + for (int readPos = 0; readPos < fields.size(); readPos += 1) { + Types.NestedField field = fields.get(readPos); + Types.NestedField expectedField = expectedType.field(field.fieldId()); + + Type fieldType = field.type(); + Object actualValue = + actual.isNullAt(readPos) ? null : actual.get(readPos, convert(fieldType)); + + Object expectedValue; + if (expectedField != null) { + int id = expectedField.fieldId(); + if (id == MetadataColumns.ROW_ID.fieldId()) { + expectedValue = expected.getField(expectedField.name()); + if (expectedValue == null && idToConstant != null) { + expectedValue = (Long) idToConstant.get(id) + pos; + } + + } else if (id == MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER.fieldId()) { + expectedValue = expected.getField(expectedField.name()); + if (expectedValue == null && idToConstant != null) { + expectedValue = idToConstant.get(id); + } + + } else { + expectedValue = expected.getField(expectedField.name()); + } + } else { + // comparison expects Iceberg's generic representation + expectedValue = GenericDataUtil.internalToGeneric(field.type(), field.initialDefault()); + } + + assertEqualsUnsafe(fieldType, expectedValue, actualValue); + } + } + + private static void assertEqualsUnsafe( + Types.ListType list, Collection expected, ArrayData actual) { + Type elementType = list.elementType(); + List expectedElements = Lists.newArrayList(expected); + for (int i = 0; i < expectedElements.size(); i += 1) { + Object expectedValue = expectedElements.get(i); + Object actualValue = actual.get(i, convert(elementType)); + + assertEqualsUnsafe(elementType, expectedValue, actualValue); + } + } + + private static void assertEqualsUnsafe(Types.MapType map, Map expected, MapData actual) { + Type keyType = map.keyType(); + Type valueType = map.valueType(); + + List> expectedElements = Lists.newArrayList(expected.entrySet()); + ArrayData actualKeys = actual.keyArray(); + ArrayData actualValues = actual.valueArray(); + + for (int i = 0; i < expectedElements.size(); i += 1) { + Map.Entry expectedPair = expectedElements.get(i); + Object actualKey = actualKeys.get(i, convert(keyType)); + Object actualValue = actualValues.get(i, convert(keyType)); + + assertEqualsUnsafe(keyType, expectedPair.getKey(), actualKey); + assertEqualsUnsafe(valueType, expectedPair.getValue(), actualValue); + } + } + + static void assertEquals(Variant expected, VariantVal actual) { + VariantMetadata actualMetadata = + VariantMetadata.from(ByteBuffer.wrap(actual.getMetadata()).order(ByteOrder.LITTLE_ENDIAN)); + VariantTestUtil.assertEqual(expected.metadata(), actualMetadata); + VariantTestUtil.assertEqual( + expected.value(), + VariantValue.from( + actualMetadata, ByteBuffer.wrap(actual.getValue()).order(ByteOrder.LITTLE_ENDIAN))); + } + + private static void assertEqualsUnsafe(Type type, Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + + switch (type.typeId()) { + case LONG: + assertThat(actual).as("Should be a long").isInstanceOf(Long.class); + if (expected instanceof Integer) { + assertThat(actual).as("Values didn't match").isEqualTo(((Number) expected).longValue()); + } else { + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + } + break; + case DOUBLE: + assertThat(actual).as("Should be a double").isInstanceOf(Double.class); + if (expected instanceof Float) { + assertThat(Double.doubleToLongBits((double) actual)) + .as("Values didn't match") + .isEqualTo(Double.doubleToLongBits(((Number) expected).doubleValue())); + } else { + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + } + break; + case BOOLEAN: + case INTEGER: + case FLOAT: + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + break; + case DATE: + assertThat(expected).as("Should expect a LocalDate").isInstanceOf(LocalDate.class); + int expectedDays = (int) ChronoUnit.DAYS.between(EPOCH_DAY, (LocalDate) expected); + assertThat(actual) + .as("Primitive value should be equal to expected") + .isEqualTo(expectedDays); + break; + case TIMESTAMP: + Types.TimestampType timestampType = (Types.TimestampType) type; + if (timestampType.shouldAdjustToUTC()) { + assertThat(expected) + .as("Should expect an OffsetDateTime") + .isInstanceOf(OffsetDateTime.class); + long expectedMicros = ChronoUnit.MICROS.between(EPOCH, (OffsetDateTime) expected); + assertThat(actual) + .as("Primitive value should be equal to expected") + .isEqualTo(expectedMicros); + } else { + assertThat(expected) + .as("Should expect an LocalDateTime") + .isInstanceOf(LocalDateTime.class); + long expectedMicros = + ChronoUnit.MICROS.between(EPOCH, ((LocalDateTime) expected).atZone(ZoneId.of("UTC"))); + assertThat(actual) + .as("Primitive value should be equal to expected") + .isEqualTo(expectedMicros); + } + break; + case STRING: + assertThat(actual) + .isInstanceOf(UTF8String.class) + .asString() + .isEqualTo(String.valueOf(expected)); + break; + case UUID: + assertThat(expected).as("Should expect a UUID").isInstanceOf(UUID.class); + assertThat(actual) + .isInstanceOf(UTF8String.class) + .asString() + .isEqualTo(String.valueOf(expected)); + break; + case FIXED: + assertThat(expected).as("Should expect a byte[]").isInstanceOf(byte[].class); + assertThat(actual).isInstanceOf(byte[].class).isEqualTo(expected); + break; + case BINARY: + assertThat(expected).as("Should expect a ByteBuffer").isInstanceOf(ByteBuffer.class); + assertThat(actual).isInstanceOf(byte[].class).isEqualTo(((ByteBuffer) expected).array()); + break; + case DECIMAL: + assertThat(expected).as("Should expect a BigDecimal").isInstanceOf(BigDecimal.class); + assertThat(actual).as("Should be a Decimal").isInstanceOf(Decimal.class); + assertThat(((Decimal) actual).toJavaBigDecimal()) + .as("BigDecimals should be equal") + .isEqualTo(expected); + break; + case STRUCT: + assertThat(expected).as("Should expect a Record").isInstanceOf(Record.class); + assertThat(actual).as("Should be an InternalRow").isInstanceOf(InternalRow.class); + assertEqualsUnsafe( + type.asNestedType().asStructType(), (Record) expected, (InternalRow) actual); + break; + case LIST: + assertThat(expected).as("Should expect a Collection").isInstanceOf(Collection.class); + assertThat(actual).as("Should be an ArrayData").isInstanceOf(ArrayData.class); + assertEqualsUnsafe( + type.asNestedType().asListType(), (Collection) expected, (ArrayData) actual); + break; + case MAP: + assertThat(expected).as("Should expect a Map").isInstanceOf(Map.class); + assertThat(actual).as("Should be an ArrayBasedMapData").isInstanceOf(MapData.class); + assertEqualsUnsafe(type.asNestedType().asMapType(), (Map) expected, (MapData) actual); + break; + case VARIANT: + assertThat(expected).as("Should expect a Variant").isInstanceOf(Variant.class); + assertThat(actual).as("Should be a VariantVal").isInstanceOf(VariantVal.class); + assertEquals((Variant) expected, (VariantVal) actual); + break; + case TIME: + default: + throw new IllegalArgumentException("Not a supported type: " + type); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java new file mode 100644 index 000000000000..b7ba7a34309a --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/InternalRowConverter.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; + +/** Converts Iceberg Record to Spark InternalRow for testing. */ +public class InternalRowConverter { + private static final OffsetDateTime EPOCH = Instant.ofEpochSecond(0).atOffset(ZoneOffset.UTC); + private static final LocalDate EPOCH_DAY = EPOCH.toLocalDate(); + + private InternalRowConverter() {} + + public static InternalRow convert(Schema schema, Record record) { + return convert(schema.asStruct(), record); + } + + private static InternalRow convert(Types.StructType struct, Record record) { + GenericInternalRow internalRow = new GenericInternalRow(struct.fields().size()); + List fields = struct.fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + + Type fieldType = field.type(); + internalRow.update(i, convert(fieldType, record.get(i))); + } + + return internalRow; + } + + private static Object convert(Type type, Object value) { + if (value == null) { + return null; + } + + return switch (type.typeId()) { + case BOOLEAN, INTEGER, LONG, FLOAT, DOUBLE -> value; + case DATE -> (int) ChronoUnit.DAYS.between(EPOCH_DAY, (LocalDate) value); + case TIMESTAMP -> + ((Types.TimestampType) type).shouldAdjustToUTC() + ? ChronoUnit.MICROS.between(EPOCH, (OffsetDateTime) value) + : ChronoUnit.MICROS.between(EPOCH, ((LocalDateTime) value).atZone(ZoneId.of("UTC"))); + case STRING -> UTF8String.fromString((String) value); + case UUID -> UTF8String.fromString(value.toString()); + case FIXED, BINARY -> { + ByteBuffer buffer = (ByteBuffer) value; + yield Arrays.copyOfRange( + buffer.array(), + buffer.arrayOffset() + buffer.position(), + buffer.arrayOffset() + buffer.remaining()); + } + case DECIMAL -> Decimal.apply((BigDecimal) value); + case STRUCT -> convert((Types.StructType) type, (Record) value); + case LIST -> + new GenericArrayData( + ((List) value) + .stream() + .map(element -> convert(type.asListType().elementType(), element)) + .toArray()); + case MAP -> + new ArrayBasedMapData( + new GenericArrayData( + ((Map) value) + .keySet().stream() + .map(o -> convert(type.asMapType().keyType(), o)) + .toArray()), + new GenericArrayData( + ((Map) value) + .values().stream() + .map(o -> convert(type.asMapType().valueType(), o)) + .toArray())); + // TIME is not supported by Spark, VARIANT not yet implemented + default -> + throw new UnsupportedOperationException( + "Unsupported type for conversion to InternalRow: " + type); + }; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/RandomData.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/RandomData.java new file mode 100644 index 000000000000..ae30d5ec88db --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/RandomData.java @@ -0,0 +1,404 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericData.Record; +import org.apache.iceberg.RandomVariants; +import org.apache.iceberg.Schema; +import org.apache.iceberg.avro.AvroSchemaUtil; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.RandomUtil; +import org.apache.iceberg.variants.Variant; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.catalyst.util.ArrayBasedMapData; +import org.apache.spark.sql.catalyst.util.GenericArrayData; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; + +public class RandomData { + + // Default percentage of number of values that are null for optional fields + public static final float DEFAULT_NULL_PERCENTAGE = 0.05f; + + private RandomData() {} + + public static List generateList(Schema schema, int numRecords, long seed) { + RandomDataGenerator generator = new RandomDataGenerator(schema, seed, DEFAULT_NULL_PERCENTAGE); + List records = Lists.newArrayListWithExpectedSize(numRecords); + for (int i = 0; i < numRecords; i += 1) { + records.add((Record) TypeUtil.visit(schema, generator)); + } + + return records; + } + + public static Iterable generateSpark(Schema schema, int numRecords, long seed) { + return () -> + new Iterator() { + private final SparkRandomDataGenerator generator = new SparkRandomDataGenerator(seed); + private int count = 0; + + @Override + public boolean hasNext() { + return count < numRecords; + } + + @Override + public InternalRow next() { + if (count >= numRecords) { + throw new NoSuchElementException(); + } + count += 1; + return (InternalRow) TypeUtil.visit(schema, generator); + } + }; + } + + public static Iterable generate(Schema schema, int numRecords, long seed) { + return newIterable( + () -> new RandomDataGenerator(schema, seed, DEFAULT_NULL_PERCENTAGE), schema, numRecords); + } + + public static Iterable generate( + Schema schema, int numRecords, long seed, float nullPercentage) { + return newIterable( + () -> new RandomDataGenerator(schema, seed, nullPercentage), schema, numRecords); + } + + public static Iterable generateFallbackData( + Schema schema, int numRecords, long seed, long numDictRecords) { + return newIterable( + () -> new FallbackDataGenerator(schema, seed, numDictRecords), schema, numRecords); + } + + public static Iterable generateDictionaryEncodableData( + Schema schema, int numRecords, long seed, float nullPercentage) { + return newIterable( + () -> new DictionaryEncodedDataGenerator(schema, seed, nullPercentage), schema, numRecords); + } + + private static Iterable newIterable( + Supplier newGenerator, Schema schema, int numRecords) { + return () -> + new Iterator() { + private int count = 0; + private final RandomDataGenerator generator = newGenerator.get(); + + @Override + public boolean hasNext() { + return count < numRecords; + } + + @Override + public Record next() { + if (count >= numRecords) { + throw new NoSuchElementException(); + } + count += 1; + return (Record) TypeUtil.visit(schema, generator); + } + }; + } + + private static class RandomDataGenerator extends TypeUtil.CustomOrderSchemaVisitor { + private final Map typeToSchema; + private final Random random; + // Percentage of number of values that are null for optional fields + private final float nullPercentage; + + private RandomDataGenerator(Schema schema, long seed, float nullPercentage) { + Preconditions.checkArgument( + 0.0f <= nullPercentage && nullPercentage <= 1.0f, + "Percentage needs to be in the range (0.0, 1.0)"); + this.nullPercentage = nullPercentage; + this.typeToSchema = AvroSchemaUtil.convertTypes(schema.asStruct(), "test"); + this.random = new Random(seed); + } + + @Override + public Record schema(Schema schema, Supplier structResult) { + return (Record) structResult.get(); + } + + @Override + public Record struct(Types.StructType struct, Iterable fieldResults) { + Record rec = new Record(typeToSchema.get(struct)); + + List values = Lists.newArrayList(fieldResults); + for (int i = 0; i < values.size(); i += 1) { + rec.put(i, values.get(i)); + } + + return rec; + } + + @Override + public Object field(Types.NestedField field, Supplier fieldResult) { + if (field.isOptional() && isNull()) { + return null; + } + return fieldResult.get(); + } + + private boolean isNull() { + return random.nextFloat() < nullPercentage; + } + + @Override + public Object list(Types.ListType list, Supplier elementResult) { + int numElements = random.nextInt(20); + + List result = Lists.newArrayListWithExpectedSize(numElements); + for (int i = 0; i < numElements; i += 1) { + if (list.isElementOptional() && isNull()) { + result.add(null); + } else { + result.add(elementResult.get()); + } + } + + return result; + } + + @Override + public Object map(Types.MapType map, Supplier keyResult, Supplier valueResult) { + int numEntries = random.nextInt(20); + + Map result = Maps.newLinkedHashMap(); + Set keySet = Sets.newHashSet(); + for (int i = 0; i < numEntries; i += 1) { + Object key = keyResult.get(); + // ensure no collisions + while (keySet.contains(key)) { + key = keyResult.get(); + } + + keySet.add(key); + + if (map.isValueOptional() && isNull()) { + result.put(key, null); + } else { + result.put(key, valueResult.get()); + } + } + + return result; + } + + public Object variant(Types.VariantType variant) { + return RandomVariants.randomVariant(random); + } + + @Override + public Object primitive(Type.PrimitiveType primitive) { + Object result = randomValue(primitive, random); + // For the primitives that Avro needs a different type than Spark, fix + // them here. + switch (primitive.typeId()) { + case FIXED: + return new GenericData.Fixed(typeToSchema.get(primitive), (byte[]) result); + case BINARY: + return ByteBuffer.wrap((byte[]) result); + case UUID: + return UUID.nameUUIDFromBytes((byte[]) result); + default: + return result; + } + } + + protected Object randomValue(Type.PrimitiveType primitive, Random rand) { + return RandomUtil.generatePrimitive(primitive, random); + } + } + + private static class SparkRandomDataGenerator extends TypeUtil.CustomOrderSchemaVisitor { + private final Random random; + private final float nullPercentage; + + private SparkRandomDataGenerator(long seed) { + this(seed, DEFAULT_NULL_PERCENTAGE); + } + + private SparkRandomDataGenerator(long seed, float nullPercentage) { + Preconditions.checkArgument( + 0.0f <= nullPercentage && nullPercentage <= 1.0f, + "Percentage needs to be in the range (0.0, 1.0)"); + this.random = new Random(seed); + this.nullPercentage = nullPercentage; + } + + @Override + public InternalRow schema(Schema schema, Supplier structResult) { + return (InternalRow) structResult.get(); + } + + @Override + public InternalRow struct(Types.StructType struct, Iterable fieldResults) { + List values = Lists.newArrayList(fieldResults); + GenericInternalRow row = new GenericInternalRow(values.size()); + for (int i = 0; i < values.size(); i += 1) { + row.update(i, values.get(i)); + } + + return row; + } + + @Override + public Object field(Types.NestedField field, Supplier fieldResult) { + // return null 5% of the time when the value is optional + if (field.isOptional() && isNull()) { + return null; + } + return fieldResult.get(); + } + + private boolean isNull() { + return random.nextFloat() < nullPercentage; + } + + @Override + public GenericArrayData list(Types.ListType list, Supplier elementResult) { + int numElements = random.nextInt(20); + Object[] arr = new Object[numElements]; + GenericArrayData result = new GenericArrayData(arr); + + for (int i = 0; i < numElements; i += 1) { + if (list.isElementOptional() && isNull()) { + arr[i] = null; + } else { + arr[i] = elementResult.get(); + } + } + + return result; + } + + @Override + public Object map(Types.MapType map, Supplier keyResult, Supplier valueResult) { + int numEntries = random.nextInt(20); + + Object[] keysArr = new Object[numEntries]; + Object[] valuesArr = new Object[numEntries]; + GenericArrayData keys = new GenericArrayData(keysArr); + GenericArrayData values = new GenericArrayData(valuesArr); + ArrayBasedMapData result = new ArrayBasedMapData(keys, values); + + Set keySet = Sets.newHashSet(); + for (int i = 0; i < numEntries; i += 1) { + Object key = keyResult.get(); + // ensure no collisions + while (keySet.contains(key)) { + key = keyResult.get(); + } + + keySet.add(key); + + keysArr[i] = key; + if (map.isValueOptional() && isNull()) { + valuesArr[i] = null; + } else { + valuesArr[i] = valueResult.get(); + } + } + + return result; + } + + @Override + public VariantVal variant(Types.VariantType type) { + Variant variant = RandomVariants.randomVariant(random); + + byte[] metadataBytes = new byte[variant.metadata().sizeInBytes()]; + ByteBuffer metadataBuffer = ByteBuffer.wrap(metadataBytes).order(ByteOrder.LITTLE_ENDIAN); + variant.metadata().writeTo(metadataBuffer, 0); + + byte[] valueBytes = new byte[variant.value().sizeInBytes()]; + ByteBuffer valueBuffer = ByteBuffer.wrap(valueBytes).order(ByteOrder.LITTLE_ENDIAN); + variant.value().writeTo(valueBuffer, 0); + + return new VariantVal(valueBytes, metadataBytes); + } + + @Override + public Object primitive(Type.PrimitiveType primitive) { + Object obj = RandomUtil.generatePrimitive(primitive, random); + switch (primitive.typeId()) { + case STRING: + return UTF8String.fromString((String) obj); + case DECIMAL: + return Decimal.apply((BigDecimal) obj); + case UUID: + return UTF8String.fromString(UUID.nameUUIDFromBytes((byte[]) obj).toString()); + default: + return obj; + } + } + } + + private static class DictionaryEncodedDataGenerator extends RandomDataGenerator { + private DictionaryEncodedDataGenerator(Schema schema, long seed, float nullPercentage) { + super(schema, seed, nullPercentage); + } + + @Override + protected Object randomValue(Type.PrimitiveType primitive, Random random) { + return RandomUtil.generateDictionaryEncodablePrimitive(primitive, random); + } + } + + private static class FallbackDataGenerator extends RandomDataGenerator { + private final long dictionaryEncodedRows; + private long rowCount = 0; + + private FallbackDataGenerator(Schema schema, long seed, long numDictionaryEncoded) { + super(schema, seed, DEFAULT_NULL_PERCENTAGE); + this.dictionaryEncodedRows = numDictionaryEncoded; + } + + @Override + protected Object randomValue(Type.PrimitiveType primitive, Random rand) { + this.rowCount += 1; + if (rowCount > dictionaryEncodedRows) { + return RandomUtil.generatePrimitive(primitive, rand); + } else { + return RandomUtil.generateDictionaryEncodablePrimitive(primitive, rand); + } + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java new file mode 100644 index 000000000000..23150e0ce665 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestHelpers.java @@ -0,0 +1,957 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.spark.SparkSchemaUtil.convert; +import static org.assertj.core.api.Assertions.assertThat; +import static scala.collection.JavaConverters.mapAsJavaMapConverter; +import static scala.collection.JavaConverters.seqAsJavaListConverter; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.apache.avro.Schema.Field; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericData.Record; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileContent; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableScan; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.deletes.DeleteCounter; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.relocated.com.google.common.collect.Streams; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.iceberg.variants.Variant; +import org.apache.orc.storage.serde2.io.DateWritable; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericRow; +import org.apache.spark.sql.catalyst.expressions.SpecializedGetters; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.DateTimeUtils; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.BinaryType; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.Decimal; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.apache.spark.unsafe.types.UTF8String; +import org.apache.spark.unsafe.types.VariantVal; +import scala.collection.Seq; + +public class TestHelpers { + + private TestHelpers() {} + + public static void assertEqualsSafe(Types.StructType struct, List recs, List rows) { + Streams.forEachPair( + recs.stream(), rows.stream(), (rec, row) -> assertEqualsSafe(struct, rec, row)); + } + + public static void assertEqualsSafe(Types.StructType struct, Record rec, Row row) { + List fields = struct.fields(); + for (int readPos = 0; readPos < fields.size(); readPos += 1) { + Types.NestedField field = fields.get(readPos); + Field writeField = rec.getSchema().getField(field.name()); + + Type fieldType = field.type(); + Object actualValue = row.get(readPos); + + Object expectedValue; + if (writeField != null) { + int writePos = writeField.pos(); + expectedValue = rec.get(writePos); + } else { + expectedValue = field.initialDefault(); + } + + assertEqualsSafe(fieldType, expectedValue, actualValue); + } + } + + public static void assertEqualsBatchWithRows( + Types.StructType struct, Iterator expected, ColumnarBatch batch) { + for (int rowId = 0; rowId < batch.numRows(); rowId++) { + List fields = struct.fields(); + InternalRow row = batch.getRow(rowId); + Row expectedRow = expected.next(); + for (int i = 0; i < fields.size(); i += 1) { + Type fieldType = fields.get(i).type(); + Object expectedValue = expectedRow.get(i); + Object actualValue = row.isNullAt(i) ? null : row.get(i, convert(fieldType)); + assertEqualsUnsafe(fieldType, expectedValue, actualValue); + } + } + } + + private static void assertEqualsSafe(Types.ListType list, Collection expected, List actual) { + Type elementType = list.elementType(); + List expectedElements = Lists.newArrayList(expected); + for (int i = 0; i < expectedElements.size(); i += 1) { + Object expectedValue = expectedElements.get(i); + Object actualValue = actual.get(i); + + assertEqualsSafe(elementType, expectedValue, actualValue); + } + } + + private static void assertEqualsSafe(Types.MapType map, Map expected, Map actual) { + Type keyType = map.keyType(); + Type valueType = map.valueType(); + + for (Object expectedKey : expected.keySet()) { + Object matchingKey = null; + for (Object actualKey : actual.keySet()) { + try { + assertEqualsSafe(keyType, expectedKey, actualKey); + matchingKey = actualKey; + } catch (AssertionError e) { + // failed + } + } + + assertThat(matchingKey).as("Should have a matching key").isNotNull(); + assertEqualsSafe(valueType, expected.get(expectedKey), actual.get(matchingKey)); + } + } + + private static final OffsetDateTime EPOCH = Instant.ofEpochMilli(0L).atOffset(ZoneOffset.UTC); + private static final LocalDate EPOCH_DAY = EPOCH.toLocalDate(); + + @SuppressWarnings("unchecked") + private static void assertEqualsSafe(Type type, Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + + switch (type.typeId()) { + case BOOLEAN: + case INTEGER: + case LONG: + case FLOAT: + case DOUBLE: + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + break; + case DATE: + assertThat(expected).as("Should be an int").isInstanceOf(Integer.class); + assertThat(actual).as("Should be a Date").isInstanceOf(Date.class); + LocalDate date = ChronoUnit.DAYS.addTo(EPOCH_DAY, (Integer) expected); + assertThat(actual) + .as("ISO-8601 date should be equal") + .asString() + .isEqualTo(String.valueOf(date)); + break; + case TIMESTAMP: + Types.TimestampType timestampType = (Types.TimestampType) type; + + assertThat(expected).as("Should be a long").isInstanceOf(Long.class); + if (timestampType.shouldAdjustToUTC()) { + assertThat(actual).as("Should be a Timestamp").isInstanceOf(Timestamp.class); + + Timestamp ts = (Timestamp) actual; + // milliseconds from nanos has already been added by getTime + long tsMicros = (ts.getTime() * 1000) + ((ts.getNanos() / 1000) % 1000); + assertThat(tsMicros).as("Timestamp micros should be equal").isEqualTo(expected); + } else { + assertThat(actual).as("Should be a LocalDateTime").isInstanceOf(LocalDateTime.class); + + LocalDateTime ts = (LocalDateTime) actual; + Instant instant = ts.toInstant(ZoneOffset.UTC); + // milliseconds from nanos has already been added by getTime + long tsMicros = (instant.toEpochMilli() * 1000) + ((ts.getNano() / 1000) % 1000); + assertThat(tsMicros).as("Timestamp micros should be equal").isEqualTo(expected); + } + break; + case STRING: + assertThat(actual).isInstanceOf(String.class).isEqualTo(String.valueOf(expected)); + break; + case UUID: + assertThat(expected).as("Should expect a UUID").isInstanceOf(UUID.class); + assertThat(actual) + .isInstanceOf(String.class) + .asString() + .isEqualTo(String.valueOf(expected)); + break; + case FIXED: + // generated data is written using Avro or Parquet/Avro so generated rows use + // GenericData.Fixed, but default values are converted from Iceberg's internal + // representation so the expected value may be either class. + byte[] expectedBytes; + if (expected instanceof ByteBuffer) { + expectedBytes = ByteBuffers.toByteArray((ByteBuffer) expected); + } else if (expected instanceof GenericData.Fixed) { + expectedBytes = ((GenericData.Fixed) expected).bytes(); + } else { + throw new IllegalStateException( + "Invalid expected value, not byte[] or Fixed: " + expected); + } + + assertThat(actual).isInstanceOf(byte[].class).isEqualTo(expectedBytes); + break; + case BINARY: + assertThat(expected).as("Should expect a ByteBuffer").isInstanceOf(ByteBuffer.class); + assertThat(actual).isInstanceOf(byte[].class).isEqualTo(((ByteBuffer) expected).array()); + break; + case DECIMAL: + assertThat(expected).as("Should expect a BigDecimal").isInstanceOf(BigDecimal.class); + assertThat(actual).isInstanceOf(BigDecimal.class).isEqualTo(expected); + break; + case STRUCT: + assertThat(expected).as("Should expect a Record").isInstanceOf(Record.class); + assertThat(actual).as("Should be a Row").isInstanceOf(Row.class); + assertEqualsSafe(type.asNestedType().asStructType(), (Record) expected, (Row) actual); + break; + case LIST: + assertThat(expected).as("Should expect a Collection").isInstanceOf(Collection.class); + assertThat(actual).as("Should be a Seq").isInstanceOf(Seq.class); + List asList = seqAsJavaListConverter((Seq) actual).asJava(); + assertEqualsSafe(type.asNestedType().asListType(), (Collection) expected, asList); + break; + case MAP: + assertThat(expected).as("Should expect a Collection").isInstanceOf(Map.class); + assertThat(actual).as("Should be a Map").isInstanceOf(scala.collection.Map.class); + Map asMap = + mapAsJavaMapConverter((scala.collection.Map) actual).asJava(); + assertEqualsSafe(type.asNestedType().asMapType(), (Map) expected, asMap); + break; + case TIME: + default: + throw new IllegalArgumentException("Not a supported type: " + type); + } + } + + public static void assertEqualsUnsafe(Types.StructType struct, Record rec, InternalRow row) { + List fields = struct.fields(); + for (int readPos = 0; readPos < fields.size(); readPos += 1) { + Types.NestedField field = fields.get(readPos); + Field writeField = rec.getSchema().getField(field.name()); + + Type fieldType = field.type(); + Object actualValue = row.isNullAt(readPos) ? null : row.get(readPos, convert(fieldType)); + + Object expectedValue; + if (writeField != null) { + int writePos = writeField.pos(); + expectedValue = rec.get(writePos); + } else { + expectedValue = field.initialDefault(); + } + + assertEqualsUnsafe(fieldType, expectedValue, actualValue); + } + } + + private static void assertEqualsUnsafe( + Types.ListType list, Collection expected, ArrayData actual) { + Type elementType = list.elementType(); + List expectedElements = Lists.newArrayList(expected); + for (int i = 0; i < expectedElements.size(); i += 1) { + Object expectedValue = expectedElements.get(i); + Object actualValue = actual.get(i, convert(elementType)); + + assertEqualsUnsafe(elementType, expectedValue, actualValue); + } + } + + private static void assertEqualsUnsafe(Types.MapType map, Map expected, MapData actual) { + Type keyType = map.keyType(); + Type valueType = map.valueType(); + + List> expectedElements = Lists.newArrayList(expected.entrySet()); + ArrayData actualKeys = actual.keyArray(); + ArrayData actualValues = actual.valueArray(); + + for (int i = 0; i < expectedElements.size(); i += 1) { + Map.Entry expectedPair = expectedElements.get(i); + Object actualKey = actualKeys.get(i, convert(keyType)); + Object actualValue = actualValues.get(i, convert(keyType)); + + assertEqualsUnsafe(keyType, expectedPair.getKey(), actualKey); + assertEqualsUnsafe(valueType, expectedPair.getValue(), actualValue); + } + } + + private static void assertEqualsUnsafe(Type type, Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + + switch (type.typeId()) { + case LONG: + assertThat(actual).as("Should be a long").isInstanceOf(Long.class); + if (expected instanceof Integer) { + assertThat(actual).as("Values didn't match").isEqualTo(((Number) expected).longValue()); + } else { + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + } + break; + case DOUBLE: + assertThat(actual).as("Should be a double").isInstanceOf(Double.class); + if (expected instanceof Float) { + assertThat(Double.doubleToLongBits((double) actual)) + .as("Values didn't match") + .isEqualTo(Double.doubleToLongBits(((Number) expected).doubleValue())); + } else { + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + } + break; + case INTEGER: + case FLOAT: + case BOOLEAN: + case DATE: + case TIMESTAMP: + assertThat(actual).as("Primitive value should be equal to expected").isEqualTo(expected); + break; + case STRING: + assertThat(actual).isInstanceOf(UTF8String.class).asString().isEqualTo(expected); + break; + case UUID: + assertThat(expected).as("Should expect a UUID").isInstanceOf(UUID.class); + assertThat(actual) + .isInstanceOf(UTF8String.class) + .asString() + .isEqualTo(String.valueOf(expected)); + break; + case FIXED: + // generated data is written using Avro or Parquet/Avro so generated rows use + // GenericData.Fixed, but default values are converted from Iceberg's internal + // representation so the expected value may be either class. + byte[] expectedBytes; + if (expected instanceof ByteBuffer) { + expectedBytes = ByteBuffers.toByteArray((ByteBuffer) expected); + } else if (expected instanceof GenericData.Fixed) { + expectedBytes = ((GenericData.Fixed) expected).bytes(); + } else { + throw new IllegalStateException( + "Invalid expected value, not byte[] or Fixed: " + expected); + } + + assertThat(actual).as("Should be a byte[]").isInstanceOf(byte[].class); + assertThat(actual).as("Bytes should match").isEqualTo(expectedBytes); + break; + case BINARY: + assertThat(expected).as("Should expect a ByteBuffer").isInstanceOf(ByteBuffer.class); + assertThat(actual).isInstanceOf(byte[].class).isEqualTo(((ByteBuffer) expected).array()); + break; + case DECIMAL: + assertThat(expected).as("Should expect a BigDecimal").isInstanceOf(BigDecimal.class); + assertThat(actual).as("Should be a Decimal").isInstanceOf(Decimal.class); + assertThat(((Decimal) actual).toJavaBigDecimal()) + .as("BigDecimals should be equal") + .isEqualTo(expected); + break; + case STRUCT: + assertThat(expected).as("Should expect a Record").isInstanceOf(Record.class); + assertThat(actual).as("Should be an InternalRow").isInstanceOf(InternalRow.class); + assertEqualsUnsafe( + type.asNestedType().asStructType(), (Record) expected, (InternalRow) actual); + break; + case LIST: + assertThat(expected).as("Should expect a Collection").isInstanceOf(Collection.class); + assertThat(actual).as("Should be an ArrayData").isInstanceOf(ArrayData.class); + assertEqualsUnsafe( + type.asNestedType().asListType(), (Collection) expected, (ArrayData) actual); + break; + case MAP: + assertThat(expected).as("Should expect a Map").isInstanceOf(Map.class); + assertThat(actual).as("Should be an ArrayBasedMapData").isInstanceOf(MapData.class); + assertEqualsUnsafe(type.asNestedType().asMapType(), (Map) expected, (MapData) actual); + break; + case VARIANT: + assertThat(expected).as("Should expect a Variant").isInstanceOf(Variant.class); + assertThat(actual).as("Should be a VariantVal").isInstanceOf(VariantVal.class); + GenericsHelpers.assertEquals((Variant) expected, (VariantVal) actual); + break; + case TIME: + default: + throw new IllegalArgumentException("Not a supported type: " + type); + } + } + + /** + * Check that the given InternalRow is equivalent to the Row. + * + * @param prefix context for error messages + * @param type the type of the row + * @param expected the expected value of the row + * @param actual the actual value of the row + */ + public static void assertEquals( + String prefix, Types.StructType type, InternalRow expected, Row actual) { + if (expected == null || actual == null) { + assertThat(actual).as(prefix).isEqualTo(expected); + } else { + List fields = type.fields(); + for (int c = 0; c < fields.size(); ++c) { + String fieldName = fields.get(c).name(); + Type childType = fields.get(c).type(); + switch (childType.typeId()) { + case BOOLEAN: + case INTEGER: + case LONG: + case FLOAT: + case DOUBLE: + case STRING: + case DECIMAL: + case DATE: + case TIMESTAMP: + assertThat(getPrimitiveValue(actual, c, childType)) + .as(prefix + "." + fieldName + " - " + childType) + .isEqualTo(getValue(expected, c, childType)); + break; + case UUID: + case FIXED: + case BINARY: + assertEqualBytes( + prefix + "." + fieldName, + (byte[]) getValue(expected, c, childType), + (byte[]) actual.get(c)); + break; + case STRUCT: + { + Types.StructType st = (Types.StructType) childType; + assertEquals( + prefix + "." + fieldName, + st, + expected.getStruct(c, st.fields().size()), + actual.getStruct(c)); + break; + } + case LIST: + assertEqualsLists( + prefix + "." + fieldName, + childType.asListType(), + expected.getArray(c), + toList((Seq) actual.get(c))); + break; + case MAP: + assertEqualsMaps( + prefix + "." + fieldName, + childType.asMapType(), + expected.getMap(c), + toJavaMap((scala.collection.Map) actual.getMap(c))); + break; + default: + throw new IllegalArgumentException("Unhandled type " + childType); + } + } + } + } + + private static void assertEqualsLists( + String prefix, Types.ListType type, ArrayData expected, List actual) { + if (expected == null || actual == null) { + assertThat(actual).as(prefix).isEqualTo(expected); + } else { + assertThat(actual).as(prefix + "length").hasSize(expected.numElements()); + Type childType = type.elementType(); + for (int e = 0; e < expected.numElements(); ++e) { + switch (childType.typeId()) { + case BOOLEAN: + case INTEGER: + case LONG: + case FLOAT: + case DOUBLE: + case STRING: + case DECIMAL: + case DATE: + case TIMESTAMP: + assertThat(actual) + .as(prefix + ".elem " + e + " - " + childType) + .element(e) + .isEqualTo(getValue(expected, e, childType)); + break; + case UUID: + case FIXED: + case BINARY: + assertEqualBytes( + prefix + ".elem " + e, + (byte[]) getValue(expected, e, childType), + (byte[]) actual.get(e)); + break; + case STRUCT: + { + Types.StructType st = (Types.StructType) childType; + assertEquals( + prefix + ".elem " + e, + st, + expected.getStruct(e, st.fields().size()), + (Row) actual.get(e)); + break; + } + case LIST: + assertEqualsLists( + prefix + ".elem " + e, + childType.asListType(), + expected.getArray(e), + toList((Seq) actual.get(e))); + break; + case MAP: + assertEqualsMaps( + prefix + ".elem " + e, + childType.asMapType(), + expected.getMap(e), + toJavaMap((scala.collection.Map) actual.get(e))); + break; + default: + throw new IllegalArgumentException("Unhandled type " + childType); + } + } + } + } + + private static void assertEqualsMaps( + String prefix, Types.MapType type, MapData expected, Map actual) { + if (expected == null || actual == null) { + assertThat(actual).as(prefix).isEqualTo(expected); + } else { + Type keyType = type.keyType(); + Type valueType = type.valueType(); + ArrayData expectedKeyArray = expected.keyArray(); + ArrayData expectedValueArray = expected.valueArray(); + assertThat(actual).as(prefix + " length").hasSize(expectedKeyArray.numElements()); + for (int e = 0; e < expected.numElements(); ++e) { + Object expectedKey = getValue(expectedKeyArray, e, keyType); + Object actualValue = actual.get(expectedKey); + if (actualValue == null) { + assertThat(expected.valueArray().isNullAt(e)) + .as(prefix + ".key=" + expectedKey + " has null") + .isTrue(); + } else { + switch (valueType.typeId()) { + case BOOLEAN: + case INTEGER: + case LONG: + case FLOAT: + case DOUBLE: + case STRING: + case DECIMAL: + case DATE: + case TIMESTAMP: + assertThat(actual.get(expectedKey)) + .as(prefix + ".key=" + expectedKey + " - " + valueType) + .isEqualTo(getValue(expectedValueArray, e, valueType)); + break; + case UUID: + case FIXED: + case BINARY: + assertEqualBytes( + prefix + ".key=" + expectedKey, + (byte[]) getValue(expectedValueArray, e, valueType), + (byte[]) actual.get(expectedKey)); + break; + case STRUCT: + { + Types.StructType st = (Types.StructType) valueType; + assertEquals( + prefix + ".key=" + expectedKey, + st, + expectedValueArray.getStruct(e, st.fields().size()), + (Row) actual.get(expectedKey)); + break; + } + case LIST: + assertEqualsLists( + prefix + ".key=" + expectedKey, + valueType.asListType(), + expectedValueArray.getArray(e), + toList((Seq) actual.get(expectedKey))); + break; + case MAP: + assertEqualsMaps( + prefix + ".key=" + expectedKey, + valueType.asMapType(), + expectedValueArray.getMap(e), + toJavaMap((scala.collection.Map) actual.get(expectedKey))); + break; + default: + throw new IllegalArgumentException("Unhandled type " + valueType); + } + } + } + } + } + + private static Object getValue(SpecializedGetters container, int ord, Type type) { + if (container.isNullAt(ord)) { + return null; + } + switch (type.typeId()) { + case BOOLEAN: + return container.getBoolean(ord); + case INTEGER: + return container.getInt(ord); + case LONG: + return container.getLong(ord); + case FLOAT: + return container.getFloat(ord); + case DOUBLE: + return container.getDouble(ord); + case STRING: + return container.getUTF8String(ord).toString(); + case BINARY: + case FIXED: + case UUID: + return container.getBinary(ord); + case DATE: + return new DateWritable(container.getInt(ord)).get(); + case TIMESTAMP: + return DateTimeUtils.toJavaTimestamp(container.getLong(ord)); + case DECIMAL: + { + Types.DecimalType dt = (Types.DecimalType) type; + return container.getDecimal(ord, dt.precision(), dt.scale()).toJavaBigDecimal(); + } + case STRUCT: + Types.StructType struct = type.asStructType(); + InternalRow internalRow = container.getStruct(ord, struct.fields().size()); + Object[] data = new Object[struct.fields().size()]; + for (int i = 0; i < data.length; i += 1) { + if (internalRow.isNullAt(i)) { + data[i] = null; + } else { + data[i] = getValue(internalRow, i, struct.fields().get(i).type()); + } + } + return new GenericRow(data); + default: + throw new IllegalArgumentException("Unhandled type " + type); + } + } + + private static Object getPrimitiveValue(Row row, int ord, Type type) { + if (row.isNullAt(ord)) { + return null; + } + switch (type.typeId()) { + case BOOLEAN: + return row.getBoolean(ord); + case INTEGER: + return row.getInt(ord); + case LONG: + return row.getLong(ord); + case FLOAT: + return row.getFloat(ord); + case DOUBLE: + return row.getDouble(ord); + case STRING: + return row.getString(ord); + case BINARY: + case FIXED: + case UUID: + return row.get(ord); + case DATE: + return row.getDate(ord); + case TIMESTAMP: + return row.getTimestamp(ord); + case DECIMAL: + return row.getDecimal(ord); + default: + throw new IllegalArgumentException("Unhandled type " + type); + } + } + + private static Map toJavaMap(scala.collection.Map map) { + return map == null ? null : mapAsJavaMapConverter(map).asJava(); + } + + private static List toList(Seq val) { + return val == null ? null : seqAsJavaListConverter(val).asJava(); + } + + private static void assertEqualBytes(String context, byte[] expected, byte[] actual) { + assertThat(actual).as(context).isEqualTo(expected); + } + + static void assertEquals(Schema schema, Object expected, Object actual) { + assertEquals("schema", convert(schema), expected, actual); + } + + private static void assertEquals(String context, DataType type, Object expected, Object actual) { + if (expected == null && actual == null) { + return; + } + + if (type instanceof StructType) { + assertThat(expected) + .as("Expected should be an InternalRow: " + context) + .isInstanceOf(InternalRow.class); + assertThat(actual) + .as("Actual should be an InternalRow: " + context) + .isInstanceOf(InternalRow.class); + assertEquals(context, (StructType) type, (InternalRow) expected, (InternalRow) actual); + + } else if (type instanceof ArrayType) { + assertThat(expected) + .as("Expected should be an ArrayData: " + context) + .isInstanceOf(ArrayData.class); + assertThat(actual) + .as("Actual should be an ArrayData: " + context) + .isInstanceOf(ArrayData.class); + assertEquals(context, (ArrayType) type, (ArrayData) expected, (ArrayData) actual); + + } else if (type instanceof MapType) { + assertThat(expected) + .as("Expected should be a MapData: " + context) + .isInstanceOf(MapData.class); + assertThat(actual).as("Actual should be a MapData: " + context).isInstanceOf(MapData.class); + assertEquals(context, (MapType) type, (MapData) expected, (MapData) actual); + + } else if (type instanceof BinaryType) { + assertEqualBytes(context, (byte[]) expected, (byte[]) actual); + } else { + assertThat(actual).as("Value should match expected: " + context).isEqualTo(expected); + } + } + + private static void assertEquals( + String context, StructType struct, InternalRow expected, InternalRow actual) { + assertThat(actual.numFields()) + .as("Should have correct number of fields") + .isEqualTo(struct.size()); + for (int i = 0; i < actual.numFields(); i += 1) { + StructField field = struct.fields()[i]; + DataType type = field.dataType(); + + assertEquals( + context + "." + field.name(), + type, + expected.isNullAt(i) ? null : expected.get(i, type), + actual.isNullAt(i) ? null : actual.get(i, type)); + } + } + + private static void assertEquals( + String context, ArrayType array, ArrayData expected, ArrayData actual) { + assertThat(actual.numElements()) + .as("Should have the same number of elements") + .isEqualTo(expected.numElements()); + DataType type = array.elementType(); + for (int i = 0; i < actual.numElements(); i += 1) { + assertEquals( + context + ".element", + type, + expected.isNullAt(i) ? null : expected.get(i, type), + actual.isNullAt(i) ? null : actual.get(i, type)); + } + } + + private static void assertEquals(String context, MapType map, MapData expected, MapData actual) { + assertThat(actual.numElements()) + .as("Should have the same number of elements") + .isEqualTo(expected.numElements()); + + DataType keyType = map.keyType(); + ArrayData expectedKeys = expected.keyArray(); + ArrayData expectedValues = expected.valueArray(); + + DataType valueType = map.valueType(); + ArrayData actualKeys = actual.keyArray(); + ArrayData actualValues = actual.valueArray(); + + for (int i = 0; i < actual.numElements(); i += 1) { + assertEquals( + context + ".key", + keyType, + expectedKeys.isNullAt(i) ? null : expectedKeys.get(i, keyType), + actualKeys.isNullAt(i) ? null : actualKeys.get(i, keyType)); + assertEquals( + context + ".value", + valueType, + expectedValues.isNullAt(i) ? null : expectedValues.get(i, valueType), + actualValues.isNullAt(i) ? null : actualValues.get(i, valueType)); + } + } + + public static List dataManifests(Table table) { + return table.currentSnapshot().dataManifests(table.io()); + } + + public static List deleteManifests(Table table) { + return table.currentSnapshot().deleteManifests(table.io()); + } + + public static List dataFiles(Table table) { + return dataFiles(table, null); + } + + public static List dataFiles(Table table, String branch) { + TableScan scan = table.newScan(); + if (branch != null) { + scan = scan.useRef(branch); + } + + CloseableIterable tasks = scan.includeColumnStats().planFiles(); + return Lists.newArrayList(CloseableIterable.transform(tasks, FileScanTask::file)); + } + + public static Set deleteFiles(Table table) { + DeleteFileSet deleteFiles = DeleteFileSet.create(); + + for (FileScanTask task : table.newScan().planFiles()) { + deleteFiles.addAll(task.deletes()); + } + + return deleteFiles; + } + + public static Set deleteFiles(Table table, Snapshot snapshot) { + DeleteFileSet deleteFiles = DeleteFileSet.create(); + + for (FileScanTask task : table.newScan().useSnapshot(snapshot.snapshotId()).planFiles()) { + deleteFiles.addAll(task.deletes()); + } + + return deleteFiles; + } + + public static Set reachableManifestPaths(Table table) { + return StreamSupport.stream(table.snapshots().spliterator(), false) + .flatMap(s -> s.allManifests(table.io()).stream()) + .map(ManifestFile::path) + .collect(Collectors.toSet()); + } + + public static void asMetadataRecord(GenericData.Record file, FileContent content) { + file.put(0, content.id()); + file.put(3, 0); // specId + } + + public static void asMetadataRecord(GenericData.Record file) { + file.put(0, FileContent.DATA.id()); + file.put(3, 0); // specId + } + + // suppress the readable metrics and first-row-id that are not in manifest files + private static final Set DERIVED_FIELDS = Set.of("readable_metrics", "first_row_id"); + + public static Dataset selectNonDerived(Dataset metadataTable) { + StructField[] fields = metadataTable.schema().fields(); + return metadataTable.select( + Stream.of(fields) + .filter(f -> !DERIVED_FIELDS.contains(f.name())) + .map(f -> new Column(f.name())) + .toArray(Column[]::new)); + } + + public static Types.StructType nonDerivedSchema(Dataset metadataTable) { + return SparkSchemaUtil.convert(TestHelpers.selectNonDerived(metadataTable).schema()).asStruct(); + } + + public static class CustomizedDeleteFilter extends DeleteFilter { + private final boolean hasDeletes; + + protected CustomizedDeleteFilter( + boolean hasDeletes, Schema tableSchema, Schema projectedSchema) { + super("", List.of(), tableSchema::findField, projectedSchema, new DeleteCounter(), true); + this.hasDeletes = hasDeletes; + } + + @Override + protected StructLike asStructLike(InternalRow record) { + return null; + } + + @Override + protected InputFile getInputFile(String location) { + return null; + } + + @Override + public boolean hasPosDeletes() { + return hasDeletes; + } + + @Override + public PositionDeleteIndex deletedRowPositions() { + PositionDeleteIndex deletedRowPos = new CustomizedPositionDeleteIndex(); + if (hasDeletes) { + deletedRowPos.delete(98, 103); + } + + return deletedRowPos; + } + } + + public static class CustomizedPositionDeleteIndex implements PositionDeleteIndex { + private final Set deleteIndex; + + private CustomizedPositionDeleteIndex() { + deleteIndex = Sets.newHashSet(); + } + + @Override + public void delete(long position) { + deleteIndex.add(position); + } + + @Override + public void delete(long posStart, long posEnd) { + for (long l = posStart; l < posEnd; l++) { + delete(l); + } + } + + @Override + public boolean isDeleted(long position) { + return deleteIndex.contains(position); + } + + @Override + public boolean isEmpty() { + return deleteIndex.isEmpty(); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestOrcWrite.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestOrcWrite.java new file mode 100644 index 000000000000..e149e57e8144 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestOrcWrite.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.orc.ORC; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestOrcWrite { + @TempDir private Path temp; + + private static final Schema SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + + @Test + public void splitOffsets() throws IOException { + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + Iterable rows = RandomData.generateSpark(SCHEMA, 1, 0L); + FileAppender writer = + ORC.write(Files.localOutput(testFile)) + .createWriterFunc(SparkOrcWriter::new) + .schema(SCHEMA) + .build(); + + writer.addAll(rows); + writer.close(); + assertThat(writer.splitOffsets()).as("Split offsets not present").isNotEmpty(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestParquetAvroReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestParquetAvroReader.java new file mode 100644 index 000000000000..3f9b4bb587ba --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestParquetAvroReader.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Iterator; +import org.apache.avro.generic.GenericData.Record; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.parquet.ParquetAvroValueReaders; +import org.apache.iceberg.parquet.ParquetSchemaUtil; +import org.apache.iceberg.types.Types; +import org.apache.parquet.schema.MessageType; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestParquetAvroReader { + @TempDir private Path temp; + + private static final Schema COMPLEX_SCHEMA = + new Schema( + required(1, "roots", Types.LongType.get()), + optional(3, "lime", Types.ListType.ofRequired(4, Types.DoubleType.get())), + required( + 5, + "strict", + Types.StructType.of( + required(9, "tangerine", Types.StringType.get()), + optional( + 6, + "hopeful", + Types.StructType.of( + required(7, "steel", Types.FloatType.get()), + required(8, "lantern", Types.DateType.get()))), + optional(10, "vehement", Types.LongType.get()))), + optional( + 11, + "metamorphosis", + Types.MapType.ofRequired( + 12, 13, Types.StringType.get(), Types.TimestampType.withoutZone())), + required( + 14, + "winter", + Types.ListType.ofOptional( + 15, + Types.StructType.of( + optional(16, "beet", Types.DoubleType.get()), + required(17, "stamp", Types.TimeType.get()), + optional(18, "wheeze", Types.StringType.get())))), + optional( + 19, + "renovate", + Types.MapType.ofRequired( + 20, + 21, + Types.StringType.get(), + Types.StructType.of( + optional(22, "jumpy", Types.DoubleType.get()), + required(23, "koala", Types.TimeType.get()), + required(24, "couch rope", Types.IntegerType.get())))), + optional(2, "slide", Types.StringType.get()), + required(25, "foo", Types.DecimalType.of(7, 5))); + + @Disabled + public void testStructSchema() throws IOException { + Schema structSchema = + new Schema( + required(1, "circumvent", Types.LongType.get()), + optional(2, "antarctica", Types.StringType.get()), + optional(3, "fluent", Types.DoubleType.get()), + required( + 4, + "quell", + Types.StructType.of( + required(5, "operator", Types.BooleanType.get()), + optional(6, "fanta", Types.IntegerType.get()), + optional(7, "cable", Types.FloatType.get()))), + required(8, "chimney", Types.TimestampType.withZone()), + required(9, "wool", Types.DateType.get())); + + File testFile = writeTestData(structSchema, 5_000_000, 1059); + // RandomData uses the root record name "test", which must match for records to be equal + MessageType readSchema = ParquetSchemaUtil.convert(structSchema, "test"); + + long sum = 0; + long sumSq = 0; + int warmups = 2; + int trials = 10; + + for (int i = 0; i < warmups + trials; i += 1) { + // clean up as much memory as possible to avoid a large GC during the timed run + System.gc(); + + try (CloseableIterable reader = + Parquet.read(Files.localInput(testFile)) + .project(structSchema) + .createReaderFunc( + fileSchema -> ParquetAvroValueReaders.buildReader(structSchema, readSchema)) + .build()) { + long start = System.currentTimeMillis(); + long val = 0; + long count = 0; + for (Record record : reader) { + // access something to ensure the compiler doesn't optimize this away + val ^= (Long) record.get(0); + count += 1; + } + long end = System.currentTimeMillis(); + long duration = end - start; + + if (i >= warmups) { + sum += duration; + sumSq += duration * duration; + } + } + } + + double mean = ((double) sum) / trials; + double stddev = Math.sqrt((((double) sumSq) / trials) - (mean * mean)); + } + + @Disabled + public void testWithOldReadPath() throws IOException { + File testFile = writeTestData(COMPLEX_SCHEMA, 500_000, 1985); + // RandomData uses the root record name "test", which must match for records to be equal + MessageType readSchema = ParquetSchemaUtil.convert(COMPLEX_SCHEMA, "test"); + + for (int i = 0; i < 5; i += 1) { + // clean up as much memory as possible to avoid a large GC during the timed run + System.gc(); + + try (CloseableIterable reader = + Parquet.read(Files.localInput(testFile)).project(COMPLEX_SCHEMA).build()) { + long start = System.currentTimeMillis(); + long val = 0; + long count = 0; + for (Record record : reader) { + // access something to ensure the compiler doesn't optimize this away + val ^= (Long) record.get(0); + count += 1; + } + long end = System.currentTimeMillis(); + } + + // clean up as much memory as possible to avoid a large GC during the timed run + System.gc(); + + try (CloseableIterable reader = + Parquet.read(Files.localInput(testFile)) + .project(COMPLEX_SCHEMA) + .createReaderFunc( + fileSchema -> ParquetAvroValueReaders.buildReader(COMPLEX_SCHEMA, readSchema)) + .build()) { + long start = System.currentTimeMillis(); + long val = 0; + long count = 0; + for (Record record : reader) { + // access something to ensure the compiler doesn't optimize this away + val ^= (Long) record.get(0); + count += 1; + } + long end = System.currentTimeMillis(); + } + } + } + + @Test + public void testCorrectness() throws IOException { + Iterable records = RandomData.generate(COMPLEX_SCHEMA, 50_000, 34139); + + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (FileAppender writer = + Parquet.write(Files.localOutput(testFile)).schema(COMPLEX_SCHEMA).build()) { + writer.addAll(records); + } + + // RandomData uses the root record name "test", which must match for records to be equal + MessageType readSchema = ParquetSchemaUtil.convert(COMPLEX_SCHEMA, "test"); + + // verify that the new read path is correct + try (CloseableIterable reader = + Parquet.read(Files.localInput(testFile)) + .project(COMPLEX_SCHEMA) + .createReaderFunc( + fileSchema -> ParquetAvroValueReaders.buildReader(COMPLEX_SCHEMA, readSchema)) + .reuseContainers() + .build()) { + int recordNum = 0; + Iterator iter = records.iterator(); + for (Record actual : reader) { + Record expected = iter.next(); + assertThat(actual).as("Record " + recordNum + " should match expected").isEqualTo(expected); + recordNum += 1; + } + } + } + + private File writeTestData(Schema schema, int numRecords, int seed) throws IOException { + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (FileAppender writer = + Parquet.write(Files.localOutput(testFile)).schema(schema).build()) { + writer.addAll(RandomData.generate(schema, numRecords, seed)); + } + + return testFile; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestParquetAvroWriter.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestParquetAvroWriter.java new file mode 100644 index 000000000000..83f8f7f168b1 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestParquetAvroWriter.java @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Iterator; +import org.apache.avro.generic.GenericData.Record; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.parquet.ParquetAvroValueReaders; +import org.apache.iceberg.parquet.ParquetAvroWriter; +import org.apache.iceberg.parquet.ParquetSchemaUtil; +import org.apache.iceberg.types.Types; +import org.apache.parquet.schema.MessageType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestParquetAvroWriter { + @TempDir private Path temp; + + private static final Schema COMPLEX_SCHEMA = + new Schema( + required(1, "roots", Types.LongType.get()), + optional(3, "lime", Types.ListType.ofRequired(4, Types.DoubleType.get())), + required( + 5, + "strict", + Types.StructType.of( + required(9, "tangerine", Types.StringType.get()), + optional( + 6, + "hopeful", + Types.StructType.of( + required(7, "steel", Types.FloatType.get()), + required(8, "lantern", Types.DateType.get()))), + optional(10, "vehement", Types.LongType.get()))), + optional( + 11, + "metamorphosis", + Types.MapType.ofRequired( + 12, 13, Types.StringType.get(), Types.TimestampType.withoutZone())), + required( + 14, + "winter", + Types.ListType.ofOptional( + 15, + Types.StructType.of( + optional(16, "beet", Types.DoubleType.get()), + required(17, "stamp", Types.TimeType.get()), + optional(18, "wheeze", Types.StringType.get())))), + optional( + 19, + "renovate", + Types.MapType.ofRequired( + 20, + 21, + Types.StringType.get(), + Types.StructType.of( + optional(22, "jumpy", Types.DoubleType.get()), + required(23, "koala", Types.TimeType.get()), + required(24, "couch rope", Types.IntegerType.get())))), + optional(2, "slide", Types.StringType.get())); + + @Test + public void testCorrectness() throws IOException { + Iterable records = RandomData.generate(COMPLEX_SCHEMA, 50_000, 34139); + + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (FileAppender writer = + Parquet.write(Files.localOutput(testFile)) + .schema(COMPLEX_SCHEMA) + .createWriterFunc(ParquetAvroWriter::buildWriter) + .build()) { + writer.addAll(records); + } + + // RandomData uses the root record name "test", which must match for records to be equal + MessageType readSchema = ParquetSchemaUtil.convert(COMPLEX_SCHEMA, "test"); + + // verify that the new read path is correct + try (CloseableIterable reader = + Parquet.read(Files.localInput(testFile)) + .project(COMPLEX_SCHEMA) + .createReaderFunc( + fileSchema -> ParquetAvroValueReaders.buildReader(COMPLEX_SCHEMA, readSchema)) + .build()) { + int recordNum = 0; + Iterator iter = records.iterator(); + for (Record actual : reader) { + Record expected = iter.next(); + assertThat(actual).as("Record " + recordNum + " should match expected").isEqualTo(expected); + recordNum += 1; + } + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkAvroEnums.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkAvroEnums.java new file mode 100644 index 000000000000..0dc8b48b2317 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkAvroEnums.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import org.apache.avro.SchemaBuilder; +import org.apache.avro.file.DataFileWriter; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericData.Record; +import org.apache.avro.generic.GenericDatumWriter; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.avro.AvroIterable; +import org.apache.iceberg.avro.AvroSchemaUtil; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestSparkAvroEnums { + + @TempDir private Path temp; + + @Test + public void writeAndValidateEnums() throws IOException { + org.apache.avro.Schema avroSchema = + SchemaBuilder.record("root") + .fields() + .name("enumCol") + .type() + .nullable() + .enumeration("testEnum") + .symbols("SYMB1", "SYMB2") + .enumDefault("SYMB2") + .endRecord(); + + org.apache.avro.Schema enumSchema = avroSchema.getField("enumCol").schema().getTypes().get(0); + Record enumRecord1 = new GenericData.Record(avroSchema); + enumRecord1.put("enumCol", new GenericData.EnumSymbol(enumSchema, "SYMB1")); + Record enumRecord2 = new GenericData.Record(avroSchema); + enumRecord2.put("enumCol", new GenericData.EnumSymbol(enumSchema, "SYMB2")); + Record enumRecord3 = new GenericData.Record(avroSchema); // null enum + List expected = ImmutableList.of(enumRecord1, enumRecord2, enumRecord3); + + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (DataFileWriter writer = new DataFileWriter<>(new GenericDatumWriter<>())) { + writer.create(avroSchema, testFile); + writer.append(enumRecord1); + writer.append(enumRecord2); + writer.append(enumRecord3); + } + + Schema schema = new Schema(AvroSchemaUtil.convert(avroSchema).asStructType().fields()); + List rows; + try (AvroIterable reader = + Avro.read(Files.localInput(testFile)) + .createResolvingReader(SparkPlannedAvroReader::create) + .project(schema) + .build()) { + rows = Lists.newArrayList(reader); + } + + // Iceberg will return enums as strings, so we compare string values for the enum field + for (int i = 0; i < expected.size(); i += 1) { + String expectedEnumString = + expected.get(i).get("enumCol") == null ? null : expected.get(i).get("enumCol").toString(); + String sparkString = + rows.get(i).getUTF8String(0) == null ? null : rows.get(i).getUTF8String(0).toString(); + assertThat(sparkString).isEqualTo(expectedEnumString); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkAvroReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkAvroReader.java new file mode 100644 index 000000000000..2d844b7bcea0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkAvroReader.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.avro.AvroIterable; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.spark.sql.catalyst.InternalRow; + +public class TestSparkAvroReader extends AvroDataTestBase { + @Override + protected void writeAndValidate(Schema schema) throws IOException { + writeAndValidate(schema, schema); + } + + @Override + protected void writeAndValidate( + Schema writeSchema, Schema expectedSchema, List records) + throws IOException { + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (DataWriter dataWriter = + Avro.writeData(Files.localOutput(testFile)) + .schema(writeSchema) + .createWriterFunc(org.apache.iceberg.data.avro.DataWriter::create) + .withSpec(PartitionSpec.unpartitioned()) + .build()) { + for (org.apache.iceberg.data.Record rec : records) { + dataWriter.write(rec); + } + } + + List rows; + try (AvroIterable reader = + Avro.read(Files.localInput(testFile)) + .createResolvingReader(schema -> SparkPlannedAvroReader.create(schema, ID_TO_CONSTANT)) + .project(expectedSchema) + .build()) { + rows = Lists.newArrayList(reader); + } + + for (int i = 0; i < records.size(); i += 1) { + GenericsHelpers.assertEqualsUnsafe( + expectedSchema.asStruct(), records.get(i), rows.get(i), ID_TO_CONSTANT, i); + } + } + + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + List expected = RandomGenericData.generate(writeSchema, 100, 0L); + writeAndValidate(writeSchema, expectedSchema, expected); + } + + @Override + protected boolean supportsDefaultValues() { + return true; + } + + @Override + protected boolean supportsRowLineage() { + return true; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkDateTimes.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkDateTimes.java new file mode 100644 index 000000000000..dbe386e89980 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkDateTimes.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZoneId; +import java.util.TimeZone; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.util.DateTimeUtils; +import org.apache.spark.sql.catalyst.util.TimestampFormatter; +import org.junit.jupiter.api.Test; + +public class TestSparkDateTimes { + @Test + public void testSparkDate() { + // checkSparkDate("1582-10-14"); // -141428 + checkSparkDate("1582-10-15"); // first day of the gregorian calendar + checkSparkDate("1601-08-12"); + checkSparkDate("1801-07-04"); + checkSparkDate("1901-08-12"); + checkSparkDate("1969-12-31"); + checkSparkDate("1970-01-01"); + checkSparkDate("2017-12-25"); + checkSparkDate("2043-08-11"); + checkSparkDate("2111-05-03"); + checkSparkDate("2224-02-29"); + checkSparkDate("3224-10-05"); + } + + public void checkSparkDate(String dateString) { + Literal date = Literal.of(dateString).to(Types.DateType.get()); + assertThat(DateTimeUtils.toJavaDate(date.value())) + .as("Should be the same date (" + date.value() + ")") + .asString() + .isEqualTo(dateString); + } + + @Test + public void testSparkTimestamp() { + TimeZone currentTz = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + checkSparkTimestamp("1582-10-15T15:51:08.440219+00:00", "1582-10-15 15:51:08.440219"); + checkSparkTimestamp("1970-01-01T00:00:00.000000+00:00", "1970-01-01 00:00:00"); + checkSparkTimestamp("2043-08-11T12:30:01.000001+00:00", "2043-08-11 12:30:01.000001"); + } finally { + TimeZone.setDefault(currentTz); + } + } + + public void checkSparkTimestamp(String timestampString, String sparkRepr) { + Literal ts = Literal.of(timestampString).to(Types.TimestampType.withZone()); + ZoneId zoneId = DateTimeUtils.getZoneId("UTC"); + TimestampFormatter formatter = TimestampFormatter.getFractionFormatter(zoneId); + String sparkTimestamp = formatter.format(ts.value()); + assertThat(sparkTimestamp) + .as("Should be the same timestamp (" + ts.value() + ")") + .isEqualTo(sparkRepr); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java new file mode 100644 index 000000000000..291bb2bca4f5 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkFormatModel.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.BaseFormatModelTests; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkUtil; +import org.apache.iceberg.types.Type; +import org.apache.spark.sql.catalyst.InternalRow; + +public class TestSparkFormatModel extends BaseFormatModelTests { + + @Override + protected Class engineType() { + return InternalRow.class; + } + + @Override + protected Object engineSchema(Schema schema) { + return SparkSchemaUtil.convert(schema); + } + + @Override + protected InternalRow convertToEngine(Record record, Schema schema) { + return InternalRowConverter.convert(schema, record); + } + + @Override + protected void assertEquals(Schema schema, List expected, List actual) { + assertThat(actual).hasSameSizeAs(expected); + for (int i = 0; i < expected.size(); i++) { + TestHelpers.assertEquals(schema, expected.get(i), actual.get(i)); + } + } + + @Override + protected Object convertConstantToEngine(Type type, Object value) { + return SparkUtil.internalToSpark(type, value); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java new file mode 100644 index 000000000000..13acaa1e3a7b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReadMetadataColumns.java @@ -0,0 +1,255 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.orc.ORC; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.spark.source.BatchReaderUtil; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.orc.OrcConf; +import org.apache.orc.OrcFile; +import org.apache.orc.Reader; +import org.apache.orc.StripeInformation; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.apache.spark.unsafe.types.UTF8String; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkOrcReadMetadataColumns { + private static final Schema DATA_SCHEMA = + new Schema( + required(100, "id", Types.LongType.get()), required(101, "data", Types.StringType.get())); + + private static final Schema PROJECTION_SCHEMA = + new Schema( + required(100, "id", Types.LongType.get()), + required(101, "data", Types.StringType.get()), + MetadataColumns.ROW_POSITION, + MetadataColumns.IS_DELETED); + + private static final DeleteFilter NO_DELETES_FILTER = + new TestHelpers.CustomizedDeleteFilter(false, DATA_SCHEMA, PROJECTION_SCHEMA); + + private static final int NUM_ROWS = 1000; + private static final int RECORDS_PER_BATCH = 10; + private static final List DATA_ROWS; + private static final List EXPECTED_ROWS; + + static { + DATA_ROWS = Lists.newArrayListWithCapacity(NUM_ROWS); + for (long i = 0; i < NUM_ROWS; i++) { + InternalRow row = new GenericInternalRow(DATA_SCHEMA.columns().size()); + row.update(0, i); + row.update(1, UTF8String.fromString("str" + i)); + DATA_ROWS.add(row); + } + + EXPECTED_ROWS = Lists.newArrayListWithCapacity(NUM_ROWS); + for (long i = 0; i < NUM_ROWS; i++) { + InternalRow row = new GenericInternalRow(PROJECTION_SCHEMA.columns().size()); + row.update(0, i); + row.update(1, UTF8String.fromString("str" + i)); + row.update(2, i); + row.update(3, false); + EXPECTED_ROWS.add(row); + } + } + + @Parameters(name = "vectorized = {0}") + public static Collection parameters() { + return Arrays.asList(false, true); + } + + @TempDir private java.nio.file.Path temp; + + @Parameter private boolean vectorized; + private File testFile; + + @BeforeEach + public void writeFile() throws IOException { + testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (FileAppender writer = + ORC.write(Files.localOutput(testFile)) + .createWriterFunc(SparkOrcWriter::new) + .schema(DATA_SCHEMA) + // write in such a way that the file contains 10 stripes each with 100 rows + .set("iceberg.orc.vectorbatch.size", "100") + .set(OrcConf.ROWS_BETWEEN_CHECKS.getAttribute(), "100") + .set(OrcConf.STRIPE_SIZE.getAttribute(), "1") + .build()) { + writer.addAll(DATA_ROWS); + } + } + + @TestTemplate + public void testReadRowNumbers() throws IOException { + readAndValidate(null, null, null, EXPECTED_ROWS, NO_DELETES_FILTER); + } + + @TestTemplate + public void testReadRowNumbersWithDelete() throws IOException { + assumeThat(vectorized).isTrue(); + + List expectedRowsAfterDelete = Lists.newArrayList(); + EXPECTED_ROWS.forEach(row -> expectedRowsAfterDelete.add(row.copy())); + // remove row at position 98, 99, 100, 101, 102, this crosses two row groups [0, 100) and [100, + // 200) + for (int i = 98; i <= 102; i++) { + expectedRowsAfterDelete.get(i).update(3, true); + } + + DeleteFilter deleteFilter = + new TestHelpers.CustomizedDeleteFilter(true, DATA_SCHEMA, PROJECTION_SCHEMA); + + readAndValidate(null, null, null, expectedRowsAfterDelete, deleteFilter); + } + + @TestTemplate + public void testReadRowNumbersWithFilter() throws IOException { + readAndValidate( + Expressions.greaterThanOrEqual("id", 500), + null, + null, + EXPECTED_ROWS.subList(500, 1000), + NO_DELETES_FILTER); + } + + @TestTemplate + public void testReadRowNumbersWithSplits() throws IOException { + Reader reader; + try { + OrcFile.ReaderOptions readerOptions = + OrcFile.readerOptions(new Configuration()).useUTCTimestamp(true); + reader = OrcFile.createReader(new Path(testFile.toString()), readerOptions); + } catch (IOException ioe) { + throw new RuntimeIOException(ioe, "Failed to open file: %s", testFile); + } + List splitOffsets = + reader.getStripes().stream().map(StripeInformation::getOffset).collect(Collectors.toList()); + List splitLengths = + reader.getStripes().stream().map(StripeInformation::getLength).collect(Collectors.toList()); + + for (int i = 0; i < 10; i++) { + readAndValidate( + null, + splitOffsets.get(i), + splitLengths.get(i), + EXPECTED_ROWS.subList(i * 100, (i + 1) * 100), + NO_DELETES_FILTER); + } + } + + private void readAndValidate( + Expression filter, + Long splitStart, + Long splitLength, + List expected, + DeleteFilter deleteFilter) + throws IOException { + Schema projectionWithoutMetadataFields = + TypeUtil.selectNot(PROJECTION_SCHEMA, MetadataColumns.metadataFieldIds()); + CloseableIterable reader = null; + try { + ORC.ReadBuilder builder = + ORC.read(Files.localInput(testFile)).project(projectionWithoutMetadataFields); + + if (vectorized) { + builder = + builder + .recordsPerBatch(RECORDS_PER_BATCH) + .createBatchedReaderFunc( + readOrcSchema -> + VectorizedSparkOrcReaders.buildReader( + PROJECTION_SCHEMA, readOrcSchema, ImmutableMap.of())); + } else { + builder = + builder.createReaderFunc( + readOrcSchema -> new SparkOrcReader(PROJECTION_SCHEMA, readOrcSchema)); + } + + if (filter != null) { + builder = builder.filter(filter); + } + + if (splitStart != null && splitLength != null) { + builder = builder.split(splitStart, splitLength); + } + + if (vectorized) { + reader = batchesToRows(BatchReaderUtil.applyDeleteFilter(builder.build(), deleteFilter)); + } else { + reader = builder.build(); + } + + final Iterator actualRows = reader.iterator(); + final Iterator expectedRows = expected.iterator(); + while (expectedRows.hasNext()) { + assertThat(actualRows).as("Should have expected number of rows").hasNext(); + TestHelpers.assertEquals(PROJECTION_SCHEMA, expectedRows.next(), actualRows.next()); + } + assertThat(actualRows).as("Should not have extra rows").isExhausted(); + } finally { + if (reader != null) { + reader.close(); + } + } + } + + private CloseableIterable batchesToRows(CloseableIterable batches) { + return CloseableIterable.combine( + Iterables.concat(Iterables.transform(batches, b -> (Iterable) b::rowIterator)), + batches); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java new file mode 100644 index 000000000000..3fcfe6845c99 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkOrcReader.java @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.spark.data.TestHelpers.assertEquals; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.orc.ORC; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterators; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.junit.jupiter.api.Test; + +public class TestSparkOrcReader extends AvroDataTestBase { + @Override + protected void writeAndValidate(Schema schema) throws IOException { + final Iterable expected = RandomData.generateSpark(schema, 100, 0L); + + writeAndValidateRecords(schema, expected); + } + + @Test + public void writeAndValidateRepeatingRecords() throws IOException { + Schema structSchema = + new Schema( + required(100, "id", Types.LongType.get()), + required(101, "data", Types.StringType.get())); + List expectedRepeating = + Collections.nCopies(100, RandomData.generateSpark(structSchema, 1, 0L).iterator().next()); + + writeAndValidateRecords(structSchema, expectedRepeating); + } + + private void writeAndValidateRecords(Schema schema, Iterable expected) + throws IOException { + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (FileAppender writer = + ORC.write(Files.localOutput(testFile)) + .createWriterFunc(SparkOrcWriter::new) + .schema(schema) + .build()) { + writer.addAll(expected); + } + + try (CloseableIterable reader = + ORC.read(Files.localInput(testFile)) + .project(schema) + .createReaderFunc(readOrcSchema -> new SparkOrcReader(schema, readOrcSchema)) + .build()) { + final Iterator actualRows = reader.iterator(); + final Iterator expectedRows = expected.iterator(); + while (expectedRows.hasNext()) { + assertThat(actualRows).as("Should have expected number of rows").hasNext(); + assertEquals(schema, expectedRows.next(), actualRows.next()); + } + assertThat(actualRows).as("Should not have extra rows").isExhausted(); + } + + try (CloseableIterable reader = + ORC.read(Files.localInput(testFile)) + .project(schema) + .createBatchedReaderFunc( + readOrcSchema -> + VectorizedSparkOrcReaders.buildReader(schema, readOrcSchema, ImmutableMap.of())) + .build()) { + final Iterator actualRows = batchesToRows(reader.iterator()); + final Iterator expectedRows = expected.iterator(); + while (expectedRows.hasNext()) { + assertThat(actualRows).as("Should have expected number of rows").hasNext(); + assertEquals(schema, expectedRows.next(), actualRows.next()); + } + assertThat(actualRows).as("Should not have extra rows").isExhausted(); + } + } + + private Iterator batchesToRows(Iterator batches) { + return Iterators.concat(Iterators.transform(batches, ColumnarBatch::rowIterator)); + } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java new file mode 100644 index 000000000000..e2e5a98ccb8b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReadMetadataColumns.java @@ -0,0 +1,283 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.util.Iterator; +import java.util.List; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.parquet.ParquetSchemaUtil; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; +import org.apache.iceberg.spark.source.BatchReaderUtil; +import org.apache.iceberg.types.Types; +import org.apache.parquet.ParquetReadOptions; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.ParquetFileWriter; +import org.apache.parquet.hadoop.metadata.BlockMetaData; +import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.apache.spark.unsafe.types.UTF8String; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkParquetReadMetadataColumns { + private static final Schema DATA_SCHEMA = + new Schema( + required(100, "id", Types.LongType.get()), required(101, "data", Types.StringType.get())); + + private static final Schema PROJECTION_SCHEMA = + new Schema( + required(100, "id", Types.LongType.get()), + required(101, "data", Types.StringType.get()), + MetadataColumns.ROW_POSITION, + MetadataColumns.IS_DELETED); + + private static final int NUM_ROWS = 1000; + private static final List DATA_ROWS; + private static final List EXPECTED_ROWS; + private static final int NUM_ROW_GROUPS = 10; + private static final int ROWS_PER_SPLIT = NUM_ROWS / NUM_ROW_GROUPS; + private static final int RECORDS_PER_BATCH = ROWS_PER_SPLIT / 10; + + static { + DATA_ROWS = Lists.newArrayListWithCapacity(NUM_ROWS); + for (long i = 0; i < NUM_ROWS; i += 1) { + InternalRow row = new GenericInternalRow(DATA_SCHEMA.columns().size()); + if (i >= NUM_ROWS / 2) { + row.update(0, 2 * i); + } else { + row.update(0, i); + } + row.update(1, UTF8String.fromString("str" + i)); + DATA_ROWS.add(row); + } + + EXPECTED_ROWS = Lists.newArrayListWithCapacity(NUM_ROWS); + for (long i = 0; i < NUM_ROWS; i += 1) { + InternalRow row = new GenericInternalRow(PROJECTION_SCHEMA.columns().size()); + if (i >= NUM_ROWS / 2) { + row.update(0, 2 * i); + } else { + row.update(0, i); + } + row.update(1, UTF8String.fromString("str" + i)); + row.update(2, i); + row.update(3, false); + EXPECTED_ROWS.add(row); + } + } + + @Parameters(name = "vectorized = {0}") + public static Object[][] parameters() { + return new Object[][] {new Object[] {false}, new Object[] {true}}; + } + + @TempDir protected java.nio.file.Path temp; + + @Parameter private boolean vectorized; + private File testFile; + + @BeforeEach + public void writeFile() throws IOException { + List fileSplits = Lists.newArrayList(); + StructType struct = SparkSchemaUtil.convert(DATA_SCHEMA); + Configuration conf = new Configuration(); + + testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + ParquetFileWriter parquetFileWriter = + new ParquetFileWriter( + conf, + ParquetSchemaUtil.convert(DATA_SCHEMA, "testSchema"), + new Path(testFile.getAbsolutePath())); + + parquetFileWriter.start(); + for (int i = 0; i < NUM_ROW_GROUPS; i += 1) { + File split = File.createTempFile("junit", null, temp.toFile()); + assertThat(split.delete()).as("Delete should succeed").isTrue(); + fileSplits.add(new Path(split.getAbsolutePath())); + try (FileAppender writer = + Parquet.write(Files.localOutput(split)) + .createWriterFunc(msgType -> SparkParquetWriters.buildWriter(struct, msgType)) + .schema(DATA_SCHEMA) + .overwrite() + .build()) { + writer.addAll(DATA_ROWS.subList(i * ROWS_PER_SPLIT, (i + 1) * ROWS_PER_SPLIT)); + } + parquetFileWriter.appendFile( + HadoopInputFile.fromPath(new Path(split.getAbsolutePath()), conf)); + } + parquetFileWriter.end( + ParquetFileWriter.mergeMetadataFiles(fileSplits, conf) + .getFileMetaData() + .getKeyValueMetaData()); + } + + @TestTemplate + public void testReadRowNumbers() throws IOException { + readAndValidate(null, null, null, EXPECTED_ROWS); + } + + @TestTemplate + public void testReadRowNumbersWithDelete() throws IOException { + assumeThat(vectorized).isTrue(); + + List expectedRowsAfterDelete = Lists.newArrayList(); + EXPECTED_ROWS.forEach(row -> expectedRowsAfterDelete.add(row.copy())); + // remove row at position 98, 99, 100, 101, 102, this crosses two row groups [0, 100) and [100, + // 200) + for (int i = 98; i <= 102; i++) { + expectedRowsAfterDelete.get(i).update(3, true); + } + + Parquet.ReadBuilder builder = + Parquet.read(Files.localInput(testFile)).project(PROJECTION_SCHEMA); + + DeleteFilter deleteFilter = + new TestHelpers.CustomizedDeleteFilter(true, DATA_SCHEMA, PROJECTION_SCHEMA); + + builder.createBatchedReaderFunc( + fileSchema -> + VectorizedSparkParquetReaders.buildReader( + PROJECTION_SCHEMA, fileSchema, Maps.newHashMap())); + builder.recordsPerBatch(RECORDS_PER_BATCH); + + validate(expectedRowsAfterDelete, builder, deleteFilter); + } + + @TestTemplate + public void testReadRowNumbersWithFilter() throws IOException { + // current iceberg supports row group filter. + for (int i = 1; i < 5; i += 1) { + readAndValidate( + Expressions.and( + Expressions.lessThan("id", NUM_ROWS / 2), + Expressions.greaterThanOrEqual("id", i * ROWS_PER_SPLIT)), + null, + null, + EXPECTED_ROWS.subList(i * ROWS_PER_SPLIT, NUM_ROWS / 2)); + } + } + + @TestTemplate + public void testReadRowNumbersWithSplits() throws IOException { + ParquetFileReader fileReader = + new ParquetFileReader( + HadoopInputFile.fromPath(new Path(testFile.getAbsolutePath()), new Configuration()), + ParquetReadOptions.builder().build()); + List rowGroups = fileReader.getRowGroups(); + for (int i = 0; i < NUM_ROW_GROUPS; i += 1) { + readAndValidate( + null, + rowGroups.get(i).getColumns().get(0).getStartingPos(), + rowGroups.get(i).getCompressedSize(), + EXPECTED_ROWS.subList(i * ROWS_PER_SPLIT, (i + 1) * ROWS_PER_SPLIT)); + } + } + + private void readAndValidate( + Expression filter, Long splitStart, Long splitLength, List expected) + throws IOException { + Parquet.ReadBuilder builder = + Parquet.read(Files.localInput(testFile)).project(PROJECTION_SCHEMA); + + if (vectorized) { + builder.createBatchedReaderFunc( + fileSchema -> + VectorizedSparkParquetReaders.buildReader( + PROJECTION_SCHEMA, fileSchema, Maps.newHashMap())); + builder.recordsPerBatch(RECORDS_PER_BATCH); + } else { + builder = + builder.createReaderFunc( + msgType -> SparkParquetReaders.buildReader(PROJECTION_SCHEMA, msgType)); + } + + if (filter != null) { + builder = builder.filter(filter); + } + + if (splitStart != null && splitLength != null) { + builder = builder.split(splitStart, splitLength); + } + + validate( + expected, + builder, + new TestHelpers.CustomizedDeleteFilter(false, DATA_SCHEMA, PROJECTION_SCHEMA)); + } + + private void validate( + List expected, Parquet.ReadBuilder builder, DeleteFilter filter) + throws IOException { + try (CloseableIterable reader = reader(builder, filter)) { + final Iterator actualRows = reader.iterator(); + + for (InternalRow internalRow : expected) { + assertThat(actualRows).as("Should have expected number of rows").hasNext(); + TestHelpers.assertEquals(PROJECTION_SCHEMA, internalRow, actualRows.next()); + } + + assertThat(actualRows).as("Should not have extra rows").isExhausted(); + } + } + + private CloseableIterable reader( + Parquet.ReadBuilder builder, DeleteFilter filter) { + if (!vectorized) { + return builder.build(); + } else { + return batchesToRows(BatchReaderUtil.applyDeleteFilter(builder.build(), filter)); + } + } + + private CloseableIterable batchesToRows(CloseableIterable batches) { + return CloseableIterable.combine( + Iterables.concat(Iterables.transform(batches, b -> (Iterable) b::rowIterator)), + batches); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java new file mode 100644 index 000000000000..f42c37f5e41d --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetReader.java @@ -0,0 +1,264 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetricsConfig; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.IcebergGenerics; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.inmemory.InMemoryOutputFile; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.parquet.ParquetUtil; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; +import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.parquet.hadoop.api.WriteSupport; +import org.apache.parquet.hadoop.util.HadoopOutputFile; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.Test; + +public class TestSparkParquetReader extends AvroDataTestBase { + @Override + protected void writeAndValidate(Schema schema) throws IOException { + writeAndValidate(schema, schema); + } + + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + List expected = RandomGenericData.generate(writeSchema, 100, 0L); + writeAndValidate(writeSchema, expectedSchema, expected); + } + + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema, List expected) + throws IOException { + OutputFile output = new InMemoryOutputFile(); + try (FileAppender writer = + Parquet.write(output) + .schema(writeSchema) + .createWriterFunc(GenericParquetWriter::create) + .named("test") + .build()) { + writer.addAll(expected); + } + + try (CloseableIterable reader = + Parquet.read(output.toInputFile()) + .project(expectedSchema) + .createReaderFunc( + type -> SparkParquetReaders.buildReader(expectedSchema, type, ID_TO_CONSTANT)) + .build()) { + Iterator rows = reader.iterator(); + int pos = 0; + for (Record record : expected) { + assertThat(rows).as("Should have expected number of rows").hasNext(); + GenericsHelpers.assertEqualsUnsafe( + expectedSchema.asStruct(), record, rows.next(), ID_TO_CONSTANT, pos); + pos += 1; + } + assertThat(rows).as("Should not have extra rows").isExhausted(); + } + } + + @Override + protected boolean supportsRowLineage() { + return true; + } + + @Override + protected boolean supportsDefaultValues() { + return true; + } + + @Override + protected boolean supportsVariant() { + return true; + } + + protected List rowsFromFile(InputFile inputFile, Schema schema) throws IOException { + try (CloseableIterable reader = + Parquet.read(inputFile) + .project(schema) + .createReaderFunc(type -> SparkParquetReaders.buildReader(schema, type)) + .build()) { + return Lists.newArrayList(reader); + } + } + + protected Table tableFromInputFile(InputFile inputFile, Schema schema) throws IOException { + HadoopTables tables = new HadoopTables(); + Table table = + tables.create( + schema, + PartitionSpec.unpartitioned(), + ImmutableMap.of(), + java.nio.file.Files.createTempDirectory(temp, null).toFile().getCanonicalPath()); + + table + .newAppend() + .appendFile( + DataFiles.builder(PartitionSpec.unpartitioned()) + .withFormat(FileFormat.PARQUET) + .withInputFile(inputFile) + .withMetrics(ParquetUtil.fileMetrics(inputFile, MetricsConfig.getDefault())) + .withFileSizeInBytes(inputFile.getLength()) + .build()) + .commit(); + + return table; + } + + @Test + public void testInt96TimestampProducedBySparkIsReadCorrectly() throws IOException { + String outputFilePath = String.format("%s/%s", temp.toAbsolutePath(), "parquet_int96.parquet"); + HadoopOutputFile outputFile = + HadoopOutputFile.fromPath( + new org.apache.hadoop.fs.Path(outputFilePath), new Configuration()); + Schema schema = new Schema(required(1, "ts", Types.TimestampType.withZone())); + StructType sparkSchema = + new StructType( + new StructField[] { + new StructField("ts", DataTypes.TimestampType, true, Metadata.empty()) + }); + List rows = Lists.newArrayList(RandomData.generateSpark(schema, 10, 0L)); + + try (ParquetWriter writer = + new NativeSparkWriterBuilder(outputFile) + .set("org.apache.spark.sql.parquet.row.attributes", sparkSchema.json()) + .set("spark.sql.parquet.writeLegacyFormat", "false") + .set("spark.sql.parquet.outputTimestampType", "INT96") + .set("spark.sql.parquet.fieldId.write.enabled", "true") + .set("spark.sql.parquet.variant.annotateLogicalType.enabled", "true") + .build()) { + for (InternalRow row : rows) { + writer.write(row); + } + } + + InputFile parquetInputFile = Files.localInput(outputFilePath); + List readRows = rowsFromFile(parquetInputFile, schema); + + assertThat(readRows).hasSameSizeAs(rows); + assertThat(readRows).isEqualTo(rows); + + // Now we try to import that file as an Iceberg table to make sure Iceberg can read + // Int96 end to end. + Table int96Table = tableFromInputFile(parquetInputFile, schema); + List tableRecords = Lists.newArrayList(IcebergGenerics.read(int96Table).build()); + + assertThat(tableRecords).hasSameSizeAs(rows); + + for (int i = 0; i < tableRecords.size(); i++) { + GenericsHelpers.assertEqualsUnsafe(schema.asStruct(), tableRecords.get(i), rows.get(i)); + } + } + + /** + * Native Spark ParquetWriter.Builder implementation so that we can write timestamps using Spark's + * native ParquetWriteSupport. + */ + private static class NativeSparkWriterBuilder + extends ParquetWriter.Builder { + private final Map config = Maps.newHashMap(); + + NativeSparkWriterBuilder(org.apache.parquet.io.OutputFile path) { + super(path); + } + + public NativeSparkWriterBuilder set(String property, String value) { + this.config.put(property, value); + return self(); + } + + @Override + protected NativeSparkWriterBuilder self() { + return this; + } + + @Override + protected WriteSupport getWriteSupport(Configuration configuration) { + for (Map.Entry entry : config.entrySet()) { + configuration.set(entry.getKey(), entry.getValue()); + } + + return new org.apache.spark.sql.execution.datasources.parquet.ParquetWriteSupport(); + } + } + + @Test + public void testMissingRequiredWithoutDefault() { + Schema writeSchema = new Schema(required(1, "id", Types.LongType.get())); + + Schema expectedSchema = + new Schema( + required(1, "id", Types.LongType.get()), + Types.NestedField.required("missing_str") + .withId(6) + .ofType(Types.StringType.get()) + .withDoc("Missing required field with no default") + .build()); + + assertThatThrownBy(() -> writeAndValidate(writeSchema, expectedSchema)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing required field: missing_str"); + } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetWriter.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetWriter.java new file mode 100644 index 000000000000..0606e9321560 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkParquetWriter.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.TableProperties.PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX; +import static org.apache.iceberg.TableProperties.PARQUET_BLOOM_FILTER_COLUMN_FPP_PREFIX; +import static org.apache.iceberg.TableProperties.PARQUET_BLOOM_FILTER_COLUMN_NDV_PREFIX; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Path; +import java.util.Iterator; +import java.util.OptionalLong; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.parquet.ParquetSchemaUtil; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.types.Types; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.ParquetProperties; +import org.apache.parquet.schema.MessageType; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestSparkParquetWriter { + @TempDir private Path temp; + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "id_long", Types.LongType.get())); + + private static final Schema COMPLEX_SCHEMA = + new Schema( + required(1, "roots", Types.LongType.get()), + optional(3, "lime", Types.ListType.ofRequired(4, Types.DoubleType.get())), + required( + 5, + "strict", + Types.StructType.of( + required(9, "tangerine", Types.StringType.get()), + optional( + 6, + "hopeful", + Types.StructType.of( + required(7, "steel", Types.FloatType.get()), + required(8, "lantern", Types.DateType.get()))), + optional(10, "vehement", Types.LongType.get()))), + optional( + 11, + "metamorphosis", + Types.MapType.ofRequired( + 12, 13, Types.StringType.get(), Types.TimestampType.withZone())), + required( + 14, + "winter", + Types.ListType.ofOptional( + 15, + Types.StructType.of( + optional(16, "beet", Types.DoubleType.get()), + required(17, "stamp", Types.FloatType.get()), + optional(18, "wheeze", Types.StringType.get())))), + optional( + 19, + "renovate", + Types.MapType.ofRequired( + 20, + 21, + Types.StringType.get(), + Types.StructType.of( + optional(22, "jumpy", Types.DoubleType.get()), + required(23, "koala", Types.UUIDType.get()), + required(24, "couch rope", Types.IntegerType.get())))), + optional(2, "slide", Types.StringType.get())); + + @Test + public void testCorrectness() throws IOException { + int numRows = 50_000; + Iterable records = RandomData.generateSpark(COMPLEX_SCHEMA, numRows, 19981); + + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (FileAppender writer = + Parquet.write(Files.localOutput(testFile)) + .schema(COMPLEX_SCHEMA) + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter( + SparkSchemaUtil.convert(COMPLEX_SCHEMA), msgType)) + .build()) { + writer.addAll(records); + } + + try (CloseableIterable reader = + Parquet.read(Files.localInput(testFile)) + .project(COMPLEX_SCHEMA) + .createReaderFunc(type -> SparkParquetReaders.buildReader(COMPLEX_SCHEMA, type)) + .build()) { + Iterator expected = records.iterator(); + Iterator rows = reader.iterator(); + for (int i = 0; i < numRows; i += 1) { + assertThat(rows).as("Should have expected number of rows").hasNext(); + TestHelpers.assertEquals(COMPLEX_SCHEMA, expected.next(), rows.next()); + } + assertThat(rows).as("Should not have extra rows").isExhausted(); + } + } + + @Test + public void testFpp() throws IOException, NoSuchFieldException, IllegalAccessException { + File testFile = File.createTempFile("junit", null, temp.toFile()); + try (FileAppender writer = + Parquet.write(Files.localOutput(testFile)) + .schema(SCHEMA) + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_FPP_PREFIX + "id", "0.05") + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + .build()) { + // Using reflection to access the private 'props' field in ParquetWriter + Field propsField = writer.getClass().getDeclaredField("props"); + propsField.setAccessible(true); + ParquetProperties props = (ParquetProperties) propsField.get(writer); + MessageType parquetSchema = ParquetSchemaUtil.convert(SCHEMA, "test"); + ColumnDescriptor descriptor = parquetSchema.getColumnDescription(new String[] {"id"}); + double fpp = props.getBloomFilterFPP(descriptor).getAsDouble(); + assertThat(fpp).isEqualTo(0.05); + } + } + + @Test + public void testNdv() throws IOException, NoSuchFieldException, IllegalAccessException { + final long expectedNdv = 1000; + final String col = "id"; + File testFile = File.createTempFile("junit", null, temp.toFile()); + try (FileAppender writer = + Parquet.write(Files.localOutput(testFile)) + .schema(SCHEMA) + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + col, "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_NDV_PREFIX + col, Long.toString(expectedNdv)) + .createWriterFunc( + msgType -> + SparkParquetWriters.buildWriter(SparkSchemaUtil.convert(SCHEMA), msgType)) + .build()) { + // Using reflection to access the private 'props' field in ParquetWriter + Field propsField = writer.getClass().getDeclaredField("props"); + propsField.setAccessible(true); + ParquetProperties props = (ParquetProperties) propsField.get(writer); + MessageType parquetSchema = ParquetSchemaUtil.convert(SCHEMA, "test"); + ColumnDescriptor descriptor = parquetSchema.getColumnDescription(new String[] {col}); + OptionalLong bloomFilterNDV = props.getBloomFilterNDV(descriptor); + assertThat(bloomFilterNDV).isPresent(); + assertThat(bloomFilterNDV.getAsLong()).isEqualTo(expectedNdv); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java new file mode 100644 index 000000000000..634327a81d86 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkRecordOrcReaderWriter.java @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Iterator; +import java.util.List; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.orc.GenericOrcReader; +import org.apache.iceberg.data.orc.GenericOrcWriter; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.orc.ORC; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.Test; + +public class TestSparkRecordOrcReaderWriter extends AvroDataTestBase { + private static final int NUM_RECORDS = 200; + + private void writeAndValidate(Schema schema, List expectedRecords) throws IOException { + final File originalFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(originalFile.delete()).as("Delete should succeed").isTrue(); + + // Write few generic records into the original test file. + try (FileAppender writer = + ORC.write(Files.localOutput(originalFile)) + .createWriterFunc(GenericOrcWriter::buildWriter) + .schema(schema) + .build()) { + writer.addAll(expectedRecords); + } + + // Read into spark InternalRow from the original test file. + List internalRows = Lists.newArrayList(); + try (CloseableIterable reader = + ORC.read(Files.localInput(originalFile)) + .project(schema) + .createReaderFunc(readOrcSchema -> new SparkOrcReader(schema, readOrcSchema)) + .build()) { + reader.forEach(internalRows::add); + assertEqualsUnsafe(schema.asStruct(), expectedRecords, reader, expectedRecords.size()); + } + + final File anotherFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(anotherFile.delete()).as("Delete should succeed").isTrue(); + + // Write those spark InternalRows into a new file again. + try (FileAppender writer = + ORC.write(Files.localOutput(anotherFile)) + .createWriterFunc(SparkOrcWriter::new) + .schema(schema) + .build()) { + writer.addAll(internalRows); + } + + // Check whether the InternalRows are expected records. + try (CloseableIterable reader = + ORC.read(Files.localInput(anotherFile)) + .project(schema) + .createReaderFunc(readOrcSchema -> new SparkOrcReader(schema, readOrcSchema)) + .build()) { + assertEqualsUnsafe(schema.asStruct(), expectedRecords, reader, expectedRecords.size()); + } + + // Read into iceberg GenericRecord and check again. + try (CloseableIterable reader = + ORC.read(Files.localInput(anotherFile)) + .createReaderFunc(typeDesc -> GenericOrcReader.buildReader(schema, typeDesc)) + .project(schema) + .build()) { + assertRecordEquals(expectedRecords, reader, expectedRecords.size()); + } + } + + @Override + protected void writeAndValidate(Schema schema) throws IOException { + List expectedRecords = RandomGenericData.generate(schema, NUM_RECORDS, 1992L); + writeAndValidate(schema, expectedRecords); + } + + @Test + public void testDecimalWithTrailingZero() throws IOException { + Schema schema = + new Schema( + required(1, "d1", Types.DecimalType.of(10, 2)), + required(2, "d2", Types.DecimalType.of(20, 5)), + required(3, "d3", Types.DecimalType.of(38, 20))); + + List expected = Lists.newArrayList(); + + GenericRecord record = GenericRecord.create(schema); + record.set(0, new BigDecimal("101.00")); + record.set(1, new BigDecimal("10.00E-3")); + record.set(2, new BigDecimal("1001.0000E-16")); + + expected.add(record.copy()); + + writeAndValidate(schema, expected); + } + + private static void assertRecordEquals( + Iterable expected, Iterable actual, int size) { + Iterator expectedIter = expected.iterator(); + Iterator actualIter = actual.iterator(); + for (int i = 0; i < size; i += 1) { + assertThat(expectedIter).as("Expected iterator should have more rows").hasNext(); + assertThat(actualIter).as("Actual iterator should have more rows").hasNext(); + assertThat(actualIter.next()).as("Should have same rows.").isEqualTo(expectedIter.next()); + } + assertThat(expectedIter).as("Expected iterator should not have any extra rows.").isExhausted(); + assertThat(actualIter).as("Actual iterator should not have any extra rows.").isExhausted(); + } + + private static void assertEqualsUnsafe( + Types.StructType struct, Iterable expected, Iterable actual, int size) { + Iterator expectedIter = expected.iterator(); + Iterator actualIter = actual.iterator(); + for (int i = 0; i < size; i += 1) { + assertThat(expectedIter).as("Expected iterator should have more rows").hasNext(); + assertThat(actualIter).as("Actual iterator should have more rows").hasNext(); + GenericsHelpers.assertEqualsUnsafe(struct, expectedIter.next(), actualIter.next()); + } + assertThat(expectedIter).as("Expected iterator should not have any extra rows.").isExhausted(); + assertThat(actualIter).as("Actual iterator should not have any extra rows.").isExhausted(); + } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkVariants.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkVariants.java new file mode 100644 index 000000000000..88723acd37fb --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestSparkVariants.java @@ -0,0 +1,271 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantArray; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantObject; +import org.apache.iceberg.variants.VariantPrimitive; +import org.apache.iceberg.variants.VariantTestHelper; +import org.apache.iceberg.variants.VariantTestUtil; +import org.apache.iceberg.variants.VariantValue; +import org.apache.spark.SparkRuntimeException; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.VariantType; +import org.apache.spark.sql.types.VariantType$; +import org.apache.spark.types.variant.VariantUtil; +import org.apache.spark.unsafe.types.VariantVal; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.FieldSource; + +public class TestSparkVariants extends TestBase { + + @Test + public void testIcebergVariantTypeToSparkVariantType() { + // Test that Iceberg's VariantType converts to Spark's VariantType + Types.VariantType icebergVariantType = Types.VariantType.get(); + DataType sparkVariantType = SparkSchemaUtil.convert(icebergVariantType); + + assertThat(sparkVariantType).isInstanceOf(VariantType.class); + } + + @Test + public void testSparkVariantTypeToIcebergVariantType() { + // Test that Spark's VariantType converts to Iceberg's VariantType + org.apache.spark.sql.types.DataType sparkVariantType = VariantType$.MODULE$; + Type icebergVariantType = SparkSchemaUtil.convert(sparkVariantType); + + assertThat(icebergVariantType).isEqualTo(Types.VariantType.get()); + } + + @ParameterizedTest + @FieldSource("org.apache.iceberg.variants.VariantTestHelper#PRIMITIVES") + public void testVariantPrimitiveRoundTrip(VariantPrimitive primitive) { + VariantTestHelper.testVariantPrimitiveRoundTrip(this::testVariantRoundTrip, primitive); + } + + @Test + public void testVariantArrayRoundTrip() { + VariantTestHelper.testVariantArrayRoundTrip(this::testVariantRoundTrip); + } + + @Test + public void testVariantObjectRoundTrip() { + VariantTestHelper.testVariantObjectRoundTrip(this::testVariantRoundTrip); + } + + @Test + public void testVariantNestedStructures() { + VariantTestHelper.testVariantNestedStructures(this::testVariantRoundTrip); + } + + @ParameterizedTest + @FieldSource("org.apache.iceberg.variants.VariantTestHelper#UNSUPPORTED_PRIMITIVES") + public void testUnsupportedOperations(VariantPrimitive primitive) { + // This tests the current state where Spark integration is not fully implemented + // TIME, nano timestamps are not supported in Spark + ByteBuffer valueBuffer = + ByteBuffer.allocate(primitive.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + primitive.writeTo(valueBuffer, 0); + + org.apache.spark.types.variant.Variant sparkVariant = + new org.apache.spark.types.variant.Variant( + valueBuffer.array(), VariantTestUtil.emptyMetadata().array()); + + assertThatThrownBy(sparkVariant::getType) + .as("Unsupported variant type in Spark") + .isInstanceOf(SparkRuntimeException.class) + .hasMessageContaining("UNKNOWN_PRIMITIVE_TYPE_IN_VARIANT"); + } + + private void testVariantRoundTrip(VariantMetadata metadata, VariantValue value) { + // Create Iceberg variant + Variant icebergVariant = Variant.of(metadata, value); + + // Serialize to bytes + ByteBuffer metadataBuffer = + ByteBuffer.allocate(metadata.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + metadata.writeTo(metadataBuffer, 0); + + ByteBuffer valueBuffer = + ByteBuffer.allocate(value.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + value.writeTo(valueBuffer, 0); + + // Create Spark VariantVal from the same bytes + byte[] metadataBytes = ByteBuffers.toByteArray(metadataBuffer); + byte[] valueBytes = ByteBuffers.toByteArray(valueBuffer); + VariantVal sparkVariant = new VariantVal(valueBytes, metadataBytes); + + GenericsHelpers.assertEquals(icebergVariant, sparkVariant); + assertEquals( + icebergVariant.value(), + new org.apache.spark.types.variant.Variant( + sparkVariant.getValue(), sparkVariant.getMetadata())); + + // TODO: Round-trip: use Spark VariantBuilder to build a Spark variant from Iceberg variant and + // deserialize back to Iceberg variant currently VariantBuilder doesn't have an easy way to + // construct array/object. + } + + private static void assertEquals( + VariantValue expected, org.apache.spark.types.variant.Variant actual) { + assertThat(actual).isNotNull(); + assertThat(expected).isNotNull(); + + switch (expected.type()) { + case OBJECT: + assertThat(actual.getType()).isEqualTo(VariantUtil.Type.OBJECT); + VariantObject expectedObject = expected.asObject(); + assertThat(actual.objectSize()) + .as("Variant object num fields should match") + .isEqualTo(expectedObject.numFields()); + + for (String fieldName : expectedObject.fieldNames()) { + assertEquals(expectedObject.get(fieldName), actual.getFieldByKey(fieldName)); + } + break; + case ARRAY: + assertThat(actual.getType()).isEqualTo(VariantUtil.Type.ARRAY); + VariantArray expectedArray = expected.asArray(); + assertThat(actual.arraySize()) + .as("Variant array num element should match") + .isEqualTo(expectedArray.numElements()); + + for (int i = 0; i < expectedArray.numElements(); i += 1) { + assertEquals(expectedArray.get(i), actual.getElementAtIndex(i)); + } + break; + case NULL: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.NULL); + break; + case BOOLEAN_TRUE: + case BOOLEAN_FALSE: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.BOOLEAN); + assertThat(actual.getBoolean()) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + case INT8: + case INT16: + case INT32: + case INT64: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.LONG); + assertThat(actual.getLong()) + .as("Variant primitive value should match") + .isEqualTo(((Number) expected.asPrimitive().get()).longValue()); + break; + case DOUBLE: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.DOUBLE); + assertThat(actual.getDouble()) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + case DECIMAL4: + case DECIMAL8: + case DECIMAL16: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.DECIMAL); + // For decimal, Spark strips trailing zeros + assertThat(actual.getDecimal()) + .as("Variant primitive value should match") + .isEqualTo(((BigDecimal) expected.asPrimitive().get()).stripTrailingZeros()); + break; + case DATE: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.DATE); + assertThat(actual.getLong()) + .as("Variant primitive value should match") + .isEqualTo(((Number) expected.asPrimitive().get()).longValue()); + break; + case TIMESTAMPTZ: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.TIMESTAMP); + assertThat(actual.getLong()) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + case TIMESTAMPNTZ: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.TIMESTAMP_NTZ); + assertThat(actual.getLong()) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + case FLOAT: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.FLOAT); + assertThat(actual.getFloat()) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + case BINARY: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.BINARY); + assertThat(ByteBuffer.wrap(actual.getBinary())) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + case STRING: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.STRING); + assertThat(actual.getString()) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + case UUID: + assertThat(actual.getType()) + .as("Variant primitive type should match") + .isEqualTo(VariantUtil.Type.UUID); + assertThat(actual.getUuid()) + .as("Variant primitive value should match") + .isEqualTo(expected.asPrimitive().get()); + break; + default: + throw new UnsupportedOperationException("Unsupported variant type: " + expected.type()); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestVectorizedOrcDataReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestVectorizedOrcDataReader.java new file mode 100644 index 000000000000..891cb9481ce6 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/TestVectorizedOrcDataReader.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.orc.GenericOrcWriter; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.orc.ORC; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterators; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkOrcReaders; +import org.apache.iceberg.types.Types; +import org.apache.orc.OrcConf; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestVectorizedOrcDataReader { + @TempDir public static Path temp; + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "binary", Types.BinaryType.get()), + Types.NestedField.required( + 4, "array", Types.ListType.ofOptional(5, Types.IntegerType.get()))); + private static OutputFile outputFile; + + @BeforeAll + public static void createDataFile() throws IOException { + GenericRecord bufferRecord = GenericRecord.create(SCHEMA); + + ImmutableList.Builder builder = ImmutableList.builder(); + builder.add( + bufferRecord.copy( + ImmutableMap.of("id", 1L, "data", "a", "array", Collections.singletonList(1)))); + builder.add( + bufferRecord.copy(ImmutableMap.of("id", 2L, "data", "b", "array", Arrays.asList(2, 3)))); + builder.add( + bufferRecord.copy(ImmutableMap.of("id", 3L, "data", "c", "array", Arrays.asList(3, 4, 5)))); + builder.add( + bufferRecord.copy( + ImmutableMap.of("id", 4L, "data", "d", "array", Arrays.asList(4, 5, 6, 7)))); + builder.add( + bufferRecord.copy( + ImmutableMap.of("id", 5L, "data", "e", "array", Arrays.asList(5, 6, 7, 8, 9)))); + + outputFile = Files.localOutput(File.createTempFile("test", ".orc", temp.toFile())); + + try (DataWriter dataWriter = + ORC.writeData(outputFile) + .schema(SCHEMA) + .createWriterFunc(GenericOrcWriter::buildWriter) + .overwrite() + .withSpec(PartitionSpec.unpartitioned()) + .build()) { + for (Record record : builder.build()) { + dataWriter.write(record); + } + } + } + + private Iterator batchesToRows(Iterator batches) { + return Iterators.concat(Iterators.transform(batches, ColumnarBatch::rowIterator)); + } + + private void validateAllRows(Iterator rows) { + long rowCount = 0; + long expId = 1; + char expChar = 'a'; + while (rows.hasNext()) { + InternalRow row = rows.next(); + assertThat(row.getLong(0)).isEqualTo(expId); + assertThat(row.getString(1)).isEqualTo(Character.toString(expChar)); + assertThat(row.isNullAt(2)).isTrue(); + expId += 1; + expChar += 1; + rowCount += 1; + } + assertThat(rowCount).isEqualTo(5); + } + + @Test + public void testReader() throws IOException { + try (CloseableIterable reader = + ORC.read(outputFile.toInputFile()) + .project(SCHEMA) + .createBatchedReaderFunc( + readOrcSchema -> + VectorizedSparkOrcReaders.buildReader(SCHEMA, readOrcSchema, ImmutableMap.of())) + .build()) { + validateAllRows(batchesToRows(reader.iterator())); + } + } + + @Test + public void testReaderWithFilter() throws IOException { + try (CloseableIterable reader = + ORC.read(outputFile.toInputFile()) + .project(SCHEMA) + .createBatchedReaderFunc( + readOrcSchema -> + VectorizedSparkOrcReaders.buildReader(SCHEMA, readOrcSchema, ImmutableMap.of())) + .filter(Expressions.equal("id", 3L)) + .config(OrcConf.ALLOW_SARG_TO_FILTER.getAttribute(), String.valueOf(true)) + .build()) { + validateAllRows(batchesToRows(reader.iterator())); + } + } + + @Test + public void testWithFilterWithSelected() throws IOException { + try (CloseableIterable reader = + ORC.read(outputFile.toInputFile()) + .project(SCHEMA) + .createBatchedReaderFunc( + readOrcSchema -> + VectorizedSparkOrcReaders.buildReader(SCHEMA, readOrcSchema, ImmutableMap.of())) + .filter(Expressions.equal("id", 3L)) + .config(OrcConf.ALLOW_SARG_TO_FILTER.getAttribute(), String.valueOf(true)) + .config(OrcConf.READER_USE_SELECTED.getAttribute(), String.valueOf(true)) + .build()) { + Iterator rows = batchesToRows(reader.iterator()); + assertThat(rows).hasNext(); + InternalRow row = rows.next(); + assertThat(row.getLong(0)).isEqualTo(3L); + assertThat(row.getString(1)).isEqualTo("c"); + assertThat(row.getArray(3).toIntArray()).isEqualTo(new int[] {3, 4, 5}); + assertThat(rows).isExhausted(); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/TestColumnarBatchUtil.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/TestColumnarBatchUtil.java new file mode 100644 index 000000000000..dadbe3e788b7 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/TestColumnarBatchUtil.java @@ -0,0 +1,302 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized; + +import static java.util.Collections.nCopies; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.function.Predicate; +import java.util.stream.Stream; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.deletes.PositionDeleteIndex; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.vectorized.ColumnVector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TestColumnarBatchUtil { + + private ColumnVector[] columnVectors; + private DeleteFilter deleteFilter; + + @BeforeEach + public void before() { + columnVectors = mockColumnVector(); + deleteFilter = mock(DeleteFilter.class); + } + + @Test + public void testBuildRowIdMappingNoDeletes() { + when(deleteFilter.hasPosDeletes()).thenReturn(true); + PositionDeleteIndex deletedRowPos = mock(PositionDeleteIndex.class); + + for (long i = 0; i <= 10; i++) { + when(deletedRowPos.isDeleted(i)).thenReturn(false); + } + + when(deleteFilter.deletedRowPositions()).thenReturn(deletedRowPos); + var rowIdMapping = ColumnarBatchUtil.buildRowIdMapping(columnVectors, deleteFilter, 0, 10); + assertThat(rowIdMapping).isNull(); + } + + @Test + public void testBuildRowIdMappingPositionDeletesOnly() { + when(deleteFilter.hasPosDeletes()).thenReturn(true); + PositionDeleteIndex deletedRowPos = mock(PositionDeleteIndex.class); + + // 5 position deletes + for (long i = 98; i < 103; i++) { + when(deletedRowPos.isDeleted(i)).thenReturn(true); + } + + when(deleteFilter.deletedRowPositions()).thenReturn(deletedRowPos); + + var rowIdMapping = ColumnarBatchUtil.buildRowIdMapping(columnVectors, deleteFilter, 0, 200); + assertThat(rowIdMapping).isNotNull(); + + int[] rowIds = (int[]) rowIdMapping.first(); + int liveRows = (int) rowIdMapping.second(); + + for (int id : rowIds) { + assertThat(id < 98 || id > 102).isTrue(); + } + + assertThat(rowIds.length).isEqualTo(200); + assertThat(liveRows).isEqualTo(195); + } + + @Test + public void testBuildRowIdMappingEqualityDeletesOnly() { + // Define raw equality delete predicate — delete rows where value == 42 + Predicate rawEqDelete = row -> row.getInt(0) == 42; + + // Mimic real eqDeletedRowFilter(): keep row only if it does NOT match delete condition + Predicate eqDeletePredicate = + Stream.of(rawEqDelete).map(Predicate::negate).reduce(Predicate::and).orElse(t -> true); + + // Mock DeleteFilter + when(deleteFilter.hasPosDeletes()).thenReturn(false); + when(deleteFilter.deletedRowPositions()).thenReturn(null); + when(deleteFilter.eqDeletedRowFilter()).thenReturn(eqDeletePredicate); + + var rowIdMapping = ColumnarBatchUtil.buildRowIdMapping(columnVectors, deleteFilter, 0, 5); + + assertThat(rowIdMapping).isNotNull(); + int[] rowIds = (int[]) rowIdMapping.first(); + int liveRows = (Integer) rowIdMapping.second(); + + // Expect to keep positions 0, 1, 3, 4 → values 40, 41, 43, 44 + assertThat(liveRows).isEqualTo(4); + assertThat(Arrays.copyOf(rowIds, liveRows)).containsExactly(0, 1, 3, 4); + } + + @Test + public void testBuildRowIdMappingPositionAndEqualityDeletes() { + + // Define raw equality delete predicate — delete rows where value == 42 + Predicate rawEqDelete = row -> row.getInt(0) == 42; + + // Mimic real eqDeletedRowFilter(): keep row only if it does NOT match delete condition + Predicate eqDeletePredicate = + Stream.of(rawEqDelete).map(Predicate::negate).reduce(Predicate::and).orElse(t -> true); + when(deleteFilter.eqDeletedRowFilter()).thenReturn(eqDeletePredicate); + + PositionDeleteIndex deletedRowPos = mock(PositionDeleteIndex.class); + when(deletedRowPos.isDeleted(1)).thenReturn(true); // 41 + when(deletedRowPos.isDeleted(4)).thenReturn(true); // 44 + when(deleteFilter.hasPosDeletes()).thenReturn(true); + when(deleteFilter.deletedRowPositions()).thenReturn(deletedRowPos); + + var rowIdMapping = ColumnarBatchUtil.buildRowIdMapping(columnVectors, deleteFilter, 0, 5); + + assertThat(rowIdMapping).isNotNull(); + int[] rowIds = (int[]) rowIdMapping.first(); + int liveRows = (Integer) rowIdMapping.second(); + + assertThat(liveRows).isEqualTo(2); + assertThat(Arrays.copyOf(rowIds, liveRows)).containsExactly(0, 3); + } + + @Test + void testBuildRowIdMappingEmptyColumVectors() { + ColumnVector[] columnVectorsZero = new ColumnVector[0]; + + PositionDeleteIndex deletedRowPos = mock(PositionDeleteIndex.class); + when(deletedRowPos.isDeleted(1)).thenReturn(true); + when(deletedRowPos.isDeleted(4)).thenReturn(true); + when(deleteFilter.hasPosDeletes()).thenReturn(true); + when(deleteFilter.deletedRowPositions()).thenReturn(deletedRowPos); + + var rowIdMapping = ColumnarBatchUtil.buildRowIdMapping(columnVectorsZero, deleteFilter, 0, 0); + + // Empty batch size, expect no rows deleted. + assertThat(rowIdMapping).isNull(); + } + + @Test + void testBuildRowIdMapAllRowsDeleted() { + + // Define raw equality delete predicate — delete rows where value == 42 or 43 + Predicate rawEqDelete = row -> row.getInt(0) == 42 || row.getInt(0) == 43; + + // Mimic real eqDeletedRowFilter(): keep row only if it does NOT match delete condition + Predicate eqDeletePredicate = + Stream.of(rawEqDelete).map(Predicate::negate).reduce(Predicate::and).orElse(t -> true); + when(deleteFilter.eqDeletedRowFilter()).thenReturn(eqDeletePredicate); + + PositionDeleteIndex deletedRowPos = mock(PositionDeleteIndex.class); + when(deletedRowPos.isDeleted(0)).thenReturn(true); // 40 + when(deletedRowPos.isDeleted(1)).thenReturn(true); // 41 + when(deletedRowPos.isDeleted(4)).thenReturn(true); // 44 + when(deleteFilter.hasPosDeletes()).thenReturn(true); + when(deleteFilter.deletedRowPositions()).thenReturn(deletedRowPos); + + var rowIdMapping = ColumnarBatchUtil.buildRowIdMapping(columnVectors, deleteFilter, 0, 5); + + assertThat(rowIdMapping).isNotNull(); + int[] rowIds = (int[]) rowIdMapping.first(); + int liveRows = (Integer) rowIdMapping.second(); + + // Expect all rows to be deleted + assertThat(liveRows).isEqualTo(0); + assertThat(rowIds).containsExactly(0, 0, 0, 0, 0); + } + + @Test + void testBuildIsDeletedPositionDeletes() { + PositionDeleteIndex deletedRowPos = mock(PositionDeleteIndex.class); + when(deleteFilter.deletedRowPositions()).thenReturn(deletedRowPos); + + for (long i = 98; i < 100; i++) { + when(deletedRowPos.isDeleted(i)).thenReturn(true); + } + + var isDeleted = ColumnarBatchUtil.buildIsDeleted(columnVectors, deleteFilter, 0, 100); + + assertThat(isDeleted).isNotNull(); + assertThat(isDeleted.length).isEqualTo(100); + + for (int i = 98; i < 100; i++) { + assertThat(isDeleted[i]).isTrue(); + } + + for (int i = 0; i < 98; i++) { + assertThat(isDeleted[i]).isFalse(); + } + } + + @Test + void testBuildIsDeletedEqualityDeletes() { + // Define raw equality delete predicate — delete rows where value == 42 or 43 + Predicate rawEqDelete = row -> row.getInt(0) == 42 || row.getInt(0) == 43; + + // Mimic real eqDeletedRowFilter(): keep row only if it does NOT match delete condition + Predicate eqDeletePredicate = + Stream.of(rawEqDelete).map(Predicate::negate).reduce(Predicate::and).orElse(t -> true); + when(deleteFilter.eqDeletedRowFilter()).thenReturn(eqDeletePredicate); + + var isDeleted = ColumnarBatchUtil.buildIsDeleted(columnVectors, deleteFilter, 0, 5); + + for (int i = 0; i < isDeleted.length; i++) { + if (i == 2 || i == 3) { // 42 and 43 + assertThat(isDeleted[i]).isTrue(); + } else { + assertThat(isDeleted[i]).isFalse(); + } + } + } + + @Test + void testBuildIsDeletedPositionAndEqualityDeletes() { + // Define raw equality delete predicate — delete rows where value == 42 + Predicate rawEqDelete = row -> row.getInt(0) == 42; + + // Mimic real eqDeletedRowFilter(): keep row only if it does NOT match delete condition + Predicate eqDeletePredicate = + Stream.of(rawEqDelete).map(Predicate::negate).reduce(Predicate::and).orElse(t -> true); + when(deleteFilter.eqDeletedRowFilter()).thenReturn(eqDeletePredicate); + + PositionDeleteIndex deletedRowPos = mock(PositionDeleteIndex.class); + when(deletedRowPos.isDeleted(1)).thenReturn(true); // 41 + when(deletedRowPos.isDeleted(4)).thenReturn(true); // 44 + when(deleteFilter.hasPosDeletes()).thenReturn(true); + when(deleteFilter.deletedRowPositions()).thenReturn(deletedRowPos); + + var isDeleted = ColumnarBatchUtil.buildIsDeleted(columnVectors, deleteFilter, 0, 5); + + for (int i = 0; i < isDeleted.length; i++) { + if (i == 0 || i == 3) { + assertThat(isDeleted[i]).isFalse(); + } else { + assertThat(isDeleted[i]).isTrue(); // 42, 41, 44 are deleted + } + } + } + + @Test + void testBuildIsDeletedNoDeletes() { + var result = ColumnarBatchUtil.buildIsDeleted(columnVectors, null, 0, 5); + assertThat(result).isNotNull(); + for (int i = 0; i < 5; i++) { + assertThat(result[i]).isFalse(); + } + } + + @Test + void testRemoveExtraColumns() { + ColumnVector[] vectors = new ColumnVector[5]; + for (int i = 0; i < 5; i++) { + vectors[i] = mock(ColumnVector.class); + } + when(deleteFilter.expectedSchema()).thenReturn(mock(Schema.class)); + when(deleteFilter.expectedSchema().columns()).thenReturn(nCopies(3, null)); + + ColumnVector[] result = ColumnarBatchUtil.removeExtraColumns(deleteFilter, vectors); + assertThat(result.length).isEqualTo(3); + } + + @Test + void testRemoveExtraColumnsNotNeeded() { + ColumnVector[] vectors = new ColumnVector[3]; + for (int i = 0; i < 3; i++) { + vectors[i] = mock(ColumnVector.class); + } + when(deleteFilter.expectedSchema()).thenReturn(mock(Schema.class)); + when(deleteFilter.expectedSchema().columns()).thenReturn(nCopies(3, null)); + + ColumnVector[] result = ColumnarBatchUtil.removeExtraColumns(deleteFilter, vectors); + assertThat(result.length).isEqualTo(3); + } + + private ColumnVector[] mockColumnVector() { + // Create a mocked Int column vector with values: 40, 41, 42, 43, 44 + ColumnVector intVector = mock(ColumnVector.class); + when(intVector.getInt(0)).thenReturn(40); + when(intVector.getInt(1)).thenReturn(41); + when(intVector.getInt(2)).thenReturn(42); + when(intVector.getInt(3)).thenReturn(43); + when(intVector.getInt(4)).thenReturn(44); + + return new ColumnVector[] {intVector}; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java new file mode 100644 index 000000000000..b61ecfa2f442 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryEncodedVectorizedReads.java @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized.parquet; + +import static org.apache.iceberg.TableProperties.PARQUET_DICT_SIZE_BYTES; +import static org.apache.iceberg.TableProperties.PARQUET_PAGE_ROW_LIMIT; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES_DEFAULT; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Iterator; +import java.util.List; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.base.Function; +import org.apache.iceberg.relocated.com.google.common.collect.FluentIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class TestParquetDictionaryEncodedVectorizedReads extends TestParquetVectorizedReads { + + protected static SparkSession spark = null; + + @BeforeAll + public static void startSpark() { + spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + if (spark != null) { + spark.stop(); + spark = null; + } + } + + @Override + Iterable generateData( + Schema schema, + int numRecords, + long seed, + float nullPercentage, + Function transform) { + Iterable data = + RandomGenericData.generateDictionaryEncodableRecords( + schema, numRecords, seed, nullPercentage); + return transform == IDENTITY ? data : Iterables.transform(data, transform); + } + + @Test + @Override + @Disabled // Ignored since this code path is already tested in TestParquetVectorizedReads + public void testVectorizedReadsWithNewContainers() throws IOException {} + + @Test + public void testMixedDictionaryNonDictionaryReads() throws IOException { + Schema schema = new Schema(SUPPORTED_PRIMITIVES.fields()); + File dictionaryEncodedFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(dictionaryEncodedFile.delete()).as("Delete should succeed").isTrue(); + Iterable dictionaryEncodableData = + RandomGenericData.generateDictionaryEncodableRecords(schema, 10000, 0L); + try (FileAppender writer = getParquetWriter(schema, dictionaryEncodedFile)) { + writer.addAll(dictionaryEncodableData); + } + + File plainEncodingFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(plainEncodingFile.delete()).as("Delete should succeed").isTrue(); + Iterable nonDictionaryData = RandomGenericData.generate(schema, 10000, 0L); + try (FileAppender writer = getParquetWriter(schema, plainEncodingFile)) { + writer.addAll(nonDictionaryData); + } + + int rowGroupSize = PARQUET_ROW_GROUP_SIZE_BYTES_DEFAULT; + File mixedFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(mixedFile.delete()).as("Delete should succeed").isTrue(); + Parquet.concat( + ImmutableList.of(dictionaryEncodedFile, plainEncodingFile, dictionaryEncodedFile), + mixedFile, + rowGroupSize, + schema, + ImmutableMap.of()); + assertRecordsMatch( + schema, + 30000, + FluentIterable.concat(dictionaryEncodableData, nonDictionaryData, dictionaryEncodableData), + mixedFile, + true, + BATCH_SIZE); + } + + @Test + public void testBinaryNotAllPagesDictionaryEncoded() throws IOException { + Schema schema = new Schema(Types.NestedField.required(1, "bytes", Types.BinaryType.get())); + File parquetFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(parquetFile.delete()).as("Delete should succeed").isTrue(); + + Iterable records = RandomGenericData.generateFallbackRecords(schema, 500, 0L, 100); + try (FileAppender writer = + Parquet.write(Files.localOutput(parquetFile)) + .schema(schema) + .set(PARQUET_DICT_SIZE_BYTES, "4096") + .set(PARQUET_PAGE_ROW_LIMIT, "100") + .createWriterFunc(GenericParquetWriter::create) + .build()) { + writer.addAll(records); + } + + // After the above, parquetFile contains one column chunk of binary data in five pages, + // the first two RLE dictionary encoded, and the remaining three plain encoded. + assertRecordsMatch(schema, 500, records, parquetFile, true, BATCH_SIZE); + } + + /** + * decimal_dict_and_plain_encoding.parquet contains one column chunk of decimal(38, 0) data in two + * pages, one RLE dictionary encoded and one plain encoded, each with 200 rows. + */ + @Test + public void testDecimalNotAllPagesDictionaryEncoded() throws Exception { + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.DecimalType.of(38, 0))); + Path path = + Paths.get( + getClass() + .getClassLoader() + .getResource("decimal_dict_and_plain_encoding.parquet") + .toURI()); + + Dataset df = spark.read().parquet(path.toString()); + List expected = df.collectAsList(); + long expectedSize = df.count(); + + assertNoLeak( + "testDecimalNotAllPagesDictionaryEncoded", + allocator -> { + Parquet.ReadBuilder readBuilder = + Parquet.read(Files.localInput(path.toFile())) + .project(schema) + .createBatchedReaderFunc( + type -> + VectorizedSparkParquetReaders.buildReader( + schema, type, ImmutableMap.of(), allocator)); + + try (CloseableIterable batchReader = readBuilder.build()) { + Iterator expectedIter = expected.iterator(); + Iterator batches = batchReader.iterator(); + int numRowsRead = 0; + while (batches.hasNext()) { + ColumnarBatch batch = batches.next(); + numRowsRead += batch.numRows(); + TestHelpers.assertEqualsBatchWithRows(schema.asStruct(), expectedIter, batch); + } + assertThat(numRowsRead).isEqualTo(expectedSize); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryFallbackToPlainEncodingVectorizedReads.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryFallbackToPlainEncodingVectorizedReads.java new file mode 100644 index 000000000000..9f9c2b961d38 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetDictionaryFallbackToPlainEncodingVectorizedReads.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized.parquet; + +import java.io.File; +import java.io.IOException; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.base.Function; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class TestParquetDictionaryFallbackToPlainEncodingVectorizedReads + extends TestParquetVectorizedReads { + private static final int NUM_ROWS = 1_000_000; + + @Override + protected int getNumRows() { + return NUM_ROWS; + } + + @Override + Iterable generateData( + Schema schema, + int numRecords, + long seed, + float nullPercentage, + Function transform) { + // TODO: take into account nullPercentage when generating fallback encoding data + Iterable data = + RandomGenericData.generateFallbackRecords(schema, numRecords, seed, numRecords / 20); + return transform == IDENTITY ? data : Iterables.transform(data, transform); + } + + @Override + FileAppender getParquetWriter(Schema schema, File testFile) throws IOException { + return Parquet.write(Files.localOutput(testFile)) + .schema(schema) + .createWriterFunc(GenericParquetWriter::create) + .named("test") + .set(TableProperties.PARQUET_DICT_SIZE_BYTES, "512000") + .build(); + } + + @Test + @Override + @Disabled // Fallback encoding not triggered when data is mostly null + public void testMostlyNullsForOptionalFields() {} + + @Test + @Override + @Disabled // Ignored since this code path is already tested in TestParquetVectorizedReads + public void testVectorizedReadsWithNewContainers() throws IOException {} +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java new file mode 100644 index 000000000000..6011c6dad7d2 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/data/vectorized/parquet/TestParquetVectorizedReads.java @@ -0,0 +1,560 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.data.vectorized.parquet; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.iceberg.Files; +import org.apache.iceberg.Schema; +import org.apache.iceberg.arrow.ArrowAllocation; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.parquet.GenericParquetReaders; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.base.Function; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.base.Strings; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.data.AvroDataTestBase; +import org.apache.iceberg.spark.data.GenericsHelpers; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.data.SparkParquetReaders; +import org.apache.iceberg.spark.data.vectorized.VectorizedSparkParquetReaders; +import org.apache.iceberg.types.Type.PrimitiveType; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.parquet.column.ParquetProperties; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.Type; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class TestParquetVectorizedReads extends AvroDataTestBase { + private static final int NUM_ROWS = 200_000; + static final int BATCH_SIZE = 10_000; + + private static final String PLAIN = "PLAIN"; + private static final List GOLDEN_FILE_ENCODINGS = + ImmutableList.of( + "PLAIN_DICTIONARY", + "RLE_DICTIONARY", + "DELTA_BINARY_PACKED", + "DELTA_LENGTH_BYTE_ARRAY", + "DELTA_BYTE_ARRAY", + "BYTE_STREAM_SPLIT"); + private static final Map GOLDEN_FILE_TYPES = + ImmutableMap.builder() + .put("string", Types.StringType.get()) + .put("float", Types.FloatType.get()) + .put("double", Types.DoubleType.get()) + .put("int32", Types.IntegerType.get()) + .put("int64", Types.LongType.get()) + .put("binary", Types.BinaryType.get()) + .put("boolean", Types.BooleanType.get()) + .put("uuid", Types.UUIDType.get()) + .buildOrThrow(); + + static final Function IDENTITY = record -> record; + + @Override + protected void writeAndValidate(Schema schema) throws IOException { + writeAndValidate(schema, schema); + } + + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + writeAndValidate( + writeSchema, + expectedSchema, + getNumRows(), + 29714278L, + RandomData.DEFAULT_NULL_PERCENTAGE, + true, + BATCH_SIZE, + IDENTITY); + } + + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema, List records) + throws IOException { + writeAndValidate( + writeSchema, expectedSchema, true, BATCH_SIZE, records.size(), records, ID_TO_CONSTANT); + } + + @Override + protected boolean supportsDefaultValues() { + return true; + } + + @Override + protected boolean supportsNestedTypes() { + return false; + } + + @Override + protected boolean supportsRowLineage() { + return true; + } + + private void writeAndValidate( + Schema schema, int numRecords, long seed, float nullPercentage, boolean reuseContainers) + throws IOException { + writeAndValidate( + schema, schema, numRecords, seed, nullPercentage, reuseContainers, BATCH_SIZE, IDENTITY); + } + + private void writeAndValidate( + Schema writeSchema, + Schema expectedSchema, + int numRecords, + long seed, + float nullPercentage, + boolean reuseContainers, + int batchSize, + Function transform) + throws IOException { + writeAndValidate( + writeSchema, + expectedSchema, + numRecords, + seed, + nullPercentage, + reuseContainers, + batchSize, + transform, + ImmutableMap.of()); + } + + private void writeAndValidate( + Schema writeSchema, + Schema expectedSchema, + int numRecords, + long seed, + float nullPercentage, + boolean reuseContainers, + int batchSize, + Function transform, + Map idToConstant) + throws IOException { + // Write test data + assumeThat( + TypeUtil.find( + writeSchema, + type -> type.isMapType() && type.asMapType().keyType() != Types.StringType.get())) + .as("Parquet Avro cannot write non-string map keys") + .isNull(); + + Iterable expected = + generateData(writeSchema, numRecords, seed, nullPercentage, transform); + + writeAndValidate( + writeSchema, + expectedSchema, + reuseContainers, + batchSize, + numRecords, + expected, + idToConstant); + } + + private void writeAndValidate( + Schema writeSchema, + Schema expectedSchema, + boolean reuseContainers, + int batchSize, + int numRecords, + Iterable expected, + Map idToConstant) + throws IOException { + // write a test parquet file using iceberg writer + File testFile = temp.resolve("data.parquet").toFile(); + + try (FileAppender writer = getParquetWriter(writeSchema, testFile)) { + writer.addAll(expected); + } + + assertRecordsMatch( + expectedSchema, numRecords, expected, testFile, reuseContainers, batchSize, idToConstant); + } + + protected int getNumRows() { + return NUM_ROWS; + } + + Iterable generateData( + Schema schema, + int numRecords, + long seed, + float nullPercentage, + Function transform) { + Iterable data = RandomGenericData.generate(schema, numRecords, seed); + return transform == IDENTITY ? data : Iterables.transform(data, transform); + } + + FileAppender getParquetWriter(Schema schema, File testFile) throws IOException { + return Parquet.write(Files.localOutput(testFile)) + .schema(schema) + .createWriterFunc(GenericParquetWriter::create) + .named("test") + .build(); + } + + FileAppender getParquetV2Writer(Schema schema, File testFile) throws IOException { + return Parquet.write(Files.localOutput(testFile)) + .schema(schema) + .createWriterFunc(GenericParquetWriter::create) + .named("test") + .writerVersion(ParquetProperties.WriterVersion.PARQUET_2_0) + .build(); + } + + void assertRecordsMatch( + Schema schema, + int expectedSize, + Iterable expected, + File testFile, + boolean reuseContainers, + int batchSize) + throws IOException { + assertRecordsMatch( + schema, expectedSize, expected, testFile, reuseContainers, batchSize, ImmutableMap.of()); + } + + void assertRecordsMatch( + Schema schema, + int expectedSize, + Iterable expected, + File testFile, + boolean reuseContainers, + int batchSize, + Map idToConstant) + throws IOException { + assertNoLeak( + testFile.getName(), + allocator -> { + Parquet.ReadBuilder readBuilder = + Parquet.read(Files.localInput(testFile)) + .project(schema) + .recordsPerBatch(batchSize) + .createBatchedReaderFunc( + type -> + VectorizedSparkParquetReaders.buildReader( + schema, type, idToConstant, allocator)); + if (reuseContainers) { + readBuilder.reuseContainers(); + } + try (CloseableIterable batchReader = readBuilder.build()) { + Iterator expectedIter = expected.iterator(); + Iterator batches = batchReader.iterator(); + int numRowsRead = 0; + while (batches.hasNext()) { + ColumnarBatch batch = batches.next(); + GenericsHelpers.assertEqualsBatch( + schema.asStruct(), expectedIter, batch, idToConstant, numRowsRead); + numRowsRead += batch.numRows(); + } + assertThat(numRowsRead).isEqualTo(expectedSize); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Test + @Override + public void testNestedStruct() { + assertThatThrownBy( + () -> + VectorizedSparkParquetReaders.buildReader( + TypeUtil.assignIncreasingFreshIds( + new Schema(required(1, "struct", SUPPORTED_PRIMITIVES))), + new MessageType( + "struct", new GroupType(Type.Repetition.OPTIONAL, "struct").withId(1)), + Maps.newHashMap())) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Vectorized reads are not supported yet for struct fields"); + } + + @Test + public void testMostlyNullsForOptionalFields() throws IOException { + writeAndValidate( + TypeUtil.assignIncreasingFreshIds(new Schema(SUPPORTED_PRIMITIVES.fields())), + getNumRows(), + 0L, + 0.99f, + true); + } + + @Test + public void testSettingArrowValidityVector() throws IOException { + writeAndValidate( + new Schema(Lists.transform(SUPPORTED_PRIMITIVES.fields(), Types.NestedField::asOptional)), + getNumRows(), + 0L, + RandomData.DEFAULT_NULL_PERCENTAGE, + true); + } + + @Test + public void testVectorizedReadsWithNewContainers() throws IOException { + writeAndValidate( + TypeUtil.assignIncreasingFreshIds(new Schema(SUPPORTED_PRIMITIVES.fields())), + getNumRows(), + 0L, + RandomData.DEFAULT_NULL_PERCENTAGE, + false); + } + + @Test + public void testVectorizedReadsWithReallocatedArrowBuffers() throws IOException { + // With a batch size of 2, 256 bytes are allocated in the VarCharVector. By adding strings of + // length 512, the vector will need to be reallocated for storing the batch. + Schema schema = + new Schema( + Lists.newArrayList( + SUPPORTED_PRIMITIVES.field("id"), SUPPORTED_PRIMITIVES.field("data"))); + int dataOrdinal = 1; + + writeAndValidate( + schema, + schema, + 10, + 0L, + RandomData.DEFAULT_NULL_PERCENTAGE, + true, + 2, + record -> { + if (record.get(dataOrdinal, String.class) != null) { + record.set( + dataOrdinal, Strings.padEnd(record.get(dataOrdinal, String.class), 512, 'a')); + } else { + record.set(dataOrdinal, Strings.padEnd("", 512, 'a')); + } + return record; + }); + } + + @Test + public void testReadsForTypePromotedColumns() throws Exception { + Schema writeSchema = + new Schema( + required(100, "id", Types.LongType.get()), + optional(101, "int_data", Types.IntegerType.get()), + optional(102, "float_data", Types.FloatType.get()), + optional(103, "decimal_data", Types.DecimalType.of(10, 5))); + + File dataFile = temp.resolve("data.parquet").toFile(); + Iterable data = + generateData(writeSchema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); + try (FileAppender writer = getParquetWriter(writeSchema, dataFile)) { + writer.addAll(data); + } + + Schema readSchema = + new Schema( + required(100, "id", Types.LongType.get()), + optional(101, "int_data", Types.LongType.get()), + optional(102, "float_data", Types.DoubleType.get()), + optional(103, "decimal_data", Types.DecimalType.of(25, 5))); + + assertRecordsMatch(readSchema, 30000, data, dataFile, false, BATCH_SIZE); + } + + @Test + public void testSupportedReadsForParquetV2() throws Exception { + // Parquet V2 uses PLAIN for float/double, DELTA_BINARY_PACKED for int/long, + // DELTA_LENGTH_BYTE_ARRAY for string/binary, and dictionary encoding for decimals + // that use fixed length binary (i.e. decimals > 8 bytes). + Schema schema = + new Schema( + optional(102, "float_data", Types.FloatType.get()), + optional(103, "double_data", Types.DoubleType.get()), + optional(104, "decimal_data", Types.DecimalType.of(25, 5)), + optional(105, "int_data", Types.IntegerType.get()), + optional(106, "long_data", Types.LongType.get()), + optional(107, "string_data", Types.StringType.get()), + optional(108, "binary_data", Types.BinaryType.get())); + + File dataFile = temp.resolve("data.parquet").toFile(); + Iterable data = + generateData(schema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); + try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { + writer.addAll(data); + } + assertRecordsMatch(schema, 30000, data, dataFile, false, BATCH_SIZE); + } + + @Test + public void testUnsupportedReadsForParquetV2() throws Exception { + // Some types use delta encoding and which are not supported for vectorized reads + Schema schema = new Schema(SUPPORTED_PRIMITIVES.fields()); + File dataFile = temp.resolve("data.parquet").toFile(); + Iterable data = + generateData(schema, 30000, 0L, RandomData.DEFAULT_NULL_PERCENTAGE, IDENTITY); + try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { + writer.addAll(data); + } + assertThatThrownBy(() -> assertRecordsMatch(schema, 30000, data, dataFile, false, BATCH_SIZE)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith("Cannot support vectorized reads for column") + .hasMessageEndingWith("Disable vectorized reads to read this table/file"); + } + + @Test + public void testUuidReads() throws Exception { + // Just one row to maintain dictionary encoding + int numRows = 1; + Schema schema = new Schema(optional(100, "uuid", Types.UUIDType.get())); + + File dataFile = temp.resolve("data.parquet").toFile(); + Iterable data = generateData(schema, numRows, 0L, 0, IDENTITY); + try (FileAppender writer = getParquetV2Writer(schema, dataFile)) { + writer.addAll(data); + } + assertRecordsMatch(schema, numRows, data, dataFile, false, BATCH_SIZE); + } + + private void assertIdenticalFileContents( + File actual, File expected, Schema schema, boolean vectorized) throws IOException { + try (CloseableIterable expectedIterator = + Parquet.read(Files.localInput(expected)) + .project(schema) + .createReaderFunc(msgType -> GenericParquetReaders.buildReader(schema, msgType)) + .build()) { + List expectedRecords = Lists.newArrayList(expectedIterator); + if (vectorized) { + assertRecordsMatch( + schema, expectedRecords.size(), expectedRecords, actual, false, BATCH_SIZE); + } else { + try (CloseableIterable actualIterator = + Parquet.read(Files.localInput(actual)) + .project(schema) + .createReaderFunc(msgType -> SparkParquetReaders.buildReader(schema, msgType)) + .build()) { + List actualRecords = Lists.newArrayList(actualIterator); + assertThat(actualRecords).hasSameSizeAs(expectedRecords); + for (int i = 0; i < actualRecords.size(); i++) { + GenericsHelpers.assertEqualsUnsafe( + schema.asStruct(), expectedRecords.get(i), actualRecords.get(i)); + } + } + } + } + } + + static Stream goldenFilesAndEncodings() { + return GOLDEN_FILE_ENCODINGS.stream() + .flatMap( + encoding -> + GOLDEN_FILE_TYPES.entrySet().stream() + .flatMap( + e -> + Stream.of(true, false) + .flatMap( + vectorized -> + Stream.of( + Arguments.of( + encoding, e.getKey(), e.getValue(), vectorized), + Arguments.of( + encoding, + e.getKey() + "_with_nulls", + e.getValue(), + vectorized))))); + } + + private File resourceUrlToLocalFile(URL url) throws IOException, URISyntaxException { + if ("file".equals(url.getProtocol())) { + return Paths.get(url.toURI()).toFile(); + } + + String name = Paths.get(url.getPath()).getFileName().toString(); // e.g., string.parquet + String suffix = name.contains(".") ? name.substring(name.lastIndexOf('.')) : ""; + File tmp = File.createTempFile("golden-", suffix, temp.toFile()); + try (InputStream in = url.openStream()) { + java.nio.file.Files.copy(in, tmp.toPath(), REPLACE_EXISTING); + } + return tmp; + } + + @ParameterizedTest + @MethodSource("goldenFilesAndEncodings") + public void testGoldenFiles( + String encoding, String typeName, PrimitiveType primitiveType, boolean vectorized) + throws Exception { + Path goldenResourcePath = Paths.get("encodings", encoding, typeName + ".parquet"); + URL goldenFileUrl = getClass().getClassLoader().getResource(goldenResourcePath.toString()); + assumeThat(goldenFileUrl).as("type/encoding pair exists").isNotNull(); + + Path plainResourcePath = Paths.get("encodings", PLAIN, typeName + ".parquet"); + URL plainFileUrl = getClass().getClassLoader().getResource(plainResourcePath.toString()); + Preconditions.checkState( + plainFileUrl != null, "PLAIN encoded file should exist: " + plainResourcePath); + + Schema expectedSchema = new Schema(optional(1, "data", primitiveType)); + assertIdenticalFileContents( + resourceUrlToLocalFile(goldenFileUrl), + resourceUrlToLocalFile(plainFileUrl), + expectedSchema, + vectorized); + } + + protected void assertNoLeak(String testName, Consumer testFunction) { + BufferAllocator allocator = + ArrowAllocation.rootAllocator().newChildAllocator(testName, 0, Long.MAX_VALUE); + try { + testFunction.accept(allocator); + assertThat(allocator.getAllocatedMemory()) + .as( + "Should have released all memory prior to closing. Expected to find 0 bytes of memory in use.") + .isEqualTo(0L); + } finally { + allocator.close(); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/functions/TestSparkFunctions.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/functions/TestSparkFunctions.java new file mode 100644 index 000000000000..38ce0d4d95f1 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/functions/TestSparkFunctions.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.functions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.spark.sql.connector.catalog.functions.ScalarFunction; +import org.apache.spark.sql.connector.catalog.functions.UnboundFunction; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.DecimalType; +import org.junit.jupiter.api.Test; + +public class TestSparkFunctions { + + @Test + public void testBuildYearsFunctionFromClass() { + UnboundFunction expected = new YearsFunction(); + + YearsFunction.DateToYearsFunction dateToYearsFunc = new YearsFunction.DateToYearsFunction(); + checkBuildFunc(dateToYearsFunc, expected); + + YearsFunction.TimestampToYearsFunction tsToYearsFunc = + new YearsFunction.TimestampToYearsFunction(); + checkBuildFunc(tsToYearsFunc, expected); + + YearsFunction.TimestampNtzToYearsFunction tsNtzToYearsFunc = + new YearsFunction.TimestampNtzToYearsFunction(); + checkBuildFunc(tsNtzToYearsFunc, expected); + } + + @Test + public void testBuildMonthsFunctionFromClass() { + UnboundFunction expected = new MonthsFunction(); + + MonthsFunction.DateToMonthsFunction dateToMonthsFunc = + new MonthsFunction.DateToMonthsFunction(); + checkBuildFunc(dateToMonthsFunc, expected); + + MonthsFunction.TimestampToMonthsFunction tsToMonthsFunc = + new MonthsFunction.TimestampToMonthsFunction(); + checkBuildFunc(tsToMonthsFunc, expected); + + MonthsFunction.TimestampNtzToMonthsFunction tsNtzToMonthsFunc = + new MonthsFunction.TimestampNtzToMonthsFunction(); + checkBuildFunc(tsNtzToMonthsFunc, expected); + } + + @Test + public void testBuildDaysFunctionFromClass() { + UnboundFunction expected = new DaysFunction(); + + DaysFunction.DateToDaysFunction dateToDaysFunc = new DaysFunction.DateToDaysFunction(); + checkBuildFunc(dateToDaysFunc, expected); + + DaysFunction.TimestampToDaysFunction tsToDaysFunc = new DaysFunction.TimestampToDaysFunction(); + checkBuildFunc(tsToDaysFunc, expected); + + DaysFunction.TimestampNtzToDaysFunction tsNtzToDaysFunc = + new DaysFunction.TimestampNtzToDaysFunction(); + checkBuildFunc(tsNtzToDaysFunc, expected); + } + + @Test + public void testBuildHoursFunctionFromClass() { + UnboundFunction expected = new HoursFunction(); + + HoursFunction.TimestampToHoursFunction tsToHoursFunc = + new HoursFunction.TimestampToHoursFunction(); + checkBuildFunc(tsToHoursFunc, expected); + + HoursFunction.TimestampNtzToHoursFunction tsNtzToHoursFunc = + new HoursFunction.TimestampNtzToHoursFunction(); + checkBuildFunc(tsNtzToHoursFunc, expected); + } + + @Test + public void testBuildBucketFunctionFromClass() { + UnboundFunction expected = new BucketFunction(); + + BucketFunction.BucketInt bucketDateFunc = new BucketFunction.BucketInt(DataTypes.DateType); + checkBuildFunc(bucketDateFunc, expected); + + BucketFunction.BucketInt bucketIntFunc = new BucketFunction.BucketInt(DataTypes.IntegerType); + checkBuildFunc(bucketIntFunc, expected); + + BucketFunction.BucketLong bucketLongFunc = new BucketFunction.BucketLong(DataTypes.LongType); + checkBuildFunc(bucketLongFunc, expected); + + BucketFunction.BucketLong bucketTsFunc = new BucketFunction.BucketLong(DataTypes.TimestampType); + checkBuildFunc(bucketTsFunc, expected); + + BucketFunction.BucketLong bucketTsNtzFunc = + new BucketFunction.BucketLong(DataTypes.TimestampNTZType); + checkBuildFunc(bucketTsNtzFunc, expected); + + BucketFunction.BucketDecimal bucketDecimalFunc = + new BucketFunction.BucketDecimal(new DecimalType()); + checkBuildFunc(bucketDecimalFunc, expected); + + BucketFunction.BucketString bucketStringFunc = new BucketFunction.BucketString(); + checkBuildFunc(bucketStringFunc, expected); + + BucketFunction.BucketBinary bucketBinary = new BucketFunction.BucketBinary(); + checkBuildFunc(bucketBinary, expected); + } + + @Test + public void testBuildTruncateFunctionFromClass() { + UnboundFunction expected = new TruncateFunction(); + + TruncateFunction.TruncateTinyInt truncateTinyIntFunc = new TruncateFunction.TruncateTinyInt(); + checkBuildFunc(truncateTinyIntFunc, expected); + + TruncateFunction.TruncateSmallInt truncateSmallIntFunc = + new TruncateFunction.TruncateSmallInt(); + checkBuildFunc(truncateSmallIntFunc, expected); + + TruncateFunction.TruncateInt truncateIntFunc = new TruncateFunction.TruncateInt(); + checkBuildFunc(truncateIntFunc, expected); + + TruncateFunction.TruncateBigInt truncateBigIntFunc = new TruncateFunction.TruncateBigInt(); + checkBuildFunc(truncateBigIntFunc, expected); + + TruncateFunction.TruncateDecimal truncateDecimalFunc = + new TruncateFunction.TruncateDecimal(10, 9); + checkBuildFunc(truncateDecimalFunc, expected); + + TruncateFunction.TruncateString truncateStringFunc = new TruncateFunction.TruncateString(); + checkBuildFunc(truncateStringFunc, expected); + + TruncateFunction.TruncateBinary truncateBinaryFunc = new TruncateFunction.TruncateBinary(); + checkBuildFunc(truncateBinaryFunc, expected); + } + + private void checkBuildFunc(ScalarFunction function, UnboundFunction expected) { + UnboundFunction actual = SparkFunctions.loadFunctionByClass(function.getClass()); + + assertThat(actual).isNotNull(); + assertThat(actual.name()).isEqualTo(expected.name()); + assertThat(actual.description()).isEqualTo(expected.description()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/BatchReaderUtil.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/BatchReaderUtil.java new file mode 100644 index 000000000000..e5d03a4efba5 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/BatchReaderUtil.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.data.DeleteFilter; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.vectorized.ColumnarBatch; + +public class BatchReaderUtil { + private BatchReaderUtil() {} + + public static CloseableIterable applyDeleteFilter( + CloseableIterable batches, DeleteFilter filter) { + return CloseableIterable.transform( + batches, new BaseBatchReader.BatchDeleteFilter(filter)::filterBatch); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ComplexRecord.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ComplexRecord.java new file mode 100644 index 000000000000..42e8552578cd --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ComplexRecord.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; +import org.apache.iceberg.relocated.com.google.common.base.Objects; + +public class ComplexRecord { + private long id; + private NestedRecord struct; + + public ComplexRecord() {} + + public ComplexRecord(long id, NestedRecord struct) { + this.id = id; + this.struct = struct; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public NestedRecord getStruct() { + return struct; + } + + public void setStruct(NestedRecord struct) { + this.struct = struct; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + ComplexRecord record = (ComplexRecord) o; + return id == record.id && Objects.equal(struct, record.struct); + } + + @Override + public int hashCode() { + return Objects.hashCode(id, struct); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("id", id).add("struct", struct).toString(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java new file mode 100644 index 000000000000..09dfaecdf417 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/DataFrameWriteTestBase.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.spark.SparkSchemaUtil.convert; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Iterator; +import java.util.List; +import org.apache.iceberg.Files; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.Tables; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.avro.AvroIterable; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.avro.DataWriter; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.data.GenericsHelpers; +import org.apache.iceberg.spark.data.SparkPlannedAvroReader; +import org.apache.iceberg.types.Types; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.sql.DataFrameWriter; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public abstract class DataFrameWriteTestBase extends ScanTestBase { + @TempDir private Path temp; + + @Override + protected boolean supportsDefaultValues() { + // disable default value tests because this tests the write path + return false; + } + + @Override + protected void writeRecords(Table table, List records) throws IOException { + Schema tableSchema = table.schema(); // use the table schema because ids are reassigned + + Dataset df = createDataset(records, tableSchema); + DataFrameWriter writer = df.write().format("iceberg").mode("append"); + + writer.save(table.location()); + + // refresh the in-memory table state to pick up Spark's write + table.refresh(); + } + + private Dataset createDataset(List records, Schema schema) throws IOException { + // this uses the SparkAvroReader to create a DataFrame from the list of records + // it assumes that SparkAvroReader is correct + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + + try (FileAppender writer = + Avro.write(Files.localOutput(testFile)) + .schema(schema) + .createWriterFunc(DataWriter::create) + .named("test") + .build()) { + writer.addAll(records); + } + + List rows; + try (AvroIterable reader = + Avro.read(Files.localInput(testFile)) + .createResolvingReader(SparkPlannedAvroReader::create) + .project(schema) + .build()) { + rows = Lists.newArrayList(reader); + } + + // verify that the dataframe matches + assertThat(rows).hasSameSizeAs(records); + Iterator recordIter = records.iterator(); + for (InternalRow row : rows) { + GenericsHelpers.assertEqualsUnsafe(schema.asStruct(), recordIter.next(), row); + } + + JavaRDD rdd = sc.parallelize(rows); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + return ((org.apache.spark.sql.classic.SparkSession) spark) + .internalCreateDataFrame(JavaRDD.toRDD(rdd), convert(schema), false); + } + + @Test + public void testAlternateLocation() throws IOException { + Schema schema = new Schema(required(1, "id", Types.LongType.get())); + + File location = temp.resolve("table_location").toFile(); + File altLocation = temp.resolve("alt_location").toFile(); + + Tables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + Table table = tables.create(schema, PartitionSpec.unpartitioned(), location.toString()); + + // override the table's data location + table + .updateProperties() + .set(TableProperties.WRITE_DATA_LOCATION, altLocation.getAbsolutePath()) + .commit(); + + writeRecords(table, RandomGenericData.generate(table.schema(), 100, 87112L)); + + SnapshotChanges.builderFor(table) + .build() + .addedDataFiles() + .forEach( + dataFile -> + assertThat(dataFile.location()) + .as( + String.format( + "File should have the parent directory %s, but has: %s.", + altLocation, dataFile.location())) + .startsWith(altLocation + "/")); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/FilePathLastModifiedRecord.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/FilePathLastModifiedRecord.java new file mode 100644 index 000000000000..c62c1de6ba33 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/FilePathLastModifiedRecord.java @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.sql.Timestamp; +import java.util.Objects; + +public class FilePathLastModifiedRecord { + private String filePath; + private Timestamp lastModified; + + public FilePathLastModifiedRecord() {} + + public FilePathLastModifiedRecord(String filePath, Timestamp lastModified) { + this.filePath = filePath; + this.lastModified = lastModified; + } + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public Timestamp getLastModified() { + return lastModified; + } + + public void setLastModified(Timestamp lastModified) { + this.lastModified = lastModified; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FilePathLastModifiedRecord that = (FilePathLastModifiedRecord) o; + return Objects.equals(filePath, that.filePath) + && Objects.equals(lastModified, that.lastModified); + } + + @Override + public int hashCode() { + return Objects.hash(filePath, lastModified); + } + + @Override + public String toString() { + return "FilePathLastModifiedRecord{" + + "filePath='" + + filePath + + '\'' + + ", lastModified='" + + lastModified + + '\'' + + '}'; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/FourColumnRecord.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/FourColumnRecord.java new file mode 100644 index 000000000000..0f9529e4d105 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/FourColumnRecord.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Objects; + +public class FourColumnRecord { + private Integer c1; + private String c2; + private String c3; + private String c4; + + public FourColumnRecord() {} + + public FourColumnRecord(Integer c1, String c2, String c3, String c4) { + this.c1 = c1; + this.c2 = c2; + this.c3 = c3; + this.c4 = c4; + } + + public Integer getC1() { + return c1; + } + + public void setC1(Integer c1) { + this.c1 = c1; + } + + public String getC2() { + return c2; + } + + public void setC2(String c2) { + this.c2 = c2; + } + + public String getC3() { + return c3; + } + + public void setC3(String c3) { + this.c3 = c3; + } + + public String getC4() { + return c4; + } + + public void setC4(String c4) { + this.c4 = c4; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FourColumnRecord that = (FourColumnRecord) o; + return Objects.equals(c1, that.c1) + && Objects.equals(c2, that.c2) + && Objects.equals(c3, that.c3) + && Objects.equals(c3, that.c4); + } + + @Override + public int hashCode() { + return Objects.hash(c1, c2, c3, c4); + } + + @Override + public String toString() { + return "ThreeColumnRecord{" + + "c1=" + + c1 + + ", c2='" + + c2 + + '\'' + + ", c3='" + + c3 + + '\'' + + ", c4='" + + c4 + + '\'' + + '}'; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/LogMessage.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/LogMessage.java new file mode 100644 index 000000000000..875b1009c37f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/LogMessage.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; + +public class LogMessage { + private static final AtomicInteger ID_COUNTER = new AtomicInteger(0); + + static LogMessage debug(String date, String message) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "DEBUG", message); + } + + static LogMessage debug(String date, String message, Instant timestamp) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "DEBUG", message, timestamp); + } + + static LogMessage info(String date, String message) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "INFO", message); + } + + static LogMessage info(String date, String message, Instant timestamp) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "INFO", message, timestamp); + } + + static LogMessage error(String date, String message) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "ERROR", message); + } + + static LogMessage error(String date, String message, Instant timestamp) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "ERROR", message, timestamp); + } + + static LogMessage warn(String date, String message) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "WARN", message); + } + + static LogMessage warn(String date, String message, Instant timestamp) { + return new LogMessage(ID_COUNTER.getAndIncrement(), date, "WARN", message, timestamp); + } + + private int id; + private String date; + private String level; + private String message; + private Instant timestamp; + + private LogMessage(int id, String date, String level, String message) { + this.id = id; + this.date = date; + this.level = level; + this.message = message; + } + + private LogMessage(int id, String date, String level, String message, Instant timestamp) { + this.id = id; + this.date = date; + this.level = level; + this.message = message; + this.timestamp = timestamp; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } + + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ManualSource.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ManualSource.java new file mode 100644 index 000000000000..b6f172248ea9 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ManualSource.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableProvider; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.sources.DataSourceRegister; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +public class ManualSource implements TableProvider, DataSourceRegister { + public static final String SHORT_NAME = "manual_source"; + public static final String TABLE_NAME = "TABLE_NAME"; + private static final Map TABLE_MAP = Maps.newHashMap(); + + public static void setTable(String name, Table table) { + Preconditions.checkArgument( + !TABLE_MAP.containsKey(name), "Cannot set " + name + ". It is already set"); + TABLE_MAP.put(name, table); + } + + public static void clearTables() { + TABLE_MAP.clear(); + } + + @Override + public String shortName() { + return SHORT_NAME; + } + + @Override + public StructType inferSchema(CaseInsensitiveStringMap options) { + return getTable(null, null, options).schema(); + } + + @Override + public Transform[] inferPartitioning(CaseInsensitiveStringMap options) { + return getTable(null, null, options).partitioning(); + } + + @Override + public org.apache.spark.sql.connector.catalog.Table getTable( + StructType schema, Transform[] partitioning, Map properties) { + Preconditions.checkArgument( + properties.containsKey(TABLE_NAME), "Missing property " + TABLE_NAME); + String tableName = properties.get(TABLE_NAME); + Preconditions.checkArgument(TABLE_MAP.containsKey(tableName), "Table missing " + tableName); + return TABLE_MAP.get(tableName); + } + + @Override + public boolean supportsExternalMetadata() { + return false; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/NestedRecord.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/NestedRecord.java new file mode 100644 index 000000000000..ca36bfd4938b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/NestedRecord.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.relocated.com.google.common.base.MoreObjects; +import org.apache.iceberg.relocated.com.google.common.base.Objects; + +public class NestedRecord { + private long innerId; + private String innerName; + + public NestedRecord() {} + + public NestedRecord(long innerId, String innerName) { + this.innerId = innerId; + this.innerName = innerName; + } + + public long getInnerId() { + return innerId; + } + + public String getInnerName() { + return innerName; + } + + public void setInnerId(long iId) { + innerId = iId; + } + + public void setInnerName(String name) { + innerName = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o == null || getClass() != o.getClass()) { + return false; + } + + NestedRecord that = (NestedRecord) o; + return innerId == that.innerId && Objects.equal(innerName, that.innerName); + } + + @Override + public int hashCode() { + return Objects.hashCode(innerId, innerName); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("innerId", innerId) + .add("innerName", innerName) + .toString(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java new file mode 100644 index 000000000000..da9cd639218f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ScanTestBase.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.AvroDataTestBase; +import org.apache.iceberg.spark.data.GenericsHelpers; +import org.apache.iceberg.types.TypeUtil; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; + +/** An AvroDataScan test that validates data by reading through Spark */ +public abstract class ScanTestBase extends AvroDataTestBase { + private static final Configuration CONF = new Configuration(); + + protected static SparkSession spark = null; + protected static JavaSparkContext sc = null; + + @BeforeAll + public static void startSpark() { + ScanTestBase.spark = + SparkSession.builder() + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .master("local[2]") + .config(TestBase.DISABLE_UI) + .getOrCreate(); + ScanTestBase.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = ScanTestBase.spark; + ScanTestBase.spark = null; + ScanTestBase.sc = null; + currentSpark.stop(); + } + + @TempDir private Path temp; + + protected void configureTable(Table table) {} + + protected abstract void writeRecords(Table table, List records) throws IOException; + + @Override + protected void writeAndValidate(Schema schema) throws IOException { + writeAndValidate(schema, schema); + } + + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + File parent = temp.resolve("scan_test").toFile(); + File location = new File(parent, "test"); + + HadoopTables tables = new HadoopTables(CONF); + // If V3 spec features are used, set the format version to 3 + Map tableProperties = ImmutableMap.of(TableProperties.FORMAT_VERSION, "3"); + Table table = + tables.create( + writeSchema, PartitionSpec.unpartitioned(), tableProperties, location.toString()); + configureTable(table); + + // Important: use the table's schema for the rest of the test + // When tables are created, the column ids are reassigned. + List expected = RandomGenericData.generate(table.schema(), 100, 1L); + + writeRecords(table, expected); + + // update the table schema to the expected schema + if (!expectedSchema.sameSchema(table.schema())) { + Schema expectedSchemaWithTableIds = + TypeUtil.reassignOrRefreshIds(expectedSchema, table.schema()); + int highestFieldId = + Math.max(table.schema().highestFieldId(), expectedSchema.highestFieldId()); + + // don't use the table API because tests cover incompatible update cases + TableOperations ops = ((BaseTable) table).operations(); + TableMetadata builder = + TableMetadata.buildFrom(ops.current()) + .upgradeFormatVersion(3) + .setCurrentSchema(expectedSchemaWithTableIds, highestFieldId) + .build(); + ops.commit(ops.current(), builder); + } + + Dataset df = spark.read().format("iceberg").load(table.location()); + + List rows = df.collectAsList(); + assertThat(rows).as("Should contain 100 rows").hasSize(100); + + for (int i = 0; i < expected.size(); i += 1) { + GenericsHelpers.assertEqualsSafe(table.schema().asStruct(), expected.get(i), rows.get(i)); + } + } + + @Override + protected boolean supportsDefaultValues() { + return true; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/SimpleRecord.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/SimpleRecord.java new file mode 100644 index 000000000000..550e20b9338e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/SimpleRecord.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.relocated.com.google.common.base.Objects; + +public class SimpleRecord { + private Integer id; + private String data; + + public SimpleRecord() {} + + public SimpleRecord(Integer id, String data) { + this.id = id; + this.data = data; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + SimpleRecord record = (SimpleRecord) o; + return Objects.equal(id, record.id) && Objects.equal(data, record.data); + } + + @Override + public int hashCode() { + return Objects.hashCode(id, data); + } + + @Override + public String toString() { + StringBuilder buffer = new StringBuilder(); + buffer.append("{\"id\"="); + buffer.append(id); + buffer.append(",\"data\"=\""); + buffer.append(data); + buffer.append("\"}"); + return buffer.toString(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/SparkSQLExecutionHelper.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/SparkSQLExecutionHelper.java new file mode 100644 index 000000000000..cdc380b1b6be --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/SparkSQLExecutionHelper.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.execution.ui.SQLAppStatusStore; +import org.apache.spark.sql.execution.ui.SQLExecutionUIData; +import org.apache.spark.sql.execution.ui.SQLPlanMetric; +import org.awaitility.Awaitility; +import scala.Option; + +public class SparkSQLExecutionHelper { + + private SparkSQLExecutionHelper() {} + + /** + * Finds the value of a specified metric for the last SQL query that was executed. Metric values + * are stored in the `SQLAppStatusStore` as strings. + * + * @param spark SparkSession used to run the SQL query + * @param metricName name of the metric + * @return value of the metric + */ + public static String lastExecutedMetricValue(SparkSession spark, String metricName) { + SQLAppStatusStore statusStore = spark.sharedState().statusStore(); + SQLExecutionUIData lastExecution = statusStore.executionsList().last(); + Option sqlPlanMetric = + lastExecution.metrics().find(metric -> metric.name().equals(metricName)); + assertThat(sqlPlanMetric.isDefined()) + .as(String.format("Metric '%s' not found in last execution", metricName)) + .isTrue(); + long metricId = sqlPlanMetric.get().accumulatorId(); + + // Refresh metricValues, they will remain null until the execution is complete and metrics are + // aggregated + Awaitility.await() + .atMost(Duration.ofSeconds(3)) + .pollInterval(Duration.ofMillis(100)) + .untilAsserted( + () -> assertThat(statusStore.execution(lastExecution.executionId()).get()).isNotNull()); + + SQLExecutionUIData exec = statusStore.execution(lastExecution.executionId()).get(); + + assertThat(exec.metricValues()).as("Metric values were not finalized").isNotNull(); + String metricValue = exec.metricValues().get(metricId).getOrElse(null); + assertThat(metricValue) + .as(String.format("Metric '%s' was not finalized", metricName)) + .isNotNull(); + return metricValue; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java new file mode 100644 index 000000000000..b6017e2001e7 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAsyncSparkMicroBatchPlanner.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.iceberg.Snapshot; +import org.junit.jupiter.api.Test; + +class TestAsyncSparkMicroBatchPlanner { + + @Test + void reachedAvailableNowCapReturnsTrueOnlyForExactCapSnapshot() { + Snapshot capSnapshot = mockSnapshot(10L); + Snapshot laterSnapshotWithHigherId = mockSnapshot(20L); + Snapshot laterSnapshotWithLowerId = mockSnapshot(5L); + StreamingOffset capOffset = new StreamingOffset(10L, 3L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(capSnapshot, capOffset)).isTrue(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap( + laterSnapshotWithHigherId, capOffset)) + .isFalse(); + assertThat( + AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(laterSnapshotWithLowerId, capOffset)) + .isFalse(); + } + + @Test + void reachedAvailableNowCapReturnsFalseWhenCapOrSnapshotIsMissing() { + Snapshot readFrom = mockSnapshot(10L); + StreamingOffset capOffset = new StreamingOffset(10L, 1L, false); + + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(readFrom, null)).isFalse(); + assertThat(AsyncSparkMicroBatchPlanner.reachedAvailableNowCap(null, capOffset)).isFalse(); + } + + private Snapshot mockSnapshot(long snapshotId) { + Snapshot snapshot = mock(Snapshot.class); + when(snapshot.snapshotId()).thenReturn(snapshotId); + return snapshot; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAvroDataFrameWrite.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAvroDataFrameWrite.java new file mode 100644 index 000000000000..7e6aee981522 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAvroDataFrameWrite.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; + +public class TestAvroDataFrameWrite extends DataFrameWriteTestBase { + @Override + protected boolean supportsVariant() { + return true; + } + + @Override + protected void configureTable(Table table) { + table + .updateProperties() + .set(TableProperties.DEFAULT_FILE_FORMAT, FileFormat.AVRO.toString()) + .commit(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAvroScan.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAvroScan.java new file mode 100644 index 000000000000..79f3cf71b594 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestAvroScan.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.Files.localOutput; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.avro.DataWriter; +import org.apache.iceberg.io.FileAppender; + +public class TestAvroScan extends ScanTestBase { + @Override + protected boolean supportsVariant() { + return true; + } + + @Override + protected void writeRecords(Table table, List records) throws IOException { + File dataFolder = new File(table.location(), "data"); + dataFolder.mkdirs(); + + File avroFile = + new File(dataFolder, FileFormat.AVRO.addExtension(UUID.randomUUID().toString())); + + try (FileAppender writer = + Avro.write(localOutput(avroFile)) + .schema(table.schema()) + .createWriterFunc(DataWriter::create) + .build()) { + writer.addAll(records); + } + + DataFile file = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withFileSizeInBytes(avroFile.length()) + .withRecordCount(records.size()) + .withPath(avroFile.toString()) + .build(); + + table.newAppend().appendFile(file).commit(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java new file mode 100644 index 000000000000..0eb9bbe52ffa --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestBaseReader.java @@ -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. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.FileFormat.PARQUET; +import static org.apache.iceberg.Files.localOutput; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.apache.avro.generic.GenericData; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.BaseCombinedScanTask; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterator; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestBaseReader { + + @TempDir private Path temp; + + private Table table; + + // Simulates the closeable iterator of data to be read + private static class CloseableIntegerRange implements CloseableIterator { + boolean closed; + Iterator iter; + + CloseableIntegerRange(long range) { + this.closed = false; + this.iter = IntStream.range(0, (int) range).iterator(); + } + + @Override + public void close() { + this.closed = true; + } + + @Override + public boolean hasNext() { + return iter.hasNext(); + } + + @Override + public Integer next() { + return iter.next(); + } + } + + // Main reader class to test base class iteration logic. + // Keeps track of iterator closure. + private static class ClosureTrackingReader extends BaseReader { + private final Map tracker = Maps.newHashMap(); + + ClosureTrackingReader(Table table, List tasks) { + super(table, table.io(), new BaseCombinedScanTask(tasks), null, false, true); + } + + @Override + protected Stream> referencedFiles(FileScanTask task) { + return Stream.of(); + } + + @Override + protected CloseableIterator open(FileScanTask task) { + CloseableIntegerRange intRange = new CloseableIntegerRange(task.file().recordCount()); + tracker.put(getKey(task), intRange); + return intRange; + } + + public Boolean isIteratorClosed(FileScanTask task) { + return tracker.get(getKey(task)).closed; + } + + public Boolean hasIterator(FileScanTask task) { + return tracker.containsKey(getKey(task)); + } + + private String getKey(FileScanTask task) { + return task.file().location(); + } + } + + @Test + public void testClosureOnDataExhaustion() throws IOException { + Integer totalTasks = 10; + Integer recordPerTask = 10; + List tasks = createFileScanTasks(totalTasks, recordPerTask); + + ClosureTrackingReader reader = new ClosureTrackingReader(table, tasks); + + int countRecords = 0; + while (reader.next()) { + countRecords += 1; + assertThat(reader.get()).as("Reader should return non-null value").isNotNull(); + } + + assertThat(totalTasks * recordPerTask) + .as("Reader returned incorrect number of records") + .isEqualTo(countRecords); + tasks.forEach( + t -> + assertThat(reader.isIteratorClosed(t)) + .as("All iterators should be closed after read exhausion") + .isTrue()); + } + + @Test + public void testClosureDuringIteration() throws IOException { + Integer totalTasks = 2; + Integer recordPerTask = 1; + List tasks = createFileScanTasks(totalTasks, recordPerTask); + assertThat(tasks).hasSize(2); + FileScanTask firstTask = tasks.get(0); + FileScanTask secondTask = tasks.get(1); + + ClosureTrackingReader reader = new ClosureTrackingReader(table, tasks); + + // Total of 2 elements + assertThat(reader.next()).isTrue(); + assertThat(reader.isIteratorClosed(firstTask)) + .as("First iter should not be closed on its last element") + .isFalse(); + + assertThat(reader.next()).isTrue(); + assertThat(reader.isIteratorClosed(firstTask)) + .as("First iter should be closed after moving to second iter") + .isTrue(); + assertThat(reader.isIteratorClosed(secondTask)) + .as("Second iter should not be closed on its last element") + .isFalse(); + + assertThat(reader.next()).isFalse(); + assertThat(reader.isIteratorClosed(firstTask)).isTrue(); + assertThat(reader.isIteratorClosed(secondTask)).isTrue(); + } + + @Test + public void testClosureWithoutAnyRead() throws IOException { + Integer totalTasks = 10; + Integer recordPerTask = 10; + List tasks = createFileScanTasks(totalTasks, recordPerTask); + + ClosureTrackingReader reader = new ClosureTrackingReader(table, tasks); + + reader.close(); + + tasks.forEach( + t -> + assertThat(reader.hasIterator(t)) + .as("Iterator should not be created eagerly for tasks") + .isFalse()); + } + + @Test + public void testExplicitClosure() throws IOException { + Integer totalTasks = 10; + Integer recordPerTask = 10; + List tasks = createFileScanTasks(totalTasks, recordPerTask); + + ClosureTrackingReader reader = new ClosureTrackingReader(table, tasks); + + Integer halfDataSize = (totalTasks * recordPerTask) / 2; + for (int i = 0; i < halfDataSize; i++) { + assertThat(reader.next()).as("Reader should have some element").isTrue(); + assertThat(reader.get()).as("Reader should return non-null value").isNotNull(); + } + + reader.close(); + + // Some tasks might have not been opened yet, so we don't have corresponding tracker for it. + // But all that have been created must be closed. + tasks.forEach( + t -> { + if (reader.hasIterator(t)) { + assertThat(reader.isIteratorClosed(t)) + .as("Iterator should be closed after read exhausion") + .isTrue(); + } + }); + } + + @Test + public void testIdempotentExplicitClosure() throws IOException { + Integer totalTasks = 10; + Integer recordPerTask = 10; + List tasks = createFileScanTasks(totalTasks, recordPerTask); + + ClosureTrackingReader reader = new ClosureTrackingReader(table, tasks); + + // Total 100 elements, only 5 iterators have been created + for (int i = 0; i < 45; i++) { + assertThat(reader.next()).as("Reader should have some element").isTrue(); + assertThat(reader.get()).as("Reader should return non-null value").isNotNull(); + } + + for (int closeAttempt = 0; closeAttempt < 5; closeAttempt++) { + reader.close(); + for (int i = 0; i < 5; i++) { + assertThat(reader.isIteratorClosed(tasks.get(i))) + .as("Iterator should be closed after read exhausion") + .isTrue(); + } + for (int i = 5; i < 10; i++) { + assertThat(reader.hasIterator(tasks.get(i))) + .as("Iterator should not be created eagerly for tasks") + .isFalse(); + } + } + } + + private List createFileScanTasks(Integer totalTasks, Integer recordPerTask) + throws IOException { + String desc = "make_scan_tasks"; + File parent = temp.resolve(desc).toFile(); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).as("mkdirs should succeed").isTrue(); + + Schema schema = new Schema(Types.NestedField.required(0, "id", Types.LongType.get())); + + try { + this.table = TestTables.create(location, desc, schema, PartitionSpec.unpartitioned()); + // Important: use the table's schema for the rest of the test + // When tables are created, the column ids are reassigned. + Schema tableSchema = table.schema(); + List expected = RandomData.generateList(tableSchema, recordPerTask, 1L); + + AppendFiles appendFiles = table.newAppend(); + for (int i = 0; i < totalTasks; i++) { + File parquetFile = new File(dataFolder, PARQUET.addExtension(UUID.randomUUID().toString())); + try (FileAppender writer = + Parquet.write(localOutput(parquetFile)).schema(tableSchema).build()) { + writer.addAll(expected); + } + DataFile file = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withFileSizeInBytes(parquetFile.length()) + .withPath(parquetFile.toString()) + .withRecordCount(recordPerTask) + .build(); + appendFiles.appendFile(file); + } + appendFiles.commit(); + + return StreamSupport.stream(table.newScan().planFiles().spliterator(), false) + .collect(Collectors.toList()); + } finally { + TestTables.clearTables(); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java new file mode 100644 index 000000000000..b12fdd443ff4 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestChangelogReader.java @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.ChangelogOperation; +import org.apache.iceberg.ChangelogScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.Files; +import org.apache.iceberg.IncrementalChangelogScan; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestChangelogReader extends TestBase { + private static final Schema SCHEMA = + new Schema( + required(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).bucket("data", 16).build(); + private final List records1 = Lists.newArrayList(); + private final List records2 = Lists.newArrayList(); + + private Table table; + private DataFile dataFile1; + private DataFile dataFile2; + + @TempDir private Path temp; + + @BeforeEach + public void before() throws IOException { + table = catalog.createTable(TableIdentifier.of("default", "test"), SCHEMA, SPEC); + // create some data + GenericRecord record = GenericRecord.create(table.schema()); + records1.add(record.copy("id", 29, "data", "a")); + records1.add(record.copy("id", 43, "data", "b")); + records1.add(record.copy("id", 61, "data", "c")); + records1.add(record.copy("id", 89, "data", "d")); + + records2.add(record.copy("id", 100, "data", "e")); + records2.add(record.copy("id", 121, "data", "f")); + records2.add(record.copy("id", 122, "data", "g")); + + // write data to files + dataFile1 = writeDataFile(records1); + dataFile2 = writeDataFile(records2); + } + + @AfterEach + public void after() { + catalog.dropTable(TableIdentifier.of("default", "test")); + } + + @Test + public void testInsert() throws IOException { + table.newAppend().appendFile(dataFile1).commit(); + long snapshotId1 = table.currentSnapshot().snapshotId(); + + table.newAppend().appendFile(dataFile2).commit(); + long snapshotId2 = table.currentSnapshot().snapshotId(); + + CloseableIterable> taskGroups = newScan().planTasks(); + + List rows = Lists.newArrayList(); + + for (ScanTaskGroup taskGroup : taskGroups) { + ChangelogRowReader reader = + new ChangelogRowReader(table, table.io(), taskGroup, table.schema(), false, true); + while (reader.next()) { + rows.add(reader.get().copy()); + } + reader.close(); + } + + rows.sort((r1, r2) -> r1.getInt(0) - r2.getInt(0)); + + List expectedRows = Lists.newArrayList(); + addExpectedRows(expectedRows, ChangelogOperation.INSERT, snapshotId1, 0, records1); + addExpectedRows(expectedRows, ChangelogOperation.INSERT, snapshotId2, 1, records2); + + assertEquals("Should have expected rows", expectedRows, internalRowsToJava(rows)); + } + + @Test + public void testDelete() throws IOException { + table.newAppend().appendFile(dataFile1).commit(); + long snapshotId1 = table.currentSnapshot().snapshotId(); + + table.newDelete().deleteFile(dataFile1).commit(); + long snapshotId2 = table.currentSnapshot().snapshotId(); + + CloseableIterable> taskGroups = + newScan().fromSnapshotExclusive(snapshotId1).planTasks(); + + List rows = Lists.newArrayList(); + + for (ScanTaskGroup taskGroup : taskGroups) { + ChangelogRowReader reader = + new ChangelogRowReader(table, table.io(), taskGroup, table.schema(), false, true); + while (reader.next()) { + rows.add(reader.get().copy()); + } + reader.close(); + } + + rows.sort((r1, r2) -> r1.getInt(0) - r2.getInt(0)); + + List expectedRows = Lists.newArrayList(); + addExpectedRows(expectedRows, ChangelogOperation.DELETE, snapshotId2, 0, records1); + + assertEquals("Should have expected rows", expectedRows, internalRowsToJava(rows)); + } + + @Test + public void testDataFileRewrite() throws IOException { + table.newAppend().appendFile(dataFile1).commit(); + table.newAppend().appendFile(dataFile2).commit(); + long snapshotId2 = table.currentSnapshot().snapshotId(); + + table + .newRewrite() + .rewriteFiles(ImmutableSet.of(dataFile1), ImmutableSet.of(dataFile2)) + .commit(); + + // the rewrite operation should generate no Changelog rows + CloseableIterable> taskGroups = + newScan().fromSnapshotExclusive(snapshotId2).planTasks(); + + List rows = Lists.newArrayList(); + + for (ScanTaskGroup taskGroup : taskGroups) { + ChangelogRowReader reader = + new ChangelogRowReader(table, table.io(), taskGroup, table.schema(), false, true); + while (reader.next()) { + rows.add(reader.get().copy()); + } + reader.close(); + } + + assertThat(rows).as("Should have no rows").isEmpty(); + } + + @Test + public void testMixDeleteAndInsert() throws IOException { + table.newAppend().appendFile(dataFile1).commit(); + long snapshotId1 = table.currentSnapshot().snapshotId(); + + table.newDelete().deleteFile(dataFile1).commit(); + long snapshotId2 = table.currentSnapshot().snapshotId(); + + table.newAppend().appendFile(dataFile2).commit(); + long snapshotId3 = table.currentSnapshot().snapshotId(); + + CloseableIterable> taskGroups = newScan().planTasks(); + + List rows = Lists.newArrayList(); + + for (ScanTaskGroup taskGroup : taskGroups) { + ChangelogRowReader reader = + new ChangelogRowReader(table, table.io(), taskGroup, table.schema(), false, true); + while (reader.next()) { + rows.add(reader.get().copy()); + } + reader.close(); + } + + // order by the change ordinal + rows.sort( + (r1, r2) -> { + if (r1.getInt(3) != r2.getInt(3)) { + return r1.getInt(3) - r2.getInt(3); + } else { + return r1.getInt(0) - r2.getInt(0); + } + }); + + List expectedRows = Lists.newArrayList(); + addExpectedRows(expectedRows, ChangelogOperation.INSERT, snapshotId1, 0, records1); + addExpectedRows(expectedRows, ChangelogOperation.DELETE, snapshotId2, 1, records1); + addExpectedRows(expectedRows, ChangelogOperation.INSERT, snapshotId3, 2, records2); + + assertEquals("Should have expected rows", expectedRows, internalRowsToJava(rows)); + } + + private IncrementalChangelogScan newScan() { + return table.newIncrementalChangelogScan(); + } + + private List addExpectedRows( + List expectedRows, + ChangelogOperation operation, + long snapshotId, + int changeOrdinal, + List records) { + records.forEach( + r -> + expectedRows.add(row(r.get(0), r.get(1), operation.name(), changeOrdinal, snapshotId))); + return expectedRows; + } + + protected List internalRowsToJava(List rows) { + return rows.stream().map(this::toJava).collect(Collectors.toList()); + } + + private Object[] toJava(InternalRow row) { + Object[] values = new Object[row.numFields()]; + values[0] = row.getInt(0); + values[1] = row.getString(1); + values[2] = row.getString(2); + values[3] = row.getInt(3); + values[4] = row.getLong(4); + return values; + } + + private DataFile writeDataFile(List records) throws IOException { + // records all use IDs that are in bucket id_bucket=0 + return FileHelpers.writeDataFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + records); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java new file mode 100644 index 000000000000..d38182248391 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestCompressionSettings.java @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.FileFormat.AVRO; +import static org.apache.iceberg.FileFormat.ORC; +import static org.apache.iceberg.FileFormat.PARQUET; +import static org.apache.iceberg.RowLevelOperationMode.MERGE_ON_READ; +import static org.apache.iceberg.TableProperties.AVRO_COMPRESSION; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DELETE_AVRO_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DELETE_MODE; +import static org.apache.iceberg.TableProperties.DELETE_ORC_COMPRESSION; +import static org.apache.iceberg.TableProperties.DELETE_PARQUET_COMPRESSION; +import static org.apache.iceberg.TableProperties.FORMAT_VERSION; +import static org.apache.iceberg.TableProperties.ORC_COMPRESSION; +import static org.apache.iceberg.TableProperties.PARQUET_COMPRESSION; +import static org.apache.iceberg.spark.SparkSQLProperties.COMPRESSION_CODEC; +import static org.apache.iceberg.spark.SparkSQLProperties.COMPRESSION_LEVEL; +import static org.apache.iceberg.spark.SparkSQLProperties.COMPRESSION_STRATEGY; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.apache.avro.file.DataFileConstants; +import org.apache.avro.file.DataFileReader; +import org.apache.avro.generic.GenericDatumReader; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.AvroFSInput; +import org.apache.hadoop.fs.FileContext; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.SizeBasedFileRewritePlanner; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.orc.OrcFile; +import org.apache.orc.Reader; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCompressionSettings extends CatalogTestBase { + + private static final Configuration CONF = new Configuration(); + private static final String TABLE_NAME = "testWriteData"; + + private static SparkSession spark = null; + + @Parameter(index = 3) + private FileFormat format; + + @Parameter(index = 4) + private Map properties; + + @TempDir private java.nio.file.Path temp; + + @Parameters( + name = + "catalogName = {0}, implementation = {1}, config = {2}, format = {3}, properties = {4}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + PARQUET, + ImmutableMap.of(COMPRESSION_CODEC, "zstd", COMPRESSION_LEVEL, "1") + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + PARQUET, + ImmutableMap.of(COMPRESSION_CODEC, "gzip") + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + ORC, + ImmutableMap.of(COMPRESSION_CODEC, "zstd", COMPRESSION_STRATEGY, "speed") + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + ORC, + ImmutableMap.of(COMPRESSION_CODEC, "zstd", COMPRESSION_STRATEGY, "compression") + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + AVRO, + ImmutableMap.of(COMPRESSION_CODEC, "snappy", COMPRESSION_LEVEL, "3") + } + }; + } + + @BeforeAll + public static void startSpark() { + TestCompressionSettings.spark = SparkSession.builder().master("local[2]").getOrCreate(); + } + + @BeforeEach + public void resetSpecificConfigurations() { + spark.conf().unset(COMPRESSION_CODEC); + spark.conf().unset(COMPRESSION_LEVEL); + spark.conf().unset(COMPRESSION_STRATEGY); + } + + @AfterEach + public void afterEach() { + spark.sql(String.format("DROP TABLE IF EXISTS %s", TABLE_NAME)); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestCompressionSettings.spark; + TestCompressionSettings.spark = null; + currentSpark.stop(); + } + + @TestTemplate + public void testWriteDataWithDifferentSetting() throws Exception { + sql("CREATE TABLE %s (id int, data string) USING iceberg", TABLE_NAME); + Map tableProperties = Maps.newHashMap(); + tableProperties.put(PARQUET_COMPRESSION, "gzip"); + tableProperties.put(AVRO_COMPRESSION, "gzip"); + tableProperties.put(ORC_COMPRESSION, "zlib"); + tableProperties.put(DELETE_PARQUET_COMPRESSION, "gzip"); + tableProperties.put(DELETE_AVRO_COMPRESSION, "gzip"); + tableProperties.put(DELETE_ORC_COMPRESSION, "zlib"); + tableProperties.put(DELETE_MODE, MERGE_ON_READ.modeName()); + tableProperties.put(FORMAT_VERSION, "2"); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s')", TABLE_NAME, DEFAULT_FILE_FORMAT, format); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s')", + TABLE_NAME, DELETE_DEFAULT_FILE_FORMAT, format); + for (Map.Entry entry : tableProperties.entrySet()) { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' '%s')", + TABLE_NAME, entry.getKey(), entry.getValue()); + } + + List expectedOrigin = Lists.newArrayList(); + for (int i = 0; i < 1000; i++) { + expectedOrigin.add(new SimpleRecord(i, "hello world" + i)); + } + + Dataset df = spark.createDataFrame(expectedOrigin, SimpleRecord.class); + + for (Map.Entry entry : properties.entrySet()) { + spark.conf().set(entry.getKey(), entry.getValue()); + } + + assertSparkConf(); + + df.select("id", "data") + .writeTo(TABLE_NAME) + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .append(); + Table table = catalog.loadTable(TableIdentifier.of("default", TABLE_NAME)); + List manifestFiles = table.currentSnapshot().dataManifests(table.io()); + try (ManifestReader reader = + ManifestFiles.read(manifestFiles.get(0), table.io(), table.specs())) { + DataFile file = reader.iterator().next(); + InputFile inputFile = table.io().newInputFile(file.location()); + assertThat(getCompressionType(inputFile)) + .isEqualToIgnoringCase(properties.get(COMPRESSION_CODEC)); + } + + sql("DELETE from %s where id < 100", TABLE_NAME); + + table.refresh(); + List deleteManifestFiles = table.currentSnapshot().deleteManifests(table.io()); + Map specMap = Maps.newHashMap(); + specMap.put(0, PartitionSpec.unpartitioned()); + try (ManifestReader reader = + ManifestFiles.readDeleteManifest(deleteManifestFiles.get(0), table.io(), specMap)) { + DeleteFile file = reader.iterator().next(); + InputFile inputFile = table.io().newInputFile(file.location()); + assertThat(getCompressionType(inputFile)) + .isEqualToIgnoringCase(properties.get(COMPRESSION_CODEC)); + } + + SparkActions.get(spark) + .rewritePositionDeletes(table) + .option(SizeBasedFileRewritePlanner.REWRITE_ALL, "true") + .execute(); + table.refresh(); + deleteManifestFiles = table.currentSnapshot().deleteManifests(table.io()); + try (ManifestReader reader = + ManifestFiles.readDeleteManifest(deleteManifestFiles.get(0), table.io(), specMap)) { + DeleteFile file = reader.iterator().next(); + InputFile inputFile = table.io().newInputFile(file.location()); + assertThat(getCompressionType(inputFile)) + .isEqualToIgnoringCase(properties.get(COMPRESSION_CODEC)); + } + } + + private String getCompressionType(InputFile inputFile) throws Exception { + switch (format) { + case ORC: + OrcFile.ReaderOptions readerOptions = OrcFile.readerOptions(CONF).useUTCTimestamp(true); + Reader orcReader = OrcFile.createReader(new Path(inputFile.location()), readerOptions); + return orcReader.getCompressionKind().name(); + case PARQUET: + ParquetMetadata footer = + ParquetFileReader.open(HadoopInputFile.fromPath(new Path(inputFile.location()), CONF)) + .getFooter(); + return footer.getBlocks().get(0).getColumns().get(0).getCodec().name(); + default: + FileContext fc = FileContext.getFileContext(CONF); + GenericDatumReader reader = new GenericDatumReader<>(); + DataFileReader fileReader = + (DataFileReader) + DataFileReader.openReader( + new AvroFSInput(fc, new Path(inputFile.location())), reader); + return fileReader.getMetaString(DataFileConstants.CODEC); + } + } + + private void assertSparkConf() { + String[] propertiesToCheck = {COMPRESSION_CODEC, COMPRESSION_LEVEL, COMPRESSION_STRATEGY}; + for (String prop : propertiesToCheck) { + String expected = properties.getOrDefault(prop, null); + String actual = spark.conf().get(prop, null); + assertThat(actual).isEqualToIgnoringCase(expected); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataFrameWriterV2.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataFrameWriterV2.java new file mode 100644 index 000000000000..186d0b2b5204 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataFrameWriterV2.java @@ -0,0 +1,390 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestDataFrameWriterV2 extends TestBaseWithCatalog { + @BeforeEach + public void createTable() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testMergeSchemaFailsWithoutWriterOption() throws Exception { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA); + + Dataset twoColDF = + jsonToDF( + "id bigint, data string", + "{ \"id\": 1, \"data\": \"a\" }", + "{ \"id\": 2, \"data\": \"b\" }"); + + twoColDF.writeTo(tableName).append(); + + assertEquals( + "Should have initial 2-column rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("select * from %s order by id", tableName)); + + Dataset threeColDF = + jsonToDF( + "id bigint, data string, new_col float", + "{ \"id\": 3, \"data\": \"c\", \"new_col\": 12.06 }", + "{ \"id\": 4, \"data\": \"d\", \"new_col\": 14.41 }"); + + // this has a different error message than the case without accept-any-schema because it uses + // Iceberg checks + assertThatThrownBy(() -> threeColDF.writeTo(tableName).append()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Field new_col not found in source schema"); + } + + @TestTemplate + public void testMergeSchemaWithoutAcceptAnySchema() throws Exception { + Dataset twoColDF = + jsonToDF( + "id bigint, data string", + "{ \"id\": 1, \"data\": \"a\" }", + "{ \"id\": 2, \"data\": \"b\" }"); + + twoColDF.writeTo(tableName).append(); + + assertEquals( + "Should have initial 2-column rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("select * from %s order by id", tableName)); + + Dataset threeColDF = + jsonToDF( + "id bigint, data string, new_col float", + "{ \"id\": 3, \"data\": \"c\", \"new_col\": 12.06 }", + "{ \"id\": 4, \"data\": \"d\", \"new_col\": 14.41 }"); + + assertThatThrownBy(() -> threeColDF.writeTo(tableName).option("merge-schema", "true").append()) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "Cannot write to `testhadoop`.`default`.`table`, the reason is too many data columns"); + } + + @TestTemplate + public void testMergeSchemaSparkProperty() throws Exception { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA); + + Dataset twoColDF = + jsonToDF( + "id bigint, data string", + "{ \"id\": 1, \"data\": \"a\" }", + "{ \"id\": 2, \"data\": \"b\" }"); + + twoColDF.writeTo(tableName).append(); + + assertEquals( + "Should have initial 2-column rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("select * from %s order by id", tableName)); + + Dataset threeColDF = + jsonToDF( + "id bigint, data string, new_col float", + "{ \"id\": 3, \"data\": \"c\", \"new_col\": 12.06 }", + "{ \"id\": 4, \"data\": \"d\", \"new_col\": 14.41 }"); + + threeColDF.writeTo(tableName).option("mergeSchema", "true").append(); + + assertEquals( + "Should have 3-column rows", + ImmutableList.of( + row(1L, "a", null), row(2L, "b", null), row(3L, "c", 12.06F), row(4L, "d", 14.41F)), + sql("select * from %s order by id", tableName)); + } + + @TestTemplate + public void testMergeSchemaIcebergProperty() throws Exception { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA); + + Dataset twoColDF = + jsonToDF( + "id bigint, data string", + "{ \"id\": 1, \"data\": \"a\" }", + "{ \"id\": 2, \"data\": \"b\" }"); + + twoColDF.writeTo(tableName).append(); + + assertEquals( + "Should have initial 2-column rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("select * from %s order by id", tableName)); + + Dataset threeColDF = + jsonToDF( + "id bigint, data string, new_col float", + "{ \"id\": 3, \"data\": \"c\", \"new_col\": 12.06 }", + "{ \"id\": 4, \"data\": \"d\", \"new_col\": 14.41 }"); + + threeColDF.writeTo(tableName).option("merge-schema", "true").append(); + + assertEquals( + "Should have 3-column rows", + ImmutableList.of( + row(1L, "a", null), row(2L, "b", null), row(3L, "c", 12.06F), row(4L, "d", 14.41F)), + sql("select * from %s order by id", tableName)); + } + + @TestTemplate + public void testWriteWithCaseSensitiveOption() throws NoSuchTableException, ParseException { + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + SparkSession sparkSession = ((org.apache.spark.sql.classic.SparkSession) spark).cloneSession(); + sparkSession + .sql( + String.format( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA)) + .collect(); + + String schema = "ID bigint, DaTa string"; + ImmutableList records = + ImmutableList.of("{ \"id\": 1, \"data\": \"a\" }", "{ \"id\": 2, \"data\": \"b\" }"); + + // disable spark.sql.caseSensitive + sparkSession.sql(String.format("SET %s=false", SQLConf.CASE_SENSITIVE().key())); + Dataset jsonDF = + sparkSession.createDataset(ImmutableList.copyOf(records), Encoders.STRING()); + Dataset ds = sparkSession.read().schema(schema).json(jsonDF); + // write should succeed + ds.writeTo(tableName).option("merge-schema", "true").option("check-ordering", "false").append(); + List fields = + Spark3Util.loadIcebergTable(sparkSession, tableName).schema().asStruct().fields(); + // Additional columns should not be created + assertThat(fields).hasSize(2); + + // enable spark.sql.caseSensitive + sparkSession.sql(String.format("SET %s=true", SQLConf.CASE_SENSITIVE().key())); + ds.writeTo(tableName).option("merge-schema", "true").option("check-ordering", "false").append(); + fields = Spark3Util.loadIcebergTable(sparkSession, tableName).schema().asStruct().fields(); + assertThat(fields).hasSize(4); + } + + @TestTemplate + public void testMergeSchemaSparkConfiguration() throws Exception { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA); + Dataset twoColDF = + jsonToDF( + "id bigint, data string", + "{ \"id\": 1, \"data\": \"a\" }", + "{ \"id\": 2, \"data\": \"b\" }"); + + twoColDF.writeTo(tableName).append(); + + assertEquals( + "Should have initial 2-column rows", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("select * from %s order by id", tableName)); + spark.conf().set("spark.sql.iceberg.merge-schema", "true"); + Dataset threeColDF = + jsonToDF( + "id bigint, data string, salary float", + "{ \"id\": 3, \"data\": \"c\", \"salary\": 120000.34 }", + "{ \"id\": 4, \"data\": \"d\", \"salary\": 140000.56 }"); + + threeColDF.writeTo(tableName).append(); + assertEquals( + "Should have 3-column rows", + ImmutableList.of( + row(1L, "a", null), + row(2L, "b", null), + row(3L, "c", 120000.34F), + row(4L, "d", 140000.56F)), + sql("select * from %s order by id", tableName)); + } + + @TestTemplate + public void testMergeSchemaIgnoreCastingLongToInt() throws Exception { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA); + + Dataset bigintDF = + jsonToDF( + "id bigint, data string", + "{ \"id\": 1, \"data\": \"a\" }", + "{ \"id\": 2, \"data\": \"b\" }"); + + bigintDF.writeTo(tableName).append(); + + assertEquals( + "Should have initial rows with long column", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("select * from %s order by id", tableName)); + + Dataset intDF = + jsonToDF( + "id int, data string", + "{ \"id\": 3, \"data\": \"c\" }", + "{ \"id\": 4, \"data\": \"d\" }"); + + // merge-schema=true on writes allows table schema updates when incoming data has schema changes + assertThatCode(() -> intDF.writeTo(tableName).option("merge-schema", "true").append()) + .doesNotThrowAnyException(); + + assertEquals( + "Should include new rows with unchanged long column type", + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c"), row(4L, "d")), + sql("select * from %s order by id", tableName)); + + // verify the column type did not change + Types.NestedField idField = + Spark3Util.loadIcebergTable(spark, tableName).schema().findField("id"); + assertThat(idField.type().typeId()).isEqualTo(Type.TypeID.LONG); + } + + @TestTemplate + public void testMergeSchemaIgnoreCastingDoubleToFloat() throws Exception { + removeTables(); + sql("CREATE TABLE %s (id double, data string) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA); + + Dataset doubleDF = + jsonToDF( + "id double, data string", + "{ \"id\": 1.0, \"data\": \"a\" }", + "{ \"id\": 2.0, \"data\": \"b\" }"); + + doubleDF.writeTo(tableName).append(); + + assertEquals( + "Should have initial rows with double column", + ImmutableList.of(row(1.0, "a"), row(2.0, "b")), + sql("select * from %s order by id", tableName)); + + Dataset floatDF = + jsonToDF( + "id float, data string", + "{ \"id\": 3.0, \"data\": \"c\" }", + "{ \"id\": 4.0, \"data\": \"d\" }"); + + // merge-schema=true on writes allows table schema updates when incoming data has schema changes + assertThatCode(() -> floatDF.writeTo(tableName).option("merge-schema", "true").append()) + .doesNotThrowAnyException(); + + assertEquals( + "Should include new rows with unchanged double column type", + ImmutableList.of(row(1.0, "a"), row(2.0, "b"), row(3.0, "c"), row(4.0, "d")), + sql("select * from %s order by id", tableName)); + + // verify the column type did not change + Types.NestedField idField = + Spark3Util.loadIcebergTable(spark, tableName).schema().findField("id"); + assertThat(idField.type().typeId()).isEqualTo(Type.TypeID.DOUBLE); + } + + @TestTemplate + public void testMergeSchemaIgnoreCastingDecimalToDecimalWithNarrowerPrecision() throws Exception { + removeTables(); + sql("CREATE TABLE %s (id decimal(6,2), data string) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s'='true')", + tableName, TableProperties.SPARK_WRITE_ACCEPT_ANY_SCHEMA); + + Dataset decimalPrecision6DF = + jsonToDF( + "id decimal(6,2), data string", + "{ \"id\": 1.0, \"data\": \"a\" }", + "{ \"id\": 2.0, \"data\": \"b\" }"); + + decimalPrecision6DF.writeTo(tableName).append(); + + assertEquals( + "Should have initial rows with decimal column with precision 6", + ImmutableList.of(row(new BigDecimal("1.00"), "a"), row(new BigDecimal("2.00"), "b")), + sql("select * from %s order by id", tableName)); + + Dataset decimalPrecision4DF = + jsonToDF( + "id decimal(4,2), data string", + "{ \"id\": 3.0, \"data\": \"c\" }", + "{ \"id\": 4.0, \"data\": \"d\" }"); + + // merge-schema=true on writes allows table schema updates when incoming data has schema changes + assertThatCode( + () -> decimalPrecision4DF.writeTo(tableName).option("merge-schema", "true").append()) + .doesNotThrowAnyException(); + + assertEquals( + "Should include new rows with unchanged decimal precision", + ImmutableList.of( + row(new BigDecimal("1.00"), "a"), + row(new BigDecimal("2.00"), "b"), + row(new BigDecimal("3.00"), "c"), + row(new BigDecimal("4.00"), "d")), + sql("select * from %s order by id", tableName)); + + // verify the decimal column precision did not change + Type idFieldType = + Spark3Util.loadIcebergTable(spark, tableName).schema().findField("id").type(); + assertThat(idFieldType.typeId()).isEqualTo(Type.TypeID.DECIMAL); + Types.DecimalType decimalType = (Types.DecimalType) idFieldType; + assertThat(decimalType.precision()).isEqualTo(6); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataFrameWriterV2Coercion.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataFrameWriterV2Coercion.java new file mode 100644 index 000000000000..f51a06853a69 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataFrameWriterV2Coercion.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestDataFrameWriterV2Coercion extends TestBaseWithCatalog { + + @Parameters( + name = "catalogName = {0}, implementation = {1}, config = {2}, format = {3}, dataType = {4}") + public static Object[][] parameters() { + return new Object[][] { + parameter(FileFormat.AVRO, "byte"), + parameter(FileFormat.ORC, "byte"), + parameter(FileFormat.PARQUET, "byte"), + parameter(FileFormat.AVRO, "short"), + parameter(FileFormat.ORC, "short"), + parameter(FileFormat.PARQUET, "short") + }; + } + + private static Object[] parameter(FileFormat fileFormat, String dataType) { + return new Object[] { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + fileFormat, + dataType + }; + } + + @Parameter(index = 3) + private FileFormat format; + + @Parameter(index = 4) + private String dataType; + + @TestTemplate + public void testByteAndShortCoercion() { + + Dataset df = + jsonToDF( + "id " + dataType + ", data string", + "{ \"id\": 1, \"data\": \"a\" }", + "{ \"id\": 2, \"data\": \"b\" }"); + + df.writeTo(tableName).option("write-format", format.name()).createOrReplace(); + + assertEquals( + "Should have initial 2-column rows", + ImmutableList.of(row(1, "a"), row(2, "b")), + sql("select * from %s order by id", tableName)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java new file mode 100644 index 000000000000..e67ec5fd62d4 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestDataSourceOptions.java @@ -0,0 +1,524 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.math.RoundingMode; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.math.LongMath; +import org.apache.iceberg.spark.CommitMetadata; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.functions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestDataSourceOptions extends TestBaseWithCatalog { + + private static final SimpleDateFormat TIMESTAMP_FORMAT = + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + private static final Configuration CONF = new Configuration(); + private static final Schema SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + private static SparkSession spark = null; + + @BeforeAll + public static void startSpark() { + TestDataSourceOptions.spark = SparkSession.builder().master("local[2]").getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestDataSourceOptions.spark; + TestDataSourceOptions.spark = null; + currentSpark.stop(); + } + + @TestTemplate + public void testWriteFormatOptionOverridesTableProperties() throws IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + options.put(TableProperties.DEFAULT_FILE_FORMAT, "avro"); + Table table = tables.create(SCHEMA, spec, options, tableLocation); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(expectedRecords, SimpleRecord.class); + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, "parquet") + .mode(SaveMode.Append) + .save(tableLocation); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + tasks.forEach( + task -> { + FileFormat fileFormat = FileFormat.fromFileName(task.file().location()); + assertThat(fileFormat).isEqualTo(FileFormat.PARQUET); + }); + } + } + + @TestTemplate + public void testNoWriteFormatOption() throws IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + options.put(TableProperties.DEFAULT_FILE_FORMAT, "avro"); + Table table = tables.create(SCHEMA, spec, options, tableLocation); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(expectedRecords, SimpleRecord.class); + df.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + tasks.forEach( + task -> { + FileFormat fileFormat = FileFormat.fromFileName(task.file().location()); + assertThat(fileFormat).isEqualTo(FileFormat.AVRO); + }); + } + } + + @TestTemplate + public void testHadoopOptions() throws IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + Configuration sparkHadoopConf = spark.sessionState().newHadoopConf(); + String originalDefaultFS = sparkHadoopConf.get("fs.default.name"); + + try { + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + tables.create(SCHEMA, spec, options, tableLocation); + + // set an invalid value for 'fs.default.name' in Spark Hadoop config + // to verify that 'hadoop.' data source options are propagated correctly + sparkHadoopConf.set("fs.default.name", "hdfs://localhost:9000"); + + List expectedRecords = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset originalDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + originalDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .option("hadoop.fs.default.name", "file:///") + .save(tableLocation); + + Dataset resultDf = + spark + .read() + .format("iceberg") + .option("hadoop.fs.default.name", "file:///") + .load(tableLocation); + List resultRecords = + resultDf.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(resultRecords).as("Records should match").isEqualTo(expectedRecords); + } finally { + sparkHadoopConf.set("fs.default.name", originalDefaultFS); + } + } + + @TestTemplate + public void testSplitOptionsOverridesTableProperties() throws IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + options.put(TableProperties.SPLIT_SIZE, String.valueOf(128L * 1024 * 1024)); // 128Mb + options.put( + TableProperties.DEFAULT_FILE_FORMAT, + String.valueOf(FileFormat.AVRO)); // Arbitrarily splittable + Table icebergTable = tables.create(SCHEMA, spec, options, tableLocation); + + List expectedRecords = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset originalDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + originalDf + .select("id", "data") + .repartition(1) + .write() + .format("iceberg") + .mode("append") + .save(tableLocation); + + List files = + Lists.newArrayList(SnapshotChanges.builderFor(icebergTable).build().addedDataFiles()); + assertThat(files).as("Should have written 1 file").hasSize(1); + + long fileSize = files.get(0).fileSizeInBytes(); + long splitSize = LongMath.divide(fileSize, 2, RoundingMode.CEILING); + + Dataset resultDf = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, String.valueOf(splitSize)) + .load(tableLocation); + + assertThat(resultDf.javaRDD().getNumPartitions()) + .as("Spark partitions should match") + .isEqualTo(2); + } + + @TestTemplate + public void testIncrementalScanOptions() throws IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + Table table = tables.create(SCHEMA, spec, options, tableLocation); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "d")); + for (SimpleRecord record : expectedRecords) { + Dataset originalDf = + spark.createDataFrame(Lists.newArrayList(record), SimpleRecord.class); + originalDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + } + List snapshotIds = SnapshotUtil.currentAncestorIds(table); + + // start-snapshot-id and snapshot-id are both configured. + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotIds.get(3).toString()) + .option("start-snapshot-id", snapshotIds.get(3).toString()) + .load(tableLocation) + .explain()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot use time travel in incremental scan"); + + // end-snapshot-id and as-of-timestamp are both configured. + long snapshotTimestamp = table.snapshot(snapshotIds.get(3)).timestampMillis(); + String formattedTimestamp = TIMESTAMP_FORMAT.format(new Date(snapshotTimestamp)); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTimestamp) + .option("end-snapshot-id", snapshotIds.get(2).toString()) + .load(tableLocation) + .explain()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot use time travel in incremental scan"); + + // only end-snapshot-id is configured. + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option("end-snapshot-id", snapshotIds.get(2).toString()) + .load(tableLocation) + .explain()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + "Cannot set only `end-snapshot-id` for incremental scans. Please, set `start-snapshot-id` too."); + + // test (1st snapshot, current snapshot] incremental scan. + Dataset unboundedIncrementalResult = + spark + .read() + .format("iceberg") + .option("start-snapshot-id", snapshotIds.get(3).toString()) + .load(tableLocation); + List result1 = + unboundedIncrementalResult + .orderBy("id") + .as(Encoders.bean(SimpleRecord.class)) + .collectAsList(); + assertThat(result1).as("Records should match").isEqualTo(expectedRecords.subList(1, 4)); + assertThat(unboundedIncrementalResult.count()) + .as("Unprocessed count should match record count") + .isEqualTo(3); + + Row row1 = unboundedIncrementalResult.agg(functions.min("id"), functions.max("id")).head(); + assertThat(row1.getInt(0)).as("min value should match").isEqualTo(2); + assertThat(row1.getInt(1)).as("max value should match").isEqualTo(4); + + // test (2nd snapshot, 3rd snapshot] incremental scan. + Dataset incrementalResult = + spark + .read() + .format("iceberg") + .option("start-snapshot-id", snapshotIds.get(2).toString()) + .option("end-snapshot-id", snapshotIds.get(1).toString()) + .load(tableLocation); + List result2 = + incrementalResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(result2).as("Records should match").isEqualTo(expectedRecords.subList(2, 3)); + assertThat(incrementalResult.count()) + .as("Unprocessed count should match record count") + .isEqualTo(1); + + Row row2 = incrementalResult.agg(functions.min("id"), functions.max("id")).head(); + assertThat(row2.getInt(0)).as("min value should match").isEqualTo(3); + assertThat(row2.getInt(1)).as("max value should match").isEqualTo(3); + } + + @TestTemplate + public void testMetadataSplitSizeOptionOverrideTableProperties() throws IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + Table table = tables.create(SCHEMA, spec, options, tableLocation); + + List expectedRecords = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset originalDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + // produce 1st manifest + originalDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + // produce 2nd manifest + originalDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + List manifests = table.currentSnapshot().allManifests(table.io()); + + assertThat(manifests).as("Must be 2 manifests").hasSize(2); + + // set the target metadata split size so each manifest ends up in a separate split + table + .updateProperties() + .set(TableProperties.METADATA_SPLIT_SIZE, String.valueOf(manifests.get(0).length())) + .commit(); + + Dataset entriesDf = spark.read().format("iceberg").load(tableLocation + "#entries"); + assertThat(entriesDf.javaRDD().getNumPartitions()).as("Num partitions must match").isEqualTo(2); + + // override the table property using options + entriesDf = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, String.valueOf(128 * 1024 * 1024)) + .load(tableLocation + "#entries"); + assertThat(entriesDf.javaRDD().getNumPartitions()).as("Num partitions must match").isEqualTo(1); + } + + @TestTemplate + public void testDefaultMetadataSplitSize() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map options = Maps.newHashMap(); + Table icebergTable = tables.create(SCHEMA, spec, options, tableLocation); + + List expectedRecords = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset originalDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + originalDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + int splitSize = (int) TableProperties.METADATA_SPLIT_SIZE_DEFAULT; // 32MB split size + + int expectedSplits = + ((int) + tables + .load(tableLocation + "#entries") + .currentSnapshot() + .allManifests(icebergTable.io()) + .get(0) + .length() + + splitSize + - 1) + / splitSize; + + Dataset metadataDf = spark.read().format("iceberg").load(tableLocation + "#entries"); + + int partitionNum = metadataDf.javaRDD().getNumPartitions(); + assertThat(partitionNum).as("Spark partitions should match").isEqualTo(expectedSplits); + } + + @TestTemplate + public void testExtraSnapshotMetadata() throws IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + HadoopTables tables = new HadoopTables(CONF); + tables.create(SCHEMA, PartitionSpec.unpartitioned(), Maps.newHashMap(), tableLocation); + + List expectedRecords = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset originalDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + originalDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .option(SparkWriteOptions.SNAPSHOT_PROPERTY_PREFIX + ".extra-key", "someValue") + .option(SparkWriteOptions.SNAPSHOT_PROPERTY_PREFIX + ".another-key", "anotherValue") + .save(tableLocation); + + Table table = tables.load(tableLocation); + + assertThat(table.currentSnapshot().summary()) + .containsEntry("extra-key", "someValue") + .containsEntry("another-key", "anotherValue"); + } + + @TestTemplate + public void testExtraSnapshotMetadataWithSQL() throws InterruptedException, IOException { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + HadoopTables tables = new HadoopTables(CONF); + + Table table = + tables.create(SCHEMA, PartitionSpec.unpartitioned(), Maps.newHashMap(), tableLocation); + + List expectedRecords = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset originalDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + originalDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + spark.read().format("iceberg").load(tableLocation).createOrReplaceTempView("target"); + Thread writerThread = + new Thread( + () -> { + Map properties = + ImmutableMap.of( + "writer-thread", + String.valueOf(Thread.currentThread().getName()), + SnapshotSummary.EXTRA_METADATA_PREFIX + "extra-key", + "someValue", + SnapshotSummary.EXTRA_METADATA_PREFIX + "another-key", + "anotherValue"); + CommitMetadata.withCommitProperties( + properties, + () -> { + spark.sql("INSERT INTO target VALUES (3, 'c'), (4, 'd')"); + return 0; + }, + RuntimeException.class); + }); + writerThread.setName("test-extra-commit-message-writer-thread"); + writerThread.start(); + writerThread.join(); + + List snapshots = Lists.newArrayList(table.snapshots()); + assertThat(snapshots).hasSize(2); + assertThat(snapshots.get(0).summary()).doesNotContainKey("writer-thread"); + assertThat(snapshots.get(1).summary()) + .containsEntry("writer-thread", "test-extra-commit-message-writer-thread") + .containsEntry("extra-key", "someValue") + .containsEntry("another-key", "anotherValue"); + } + + @TestTemplate + public void testExtraSnapshotMetadataWithDelete() + throws InterruptedException, NoSuchTableException { + spark.sessionState().conf().setConfString("spark.sql.shuffle.partitions", "1"); + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset originalDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + originalDf.repartition(5, new Column("data")).select("id", "data").writeTo(tableName).append(); + Thread writerThread = + new Thread( + () -> { + Map properties = + ImmutableMap.of( + "writer-thread", + String.valueOf(Thread.currentThread().getName()), + SnapshotSummary.EXTRA_METADATA_PREFIX + "extra-key", + "someValue", + SnapshotSummary.EXTRA_METADATA_PREFIX + "another-key", + "anotherValue"); + CommitMetadata.withCommitProperties( + properties, + () -> { + spark.sql("DELETE FROM " + tableName + " where id = 1"); + return 0; + }, + RuntimeException.class); + }); + writerThread.setName("test-extra-commit-message-delete-thread"); + writerThread.start(); + writerThread.join(); + + Table table = validationCatalog.loadTable(tableIdent); + List snapshots = Lists.newArrayList(table.snapshots()); + + assertThat(snapshots).hasSize(2); + assertThat(snapshots.get(0).summary()).doesNotContainKey("writer-thread"); + assertThat(snapshots.get(1).summary()) + .containsEntry("writer-thread", "test-extra-commit-message-delete-thread") + .containsEntry("extra-key", "someValue") + .containsEntry("another-key", "anotherValue"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java new file mode 100644 index 000000000000..24fecf4eb2ca --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestFilteredScan.java @@ -0,0 +1,745 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.Files.localOutput; +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.apache.iceberg.data.FileHelpers.encrypt; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.GenericFileWriterFactory; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.GenericsHelpers; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.expressions.UnsafeRow; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.connector.read.SupportsPushDownV2Filters; +import org.apache.spark.sql.sources.And; +import org.apache.spark.sql.sources.EqualTo; +import org.apache.spark.sql.sources.Filter; +import org.apache.spark.sql.sources.GreaterThan; +import org.apache.spark.sql.sources.LessThan; +import org.apache.spark.sql.sources.Not; +import org.apache.spark.sql.sources.StringStartsWith; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.assertj.core.api.AbstractObjectAssert; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestFilteredScan { + private static final Configuration CONF = new Configuration(); + private static final HadoopTables TABLES = new HadoopTables(CONF); + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "ts", Types.TimestampType.withZone()), + Types.NestedField.optional(3, "data", Types.StringType.get())); + + private static final PartitionSpec BUCKET_BY_ID = + PartitionSpec.builderFor(SCHEMA).bucket("id", 4).build(); + + private static final PartitionSpec PARTITION_BY_DAY = + PartitionSpec.builderFor(SCHEMA).day("ts").build(); + + private static final PartitionSpec PARTITION_BY_HOUR = + PartitionSpec.builderFor(SCHEMA).hour("ts").build(); + + private static final PartitionSpec PARTITION_BY_DATA = + PartitionSpec.builderFor(SCHEMA).identity("data").build(); + + private static final PartitionSpec PARTITION_BY_ID = + PartitionSpec.builderFor(SCHEMA).identity("id").build(); + + private static SparkSession spark = null; + + @BeforeAll + public static void startSpark() { + TestFilteredScan.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestFilteredScan.spark; + TestFilteredScan.spark = null; + currentSpark.stop(); + } + + @TempDir private Path temp; + + @Parameter(index = 0) + private FileFormat fileFormat; + + @Parameter(index = 1) + private boolean vectorized; + + @Parameter(index = 2) + private PlanningMode planningMode; + + @Parameters(name = "format = {0}, vectorized = {1}, planningMode = {2}") + public static Object[][] parameters() { + return new Object[][] { + {FileFormat.PARQUET, false, LOCAL}, + {FileFormat.PARQUET, true, DISTRIBUTED}, + {FileFormat.AVRO, false, LOCAL}, + {FileFormat.ORC, false, DISTRIBUTED}, + {FileFormat.ORC, true, LOCAL} + }; + } + + private File parent = null; + private File unpartitioned = null; + private List records = null; + + @BeforeEach + public void writeUnpartitionedTable() throws IOException { + this.parent = temp.resolve("TestFilteredScan").toFile(); + this.unpartitioned = new File(parent, "unpartitioned"); + File dataFolder = new File(unpartitioned, "data"); + assertThat(dataFolder.mkdirs()).as("Mkdir should succeed").isTrue(); + + Table table = + TABLES.create( + SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of( + TableProperties.DATA_PLANNING_MODE, + planningMode.modeName(), + TableProperties.DELETE_PLANNING_MODE, + planningMode.modeName()), + unpartitioned.toString()); + Schema tableSchema = table.schema(); // use the table schema because ids are reassigned + + File testFile = new File(dataFolder, fileFormat.addExtension(UUID.randomUUID().toString())); + + this.records = testRecords(tableSchema); + + DataWriter writer = + new GenericFileWriterFactory.Builder() + .dataFileFormat(fileFormat) + .dataSchema(tableSchema) + .build() + .newDataWriter(encrypt(localOutput(testFile)), PartitionSpec.unpartitioned(), null); + try (writer) { + writer.write(records); + } + + DataFile file = writer.toDataFile(); + + table.newAppend().appendFile(file).commit(); + } + + @TestTemplate + public void testUnpartitionedIDFilters() { + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", unpartitioned.toString())); + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + for (int i = 0; i < 10; i += 1) { + pushFilters(builder, EqualTo.apply("id", i)); + Batch scan = builder.build().toBatch(); + + InputPartition[] partitions = scan.planInputPartitions(); + assertThat(partitions).as("Should only create one task for a small file").hasSize(1); + + // validate row filtering + assertEqualsSafe( + SCHEMA.asStruct(), expected(i), read(unpartitioned.toString(), vectorized, "id = " + i)); + } + } + + @TestTemplate + public void testUnpartitionedCaseInsensitiveIDFilters() { + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", unpartitioned.toString())); + + // set spark.sql.caseSensitive to false + String caseSensitivityBeforeTest = TestFilteredScan.spark.conf().get("spark.sql.caseSensitive"); + TestFilteredScan.spark.conf().set("spark.sql.caseSensitive", "false"); + + try { + for (int i = 0; i < 10; i += 1) { + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters( + builder, + EqualTo.apply("ID", i)); // note lower(ID) == lower(id), so there must be a match + Batch scan = builder.build().toBatch(); + + InputPartition[] tasks = scan.planInputPartitions(); + assertThat(tasks).as("Should only create one task for a small file").hasSize(1); + + // validate row filtering + assertEqualsSafe( + SCHEMA.asStruct(), + expected(i), + read(unpartitioned.toString(), vectorized, "id = " + i)); + } + } finally { + // return global conf to previous state + TestFilteredScan.spark.conf().set("spark.sql.caseSensitive", caseSensitivityBeforeTest); + } + } + + @TestTemplate + public void testUnpartitionedTimestampFilter() { + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", unpartitioned.toString())); + + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, LessThan.apply("ts", "2017-12-22T00:00:00+00:00")); + Batch scan = builder.build().toBatch(); + + InputPartition[] tasks = scan.planInputPartitions(); + assertThat(tasks).as("Should only create one task for a small file").hasSize(1); + + assertEqualsSafe( + SCHEMA.asStruct(), + expected(5, 6, 7, 8, 9), + read( + unpartitioned.toString(), + vectorized, + "ts < cast('2017-12-22 00:00:00+00:00' as timestamp)")); + } + + @TestTemplate + public void limitPushedDownToSparkScan() { + assumeThat(fileFormat) + .as("no need to run this across the entire test matrix") + .isEqualTo(FileFormat.PARQUET); + + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", unpartitioned.toString())); + + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + long limit = 23; + // simulate Spark pushing down the limit to the scan builder + builder.pushLimit((int) limit); + assertThat(builder).extracting("limit").isEqualTo((int) limit); + + // verify batch scan + AbstractObjectAssert scanAssert = assertThat(builder.build()).extracting("scan"); + if (LOCAL == planningMode) { + scanAssert = scanAssert.extracting("scan"); + } + + scanAssert.extracting("context").extracting("minRowsRequested").isEqualTo(limit); + + // verify CoW scan + scanAssert = assertThat(builder.buildCopyOnWriteScan()).extracting("scan"); + if (LOCAL == planningMode) { + scanAssert = scanAssert.extracting("scan"); + } + + scanAssert.extracting("context").extracting("minRowsRequested").isEqualTo(limit); + + // verify MoR scan + scanAssert = assertThat(builder.build()).extracting("scan"); + if (LOCAL == planningMode) { + scanAssert = scanAssert.extracting("scan"); + } + + scanAssert.extracting("context").extracting("minRowsRequested").isEqualTo(limit); + } + + @TestTemplate + public void limitPushedDownToSparkScanForMetadataTable() { + assumeThat(fileFormat) + .as("no need to run this across the entire test matrix") + .isEqualTo(FileFormat.PARQUET); + + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", unpartitioned.toString())); + + // load the snapshots metadata table + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path") + "#snapshots"), options); + + long limit = 23; + // simulate Spark pushing down the limit to the scan builder + builder.pushLimit((int) limit); + assertThat(builder).extracting("limit").isEqualTo((int) limit); + + // verify batch scan + assertThat(builder.build()) + .extracting("scan") + .extracting("scan") + .extracting("context") + .extracting("minRowsRequested") + .isEqualTo(limit); + + // verify CoW scan + assertThat(builder.buildCopyOnWriteScan()) + .extracting("scan") + .extracting("scan") + .extracting("context") + .extracting("minRowsRequested") + .isEqualTo(limit); + } + + @TestTemplate + public void testBucketPartitionedIDFilters() { + Table table = buildPartitionedTable("bucketed_by_id", BUCKET_BY_ID); + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", table.location())); + + Batch unfiltered = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options).build().toBatch(); + assertThat(unfiltered.planInputPartitions()) + .as("Unfiltered table should created 4 read tasks") + .hasSize(4); + + for (int i = 0; i < 10; i += 1) { + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, EqualTo.apply("id", i)); + Batch scan = builder.build().toBatch(); + + InputPartition[] tasks = scan.planInputPartitions(); + + // validate predicate push-down + assertThat(tasks).as("Should only create one task for a single bucket").hasSize(1); + + // validate row filtering + assertEqualsSafe( + SCHEMA.asStruct(), expected(i), read(table.location(), vectorized, "id = " + i)); + } + } + + @SuppressWarnings("checkstyle:AvoidNestedBlocks") + @TestTemplate + public void testDayPartitionedTimestampFilters() { + Table table = buildPartitionedTable("partitioned_by_day", PARTITION_BY_DAY); + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", table.location())); + Batch unfiltered = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options).build().toBatch(); + + assertThat(unfiltered.planInputPartitions()) + .as("Unfiltered table should created 2 read tasks") + .hasSize(2); + + { + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, LessThan.apply("ts", "2017-12-22T00:00:00+00:00")); + Batch scan = builder.build().toBatch(); + + InputPartition[] tasks = scan.planInputPartitions(); + assertThat(tasks).as("Should create one task for 2017-12-21").hasSize(1); + + assertEqualsSafe( + SCHEMA.asStruct(), + expected(5, 6, 7, 8, 9), + read( + table.location(), vectorized, "ts < cast('2017-12-22 00:00:00+00:00' as timestamp)")); + } + + { + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters( + builder, + And.apply( + GreaterThan.apply("ts", "2017-12-22T06:00:00+00:00"), + LessThan.apply("ts", "2017-12-22T08:00:00+00:00"))); + Batch scan = builder.build().toBatch(); + + InputPartition[] tasks = scan.planInputPartitions(); + assertThat(tasks).as("Should create one task for 2017-12-22").hasSize(1); + + assertEqualsSafe( + SCHEMA.asStruct(), + expected(1, 2), + read( + table.location(), + vectorized, + "ts > cast('2017-12-22 06:00:00+00:00' as timestamp) and " + + "ts < cast('2017-12-22 08:00:00+00:00' as timestamp)")); + } + } + + @SuppressWarnings("checkstyle:AvoidNestedBlocks") + @TestTemplate + public void testHourPartitionedTimestampFilters() { + Table table = buildPartitionedTable("partitioned_by_hour", PARTITION_BY_HOUR); + + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", table.location())); + Batch unfiltered = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options).build().toBatch(); + + assertThat(unfiltered.planInputPartitions()) + .as("Unfiltered table should created 9 read tasks") + .hasSize(9); + + { + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, LessThan.apply("ts", "2017-12-22T00:00:00+00:00")); + Batch scan = builder.build().toBatch(); + + InputPartition[] tasks = scan.planInputPartitions(); + assertThat(tasks).as("Should create 4 tasks for 2017-12-21: 15, 17, 21, 22").hasSize(4); + + assertEqualsSafe( + SCHEMA.asStruct(), + expected(8, 9, 7, 6, 5), + read( + table.location(), vectorized, "ts < cast('2017-12-22 00:00:00+00:00' as timestamp)")); + } + + { + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters( + builder, + And.apply( + GreaterThan.apply("ts", "2017-12-22T06:00:00+00:00"), + LessThan.apply("ts", "2017-12-22T08:00:00+00:00"))); + Batch scan = builder.build().toBatch(); + + InputPartition[] tasks = scan.planInputPartitions(); + assertThat(tasks).as("Should create 2 tasks for 2017-12-22: 6, 7").hasSize(2); + + assertEqualsSafe( + SCHEMA.asStruct(), + expected(2, 1), + read( + table.location(), + vectorized, + "ts > cast('2017-12-22 06:00:00+00:00' as timestamp) and " + + "ts < cast('2017-12-22 08:00:00+00:00' as timestamp)")); + } + } + + @SuppressWarnings("checkstyle:AvoidNestedBlocks") + @TestTemplate + public void testFilterByNonProjectedColumn() { + { + Schema actualProjection = SCHEMA.select("id", "data"); + List expected = Lists.newArrayList(); + for (Record rec : expected(5, 6, 7, 8, 9)) { + expected.add(projectFlat(actualProjection, rec)); + } + + assertEqualsSafe( + actualProjection.asStruct(), + expected, + read( + unpartitioned.toString(), + vectorized, + "ts < cast('2017-12-22 00:00:00+00:00' as timestamp)", + "id", + "data")); + } + + { + // only project id: ts will be projected because of the filter, but data will not be included + + Schema actualProjection = SCHEMA.select("id"); + List expected = Lists.newArrayList(); + for (Record rec : expected(1, 2)) { + expected.add(projectFlat(actualProjection, rec)); + } + + assertEqualsSafe( + actualProjection.asStruct(), + expected, + read( + unpartitioned.toString(), + vectorized, + "ts > cast('2017-12-22 06:00:00+00:00' as timestamp) and " + + "ts < cast('2017-12-22 08:00:00+00:00' as timestamp)", + "id")); + } + } + + @TestTemplate + public void testPartitionedByDataStartsWithFilter() { + Table table = buildPartitionedTable("partitioned_by_data", PARTITION_BY_DATA); + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", table.location())); + + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, new StringStartsWith("data", "junc")); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(1); + } + + @TestTemplate + public void testPartitionedByDataNotStartsWithFilter() { + Table table = buildPartitionedTable("partitioned_by_data", PARTITION_BY_DATA); + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", table.location())); + + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, new Not(new StringStartsWith("data", "junc"))); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(9); + } + + @TestTemplate + public void testPartitionedByIdStartsWith() { + Table table = buildPartitionedTable("partitioned_by_id", PARTITION_BY_ID); + + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", table.location())); + + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, new StringStartsWith("data", "junc")); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(1); + } + + @TestTemplate + public void testPartitionedByIdNotStartsWith() { + Table table = buildPartitionedTable("partitioned_by_id", PARTITION_BY_ID); + + CaseInsensitiveStringMap options = + new CaseInsensitiveStringMap(ImmutableMap.of("path", table.location())); + + SparkScanBuilder builder = + new SparkScanBuilder(spark, TABLES.load(options.get("path")), options); + + pushFilters(builder, new Not(new StringStartsWith("data", "junc"))); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(9); + } + + @TestTemplate + public void testUnpartitionedStartsWith() { + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(unpartitioned.toString()); + + List matchedData = + df.select("data").where("data LIKE 'jun%'").as(Encoders.STRING()).collectAsList(); + + assertThat(matchedData).singleElement().isEqualTo("junction"); + } + + @TestTemplate + public void testUnpartitionedNotStartsWith() { + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(unpartitioned.toString()); + + List matchedData = + df.select("data").where("data NOT LIKE 'jun%'").as(Encoders.STRING()).collectAsList(); + + List expected = + testRecords(SCHEMA).stream() + .map(r -> r.getField("data").toString()) + .filter(d -> !d.startsWith("jun")) + .collect(Collectors.toList()); + + assertThat(matchedData).hasSize(9).containsExactlyInAnyOrderElementsOf(expected); + } + + private static Record projectFlat(Schema projection, Record record) { + Record result = GenericRecord.create(projection); + List fields = projection.asStruct().fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + result.set(i, record.getField(field.name())); + } + return result; + } + + public static void assertEqualsUnsafe( + Types.StructType struct, List expected, List actual) { + // TODO: match records by ID + int numRecords = Math.min(expected.size(), actual.size()); + for (int i = 0; i < numRecords; i += 1) { + GenericsHelpers.assertEqualsUnsafe(struct, expected.get(i), actual.get(i)); + } + assertThat(actual).as("Number of results should match expected").hasSameSizeAs(expected); + } + + public static void assertEqualsSafe( + Types.StructType struct, List expected, List actual) { + // TODO: match records by ID + int numRecords = Math.min(expected.size(), actual.size()); + for (int i = 0; i < numRecords; i += 1) { + GenericsHelpers.assertEqualsSafe(struct, expected.get(i), actual.get(i)); + } + assertThat(actual).as("Number of results should match expected").hasSameSizeAs(expected); + } + + private List expected(int... ordinals) { + List expected = Lists.newArrayListWithExpectedSize(ordinals.length); + for (int ord : ordinals) { + expected.add(records.get(ord)); + } + return expected; + } + + private void pushFilters(ScanBuilder scan, Filter... filters) { + assertThat(scan).isInstanceOf(SupportsPushDownV2Filters.class); + SupportsPushDownV2Filters filterable = (SupportsPushDownV2Filters) scan; + filterable.pushPredicates(Arrays.stream(filters).map(Filter::toV2).toArray(Predicate[]::new)); + } + + private Table buildPartitionedTable(String desc, PartitionSpec spec) { + File location = new File(parent, desc); + Table table = TABLES.create(SCHEMA, spec, location.toString()); + + // Do not combine or split files because the tests expect a split per partition. + // A target split size of 2048 helps us achieve that. + table.updateProperties().set("read.split.target-size", "2048").commit(); + + // copy the unpartitioned table into the partitioned table to produce the partitioned data + Dataset allRows = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(unpartitioned.toString()); + + // disable fanout writers to locally order records for future verifications + allRows + .write() + .option(SparkWriteOptions.FANOUT_ENABLED, "false") + .format("iceberg") + .mode("append") + .save(table.location()); + + table.refresh(); + + return table; + } + + private List testRecords(Schema schema) { + return Lists.newArrayList( + record(schema, 0L, parse("2017-12-22T09:20:44.294658+00:00"), "junction"), + record(schema, 1L, parse("2017-12-22T07:15:34.582910+00:00"), "alligator"), + record(schema, 2L, parse("2017-12-22T06:02:09.243857+00:00"), ""), + record(schema, 3L, parse("2017-12-22T03:10:11.134509+00:00"), "clapping"), + record(schema, 4L, parse("2017-12-22T00:34:00.184671+00:00"), "brush"), + record(schema, 5L, parse("2017-12-21T22:20:08.935889+00:00"), "trap"), + record(schema, 6L, parse("2017-12-21T21:55:30.589712+00:00"), "element"), + record(schema, 7L, parse("2017-12-21T17:31:14.532797+00:00"), "limited"), + record(schema, 8L, parse("2017-12-21T15:21:51.237521+00:00"), "global"), + record(schema, 9L, parse("2017-12-21T15:02:15.230570+00:00"), "goldfish")); + } + + private static List read(String table, boolean vectorized, String expr) { + return read(table, vectorized, expr, "*"); + } + + private static List read( + String table, boolean vectorized, String expr, String select0, String... selectN) { + Dataset dataset = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(table) + .filter(expr) + .select(select0, selectN); + return dataset.collectAsList(); + } + + private static OffsetDateTime parse(String timestamp) { + return OffsetDateTime.parse(timestamp); + } + + private static Record record(Schema schema, Object... values) { + Record rec = GenericRecord.create(schema); + for (int i = 0; i < values.length; i += 1) { + rec.set(i, values[i]); + } + return rec; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java new file mode 100644 index 000000000000..d0103ff46ead --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestForwardCompatibility.java @@ -0,0 +1,233 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.Files.localInput; +import static org.apache.iceberg.Files.localOutput; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeoutException; +import org.apache.avro.generic.GenericData; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestWriter; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.execution.streaming.runtime.MemoryStream; +import org.apache.spark.sql.streaming.StreamingQuery; +import org.apache.spark.sql.streaming.StreamingQueryException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import scala.Option; +import scala.collection.JavaConverters; + +public class TestForwardCompatibility { + private static final Configuration CONF = new Configuration(); + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + // create a spec for the schema that uses a "zero" transform that produces all 0s + private static final PartitionSpec UNKNOWN_SPEC = + org.apache.iceberg.TestHelpers.newExpectedSpecBuilder() + .withSchema(SCHEMA) + .withSpecId(0) + .addField("zero", 1, "id_zero") + .build(); + // create a fake spec to use to write table metadata + private static final PartitionSpec FAKE_SPEC = + org.apache.iceberg.TestHelpers.newExpectedSpecBuilder() + .withSchema(SCHEMA) + .withSpecId(0) + .addField("identity", 1, "id_zero") + .build(); + + @TempDir private Path temp; + + private static SparkSession spark = null; + + @BeforeAll + public static void startSpark() { + TestForwardCompatibility.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestForwardCompatibility.spark; + TestForwardCompatibility.spark = null; + currentSpark.stop(); + } + + @Test + public void testSparkWriteFailsUnknownTransform() throws IOException { + File parent = temp.resolve("avro").toFile(); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).isTrue(); + + HadoopTables tables = new HadoopTables(CONF); + tables.create(SCHEMA, UNKNOWN_SPEC, location.toString()); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + assertThatThrownBy( + () -> + df.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(location.toString())) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageEndingWith("Cannot write using unsupported transforms: zero"); + } + + @Test + public void testSparkStreamingWriteFailsUnknownTransform() throws IOException, TimeoutException { + File parent = temp.resolve("avro").toFile(); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).isTrue(); + File checkpoint = new File(parent, "checkpoint"); + assertThat(checkpoint.mkdirs()).isTrue(); + + HadoopTables tables = new HadoopTables(CONF); + tables.create(SCHEMA, UNKNOWN_SPEC, location.toString()); + + MemoryStream inputStream = newMemoryStream(1, spark, Encoders.INT()); + StreamingQuery query = + inputStream + .toDF() + .selectExpr("value AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("append") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()) + .start(); + + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + + assertThatThrownBy(query::processAllAvailable) + .isInstanceOf(StreamingQueryException.class) + .hasMessageContaining("Cannot write using unsupported transforms: zero"); + } + + @Disabled("https://issues.apache.org/jira/browse/SPARK-55626") + @Test + public void testSparkCanReadUnknownTransform() throws IOException { + File parent = temp.resolve("avro").toFile(); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).isTrue(); + + HadoopTables tables = new HadoopTables(CONF); + Table table = tables.create(SCHEMA, UNKNOWN_SPEC, location.toString()); + + // enable snapshot inheritance to avoid rewriting the manifest with an unknown transform + table.updateProperties().set(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, "true").commit(); + + List expected = RandomData.generateList(table.schema(), 100, 1L); + + File parquetFile = + new File(dataFolder, FileFormat.PARQUET.addExtension(UUID.randomUUID().toString())); + FileAppender writer = + Parquet.write(localOutput(parquetFile)).schema(table.schema()).build(); + try { + writer.addAll(expected); + } finally { + writer.close(); + } + + DataFile file = + DataFiles.builder(FAKE_SPEC) + .withInputFile(localInput(parquetFile)) + .withMetrics(writer.metrics()) + .withPartitionPath("id_zero=0") + .build(); + + OutputFile manifestFile = localOutput(FileFormat.AVRO.addExtension(temp.toFile().toString())); + ManifestWriter manifestWriter = ManifestFiles.write(FAKE_SPEC, manifestFile); + try { + manifestWriter.add(file); + } finally { + manifestWriter.close(); + } + + table.newFastAppend().appendManifest(manifestWriter.toManifestFile()).commit(); + + Dataset df = spark.read().format("iceberg").load(location.toString()); + + List rows = df.collectAsList(); + assertThat(rows).as("Should contain 100 rows").hasSize(100); + + for (int i = 0; i < expected.size(); i += 1) { + TestHelpers.assertEqualsSafe(table.schema().asStruct(), expected.get(i), rows.get(i)); + } + } + + private MemoryStream newMemoryStream( + int id, SparkSession sparkSession, Encoder encoder) { + return new MemoryStream<>(id, sparkSession, Option.empty(), encoder); + } + + private void send(List records, MemoryStream stream) { + stream.addData(JavaConverters.asScalaBuffer(records)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSource.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSource.java new file mode 100644 index 000000000000..a850275118db --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSource.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +public class TestIcebergSource extends IcebergSource { + @Override + public String shortName() { + return "iceberg-test"; + } + + @Override + public Identifier extractIdentifier(CaseInsensitiveStringMap options) { + TableIdentifier ti = TableIdentifier.parse(options.get("iceberg.table.name")); + return Identifier.of(ti.namespace().levels(), ti.name()); + } + + @Override + public String extractCatalog(CaseInsensitiveStringMap options) { + return SparkSession.active().sessionState().catalogManager().currentCatalog().name(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceHadoopTables.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceHadoopTables.java new file mode 100644 index 000000000000..701c421a253b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceHadoopTables.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.File; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.hadoop.HadoopTables; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.io.TempDir; + +public class TestIcebergSourceHadoopTables extends TestIcebergSourceTablesBase { + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + @TempDir private File tableDir; + String tableLocation = null; + + @BeforeEach + public void setupTable() { + this.tableLocation = tableDir.toURI().toString(); + } + + @Override + public Table createTable( + TableIdentifier ident, Schema schema, PartitionSpec spec, Map properties) { + return TABLES.create(schema, spec, properties, tableLocation); + } + + @Override + public void dropTable(TableIdentifier ident) { + TABLES.dropTable(tableLocation); + } + + @Override + public Table loadTable(TableIdentifier ident, String entriesSuffix) { + return TABLES.load(loadLocation(ident, entriesSuffix)); + } + + @Override + public String loadLocation(TableIdentifier ident, String entriesSuffix) { + return String.format("%s#%s", loadLocation(ident), entriesSuffix); + } + + @Override + public String loadLocation(TableIdentifier ident) { + return tableLocation; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceHiveTables.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceHiveTables.java new file mode 100644 index 000000000000..9120bbcc35a3 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceHiveTables.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.IOException; +import java.util.Map; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; + +public class TestIcebergSourceHiveTables extends TestIcebergSourceTablesBase { + + private static TableIdentifier currentIdentifier; + + @BeforeAll + public static void start() { + Namespace db = Namespace.of("db"); + if (!catalog.namespaceExists(db)) { + catalog.createNamespace(db); + } + } + + @AfterEach + public void dropTable() throws IOException { + if (!catalog.tableExists(currentIdentifier)) { + return; + } + + dropTable(currentIdentifier); + } + + @Override + public Table createTable( + TableIdentifier ident, Schema schema, PartitionSpec spec, Map properties) { + TestIcebergSourceHiveTables.currentIdentifier = ident; + return TestIcebergSourceHiveTables.catalog.createTable(ident, schema, spec, properties); + } + + @Override + public void dropTable(TableIdentifier ident) throws IOException { + Table table = catalog.loadTable(ident); + Path tablePath = new Path(table.location()); + FileSystem fs = tablePath.getFileSystem(spark.sessionState().newHadoopConf()); + fs.delete(tablePath, true); + catalog.dropTable(ident, false); + } + + @Override + public Table loadTable(TableIdentifier ident, String entriesSuffix) { + TableIdentifier identifier = + TableIdentifier.of(ident.namespace().level(0), ident.name(), entriesSuffix); + return TestIcebergSourceHiveTables.catalog.loadTable(identifier); + } + + @Override + public String loadLocation(TableIdentifier ident, String entriesSuffix) { + return String.format("%s.%s", loadLocation(ident), entriesSuffix); + } + + @Override + public String loadLocation(TableIdentifier ident) { + return ident.toString(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java new file mode 100644 index 000000000000..c322730440d1 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSourceTablesBase.java @@ -0,0 +1,2486 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.ManifestContent.DATA; +import static org.apache.iceberg.ManifestContent.DELETES; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecordBuilder; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.Files; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.actions.RewriteManifests; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.avro.AvroSchemaUtil; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.deletes.PositionDeleteWriter; +import org.apache.iceberg.encryption.EncryptedOutputFile; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFileFactory; +import org.apache.iceberg.mapping.MappingUtil; +import org.apache.iceberg.mapping.NameMapping; +import org.apache.iceberg.mapping.NameMappingParser; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.actions.SparkActions; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; +import org.apache.spark.SparkException; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.functions; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public abstract class TestIcebergSourceTablesBase extends TestBase { + + private static final SimpleDateFormat TIMESTAMP_FORMAT = + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + + private static final Schema SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + + private static final Schema SCHEMA2 = + new Schema( + optional(1, "id", Types.IntegerType.get()), + optional(2, "data", Types.StringType.get()), + optional(3, "category", Types.StringType.get())); + + private static final Schema SCHEMA3 = + new Schema( + optional(1, "id", Types.IntegerType.get()), + optional(3, "category", Types.StringType.get())); + + private static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + + @TempDir protected Path temp; + + public abstract Table createTable( + TableIdentifier ident, Schema schema, PartitionSpec spec, Map properties); + + public abstract Table loadTable(TableIdentifier ident, String entriesSuffix); + + public abstract String loadLocation(TableIdentifier ident, String entriesSuffix); + + public abstract String loadLocation(TableIdentifier ident); + + public abstract void dropTable(TableIdentifier ident) throws IOException; + + @AfterEach + public void removeTable() { + spark.sql("DROP TABLE IF EXISTS parquet_table"); + } + + private Table createTable(TableIdentifier ident, Schema schema, PartitionSpec spec) { + return createTable(ident, schema, spec, ImmutableMap.of()); + } + + @Test + public synchronized void testTablesSupport() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "table"); + createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "1"), new SimpleRecord(2, "2"), new SimpleRecord(3, "3")); + + Dataset inputDf = spark.createDataFrame(expectedRecords, SimpleRecord.class); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + Dataset resultDf = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + List actualRecords = + resultDf.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(actualRecords).as("Records should match").isEqualTo(expectedRecords); + } + + @Test + public void testEntriesTable() throws Exception { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "entries_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + Table entriesTable = loadTable(tableIdentifier, "entries"); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + Dataset entriesTableDs = + spark.read().format("iceberg").load(loadLocation(tableIdentifier, "entries")); + List actual = TestHelpers.selectNonDerived(entriesTableDs).collectAsList(); + + Snapshot snapshot = table.currentSnapshot(); + + assertThat(snapshot.allManifests(table.io())).as("Should only contain one manifest").hasSize(1); + + InputFile manifest = table.io().newInputFile(snapshot.allManifests(table.io()).get(0).path()); + List expected = Lists.newArrayList(); + try (CloseableIterable rows = + Avro.read(manifest).project(entriesTable.schema()).build()) { + // each row must inherit snapshot_id and sequence_number + rows.forEach( + row -> { + row.put(2, 1L); // data sequence number + row.put(3, 1L); // file sequence number + GenericData.Record file = (GenericData.Record) row.get("data_file"); + TestHelpers.asMetadataRecord(file); + expected.add(row); + }); + } + + assertThat(expected).as("Entries table should have one row").hasSize(1); + assertThat(actual).as("Actual results should have one row").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(entriesTableDs), expected.get(0), actual.get(0)); + } + + @Test + public void testEntriesTablePartitionedPrune() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "entries_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "entries")) + .select("status") + .collectAsList(); + + assertThat(actual).singleElement().satisfies(row -> assertThat(row.getInt(0)).isEqualTo(1)); + } + + @Test + public void testEntriesTableDataFilePrune() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "entries_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); + + List singleActual = + rowsToJava( + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "entries")) + .select("data_file.file_path") + .collectAsList()); + + List singleExpected = ImmutableList.of(row(file.location())); + + assertEquals( + "Should prune a single element from a nested struct", singleExpected, singleActual); + } + + @Test + public void testEntriesTableDataFilePruneMulti() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "entries_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); + + List multiActual = + rowsToJava( + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "entries")) + .select( + "data_file.file_path", + "data_file.value_counts", + "data_file.record_count", + "data_file.column_sizes") + .collectAsList()); + + List multiExpected = + ImmutableList.of( + row(file.location(), file.valueCounts(), file.recordCount(), file.columnSizes())); + + assertEquals("Should prune a single element from a nested struct", multiExpected, multiActual); + } + + @Test + public void testFilesSelectMap() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "entries_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); + + List multiActual = + rowsToJava( + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "files")) + .select("file_path", "value_counts", "record_count", "column_sizes") + .collectAsList()); + + List multiExpected = + ImmutableList.of( + row(file.location(), file.valueCounts(), file.recordCount(), file.columnSizes())); + + assertEquals("Should prune a single element from a row", multiExpected, multiActual); + } + + @Test + public void testAllEntriesTable() throws Exception { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "entries_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + Table entriesTable = loadTable(tableIdentifier, "all_entries"); + + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "b")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // delete the first file to test that not only live files are listed + table.newDelete().deleteFromRowFilter(Expressions.equal("id", 1)).commit(); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // ensure table data isn't stale + table.refresh(); + + Dataset entriesTableDs = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "all_entries")) + .orderBy("snapshot_id"); + List actual = TestHelpers.selectNonDerived(entriesTableDs).collectAsList(); + + List expected = Lists.newArrayList(); + for (ManifestFile manifest : + Iterables.concat(Iterables.transform(table.snapshots(), s -> s.allManifests(table.io())))) { + InputFile in = table.io().newInputFile(manifest.path()); + try (CloseableIterable rows = + Avro.read(in).project(entriesTable.schema()).build()) { + // each row must inherit snapshot_id and sequence_number + rows.forEach( + row -> { + if (row.get("snapshot_id").equals(table.currentSnapshot().snapshotId())) { + row.put(2, 3L); // data sequence number + row.put(3, 3L); // file sequence number + } else { + row.put(2, 1L); // data sequence number + row.put(3, 1L); // file sequence number + } + GenericData.Record file = (GenericData.Record) row.get("data_file"); + TestHelpers.asMetadataRecord(file); + expected.add(row); + }); + } + } + + expected.sort(Comparator.comparing(o -> (Long) o.get("snapshot_id"))); + + assertThat(expected).as("Entries table should have 3 rows").hasSize(3); + assertThat(actual).as("Actual results should have 3 rows").hasSize(3); + + for (int i = 0; i < expected.size(); i += 1) { + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(entriesTableDs), expected.get(i), actual.get(i)); + } + } + + @Test + public void testCountEntriesTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "count_entries_test"); + createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + // init load + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + final int expectedEntryCount = 1; + + // count entries + assertThat( + spark.read().format("iceberg").load(loadLocation(tableIdentifier, "entries")).count()) + .as("Count should return " + expectedEntryCount) + .isEqualTo(expectedEntryCount); + + // count all_entries + assertThat( + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "all_entries")) + .count()) + .as("Count should return " + expectedEntryCount) + .isEqualTo(expectedEntryCount); + } + + @Test + public void testFilesTable() throws Exception { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "files_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table entriesTable = loadTable(tableIdentifier, "entries"); + + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(2, "b")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // delete the first file to test that only live files are listed + table.newDelete().deleteFromRowFilter(Expressions.equal("id", 1)).commit(); + + Dataset filesTableDs = + spark.read().format("iceberg").load(loadLocation(tableIdentifier, "files")); + List actual = TestHelpers.selectNonDerived(filesTableDs).collectAsList(); + + List expected = Lists.newArrayList(); + for (ManifestFile manifest : table.currentSnapshot().dataManifests(table.io())) { + InputFile in = table.io().newInputFile(manifest.path()); + try (CloseableIterable rows = + Avro.read(in).project(entriesTable.schema()).build()) { + for (GenericData.Record record : rows) { + if ((Integer) record.get("status") < 2 /* added or existing */) { + GenericData.Record file = (GenericData.Record) record.get("data_file"); + TestHelpers.asMetadataRecord(file); + expected.add(file); + } + } + } + } + + assertThat(expected).as("Files table should have one row").hasSize(1); + assertThat(actual).as("Actual results should have one row").hasSize(1); + + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(filesTableDs), expected.get(0), actual.get(0)); + } + + @Test + public void testFilesTableWithSnapshotIdInheritance() throws Exception { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "files_inheritance_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + table.updateProperties().set(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, "true").commit(); + Table entriesTable = loadTable(tableIdentifier, "entries"); + + spark.sql( + String.format( + "CREATE TABLE parquet_table (data string, id int) " + + "USING parquet PARTITIONED BY (id) LOCATION '%s'", + temp.toFile())); + + List records = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + + Dataset inputDF = spark.createDataFrame(records, SimpleRecord.class); + inputDF.select("data", "id").write().mode("overwrite").insertInto("parquet_table"); + + NameMapping mapping = MappingUtil.create(table.schema()); + String mappingJson = NameMappingParser.toJson(mapping); + + table.updateProperties().set(TableProperties.DEFAULT_NAME_MAPPING, mappingJson).commit(); + + String stagingLocation = table.location() + "/metadata"; + SparkTableUtil.importSparkTable( + spark, + new org.apache.spark.sql.catalyst.TableIdentifier("parquet_table"), + table, + stagingLocation); + + Dataset filesTableDs = + spark.read().format("iceberg").load(loadLocation(tableIdentifier, "files")); + List actual = TestHelpers.selectNonDerived(filesTableDs).collectAsList(); + + List expected = Lists.newArrayList(); + for (ManifestFile manifest : table.currentSnapshot().dataManifests(table.io())) { + InputFile in = table.io().newInputFile(manifest.path()); + try (CloseableIterable rows = + Avro.read(in).project(entriesTable.schema()).build()) { + for (GenericData.Record record : rows) { + GenericData.Record file = (GenericData.Record) record.get("data_file"); + TestHelpers.asMetadataRecord(file); + expected.add(file); + } + } + } + + Types.StructType struct = TestHelpers.nonDerivedSchema(filesTableDs); + assertThat(expected).as("Files table should have 2 rows").hasSize(2); + assertThat(actual).as("Actual results should have 2 rows").hasSize(2); + TestHelpers.assertEqualsSafe(struct, expected.get(0), actual.get(0)); + TestHelpers.assertEqualsSafe(struct, expected.get(1), actual.get(1)); + } + + @Test + public void testV1EntriesTableWithSnapshotIdInheritance() throws Exception { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "entries_inheritance_test"); + Map properties = ImmutableMap.of(TableProperties.FORMAT_VERSION, "1"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC, properties); + + table.updateProperties().set(TableProperties.SNAPSHOT_ID_INHERITANCE_ENABLED, "true").commit(); + + spark.sql( + String.format( + "CREATE TABLE parquet_table (data string, id int) " + + "USING parquet PARTITIONED BY (id) LOCATION '%s'", + temp.toFile())); + + List records = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + + Dataset inputDF = spark.createDataFrame(records, SimpleRecord.class); + inputDF.select("data", "id").write().mode("overwrite").insertInto("parquet_table"); + + String stagingLocation = table.location() + "/metadata"; + SparkTableUtil.importSparkTable( + spark, + new org.apache.spark.sql.catalyst.TableIdentifier("parquet_table"), + table, + stagingLocation); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "entries")) + .select("sequence_number", "snapshot_id", "data_file") + .collectAsList(); + + table.refresh(); + + long snapshotId = table.currentSnapshot().snapshotId(); + + assertThat(actual).as("Entries table should have 2 rows").hasSize(2); + assertThat(actual) + .first() + .satisfies( + row -> { + assertThat(row.getLong(0)).isEqualTo(0); + assertThat(row.getLong(1)).isEqualTo(snapshotId); + }); + assertThat(actual) + .element(1) + .satisfies( + row -> { + assertThat(row.getLong(0)).isEqualTo(0); + assertThat(row.getLong(1)).isEqualTo(snapshotId); + }); + } + + @Test + public void testFilesUnpartitionedTable() throws Exception { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "unpartitioned_files_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + Table entriesTable = loadTable(tableIdentifier, "entries"); + + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(2, "b")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + DataFile toDelete = + Iterables.getOnlyElement(SnapshotChanges.builderFor(table).build().addedDataFiles()); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // delete the first file to test that only live files are listed + table.newDelete().deleteFile(toDelete).commit(); + + Dataset filesTableDs = + spark.read().format("iceberg").load(loadLocation(tableIdentifier, "files")); + List actual = TestHelpers.selectNonDerived(filesTableDs).collectAsList(); + + List expected = Lists.newArrayList(); + for (ManifestFile manifest : table.currentSnapshot().dataManifests(table.io())) { + InputFile in = table.io().newInputFile(manifest.path()); + try (CloseableIterable rows = + Avro.read(in).project(entriesTable.schema()).build()) { + for (GenericData.Record record : rows) { + if ((Integer) record.get("status") < 2 /* added or existing */) { + GenericData.Record file = (GenericData.Record) record.get("data_file"); + TestHelpers.asMetadataRecord(file); + expected.add(file); + } + } + } + } + + assertThat(expected).as("Files table should have one row").hasSize(1); + assertThat(actual).as("Actual results should have one row").hasSize(1); + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(filesTableDs), expected.get(0), actual.get(0)); + } + + @Test + public void testAllMetadataTablesWithStagedCommits() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "stage_aggregate_table_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + + table.updateProperties().set(TableProperties.WRITE_AUDIT_PUBLISH_ENABLED, "true").commit(); + spark.conf().set(SparkSQLProperties.WAP_ID, "1234567"); + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(2, "b")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + List actualAllData = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "all_data_files")) + .collectAsList(); + + List actualAllManifests = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "all_manifests")) + .collectAsList(); + + List actualAllEntries = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "all_entries")) + .collectAsList(); + + assertThat(table.snapshots().iterator()).as("Stage table should have some snapshots").hasNext(); + assertThat(table.currentSnapshot()).as("Stage table should have null currentSnapshot").isNull(); + assertThat(actualAllData).as("Actual results should have two rows").hasSize(2); + assertThat(actualAllManifests).as("Actual results should have two rows").hasSize(2); + assertThat(actualAllEntries).as("Actual results should have two rows").hasSize(2); + } + + @Test + public void testAllDataFilesTable() throws Exception { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "files_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table entriesTable = loadTable(tableIdentifier, "entries"); + + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(2, "b")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // delete the first file to test that not only live files are listed + table.newDelete().deleteFromRowFilter(Expressions.equal("id", 1)).commit(); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // ensure table data isn't stale + table.refresh(); + + Dataset filesTableDs = + spark.read().format("iceberg").load(loadLocation(tableIdentifier, "all_data_files")); + List actual = TestHelpers.selectNonDerived(filesTableDs).collectAsList(); + actual.sort(Comparator.comparing(o -> o.getString(1))); + + List expected = Lists.newArrayList(); + Iterable dataManifests = + Iterables.concat( + Iterables.transform(table.snapshots(), snapshot -> snapshot.dataManifests(table.io()))); + for (ManifestFile manifest : dataManifests) { + InputFile in = table.io().newInputFile(manifest.path()); + try (CloseableIterable rows = + Avro.read(in).project(entriesTable.schema()).build()) { + for (GenericData.Record record : rows) { + if ((Integer) record.get("status") < 2 /* added or existing */) { + GenericData.Record file = (GenericData.Record) record.get("data_file"); + TestHelpers.asMetadataRecord(file); + expected.add(file); + } + } + } + } + + expected.sort(Comparator.comparing(o -> o.get("file_path").toString())); + + assertThat(expected).as("Files table should have two rows").hasSize(2); + assertThat(actual).as("Actual results should have two rows").hasSize(2); + for (int i = 0; i < expected.size(); i += 1) { + TestHelpers.assertEqualsSafe( + TestHelpers.nonDerivedSchema(filesTableDs), expected.get(i), actual.get(i)); + } + } + + @Test + public void testHistoryTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "history_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + Table historyTable = loadTable(tableIdentifier, "history"); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long firstSnapshotTimestamp = table.currentSnapshot().timestampMillis(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long secondSnapshotTimestamp = table.currentSnapshot().timestampMillis(); + long secondSnapshotId = table.currentSnapshot().snapshotId(); + + // rollback the table state to the first snapshot + table.manageSnapshots().rollbackTo(firstSnapshotId).commit(); + long rollbackTimestamp = Iterables.getLast(table.history()).timestampMillis(); + assertThat(rollbackTimestamp) + .as("Rollback history timestamp should be greater than first snapshot timestamp") + .isEqualTo(((HasTableOperations) table).operations().current().lastUpdatedMillis()) + .isGreaterThan(firstSnapshotTimestamp); + + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long thirdSnapshotTimestamp = table.currentSnapshot().timestampMillis(); + long thirdSnapshotId = table.currentSnapshot().snapshotId(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "history")) + .collectAsList(); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(historyTable.schema(), "history")); + List expected = + Lists.newArrayList( + builder + .set("made_current_at", firstSnapshotTimestamp * 1000) + .set("snapshot_id", firstSnapshotId) + .set("parent_id", null) + .set("is_current_ancestor", true) + .build(), + builder + .set("made_current_at", secondSnapshotTimestamp * 1000) + .set("snapshot_id", secondSnapshotId) + .set("parent_id", firstSnapshotId) + .set( + "is_current_ancestor", + false) // commit rolled back, not an ancestor of the current table state + .build(), + builder + .set("made_current_at", rollbackTimestamp * 1000) + .set("snapshot_id", firstSnapshotId) + .set("parent_id", null) + .set("is_current_ancestor", true) + .build(), + builder + .set("made_current_at", thirdSnapshotTimestamp * 1000) + .set("snapshot_id", thirdSnapshotId) + .set("parent_id", firstSnapshotId) + .set("is_current_ancestor", true) + .build()); + + assertThat(actual).as("History table should have a row for each commit").hasSize(4); + TestHelpers.assertEqualsSafe(historyTable.schema().asStruct(), expected.get(0), actual.get(0)); + TestHelpers.assertEqualsSafe(historyTable.schema().asStruct(), expected.get(1), actual.get(1)); + TestHelpers.assertEqualsSafe(historyTable.schema().asStruct(), expected.get(2), actual.get(2)); + TestHelpers.assertEqualsSafe(historyTable.schema().asStruct(), expected.get(3), actual.get(3)); + } + + @Test + public void testSnapshotsTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "snapshots_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + Table snapTable = loadTable(tableIdentifier, "snapshots"); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long firstSnapshotTimestamp = table.currentSnapshot().timestampMillis(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + String firstManifestList = table.currentSnapshot().manifestListLocation(); + + table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()).commit(); + + long secondSnapshotTimestamp = table.currentSnapshot().timestampMillis(); + long secondSnapshotId = table.currentSnapshot().snapshotId(); + String secondManifestList = table.currentSnapshot().manifestListLocation(); + + // rollback the table state to the first snapshot + table.manageSnapshots().rollbackTo(firstSnapshotId).commit(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "snapshots")) + .collectAsList(); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(snapTable.schema(), "snapshots")); + List expected = + Lists.newArrayList( + builder + .set("committed_at", firstSnapshotTimestamp * 1000) + .set("snapshot_id", firstSnapshotId) + .set("parent_id", null) + .set("operation", "append") + .set("manifest_list", firstManifestList) + .set( + "summary", + ImmutableMap.of( + "added-records", "1", + "added-data-files", "1", + "changed-partition-count", "1", + "total-data-files", "1", + "total-records", "1")) + .build(), + builder + .set("committed_at", secondSnapshotTimestamp * 1000) + .set("snapshot_id", secondSnapshotId) + .set("parent_id", firstSnapshotId) + .set("operation", "delete") + .set("manifest_list", secondManifestList) + .set( + "summary", + ImmutableMap.of( + "deleted-records", "1", + "deleted-data-files", "1", + "changed-partition-count", "1", + "total-records", "0", + "total-data-files", "0")) + .build()); + + assertThat(actual).as("Snapshots table should have a row for each snapshot").hasSize(2); + TestHelpers.assertEqualsSafe(snapTable.schema().asStruct(), expected.get(0), actual.get(0)); + TestHelpers.assertEqualsSafe(snapTable.schema().asStruct(), expected.get(1), actual.get(1)); + } + + @Test + public void testPrunedSnapshotsTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "snapshots_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + Dataset inputDf = spark.createDataFrame(records, SimpleRecord.class); + + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long firstSnapshotTimestamp = table.currentSnapshot().timestampMillis(); + long firstSnapshotId = table.currentSnapshot().snapshotId(); + + table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()).commit(); + + long secondSnapshotTimestamp = table.currentSnapshot().timestampMillis(); + + // rollback the table state to the first snapshot + table.manageSnapshots().rollbackTo(firstSnapshotId).commit(); + + Dataset actualDf = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "snapshots")) + .select("operation", "committed_at", "summary", "parent_id"); + + Schema projectedSchema = SparkSchemaUtil.convert(actualDf.schema()); + + List actual = actualDf.collectAsList(); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(projectedSchema, "snapshots")); + List expected = + Lists.newArrayList( + builder + .set("committed_at", firstSnapshotTimestamp * 1000) + .set("parent_id", null) + .set("operation", "append") + .set( + "summary", + ImmutableMap.of( + "added-records", "1", + "added-data-files", "1", + "changed-partition-count", "1", + "total-data-files", "1", + "total-records", "1")) + .build(), + builder + .set("committed_at", secondSnapshotTimestamp * 1000) + .set("parent_id", firstSnapshotId) + .set("operation", "delete") + .set( + "summary", + ImmutableMap.of( + "deleted-records", "1", + "deleted-data-files", "1", + "changed-partition-count", "1", + "total-records", "0", + "total-data-files", "0")) + .build()); + + assertThat(actual).as("Snapshots table should have a row for each snapshot").hasSize(2); + TestHelpers.assertEqualsSafe(projectedSchema.asStruct(), expected.get(0), actual.get(0)); + TestHelpers.assertEqualsSafe(projectedSchema.asStruct(), expected.get(1), actual.get(1)); + } + + @Test + public void testManifestsTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "manifests_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table manifestTable = loadTable(tableIdentifier, "manifests"); + Dataset df1 = + spark.createDataFrame( + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(null, "b")), + SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .option(SparkWriteOptions.DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_NONE) + .save(loadLocation(tableIdentifier)); + + table.updateProperties().set(TableProperties.FORMAT_VERSION, "2").commit(); + + DeleteFile deleteFile = writePosDeleteFile(table); + + table.newRowDelta().addDeletes(deleteFile).commit(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "manifests")) + .collectAsList(); + + table.refresh(); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(manifestTable.schema(), "manifests")); + GenericRecordBuilder summaryBuilder = + new GenericRecordBuilder( + AvroSchemaUtil.convert( + manifestTable.schema().findType("partition_summaries.element").asStructType(), + "partition_summary")); + List expected = + Lists.transform( + table.currentSnapshot().allManifests(table.io()), + manifest -> + builder + .set("content", manifest.content().id()) + .set("path", manifest.path()) + .set("length", manifest.length()) + .set("partition_spec_id", manifest.partitionSpecId()) + .set("added_snapshot_id", manifest.snapshotId()) + .set( + "added_data_files_count", + manifest.content() == DATA ? manifest.addedFilesCount() : 0) + .set( + "existing_data_files_count", + manifest.content() == DATA ? manifest.existingFilesCount() : 0) + .set( + "deleted_data_files_count", + manifest.content() == DATA ? manifest.deletedFilesCount() : 0) + .set( + "added_delete_files_count", + manifest.content() == DELETES ? manifest.addedFilesCount() : 0) + .set( + "existing_delete_files_count", + manifest.content() == DELETES ? manifest.existingFilesCount() : 0) + .set( + "deleted_delete_files_count", + manifest.content() == DELETES ? manifest.deletedFilesCount() : 0) + .set( + "partition_summaries", + Lists.transform( + manifest.partitions(), + partition -> + summaryBuilder + .set("contains_null", manifest.content() == DATA) + .set("contains_nan", false) + .set("lower_bound", "1") + .set("upper_bound", "1") + .build())) + .build()); + + assertThat(actual).as("Manifests table should have two manifest rows").hasSize(2); + TestHelpers.assertEqualsSafe(manifestTable.schema().asStruct(), expected.get(0), actual.get(0)); + TestHelpers.assertEqualsSafe(manifestTable.schema().asStruct(), expected.get(1), actual.get(1)); + } + + @Test + public void testPruneManifestsTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "manifests_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table manifestTable = loadTable(tableIdentifier, "manifests"); + Dataset df1 = + spark.createDataFrame( + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(null, "b")), + SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "manifests")) + .select("partition_spec_id", "path", "partition_summaries.contains_null") + .collectAsList()) + .isInstanceOf(SparkException.class) + .hasMessageContaining("Cannot project a partial list element struct"); + + Dataset actualDf = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "manifests")) + .select("partition_spec_id", "path", "partition_summaries"); + + Schema projectedSchema = SparkSchemaUtil.convert(actualDf.schema()); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "manifests")) + .select("partition_spec_id", "path", "partition_summaries") + .collectAsList(); + + table.refresh(); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(projectedSchema.asStruct())); + GenericRecordBuilder summaryBuilder = + new GenericRecordBuilder( + AvroSchemaUtil.convert( + projectedSchema.findType("partition_summaries.element").asStructType(), + "partition_summary")); + List expected = + Lists.transform( + table.currentSnapshot().allManifests(table.io()), + manifest -> + builder + .set("partition_spec_id", manifest.partitionSpecId()) + .set("path", manifest.path()) + .set( + "partition_summaries", + Lists.transform( + manifest.partitions(), + partition -> + summaryBuilder + .set("contains_null", true) + .set("contains_nan", false) + .set("lower_bound", "1") + .set("upper_bound", "1") + .build())) + .build()); + + assertThat(actual).as("Manifests table should have one manifest row").hasSize(1); + TestHelpers.assertEqualsSafe(projectedSchema.asStruct(), expected.get(0), actual.get(0)); + } + + @Test + public void testAllManifestsTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "manifests_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table manifestTable = loadTable(tableIdentifier, "all_manifests"); + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.updateProperties().set(TableProperties.FORMAT_VERSION, "2").commit(); + + DeleteFile deleteFile = writePosDeleteFile(table); + + table.newRowDelta().addDeletes(deleteFile).commit(); + + table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()).commit(); + + Stream> snapshotIdToManifests = + StreamSupport.stream(table.snapshots().spliterator(), false) + .flatMap( + snapshot -> + snapshot.allManifests(table.io()).stream() + .map(manifest -> Pair.of(snapshot.snapshotId(), manifest))); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "all_manifests")) + .orderBy("path") + .collectAsList(); + + table.refresh(); + + List expected = + snapshotIdToManifests + .map( + snapshotManifest -> + manifestRecord( + manifestTable, snapshotManifest.first(), snapshotManifest.second())) + .sorted(Comparator.comparing(o -> o.get("path").toString())) + .collect(Collectors.toList()); + + assertThat(actual).as("Manifests table should have 5 manifest rows").hasSize(5); + for (int i = 0; i < expected.size(); i += 1) { + TestHelpers.assertEqualsSafe( + manifestTable.schema().asStruct(), expected.get(i), actual.get(i)); + } + } + + @Test + public void testUnpartitionedPartitionsTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "unpartitioned_partitions_test"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + Dataset df = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + Types.StructType expectedSchema = + Types.StructType.of( + required(2, "record_count", Types.LongType.get(), "Count of records in data files"), + required(3, "file_count", Types.IntegerType.get(), "Count of data files"), + required( + 11, + "total_data_file_size_in_bytes", + Types.LongType.get(), + "Total size in bytes of data files"), + required( + 5, + "position_delete_record_count", + Types.LongType.get(), + "Count of records in position delete files"), + required( + 6, + "position_delete_file_count", + Types.IntegerType.get(), + "Count of position delete files"), + required( + 7, + "equality_delete_record_count", + Types.LongType.get(), + "Count of records in equality delete files"), + required( + 8, + "equality_delete_file_count", + Types.IntegerType.get(), + "Count of equality delete files"), + optional( + 9, + "last_updated_at", + Types.TimestampType.withZone(), + "Commit time of snapshot that last updated this partition"), + optional( + 10, + "last_updated_snapshot_id", + Types.LongType.get(), + "Id of snapshot that last updated this partition")); + + Table partitionsTable = loadTable(tableIdentifier, "partitions"); + + assertThat(expectedSchema) + .as("Schema should not have partition field") + .isEqualTo(partitionsTable.schema().asStruct()); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(partitionsTable.schema(), "partitions")); + GenericData.Record expectedRow = + builder + .set("last_updated_at", table.currentSnapshot().timestampMillis() * 1000) + .set("last_updated_snapshot_id", table.currentSnapshot().snapshotId()) + .set("record_count", 1L) + .set("file_count", 1) + .set( + "total_data_file_size_in_bytes", + totalSizeInBytes(SnapshotChanges.builderFor(table).build().addedDataFiles())) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .build(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .collectAsList(); + + assertThat(actual).as("Unpartitioned partitions table should have one row").hasSize(1); + TestHelpers.assertEqualsSafe(expectedSchema, expectedRow, actual.get(0)); + } + + @Test + public void testPartitionsTable() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "partitions_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table partitionsTable = loadTable(tableIdentifier, "partitions"); + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(2, "b")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long firstCommitId = table.currentSnapshot().snapshotId(); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long secondCommitId = table.currentSnapshot().snapshotId(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .orderBy("partition.id") + .collectAsList(); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(partitionsTable.schema(), "partitions")); + GenericRecordBuilder partitionBuilder = + new GenericRecordBuilder( + AvroSchemaUtil.convert( + partitionsTable.schema().findType("partition").asStructType(), "partition")); + List expected = Lists.newArrayList(); + expected.add( + builder + .set("partition", partitionBuilder.set("id", 1).build()) + .set("record_count", 1L) + .set("file_count", 1) + .set( + "total_data_file_size_in_bytes", + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .set("spec_id", 0) + .set("last_updated_at", table.snapshot(firstCommitId).timestampMillis() * 1000) + .set("last_updated_snapshot_id", firstCommitId) + .build()); + expected.add( + builder + .set("partition", partitionBuilder.set("id", 2).build()) + .set("record_count", 1L) + .set("file_count", 1) + .set( + "total_data_file_size_in_bytes", + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(secondCommitId)) + .build() + .addedDataFiles())) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .set("spec_id", 0) + .set("last_updated_at", table.snapshot(secondCommitId).timestampMillis() * 1000) + .set("last_updated_snapshot_id", secondCommitId) + .build()); + + assertThat(expected).as("Partitions table should have two rows").hasSize(2); + assertThat(actual).as("Actual results should have two rows").hasSize(2); + for (int i = 0; i < 2; i += 1) { + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(i), actual.get(i)); + } + + // check time travel + List actualAfterFirstCommit = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, String.valueOf(firstCommitId)) + .load(loadLocation(tableIdentifier, "partitions")) + .orderBy("partition.id") + .collectAsList(); + + assertThat(actualAfterFirstCommit).as("Actual results should have one row").hasSize(1); + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(0), actualAfterFirstCommit.get(0)); + + // check predicate push down + List filtered = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .filter("partition.id < 2") + .collectAsList(); + + assertThat(filtered).as("Actual results should have one row").hasSize(1); + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(0), filtered.get(0)); + + List nonFiltered = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .filter("partition.id < 2 or record_count=1") + .collectAsList(); + + assertThat(nonFiltered).as("Actual results should have two rows").hasSize(2); + for (int i = 0; i < 2; i += 1) { + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(i), actual.get(i)); + } + } + + @Test + public void testPartitionsTableLastUpdatedSnapshot() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "partitions_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table partitionsTable = loadTable(tableIdentifier, "partitions"); + Dataset df1 = + spark.createDataFrame( + Lists.newArrayList(new SimpleRecord(1, "1"), new SimpleRecord(2, "2")), + SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(2, "20")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long firstCommitId = table.currentSnapshot().snapshotId(); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long secondCommitId = table.currentSnapshot().snapshotId(); + + // check if rewrite manifest does not override metadata about data file's creating snapshot + RewriteManifests.Result rewriteManifestResult = + SparkActions.get().rewriteManifests(table).execute(); + assertThat(rewriteManifestResult.rewrittenManifests()) + .as("rewrite replaced 2 manifests") + .hasSize(2); + assertThat(rewriteManifestResult.addedManifests()).as("rewrite added 1 manifests").hasSize(1); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .orderBy("partition.id") + .collectAsList(); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(partitionsTable.schema(), "partitions")); + GenericRecordBuilder partitionBuilder = + new GenericRecordBuilder( + AvroSchemaUtil.convert( + partitionsTable.schema().findType("partition").asStructType(), "partition")); + + List dataFiles = TestHelpers.dataFiles(table); + assertDataFilePartitions(dataFiles, Arrays.asList(1, 2, 2)); + + List expected = Lists.newArrayList(); + expected.add( + builder + .set("partition", partitionBuilder.set("id", 1).build()) + .set("record_count", 1L) + .set("file_count", 1) + .set("total_data_file_size_in_bytes", dataFiles.get(0).fileSizeInBytes()) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .set("spec_id", 0) + .set("last_updated_at", table.snapshot(firstCommitId).timestampMillis() * 1000) + .set("last_updated_snapshot_id", firstCommitId) + .build()); + expected.add( + builder + .set("partition", partitionBuilder.set("id", 2).build()) + .set("record_count", 2L) + .set("file_count", 2) + .set( + "total_data_file_size_in_bytes", + dataFiles.get(1).fileSizeInBytes() + dataFiles.get(2).fileSizeInBytes()) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .set("spec_id", 0) + .set("last_updated_at", table.snapshot(secondCommitId).timestampMillis() * 1000) + .set("last_updated_snapshot_id", secondCommitId) + .build()); + + assertThat(expected).as("Partitions table should have two rows").hasSize(2); + assertThat(actual).as("Actual results should have two rows").hasSize(2); + for (int i = 0; i < 2; i += 1) { + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(i), actual.get(i)); + } + + // check predicate push down + List filtered = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .filter("partition.id < 2") + .collectAsList(); + assertThat(filtered).as("Actual results should have one row").hasSize(1); + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(0), filtered.get(0)); + + // check for snapshot expiration + // if snapshot with firstCommitId is expired, + // we expect the partition of id=1 will no longer have last updated timestamp and snapshotId + SparkActions.get().expireSnapshots(table).expireSnapshotId(firstCommitId).execute(); + GenericData.Record newPartitionRecord = + builder + .set("partition", partitionBuilder.set("id", 1).build()) + .set("record_count", 1L) + .set("file_count", 1) + .set("total_data_file_size_in_bytes", dataFiles.get(0).fileSizeInBytes()) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .set("spec_id", 0) + .set("last_updated_at", null) + .set("last_updated_snapshot_id", null) + .build(); + expected.remove(0); + expected.add(0, newPartitionRecord); + + List actualAfterSnapshotExpiration = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .collectAsList(); + assertThat(actualAfterSnapshotExpiration).as("Actual results should have two rows").hasSize(2); + for (int i = 0; i < 2; i += 1) { + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), + expected.get(i), + actualAfterSnapshotExpiration.get(i)); + } + } + + @Test + public void testPartitionsTableDeleteStats() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "partitions_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table partitionsTable = loadTable(tableIdentifier, "partitions"); + Dataset df1 = + spark.createDataFrame( + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(1, "b"), new SimpleRecord(1, "c")), + SimpleRecord.class); + Dataset df2 = + spark.createDataFrame( + Lists.newArrayList( + new SimpleRecord(2, "d"), new SimpleRecord(2, "e"), new SimpleRecord(2, "f")), + SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + long firstCommitId = table.currentSnapshot().snapshotId(); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // test position deletes + table.updateProperties().set(TableProperties.FORMAT_VERSION, "2").commit(); + DeleteFile deleteFile1 = writePosDeleteFile(table, 0); + DeleteFile deleteFile2 = writePosDeleteFile(table, 1); + table.newRowDelta().addDeletes(deleteFile1).addDeletes(deleteFile2).commit(); + table.refresh(); + long posDeleteCommitId = table.currentSnapshot().snapshotId(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .orderBy("partition.id") + .collectAsList(); + assertThat(actual).as("Actual results should have two rows").hasSize(2); + + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(partitionsTable.schema(), "partitions")); + GenericRecordBuilder partitionBuilder = + new GenericRecordBuilder( + AvroSchemaUtil.convert( + partitionsTable.schema().findType("partition").asStructType(), "partition")); + List expected = Lists.newArrayList(); + expected.add( + builder + .set("partition", partitionBuilder.set("id", 1).build()) + .set("record_count", 3L) + .set("file_count", 1) + .set( + "total_data_file_size_in_bytes", + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .set("spec_id", 0) + .set("last_updated_at", table.snapshot(firstCommitId).timestampMillis() * 1000) + .set("last_updated_snapshot_id", firstCommitId) + .build()); + expected.add( + builder + .set("partition", partitionBuilder.set("id", 2).build()) + .set("record_count", 3L) + .set("file_count", 1) + .set( + "total_data_file_size_in_bytes", + totalSizeInBytes( + SnapshotChanges.builderFor(table) + .snapshot(table.snapshot(firstCommitId)) + .build() + .addedDataFiles())) + .set("position_delete_record_count", 2L) // should be incremented now + .set("position_delete_file_count", 2) // should be incremented now + .set("equality_delete_record_count", 0L) + .set("equality_delete_file_count", 0) + .set("spec_id", 0) + .set("last_updated_at", table.snapshot(posDeleteCommitId).timestampMillis() * 1000) + .set("last_updated_snapshot_id", posDeleteCommitId) + .build()); + + for (int i = 0; i < 2; i += 1) { + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(i), actual.get(i)); + } + + // test equality delete + DeleteFile eqDeleteFile1 = writeEqDeleteFile(table, "d"); + DeleteFile eqDeleteFile2 = writeEqDeleteFile(table, "f"); + table.newRowDelta().addDeletes(eqDeleteFile1).addDeletes(eqDeleteFile2).commit(); + table.refresh(); + long eqDeleteCommitId = table.currentSnapshot().snapshotId(); + actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .orderBy("partition.id") + .collectAsList(); + assertThat(actual).as("Actual results should have two rows").hasSize(2); + expected.remove(0); + expected.add( + 0, + builder + .set("partition", partitionBuilder.set("id", 1).build()) + .set("record_count", 3L) + .set("file_count", 1) + .set("position_delete_record_count", 0L) + .set("position_delete_file_count", 0) + .set("equality_delete_record_count", 2L) // should be incremented now + .set("equality_delete_file_count", 2) // should be incremented now + .set("last_updated_at", table.snapshot(eqDeleteCommitId).timestampMillis() * 1000) + .set("last_updated_snapshot_id", eqDeleteCommitId) + .build()); + for (int i = 0; i < 2; i += 1) { + TestHelpers.assertEqualsSafe( + partitionsTable.schema().asStruct(), expected.get(i), actual.get(i)); + } + } + + @Test + public synchronized void testSnapshotReadAfterAddColumn() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "table"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List originalRecords = + Lists.newArrayList( + RowFactory.create(1, "x"), RowFactory.create(2, "y"), RowFactory.create(3, "z")); + + StructType originalSparkSchema = SparkSchemaUtil.convert(SCHEMA); + Dataset inputDf = spark.createDataFrame(originalRecords, originalSparkSchema); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + Dataset resultDf = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + assertThat(resultDf.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(originalRecords); + + Snapshot snapshotBeforeAddColumn = table.currentSnapshot(); + + table.updateSchema().addColumn("category", Types.StringType.get()).commit(); + + List newRecords = + Lists.newArrayList(RowFactory.create(4, "xy", "B"), RowFactory.create(5, "xyz", "C")); + + StructType newSparkSchema = SparkSchemaUtil.convert(SCHEMA2); + Dataset inputDf2 = spark.createDataFrame(newRecords, newSparkSchema); + inputDf2 + .select("id", "data", "category") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + List updatedRecords = + Lists.newArrayList( + RowFactory.create(1, "x", null), + RowFactory.create(2, "y", null), + RowFactory.create(3, "z", null), + RowFactory.create(4, "xy", "B"), + RowFactory.create(5, "xyz", "C")); + + Dataset resultDf2 = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + assertThat(resultDf2.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(updatedRecords); + + Dataset resultDf3 = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotBeforeAddColumn.snapshotId()) + .load(loadLocation(tableIdentifier)); + assertThat(resultDf3.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(originalRecords); + assertThat(resultDf3.schema()).as("Schemas should match").isEqualTo(originalSparkSchema); + } + + @Test + public synchronized void testSnapshotReadAfterDropColumn() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "table"); + Table table = createTable(tableIdentifier, SCHEMA2, PartitionSpec.unpartitioned()); + + List originalRecords = + Lists.newArrayList( + RowFactory.create(1, "x", "A"), + RowFactory.create(2, "y", "A"), + RowFactory.create(3, "z", "B")); + + StructType originalSparkSchema = SparkSchemaUtil.convert(SCHEMA2); + Dataset inputDf = spark.createDataFrame(originalRecords, originalSparkSchema); + inputDf + .select("id", "data", "category") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + Dataset resultDf = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + assertThat(resultDf.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(originalRecords); + + long tsBeforeDropColumn = waitUntilAfter(System.currentTimeMillis()); + table.updateSchema().deleteColumn("data").commit(); + long tsAfterDropColumn = waitUntilAfter(System.currentTimeMillis()); + + String formattedTsBeforeDropColumn = TIMESTAMP_FORMAT.format(new Date(tsBeforeDropColumn)); + String formattedTsAfterDropColumn = TIMESTAMP_FORMAT.format(new Date(tsAfterDropColumn)); + + List newRecords = Lists.newArrayList(RowFactory.create(4, "B"), RowFactory.create(5, "C")); + + StructType newSparkSchema = SparkSchemaUtil.convert(SCHEMA3); + Dataset inputDf2 = spark.createDataFrame(newRecords, newSparkSchema); + inputDf2 + .select("id", "category") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + List updatedRecords = + Lists.newArrayList( + RowFactory.create(1, "A"), + RowFactory.create(2, "A"), + RowFactory.create(3, "B"), + RowFactory.create(4, "B"), + RowFactory.create(5, "C")); + + Dataset resultDf2 = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + assertThat(resultDf2.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(updatedRecords); + + Dataset resultDf3 = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTsBeforeDropColumn) + .load(loadLocation(tableIdentifier)); + assertThat(resultDf3.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(originalRecords); + assertThat(resultDf3.schema()).as("Schemas should match").isEqualTo(originalSparkSchema); + + // At tsAfterDropColumn, there has been a schema change, but no new snapshot, + // so the snapshot as of tsAfterDropColumn is the same as that as of tsBeforeDropColumn. + Dataset resultDf4 = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTsAfterDropColumn) + .load(loadLocation(tableIdentifier)); + assertThat(resultDf4.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(originalRecords); + assertThat(resultDf4.schema()).as("Schemas should match").isEqualTo(originalSparkSchema); + } + + @Test + public synchronized void testSnapshotReadAfterAddAndDropColumn() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "table"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List originalRecords = + Lists.newArrayList( + RowFactory.create(1, "x"), RowFactory.create(2, "y"), RowFactory.create(3, "z")); + + StructType originalSparkSchema = SparkSchemaUtil.convert(SCHEMA); + Dataset inputDf = spark.createDataFrame(originalRecords, originalSparkSchema); + inputDf + .select("id", "data") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + Dataset resultDf = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + assertThat(resultDf.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(originalRecords); + + Snapshot snapshotBeforeAddColumn = table.currentSnapshot(); + + table.updateSchema().addColumn("category", Types.StringType.get()).commit(); + + List newRecords = + Lists.newArrayList(RowFactory.create(4, "xy", "B"), RowFactory.create(5, "xyz", "C")); + + StructType sparkSchemaAfterAddColumn = SparkSchemaUtil.convert(SCHEMA2); + Dataset inputDf2 = spark.createDataFrame(newRecords, sparkSchemaAfterAddColumn); + inputDf2 + .select("id", "data", "category") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + table.refresh(); + + List updatedRecords = + Lists.newArrayList( + RowFactory.create(1, "x", null), + RowFactory.create(2, "y", null), + RowFactory.create(3, "z", null), + RowFactory.create(4, "xy", "B"), + RowFactory.create(5, "xyz", "C")); + + Dataset resultDf2 = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + assertThat(resultDf2.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(updatedRecords); + + table.updateSchema().deleteColumn("data").commit(); + + List recordsAfterDropColumn = + Lists.newArrayList( + RowFactory.create(1, null), + RowFactory.create(2, null), + RowFactory.create(3, null), + RowFactory.create(4, "B"), + RowFactory.create(5, "C")); + + Dataset resultDf3 = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + assertThat(resultDf3.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(recordsAfterDropColumn); + + Dataset resultDf4 = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotBeforeAddColumn.snapshotId()) + .load(loadLocation(tableIdentifier)); + assertThat(resultDf4.orderBy("id").collectAsList()) + .as("Records should match") + .containsExactlyElementsOf(originalRecords); + assertThat(resultDf4.schema()).as("Schemas should match").isEqualTo(originalSparkSchema); + } + + @Test + public void testRemoveOrphanFilesActionSupport() throws InterruptedException { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "table"); + Table table = createTable(tableIdentifier, SCHEMA, PartitionSpec.unpartitioned()); + + List records = Lists.newArrayList(new SimpleRecord(1, "1")); + + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + df.write().mode("append").parquet(table.location() + "/data"); + + // sleep for 1 second to ensure files will be old enough + Thread.sleep(1000); + + SparkActions actions = SparkActions.get(); + + DeleteOrphanFiles.Result result1 = + actions + .deleteOrphanFiles(table) + .location(table.location() + "/metadata") + .olderThan(System.currentTimeMillis()) + .execute(); + assertThat(result1.orphanFileLocations()).as("Should not delete any metadata files").isEmpty(); + + DeleteOrphanFiles.Result result2 = + actions.deleteOrphanFiles(table).olderThan(System.currentTimeMillis()).execute(); + assertThat(result2.orphanFileLocations()).as("Should delete 1 data file").hasSize(1); + + Dataset resultDF = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + List actualRecords = + resultDF.as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actualRecords).as("Rows must match").containsExactlyInAnyOrderElementsOf(records); + } + + @Test + public void testFilesTablePartitionId() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "files_test"); + Table table = + createTable( + tableIdentifier, SCHEMA, PartitionSpec.builderFor(SCHEMA).identity("id").build()); + int spec0 = table.spec().specId(); + + Dataset df1 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + Dataset df2 = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(2, "b")), SimpleRecord.class); + + df1.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + // change partition spec + table.refresh(); + table.updateSpec().removeField("id").commit(); + int spec1 = table.spec().specId(); + + // add a second file + df2.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "files")) + .sort(DataFile.SPEC_ID.name()) + .collectAsList() + .stream() + .map(r -> (Integer) r.getAs(DataFile.SPEC_ID.name())) + .collect(Collectors.toList()); + + assertThat(actual).as("Should have two partition specs").containsExactly(spec0, spec1); + } + + @Test + public void testAllManifestTableSnapshotFiltering() { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "all_manifest_snapshot_filtering"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + Table manifestTable = loadTable(tableIdentifier, "all_manifests"); + Dataset df = + spark.createDataFrame(Lists.newArrayList(new SimpleRecord(1, "a")), SimpleRecord.class); + + List> snapshotIdToManifests = Lists.newArrayList(); + + df.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + Snapshot snapshot1 = table.currentSnapshot(); + snapshotIdToManifests.addAll( + snapshot1.allManifests(table.io()).stream() + .map(manifest -> Pair.of(snapshot1.snapshotId(), manifest)) + .collect(Collectors.toList())); + + df.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + table.refresh(); + Snapshot snapshot2 = table.currentSnapshot(); + assertThat(snapshot2.allManifests(table.io())).as("Should have two manifests").hasSize(2); + snapshotIdToManifests.addAll( + snapshot2.allManifests(table.io()).stream() + .map(manifest -> Pair.of(snapshot2.snapshotId(), manifest)) + .collect(Collectors.toList())); + + // Add manifests that will not be selected + df.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + df.select("id", "data") + .write() + .format("iceberg") + .mode("append") + .save(loadLocation(tableIdentifier)); + + StringJoiner snapshotIds = new StringJoiner(",", "(", ")"); + snapshotIds.add(String.valueOf(snapshot1.snapshotId())); + snapshotIds.add(String.valueOf(snapshot2.snapshotId())); + snapshotIds.toString(); + + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "all_manifests")) + .filter("reference_snapshot_id in " + snapshotIds) + .orderBy("path") + .collectAsList(); + table.refresh(); + + List expected = + snapshotIdToManifests.stream() + .map( + snapshotManifest -> + manifestRecord( + manifestTable, snapshotManifest.first(), snapshotManifest.second())) + .sorted(Comparator.comparing(o -> o.get("path").toString())) + .collect(Collectors.toList()); + + assertThat(actual).as("Manifests table should have 3 manifest rows").hasSize(3); + for (int i = 0; i < expected.size(); i += 1) { + TestHelpers.assertEqualsSafe( + manifestTable.schema().asStruct(), expected.get(i), actual.get(i)); + } + } + + @Test + public void testTableWithInt96Timestamp() throws IOException { + File parquetTableDir = temp.resolve("table_timestamp_int96").toFile(); + String parquetTableLocation = parquetTableDir.toURI().toString(); + Schema schema = + new Schema( + optional(1, "id", Types.LongType.get()), + optional(2, "tmp_col", Types.TimestampType.withZone())); + spark.conf().set(SQLConf.PARQUET_OUTPUT_TIMESTAMP_TYPE().key(), "INT96"); + + LocalDateTime start = LocalDateTime.of(2000, 1, 31, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2100, 1, 1, 0, 0, 0); + long startSec = start.toEpochSecond(ZoneOffset.UTC); + long endSec = end.toEpochSecond(ZoneOffset.UTC); + Column idColumn = functions.expr("id"); + Column secondsColumn = + functions.expr("(id % " + (endSec - startSec) + " + " + startSec + ")").as("seconds"); + Column timestampColumn = functions.expr("cast( seconds as timestamp) as tmp_col"); + + for (Boolean useDict : new Boolean[] {true, false}) { + for (Boolean useVectorization : new Boolean[] {true, false}) { + spark.sql("DROP TABLE IF EXISTS parquet_table"); + spark + .range(0, 5000, 100, 1) + .select(idColumn, secondsColumn) + .select(idColumn, timestampColumn) + .write() + .format("parquet") + .option("parquet.enable.dictionary", useDict) + .mode("overwrite") + .option("path", parquetTableLocation) + .saveAsTable("parquet_table"); + TableIdentifier tableIdentifier = TableIdentifier.of("db", "table_with_timestamp_int96"); + Table table = createTable(tableIdentifier, schema, PartitionSpec.unpartitioned()); + table + .updateProperties() + .set(TableProperties.PARQUET_VECTORIZATION_ENABLED, useVectorization.toString()) + .commit(); + + String stagingLocation = table.location() + "/metadata"; + SparkTableUtil.importSparkTable( + spark, + new org.apache.spark.sql.catalyst.TableIdentifier("parquet_table"), + table, + stagingLocation); + + // validate we get the expected results back + testWithFilter("tmp_col < to_timestamp('2000-01-31 08:30:00')", tableIdentifier); + testWithFilter("tmp_col <= to_timestamp('2000-01-31 08:30:00')", tableIdentifier); + testWithFilter("tmp_col == to_timestamp('2000-01-31 08:30:00')", tableIdentifier); + testWithFilter("tmp_col > to_timestamp('2000-01-31 08:30:00')", tableIdentifier); + testWithFilter("tmp_col >= to_timestamp('2000-01-31 08:30:00')", tableIdentifier); + dropTable(tableIdentifier); + } + } + } + + @Test + public void testImportSparkTableWithMissingFilesFailure() throws IOException { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "missing_files_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + + File parquetTableDir = temp.resolve("table_missing_files").toFile(); + String parquetTableLocation = parquetTableDir.toURI().toString(); + spark.sql( + String.format( + "CREATE TABLE parquet_table (data string, id int) " + + "USING parquet PARTITIONED BY (id) LOCATION '%s'", + parquetTableLocation)); + + List records = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + + Dataset inputDF = spark.createDataFrame(records, SimpleRecord.class); + inputDF.write().mode("overwrite").insertInto("parquet_table"); + + // Add a Spark partition of which location is missing + spark.sql("ALTER TABLE parquet_table ADD PARTITION (id = 1234)"); + Path partitionLocationPath = parquetTableDir.toPath().resolve("id=1234"); + java.nio.file.Files.delete(partitionLocationPath); + + String stagingLocation = table.location() + "/metadata"; + + assertThatThrownBy( + () -> + SparkTableUtil.importSparkTable( + spark, + new org.apache.spark.sql.catalyst.TableIdentifier("parquet_table"), + table, + stagingLocation)) + .hasMessageContaining( + "Unable to list files in partition: " + partitionLocationPath.toFile().toURI()) + .isInstanceOf(SparkException.class) + .hasRootCauseInstanceOf(FileNotFoundException.class); + } + + @Test + public void testImportSparkTableWithIgnoreMissingFilesEnabled() throws IOException { + TableIdentifier tableIdentifier = TableIdentifier.of("db", "missing_files_test"); + Table table = createTable(tableIdentifier, SCHEMA, SPEC); + + File parquetTableDir = temp.resolve("table_missing_files").toFile(); + String parquetTableLocation = parquetTableDir.toURI().toString(); + spark.sql( + String.format( + "CREATE TABLE parquet_table (data string, id int) " + + "USING parquet PARTITIONED BY (id) LOCATION '%s'", + parquetTableLocation)); + + List records = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + + Dataset inputDF = spark.createDataFrame(records, SimpleRecord.class); + inputDF.write().mode("overwrite").insertInto("parquet_table"); + + // Add a Spark partition of which location is missing + spark.sql("ALTER TABLE parquet_table ADD PARTITION (id = 1234)"); + Path partitionLocationPath = parquetTableDir.toPath().resolve("id=1234"); + java.nio.file.Files.delete(partitionLocationPath); + + String stagingLocation = table.location() + "/metadata"; + + SparkTableUtil.importSparkTable( + spark, + new org.apache.spark.sql.catalyst.TableIdentifier("parquet_table"), + table, + stagingLocation, + Collections.emptyMap(), + false, + true, + SparkTableUtil.migrationService(1)); + + List partitionsTableRows = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier, "partitions")) + .collectAsList(); + assertThat(partitionsTableRows).as("Partitions table should have 2 rows").hasSize(2); + } + + private void testWithFilter(String filterExpr, TableIdentifier tableIdentifier) { + List expected = + spark.table("parquet_table").select("tmp_col").filter(filterExpr).collectAsList(); + List actual = + spark + .read() + .format("iceberg") + .load(loadLocation(tableIdentifier)) + .select("tmp_col") + .filter(filterExpr) + .collectAsList(); + assertThat(actual).as("Rows must match").containsExactlyInAnyOrderElementsOf(expected); + } + + @Test + public void testSessionConfigSupport() { + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + TableIdentifier tableIdentifier = TableIdentifier.of("db", "session_config_table"); + Table table = createTable(tableIdentifier, SCHEMA, spec); + + List initialRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(initialRecords, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + + long s1 = table.currentSnapshot().snapshotId(); + + withSQLConf( + // set write option through session configuration + ImmutableMap.of("spark.datasource.iceberg.snapshot-property.foo", "bar"), + () -> { + df.select("id", "data") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(loadLocation(tableIdentifier)); + }); + + table.refresh(); + assertThat(table.currentSnapshot().summary()).containsEntry("foo", "bar"); + + withSQLConf( + // set read option through session configuration + ImmutableMap.of("spark.datasource.iceberg.versionAsOf", String.valueOf(s1)), + () -> { + Dataset result = spark.read().format("iceberg").load(loadLocation(tableIdentifier)); + List actual = result.as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual) + .as("Rows must match") + .containsExactlyInAnyOrderElementsOf(initialRecords); + }); + } + + private GenericData.Record manifestRecord( + Table manifestTable, Long referenceSnapshotId, ManifestFile manifest) { + GenericRecordBuilder builder = + new GenericRecordBuilder(AvroSchemaUtil.convert(manifestTable.schema(), "manifests")); + GenericRecordBuilder summaryBuilder = + new GenericRecordBuilder( + AvroSchemaUtil.convert( + manifestTable.schema().findType("partition_summaries.element").asStructType(), + "partition_summary")); + return builder + .set("content", manifest.content().id()) + .set("path", manifest.path()) + .set("length", manifest.length()) + .set("partition_spec_id", manifest.partitionSpecId()) + .set("added_snapshot_id", manifest.snapshotId()) + .set("added_data_files_count", manifest.content() == DATA ? manifest.addedFilesCount() : 0) + .set( + "existing_data_files_count", + manifest.content() == DATA ? manifest.existingFilesCount() : 0) + .set( + "deleted_data_files_count", + manifest.content() == DATA ? manifest.deletedFilesCount() : 0) + .set( + "added_delete_files_count", + manifest.content() == DELETES ? manifest.addedFilesCount() : 0) + .set( + "existing_delete_files_count", + manifest.content() == DELETES ? manifest.existingFilesCount() : 0) + .set( + "deleted_delete_files_count", + manifest.content() == DELETES ? manifest.deletedFilesCount() : 0) + .set( + "partition_summaries", + Lists.transform( + manifest.partitions(), + partition -> + summaryBuilder + .set("contains_null", false) + .set("contains_nan", false) + .set("lower_bound", "1") + .set("upper_bound", "1") + .build())) + .set("reference_snapshot_id", referenceSnapshotId) + .build(); + } + + private PositionDeleteWriter newPositionDeleteWriter( + Table table, PartitionSpec spec, StructLike partition) { + OutputFileFactory fileFactory = OutputFileFactory.builderFor(table, 0, 0).build(); + EncryptedOutputFile outputFile = fileFactory.newOutputFile(spec, partition); + + SparkFileWriterFactory fileWriterFactory = SparkFileWriterFactory.builderFor(table).build(); + return fileWriterFactory.newPositionDeleteWriter(outputFile, spec, partition); + } + + private DeleteFile writePositionDeletes( + Table table, + PartitionSpec spec, + StructLike partition, + Iterable> deletes) { + PositionDeleteWriter positionDeleteWriter = + newPositionDeleteWriter(table, spec, partition); + + try (PositionDeleteWriter writer = positionDeleteWriter) { + for (PositionDelete delete : deletes) { + writer.write(delete); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + return positionDeleteWriter.toDeleteFile(); + } + + private DeleteFile writePosDeleteFile(Table table) { + return writePosDeleteFile(table, 0L); + } + + private DeleteFile writePosDeleteFile(Table table, long pos) { + DataFile dataFile = + Iterables.getFirst(SnapshotChanges.builderFor(table).build().addedDataFiles(), null); + PartitionSpec dataFileSpec = table.specs().get(dataFile.specId()); + StructLike dataFilePartition = dataFile.partition(); + + PositionDelete delete = PositionDelete.create(); + delete.set(dataFile.location(), pos, null); + + return writePositionDeletes(table, dataFileSpec, dataFilePartition, ImmutableList.of(delete)); + } + + private DeleteFile writeEqDeleteFile(Table table, String dataValue) { + List deletes = Lists.newArrayList(); + Schema deleteRowSchema = SCHEMA.select("data"); + Record delete = GenericRecord.create(deleteRowSchema); + deletes.add(delete.copy("data", dataValue)); + try { + return FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + org.apache.iceberg.TestHelpers.Row.of(1), + deletes, + deleteRowSchema); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private long totalSizeInBytes(Iterable dataFiles) { + return Lists.newArrayList(dataFiles).stream().mapToLong(DataFile::fileSizeInBytes).sum(); + } + + private void assertDataFilePartitions( + List dataFiles, List expectedPartitionIds) { + assertThat(dataFiles) + .as("Table should have " + expectedPartitionIds.size() + " data files") + .hasSameSizeAs(expectedPartitionIds); + + for (int i = 0; i < dataFiles.size(); ++i) { + assertThat(dataFiles.get(i).partition().get(0, Integer.class).intValue()) + .as("Data file should have partition of id " + expectedPartitionIds.get(i)) + .isEqualTo(expectedPartitionIds.get(i).intValue()); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java new file mode 100644 index 000000000000..a637b975fe2b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIcebergSpark.java @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.sql.Date; +import java.sql.Timestamp; +import java.util.List; +import org.apache.iceberg.spark.IcebergSpark; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.transforms.Transforms; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.util.DateTimeUtils; +import org.apache.spark.sql.types.CharType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.DecimalType; +import org.apache.spark.sql.types.VarcharType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class TestIcebergSpark { + + private static SparkSession spark = null; + + @BeforeAll + public static void startSpark() { + TestIcebergSpark.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestIcebergSpark.spark; + TestIcebergSpark.spark = null; + currentSpark.stop(); + } + + @Test + public void testRegisterIntegerBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_int_16", DataTypes.IntegerType, 16); + List results = spark.sql("SELECT iceberg_bucket_int_16(1)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.bucket(16).bind(Types.IntegerType.get()).apply(1))); + } + + @Test + public void testRegisterShortBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_short_16", DataTypes.ShortType, 16); + List results = spark.sql("SELECT iceberg_bucket_short_16(1S)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.bucket(16).bind(Types.IntegerType.get()).apply(1))); + } + + @Test + public void testRegisterByteBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_byte_16", DataTypes.ByteType, 16); + List results = spark.sql("SELECT iceberg_bucket_byte_16(1Y)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.bucket(16).bind(Types.IntegerType.get()).apply(1))); + } + + @Test + public void testRegisterLongBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_long_16", DataTypes.LongType, 16); + List results = spark.sql("SELECT iceberg_bucket_long_16(1L)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.bucket(16).bind(Types.LongType.get()).apply(1L))); + } + + @Test + public void testRegisterStringBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_string_16", DataTypes.StringType, 16); + List results = spark.sql("SELECT iceberg_bucket_string_16('hello')").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.bucket(16).bind(Types.StringType.get()).apply("hello"))); + } + + @Test + public void testRegisterCharBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_char_16", new CharType(5), 16); + List results = spark.sql("SELECT iceberg_bucket_char_16('hello')").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.bucket(16).bind(Types.StringType.get()).apply("hello"))); + } + + @Test + public void testRegisterVarCharBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_varchar_16", new VarcharType(5), 16); + List results = spark.sql("SELECT iceberg_bucket_varchar_16('hello')").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.bucket(16).bind(Types.StringType.get()).apply("hello"))); + } + + @Test + public void testRegisterDateBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_date_16", DataTypes.DateType, 16); + List results = + spark.sql("SELECT iceberg_bucket_date_16(DATE '2021-06-30')").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo( + Transforms.bucket(16) + .bind(Types.DateType.get()) + .apply(DateTimeUtils.fromJavaDate(Date.valueOf("2021-06-30"))))); + } + + @Test + public void testRegisterTimestampBucketUDF() { + IcebergSpark.registerBucketUDF( + spark, "iceberg_bucket_timestamp_16", DataTypes.TimestampType, 16); + List results = + spark + .sql("SELECT iceberg_bucket_timestamp_16(TIMESTAMP '2021-06-30 00:00:00.000')") + .collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo( + Transforms.bucket(16) + .bind(Types.TimestampType.withZone()) + .apply( + DateTimeUtils.fromJavaTimestamp( + Timestamp.valueOf("2021-06-30 00:00:00.000"))))); + } + + @Test + public void testRegisterBinaryBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_binary_16", DataTypes.BinaryType, 16); + List results = spark.sql("SELECT iceberg_bucket_binary_16(X'0020001F')").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo( + Transforms.bucket(16) + .bind(Types.BinaryType.get()) + .apply(ByteBuffer.wrap(new byte[] {0x00, 0x20, 0x00, 0x1F})))); + } + + @Test + public void testRegisterDecimalBucketUDF() { + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket_decimal_16", new DecimalType(4, 2), 16); + List results = spark.sql("SELECT iceberg_bucket_decimal_16(11.11)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo( + Transforms.bucket(16) + .bind(Types.DecimalType.of(4, 2)) + .apply(new BigDecimal("11.11")))); + } + + @Test + public void testRegisterBooleanBucketUDF() { + assertThatThrownBy( + () -> + IcebergSpark.registerBucketUDF( + spark, "iceberg_bucket_boolean_16", DataTypes.BooleanType, 16)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot bucket by type: boolean"); + } + + @Test + public void testRegisterDoubleBucketUDF() { + assertThatThrownBy( + () -> + IcebergSpark.registerBucketUDF( + spark, "iceberg_bucket_double_16", DataTypes.DoubleType, 16)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot bucket by type: double"); + } + + @Test + public void testRegisterFloatBucketUDF() { + assertThatThrownBy( + () -> + IcebergSpark.registerBucketUDF( + spark, "iceberg_bucket_float_16", DataTypes.FloatType, 16)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot bucket by type: float"); + } + + @Test + public void testRegisterIntegerTruncateUDF() { + IcebergSpark.registerTruncateUDF(spark, "iceberg_truncate_int_4", DataTypes.IntegerType, 4); + List results = spark.sql("SELECT iceberg_truncate_int_4(1)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getInt(0)) + .isEqualTo(Transforms.truncate(4).bind(Types.IntegerType.get()).apply(1))); + } + + @Test + public void testRegisterLongTruncateUDF() { + IcebergSpark.registerTruncateUDF(spark, "iceberg_truncate_long_4", DataTypes.LongType, 4); + List results = spark.sql("SELECT iceberg_truncate_long_4(1L)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getLong(0)) + .isEqualTo(Transforms.truncate(4).bind(Types.LongType.get()).apply(1L))); + } + + @Test + public void testRegisterDecimalTruncateUDF() { + IcebergSpark.registerTruncateUDF(spark, "iceberg_truncate_decimal_4", new DecimalType(4, 2), 4); + List results = spark.sql("SELECT iceberg_truncate_decimal_4(11.11)").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getDecimal(0)) + .isEqualTo( + Transforms.truncate(4) + .bind(Types.DecimalType.of(4, 2)) + .apply(new BigDecimal("11.11")))); + } + + @Test + public void testRegisterStringTruncateUDF() { + IcebergSpark.registerTruncateUDF(spark, "iceberg_truncate_string_4", DataTypes.StringType, 4); + List results = spark.sql("SELECT iceberg_truncate_string_4('hello')").collectAsList(); + assertThat(results) + .singleElement() + .satisfies( + row -> + assertThat(row.getString(0)) + .isEqualTo(Transforms.truncate(4).bind(Types.StringType.get()).apply("hello"))); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIdentityPartitionData.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIdentityPartitionData.java new file mode 100644 index 000000000000..bf3bcacbfbe9 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestIdentityPartitionData.java @@ -0,0 +1,251 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkTableUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.TableIdentifier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestIdentityPartitionData extends TestBase { + private static final Configuration CONF = new Configuration(); + private static final HadoopTables TABLES = new HadoopTables(CONF); + + @Parameters(name = "format = {0}, vectorized = {1}, properties = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + FileFormat.PARQUET, + false, + ImmutableMap.of( + TableProperties.DEFAULT_FILE_FORMAT, "parquet", + TableProperties.DATA_PLANNING_MODE, LOCAL.modeName(), + TableProperties.DELETE_PLANNING_MODE, LOCAL.modeName()) + }, + { + FileFormat.PARQUET, + true, + ImmutableMap.of( + TableProperties.DEFAULT_FILE_FORMAT, "parquet", + TableProperties.DATA_PLANNING_MODE, DISTRIBUTED.modeName(), + TableProperties.DELETE_PLANNING_MODE, DISTRIBUTED.modeName()) + }, + { + FileFormat.AVRO, + false, + ImmutableMap.of( + TableProperties.DEFAULT_FILE_FORMAT, "avro", + TableProperties.DATA_PLANNING_MODE, LOCAL.modeName(), + TableProperties.DELETE_PLANNING_MODE, LOCAL.modeName()) + }, + { + FileFormat.ORC, + false, + ImmutableMap.of( + TableProperties.DEFAULT_FILE_FORMAT, "orc", + TableProperties.DATA_PLANNING_MODE, DISTRIBUTED.modeName(), + TableProperties.DELETE_PLANNING_MODE, DISTRIBUTED.modeName()) + }, + { + FileFormat.ORC, + true, + ImmutableMap.of( + TableProperties.DEFAULT_FILE_FORMAT, "orc", + TableProperties.DATA_PLANNING_MODE, LOCAL.modeName(), + TableProperties.DELETE_PLANNING_MODE, LOCAL.modeName()) + }, + }; + } + + @Parameter(index = 0) + private FileFormat format; + + @Parameter(index = 1) + private boolean vectorized; + + @Parameter(index = 2) + private Map properties; + + private static final Schema LOG_SCHEMA = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "date", Types.StringType.get()), + Types.NestedField.optional(3, "level", Types.StringType.get()), + Types.NestedField.optional(4, "message", Types.StringType.get())); + + private static final List LOGS = + ImmutableList.of( + LogMessage.debug("2020-02-02", "debug event 1"), + LogMessage.info("2020-02-02", "info event 1"), + LogMessage.debug("2020-02-02", "debug event 2"), + LogMessage.info("2020-02-03", "info event 2"), + LogMessage.debug("2020-02-03", "debug event 3"), + LogMessage.info("2020-02-03", "info event 3"), + LogMessage.error("2020-02-03", "error event 1"), + LogMessage.debug("2020-02-04", "debug event 4"), + LogMessage.warn("2020-02-04", "warn event 1"), + LogMessage.debug("2020-02-04", "debug event 5")); + + @TempDir private Path temp; + + private final PartitionSpec spec = + PartitionSpec.builderFor(LOG_SCHEMA).identity("date").identity("level").build(); + private Table table = null; + private Dataset logs = null; + + /** + * Use the Hive Based table to make Identity Partition Columns with no duplication of the data in + * the underlying parquet files. This makes sure that if the identity mapping fails, the test will + * also fail. + */ + private void setupParquet() throws Exception { + File location = Files.createTempDirectory(temp, "logs").toFile(); + File hiveLocation = Files.createTempDirectory(temp, "hive").toFile(); + String hiveTable = "hivetable"; + assertThat(location).as("Temp folder should exist").exists(); + + this.logs = + spark.createDataFrame(LOGS, LogMessage.class).select("id", "date", "level", "message"); + spark.sql(String.format("DROP TABLE IF EXISTS %s", hiveTable)); + logs.orderBy("date", "level", "id") + .write() + .partitionBy("date", "level") + .format("parquet") + .option("path", hiveLocation.toString()) + .saveAsTable(hiveTable); + + this.table = + TABLES.create( + SparkSchemaUtil.schemaForTable(spark, hiveTable), + SparkSchemaUtil.specForTable(spark, hiveTable), + properties, + location.toString()); + + SparkTableUtil.importSparkTable( + spark, new TableIdentifier(hiveTable), table, location.toString()); + } + + @BeforeEach + public void setupTable() throws Exception { + if (format.equals(FileFormat.PARQUET)) { + setupParquet(); + } else { + File location = Files.createTempDirectory(temp, "logs").toFile(); + assertThat(location).as("Temp folder should exist").exists(); + + this.table = TABLES.create(LOG_SCHEMA, spec, properties, location.toString()); + this.logs = + spark.createDataFrame(LOGS, LogMessage.class).select("id", "date", "level", "message"); + + logs.orderBy("date", "level", "id") + .write() + .format("iceberg") + .mode("append") + .save(location.toString()); + } + } + + @TestTemplate + public void testFullProjection() { + List expected = logs.orderBy("id").collectAsList(); + List actual = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(table.location()) + .orderBy("id") + .select("id", "date", "level", "message") + .collectAsList(); + assertThat(actual).as("Rows should match").isEqualTo(expected); + } + + @TestTemplate + public void testProjections() { + String[][] cases = + new String[][] { + // individual fields + new String[] {"date"}, + new String[] {"level"}, + new String[] {"message"}, + // field pairs + new String[] {"date", "message"}, + new String[] {"level", "message"}, + new String[] {"date", "level"}, + // out-of-order pairs + new String[] {"message", "date"}, + new String[] {"message", "level"}, + new String[] {"level", "date"}, + // full projection, different orderings + new String[] {"date", "level", "message"}, + new String[] {"level", "date", "message"}, + new String[] {"date", "message", "level"}, + new String[] {"level", "message", "date"}, + new String[] {"message", "date", "level"}, + new String[] {"message", "level", "date"} + }; + + for (String[] ordering : cases) { + List expected = logs.select("id", ordering).orderBy("id").collectAsList(); + List actual = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(table.location()) + .select("id", ordering) + .orderBy("id") + .collectAsList(); + assertThat(actual) + .as("Rows should match for ordering: " + Arrays.toString(ordering)) + .isEqualTo(expected); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java new file mode 100644 index 000000000000..63fef0f2e37a --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestInternalRowWrapper.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Iterator; +import org.apache.iceberg.RecordWrapperTestBase; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.data.InternalRecordWrapper; +import org.apache.iceberg.data.RandomGenericData; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.util.StructLikeWrapper; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.Disabled; + +public class TestInternalRowWrapper extends RecordWrapperTestBase { + + @Disabled + @Override + public void testTimestampWithoutZone() { + // Spark does not support timestamp without zone. + } + + @Disabled + @Override + public void testTime() { + // Spark does not support time fields. + } + + @Disabled + @Override + public void testTimestampNanoWithoutZone() { + // Spark does not support nanosecond timestamp without zone. + } + + @Disabled + @Override + public void testTimestampNanoWithZone() { + // Spark does not support nanosecond timestamp with zone. + } + + @Override + protected void generateAndValidate(Schema schema, AssertMethod assertMethod) { + int numRecords = 100; + Iterable recordList = RandomGenericData.generate(schema, numRecords, 101L); + Iterable rowList = RandomData.generateSpark(schema, numRecords, 101L); + + InternalRecordWrapper recordWrapper = new InternalRecordWrapper(schema.asStruct()); + InternalRowWrapper rowWrapper = + new InternalRowWrapper(SparkSchemaUtil.convert(schema), schema.asStruct()); + + Iterator actual = recordList.iterator(); + Iterator expected = rowList.iterator(); + + StructLikeWrapper actualWrapper = StructLikeWrapper.forType(schema.asStruct()); + StructLikeWrapper expectedWrapper = StructLikeWrapper.forType(schema.asStruct()); + for (int i = 0; i < numRecords; i++) { + assertThat(actual).as("Should have more records").hasNext(); + assertThat(expected).as("Should have more InternalRow").hasNext(); + + StructLike recordStructLike = recordWrapper.wrap(actual.next()); + StructLike rowStructLike = rowWrapper.wrap(expected.next()); + + assertMethod.assertEquals( + "Should have expected StructLike values", + actualWrapper.set(recordStructLike), + expectedWrapper.set(rowStructLike)); + } + + assertThat(actual).as("Shouldn't have more record").isExhausted(); + assertThat(expected).as("Shouldn't have more InternalRow").isExhausted(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java new file mode 100644 index 000000000000..f74446b2f416 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTableReadableMetrics.java @@ -0,0 +1,381 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.Files; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMetadataTableReadableMetrics extends TestBaseWithCatalog { + + private static final Types.StructType LEAF_STRUCT_TYPE = + Types.StructType.of( + optional(1, "leafLongCol", Types.LongType.get()), + optional(2, "leafDoubleCol", Types.DoubleType.get())); + + private static final Types.StructType NESTED_STRUCT_TYPE = + Types.StructType.of(required(3, "leafStructCol", LEAF_STRUCT_TYPE)); + + private static final Schema NESTED_SCHEMA = + new Schema(required(4, "nestedStructCol", NESTED_STRUCT_TYPE)); + + private static final Schema PRIMITIVE_SCHEMA = + new Schema( + required(1, "booleanCol", Types.BooleanType.get()), + required(2, "intCol", Types.IntegerType.get()), + required(3, "longCol", Types.LongType.get()), + required(4, "floatCol", Types.FloatType.get()), + required(5, "doubleCol", Types.DoubleType.get()), + optional(6, "decimalCol", Types.DecimalType.of(10, 2)), + optional(7, "stringCol", Types.StringType.get()), + optional(8, "fixedCol", Types.FixedType.ofLength(3)), + optional(9, "binaryCol", Types.BinaryType.get())); + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + // only SparkCatalog supports metadata table sql queries + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties() + }, + }; + } + + protected String tableName() { + return tableName.split("\\.")[2]; + } + + protected String database() { + return tableName.split("\\.")[1]; + } + + private Table createPrimitiveTable() throws IOException { + Table table = + catalog.createTable( + TableIdentifier.of(Namespace.of(database()), tableName()), + PRIMITIVE_SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of()); + List records = + Lists.newArrayList( + createPrimitiveRecord( + false, + 1, + 1L, + 0, + 1.0D, + new BigDecimal("1.00"), + "1", + Base64.getDecoder().decode("1111"), + ByteBuffer.wrap(Base64.getDecoder().decode("1111"))), + createPrimitiveRecord( + true, + 2, + 2L, + 0, + 2.0D, + new BigDecimal("2.00"), + "2", + Base64.getDecoder().decode("2222"), + ByteBuffer.wrap(Base64.getDecoder().decode("2222"))), + createPrimitiveRecord(false, 1, 1, Float.NaN, Double.NaN, null, "1", null, null), + createPrimitiveRecord( + false, 2, 2L, Float.NaN, 2.0D, new BigDecimal("2.00"), "2", null, null)); + + DataFile dataFile = FileHelpers.writeDataFile(table, Files.localOutput(temp.toFile()), records); + table.newAppend().appendFile(dataFile).commit(); + return table; + } + + private Pair createNestedTable() throws IOException { + Table table = + catalog.createTable( + TableIdentifier.of(Namespace.of(database()), tableName()), + NESTED_SCHEMA, + PartitionSpec.unpartitioned(), + ImmutableMap.of()); + + List records = + Lists.newArrayList( + createNestedRecord(0L, 0.0), + createNestedRecord(1L, Double.NaN), + createNestedRecord(null, null)); + DataFile dataFile = FileHelpers.writeDataFile(table, Files.localOutput(temp.toFile()), records); + table.newAppend().appendFile(dataFile).commit(); + return Pair.of(table, dataFile); + } + + @AfterEach + public void dropTable() { + sql("DROP TABLE %s", tableName); + } + + private Dataset filesDf() { + return spark.read().format("iceberg").load(database() + "." + tableName() + ".files"); + } + + protected GenericRecord createPrimitiveRecord( + boolean booleanCol, + int intCol, + long longCol, + float floatCol, + double doubleCol, + BigDecimal decimalCol, + String stringCol, + byte[] fixedCol, + ByteBuffer binaryCol) { + GenericRecord record = GenericRecord.create(PRIMITIVE_SCHEMA); + record.set(0, booleanCol); + record.set(1, intCol); + record.set(2, longCol); + record.set(3, floatCol); + record.set(4, doubleCol); + record.set(5, decimalCol); + record.set(6, stringCol); + record.set(7, fixedCol); + record.set(8, binaryCol); + return record; + } + + private GenericRecord createNestedRecord(Long longCol, Double doubleCol) { + GenericRecord record = GenericRecord.create(NESTED_SCHEMA); + GenericRecord nested = GenericRecord.create(NESTED_STRUCT_TYPE); + GenericRecord leaf = GenericRecord.create(LEAF_STRUCT_TYPE); + leaf.set(0, longCol); + leaf.set(1, doubleCol); + nested.set(0, leaf); + record.set(0, nested); + return record; + } + + @TestTemplate + public void testPrimitiveColumns() throws Exception { + Table table = createPrimitiveTable(); + DataFile dataFile = + SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); + Map columnSizeStats = dataFile.columnSizes(); + + Object[] binaryCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("binaryCol").fieldId()), + 4L, + 2L, + null, + Base64.getDecoder().decode("1111"), + Base64.getDecoder().decode("2222")); + Object[] booleanCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("booleanCol").fieldId()), + 4L, + 0L, + null, + false, + true); + Object[] decimalCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("decimalCol").fieldId()), + 4L, + 1L, + null, + new BigDecimal("1.00"), + new BigDecimal("2.00")); + Object[] doubleCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("doubleCol").fieldId()), + 4L, + 0L, + 1L, + 1.0D, + 2.0D); + Object[] fixedCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("fixedCol").fieldId()), + 4L, + 2L, + null, + Base64.getDecoder().decode("1111"), + Base64.getDecoder().decode("2222")); + Object[] floatCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("floatCol").fieldId()), + 4L, + 0L, + 2L, + 0f, + 0f); + Object[] intCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("intCol").fieldId()), + 4L, + 0L, + null, + 1, + 2); + Object[] longCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("longCol").fieldId()), + 4L, + 0L, + null, + 1L, + 2L); + Object[] stringCol = + row( + columnSizeStats.get(PRIMITIVE_SCHEMA.findField("stringCol").fieldId()), + 4L, + 0L, + null, + "1", + "2"); + + Object[] metrics = + row( + binaryCol, + booleanCol, + decimalCol, + doubleCol, + fixedCol, + floatCol, + intCol, + longCol, + stringCol); + + List expected = ImmutableList.of(new Object[] {metrics}); + String sql = "SELECT readable_metrics FROM %s.%s"; + List filesReadableMetrics = sql(String.format(sql, tableName, "files")); + List entriesReadableMetrics = sql(String.format(sql, tableName, "entries")); + assertEquals("Row should match for files table", expected, filesReadableMetrics); + assertEquals("Row should match for entries table", expected, entriesReadableMetrics); + } + + @TestTemplate + public void testSelectPrimitiveValues() throws Exception { + createPrimitiveTable(); + + List expected = ImmutableList.of(row(1, true)); + String sql = + "SELECT readable_metrics.intCol.lower_bound, readable_metrics.booleanCol.upper_bound FROM %s.%s"; + List filesReadableMetrics = sql(String.format(sql, tableName, "files")); + List entriesReadableMetrics = sql(String.format(sql, tableName, "entries")); + assertEquals( + "select of primitive readable_metrics fields should work for files table", + expected, + filesReadableMetrics); + assertEquals( + "select of primitive readable_metrics fields should work for entries table", + expected, + entriesReadableMetrics); + + assertEquals( + "mixed select of readable_metrics and other field should work", + ImmutableList.of(row(0, 4L)), + sql("SELECT content, readable_metrics.longCol.value_count FROM %s.files", tableName)); + + assertEquals( + "mixed select of readable_metrics and other field should work, in the other order", + ImmutableList.of(row(4L, 0)), + sql("SELECT readable_metrics.longCol.value_count, content FROM %s.files", tableName)); + + assertEquals( + "mixed select of readable_metrics and other field should work for entries table", + ImmutableList.of(row(1, 4L)), + sql("SELECT status, readable_metrics.longCol.value_count FROM %s.entries", tableName)); + + assertEquals( + "mixed select of readable_metrics and other field should work, in the other order for entries table", + ImmutableList.of(row(4L, 1)), + sql("SELECT readable_metrics.longCol.value_count, status FROM %s.entries", tableName)); + } + + @TestTemplate + public void testSelectNestedValues() throws Exception { + createNestedTable(); + + List expected = ImmutableList.of(row(0L, 3L)); + String sql = + "SELECT readable_metrics.`nestedStructCol.leafStructCol.leafLongCol`.lower_bound, " + + "readable_metrics.`nestedStructCol.leafStructCol.leafDoubleCol`.value_count FROM %s.%s"; + List filesReadableMetrics = sql(String.format(sql, tableName, "files")); + List entriesReadableMetrics = sql(String.format(sql, tableName, "entries")); + + assertEquals( + "select of nested readable_metrics fields should work for files table", + expected, + filesReadableMetrics); + assertEquals( + "select of nested readable_metrics fields should work for entries table", + expected, + entriesReadableMetrics); + } + + @TestTemplate + public void testNestedValues() throws Exception { + Pair table = createNestedTable(); + int longColId = + table.first().schema().findField("nestedStructCol.leafStructCol.leafLongCol").fieldId(); + int doubleColId = + table.first().schema().findField("nestedStructCol.leafStructCol.leafDoubleCol").fieldId(); + + Object[] leafDoubleCol = + row(table.second().columnSizes().get(doubleColId), 3L, 1L, 1L, 0.0D, 0.0D); + Object[] leafLongCol = row(table.second().columnSizes().get(longColId), 3L, 1L, null, 0L, 1L); + Object[] metrics = row(leafDoubleCol, leafLongCol); + + List expected = ImmutableList.of(new Object[] {metrics}); + String sql = "SELECT readable_metrics FROM %s.%s"; + List filesReadableMetrics = sql(String.format(sql, tableName, "files")); + List entriesReadableMetrics = sql(String.format(sql, tableName, "entries")); + assertEquals("Row should match for files table", expected, filesReadableMetrics); + assertEquals("Row should match for entries table", expected, entriesReadableMetrics); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTablesWithPartitionEvolution.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTablesWithPartitionEvolution.java new file mode 100644 index 000000000000..a417454b45dc --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMetadataTablesWithPartitionEvolution.java @@ -0,0 +1,725 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.FileFormat.AVRO; +import static org.apache.iceberg.FileFormat.ORC; +import static org.apache.iceberg.FileFormat.PARQUET; +import static org.apache.iceberg.MetadataTableType.ALL_DATA_FILES; +import static org.apache.iceberg.MetadataTableType.ALL_ENTRIES; +import static org.apache.iceberg.MetadataTableType.ENTRIES; +import static org.apache.iceberg.MetadataTableType.FILES; +import static org.apache.iceberg.MetadataTableType.PARTITIONS; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.FORMAT_VERSION; +import static org.apache.iceberg.TableProperties.MANIFEST_MERGE_ENABLED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.Arrays; +import java.util.List; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.parser.ParseException; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMetadataTablesWithPartitionEvolution extends CatalogTestBase { + + @Parameters(name = "catalog = {0}, impl = {1}, conf = {2}, fileFormat = {3}, formatVersion = {4}") + public static Object[][] parameters() { + return new Object[][] { + { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default"), + ORC, + 1 + }, + { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default"), + ORC, + 2 + }, + {"testhadoop", SparkCatalog.class.getName(), ImmutableMap.of("type", "hadoop"), PARQUET, 1}, + {"testhadoop", SparkCatalog.class.getName(), ImmutableMap.of("type", "hadoop"), PARQUET, 2}, + { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "clients", "1", + "parquet-enabled", "false", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + ), + AVRO, + 1 + }, + { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "clients", "1", + "parquet-enabled", "false", + "cache-enabled", + "false" // Spark will delete tables using v1, leaving the cache out of sync + ), + AVRO, + 2 + } + }; + } + + @Parameter(index = 3) + private FileFormat fileFormat; + + @Parameter(index = 4) + private int formatVersion; + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testFilesMetadataTable() throws ParseException { + createTable("id bigint NOT NULL, category string, data string"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables while the current spec is still unpartitioned + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + Dataset df = loadMetadataTable(tableType); + assertThat(df.schema().getFieldIndex("partition").isEmpty()) + .as("Partition must be skipped") + .isTrue(); + } + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateSpec().addField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables after adding the first partition column + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row(new Object[] {null}), row("b1")), "STRUCT", tableType); + } + + table.updateSpec().addField(Expressions.bucket("category", 8)).commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables after adding the second partition column + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row(null, null), row("b1", null), row("b1", 2)), + "STRUCT", + tableType); + } + + table.updateSpec().removeField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables after dropping the first partition column + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row(null, null), row(null, 2), row("b1", null), row("b1", 2)), + "STRUCT", + tableType); + } + + table.updateSpec().renameField("category_bucket_8", "category_bucket_8_another_name").commit(); + sql("REFRESH TABLE %s", tableName); + + // verify the metadata tables after renaming the second partition column + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row(null, null), row(null, 2), row("b1", null), row("b1", 2)), + "STRUCT", + tableType); + } + } + + @TestTemplate + public void testFilesMetadataTableFilter() throws ParseException { + createTable("id bigint NOT NULL, category string, data string"); + sql("ALTER TABLE %s SET TBLPROPERTIES ('%s' 'false')", tableName, MANIFEST_MERGE_ENABLED); + + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // verify the metadata tables while the current spec is still unpartitioned + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + Dataset df = loadMetadataTable(tableType); + assertThat(df.schema().getFieldIndex("partition").isEmpty()) + .as("Partition must be skipped") + .isTrue(); + } + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateSpec().addField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // verify the metadata tables after adding the first partition column + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row("d2")), "STRUCT", tableType, "partition.data = 'd2'"); + } + + table.updateSpec().addField("category").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // verify the metadata tables after adding the second partition column + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row("d2", null), row("d2", "c2")), + "STRUCT", + tableType, + "partition.data = 'd2'"); + } + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row("d2", "c2")), + "STRUCT", + tableType, + "partition.category = 'c2'"); + } + + table.updateSpec().removeField("data").commit(); + sql("REFRESH TABLE %s", tableName); + + // Verify new partitions do not show up for removed 'partition.data=d2' query + sql("INSERT INTO TABLE %s VALUES (3, 'c3', 'd2')", tableName); + sql("INSERT INTO TABLE %s VALUES (4, 'c4', 'd2')", tableName); + + // Verify new partitions do show up for 'partition.category=c2' query + sql("INSERT INTO TABLE %s VALUES (5, 'c2', 'd5')", tableName); + + // no new partition should show up for 'data' partition query as partition field has been + // removed + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row("d2", null), row("d2", "c2")), + "STRUCT", + tableType, + "partition.data = 'd2'"); + } + // new partition shows up from 'category' partition field query + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row(null, "c2"), row("d2", "c2")), + "STRUCT", + tableType, + "partition.category = 'c2'"); + } + + table.updateSpec().renameField("category", "category_another_name").commit(); + sql("REFRESH TABLE %s", tableName); + + // Verify new partitions do show up for 'category=c2' query + sql("INSERT INTO TABLE %s VALUES (6, 'c2', 'd6')", tableName); + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES)) { + assertPartitions( + ImmutableList.of(row(null, "c2"), row(null, "c2"), row("d2", "c2")), + "STRUCT", + tableType, + "partition.category_another_name = 'c2'"); + } + } + + @TestTemplate + public void testEntriesMetadataTable() throws ParseException { + createTable("id bigint NOT NULL, category string, data string"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables while the current spec is still unpartitioned + for (MetadataTableType tableType : Arrays.asList(ENTRIES, ALL_ENTRIES)) { + Dataset df = loadMetadataTable(tableType); + StructType dataFileType = (StructType) df.schema().apply("data_file").dataType(); + assertThat(dataFileType.getFieldIndex("").isEmpty()).as("Partition must be skipped").isTrue(); + } + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateSpec().addField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables after adding the first partition column + for (MetadataTableType tableType : Arrays.asList(ENTRIES, ALL_ENTRIES)) { + assertPartitions( + ImmutableList.of(row(new Object[] {null}), row("b1")), "STRUCT", tableType); + } + + table.updateSpec().addField(Expressions.bucket("category", 8)).commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables after adding the second partition column + for (MetadataTableType tableType : Arrays.asList(ENTRIES, ALL_ENTRIES)) { + assertPartitions( + ImmutableList.of(row(null, null), row("b1", null), row("b1", 2)), + "STRUCT", + tableType); + } + + table.updateSpec().removeField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + // verify the metadata tables after dropping the first partition column + for (MetadataTableType tableType : Arrays.asList(ENTRIES, ALL_ENTRIES)) { + assertPartitions( + ImmutableList.of(row(null, null), row(null, 2), row("b1", null), row("b1", 2)), + "STRUCT", + tableType); + } + + table.updateSpec().renameField("category_bucket_8", "category_bucket_8_another_name").commit(); + sql("REFRESH TABLE %s", tableName); + + // verify the metadata tables after renaming the second partition column + for (MetadataTableType tableType : Arrays.asList(ENTRIES, ALL_ENTRIES)) { + assertPartitions( + ImmutableList.of(row(null, null), row(null, 2), row("b1", null), row("b1", 2)), + "STRUCT", + tableType); + } + } + + @TestTemplate + public void testPartitionsTableAddRemoveFields() throws ParseException { + createTable("id bigint NOT NULL, category string, data string"); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // verify the metadata tables while the current spec is still unpartitioned + Dataset df = loadMetadataTable(PARTITIONS); + assertThat(df.schema().getFieldIndex("partition").isEmpty()) + .as("Partition must be skipped") + .isTrue(); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateSpec().addField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // verify the metadata tables after adding the first partition column + assertPartitions( + ImmutableList.of(row(new Object[] {null}), row("d1"), row("d2")), + "STRUCT", + PARTITIONS); + + table.updateSpec().addField("category").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // verify the metadata tables after adding the second partition column + assertPartitions( + ImmutableList.of( + row(null, null), row("d1", null), row("d1", "c1"), row("d2", null), row("d2", "c2")), + "STRUCT", + PARTITIONS); + + // verify the metadata tables after removing the first partition column + table.updateSpec().removeField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of( + row(null, null), + row(null, "c1"), + row(null, "c2"), + row("d1", null), + row("d1", "c1"), + row("d2", null), + row("d2", "c2")), + "STRUCT", + PARTITIONS); + } + + @TestTemplate + public void testPartitionsTableRenameFields() throws ParseException { + createTable("id bigint NOT NULL, category string, data string"); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateSpec().addField("data").addField("category").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row("d1", "c1"), row("d2", "c2")), + "STRUCT", + PARTITIONS); + + table.updateSpec().renameField("category", "category_another_name").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row("d1", "c1"), row("d2", "c2")), + "STRUCT", + PARTITIONS); + } + + @TestTemplate + public void testPartitionsTableSwitchFields() throws Exception { + createTable("id bigint NOT NULL, category string, data string"); + + Table table = validationCatalog.loadTable(tableIdent); + + // verify the metadata tables after re-adding the first dropped column in the second location + table.updateSpec().addField("data").addField("category").commit(); + sql("REFRESH TABLE %s", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row("d1", "c1"), row("d2", "c2")), + "STRUCT", + PARTITIONS); + + table.updateSpec().removeField("data").commit(); + sql("REFRESH TABLE %s", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row(null, "c1"), row(null, "c2"), row("d1", "c1"), row("d2", "c2")), + "STRUCT", + PARTITIONS); + + table.updateSpec().addField("data").commit(); + sql("REFRESH TABLE %s", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + sql("INSERT INTO TABLE %s VALUES (3, 'c3', 'd3')", tableName); + + if (formatVersion == 1) { + assertPartitions( + ImmutableList.of( + row(null, "c1", null), + row(null, "c1", "d1"), + row(null, "c2", null), + row(null, "c2", "d2"), + row(null, "c3", "d3"), + row("d1", "c1", null), + row("d2", "c2", null)), + "STRUCT", + PARTITIONS); + } else { + // In V2 re-adding a former partition field that was part of an older spec will not change its + // name or its + // field ID either, thus values will be collapsed into a single common column (as opposed to + // V1 where any new + // partition field addition will result in a new column in this metadata table) + assertPartitions( + ImmutableList.of( + row(null, "c1"), row(null, "c2"), row("d1", "c1"), row("d2", "c2"), row("d3", "c3")), + "STRUCT", + PARTITIONS); + } + } + + @TestTemplate + public void testPartitionTableFilterAddRemoveFields() throws ParseException { + // Create un-partitioned table + createTable("id bigint NOT NULL, category string, data string"); + + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // Partition Table with one partition column + Table table = validationCatalog.loadTable(tableIdent); + table.updateSpec().addField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row("d2")), "STRUCT", PARTITIONS, "partition.data = 'd2'"); + + // Partition Table with two partition column + table.updateSpec().addField("category").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row("d2", null), row("d2", "c2")), + "STRUCT", + PARTITIONS, + "partition.data = 'd2'"); + assertPartitions( + ImmutableList.of(row("d2", "c2")), + "STRUCT", + PARTITIONS, + "partition.category = 'c2'"); + + // Partition Table with first partition column removed + table.updateSpec().removeField("data").commit(); + sql("REFRESH TABLE %s", tableName); + + sql("INSERT INTO TABLE %s VALUES (3, 'c3', 'd2')", tableName); + sql("INSERT INTO TABLE %s VALUES (4, 'c4', 'd2')", tableName); + sql("INSERT INTO TABLE %s VALUES (5, 'c2', 'd5')", tableName); + assertPartitions( + ImmutableList.of(row("d2", null), row("d2", "c2")), + "STRUCT", + PARTITIONS, + "partition.data = 'd2'"); + assertPartitions( + ImmutableList.of(row(null, "c2"), row("d2", "c2")), + "STRUCT", + PARTITIONS, + "partition.category = 'c2'"); + } + + @TestTemplate + public void testPartitionTableFilterSwitchFields() throws Exception { + // Re-added partition fields currently not re-associated: + // https://github.com/apache/iceberg/issues/4292 + // In V1, dropped partition fields show separately when field is re-added + // In V2, re-added field currently conflicts with its deleted form + assumeThat(formatVersion).isEqualTo(1); + + createTable("id bigint NOT NULL, category string, data string"); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + Table table = validationCatalog.loadTable(tableIdent); + + // Two partition columns + table.updateSpec().addField("data").addField("category").commit(); + sql("REFRESH TABLE %s", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // Drop first partition column + table.updateSpec().removeField("data").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + // Re-add first partition column at the end + table.updateSpec().addField("data").commit(); + sql("REFRESH TABLE %s", tableName); + + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row(null, "c2", null), row(null, "c2", "d2"), row("d2", "c2", null)), + "STRUCT", + PARTITIONS, + "partition.category = 'c2'"); + + assertPartitions( + ImmutableList.of(row(null, "c1", "d1")), + "STRUCT", + PARTITIONS, + "partition.data = 'd1'"); + } + + @TestTemplate + public void testPartitionsTableFilterRenameFields() throws ParseException { + createTable("id bigint NOT NULL, category string, data string"); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateSpec().addField("data").addField("category").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + table.updateSpec().renameField("category", "category_another_name").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO TABLE %s VALUES (1, 'c1', 'd1')", tableName); + sql("INSERT INTO TABLE %s VALUES (2, 'c2', 'd2')", tableName); + + assertPartitions( + ImmutableList.of(row("d1", "c1")), + "STRUCT", + PARTITIONS, + "partition.category_another_name = 'c1'"); + } + + @TestTemplate + public void testMetadataTablesWithUnknownTransforms() { + createTable("id bigint NOT NULL, category string, data string"); + + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + + PartitionSpec unknownSpec = + TestHelpers.newExpectedSpecBuilder() + .withSchema(table.schema()) + .withSpecId(1) + .addField("zero", 1, "id_zero") + .build(); + + // replace the table spec to include an unknown transform + TableOperations ops = ((HasTableOperations) table).operations(); + TableMetadata base = ops.current(); + ops.commit(base, base.updatePartitionSpec(unknownSpec)); + + sql("REFRESH TABLE %s", tableName); + + for (MetadataTableType tableType : Arrays.asList(FILES, ALL_DATA_FILES, ENTRIES, ALL_ENTRIES)) { + assertThatThrownBy(() -> loadMetadataTable(tableType)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot build table partition type, unknown transforms: [zero]"); + } + } + + @TestTemplate + public void testPartitionColumnNamedPartition() { + sql( + "CREATE TABLE %s (id int, partition int) USING iceberg PARTITIONED BY (partition)", + tableName); + sql("INSERT INTO %s VALUES (1, 1), (2, 1), (3, 2), (2, 2)", tableName); + List expected = ImmutableList.of(row(1, 1), row(2, 1), row(3, 2), row(2, 2)); + assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + assertThat(sql("SELECT * FROM %s.files", tableName)).hasSize(2); + } + + private void assertPartitions( + List expectedPartitions, String expectedTypeAsString, MetadataTableType tableType) + throws ParseException { + assertPartitions(expectedPartitions, expectedTypeAsString, tableType, null); + } + + private void assertPartitions( + List expectedPartitions, + String expectedTypeAsString, + MetadataTableType tableType, + String filter) + throws ParseException { + Dataset df = loadMetadataTable(tableType); + if (filter != null) { + df = df.filter(filter); + } + + DataType expectedType = spark.sessionState().sqlParser().parseDataType(expectedTypeAsString); + switch (tableType) { + case PARTITIONS: + case FILES: + case ALL_DATA_FILES: + DataType actualFilesType = df.schema().apply("partition").dataType(); + assertThat(actualFilesType).as("Partition type must match").isEqualTo(expectedType); + break; + + case ENTRIES: + case ALL_ENTRIES: + StructType dataFileType = (StructType) df.schema().apply("data_file").dataType(); + DataType actualEntriesType = dataFileType.apply("partition").dataType(); + assertThat(actualEntriesType).as("Partition type must match").isEqualTo(expectedType); + break; + + default: + throw new UnsupportedOperationException("Unsupported metadata table type: " + tableType); + } + + switch (tableType) { + case PARTITIONS: + case FILES: + case ALL_DATA_FILES: + List actualFilesPartitions = + df.orderBy("partition").select("partition.*").collectAsList(); + assertEquals( + "Partitions must match", expectedPartitions, rowsToJava(actualFilesPartitions)); + break; + + case ENTRIES: + case ALL_ENTRIES: + List actualEntriesPartitions = + df.orderBy("data_file.partition").select("data_file.partition.*").collectAsList(); + assertEquals( + "Partitions must match", expectedPartitions, rowsToJava(actualEntriesPartitions)); + break; + + default: + throw new UnsupportedOperationException("Unsupported metadata table type: " + tableType); + } + } + + private Dataset loadMetadataTable(MetadataTableType tableType) { + return spark.read().format("iceberg").load(tableName + "." + tableType.name()); + } + + private void createTable(String schema) { + sql( + "CREATE TABLE %s (%s) USING iceberg TBLPROPERTIES ('%s' '%s', '%s' '%d')", + tableName, schema, DEFAULT_FILE_FORMAT, fileFormat.name(), FORMAT_VERSION, formatVersion); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java new file mode 100644 index 000000000000..a9ce340fd4ec --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestMicroBatchPlanningUtils.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.spark.sql.connector.read.streaming.ReadLimit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestMicroBatchPlanningUtils extends CatalogTestBase { + + private Table table; + + @BeforeEach + public void setupTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql( + "CREATE TABLE %s " + + "(id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(3, id))", + tableName); + this.table = validationCatalog.loadTable(tableIdent); + } + + @AfterEach + public void dropTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testUnpackedLimitsCompositeChoosesMinimum() { + ReadLimit[] limits = + new ReadLimit[] { + ReadLimit.maxRows(10), ReadLimit.maxRows(4), ReadLimit.maxFiles(8), ReadLimit.maxFiles(2) + }; + + ReadLimit composite = ReadLimit.compositeLimit(limits); + + BaseSparkMicroBatchPlanner.UnpackedLimits unpacked = + new BaseSparkMicroBatchPlanner.UnpackedLimits(composite); + + assertThat(unpacked.getMaxRows()).isEqualTo(4); + assertThat(unpacked.getMaxFiles()).isEqualTo(2); + } + + @TestTemplate + public void testDetermineStartingOffsetWithTimestampBetweenSnapshots() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + long snapshot1Time = table.currentSnapshot().timestampMillis(); + + sql("INSERT INTO %s VALUES (2, 'two')", tableName); + table.refresh(); + long snapshot2Id = table.currentSnapshot().snapshotId(); + + StreamingOffset offset = MicroBatchUtils.determineStartingOffset(table, snapshot1Time + 1); + + assertThat(offset.snapshotId()).isEqualTo(snapshot2Id); + assertThat(offset.position()).isEqualTo(0L); + assertThat(offset.shouldScanAllFiles()).isFalse(); + } + + @TestTemplate + public void testAddedFilesCountUsesSummaryWhenPresent() { + sql("INSERT INTO %s VALUES (1, 'one')", tableName); + table.refresh(); + + long expectedAddedFiles = + Long.parseLong(table.currentSnapshot().summary().get(SnapshotSummary.ADDED_FILES_PROP)); + + long actual = MicroBatchUtils.addedFilesCount(table, table.currentSnapshot()); + + assertThat(actual).isEqualTo(expectedAddedFiles); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java new file mode 100644 index 000000000000..892e260f66f0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestORCDataFrameWrite.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.spark.SparkException; +import org.junit.jupiter.api.Test; + +public class TestORCDataFrameWrite extends DataFrameWriteTestBase { + @Override + protected void configureTable(Table table) { + table + .updateProperties() + .set(TableProperties.DEFAULT_FILE_FORMAT, FileFormat.ORC.toString()) + .commit(); + } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create ListType with unknown element type"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot create MapType with unknown value type"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java new file mode 100644 index 000000000000..7a8531278314 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetDataFrameWrite.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.spark.SparkException; +import org.junit.jupiter.api.Test; + +public class TestParquetDataFrameWrite extends DataFrameWriteTestBase { + @Override + protected boolean supportsVariant() { + return true; + } + + @Override + protected void configureTable(Table table) { + table + .updateProperties() + .set(TableProperties.DEFAULT_FILE_FORMAT, FileFormat.PARQUET.toString()) + .commit(); + } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(SparkException.class) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java new file mode 100644 index 000000000000..0ea54821a46a --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetScan.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.Files.localOutput; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.data.parquet.GenericParquetWriter; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.Test; + +public class TestParquetScan extends ScanTestBase { + protected boolean vectorized() { + return false; + } + + @Override + protected boolean supportsVariant() { + return true; + } + + @Override + protected void configureTable(Table table) { + table + .updateProperties() + .set(TableProperties.PARQUET_VECTORIZATION_ENABLED, String.valueOf(vectorized())) + .commit(); + } + + @Override + protected void writeRecords(Table table, List records) throws IOException { + File dataFolder = new File(table.location(), "data"); + File parquetFile = + new File(dataFolder, FileFormat.PARQUET.addExtension(UUID.randomUUID().toString())); + + try (FileAppender writer = + Parquet.write(localOutput(parquetFile)) + .schema(table.schema()) + .createWriterFunc(GenericParquetWriter::create) + .build()) { + writer.addAll(records); + } + + DataFile file = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withFileSizeInBytes(parquetFile.length()) + .withPath(parquetFile.toString()) + .withRecordCount(records.size()) + .build(); + + table.newAppend().appendFile(file).commit(); + } + + @Override + protected void writeAndValidate(Schema writeSchema, Schema expectedSchema) throws IOException { + assumeThat( + TypeUtil.find( + writeSchema, + type -> type.isMapType() && type.asMapType().keyType() != Types.StringType.get())) + .as("Cannot handle non-string map keys in parquet-avro") + .isNull(); + + super.writeAndValidate(writeSchema, expectedSchema); + } + + @Test + @Override + public void testUnknownListType() { + assertThatThrownBy(super::testUnknownListType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert element Parquet: unknown"); + } + + @Test + @Override + public void testUnknownMapType() { + assertThatThrownBy(super::testUnknownMapType) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot convert value Parquet: unknown"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetVectorizedScan.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetVectorizedScan.java new file mode 100644 index 000000000000..a6b5166b3a4e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestParquetVectorizedScan.java @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +public class TestParquetVectorizedScan extends TestParquetScan { + @Override + protected boolean vectorized() { + return true; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java new file mode 100644 index 000000000000..8098db81f999 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionPruning.java @@ -0,0 +1,484 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.net.URI; +import java.nio.file.Files; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FSDataInputStream; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.fs.RawLocalFileSystem; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.transforms.Transforms; +import org.apache.iceberg.types.Types; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.unsafe.types.UTF8String; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPartitionPruning { + + private static final Configuration CONF = new Configuration(); + private static final HadoopTables TABLES = new HadoopTables(CONF); + + @Parameters(name = "format = {0}, vectorized = {1}, planningMode = {2}") + public static Object[][] parameters() { + return new Object[][] { + {FileFormat.PARQUET, false, DISTRIBUTED}, + {FileFormat.PARQUET, true, LOCAL}, + {FileFormat.AVRO, false, DISTRIBUTED}, + {FileFormat.ORC, false, LOCAL}, + {FileFormat.ORC, true, DISTRIBUTED} + }; + } + + @Parameter(index = 0) + private FileFormat format; + + @Parameter(index = 1) + private boolean vectorized; + + @Parameter(index = 2) + private PlanningMode planningMode; + + private static SparkSession spark = null; + private static JavaSparkContext sparkContext = null; + + private static final Function BUCKET_FUNC = + Transforms.bucket(3).bind(Types.IntegerType.get()); + private static final Function TRUNCATE_FUNC = + Transforms.truncate(5).bind(Types.StringType.get()); + private static final Function HOUR_FUNC = + Transforms.hour().bind(Types.TimestampType.withoutZone()); + + @BeforeAll + public static void startSpark() { + TestPartitionPruning.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + TestPartitionPruning.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + + String optionKey = String.format("fs.%s.impl", CountOpenLocalFileSystem.scheme); + CONF.set(optionKey, CountOpenLocalFileSystem.class.getName()); + spark.conf().set(optionKey, CountOpenLocalFileSystem.class.getName()); + spark.conf().set("spark.sql.session.timeZone", "UTC"); + spark.udf().register("bucket3", (Integer num) -> BUCKET_FUNC.apply(num), DataTypes.IntegerType); + spark + .udf() + .register("truncate5", (String str) -> TRUNCATE_FUNC.apply(str), DataTypes.StringType); + // NOTE: date transforms take the type long, not Timestamp + spark + .udf() + .register( + "hour", + (Timestamp ts) -> + HOUR_FUNC.apply( + org.apache.spark.sql.catalyst.util.DateTimeUtils.fromJavaTimestamp(ts)), + DataTypes.IntegerType); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestPartitionPruning.spark; + TestPartitionPruning.spark = null; + currentSpark.stop(); + } + + private static final Schema LOG_SCHEMA = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "date", Types.StringType.get()), + Types.NestedField.optional(3, "level", Types.StringType.get()), + Types.NestedField.optional(4, "message", Types.StringType.get()), + Types.NestedField.optional(5, "timestamp", Types.TimestampType.withZone())); + + private static final List LOGS = + ImmutableList.of( + LogMessage.debug("2020-02-02", "debug event 1", getInstant("2020-02-02T00:00:00")), + LogMessage.info("2020-02-02", "info event 1", getInstant("2020-02-02T01:00:00")), + LogMessage.debug("2020-02-02", "debug event 2", getInstant("2020-02-02T02:00:00")), + LogMessage.info("2020-02-03", "info event 2", getInstant("2020-02-03T00:00:00")), + LogMessage.debug("2020-02-03", "debug event 3", getInstant("2020-02-03T01:00:00")), + LogMessage.info("2020-02-03", "info event 3", getInstant("2020-02-03T02:00:00")), + LogMessage.error("2020-02-03", "error event 1", getInstant("2020-02-03T03:00:00")), + LogMessage.debug("2020-02-04", "debug event 4", getInstant("2020-02-04T01:00:00")), + LogMessage.warn("2020-02-04", "warn event 1", getInstant("2020-02-04T02:00:00")), + LogMessage.debug("2020-02-04", "debug event 5", getInstant("2020-02-04T03:00:00"))); + + private static Instant getInstant(String timestampWithoutZone) { + Long epochMicros = + (Long) Literal.of(timestampWithoutZone).to(Types.TimestampType.withoutZone()).value(); + return Instant.ofEpochMilli(TimeUnit.MICROSECONDS.toMillis(epochMicros)); + } + + @TempDir private java.nio.file.Path temp; + + private final PartitionSpec spec = + PartitionSpec.builderFor(LOG_SCHEMA) + .identity("date") + .identity("level") + .bucket("id", 3) + .truncate("message", 5) + .hour("timestamp") + .build(); + + @TestTemplate + public void testPartitionPruningIdentityString() { + String filterCond = "date >= '2020-02-03' AND level = 'DEBUG'"; + Predicate partCondition = + (Row r) -> { + String date = r.getString(0); + String level = r.getString(1); + return date.compareTo("2020-02-03") >= 0 && level.equals("DEBUG"); + }; + + runTest(filterCond, partCondition); + } + + @TestTemplate + public void testPartitionPruningBucketingInteger() { + final int[] ids = new int[] {LOGS.get(3).getId(), LOGS.get(7).getId()}; + String condForIds = + Arrays.stream(ids).mapToObj(String::valueOf).collect(Collectors.joining(",", "(", ")")); + String filterCond = "id in " + condForIds; + Predicate partCondition = + (Row r) -> { + int bucketId = r.getInt(2); + Set buckets = + Arrays.stream(ids).map(BUCKET_FUNC::apply).boxed().collect(Collectors.toSet()); + return buckets.contains(bucketId); + }; + + runTest(filterCond, partCondition); + } + + @TestTemplate + public void testPartitionPruningTruncatedString() { + String filterCond = "message like 'info event%'"; + Predicate partCondition = + (Row r) -> { + String truncatedMessage = r.getString(3); + return truncatedMessage.equals("info "); + }; + + runTest(filterCond, partCondition); + } + + @TestTemplate + public void testPartitionPruningTruncatedStringComparingValueShorterThanPartitionValue() { + String filterCond = "message like 'inf%'"; + Predicate partCondition = + (Row r) -> { + String truncatedMessage = r.getString(3); + return truncatedMessage.startsWith("inf"); + }; + + runTest(filterCond, partCondition); + } + + @TestTemplate + public void testPartitionPruningHourlyPartition() { + String filterCond = "timestamp >= '2020-02-03T01:00:00'"; + Predicate partCondition = + (Row r) -> { + int hourValue = r.getInt(4); + Instant instant = getInstant("2020-02-03T01:00:00"); + Integer hourValueToFilter = + HOUR_FUNC.apply(TimeUnit.MILLISECONDS.toMicros(instant.toEpochMilli())); + return hourValue >= hourValueToFilter; + }; + + runTest(filterCond, partCondition); + } + + private void runTest(String filterCond, Predicate partCondition) { + File originTableLocation = createTempDir(); + assertThat(originTableLocation).as("Temp folder should exist").exists(); + + Table table = createTable(originTableLocation); + Dataset logs = createTestDataset(); + saveTestDatasetToTable(logs, table); + + List expected = + logs.select("id", "date", "level", "message", "timestamp") + .filter(filterCond) + .orderBy("id") + .collectAsList(); + assertThat(expected).as("Expected rows should not be empty").isNotEmpty(); + + // remove records which may be recorded during storing to table + CountOpenLocalFileSystem.resetRecordsInPathPrefix(originTableLocation.getAbsolutePath()); + + List actual = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(table.location()) + .select("id", "date", "level", "message", "timestamp") + .filter(filterCond) + .orderBy("id") + .collectAsList(); + assertThat(actual).isNotEmpty().isEqualTo(expected); + + assertAccessOnDataFiles(originTableLocation, table, partCondition); + } + + private File createTempDir() { + try { + return Files.createTempDirectory(temp, "junit").toFile(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Table createTable(File originTableLocation) { + String trackedTableLocation = CountOpenLocalFileSystem.convertPath(originTableLocation); + Map properties = + ImmutableMap.of( + TableProperties.DEFAULT_FILE_FORMAT, format.toString(), + TableProperties.DATA_PLANNING_MODE, planningMode.modeName(), + TableProperties.DELETE_PLANNING_MODE, planningMode.modeName()); + return TABLES.create(LOG_SCHEMA, spec, properties, trackedTableLocation); + } + + private Dataset createTestDataset() { + List rows = + LOGS.stream() + .map( + logMessage -> { + Object[] underlying = + new Object[] { + logMessage.getId(), + UTF8String.fromString(logMessage.getDate()), + UTF8String.fromString(logMessage.getLevel()), + UTF8String.fromString(logMessage.getMessage()), + // discard the nanoseconds part to simplify + TimeUnit.MILLISECONDS.toMicros(logMessage.getTimestamp().toEpochMilli()) + }; + return new GenericInternalRow(underlying); + }) + .collect(Collectors.toList()); + + JavaRDD rdd = sparkContext.parallelize(rows); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + Dataset df = + ((org.apache.spark.sql.classic.SparkSession) spark) + .internalCreateDataFrame( + JavaRDD.toRDD(rdd), SparkSchemaUtil.convert(LOG_SCHEMA), false); + + return df.selectExpr("id", "date", "level", "message", "timestamp") + .selectExpr( + "id", + "date", + "level", + "message", + "timestamp", + "bucket3(id) AS bucket_id", + "truncate5(message) AS truncated_message", + "hour(timestamp) AS ts_hour"); + } + + private void saveTestDatasetToTable(Dataset logs, Table table) { + logs.orderBy("date", "level", "bucket_id", "truncated_message", "ts_hour") + .select("id", "date", "level", "message", "timestamp") + .write() + .format("iceberg") + .mode("append") + .save(table.location()); + } + + private void assertAccessOnDataFiles( + File originTableLocation, Table table, Predicate partCondition) { + // only use files in current table location to avoid side-effects on concurrent test runs + Set readFilesInQuery = + CountOpenLocalFileSystem.pathToNumOpenCalled.keySet().stream() + .filter(path -> path.startsWith(originTableLocation.getAbsolutePath())) + .collect(Collectors.toSet()); + + List files = + spark.read().format("iceberg").load(table.location() + "#files").collectAsList(); + + Set filesToRead = extractFilePathsMatchingConditionOnPartition(files, partCondition); + Set filesToNotRead = extractFilePathsNotIn(files, filesToRead); + + // Just to be sure, they should be mutually exclusive. + assertThat(filesToRead).doesNotContainAnyElementsOf(filesToNotRead); + + assertThat(filesToNotRead).as("The query should prune some data files.").isNotEmpty(); + + // We don't check "all" data files bound to the condition are being read, as data files can be + // pruned on + // other conditions like lower/upper bound of columns. + assertThat(filesToRead) + .as( + "Some of data files in partition range should be read. " + + "Read files in query: " + + readFilesInQuery + + " / data files in partition range: " + + filesToRead) + .containsAnyElementsOf(readFilesInQuery); + + // Data files which aren't bound to the condition shouldn't be read. + assertThat(filesToNotRead) + .as( + "Data files outside of partition range should not be read. " + + "Read files in query: " + + readFilesInQuery + + " / data files outside of partition range: " + + filesToNotRead) + .doesNotContainAnyElementsOf(readFilesInQuery); + } + + private Set extractFilePathsMatchingConditionOnPartition( + List files, Predicate condition) { + // idx 1: file_path, idx 3: partition + return files.stream() + .filter( + r -> { + Row partition = r.getStruct(4); + return condition.test(partition); + }) + .map(r -> CountOpenLocalFileSystem.stripScheme(r.getString(1))) + .collect(Collectors.toSet()); + } + + private Set extractFilePathsNotIn(List files, Set filePaths) { + Set allFilePaths = + files.stream() + .map(r -> CountOpenLocalFileSystem.stripScheme(r.getString(1))) + .collect(Collectors.toSet()); + return Sets.newHashSet(Sets.symmetricDifference(allFilePaths, filePaths)); + } + + public static class CountOpenLocalFileSystem extends RawLocalFileSystem { + public static String scheme = + String.format("TestIdentityPartitionData%dfs", new Random().nextInt()); + public static Map pathToNumOpenCalled = Maps.newConcurrentMap(); + + public static String convertPath(String absPath) { + return scheme + "://" + absPath; + } + + public static String convertPath(File file) { + return convertPath(file.getAbsolutePath()); + } + + public static String stripScheme(String pathWithScheme) { + if (!pathWithScheme.startsWith(scheme + ":")) { + throw new IllegalArgumentException("Received unexpected path: " + pathWithScheme); + } + + int idxToCut = scheme.length() + 1; + while (pathWithScheme.charAt(idxToCut) == '/') { + idxToCut++; + } + + // leave the last '/' + idxToCut--; + + return pathWithScheme.substring(idxToCut); + } + + public static void resetRecordsInPathPrefix(String pathPrefix) { + pathToNumOpenCalled.keySet().stream() + .filter(p -> p.startsWith(pathPrefix)) + .forEach(key -> pathToNumOpenCalled.remove(key)); + } + + @Override + public URI getUri() { + return URI.create(scheme + ":///"); + } + + @Override + public String getScheme() { + return scheme; + } + + @Override + public FSDataInputStream open(Path f, int bufferSize) throws IOException { + String path = f.toUri().getPath(); + pathToNumOpenCalled.compute( + path, + (ignored, v) -> { + if (v == null) { + return 1L; + } else { + return v + 1; + } + }); + return super.open(f, bufferSize); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java new file mode 100644 index 000000000000..9b5b22a73f36 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPartitionValues.java @@ -0,0 +1,513 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.net.InetAddress; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.avro.generic.GenericData; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.avro.Avro; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.data.TestHelpers; +import org.apache.iceberg.types.Types; +import org.apache.spark.api.java.function.MapFunction; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPartitionValues { + @Parameters(name = "format = {0}, vectorized = {1}") + public static Object[][] parameters() { + return new Object[][] { + {FileFormat.PARQUET, false}, + {FileFormat.PARQUET, true}, + {FileFormat.AVRO, false}, + {FileFormat.ORC, false}, + {FileFormat.ORC, true} + }; + } + + private static final Schema SUPPORTED_PRIMITIVES = + new Schema( + required(100, "id", Types.LongType.get()), + required(101, "data", Types.StringType.get()), + required(102, "b", Types.BooleanType.get()), + required(103, "i", Types.IntegerType.get()), + required(104, "l", Types.LongType.get()), + required(105, "f", Types.FloatType.get()), + required(106, "d", Types.DoubleType.get()), + required(107, "date", Types.DateType.get()), + required(108, "ts", Types.TimestampType.withZone()), + required(110, "s", Types.StringType.get()), + required(113, "bytes", Types.BinaryType.get()), + required(114, "dec_9_0", Types.DecimalType.of(9, 0)), + required(115, "dec_11_2", Types.DecimalType.of(11, 2)), + required(116, "dec_38_10", Types.DecimalType.of(38, 10)) // spark's maximum precision + ); + + private static final Schema SIMPLE_SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SIMPLE_SCHEMA).identity("data").build(); + + private static SparkSession spark = null; + + @BeforeAll + public static void startSpark() { + TestPartitionValues.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestPartitionValues.spark; + TestPartitionValues.spark = null; + currentSpark.stop(); + } + + @TempDir private Path temp; + + @Parameter(index = 0) + private FileFormat format; + + @Parameter(index = 1) + private boolean vectorized; + + @TestTemplate + public void testNullPartitionValue() throws Exception { + String desc = "null_part"; + File parent = new File(temp.toFile(), desc); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).as("mkdirs should succeed").isTrue(); + + HadoopTables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + Table table = tables.create(SIMPLE_SCHEMA, SPEC, location.toString()); + table.updateProperties().set(TableProperties.DEFAULT_FILE_FORMAT, format.toString()).commit(); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, null)); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(location.toString()); + + Dataset result = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(location.toString()); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testReorderedColumns() throws Exception { + String desc = "reorder_columns"; + File parent = new File(temp.toFile(), desc); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).as("mkdirs should succeed").isTrue(); + + HadoopTables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + Table table = tables.create(SIMPLE_SCHEMA, SPEC, location.toString()); + table.updateProperties().set(TableProperties.DEFAULT_FILE_FORMAT, format.toString()).commit(); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("data", "id") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .option(SparkWriteOptions.CHECK_ORDERING, "false") + .save(location.toString()); + + Dataset result = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(location.toString()); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testReorderedColumnsNoNullability() throws Exception { + String desc = "reorder_columns_no_nullability"; + File parent = new File(temp.toFile(), desc); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).as("mkdirs should succeed").isTrue(); + + HadoopTables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + Table table = tables.create(SIMPLE_SCHEMA, SPEC, location.toString()); + table.updateProperties().set(TableProperties.DEFAULT_FILE_FORMAT, format.toString()).commit(); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("data", "id") + .write() + .format("iceberg") + .mode(SaveMode.Append) + .option(SparkWriteOptions.CHECK_ORDERING, "false") + .option(SparkWriteOptions.CHECK_NULLABILITY, "false") + .save(location.toString()); + + Dataset result = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(location.toString()); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testPartitionValueTypes() throws Exception { + String[] columnNames = + new String[] { + "b", "i", "l", "f", "d", "date", "ts", "s", "bytes", "dec_9_0", "dec_11_2", "dec_38_10" + }; + + HadoopTables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + + // create a table around the source data + String sourceLocation = temp.resolve("source_table").toString(); + Table source = tables.create(SUPPORTED_PRIMITIVES, sourceLocation); + + // write out an Avro data file with all of the data types for source data + List expected = RandomData.generateList(source.schema(), 2, 128735L); + File avroData = File.createTempFile("data", ".avro", temp.toFile()); + assertThat(avroData.delete()).isTrue(); + try (FileAppender appender = + Avro.write(Files.localOutput(avroData)).schema(source.schema()).build()) { + appender.addAll(expected); + } + + // add the Avro data file to the source table + source + .newAppend() + .appendFile( + DataFiles.builder(PartitionSpec.unpartitioned()) + .withRecordCount(10) + .withInputFile(Files.localInput(avroData)) + .build()) + .commit(); + + Dataset sourceDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(sourceLocation); + + for (String column : columnNames) { + String desc = "partition_by_" + SUPPORTED_PRIMITIVES.findType(column).toString(); + + File parent = new File(temp.toFile(), desc); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).as("mkdirs should succeed").isTrue(); + + PartitionSpec spec = PartitionSpec.builderFor(SUPPORTED_PRIMITIVES).identity(column).build(); + + Table table = tables.create(SUPPORTED_PRIMITIVES, spec, location.toString()); + table.updateProperties().set(TableProperties.DEFAULT_FILE_FORMAT, format.toString()).commit(); + + // disable distribution/ordering and fanout writers to preserve the original ordering + sourceDF + .write() + .format("iceberg") + .mode(SaveMode.Append) + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") + .option(SparkWriteOptions.FANOUT_ENABLED, "false") + .save(location.toString()); + + List actual = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(location.toString()) + .collectAsList(); + + assertThat(actual).as("Number of rows should match").hasSameSizeAs(expected); + + for (int i = 0; i < expected.size(); i += 1) { + TestHelpers.assertEqualsSafe( + SUPPORTED_PRIMITIVES.asStruct(), expected.get(i), actual.get(i)); + } + } + } + + @TestTemplate + public void testNestedPartitionValues() throws Exception { + String[] columnNames = + new String[] { + "b", "i", "l", "f", "d", "date", "ts", "s", "bytes", "dec_9_0", "dec_11_2", "dec_38_10" + }; + + HadoopTables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + Schema nestedSchema = new Schema(optional(1, "nested", SUPPORTED_PRIMITIVES.asStruct())); + + // create a table around the source data + String sourceLocation = temp.resolve("source_table").toString(); + Table source = tables.create(nestedSchema, sourceLocation); + + // write out an Avro data file with all of the data types for source data + List expected = RandomData.generateList(source.schema(), 2, 128735L); + File avroData = File.createTempFile("data", ".avro", temp.toFile()); + assertThat(avroData.delete()).isTrue(); + try (FileAppender appender = + Avro.write(Files.localOutput(avroData)).schema(source.schema()).build()) { + appender.addAll(expected); + } + + // add the Avro data file to the source table + source + .newAppend() + .appendFile( + DataFiles.builder(PartitionSpec.unpartitioned()) + .withRecordCount(10) + .withInputFile(Files.localInput(avroData)) + .build()) + .commit(); + + Dataset sourceDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(sourceLocation); + + for (String column : columnNames) { + String desc = "partition_by_" + SUPPORTED_PRIMITIVES.findType(column).toString(); + + File parent = new File(temp.toFile(), desc); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).as("mkdirs should succeed").isTrue(); + + PartitionSpec spec = + PartitionSpec.builderFor(nestedSchema).identity("nested." + column).build(); + + Table table = tables.create(nestedSchema, spec, location.toString()); + table.updateProperties().set(TableProperties.DEFAULT_FILE_FORMAT, format.toString()).commit(); + + // disable distribution/ordering and fanout writers to preserve the original ordering + sourceDF + .write() + .format("iceberg") + .mode(SaveMode.Append) + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") + .option(SparkWriteOptions.FANOUT_ENABLED, "false") + .save(location.toString()); + + List actual = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(location.toString()) + .collectAsList(); + + assertThat(actual).as("Number of rows should match").hasSameSizeAs(expected); + + for (int i = 0; i < expected.size(); i += 1) { + TestHelpers.assertEqualsSafe(nestedSchema.asStruct(), expected.get(i), actual.get(i)); + } + } + } + + /** + * To verify if WrappedPositionAccessor is generated against a string field within a nested field, + * rather than a Position2Accessor. Or when building the partition path, a ClassCastException is + * thrown with the message like: Cannot cast org.apache.spark.unsafe.types.UTF8String to + * java.lang.CharSequence + */ + @TestTemplate + public void testPartitionedByNestedString() throws Exception { + // schema and partition spec + Schema nestedSchema = + new Schema( + Types.NestedField.required( + 1, + "struct", + Types.StructType.of( + Types.NestedField.required(2, "string", Types.StringType.get())))); + PartitionSpec spec = PartitionSpec.builderFor(nestedSchema).identity("struct.string").build(); + + // create table + HadoopTables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + String baseLocation = temp.resolve("partition_by_nested_string").toString(); + tables.create(nestedSchema, spec, baseLocation); + + // input data frame + StructField[] structFields = { + new StructField( + "struct", + DataTypes.createStructType( + new StructField[] { + new StructField("string", DataTypes.StringType, false, Metadata.empty()) + }), + false, + Metadata.empty()) + }; + + List rows = Lists.newArrayList(); + rows.add(RowFactory.create(RowFactory.create("nested_string_value"))); + Dataset sourceDF = spark.createDataFrame(rows, new StructType(structFields)); + + // write into iceberg + sourceDF.write().format("iceberg").mode(SaveMode.Append).save(baseLocation); + + // verify + List actual = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(baseLocation) + .collectAsList(); + + assertThat(actual).as("Number of rows should match").hasSameSizeAs(rows); + } + + @TestTemplate + public void testReadPartitionColumn() throws Exception { + assumeThat(format).as("Temporary skip ORC").isNotEqualTo(FileFormat.ORC); + + Schema nestedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional( + 2, + "struct", + Types.StructType.of( + Types.NestedField.optional(3, "innerId", Types.LongType.get()), + Types.NestedField.optional(4, "innerName", Types.StringType.get())))); + PartitionSpec spec = + PartitionSpec.builderFor(nestedSchema).identity("struct.innerName").build(); + + // create table + HadoopTables tables = new HadoopTables(spark.sessionState().newHadoopConf()); + String baseLocation = temp.resolve("partition_by_nested_string").toString(); + Table table = tables.create(nestedSchema, spec, baseLocation); + table.updateProperties().set(TableProperties.DEFAULT_FILE_FORMAT, format.toString()).commit(); + + // write into iceberg + MapFunction func = + value -> new ComplexRecord(value, new NestedRecord(value, "name_" + value)); + spark + .range(0, 10, 1, 1) + .map(func, Encoders.bean(ComplexRecord.class)) + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(baseLocation); + + List actual = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(baseLocation) + .select("struct.innerName") + .orderBy("struct.innerName") + .as(Encoders.STRING()) + .collectAsList(); + + assertThat(actual).as("Number of rows should match").hasSize(10); + + List inputRecords = + IntStream.range(0, 10).mapToObj(i -> "name_" + i).collect(Collectors.toList()); + assertThat(actual).as("Read object should be matched").isEqualTo(inputRecords); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPathIdentifier.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPathIdentifier.java new file mode 100644 index 000000000000..aa8fe047b3f7 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPathIdentifier.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.Schema; +import org.apache.iceberg.hadoop.HadoopTableOperations; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.PathIdentifier; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestPathIdentifier extends TestBase { + + private static final Schema SCHEMA = + new Schema( + required(1, "id", Types.LongType.get()), required(2, "data", Types.StringType.get())); + + @TempDir private Path temp; + private File tableLocation; + private PathIdentifier identifier; + private SparkCatalog sparkCatalog; + + @BeforeEach + public void before() throws IOException { + tableLocation = temp.toFile(); + identifier = new PathIdentifier(tableLocation.getAbsolutePath()); + sparkCatalog = new SparkCatalog(); + sparkCatalog.initialize("test", new CaseInsensitiveStringMap(ImmutableMap.of())); + } + + @AfterEach + public void after() { + sparkCatalog = null; + } + + @Test + public void testPathIdentifier() throws TableAlreadyExistsException, NoSuchTableException { + SparkTable table = + (SparkTable) + sparkCatalog.createTable( + identifier, SparkSchemaUtil.convert(SCHEMA), new Transform[0], ImmutableMap.of()); + + assertThat(tableLocation.getAbsolutePath()).isEqualTo(table.table().location()); + assertThat(table.table()).isInstanceOf(BaseTable.class); + assertThat(((BaseTable) table.table()).operations()).isInstanceOf(HadoopTableOperations.class); + + assertThat(table).isEqualTo(sparkCatalog.loadTable(identifier)); + assertThat(sparkCatalog.dropTable(identifier)).isTrue(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java new file mode 100644 index 000000000000..681ab1fd76a9 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesReader.java @@ -0,0 +1,332 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.BaseScanTaskGroup; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.PositionDeletesTable; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.CharSequenceSet; +import org.apache.iceberg.util.Pair; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.unsafe.types.UTF8String; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPositionDeletesReader extends TestBase { + private static final Schema SCHEMA = + new Schema( + required(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA).bucket("data", 16).build(); + + private Table table; + private DataFile dataFile1; + private DataFile dataFile2; + + @TempDir private Path temp; + + @Parameter(index = 0) + private int formatVersion; + + @Parameters(name = "formatVersion = {0}") + protected static List parameters() { + return org.apache.iceberg.TestHelpers.V2_AND_ABOVE; + } + + @BeforeEach + public void before() throws IOException { + table = + catalog.createTable( + TableIdentifier.of("default", "test"), + SCHEMA, + SPEC, + ImmutableMap.of(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion))); + + GenericRecord record = GenericRecord.create(table.schema()); + List records1 = Lists.newArrayList(); + records1.add(record.copy("id", 29, "data", "a")); + records1.add(record.copy("id", 43, "data", "b")); + records1.add(record.copy("id", 61, "data", "c")); + records1.add(record.copy("id", 89, "data", "d")); + + List records2 = Lists.newArrayList(); + records2.add(record.copy("id", 100, "data", "e")); + records2.add(record.copy("id", 121, "data", "f")); + records2.add(record.copy("id", 122, "data", "g")); + + dataFile1 = writeDataFile(records1); + dataFile2 = writeDataFile(records2); + table.newAppend().appendFile(dataFile1).appendFile(dataFile2).commit(); + } + + @AfterEach + public void after() { + catalog.dropTable(TableIdentifier.of("default", "test")); + } + + @TestTemplate + public void readPositionDeletesTableWithNoDeleteFiles() { + Table positionDeletesTable = + catalog.loadTable(TableIdentifier.of("default", "test", "position_deletes")); + + assertThat(positionDeletesTable.newBatchScan().planFiles()).isEmpty(); + } + + @TestTemplate + public void readPositionDeletesTableWithMultipleDeleteFiles() throws IOException { + Pair posDeletes1 = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + Lists.newArrayList( + Pair.of(dataFile1.location(), 0L), Pair.of(dataFile1.location(), 1L)), + formatVersion); + + Pair posDeletes2 = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + Lists.newArrayList( + Pair.of(dataFile2.location(), 2L), Pair.of(dataFile2.location(), 3L)), + formatVersion); + + DeleteFile deleteFile1 = posDeletes1.first(); + DeleteFile deleteFile2 = posDeletes2.first(); + table + .newRowDelta() + .addDeletes(deleteFile1) + .addDeletes(deleteFile2) + .validateDataFilesExist(posDeletes1.second()) + .validateDataFilesExist(posDeletes2.second()) + .commit(); + + Table positionDeletesTable = + catalog.loadTable(TableIdentifier.of("default", "test", "position_deletes")); + + List columns = + Lists.newArrayList( + MetadataColumns.DELETE_FILE_PATH.name(), + MetadataColumns.DELETE_FILE_POS.name(), + PositionDeletesTable.DELETE_FILE_PATH); + if (formatVersion >= 3) { + columns.add(PositionDeletesTable.CONTENT_OFFSET); + columns.add(PositionDeletesTable.CONTENT_SIZE_IN_BYTES); + } + + Schema projectedSchema = positionDeletesTable.schema().select(columns); + + List scanTasks = + Lists.newArrayList( + positionDeletesTable.newBatchScan().project(projectedSchema).planFiles()); + assertThat(scanTasks).hasSize(2); + + assertThat(scanTasks.get(0)).isInstanceOf(PositionDeletesScanTask.class); + PositionDeletesScanTask scanTask1 = (PositionDeletesScanTask) scanTasks.get(0); + + try (PositionDeletesRowReader reader = + new PositionDeletesRowReader( + table, + table.io(), + new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), + projectedSchema, + false, + true)) { + List actualRows = Lists.newArrayList(); + while (reader.next()) { + actualRows.add(reader.get().copy()); + } + + String dataFileLocation = + formatVersion >= 3 ? deleteFile1.referencedDataFile() : dataFile1.location(); + List first = + Lists.newArrayList( + UTF8String.fromString(dataFileLocation), + 0L, + UTF8String.fromString(deleteFile1.location())); + List second = + Lists.newArrayList( + UTF8String.fromString(dataFileLocation), + 1L, + UTF8String.fromString(deleteFile1.location())); + + if (formatVersion >= 3) { + first.add(deleteFile1.contentOffset()); + first.add(deleteFile1.contentSizeInBytes()); + second.add(deleteFile1.contentOffset()); + second.add(deleteFile1.contentSizeInBytes()); + } + + assertThat(internalRowsToJava(actualRows, projectedSchema)) + .hasSize(2) + .containsExactly(first.toArray(), second.toArray()); + } + + assertThat(scanTasks.get(1)).isInstanceOf(PositionDeletesScanTask.class); + PositionDeletesScanTask scanTask2 = (PositionDeletesScanTask) scanTasks.get(1); + try (PositionDeletesRowReader reader = + new PositionDeletesRowReader( + table, + table.io(), + new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask2)), + projectedSchema, + false, + true)) { + List actualRows = Lists.newArrayList(); + while (reader.next()) { + actualRows.add(reader.get().copy()); + } + + String dataFileLocation = + formatVersion >= 3 ? deleteFile2.referencedDataFile() : dataFile2.location(); + List first = + Lists.newArrayList( + UTF8String.fromString(dataFileLocation), + 2L, + UTF8String.fromString(deleteFile2.location())); + List second = + Lists.newArrayList( + UTF8String.fromString(dataFileLocation), + 3L, + UTF8String.fromString(deleteFile2.location())); + + if (formatVersion >= 3) { + first.add(deleteFile2.contentOffset()); + first.add(deleteFile2.contentSizeInBytes()); + second.add(deleteFile2.contentOffset()); + second.add(deleteFile2.contentSizeInBytes()); + } + + assertThat(internalRowsToJava(actualRows, projectedSchema)) + .hasSize(2) + .containsExactly(first.toArray(), second.toArray()); + } + } + + @TestTemplate + public void readPositionDeletesTableWithDifferentColumnOrdering() throws IOException { + Pair posDeletes1 = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + Lists.newArrayList( + Pair.of(dataFile1.location(), 0L), Pair.of(dataFile1.location(), 1L)), + formatVersion); + + DeleteFile deleteFile1 = posDeletes1.first(); + table + .newRowDelta() + .addDeletes(deleteFile1) + .validateDataFilesExist(posDeletes1.second()) + .commit(); + + Table positionDeletesTable = + catalog.loadTable(TableIdentifier.of("default", "test", "position_deletes")); + + // select a few fields in backwards order + Schema projectedSchema = + new Schema(MetadataColumns.DELETE_FILE_POS, MetadataColumns.DELETE_FILE_PATH); + + List scanTasks = + Lists.newArrayList( + positionDeletesTable.newBatchScan().project(projectedSchema).planFiles()); + assertThat(scanTasks).hasSize(1); + + assertThat(scanTasks.get(0)).isInstanceOf(PositionDeletesScanTask.class); + PositionDeletesScanTask scanTask1 = (PositionDeletesScanTask) scanTasks.get(0); + + try (PositionDeletesRowReader reader = + new PositionDeletesRowReader( + table, + table.io(), + new BaseScanTaskGroup<>(null, ImmutableList.of(scanTask1)), + projectedSchema, + false, + true)) { + List actualRows = Lists.newArrayList(); + while (reader.next()) { + actualRows.add(reader.get().copy()); + } + + assertThat(internalRowsToJava(actualRows, projectedSchema)) + .hasSize(2) + .containsExactly( + new Object[] {0L, UTF8String.fromString(dataFile1.location())}, + new Object[] {1L, UTF8String.fromString(dataFile1.location())}); + } + } + + private DataFile writeDataFile(List records) throws IOException { + return FileHelpers.writeDataFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + records); + } + + private List internalRowsToJava(List rows, Schema projection) { + return rows.stream().map(row -> toJava(row, projection)).collect(Collectors.toList()); + } + + private Object[] toJava(InternalRow row, Schema projection) { + Object[] values = new Object[row.numFields()]; + for (int i = 0; i < projection.columns().size(); i++) { + values[i] = row.get(i, SparkSchemaUtil.convert(projection.columns().get(i).type())); + } + return values; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java new file mode 100644 index 000000000000..14ad107e50e3 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestPositionDeletesTable.java @@ -0,0 +1,1795 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Partitioning; +import org.apache.iceberg.PositionDeletesScanTask; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.deletes.PositionDelete; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.PositionDeletesRewriteCoordinator; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkStructLike; +import org.apache.iceberg.spark.SparkTableCache; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.CharSequenceSet; +import org.apache.iceberg.util.DeleteFileSet; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.ScanTaskUtil; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.functions; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPositionDeletesTable extends CatalogTestBase { + + public static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get())); + private static final Map CATALOG_PROPS = + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "cache-enabled", "false"); + private static final List NON_PATH_COLS = + ImmutableList.of("file_path", "pos", "row", "partition", "spec_id"); + private static final List NON_PATH_V3_COLS = + ImmutableList.builder() + .addAll(NON_PATH_COLS) + .add("content_offset") + .add("content_size_in_bytes") + .build(); + + @Parameter(index = 3) + private FileFormat format; + + @Parameter(index = 4) + private int formatVersion; + + @Parameters( + name = + "catalogName = {1}, implementation = {2}, config = {3}, fileFormat = {4}, formatVersion = {5}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + CATALOG_PROPS, + FileFormat.PARQUET, + 2 + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + CATALOG_PROPS, + FileFormat.AVRO, + 2 + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + CATALOG_PROPS, + FileFormat.ORC, + 2 + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + CATALOG_PROPS, + FileFormat.PARQUET, + 3 + }, + }; + } + + @TestTemplate + public void testNullRows() throws IOException { + String tableName = "null_rows"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + + DataFile dFile = dataFile(tab); + tab.newAppend().appendFile(dFile).commit(); + + List> deletes = Lists.newArrayList(); + deletes.add(Pair.of(dFile.location(), 0L)); + deletes.add(Pair.of(dFile.location(), 1L)); + Pair posDeletes = + FileHelpers.writeDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + deletes, + formatVersion); + tab.newRowDelta().addDeletes(posDeletes.first()).commit(); + + StructLikeSet actual = actual(tableName, tab); + + List> expectedDeletes = + Lists.newArrayList( + positionDelete(dFile.location(), 0L), positionDelete(dFile.location(), 1L)); + StructLikeSet expected = expected(tab, expectedDeletes, null, posDeletes.first()); + + assertThat(actual).as("Position Delete table should contain expected rows").isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testPartitionedTable() throws IOException { + assumeThat(formatVersion).as("DVs don't have row info in PositionDeletesTable").isEqualTo(2); + // Create table with two partitions + String tableName = "partitioned_table"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + // Add position deletes for both partitions + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Select deletes from one partition + StructLikeSet actual = actual(tableName, tab, "row.data='b'"); + GenericRecord partitionB = GenericRecord.create(tab.spec().partitionType()); + partitionB.setField("data", "b"); + StructLikeSet expected = expected(tab, deletesB.first(), partitionB, deletesB.second()); + + assertThat(actual).as("Position Delete table should contain expected rows").isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testArrayColumnFilter() throws IOException { + assumeThat(formatVersion) + .as("Row content in position_deletes is required for array column filter test") + .isEqualTo(2); + String tableName = "array_column_filter"; + Schema schemaWithArray = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional( + 3, "arr_col", Types.ListType.ofOptional(4, Types.IntegerType.get()))); + Table tab = createTable(tableName, schemaWithArray, PartitionSpec.unpartitioned()); + + GenericRecord record1 = GenericRecord.create(schemaWithArray); + record1.set(0, 1); + record1.set(1, "a"); + record1.set(2, ImmutableList.of(1, 2)); + GenericRecord record2 = GenericRecord.create(schemaWithArray); + record2.set(0, 2); + record2.set(1, "b"); + record2.set(2, ImmutableList.of(3, 4)); + List dataRecords = ImmutableList.of(record1, record2); + DataFile dFile = + FileHelpers.writeDataFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + dataRecords); + tab.newAppend().appendFile(dFile).commit(); + + List> deletes = + ImmutableList.of( + positionDelete(schemaWithArray, dFile.location(), 0L, 1, "a", ImmutableList.of(1, 2)), + positionDelete(schemaWithArray, dFile.location(), 1L, 2, "b", ImmutableList.of(3, 4))); + DeleteFile posDeletes = + FileHelpers.writePosDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(), + deletes, + formatVersion); + tab.newRowDelta().addDeletes(posDeletes).commit(); + + // Filter directly on array column: row.arr_col = array(1, 2) + StructLikeSet actual = actual(tableName, tab, "row.arr_col = array(1, 2)"); + StructLikeSet expected = expected(tab, ImmutableList.of(deletes.get(0)), null, posDeletes); + + assertThat(actual) + .as("Filtering position_deletes by row.arr_col = array(1, 2) should return matching row") + .isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testSelect() throws IOException { + assumeThat(formatVersion).as("DVs don't have row info in PositionDeletesTable").isEqualTo(2); + // Create table with two partitions + String tableName = "select"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + // Add position deletes for both partitions + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Select certain columns + Dataset df = + spark + .read() + .format("iceberg") + .load("default." + tableName + ".position_deletes") + .withColumn("input_file", functions.input_file_name()) + .select("row.id", "pos", "delete_file_path", "input_file"); + List actual = rowsToJava(df.collectAsList()); + + // Select cols from expected delete values + List expected = Lists.newArrayList(); + BiFunction, DeleteFile, Object[]> toRow = + (delete, file) -> { + int rowData = delete.get(2, GenericRecord.class).get(0, Integer.class); + long pos = delete.get(1, Long.class); + return row(rowData, pos, file.location(), file.location()); + }; + expected.addAll( + deletesA.first().stream() + .map(d -> toRow.apply(d, deletesA.second())) + .collect(Collectors.toList())); + expected.addAll( + deletesB.first().stream() + .map(d -> toRow.apply(d, deletesB.second())) + .collect(Collectors.toList())); + + // Sort and compare + Comparator comp = + (o1, o2) -> { + int result = Integer.compare((int) o1[0], (int) o2[0]); + if (result != 0) { + return result; + } else { + return ((String) o1[2]).compareTo((String) o2[2]); + } + }; + actual.sort(comp); + expected.sort(comp); + assertThat(actual) + .as("Position Delete table should contain expected rows") + .usingRecursiveComparison() + .isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testSelectWithDVs() throws IOException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + String tableName = "select_with_dvs"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + + DataFile dataFileA = dataFile(tab); + DataFile dataFileB = dataFile(tab); + + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB); + + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Select certain columns + Dataset df = + spark + .read() + .format("iceberg") + .load("default." + tableName + ".position_deletes") + .select( + "pos", "file_path", "delete_file_path", "content_offset", "content_size_in_bytes"); + List actual = rowsToJava(df.collectAsList()); + + // Select cols from expected delete values + List expected = Lists.newArrayList(); + BiFunction, DeleteFile, Object[]> toRow = + (delete, file) -> + row( + delete.get(1, Long.class), + file.referencedDataFile(), + file.location(), + file.contentOffset(), + ScanTaskUtil.contentSizeInBytes(file)); + + expected.addAll( + deletesA.first().stream() + .map(d -> toRow.apply(d, deletesA.second())) + .collect(Collectors.toList())); + expected.addAll( + deletesB.first().stream() + .map(d -> toRow.apply(d, deletesB.second())) + .collect(Collectors.toList())); + + // Sort by pos and file_path + Comparator comp = + (o1, o2) -> { + int result = Long.compare((long) o1[0], (long) o2[0]); + if (result != 0) { + return result; + } else { + return ((String) o1[1]).compareTo((String) o2[1]); + } + }; + + actual.sort(comp); + expected.sort(comp); + + assertThat(actual) + .as("Position Delete table should contain expected rows") + .usingRecursiveComparison() + .isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testSplitTasks() throws IOException { + String tableName = "big_table"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + tab.updateProperties().set("read.split.target-size", "100").commit(); + int records = 500; + + GenericRecord record = GenericRecord.create(tab.schema()); + List dataRecords = Lists.newArrayList(); + for (int i = 0; i < records; i++) { + dataRecords.add(record.copy("id", i, "data", String.valueOf(i))); + } + DataFile dFile = + FileHelpers.writeDataFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + org.apache.iceberg.TestHelpers.Row.of(), + dataRecords); + tab.newAppend().appendFile(dFile).commit(); + + List> deletes = Lists.newArrayList(); + for (long i = 0; i < records; i++) { + deletes.add(positionDelete(tab.schema(), dFile.location(), i, (int) i, String.valueOf(i))); + } + DeleteFile posDeletes = + FileHelpers.writePosDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + deletes, + formatVersion); + tab.newRowDelta().addDeletes(posDeletes).commit(); + + Table deleteTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + + if (format.equals(FileFormat.AVRO)) { + assertThat(deleteTable.newBatchScan().planTasks()) + .as("Position delete scan should produce more than one split") + .hasSizeGreaterThan(1); + } else { + assertThat(deleteTable.newBatchScan().planTasks()) + .as("Position delete scan should produce one split") + .hasSize(1); + } + + StructLikeSet actual = actual(tableName, tab); + StructLikeSet expected = expected(tab, deletes, null, posDeletes); + + assertThat(actual).as("Position Delete table should contain expected rows").isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testPartitionFilter() throws IOException { + // Create table with two partitions + String tableName = "partition_filter"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + Table deletesTab = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + // Add position deletes for both partitions + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Prepare expected values + GenericRecord partitionRecordTemplate = GenericRecord.create(tab.spec().partitionType()); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + Record partitionB = partitionRecordTemplate.copy("data", "b"); + StructLikeSet expectedA = expected(tab, deletesA.first(), partitionA, deletesA.second()); + StructLikeSet expectedB = expected(tab, deletesB.first(), partitionB, deletesB.second()); + StructLikeSet allExpected = StructLikeSet.create(deletesTab.schema().asStruct()); + allExpected.addAll(expectedA); + allExpected.addAll(expectedB); + + // Select deletes from all partitions + StructLikeSet actual = actual(tableName, tab); + assertThat(actual) + .as("Position Delete table should contain expected rows") + .isEqualTo(allExpected); + + // Select deletes from one partition + StructLikeSet actual2 = actual(tableName, tab, "partition.data = 'a' AND pos >= 0"); + + assertThat(actual2) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + dropTable(tableName); + } + + @TestTemplate + public void testPartitionTransformFilter() throws IOException { + // Create table with two partitions + String tableName = "partition_filter"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).truncate("data", 1).build(); + Table tab = createTable(tableName, SCHEMA, spec); + Table deletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + + DataFile dataFileA = dataFile(tab, new Object[] {"aa"}, new Object[] {"a"}); + DataFile dataFileB = dataFile(tab, new Object[] {"bb"}, new Object[] {"b"}); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + // Add position deletes for both partitions + Pair>, DeleteFile> deletesA = + deleteFile(tab, dataFileA, new Object[] {"aa"}, new Object[] {"a"}); + Pair>, DeleteFile> deletesB = + deleteFile(tab, dataFileB, new Object[] {"bb"}, new Object[] {"b"}); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Prepare expected values + GenericRecord partitionRecordTemplate = GenericRecord.create(tab.spec().partitionType()); + Record partitionA = partitionRecordTemplate.copy("data_trunc", "a"); + Record partitionB = partitionRecordTemplate.copy("data_trunc", "b"); + StructLikeSet expectedA = expected(tab, deletesA.first(), partitionA, deletesA.second()); + StructLikeSet expectedB = expected(tab, deletesB.first(), partitionB, deletesB.second()); + StructLikeSet allExpected = StructLikeSet.create(deletesTable.schema().asStruct()); + allExpected.addAll(expectedA); + allExpected.addAll(expectedB); + + // Select deletes from all partitions + StructLikeSet actual = actual(tableName, tab); + assertThat(actual) + .as("Position Delete table should contain expected rows") + .isEqualTo(allExpected); + + // Select deletes from one partition + StructLikeSet actual2 = actual(tableName, tab, "partition.data_trunc = 'a' AND pos >= 0"); + + assertThat(actual2) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + dropTable(tableName); + } + + @TestTemplate + public void testPartitionEvolutionReplace() throws Exception { + // Create table with spec (data) + String tableName = "partition_evolution"; + PartitionSpec originalSpec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, originalSpec); + int dataSpec = tab.spec().specId(); + + // Add files with old spec (data) + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Switch partition spec from (data) to (id) + tab.updateSpec().removeField("data").addField("id").commit(); + + // Add data and delete files with new spec (id) + DataFile dataFile10 = dataFile(tab, 10); + DataFile dataFile99 = dataFile(tab, 99); + tab.newAppend().appendFile(dataFile10).appendFile(dataFile99).commit(); + + Pair>, DeleteFile> deletes10 = deleteFile(tab, dataFile10, 10); + Pair>, DeleteFile> deletes99 = deleteFile(tab, dataFile99, 99); + tab.newRowDelta().addDeletes(deletes10.second()).addDeletes(deletes99.second()).commit(); + + // Query partition of old spec + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + StructLikeSet expectedA = + expected(tab, deletesA.first(), partitionA, dataSpec, deletesA.second()); + StructLikeSet actualA = actual(tableName, tab, "partition.data = 'a' AND pos >= 0"); + assertThat(actualA) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + + // Query partition of new spec + Record partition10 = partitionRecordTemplate.copy("id", 10); + StructLikeSet expected10 = + expected(tab, deletes10.first(), partition10, tab.spec().specId(), deletes10.second()); + StructLikeSet actual10 = actual(tableName, tab, "partition.id = 10 AND pos >= 0"); + + assertThat(actual10) + .as("Position Delete table should contain expected rows") + .isEqualTo(expected10); + dropTable(tableName); + } + + @TestTemplate + public void testPartitionEvolutionAdd() throws Exception { + // Create unpartitioned table + String tableName = "partition_evolution_add"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + int specId0 = tab.spec().specId(); + + // Add files with unpartitioned spec + DataFile dataFileUnpartitioned = dataFile(tab); + tab.newAppend().appendFile(dataFileUnpartitioned).commit(); + Pair>, DeleteFile> deletesUnpartitioned = + deleteFile(tab, dataFileUnpartitioned); + tab.newRowDelta().addDeletes(deletesUnpartitioned.second()).commit(); + + // Switch partition spec to (data) + tab.updateSpec().addField("data").commit(); + int specId1 = tab.spec().specId(); + + // Add files with new spec (data) + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Select deletes from new spec (data) + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + StructLikeSet expectedA = + expected(tab, deletesA.first(), partitionA, specId1, deletesA.second()); + StructLikeSet actualA = actual(tableName, tab, "partition.data = 'a' AND pos >= 0"); + assertThat(actualA) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + + // Select deletes from 'unpartitioned' data + Record unpartitionedRecord = partitionRecordTemplate.copy("data", null); + StructLikeSet expectedUnpartitioned = + expected( + tab, + deletesUnpartitioned.first(), + unpartitionedRecord, + specId0, + deletesUnpartitioned.second()); + StructLikeSet actualUnpartitioned = + actual(tableName, tab, "partition.data IS NULL and pos >= 0"); + + assertThat(actualUnpartitioned) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedUnpartitioned); + dropTable(tableName); + } + + @TestTemplate + public void testPartitionEvolutionRemove() throws Exception { + // Create table with spec (data) + String tableName = "partition_evolution_remove"; + PartitionSpec originalSpec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, originalSpec); + int specId0 = tab.spec().specId(); + + // Add files with spec (data) + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Remove partition field + tab.updateSpec().removeField("data").commit(); + int specId1 = tab.spec().specId(); + + // Add unpartitioned files + DataFile dataFileUnpartitioned = dataFile(tab); + tab.newAppend().appendFile(dataFileUnpartitioned).commit(); + Pair>, DeleteFile> deletesUnpartitioned = + deleteFile(tab, dataFileUnpartitioned); + tab.newRowDelta().addDeletes(deletesUnpartitioned.second()).commit(); + + // Select deletes from (data) spec + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + StructLikeSet expectedA = + expected(tab, deletesA.first(), partitionA, specId0, deletesA.second()); + StructLikeSet actualA = actual(tableName, tab, "partition.data = 'a' AND pos >= 0"); + assertThat(actualA) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + + // Select deletes from 'unpartitioned' spec + Record unpartitionedRecord = partitionRecordTemplate.copy("data", null); + StructLikeSet expectedUnpartitioned = + expected( + tab, + deletesUnpartitioned.first(), + unpartitionedRecord, + specId1, + deletesUnpartitioned.second()); + StructLikeSet actualUnpartitioned = + actual(tableName, tab, "partition.data IS NULL and pos >= 0"); + + assertThat(actualUnpartitioned) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedUnpartitioned); + dropTable(tableName); + } + + @TestTemplate + public void testSpecIdFilter() throws Exception { + // Create table with spec (data) + String tableName = "spec_id_filter"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + int unpartitionedSpec = tab.spec().specId(); + + // Add data file and delete + DataFile dataFileUnpartitioned = dataFile(tab); + tab.newAppend().appendFile(dataFileUnpartitioned).commit(); + Pair>, DeleteFile> deletesUnpartitioned = + deleteFile(tab, dataFileUnpartitioned); + tab.newRowDelta().addDeletes(deletesUnpartitioned.second()).commit(); + + // Switch partition spec to (data) and add files + tab.updateSpec().addField("data").commit(); + int dataSpec = tab.spec().specId(); + + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Select deletes from 'unpartitioned' + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + StructLikeSet expectedUnpartitioned = + expected( + tab, + deletesUnpartitioned.first(), + partitionRecordTemplate, + unpartitionedSpec, + deletesUnpartitioned.second()); + StructLikeSet actualUnpartitioned = + actual(tableName, tab, String.format("spec_id = %d", unpartitionedSpec)); + assertThat(actualUnpartitioned) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedUnpartitioned); + + // Select deletes from 'data' partition spec + StructLike partitionA = partitionRecordTemplate.copy("data", "a"); + StructLike partitionB = partitionRecordTemplate.copy("data", "b"); + StructLikeSet expected = + expected(tab, deletesA.first(), partitionA, dataSpec, deletesA.second()); + expected.addAll(expected(tab, deletesB.first(), partitionB, dataSpec, deletesB.second())); + + StructLikeSet actual = actual(tableName, tab, String.format("spec_id = %d", dataSpec)); + assertThat(actual).as("Position Delete table should contain expected rows").isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testSchemaEvolutionAdd() throws Exception { + // Create table with original schema + String tableName = "schema_evolution_add"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + + // Add files with original schema + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Add files with new schema + tab.updateSchema() + .addColumn("new_col_1", Types.IntegerType.get()) + .addColumn("new_col_2", Types.IntegerType.get()) + .commit(); + + // Add files with new schema + DataFile dataFileC = dataFile(tab, "c"); + DataFile dataFileD = dataFile(tab, "d"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesC = deleteFile(tab, dataFileC, "c"); + Pair>, DeleteFile> deletesD = deleteFile(tab, dataFileD, "d"); + tab.newRowDelta().addDeletes(deletesC.second()).addDeletes(deletesD.second()).commit(); + + // Select deletes from old schema + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + // pad expected delete rows with null values for new columns + List> expectedDeletesA = deletesA.first(); + expectedDeletesA.forEach( + d -> { + GenericRecord nested = d.get(2, GenericRecord.class); + GenericRecord padded = GenericRecord.create(tab.schema().asStruct()); + padded.set(0, nested.get(0)); + padded.set(1, nested.get(1)); + padded.set(2, null); + padded.set(3, null); + d.set(2, padded); + }); + StructLikeSet expectedA = expected(tab, expectedDeletesA, partitionA, deletesA.second()); + StructLikeSet actualA = actual(tableName, tab, "partition.data = 'a' AND pos >= 0"); + assertThat(actualA) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + + // Select deletes from new schema + Record partitionC = partitionRecordTemplate.copy("data", "c"); + StructLikeSet expectedC = expected(tab, deletesC.first(), partitionC, deletesC.second()); + StructLikeSet actualC = actual(tableName, tab, "partition.data = 'c' and pos >= 0"); + + assertThat(actualC) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedC); + dropTable(tableName); + } + + @TestTemplate + public void testSchemaEvolutionRemove() throws Exception { + // Create table with original schema + String tableName = "schema_evolution_remove"; + Schema oldSchema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "new_col_1", Types.IntegerType.get()), + Types.NestedField.optional(4, "new_col_2", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(oldSchema).identity("data").build(); + Table tab = createTable(tableName, oldSchema, spec); + + // Add files with original schema + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Add files with new schema + tab.updateSchema().deleteColumn("new_col_1").deleteColumn("new_col_2").commit(); + + // Add files with new schema + DataFile dataFileC = dataFile(tab, "c"); + DataFile dataFileD = dataFile(tab, "d"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesC = deleteFile(tab, dataFileC, "c"); + Pair>, DeleteFile> deletesD = deleteFile(tab, dataFileD, "d"); + tab.newRowDelta().addDeletes(deletesC.second()).addDeletes(deletesD.second()).commit(); + + // Select deletes from old schema + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + // remove deleted columns from expected result + List> expectedDeletesA = deletesA.first(); + expectedDeletesA.forEach( + d -> { + GenericRecord nested = d.get(2, GenericRecord.class); + GenericRecord padded = GenericRecord.create(tab.schema().asStruct()); + padded.set(0, nested.get(0)); + padded.set(1, nested.get(1)); + d.set(2, padded); + }); + StructLikeSet expectedA = expected(tab, expectedDeletesA, partitionA, deletesA.second()); + StructLikeSet actualA = actual(tableName, tab, "partition.data = 'a' AND pos >= 0"); + assertThat(actualA) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + + // Select deletes from new schema + Record partitionC = partitionRecordTemplate.copy("data", "c"); + StructLikeSet expectedC = expected(tab, deletesC.first(), partitionC, deletesC.second()); + StructLikeSet actualC = actual(tableName, tab, "partition.data = 'c' and pos >= 0"); + + assertThat(actualC) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedC); + dropTable(tableName); + } + + @TestTemplate + public void testWrite() throws IOException, NoSuchTableException { + String tableName = "test_write"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + // Add position deletes for both partitions + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + Table posDeletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + for (String partValue : ImmutableList.of("a", "b")) { + try (CloseableIterable tasks = tasks(posDeletesTable, "data", partValue)) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + commit(tab, posDeletesTable, fileSetID, 1); + } + } + + // Prepare expected values (without 'delete_file_path' as these have been rewritten) + GenericRecord partitionRecordTemplate = GenericRecord.create(tab.spec().partitionType()); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + Record partitionB = partitionRecordTemplate.copy("data", "b"); + StructLikeSet expectedA = expected(tab, deletesA.first(), partitionA, deletesA.second(), false); + StructLikeSet expectedB = expected(tab, deletesB.first(), partitionB, deletesB.second(), false); + StructLikeSet allExpected = + StructLikeSet.create( + TypeUtil.selectNot( + posDeletesTable.schema(), ImmutableSet.of(MetadataColumns.FILE_PATH_COLUMN_ID)) + .asStruct()); + allExpected.addAll(expectedA); + allExpected.addAll(expectedB); + + // Compare values without 'delete_file_path' as these have been rewritten + StructLikeSet actual = + actual(tableName, tab, null, formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + assertThat(actual) + .as("Position Delete table should contain expected rows") + .isEqualTo(allExpected); + dropTable(tableName); + } + + @TestTemplate + public void testWriteUnpartitionedNullRows() throws Exception { + String tableName = "write_null_rows"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + + DataFile dFile = dataFile(tab); + tab.newAppend().appendFile(dFile).commit(); + + List> deletes = Lists.newArrayList(); + deletes.add(Pair.of(dFile.location(), 0L)); + deletes.add(Pair.of(dFile.location(), 1L)); + Pair posDeletes = + FileHelpers.writeDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + deletes, + formatVersion); + tab.newRowDelta().addDeletes(posDeletes.first()).commit(); + + Table posDeletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + try (CloseableIterable tasks = posDeletesTable.newBatchScan().planFiles()) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + commit(tab, posDeletesTable, fileSetID, 1); + } + + List columns = ImmutableList.of("file_path", "pos", "row", "spec_id"); + if (formatVersion >= 3) { + columns = + ImmutableList.builder() + .addAll(columns) + .add("content_offset") + .add("content_size_in_bytes") + .build(); + } + + // Compare values without 'delete_file_path' as these have been rewritten + StructLikeSet actual = actual(tableName, tab, null, columns); + + List> expectedDeletes = + Lists.newArrayList( + positionDelete(dFile.location(), 0L), positionDelete(dFile.location(), 1L)); + StructLikeSet expected = expected(tab, expectedDeletes, null, posDeletes.first(), false); + + assertThat(actual).as("Position Delete table should contain expected rows").isEqualTo(expected); + dropTable(tableName); + } + + @TestTemplate + public void testWriteMixedRows() throws Exception { + String tableName = "write_mixed_rows"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + // Add a delete file with row and without row + List> deletes = Lists.newArrayList(); + deletes.add(Pair.of(dataFileA.location(), 0L)); + deletes.add(Pair.of(dataFileA.location(), 1L)); + Pair deletesWithoutRow = + FileHelpers.writeDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of("a"), + deletes, + formatVersion); + + Pair>, DeleteFile> deletesWithRow = deleteFile(tab, dataFileB, "b"); + + tab.newRowDelta() + .addDeletes(deletesWithoutRow.first()) + .addDeletes(deletesWithRow.second()) + .commit(); + + // rewrite delete files + Table posDeletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + for (String partValue : ImmutableList.of("a", "b")) { + try (CloseableIterable tasks = tasks(posDeletesTable, "data", partValue)) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = spark.read().format("iceberg").load(fileSetID); + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + commit(tab, posDeletesTable, fileSetID, 1); + } + } + + // Compare values without 'delete_file_path' as these have been rewritten + StructLikeSet actual = + actual(tableName, tab, null, formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + + // Prepare expected values + GenericRecord partitionRecordTemplate = GenericRecord.create(tab.spec().partitionType()); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + Record partitionB = partitionRecordTemplate.copy("data", "b"); + StructLikeSet allExpected = + StructLikeSet.create( + TypeUtil.selectNot( + posDeletesTable.schema(), ImmutableSet.of(MetadataColumns.FILE_PATH_COLUMN_ID)) + .asStruct()); + allExpected.addAll( + expected( + tab, + Lists.newArrayList( + positionDelete(dataFileA.location(), 0L), positionDelete(dataFileA.location(), 1L)), + partitionA, + deletesWithoutRow.first(), + false)); + allExpected.addAll( + expected(tab, deletesWithRow.first(), partitionB, deletesWithRow.second(), false)); + + assertThat(actual) + .as("Position Delete table should contain expected rows") + .isEqualTo(allExpected); + dropTable(tableName); + } + + @TestTemplate + public void testWritePartitionEvolutionAdd() throws Exception { + // Create unpartitioned table + String tableName = "write_partition_evolution_add"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + int specId0 = tab.spec().specId(); + + // Add files with unpartitioned spec + DataFile dataFileUnpartitioned = dataFile(tab); + tab.newAppend().appendFile(dataFileUnpartitioned).commit(); + Pair>, DeleteFile> deletesUnpartitioned = + deleteFile(tab, dataFileUnpartitioned); + tab.newRowDelta().addDeletes(deletesUnpartitioned.second()).commit(); + + // Switch partition spec to (data) + tab.updateSpec().addField("data").commit(); + int specId1 = tab.spec().specId(); + + // Add files with new spec (data) + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + Table posDeletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + + // Read/write back unpartitioned data + try (CloseableIterable tasks = + posDeletesTable.newBatchScan().filter(Expressions.isNull("partition.data")).planFiles()) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + commit(tab, posDeletesTable, fileSetID, 1); + } + + // Select deletes from unpartitioned data + // Compare values without 'delete_file_path' as these have been rewritten + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record unpartitionedRecord = partitionRecordTemplate.copy("data", null); + StructLikeSet expectedUnpartitioned = + expected( + tab, + deletesUnpartitioned.first(), + unpartitionedRecord, + specId0, + deletesUnpartitioned.second(), + false); + StructLikeSet actualUnpartitioned = + actual( + tableName, + tab, + "partition.data IS NULL", + formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + assertThat(actualUnpartitioned) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedUnpartitioned); + + // Read/write back new partition spec (data) + for (String partValue : ImmutableList.of("a", "b")) { + try (CloseableIterable tasks = tasks(posDeletesTable, "data", partValue)) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + // commit the rewrite + commit(tab, posDeletesTable, fileSetID, 1); + } + } + + // Select deletes from new spec (data) + Record partitionA = partitionRecordTemplate.copy("data", "a"); + Record partitionB = partitionRecordTemplate.copy("data", "b"); + StructLikeSet expectedAll = + StructLikeSet.create( + TypeUtil.selectNot( + posDeletesTable.schema(), ImmutableSet.of(MetadataColumns.FILE_PATH_COLUMN_ID)) + .asStruct()); + expectedAll.addAll( + expected(tab, deletesA.first(), partitionA, specId1, deletesA.second(), false)); + expectedAll.addAll( + expected(tab, deletesB.first(), partitionB, specId1, deletesB.second(), false)); + StructLikeSet actualAll = + actual( + tableName, + tab, + "partition.data = 'a' OR partition.data = 'b'", + formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + assertThat(actualAll) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedAll); + dropTable(tableName); + } + + @TestTemplate + public void testWritePartitionEvolutionDisallowed() throws Exception { + // Create unpartitioned table + String tableName = "write_partition_evolution_write"; + Table tab = createTable(tableName, SCHEMA, PartitionSpec.unpartitioned()); + + // Add files with unpartitioned spec + DataFile dataFileUnpartitioned = dataFile(tab); + tab.newAppend().appendFile(dataFileUnpartitioned).commit(); + Pair>, DeleteFile> deletesUnpartitioned = + deleteFile(tab, dataFileUnpartitioned); + tab.newRowDelta().addDeletes(deletesUnpartitioned.second()).commit(); + + Table posDeletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + String posDeletesTableName = catalogName + ".default." + tableName + ".position_deletes"; + + Dataset scanDF; + String fileSetID = UUID.randomUUID().toString(); + try (CloseableIterable tasks = posDeletesTable.newBatchScan().planFiles()) { + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + + // Add partition field to render the original un-partitioned dataset un-commitable + tab.updateSpec().addField("data").commit(); + } + + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + assertThat(Arrays.asList(scanDF.columns()).contains("partition")); + + dropTable(tableName); + dropTable(posDeletesTableName); + } + + @TestTemplate + public void testWriteSchemaEvolutionAdd() throws Exception { + // Create table with original schema + String tableName = "write_schema_evolution_add"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + + // Add files with original schema + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Add files with new schema + tab.updateSchema() + .addColumn("new_col_1", Types.IntegerType.get()) + .addColumn("new_col_2", Types.IntegerType.get()) + .commit(); + + // Add files with new schema + DataFile dataFileC = dataFile(tab, "c"); + DataFile dataFileD = dataFile(tab, "d"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesC = deleteFile(tab, dataFileC, "c"); + Pair>, DeleteFile> deletesD = deleteFile(tab, dataFileD, "d"); + tab.newRowDelta().addDeletes(deletesC.second()).addDeletes(deletesD.second()).commit(); + + Table posDeletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + + // rewrite files of old schema + try (CloseableIterable tasks = tasks(posDeletesTable, "data", "a")) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + commit(tab, posDeletesTable, fileSetID, 1); + } + + // Select deletes from old schema + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + // pad expected delete rows with null values for new columns + List> expectedDeletesA = deletesA.first(); + expectedDeletesA.forEach( + d -> { + GenericRecord nested = d.get(2, GenericRecord.class); + GenericRecord padded = GenericRecord.create(tab.schema().asStruct()); + padded.set(0, nested.get(0)); + padded.set(1, nested.get(1)); + padded.set(2, null); + padded.set(3, null); + d.set(2, padded); + }); + StructLikeSet expectedA = expected(tab, expectedDeletesA, partitionA, deletesA.second(), false); + StructLikeSet actualA = + actual( + tableName, + tab, + "partition.data = 'a'", + formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + assertThat(actualA) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + + // rewrite files of new schema + try (CloseableIterable tasks = tasks(posDeletesTable, "data", "c")) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + commit(tab, posDeletesTable, fileSetID, 1); + } + + // Select deletes from new schema + Record partitionC = partitionRecordTemplate.copy("data", "c"); + StructLikeSet expectedC = expected(tab, deletesC.first(), partitionC, deletesC.second(), false); + StructLikeSet actualC = + actual( + tableName, + tab, + "partition.data = 'c'", + formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + + assertThat(actualC) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedC); + dropTable(tableName); + } + + @TestTemplate + public void testWriteSchemaEvolutionRemove() throws Exception { + // Create table with original schema + String tableName = "write_schema_evolution_remove"; + Schema oldSchema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "new_col_1", Types.IntegerType.get()), + Types.NestedField.optional(4, "new_col_2", Types.IntegerType.get())); + PartitionSpec spec = PartitionSpec.builderFor(oldSchema).identity("data").build(); + Table tab = createTable(tableName, oldSchema, spec); + + // Add files with original schema + DataFile dataFileA = dataFile(tab, "a"); + DataFile dataFileB = dataFile(tab, "b"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + Pair>, DeleteFile> deletesB = deleteFile(tab, dataFileB, "b"); + tab.newRowDelta().addDeletes(deletesA.second()).addDeletes(deletesB.second()).commit(); + + // Add files with new schema + tab.updateSchema().deleteColumn("new_col_1").deleteColumn("new_col_2").commit(); + + // Add files with new schema + DataFile dataFileC = dataFile(tab, "c"); + DataFile dataFileD = dataFile(tab, "d"); + tab.newAppend().appendFile(dataFileA).appendFile(dataFileB).commit(); + + Pair>, DeleteFile> deletesC = deleteFile(tab, dataFileC, "c"); + Pair>, DeleteFile> deletesD = deleteFile(tab, dataFileD, "d"); + tab.newRowDelta().addDeletes(deletesC.second()).addDeletes(deletesD.second()).commit(); + + Table posDeletesTable = + MetadataTableUtils.createMetadataTableInstance(tab, MetadataTableType.POSITION_DELETES); + + // rewrite files + for (String partValue : ImmutableList.of("a", "b", "c", "d")) { + try (CloseableIterable tasks = tasks(posDeletesTable, "data", partValue)) { + String fileSetID = UUID.randomUUID().toString(); + SparkTableCache.get().add(fileSetID, posDeletesTable); + stageTask(tab, fileSetID, tasks); + + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.FILE_OPEN_COST, Integer.MAX_VALUE) + .load(fileSetID); + assertThat(scanDF.javaRDD().getNumPartitions()).isEqualTo(1); + scanDF.write().format("iceberg").mode("append").save(fileSetID); + + commit(tab, posDeletesTable, fileSetID, 1); + } + } + + // Select deletes from old schema + GenericRecord partitionRecordTemplate = GenericRecord.create(Partitioning.partitionType(tab)); + Record partitionA = partitionRecordTemplate.copy("data", "a"); + // remove deleted columns from expected result + List> expectedDeletesA = deletesA.first(); + expectedDeletesA.forEach( + d -> { + GenericRecord nested = d.get(2, GenericRecord.class); + GenericRecord padded = GenericRecord.create(tab.schema().asStruct()); + padded.set(0, nested.get(0)); + padded.set(1, nested.get(1)); + d.set(2, padded); + }); + StructLikeSet expectedA = expected(tab, expectedDeletesA, partitionA, deletesA.second(), false); + StructLikeSet actualA = + actual( + tableName, + tab, + "partition.data = 'a'", + formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + assertThat(actualA) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedA); + + // Select deletes from new schema + Record partitionC = partitionRecordTemplate.copy("data", "c"); + StructLikeSet expectedC = expected(tab, deletesC.first(), partitionC, deletesC.second(), false); + StructLikeSet actualC = + actual( + tableName, + tab, + "partition.data = 'c'", + formatVersion >= 3 ? NON_PATH_V3_COLS : NON_PATH_COLS); + + assertThat(actualC) + .as("Position Delete table should contain expected rows") + .isEqualTo(expectedC); + dropTable(tableName); + } + + @TestTemplate + public void testNormalWritesNotAllowed() throws IOException { + String tableName = "test_normal_write_not_allowed"; + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table tab = createTable(tableName, SCHEMA, spec); + + DataFile dataFileA = dataFile(tab, "a"); + tab.newAppend().appendFile(dataFileA).commit(); + + Pair>, DeleteFile> deletesA = deleteFile(tab, dataFileA, "a"); + tab.newRowDelta().addDeletes(deletesA.second()).commit(); + + String posDeletesTableName = catalogName + ".default." + tableName + ".position_deletes"; + + Dataset scanDF = spark.read().format("iceberg").load(posDeletesTableName); + + assertThatThrownBy(() -> scanDF.writeTo(posDeletesTableName).append()) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessage("Cannot append to a metadata table"); + + dropTable(tableName); + } + + private StructLikeSet actual(String tableName, Table table) { + return actual(tableName, table, null, null); + } + + private StructLikeSet actual(String tableName, Table table, String filter) { + return actual(tableName, table, filter, null); + } + + private StructLikeSet actual(String tableName, Table table, String filter, List cols) { + Dataset df = + spark + .read() + .format("iceberg") + .load(catalogName + ".default." + tableName + ".position_deletes"); + if (filter != null) { + df = df.filter(filter); + } + if (cols != null) { + df = df.select(cols.get(0), cols.subList(1, cols.size()).toArray(new String[0])); + } + Table deletesTable = + MetadataTableUtils.createMetadataTableInstance(table, MetadataTableType.POSITION_DELETES); + Types.StructType projection = deletesTable.schema().asStruct(); + if (cols != null) { + projection = + Types.StructType.of( + projection.fields().stream() + .filter(f -> cols.contains(f.name())) + .collect(Collectors.toList())); + } + + Types.StructType finalProjection = projection; + StructLikeSet set = StructLikeSet.create(projection); + df.collectAsList() + .forEach( + row -> { + SparkStructLike rowWrapper = new SparkStructLike(finalProjection); + set.add(rowWrapper.wrap(row)); + }); + + return set; + } + + protected Table createTable(String name, Schema schema, PartitionSpec spec) { + Map properties = + ImmutableMap.of( + TableProperties.FORMAT_VERSION, + String.valueOf(formatVersion), + TableProperties.DEFAULT_FILE_FORMAT, + format.toString()); + return validationCatalog.createTable( + TableIdentifier.of("default", name), schema, spec, properties); + } + + protected void dropTable(String name) { + validationCatalog.dropTable(TableIdentifier.of("default", name), false); + } + + private PositionDelete positionDelete(CharSequence path, Long position) { + PositionDelete posDelete = PositionDelete.create(); + posDelete.set(path, position, null); + return posDelete; + } + + private PositionDelete positionDelete( + Schema tableSchema, CharSequence path, Long position, Object... values) { + PositionDelete posDelete = PositionDelete.create(); + GenericRecord nested = GenericRecord.create(tableSchema); + for (int i = 0; i < values.length; i++) { + nested.set(i, values[i]); + } + posDelete.set(path, position, nested); + return posDelete; + } + + private StructLikeSet expected( + Table testTable, + List> deletes, + StructLike partitionStruct, + int specId, + DeleteFile deleteFile) { + return expected(testTable, deletes, partitionStruct, specId, deleteFile, true); + } + + private StructLikeSet expected( + Table testTable, + List> deletes, + StructLike partitionStruct, + int specId, + DeleteFile deleteFile, + boolean includeDeleteFilePath) { + Table deletesTable = + MetadataTableUtils.createMetadataTableInstance( + testTable, MetadataTableType.POSITION_DELETES); + Types.StructType posDeleteSchema = deletesTable.schema().asStruct(); + // Do not compare file paths + if (!includeDeleteFilePath) { + posDeleteSchema = + TypeUtil.selectNot( + deletesTable.schema(), ImmutableSet.of(MetadataColumns.FILE_PATH_COLUMN_ID)) + .asStruct(); + } + final Types.StructType finalSchema = posDeleteSchema; + StructLikeSet set = StructLikeSet.create(posDeleteSchema); + deletes.stream() + .map( + p -> { + GenericRecord record = GenericRecord.create(finalSchema); + record.setField("file_path", p.path()); + record.setField("pos", p.pos()); + record.setField("row", formatVersion >= 3 ? null : p.row()); + if (partitionStruct != null) { + record.setField("partition", partitionStruct); + } + record.setField("spec_id", specId); + if (includeDeleteFilePath) { + record.setField("delete_file_path", deleteFile.location()); + } + if (formatVersion >= 3) { + record.setField("content_offset", deleteFile.contentOffset()); + record.setField( + "content_size_in_bytes", ScanTaskUtil.contentSizeInBytes(deleteFile)); + } + return record; + }) + .forEach(set::add); + return set; + } + + private StructLikeSet expected( + Table testTable, + List> deletes, + StructLike partitionStruct, + DeleteFile deleteFile) { + return expected(testTable, deletes, partitionStruct, testTable.spec().specId(), deleteFile); + } + + private StructLikeSet expected( + Table testTable, + List> deletes, + StructLike partitionStruct, + DeleteFile deleteFile, + boolean includeDeleteFilePath) { + return expected( + testTable, + deletes, + partitionStruct, + testTable.spec().specId(), + deleteFile, + includeDeleteFilePath); + } + + private DataFile dataFile(Table tab, Object... partValues) throws IOException { + return dataFile(tab, partValues, partValues); + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + private DataFile dataFile(Table tab, Object[] partDataValues, Object[] partFieldValues) + throws IOException { + GenericRecord record = GenericRecord.create(tab.schema()); + List partitionFieldNames = + tab.spec().fields().stream().map(PartitionField::name).collect(Collectors.toList()); + int idIndex = partitionFieldNames.indexOf("id"); + int dataIndex = partitionFieldNames.indexOf("data"); + Integer idPartition = idIndex != -1 ? (Integer) partDataValues[idIndex] : null; + String dataPartition = dataIndex != -1 ? (String) partDataValues[dataIndex] : null; + + // fill columns with partition source fields, or preset values + List records = + Lists.newArrayList( + record.copy( + "id", + idPartition != null ? idPartition : 29, + "data", + dataPartition != null ? dataPartition : "c"), + record.copy( + "id", + idPartition != null ? idPartition : 43, + "data", + dataPartition != null ? dataPartition : "k"), + record.copy( + "id", + idPartition != null ? idPartition : 61, + "data", + dataPartition != null ? dataPartition : "r"), + record.copy( + "id", + idPartition != null ? idPartition : 89, + "data", + dataPartition != null ? dataPartition : "t")); + + // fill remaining columns with incremental values + List cols = tab.schema().columns(); + if (cols.size() > 2) { + for (int i = 2; i < cols.size(); i++) { + final int pos = i; + records.forEach(r -> r.set(pos, pos)); + } + } + + TestHelpers.Row partitionInfo = TestHelpers.Row.of(partFieldValues); + return FileHelpers.writeDataFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + partitionInfo, + records); + } + + private Pair>, DeleteFile> deleteFile( + Table tab, DataFile dataFile, Object... partValues) throws IOException { + return deleteFile(tab, dataFile, partValues, partValues); + } + + private Pair>, DeleteFile> deleteFile( + Table tab, DataFile dataFile, Object[] partDataValues, Object[] partFieldValues) + throws IOException { + List partFields = tab.spec().fields(); + List partitionFieldNames = + partFields.stream().map(PartitionField::name).collect(Collectors.toList()); + int idIndex = partitionFieldNames.indexOf("id"); + int dataIndex = partitionFieldNames.indexOf("data"); + Integer idPartition = idIndex != -1 ? (Integer) partDataValues[idIndex] : null; + String dataPartition = dataIndex != -1 ? (String) partDataValues[dataIndex] : null; + + // fill columns with partition source fields, or preset values + List> deletes = + Lists.newArrayList( + positionDelete( + tab.schema(), + dataFile.location(), + 0L, + idPartition != null ? idPartition : 29, + dataPartition != null ? dataPartition : "c"), + positionDelete( + tab.schema(), + dataFile.location(), + 1L, + idPartition != null ? idPartition : 61, + dataPartition != null ? dataPartition : "r")); + + // fill remaining columns with incremental values + List cols = tab.schema().columns(); + if (cols.size() > 2) { + for (int i = 2; i < cols.size(); i++) { + final int pos = i; + deletes.forEach(d -> d.get(2, GenericRecord.class).set(pos, pos)); + } + } + + TestHelpers.Row partitionInfo = TestHelpers.Row.of(partFieldValues); + + DeleteFile deleteFile = + FileHelpers.writePosDeleteFile( + tab, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + partitionInfo, + deletes, + formatVersion); + return Pair.of(deletes, deleteFile); + } + + private void stageTask( + Table tab, String fileSetID, CloseableIterable tasks) { + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + taskSetManager.stageTasks(tab, fileSetID, Lists.newArrayList(tasks)); + } + + private void commit( + Table baseTab, + Table posDeletesTable, + String fileSetID, + int expectedSourceFiles, + int expectedTargetFiles) { + PositionDeletesRewriteCoordinator rewriteCoordinator = PositionDeletesRewriteCoordinator.get(); + Set rewrittenFiles = + ScanTaskSetManager.get().fetchTasks(posDeletesTable, fileSetID).stream() + .map(t -> ((PositionDeletesScanTask) t).file()) + .collect(Collectors.toCollection(DeleteFileSet::create)); + Set addedFiles = rewriteCoordinator.fetchNewFiles(posDeletesTable, fileSetID); + + // Assert new files and old files are equal in number but different in paths + assertThat(rewrittenFiles).hasSize(expectedSourceFiles); + assertThat(addedFiles).hasSize(expectedTargetFiles); + + List sortedAddedFiles = + addedFiles.stream().map(ContentFile::location).sorted().collect(Collectors.toList()); + List sortedRewrittenFiles = + rewrittenFiles.stream().map(ContentFile::location).sorted().collect(Collectors.toList()); + assertThat(sortedRewrittenFiles) + .as("Lists should not be the same") + .isNotEqualTo(sortedAddedFiles); + + baseTab + .newRewrite() + .rewriteFiles(ImmutableSet.of(), rewrittenFiles, ImmutableSet.of(), addedFiles) + .commit(); + } + + private void commit(Table baseTab, Table posDeletesTable, String fileSetID, int expectedFiles) { + commit(baseTab, posDeletesTable, fileSetID, expectedFiles, expectedFiles); + } + + private CloseableIterable tasks( + Table posDeletesTable, String partitionColumn, String partitionValue) { + + Expression filter = Expressions.equal("partition." + partitionColumn, partitionValue); + CloseableIterable files = posDeletesTable.newBatchScan().filter(filter).planFiles(); + + // take care of fail to filter in some partition evolution cases + return CloseableIterable.filter( + files, + t -> { + StructLike filePartition = ((PositionDeletesScanTask) t).partition(); + String filePartitionValue = filePartition.get(0, String.class); + return filePartitionValue != null && filePartitionValue.equals(partitionValue); + }); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestReadProjection.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestReadProjection.java new file mode 100644 index 000000000000..9cf8e435270c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestReadProjection.java @@ -0,0 +1,654 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.avro.Schema.Type.UNION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Schema; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Comparators; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class TestReadProjection { + @Parameter(index = 0) + protected FileFormat format; + + protected abstract Record writeAndRead( + String desc, Schema writeSchema, Schema readSchema, Record record) throws IOException; + + @TempDir protected Path temp; + + @TestTemplate + public void testFullProjection() throws Exception { + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(1, "data", Types.StringType.get())); + + Record record = GenericRecord.create(schema); + record.setField("id", 34L); + record.setField("data", "test"); + + Record projected = writeAndRead("full_projection", schema, schema, record); + + assertThat((long) projected.getField("id")) + .as("Should contain the correct id value") + .isEqualTo(34L); + + int cmp = + Comparators.charSequences().compare("test", (CharSequence) projected.getField("data")); + assertThat(cmp).as("Should contain the correct data value").isEqualTo(0); + } + + @TestTemplate + public void testReorderedFullProjection() throws Exception { + // Assume.assumeTrue( + // "Spark's Parquet read support does not support reordered columns", + // !format.equalsIgnoreCase("parquet")); + + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(1, "data", Types.StringType.get())); + + Record record = GenericRecord.create(schema); + record.setField("id", 34L); + record.setField("data", "test"); + + Schema reordered = + new Schema( + Types.NestedField.optional(1, "data", Types.StringType.get()), + Types.NestedField.required(0, "id", Types.LongType.get())); + + Record projected = writeAndRead("reordered_full_projection", schema, reordered, record); + + assertThat(projected.get(0)) + .asString() + .as("Should contain the correct 0 value") + .isEqualTo("test"); + assertThat(projected.get(1)).as("Should contain the correct 1 value").isEqualTo(34L); + } + + @TestTemplate + public void testReorderedProjection() throws Exception { + // Assume.assumeTrue( + // "Spark's Parquet read support does not support reordered columns", + // !format.equalsIgnoreCase("parquet")); + + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(1, "data", Types.StringType.get())); + + Record record = GenericRecord.create(schema); + record.setField("id", 34L); + record.setField("data", "test"); + + Schema reordered = + new Schema( + Types.NestedField.optional(2, "missing_1", Types.StringType.get()), + Types.NestedField.optional(1, "data", Types.StringType.get()), + Types.NestedField.optional(3, "missing_2", Types.LongType.get())); + + Record projected = writeAndRead("reordered_projection", schema, reordered, record); + + assertThat(projected.get(0)).as("Should contain the correct 0 value").isNull(); + assertThat(projected.get(1).toString()) + .as("Should contain the correct 1 value") + .isEqualTo("test"); + assertThat(projected.get(2)).as("Should contain the correct 2 value").isNull(); + } + + @TestTemplate + @SuppressWarnings("checkstyle:AssertThatThrownByWithMessageCheck") + public void testEmptyProjection() throws Exception { + Schema schema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(1, "data", Types.StringType.get())); + + Record record = GenericRecord.create(schema); + record.setField("id", 34L); + record.setField("data", "test"); + + Record projected = writeAndRead("empty_projection", schema, schema.select(), record); + + assertThat(projected).as("Should read a non-null record").isNotNull(); + // this is expected because there are no values + // no check on the underlying error msg as it might be missing based on the JDK version + assertThatThrownBy(() -> projected.get(0)).isInstanceOf(ArrayIndexOutOfBoundsException.class); + } + + @TestTemplate + public void testBasicProjection() throws Exception { + Schema writeSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(1, "data", Types.StringType.get())); + + Record record = GenericRecord.create(writeSchema); + record.setField("id", 34L); + record.setField("data", "test"); + + Schema idOnly = new Schema(Types.NestedField.required(0, "id", Types.LongType.get())); + + Record projected = writeAndRead("basic_projection_id", writeSchema, idOnly, record); + assertThat(projected.getField("data")).as("Should not project data").isNull(); + assertThat((long) projected.getField("id")) + .as("Should contain the correct id value") + .isEqualTo(34L); + + Schema dataOnly = new Schema(Types.NestedField.optional(1, "data", Types.StringType.get())); + + projected = writeAndRead("basic_projection_data", writeSchema, dataOnly, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + + int cmp = + Comparators.charSequences().compare("test", (CharSequence) projected.getField("data")); + assertThat(cmp).as("Should contain the correct data value").isEqualTo(0); + } + + @TestTemplate + public void testRename() throws Exception { + Schema writeSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(1, "data", Types.StringType.get())); + + Record record = GenericRecord.create(writeSchema); + record.setField("id", 34L); + record.setField("data", "test"); + + Schema readSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional(1, "renamed", Types.StringType.get())); + + Record projected = writeAndRead("project_and_rename", writeSchema, readSchema, record); + assertThat((long) projected.getField("id")) + .as("Should contain the correct id value") + .isEqualTo(34L); + + int cmp = + Comparators.charSequences().compare("test", (CharSequence) projected.getField("renamed")); + assertThat(cmp).as("Should contain the correct data/renamed value").isEqualTo(0); + } + + @TestTemplate + public void testNestedStructProjection() throws Exception { + Schema writeSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 3, + "location", + Types.StructType.of( + Types.NestedField.required(1, "lat", Types.FloatType.get()), + Types.NestedField.required(2, "long", Types.FloatType.get())))); + + Record record = GenericRecord.create(writeSchema); + record.setField("id", 34L); + Record location = GenericRecord.create(writeSchema.findType("location").asStructType()); + location.setField("lat", 52.995143f); + location.setField("long", -1.539054f); + record.setField("location", location); + + Schema idOnly = new Schema(Types.NestedField.required(0, "id", Types.LongType.get())); + + Record projected = writeAndRead("id_only", writeSchema, idOnly, record); + Record projectedLocation = (Record) projected.getField("location"); + assertThat((long) projected.getField("id")) + .as("Should contain the correct id value") + .isEqualTo(34L); + assertThat(projectedLocation).as("Should not project location").isNull(); + + Schema latOnly = + new Schema( + Types.NestedField.optional( + 3, + "location", + Types.StructType.of(Types.NestedField.required(1, "lat", Types.FloatType.get())))); + + projected = writeAndRead("latitude_only", writeSchema, latOnly, record); + projectedLocation = (Record) projected.getField("location"); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("location")).as("Should project location").isNotNull(); + assertThat(projectedLocation.getField("long")).as("Should not project longitude").isNull(); + assertThat((float) projectedLocation.getField("lat")) + .as("Should project latitude") + .isCloseTo(52.995143f, within(0.000001f)); + + Schema longOnly = + new Schema( + Types.NestedField.optional( + 3, + "location", + Types.StructType.of(Types.NestedField.required(2, "long", Types.FloatType.get())))); + + projected = writeAndRead("longitude_only", writeSchema, longOnly, record); + projectedLocation = (Record) projected.getField("location"); + + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("location")).as("Should project location").isNotNull(); + assertThat(projectedLocation.getField("lat")).as("Should not project latitude").isNull(); + assertThat((float) projectedLocation.getField("long")) + .as("Should project longitude") + .isCloseTo(-1.539054f, within(0.000001f)); + + Schema locationOnly = writeSchema.select("location"); + projected = writeAndRead("location_only", writeSchema, locationOnly, record); + projectedLocation = (Record) projected.getField("location"); + + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("location")).as("Should project location").isNotNull(); + assertThat((float) projectedLocation.getField("lat")) + .as("Should project latitude") + .isCloseTo(52.995143f, within(0.000001f)); + assertThat((float) projectedLocation.getField("long")) + .as("Should project longitude") + .isCloseTo(-1.539054f, within(0.000001f)); + } + + @TestTemplate + public void testMapProjection() throws IOException { + Schema writeSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 5, + "properties", + Types.MapType.ofOptional(6, 7, Types.StringType.get(), Types.StringType.get()))); + + Map properties = ImmutableMap.of("a", "A", "b", "B"); + + Record record = GenericRecord.create(writeSchema); + record.setField("id", 34L); + record.setField("properties", properties); + + Schema idOnly = new Schema(Types.NestedField.required(0, "id", Types.LongType.get())); + + Record projected = writeAndRead("id_only", writeSchema, idOnly, record); + assertThat((long) projected.getField("id")) + .as("Should contain the correct id value") + .isEqualTo(34L); + assertThat(projected.getField("properties")).as("Should not project properties map").isNull(); + + Schema keyOnly = writeSchema.select("properties.key"); + projected = writeAndRead("key_only", writeSchema, keyOnly, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(toStringMap((Map) projected.getField("properties"))) + .as("Should project entire map") + .isEqualTo(properties); + + Schema valueOnly = writeSchema.select("properties.value"); + projected = writeAndRead("value_only", writeSchema, valueOnly, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(toStringMap((Map) projected.getField("properties"))) + .as("Should project entire map") + .isEqualTo(properties); + + Schema mapOnly = writeSchema.select("properties"); + projected = writeAndRead("map_only", writeSchema, mapOnly, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(toStringMap((Map) projected.getField("properties"))) + .as("Should project entire map") + .isEqualTo(properties); + } + + private Map toStringMap(Map map) { + Map stringMap = Maps.newHashMap(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() instanceof CharSequence) { + stringMap.put(entry.getKey().toString(), entry.getValue().toString()); + } else { + stringMap.put(entry.getKey().toString(), entry.getValue()); + } + } + return stringMap; + } + + @TestTemplate + public void testMapOfStructsProjection() throws IOException { + Schema writeSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 5, + "locations", + Types.MapType.ofOptional( + 6, + 7, + Types.StringType.get(), + Types.StructType.of( + Types.NestedField.required(1, "lat", Types.FloatType.get()), + Types.NestedField.required(2, "long", Types.FloatType.get()))))); + + Record record = GenericRecord.create(writeSchema); + record.setField("id", 34L); + Record l1 = GenericRecord.create(writeSchema.findType("locations.value").asStructType()); + l1.setField("lat", 53.992811f); + l1.setField("long", -1.542616f); + Record l2 = GenericRecord.create(l1.struct()); + l2.setField("lat", 52.995143f); + l2.setField("long", -1.539054f); + record.setField("locations", ImmutableMap.of("L1", l1, "L2", l2)); + + Schema idOnly = new Schema(Types.NestedField.required(0, "id", Types.LongType.get())); + + Record projected = writeAndRead("id_only", writeSchema, idOnly, record); + assertThat(34L) + .as("Should contain the correct id value") + .isEqualTo((long) projected.getField("id")); + assertThat(projected.getField("locations")).as("Should not project locations map").isNull(); + + projected = writeAndRead("all_locations", writeSchema, writeSchema.select("locations"), record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(toStringMap((Map) projected.getField("locations"))) + .as("Should project locations map") + .isEqualTo(record.getField("locations")); + + projected = writeAndRead("lat_only", writeSchema, writeSchema.select("locations.lat"), record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + + Map locations = toStringMap((Map) projected.getField("locations")); + assertThat(locations).isNotNull().containsKeys("L1", "L2"); + + Record projectedL1 = (Record) locations.get("L1"); + assertThat(projectedL1).as("L1 should not be null").isNotNull(); + assertThat((float) projectedL1.getField("lat")) + .as("L1 should contain lat") + .isCloseTo(53.992811f, within(0.000001f)); + assertThat(projectedL1.getField("long")).as("L1 should not contain long").isNull(); + + Record projectedL2 = (Record) locations.get("L2"); + assertThat(projectedL2).as("L2 should not be null").isNotNull(); + assertThat((float) projectedL2.getField("lat")) + .as("L2 should contain lat") + .isCloseTo(52.995143f, within(0.000001f)); + assertThat(projectedL2.getField("long")).as("L2 should not contain long").isNull(); + + projected = + writeAndRead("long_only", writeSchema, writeSchema.select("locations.long"), record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + + locations = toStringMap((Map) projected.getField("locations")); + assertThat(locations).isNotNull().containsKeys("L1", "L2"); + + projectedL1 = (Record) locations.get("L1"); + assertThat(projectedL1).as("L1 should not be null").isNotNull(); + assertThat(projectedL1.getField("lat")).as("L1 should not contain lat").isNull(); + assertThat((float) projectedL1.getField("long")) + .as("L1 should contain long") + .isCloseTo(-1.542616f, within(0.000001f)); + + projectedL2 = (Record) locations.get("L2"); + assertThat(projectedL2).as("L2 should not be null").isNotNull(); + assertThat(projectedL2.getField("lat")).as("L2 should not contain lat").isNull(); + assertThat((float) projectedL2.getField("long")) + .as("L2 should contain long") + .isCloseTo(-1.539054f, within(0.000001f)); + + Schema latitiudeRenamed = + new Schema( + Types.NestedField.optional( + 5, + "locations", + Types.MapType.ofOptional( + 6, + 7, + Types.StringType.get(), + Types.StructType.of( + Types.NestedField.required(1, "latitude", Types.FloatType.get()))))); + + projected = writeAndRead("latitude_renamed", writeSchema, latitiudeRenamed, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + locations = toStringMap((Map) projected.getField("locations")); + assertThat(locations).isNotNull().containsKeys("L1", "L2"); + + projectedL1 = (Record) locations.get("L1"); + assertThat(projectedL1).as("L1 should not be null").isNotNull(); + assertThat((float) projectedL1.getField("latitude")) + .as("L1 should contain latitude") + .isCloseTo(53.992811f, within(0.000001f)); + assertThat(projectedL1.getField("lat")).as("L1 should not contain lat").isNull(); + assertThat(projectedL1.getField("long")).as("L1 should not contain long").isNull(); + + projectedL2 = (Record) locations.get("L2"); + assertThat(projectedL2).as("L2 should not be null").isNotNull(); + assertThat((float) projectedL2.getField("latitude")) + .as("L2 should contain latitude") + .isCloseTo(52.995143f, within(0.000001f)); + assertThat(projectedL2.getField("lat")).as("L2 should not contain lat").isNull(); + assertThat(projectedL2.getField("long")).as("L2 should not contain long").isNull(); + } + + @TestTemplate + public void testListProjection() throws IOException { + Schema writeSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 10, "values", Types.ListType.ofOptional(11, Types.LongType.get()))); + + List values = ImmutableList.of(56L, 57L, 58L); + + Record record = GenericRecord.create(writeSchema); + record.setField("id", 34L); + record.setField("values", values); + + Schema idOnly = new Schema(Types.NestedField.required(0, "id", Types.LongType.get())); + + Record projected = writeAndRead("id_only", writeSchema, idOnly, record); + assertThat((long) projected.getField("id")) + .as("Should contain the correct id value") + .isEqualTo(34L); + assertThat(projected.getField("values")).as("Should not project values list").isNull(); + + Schema elementOnly = writeSchema.select("values.element"); + projected = writeAndRead("element_only", writeSchema, elementOnly, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("values")).as("Should project entire list").isEqualTo(values); + + Schema listOnly = writeSchema.select("values"); + projected = writeAndRead("list_only", writeSchema, listOnly, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("values")).as("Should project entire list").isEqualTo(values); + } + + @TestTemplate + @SuppressWarnings("unchecked") + public void testListOfStructsProjection() throws IOException { + Schema writeSchema = + new Schema( + Types.NestedField.required(0, "id", Types.LongType.get()), + Types.NestedField.optional( + 22, + "points", + Types.ListType.ofOptional( + 21, + Types.StructType.of( + Types.NestedField.required(19, "x", Types.IntegerType.get()), + Types.NestedField.optional(18, "y", Types.IntegerType.get()))))); + + Record record = GenericRecord.create(writeSchema); + record.setField("id", 34L); + Record p1 = GenericRecord.create(writeSchema.findType("points.element").asStructType()); + p1.setField("x", 1); + p1.setField("y", 2); + Record p2 = GenericRecord.create(p1.struct()); + p2.setField("x", 3); + p2.setField("y", null); + record.setField("points", ImmutableList.of(p1, p2)); + + Schema idOnly = new Schema(Types.NestedField.required(0, "id", Types.LongType.get())); + + Record projected = writeAndRead("id_only", writeSchema, idOnly, record); + assertThat((long) projected.getField("id")) + .as("Should contain the correct id value") + .isEqualTo(34L); + assertThat(projected.getField("points")).as("Should not project points list").isNull(); + + projected = writeAndRead("all_points", writeSchema, writeSchema.select("points"), record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("points")) + .as("Should project points list") + .isEqualTo(record.getField("points")); + + projected = writeAndRead("x_only", writeSchema, writeSchema.select("points.x"), record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("points")).as("Should project points list").isNotNull(); + + List points = (List) projected.getField("points"); + assertThat(points).as("Should read 2 points").hasSize(2); + assertThat(points) + .element(0) + .satisfies( + projectedP1 -> { + assertThat((int) projectedP1.getField("x")).isEqualTo(1); + assertThat(projectedP1.getField("y")).isNull(); + }); + assertThat(points) + .element(1) + .satisfies( + projectedP2 -> { + assertThat((int) projectedP2.getField("x")).isEqualTo(3); + assertThat(projectedP2.getField("y")).isNull(); + }); + + projected = writeAndRead("y_only", writeSchema, writeSchema.select("points.y"), record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("points")).as("Should project points list").isNotNull(); + + points = (List) projected.getField("points"); + assertThat(points).as("Should read 2 points").hasSize(2); + assertThat(points) + .element(0) + .satisfies( + projectedP1 -> { + assertThat(projectedP1.getField("x")).isNull(); + assertThat((int) projectedP1.getField("y")).isEqualTo(2); + }); + assertThat(points) + .element(1) + .satisfies( + projectedP2 -> { + assertThat(projectedP2.getField("x")).isNull(); + assertThat(projectedP2.getField("y")).isNull(); + }); + + Schema yRenamed = + new Schema( + Types.NestedField.optional( + 22, + "points", + Types.ListType.ofOptional( + 21, + Types.StructType.of( + Types.NestedField.optional(18, "z", Types.IntegerType.get()))))); + + projected = writeAndRead("y_renamed", writeSchema, yRenamed, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("points")).as("Should project points list").isNotNull(); + + points = (List) projected.getField("points"); + assertThat(points).as("Should read 2 points").hasSize(2); + assertThat(points) + .element(0) + .satisfies( + projectedP1 -> { + assertThat(projectedP1.getField("x")).isNull(); + assertThat(projectedP1.getField("y")).isNull(); + assertThat((int) projectedP1.getField("z")).isEqualTo(2); + }); + assertThat(points) + .element(1) + .satisfies( + projectedP2 -> { + assertThat(projectedP2.getField("x")).isNull(); + assertThat(projectedP2.getField("y")).isNull(); + assertThat(projectedP2.getField("z")).isNull(); + }); + + Schema zAdded = + new Schema( + Types.NestedField.optional( + 22, + "points", + Types.ListType.ofOptional( + 21, + Types.StructType.of( + Types.NestedField.required(19, "x", Types.IntegerType.get()), + Types.NestedField.optional(18, "y", Types.IntegerType.get()), + Types.NestedField.optional(20, "z", Types.IntegerType.get()))))); + + projected = writeAndRead("z_added", writeSchema, zAdded, record); + assertThat(projected.getField("id")).as("Should not project id").isNull(); + assertThat(projected.getField("points")).as("Should project points list").isNotNull(); + + points = (List) projected.getField("points"); + assertThat(points).as("Should read 2 points").hasSize(2); + assertThat(points) + .element(0) + .satisfies( + projectedP1 -> { + assertThat((int) projectedP1.getField("x")).isEqualTo(1); + assertThat((int) projectedP1.getField("y")).isEqualTo(2); + assertThat(projectedP1.getField("z")).isNull(); + }); + assertThat(points) + .element(1) + .satisfies( + projectedP2 -> { + assertThat((int) projectedP2.getField("x")).isEqualTo(3); + assertThat(projectedP2.getField("y")).isNull(); + assertThat(projectedP2.getField("z")).isNull(); + }); + } + + private static org.apache.avro.Schema fromOption(org.apache.avro.Schema schema) { + Preconditions.checkArgument( + schema.getType() == UNION, "Expected union schema but was passed: %s", schema); + Preconditions.checkArgument( + schema.getTypes().size() == 2, "Expected optional schema, but was passed: %s", schema); + if (schema.getTypes().get(0).getType() == org.apache.avro.Schema.Type.NULL) { + return schema.getTypes().get(1); + } else { + return schema.getTypes().get(0); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestRequiredDistributionAndOrdering.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestRequiredDistributionAndOrdering.java new file mode 100644 index 000000000000..5dbfc7fa6c0f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestRequiredDistributionAndOrdering.java @@ -0,0 +1,310 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRequiredDistributionAndOrdering extends CatalogTestBase { + + @AfterEach + public void dropTestTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testDefaultLocalSort() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (c3)", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + // should insert a local sort by partition columns by default + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testPartitionColumnsArePrependedForRangeDistribution() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (c3)", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + Table table = validationCatalog.loadTable(tableIdent); + + // should automatically prepend partition columns to the ordering + table + .updateProperties() + .set(TableProperties.WRITE_DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_RANGE) + .commit(); + table.replaceSortOrder().asc("c1").asc("c2").commit(); + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testSortOrderIncludesPartitionColumns() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (c3)", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + Table table = validationCatalog.loadTable(tableIdent); + + // should succeed with a correct sort order + table.replaceSortOrder().asc("c3").asc("c1").asc("c2").commit(); + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testDisabledDistributionAndOrdering() { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (c3)", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + // should fail if ordering is disabled + assertThatThrownBy( + () -> + inputDF + .writeTo(tableName) + .option(SparkWriteOptions.USE_TABLE_DISTRIBUTION_AND_ORDERING, "false") + .option(SparkWriteOptions.FANOUT_ENABLED, "false") + .append()) + .cause() + .isInstanceOf(IllegalStateException.class) + .hasMessageStartingWith( + "Incoming records violate the writer assumption that records are clustered by spec " + + "and by partition within each spec. Either cluster the incoming records or switch to fanout writers."); + } + + @TestTemplate + public void testHashDistribution() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (c3)", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + Table table = validationCatalog.loadTable(tableIdent); + + // should automatically prepend partition columns to the local ordering after hash distribution + table + .updateProperties() + .set(TableProperties.WRITE_DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_HASH) + .commit(); + table.replaceSortOrder().asc("c1").asc("c2").commit(); + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testSortBucketTransformsWithoutExtensions() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, c3 STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(2, c1))", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBB", "B"), + new ThreeColumnRecord(3, "BBBB", "B"), + new ThreeColumnRecord(4, "BBBB", "B")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = ds.coalesce(1).sortWithinPartitions("c1"); + + inputDF.writeTo(tableName).append(); + + List expected = + ImmutableList.of( + row(1, null, "A"), row(2, "BBBB", "B"), row(3, "BBBB", "B"), row(4, "BBBB", "B")); + + assertEquals("Rows must match", expected, sql("SELECT * FROM %s ORDER BY c1", tableName)); + } + + @TestTemplate + public void testRangeDistributionWithQuotedColumnsNames() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, `c.3` STRING) " + + "USING iceberg " + + "PARTITIONED BY (`c.3`)", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = + ds.selectExpr("c1", "c2", "c3 as `c.3`").coalesce(1).sortWithinPartitions("c1"); + + Table table = validationCatalog.loadTable(tableIdent); + + table + .updateProperties() + .set(TableProperties.WRITE_DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_RANGE) + .commit(); + table.replaceSortOrder().asc("c1").asc("c2").commit(); + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } + + @TestTemplate + public void testHashDistributionWithQuotedColumnsNames() throws NoSuchTableException { + sql( + "CREATE TABLE %s (c1 INT, c2 STRING, `c``3` STRING) " + + "USING iceberg " + + "PARTITIONED BY (`c``3`)", + tableName); + + List data = + ImmutableList.of( + new ThreeColumnRecord(1, null, "A"), + new ThreeColumnRecord(2, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(3, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(4, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(5, "BBBBBBBBBB", "A"), + new ThreeColumnRecord(6, "BBBBBBBBBB", "B"), + new ThreeColumnRecord(7, "BBBBBBBBBB", "A")); + Dataset ds = spark.createDataFrame(data, ThreeColumnRecord.class); + Dataset inputDF = + ds.selectExpr("c1", "c2", "c3 as `c``3`").coalesce(1).sortWithinPartitions("c1"); + + Table table = validationCatalog.loadTable(tableIdent); + + table + .updateProperties() + .set(TableProperties.WRITE_DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_HASH) + .commit(); + table.replaceSortOrder().asc("c1").asc("c2").commit(); + inputDF.writeTo(tableName).append(); + + assertEquals( + "Row count must match", + ImmutableList.of(row(7L)), + sql("SELECT count(*) FROM %s", tableName)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestRuntimeFiltering.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestRuntimeFiltering.java new file mode 100644 index 000000000000..e7346e270f38 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestRuntimeFiltering.java @@ -0,0 +1,510 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRuntimeFiltering extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, planningMode = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + LOCAL + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + DISTRIBUTED + } + }; + } + + @Parameter(index = 3) + private PlanningMode planningMode; + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS dim"); + } + + @TestTemplate + public void testIdentityPartitionedTable() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (date)", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE) USING parquet"); + Dataset dimDF = + spark.range(1, 10).withColumn("date", expr("DATE '1970-01-02'")).select("id", "date"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.date = d.date AND d.id = 1 ORDER BY id", + tableName); + + assertQueryContainsRuntimeFilter(query); + + deleteNotMatchingFiles(Expressions.equal("date", 1), 3); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE date = DATE '1970-01-02' ORDER BY id", tableName), + sql(query)); + } + + @TestTemplate + public void testBucketedTable() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (bucket(8, id))", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE) USING parquet"); + Dataset dimDF = + spark.range(1, 2).withColumn("date", expr("DATE '1970-01-02'")).select("id", "date"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.id = d.id AND d.date = DATE '1970-01-02' ORDER BY date", + tableName); + + assertQueryContainsRuntimeFilter(query); + + deleteNotMatchingFiles(Expressions.equal("id", 1), 7); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE id = 1 ORDER BY date", tableName), + sql(query)); + } + + @TestTemplate + public void testRenamedSourceColumnTable() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (bucket(8, id))", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE) USING parquet"); + Dataset dimDF = + spark.range(1, 2).withColumn("date", expr("DATE '1970-01-02'")).select("id", "date"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + sql("ALTER TABLE %s RENAME COLUMN id TO row_id", tableName); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.row_id = d.id AND d.date = DATE '1970-01-02' ORDER BY date", + tableName); + + assertQueryContainsRuntimeFilter(query); + + deleteNotMatchingFiles(Expressions.equal("row_id", 1), 7); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE row_id = 1 ORDER BY date", tableName), + sql(query)); + } + + @TestTemplate + public void testMultipleRuntimeFilters() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (data, bucket(8, id))", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE, data STRING) USING parquet"); + Dataset dimDF = + spark + .range(1, 2) + .withColumn("date", expr("DATE '1970-01-02'")) + .withColumn("data", expr("'1970-01-02'")) + .select("id", "date", "data"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.id = d.id AND f.data = d.data AND d.date = DATE '1970-01-02'", + tableName); + + assertQueryContainsRuntimeFilters(query, 2, "Query should have 2 runtime filters"); + + deleteNotMatchingFiles(Expressions.equal("id", 1), 31); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE id = 1 AND data = '1970-01-02'", tableName), + sql(query)); + } + + @TestTemplate + public void testCaseSensitivityOfRuntimeFilters() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (data, bucket(8, id))", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE, data STRING) USING parquet"); + Dataset dimDF = + spark + .range(1, 2) + .withColumn("date", expr("DATE '1970-01-02'")) + .withColumn("data", expr("'1970-01-02'")) + .select("id", "date", "data"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String caseInsensitiveQuery = + String.format( + "select f.* from %s F join dim d ON f.Id = d.iD and f.DaTa = d.dAtA and d.dAtE = date '1970-01-02'", + tableName); + + assertQueryContainsRuntimeFilters( + caseInsensitiveQuery, 2, "Query should have 2 runtime filters"); + + deleteNotMatchingFiles(Expressions.equal("id", 1), 31); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE id = 1 AND data = '1970-01-02'", tableName), + sql(caseInsensitiveQuery)); + } + + @TestTemplate + public void testBucketedTableWithMultipleSpecs() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) USING iceberg", + tableName); + configurePlanningMode(planningMode); + + Dataset df1 = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 2 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df1.coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + table.updateSpec().addField(Expressions.bucket("id", 8)).commit(); + + sql("REFRESH TABLE %s", tableName); + + Dataset df2 = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df2.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE) USING parquet"); + Dataset dimDF = + spark.range(1, 2).withColumn("date", expr("DATE '1970-01-02'")).select("id", "date"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.id = d.id AND d.date = DATE '1970-01-02' ORDER BY date", + tableName); + + assertQueryContainsRuntimeFilter(query); + + deleteNotMatchingFiles(Expressions.equal("id", 1), 7); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE id = 1 ORDER BY date", tableName), + sql(query)); + } + + @TestTemplate + public void testSourceColumnWithDots() throws NoSuchTableException { + sql( + "CREATE TABLE %s (`i.d` BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (bucket(8, `i.d`))", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumnRenamed("id", "i.d") + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(`i.d` % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("`i.d`", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("SELECT * FROM %s WHERE `i.d` = 1", tableName); + + sql("CREATE TABLE dim (id BIGINT, date DATE) USING parquet"); + Dataset dimDF = + spark.range(1, 2).withColumn("date", expr("DATE '1970-01-02'")).select("id", "date"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.`i.d` = d.id AND d.date = DATE '1970-01-02' ORDER BY date", + tableName); + + assertQueryContainsRuntimeFilter(query); + + deleteNotMatchingFiles(Expressions.equal("i.d", 1), 7); + + sql(query); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE `i.d` = 1 ORDER BY date", tableName), + sql(query)); + } + + @TestTemplate + public void testSourceColumnWithBackticks() throws NoSuchTableException { + sql( + "CREATE TABLE %s (`i``d` BIGINT, data STRING, date DATE, ts TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (bucket(8, `i``d`))", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumnRenamed("id", "i`d") + .withColumn( + "date", date_add(expr("DATE '1970-01-01'"), expr("CAST(`i``d` % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("`i``d`", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE) USING parquet"); + Dataset dimDF = + spark.range(1, 2).withColumn("date", expr("DATE '1970-01-02'")).select("id", "date"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.`i``d` = d.id AND d.date = DATE '1970-01-02' ORDER BY date", + tableName); + + assertQueryContainsRuntimeFilter(query); + + deleteNotMatchingFiles(Expressions.equal("i`d", 1), 7); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE `i``d` = 1 ORDER BY date", tableName), + sql(query)); + } + + @TestTemplate + public void testUnpartitionedTable() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, data STRING, date DATE, ts TIMESTAMP) USING iceberg", + tableName); + configurePlanningMode(planningMode); + + Dataset df = + spark + .range(1, 100) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id % 4 AS INT)"))) + .withColumn("ts", expr("TO_TIMESTAMP(date)")) + .withColumn("data", expr("CAST(date AS STRING)")) + .select("id", "data", "date", "ts"); + + df.coalesce(1).writeTo(tableName).append(); + + sql("CREATE TABLE dim (id BIGINT, date DATE) USING parquet"); + Dataset dimDF = + spark.range(1, 2).withColumn("date", expr("DATE '1970-01-02'")).select("id", "date"); + dimDF.coalesce(1).write().mode("append").insertInto("dim"); + + String query = + String.format( + "SELECT f.* FROM %s f JOIN dim d ON f.id = d.id AND d.date = DATE '1970-01-02' ORDER BY date", + tableName); + + assertQueryContainsNoRuntimeFilter(query); + + assertEquals( + "Should have expected rows", + sql("SELECT * FROM %s WHERE id = 1 ORDER BY date", tableName), + sql(query)); + } + + private void assertQueryContainsRuntimeFilter(String query) { + assertQueryContainsRuntimeFilters(query, 1, "Query should have 1 runtime filter"); + } + + private void assertQueryContainsNoRuntimeFilter(String query) { + assertQueryContainsRuntimeFilters(query, 0, "Query should have no runtime filters"); + } + + private void assertQueryContainsRuntimeFilters( + String query, int expectedFilterCount, String errorMessage) { + List output = spark.sql("EXPLAIN EXTENDED " + query).collectAsList(); + String plan = output.get(0).getString(0); + int actualFilterCount = StringUtils.countMatches(plan, "dynamicpruningexpression"); + assertThat(actualFilterCount).as(errorMessage).isEqualTo(expectedFilterCount); + } + + // delete files that don't match the filter to ensure dynamic filtering works and only required + // files are read + private void deleteNotMatchingFiles(Expression filter, int expectedDeletedFileCount) { + Table table = validationCatalog.loadTable(tableIdent); + FileIO io = table.io(); + + Set matchingFileLocations = Sets.newHashSet(); + try (CloseableIterable files = table.newScan().filter(filter).planFiles()) { + for (FileScanTask file : files) { + String path = file.file().location(); + matchingFileLocations.add(path); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + Set deletedFileLocations = Sets.newHashSet(); + try (CloseableIterable files = table.newScan().planFiles()) { + for (FileScanTask file : files) { + String path = file.file().location(); + if (!matchingFileLocations.contains(path)) { + io.deleteFile(path); + deletedFileLocations.add(path); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + assertThat(deletedFileLocations) + .as("Deleted unexpected number of files") + .hasSize(expectedDeletedFileCount); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java new file mode 100644 index 000000000000..e025114f4fd9 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSerializableFileIOWithSize.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import org.apache.iceberg.io.FileIO; +import org.junit.jupiter.api.Test; + +class TestSerializableFileIOWithSize { + + @Test + void newInputFileWithLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + long length = 1024L; + + serializableFileIO.newInputFile(path, length); + + verify(mockFileIO).newInputFile(path, length); + } + + @Test + void newInputFileWithoutLength() { + FileIO mockFileIO = mock(FileIO.class); + FileIO serializableFileIO = SerializableFileIOWithSize.wrap(mockFileIO); + String path = "gs://bucket/path/to/file.parquet"; + + serializableFileIO.newInputFile(path); + + verify(mockFileIO).newInputFile(path); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java new file mode 100644 index 000000000000..3004e8fa5c5e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSnapshotSelection.java @@ -0,0 +1,619 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.InetAddress; +import java.nio.file.Path; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSnapshotSelection { + + private static final SimpleDateFormat TIMESTAMP_FORMAT = + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + + @Parameters(name = "properties = {0}") + public static Object[] parameters() { + return new Object[][] { + { + ImmutableMap.of( + TableProperties.DATA_PLANNING_MODE, LOCAL.modeName(), + TableProperties.DELETE_PLANNING_MODE, LOCAL.modeName()) + }, + { + ImmutableMap.of( + TableProperties.DATA_PLANNING_MODE, DISTRIBUTED.modeName(), + TableProperties.DELETE_PLANNING_MODE, DISTRIBUTED.modeName()) + } + }; + } + + private static final Configuration CONF = new Configuration(); + private static final Schema SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + + @TempDir private Path temp; + + private static SparkSession spark = null; + + @Parameter(index = 0) + private Map properties; + + @BeforeAll + public static void startSpark() { + TestSnapshotSelection.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestSnapshotSelection.spark; + TestSnapshotSelection.spark = null; + currentSpark.stop(); + } + + @TestTemplate + public void testSnapshotSelectionById() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + // produce the second snapshot + List secondBatchRecords = + Lists.newArrayList( + new SimpleRecord(4, "d"), new SimpleRecord(5, "e"), new SimpleRecord(6, "f")); + Dataset secondDf = spark.createDataFrame(secondBatchRecords, SimpleRecord.class); + secondDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + assertThat(table.snapshots()).as("Expected 2 snapshots").hasSize(2); + + // verify records in the current snapshot + Dataset currentSnapshotResult = spark.read().format("iceberg").load(tableLocation); + List currentSnapshotRecords = + currentSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(firstBatchRecords); + expectedRecords.addAll(secondBatchRecords); + assertThat(currentSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + + // verify records in the previous snapshot + Snapshot currentSnapshot = table.currentSnapshot(); + Long parentSnapshotId = currentSnapshot.parentId(); + Dataset previousSnapshotResult = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, parentSnapshotId) + .load(tableLocation); + List previousSnapshotRecords = + previousSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(previousSnapshotRecords) + .as("Previous snapshot rows should match") + .isEqualTo(firstBatchRecords); + } + + @TestTemplate + public void testSnapshotSelectionByTimestamp() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + // remember the time when the first snapshot was valid + long firstSnapshotTimestamp = System.currentTimeMillis(); + String formattedTimestamp = TIMESTAMP_FORMAT.format(new Date(firstSnapshotTimestamp)); + + // produce the second snapshot + List secondBatchRecords = + Lists.newArrayList( + new SimpleRecord(4, "d"), new SimpleRecord(5, "e"), new SimpleRecord(6, "f")); + Dataset secondDf = spark.createDataFrame(secondBatchRecords, SimpleRecord.class); + secondDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + assertThat(table.snapshots()).as("Expected 2 snapshots").hasSize(2); + + // verify records in the current snapshot + Dataset currentSnapshotResult = spark.read().format("iceberg").load(tableLocation); + List currentSnapshotRecords = + currentSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(firstBatchRecords); + expectedRecords.addAll(secondBatchRecords); + assertThat(currentSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + + // verify records in the previous snapshot + Dataset previousSnapshotResult = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTimestamp) + .load(tableLocation); + List previousSnapshotRecords = + previousSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(previousSnapshotRecords) + .as("Previous snapshot rows should match") + .isEqualTo(firstBatchRecords); + } + + @TestTemplate + public void testSnapshotSelectionByInvalidSnapshotId() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + tables.create(SCHEMA, spec, properties, tableLocation); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, -10) + .load(tableLocation)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot find snapshot with ID -10"); + } + + @TestTemplate + public void testSnapshotSelectionByInvalidTimestamp() { + long timestamp = System.currentTimeMillis(); + String formattedTimestamp = TIMESTAMP_FORMAT.format(new Date(timestamp)); + + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + tables.create(SCHEMA, spec, properties, tableLocation); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTimestamp) + .load(tableLocation)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot find a snapshot older than"); + } + + @TestTemplate + public void testSnapshotSelectionBySnapshotIdAndTimestamp() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + long timestamp = System.currentTimeMillis(); + String formattedTimestamp = TIMESTAMP_FORMAT.format(new Date(timestamp)); + long snapshotId = table.currentSnapshot().snapshotId(); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotId) + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTimestamp) + .load(tableLocation)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot specify both version and timestamp"); + } + + @TestTemplate + public void testSnapshotSelectionByTag() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + table.manageSnapshots().createTag("tag", table.currentSnapshot().snapshotId()).commit(); + + // produce the second snapshot + List secondBatchRecords = + Lists.newArrayList( + new SimpleRecord(4, "d"), new SimpleRecord(5, "e"), new SimpleRecord(6, "f")); + Dataset secondDf = spark.createDataFrame(secondBatchRecords, SimpleRecord.class); + secondDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + // verify records in the current snapshot by tag + Dataset currentSnapshotResult = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, "tag") + .load(tableLocation); + List currentSnapshotRecords = + currentSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(firstBatchRecords); + assertThat(currentSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + } + + @TestTemplate + public void testSnapshotSelectionByBranch() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + table.manageSnapshots().createBranch("branch", table.currentSnapshot().snapshotId()).commit(); + + // produce the second snapshot + List secondBatchRecords = + Lists.newArrayList( + new SimpleRecord(4, "d"), new SimpleRecord(5, "e"), new SimpleRecord(6, "f")); + Dataset secondDf = spark.createDataFrame(secondBatchRecords, SimpleRecord.class); + secondDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + // verify records in the current snapshot by branch + Dataset currentSnapshotResult = + spark.read().format("iceberg").option("branch", "branch").load(tableLocation); + List currentSnapshotRecords = + currentSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(firstBatchRecords); + assertThat(currentSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + } + + @TestTemplate + public void testSnapshotSelectionByBranchAndTagFails() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + table.manageSnapshots().createBranch("branch", table.currentSnapshot().snapshotId()).commit(); + table.manageSnapshots().createTag("tag", table.currentSnapshot().snapshotId()).commit(); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, "tag") + .option(SparkReadOptions.BRANCH, "branch") + .load(tableLocation) + .show()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot time travel in branch"); + } + + @TestTemplate + public void testSnapshotSelectionByTimestampAndBranchOrTagFails() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + long timestamp = System.currentTimeMillis(); + String formattedTimestamp = TIMESTAMP_FORMAT.format(new Date(timestamp)); + table.manageSnapshots().createBranch("branch", table.currentSnapshot().snapshotId()).commit(); + table.manageSnapshots().createTag("tag", table.currentSnapshot().snapshotId()).commit(); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTimestamp) + .option(SparkReadOptions.BRANCH, "branch") + .load(tableLocation) + .show()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot time travel in branch"); + + assertThatThrownBy( + () -> + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTimestamp) + .option(SparkReadOptions.VERSION_AS_OF, "tag") + .load(tableLocation) + .show()) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot specify both version and timestamp"); + } + + @TestTemplate + public void testSnapshotSelectionByBranchWithSchemaChange() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + table.manageSnapshots().createBranch("branch", table.currentSnapshot().snapshotId()).commit(); + + Dataset branchSnapshotResult = + spark.read().format("iceberg").option("branch", "branch").load(tableLocation); + List branchSnapshotRecords = + branchSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(firstBatchRecords); + assertThat(branchSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + + // Deleting a column to indicate schema change + table.updateSchema().deleteColumn("data").commit(); + + // The data should not have the deleted column + assertThat( + spark + .read() + .format("iceberg") + .option("branch", "branch") + .load(tableLocation) + .orderBy("id") + .collectAsList()) + .containsExactly(RowFactory.create(1), RowFactory.create(2), RowFactory.create(3)); + + // re-introducing the column should not let the data re-appear + table.updateSchema().addColumn("data", Types.StringType.get()).commit(); + + assertThat( + spark + .read() + .format("iceberg") + .option("branch", "branch") + .load(tableLocation) + .orderBy("id") + .as(Encoders.bean(SimpleRecord.class)) + .collectAsList()) + .containsExactly( + new SimpleRecord(1, null), new SimpleRecord(2, null), new SimpleRecord(3, null)); + } + + @TestTemplate + public void testWritingToBranchAfterSchemaChange() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + table.manageSnapshots().createBranch("branch", table.currentSnapshot().snapshotId()).commit(); + + Dataset branchSnapshotResult = + spark.read().format("iceberg").option("branch", "branch").load(tableLocation); + List branchSnapshotRecords = + branchSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(firstBatchRecords); + assertThat(branchSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + + // Deleting and add a new column of the same type to indicate schema change + table.updateSchema().deleteColumn("data").addColumn("zip", Types.IntegerType.get()).commit(); + + assertThat( + spark + .read() + .format("iceberg") + .option("branch", "branch") + .load(tableLocation) + .orderBy("id") + .collectAsList()) + .containsExactly( + RowFactory.create(1, null), RowFactory.create(2, null), RowFactory.create(3, null)); + + // writing new records into the branch should work with the new column + List records = + Lists.newArrayList( + RowFactory.create(4, 12345), RowFactory.create(5, 54321), RowFactory.create(6, 67890)); + + Dataset dataFrame = + spark.createDataFrame( + records, + SparkSchemaUtil.convert( + new Schema( + optional(1, "id", Types.IntegerType.get()), + optional(2, "zip", Types.IntegerType.get())))); + dataFrame + .select("id", "zip") + .write() + .format("iceberg") + .option("branch", "branch") + .mode("append") + .save(tableLocation); + + assertThat( + spark + .read() + .format("iceberg") + .option("branch", "branch") + .load(tableLocation) + .collectAsList()) + .hasSize(6) + .contains( + RowFactory.create(1, null), RowFactory.create(2, null), RowFactory.create(3, null)) + .containsAll(records); + } + + @TestTemplate + public void testSnapshotSelectionByTagWithSchemaChange() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, properties, tableLocation); + + // produce the first snapshot + List firstBatchRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset firstDf = spark.createDataFrame(firstBatchRecords, SimpleRecord.class); + firstDf.select("id", "data").write().format("iceberg").mode("append").save(tableLocation); + + table.manageSnapshots().createTag("tag", table.currentSnapshot().snapshotId()).commit(); + + List expectedRecords = Lists.newArrayList(); + expectedRecords.addAll(firstBatchRecords); + + Dataset tagSnapshotResult = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, "tag") + .load(tableLocation); + List tagSnapshotRecords = + tagSnapshotResult.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(tagSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + + // Deleting a column to indicate schema change + table.updateSchema().deleteColumn("data").commit(); + + // The data should have the deleted column as it was captured in an earlier snapshot. + Dataset deletedColumnTagSnapshotResult = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, "tag") + .load(tableLocation); + List deletedColumnTagSnapshotRecords = + deletedColumnTagSnapshotResult + .orderBy("id") + .as(Encoders.bean(SimpleRecord.class)) + .collectAsList(); + assertThat(deletedColumnTagSnapshotRecords) + .as("Current snapshot rows should match") + .isEqualTo(expectedRecords); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkAggregates.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkAggregates.java new file mode 100644 index 000000000000..6cbe3914dcf8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkAggregates.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.iceberg.expressions.Expression; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkAggregates; +import org.apache.spark.sql.connector.expressions.FieldReference; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.aggregate.Count; +import org.apache.spark.sql.connector.expressions.aggregate.CountStar; +import org.apache.spark.sql.connector.expressions.aggregate.Max; +import org.apache.spark.sql.connector.expressions.aggregate.Min; +import org.junit.jupiter.api.Test; + +public class TestSparkAggregates { + + @Test + public void testAggregates() { + Map attrMap = Maps.newHashMap(); + attrMap.put("id", "id"); + attrMap.put("`i.d`", "i.d"); + attrMap.put("`i``d`", "i`d"); + attrMap.put("`d`.b.`dd```", "d.b.dd`"); + attrMap.put("a.`aa```.c", "a.aa`.c"); + + attrMap.forEach( + (quoted, unquoted) -> { + NamedReference namedReference = FieldReference.apply(quoted); + + Max max = new Max(namedReference); + Expression expectedMax = Expressions.max(unquoted); + Expression actualMax = SparkAggregates.convert(max); + assertThat(actualMax).asString().isEqualTo(expectedMax.toString()); + + Min min = new Min(namedReference); + Expression expectedMin = Expressions.min(unquoted); + Expression actualMin = SparkAggregates.convert(min); + assertThat(actualMin).asString().isEqualTo(expectedMin.toString()); + + Count count = new Count(namedReference, false); + Expression expectedCount = Expressions.count(unquoted); + Expression actualCount = SparkAggregates.convert(count); + assertThat(actualCount).asString().isEqualTo(expectedCount.toString()); + + Count countDistinct = new Count(namedReference, true); + Expression convertedCountDistinct = SparkAggregates.convert(countDistinct); + assertThat(convertedCountDistinct).as("Count Distinct is converted to null").isNull(); + + CountStar countStar = new CountStar(); + Expression expectedCountStar = Expressions.countStar(); + Expression actualCountStar = SparkAggregates.convert(countStar); + assertThat(actualCountStar).asString().isEqualTo(expectedCountStar.toString()); + }); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalog.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalog.java new file mode 100644 index 000000000000..ce24e173c67b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalog.java @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Map; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.connector.catalog.FunctionCatalog; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.SupportsNamespaces; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.ViewCatalog; + +public class TestSparkCatalog< + T extends TableCatalog & FunctionCatalog & SupportsNamespaces & ViewCatalog> + extends SparkSessionCatalog { + + private static final Map TABLE_MAP = Maps.newHashMap(); + + public static void setTable(Identifier ident, Table table) { + Preconditions.checkArgument( + !TABLE_MAP.containsKey(ident), "Cannot set " + ident + ". It is already set"); + TABLE_MAP.put(ident, table); + } + + public static void unsetTable(Identifier ident) { + TABLE_MAP.remove(ident); + } + + @Override + public Table loadTable(Identifier ident) throws NoSuchTableException { + if (TABLE_MAP.containsKey(ident)) { + return TABLE_MAP.get(ident); + } + + TableIdentifier tableIdentifier = Spark3Util.identifierToTableIdentifier(ident); + Namespace namespace = tableIdentifier.namespace(); + + TestTables.TestTable table = TestTables.load(tableIdentifier.toString()); + if (table == null && namespace.equals(Namespace.of("default"))) { + table = TestTables.load(tableIdentifier.name()); + } + + return new SparkTable(table); + } + + public static void clearTables() { + TABLE_MAP.clear(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalogCacheExpiration.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalogCacheExpiration.java new file mode 100644 index 000000000000..43b282c7e5d4 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalogCacheExpiration.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.iceberg.CachingCatalog; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkCatalogCacheExpiration extends TestBaseWithCatalog { + + private static final Map SESSION_CATALOG_CONFIG = + ImmutableMap.of( + "type", + "hadoop", + "default-namespace", + "default", + CatalogProperties.CACHE_ENABLED, + "true", + CatalogProperties.CACHE_EXPIRATION_INTERVAL_MS, + "3000"); + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + {"spark_catalog", SparkSessionCatalog.class.getName(), SESSION_CATALOG_CONFIG}, + }; + } + + private static String asSqlConfCatalogKeyFor(String catalog, String configKey) { + // configKey is empty when the catalog's class is being defined + if (configKey.isEmpty()) { + return String.format("spark.sql.catalog.%s", catalog); + } else { + return String.format("spark.sql.catalog.%s.%s", catalog, configKey); + } + } + + // Add more catalogs to the spark session, so we only need to start spark one time for multiple + // different catalog configuration tests. + @BeforeAll + public static void beforeClass() { + // Catalog - expiration_disabled: Catalog with caching on and expiration disabled. + ImmutableMap.of( + "", + "org.apache.iceberg.spark.SparkCatalog", + "type", + "hive", + CatalogProperties.CACHE_ENABLED, + "true", + CatalogProperties.CACHE_EXPIRATION_INTERVAL_MS, + "-1") + .forEach((k, v) -> spark.conf().set(asSqlConfCatalogKeyFor("expiration_disabled", k), v)); + + // Catalog - cache_disabled_implicitly: Catalog that does not cache, as the cache expiration + // interval is 0. + ImmutableMap.of( + "", + "org.apache.iceberg.spark.SparkCatalog", + "type", + "hive", + CatalogProperties.CACHE_ENABLED, + "true", + CatalogProperties.CACHE_EXPIRATION_INTERVAL_MS, + "0") + .forEach( + (k, v) -> spark.conf().set(asSqlConfCatalogKeyFor("cache_disabled_implicitly", k), v)); + } + + @TestTemplate + public void testSparkSessionCatalogWithExpirationEnabled() { + SparkSessionCatalog sparkSessionCatalog = sparkSessionCatalog(); + Catalog icebergCatalog = sparkSessionCatalog.icebergCatalog(); + + assertThat(icebergCatalog) + .isInstanceOfSatisfying( + CachingCatalog.class, + cachingCatalog -> { + assertThat(cachingCatalog).extracting("expirationIntervalMillis").isEqualTo(3000L); + }); + } + + @TestTemplate + public void testCacheEnabledAndExpirationDisabled() { + SparkCatalog sparkCatalog = getSparkCatalog("expiration_disabled"); + Catalog icebergCatalog = sparkCatalog.icebergCatalog(); + + assertThat(icebergCatalog) + .isInstanceOfSatisfying( + CachingCatalog.class, + cachingCatalog -> { + assertThat(cachingCatalog).extracting("expirationIntervalMillis").isEqualTo(-1L); + }); + } + + @TestTemplate + public void testCacheDisabledImplicitly() { + SparkCatalog sparkCatalog = getSparkCatalog("cache_disabled_implicitly"); + Catalog icebergCatalog = sparkCatalog.icebergCatalog(); + + assertThat(icebergCatalog).isNotInstanceOf(CachingCatalog.class); + } + + private SparkSessionCatalog sparkSessionCatalog() { + TableCatalog catalog = + (TableCatalog) spark.sessionState().catalogManager().catalog("spark_catalog"); + return (SparkSessionCatalog) catalog; + } + + private SparkCatalog getSparkCatalog(String catalog) { + return (SparkCatalog) spark.sessionState().catalogManager().catalog(catalog); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalogHadoopOverrides.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalogHadoopOverrides.java new file mode 100644 index 000000000000..fd155a6bcaf3 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalogHadoopOverrides.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.hadoop.conf.Configurable; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.KryoHelpers; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkCatalogHadoopOverrides extends CatalogTestBase { + + private static final String CONFIG_TO_OVERRIDE = "fs.s3a.buffer.dir"; + // prepend "hadoop." so that the test base formats SQLConf correctly + // as `spark.sql.catalogs..hadoop. + private static final String HADOOP_PREFIXED_CONFIG_TO_OVERRIDE = "hadoop." + CONFIG_TO_OVERRIDE; + private static final String CONFIG_OVERRIDE_VALUE = "/tmp-overridden"; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + "testhive", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hive", + "default-namespace", + "default", + HADOOP_PREFIXED_CONFIG_TO_OVERRIDE, + CONFIG_OVERRIDE_VALUE) + }, + { + "testhadoop", + SparkCatalog.class.getName(), + ImmutableMap.of("type", "hadoop", HADOOP_PREFIXED_CONFIG_TO_OVERRIDE, CONFIG_OVERRIDE_VALUE) + }, + { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", + "hive", + "default-namespace", + "default", + HADOOP_PREFIXED_CONFIG_TO_OVERRIDE, + CONFIG_OVERRIDE_VALUE) + } + }; + } + + @BeforeEach + public void createTable() { + sql("CREATE TABLE IF NOT EXISTS %s (id bigint) USING iceberg", tableName(tableIdent.name())); + } + + @AfterEach + public void dropTable() { + sql("DROP TABLE IF EXISTS %s", tableName(tableIdent.name())); + } + + @TestTemplate + public void testTableFromCatalogHasOverrides() throws Exception { + Table table = getIcebergTableFromSparkCatalog(); + Configuration conf = ((Configurable) table.io()).getConf(); + String actualCatalogOverride = conf.get(CONFIG_TO_OVERRIDE, "/whammies"); + assertThat(actualCatalogOverride) + .as( + "Iceberg tables from spark should have the overridden hadoop configurations from the spark config") + .isEqualTo(CONFIG_OVERRIDE_VALUE); + } + + @TestTemplate + public void ensureRoundTripSerializedTableRetainsHadoopConfig() throws Exception { + Table table = getIcebergTableFromSparkCatalog(); + Configuration originalConf = ((Configurable) table.io()).getConf(); + String actualCatalogOverride = originalConf.get(CONFIG_TO_OVERRIDE, "/whammies"); + assertThat(actualCatalogOverride) + .as( + "Iceberg tables from spark should have the overridden hadoop configurations from the spark config") + .isEqualTo(CONFIG_OVERRIDE_VALUE); + + // Now convert to SerializableTable and ensure overridden property is still present. + Table serializableTable = SerializableTableWithSize.copyOf(table); + Table kryoSerializedTable = + KryoHelpers.roundTripSerialize(SerializableTableWithSize.copyOf(table)); + Configuration configFromKryoSerde = ((Configurable) kryoSerializedTable.io()).getConf(); + String kryoSerializedCatalogOverride = configFromKryoSerde.get(CONFIG_TO_OVERRIDE, "/whammies"); + assertThat(kryoSerializedCatalogOverride) + .as( + "Tables serialized with Kryo serialization should retain overridden hadoop configuration properties") + .isEqualTo(CONFIG_OVERRIDE_VALUE); + + // Do the same for Java based serde + Table javaSerializedTable = TestHelpers.roundTripSerialize(serializableTable); + Configuration configFromJavaSerde = ((Configurable) javaSerializedTable.io()).getConf(); + String javaSerializedCatalogOverride = configFromJavaSerde.get(CONFIG_TO_OVERRIDE, "/whammies"); + assertThat(javaSerializedCatalogOverride) + .as( + "Tables serialized with Java serialization should retain overridden hadoop configuration properties") + .isEqualTo(CONFIG_OVERRIDE_VALUE); + } + + @SuppressWarnings("ThrowSpecificity") + private Table getIcebergTableFromSparkCatalog() throws Exception { + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + TableCatalog catalog = + (TableCatalog) spark.sessionState().catalogManager().catalog(catalogName); + SparkTable sparkTable = (SparkTable) catalog.loadTable(identifier); + return sparkTable.table(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDVWriters.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDVWriters.java new file mode 100644 index 000000000000..dfc693d3094d --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDVWriters.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.FileWriterFactory; +import org.apache.iceberg.io.TestDVWriters; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +public class TestSparkDVWriters extends TestDVWriters { + + @Override + protected FileWriterFactory newWriterFactory( + Schema dataSchema, + List equalityFieldIds, + Schema equalityDeleteRowSchema, + Schema positionDeleteRowSchema) { + return SparkFileWriterFactory.builderFor(table) + .dataSchema(table.schema()) + .dataFileFormat(dataFormat()) + .deleteFileFormat(dataFormat()) + .equalityFieldIds(ArrayUtil.toIntArray(equalityFieldIds)) + .equalityDeleteRowSchema(equalityDeleteRowSchema) + .positionDeleteRowSchema(positionDeleteRowSchema) + .build(); + } + + @Override + protected InternalRow toRow(Integer id, String data) { + InternalRow row = new GenericInternalRow(2); + row.update(0, id); + row.update(1, UTF8String.fromString(data)); + return row; + } + + @Override + protected StructLikeSet toSet(Iterable rows) { + StructLikeSet set = StructLikeSet.create(table.schema().asStruct()); + StructType sparkType = SparkSchemaUtil.convert(table.schema()); + for (InternalRow row : rows) { + InternalRowWrapper wrapper = new InternalRowWrapper(sparkType, table.schema().asStruct()); + set.add(wrapper.wrap(row)); + } + return set; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java new file mode 100644 index 000000000000..d719ca6751a0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataFile.java @@ -0,0 +1,353 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.ContentFile; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileMetadata; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.ManifestReader; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Metrics; +import org.apache.iceberg.PartitionField; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.RowDelta; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.UpdatePartitionSpec; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkDataFile; +import org.apache.iceberg.spark.SparkDeleteFile; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.types.Conversions; +import org.apache.iceberg.types.Types; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.Column; +import org.apache.spark.sql.ColumnName; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestSparkDataFile { + + private static final HadoopTables TABLES = new HadoopTables(new Configuration()); + private static final Schema SCHEMA = + new Schema( + required(100, "id", Types.LongType.get()), + optional(101, "data", Types.StringType.get()), + required(102, "b", Types.BooleanType.get()), + optional(103, "i", Types.IntegerType.get()), + required(104, "l", Types.LongType.get()), + optional(105, "f", Types.FloatType.get()), + required(106, "d", Types.DoubleType.get()), + optional(107, "date", Types.DateType.get()), + required(108, "ts", Types.TimestampType.withZone()), + required(109, "tsntz", Types.TimestampType.withoutZone()), + required(110, "s", Types.StringType.get()), + optional(113, "bytes", Types.BinaryType.get()), + required(114, "dec_9_0", Types.DecimalType.of(9, 0)), + required(115, "dec_11_2", Types.DecimalType.of(11, 2)), + required(116, "dec_38_10", Types.DecimalType.of(38, 10)) // maximum precision + ); + private static final PartitionSpec SPEC = + PartitionSpec.builderFor(SCHEMA) + .identity("b") + .bucket("i", 2) + .identity("l") + .identity("f") + .identity("d") + .identity("date") + .hour("ts") + .identity("ts") + .identity("tsntz") + .truncate("s", 2) + .identity("bytes") + .bucket("dec_9_0", 2) + .bucket("dec_11_2", 2) + .bucket("dec_38_10", 2) + .build(); + + private static SparkSession spark; + private static JavaSparkContext sparkContext = null; + + @BeforeAll + public static void startSpark() { + TestSparkDataFile.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + TestSparkDataFile.sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestSparkDataFile.spark; + TestSparkDataFile.spark = null; + TestSparkDataFile.sparkContext = null; + currentSpark.stop(); + } + + @TempDir private File tableDir; + private String tableLocation = null; + + @BeforeEach + public void setupTableLocation() { + this.tableLocation = tableDir.toURI().toString(); + } + + @Test + public void testValueConversion() throws IOException { + Table table = + TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), Maps.newHashMap(), tableLocation); + checkSparkContentFiles(table); + } + + @Test + public void testValueConversionPartitionedTable() throws IOException { + Table table = TABLES.create(SCHEMA, SPEC, Maps.newHashMap(), tableLocation); + checkSparkContentFiles(table); + } + + @Test + public void testValueConversionWithEmptyStats() throws IOException { + Map props = Maps.newHashMap(); + props.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, "none"); + Table table = TABLES.create(SCHEMA, SPEC, props, tableLocation); + checkSparkContentFiles(table); + } + + private void checkSparkContentFiles(Table table) throws IOException { + Iterable rows = RandomData.generateSpark(table.schema(), 200, 0); + JavaRDD rdd = sparkContext.parallelize(Lists.newArrayList(rows)); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + Dataset df = + ((org.apache.spark.sql.classic.SparkSession) spark) + .internalCreateDataFrame( + JavaRDD.toRDD(rdd), SparkSchemaUtil.convert(table.schema()), false); + + df.write().format("iceberg").mode("append").save(tableLocation); + + table.refresh(); + + PartitionSpec dataFilesSpec = table.spec(); + + List manifests = table.currentSnapshot().allManifests(table.io()); + assertThat(manifests).hasSize(1); + + List dataFiles = Lists.newArrayList(); + try (ManifestReader reader = + ManifestFiles.read(manifests.get(0), table.io(), table.specs())) { + for (DataFile dataFile : reader) { + checkDataFile(dataFile.copy(), DataFiles.builder(dataFilesSpec).copy(dataFile).build()); + dataFiles.add(dataFile.copy()); + } + } + + UpdatePartitionSpec updateSpec = table.updateSpec(); + for (PartitionField field : dataFilesSpec.fields()) { + updateSpec.removeField(field.name()); + } + updateSpec.commit(); + + List positionDeleteFiles = Lists.newArrayList(); + List equalityDeleteFiles = Lists.newArrayList(); + + RowDelta rowDelta = table.newRowDelta(); + + for (DataFile dataFile : dataFiles) { + DeleteFile positionDeleteFile = createPositionDeleteFile(table, dataFile); + positionDeleteFiles.add(positionDeleteFile); + rowDelta.addDeletes(positionDeleteFile); + } + + DeleteFile equalityDeleteFile1 = createEqualityDeleteFile(table); + equalityDeleteFiles.add(equalityDeleteFile1); + rowDelta.addDeletes(equalityDeleteFile1); + + DeleteFile equalityDeleteFile2 = createEqualityDeleteFile(table); + equalityDeleteFiles.add(equalityDeleteFile2); + rowDelta.addDeletes(equalityDeleteFile2); + + rowDelta.commit(); + + Dataset dataFileDF = spark.read().format("iceberg").load(tableLocation + "#data_files"); + List sparkDataFiles = shuffleColumns(dataFileDF).collectAsList(); + assertThat(sparkDataFiles).hasSameSizeAs(dataFiles); + + Types.StructType dataFileType = DataFile.getType(dataFilesSpec.partitionType()); + StructType sparkDataFileType = sparkDataFiles.get(0).schema(); + SparkDataFile dataFileWrapper = new SparkDataFile(dataFileType, sparkDataFileType); + + for (int i = 0; i < dataFiles.size(); i++) { + checkDataFile(dataFiles.get(i), dataFileWrapper.wrap(sparkDataFiles.get(i))); + } + + Dataset positionDeleteFileDF = + spark.read().format("iceberg").load(tableLocation + "#delete_files").where("content = 1"); + List sparkPositionDeleteFiles = shuffleColumns(positionDeleteFileDF).collectAsList(); + assertThat(sparkPositionDeleteFiles).hasSameSizeAs(positionDeleteFiles); + + Types.StructType positionDeleteFileType = DataFile.getType(dataFilesSpec.partitionType()); + StructType sparkPositionDeleteFileType = sparkPositionDeleteFiles.get(0).schema(); + SparkDeleteFile positionDeleteFileWrapper = + new SparkDeleteFile(positionDeleteFileType, sparkPositionDeleteFileType); + + for (int i = 0; i < positionDeleteFiles.size(); i++) { + checkDeleteFile( + positionDeleteFiles.get(i), + positionDeleteFileWrapper.wrap(sparkPositionDeleteFiles.get(i))); + } + + Dataset equalityDeleteFileDF = + spark.read().format("iceberg").load(tableLocation + "#delete_files").where("content = 2"); + List sparkEqualityDeleteFiles = shuffleColumns(equalityDeleteFileDF).collectAsList(); + assertThat(sparkEqualityDeleteFiles).hasSameSizeAs(equalityDeleteFiles); + + Types.StructType equalityDeleteFileType = DataFile.getType(table.spec().partitionType()); + StructType sparkEqualityDeleteFileType = sparkEqualityDeleteFiles.get(0).schema(); + SparkDeleteFile equalityDeleteFileWrapper = + new SparkDeleteFile(equalityDeleteFileType, sparkEqualityDeleteFileType); + + for (int i = 0; i < equalityDeleteFiles.size(); i++) { + checkDeleteFile( + equalityDeleteFiles.get(i), + equalityDeleteFileWrapper.wrap(sparkEqualityDeleteFiles.get(i))); + } + } + + private Dataset shuffleColumns(Dataset df) { + List columns = + Arrays.stream(df.columns()).map(ColumnName::new).collect(Collectors.toList()); + Collections.shuffle(columns); + return df.select(columns.toArray(new Column[0])); + } + + private void checkDataFile(DataFile expected, DataFile actual) { + assertThat(expected.equalityFieldIds()).isNull(); + assertThat(actual.equalityFieldIds()).isNull(); + checkContentFile(expected, actual); + checkStructLike(expected.partition(), actual.partition()); + } + + private void checkDeleteFile(DeleteFile expected, DeleteFile actual) { + assertThat(expected.equalityFieldIds()).isEqualTo(actual.equalityFieldIds()); + checkContentFile(expected, actual); + checkStructLike(expected.partition(), actual.partition()); + } + + private void checkContentFile(ContentFile expected, ContentFile actual) { + assertThat(actual.content()).isEqualTo(expected.content()); + assertThat(actual.location()).isEqualTo(expected.location()); + assertThat(actual.format()).isEqualTo(expected.format()); + assertThat(actual.recordCount()).isEqualTo(expected.recordCount()); + assertThat(actual.fileSizeInBytes()).isEqualTo(expected.fileSizeInBytes()); + assertThat(actual.valueCounts()).isEqualTo(expected.valueCounts()); + assertThat(actual.nullValueCounts()).isEqualTo(expected.nullValueCounts()); + assertThat(actual.nanValueCounts()).isEqualTo(expected.nanValueCounts()); + assertThat(actual.lowerBounds()).isEqualTo(expected.lowerBounds()); + assertThat(actual.upperBounds()).isEqualTo(expected.upperBounds()); + assertThat(actual.keyMetadata()).isEqualTo(expected.keyMetadata()); + assertThat(actual.splitOffsets()).isEqualTo(expected.splitOffsets()); + assertThat(actual.sortOrderId()).isEqualTo(expected.sortOrderId()); + } + + private void checkStructLike(StructLike expected, StructLike actual) { + assertThat(actual.size()).isEqualTo(expected.size()); + for (int i = 0; i < expected.size(); i++) { + assertThat(actual.get(i, Object.class)).isEqualTo(expected.get(i, Object.class)); + } + } + + private DeleteFile createPositionDeleteFile(Table table, DataFile dataFile) { + PartitionSpec spec = table.specs().get(dataFile.specId()); + return FileMetadata.deleteFileBuilder(spec) + .ofPositionDeletes() + .withPath("/path/to/pos-deletes-" + UUID.randomUUID() + ".parquet") + .withFileSizeInBytes(dataFile.fileSizeInBytes() / 4) + .withPartition(dataFile.partition()) + .withRecordCount(2) + .withMetrics( + new Metrics( + 2L, + null, // no column sizes + null, // no value counts + null, // no null counts + null, // no NaN counts + ImmutableMap.of( + MetadataColumns.DELETE_FILE_PATH.fieldId(), + Conversions.toByteBuffer(Types.StringType.get(), dataFile.location())), + ImmutableMap.of( + MetadataColumns.DELETE_FILE_PATH.fieldId(), + Conversions.toByteBuffer(Types.StringType.get(), dataFile.location())))) + .withEncryptionKeyMetadata(ByteBuffer.allocate(4).putInt(35)) + .build(); + } + + private DeleteFile createEqualityDeleteFile(Table table) { + return FileMetadata.deleteFileBuilder(table.spec()) + .ofEqualityDeletes(3, 4) + .withPath("/path/to/eq-deletes-" + UUID.randomUUID() + ".parquet") + .withFileSizeInBytes(250) + .withRecordCount(1) + .withSortOrder(SortOrder.unsorted()) + .withEncryptionKeyMetadata(ByteBuffer.allocate(4).putInt(35)) + .build(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java new file mode 100644 index 000000000000..c2f5afef0e8c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkDataWrite.java @@ -0,0 +1,847 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.TableProperties.SPARK_WRITE_PARTITIONED_FANOUT_ENABLED; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ManifestFile; +import org.apache.iceberg.ManifestFiles; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SnapshotRef; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.exceptions.CommitStateUnknownException; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.SnapshotUtil; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkDataWrite { + private static final Configuration CONF = new Configuration(); + + @Parameter(index = 0) + private FileFormat format; + + @Parameter(index = 1) + private String branch; + + private static SparkSession spark = null; + private static final Schema SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + + @TempDir private Path temp; + + @Parameters(name = "format = {0}, branch = {1}") + public static Object[][] parameters() { + return new Object[][] { + new Object[] {FileFormat.PARQUET, null}, + new Object[] {FileFormat.PARQUET, SnapshotRef.MAIN_BRANCH}, + new Object[] {FileFormat.PARQUET, "testBranch"}, + new Object[] {FileFormat.AVRO, null}, + new Object[] {FileFormat.ORC, "testBranch"} + }; + } + + @BeforeAll + public static void startSpark() { + TestSparkDataWrite.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterEach + public void clearSourceCache() { + ManualSource.clearTables(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestSparkDataWrite.spark; + TestSparkDataWrite.spark = null; + currentSpark.stop(); + } + + @TestTemplate + public void testBasicWrite() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + // TODO: incoming columns must be ordered according to the table's schema + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + for (ManifestFile manifest : + SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { + // TODO: avro not support split + if (!format.equals(FileFormat.AVRO)) { + assertThat(file.splitOffsets()).as("Split offsets not present").isNotNull(); + } + assertThat(file.recordCount()).as("Should have reported record count as 1").isEqualTo(1); + assertThat(file.sortOrderId()).isEqualTo(SortOrder.unsorted().orderId()); + // TODO: append more metric info + if (format.equals(FileFormat.PARQUET)) { + assertThat(file.columnSizes()).as("Column sizes metric not present").isNotNull(); + assertThat(file.valueCounts()).as("Counts metric not present").isNotNull(); + assertThat(file.nullValueCounts()).as("Null value counts metric not present").isNotNull(); + assertThat(file.lowerBounds()).as("Lower bounds metric not present").isNotNull(); + assertThat(file.upperBounds()).as("Upper bounds metric not present").isNotNull(); + } + } + } + } + + @TestTemplate + public void testAppend() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "a"), + new SimpleRecord(5, "b"), + new SimpleRecord(6, "c")); + + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + + df.withColumn("id", df.col("id").plus(3)) + .select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(targetLocation); + + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testEmptyOverwrite() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + List expected = records; + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + + Dataset empty = spark.createDataFrame(ImmutableList.of(), SimpleRecord.class); + empty + .select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Overwrite) + .option("overwrite-mode", "dynamic") + .save(targetLocation); + + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testOverwrite() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("id").build(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "a"), + new SimpleRecord(3, "c"), + new SimpleRecord(4, "b"), + new SimpleRecord(6, "c")); + + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + + // overwrite with 2*id to replace record 2, append 4 and 6 + df.withColumn("id", df.col("id").multiply(2)) + .select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Overwrite) + .option("overwrite-mode", "dynamic") + .save(targetLocation); + + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testUnpartitionedOverwrite() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + + // overwrite with the same data; should not produce two copies + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Overwrite) + .save(targetLocation); + + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testUnpartitionedCreateWithTargetFileSizeViaTableProperties() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + table + .updateProperties() + .set(TableProperties.WRITE_TARGET_FILE_SIZE_BYTES, "4") // ~4 bytes; low enough to trigger + .commit(); + + List expected = Lists.newArrayListWithCapacity(4000); + for (int i = 0; i < 4000; i++) { + expected.add(new SimpleRecord(i, "a")); + } + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + + List files = Lists.newArrayList(); + for (ManifestFile manifest : + SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { + files.add(file); + } + } + + assertThat(files) + .hasSize(4) + .allSatisfy( + dataFile -> + assertThat(dataFile.recordCount()) + .as("All DataFiles contain 1000 rows") + .isEqualTo(1000)); + } + + @TestTemplate + public void testPartitionedCreateWithTargetFileSizeViaOption() { + partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType.NONE); + } + + @TestTemplate + public void testPartitionedFanoutCreateWithTargetFileSizeViaOption() { + partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType.TABLE); + } + + @TestTemplate + public void testPartitionedFanoutCreateWithTargetFileSizeViaOption2() { + partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType.JOB); + } + + @TestTemplate + public void testWriteProjection() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, null), new SimpleRecord(2, null), new SimpleRecord(3, null)); + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id") + .write() // select only id column + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testWriteProjectionWithMiddle() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Schema schema = + new Schema( + optional(1, "c1", Types.IntegerType.get()), + optional(2, "c2", Types.StringType.get()), + optional(3, "c3", Types.StringType.get())); + Table table = tables.create(schema, spec, location.toString()); + + List expected = + Lists.newArrayList( + new ThreeColumnRecord(1, null, "hello"), + new ThreeColumnRecord(2, null, "world"), + new ThreeColumnRecord(3, null, null)); + + Dataset df = spark.createDataFrame(expected, ThreeColumnRecord.class); + + df.select("c1", "c3") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("c1").as(Encoders.bean(ThreeColumnRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + } + + @TestTemplate + public void testViewsReturnRecentResults() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + tables.create(SCHEMA, spec, location.toString()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + Table table = tables.load(location.toString()); + createBranch(table); + + Dataset query = spark.read().format("iceberg").load(targetLocation).where("id = 1"); + query.createOrReplaceTempView("tmp"); + + List actual1 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected1 = Lists.newArrayList(new SimpleRecord(1, "a")); + assertThat(actual1).hasSameSizeAs(expected1).isEqualTo(expected1); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(targetLocation); + + List actual2 = + spark.table("tmp").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + List expected2 = + Lists.newArrayList(new SimpleRecord(1, "a"), new SimpleRecord(1, "a")); + assertThat(actual2).hasSameSizeAs(expected2).isEqualTo(expected2); + } + + @TestTemplate + public void testWriteDataFilesInTableSortOrder() throws IOException { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + List expected = Lists.newArrayListWithCapacity(10); + for (int i = 0; i < 10; i++) { + expected.add(new SimpleRecord(i, "a")); + } + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + Dataset result = spark.read().format("iceberg").load(location.toString()); + + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + assertThat(fileScanTasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + } + + @TestTemplate + public void testWriteDataFilesUnsortedTable() throws IOException { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List expected = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles should have unsorted sort order id") + .containsOnly(SortOrder.unsorted().orderId()); + } + } + + @TestTemplate + public void testWriteDataFilesAfterSortOrderChange() throws IOException { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List records = Lists.newArrayList(new SimpleRecord(1, "a")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + table.refresh(); + int unsortedId = SortOrder.unsorted().orderId(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks).extracting(task -> task.file().sortOrderId()).containsOnly(unsortedId); + } + + table.replaceSortOrder().asc("id").commit(); + int sortedId = table.sortOrder().orderId(); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("Should contain both unsorted and sorted files") + .containsOnly(unsortedId, sortedId); + } + } + + public void partitionedCreateWithTargetFileSizeViaOption(IcebergOptionsType option) { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "test"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Map properties = + ImmutableMap.of( + TableProperties.WRITE_DISTRIBUTION_MODE, TableProperties.WRITE_DISTRIBUTION_MODE_NONE); + Table table = tables.create(SCHEMA, spec, properties, location.toString()); + + List expected = Lists.newArrayListWithCapacity(8000); + for (int i = 0; i < 2000; i++) { + expected.add(new SimpleRecord(i, "a")); + expected.add(new SimpleRecord(i, "b")); + expected.add(new SimpleRecord(i, "c")); + expected.add(new SimpleRecord(i, "d")); + } + + Dataset df = spark.createDataFrame(expected, SimpleRecord.class); + + switch (option) { + case NONE: + df.select("id", "data") + .sort("data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, 4) // ~4 bytes; low enough to trigger + .save(location.toString()); + break; + case TABLE: + table.updateProperties().set(SPARK_WRITE_PARTITIONED_FANOUT_ENABLED, "true").commit(); + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, 4) // ~4 bytes; low enough to trigger + .save(location.toString()); + break; + case JOB: + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .option(SparkWriteOptions.TARGET_FILE_SIZE_BYTES, 4) // ~4 bytes; low enough to trigger + .option(SparkWriteOptions.FANOUT_ENABLED, true) + .save(location.toString()); + break; + default: + break; + } + + createBranch(table); + table.refresh(); + + Dataset result = spark.read().format("iceberg").load(targetLocation); + + List actual = + result.orderBy("id", "data").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + + List files = Lists.newArrayList(); + for (ManifestFile manifest : + SnapshotUtil.latestSnapshot(table, branch).allManifests(table.io())) { + for (DataFile file : ManifestFiles.read(manifest, table.io(), table.specs())) { + files.add(file); + } + } + assertThat(files) + .hasSize(8) + .allSatisfy( + dataFile -> + assertThat(dataFile.recordCount()) + .as("All DataFiles contain 1000 rows") + .isEqualTo(1000)); + } + + @TestTemplate + public void testCommitUnknownException() { + File parent = temp.resolve(format.toString()).toFile(); + File location = new File(parent, "commitunknown"); + String targetLocation = locationWithBranch(location); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + + df.select("id", "data") + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, format.toString()) + .mode(SaveMode.Append) + .save(location.toString()); + + createBranch(table); + table.refresh(); + + List records2 = + Lists.newArrayList( + new SimpleRecord(4, "d"), new SimpleRecord(5, "e"), new SimpleRecord(6, "f")); + + Dataset df2 = spark.createDataFrame(records2, SimpleRecord.class); + + AppendFiles append = table.newFastAppend(); + if (branch != null) { + append.toBranch(branch); + } + + AppendFiles spyAppend = spy(append); + doAnswer( + invocation -> { + append.commit(); + throw new CommitStateUnknownException(new RuntimeException("Datacenter on Fire")); + }) + .when(spyAppend) + .commit(); + + Table spyTable = spy(table); + when(spyTable.newAppend()).thenReturn(spyAppend); + SparkTable sparkTable = new SparkTable(spyTable); + + String manualTableName = "unknown_exception"; + ManualSource.setTable(manualTableName, sparkTable); + + // Although an exception is thrown here, write and commit have succeeded + assertThatThrownBy( + () -> + df2.select("id", "data") + .sort("data") + .write() + .format("org.apache.iceberg.spark.source.ManualSource") + .option(ManualSource.TABLE_NAME, manualTableName) + .mode(SaveMode.Append) + .save(targetLocation)) + .isInstanceOf(CommitStateUnknownException.class) + .hasMessageStartingWith("Datacenter on Fire"); + + // Since write and commit succeeded, the rows should be readable + Dataset result = spark.read().format("iceberg").load(targetLocation); + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual) + .hasSize(records.size() + records2.size()) + .containsExactlyInAnyOrder( + ImmutableList.builder() + .addAll(records) + .addAll(records2) + .build() + .toArray(new SimpleRecord[0])); + } + + public enum IcebergOptionsType { + NONE, + TABLE, + JOB + } + + private String locationWithBranch(File location) { + if (branch == null) { + return location.toString(); + } + + return location + "#branch_" + branch; + } + + private void createBranch(Table table) { + if (branch != null && !branch.equals(SnapshotRef.MAIN_BRANCH)) { + table.manageSnapshots().createBranch(branch, table.currentSnapshot().snapshotId()).commit(); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkFileWriterFactory.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkFileWriterFactory.java new file mode 100644 index 000000000000..575e6658db22 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkFileWriterFactory.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.FileWriterFactory; +import org.apache.iceberg.io.TestFileWriterFactory; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +public class TestSparkFileWriterFactory extends TestFileWriterFactory { + + @Override + protected FileWriterFactory newWriterFactory( + Schema dataSchema, + List equalityFieldIds, + Schema equalityDeleteRowSchema, + Schema positionDeleteRowSchema) { + return SparkFileWriterFactory.builderFor(table) + .dataSchema(table.schema()) + .dataFileFormat(format()) + .deleteFileFormat(format()) + .equalityFieldIds(ArrayUtil.toIntArray(equalityFieldIds)) + .equalityDeleteRowSchema(equalityDeleteRowSchema) + .positionDeleteRowSchema(positionDeleteRowSchema) + .build(); + } + + @Override + protected InternalRow toRow(Integer id, String data) { + InternalRow row = new GenericInternalRow(2); + row.update(0, id); + row.update(1, UTF8String.fromString(data)); + return row; + } + + @Override + protected StructLikeSet toSet(Iterable rows) { + StructLikeSet set = StructLikeSet.create(table.schema().asStruct()); + StructType sparkType = SparkSchemaUtil.convert(table.schema()); + for (InternalRow row : rows) { + InternalRowWrapper wrapper = new InternalRowWrapper(sparkType, table.schema().asStruct()); + set.add(wrapper.wrap(row)); + } + return set; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkMetadataColumns.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkMetadataColumns.java new file mode 100644 index 000000000000..7498f129b21b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkMetadataColumns.java @@ -0,0 +1,453 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.FORMAT_VERSION; +import static org.apache.iceberg.TableProperties.ORC_VECTORIZATION_ENABLED; +import static org.apache.iceberg.TableProperties.PARQUET_BATCH_SIZE; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; +import static org.apache.iceberg.TableProperties.PARQUET_VECTORIZATION_ENABLED; +import static org.apache.spark.sql.functions.expr; +import static org.apache.spark.sql.functions.lit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkMetadataColumns extends TestBase { + + private static final String TABLE_NAME = "test_table"; + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "category", Types.StringType.get()), + Types.NestedField.optional(3, "data", Types.StringType.get())); + private static final PartitionSpec SPEC = PartitionSpec.unpartitioned(); + private static final PartitionSpec UNKNOWN_SPEC = + TestHelpers.newExpectedSpecBuilder() + .withSchema(SCHEMA) + .withSpecId(1) + .addField("zero", 1, "id_zero") + .build(); + + @Parameters(name = "fileFormat = {0}, vectorized = {1}, formatVersion = {2}") + public static Object[][] parameters() { + List parameters = Lists.newArrayList(); + for (int version : TestHelpers.ALL_VERSIONS) { + parameters.add(new Object[] {FileFormat.PARQUET, false, version}); + parameters.add(new Object[] {FileFormat.PARQUET, true, version}); + parameters.add(new Object[] {FileFormat.AVRO, false, version}); + parameters.add(new Object[] {FileFormat.ORC, false, version}); + parameters.add(new Object[] {FileFormat.ORC, true, version}); + } + return parameters.toArray(new Object[0][]); + } + + @TempDir private Path temp; + + @Parameter(index = 0) + private FileFormat fileFormat; + + @Parameter(index = 1) + private boolean vectorized; + + @Parameter(index = 2) + private int formatVersion; + + private Table table = null; + + @BeforeAll + public static void setupSpark() { + ImmutableMap config = + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "cache-enabled", "true"); + spark + .conf() + .set("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.source.TestSparkCatalog"); + config.forEach( + (key, value) -> spark.conf().set("spark.sql.catalog.spark_catalog." + key, value)); + } + + @BeforeEach + public void setupTable() throws IOException { + createAndInitTable(); + } + + @AfterEach + public void dropTable() { + TestTables.clearTables(); + } + + @TestTemplate + public void testSpecAndPartitionMetadataColumns() { + // TODO: support metadata structs in vectorized ORC reads + assumeThat(fileFormat).isNotEqualTo(FileFormat.ORC); + assumeThat(vectorized).isFalse(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", TABLE_NAME); + + table.refresh(); + table.updateSpec().addField("data").commit(); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", TABLE_NAME); + + table.refresh(); + table.updateSpec().addField(Expressions.bucket("category", 8)).commit(); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", TABLE_NAME); + + table.refresh(); + table.updateSpec().removeField("data").commit(); + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", TABLE_NAME); + + table.refresh(); + table.updateSpec().renameField("category_bucket_8", "category_bucket_8_another_name").commit(); + + List expected = + ImmutableList.of( + row(0, row(null, null)), + row(1, row("b1", null)), + row(2, row("b1", 2)), + row(3, row(null, 2))); + assertEquals( + "Rows must match", + expected, + sql("SELECT _spec_id, _partition FROM %s ORDER BY _spec_id", TABLE_NAME)); + } + + @TestTemplate + public void testPartitionMetadataColumnWithManyColumns() { + List fields = + Lists.newArrayList(Types.NestedField.required(0, "id", Types.LongType.get())); + List additionalCols = + IntStream.range(1, 1010) + .mapToObj(i -> Types.NestedField.optional(i, "c" + i, Types.StringType.get())) + .collect(Collectors.toList()); + fields.addAll(additionalCols); + Schema manyColumnsSchema = new Schema(fields); + PartitionSpec spec = PartitionSpec.builderFor(manyColumnsSchema).identity("id").build(); + + TableOperations ops = ((HasTableOperations) table).operations(); + TableMetadata base = ops.current(); + ops.commit(base, base.updateSchema(manyColumnsSchema).updatePartitionSpec(spec)); + + Dataset df = + spark + .range(2) + .withColumns( + IntStream.range(1, 1010) + .boxed() + .collect(Collectors.toMap(i -> "c" + i, i -> expr("CAST(id as STRING)")))); + StructType sparkSchema = spark.table(TABLE_NAME).schema(); + spark + .createDataFrame(df.rdd(), sparkSchema) + .coalesce(1) + .write() + .format("iceberg") + .mode("append") + .save(TABLE_NAME); + + assertThat(spark.table(TABLE_NAME).select("*", "_partition").count()).isEqualTo(2); + List expected = + ImmutableList.of(row(row(0L), 0L, "0", "0", "0"), row(row(1L), 1L, "1", "1", "1")); + assertEquals( + "Rows must match", + expected, + sql("SELECT _partition, id, c999, c1000, c1001 FROM %s ORDER BY id", TABLE_NAME)); + } + + @TestTemplate + public void testPositionMetadataColumnWithMultipleRowGroups() throws NoSuchTableException { + assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET); + + table.updateProperties().set(PARQUET_ROW_GROUP_SIZE_BYTES, "100").commit(); + + List ids = Lists.newArrayList(); + for (long id = 0L; id < 200L; id++) { + ids.add(id); + } + Dataset df = + spark + .createDataset(ids, Encoders.LONG()) + .withColumnRenamed("value", "id") + .withColumn("category", lit("hr")) + .withColumn("data", lit("ABCDEF")); + df.coalesce(1).writeTo(TABLE_NAME).append(); + + assertThat(spark.table(TABLE_NAME).count()).isEqualTo(200); + + List expectedRows = ids.stream().map(this::row).collect(Collectors.toList()); + assertEquals("Rows must match", expectedRows, sql("SELECT _pos FROM %s", TABLE_NAME)); + } + + @TestTemplate + public void testPositionMetadataColumnWithMultipleBatches() throws NoSuchTableException { + assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET); + + table.updateProperties().set(PARQUET_BATCH_SIZE, "1000").commit(); + + List ids = Lists.newArrayList(); + for (long id = 0L; id < 7500L; id++) { + ids.add(id); + } + Dataset df = + spark + .createDataset(ids, Encoders.LONG()) + .withColumnRenamed("value", "id") + .withColumn("category", lit("hr")) + .withColumn("data", lit("ABCDEF")); + df.coalesce(1).writeTo(TABLE_NAME).append(); + + assertThat(spark.table(TABLE_NAME).count()).isEqualTo(7500); + + List expectedRows = ids.stream().map(this::row).collect(Collectors.toList()); + assertEquals("Rows must match", expectedRows, sql("SELECT _pos FROM %s", TABLE_NAME)); + } + + @TestTemplate + public void testPartitionMetadataColumnWithUnknownTransforms() { + // replace the table spec to include an unknown transform + TableOperations ops = ((HasTableOperations) table).operations(); + TableMetadata base = ops.current(); + ops.commit(base, base.updatePartitionSpec(UNKNOWN_SPEC)); + + assertThatThrownBy(() -> sql("SELECT _partition FROM %s", TABLE_NAME)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot build table partition type, unknown transforms: [zero]"); + } + + @TestTemplate + public void testConflictingColumns() { + table + .updateSchema() + .addColumn(MetadataColumns.SPEC_ID.name(), Types.IntegerType.get()) + .addColumn(MetadataColumns.FILE_PATH.name(), Types.StringType.get()) + .commit(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1', -1, 'path/to/file')", TABLE_NAME); + + assertEquals( + "Rows must match", + ImmutableList.of(row(1L, "a1")), + sql("SELECT id, category FROM %s", TABLE_NAME)); + + assertThatThrownBy(() -> sql("SELECT * FROM %s", TABLE_NAME)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Table column names conflict with names reserved for Iceberg metadata columns: [_spec_id, _file]."); + + table.refresh(); + + table + .updateSchema() + .renameColumn(MetadataColumns.SPEC_ID.name(), "_renamed" + MetadataColumns.SPEC_ID.name()) + .renameColumn( + MetadataColumns.FILE_PATH.name(), "_renamed" + MetadataColumns.FILE_PATH.name()) + .commit(); + + assertEquals( + "Rows must match", + ImmutableList.of(row(0, null, -1)), + sql("SELECT _spec_id, _partition, _renamed_spec_id FROM %s", TABLE_NAME)); + } + + @TestTemplate + public void testIdentifierFields() { + table.updateSchema().setIdentifierFields("id").commit(); + + sql("INSERT INTO TABLE %s VALUES (1, 'a1', 'b1')", TABLE_NAME); + + assertEquals( + "Rows must match", + ImmutableList.of(row(1L, 0, null)), + sql("SELECT id, _spec_id, _partition FROM %s", TABLE_NAME)); + } + + @TestTemplate + public void testRowLineageColumnsResolvedInV3OrHigher() { + if (formatVersion >= 3) { + // Test against an empty table to ensure column resolution in formats supporting row lineage + // and so that the test doesn't have to change with inheritance + assertEquals( + "Rows must match", + ImmutableList.of(), + sql("SELECT _row_id, _last_updated_sequence_number, id FROM %s", TABLE_NAME)); + } else { + // Should fail to resolve row lineage metadata columns in V1/V2 tables + assertThatThrownBy(() -> sql("SELECT _row_id FROM %s", TABLE_NAME)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `_row_id` cannot be resolved"); + assertThatThrownBy(() -> sql("SELECT _last_updated_sequence_number FROM %s", TABLE_NAME)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining( + "A column, variable, or function parameter with name `_last_updated_sequence_number` cannot be resolved"); + } + } + + @TestTemplate + public void testPartitionMetadataColumnWithMapColumn() throws IOException { + assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET); + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + + Schema mapSchema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "ts", Types.LongType.get()), + Types.NestedField.optional( + 3, + "tags", + Types.MapType.ofOptional(4, 5, Types.StringType.get(), Types.StringType.get()))); + PartitionSpec bucketSpec = PartitionSpec.builderFor(mapSchema).bucket("id", 1).build(); + + Map properties = Maps.newHashMap(); + properties.put(FORMAT_VERSION, String.valueOf(formatVersion)); + properties.put(DEFAULT_FILE_FORMAT, FileFormat.PARQUET.name()); + properties.put(PARQUET_VECTORIZATION_ENABLED, String.valueOf(vectorized)); + // merge-on-read: DELETE writes position delete files instead of rewriting data files. + // This routes through SupportsDelta which adds _partition to the scan projection. + properties.put("write.delete.mode", "merge-on-read"); + + String mapTableName = "test_map_partition_collision"; + TestTables.create( + Files.createTempDirectory(temp, "junit").toFile(), + mapTableName, + mapSchema, + bucketSpec, + properties); + + // Both rows in a single INSERT so they land in the same Parquet file. + // With both rows sharing a file, Spark uses merge-on-read which adds _partition to the scan + // projection. + sql( + "INSERT INTO TABLE %s VALUES (1, 1000, map('env', 'prod')), (2, 9999999999999999, map('env', 'dev'))", + mapTableName); + + sql("DELETE FROM %s WHERE ts < 9999999999999999", mapTableName); + assertThat(sql("SELECT id, _partition FROM %s", mapTableName)).hasSize(1); + } + + @TestTemplate + public void testPartitionMetadataColumnWithListColumn() throws IOException { + assumeThat(fileFormat).isEqualTo(FileFormat.PARQUET); + assumeThat(formatVersion).isGreaterThanOrEqualTo(2); + + Schema listSchema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "ts", Types.LongType.get()), + Types.NestedField.optional( + 3, "tags", Types.ListType.ofOptional(4, Types.StringType.get()))); + PartitionSpec bucketSpec = PartitionSpec.builderFor(listSchema).bucket("id", 1).build(); + + Map properties = Maps.newHashMap(); + properties.put(FORMAT_VERSION, String.valueOf(formatVersion)); + properties.put(DEFAULT_FILE_FORMAT, FileFormat.PARQUET.name()); + properties.put(PARQUET_VECTORIZATION_ENABLED, String.valueOf(vectorized)); + // merge-on-read: DELETE writes position delete files instead of rewriting data files. + // This routes through SupportsDelta which adds _partition to the scan projection. + properties.put("write.delete.mode", "merge-on-read"); + + String listTableName = "test_list_partition_collision"; + TestTables.create( + Files.createTempDirectory(temp, "junit").toFile(), + listTableName, + listSchema, + bucketSpec, + properties); + + // Both rows in a single INSERT so they land in the same Parquet file. + // With both rows sharing a file, Spark uses merge-on-read which adds _partition to the scan + // projection. + sql( + "INSERT INTO TABLE %s VALUES (1, 1000, array('prod')), (2, 9999999999999999, array('dev'))", + listTableName); + + sql("DELETE FROM %s WHERE ts < 9999999999999999", listTableName); + assertThat(sql("SELECT id, _partition FROM %s", listTableName)).hasSize(1); + } + + private void createAndInitTable() throws IOException { + Map properties = Maps.newHashMap(); + properties.put(FORMAT_VERSION, String.valueOf(formatVersion)); + properties.put(DEFAULT_FILE_FORMAT, fileFormat.name()); + + switch (fileFormat) { + case PARQUET: + properties.put(PARQUET_VECTORIZATION_ENABLED, String.valueOf(vectorized)); + break; + case ORC: + properties.put(ORC_VECTORIZATION_ENABLED, String.valueOf(vectorized)); + break; + default: + Preconditions.checkState( + !vectorized, "File format %s does not support vectorized reads", fileFormat); + } + + this.table = + TestTables.create( + Files.createTempDirectory(temp, "junit").toFile(), + TABLE_NAME, + SCHEMA, + SPEC, + properties); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPartitioningWriters.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPartitioningWriters.java new file mode 100644 index 000000000000..979abd21e7f7 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPartitioningWriters.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.FileWriterFactory; +import org.apache.iceberg.io.TestPartitioningWriters; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +public class TestSparkPartitioningWriters extends TestPartitioningWriters { + + @Override + protected FileWriterFactory newWriterFactory( + Schema dataSchema, + List equalityFieldIds, + Schema equalityDeleteRowSchema, + Schema positionDeleteRowSchema) { + return SparkFileWriterFactory.builderFor(table) + .dataSchema(table.schema()) + .dataFileFormat(format()) + .deleteFileFormat(format()) + .equalityFieldIds(ArrayUtil.toIntArray(equalityFieldIds)) + .equalityDeleteRowSchema(equalityDeleteRowSchema) + .positionDeleteRowSchema(positionDeleteRowSchema) + .build(); + } + + @Override + protected InternalRow toRow(Integer id, String data) { + InternalRow row = new GenericInternalRow(2); + row.update(0, id); + row.update(1, UTF8String.fromString(data)); + return row; + } + + @Override + protected StructLikeSet toSet(Iterable rows) { + StructLikeSet set = StructLikeSet.create(table.schema().asStruct()); + StructType sparkType = SparkSchemaUtil.convert(table.schema()); + for (InternalRow row : rows) { + InternalRowWrapper wrapper = new InternalRowWrapper(sparkType, table.schema().asStruct()); + set.add(wrapper.wrap(row)); + } + return set; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPlanningUtil.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPlanningUtil.java new file mode 100644 index 000000000000..e4602532f029 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPlanningUtil.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.apache.iceberg.BaseScanTaskGroup; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataTask; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.MockFileScanTask; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.ScanTask; +import org.apache.iceberg.ScanTaskGroup; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.TestHelpers.Row; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkPlanningUtil extends TestBaseWithCatalog { + + private static final Schema SCHEMA = + new Schema( + required(1, "id", Types.IntegerType.get()), + required(2, "data", Types.StringType.get()), + required(3, "category", Types.StringType.get())); + private static final PartitionSpec SPEC_1 = + PartitionSpec.builderFor(SCHEMA).withSpecId(1).bucket("id", 16).identity("data").build(); + private static final PartitionSpec SPEC_2 = + PartitionSpec.builderFor(SCHEMA).withSpecId(2).identity("data").build(); + private static final List EXECUTOR_LOCATIONS = + ImmutableList.of("host1_exec1", "host1_exec2", "host1_exec3", "host2_exec1", "host2_exec2"); + + @TestTemplate + public void testFileScanTaskWithoutDeletes() { + List tasks = + ImmutableList.of( + new MockFileScanTask(mockDataFile(Row.of(1, "a")), SCHEMA, SPEC_1), + new MockFileScanTask(mockDataFile(Row.of(2, "b")), SCHEMA, SPEC_1), + new MockFileScanTask(mockDataFile(Row.of(3, "c")), SCHEMA, SPEC_1)); + ScanTaskGroup taskGroup = new BaseScanTaskGroup<>(tasks); + List> taskGroups = ImmutableList.of(taskGroup); + + String[][] locations = SparkPlanningUtil.assignExecutors(taskGroups, EXECUTOR_LOCATIONS); + + // should not assign executors if there are no deletes + assertThat(locations).hasDimensions(1, 0); + } + + @TestTemplate + public void testFileScanTaskWithDeletes() { + StructLike partition1 = Row.of("k2", null); + StructLike partition2 = Row.of("k1"); + List tasks = + ImmutableList.of( + new MockFileScanTask( + mockDataFile(partition1), mockDeleteFiles(1, partition1), SCHEMA, SPEC_1), + new MockFileScanTask( + mockDataFile(partition2), mockDeleteFiles(3, partition2), SCHEMA, SPEC_2), + new MockFileScanTask( + mockDataFile(partition1), mockDeleteFiles(2, partition1), SCHEMA, SPEC_1)); + ScanTaskGroup taskGroup = new BaseScanTaskGroup<>(tasks); + List> taskGroups = ImmutableList.of(taskGroup); + + String[][] locations = SparkPlanningUtil.assignExecutors(taskGroups, EXECUTOR_LOCATIONS); + + // should assign executors and handle different size of partitions + assertThat(locations.length).isEqualTo(1); + assertThat(locations[0].length).isGreaterThanOrEqualTo(1); + } + + @TestTemplate + public void testFileScanTaskWithUnpartitionedDeletes() { + List tasks1 = + ImmutableList.of( + new MockFileScanTask( + mockDataFile(Row.of()), + mockDeleteFiles(2, Row.of()), + SCHEMA, + PartitionSpec.unpartitioned()), + new MockFileScanTask( + mockDataFile(Row.of()), + mockDeleteFiles(2, Row.of()), + SCHEMA, + PartitionSpec.unpartitioned()), + new MockFileScanTask( + mockDataFile(Row.of()), + mockDeleteFiles(2, Row.of()), + SCHEMA, + PartitionSpec.unpartitioned())); + ScanTaskGroup taskGroup1 = new BaseScanTaskGroup<>(tasks1); + List tasks2 = + ImmutableList.of( + new MockFileScanTask( + mockDataFile(null), + mockDeleteFiles(2, null), + SCHEMA, + PartitionSpec.unpartitioned()), + new MockFileScanTask( + mockDataFile(null), + mockDeleteFiles(2, null), + SCHEMA, + PartitionSpec.unpartitioned()), + new MockFileScanTask( + mockDataFile(null), + mockDeleteFiles(2, null), + SCHEMA, + PartitionSpec.unpartitioned())); + ScanTaskGroup taskGroup2 = new BaseScanTaskGroup<>(tasks2); + List> taskGroups = ImmutableList.of(taskGroup1, taskGroup2); + + String[][] locations = SparkPlanningUtil.assignExecutors(taskGroups, EXECUTOR_LOCATIONS); + + // should not assign executors if the table is unpartitioned + assertThat(locations).hasDimensions(2, 0); + } + + @TestTemplate + public void testDataTasks() { + List tasks = + ImmutableList.of( + new MockDataTask(mockDataFile(Row.of(1, "a"))), + new MockDataTask(mockDataFile(Row.of(2, "b"))), + new MockDataTask(mockDataFile(Row.of(3, "c")))); + ScanTaskGroup taskGroup = new BaseScanTaskGroup<>(tasks); + List> taskGroups = ImmutableList.of(taskGroup); + + String[][] locations = SparkPlanningUtil.assignExecutors(taskGroups, EXECUTOR_LOCATIONS); + + // should not assign executors for data tasks + assertThat(locations).hasDimensions(1, 0); + } + + @TestTemplate + public void testUnknownTasks() { + List tasks = ImmutableList.of(new UnknownScanTask(), new UnknownScanTask()); + ScanTaskGroup taskGroup = new BaseScanTaskGroup<>(tasks); + List> taskGroups = ImmutableList.of(taskGroup); + + String[][] locations = SparkPlanningUtil.assignExecutors(taskGroups, EXECUTOR_LOCATIONS); + + // should not assign executors for unknown tasks + assertThat(locations).hasDimensions(1, 0); + } + + private static DataFile mockDataFile(StructLike partition) { + DataFile file = Mockito.mock(DataFile.class); + when(file.partition()).thenReturn(partition); + return file; + } + + private static DeleteFile[] mockDeleteFiles(int count, StructLike partition) { + DeleteFile[] files = new DeleteFile[count]; + for (int index = 0; index < count; index++) { + files[index] = mockDeleteFile(partition); + } + return files; + } + + private static DeleteFile mockDeleteFile(StructLike partition) { + DeleteFile file = Mockito.mock(DeleteFile.class); + when(file.partition()).thenReturn(partition); + return file; + } + + private static class MockDataTask extends MockFileScanTask implements DataTask { + + MockDataTask(DataFile file) { + super(file); + } + + @Override + public PartitionSpec spec() { + return PartitionSpec.unpartitioned(); + } + + @Override + public CloseableIterable rows() { + throw new UnsupportedOperationException(); + } + } + + private static class UnknownScanTask implements ScanTask {} +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPositionDeltaWriters.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPositionDeltaWriters.java new file mode 100644 index 000000000000..9dc56abf9fb6 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkPositionDeltaWriters.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.FileWriterFactory; +import org.apache.iceberg.io.TestPositionDeltaWriters; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.unsafe.types.UTF8String; + +public class TestSparkPositionDeltaWriters extends TestPositionDeltaWriters { + + @Override + protected FileWriterFactory newWriterFactory( + Schema dataSchema, + List equalityFieldIds, + Schema equalityDeleteRowSchema, + Schema positionDeleteRowSchema) { + return SparkFileWriterFactory.builderFor(table) + .dataSchema(table.schema()) + .dataFileFormat(format()) + .deleteFileFormat(format()) + .equalityFieldIds(ArrayUtil.toIntArray(equalityFieldIds)) + .equalityDeleteRowSchema(equalityDeleteRowSchema) + .positionDeleteRowSchema(positionDeleteRowSchema) + .build(); + } + + @Override + protected InternalRow toRow(Integer id, String data) { + InternalRow row = new GenericInternalRow(2); + row.update(0, id); + row.update(1, UTF8String.fromString(data)); + return row; + } + + @Override + protected StructLikeSet toSet(Iterable rows) { + StructLikeSet set = StructLikeSet.create(table.schema().asStruct()); + StructType sparkType = SparkSchemaUtil.convert(table.schema()); + for (InternalRow row : rows) { + InternalRowWrapper wrapper = new InternalRowWrapper(sparkType, table.schema().asStruct()); + set.add(wrapper.wrap(row)); + } + return set; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadMetrics.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadMetrics.java new file mode 100644 index 000000000000..57b2c7146b79 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadMetrics.java @@ -0,0 +1,279 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; +import static scala.collection.JavaConverters.seqAsJavaListConverter; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.execution.metric.SQLMetric; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import scala.collection.JavaConverters; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReadMetrics extends TestBaseWithCatalog { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testReadMetricsForV1Table() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT) USING iceberg TBLPROPERTIES ('format-version'='1')", + tableName); + + spark.range(10000).coalesce(1).writeTo(tableName).append(); + spark.range(10001, 20000).coalesce(1).writeTo(tableName).append(); + + Dataset df = spark.sql(String.format("select * from %s where id < 10000", tableName)); + df.collect(); + + List sparkPlans = + seqAsJavaListConverter(df.queryExecution().executedPlan().collectLeaves()).asJava(); + Map metricsMap = + JavaConverters.mapAsJavaMapConverter(sparkPlans.get(0).metrics()).asJava(); + // Common + assertThat(metricsMap) + .hasEntrySatisfying( + "totalPlanningDuration", sqlMetric -> assertThat(sqlMetric.value()).isNotEqualTo(0)); + + // data manifests + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDataManifest", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + "scannedDataManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDataManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + + // data files + assertThat(metricsMap) + .hasEntrySatisfying( + "resultDataFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDataFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDataFileSize", sqlMetric -> assertThat(sqlMetric.value()).isNotEqualTo(0)); + + // delete manifests + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "scannedDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + + // delete files + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDeleteFileSize", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "resultDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "equalityDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "indexedDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "positionalDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + } + + @TestTemplate + public void testReadMetricsForV2Table() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT) USING iceberg TBLPROPERTIES ('format-version'='2')", + tableName); + + spark.range(10000).coalesce(1).writeTo(tableName).append(); + spark.range(10001, 20000).coalesce(1).writeTo(tableName).append(); + + Dataset df = spark.sql(String.format("select * from %s where id < 10000", tableName)); + df.collect(); + + List sparkPlans = + seqAsJavaListConverter(df.queryExecution().executedPlan().collectLeaves()).asJava(); + Map metricsMap = + JavaConverters.mapAsJavaMapConverter(sparkPlans.get(0).metrics()).asJava(); + + // Common + assertThat(metricsMap) + .hasEntrySatisfying( + "totalPlanningDuration", sqlMetric -> assertThat(sqlMetric.value()).isNotEqualTo(0)); + + // data manifests + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDataManifest", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + "scannedDataManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDataManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + + // data files + assertThat(metricsMap) + .hasEntrySatisfying( + "resultDataFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDataFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDataFileSize", sqlMetric -> assertThat(sqlMetric.value()).isNotEqualTo(0)); + + // delete manifests + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "scannedDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + + // delete files + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDeleteFileSize", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "resultDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "equalityDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "indexedDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "positionalDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + } + + @TestTemplate + public void testDeleteMetrics() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT)" + + " USING iceberg" + + " TBLPROPERTIES (\n" + + " 'write.delete.mode'='merge-on-read',\n" + + " 'write.update.mode'='merge-on-read',\n" + + " 'write.merge.mode'='merge-on-read',\n" + + " 'format-version'='2'\n" + + " )", + tableName); + + spark.range(10000).coalesce(1).writeTo(tableName).append(); + + spark.sql(String.format("DELETE FROM %s WHERE id = 1", tableName)).collect(); + Dataset df = spark.sql(String.format("SELECT * FROM %s", tableName)); + df.collect(); + + List sparkPlans = + seqAsJavaListConverter(df.queryExecution().executedPlan().collectLeaves()).asJava(); + Map metricsMap = + JavaConverters.mapAsJavaMapConverter(sparkPlans.get(0).metrics()).asJava(); + + // Common + assertThat(metricsMap) + .hasEntrySatisfying( + "totalPlanningDuration", sqlMetric -> assertThat(sqlMetric.value()).isNotEqualTo(0)); + + // data manifests + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDataManifest", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "scannedDataManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDataManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + + // data files + assertThat(metricsMap) + .hasEntrySatisfying( + "resultDataFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDataFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDataFileSize", sqlMetric -> assertThat(sqlMetric.value()).isNotEqualTo(0)); + + // delete manifests + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "scannedDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDeleteManifests", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + + // delete files + assertThat(metricsMap) + .hasEntrySatisfying( + "totalDeleteFileSize", sqlMetric -> assertThat(sqlMetric.value()).isNotEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "resultDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "equalityDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + "indexedDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "positionalDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + "skippedDeleteFiles", sqlMetric -> assertThat(sqlMetric.value()).isEqualTo(0)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java new file mode 100644 index 000000000000..de6a5e59029c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReadProjection.java @@ -0,0 +1,275 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.Files.localOutput; +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.apache.iceberg.data.FileHelpers.encrypt; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.data.GenericFileWriterFactory; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.TypeUtil; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReadProjection extends TestReadProjection { + + private static SparkSession spark = null; + + @Parameters(name = "format = {0}, vectorized = {1}, planningMode = {2}") + public static Object[][] parameters() { + return new Object[][] { + {FileFormat.PARQUET, false, LOCAL}, + {FileFormat.PARQUET, true, DISTRIBUTED}, + {FileFormat.AVRO, false, LOCAL}, + {FileFormat.ORC, false, DISTRIBUTED}, + {FileFormat.ORC, true, LOCAL} + }; + } + + @Parameter(index = 1) + private boolean vectorized; + + @Parameter(index = 2) + private PlanningMode planningMode; + + @BeforeAll + public static void startSpark() { + TestSparkReadProjection.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + ImmutableMap config = + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "parquet-enabled", "true", + "cache-enabled", "false"); + spark + .conf() + .set("spark.sql.catalog.spark_catalog", "org.apache.iceberg.spark.source.TestSparkCatalog"); + config.forEach( + (key, value) -> spark.conf().set("spark.sql.catalog.spark_catalog." + key, value)); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestSparkReadProjection.spark; + TestSparkReadProjection.spark = null; + currentSpark.stop(); + } + + @Override + protected Record writeAndRead(String desc, Schema writeSchema, Schema readSchema, Record record) + throws IOException { + File parent = new File(temp.toFile(), desc); + File location = new File(parent, "test"); + File dataFolder = new File(location, "data"); + assertThat(dataFolder.mkdirs()).as("mkdirs should succeed").isTrue(); + + File testFile = new File(dataFolder, format.addExtension(UUID.randomUUID().toString())); + + Table table = + TestTables.create( + location, + desc, + writeSchema, + PartitionSpec.unpartitioned(), + ImmutableMap.of( + TableProperties.DATA_PLANNING_MODE, planningMode.modeName(), + TableProperties.DELETE_PLANNING_MODE, planningMode.modeName())); + try { + // Important: use the table's schema for the rest of the test + // When tables are created, the column ids are reassigned. + Schema tableSchema = table.schema(); + + DataWriter writer = + new GenericFileWriterFactory.Builder() + .dataFileFormat(format) + .dataSchema(tableSchema) + .build() + .newDataWriter(encrypt(localOutput(testFile)), PartitionSpec.unpartitioned(), null); + try (writer) { + writer.write(record); + } + + DataFile file = writer.toDataFile(); + + table.newAppend().appendFile(file).commit(); + + // rewrite the read schema for the table's reassigned ids + Map idMapping = Maps.newHashMap(); + for (int id : allIds(writeSchema)) { + // translate each id to the original schema's column name, then to the new schema's id + String originalName = writeSchema.findColumnName(id); + idMapping.put(id, tableSchema.findField(originalName).fieldId()); + } + Schema expectedSchema = reassignIds(readSchema, idMapping); + + // Set the schema to the expected schema directly to simulate the table schema evolving + TestTables.replaceMetadata(desc, TestTables.readMetadata(desc).updateSchema(expectedSchema)); + + Dataset df = + spark + .read() + .format("org.apache.iceberg.spark.source.TestIcebergSource") + .option("iceberg.table.name", desc) + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(); + + return SparkValueConverter.convert(readSchema, df.collectAsList().get(0)); + + } finally { + TestTables.clearTables(); + } + } + + private List allIds(Schema schema) { + List ids = Lists.newArrayList(); + TypeUtil.visit( + schema, + new TypeUtil.SchemaVisitor() { + @Override + public Void field(Types.NestedField field, Void fieldResult) { + ids.add(field.fieldId()); + return null; + } + + @Override + public Void list(Types.ListType list, Void elementResult) { + ids.add(list.elementId()); + return null; + } + + @Override + public Void map(Types.MapType map, Void keyResult, Void valueResult) { + ids.add(map.keyId()); + ids.add(map.valueId()); + return null; + } + }); + return ids; + } + + private Schema reassignIds(Schema schema, Map idMapping) { + return new Schema( + TypeUtil.visit( + schema, + new TypeUtil.SchemaVisitor() { + private int mapId(int id) { + if (idMapping.containsKey(id)) { + return idMapping.get(id); + } + return 1000 + id; // make sure the new IDs don't conflict with reassignment + } + + @Override + public Type schema(Schema schema, Type structResult) { + return structResult; + } + + @Override + public Type struct(Types.StructType struct, List fieldResults) { + List newFields = + Lists.newArrayListWithExpectedSize(fieldResults.size()); + List fields = struct.fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + if (field.isOptional()) { + newFields.add( + optional(mapId(field.fieldId()), field.name(), fieldResults.get(i))); + } else { + newFields.add( + required(mapId(field.fieldId()), field.name(), fieldResults.get(i))); + } + } + return Types.StructType.of(newFields); + } + + @Override + public Type field(Types.NestedField field, Type fieldResult) { + return fieldResult; + } + + @Override + public Type list(Types.ListType list, Type elementResult) { + if (list.isElementOptional()) { + return Types.ListType.ofOptional(mapId(list.elementId()), elementResult); + } else { + return Types.ListType.ofRequired(mapId(list.elementId()), elementResult); + } + } + + @Override + public Type map(Types.MapType map, Type keyResult, Type valueResult) { + if (map.isValueOptional()) { + return Types.MapType.ofOptional( + mapId(map.keyId()), mapId(map.valueId()), keyResult, valueResult); + } else { + return Types.MapType.ofRequired( + mapId(map.keyId()), mapId(map.valueId()), keyResult, valueResult); + } + } + + @Override + public Type primitive(Type.PrimitiveType primitive) { + return primitive; + } + }) + .asNestedType() + .asStructType() + .fields()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java new file mode 100644 index 000000000000..0d619305716e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderDeletes.java @@ -0,0 +1,872 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; +import static org.apache.iceberg.spark.source.SparkSQLExecutionHelper.lastExecutedMetricValue; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.apache.spark.sql.types.DataTypes.IntegerType; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.io.IOException; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import javax.annotation.Nonnull; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.CombinedScanTask; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.MetadataColumns; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.DeleteReadTests; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.InternalRecordWrapper; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.hive.TestHiveMetastore; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.io.FileAppender; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.parquet.ParquetSchemaUtil; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Sets; +import org.apache.iceberg.spark.ImmutableParquetBatchReadConf; +import org.apache.iceberg.spark.ParquetBatchReadConf; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkStructLike; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.spark.data.SparkParquetWriters; +import org.apache.iceberg.spark.source.metrics.NumDeletes; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.iceberg.util.CharSequenceSet; +import org.apache.iceberg.util.Pair; +import org.apache.iceberg.util.StructLikeSet; +import org.apache.iceberg.util.TableScanUtil; +import org.apache.parquet.hadoop.ParquetFileWriter; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarBatch; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReaderDeletes extends DeleteReadTests { + private static TestHiveMetastore metastore = null; + protected static SparkSession spark = null; + protected static HiveCatalog catalog = null; + + @Parameter(index = 2) + private boolean vectorized; + + @Parameter(index = 3) + private PlanningMode planningMode; + + @Parameters(name = "fileFormat = {0}, formatVersion = {1}, vectorized = {2}, planningMode = {3}") + public static Object[][] parameters() { + List parameters = Lists.newArrayList(); + for (int version : TestHelpers.V2_AND_ABOVE) { + parameters.add(new Object[] {FileFormat.PARQUET, version, false, PlanningMode.DISTRIBUTED}); + parameters.add(new Object[] {FileFormat.PARQUET, version, true, PlanningMode.LOCAL}); + if (version == 2) { + parameters.add(new Object[] {FileFormat.ORC, version, false, PlanningMode.DISTRIBUTED}); + parameters.add(new Object[] {FileFormat.AVRO, version, false, PlanningMode.LOCAL}); + } + } + return parameters.toArray(new Object[0][]); + } + + @BeforeAll + public static void startMetastoreAndSpark() { + metastore = new TestHiveMetastore(); + metastore.start(); + HiveConf hiveConf = metastore.hiveConf(); + + spark = + SparkSession.builder() + .master("local[2]") + .config("spark.appStateStore.asyncTracking.enable", false) + .config("spark.ui.liveUpdate.period", 0) + .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") + .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + catalog = + (HiveCatalog) + CatalogUtil.loadCatalog( + HiveCatalog.class.getName(), "hive", ImmutableMap.of(), hiveConf); + + try { + catalog.createNamespace(Namespace.of("default")); + } catch (AlreadyExistsException ignored) { + // the default namespace already exists. ignore the create error + } + } + + @AfterAll + public static void stopMetastoreAndSpark() throws Exception { + catalog = null; + metastore.stop(); + metastore = null; + spark.stop(); + spark = null; + } + + private static final String EQ_CACHE_TABLE = "test_eq_cache_ordering"; + + @AfterEach + @Override + public void cleanup() throws IOException { + super.cleanup(); + dropTable("test3"); + dropTable(EQ_CACHE_TABLE); + } + + @Override + protected Table createTable(String name, Schema schema, PartitionSpec spec) { + Table table = catalog.createTable(TableIdentifier.of("default", name), schema); + TableOperations ops = ((BaseTable) table).operations(); + TableMetadata meta = ops.current(); + ops.commit(meta, meta.upgradeToFormatVersion(formatVersion)); + table + .updateProperties() + .set(TableProperties.DEFAULT_FILE_FORMAT, format.name()) + .set(TableProperties.DATA_PLANNING_MODE, planningMode.modeName()) + .set(TableProperties.DELETE_PLANNING_MODE, planningMode.modeName()) + .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)) + .commit(); + if (format.equals(FileFormat.PARQUET) || format.equals(FileFormat.ORC)) { + String vectorizationEnabled = + format.equals(FileFormat.PARQUET) + ? TableProperties.PARQUET_VECTORIZATION_ENABLED + : TableProperties.ORC_VECTORIZATION_ENABLED; + String batchSize = + format.equals(FileFormat.PARQUET) + ? TableProperties.PARQUET_BATCH_SIZE + : TableProperties.ORC_BATCH_SIZE; + table.updateProperties().set(vectorizationEnabled, String.valueOf(vectorized)).commit(); + if (vectorized) { + // split 7 records to two batches to cover more code paths + table.updateProperties().set(batchSize, "4").commit(); + } + } + return table; + } + + @Override + protected void dropTable(String name) { + catalog.dropTable(TableIdentifier.of("default", name)); + } + + protected boolean countDeletes() { + return true; + } + + @Override + protected long deleteCount() { + return Long.parseLong(lastExecutedMetricValue(spark, NumDeletes.DISPLAY_STRING)); + } + + @Override + public StructLikeSet rowSet(String name, Table table, String... columns) { + return rowSet(name, table.schema().select(columns).asStruct(), columns); + } + + public StructLikeSet rowSet(String name, Types.StructType projection, String... columns) { + Dataset df = + spark + .read() + .format("iceberg") + .load(TableIdentifier.of("default", name).toString()) + .selectExpr(columns); + + StructLikeSet set = StructLikeSet.create(projection); + df.collectAsList() + .forEach( + row -> { + SparkStructLike rowWrapper = new SparkStructLike(projection); + set.add(rowWrapper.wrap(row)); + }); + + return set; + } + + @TestTemplate + public void testEqualityDeleteWithFilter() throws IOException { + String tableName = table.name().substring(table.name().lastIndexOf(".") + 1); + Schema deleteRowSchema = table.schema().select("data"); + Record dataDelete = GenericRecord.create(deleteRowSchema); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("data", "a"), // id = 29 + dataDelete.copy("data", "d"), // id = 89 + dataDelete.copy("data", "g") // id = 122 + ); + + DeleteFile eqDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + dataDeletes, + deleteRowSchema); + + table.newRowDelta().addDeletes(eqDeletes).commit(); + + Types.StructType projection = table.schema().select("*").asStruct(); + Dataset df = + spark + .read() + .format("iceberg") + .load(TableIdentifier.of("default", tableName).toString()) + .filter("data = 'a'") // select a deleted row + .selectExpr("*"); + + StructLikeSet actual = StructLikeSet.create(projection); + df.collectAsList() + .forEach( + row -> { + SparkStructLike rowWrapper = new SparkStructLike(projection); + actual.add(rowWrapper.wrap(row)); + }); + + assertThat(actual).as("Table should contain no rows").hasSize(0); + } + + @TestTemplate + public void testReadEqualityDeleteRows() throws IOException { + Schema deleteSchema1 = table.schema().select("data"); + Record dataDelete = GenericRecord.create(deleteSchema1); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("data", "a"), // id = 29 + dataDelete.copy("data", "d") // id = 89 + ); + + Schema deleteSchema2 = table.schema().select("id"); + Record idDelete = GenericRecord.create(deleteSchema2); + List idDeletes = + Lists.newArrayList( + idDelete.copy("id", 121), // id = 121 + idDelete.copy("id", 122) // id = 122 + ); + + DeleteFile eqDelete1 = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + dataDeletes, + deleteSchema1); + + DeleteFile eqDelete2 = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + idDeletes, + deleteSchema2); + + table.newRowDelta().addDeletes(eqDelete1).addDeletes(eqDelete2).commit(); + + StructLikeSet expectedRowSet = rowSetWithIds(29, 89, 121, 122); + + Types.StructType type = table.schema().asStruct(); + StructLikeSet actualRowSet = StructLikeSet.create(type); + + CloseableIterable tasks = + TableScanUtil.planTasks( + table.newScan().planFiles(), + TableProperties.METADATA_SPLIT_SIZE_DEFAULT, + TableProperties.SPLIT_LOOKBACK_DEFAULT, + TableProperties.SPLIT_OPEN_FILE_COST_DEFAULT); + + for (CombinedScanTask task : tasks) { + try (EqualityDeleteRowReader reader = + new EqualityDeleteRowReader(task, table, table.io(), table.schema(), false, true)) { + while (reader.next()) { + actualRowSet.add( + new InternalRowWrapper( + SparkSchemaUtil.convert(table.schema()), table.schema().asStruct()) + .wrap(reader.get().copy())); + } + } + } + + assertThat(actualRowSet).as("should include 4 deleted row").hasSize(4); + assertThat(actualRowSet).as("deleted row should be matched").isEqualTo(expectedRowSet); + } + + @TestTemplate + public void testPosDeletesAllRowsInBatch() throws IOException { + // read.parquet.vectorization.batch-size is set to 4, so the 4 rows in the first batch are all + // deleted. + List> deletes = + Lists.newArrayList( + Pair.of(dataFile.location(), 0L), // id = 29 + Pair.of(dataFile.location(), 1L), // id = 43 + Pair.of(dataFile.location(), 2L), // id = 61 + Pair.of(dataFile.location(), 3L) // id = 89 + ); + + Pair posDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + deletes, + formatVersion); + + table + .newRowDelta() + .addDeletes(posDeletes.first()) + .validateDataFilesExist(posDeletes.second()) + .commit(); + + StructLikeSet expected = rowSetWithoutIds(table, records, 29, 43, 61, 89); + StructLikeSet actual = rowSet(tableName, table, "*"); + + assertThat(actual).as("Table should contain expected rows").isEqualTo(expected); + checkDeleteCount(4L); + } + + @TestTemplate + public void testPosDeletesWithDeletedColumn() throws IOException { + // read.parquet.vectorization.batch-size is set to 4, so the 4 rows in the first batch are all + // deleted. + List> deletes = + Lists.newArrayList( + Pair.of(dataFile.location(), 0L), // id = 29 + Pair.of(dataFile.location(), 1L), // id = 43 + Pair.of(dataFile.location(), 2L), // id = 61 + Pair.of(dataFile.location(), 3L) // id = 89 + ); + + Pair posDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + deletes, + formatVersion); + + table + .newRowDelta() + .addDeletes(posDeletes.first()) + .validateDataFilesExist(posDeletes.second()) + .commit(); + + StructLikeSet expected = expectedRowSet(29, 43, 61, 89); + StructLikeSet actual = + rowSet(tableName, PROJECTION_SCHEMA.asStruct(), "id", "data", "_deleted"); + + assertThat(actual).as("Table should contain expected row").isEqualTo(expected); + checkDeleteCount(4L); + } + + @TestTemplate + public void testEqualityDeleteWithDeletedColumn() throws IOException { + String tableName = table.name().substring(table.name().lastIndexOf(".") + 1); + Schema deleteRowSchema = table.schema().select("data"); + Record dataDelete = GenericRecord.create(deleteRowSchema); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("data", "a"), // id = 29 + dataDelete.copy("data", "d"), // id = 89 + dataDelete.copy("data", "g") // id = 122 + ); + + DeleteFile eqDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + dataDeletes, + deleteRowSchema); + + table.newRowDelta().addDeletes(eqDeletes).commit(); + + StructLikeSet expected = expectedRowSet(29, 89, 122); + StructLikeSet actual = + rowSet(tableName, PROJECTION_SCHEMA.asStruct(), "id", "data", "_deleted"); + + assertThat(actual).as("Table should contain expected row").isEqualTo(expected); + checkDeleteCount(3L); + } + + @TestTemplate + public void testMixedPosAndEqDeletesWithDeletedColumn() throws IOException { + Schema dataSchema = table.schema().select("data"); + Record dataDelete = GenericRecord.create(dataSchema); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("data", "a"), // id = 29 + dataDelete.copy("data", "d"), // id = 89 + dataDelete.copy("data", "g") // id = 122 + ); + + DeleteFile eqDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + dataDeletes, + dataSchema); + + List> deletes = + Lists.newArrayList( + Pair.of(dataFile.location(), 3L), // id = 89 + Pair.of(dataFile.location(), 5L) // id = 121 + ); + + Pair posDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + deletes, + formatVersion); + + table + .newRowDelta() + .addDeletes(eqDeletes) + .addDeletes(posDeletes.first()) + .validateDataFilesExist(posDeletes.second()) + .commit(); + + StructLikeSet expected = expectedRowSet(29, 89, 121, 122); + StructLikeSet actual = + rowSet(tableName, PROJECTION_SCHEMA.asStruct(), "id", "data", "_deleted"); + + assertThat(actual).as("Table should contain expected row").isEqualTo(expected); + checkDeleteCount(4L); + } + + @TestTemplate + public void testFilterOnDeletedMetadataColumn() throws IOException { + List> deletes = + Lists.newArrayList( + Pair.of(dataFile.location(), 0L), // id = 29 + Pair.of(dataFile.location(), 1L), // id = 43 + Pair.of(dataFile.location(), 2L), // id = 61 + Pair.of(dataFile.location(), 3L) // id = 89 + ); + + Pair posDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + deletes, + formatVersion); + + table + .newRowDelta() + .addDeletes(posDeletes.first()) + .validateDataFilesExist(posDeletes.second()) + .commit(); + + StructLikeSet expected = expectedRowSetWithNonDeletesOnly(29, 43, 61, 89); + + // get non-deleted rows + Dataset df = + spark + .read() + .format("iceberg") + .load(TableIdentifier.of("default", tableName).toString()) + .select("id", "data", "_deleted") + .filter("_deleted = false"); + + Types.StructType projection = PROJECTION_SCHEMA.asStruct(); + StructLikeSet actual = StructLikeSet.create(projection); + df.collectAsList() + .forEach( + row -> { + SparkStructLike rowWrapper = new SparkStructLike(projection); + actual.add(rowWrapper.wrap(row)); + }); + + assertThat(actual).as("Table should contain expected row").isEqualTo(expected); + + StructLikeSet expectedDeleted = expectedRowSetWithDeletesOnly(29, 43, 61, 89); + + // get deleted rows + df = + spark + .read() + .format("iceberg") + .load(TableIdentifier.of("default", tableName).toString()) + .select("id", "data", "_deleted") + .filter("_deleted = true"); + + StructLikeSet actualDeleted = StructLikeSet.create(projection); + df.collectAsList() + .forEach( + row -> { + SparkStructLike rowWrapper = new SparkStructLike(projection); + actualDeleted.add(rowWrapper.wrap(row)); + }); + + assertThat(actualDeleted).as("Table should contain expected row").isEqualTo(expectedDeleted); + } + + @TestTemplate + public void testIsDeletedColumnWithoutDeleteFile() { + StructLikeSet expected = expectedRowSet(); + StructLikeSet actual = + rowSet(tableName, PROJECTION_SCHEMA.asStruct(), "id", "data", "_deleted"); + assertThat(actual).as("Table should contain expected row").isEqualTo(expected); + checkDeleteCount(0L); + } + + @TestTemplate + public void testPosDeletesOnParquetFileWithMultipleRowGroups() throws IOException { + assumeThat(format).isEqualTo(FileFormat.PARQUET); + + String tblName = "test3"; + Table tbl = createTable(tblName, SCHEMA, PartitionSpec.unpartitioned()); + + List fileSplits = Lists.newArrayList(); + StructType sparkSchema = SparkSchemaUtil.convert(SCHEMA); + Configuration conf = new Configuration(); + File testFile = File.createTempFile("junit", null, temp.toFile()); + assertThat(testFile.delete()).as("Delete should succeed").isTrue(); + Path testFilePath = new Path(testFile.getAbsolutePath()); + + // Write a Parquet file with more than one row group + ParquetFileWriter parquetFileWriter = + new ParquetFileWriter(conf, ParquetSchemaUtil.convert(SCHEMA, "test3Schema"), testFilePath); + parquetFileWriter.start(); + for (int i = 0; i < 2; i += 1) { + File split = File.createTempFile("junit", null, temp.toFile()); + assertThat(split.delete()).as("Delete should succeed").isTrue(); + Path splitPath = new Path(split.getAbsolutePath()); + fileSplits.add(splitPath); + try (FileAppender writer = + Parquet.write(Files.localOutput(split)) + .createWriterFunc(msgType -> SparkParquetWriters.buildWriter(sparkSchema, msgType)) + .schema(SCHEMA) + .overwrite() + .build()) { + Iterable records = RandomData.generateSpark(SCHEMA, 100, 34 * i + 37); + writer.addAll(records); + } + parquetFileWriter.appendFile( + org.apache.parquet.hadoop.util.HadoopInputFile.fromPath(splitPath, conf)); + } + parquetFileWriter.end( + ParquetFileWriter.mergeMetadataFiles(fileSplits, conf) + .getFileMetaData() + .getKeyValueMetaData()); + + // Add the file to the table + DataFile dataFile = + DataFiles.builder(PartitionSpec.unpartitioned()) + .withInputFile(org.apache.iceberg.hadoop.HadoopInputFile.fromPath(testFilePath, conf)) + .withFormat("parquet") + .withRecordCount(200) + .build(); + tbl.newAppend().appendFile(dataFile).commit(); + + // Add positional deletes to the table + List> deletes = + Lists.newArrayList( + Pair.of(dataFile.location(), 97L), + Pair.of(dataFile.location(), 98L), + Pair.of(dataFile.location(), 99L), + Pair.of(dataFile.location(), 101L), + Pair.of(dataFile.location(), 103L), + Pair.of(dataFile.location(), 107L), + Pair.of(dataFile.location(), 109L)); + Pair posDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + deletes, + formatVersion); + tbl.newRowDelta() + .addDeletes(posDeletes.first()) + .validateDataFilesExist(posDeletes.second()) + .commit(); + + assertThat(rowSet(tblName, tbl, "*")).hasSize(193); + } + + @TestTemplate + public void testEqualityDeleteWithDifferentScanAndDeleteColumns() throws IOException { + assumeThat(format).isEqualTo(FileFormat.PARQUET); + initDateTable(); + + Schema deleteRowSchema = dateTable.schema().select("dt"); + Record dataDelete = GenericRecord.create(deleteRowSchema); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("dt", LocalDate.parse("2021-09-01")), + dataDelete.copy("dt", LocalDate.parse("2021-09-02")), + dataDelete.copy("dt", LocalDate.parse("2021-09-03"))); + + DeleteFile eqDeletes = + FileHelpers.writeDeleteFile( + dateTable, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + dataDeletes.subList(0, 3), + deleteRowSchema); + + dateTable.newRowDelta().addDeletes(eqDeletes).commit(); + + CloseableIterable tasks = + TableScanUtil.planTasks( + dateTable.newScan().planFiles(), + TableProperties.METADATA_SPLIT_SIZE_DEFAULT, + TableProperties.SPLIT_LOOKBACK_DEFAULT, + TableProperties.SPLIT_OPEN_FILE_COST_DEFAULT); + + ParquetBatchReadConf conf = ImmutableParquetBatchReadConf.builder().batchSize(7).build(); + + for (CombinedScanTask task : tasks) { + try (BatchDataReader reader = + new BatchDataReader( + // expected column is id, while the equality filter column is dt + dateTable, + dateTable.io(), + task, + dateTable.schema().select("id"), + false, + conf, + null, + true)) { + while (reader.next()) { + ColumnarBatch columnarBatch = reader.get(); + int numOfCols = columnarBatch.numCols(); + assertThat(numOfCols).as("Number of columns").isEqualTo(1); + assertThat(columnarBatch.column(0).dataType()).as("Column type").isEqualTo(IntegerType); + } + } + } + } + + @TestTemplate + public void testEqualityDeleteWithSchemaEvolution() throws IOException { + assumeThat(format).isEqualTo(FileFormat.PARQUET); + + String tableName = table.name().substring(table.name().lastIndexOf(".") + 1); + + // add column `status` + table.updateSchema().addColumn("status", Types.StringType.get()).commit(); + + // add data with `status` column + GenericRecord recordWithStatus = GenericRecord.create(table.schema()); + List recordsWithStatus = + Lists.newArrayList( + recordWithStatus.copy("id", 200, "data", "h", "status", "ACTIVE"), + recordWithStatus.copy("id", 201, "data", "i", "status", "INACTIVE"), + recordWithStatus.copy("id", 202, "data", "j", "status", "ACTIVE")); + DataFile dataFileWithStatus = + FileHelpers.writeDataFile( + table, + Files.localOutput(temp.resolve("junit-v2-" + System.nanoTime()).toFile()), + TestHelpers.Row.of(0), + recordsWithStatus); + table.newAppend().appendFile(dataFileWithStatus).commit(); + + // issue equality delete on `status` column + Schema deleteSchema = table.schema().select("status"); + Record deleteRecordWithStatus = GenericRecord.create(deleteSchema); + List deleteRecordsWithStatus = + Lists.newArrayList(deleteRecordWithStatus.copy("status", "INACTIVE")); + DeleteFile eqDeleteFileWithStatus = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(temp.resolve("junit-deletes-" + System.nanoTime()).toFile()), + TestHelpers.Row.of(0), + deleteRecordsWithStatus, + deleteSchema); + table.newRowDelta().addDeletes(eqDeleteFileWithStatus).commit(); + + // drop `status` column + table.updateSchema().deleteColumn("status").commit(); + + // verify reading of equality deletes even though schema doesn't contain `status` column + // only 1 of 3 records with status got removed + StructLikeSet actual = rowSet(tableName, table, "id", "data"); + int expectedRecordCount = records.size() + 2; + assertThat(actual).hasSize(expectedRecordCount); + } + + /** + * Covers a bug where equality deletes columns are appended to the required schema in a different + * order than the table schema, which can cause different deleteSchema orderings, poisoning the + * cache. + */ + @TestTemplate + public void testEqualityDeletesAppliedWithCachedFieldReordering() throws IOException { + Schema eqDeleteTestSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "a", Types.IntegerType.get()), + Types.NestedField.optional(3, "b", Types.IntegerType.get())); + Table eqTestTable = + createTable(EQ_CACHE_TABLE, eqDeleteTestSchema, PartitionSpec.unpartitioned()); + + GenericRecord record = GenericRecord.create(eqDeleteTestSchema); + List data = Lists.newArrayList(); + for (int i = 0; i < 10; i++) { + data.add(record.copy("id", i, "a", i * 10, "b", i * 100)); + } + + DataFile dataFile = + FileHelpers.writeDataFile( + eqTestTable, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + data); + eqTestTable.newAppend().appendFile(dataFile).commit(); + + Schema deleteSchema = + new Schema( + Types.NestedField.optional(3, "b", Types.IntegerType.get()), + Types.NestedField.optional(2, "a", Types.IntegerType.get())); + + Record eqDel = GenericRecord.create(deleteSchema); + List deletes = + Lists.newArrayList( + eqDel.copy("b", 0, "a", 0), + eqDel.copy("b", 100, "a", 10), + eqDel.copy("b", 200, "a", 20)); + + DeleteFile eqFile = + FileHelpers.writeDeleteFile( + eqTestTable, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + deletes, + deleteSchema); + eqTestTable.newRowDelta().addDeletes(eqFile).commit(); + + String tableRef = TableIdentifier.of("default", EQ_CACHE_TABLE).toString(); + int expectedRows = data.size() - deletes.size(); + + // Narrow projection: Spark will not request a or b columns, so the delete columns are appended + // in identifier fields definition order [b, a] + long narrowCount = + spark.read().format("iceberg").load(tableRef).select("id").collectAsList().size(); + + // Wide projection: Spark will request all columns, so the delete columns are already present in + // table schema order [a, b]. + long wideCount = + spark.read().format("iceberg").load(tableRef).select("*").collectAsList().size(); + + assertThat(narrowCount) + .as("Narrow projection should return %d rows after equality deletes", expectedRows) + .isEqualTo(expectedRows); + assertThat(wideCount) + .as("Wide projection should return %d rows after equality deletes", expectedRows) + .isEqualTo(expectedRows); + } + + private static final Schema PROJECTION_SCHEMA = + new Schema( + required(1, "id", Types.IntegerType.get()), + required(2, "data", Types.StringType.get()), + MetadataColumns.IS_DELETED); + + private static StructLikeSet expectedRowSet(int... idsToRemove) { + return expectedRowSet(false, false, idsToRemove); + } + + private static StructLikeSet expectedRowSetWithDeletesOnly(int... idsToRemove) { + return expectedRowSet(false, true, idsToRemove); + } + + private static StructLikeSet expectedRowSetWithNonDeletesOnly(int... idsToRemove) { + return expectedRowSet(true, false, idsToRemove); + } + + private static StructLikeSet expectedRowSet( + boolean removeDeleted, boolean removeNonDeleted, int... idsToRemove) { + Set deletedIds = Sets.newHashSet(ArrayUtil.toIntList(idsToRemove)); + List records = recordsWithDeletedColumn(); + // mark rows deleted + records.forEach( + record -> { + if (deletedIds.contains(record.getField("id"))) { + record.setField(MetadataColumns.IS_DELETED.name(), true); + } + }); + + records.removeIf(record -> deletedIds.contains(record.getField("id")) && removeDeleted); + records.removeIf(record -> !deletedIds.contains(record.getField("id")) && removeNonDeleted); + + StructLikeSet set = StructLikeSet.create(PROJECTION_SCHEMA.asStruct()); + records.forEach( + record -> set.add(new InternalRecordWrapper(PROJECTION_SCHEMA.asStruct()).wrap(record))); + + return set; + } + + @Nonnull + private static List recordsWithDeletedColumn() { + List records = Lists.newArrayList(); + + // records all use IDs that are in bucket id_bucket=0 + GenericRecord record = GenericRecord.create(PROJECTION_SCHEMA); + records.add(record.copy("id", 29, "data", "a", "_deleted", false)); + records.add(record.copy("id", 43, "data", "b", "_deleted", false)); + records.add(record.copy("id", 61, "data", "c", "_deleted", false)); + records.add(record.copy("id", 89, "data", "d", "_deleted", false)); + records.add(record.copy("id", 100, "data", "e", "_deleted", false)); + records.add(record.copy("id", 121, "data", "f", "_deleted", false)); + records.add(record.copy("id", 122, "data", "g", "_deleted", false)); + return records; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java new file mode 100644 index 000000000000..cb2f866fab10 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkReaderWithBloomFilter.java @@ -0,0 +1,378 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT; +import static org.apache.iceberg.TableProperties.DEFAULT_FILE_FORMAT_DEFAULT; +import static org.apache.iceberg.TableProperties.PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES_DEFAULT; +import static org.apache.iceberg.data.FileHelpers.encrypt; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Files; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.StructLike; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers.Row; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.data.GenericFileWriterFactory; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.hive.TestHiveMetastore; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkValueConverter; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.PropertyUtil; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkReaderWithBloomFilter { + + protected String tableName = null; + protected Table table = null; + protected List records = null; + protected DataFile dataFile = null; + + private static TestHiveMetastore metastore = null; + protected static SparkSession spark = null; + protected static HiveCatalog catalog = null; + + @Parameter(index = 0) + protected boolean vectorized; + + @Parameter(index = 1) + protected boolean useBloomFilter; + + // Schema passed to create tables + public static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.required(2, "id_long", Types.LongType.get()), + Types.NestedField.required(3, "id_double", Types.DoubleType.get()), + Types.NestedField.required(4, "id_float", Types.FloatType.get()), + Types.NestedField.required(5, "id_string", Types.StringType.get()), + Types.NestedField.optional(6, "id_boolean", Types.BooleanType.get()), + Types.NestedField.optional(7, "id_date", Types.DateType.get()), + Types.NestedField.optional(8, "id_int_decimal", Types.DecimalType.of(8, 2)), + Types.NestedField.optional(9, "id_long_decimal", Types.DecimalType.of(14, 2)), + Types.NestedField.optional(10, "id_fixed_decimal", Types.DecimalType.of(31, 2))); + + private static final int INT_MIN_VALUE = 30; + private static final int INT_MAX_VALUE = 329; + private static final int INT_VALUE_COUNT = INT_MAX_VALUE - INT_MIN_VALUE + 1; + private static final long LONG_BASE = 1000L; + private static final double DOUBLE_BASE = 10000D; + private static final float FLOAT_BASE = 100000F; + private static final String BINARY_PREFIX = "BINARY测试_"; + + @TempDir private Path temp; + + @BeforeEach + public void writeTestDataFile() throws IOException { + this.tableName = "test"; + createTable(tableName, SCHEMA); + this.records = Lists.newArrayList(); + + // records all use IDs that are in bucket id_bucket=0 + GenericRecord record = GenericRecord.create(table.schema()); + + for (int i = 0; i < INT_VALUE_COUNT; i += 1) { + records.add( + record.copy( + ImmutableMap.of( + "id", + INT_MIN_VALUE + i, + "id_long", + LONG_BASE + INT_MIN_VALUE + i, + "id_double", + DOUBLE_BASE + INT_MIN_VALUE + i, + "id_float", + FLOAT_BASE + INT_MIN_VALUE + i, + "id_string", + BINARY_PREFIX + (INT_MIN_VALUE + i), + "id_boolean", + i % 2 == 0, + "id_date", + LocalDate.parse("2021-09-05"), + "id_int_decimal", + new BigDecimal(String.valueOf(77.77)), + "id_long_decimal", + new BigDecimal(String.valueOf(88.88)), + "id_fixed_decimal", + new BigDecimal(String.valueOf(99.99))))); + } + + this.dataFile = + writeDataFile( + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + Row.of(0), + records); + + table.newAppend().appendFile(dataFile).commit(); + } + + @AfterEach + public void cleanup() throws IOException { + dropTable("test"); + } + + @Parameters(name = "vectorized = {0}, useBloomFilter = {1}") + public static Object[][] parameters() { + return new Object[][] {{false, false}, {true, false}, {false, true}, {true, true}}; + } + + @BeforeAll + public static void startMetastoreAndSpark() { + metastore = new TestHiveMetastore(); + metastore.start(); + HiveConf hiveConf = metastore.hiveConf(); + + spark = + SparkSession.builder() + .master("local[2]") + .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config(TestBase.DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + catalog = + (HiveCatalog) + CatalogUtil.loadCatalog( + HiveCatalog.class.getName(), "hive", ImmutableMap.of(), hiveConf); + + try { + catalog.createNamespace(Namespace.of("default")); + } catch (AlreadyExistsException ignored) { + // the default namespace already exists. ignore the create error + } + } + + @AfterAll + public static void stopMetastoreAndSpark() throws Exception { + catalog = null; + metastore.stop(); + metastore = null; + spark.stop(); + spark = null; + } + + protected void createTable(String name, Schema schema) { + table = catalog.createTable(TableIdentifier.of("default", name), schema); + TableOperations ops = ((BaseTable) table).operations(); + TableMetadata meta = ops.current(); + ops.commit(meta, meta.upgradeToFormatVersion(2)); + + if (useBloomFilter) { + table + .updateProperties() + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_long", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_double", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_float", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_string", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_boolean", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_date", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_int_decimal", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_long_decimal", "true") + .set(PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_fixed_decimal", "true") + .commit(); + } + + table + .updateProperties() + .set(TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES, "100") // to have multiple row groups + .commit(); + if (vectorized) { + table + .updateProperties() + .set(TableProperties.PARQUET_VECTORIZATION_ENABLED, "true") + .set(TableProperties.PARQUET_BATCH_SIZE, "4") + .commit(); + } + } + + protected void dropTable(String name) { + catalog.dropTable(TableIdentifier.of("default", name)); + } + + private DataFile writeDataFile(OutputFile out, StructLike partition, List rows) + throws IOException { + FileFormat format = defaultFormat(table.properties()); + Map writeProperties = Maps.newHashMap(); + + boolean useBloomFilterCol1 = + PropertyUtil.propertyAsBoolean( + table.properties(), PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id", false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id", Boolean.toString(useBloomFilterCol1)); + boolean useBloomFilterCol2 = + PropertyUtil.propertyAsBoolean( + table.properties(), PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_long", false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_long", + Boolean.toString(useBloomFilterCol2)); + boolean useBloomFilterCol3 = + PropertyUtil.propertyAsBoolean( + table.properties(), PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_double", false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_double", + Boolean.toString(useBloomFilterCol3)); + boolean useBloomFilterCol4 = + PropertyUtil.propertyAsBoolean( + table.properties(), PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_float", false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_float", + Boolean.toString(useBloomFilterCol4)); + boolean useBloomFilterCol5 = + PropertyUtil.propertyAsBoolean( + table.properties(), PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_string", false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_string", + Boolean.toString(useBloomFilterCol5)); + boolean useBloomFilterCol6 = + PropertyUtil.propertyAsBoolean( + table.properties(), PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_boolean", false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_boolean", + Boolean.toString(useBloomFilterCol6)); + boolean useBloomFilterCol7 = + PropertyUtil.propertyAsBoolean( + table.properties(), PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_date", false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_date", + Boolean.toString(useBloomFilterCol7)); + boolean useBloomFilterCol8 = + PropertyUtil.propertyAsBoolean( + table.properties(), + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_int_decimal", + false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_int_decimal", + Boolean.toString(useBloomFilterCol8)); + boolean useBloomFilterCol9 = + PropertyUtil.propertyAsBoolean( + table.properties(), + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_long_decimal", + false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_long_decimal", + Boolean.toString(useBloomFilterCol9)); + boolean useBloomFilterCol10 = + PropertyUtil.propertyAsBoolean( + table.properties(), + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_fixed_decimal", + false); + writeProperties.put( + PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "id_fixed_decimal", + Boolean.toString(useBloomFilterCol10)); + int blockSize = + PropertyUtil.propertyAsInt( + table.properties(), PARQUET_ROW_GROUP_SIZE_BYTES, PARQUET_ROW_GROUP_SIZE_BYTES_DEFAULT); + writeProperties.put(PARQUET_ROW_GROUP_SIZE_BYTES, Integer.toString(blockSize)); + + DataWriter writer = + new GenericFileWriterFactory.Builder() + .dataSchema(table.schema()) + .dataFileFormat(format) + .writerProperties(writeProperties) + .build() + .newDataWriter(encrypt(out), table.spec(), partition); + try (writer) { + writer.write(rows); + } + + return writer.toDataFile(); + } + + private FileFormat defaultFormat(Map properties) { + String formatString = properties.getOrDefault(DEFAULT_FILE_FORMAT, DEFAULT_FILE_FORMAT_DEFAULT); + return FileFormat.fromString(formatString); + } + + @TestTemplate + public void testReadWithFilter() { + Dataset df = + spark + .read() + .format("iceberg") + .load(TableIdentifier.of("default", tableName).toString()) + // this is from the first row group + .filter( + "id = 30 AND id_long = 1030 AND id_double = 10030.0 AND id_float = 100030.0" + + " AND id_string = 'BINARY测试_30' AND id_boolean = true AND id_date = '2021-09-05'" + + " AND id_int_decimal = 77.77 AND id_long_decimal = 88.88 AND id_fixed_decimal = 99.99"); + + Record record = SparkValueConverter.convert(table.schema(), df.collectAsList().get(0)); + + assertThat(df.collectAsList()).as("Table should contain 1 row").hasSize(1); + assertThat(record.get(0)).as("Table should contain expected rows").isEqualTo(30); + + df = + spark + .read() + .format("iceberg") + .load(TableIdentifier.of("default", tableName).toString()) + // this is from the third row group + .filter( + "id = 250 AND id_long = 1250 AND id_double = 10250.0 AND id_float = 100250.0" + + " AND id_string = 'BINARY测试_250' AND id_boolean = true AND id_date = '2021-09-05'" + + " AND id_int_decimal = 77.77 AND id_long_decimal = 88.88 AND id_fixed_decimal = 99.99"); + + record = SparkValueConverter.convert(table.schema(), df.collectAsList().get(0)); + + assertThat(df.collectAsList()).as("Table should contain 1 row").hasSize(1); + assertThat(record.get(0)).as("Table should contain expected rows").isEqualTo(250); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkRollingFileWriters.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkRollingFileWriters.java new file mode 100644 index 000000000000..5ebeafcb8cef --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkRollingFileWriters.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.List; +import org.apache.iceberg.Schema; +import org.apache.iceberg.io.FileWriterFactory; +import org.apache.iceberg.io.TestRollingFileWriters; +import org.apache.iceberg.util.ArrayUtil; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.unsafe.types.UTF8String; + +public class TestSparkRollingFileWriters extends TestRollingFileWriters { + + @Override + protected FileWriterFactory newWriterFactory( + Schema dataSchema, + List equalityFieldIds, + Schema equalityDeleteRowSchema, + Schema positionDeleteRowSchema) { + return SparkFileWriterFactory.builderFor(table) + .dataSchema(table.schema()) + .dataFileFormat(format()) + .deleteFileFormat(format()) + .equalityFieldIds(ArrayUtil.toIntArray(equalityFieldIds)) + .equalityDeleteRowSchema(equalityDeleteRowSchema) + .positionDeleteRowSchema(positionDeleteRowSchema) + .build(); + } + + @Override + protected InternalRow toRow(Integer id, String data) { + InternalRow row = new GenericInternalRow(2); + row.update(0, id); + row.update(1, UTF8String.fromString(data)); + return row; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java new file mode 100644 index 000000000000..fd133de4baa8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkScan.java @@ -0,0 +1,1173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.puffin.StandardBlobTypes.APACHE_DATASKETCHES_THETA_V1; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.createPartitionedTable; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.createUnpartitionedTable; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToDayOrdinal; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToHourOrdinal; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToMonthOrdinal; +import static org.apache.iceberg.spark.SystemFunctionPushDownHelper.timestampStrToYearOrdinal; +import static org.apache.spark.sql.functions.date_add; +import static org.apache.spark.sql.functions.expr; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.GenericBlobMetadata; +import org.apache.iceberg.GenericStatisticsFile; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.spark.functions.BucketFunction; +import org.apache.iceberg.spark.functions.DaysFunction; +import org.apache.iceberg.spark.functions.HoursFunction; +import org.apache.iceberg.spark.functions.MonthsFunction; +import org.apache.iceberg.spark.functions.TruncateFunction; +import org.apache.iceberg.spark.functions.YearsFunction; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.connector.catalog.functions.BoundFunction; +import org.apache.spark.sql.connector.expressions.Expression; +import org.apache.spark.sql.connector.expressions.FieldReference; +import org.apache.spark.sql.connector.expressions.LiteralValue; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.apache.spark.sql.connector.expressions.UserDefinedScalarFunc; +import org.apache.spark.sql.connector.expressions.filter.And; +import org.apache.spark.sql.connector.expressions.filter.Not; +import org.apache.spark.sql.connector.expressions.filter.Or; +import org.apache.spark.sql.connector.expressions.filter.Predicate; +import org.apache.spark.sql.connector.read.Batch; +import org.apache.spark.sql.connector.read.Scan; +import org.apache.spark.sql.connector.read.ScanBuilder; +import org.apache.spark.sql.connector.read.Statistics; +import org.apache.spark.sql.connector.read.SupportsPushDownV2Filters; +import org.apache.spark.sql.connector.read.colstats.ColumnStatistics; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkScan extends TestBaseWithCatalog { + + private static final String DUMMY_BLOB_TYPE = "sum-data-size-bytes-v1"; + + @Parameter(index = 3) + private String format; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, format = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + "parquet" + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + "avro" + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + "orc" + } + }; + } + + @BeforeEach + public void useCatalog() { + sql("USE %s", catalogName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testEstimatedRowCount() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, date DATE) USING iceberg TBLPROPERTIES('%s' = '%s')", + tableName, TableProperties.DEFAULT_FILE_FORMAT, format); + + Dataset df = + spark + .range(10000) + .withColumn("date", date_add(expr("DATE '1970-01-01'"), expr("CAST(id AS INT)"))) + .select("id", "date"); + + df.coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + SparkScanBuilder scanBuilder = + new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + SparkScan scan = (SparkScan) scanBuilder.build(); + Statistics stats = scan.estimateStatistics(); + + assertThat(stats.numRows().getAsLong()).isEqualTo(10000L); + } + + @TestTemplate + public void testTableWithoutColStats() throws NoSuchTableException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "a"), + new SimpleRecord(4, "b")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = validationCatalog.loadTable(tableIdent); + + SparkScanBuilder scanBuilder = + new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + SparkScan scan = (SparkScan) scanBuilder.build(); + + Map reportColStatsDisabled = + ImmutableMap.of( + SQLConf.CBO_ENABLED().key(), "true", SparkSQLProperties.REPORT_COLUMN_STATS, "false"); + + Map reportColStatsEnabled = + ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true"); + + checkColStatisticsNotReported(scan, 4L); + withSQLConf(reportColStatsDisabled, () -> checkColStatisticsNotReported(scan, 4L)); + // The expected col NDVs are nulls + withSQLConf( + reportColStatsEnabled, () -> checkColStatisticsReported(scan, 4L, Maps.newHashMap())); + } + + @TestTemplate + public void testTableWithoutApacheDatasketchColStat() throws NoSuchTableException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "a"), + new SimpleRecord(4, "b")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + SparkScanBuilder scanBuilder = + new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + SparkScan scan = (SparkScan) scanBuilder.build(); + + Map reportColStatsDisabled = + ImmutableMap.of( + SQLConf.CBO_ENABLED().key(), "true", SparkSQLProperties.REPORT_COLUMN_STATS, "false"); + + Map reportColStatsEnabled = + ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true"); + + GenericStatisticsFile statisticsFile = + new GenericStatisticsFile( + snapshotId, + "/test/statistics/file.puffin", + 100, + 42, + ImmutableList.of( + new GenericBlobMetadata( + DUMMY_BLOB_TYPE, + snapshotId, + 1, + ImmutableList.of(1), + ImmutableMap.of("data_size", "4")))); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + checkColStatisticsNotReported(scan, 4L); + withSQLConf(reportColStatsDisabled, () -> checkColStatisticsNotReported(scan, 4L)); + // The expected col NDVs are nulls + withSQLConf( + reportColStatsEnabled, () -> checkColStatisticsReported(scan, 4L, Maps.newHashMap())); + } + + @TestTemplate + public void testTableWithOneColStats() throws NoSuchTableException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "a"), + new SimpleRecord(4, "b")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + GenericStatisticsFile statisticsFile = + new GenericStatisticsFile( + snapshotId, + "/test/statistics/file.puffin", + 100, + 42, + ImmutableList.of( + new GenericBlobMetadata( + APACHE_DATASKETCHES_THETA_V1, + snapshotId, + 1, + ImmutableList.of(1), + ImmutableMap.of("ndv", "4")))); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + List newRecords = + Lists.newArrayList(new SimpleRecord(5, "a"), new SimpleRecord(6, "b")); + spark + .createDataset(newRecords, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + table.refresh(); + long snapshotId2 = table.currentSnapshot().snapshotId(); + + GenericStatisticsFile statisticsFile2 = + new GenericStatisticsFile( + snapshotId2, + "/test/statistics/file2.puffin", + 100, + 42, + ImmutableList.of( + new GenericBlobMetadata( + APACHE_DATASKETCHES_THETA_V1, + snapshotId2, + 2, + ImmutableList.of(1), + ImmutableMap.of("ndv", "6")))); + + table.updateStatistics().setStatistics(statisticsFile2).commit(); + + SparkScanBuilder scanBuilder = + new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + SparkScan scan = (SparkScan) scanBuilder.build(); + + Map reportColStatsDisabled = + ImmutableMap.of( + SQLConf.CBO_ENABLED().key(), "true", SparkSQLProperties.REPORT_COLUMN_STATS, "false"); + + checkColStatisticsNotReported(scan, 6L); + withSQLConf(reportColStatsDisabled, () -> checkColStatisticsNotReported(scan, 6L)); + + Map reportColStatsEnabled = + ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true"); + + Map expectedNDV = Maps.newHashMap(); + expectedNDV.put("id", 6L); + withSQLConf(reportColStatsEnabled, () -> checkColStatisticsReported(scan, 6L, expectedNDV)); + } + + @TestTemplate + public void testTableWithOneApacheDatasketchColStatAndOneDifferentColStat() + throws NoSuchTableException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "a"), + new SimpleRecord(4, "b")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + SparkScanBuilder scanBuilder = + new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + SparkScan scan = (SparkScan) scanBuilder.build(); + + Map reportColStatsDisabled = + ImmutableMap.of( + SQLConf.CBO_ENABLED().key(), "true", SparkSQLProperties.REPORT_COLUMN_STATS, "false"); + + Map reportColStatsEnabled = + ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true"); + + GenericStatisticsFile statisticsFile = + new GenericStatisticsFile( + snapshotId, + "/test/statistics/file.puffin", + 100, + 42, + ImmutableList.of( + new GenericBlobMetadata( + APACHE_DATASKETCHES_THETA_V1, + snapshotId, + 1, + ImmutableList.of(1), + ImmutableMap.of("ndv", "4")), + new GenericBlobMetadata( + DUMMY_BLOB_TYPE, + snapshotId, + 1, + ImmutableList.of(1), + ImmutableMap.of("data_size", "2")))); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + checkColStatisticsNotReported(scan, 4L); + withSQLConf(reportColStatsDisabled, () -> checkColStatisticsNotReported(scan, 4L)); + + Map expectedOneNDV = Maps.newHashMap(); + expectedOneNDV.put("id", 4L); + withSQLConf(reportColStatsEnabled, () -> checkColStatisticsReported(scan, 4L, expectedOneNDV)); + } + + @TestTemplate + public void testTableWithTwoColStats() throws NoSuchTableException { + sql("CREATE TABLE %s (id int, data string) USING iceberg", tableName); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), + new SimpleRecord(2, "b"), + new SimpleRecord(3, "a"), + new SimpleRecord(4, "b")); + spark + .createDataset(records, Encoders.bean(SimpleRecord.class)) + .coalesce(1) + .writeTo(tableName) + .append(); + + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + + SparkScanBuilder scanBuilder = + new SparkScanBuilder(spark, table, CaseInsensitiveStringMap.empty()); + SparkScan scan = (SparkScan) scanBuilder.build(); + + Map reportColStatsDisabled = + ImmutableMap.of( + SQLConf.CBO_ENABLED().key(), "true", SparkSQLProperties.REPORT_COLUMN_STATS, "false"); + + Map reportColStatsEnabled = + ImmutableMap.of(SQLConf.CBO_ENABLED().key(), "true"); + + GenericStatisticsFile statisticsFile = + new GenericStatisticsFile( + snapshotId, + "/test/statistics/file.puffin", + 100, + 42, + ImmutableList.of( + new GenericBlobMetadata( + APACHE_DATASKETCHES_THETA_V1, + snapshotId, + 1, + ImmutableList.of(1), + ImmutableMap.of("ndv", "4")), + new GenericBlobMetadata( + APACHE_DATASKETCHES_THETA_V1, + snapshotId, + 1, + ImmutableList.of(2), + ImmutableMap.of("ndv", "2")))); + + table.updateStatistics().setStatistics(statisticsFile).commit(); + + checkColStatisticsNotReported(scan, 4L); + withSQLConf(reportColStatsDisabled, () -> checkColStatisticsNotReported(scan, 4L)); + + Map expectedTwoNDVs = Maps.newHashMap(); + expectedTwoNDVs.put("id", 4L); + expectedTwoNDVs.put("data", 2L); + withSQLConf(reportColStatsEnabled, () -> checkColStatisticsReported(scan, 4L, expectedTwoNDVs)); + } + + @TestTemplate + public void testUnpartitionedYears() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + YearsFunction.TimestampToYearsFunction function = new YearsFunction.TimestampToYearsFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + "=", + expressions( + udf, intLit(timestampStrToYearOrdinal("2017-11-22T00:00:00.000000+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT Equal + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testPartitionedYears() throws Exception { + createPartitionedTable(spark, tableName, "years(ts)"); + + SparkScanBuilder builder = scanBuilder(); + + YearsFunction.TimestampToYearsFunction function = new YearsFunction.TimestampToYearsFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + "=", + expressions( + udf, intLit(timestampStrToYearOrdinal("2017-11-22T00:00:00.000000+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT Equal + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testUnpartitionedMonths() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + MonthsFunction.TimestampToMonthsFunction function = + new MonthsFunction.TimestampToMonthsFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + ">", + expressions( + udf, intLit(timestampStrToMonthOrdinal("2017-11-22T00:00:00.000000+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT GT + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testPartitionedMonths() throws Exception { + createPartitionedTable(spark, tableName, "months(ts)"); + + SparkScanBuilder builder = scanBuilder(); + + MonthsFunction.TimestampToMonthsFunction function = + new MonthsFunction.TimestampToMonthsFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + ">", + expressions( + udf, intLit(timestampStrToMonthOrdinal("2017-11-22T00:00:00.000000+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT GT + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testUnpartitionedDays() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + DaysFunction.TimestampToDaysFunction function = new DaysFunction.TimestampToDaysFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + "<", + expressions( + udf, dateLit(timestampStrToDayOrdinal("2018-11-20T00:00:00.000000+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT LT + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testPartitionedDays() throws Exception { + createPartitionedTable(spark, tableName, "days(ts)"); + + SparkScanBuilder builder = scanBuilder(); + + DaysFunction.TimestampToDaysFunction function = new DaysFunction.TimestampToDaysFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + "<", + expressions( + udf, dateLit(timestampStrToDayOrdinal("2018-11-20T00:00:00.000000+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT LT + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testUnpartitionedHours() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + HoursFunction.TimestampToHoursFunction function = new HoursFunction.TimestampToHoursFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + ">=", + expressions( + udf, intLit(timestampStrToHourOrdinal("2017-11-22T06:02:09.243857+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(8); + + // NOT GTEQ + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(2); + } + + @TestTemplate + public void testPartitionedHours() throws Exception { + createPartitionedTable(spark, tableName, "hours(ts)"); + + SparkScanBuilder builder = scanBuilder(); + + HoursFunction.TimestampToHoursFunction function = new HoursFunction.TimestampToHoursFunction(); + UserDefinedScalarFunc udf = toUDF(function, expressions(fieldRef("ts"))); + Predicate predicate = + new Predicate( + ">=", + expressions( + udf, intLit(timestampStrToHourOrdinal("2017-11-22T06:02:09.243857+00:00")))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(8); + + // NOT GTEQ + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(2); + } + + @TestTemplate + public void testUnpartitionedBucketLong() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + BucketFunction.BucketLong function = new BucketFunction.BucketLong(DataTypes.LongType); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(5), fieldRef("id"))); + Predicate predicate = new Predicate(">=", expressions(udf, intLit(2))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + + // NOT GTEQ + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + } + + @TestTemplate + public void testPartitionedBucketLong() throws Exception { + createPartitionedTable(spark, tableName, "bucket(5, id)"); + + SparkScanBuilder builder = scanBuilder(); + + BucketFunction.BucketLong function = new BucketFunction.BucketLong(DataTypes.LongType); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(5), fieldRef("id"))); + Predicate predicate = new Predicate(">=", expressions(udf, intLit(2))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(6); + + // NOT GTEQ + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(4); + } + + @TestTemplate + public void testUnpartitionedBucketString() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + BucketFunction.BucketString function = new BucketFunction.BucketString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(5), fieldRef("data"))); + Predicate predicate = new Predicate("<=", expressions(udf, intLit(2))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + + // NOT LTEQ + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + } + + @TestTemplate + public void testPartitionedBucketString() throws Exception { + createPartitionedTable(spark, tableName, "bucket(5, data)"); + + SparkScanBuilder builder = scanBuilder(); + + BucketFunction.BucketString function = new BucketFunction.BucketString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(5), fieldRef("data"))); + Predicate predicate = new Predicate("<=", expressions(udf, intLit(2))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(6); + + // NOT LTEQ + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(4); + } + + @TestTemplate + public void testUnpartitionedTruncateString() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + TruncateFunction.TruncateString function = new TruncateFunction.TruncateString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(4), fieldRef("data"))); + Predicate predicate = new Predicate("<>", expressions(udf, stringLit("data"))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT NotEqual + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testPartitionedTruncateString() throws Exception { + createPartitionedTable(spark, tableName, "truncate(4, data)"); + + SparkScanBuilder builder = scanBuilder(); + + TruncateFunction.TruncateString function = new TruncateFunction.TruncateString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(4), fieldRef("data"))); + Predicate predicate = new Predicate("<>", expressions(udf, stringLit("data"))); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT NotEqual + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testUnpartitionedIsNull() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + TruncateFunction.TruncateString function = new TruncateFunction.TruncateString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(4), fieldRef("data"))); + Predicate predicate = new Predicate("IS_NULL", expressions(udf)); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).isEmpty(); + + // NOT IsNull + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + } + + @TestTemplate + public void testPartitionedIsNull() throws Exception { + createPartitionedTable(spark, tableName, "truncate(4, data)"); + + SparkScanBuilder builder = scanBuilder(); + + TruncateFunction.TruncateString function = new TruncateFunction.TruncateString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(4), fieldRef("data"))); + Predicate predicate = new Predicate("IS_NULL", expressions(udf)); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).isEmpty(); + + // NOT IsNULL + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + } + + @TestTemplate + public void testUnpartitionedIsNotNull() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + TruncateFunction.TruncateString function = new TruncateFunction.TruncateString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(4), fieldRef("data"))); + Predicate predicate = new Predicate("IS_NOT_NULL", expressions(udf)); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + + // NOT IsNotNull + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).isEmpty(); + } + + @TestTemplate + public void testPartitionedIsNotNull() throws Exception { + createPartitionedTable(spark, tableName, "truncate(4, data)"); + + SparkScanBuilder builder = scanBuilder(); + + TruncateFunction.TruncateString function = new TruncateFunction.TruncateString(); + UserDefinedScalarFunc udf = toUDF(function, expressions(intLit(4), fieldRef("data"))); + Predicate predicate = new Predicate("IS_NOT_NULL", expressions(udf)); + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + + // NOT IsNotNULL + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).isEmpty(); + } + + @TestTemplate + public void testUnpartitionedAnd() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + YearsFunction.TimestampToYearsFunction tsToYears = new YearsFunction.TimestampToYearsFunction(); + UserDefinedScalarFunc udf1 = toUDF(tsToYears, expressions(fieldRef("ts"))); + Predicate predicate1 = new Predicate("=", expressions(udf1, intLit(2017 - 1970))); + + BucketFunction.BucketLong bucketLong = new BucketFunction.BucketLong(DataTypes.LongType); + UserDefinedScalarFunc udf = toUDF(bucketLong, expressions(intLit(5), fieldRef("id"))); + Predicate predicate2 = new Predicate(">=", expressions(udf, intLit(2))); + Predicate predicate = new And(predicate1, predicate2); + + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + + // NOT (years(ts) = 47 AND bucket(id, 5) >= 2) + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + } + + @TestTemplate + public void testPartitionedAnd() throws Exception { + createPartitionedTable(spark, tableName, "years(ts), bucket(5, id)"); + + SparkScanBuilder builder = scanBuilder(); + + YearsFunction.TimestampToYearsFunction tsToYears = new YearsFunction.TimestampToYearsFunction(); + UserDefinedScalarFunc udf1 = toUDF(tsToYears, expressions(fieldRef("ts"))); + Predicate predicate1 = new Predicate("=", expressions(udf1, intLit(2017 - 1970))); + + BucketFunction.BucketLong bucketLong = new BucketFunction.BucketLong(DataTypes.LongType); + UserDefinedScalarFunc udf = toUDF(bucketLong, expressions(intLit(5), fieldRef("id"))); + Predicate predicate2 = new Predicate(">=", expressions(udf, intLit(2))); + Predicate predicate = new And(predicate1, predicate2); + + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(1); + + // NOT (years(ts) = 47 AND bucket(id, 5) >= 2) + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(9); + } + + @TestTemplate + public void testUnpartitionedOr() throws Exception { + createUnpartitionedTable(spark, tableName); + + SparkScanBuilder builder = scanBuilder(); + + YearsFunction.TimestampToYearsFunction tsToYears = new YearsFunction.TimestampToYearsFunction(); + UserDefinedScalarFunc udf1 = toUDF(tsToYears, expressions(fieldRef("ts"))); + Predicate predicate1 = new Predicate("=", expressions(udf1, intLit(2017 - 1970))); + + BucketFunction.BucketLong bucketLong = new BucketFunction.BucketLong(DataTypes.LongType); + UserDefinedScalarFunc udf = toUDF(bucketLong, expressions(intLit(5), fieldRef("id"))); + Predicate predicate2 = new Predicate(">=", expressions(udf, intLit(2))); + Predicate predicate = new Or(predicate1, predicate2); + + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(10); + + // NOT (years(ts) = 47 OR bucket(id, 5) >= 2) + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(5); + } + + @TestTemplate + public void testPartitionedOr() throws Exception { + createPartitionedTable(spark, tableName, "years(ts), bucket(5, id)"); + + SparkScanBuilder builder = scanBuilder(); + + YearsFunction.TimestampToYearsFunction tsToYears = new YearsFunction.TimestampToYearsFunction(); + UserDefinedScalarFunc udf1 = toUDF(tsToYears, expressions(fieldRef("ts"))); + Predicate predicate1 = new Predicate("=", expressions(udf1, intLit(2018 - 1970))); + + BucketFunction.BucketLong bucketLong = new BucketFunction.BucketLong(DataTypes.LongType); + UserDefinedScalarFunc udf = toUDF(bucketLong, expressions(intLit(5), fieldRef("id"))); + Predicate predicate2 = new Predicate(">=", expressions(udf, intLit(2))); + Predicate predicate = new Or(predicate1, predicate2); + + pushFilters(builder, predicate); + Batch scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(6); + + // NOT (years(ts) = 48 OR bucket(id, 5) >= 2) + builder = scanBuilder(); + + predicate = new Not(predicate); + pushFilters(builder, predicate); + scan = builder.build().toBatch(); + + assertThat(scan.planInputPartitions()).hasSize(4); + } + + @TestTemplate + public void testBatchQueryScanDescription() throws Exception { + createPartitionedTable(spark, tableName, "data"); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + SparkScanBuilder builder = scanBuilder(); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.PRESERVE_DATA_GROUPING, "true"), + () -> { + Predicate predicate1 = new Predicate("=", expressions(fieldRef("id"), intLit(1))); + Predicate predicate2 = new Predicate(">", expressions(fieldRef("id"), intLit(0))); + pushFilters(builder, predicate1, predicate2); + + Scan scan = builder.build(); + String description = scan.description(); + + assertThat(description).contains("IcebergScan"); + assertThat(description).contains(tableName); + assertThat(description).contains("schemaId=" + table.schema().schemaId()); + assertThat(description).contains("snapshotId=" + table.currentSnapshot().snapshotId()); + assertThat(description).contains("branch=null"); + assertThat(description).contains("filters=id = 1, id > 0"); + assertThat(description).contains("groupedBy=data"); + }); + } + + @TestTemplate + public void testCopyOnWriteScanDescription() throws Exception { + createPartitionedTable(spark, tableName, "data"); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + SparkScanBuilder builder = scanBuilder(); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.PRESERVE_DATA_GROUPING, "true"), + () -> { + Predicate predicate1 = new Predicate("=", expressions(fieldRef("id"), intLit(2))); + Predicate predicate2 = new Predicate("<", expressions(fieldRef("id"), intLit(10))); + pushFilters(builder, predicate1, predicate2); + + Scan scan = builder.buildCopyOnWriteScan(); + String description = scan.description(); + + assertThat(description).contains("IcebergCopyOnWriteScan"); + assertThat(description).contains(tableName); + assertThat(description).contains("schemaId=" + table.schema().schemaId()); + assertThat(description).contains("snapshotId=" + table.currentSnapshot().snapshotId()); + assertThat(description).contains("branch=null"); + assertThat(description).contains("filters=id = 2, id < 10"); + assertThat(description).contains("groupedBy=data"); + }); + } + + @TestTemplate + public void testIncrementalScanDescription() throws Exception { + createPartitionedTable(spark, tableName, "data"); + Table table = Spark3Util.loadIcebergTable(spark, tableName); + long startSnapshotId = table.currentSnapshot().snapshotId(); + + // add a second snapshot to use as the end + sql("INSERT INTO %s VALUES (1, CAST('2021-01-01 00:00:00' AS TIMESTAMP), 'b')", tableName); + table.refresh(); + long endSnapshotId = table.currentSnapshot().snapshotId(); + + SparkScanBuilder builder = + scanBuilder( + ImmutableMap.of( + SparkReadOptions.START_SNAPSHOT_ID, String.valueOf(startSnapshotId), + SparkReadOptions.END_SNAPSHOT_ID, String.valueOf(endSnapshotId))); + + withSQLConf( + ImmutableMap.of(SparkSQLProperties.PRESERVE_DATA_GROUPING, "true"), + () -> { + Scan scan = builder.build(); + String description = scan.description(); + + assertThat(description).contains("IcebergIncrementalScan"); + assertThat(description).contains(tableName); + assertThat(description).contains("startSnapshotId=" + startSnapshotId); + assertThat(description).contains("endSnapshotId=" + endSnapshotId); + }); + } + + private SparkScanBuilder scanBuilder() throws Exception { + return scanBuilder(ImmutableMap.of()); + } + + private SparkScanBuilder scanBuilder(Map extraOptions) throws Exception { + Table table = Spark3Util.loadIcebergTable(spark, tableName); + return new SparkScanBuilder(spark, table, new CaseInsensitiveStringMap(extraOptions)); + } + + private void pushFilters(ScanBuilder scan, Predicate... predicates) { + assertThat(scan).isInstanceOf(SupportsPushDownV2Filters.class); + SupportsPushDownV2Filters filterable = (SupportsPushDownV2Filters) scan; + filterable.pushPredicates(predicates); + } + + private Expression[] expressions(Expression... expressions) { + return expressions; + } + + private void checkColStatisticsNotReported(SparkScan scan, long expectedRowCount) { + Statistics stats = scan.estimateStatistics(); + assertThat(stats.numRows().getAsLong()).isEqualTo(expectedRowCount); + + Map columnStats = stats.columnStats(); + assertThat(columnStats).isEmpty(); + } + + private void checkColStatisticsReported( + SparkScan scan, long expectedRowCount, Map expectedNDVs) { + Statistics stats = scan.estimateStatistics(); + assertThat(stats.numRows().getAsLong()).isEqualTo(expectedRowCount); + + Map columnStats = stats.columnStats(); + if (expectedNDVs.isEmpty()) { + assertThat(columnStats.values().stream().allMatch(value -> value.distinctCount().isEmpty())) + .isTrue(); + } else { + for (Map.Entry entry : expectedNDVs.entrySet()) { + assertThat( + columnStats.get(FieldReference.column(entry.getKey())).distinctCount().getAsLong()) + .isEqualTo(entry.getValue()); + } + } + } + + private static LiteralValue intLit(int value) { + return LiteralValue.apply(value, DataTypes.IntegerType); + } + + private static LiteralValue dateLit(int value) { + return LiteralValue.apply(value, DataTypes.DateType); + } + + private static LiteralValue stringLit(String value) { + return LiteralValue.apply(value, DataTypes.StringType); + } + + private static NamedReference fieldRef(String col) { + return FieldReference.apply(col); + } + + private static UserDefinedScalarFunc toUDF(BoundFunction function, Expression[] expressions) { + return new UserDefinedScalarFunc(function.name(), function.canonicalName(), expressions); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkStagedScan.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkStagedScan.java new file mode 100644 index 000000000000..de07b7471f6e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkStagedScan.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.ScanTaskSetManager; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.SparkTableCache; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkStagedScan extends CatalogTestBase { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testTaskSetLoading() throws NoSuchTableException, IOException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should produce 1 snapshot").hasSize(1); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + String groupId = UUID.randomUUID().toString(); + taskSetManager.stageTasks(table, groupId, ImmutableList.copyOf(fileScanTasks)); + SparkTableCache.get().add(groupId, table); + + // load the staged file set via the rewrite catalog + Dataset scanDF = spark.read().format("iceberg").load(groupId); + + // write the records back essentially duplicating data + scanDF.writeTo(tableName).append(); + } + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "a"), row(1, "a"), row(2, "b"), row(2, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testTaskSetPlanning() throws NoSuchTableException, IOException { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + + List records = + ImmutableList.of(new SimpleRecord(1, "a"), new SimpleRecord(2, "b")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.coalesce(1).writeTo(tableName).append(); + df.coalesce(1).writeTo(tableName).append(); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should produce 2 snapshot").hasSize(2); + + try (CloseableIterable fileScanTasks = table.newScan().planFiles()) { + ScanTaskSetManager taskSetManager = ScanTaskSetManager.get(); + String groupId = UUID.randomUUID().toString(); + List tasks = ImmutableList.copyOf(fileScanTasks); + taskSetManager.stageTasks(table, groupId, tasks); + SparkTableCache.get().add(groupId, table); + + // load the staged file set and make sure each file is in a separate split + Dataset scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, tasks.get(0).file().fileSizeInBytes()) + .load(groupId); + assertThat(scanDF.javaRDD().getNumPartitions()) + .as("Num partitions should match") + .isEqualTo(2); + + // load the staged file set and make sure we combine both files into a single split + scanDF = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.SPLIT_SIZE, Long.MAX_VALUE) + .load(groupId); + assertThat(scanDF.javaRDD().getNumPartitions()) + .as("Num partitions should match") + .isEqualTo(1); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java new file mode 100644 index 000000000000..106d908b49f4 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkTable.java @@ -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. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.connector.catalog.CatalogManager; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.constraints.Constraint; +import org.apache.spark.sql.connector.catalog.constraints.PrimaryKey; +import org.apache.spark.sql.connector.expressions.NamedReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkTable extends CatalogTestBase { + + @BeforeEach + public void createTable() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, name string NOT NULL, data string) USING iceberg", + tableName); + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testTableEquality() { + SparkTable table1 = loadSparkTable(); + SparkTable table2 = loadSparkTable(); + + // different instances pointing to the same table must be equivalent + assertThat(table1).as("References must be different").isNotSameAs(table2); + assertThat(table1).as("Tables must be equivalent").isEqualTo(table2); + } + + @TestTemplate + public void testNoIdentifierFieldsRelyByDefault() { + SparkTable sparkTable = loadSparkTable(); + assertThat(primaryKeys(sparkTable)).isEmpty(); + + // enabling rely without identifier fields still produces no primary key + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.IDENTIFIER_FIELDS_RELY); + sparkTable = loadSparkTable(); + assertThat(primaryKeys(sparkTable)).isEmpty(); + } + + @TestTemplate + public void testIdentifierFieldsRelyViaTableProperty() { + SparkTable sparkTable = loadSparkTable(); + sparkTable + .table() + .updateSchema() + .allowIncompatibleChanges() + .setIdentifierFields("id", "name") + .commit(); + + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.IDENTIFIER_FIELDS_RELY); + + sparkTable = loadSparkTable(); + List pks = primaryKeys(sparkTable); + assertThat(pks).hasSize(1); + + PrimaryKey pk = pks.get(0); + assertThat(pk.name()).isEqualTo("iceberg_pk"); + assertThat(pk.enforced()).isFalse(); + assertThat(pk.rely()).isTrue(); + assertThat(pk.validationStatus()).isEqualTo(Constraint.ValidationStatus.UNVALIDATED); + + Set columnNames = + Arrays.stream(pk.columns()).map(NamedReference::toString).collect(Collectors.toSet()); + assertThat(columnNames).containsExactlyInAnyOrder("id", "name"); + + // disabling rely removes the primary key + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'false')", + tableName, TableProperties.IDENTIFIER_FIELDS_RELY); + sparkTable = loadSparkTable(); + assertThat(primaryKeys(sparkTable)).isEmpty(); + } + + @TestTemplate + public void testIdentifierFieldsRelyViaSessionConf() { + SparkTable sparkTable = loadSparkTable(); + sparkTable.table().updateSchema().allowIncompatibleChanges().setIdentifierFields("id").commit(); + + // session conf enables rely without a table property + withSQLConf( + ImmutableMap.of(SparkSQLProperties.IDENTIFIER_FIELDS_RELY, "true"), + () -> { + List pks = primaryKeys(loadSparkTable()); + assertThat(pks).hasSize(1); + + Set columnNames = + Arrays.stream(pks.get(0).columns()) + .map(NamedReference::toString) + .collect(Collectors.toSet()); + assertThat(columnNames).containsExactly("id"); + }); + + // session conf rely=false overrides table property rely=true + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('%s' = 'true')", + tableName, TableProperties.IDENTIFIER_FIELDS_RELY); + withSQLConf( + ImmutableMap.of(SparkSQLProperties.IDENTIFIER_FIELDS_RELY, "false"), + () -> assertThat(primaryKeys(loadSparkTable())).isEmpty()); + } + + private static List primaryKeys(SparkTable table) { + return Arrays.stream(table.constraints()) + .filter(c -> c instanceof PrimaryKey) + .map(c -> (PrimaryKey) c) + .collect(Collectors.toList()); + } + + private SparkTable loadSparkTable() { + try { + CatalogManager catalogManager = spark.sessionState().catalogManager(); + TableCatalog catalog = (TableCatalog) catalogManager.catalog(catalogName); + Identifier identifier = Identifier.of(tableIdent.namespace().levels(), tableIdent.name()); + return (SparkTable) catalog.loadTable(identifier); + } catch (NoSuchTableException e) { + throw new RuntimeException(e); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriteMetrics.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriteMetrics.java new file mode 100644 index 000000000000..661aac1372ec --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriteMetrics.java @@ -0,0 +1,223 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.spark.source.metrics.AddedDataFiles; +import org.apache.iceberg.spark.source.metrics.AddedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.AddedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.AddedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.AddedRecords; +import org.apache.iceberg.spark.source.metrics.RemovedDataFiles; +import org.apache.iceberg.spark.source.metrics.RemovedDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.RemovedPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.RemovedRecords; +import org.apache.iceberg.spark.source.metrics.TotalDataFiles; +import org.apache.iceberg.spark.source.metrics.TotalDeleteFiles; +import org.apache.iceberg.spark.source.metrics.TotalEqualityDeletes; +import org.apache.iceberg.spark.source.metrics.TotalFileSizeInBytes; +import org.apache.iceberg.spark.source.metrics.TotalPositionalDeletes; +import org.apache.iceberg.spark.source.metrics.TotalRecords; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.sql.execution.metric.SQLMetric; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import scala.jdk.javaapi.CollectionConverters; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkWriteMetrics extends TestBaseWithCatalog { + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void writeMetrics() { + sql("CREATE TABLE %s (id BIGINT) USING iceberg", tableName); + + String insertSql = String.format("INSERT INTO %s SELECT id FROM range(1000)", tableName); + Dataset result = spark.sql(insertSql); + result.collect(); + + SparkPlan plan = result.queryExecution().executedPlan(); + Map metricsMap = CollectionConverters.asJava(plan.metrics()); + + // If we are at the root, check if we have the metrics. + // Sometimes the plan structure is complex (e.g. AdaptiveSparkPlanExec). + // We might want to find the specific write node. + + if (!metricsMap.containsKey(AddedDataFiles.NAME)) { + // Attempt to find a node with these metrics + metricsMap = findMetrics(plan, AddedDataFiles.NAME); + } + + assertThat(metricsMap).isNotNull(); + assertThat(metricsMap) + .hasEntrySatisfying(AddedDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedRecords.NAME, metric -> assertThat(metric.value()).isEqualTo(1000)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + assertThat(metricsMap) + .hasEntrySatisfying(TotalDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(2)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalRecords.NAME, metric -> assertThat(metric.value()).isEqualTo(1000)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + + // Verify other metrics are 0 + String[] zeroMetrics = { + AddedDeleteFiles.NAME, + AddedEqualityDeleteFiles.NAME, + AddedPositionalDeleteFiles.NAME, + AddedEqualityDeletes.NAME, + AddedPositionalDeletes.NAME, + RemovedDataFiles.NAME, + RemovedDeleteFiles.NAME, + RemovedEqualityDeleteFiles.NAME, + RemovedPositionalDeleteFiles.NAME, + RemovedEqualityDeletes.NAME, + RemovedPositionalDeletes.NAME, + RemovedRecords.NAME, + RemovedFileSizeInBytes.NAME, + TotalDeleteFiles.NAME, + TotalEqualityDeletes.NAME, + TotalPositionalDeletes.NAME + }; + + for (String metric : zeroMetrics) { + assertThat(metricsMap) + .hasEntrySatisfying(metric, m -> assertThat(m.value()).as(metric).isEqualTo(0)); + } + } + + @TestTemplate + public void deleteMetrics() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT) USING iceberg TBLPROPERTIES ('write.delete.mode'='merge-on-read')", + tableName); + + spark.range(100).coalesce(1).writeTo(tableName).append(); + + String deleteSql = String.format("DELETE FROM %s WHERE id = 1", tableName); + Dataset result = spark.sql(deleteSql); + result.collect(); + + SparkPlan plan = result.queryExecution().executedPlan(); + + Map metricsMap = findMetrics(plan, AddedPositionalDeleteFiles.NAME); + + assertThat(metricsMap).isNotNull(); + + assertThat(metricsMap) + .hasEntrySatisfying( + AddedPositionalDeleteFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + RemovedDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(0)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedPositionalDeletes.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalDeleteFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalPositionalDeletes.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedDeleteFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying( + AddedFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + assertThat(metricsMap) + .hasEntrySatisfying(TotalDataFiles.NAME, metric -> assertThat(metric.value()).isEqualTo(1)); + assertThat(metricsMap) + .hasEntrySatisfying(TotalRecords.NAME, metric -> assertThat(metric.value()).isEqualTo(100)); + assertThat(metricsMap) + .hasEntrySatisfying( + TotalFileSizeInBytes.NAME, metric -> assertThat(metric.value()).isGreaterThan(0)); + + // Verify other metrics are 0 + String[] zeroMetrics = { + AddedDataFiles.NAME, + AddedEqualityDeleteFiles.NAME, + AddedEqualityDeletes.NAME, + AddedRecords.NAME, + RemovedDeleteFiles.NAME, + RemovedEqualityDeleteFiles.NAME, + RemovedPositionalDeleteFiles.NAME, + RemovedEqualityDeletes.NAME, + RemovedPositionalDeletes.NAME, + RemovedRecords.NAME, + RemovedFileSizeInBytes.NAME, + TotalEqualityDeletes.NAME + }; + + for (String metric : zeroMetrics) { + assertThat(metricsMap) + .hasEntrySatisfying(metric, m -> assertThat(m.value()).as(metric).isEqualTo(0)); + } + } + + private Map findMetrics(SparkPlan plan, String metricName) { + Map metrics = CollectionConverters.asJava(plan.metrics()); + if (metrics.containsKey(metricName)) { + return metrics; + } + + for (SparkPlan child : CollectionConverters.asJava(plan.children())) { + Map result = findMetrics(child, metricName); + if (result != null) { + return result; + } + } + + for (Object child : CollectionConverters.asJava(plan.innerChildren())) { + if (child instanceof SparkPlan) { + Map result = findMetrics((SparkPlan) child, metricName); + if (result != null) { + return result; + } + } + } + + return null; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriterMetrics.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriterMetrics.java new file mode 100644 index 000000000000..06ecc20c2fc3 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkWriterMetrics.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Table; +import org.apache.iceberg.io.FileWriterFactory; +import org.apache.iceberg.io.TestWriterMetrics; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.expressions.GenericInternalRow; +import org.apache.spark.unsafe.types.UTF8String; + +public class TestSparkWriterMetrics extends TestWriterMetrics { + + public TestSparkWriterMetrics(FileFormat fileFormat) { + super(fileFormat); + } + + @Override + protected FileWriterFactory newWriterFactory(Table sourceTable) { + return SparkFileWriterFactory.builderFor(sourceTable) + .dataSchema(sourceTable.schema()) + .dataFileFormat(fileFormat) + .deleteFileFormat(fileFormat) + .positionDeleteRowSchema(sourceTable.schema()) + .build(); + } + + @Override + protected InternalRow toRow(Integer id, String data, boolean boolValue, Long longValue) { + InternalRow row = new GenericInternalRow(3); + row.update(0, id); + row.update(1, UTF8String.fromString(data)); + + InternalRow nested = new GenericInternalRow(2); + nested.update(0, boolValue); + nested.update(1, longValue); + + row.update(2, nested); + return row; + } + + @Override + protected InternalRow toGenericRow(int value, int repeated) { + InternalRow row = new GenericInternalRow(repeated); + for (int i = 0; i < repeated; i++) { + row.update(i, value); + } + return row; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java new file mode 100644 index 000000000000..6428689aaa19 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingCheckpointHadoopIO.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.hadoop.HadoopFileIO; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.streaming.StreamingQuery; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests to verify that streaming checkpoints always use HadoopFileIO and never use the table's + * FileIO implementation. + */ +@ExtendWith(ParameterizedTestExtension.class) +public class TestStreamingCheckpointHadoopIO extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + "testtableio", + SparkCatalog.class.getName(), + ImmutableMap.of( + "type", + "hadoop", + CatalogProperties.FILE_IO_IMPL, + TrackingFileIO.class.getName(), + "cache-enabled", + "false") + } + }; + } + + @AfterEach + public void stopStreams() throws TimeoutException { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + TrackingFileIO.reset(); + } + + @TestTemplate + public void testCheckpointsUseHadoopIONotTableIO() throws Exception { + sql("CREATE TABLE %s (id INT, data STRING) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b')", tableName); + + // Use nested checkpoint path to verify parent directory creation + File checkpointDir = new File(temp.toFile(), "nested/checkpoint"); + TrackingFileIO.reset(); + + // Run streaming query with checkpoints + StreamingQuery query = + spark + .readStream() + .format("iceberg") + .load(tableName) + .writeStream() + .format("console") + .option("checkpointLocation", checkpointDir.getAbsolutePath()) + .start(); + + query.processAllAvailable(); + query.stop(); + + // Verify TrackingFileIO (table's FileIO) was NOT used for checkpoint operations + assertThat(TrackingFileIO.wasUsed()) + .as("HadoopFileIO should be used for checkpoints, not table's FileIO") + .isFalse(); + + // Verify checkpoint files were actually created using HadoopFileIO + assertThat(new File(checkpointDir, "offsets/0")).exists().isFile(); + } + + /** + * A FileIO that tracks whether it was used for checkpoint operations. This allows us to verify + * that the table's FileIO is NOT being used for checkpoints. + */ + public static class TrackingFileIO implements FileIO { + private static final String CHECKPOINT_OFFSETS_PATH = "/offsets/"; + private static final AtomicBoolean USED = new AtomicBoolean(false); + private FileIO delegate; + + public static void reset() { + USED.set(false); + } + + public static boolean wasUsed() { + return USED.get(); + } + + @Override + public InputFile newInputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newInputFile(path); + } + + @Override + public OutputFile newOutputFile(String path) { + if (path.contains(CHECKPOINT_OFFSETS_PATH)) { + USED.set(true); + } + return delegate.newOutputFile(path); + } + + @Override + public void deleteFile(String path) { + delegate.deleteFile(path); + } + + @Override + public void initialize(Map properties) { + this.delegate = new HadoopFileIO(); + this.delegate.initialize(properties); + } + + @Override + public void close() { + if (delegate != null) { + delegate.close(); + } + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingOffset.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingOffset.java new file mode 100644 index 000000000000..d55e718ff2d3 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStreamingOffset.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Arrays; +import org.apache.iceberg.util.JsonUtil; +import org.junit.jupiter.api.Test; + +public class TestStreamingOffset { + + @Test + public void testJsonConversion() { + StreamingOffset[] expected = + new StreamingOffset[] { + new StreamingOffset(System.currentTimeMillis(), 1L, false), + new StreamingOffset(System.currentTimeMillis(), 2L, false), + new StreamingOffset(System.currentTimeMillis(), 3L, false), + new StreamingOffset(System.currentTimeMillis(), 4L, true) + }; + assertThat(Arrays.stream(expected).map(elem -> StreamingOffset.fromJson(elem.json())).toArray()) + .as("StreamingOffsets should match") + .isEqualTo(expected); + } + + @Test + public void testToJson() throws Exception { + StreamingOffset expected = new StreamingOffset(System.currentTimeMillis(), 1L, false); + ObjectNode actual = JsonUtil.mapper().createObjectNode(); + actual.put("version", 1); + actual.put("snapshot_id", expected.snapshotId()); + actual.put("position", 1L); + actual.put("scan_all_files", false); + String expectedJson = expected.json(); + String actualJson = JsonUtil.mapper().writeValueAsString(actual); + assertThat(actualJson).isEqualTo(expectedJson); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructInternalRowVariant.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructInternalRowVariant.java new file mode 100644 index 000000000000..78e7e23b861b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructInternalRowVariant.java @@ -0,0 +1,182 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.variants.Variant; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.VariantValue; +import org.apache.iceberg.variants.Variants; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.util.ArrayData; +import org.apache.spark.sql.catalyst.util.MapData; +import org.apache.spark.sql.types.VariantType$; +import org.apache.spark.unsafe.types.VariantVal; +import org.junit.jupiter.api.Test; + +public class TestStructInternalRowVariant { + + @Test + public void testGetVariantReturnsVariantVal() { + Types.StructType structType = variantStructType(); + GenericRecord rec = newRecord(structType); + Variant variant = sampleVariant(); + rec.set(0, variant); + + InternalRow row = new StructInternalRow(structType).setStruct(rec); + + VariantVal actual = row.getVariant(0); + assertThat(actual).isNotNull(); + + VariantMetadata metadata = + VariantMetadata.from(ByteBuffer.wrap(actual.getMetadata()).order(ByteOrder.LITTLE_ENDIAN)); + assertThat(metadata.dictionarySize()).isEqualTo(1); + assertThat(metadata.get(0)).isEqualTo("k"); + + VariantValue actualValue = + VariantValue.from( + metadata, ByteBuffer.wrap(actual.getValue()).order(ByteOrder.LITTLE_ENDIAN)); + + assertThat(actualValue.asObject().get("k").asPrimitive().get()).isEqualTo("v1"); + } + + @Test + public void testGetVariantNull() { + Types.StructType structType = variantStructType(); + GenericRecord rec = newRecord(structType); + rec.set(0, null); + + InternalRow row = new StructInternalRow(structType).setStruct(rec); + assertThat(row.getVariant(0)).isNull(); + } + + @Test + public void testArrayOfVariant() { + Types.ListType listType = Types.ListType.ofOptional(2, Types.VariantType.get()); + Types.StructType structType = + Types.StructType.of(Types.NestedField.optional(1, "arr", listType)); + + GenericRecord rec = GenericRecord.create(structType); + + Variant v1 = sampleVariant(); + Variant v2 = sampleVariant(); + + List elements = Arrays.asList(v1, v2, null); + rec.set(0, elements); + + InternalRow row = new StructInternalRow(structType).setStruct(rec); + ArrayData arr = row.getArray(0); + + Object firstVar = arr.get(0, VariantType$.MODULE$); + Object secondVar = arr.get(1, VariantType$.MODULE$); + + assertThat(firstVar).isInstanceOf(VariantVal.class); + assertThat(secondVar).isInstanceOf(VariantVal.class); + assertThat(arr.isNullAt(2)).isTrue(); + + assertVariantValEqualsKV((VariantVal) firstVar, "k", "v1"); + assertVariantValEqualsKV((VariantVal) secondVar, "k", "v1"); + } + + @Test + public void testMapWithVariant() { + Types.MapType mapType = + Types.MapType.ofOptional(2, 3, Types.StringType.get(), Types.VariantType.get()); + Types.StructType structType = Types.StructType.of(Types.NestedField.optional(1, "m", mapType)); + + GenericRecord rec = GenericRecord.create(structType); + Map map = Maps.newHashMap(); + map.put("a", sampleVariant()); + map.put("b", sampleVariant()); + rec.set(0, map); + + InternalRow row = new StructInternalRow(structType).setStruct(rec); + MapData mapData = row.getMap(0); + + ArrayData values = mapData.valueArray(); + for (int i = 0; i < values.numElements(); i++) { + Object variant = values.get(i, VariantType$.MODULE$); + assertThat(variant).isInstanceOf(VariantVal.class); + assertVariantValEqualsKV((VariantVal) variant, "k", "v1"); + } + } + + @Test + public void testNestedStructVariant() { + Types.StructType variant = + Types.StructType.of(Types.NestedField.optional(2, "v", Types.VariantType.get())); + Types.StructType structVariant = + Types.StructType.of(Types.NestedField.optional(1, "n", variant)); + + // Case 1: nested struct holds Iceberg Variant + GenericRecord variantStructRec = GenericRecord.create(variant); + variantStructRec.set(0, sampleVariant()); + GenericRecord structRec = GenericRecord.create(structVariant); + structRec.set(0, variantStructRec); + + InternalRow structRow = new StructInternalRow(structVariant).setStruct(structRec); + InternalRow nested = structRow.getStruct(0, 1); + VariantVal variantVal1 = nested.getVariant(0); + assertVariantValEqualsKV(variantVal1, "k", "v1"); + } + + @Test + public void testGetWithVariantType() { + Types.StructType structType = variantStructType(); + GenericRecord rec = newRecord(structType); + rec.set(0, sampleVariant()); + + InternalRow row = new StructInternalRow(structType).setStruct(rec); + Object obj = row.get(0, VariantType$.MODULE$); + assertThat(obj).isInstanceOf(VariantVal.class); + assertVariantValEqualsKV((VariantVal) obj, "k", "v1"); + } + + private static Types.StructType variantStructType() { + return Types.StructType.of(Types.NestedField.optional(1, "a", Types.VariantType.get())); + } + + private static GenericRecord newRecord(Types.StructType structType) { + return GenericRecord.create(structType); + } + + private static Variant sampleVariant() { + VariantMetadata md = Variants.metadata("k"); + org.apache.iceberg.variants.ShreddedObject obj = Variants.object(md); + obj.put("k", Variants.of("v1")); + return Variant.of(md, obj); + } + + private static void assertVariantValEqualsKV(VariantVal vv, String key, String expected) { + VariantMetadata metadata = + VariantMetadata.from(ByteBuffer.wrap(vv.getMetadata()).order(ByteOrder.LITTLE_ENDIAN)); + VariantValue value = + VariantValue.from(metadata, ByteBuffer.wrap(vv.getValue()).order(ByteOrder.LITTLE_ENDIAN)); + assertThat(value.asObject().get(key).asPrimitive().get()).isEqualTo(expected); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java new file mode 100644 index 000000000000..5e900ea0bad4 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreaming.java @@ -0,0 +1,359 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.SortOrder; +import org.apache.iceberg.Table; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoder; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.execution.streaming.runtime.MemoryStream; +import org.apache.spark.sql.streaming.DataStreamWriter; +import org.apache.spark.sql.streaming.StreamingQuery; +import org.apache.spark.sql.streaming.StreamingQueryException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import scala.Option; +import scala.collection.JavaConverters; + +public class TestStructuredStreaming { + + private static final Configuration CONF = new Configuration(); + private static final Schema SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + private static SparkSession spark = null; + + @TempDir private Path temp; + + @BeforeAll + public static void startSpark() { + TestStructuredStreaming.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config("spark.sql.shuffle.partitions", 4) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestStructuredStreaming.spark; + TestStructuredStreaming.spark = null; + currentSpark.stop(); + } + + @Test + public void testStreamingWriteAppendMode() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, "1"), + new SimpleRecord(2, "2"), + new SimpleRecord(3, "3"), + new SimpleRecord(4, "4")); + + MemoryStream inputStream = newMemoryStream(1, spark, Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .selectExpr("value AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("append") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + // start the original query with checkpointing + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + query.processAllAvailable(); + List batch2 = Lists.newArrayList(3, 4); + send(batch2, inputStream); + query.processAllAvailable(); + query.stop(); + + // remove the last commit to force Spark to reprocess batch #1 + File lastCommitFile = new File(checkpoint + "/commits/1"); + assertThat(lastCommitFile.delete()).as("The commit file must be deleted").isTrue(); + Files.deleteIfExists(Paths.get(checkpoint + "/commits/.1.crc")); + + // restart the query from the checkpoint + StreamingQuery restartedQuery = streamWriter.start(); + restartedQuery.processAllAvailable(); + + // ensure the write was idempotent + Dataset result = spark.read().format("iceberg").load(location.toString()); + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + assertThat(table.snapshots()).as("Number of snapshots should match").hasSize(2); + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + + @Test + public void testStreamingWriteCompleteMode() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List expected = + Lists.newArrayList( + new SimpleRecord(2, "1"), new SimpleRecord(3, "2"), new SimpleRecord(1, "3")); + + MemoryStream inputStream = newMemoryStream(1, spark, Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .groupBy("value") + .count() + .selectExpr("CAST(count AS INT) AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("complete") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + // start the original query with checkpointing + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + query.processAllAvailable(); + List batch2 = Lists.newArrayList(1, 2, 2, 3); + send(batch2, inputStream); + query.processAllAvailable(); + query.stop(); + + // remove the last commit to force Spark to reprocess batch #1 + File lastCommitFile = new File(checkpoint + "/commits/1"); + assertThat(lastCommitFile.delete()).as("The commit file must be deleted").isTrue(); + Files.deleteIfExists(Paths.get(checkpoint + "/commits/.1.crc")); + + // restart the query from the checkpoint + StreamingQuery restartedQuery = streamWriter.start(); + restartedQuery.processAllAvailable(); + + // ensure the write was idempotent + Dataset result = spark.read().format("iceberg").load(location.toString()); + List actual = + result.orderBy("data").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + assertThat(table.snapshots()).as("Number of snapshots should match").hasSize(2); + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + + @Test + public void testStreamingWriteCompleteModeWithProjection() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Table table = tables.create(SCHEMA, spec, location.toString()); + + List expected = + Lists.newArrayList( + new SimpleRecord(1, null), new SimpleRecord(2, null), new SimpleRecord(3, null)); + + MemoryStream inputStream = newMemoryStream(1, spark, Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .groupBy("value") + .count() + .selectExpr("CAST(count AS INT) AS id") // select only id column + .writeStream() + .outputMode("complete") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + // start the original query with checkpointing + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + query.processAllAvailable(); + List batch2 = Lists.newArrayList(1, 2, 2, 3); + send(batch2, inputStream); + query.processAllAvailable(); + query.stop(); + + // remove the last commit to force Spark to reprocess batch #1 + File lastCommitFile = new File(checkpoint + "/commits/1"); + assertThat(lastCommitFile.delete()).as("The commit file must be deleted").isTrue(); + Files.deleteIfExists(Paths.get(checkpoint + "/commits/.1.crc")); + + // restart the query from the checkpoint + StreamingQuery restartedQuery = streamWriter.start(); + restartedQuery.processAllAvailable(); + + // ensure the write was idempotent + Dataset result = spark.read().format("iceberg").load(location.toString()); + List actual = + result.orderBy("id").as(Encoders.bean(SimpleRecord.class)).collectAsList(); + + assertThat(actual).hasSameSizeAs(expected).isEqualTo(expected); + assertThat(table.snapshots()).as("Number of snapshots should match").hasSize(2); + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + + @Test + public void testStreamingWriteDataFilesInTableSortOrder() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + SortOrder sortOrder = SortOrder.builderFor(SCHEMA).asc("id").build(); + Table table = tables.create(SCHEMA, spec, sortOrder, ImmutableMap.of(), location.toString()); + + MemoryStream inputStream = newMemoryStream(1, spark, Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .selectExpr("value AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("append") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + query.processAllAvailable(); + query.stop(); + + table.refresh(); + + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks) + .extracting(task -> task.file().sortOrderId()) + .as("All DataFiles are written with the table sort order id") + .containsOnly(table.sortOrder().orderId()); + } + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + + @Test + public void testStreamingWriteUpdateMode() throws Exception { + File parent = temp.resolve("parquet").toFile(); + File location = new File(parent, "test-table"); + File checkpoint = new File(parent, "checkpoint"); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(SCHEMA).identity("data").build(); + tables.create(SCHEMA, spec, location.toString()); + + MemoryStream inputStream = newMemoryStream(1, spark, Encoders.INT()); + DataStreamWriter streamWriter = + inputStream + .toDF() + .selectExpr("value AS id", "CAST (value AS STRING) AS data") + .writeStream() + .outputMode("update") + .format("iceberg") + .option("checkpointLocation", checkpoint.toString()) + .option("path", location.toString()); + + try { + StreamingQuery query = streamWriter.start(); + List batch1 = Lists.newArrayList(1, 2); + send(batch1, inputStream); + + assertThatThrownBy(query::processAllAvailable) + .isInstanceOf(StreamingQueryException.class) + .hasMessageContaining("does not support Update mode"); + } finally { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + } + + private MemoryStream newMemoryStream( + int id, SparkSession sparkSession, Encoder encoder) { + return new MemoryStream<>(id, sparkSession, Option.empty(), encoder); + } + + private void send(List records, MemoryStream stream) { + stream.addData(JavaConverters.asScalaBuffer(records)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java new file mode 100644 index 000000000000..3957872be721 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java @@ -0,0 +1,1217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.expressions.Expressions.ref; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.DataOperations; +import org.apache.iceberg.DeleteFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Files; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.RewriteFiles; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.data.FileHelpers; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Iterables; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadConf; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.api.java.function.VoidFunction2; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Encoders; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.connector.read.InputPartition; +import org.apache.spark.sql.connector.read.streaming.Offset; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.streaming.DataStreamWriter; +import org.apache.spark.sql.streaming.OutputMode; +import org.apache.spark.sql.streaming.StreamingQuery; +import org.apache.spark.sql.streaming.Trigger; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public final class TestStructuredStreamingRead3 extends CatalogTestBase { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, async = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + false + }, + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + true + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + false + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + true + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + false + }, + { + SparkCatalogConfig.REST.catalogName(), + SparkCatalogConfig.REST.implementation(), + ImmutableMap.builder() + .putAll(SparkCatalogConfig.REST.properties()) + .put(CatalogProperties.URI, restCatalog.properties().get(CatalogProperties.URI)) + .build(), + true + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + false + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + true + } + }; + } + + private Table table; + + private final AtomicInteger microBatches = new AtomicInteger(); + + @Parameter(index = 3) + private Boolean async; + + /** + * test data to be used by multiple writes each write creates a snapshot and writes a list of + * records + */ + private static final List> TEST_DATA_MULTIPLE_SNAPSHOTS = + Lists.newArrayList( + Lists.newArrayList( + new SimpleRecord(1, "one"), new SimpleRecord(2, "two"), new SimpleRecord(3, "three")), + Lists.newArrayList(new SimpleRecord(4, "four"), new SimpleRecord(5, "five")), + Lists.newArrayList(new SimpleRecord(6, "six"), new SimpleRecord(7, "seven"))); + + /** + * test data - to be used for multiple write batches each batch inturn will have multiple + * snapshots + */ + private static final List>> TEST_DATA_MULTIPLE_WRITES_MULTIPLE_SNAPSHOTS = + Lists.newArrayList( + Lists.newArrayList( + Lists.newArrayList( + new SimpleRecord(1, "one"), + new SimpleRecord(2, "two"), + new SimpleRecord(3, "three")), + Lists.newArrayList(new SimpleRecord(4, "four"), new SimpleRecord(5, "five"))), + Lists.newArrayList( + Lists.newArrayList(new SimpleRecord(6, "six"), new SimpleRecord(7, "seven")), + Lists.newArrayList(new SimpleRecord(8, "eight"), new SimpleRecord(9, "nine"))), + Lists.newArrayList( + Lists.newArrayList( + new SimpleRecord(10, "ten"), + new SimpleRecord(11, "eleven"), + new SimpleRecord(12, "twelve")), + Lists.newArrayList( + new SimpleRecord(13, "thirteen"), new SimpleRecord(14, "fourteen")), + Lists.newArrayList( + new SimpleRecord(15, "fifteen"), new SimpleRecord(16, "sixteen")))); + + @BeforeAll + public static void setupSpark() { + // disable AQE as tests assume that writes generate a particular number of files + spark.conf().set(SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), "false"); + } + + @BeforeEach + public void setupTable() { + sql( + "CREATE TABLE %s " + + "(id INT, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (bucket(3, id)) " + + "TBLPROPERTIES ('commit.manifest.min-count-to-merge'='3', 'commit.manifest-merge.enabled'='true')", + tableName); + this.table = validationCatalog.loadTable(tableIdent); + microBatches.set(0); + } + + @AfterEach + public void stopStreams() throws TimeoutException { + for (StreamingQuery query : spark.streams().active()) { + query.stop(); + } + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testReadStreamOnIcebergTableWithMultipleSnapshots() throws Exception { + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + StreamingQuery query = startStream(); + + List actual = rowsAvailable(query); + assertThat(actual).containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + } + + @TestTemplate + public void testReadStreamWithMaxFiles1() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L)); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L), + Trigger.AvailableNow()); + } + + @TestTemplate + public void testReadStreamWithMaxFiles2() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "2"), + List.of(3L, 2L, 2L)); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "2"), + List.of(3L, 2L, 2L), + Trigger.AvailableNow()); + } + + @TestTemplate + public void testReadStreamWithMaxRows1() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L)); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L), + Trigger.AvailableNow()); + + // soft limit of 1 is being enforced, the stream is not blocked. + StreamingQuery query = startStream(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "1"); + + // check answer correctness only 1 record read the micro-batch will be stuck + List actual = rowsAvailable(query); + assertThat(actual) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(TEST_DATA_MULTIPLE_SNAPSHOTS)); + } + + @TestTemplate + public void testReadStreamWithMaxRows2() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "2"), + List.of(3L, 2L, 2L)); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "2"), + List.of(3L, 2L, 2L), + Trigger.AvailableNow()); + + StreamingQuery query = + startStream(ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "2")); + + List actual = rowsAvailable(query); + assertThat(actual) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(TEST_DATA_MULTIPLE_SNAPSHOTS)); + } + + @TestTemplate + public void testReadStreamWithMaxRows4() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "4"), List.of(4L, 3L)); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "4"), + List.of(4L, 3L), + Trigger.AvailableNow()); + } + + @TestTemplate + public void testReadStreamWithCompositeReadLimit() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + assertMicroBatchRecordSizes( + ImmutableMap.of( + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1", + SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "2"), + List.of(1L, 2L, 1L, 1L, 1L, 1L)); + + assertMicroBatchRecordSizes( + ImmutableMap.of( + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1", + SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "2"), + List.of(1L, 2L, 1L, 1L, 1L, 1L), + Trigger.AvailableNow()); + } + + @TestTemplate + public void testReadStreamWithLowAsyncQueuePreload() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + // Set low preload limits to test async queue behavior - background thread should load + // remaining data + StreamingQuery query = + startStream( + ImmutableMap.of( + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "5", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "5")); + + List actual = rowsAvailable(query); + assertThat(actual) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(TEST_DATA_MULTIPLE_SNAPSHOTS)); + } + + @TestTemplate + public void testAvailableNowStreamReadShouldNotHangOrReprocessData() throws Exception { + File writerCheckpointFolder = temp.resolve("writer-checkpoint-folder").toFile(); + File writerCheckpoint = new File(writerCheckpointFolder, "writer-checkpoint"); + File output = temp.resolve("junit").toFile(); + + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + DataStreamWriter querySource = + spark + .readStream() + .options(options) + .format("iceberg") + .load(tableName) + .writeStream() + .option("checkpointLocation", writerCheckpoint.toString()) + .format("parquet") + .trigger(Trigger.AvailableNow()) + .option("path", output.getPath()); + + List expected = Lists.newArrayList(); + for (List> expectedCheckpoint : + TEST_DATA_MULTIPLE_WRITES_MULTIPLE_SNAPSHOTS) { + + // New data was added while the stream was not running + appendDataAsMultipleSnapshots(expectedCheckpoint); + expected.addAll(Lists.newArrayList(Iterables.concat(Iterables.concat(expectedCheckpoint)))); + + try { + StreamingQuery query = querySource.start(); + + // Query should terminate on its own after processing all available data + assertThat(query.awaitTermination(60000)).isTrue(); + + // Check output + List actual = + spark + .read() + .load(output.getPath()) + .as(Encoders.bean(SimpleRecord.class)) + .collectAsList(); + assertThat(actual).containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + + // Restarting immediately should not reprocess data + query = querySource.start(); + assertThat(query.awaitTermination(60000)).isTrue(); + assertThat(query.recentProgress().length).isEqualTo(1); + assertThat(query.recentProgress()[0].sources()[0].startOffset()) + .isEqualTo(query.recentProgress()[0].sources()[0].endOffset()); + } finally { + stopStreams(); + } + } + } + + @TestTemplate + public void testTriggerAvailableNowDoesNotProcessNewDataWhileRunning() throws Exception { + List> expectedData = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expectedData); + + long expectedRecordCount = expectedData.stream().mapToLong(List::size).sum(); + + table.refresh(); + long expectedSnapshotId = table.currentSnapshot().snapshotId(); + + String sinkTable = "availablenow_sink"; + Map options = Maps.newHashMap(); + options.put(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"); + options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + StreamingQuery query = + spark + .readStream() + .options(options) + .format("iceberg") + .load(tableName) + .writeStream() + .format("memory") + .queryName(sinkTable) + .trigger(Trigger.AvailableNow()) + .start(); + + assertThat(query.isActive()).isTrue(); + + // Add new data while the stream is running + List newDataDuringStreamSnap1 = + Lists.newArrayList( + new SimpleRecord(100, "hundred"), + new SimpleRecord(101, "hundred-one"), + new SimpleRecord(102, "hundred-two")); + List newDataDuringStreamSnap2 = + Lists.newArrayList( + new SimpleRecord(200, "two-hundred"), new SimpleRecord(201, "two-hundred-one")); + appendData(newDataDuringStreamSnap1); + appendData(newDataDuringStreamSnap2); + + // Query should terminate on its own after processing all available data till expectedSnapshotId + assertThat(query.awaitTermination(60000)).isTrue(); + + List actualResults = + spark + .sql("SELECT * FROM " + sinkTable) + .as(Encoders.bean(SimpleRecord.class)) + .collectAsList(); + long endOffsetSnapshotId = + StreamingOffset.fromJson(query.lastProgress().sources()[0].endOffset()).snapshotId(); + + // Verify the stream processed only up to the snapshot present when started + assertThat(expectedSnapshotId).isEqualTo(endOffsetSnapshotId); + + // Verify only the initial data was processed + assertThat(actualResults.size()).isEqualTo(expectedRecordCount); + assertThat(actualResults).containsExactlyInAnyOrderElementsOf(Iterables.concat(expectedData)); + } + + @TestTemplate + public void testTriggerAvailableNowCapsAsyncPreloadAfterPrepare() { + List> initialData = + List.of(List.of(new SimpleRecord(1, "one")), List.of(new SimpleRecord(2, "two"))); + appendDataAsMultipleSnapshots(initialData); + + table.refresh(); + long expectedCapSnapshotId = table.currentSnapshot().snapshotId(); + + SparkMicroBatchStream stream = + new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf( + spark, + table, + new CaseInsensitiveStringMap( + ImmutableMap.of( + SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, + async.toString(), + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, + "1", + SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, + "1", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_FILE_LIMIT, + "10", + SparkReadOptions.ASYNC_QUEUE_PRELOAD_ROW_LIMIT, + "10"))), + table.schema(), + temp.resolve("available-now-cap-checkpoint").toString()); + + try { + stream.prepareForTriggerAvailableNow(); + + appendData(List.of(new SimpleRecord(3, "three"))); + + Offset startOffset = stream.initialOffset(); + Offset firstEndOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + assertThat(firstEndOffset).isNotNull(); + stream.planInputPartitions(startOffset, firstEndOffset); + + Offset secondEndOffset = stream.latestOffset(firstEndOffset, stream.getDefaultReadLimit()); + assertThat(secondEndOffset).isNotNull(); + stream.planInputPartitions(firstEndOffset, secondEndOffset); + + assertThat(stream.latestOffset(secondEndOffset, stream.getDefaultReadLimit())).isNull(); + assertThat(((StreamingOffset) secondEndOffset).snapshotId()).isEqualTo(expectedCapSnapshotId); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testLatestOffsetReturnsNullAfterFinalBatchIsConsumed() throws Exception { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + table.refresh(); + int expectedBatchCount; + try (CloseableIterable tasks = table.newScan().planFiles()) { + expectedBatchCount = Iterables.size(tasks); + } + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "drain-to-null-checkpoint"); + + try { + int plannedBatchCount = 0; + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + InputPartition[] partitions = stream.planInputPartitions(startOffset, endOffset); + assertThat(partitions).isNotEmpty(); + plannedBatchCount += 1; + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + assertThat(plannedBatchCount).isEqualTo(expectedBatchCount); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testPlanInputPartitionsIsIdempotentForSameOffsets() { + appendDataAsMultipleSnapshots(TEST_DATA_MULTIPLE_SNAPSHOTS); + + SparkMicroBatchStream stream = + newMicroBatchStream( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + "idempotent-plan-files-checkpoint"); + + try { + Offset startOffset = stream.initialOffset(); + Offset endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + + assertThat(endOffset).isNotNull(); + + InputPartition[] firstPartitions = stream.planInputPartitions(startOffset, endOffset); + InputPartition[] secondPartitions = stream.planInputPartitions(startOffset, endOffset); + + List firstFileLocations = Lists.newArrayList(); + for (InputPartition partition : firstPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + firstFileLocations.add(task.file().location()); + } + } + + List secondFileLocations = Lists.newArrayList(); + for (InputPartition partition : secondPartitions) { + SparkInputPartition sparkInputPartition = (SparkInputPartition) partition; + for (FileScanTask task : sparkInputPartition.taskGroup().tasks()) { + secondFileLocations.add(task.file().location()); + } + } + + assertThat(firstFileLocations).containsExactlyInAnyOrderElementsOf(secondFileLocations); + + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + while (endOffset != null) { + assertThat(stream.planInputPartitions(startOffset, endOffset)).isNotEmpty(); + startOffset = endOffset; + endOffset = stream.latestOffset(startOffset, stream.getDefaultReadLimit()); + } + + assertThat(endOffset).isNull(); + } finally { + stream.stop(); + } + } + + @TestTemplate + public void testReadStreamOnIcebergThenAddData() throws Exception { + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + + StreamingQuery query = startStream(); + + appendDataAsMultipleSnapshots(expected); + + List actual = rowsAvailable(query); + assertThat(actual).containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + } + + @TestTemplate + public void testReadingStreamFromTimestamp() throws Exception { + List dataBeforeTimestamp = + Lists.newArrayList( + new SimpleRecord(-2, "minustwo"), + new SimpleRecord(-1, "minusone"), + new SimpleRecord(0, "zero")); + + appendData(dataBeforeTimestamp); + + table.refresh(); + long streamStartTimestamp = table.currentSnapshot().timestampMillis() + 1; + + StreamingQuery query = + startStream(SparkReadOptions.STREAM_FROM_TIMESTAMP, Long.toString(streamStartTimestamp)); + + List empty = rowsAvailable(query); + assertThat(empty).isEmpty(); + + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + List actual = rowsAvailable(query); + + assertThat(actual).containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + } + + @TestTemplate + public void testReadingStreamFromFutureTimetsamp() throws Exception { + long futureTimestamp = System.currentTimeMillis() + 10000; + + StreamingQuery query = + startStream(SparkReadOptions.STREAM_FROM_TIMESTAMP, Long.toString(futureTimestamp)); + + List actual = rowsAvailable(query); + assertThat(actual).isEmpty(); + + List data = + Lists.newArrayList( + new SimpleRecord(-2, "minustwo"), + new SimpleRecord(-1, "minusone"), + new SimpleRecord(0, "zero")); + + // Perform several inserts that should not show up because the fromTimestamp has not elapsed + IntStream.range(0, 3) + .forEach( + x -> { + appendData(data); + assertThat(rowsAvailable(query)).isEmpty(); + }); + + waitUntilAfter(futureTimestamp); + + // Data appended after the timestamp should appear + appendData(data); + // Allow async background thread to refresh, else test sometimes fails + Thread.sleep(50); + actual = rowsAvailable(query); + assertThat(actual).containsExactlyInAnyOrderElementsOf(data); + } + + @TestTemplate + public void testReadingStreamFromTimestampFutureWithExistingSnapshots() throws Exception { + List dataBeforeTimestamp = + Lists.newArrayList( + new SimpleRecord(1, "one"), new SimpleRecord(2, "two"), new SimpleRecord(3, "three")); + appendData(dataBeforeTimestamp); + + long streamStartTimestamp = System.currentTimeMillis() + 2000; + + // Start the stream with a future timestamp after the current snapshot + StreamingQuery query = + startStream(SparkReadOptions.STREAM_FROM_TIMESTAMP, Long.toString(streamStartTimestamp)); + List actual = rowsAvailable(query); + assertThat(actual).isEmpty(); + + // Stream should contain data added after the timestamp elapses + waitUntilAfter(streamStartTimestamp); + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + assertThat(rowsAvailable(query)) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + } + + @TestTemplate + public void testReadingStreamFromTimestampOfExistingSnapshot() throws Exception { + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + + // Create an existing snapshot with some data + appendData(expected.get(0)); + table.refresh(); + long firstSnapshotTime = table.currentSnapshot().timestampMillis(); + + // Start stream giving the first Snapshot's time as the start point + StreamingQuery stream = + startStream(SparkReadOptions.STREAM_FROM_TIMESTAMP, Long.toString(firstSnapshotTime)); + + // Append rest of expected data + for (int i = 1; i < expected.size(); i++) { + appendData(expected.get(i)); + } + + List actual = rowsAvailable(stream); + assertThat(actual).containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + } + + @TestTemplate + public void testReadingStreamWithExpiredSnapshotFromTimestamp() throws TimeoutException { + List firstSnapshotRecordList = Lists.newArrayList(new SimpleRecord(1, "one")); + + List secondSnapshotRecordList = Lists.newArrayList(new SimpleRecord(2, "two")); + + List thirdSnapshotRecordList = Lists.newArrayList(new SimpleRecord(3, "three")); + + List expectedRecordList = Lists.newArrayList(); + expectedRecordList.addAll(secondSnapshotRecordList); + expectedRecordList.addAll(thirdSnapshotRecordList); + + appendData(firstSnapshotRecordList); + table.refresh(); + long firstSnapshotid = table.currentSnapshot().snapshotId(); + long firstSnapshotCommitTime = table.currentSnapshot().timestampMillis(); + + appendData(secondSnapshotRecordList); + appendData(thirdSnapshotRecordList); + + table.expireSnapshots().expireSnapshotId(firstSnapshotid).commit(); + + StreamingQuery query = + startStream( + SparkReadOptions.STREAM_FROM_TIMESTAMP, String.valueOf(firstSnapshotCommitTime)); + List actual = rowsAvailable(query); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expectedRecordList); + } + + @TestTemplate + public void testResumingStreamReadFromCheckpoint() throws Exception { + File writerCheckpointFolder = temp.resolve("writer-checkpoint-folder").toFile(); + File writerCheckpoint = new File(writerCheckpointFolder, "writer-checkpoint"); + File output = temp.resolve("junit").toFile(); + + DataStreamWriter querySource = + spark + .readStream() + .format("iceberg") + .load(tableName) + .writeStream() + .option("checkpointLocation", writerCheckpoint.toString()) + .format("parquet") + .queryName("checkpoint_test") + .option("path", output.getPath()); + + StreamingQuery startQuery = querySource.start(); + startQuery.processAllAvailable(); + startQuery.stop(); + + List expected = Lists.newArrayList(); + for (List> expectedCheckpoint : + TEST_DATA_MULTIPLE_WRITES_MULTIPLE_SNAPSHOTS) { + // New data was added while the stream was down + appendDataAsMultipleSnapshots(expectedCheckpoint); + expected.addAll(Lists.newArrayList(Iterables.concat(Iterables.concat(expectedCheckpoint)))); + + // Stream starts up again from checkpoint read the newly added data and shut down + StreamingQuery restartedQuery = querySource.start(); + restartedQuery.processAllAvailable(); + restartedQuery.stop(); + + // Read data added by the stream + List actual = + spark.read().load(output.getPath()).as(Encoders.bean(SimpleRecord.class)).collectAsList(); + assertThat(actual).containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + } + } + + @TestTemplate + public void testFailReadingCheckpointInvalidSnapshot() throws IOException, TimeoutException { + File writerCheckpointFolder = temp.resolve("writer-checkpoint-folder").toFile(); + File writerCheckpoint = new File(writerCheckpointFolder, "writer-checkpoint"); + File output = temp.resolve("junit").toFile(); + + DataStreamWriter querySource = + spark + .readStream() + .format("iceberg") + .load(tableName) + .writeStream() + .option("checkpointLocation", writerCheckpoint.toString()) + .format("parquet") + .queryName("checkpoint_test") + .option("path", output.getPath()); + + List firstSnapshotRecordList = Lists.newArrayList(new SimpleRecord(1, "one")); + List secondSnapshotRecordList = Lists.newArrayList(new SimpleRecord(2, "two")); + StreamingQuery startQuery = querySource.start(); + + appendData(firstSnapshotRecordList); + table.refresh(); + long firstSnapshotid = table.currentSnapshot().snapshotId(); + startQuery.processAllAvailable(); + startQuery.stop(); + + appendData(secondSnapshotRecordList); + + table.expireSnapshots().expireSnapshotId(firstSnapshotid).commit(); + + StreamingQuery restartedQuery = querySource.start(); + assertThatThrownBy(restartedQuery::processAllAvailable) + .hasCauseInstanceOf(IllegalStateException.class) + .hasMessageContaining( + String.format( + "Cannot load current offset at snapshot %d, the snapshot was expired or removed", + firstSnapshotid)); + } + + @TestTemplate + public void testParquetOrcAvroDataInOneTable() throws Exception { + List parquetFileRecords = + Lists.newArrayList( + new SimpleRecord(1, "one"), new SimpleRecord(2, "two"), new SimpleRecord(3, "three")); + + List orcFileRecords = + Lists.newArrayList(new SimpleRecord(4, "four"), new SimpleRecord(5, "five")); + + List avroFileRecords = + Lists.newArrayList(new SimpleRecord(6, "six"), new SimpleRecord(7, "seven")); + + appendData(parquetFileRecords); + appendData(orcFileRecords, "orc"); + appendData(avroFileRecords, "avro"); + + StreamingQuery query = startStream(); + assertThat(rowsAvailable(query)) + .containsExactlyInAnyOrderElementsOf( + Iterables.concat(parquetFileRecords, orcFileRecords, avroFileRecords)); + } + + @TestTemplate + public void testReadStreamFromEmptyTable() throws Exception { + StreamingQuery stream = startStream(); + List actual = rowsAvailable(stream); + assertThat(actual).isEmpty(); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeOverwriteErrorsOut() throws Exception { + // upgrade table to version 2 - to facilitate creation of Snapshot of type OVERWRITE. + TableOperations ops = ((BaseTable) table).operations(); + TableMetadata meta = ops.current(); + ops.commit(meta, meta.upgradeToFormatVersion(2)); + + // fill table with some initial data + List> dataAcrossSnapshots = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(dataAcrossSnapshots); + + Schema deleteRowSchema = table.schema().select("data"); + Record dataDelete = GenericRecord.create(deleteRowSchema); + List dataDeletes = + Lists.newArrayList( + dataDelete.copy("data", "one") // id = 1 + ); + + DeleteFile eqDeletes = + FileHelpers.writeDeleteFile( + table, + Files.localOutput(File.createTempFile("junit", null, temp.toFile())), + TestHelpers.Row.of(0), + dataDeletes, + deleteRowSchema); + + DataFile dataFile = + DataFiles.builder(table.spec()) + .withPath(File.createTempFile("junit", null, temp.toFile()).getPath()) + .withFileSizeInBytes(10) + .withRecordCount(1) + .withFormat(FileFormat.PARQUET) + .build(); + + table.newRowDelta().addRows(dataFile).addDeletes(eqDeletes).commit(); + + // check pre-condition - that the above Delete file write - actually resulted in snapshot of + // type OVERWRITE + assertThat(table.currentSnapshot().operation()).isEqualTo(DataOperations.OVERWRITE); + + StreamingQuery query = startStream(); + + assertThatThrownBy(query::processAllAvailable) + .cause() + .isInstanceOf(IllegalStateException.class) + .hasMessageStartingWith("Cannot process overwrite snapshot"); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeRewriteDataFilesIgnoresReplace() throws Exception { + // fill table with some data + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + makeRewriteDataFiles(); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L)); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeRewriteDataFilesIgnoresReplaceMaxRows() + throws Exception { + // fill table with some data + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + makeRewriteDataFiles(); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, "4"), List.of(4L, 3L)); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeRewriteDataFilesIgnoresReplaceMaxFilesAndRows() + throws Exception { + // fill table with some data + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + makeRewriteDataFiles(); + + assertMicroBatchRecordSizes( + ImmutableMap.of( + SparkReadOptions.STREAMING_MAX_ROWS_PER_MICRO_BATCH, + "4", + SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, + "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L)); + } + + @TestTemplate + public void testReadStreamWithSnapshotType2RewriteDataFilesIgnoresReplace() throws Exception { + // fill table with some data + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + makeRewriteDataFiles(); + makeRewriteDataFiles(); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L)); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeRewriteDataFilesIgnoresReplaceFollowedByAppend() + throws Exception { + // fill table with some data + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + makeRewriteDataFiles(); + + appendDataAsMultipleSnapshots(expected); + + assertMicroBatchRecordSizes( + ImmutableMap.of(SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1"), + List.of(1L, 2L, 1L, 1L, 1L, 1L, 1L, 2L, 1L, 1L, 1L, 1L)); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeReplaceIgnoresReplace() throws Exception { + // fill table with some data + List> expected = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(expected); + + // this should create a snapshot with type Replace. + table.rewriteManifests().clusterBy(f -> 1).commit(); + + // check pre-condition + assertThat(table.currentSnapshot().operation()).isEqualTo(DataOperations.REPLACE); + + StreamingQuery query = startStream(); + List actual = rowsAvailable(query); + assertThat(actual).containsExactlyInAnyOrderElementsOf(Iterables.concat(expected)); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeDeleteErrorsOut() throws Exception { + table.updateSpec().removeField("id_bucket").addField(ref("id")).commit(); + + // fill table with some data + List> dataAcrossSnapshots = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(dataAcrossSnapshots); + + // this should create a snapshot with type delete. + table.newDelete().deleteFromRowFilter(Expressions.equal("id", 4)).commit(); + + // check pre-condition - that the above delete operation on table resulted in Snapshot of Type + // DELETE. + assertThat(table.currentSnapshot().operation()).isEqualTo(DataOperations.DELETE); + + StreamingQuery query = startStream(); + + assertThatThrownBy(query::processAllAvailable) + .cause() + .isInstanceOf(IllegalStateException.class) + .hasMessageStartingWith("Cannot process delete snapshot"); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeDeleteAndSkipDeleteOption() throws Exception { + table.updateSpec().removeField("id_bucket").addField(ref("id")).commit(); + + // fill table with some data + List> dataAcrossSnapshots = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(dataAcrossSnapshots); + + // this should create a snapshot with type delete. + table.newDelete().deleteFromRowFilter(Expressions.equal("id", 4)).commit(); + + // check pre-condition - that the above delete operation on table resulted in Snapshot of Type + // DELETE. + assertThat(table.currentSnapshot().operation()).isEqualTo(DataOperations.DELETE); + + StreamingQuery query = startStream(SparkReadOptions.STREAMING_SKIP_DELETE_SNAPSHOTS, "true"); + assertThat(rowsAvailable(query)) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(dataAcrossSnapshots)); + } + + @TestTemplate + public void testReadStreamWithSnapshotTypeDeleteAndSkipOverwriteOption() throws Exception { + table.updateSpec().removeField("id_bucket").addField(ref("id")).commit(); + + // fill table with some data + List> dataAcrossSnapshots = TEST_DATA_MULTIPLE_SNAPSHOTS; + appendDataAsMultipleSnapshots(dataAcrossSnapshots); + + DataFile dataFile = + DataFiles.builder(table.spec()) + .withPath(File.createTempFile("junit", null, temp.toFile()).getPath()) + .withFileSizeInBytes(10) + .withRecordCount(1) + .withFormat(FileFormat.PARQUET) + .build(); + + // this should create a snapshot with type overwrite. + table + .newOverwrite() + .addFile(dataFile) + .overwriteByRowFilter(Expressions.greaterThan("id", 4)) + .commit(); + + // check pre-condition - that the above delete operation on table resulted in Snapshot of Type + // OVERWRITE. + assertThat(table.currentSnapshot().operation()).isEqualTo(DataOperations.OVERWRITE); + + StreamingQuery query = startStream(SparkReadOptions.STREAMING_SKIP_OVERWRITE_SNAPSHOTS, "true"); + assertThat(rowsAvailable(query)) + .containsExactlyInAnyOrderElementsOf(Iterables.concat(dataAcrossSnapshots)); + } + + /** + * We are testing that all the files in a rewrite snapshot are skipped Create a rewrite data files + * snapshot using existing files. + */ + public void makeRewriteDataFiles() { + table.refresh(); + + // we are testing that all the files in a rewrite snapshot are skipped + // create a rewrite data files snapshot using existing files + RewriteFiles rewrite = table.newRewrite(); + Iterable it = table.snapshots(); + for (Snapshot snapshot : it) { + if (snapshot.operation().equals(DataOperations.APPEND)) { + Iterable datafiles = + SnapshotChanges.builderFor(table).snapshot(snapshot).build().addedDataFiles(); + for (DataFile datafile : datafiles) { + rewrite.addFile(datafile); + rewrite.deleteFile(datafile); + } + } + } + rewrite.commit(); + } + + /** + * appends each list as a Snapshot on the iceberg table at the given location. accepts a list of + * lists - each list representing data per snapshot. + */ + private void appendDataAsMultipleSnapshots(List> data) { + for (List l : data) { + appendData(l); + } + } + + private void appendData(List data) { + appendData(data, "parquet"); + } + + private void appendData(List data, String format) { + Dataset df = spark.createDataFrame(data, SimpleRecord.class); + df.select("id", "data") + .write() + .format("iceberg") + .option("write-format", format) + .mode("append") + .save(tableName); + } + + private static final String MEMORY_TABLE = "_stream_view_mem"; + + private StreamingQuery startStream(Map options) throws TimeoutException { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + return spark + .readStream() + .options(allOptions) + .format("iceberg") + .load(tableName) + .writeStream() + .options(allOptions) + .format("memory") + .queryName(MEMORY_TABLE) + .outputMode(OutputMode.Append()) + .start(); + } + + private StreamingQuery startStream() throws TimeoutException { + return startStream(Collections.emptyMap()); + } + + private StreamingQuery startStream(String key, String value) throws TimeoutException { + return startStream( + ImmutableMap.of(key, value, SparkReadOptions.STREAMING_MAX_FILES_PER_MICRO_BATCH, "1")); + } + + private void assertMicroBatchRecordSizes( + Map options, List expectedMicroBatchRecordSize) + throws TimeoutException { + assertMicroBatchRecordSizes(options, expectedMicroBatchRecordSize, Trigger.ProcessingTime(0L)); + } + + private void assertMicroBatchRecordSizes( + Map options, List expectedMicroBatchRecordSize, Trigger trigger) + throws TimeoutException { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + Dataset ds = spark.readStream().options(allOptions).format("iceberg").load(tableName); + + List syncList = Collections.synchronizedList(Lists.newArrayList()); + ds.writeStream() + .options(allOptions) + .trigger(trigger) + .foreachBatch( + (VoidFunction2, Long>) + (dataset, batchId) -> { + syncList.add(dataset.count()); + }) + .start() + .processAllAvailable(); + + stopStreams(); + assertThat(syncList).containsExactlyInAnyOrderElementsOf(expectedMicroBatchRecordSize); + } + + private List rowsAvailable(StreamingQuery query) { + query.processAllAvailable(); + return spark + .sql("select * from " + MEMORY_TABLE) + .as(Encoders.bean(SimpleRecord.class)) + .collectAsList(); + } + + private SparkMicroBatchStream newMicroBatchStream( + Map options, String checkpointDirName) { + Map allOptions = Maps.newHashMap(options); + allOptions.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, async.toString()); + if (async) { + allOptions.putIfAbsent(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, "1"); + } + + return new SparkMicroBatchStream( + JavaSparkContext.fromSparkContext(spark.sparkContext()), + table, + table::io, + new SparkReadConf(spark, table, new CaseInsensitiveStringMap(allOptions)), + table.schema(), + temp.resolve(checkpointDirName).toString()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestTables.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestTables.java new file mode 100644 index 000000000000..b54bb315c543 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestTables.java @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.io.File; +import java.util.Map; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.Files; +import org.apache.iceberg.LocationProviders; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Snapshot; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.exceptions.CommitFailedException; +import org.apache.iceberg.exceptions.RuntimeIOException; +import org.apache.iceberg.io.FileIO; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.LocationProvider; +import org.apache.iceberg.io.OutputFile; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; + +// TODO: Use the copy of this from core. +class TestTables { + private TestTables() {} + + static TestTable create(File temp, String name, Schema schema, PartitionSpec spec) { + return create(temp, name, schema, spec, ImmutableMap.of()); + } + + static TestTable create( + File temp, String name, Schema schema, PartitionSpec spec, Map properties) { + TestTableOperations ops = new TestTableOperations(name); + if (ops.current() != null) { + throw new AlreadyExistsException("Table %s already exists at location: %s", name, temp); + } + ops.commit(null, TableMetadata.newTableMetadata(schema, spec, temp.toString(), properties)); + return new TestTable(ops, name); + } + + static TestTable load(String name) { + TestTableOperations ops = new TestTableOperations(name); + if (ops.current() == null) { + return null; + } + return new TestTable(ops, name); + } + + static boolean drop(String name) { + synchronized (METADATA) { + return METADATA.remove(name) != null; + } + } + + static class TestTable extends BaseTable { + private final TestTableOperations ops; + + private TestTable(TestTableOperations ops, String name) { + super(ops, name); + this.ops = ops; + } + + @Override + public TestTableOperations operations() { + return ops; + } + } + + private static final Map METADATA = Maps.newHashMap(); + + static void clearTables() { + synchronized (METADATA) { + METADATA.clear(); + } + } + + static TableMetadata readMetadata(String tableName) { + synchronized (METADATA) { + return METADATA.get(tableName); + } + } + + static void replaceMetadata(String tableName, TableMetadata metadata) { + synchronized (METADATA) { + METADATA.put(tableName, metadata); + } + } + + static class TestTableOperations implements TableOperations { + + private final String tableName; + private TableMetadata current = null; + private long lastSnapshotId = 0; + private int failCommits = 0; + + TestTableOperations(String tableName) { + this.tableName = tableName; + refresh(); + if (current != null) { + for (Snapshot snap : current.snapshots()) { + this.lastSnapshotId = Math.max(lastSnapshotId, snap.snapshotId()); + } + } else { + this.lastSnapshotId = 0; + } + } + + void failCommits(int numFailures) { + this.failCommits = numFailures; + } + + @Override + public TableMetadata current() { + return current; + } + + @Override + public TableMetadata refresh() { + synchronized (METADATA) { + this.current = METADATA.get(tableName); + } + return current; + } + + @Override + public void commit(TableMetadata base, TableMetadata metadata) { + if (base != current) { + throw new CommitFailedException("Cannot commit changes based on stale metadata"); + } + synchronized (METADATA) { + refresh(); + if (base == current) { + if (failCommits > 0) { + this.failCommits -= 1; + throw new CommitFailedException("Injected failure"); + } + METADATA.put(tableName, metadata); + this.current = metadata; + } else { + throw new CommitFailedException( + "Commit failed: table was updated at %d", base.lastUpdatedMillis()); + } + } + } + + @Override + public FileIO io() { + return new LocalFileIO(); + } + + @Override + public LocationProvider locationProvider() { + Preconditions.checkNotNull( + current, "Current metadata should not be null when locationProvider is called"); + return LocationProviders.locationsFor(current.location(), current.properties()); + } + + @Override + public String metadataFileLocation(String fileName) { + return new File(new File(current.location(), "metadata"), fileName).getAbsolutePath(); + } + + @Override + public long newSnapshotId() { + long nextSnapshotId = lastSnapshotId + 1; + this.lastSnapshotId = nextSnapshotId; + return nextSnapshotId; + } + } + + static class LocalFileIO implements FileIO { + + @Override + public InputFile newInputFile(String path) { + return Files.localInput(path); + } + + @Override + public OutputFile newOutputFile(String path) { + return Files.localOutput(new File(path)); + } + + @Override + public void deleteFile(String path) { + if (!new File(path).delete()) { + throw new RuntimeIOException("Failed to delete file: " + path); + } + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestTimestampWithoutZone.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestTimestampWithoutZone.java new file mode 100644 index 000000000000..79781a8fc314 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestTimestampWithoutZone.java @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.Files.localOutput; +import static org.apache.iceberg.data.FileHelpers.encrypt; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileFormat; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.data.GenericFileWriterFactory; +import org.apache.iceberg.data.GenericRecord; +import org.apache.iceberg.data.Record; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.io.DataWriter; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.GenericsHelpers; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestTimestampWithoutZone extends TestBase { + private static final Configuration CONF = new Configuration(); + private static final HadoopTables TABLES = new HadoopTables(CONF); + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "ts", Types.TimestampType.withoutZone()), + Types.NestedField.optional(3, "data", Types.StringType.get())); + + private static SparkSession spark = null; + + @BeforeAll + public static void startSpark() { + TestTimestampWithoutZone.spark = SparkSession.builder().master("local[2]").getOrCreate(); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestTimestampWithoutZone.spark; + TestTimestampWithoutZone.spark = null; + currentSpark.stop(); + } + + @TempDir private Path temp; + + @Parameter(index = 0) + private FileFormat fileFormat; + + @Parameter(index = 1) + private boolean vectorized; + + @Parameters(name = "format = {0}, vectorized = {1}") + public static Object[][] parameters() { + return new Object[][] { + {FileFormat.PARQUET, false}, + {FileFormat.PARQUET, true}, + {FileFormat.AVRO, false} + }; + } + + private File parent = null; + private File unpartitioned = null; + private List records = null; + + @BeforeEach + public void writeUnpartitionedTable() throws IOException { + this.parent = temp.resolve("TestTimestampWithoutZone").toFile(); + this.unpartitioned = new File(parent, "unpartitioned"); + File dataFolder = new File(unpartitioned, "data"); + assertThat(dataFolder.mkdirs()).as("Mkdir should succeed").isTrue(); + + Table table = TABLES.create(SCHEMA, PartitionSpec.unpartitioned(), unpartitioned.toString()); + Schema tableSchema = table.schema(); // use the table schema because ids are reassigned + + File testFile = new File(dataFolder, fileFormat.addExtension(UUID.randomUUID().toString())); + + // create records using the table's schema + this.records = testRecords(tableSchema); + + DataWriter writer = + new GenericFileWriterFactory.Builder(table) + .dataFileFormat(fileFormat) + .build() + .newDataWriter(encrypt(localOutput(testFile)), PartitionSpec.unpartitioned(), null); + try (writer) { + writer.write(records); + } + + DataFile file = writer.toDataFile(); + + table.newAppend().appendFile(file).commit(); + } + + @TestTemplate + public void testUnpartitionedTimestampWithoutZone() { + assertEqualsSafe(SCHEMA.asStruct(), records, read(unpartitioned.toString(), vectorized)); + } + + @TestTemplate + public void testUnpartitionedTimestampWithoutZoneProjection() { + Schema projection = SCHEMA.select("id", "ts"); + assertEqualsSafe( + projection.asStruct(), + records.stream().map(r -> projectFlat(projection, r)).collect(Collectors.toList()), + read(unpartitioned.toString(), vectorized, "id", "ts")); + } + + @TestTemplate + public void testUnpartitionedTimestampWithoutZoneAppend() { + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(unpartitioned.toString()) + .write() + .format("iceberg") + .mode(SaveMode.Append) + .save(unpartitioned.toString()); + + assertEqualsSafe( + SCHEMA.asStruct(), + Stream.concat(records.stream(), records.stream()).collect(Collectors.toList()), + read(unpartitioned.toString(), vectorized)); + } + + private static Record projectFlat(Schema projection, Record record) { + Record result = GenericRecord.create(projection); + List fields = projection.asStruct().fields(); + for (int i = 0; i < fields.size(); i += 1) { + Types.NestedField field = fields.get(i); + result.set(i, record.getField(field.name())); + } + return result; + } + + public static void assertEqualsSafe( + Types.StructType struct, List expected, List actual) { + assertThat(actual).as("Number of results should match expected").hasSameSizeAs(expected); + for (int i = 0; i < expected.size(); i += 1) { + GenericsHelpers.assertEqualsSafe(struct, expected.get(i), actual.get(i)); + } + } + + private List testRecords(Schema schema) { + return Lists.newArrayList( + record(schema, 0L, parseToLocal("2017-12-22T09:20:44.294658"), "junction"), + record(schema, 1L, parseToLocal("2017-12-22T07:15:34.582910"), "alligator"), + record(schema, 2L, parseToLocal("2017-12-22T06:02:09.243857"), "forrest"), + record(schema, 3L, parseToLocal("2017-12-22T03:10:11.134509"), "clapping"), + record(schema, 4L, parseToLocal("2017-12-22T00:34:00.184671"), "brush"), + record(schema, 5L, parseToLocal("2017-12-21T22:20:08.935889"), "trap"), + record(schema, 6L, parseToLocal("2017-12-21T21:55:30.589712"), "element"), + record(schema, 7L, parseToLocal("2017-12-21T17:31:14.532797"), "limited"), + record(schema, 8L, parseToLocal("2017-12-21T15:21:51.237521"), "global"), + record(schema, 9L, parseToLocal("2017-12-21T15:02:15.230570"), "goldfish")); + } + + private static List read(String table, boolean vectorized) { + return read(table, vectorized, "*"); + } + + private static List read( + String table, boolean vectorized, String select0, String... selectN) { + Dataset dataset = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VECTORIZATION_ENABLED, String.valueOf(vectorized)) + .load(table) + .select(select0, selectN); + return dataset.collectAsList(); + } + + private static LocalDateTime parseToLocal(String timestamp) { + return LocalDateTime.parse(timestamp); + } + + private static Record record(Schema schema, Object... values) { + Record rec = GenericRecord.create(schema); + for (int i = 0; i < values.length; i += 1) { + rec.set(i, values[i]); + } + return rec; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java new file mode 100644 index 000000000000..ab2479d61058 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestWriteMetricsConfig.java @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import static org.apache.iceberg.spark.SparkSchemaUtil.convert; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.apache.iceberg.types.Types.NestedField.required; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.hadoop.HadoopTables; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.InternalRow; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class TestWriteMetricsConfig { + + private static final Configuration CONF = new Configuration(); + private static final Schema SIMPLE_SCHEMA = + new Schema( + optional(1, "id", Types.IntegerType.get()), optional(2, "data", Types.StringType.get())); + private static final Schema COMPLEX_SCHEMA = + new Schema( + required(1, "longCol", Types.IntegerType.get()), + optional(2, "strCol", Types.StringType.get()), + required( + 3, + "record", + Types.StructType.of( + required(4, "id", Types.IntegerType.get()), + required(5, "data", Types.StringType.get())))); + + @TempDir private Path temp; + + private static SparkSession spark = null; + private static JavaSparkContext sc = null; + + @BeforeAll + public static void startSpark() { + TestWriteMetricsConfig.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(TestBase.DISABLE_UI) + .getOrCreate(); + TestWriteMetricsConfig.sc = JavaSparkContext.fromSparkContext(spark.sparkContext()); + } + + @AfterAll + public static void stopSpark() { + SparkSession currentSpark = TestWriteMetricsConfig.spark; + TestWriteMetricsConfig.spark = null; + TestWriteMetricsConfig.sc = null; + currentSpark.stop(); + } + + @Test + public void testFullMetricsCollectionForParquet() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); + Table table = tables.create(SIMPLE_SCHEMA, spec, properties, tableLocation); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(expectedRecords, SimpleRecord.class); + df.select("id", "data") + .coalesce(1) + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, "parquet") + .mode(SaveMode.Append) + .save(tableLocation); + + for (FileScanTask task : table.newScan().includeColumnStats().planFiles()) { + DataFile file = task.file(); + assertThat(file.nullValueCounts()).hasSize(2); + assertThat(file.valueCounts()).hasSize(2); + assertThat(file.lowerBounds()).hasSize(2); + assertThat(file.upperBounds()).hasSize(2); + } + } + + @Test + public void testCountMetricsCollectionForParquet() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, "counts"); + Table table = tables.create(SIMPLE_SCHEMA, spec, properties, tableLocation); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(expectedRecords, SimpleRecord.class); + df.select("id", "data") + .coalesce(1) + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, "parquet") + .mode(SaveMode.Append) + .save(tableLocation); + + for (FileScanTask task : table.newScan().includeColumnStats().planFiles()) { + DataFile file = task.file(); + assertThat(file.nullValueCounts()).hasSize(2); + assertThat(file.valueCounts()).hasSize(2); + assertThat(file.lowerBounds()).isEmpty(); + assertThat(file.upperBounds()).isEmpty(); + } + } + + @Test + public void testNoMetricsCollectionForParquet() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, "none"); + Table table = tables.create(SIMPLE_SCHEMA, spec, properties, tableLocation); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(expectedRecords, SimpleRecord.class); + df.select("id", "data") + .coalesce(1) + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, "parquet") + .mode(SaveMode.Append) + .save(tableLocation); + + for (FileScanTask task : table.newScan().includeColumnStats().planFiles()) { + DataFile file = task.file(); + assertThat(file.nullValueCounts()).isEmpty(); + assertThat(file.valueCounts()).isEmpty(); + assertThat(file.lowerBounds()).isEmpty(); + assertThat(file.upperBounds()).isEmpty(); + } + } + + @Test + public void testCustomMetricCollectionForParquet() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, "counts"); + properties.put("write.metadata.metrics.column.id", "full"); + Table table = tables.create(SIMPLE_SCHEMA, spec, properties, tableLocation); + + List expectedRecords = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(expectedRecords, SimpleRecord.class); + df.select("id", "data") + .coalesce(1) + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, "parquet") + .mode(SaveMode.Append) + .save(tableLocation); + + Schema schema = table.schema(); + Types.NestedField id = schema.findField("id"); + for (FileScanTask task : table.newScan().includeColumnStats().planFiles()) { + DataFile file = task.file(); + assertThat(file.nullValueCounts()).hasSize(2); + assertThat(file.valueCounts()).hasSize(2); + assertThat(file.lowerBounds()).hasSize(1).containsKey(id.fieldId()); + assertThat(file.upperBounds()).hasSize(1).containsKey(id.fieldId()); + } + } + + @Test + public void testBadCustomMetricCollectionForParquet() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.unpartitioned(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, "counts"); + properties.put("write.metadata.metrics.column.ids", "full"); + + assertThatThrownBy(() -> tables.create(SIMPLE_SCHEMA, spec, properties, tableLocation)) + .isInstanceOf(ValidationException.class) + .hasMessageStartingWith( + "Invalid metrics config, could not find column ids from table prop write.metadata.metrics.column.ids in schema table"); + } + + @Test + public void testCustomMetricCollectionForNestedParquet() { + String tableLocation = temp.resolve("iceberg-table").toFile().toString(); + + HadoopTables tables = new HadoopTables(CONF); + PartitionSpec spec = PartitionSpec.builderFor(COMPLEX_SCHEMA).identity("strCol").build(); + Map properties = Maps.newHashMap(); + properties.put(TableProperties.DEFAULT_WRITE_METRICS_MODE, "none"); + properties.put("write.metadata.metrics.column.longCol", "counts"); + properties.put("write.metadata.metrics.column.record.id", "full"); + properties.put("write.metadata.metrics.column.record.data", "truncate(2)"); + Table table = tables.create(COMPLEX_SCHEMA, spec, properties, tableLocation); + + Iterable rows = RandomData.generateSpark(COMPLEX_SCHEMA, 10, 0); + JavaRDD rdd = sc.parallelize(Lists.newArrayList(rows)); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + Dataset df = + ((org.apache.spark.sql.classic.SparkSession) spark) + .internalCreateDataFrame(JavaRDD.toRDD(rdd), convert(COMPLEX_SCHEMA), false); + + df.coalesce(1) + .write() + .format("iceberg") + .option(SparkWriteOptions.WRITE_FORMAT, "parquet") + .mode(SaveMode.Append) + .save(tableLocation); + + Schema schema = table.schema(); + Types.NestedField longCol = schema.findField("longCol"); + Types.NestedField recordId = schema.findField("record.id"); + Types.NestedField recordData = schema.findField("record.data"); + for (FileScanTask task : table.newScan().includeColumnStats().planFiles()) { + DataFile file = task.file(); + + Map nullValueCounts = file.nullValueCounts(); + assertThat(nullValueCounts) + .hasSize(3) + .containsKeys(longCol.fieldId(), recordId.fieldId(), recordData.fieldId()); + + Map valueCounts = file.valueCounts(); + assertThat(valueCounts) + .hasSize(3) + .containsKeys(longCol.fieldId(), recordId.fieldId(), recordData.fieldId()); + + Map lowerBounds = file.lowerBounds(); + assertThat(lowerBounds).hasSize(2).containsKey(recordId.fieldId()); + + ByteBuffer recordDataLowerBound = lowerBounds.get(recordData.fieldId()); + assertThat(ByteBuffers.toByteArray(recordDataLowerBound)).hasSize(2); + + Map upperBounds = file.upperBounds(); + assertThat(upperBounds).hasSize(2).containsKey(recordId.fieldId()); + + ByteBuffer recordDataUpperBound = upperBounds.get(recordData.fieldId()); + assertThat(ByteBuffers.toByteArray(recordDataUpperBound)).hasSize(2); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ThreeColumnRecord.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ThreeColumnRecord.java new file mode 100644 index 000000000000..554557df416c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/ThreeColumnRecord.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.source; + +import java.util.Objects; + +public class ThreeColumnRecord { + private Integer c1; + private String c2; + private String c3; + + public ThreeColumnRecord() {} + + public ThreeColumnRecord(Integer c1, String c2, String c3) { + this.c1 = c1; + this.c2 = c2; + this.c3 = c3; + } + + public Integer getC1() { + return c1; + } + + public void setC1(Integer c1) { + this.c1 = c1; + } + + public String getC2() { + return c2; + } + + public void setC2(String c2) { + this.c2 = c2; + } + + public String getC3() { + return c3; + } + + public void setC3(String c3) { + this.c3 = c3; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ThreeColumnRecord that = (ThreeColumnRecord) o; + return Objects.equals(c1, that.c1) + && Objects.equals(c2, that.c2) + && Objects.equals(c3, that.c3); + } + + @Override + public int hashCode() { + return Objects.hash(c1, c2, c3); + } + + @Override + public String toString() { + return "ThreeColumnRecord{" + "c1=" + c1 + ", c2='" + c2 + '\'' + ", c3='" + c3 + '\'' + '}'; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/PartitionedWritesTestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/PartitionedWritesTestBase.java new file mode 100644 index 000000000000..c2fa8dc0ce24 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/PartitionedWritesTestBase.java @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.functions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class PartitionedWritesTestBase extends CatalogTestBase { + + @BeforeEach + public void createTables() { + sql( + "CREATE TABLE %s (id bigint, data string) USING iceberg PARTITIONED BY (truncate(id, 3))", + tableName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testInsertAppend() { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Rows before insert") + .isEqualTo(3L); + + sql("INSERT INTO %s VALUES (4, 'd'), (5, 'e')", commitTarget()); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 5 rows after insert") + .isEqualTo(5L); + + List expected = + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c"), row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testInsertOverwrite() { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Rows before overwrite") + .isEqualTo(3L); + + // 4 and 5 replace 3 in the partition (id - (id % 3)) = 3 + sql("INSERT OVERWRITE %s VALUES (4, 'd'), (5, 'e')", commitTarget()); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 4 rows after overwrite") + .isEqualTo(4L); + + List expected = + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDataFrameV2Append() throws NoSuchTableException { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + List data = ImmutableList.of(new SimpleRecord(4, "d"), new SimpleRecord(5, "e")); + Dataset ds = spark.createDataFrame(data, SimpleRecord.class); + + ds.writeTo(commitTarget()).append(); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 5 rows after insert") + .isEqualTo(5L); + + List expected = + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c"), row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDataFrameV2DynamicOverwrite() throws NoSuchTableException { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + List data = ImmutableList.of(new SimpleRecord(4, "d"), new SimpleRecord(5, "e")); + Dataset ds = spark.createDataFrame(data, SimpleRecord.class); + + ds.writeTo(commitTarget()).overwritePartitions(); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 4 rows after overwrite") + .isEqualTo(4L); + + List expected = + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDataFrameV2Overwrite() throws NoSuchTableException { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + List data = ImmutableList.of(new SimpleRecord(4, "d"), new SimpleRecord(5, "e")); + Dataset ds = spark.createDataFrame(data, SimpleRecord.class); + + ds.writeTo(commitTarget()).overwrite(functions.col("id").$less(3)); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows after overwrite") + .isEqualTo(3L); + + List expected = ImmutableList.of(row(3L, "c"), row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testViewsReturnRecentResults() { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + Dataset query = spark.sql("SELECT * FROM " + commitTarget() + " WHERE id = 1"); + query.createOrReplaceTempView("tmp"); + + assertEquals( + "View should have expected rows", ImmutableList.of(row(1L, "a")), sql("SELECT * FROM tmp")); + + sql("INSERT INTO TABLE %s VALUES (1, 'a')", commitTarget()); + + assertEquals( + "View should have expected rows", + ImmutableList.of(row(1L, "a"), row(1L, "a")), + sql("SELECT * FROM tmp")); + } + + // Asserts whether the given table .partitions table has the expected rows. Note that the output + // row should have spec_id and it is sorted by spec_id and selectPartitionColumns. + protected void assertPartitionMetadata( + String tableName, List expected, String... selectPartitionColumns) { + String[] fullyQualifiedCols = + Arrays.stream(selectPartitionColumns).map(s -> "partition." + s).toArray(String[]::new); + Dataset actualPartitionRows = + spark + .read() + .format("iceberg") + .load(tableName + ".partitions") + .select("spec_id", fullyQualifiedCols) + .orderBy("spec_id", fullyQualifiedCols); + + assertEquals( + "There are 3 partitions, one with the original spec ID and two with the new one", + expected, + rowsToJava(actualPartitionRows.collectAsList())); + } + + @TestTemplate + public void testWriteWithOutputSpec() throws NoSuchTableException { + Table table = validationCatalog.loadTable(tableIdent); + + // Drop all records in table to have a fresh start. + table.newDelete().deleteFromRowFilter(Expressions.alwaysTrue()).commit(); + + final int originalSpecId = table.spec().specId(); + table.updateSpec().addField("data").commit(); + + // Refresh this when using SparkCatalog since otherwise the new spec would not be caught. + sql("REFRESH TABLE %s", tableName); + + // By default, we write to the current spec. + List data = ImmutableList.of(new SimpleRecord(10, "a")); + spark.createDataFrame(data, SimpleRecord.class).toDF().writeTo(tableName).append(); + + List expected = ImmutableList.of(row(10L, "a", table.spec().specId())); + assertEquals( + "Rows must match", + expected, + sql("SELECT id, data, _spec_id FROM %s WHERE id >= 10 ORDER BY id", tableName)); + + // Output spec ID should be respected when present. + data = ImmutableList.of(new SimpleRecord(11, "b"), new SimpleRecord(12, "c")); + spark + .createDataFrame(data, SimpleRecord.class) + .toDF() + .writeTo(tableName) + .option("output-spec-id", Integer.toString(originalSpecId)) + .append(); + + expected = + ImmutableList.of( + row(10L, "a", table.spec().specId()), + row(11L, "b", originalSpecId), + row(12L, "c", originalSpecId)); + assertEquals( + "Rows must match", + expected, + sql("SELECT id, data, _spec_id FROM %s WHERE id >= 10 ORDER BY id", tableName)); + + // Verify that the actual partitions are written with the correct spec ID. + // Two of the partitions should have the original spec ID and one should have the new one. + // TODO: WAP branch does not support reading partitions table, skip this check for now. + expected = + ImmutableList.of( + row(originalSpecId, 9L, null), + row(originalSpecId, 12L, null), + row(table.spec().specId(), 9L, "a")); + assertPartitionMetadata(tableName, expected, "id_trunc", "data"); + + // Even the default spec ID should be followed when present. + data = ImmutableList.of(new SimpleRecord(13, "d")); + spark + .createDataFrame(data, SimpleRecord.class) + .toDF() + .writeTo(tableName) + .option("output-spec-id", Integer.toString(table.spec().specId())) + .append(); + + expected = + ImmutableList.of( + row(10L, "a", table.spec().specId()), + row(11L, "b", originalSpecId), + row(12L, "c", originalSpecId), + row(13L, "d", table.spec().specId())); + assertEquals( + "Rows must match", + expected, + sql("SELECT id, data, _spec_id FROM %s WHERE id >= 10 ORDER BY id", tableName)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java new file mode 100644 index 000000000000..1669301d2d66 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestAggregatePushDown.java @@ -0,0 +1,978 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import org.apache.iceberg.CatalogUtil; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.exceptions.AlreadyExistsException; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.hive.TestHiveMetastore; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.execution.ExplainMode; +import org.apache.spark.sql.functions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestAggregatePushDown extends CatalogTestBase { + + @BeforeAll + public static void startMetastoreAndSpark() { + TestBase.metastore = new TestHiveMetastore(); + metastore.start(); + TestBase.hiveConf = metastore.hiveConf(); + + TestBase.spark.stop(); + + TestBase.spark = + SparkSession.builder() + .master("local[2]") + .config("spark.sql.iceberg.aggregate_pushdown", "true") + .config(TestBase.DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + TestBase.catalog = + (HiveCatalog) + CatalogUtil.loadCatalog( + HiveCatalog.class.getName(), "hive", ImmutableMap.of(), hiveConf); + + try { + catalog.createNamespace(Namespace.of("default")); + } catch (AlreadyExistsException ignored) { + // the default namespace already exists. ignore the create error + } + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testDifferentDataTypesAggregatePushDownInPartitionedTable() { + testDifferentDataTypesAggregatePushDown(true); + } + + @TestTemplate + public void testDifferentDataTypesAggregatePushDownInNonPartitionedTable() { + testDifferentDataTypesAggregatePushDown(false); + } + + @SuppressWarnings("checkstyle:CyclomaticComplexity") + private void testDifferentDataTypesAggregatePushDown(boolean hasPartitionCol) { + String createTable; + if (hasPartitionCol) { + createTable = + "CREATE TABLE %s (id LONG, int_data INT, boolean_data BOOLEAN, float_data FLOAT, double_data DOUBLE, " + + "decimal_data DECIMAL(14, 2), binary_data binary) USING iceberg PARTITIONED BY (id)"; + } else { + createTable = + "CREATE TABLE %s (id LONG, int_data INT, boolean_data BOOLEAN, float_data FLOAT, double_data DOUBLE, " + + "decimal_data DECIMAL(14, 2), binary_data binary) USING iceberg"; + } + + sql(createTable, tableName); + sql( + "INSERT INTO TABLE %s VALUES " + + "(1, null, false, null, null, 11.11, X'1111')," + + " (1, null, true, 2.222, 2.222222, 22.22, X'2222')," + + " (2, 33, false, 3.333, 3.333333, 33.33, X'3333')," + + " (2, 44, true, null, 4.444444, 44.44, X'4444')," + + " (3, 55, false, 5.555, 5.555555, 55.55, X'5555')," + + " (3, null, true, null, 6.666666, 66.66, null) ", + tableName); + + String select = + "SELECT count(*), max(id), min(id), count(id), " + + "max(int_data), min(int_data), count(int_data), " + + "max(boolean_data), min(boolean_data), count(boolean_data), " + + "max(float_data), min(float_data), count(float_data), " + + "max(double_data), min(double_data), count(double_data), " + + "max(decimal_data), min(decimal_data), count(decimal_data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("count(*)") + && explainString.contains("max(id)") + && explainString.contains("min(id)") + && explainString.contains("count(id)") + && explainString.contains("max(int_data)") + && explainString.contains("min(int_data)") + && explainString.contains("count(int_data)") + && explainString.contains("max(boolean_data)") + && explainString.contains("min(boolean_data)") + && explainString.contains("count(boolean_data)") + && explainString.contains("max(float_data)") + && explainString.contains("min(float_data)") + && explainString.contains("count(float_data)") + && explainString.contains("max(double_data)") + && explainString.contains("min(double_data)") + && explainString.contains("count(double_data)") + && explainString.contains("max(decimal_data)") + && explainString.contains("min(decimal_data)") + && explainString.contains("count(decimal_data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add( + new Object[] { + 6L, + 3L, + 1L, + 6L, + 55, + 33, + 3L, + true, + false, + 6L, + 5.555f, + 2.222f, + 3L, + 6.666666, + 2.222222, + 5L, + new BigDecimal("66.66"), + new BigDecimal("11.11"), + 6L + }); + assertEquals("min/max/count push down", expected, actual); + } + + @TestTemplate + public void testDateAndTimestampWithPartition() { + sql( + "CREATE TABLE %s (id bigint, data string, d date, ts timestamp) USING iceberg PARTITIONED BY (id)", + tableName); + sql( + "INSERT INTO %s VALUES (1, '1', date('2021-11-10'), null)," + + "(1, '2', date('2021-11-11'), timestamp('2021-11-11 22:22:22')), " + + "(2, '3', date('2021-11-12'), timestamp('2021-11-12 22:22:22')), " + + "(2, '4', date('2021-11-13'), timestamp('2021-11-13 22:22:22')), " + + "(3, '5', null, timestamp('2021-11-14 22:22:22')), " + + "(3, '6', date('2021-11-14'), null)", + tableName); + String select = "SELECT max(d), min(d), count(d), max(ts), min(ts), count(ts) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("max(d)") + && explainString.contains("min(d)") + && explainString.contains("count(d)") + && explainString.contains("max(ts)") + && explainString.contains("min(ts)") + && explainString.contains("count(ts)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add( + new Object[] { + Date.valueOf("2021-11-14"), + Date.valueOf("2021-11-10"), + 5L, + Timestamp.valueOf("2021-11-14 22:22:22.0"), + Timestamp.valueOf("2021-11-11 22:22:22.0"), + 4L + }); + assertEquals("min/max/count push down", expected, actual); + } + + @TestTemplate + public void testAggregateNotPushDownIfOneCantPushDown() { + sql("CREATE TABLE %s (id LONG, data DOUBLE) USING iceberg", tableName); + sql( + "INSERT INTO TABLE %s VALUES (1, 1111), (1, 2222), (2, 3333), (2, 4444), (3, 5555), (3, 6666) ", + tableName); + String select = "SELECT COUNT(data), SUM(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {6L, 23331.0}); + assertEquals("expected and actual should equal", expected, actual); + } + + @TestTemplate + public void testAggregatePushDownWithMetricsMode() { + sql("CREATE TABLE %s (id LONG, data DOUBLE) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "none"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "id", "counts"); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX + "data", "none"); + sql( + "INSERT INTO TABLE %s VALUES (1, 1111), (1, 2222), (2, 3333), (2, 4444), (3, 5555), (3, 6666)", + tableName); + + String select1 = "SELECT COUNT(data) FROM %s"; + + List explain1 = sql("EXPLAIN " + select1, tableName); + String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString1.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + // count(data) is not pushed down because the metrics mode is `none` + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual1 = sql(select1, tableName); + List expected1 = Lists.newArrayList(); + expected1.add(new Object[] {6L}); + assertEquals("expected and actual should equal", expected1, actual1); + + String select2 = "SELECT COUNT(id) FROM %s"; + List explain2 = sql("EXPLAIN " + select2, tableName); + String explainString2 = explain2.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString2.contains("count(id)")) { + explainContainsPushDownAggregates = true; + } + + // count(id) is pushed down because the metrics mode is `counts` + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + + List actual2 = sql(select2, tableName); + List expected2 = Lists.newArrayList(); + expected2.add(new Object[] {6L}); + assertEquals("expected and actual should equal", expected2, actual2); + + String select3 = "SELECT COUNT(id), MAX(id) FROM %s"; + explainContainsPushDownAggregates = false; + List explain3 = sql("EXPLAIN " + select3, tableName); + String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString3.contains("count(id)")) { + explainContainsPushDownAggregates = true; + } + + // COUNT(id), MAX(id) are not pushed down because MAX(id) is not pushed down (metrics mode is + // `counts`) + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual3 = sql(select3, tableName); + List expected3 = Lists.newArrayList(); + expected3.add(new Object[] {6L, 3L}); + assertEquals("expected and actual should equal", expected3, actual3); + } + + @TestTemplate + public void testAggregateNotPushDownForStringType() { + sql("CREATE TABLE %s (id LONG, data STRING) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, '111111'), (1, '2222'), (2, '3333'), (2, '4444'), (3, '5555'), (3, '666666') ", + tableName); + + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; + + List explain1 = sql("EXPLAIN " + select1, tableName); + String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString1.contains("max(id)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual1 = sql(select1, tableName); + List expected1 = Lists.newArrayList(); + expected1.add(new Object[] {3L, "666666", "111111"}); + assertEquals("expected and actual should equal", expected1, actual1); + + String select2 = "SELECT COUNT(data) FROM %s"; + List explain2 = sql("EXPLAIN " + select2, tableName); + String explainString2 = explain2.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString2.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + + List actual2 = sql(select2, tableName); + List expected2 = Lists.newArrayList(); + expected2.add(new Object[] {6L}); + assertEquals("expected and actual should equal", expected2, actual2); + + explainContainsPushDownAggregates = false; + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; + List explain3 = sql("EXPLAIN " + select3, tableName); + String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual3 = sql(select3, tableName); + List expected3 = Lists.newArrayList(); + expected3.add(new Object[] {6L, "666666", "111111"}); + assertEquals("expected and actual should equal", expected3, actual3); + } + + @TestTemplate + public void testAggregateNotPushDownForBinaryType() { + sql("CREATE TABLE %s (id LONG, data BINARY) USING iceberg", tableName); + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "truncate(5)"); + sql( + "INSERT INTO TABLE %s VALUES (1, X'111111111111'), (1, X'2222')," + + " (2, X'3333'), (2, X'4444')," + + " (3, X'5555'), (3, X'555555555555') ", + tableName); + + String select1 = "SELECT MAX(id), MAX(data), MIN(data) FROM %s"; + + List explain1 = sql("EXPLAIN " + select1, tableName); + String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString1.contains("max(id)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual1 = sql(select1, tableName); + List expected1 = Lists.newArrayList(); + expected1.add( + new Object[] { + 3L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); + assertEquals("expected and actual should equal", expected1, actual1); + + String select2 = "SELECT COUNT(data) FROM %s"; + List explain2 = sql("EXPLAIN " + select2, tableName); + String explainString2 = explain2.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString2.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + + List actual2 = sql(select2, tableName); + List expected2 = Lists.newArrayList(); + expected2.add(new Object[] {6L}); + assertEquals("expected and actual should equal", expected2, actual2); + + explainContainsPushDownAggregates = false; + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%s')", + tableName, TableProperties.DEFAULT_WRITE_METRICS_MODE, "full"); + String select3 = "SELECT count(data), max(data), min(data) FROM %s"; + List explain3 = sql("EXPLAIN " + select3, tableName); + String explainString3 = explain3.get(0)[0].toString().toLowerCase(Locale.ROOT); + if (explainString3.contains("count(data)") && explainString3.contains("max(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual3 = sql(select3, tableName); + List expected3 = Lists.newArrayList(); + expected3.add( + new Object[] { + 6L, new byte[] {85, 85, 85, 85, 85, 85}, new byte[] {17, 17, 17, 17, 17, 17} + }); + assertEquals("expected and actual should equal", expected3, actual3); + } + + @TestTemplate + public void testAggregatePushDownWithDataFilter() { + testAggregatePushDownWithFilter(false); + } + + @TestTemplate + public void testAggregatePushDownWithPartitionFilter() { + testAggregatePushDownWithFilter(true); + } + + private void testAggregatePushDownWithFilter(boolean partitionFilerOnly) { + String createTable; + if (!partitionFilerOnly) { + createTable = "CREATE TABLE %s (id LONG, data INT) USING iceberg"; + } else { + createTable = "CREATE TABLE %s (id LONG, data INT) USING iceberg PARTITIONED BY (id)"; + } + + sql(createTable, tableName); + + sql( + "INSERT INTO TABLE %s VALUES" + + " (1, 11)," + + " (1, 22)," + + " (2, 33)," + + " (2, 44)," + + " (3, 55)," + + " (3, 66) ", + tableName); + + String select = "SELECT MIN(data) FROM %s WHERE id > 1"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("min(data)")) { + explainContainsPushDownAggregates = true; + } + + if (!partitionFilerOnly) { + // Filters are not completely pushed down, we can't push down aggregates + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + } else { + // Filters are not completely pushed down, we can push down aggregates + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + } + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {33}); + assertEquals("expected and actual should equal", expected, actual); + } + + @TestTemplate + public void testAggregateWithComplexType() { + sql("CREATE TABLE %s (id INT, complex STRUCT) USING iceberg", tableName); + sql( + "INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", 3, \"c2\", \"v1\"))," + + "(2, named_struct(\"c1\", 2, \"c2\", \"v2\")), (3, null)", + tableName); + String select1 = "SELECT count(complex), count(id) FROM %s"; + List explain = sql("EXPLAIN " + select1, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("count(complex)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("count not pushed down for complex types") + .isFalse(); + + List actual = sql(select1, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {2L, 3L}); + assertEquals("count not push down", actual, expected); + + String select2 = "SELECT max(complex) FROM %s"; + explain = sql("EXPLAIN " + select2, tableName); + explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + explainContainsPushDownAggregates = false; + if (explainString.contains("max(complex)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("max not pushed down for complex types") + .isFalse(); + } + + @TestTemplate + public void testAggregationPushdownStructInteger() { + sql("CREATE TABLE %s (id BIGINT, struct_with_int STRUCT) USING iceberg", tableName); + sql("INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", NULL))", tableName); + sql("INSERT INTO TABLE %s VALUES (2, named_struct(\"c1\", 2))", tableName); + sql("INSERT INTO TABLE %s VALUES (3, named_struct(\"c1\", 3))", tableName); + + String query = "SELECT COUNT(%s), MAX(%s), MIN(%s) FROM %s"; + String aggField = "struct_with_int.c1"; + assertAggregates(sql(query, aggField, aggField, aggField, tableName), 2L, 3L, 2L); + assertExplainContains( + sql("EXPLAIN " + query, aggField, aggField, aggField, tableName), + "count(struct_with_int.c1)", + "max(struct_with_int.c1)", + "min(struct_with_int.c1)"); + } + + @TestTemplate + public void testAggregationPushdownNestedStruct() { + sql( + "CREATE TABLE %s (id BIGINT, struct_with_int STRUCT>>>) USING iceberg", + tableName); + sql( + "INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", named_struct(\"c2\", named_struct(\"c3\", named_struct(\"c4\", NULL)))))", + tableName); + sql( + "INSERT INTO TABLE %s VALUES (2, named_struct(\"c1\", named_struct(\"c2\", named_struct(\"c3\", named_struct(\"c4\", 2)))))", + tableName); + sql( + "INSERT INTO TABLE %s VALUES (3, named_struct(\"c1\", named_struct(\"c2\", named_struct(\"c3\", named_struct(\"c4\", 3)))))", + tableName); + + String query = "SELECT COUNT(%s), MAX(%s), MIN(%s) FROM %s"; + String aggField = "struct_with_int.c1.c2.c3.c4"; + + assertAggregates(sql(query, aggField, aggField, aggField, tableName), 2L, 3L, 2L); + + assertExplainContains( + sql("EXPLAIN " + query, aggField, aggField, aggField, tableName), + "count(struct_with_int.c1.c2.c3.c4)", + "max(struct_with_int.c1.c2.c3.c4)", + "min(struct_with_int.c1.c2.c3.c4)"); + } + + @TestTemplate + public void testAggregationPushdownStructTimestamp() { + sql( + "CREATE TABLE %s (id BIGINT, struct_with_ts STRUCT) USING iceberg", + tableName); + sql("INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", NULL))", tableName); + sql( + "INSERT INTO TABLE %s VALUES (2, named_struct(\"c1\", timestamp('2023-01-30T22:22:22Z')))", + tableName); + sql( + "INSERT INTO TABLE %s VALUES (3, named_struct(\"c1\", timestamp('2023-01-30T22:23:23Z')))", + tableName); + + String query = "SELECT COUNT(%s), MAX(%s), MIN(%s) FROM %s"; + String aggField = "struct_with_ts.c1"; + + assertAggregates( + sql(query, aggField, aggField, aggField, tableName), + 2L, + new Timestamp(1675117403000L), + new Timestamp(1675117342000L)); + + assertExplainContains( + sql("EXPLAIN " + query, aggField, aggField, aggField, tableName), + "count(struct_with_ts.c1)", + "max(struct_with_ts.c1)", + "min(struct_with_ts.c1)"); + } + + @TestTemplate + public void testAggregationPushdownOnBucketedColumn() { + sql( + "CREATE TABLE %s (id BIGINT, struct_with_int STRUCT) USING iceberg PARTITIONED BY (bucket(8, id))", + tableName); + + sql("INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", NULL))", tableName); + sql("INSERT INTO TABLE %s VALUES (null, named_struct(\"c1\", 2))", tableName); + sql("INSERT INTO TABLE %s VALUES (2, named_struct(\"c1\", 3))", tableName); + + String query = "SELECT COUNT(%s), MAX(%s), MIN(%s) FROM %s"; + String aggField = "id"; + assertAggregates(sql(query, aggField, aggField, aggField, tableName), 2L, 2L, 1L); + assertExplainContains( + sql("EXPLAIN " + query, aggField, aggField, aggField, tableName), + "count(id)", + "max(id)", + "min(id)"); + } + + private void assertAggregates( + List actual, Object expectedCount, Object expectedMax, Object expectedMin) { + Object actualCount = actual.get(0)[0]; + Object actualMax = actual.get(0)[1]; + Object actualMin = actual.get(0)[2]; + + assertThat(actualCount).as("Expected and actual count should equal").isEqualTo(expectedCount); + assertThat(actualMax).as("Expected and actual max should equal").isEqualTo(expectedMax); + assertThat(actualMin).as("Expected and actual min should equal").isEqualTo(expectedMin); + } + + private void assertExplainContains(List explain, String... expectedFragments) { + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + Arrays.stream(expectedFragments) + .forEach( + fragment -> + assertThat(explainString) + .as("Expected to find plan fragment in explain plan") + .contains(fragment)); + } + + @TestTemplate + public void testAggregatePushDownInDeleteCopyOnWrite() { + sql("CREATE TABLE %s (id LONG, data INT) USING iceberg", tableName); + sql( + "INSERT INTO TABLE %s VALUES (1, 1111), (1, 2222), (2, 3333), (2, 4444), (3, 5555), (3, 6666) ", + tableName); + sql("DELETE FROM %s WHERE data = 1111", tableName); + String select = "SELECT max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("max(data)") + && explainString.contains("min(data)") + && explainString.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("min/max/count pushed down for deleted") + .isTrue(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {6666, 2222, 5L}); + assertEquals("min/max/count push down", expected, actual); + } + + @TestTemplate + public void testAggregatePushDownForTimeTravel() { + sql("CREATE TABLE %s (id LONG, data INT) USING iceberg", tableName); + sql( + "INSERT INTO TABLE %s VALUES (1, 1111), (1, 2222), (2, 3333), (2, 4444), (3, 5555), (3, 6666) ", + tableName); + + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + List expected1 = sql("SELECT count(id) FROM %s", tableName); + + sql("INSERT INTO %s VALUES (4, 7777), (5, 8888)", tableName); + List expected2 = sql("SELECT count(id) FROM %s", tableName); + + List explain1 = + sql("EXPLAIN SELECT count(id) FROM %s VERSION AS OF %s", tableName, snapshotId); + String explainString1 = explain1.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates1 = false; + if (explainString1.contains("count(id)")) { + explainContainsPushDownAggregates1 = true; + } + assertThat(explainContainsPushDownAggregates1).as("count pushed down").isTrue(); + + List actual1 = + sql("SELECT count(id) FROM %s VERSION AS OF %s", tableName, snapshotId); + assertEquals("count push down", expected1, actual1); + + List explain2 = sql("EXPLAIN SELECT count(id) FROM %s", tableName); + String explainString2 = explain2.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates2 = false; + if (explainString2.contains("count(id)")) { + explainContainsPushDownAggregates2 = true; + } + + assertThat(explainContainsPushDownAggregates2).as("count pushed down").isTrue(); + + List actual2 = sql("SELECT count(id) FROM %s", tableName); + assertEquals("count push down", expected2, actual2); + } + + @TestTemplate + public void testAllNull() { + sql("CREATE TABLE %s (id int, data int) USING iceberg PARTITIONED BY (id)", tableName); + sql( + "INSERT INTO %s VALUES (1, null)," + + "(1, null), " + + "(2, null), " + + "(2, null), " + + "(3, null), " + + "(3, null)", + tableName); + String select = "SELECT count(*), max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("max(data)") + && explainString.contains("min(data)") + && explainString.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {6L, null, null, 0L}); + assertEquals("min/max/count push down", expected, actual); + } + + @TestTemplate + public void testAllNaN() { + sql("CREATE TABLE %s (id int, data float) USING iceberg PARTITIONED BY (id)", tableName); + sql( + "INSERT INTO %s VALUES (1, float('nan'))," + + "(1, float('nan')), " + + "(2, float('nan')), " + + "(2, float('nan')), " + + "(3, float('nan')), " + + "(3, float('nan'))", + tableName); + String select = "SELECT count(*), max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("max(data)") + || explainString.contains("min(data)") + || explainString.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {6L, Float.NaN, Float.NaN, 6L}); + assertEquals("expected and actual should equal", expected, actual); + } + + @TestTemplate + public void testNaN() { + sql("CREATE TABLE %s (id int, data float) USING iceberg PARTITIONED BY (id)", tableName); + sql( + "INSERT INTO %s VALUES (1, float('nan'))," + + "(1, float('nan')), " + + "(2, 2), " + + "(2, float('nan')), " + + "(3, float('nan')), " + + "(3, 1)", + tableName); + String select = "SELECT count(*), max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("max(data)") + || explainString.contains("min(data)") + || explainString.contains("count(data)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {6L, Float.NaN, 1.0F, 6L}); + assertEquals("expected and actual should equal", expected, actual); + } + + @TestTemplate + public void testNanWithLowerAndUpperBoundMetrics() { + sql("CREATE TABLE %s (id int, data float) USING iceberg PARTITIONED BY (id)", tableName); + sql( + "INSERT INTO %s VALUES (1, float('nan'))," + + "(1, float('nan')), " + + "(1, 10.0), " + + "(2, 2), " + + "(2, float('nan')), " + + "(3, float('nan')), " + + "(3, 1)", + tableName); + + // Validate all files has upper bound, lower bound and nan count + String countsQuery = + "select readable_metrics.data.nan_value_count > 0, " + + "isnull(readable_metrics.data.lower_bound), " + + "isnull(readable_metrics.data.upper_bound) " + + "from %s.files"; + + Object[] expectedResult = new Object[] {true, false, false}; + assertThat(sql(countsQuery, tableName)) + .as("Data files should contain nan count, lower bound and upper bound.") + .allMatch(row -> Arrays.equals(row, expectedResult)); + + // Check aggregates are not pushed down + String select = "SELECT count(*), max(data), min(data), count(data) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = + (explainString.contains("max(data)") + || explainString.contains("min(data)") + || explainString.contains("count(data)")); + + assertThat(explainContainsPushDownAggregates) + .as("explain should not contain the pushed down aggregates") + .isFalse(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add(new Object[] {7L, Float.NaN, 1.0F, 7L}); + assertEquals("expected and actual should equal", expected, actual); + } + + @TestTemplate + public void testInfinity() { + sql( + "CREATE TABLE %s (id int, data1 float, data2 double, data3 double) USING iceberg PARTITIONED BY (id)", + tableName); + sql( + "INSERT INTO %s VALUES (1, float('-infinity'), double('infinity'), 1.23), " + + "(1, float('-infinity'), double('infinity'), -1.23), " + + "(1, float('-infinity'), double('infinity'), double('infinity')), " + + "(1, float('-infinity'), double('infinity'), 2.23), " + + "(1, float('-infinity'), double('infinity'), double('-infinity')), " + + "(1, float('-infinity'), double('infinity'), -2.23)", + tableName); + String select = + "SELECT count(*), max(data1), min(data1), count(data1), max(data2), min(data2), count(data2), max(data3), min(data3), count(data3) FROM %s"; + + List explain = sql("EXPLAIN " + select, tableName); + String explainString = explain.get(0)[0].toString().toLowerCase(Locale.ROOT); + boolean explainContainsPushDownAggregates = false; + if (explainString.contains("max(data1)") + && explainString.contains("min(data1)") + && explainString.contains("count(data1)") + && explainString.contains("max(data2)") + && explainString.contains("min(data2)") + && explainString.contains("count(data2)") + && explainString.contains("max(data3)") + && explainString.contains("min(data3)") + && explainString.contains("count(data3)")) { + explainContainsPushDownAggregates = true; + } + + assertThat(explainContainsPushDownAggregates) + .as("explain should contain the pushed down aggregates") + .isTrue(); + + List actual = sql(select, tableName); + List expected = Lists.newArrayList(); + expected.add( + new Object[] { + 6L, + Float.NEGATIVE_INFINITY, + Float.NEGATIVE_INFINITY, + 6L, + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY, + 6L, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + 6L + }); + assertEquals("min/max/count push down", expected, actual); + } + + @TestTemplate + public void testAggregatePushDownForIncrementalScan() { + sql("CREATE TABLE %s (id LONG, data INT) USING iceberg", tableName); + sql( + "INSERT INTO TABLE %s VALUES (1, 1111), (1, 2222), (2, 3333), (2, 4444), (3, 5555), (3, 6666) ", + tableName); + long snapshotId1 = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + sql("INSERT INTO %s VALUES (4, 7777), (5, 8888)", tableName); + long snapshotId2 = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + sql("INSERT INTO %s VALUES (6, -7777), (7, 8888)", tableName); + long snapshotId3 = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + sql("INSERT INTO %s VALUES (8, 7777), (9, 9999)", tableName); + + Dataset pushdownDs = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.START_SNAPSHOT_ID, snapshotId2) + .option(SparkReadOptions.END_SNAPSHOT_ID, snapshotId3) + .load(tableName) + .agg(functions.min("data"), functions.max("data"), functions.count("data")); + String explain1 = pushdownDs.queryExecution().explainString(ExplainMode.fromString("simple")); + assertThat(explain1).contains("LocalTableScan", "min(data)", "max(data)", "count(data)"); + + List expected1 = Lists.newArrayList(); + expected1.add(new Object[] {-7777, 8888, 2L}); + assertEquals("min/max/count push down", expected1, rowsToJava(pushdownDs.collectAsList())); + + Dataset unboundedPushdownDs = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.START_SNAPSHOT_ID, snapshotId1) + .load(tableName) + .agg(functions.min("data"), functions.max("data"), functions.count("data")); + String explain2 = + unboundedPushdownDs.queryExecution().explainString(ExplainMode.fromString("simple")); + assertThat(explain2).contains("LocalTableScan", "min(data)", "max(data)", "count(data)"); + + List expected2 = Lists.newArrayList(); + expected2.add(new Object[] {-7777, 9999, 6L}); + assertEquals( + "min/max/count push down", expected2, rowsToJava(unboundedPushdownDs.collectAsList())); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestAlterTable.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestAlterTable.java new file mode 100644 index 000000000000..6ba7e01c402d --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestAlterTable.java @@ -0,0 +1,333 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_REST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.hadoop.HadoopCatalog; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.spark.SparkException; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestAlterTable extends CatalogTestBase { + private final TableIdentifier renamedIdent = + TableIdentifier.of(Namespace.of("default"), "table2"); + + @BeforeEach + public void createTable() { + sql("CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg", tableName); + } + + @AfterEach + public void removeTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s2", tableName); + } + + @TestTemplate + public void testAddColumnNotNull() { + assertThatThrownBy(() -> sql("ALTER TABLE %s ADD COLUMN c3 INT NOT NULL", tableName)) + .isInstanceOf(SparkException.class) + .hasMessage( + "Unsupported table change: Incompatible change: cannot add required column: c3"); + } + + @TestTemplate + public void testAddColumn() { + sql( + "ALTER TABLE %s ADD COLUMN point struct AFTER id", + tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional( + 3, + "point", + Types.StructType.of( + NestedField.required(4, "x", Types.DoubleType.get()), + NestedField.required(5, "y", Types.DoubleType.get()))), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + + sql("ALTER TABLE %s ADD COLUMN point.z double COMMENT 'May be null' FIRST", tableName); + + Types.StructType expectedSchema2 = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional( + 3, + "point", + Types.StructType.of( + NestedField.optional(6, "z", Types.DoubleType.get(), "May be null"), + NestedField.required(4, "x", Types.DoubleType.get()), + NestedField.required(5, "y", Types.DoubleType.get()))), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema2); + } + + @TestTemplate + public void testAddColumnWithArray() { + sql("ALTER TABLE %s ADD COLUMN data2 array>", tableName); + // use the implicit column name 'element' to access member of array and add column d to struct. + sql("ALTER TABLE %s ADD COLUMN data2.element.d int", tableName); + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get()), + NestedField.optional( + 3, + "data2", + Types.ListType.ofOptional( + 4, + Types.StructType.of( + NestedField.optional(5, "a", Types.IntegerType.get()), + NestedField.optional(6, "b", Types.IntegerType.get()), + NestedField.optional(7, "c", Types.IntegerType.get()), + NestedField.optional(8, "d", Types.IntegerType.get()))))); + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testAddColumnWithMap() { + sql("ALTER TABLE %s ADD COLUMN data2 map, struct>", tableName); + // use the implicit column name 'key' and 'value' to access member of map. + // add column to value struct column + sql("ALTER TABLE %s ADD COLUMN data2.value.c int", tableName); + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get()), + NestedField.optional( + 3, + "data2", + Types.MapType.ofOptional( + 4, + 5, + Types.StructType.of(NestedField.optional(6, "x", Types.IntegerType.get())), + Types.StructType.of( + NestedField.optional(7, "a", Types.IntegerType.get()), + NestedField.optional(8, "b", Types.IntegerType.get()), + NestedField.optional(9, "c", Types.IntegerType.get()))))); + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + + // should not allow changing map key column + assertThatThrownBy(() -> sql("ALTER TABLE %s ADD COLUMN data2.key.y int", tableName)) + .isInstanceOf(SparkException.class) + .hasMessageStartingWith("Unsupported table change: Cannot add fields to map keys:"); + } + + @TestTemplate + public void testAddColumnWithDefaultValuesUnsupported() { + assertThatThrownBy( + () -> sql("ALTER TABLE %s ADD COLUMN col_with_default int DEFAULT 123", tableName)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Cannot add column col_with_default since setting default values in Spark is currently unsupported"); + } + + @TestTemplate + public void testDropColumn() { + sql("ALTER TABLE %s DROP COLUMN data", tableName); + + Types.StructType expectedSchema = + Types.StructType.of(NestedField.required(1, "id", Types.LongType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testRenameColumn() { + sql("ALTER TABLE %s RENAME COLUMN id TO row_id", tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "row_id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testAlterColumnComment() { + sql("ALTER TABLE %s ALTER COLUMN id COMMENT 'Record id'", tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get(), "Record id"), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testAlterColumnType() { + sql("ALTER TABLE %s ADD COLUMN count int", tableName); + sql("ALTER TABLE %s ALTER COLUMN count TYPE bigint", tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get()), + NestedField.optional(3, "count", Types.LongType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testAlterColumnDropNotNull() { + sql("ALTER TABLE %s ALTER COLUMN id DROP NOT NULL", tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.optional(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testAlterColumnSetNotNull() { + // no-op changes are allowed + sql("ALTER TABLE %s ALTER COLUMN id SET NOT NULL", tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + + assertThatThrownBy(() -> sql("ALTER TABLE %s ALTER COLUMN data SET NOT NULL", tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Cannot change nullable column to non-nullable: data"); + } + + @TestTemplate + public void testAlterColumnPositionAfter() { + sql("ALTER TABLE %s ADD COLUMN count int", tableName); + sql("ALTER TABLE %s ALTER COLUMN count AFTER id", tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(3, "count", Types.IntegerType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testAlterColumnPositionFirst() { + sql("ALTER TABLE %s ADD COLUMN count int", tableName); + sql("ALTER TABLE %s ALTER COLUMN count FIRST", tableName); + + Types.StructType expectedSchema = + Types.StructType.of( + NestedField.optional(3, "count", Types.IntegerType.get()), + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + + assertThat(validationCatalog.loadTable(tableIdent).schema().asStruct()) + .as("Schema should match expected") + .isEqualTo(expectedSchema); + } + + @TestTemplate + public void testTableRename() { + assumeThat(catalogConfig.get(ICEBERG_CATALOG_TYPE)) + .as( + "need to fix https://github.com/apache/iceberg/issues/11154 before enabling this for the REST catalog") + .isNotEqualTo(ICEBERG_CATALOG_TYPE_REST); + assumeThat(validationCatalog) + .as("Hadoop catalog does not support rename") + .isNotInstanceOf(HadoopCatalog.class); + + assertThat(validationCatalog.tableExists(tableIdent)).as("Initial name should exist").isTrue(); + assertThat(validationCatalog.tableExists(renamedIdent)) + .as("New name should not exist") + .isFalse(); + + sql("ALTER TABLE %s RENAME TO %s2", tableName, tableName); + + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Initial name should not exist") + .isFalse(); + assertThat(validationCatalog.tableExists(renamedIdent)).as("New name should exist").isTrue(); + } + + @TestTemplate + public void testSetTableProperties() { + sql("ALTER TABLE %s SET TBLPROPERTIES ('prop'='value')", tableName); + + assertThat(validationCatalog.loadTable(tableIdent).properties()) + .as("Should have the new table property") + .containsEntry("prop", "value"); + + sql("ALTER TABLE %s UNSET TBLPROPERTIES ('prop')", tableName); + + assertThat(validationCatalog.loadTable(tableIdent).properties()) + .as("Should not have the removed table property") + .doesNotContainKey("prop"); + + String[] reservedProperties = new String[] {"sort-order", "identifier-fields"}; + for (String reservedProp : reservedProperties) { + assertThatThrownBy( + () -> sql("ALTER TABLE %s SET TBLPROPERTIES ('%s'='value')", tableName, reservedProp)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageStartingWith( + "Cannot specify the '%s' because it's a reserved table property", reservedProp); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCTASEncryption.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCTASEncryption.java new file mode 100644 index 000000000000..3dee6e1e1d54 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCTASEncryption.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.iceberg.Files.localInput; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.encryption.UnitestKMS; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Streams; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.types.Types; +import org.apache.parquet.crypto.ParquetCryptoRuntimeException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestCTASEncryption extends CatalogTestBase { + private static Map appendCatalogEncryptionProperties(Map props) { + Map newProps = Maps.newHashMap(); + newProps.putAll(props); + newProps.put(CatalogProperties.ENCRYPTION_KMS_IMPL, UnitestKMS.class.getCanonicalName()); + return newProps; + } + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + appendCatalogEncryptionProperties(SparkCatalogConfig.HIVE.properties()) + } + }; + } + + @BeforeEach + public void createTables() { + sql("CREATE TABLE %s (id bigint, data string, float float) USING iceberg ", tableName + "1"); + sql( + "INSERT INTO %s VALUES (1, 'a', 1.0), (2, 'b', 2.0), (3, 'c', float('NaN'))", + tableName + "1"); + + sql( + "CREATE TABLE %s USING iceberg " + + "TBLPROPERTIES ( " + + "'encryption.key-id'='%s', 'format-version'='3')" + + " AS SELECT * from %s", + tableName, UnitestKMS.MASTER_KEY_NAME1, tableName + "1"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", tableName + "1"); + } + + @TestTemplate + public void testSelect() { + List expected = + ImmutableList.of(row(1L, "a", 1.0F), row(2L, "b", 2.0F), row(3L, "c", Float.NaN)); + + assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + } + + @TestTemplate + public void testDirectDataFileRead() { + List dataFileTable = + sql("SELECT file_path FROM %s.%s", tableName, MetadataTableType.ALL_DATA_FILES); + List dataFiles = + Streams.concat(dataFileTable.stream()) + .map(row -> (String) row[0]) + .collect(Collectors.toList()); + + if (dataFiles.isEmpty()) { + throw new RuntimeException("No data files found for table " + tableName); + } + + Schema schema = new Schema(optional(0, "id", Types.IntegerType.get())); + for (String filePath : dataFiles) { + assertThatThrownBy( + () -> + Parquet.read(localInput(filePath)) + .project(schema) + .callInit() + .build() + .iterator() + .next()) + .isInstanceOf(ParquetCryptoRuntimeException.class) + .hasMessageContaining("Trying to read file with encrypted footer. No keys available"); + } + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java new file mode 100644 index 000000000000..afb4e9964842 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTable.java @@ -0,0 +1,456 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE; +import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE_REST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.nio.file.Files; +import java.util.UUID; +import org.apache.iceberg.BaseTable; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.hadoop.HadoopCatalog; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.types.Types.NestedField; +import org.apache.iceberg.types.Types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCreateTable extends CatalogTestBase { + + @AfterEach + public void dropTestTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testTransformIgnoreCase() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + sql( + "CREATE TABLE IF NOT EXISTS %s (id BIGINT NOT NULL, ts timestamp) " + + "USING iceberg partitioned by (HOURS(ts))", + tableName); + assertThat(validationCatalog.tableExists(tableIdent)).as("Table should already exist").isTrue(); + sql( + "CREATE TABLE IF NOT EXISTS %s (id BIGINT NOT NULL, ts timestamp) " + + "USING iceberg partitioned by (hours(ts))", + tableName); + assertThat(validationCatalog.tableExists(tableIdent)).as("Table should already exist").isTrue(); + } + + @TestTemplate + public void testTransformSingularForm() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + sql( + "CREATE TABLE IF NOT EXISTS %s (id BIGINT NOT NULL, ts timestamp) " + + "USING iceberg partitioned by (hour(ts))", + tableName); + assertThat(validationCatalog.tableExists(tableIdent)).as("Table should exist").isTrue(); + } + + @TestTemplate + public void testTransformPluralForm() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + sql( + "CREATE TABLE IF NOT EXISTS %s (id BIGINT NOT NULL, ts timestamp) " + + "USING iceberg partitioned by (hours(ts))", + tableName); + assertThat(validationCatalog.tableExists(tableIdent)).as("Table should exist").isTrue(); + } + + @TestTemplate + public void testCreateTable() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql("CREATE TABLE %s (id BIGINT NOT NULL, data STRING) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load the new table").isNotNull(); + + StructType expectedSchema = + StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + assertThat(table.schema().asStruct()) + .as("Should have the expected schema") + .isEqualTo(expectedSchema); + assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); + assertThat(table.properties()).doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT); + } + + @TestTemplate + public void testCreateTablePartitionedByUUID() { + assertThat(validationCatalog.tableExists(tableIdent)).isFalse(); + Schema schema = new Schema(1, Types.NestedField.optional(1, "uuid", Types.UUIDType.get())); + PartitionSpec spec = PartitionSpec.builderFor(schema).bucket("uuid", 16).build(); + validationCatalog.createTable(tableIdent, schema, spec); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).isNotNull(); + + StructType expectedSchema = + StructType.of(Types.NestedField.optional(1, "uuid", Types.UUIDType.get())); + assertThat(table.schema().asStruct()).isEqualTo(expectedSchema); + assertThat(table.spec().fields()).hasSize(1); + + String uuid = UUID.randomUUID().toString(); + + sql("INSERT INTO %s VALUES('%s')", tableName, uuid); + + assertThat(sql("SELECT uuid FROM %s", tableName)).singleElement().isEqualTo(row(uuid)); + } + + @TestTemplate + public void testCreateTableInRootNamespace() { + assumeThat(catalogName) + .as("Hadoop has no default namespace configured") + .isEqualTo("testhadoop"); + + try { + sql("CREATE TABLE %s.table (id bigint) USING iceberg", catalogName); + } finally { + sql("DROP TABLE IF EXISTS %s.table", catalogName); + } + } + + @TestTemplate + public void testCreateTableUsingParquet() { + assumeThat(catalogName) + .as("Not working with session catalog because Spark will not use v2 for a Parquet table") + .isNotEqualTo("spark_catalog"); + + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql("CREATE TABLE %s (id BIGINT NOT NULL, data STRING) USING parquet", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load the new table").isNotNull(); + + StructType expectedSchema = + StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + assertThat(table.schema().asStruct()) + .as("Should have the expected schema") + .isEqualTo(expectedSchema); + assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); + assertThat(table.properties()).containsEntry(TableProperties.DEFAULT_FILE_FORMAT, "parquet"); + + assertThatThrownBy( + () -> + sql( + "CREATE TABLE %s.default.fail (id BIGINT NOT NULL, data STRING) USING crocodile", + catalogName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Unsupported format in USING: crocodile"); + } + + @TestTemplate + public void testCreateTablePartitionedBy() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, created_at TIMESTAMP, category STRING, data STRING) " + + "USING iceberg " + + "PARTITIONED BY (category, bucket(8, id), days(created_at))", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load the new table").isNotNull(); + + StructType expectedSchema = + StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "created_at", Types.TimestampType.withZone()), + NestedField.optional(3, "category", Types.StringType.get()), + NestedField.optional(4, "data", Types.StringType.get())); + assertThat(table.schema().asStruct()) + .as("Should have the expected schema") + .isEqualTo(expectedSchema); + + PartitionSpec expectedSpec = + PartitionSpec.builderFor(new Schema(expectedSchema.fields())) + .identity("category") + .bucket("id", 8) + .day("created_at") + .build(); + assertThat(table.spec()).as("Should be partitioned correctly").isEqualTo(expectedSpec); + assertThat(table.properties()).doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT); + } + + @TestTemplate + public void testCreateTableColumnComments() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL COMMENT 'Unique identifier', data STRING COMMENT 'Data value') " + + "USING iceberg", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load the new table").isNotNull(); + + StructType expectedSchema = + StructType.of( + NestedField.required(1, "id", Types.LongType.get(), "Unique identifier"), + NestedField.optional(2, "data", Types.StringType.get(), "Data value")); + assertThat(table.schema().asStruct()) + .as("Should have the expected schema") + .isEqualTo(expectedSchema); + assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); + assertThat(table.properties()).doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT); + } + + @TestTemplate + public void testCreateTableComment() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "COMMENT 'Table doc'", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load the new table").isNotNull(); + + StructType expectedSchema = + StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + assertThat(table.schema().asStruct()) + .as("Should have the expected schema") + .isEqualTo(expectedSchema); + assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); + assertThat(table.properties()) + .doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT) + .containsEntry(TableProperties.COMMENT, "Table doc"); + } + + @TestTemplate + public void testCreateTableLocation() throws Exception { + assumeThat(validationCatalog) + .as("Cannot set custom locations for Hadoop catalog tables") + .isNotInstanceOf(HadoopCatalog.class); + + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + File tableLocation = Files.createTempDirectory(temp, "junit").toFile(); + assertThat(tableLocation.delete()).isTrue(); + + String location = "file:" + tableLocation; + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "LOCATION '%s'", + tableName, location); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load the new table").isNotNull(); + + StructType expectedSchema = + StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + assertThat(table.schema().asStruct()) + .as("Should have the expected schema") + .isEqualTo(expectedSchema); + assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); + assertThat(table.properties()).doesNotContainKey(TableProperties.DEFAULT_FILE_FORMAT); + assertThat(table.location()).as("Should have a custom table location").isEqualTo(location); + } + + @TestTemplate + public void testCreateTableProperties() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "TBLPROPERTIES (p1=2, p2='x')", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load the new table").isNotNull(); + + StructType expectedSchema = + StructType.of( + NestedField.required(1, "id", Types.LongType.get()), + NestedField.optional(2, "data", Types.StringType.get())); + assertThat(table.schema().asStruct()) + .as("Should have the expected schema") + .isEqualTo(expectedSchema); + assertThat(table.spec().fields()).as("Should not be partitioned").isEmpty(); + assertThat(table.properties()).containsEntry("p1", "2").containsEntry("p2", "x"); + } + + @TestTemplate + public void testCreateTableCommitProperties() { + assumeThat(catalogConfig.get(ICEBERG_CATALOG_TYPE)) + .as( + "need to fix https://github.com/apache/iceberg/issues/11554 before enabling this for the REST catalog") + .isNotEqualTo(ICEBERG_CATALOG_TYPE_REST); + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + assertThatThrownBy( + () -> + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "TBLPROPERTIES ('commit.retry.num-retries'='x', p2='x')", + tableName)) + .isInstanceOf(ValidationException.class) + .hasMessage("Table property commit.retry.num-retries must have integer value"); + + assertThatThrownBy( + () -> + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "TBLPROPERTIES ('commit.retry.max-wait-ms'='-1')", + tableName)) + .isInstanceOf(ValidationException.class) + .hasMessage("Table property commit.retry.max-wait-ms must have non negative integer value"); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "TBLPROPERTIES ('commit.retry.num-retries'='1', 'commit.retry.max-wait-ms'='3000')", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.properties()) + .containsEntry(TableProperties.COMMIT_NUM_RETRIES, "1") + .containsEntry(TableProperties.COMMIT_MAX_RETRY_WAIT_MS, "3000"); + } + + @TestTemplate + public void testCreateTableWithFormatV2ThroughTableProperty() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "TBLPROPERTIES ('format-version'='2')", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(((BaseTable) table).operations().current().formatVersion()) + .as("should create table using format v2") + .isEqualTo(2); + } + + @TestTemplate + public void testUpgradeTableWithFormatV2ThroughTableProperty() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "TBLPROPERTIES ('format-version'='1')", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + TableOperations ops = ((BaseTable) table).operations(); + assertThat(ops.refresh().formatVersion()) + .as("should create table using format v1") + .isEqualTo(1); + + sql("ALTER TABLE %s SET TBLPROPERTIES ('format-version'='2')", tableName); + assertThat(ops.refresh().formatVersion()) + .as("should update table to use format v2") + .isEqualTo(2); + } + + @TestTemplate + public void testDowngradeTableToFormatV1ThroughTablePropertyFails() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + sql( + "CREATE TABLE %s " + + "(id BIGINT NOT NULL, data STRING) " + + "USING iceberg " + + "TBLPROPERTIES ('format-version'='2')", + tableName); + + Table table = validationCatalog.loadTable(tableIdent); + TableOperations ops = ((BaseTable) table).operations(); + assertThat(ops.refresh().formatVersion()) + .as("should create table using format v2") + .isEqualTo(2); + + assertThatThrownBy( + () -> sql("ALTER TABLE %s SET TBLPROPERTIES ('format-version'='1')", tableName)) + .cause() + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot downgrade v2 table to v1"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTableAsSelect.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTableAsSelect.java new file mode 100644 index 000000000000..6cb0ca5a6611 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestCreateTableAsSelect.java @@ -0,0 +1,451 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.spark.sql.functions.col; +import static org.apache.spark.sql.functions.lit; +import static org.apache.spark.sql.functions.when; +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.types.Types; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestCreateTableAsSelect extends CatalogTestBase { + + @Parameter(index = 3) + private String sourceName; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, sourceName = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + SparkCatalogConfig.HIVE.catalogName() + ".default.source" + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + SparkCatalogConfig.HADOOP.catalogName() + ".default.source" + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + "default.source" + } + }; + } + + @BeforeEach + public void createTableIfNotExists() { + sql( + "CREATE TABLE IF NOT EXISTS %s (id bigint NOT NULL, data string) " + + "USING iceberg PARTITIONED BY (truncate(id, 3))", + sourceName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c'), (4, 'd'), (5, 'e')", sourceName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testUnpartitionedCTAS() { + sql("CREATE TABLE %s USING iceberg AS SELECT * FROM %s", tableName, sourceName); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Table ctasTable = validationCatalog.loadTable(tableIdent); + + assertThat(ctasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(ctasTable.spec().fields()).as("Should be an unpartitioned table").isEmpty(); + assertEquals( + "Should have rows matching the source table", + sql("SELECT * FROM %s ORDER BY id", sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testPartitionedCTAS() { + sql( + "CREATE TABLE %s USING iceberg PARTITIONED BY (id) AS SELECT * FROM %s ORDER BY id", + tableName, sourceName); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + PartitionSpec expectedSpec = PartitionSpec.builderFor(expectedSchema).identity("id").build(); + + Table ctasTable = validationCatalog.loadTable(tableIdent); + + assertThat(ctasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(ctasTable.spec()).as("Should be partitioned by id").isEqualTo(expectedSpec); + assertEquals( + "Should have rows matching the source table", + sql("SELECT * FROM %s ORDER BY id", sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testCTASWriteDistributionModeRespected() { + sql( + "CREATE TABLE %s USING iceberg PARTITIONED BY (bucket(2, id)) AS SELECT * FROM %s", + tableName, sourceName); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + PartitionSpec expectedSpec = PartitionSpec.builderFor(expectedSchema).bucket("id", 2).build(); + + Table ctasTable = validationCatalog.loadTable(tableIdent); + + assertThat(ctasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(ctasTable.spec()).as("Should be partitioned by id").isEqualTo(expectedSpec); + assertEquals( + "Should have rows matching the source table", + sql("SELECT * FROM %s ORDER BY id", sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testRTAS() { + sql( + "CREATE TABLE %s USING iceberg TBLPROPERTIES ('prop1'='val1', 'prop2'='val2')" + + "AS SELECT * FROM %s", + tableName, sourceName); + + assertEquals( + "Should have rows matching the source table", + sql("SELECT * FROM %s ORDER BY id", sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql( + "REPLACE TABLE %s USING iceberg PARTITIONED BY (part) TBLPROPERTIES ('prop1'='newval1', 'prop3'='val3') AS " + + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY 3, 1", + tableName, sourceName); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "part", Types.StringType.get())); + + PartitionSpec expectedSpec = + PartitionSpec.builderFor(expectedSchema).identity("part").withSpecId(1).build(); + + Table rtasTable = validationCatalog.loadTable(tableIdent); + + // the replacement table has a different schema and partition spec than the original + assertThat(rtasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(rtasTable.spec()).as("Should be partitioned by part").isEqualTo(expectedSpec); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + assertThat(rtasTable.snapshots()).as("Table should have expected snapshots").hasSize(2); + assertThat(rtasTable.properties()) + .containsEntry("prop1", "newval1") + .containsEntry("prop2", "val2") + .containsEntry("prop3", "val3"); + } + + @TestTemplate + public void testCreateRTAS() { + sql( + "CREATE OR REPLACE TABLE %s USING iceberg PARTITIONED BY (part) AS " + + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY 3, 1", + tableName, sourceName); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql( + "CREATE OR REPLACE TABLE %s USING iceberg PARTITIONED BY (part) AS " + + "SELECT 2 * id as id, data, CASE WHEN ((2 * id) %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY 3, 1", + tableName, sourceName); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "part", Types.StringType.get())); + + PartitionSpec expectedSpec = + PartitionSpec.builderFor(expectedSchema) + .identity("part") + .withSpecId(0) // the spec is identical and should be reused + .build(); + + Table rtasTable = validationCatalog.loadTable(tableIdent); + + // the replacement table has a different schema and partition spec than the original + assertThat(rtasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(rtasTable.spec()).as("Should be partitioned by part").isEqualTo(expectedSpec); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT 2 * id, data, CASE WHEN ((2 * id) %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + assertThat(rtasTable.snapshots()).as("Table should have expected snapshots").hasSize(2); + } + + @TestTemplate + public void testDataFrameV2Create() throws Exception { + spark.table(sourceName).writeTo(tableName).using("iceberg").create(); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get())); + + Table ctasTable = validationCatalog.loadTable(tableIdent); + + assertThat(ctasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(ctasTable.spec().fields()).as("Should be an unpartitioned table").isEmpty(); + assertEquals( + "Should have rows matching the source table", + sql("SELECT * FROM %s ORDER BY id", sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testDataFrameV2Replace() throws Exception { + spark.table(sourceName).writeTo(tableName).using("iceberg").create(); + + assertEquals( + "Should have rows matching the source table", + sql("SELECT * FROM %s ORDER BY id", sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + spark + .table(sourceName) + .select( + col("id"), + col("data"), + when(col("id").mod(lit(2)).equalTo(lit(0)), lit("even")).otherwise("odd").as("part")) + .orderBy("part", "id") + .writeTo(tableName) + .partitionedBy(col("part")) + .using("iceberg") + .replace(); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "part", Types.StringType.get())); + + PartitionSpec expectedSpec = + PartitionSpec.builderFor(expectedSchema).identity("part").withSpecId(1).build(); + + Table rtasTable = validationCatalog.loadTable(tableIdent); + + // the replacement table has a different schema and partition spec than the original + assertThat(rtasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(rtasTable.spec()).as("Should be partitioned by part").isEqualTo(expectedSpec); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + assertThat(rtasTable.snapshots()).as("Table should have expected snapshots").hasSize(2); + } + + @TestTemplate + public void testDataFrameV2CreateOrReplace() { + spark + .table(sourceName) + .select( + col("id"), + col("data"), + when(col("id").mod(lit(2)).equalTo(lit(0)), lit("even")).otherwise("odd").as("part")) + .orderBy("part", "id") + .writeTo(tableName) + .partitionedBy(col("part")) + .using("iceberg") + .createOrReplace(); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + spark + .table(sourceName) + .select(col("id").multiply(lit(2)).as("id"), col("data")) + .select( + col("id"), + col("data"), + when(col("id").mod(lit(2)).equalTo(lit(0)), lit("even")).otherwise("odd").as("part")) + .orderBy("part", "id") + .writeTo(tableName) + .partitionedBy(col("part")) + .using("iceberg") + .createOrReplace(); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "part", Types.StringType.get())); + + PartitionSpec expectedSpec = + PartitionSpec.builderFor(expectedSchema) + .identity("part") + .withSpecId(0) // the spec is identical and should be reused + .build(); + + Table rtasTable = validationCatalog.loadTable(tableIdent); + + // the replacement table has a different schema and partition spec than the original + assertThat(rtasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + assertThat(rtasTable.spec()).as("Should be partitioned by part").isEqualTo(expectedSpec); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT 2 * id, data, CASE WHEN ((2 * id) %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + assertThat(rtasTable.snapshots()).as("Table should have expected snapshots").hasSize(2); + } + + @TestTemplate + public void testCreateRTASWithPartitionSpecChanging() { + sql( + "CREATE OR REPLACE TABLE %s USING iceberg PARTITIONED BY (part) AS " + + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY 3, 1", + tableName, sourceName); + + Table rtasTable = validationCatalog.loadTable(tableIdent); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT id, data, CASE WHEN (id %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + // Change the partitioning of the table + rtasTable.updateSpec().removeField("part").commit(); // Spec 1 + + sql( + "CREATE OR REPLACE TABLE %s USING iceberg PARTITIONED BY (part, id) AS " + + "SELECT 2 * id as id, data, CASE WHEN ((2 * id) %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY 3, 1", + tableName, sourceName); + + Schema expectedSchema = + new Schema( + Types.NestedField.optional(1, "id", Types.LongType.get()), + Types.NestedField.optional(2, "data", Types.StringType.get()), + Types.NestedField.optional(3, "part", Types.StringType.get())); + + PartitionSpec expectedSpec = + PartitionSpec.builderFor(expectedSchema) + .identity("part") + .identity("id") + .withSpecId(2) // The Spec is new + .build(); + + assertThat(rtasTable.spec()).as("Should be partitioned by part and id").isEqualTo(expectedSpec); + + // the replacement table has a different schema and partition spec than the original + assertThat(rtasTable.schema().asStruct()) + .as("Should have expected nullable schema") + .isEqualTo(expectedSchema.asStruct()); + + assertEquals( + "Should have rows matching the source table", + sql( + "SELECT 2 * id, data, CASE WHEN ((2 * id) %% 2) = 0 THEN 'even' ELSE 'odd' END AS part " + + "FROM %s ORDER BY id", + sourceName), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + assertThat(rtasTable.snapshots()).as("Table should have expected snapshots").hasSize(2); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java new file mode 100644 index 000000000000..0d010087cd8b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestDeleteFrom.java @@ -0,0 +1,271 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.List; +import java.util.Map; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.RowLevelOperationMode; +import org.apache.iceberg.SnapshotSummary; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.TestHelpers; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestDeleteFrom extends CatalogTestBase { + @Parameter(index = 3) + private int formatVersion; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, formatVersion = {3}") + protected static Object[][] parameters() { + List parameters = Lists.newArrayList(); + for (Object[] catalogParams : CatalogTestBase.parameters()) { + for (int version : TestHelpers.V2_AND_ABOVE) { + parameters.add( + new Object[] {catalogParams[0], catalogParams[1], catalogParams[2], version}); + } + } + + return parameters.toArray(new Object[0][]); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + private String tableProperties() { + return tableProperties(ImmutableMap.of()); + } + + private String tableProperties(Map additionalProperties) { + ImmutableMap.Builder builder = ImmutableMap.builder(); + builder.putAll(additionalProperties); + builder.put(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion)); + return String.format("TBLPROPERTIES (%s)", tablePropsAsString(builder.buildKeepingLast())); + } + + @TestTemplate + public void testDeleteFromUnpartitionedTable() throws NoSuchTableException { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg %s", tableName, tableProperties()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.coalesce(1).writeTo(tableName).append(); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE id < 2", tableName); + + assertEquals( + "Should have no rows after successful delete", + ImmutableList.of(row(2L, "b"), row(3L, "c")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE id < 4", tableName); + + assertEquals( + "Should have no rows after successful delete", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testDeleteFromTableAtSnapshot() throws NoSuchTableException { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg %s", tableName, tableProperties()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.coalesce(1).writeTo(tableName).append(); + + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + String prefix = "snapshot_id_"; + assertThatThrownBy(() -> sql("DELETE FROM %s.%s WHERE id < 4", tableName, prefix + snapshotId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot modify table with time travel"); + } + + @TestTemplate + public void testDeleteFromPartitionedTable() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id bigint, data string) " + + "USING iceberg " + + "PARTITIONED BY (truncate(id, 2)) " + + "%s", + tableName, tableProperties()); + + List records = + Lists.newArrayList( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.coalesce(1).writeTo(tableName).append(); + + assertEquals( + "Should have 3 rows in 2 partitions", + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE id > 2", tableName); + assertEquals( + "Should have two rows in the second partition", + ImmutableList.of(row(1L, "a"), row(2L, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE id < 2", tableName); + + assertEquals( + "Should have two rows in the second partition", + ImmutableList.of(row(2L, "b")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testDeleteFromWhereFalse() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg %s", + tableName, tableProperties()); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 1 snapshot").hasSize(1); + + sql("DELETE FROM %s WHERE false", tableName); + + table.refresh(); + + assertThat(table.snapshots()).as("Delete should not produce a new snapshot").hasSize(1); + } + + @TestTemplate + public void testTruncate() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg %s", + tableName, tableProperties()); + sql("INSERT INTO TABLE %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c")), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table.snapshots()).as("Should have 1 snapshot").hasSize(1); + + sql("TRUNCATE TABLE %s", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(), + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testDeleteFromTablePartitionedByVarbinary() { + sql( + "CREATE TABLE %s (id bigint NOT NULL, data binary) USING iceberg PARTITIONED BY (data) %s", + tableName, tableProperties()); + sql("INSERT INTO TABLE %s VALUES(1, X'e3bcd1'), (2, X'bcd1')", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47}), row(2L, new byte[] {-68, -47})), + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE data = X'bcd1'", tableName); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47})), + sql("SELECT * FROM %s", tableName)); + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1L, new byte[] {-29, -68, -47})), + sql("SELECT * FROM %s where data = X'e3bcd1'", tableName)); + } + + @TestTemplate + public void truncateWithDVs() throws NoSuchTableException { + assumeThat(formatVersion).isGreaterThanOrEqualTo(3); + + sql( + "CREATE TABLE %s (id bigint NOT NULL, data string) USING iceberg %s", + tableName, + tableProperties( + ImmutableMap.of( + TableProperties.DELETE_MODE, RowLevelOperationMode.MERGE_ON_READ.modeName()))); + List records = + ImmutableList.of( + new SimpleRecord(1, "a"), new SimpleRecord(2, "b"), new SimpleRecord(3, "c")); + Dataset df = spark.createDataFrame(records, SimpleRecord.class); + df.coalesce(1).writeTo(tableName).append(); + + assertThat(sql("SELECT * FROM %s ORDER BY id", tableName)) + .containsExactly(row(1L, "a"), row(2L, "b"), row(3L, "c")); + + sql("DELETE FROM %s WHERE id = 1", tableName); + assertThat(validationCatalog.loadTable(tableIdent).currentSnapshot().summary()) + .containsEntry(SnapshotSummary.ADDED_DVS_PROP, "1") + .containsEntry(SnapshotSummary.ADDED_POS_DELETES_PROP, "1"); + + sql("DELETE FROM %s WHERE id = 2", tableName); + // DVs have been merged into single file + assertThat(validationCatalog.loadTable(tableIdent).currentSnapshot().summary()) + .containsEntry(SnapshotSummary.ADDED_DVS_PROP, "1") + .containsEntry(SnapshotSummary.REMOVED_DVS_PROP, "1") + .containsEntry(SnapshotSummary.ADDED_POS_DELETES_PROP, "2"); + + assertThat(sql("SELECT * FROM %s ORDER BY id", tableName)).containsExactly(row(3L, "c")); + + sql("TRUNCATE TABLE %s", tableName); + assertThat(validationCatalog.loadTable(tableIdent).currentSnapshot().summary()) + .containsEntry(SnapshotSummary.REMOVED_DVS_PROP, "1") + .containsEntry(SnapshotSummary.REMOVED_DELETE_FILES_PROP, "1") + .containsEntry(SnapshotSummary.REMOVED_POS_DELETES_PROP, "2"); + + assertThat(sql("SELECT * FROM %s ORDER BY id", tableName)).isEmpty(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestDropTable.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestDropTable.java new file mode 100644 index 000000000000..07faae52749b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestDropTable.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.exceptions.ValidationException; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Streams; +import org.apache.iceberg.spark.CatalogTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestDropTable extends CatalogTestBase { + + @BeforeEach + public void createTable() { + sql("CREATE TABLE %s (id INT, name STRING) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 'test')", tableName); + } + + @AfterEach + public void removeTable() throws IOException { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testDropTable() throws IOException { + dropTableInternal(); + } + + @TestTemplate + public void testDropTableGCDisabled() throws IOException { + sql("ALTER TABLE %s SET TBLPROPERTIES (gc.enabled = false)", tableName); + dropTableInternal(); + } + + private void dropTableInternal() throws IOException { + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "test")), + sql("SELECT * FROM %s", tableName)); + + List manifestAndFiles = manifestsAndFiles(); + assertThat(manifestAndFiles).as("There should be 2 files for manifests and files").hasSize(2); + assertThat(checkFilesExist(manifestAndFiles, true)).as("All files should exist").isTrue(); + + sql("DROP TABLE %s", tableName); + assertThat(validationCatalog.tableExists(tableIdent)).as("Table should not exist").isFalse(); + + if (catalogName.equals("testhadoop")) { + // HadoopCatalog drop table without purge will delete the base table location. + assertThat(checkFilesExist(manifestAndFiles, false)) + .as("All files should be deleted") + .isTrue(); + } else { + assertThat(checkFilesExist(manifestAndFiles, true)) + .as("All files should not be deleted") + .isTrue(); + } + } + + @TestTemplate + public void testPurgeTable() throws IOException { + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "test")), + sql("SELECT * FROM %s", tableName)); + + List manifestAndFiles = manifestsAndFiles(); + assertThat(manifestAndFiles).as("There should be 2 files for manifests and files").hasSize(2); + assertThat(checkFilesExist(manifestAndFiles, true)).as("All files should exist").isTrue(); + + sql("DROP TABLE %s PURGE", tableName); + assertThat(validationCatalog.tableExists(tableIdent)).as("Table should not exist").isFalse(); + assertThat(checkFilesExist(manifestAndFiles, false)).as("All files should be deleted").isTrue(); + } + + @TestTemplate + public void testPurgeTableGCDisabled() throws IOException { + sql("ALTER TABLE %s SET TBLPROPERTIES (gc.enabled = false)", tableName); + + assertEquals( + "Should have expected rows", + ImmutableList.of(row(1, "test")), + sql("SELECT * FROM %s", tableName)); + + List manifestAndFiles = manifestsAndFiles(); + assertThat(manifestAndFiles).as("There should be 2 files for manifests and files").hasSize(2); + assertThat(checkFilesExist(manifestAndFiles, true)).as("All files should exist").isTrue(); + + assertThatThrownBy(() -> sql("DROP TABLE %s PURGE", tableName)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining( + "Cannot purge table: GC is disabled (deleting files may corrupt other tables"); + + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not been dropped") + .isTrue(); + assertThat(checkFilesExist(manifestAndFiles, true)) + .as("All files should not be deleted") + .isTrue(); + } + + private List manifestsAndFiles() { + List files = sql("SELECT file_path FROM %s.%s", tableName, MetadataTableType.FILES); + List manifests = + sql("SELECT path FROM %s.%s", tableName, MetadataTableType.MANIFESTS); + return Streams.concat(files.stream(), manifests.stream()) + .map(row -> (String) row[0]) + .collect(Collectors.toList()); + } + + private boolean checkFilesExist(List files, boolean shouldExist) throws IOException { + boolean mask = !shouldExist; + if (files.isEmpty()) { + return mask; + } + + FileSystem fs = new Path(files.get(0)).getFileSystem(hiveConf); + return files.stream() + .allMatch( + file -> { + try { + return fs.exists(new Path(file)) ^ mask; + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestFilterPushDown.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestFilterPushDown.java new file mode 100644 index 000000000000..e5a9d63b68d6 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestFilterPushDown.java @@ -0,0 +1,703 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.Table; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.util.ByteBuffers; +import org.apache.iceberg.variants.ShreddedObject; +import org.apache.iceberg.variants.VariantMetadata; +import org.apache.iceberg.variants.Variants; +import org.apache.spark.sql.execution.SparkPlan; +import org.apache.spark.unsafe.types.VariantVal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestFilterPushDown extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, planningMode = {0}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + LOCAL + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + DISTRIBUTED + } + }; + } + + @Parameter(index = 3) + private PlanningMode planningMode; + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS tmp_view"); + } + + @TestTemplate + public void testFilterPushdownWithDecimalValues() { + sql( + "CREATE TABLE %s (id INT, salary DECIMAL(10, 2), dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100.01, 'd1')", tableName); + sql("INSERT INTO %s VALUES (2, 100.05, 'd1')", tableName); + + checkFilters( + "dep = 'd1' AND salary > 100.03" /* query predicate */, + "isnotnull(salary) AND (salary > 100.03)" /* Spark post scan filter */, + "dep IS NOT NULL, salary IS NOT NULL, dep = 'd1', salary > 100.03" /* Iceberg scan filters */, + ImmutableList.of(row(2, new BigDecimal("100.05"), "d1"))); + } + + @TestTemplate + public void testFilterPushdownWithIdentityTransform() { + sql( + "CREATE TABLE %s (id INT, salary INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, 'd1')", tableName); + sql("INSERT INTO %s VALUES (2, 200, 'd2')", tableName); + sql("INSERT INTO %s VALUES (3, 300, 'd3')", tableName); + sql("INSERT INTO %s VALUES (4, 400, 'd4')", tableName); + sql("INSERT INTO %s VALUES (5, 500, 'd5')", tableName); + sql("INSERT INTO %s VALUES (6, 600, null)", tableName); + + checkOnlyIcebergFilters( + "dep IS NULL" /* query predicate */, + "dep IS NULL" /* Iceberg scan filters */, + ImmutableList.of(row(6, 600, null))); + + checkOnlyIcebergFilters( + "dep IS NOT NULL" /* query predicate */, + "dep IS NOT NULL" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100, "d1"), + row(2, 200, "d2"), + row(3, 300, "d3"), + row(4, 400, "d4"), + row(5, 500, "d5"))); + + checkOnlyIcebergFilters( + "dep = 'd3'" /* query predicate */, + "dep IS NOT NULL, dep = 'd3'" /* Iceberg scan filters */, + ImmutableList.of(row(3, 300, "d3"))); + + checkOnlyIcebergFilters( + "dep > 'd3'" /* query predicate */, + "dep IS NOT NULL, dep > 'd3'" /* Iceberg scan filters */, + ImmutableList.of(row(4, 400, "d4"), row(5, 500, "d5"))); + + checkOnlyIcebergFilters( + "dep >= 'd5'" /* query predicate */, + "dep IS NOT NULL, dep >= 'd5'" /* Iceberg scan filters */, + ImmutableList.of(row(5, 500, "d5"))); + + checkOnlyIcebergFilters( + "dep < 'd2'" /* query predicate */, + "dep IS NOT NULL, dep < 'd2'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + + checkOnlyIcebergFilters( + "dep <= 'd2'" /* query predicate */, + "dep IS NOT NULL, dep <= 'd2'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"), row(2, 200, "d2"))); + + checkOnlyIcebergFilters( + "dep <=> 'd3'" /* query predicate */, + "dep = 'd3'" /* Iceberg scan filters */, + ImmutableList.of(row(3, 300, "d3"))); + + checkOnlyIcebergFilters( + "dep IN (null, 'd1')" /* query predicate */, + "dep IN ('d1')" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + + checkOnlyIcebergFilters( + "dep NOT IN ('d2', 'd4')" /* query predicate */, + "(dep IS NOT NULL AND dep NOT IN ('d2', 'd4'))" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"), row(3, 300, "d3"), row(5, 500, "d5"))); + + checkOnlyIcebergFilters( + "dep = 'd1' AND dep IS NOT NULL" /* query predicate */, + "dep = 'd1', dep IS NOT NULL" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + + checkOnlyIcebergFilters( + "dep = 'd1' OR dep = 'd2' OR dep = 'd3'" /* query predicate */, + "((dep = 'd1' OR dep = 'd2') OR dep = 'd3')" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"), row(2, 200, "d2"), row(3, 300, "d3"))); + + checkFilters( + "dep = 'd1' AND id = 1" /* query predicate */, + "isnotnull(id) AND (id = 1)" /* Spark post scan filter */, + "dep IS NOT NULL, id IS NOT NULL, dep = 'd1', id = 1" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + + checkFilters( + "dep = 'd2' OR id = 1" /* query predicate */, + "(dep = d2) OR (id = 1)" /* Spark post scan filter */, + "(dep = 'd2' OR id = 1)" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"), row(2, 200, "d2"))); + + checkFilters( + "dep LIKE 'd1%' AND id = 1" /* query predicate */, + "isnotnull(id) AND (id = 1)" /* Spark post scan filter */, + "dep IS NOT NULL, id IS NOT NULL, dep LIKE 'd1%', id = 1" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + + checkFilters( + "dep NOT LIKE 'd5%' AND (id = 1 OR id = 5)" /* query predicate */, + "(id = 1) OR (id = 5)" /* Spark post scan filter */, + "dep IS NOT NULL, NOT (dep LIKE 'd5%'), (id = 1 OR id = 5)" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + + checkFilters( + "dep LIKE '%d5' AND id IN (1, 5)" /* query predicate */, + "EndsWith(dep, d5) AND id IN (1,5)" /* Spark post scan filter */, + "dep IS NOT NULL, id IN (1, 5)" /* Iceberg scan filters */, + ImmutableList.of(row(5, 500, "d5"))); + } + + @TestTemplate + public void testFilterPushdownWithHoursTransform() { + sql( + "CREATE TABLE %s (id INT, price INT, t TIMESTAMP)" + + "USING iceberg " + + "PARTITIONED BY (hours(t))", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, TIMESTAMP '2021-06-30T01:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (2, 200, TIMESTAMP '2021-06-30T02:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (3, 300, null)", tableName); + + withDefaultTimeZone( + "UTC", + () -> { + checkOnlyIcebergFilters( + "t IS NULL" /* query predicate */, + "t IS NULL" /* Iceberg scan filters */, + ImmutableList.of(row(3, 300, null))); + + // strict/inclusive projections for t < TIMESTAMP '2021-06-30T02:00:00.000Z' are equal, + // so this filter selects entire partitions and can be pushed down completely + checkOnlyIcebergFilters( + "t < TIMESTAMP '2021-06-30T02:00:00.000Z'" /* query predicate */, + "t IS NOT NULL, t < 1625018400000000" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, timestamp("2021-06-30T01:00:00.0Z")))); + + // strict/inclusive projections for t < TIMESTAMP '2021-06-30T01:00:00.001Z' differ, + // so this filter does NOT select entire partitions and can't be pushed down completely + checkFilters( + "t < TIMESTAMP '2021-06-30T01:00:00.001Z'" /* query predicate */, + "t < 2021-06-30 01:00:00.001" /* Spark post scan filter */, + "t IS NOT NULL, t < 1625014800001000" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, timestamp("2021-06-30T01:00:00.0Z")))); + + // strict/inclusive projections for t <= TIMESTAMP '2021-06-30T01:00:00.000Z' differ, + // so this filter does NOT select entire partitions and can't be pushed down completely + checkFilters( + "t <= TIMESTAMP '2021-06-30T01:00:00.000Z'" /* query predicate */, + "t <= 2021-06-30 01:00:00" /* Spark post scan filter */, + "t IS NOT NULL, t <= 1625014800000000" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, timestamp("2021-06-30T01:00:00.0Z")))); + }); + } + + @TestTemplate + public void testFilterPushdownWithDaysTransform() { + sql( + "CREATE TABLE %s (id INT, price INT, t TIMESTAMP)" + + "USING iceberg " + + "PARTITIONED BY (days(t))", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, TIMESTAMP '2021-06-15T01:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (2, 200, TIMESTAMP '2021-06-30T02:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (3, 300, TIMESTAMP '2021-07-15T10:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (4, 400, null)", tableName); + + withDefaultTimeZone( + "UTC", + () -> { + checkOnlyIcebergFilters( + "t IS NULL" /* query predicate */, + "t IS NULL" /* Iceberg scan filters */, + ImmutableList.of(row(4, 400, null))); + + // strict/inclusive projections for t < TIMESTAMP '2021-07-05T00:00:00.000Z' are equal, + // so this filter selects entire partitions and can be pushed down completely + checkOnlyIcebergFilters( + "t < TIMESTAMP '2021-07-05T00:00:00.000Z'" /* query predicate */, + "t IS NOT NULL, t < 1625443200000000" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100, timestamp("2021-06-15T01:00:00.000Z")), + row(2, 200, timestamp("2021-06-30T02:00:00.000Z")))); + + // strict/inclusive projections for t < TIMESTAMP '2021-06-30T03:00:00.000Z' differ, + // so this filter does NOT select entire partitions and can't be pushed down completely + checkFilters( + "t < TIMESTAMP '2021-06-30T03:00:00.000Z'" /* query predicate */, + "t < 2021-06-30 03:00:00" /* Spark post scan filter */, + "t IS NOT NULL, t < 1625022000000000" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100, timestamp("2021-06-15T01:00:00.000Z")), + row(2, 200, timestamp("2021-06-30T02:00:00.000Z")))); + }); + } + + @TestTemplate + public void testFilterPushdownWithMonthsTransform() { + sql( + "CREATE TABLE %s (id INT, price INT, t TIMESTAMP)" + + "USING iceberg " + + "PARTITIONED BY (months(t))", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, TIMESTAMP '2021-06-30T01:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (2, 200, TIMESTAMP '2021-06-30T02:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (3, 300, TIMESTAMP '2021-07-15T10:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (4, 400, null)", tableName); + + withDefaultTimeZone( + "UTC", + () -> { + checkOnlyIcebergFilters( + "t IS NULL" /* query predicate */, + "t IS NULL" /* Iceberg scan filters */, + ImmutableList.of(row(4, 400, null))); + + // strict/inclusive projections for t < TIMESTAMP '2021-07-01T00:00:00.000Z' are equal, + // so this filter selects entire partitions and can be pushed down completely + checkOnlyIcebergFilters( + "t < TIMESTAMP '2021-07-01T00:00:00.000Z'" /* query predicate */, + "t IS NOT NULL, t < 1625097600000000" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100, timestamp("2021-06-30T01:00:00.000Z")), + row(2, 200, timestamp("2021-06-30T02:00:00.000Z")))); + + // strict/inclusive projections for t < TIMESTAMP '2021-06-30T03:00:00.000Z' differ, + // so this filter does NOT select entire partitions and can't be pushed down completely + checkFilters( + "t < TIMESTAMP '2021-06-30T03:00:00.000Z'" /* query predicate */, + "t < 2021-06-30 03:00:00" /* Spark post scan filter */, + "t IS NOT NULL, t < 1625022000000000" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100, timestamp("2021-06-30T01:00:00.000Z")), + row(2, 200, timestamp("2021-06-30T02:00:00.000Z")))); + }); + } + + @TestTemplate + public void testFilterPushdownWithYearsTransform() { + sql( + "CREATE TABLE %s (id INT, price INT, t TIMESTAMP)" + + "USING iceberg " + + "PARTITIONED BY (years(t))", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, TIMESTAMP '2021-06-30T01:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (2, 200, TIMESTAMP '2021-06-30T02:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (2, 200, TIMESTAMP '2022-09-25T02:00:00.000Z')", tableName); + sql("INSERT INTO %s VALUES (3, 300, null)", tableName); + + withDefaultTimeZone( + "UTC", + () -> { + checkOnlyIcebergFilters( + "t IS NULL" /* query predicate */, + "t IS NULL" /* Iceberg scan filters */, + ImmutableList.of(row(3, 300, null))); + + // strict/inclusive projections for t < TIMESTAMP '2022-01-01T00:00:00.000Z' are equal, + // so this filter selects entire partitions and can be pushed down completely + checkOnlyIcebergFilters( + "t < TIMESTAMP '2022-01-01T00:00:00.000Z'" /* query predicate */, + "t IS NOT NULL, t < 1640995200000000" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100, timestamp("2021-06-30T01:00:00.000Z")), + row(2, 200, timestamp("2021-06-30T02:00:00.000Z")))); + + // strict/inclusive projections for t < TIMESTAMP '2021-06-30T03:00:00.000Z' differ, + // so this filter does NOT select entire partitions and can't be pushed down completely + checkFilters( + "t < TIMESTAMP '2021-06-30T03:00:00.000Z'" /* query predicate */, + "t < 2021-06-30 03:00:00" /* Spark post scan filter */, + "t IS NOT NULL, t < 1625022000000000" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100, timestamp("2021-06-30T01:00:00.000Z")), + row(2, 200, timestamp("2021-06-30T02:00:00.000Z")))); + }); + } + + @TestTemplate + public void testFilterPushdownWithBucketTransform() { + sql( + "CREATE TABLE %s (id INT, salary INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep, bucket(8, id))", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, 'd1')", tableName); + sql("INSERT INTO %s VALUES (2, 200, 'd2')", tableName); + + checkFilters( + "dep = 'd1' AND id = 1" /* query predicate */, + "id = 1" /* Spark post scan filter */, + "dep IS NOT NULL, id IS NOT NULL, dep = 'd1'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + } + + @TestTemplate + public void testFilterPushdownWithTruncateTransform() { + sql( + "CREATE TABLE %s (id INT, salary INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (truncate(1, dep))", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, 'd1')", tableName); + sql("INSERT INTO %s VALUES (2, 200, 'd2')", tableName); + sql("INSERT INTO %s VALUES (3, 300, 'a3')", tableName); + + checkOnlyIcebergFilters( + "dep LIKE 'd%'" /* query predicate */, + "dep IS NOT NULL, dep LIKE 'd%'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"), row(2, 200, "d2"))); + + checkFilters( + "dep = 'd1'" /* query predicate */, + "dep = d1" /* Spark post scan filter */, + "dep IS NOT NULL" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + } + + @TestTemplate + public void testFilterPushdownWithSpecEvolutionAndIdentityTransforms() { + sql( + "CREATE TABLE %s (id INT, salary INT, dep STRING, sub_dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, 'd1', 'sd1')", tableName); + + // the filter can be pushed completely because all specs include identity(dep) + checkOnlyIcebergFilters( + "dep = 'd1'" /* query predicate */, + "dep IS NOT NULL, dep = 'd1'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1", "sd1"))); + + Table table = validationCatalog.loadTable(tableIdent); + + table.updateSpec().addField("sub_dep").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO %s VALUES (2, 200, 'd2', 'sd2')", tableName); + + // the filter can be pushed completely because all specs include identity(dep) + checkOnlyIcebergFilters( + "dep = 'd1'" /* query predicate */, + "dep IS NOT NULL, dep = 'd1'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1", "sd1"))); + + table.updateSpec().removeField("sub_dep").removeField("dep").commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO %s VALUES (3, 300, 'd3', 'sd3')", tableName); + + // the filter can't be pushed completely because not all specs include identity(dep) + checkFilters( + "dep = 'd1'" /* query predicate */, + "isnotnull(dep) AND (dep = d1)" /* Spark post scan filter */, + "dep IS NOT NULL, dep = 'd1'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1", "sd1"))); + } + + @TestTemplate + public void testFilterPushdownWithSpecEvolutionAndTruncateTransform() { + sql( + "CREATE TABLE %s (id INT, salary INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (truncate(2, dep))", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100, 'd1')", tableName); + + // the filter can be pushed completely because the current spec supports it + checkOnlyIcebergFilters( + "dep LIKE 'd1%'" /* query predicate */, + "dep IS NOT NULL, dep LIKE 'd1%'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + + Table table = validationCatalog.loadTable(tableIdent); + table + .updateSpec() + .removeField(Expressions.truncate("dep", 2)) + .addField(Expressions.truncate("dep", 1)) + .commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO %s VALUES (2, 200, 'd2')", tableName); + + // the filter can be pushed completely because both specs support it + checkOnlyIcebergFilters( + "dep LIKE 'd%'" /* query predicate */, + "dep IS NOT NULL, dep LIKE 'd%'" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"), row(2, 200, "d2"))); + + // the filter can't be pushed completely because the second spec is truncate(dep, 1) and + // the predicate literal is d1, which is two chars + checkFilters( + "dep LIKE 'd1%' AND id = 1" /* query predicate */, + "(isnotnull(id) AND StartsWith(dep, d1)) AND (id = 1)" /* Spark post scan filter */, + "dep IS NOT NULL, id IS NOT NULL, dep LIKE 'd1%', id = 1" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, "d1"))); + } + + @TestTemplate + public void testFilterPushdownWithSpecEvolutionAndTimeTransforms() { + sql( + "CREATE TABLE %s (id INT, price INT, t TIMESTAMP)" + + "USING iceberg " + + "PARTITIONED BY (hours(t))", + tableName); + configurePlanningMode(planningMode); + + withDefaultTimeZone( + "UTC", + () -> { + sql("INSERT INTO %s VALUES (1, 100, TIMESTAMP '2021-06-30T01:00:00.000Z')", tableName); + + // the filter can be pushed completely because the current spec supports it + checkOnlyIcebergFilters( + "t < TIMESTAMP '2021-07-01T00:00:00.000Z'" /* query predicate */, + "t IS NOT NULL, t < 1625097600000000" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100, timestamp("2021-06-30T01:00:00.000Z")))); + + Table table = validationCatalog.loadTable(tableIdent); + table + .updateSpec() + .removeField(Expressions.hour("t")) + .addField(Expressions.month("t")) + .commit(); + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO %s VALUES (2, 200, TIMESTAMP '2021-05-30T01:00:00.000Z')", tableName); + + // the filter can be pushed completely because both specs support it + checkOnlyIcebergFilters( + "t < TIMESTAMP '2021-06-01T00:00:00.000Z'" /* query predicate */, + "t IS NOT NULL, t < 1622505600000000" /* Iceberg scan filters */, + ImmutableList.of(row(2, 200, timestamp("2021-05-30T01:00:00.000Z")))); + }); + } + + @TestTemplate + public void testFilterPushdownWithSpecialFloatingPointPartitionValues() { + sql( + "CREATE TABLE %s (id INT, salary DOUBLE)" + "USING iceberg " + "PARTITIONED BY (salary)", + tableName); + configurePlanningMode(planningMode); + + sql("INSERT INTO %s VALUES (1, 100.5)", tableName); + sql("INSERT INTO %s VALUES (2, double('NaN'))", tableName); + sql("INSERT INTO %s VALUES (3, double('infinity'))", tableName); + sql("INSERT INTO %s VALUES (4, double('-infinity'))", tableName); + + checkOnlyIcebergFilters( + "salary = 100.5" /* query predicate */, + "salary IS NOT NULL, salary = 100.5" /* Iceberg scan filters */, + ImmutableList.of(row(1, 100.5))); + + checkOnlyIcebergFilters( + "salary = double('NaN')" /* query predicate */, + "salary IS NOT NULL, is_nan(salary)" /* Iceberg scan filters */, + ImmutableList.of(row(2, Double.NaN))); + + checkOnlyIcebergFilters( + "salary != double('NaN')" /* query predicate */, + "salary IS NOT NULL, NOT (is_nan(salary))" /* Iceberg scan filters */, + ImmutableList.of( + row(1, 100.5), row(3, Double.POSITIVE_INFINITY), row(4, Double.NEGATIVE_INFINITY))); + + checkOnlyIcebergFilters( + "salary = double('infinity')" /* query predicate */, + "salary IS NOT NULL, salary = Infinity" /* Iceberg scan filters */, + ImmutableList.of(row(3, Double.POSITIVE_INFINITY))); + + checkOnlyIcebergFilters( + "salary = double('-infinity')" /* query predicate */, + "salary IS NOT NULL, salary = -Infinity" /* Iceberg scan filters */, + ImmutableList.of(row(4, Double.NEGATIVE_INFINITY))); + } + + @TestTemplate + public void testVariantExtractFiltering() { + sql( + "CREATE TABLE %s (id BIGINT, data VARIANT) USING iceberg TBLPROPERTIES" + + "('format-version'='3')", + tableName); + configurePlanningMode(planningMode); + + sql( + "INSERT INTO %s VALUES " + + "(1, parse_json('{\"field\": \"foo\", \"num\": 25}')), " + + "(2, parse_json('{\"field\": \"bar\", \"num\": 30}')), " + + "(3, parse_json('{\"field\": \"baz\", \"num\": 35}')), " + + "(4, null)", + tableName); + + withDefaultTimeZone( + "UTC", + () -> { + checkFilters( + "try_variant_get(data, '$.num', 'int') IS NOT NULL", + "isnotnull(data) AND isnotnull(try_variant_get(data, $.num, IntegerType, false, Some(UTC)))", + "data IS NOT NULL", + ImmutableList.of( + row(1L, toSparkVariantRow("foo", 25)), + row(2L, toSparkVariantRow("bar", 30)), + row(3L, toSparkVariantRow("baz", 35)))); + + checkFilters( + "try_variant_get(data, '$.num', 'int') IS NULL", + "isnull(try_variant_get(data, $.num, IntegerType, false, Some(UTC)))", + "", + ImmutableList.of(row(4L, null))); + + checkFilters( + "try_variant_get(data, '$.num', 'int') > 30", + "isnotnull(data) AND (try_variant_get(data, $.num, IntegerType, false, Some(UTC)) > 30)", + "data IS NOT NULL", + ImmutableList.of(row(3L, toSparkVariantRow("baz", 35)))); + + checkFilters( + "try_variant_get(data, '$.num', 'int') = 30", + "isnotnull(data) AND (try_variant_get(data, $.num, IntegerType, false, Some(UTC)) = 30)", + "data IS NOT NULL", + ImmutableList.of(row(2L, toSparkVariantRow("bar", 30)))); + + checkFilters( + "try_variant_get(data, '$.num', 'int') IN (25, 35)", + "try_variant_get(data, $.num, IntegerType, false, Some(UTC)) IN (25,35)", + "", + ImmutableList.of( + row(1L, toSparkVariantRow("foo", 25)), row(3L, toSparkVariantRow("baz", 35)))); + + checkFilters( + "try_variant_get(data, '$.num', 'int') != 25", + "isnotnull(data) AND NOT (try_variant_get(data, $.num, IntegerType, false, Some(UTC)) = 25)", + "data IS NOT NULL", + ImmutableList.of( + row(2L, toSparkVariantRow("bar", 30)), row(3L, toSparkVariantRow("baz", 35)))); + }); + } + + private void checkOnlyIcebergFilters( + String predicate, String icebergFilters, List expectedRows) { + + checkFilters(predicate, null, icebergFilters, expectedRows); + } + + private void checkFilters( + String predicate, String sparkFilter, String icebergFilters, List expectedRows) { + + Action check = + () -> { + assertEquals( + "Rows must match", + expectedRows, + sql("SELECT * FROM %s WHERE %s ORDER BY id", tableName, predicate)); + }; + SparkPlan sparkPlan = executeAndKeepPlan(check); + String planAsString = sparkPlan.toString().replaceAll("#(\\d+L?)", ""); + + if (sparkFilter != null) { + assertThat(planAsString) + .as("Post scan filter should match") + .containsAnyOf("Filter (" + sparkFilter + ")", "Filter " + sparkFilter); + } else { + assertThat(planAsString).as("Should be no post scan filter").doesNotContain("Filter ("); + } + + assertThat(planAsString) + .as("Pushed filters must match") + .contains(", filters=" + icebergFilters + ","); + } + + private Timestamp timestamp(String timestampAsString) { + return Timestamp.from(Instant.parse(timestampAsString)); + } + + private VariantVal toSparkVariantRow(String field, int num) { + VariantMetadata metadata = Variants.metadata("field", "num"); + + ShreddedObject obj = Variants.object(metadata); + obj.put("field", Variants.of(field)); + obj.put("num", Variants.of(num)); + + ByteBuffer metadataBuffer = + ByteBuffer.allocate(metadata.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + metadata.writeTo(metadataBuffer, 0); + + ByteBuffer valueBuffer = ByteBuffer.allocate(obj.sizeInBytes()).order(ByteOrder.LITTLE_ENDIAN); + obj.writeTo(valueBuffer, 0); + + return new VariantVal( + ByteBuffers.toByteArray(valueBuffer), ByteBuffers.toByteArray(metadataBuffer)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestNamespaceSQL.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestNamespaceSQL.java new file mode 100644 index 000000000000..d702f65e640b --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestNamespaceSQL.java @@ -0,0 +1,302 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NamespaceNotEmptyException; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestNamespaceSQL extends CatalogTestBase { + private static final Namespace NS = Namespace.of("db"); + + @Parameter(index = 3) + private String fullNamespace; + + @Parameter(index = 4) + private boolean isHadoopCatalog; + + @Parameters( + name = + "catalogName = {0}, implementation = {1}, config = {2}, fullNameSpace = {3}, isHadoopCatalog = {4}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + SparkCatalogConfig.HIVE.catalogName() + "." + NS.toString(), + false + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + SparkCatalogConfig.HADOOP.catalogName() + "." + NS, + true + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + NS.toString(), + false + } + }; + } + + @AfterEach + public void cleanNamespaces() { + sql("DROP TABLE IF EXISTS %s.table", fullNamespace); + sql("DROP NAMESPACE IF EXISTS %s", fullNamespace); + } + + @TestTemplate + public void testCreateNamespace() { + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + } + + @TestTemplate + public void testDefaultNamespace() { + assumeThat(isHadoopCatalog).as("Hadoop has no default namespace configured").isFalse(); + + sql("USE %s", catalogName); + + assertThat(sql("SHOW CURRENT NAMESPACE")) + .singleElement() + .satisfies( + ns -> { + assertThat(ns).containsExactly(catalogName, "default"); + }); + } + + @TestTemplate + public void testDropEmptyNamespace() { + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + + sql("DROP NAMESPACE %s", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should have been dropped") + .isFalse(); + } + + @TestTemplate + public void testDropNonEmptyNamespace() { + assumeThat(catalogName).as("Session catalog has flaky behavior").isNotEqualTo("spark_catalog"); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s", fullNamespace); + sql("CREATE TABLE %s.table (id bigint) USING iceberg", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + assertThat(validationCatalog.tableExists(TableIdentifier.of(NS, "table"))) + .as("Table should exist") + .isTrue(); + + assertThatThrownBy(() -> sql("DROP NAMESPACE %s", fullNamespace)) + .isInstanceOf(NamespaceNotEmptyException.class) + .hasMessageStartingWith("Namespace db is not empty."); + + sql("DROP TABLE %s.table", fullNamespace); + } + + @TestTemplate + public void testListTables() { + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + + List rows = sql("SHOW TABLES IN %s", fullNamespace); + assertThat(rows).as("Should not list any tables").isEmpty(); + + sql("CREATE TABLE %s.table (id bigint) USING iceberg", fullNamespace); + + assertThat(sql("SHOW TABLES IN %s", fullNamespace)) + .singleElement() + .satisfies( + row -> { + assertThat(row).containsExactly("db", "table", false); + }); + } + + @TestTemplate + public void testListNamespace() { + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + + List namespaces = sql("SHOW NAMESPACES IN %s", catalogName); + + if (isHadoopCatalog) { + assertThat(namespaces) + .singleElement() + .satisfies( + ns -> { + assertThat(ns).containsExactly("db"); + }); + } else { + assertThat(namespaces).as("Should have 2 namespaces").hasSize(2); + Set namespaceNames = + namespaces.stream().map(arr -> arr[0].toString()).collect(Collectors.toSet()); + assertThat(namespaceNames) + .as("Should have default and db namespaces") + .containsExactlyInAnyOrder("default", "db"); + } + + List nestedNamespaces = sql("SHOW NAMESPACES IN %s", fullNamespace); + assertThat(nestedNamespaces).as("Should not have nested namespaces").isEmpty(); + } + + @TestTemplate + public void testCreateNamespaceWithMetadata() { + assumeThat(isHadoopCatalog).as("HadoopCatalog does not support namespace metadata").isFalse(); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s WITH PROPERTIES ('prop'='value')", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + + Map nsMetadata = validationNamespaceCatalog.loadNamespaceMetadata(NS); + + assertThat(nsMetadata).containsEntry("prop", "value"); + } + + @TestTemplate + public void testCreateNamespaceWithComment() { + assumeThat(isHadoopCatalog).as("HadoopCatalog does not support namespace metadata").isFalse(); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s COMMENT 'namespace doc'", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + + Map nsMetadata = validationNamespaceCatalog.loadNamespaceMetadata(NS); + + assertThat(nsMetadata).containsEntry("comment", "namespace doc"); + } + + @TestTemplate + public void testCreateNamespaceWithLocation() throws Exception { + assumeThat(isHadoopCatalog).as("HadoopCatalog does not support namespace metadata").isFalse(); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + File location = File.createTempFile("junit", null, temp.toFile()); + assertThat(location.delete()).isTrue(); + + sql("CREATE NAMESPACE %s LOCATION '%s'", fullNamespace, location); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + + Map nsMetadata = validationNamespaceCatalog.loadNamespaceMetadata(NS); + + assertThat(nsMetadata).containsEntry("location", "file:" + location.getPath()); + } + + @TestTemplate + public void testSetProperties() { + assumeThat(isHadoopCatalog).as("HadoopCatalog does not support namespace metadata").isFalse(); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should not already exist") + .isFalse(); + + sql("CREATE NAMESPACE %s", fullNamespace); + + assertThat(validationNamespaceCatalog.namespaceExists(NS)) + .as("Namespace should exist") + .isTrue(); + + Map defaultMetadata = validationNamespaceCatalog.loadNamespaceMetadata(NS); + assertThat(defaultMetadata) + .as("Default metadata should not have custom property") + .doesNotContainKey("prop"); + + sql("ALTER NAMESPACE %s SET PROPERTIES ('prop'='value')", fullNamespace); + + Map nsMetadata = validationNamespaceCatalog.loadNamespaceMetadata(NS); + + assertThat(nsMetadata).containsEntry("prop", "value"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWrites.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWrites.java new file mode 100644 index 000000000000..800d17dd4559 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWrites.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +public class TestPartitionedWrites extends PartitionedWritesTestBase {} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWritesAsSelect.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWritesAsSelect.java new file mode 100644 index 000000000000..fde0f0a39a9f --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWritesAsSelect.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.stream.IntStream; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.spark.IcebergSpark; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.types.DataTypes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPartitionedWritesAsSelect extends TestBaseWithCatalog { + + @Parameter(index = 3) + private String targetTable; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, targetTable = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + SparkCatalogConfig.HADOOP.catalogName() + ".default.target_table" + }, + }; + } + + @BeforeEach + public void createTables() { + sql( + "CREATE TABLE %s (id bigint, data string, category string, ts timestamp) USING iceberg", + tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", targetTable); + } + + @TestTemplate + public void testInsertAsSelectAppend() { + insertData(3); + List expected = currentData(); + + sql( + "CREATE TABLE %s (id bigint, data string, category string, ts timestamp)" + + "USING iceberg PARTITIONED BY (days(ts), category)", + targetTable); + + sql( + "INSERT INTO %s SELECT id, data, category, ts FROM %s ORDER BY ts,category", + targetTable, tableName); + assertThat(scalarSql("SELECT count(*) FROM %s", targetTable)) + .as("Should have 15 rows after insert") + .isEqualTo(3 * 5L); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", targetTable)); + } + + @TestTemplate + public void testInsertAsSelectWithBucket() { + insertData(3); + List expected = currentData(); + + sql( + "CREATE TABLE %s (id bigint, data string, category string, ts timestamp)" + + "USING iceberg PARTITIONED BY (bucket(8, data))", + targetTable); + + IcebergSpark.registerBucketUDF(spark, "iceberg_bucket8", DataTypes.StringType, 8); + sql( + "INSERT INTO %s SELECT id, data, category, ts FROM %s ORDER BY iceberg_bucket8(data)", + targetTable, tableName); + assertThat(scalarSql("SELECT count(*) FROM %s", targetTable)) + .as("Should have 15 rows after insert") + .isEqualTo(3 * 5L); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", targetTable)); + } + + @TestTemplate + public void testInsertAsSelectWithTruncate() { + insertData(3); + List expected = currentData(); + + sql( + "CREATE TABLE %s (id bigint, data string, category string, ts timestamp)" + + "USING iceberg PARTITIONED BY (truncate(data, 4), truncate(id, 4))", + targetTable); + + IcebergSpark.registerTruncateUDF(spark, "iceberg_truncate_string4", DataTypes.StringType, 4); + IcebergSpark.registerTruncateUDF(spark, "iceberg_truncate_long4", DataTypes.LongType, 4); + sql( + "INSERT INTO %s SELECT id, data, category, ts FROM %s " + + "ORDER BY iceberg_truncate_string4(data),iceberg_truncate_long4(id)", + targetTable, tableName); + assertThat(scalarSql("SELECT count(*) FROM %s", targetTable)) + .as("Should have 15 rows after insert") + .isEqualTo(3 * 5L); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", targetTable)); + } + + private void insertData(int repeatCounter) { + IntStream.range(0, repeatCounter) + .forEach( + i -> { + sql( + "INSERT INTO %s VALUES (13, '1', 'bgd16', timestamp('2021-11-10 11:20:10'))," + + "(21, '2', 'bgd13', timestamp('2021-11-10 11:20:10')), " + + "(12, '3', 'bgd14', timestamp('2021-11-10 11:20:10'))," + + "(222, '3', 'bgd15', timestamp('2021-11-10 11:20:10'))," + + "(45, '4', 'bgd16', timestamp('2021-11-10 11:20:10'))", + tableName); + }); + } + + private List currentData() { + return rowsToJava(spark.sql("SELECT * FROM " + tableName + " order by id").collectAsList()); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWritesToBranch.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWritesToBranch.java new file mode 100644 index 000000000000..0a90d3ffec75 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestPartitionedWritesToBranch.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestPartitionedWritesToBranch extends PartitionedWritesTestBase { + + private static final String BRANCH = "test"; + + @BeforeEach + @Override + public void createTables() { + super.createTables(); + Table table = validationCatalog.loadTable(tableIdent); + table.manageSnapshots().createBranch(BRANCH, table.currentSnapshot().snapshotId()).commit(); + sql("REFRESH TABLE " + tableName); + } + + @Override + protected String commitTarget() { + return String.format("%s.branch_%s", tableName, BRANCH); + } + + @Override + protected String selectTarget() { + return String.format("%s VERSION AS OF '%s'", tableName, BRANCH); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java new file mode 100644 index 000000000000..34e9dbc87d02 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestRefreshTable.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import java.util.List; +import java.util.Set; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.SnapshotChanges; +import org.apache.iceberg.Table; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestRefreshTable extends CatalogTestBase { + + @BeforeEach + public void createTables() { + sql("CREATE TABLE %s (key int, value int) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1,1)", tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testRefreshCommand() { + // We are not allowed to change the session catalog after it has been initialized, so build a + // new one + if (Set.of( + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.REST.catalogName()) + .contains(catalogName)) { + spark.conf().set("spark.sql.catalog." + catalogName + ".cache-enabled", true); + + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + spark = ((org.apache.spark.sql.classic.SparkSession) spark).cloneSession(); + } + + List originalExpected = ImmutableList.of(row(1, 1)); + List originalActual = sql("SELECT * FROM %s", tableName); + assertEquals("Table should start as expected", originalExpected, originalActual); + + // Modify table outside of spark, it should be cached so Spark should see the same value after + // mutation + Table table = validationCatalog.loadTable(tableIdent); + DataFile file = SnapshotChanges.builderFor(table).build().addedDataFiles().iterator().next(); + table.newDelete().deleteFile(file).commit(); + + List cachedActual = sql("SELECT * FROM %s", tableName); + assertEquals("Cached table should be unchanged", originalExpected, cachedActual); + + // Refresh the Spark catalog, should be empty + sql("REFRESH TABLE %s", tableName); + List refreshedExpected = ImmutableList.of(); + List refreshedActual = sql("SELECT * FROM %s", tableName); + assertEquals("Refreshed table should be empty", refreshedExpected, refreshedActual); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java new file mode 100644 index 000000000000..6270296b01b0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSelect.java @@ -0,0 +1,773 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.iceberg.TableProperties.SPLIT_SIZE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.events.Listeners; +import org.apache.iceberg.events.ScanEvent; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.Spark3Util; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkReadOptions; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSelect extends CatalogTestBase { + private int scanEventCount = 0; + private ScanEvent lastScanEvent = null; + + @Parameter(index = 3) + private String binaryTableName; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, binaryTableName = {3}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + SparkCatalogConfig.HIVE.properties(), + SparkCatalogConfig.HIVE.catalogName() + ".default.binary_table" + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + SparkCatalogConfig.HADOOP.catalogName() + ".default.binary_table" + }, + { + SparkCatalogConfig.SPARK_SESSION.catalogName(), + SparkCatalogConfig.SPARK_SESSION.implementation(), + SparkCatalogConfig.SPARK_SESSION.properties(), + "default.binary_table" + } + }; + } + + @BeforeEach + public void createTables() { + // register a scan event listener to validate pushdown + Listeners.register( + event -> { + scanEventCount += 1; + lastScanEvent = event; + }, + ScanEvent.class); + + sql("CREATE TABLE %s (id bigint, data string, float float) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 'a', 1.0), (2, 'b', 2.0), (3, 'c', float('NaN'))", tableName); + + this.scanEventCount = 0; + this.lastScanEvent = null; + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", binaryTableName); + } + + @TestTemplate + public void testSelect() { + List expected = + ImmutableList.of(row(1L, "a", 1.0F), row(2L, "b", 2.0F), row(3L, "c", Float.NaN)); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testSelectWithSpecifiedTargetSplitSize() { + List expected = + ImmutableList.of(row(1L, "a", 1.0F), row(2L, "b", 2.0F), row(3L, "c", Float.NaN)); + + Table table = validationCatalog.loadTable(tableIdent); + table.updateProperties().set("read.split.target-size", "1024").commit(); + spark.sql("REFRESH TABLE " + tableName); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); + + // Query failed when `SPLIT_SIZE` < 0 + table.updateProperties().set(SPLIT_SIZE, "-1").commit(); + spark.sql("REFRESH TABLE " + tableName); + assertThatThrownBy(() -> sql("SELECT * FROM %s", tableName)) + .hasMessageContaining("Split size must be > 0: -1") + .isInstanceOf(IllegalArgumentException.class); + + // Query failed when `SPLIT_SIZE` == 0 + table.updateProperties().set(SPLIT_SIZE, "0").commit(); + spark.sql("REFRESH TABLE " + tableName); + assertThatThrownBy(() -> sql("SELECT * FROM %s", tableName)) + .hasMessageContaining("Split size must be > 0: 0") + .isInstanceOf(IllegalArgumentException.class); + } + + @TestTemplate + public void testSelectRewrite() { + List expected = ImmutableList.of(row(3L, "c", Float.NaN)); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s where float = float('NaN')", tableName)); + + assertThat(scanEventCount).as("Should create only one scan").isEqualTo(1); + assertThat(Spark3Util.describe(lastScanEvent.filter())) + .as("Should push down expected filter") + .isEqualTo("(float IS NOT NULL AND is_nan(float))"); + } + + @TestTemplate + public void selectWithLimit() { + Object[] first = row(1L, "a", 1.0F); + Object[] second = row(2L, "b", 2.0F); + Object[] third = row(3L, "c", Float.NaN); + + // verify that LIMIT is properly applied in case SupportsPushDownLimit.isPartiallyPushed() is + // ever overridden in SparkScanBuilder + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 1", tableName)).containsExactly(first); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 2", tableName)) + .containsExactly(first, second); + assertThat(sql("SELECT * FROM %s ORDER BY id LIMIT 3", tableName)) + .containsExactly(first, second, third); + } + + @TestTemplate + public void testProjection() { + List expected = ImmutableList.of(row(1L), row(2L), row(3L)); + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id FROM %s ORDER BY id", tableName)); + + assertThat(scanEventCount).as("Should create only one scan").isEqualTo(1); + assertThat(lastScanEvent.filter()) + .as("Should not push down a filter") + .isEqualTo(Expressions.alwaysTrue()); + assertThat(lastScanEvent.projection().asStruct()) + .as("Should project only the id column") + .isEqualTo(validationCatalog.loadTable(tableIdent).schema().select("id").asStruct()); + } + + @TestTemplate + public void testExpressionPushdown() { + List expected = ImmutableList.of(row("b")); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT data FROM %s WHERE id = 2", tableName)); + + assertThat(scanEventCount).as("Should create only one scan").isEqualTo(1); + assertThat(Spark3Util.describe(lastScanEvent.filter())) + .as("Should push down expected filter") + .isEqualTo("(id IS NOT NULL AND id = 2)"); + assertThat(lastScanEvent.projection().asStruct()) + .as("Should project only id and data columns") + .isEqualTo( + validationCatalog.loadTable(tableIdent).schema().select("id", "data").asStruct()); + } + + @TestTemplate + public void testMetadataTables() { + assertEquals( + "Snapshot metadata table", + ImmutableList.of(row(ANY, ANY, null, "append", ANY, ANY)), + sql("SELECT * FROM %s.snapshots", tableName)); + } + + @TestTemplate + public void testSnapshotInTableName() { + // get the snapshot ID of the last write and get the current row set as expected + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); + + // create a second snapshot + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + String prefix = "snapshot_id_"; + // read the table at the snapshot + List actual = sql("SELECT * FROM %s.%s ORDER by id", tableName, prefix + snapshotId); + assertEquals("Snapshot at specific ID, prefix " + prefix, expected, actual); + + // read the table using DataFrameReader option + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotId) + .load(tableName) + .orderBy("id"); + List fromDF = rowsToJava(df.collectAsList()); + assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); + } + + @TestTemplate + public void testTimestampInTableName() { + // get a timestamp just after the last write and get the current row set as expected + long snapshotTs = validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis(); + long timestamp = waitUntilAfter(snapshotTs + 2); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + String formattedDate = sdf.format(new Date(timestamp)); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); + + // create a second snapshot + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + String prefix = "at_timestamp_"; + // read the table at the snapshot + List actual = sql("SELECT * FROM %s.%s ORDER by id", tableName, prefix + timestamp); + assertEquals("Snapshot at timestamp, prefix " + prefix, expected, actual); + + // read the table using DataFrameReader option + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedDate) + .load(tableName) + .orderBy("id"); + List fromDF = rowsToJava(df.collectAsList()); + assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); + } + + @TestTemplate + public void testVersionAsOf() { + // get the snapshot ID of the last write and get the current row set as expected + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); + + // create a second snapshot + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + // read the table at the snapshot + List actual1 = + sql("SELECT * FROM %s VERSION AS OF %s ORDER BY id", tableName, snapshotId); + assertEquals("Snapshot at specific ID", expected, actual1); + + // read the table at the snapshot + // HIVE time travel syntax + List actual2 = + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF %s ORDER BY id", tableName, snapshotId); + assertEquals("Snapshot at specific ID", expected, actual2); + + // read the table using DataFrameReader option: versionAsOf + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotId) + .load(tableName) + .orderBy("id"); + List fromDF = rowsToJava(df.collectAsList()); + assertEquals("Snapshot at specific ID " + snapshotId, expected, fromDF); + } + + @TestTemplate + public void testTagReference() { + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createTag("test_tag", snapshotId).commit(); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); + + // create a second snapshot, read the table at the tag + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_tag' ORDER by id", tableName); + assertEquals("Snapshot at specific tag reference name", expected, actual1); + + // read the table at the tag + // HIVE time travel syntax + List actual2 = + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_tag' ORDER by id", tableName); + assertEquals("Snapshot at specific tag reference name", expected, actual2); + + // Spark session catalog does not support extended table names + if (!"spark_catalog".equals(catalogName)) { + // read the table using the "tag_" prefix in the table name + List actual3 = sql("SELECT * FROM %s.tag_test_tag ORDER by id", tableName); + assertEquals("Snapshot at specific tag reference name, prefix", expected, actual3); + } + + // read the table using DataFrameReader option: tag + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, "test_tag") + .load(tableName) + .orderBy("id"); + List fromDF = rowsToJava(df.collectAsList()); + assertEquals("Snapshot at specific tag reference name", expected, fromDF); + } + + @TestTemplate + public void testUseSnapshotIdForTagReferenceAsOf() { + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId1 = table.currentSnapshot().snapshotId(); + + // create a second snapshot, read the table at the snapshot + List actual = sql("SELECT * FROM %s ORDER by id", tableName); + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + table.refresh(); + long snapshotId2 = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createTag(Long.toString(snapshotId1), snapshotId2).commit(); + + // currently Spark version travel ignores the type of the AS OF + // this means if a tag name matches a snapshot ID, it will always choose snapshotID to travel + // to. + List travelWithStringResult = + sql("SELECT * FROM %s VERSION AS OF '%s' ORDER by id", tableName, snapshotId1); + assertEquals("Snapshot at specific tag reference name", actual, travelWithStringResult); + + List travelWithLongResult = + sql("SELECT * FROM %s VERSION AS OF %s ORDER by id", tableName, snapshotId1); + assertEquals("Snapshot at specific tag reference name", actual, travelWithLongResult); + } + + @TestTemplate + public void testBranchReference() { + Table table = validationCatalog.loadTable(tableIdent); + long snapshotId = table.currentSnapshot().snapshotId(); + table.manageSnapshots().createBranch("test_branch", snapshotId).commit(); + List expected = sql("SELECT * FROM %s ORDER by id", tableName); + + // create a second snapshot, read the table at the branch + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + List actual1 = + sql("SELECT * FROM %s VERSION AS OF 'test_branch' ORDER by id", tableName); + assertEquals("Snapshot at specific branch reference name", expected, actual1); + + // read the table at the branch + // HIVE time travel syntax + List actual2 = + sql("SELECT * FROM %s FOR SYSTEM_VERSION AS OF 'test_branch' ORDER by id", tableName); + assertEquals("Snapshot at specific branch reference name", expected, actual2); + + // Spark session catalog does not support extended table names + if (!"spark_catalog".equals(catalogName)) { + // read the table using the "branch_" prefix in the table name + List actual3 = sql("SELECT * FROM %s.branch_test_branch ORDER by id", tableName); + assertEquals("Snapshot at specific branch reference name, prefix", expected, actual3); + } + + // read the table using DataFrameReader option: branch + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.BRANCH, "test_branch") + .load(tableName) + .orderBy("id"); + List fromDF = rowsToJava(df.collectAsList()); + assertEquals("Snapshot at specific branch reference name", expected, fromDF); + } + + @TestTemplate + public void readAndWriteWithBranchAfterSchemaChange() { + Table table = validationCatalog.loadTable(tableIdent); + String branchName = "test_branch"; + table.manageSnapshots().createBranch(branchName, table.currentSnapshot().snapshotId()).commit(); + + List expected = + Arrays.asList(row(1L, "a", 1.0f), row(2L, "b", 2.0f), row(3L, "c", Float.NaN)); + assertThat(sql("SELECT * FROM %s", tableName)).containsExactlyInAnyOrderElementsOf(expected); + + // change schema on the table and add more data + sql("ALTER TABLE %s DROP COLUMN float", tableName); + sql("ALTER TABLE %s ADD COLUMN new_col date", tableName); + sql( + "INSERT INTO %s VALUES (4, 'd', date('2024-04-04')), (5, 'e', date('2024-05-05'))", + tableName); + + // time-travel query using snapshot id should return the snapshot's schema + long branchSnapshotId = table.refs().get(branchName).snapshotId(); + assertThat(sql("SELECT * FROM %s VERSION AS OF %s ORDER by id", tableName, branchSnapshotId)) + .containsExactlyElementsOf(expected); + + // querying the head of the branch should return the table's schema + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER by id", tableName, branchName)) + .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); + + if (!"spark_catalog".equals(catalogName)) { + // querying the head of the branch using 'branch_' should return the table's schema + assertThat(sql("SELECT * FROM %s.branch_%s ORDER by id", tableName, branchName)) + .containsExactly(row(1L, "a", null), row(2L, "b", null), row(3L, "c", null)); + } + + // writing to a branch uses the table's schema + sql( + "INSERT INTO %s.branch_%s VALUES (6L, 'f', cast('2023-06-06' as date)), (7L, 'g', cast('2023-07-07' as date))", + tableName, branchName); + + // querying the head of the branch returns the table's schema + assertThat(sql("SELECT * FROM %s VERSION AS OF '%s' ORDER by id", tableName, branchName)) + .containsExactlyInAnyOrder( + row(1L, "a", null), + row(2L, "b", null), + row(3L, "c", null), + row(6L, "f", java.sql.Date.valueOf("2023-06-06")), + row(7L, "g", java.sql.Date.valueOf("2023-07-07"))); + + // using DataFrameReader with the 'branch' option should return the table's schema + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.BRANCH, branchName) + .load(tableName) + .orderBy("id"); + assertThat(rowsToJava(df.collectAsList())) + .containsExactlyInAnyOrder( + row(1L, "a", null), + row(2L, "b", null), + row(3L, "c", null), + row(6L, "f", java.sql.Date.valueOf("2023-06-06")), + row(7L, "g", java.sql.Date.valueOf("2023-07-07"))); + } + + @TestTemplate + public void testUnknownReferenceAsOf() { + assertThatThrownBy(() -> sql("SELECT * FROM %s VERSION AS OF 'test_unknown'", tableName)) + .hasMessageContaining("Cannot find matching snapshot ID or reference name for version") + .isInstanceOf(IllegalArgumentException.class); + } + + @TestTemplate + public void testTimestampAsOf() { + long snapshotTs = validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis(); + long timestamp = waitUntilAfter(snapshotTs + 1000); + waitUntilAfter(timestamp + 1000); + // AS OF expects the timestamp if given in long format will be of seconds precision + long timestampInSeconds = TimeUnit.MILLISECONDS.toSeconds(timestamp); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String formattedDate = sdf.format(new Date(timestamp)); + + List expected = sql("SELECT * FROM %s ORDER BY id", tableName); + + // create a second snapshot + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + // read the table at the timestamp in long format i.e 1656507980463. + List actualWithLongFormat = + sql("SELECT * FROM %s TIMESTAMP AS OF %s ORDER BY id", tableName, timestampInSeconds); + assertEquals("Snapshot at timestamp", expected, actualWithLongFormat); + + // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 + List actualWithDateFormat = + sql("SELECT * FROM %s TIMESTAMP AS OF '%s' ORDER BY id", tableName, formattedDate); + assertEquals("Snapshot at timestamp", expected, actualWithDateFormat); + + // HIVE time travel syntax + // read the table at the timestamp in long format i.e 1656507980463. + List actualWithLongFormatInHiveSyntax = + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF %s ORDER BY id", tableName, timestampInSeconds); + assertEquals("Snapshot at specific ID", expected, actualWithLongFormatInHiveSyntax); + + // read the table at the timestamp in date format i.e 2022-06-29 18:40:37 + List actualWithDateFormatInHiveSyntax = + sql("SELECT * FROM %s FOR SYSTEM_TIME AS OF '%s' ORDER BY id", tableName, formattedDate); + assertEquals("Snapshot at specific ID", expected, actualWithDateFormatInHiveSyntax); + + // read the table using DataFrameReader option + Dataset df = + spark + .read() + .format("iceberg") + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedDate) + .load(tableName) + .orderBy("id"); + List fromDF = rowsToJava(df.collectAsList()); + assertEquals("Snapshot at timestamp " + timestamp, expected, fromDF); + } + + @TestTemplate + public void testInvalidTimeTravelBasedOnBothAsOfAndTableIdentifier() { + // get the snapshot ID of the last write + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + // get a timestamp just after the last write + long timestamp = + validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis() + 2; + + String timestampPrefix = "at_timestamp_"; + String snapshotPrefix = "snapshot_id_"; + + // create a second snapshot + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + // using snapshot in table identifier and VERSION AS OF + assertThatThrownBy( + () -> { + sql( + "SELECT * FROM %s.%s VERSION AS OF %s", + tableName, snapshotPrefix + snapshotId, snapshotId); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Can't time travel using selector and Spark time travel spec at the same time"); + + // using snapshot in table identifier and TIMESTAMP AS OF + assertThatThrownBy( + () -> { + sql( + "SELECT * FROM %s.%s VERSION AS OF %s", + tableName, timestampPrefix + timestamp, snapshotId); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Can't time travel using selector and Spark time travel spec at the same time"); + + // using timestamp in table identifier and VERSION AS OF + assertThatThrownBy( + () -> { + sql( + "SELECT * FROM %s.%s TIMESTAMP AS OF %s", + tableName, snapshotPrefix + snapshotId, timestamp); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Can't time travel using selector and Spark time travel spec at the same time"); + + // using timestamp in table identifier and TIMESTAMP AS OF + assertThatThrownBy( + () -> { + sql( + "SELECT * FROM %s.%s TIMESTAMP AS OF %s", + tableName, timestampPrefix + timestamp, timestamp); + }) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Can't time travel using selector and Spark time travel spec at the same time"); + } + + @TestTemplate + public void testInvalidTimeTravelAgainstBranchIdentifierWithAsOf() { + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + validationCatalog.loadTable(tableIdent).manageSnapshots().createBranch("b1").commit(); + + // create a second snapshot + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + // using branch_b1 in the table identifier and VERSION AS OF + assertThatThrownBy( + () -> sql("SELECT * FROM %s.branch_b1 VERSION AS OF %s", tableName, snapshotId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Can't time travel in branch"); + + // using branch_b1 in the table identifier and TIMESTAMP AS OF + assertThatThrownBy(() -> sql("SELECT * FROM %s.branch_b1 TIMESTAMP AS OF now()", tableName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Can't time travel in branch"); + } + + @TestTemplate + public void testSpecifySnapshotAndTimestamp() { + // get the snapshot ID of the last write + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + // get a timestamp just after the last write + long timestamp = + validationCatalog.loadTable(tableIdent).currentSnapshot().timestampMillis() + 2; + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS"); + String formattedTimestamp = sdf.format(new Date(timestamp)); + + // create a second snapshot + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0)", tableName); + + assertThatThrownBy( + () -> { + spark + .read() + .format("iceberg") + .option(SparkReadOptions.VERSION_AS_OF, snapshotId) + .option(SparkReadOptions.TIMESTAMP_AS_OF, formattedTimestamp) + .load(tableName) + .collectAsList(); + }) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("Cannot specify both version and timestamp when time travelling"); + } + + @TestTemplate + public void testBinaryInFilter() { + sql("CREATE TABLE %s (id bigint, binary binary) USING iceberg", binaryTableName); + sql("INSERT INTO %s VALUES (1, X''), (2, X'1111'), (3, X'11')", binaryTableName); + List expected = ImmutableList.of(row(2L, new byte[] {0x11, 0x11})); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id, binary FROM %s where binary > X'11'", binaryTableName)); + } + + @TestTemplate + public void testFixedInFilter() { + // Create table programmatically with fixed type since Spark SQL DDL doesn't support it + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "fixed", Types.FixedType.ofLength(2))); + + TableIdentifier fixedTableIdent = TableIdentifier.of(tableIdent.namespace(), "fixed_table"); + validationCatalog.createTable(fixedTableIdent, schema, PartitionSpec.unpartitioned()); + + String fixedTableName = tableName("fixed_table"); + sql("INSERT INTO %s VALUES (1, X'0000'), (2, X'1111'), (3, X'0011')", fixedTableName); + List expected = ImmutableList.of(row(2L, new byte[] {0x11, 0x11})); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT id, fixed FROM %s WHERE fixed > X'0011'", fixedTableName)); + + sql("DROP TABLE IF EXISTS %s", fixedTableName); + } + + @TestTemplate + public void testComplexTypeFilter() { + String complexTypeTableName = tableName("complex_table"); + sql( + "CREATE TABLE %s (id INT, complex STRUCT) USING iceberg", + complexTypeTableName); + sql( + "INSERT INTO TABLE %s VALUES (1, named_struct(\"c1\", 3, \"c2\", \"v1\"))", + complexTypeTableName); + sql( + "INSERT INTO TABLE %s VALUES (2, named_struct(\"c1\", 2, \"c2\", \"v2\"))", + complexTypeTableName); + + List result = + sql( + "SELECT id FROM %s WHERE complex = named_struct(\"c1\", 3, \"c2\", \"v1\")", + complexTypeTableName); + + assertEquals("Should return all expected rows", ImmutableList.of(row(1)), result); + sql("DROP TABLE IF EXISTS %s", complexTypeTableName); + } + + @TestTemplate + public void testRequiredNestedFieldInOptionalStructFilter() { + String nestedStructTable = tableName("nested_struct_table"); + sql( + "CREATE TABLE %s (id INT NOT NULL, address STRUCT) " + + "USING iceberg", + nestedStructTable); + sql("INSERT INTO %s VALUES (0, NULL)", nestedStructTable); + sql("INSERT INTO %s VALUES (1, STRUCT('123 Main St'))", nestedStructTable); + + List result = + sql("SELECT id FROM %s WHERE address.street IS NULL", nestedStructTable); + + assertEquals("Should return all expected rows", ImmutableList.of(row(0)), result); + sql("DROP TABLE IF EXISTS %s", nestedStructTable); + } + + @TestTemplate + public void simpleTypesInFilter() { + String tableName = tableName("simple_types_table"); + sql( + "CREATE TABLE IF NOT EXISTS %s (id bigint, boolean boolean, integer integer, long long, " + + "float float, double double, string string, date date, timestamp timestamp) USING iceberg", + tableName); + sql( + "INSERT INTO %s VALUES (1, true, 1, 1L, 1.1, 1.3, '1.5', to_date('2021-01-01'), to_timestamp('2021-01-01T00:00:00')), " + + "(2, false, 2, 2L, 2.2, 2.4, '2.6', to_date('2022-02-02'), to_timestamp('2022-02-02T00:00:00')), " + + "(3, true, 3, 3L, 3.3, 3.6, '3.9', to_date('2023-03-03'), to_timestamp('2023-03-03T00:00:00'))", + tableName); + assertThat(sql("SELECT id FROM %s where id > 1", tableName)) + .containsExactlyInAnyOrder(row(2L), row(3L)); + assertThat(sql("SELECT id, boolean FROM %s where boolean = true", tableName)) + .containsExactlyInAnyOrder(row(1L, true), row(3L, true)); + assertThat(sql("SELECT long FROM %s where long > 1", tableName)) + .containsExactlyInAnyOrder(row(2L), row(3L)); + assertThat(sql("SELECT float FROM %s where float > 1.1f", tableName)) + .containsExactlyInAnyOrder(row(2.2f), row(3.3f)); + assertThat(sql("SELECT double FROM %s where double > 1.3", tableName)) + .containsExactlyInAnyOrder(row(2.4d), row(3.6d)); + assertThat(sql("SELECT string FROM %s where string > '1.5'", tableName)) + .containsExactlyInAnyOrder(row("2.6"), row("3.9")); + java.sql.Date dateOne = java.sql.Date.valueOf("2022-02-02"); + java.sql.Date dateTwo = java.sql.Date.valueOf("2023-03-03"); + assertThat(sql("SELECT date FROM %s where date > to_date('2021-01-01')", tableName)) + .containsExactlyInAnyOrder(row(dateOne), row(dateTwo)); + assertThat( + sql("SELECT timestamp FROM %s where timestamp > to_timestamp('2021-01-01')", tableName)) + .containsExactlyInAnyOrder( + row(new Timestamp(dateOne.getTime())), row(new Timestamp(dateTwo.getTime()))); + + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void variantTypeInFilter() { + assumeThat(validationCatalog) + .as("Variant is not supported in Hive catalog") + .isNotInstanceOf(HiveCatalog.class); + + String tableName = tableName("variant_table"); + sql( + "CREATE TABLE %s (id BIGINT, v1 VARIANT, v2 VARIANT) USING iceberg TBLPROPERTIES ('format-version'='3')", + tableName); + + String v1r1 = "{\"a\":5}"; + String v1r2 = "{\"a\":10}"; + String v2r1 = "{\"x\":15}"; + String v2r2 = "{\"x\":20}"; + + sql("INSERT INTO %s SELECT 1, parse_json('%s'), parse_json('%s')", tableName, v1r1, v2r1); + sql("INSERT INTO %s SELECT 2, parse_json('%s'), parse_json('%s')", tableName, v1r2, v2r2); + + assertThat( + sql( + "SELECT id, try_variant_get(v1, '$.a', 'int') FROM %s WHERE try_variant_get(v1, '$.a', 'int') > 5", + tableName)) + .containsExactly(row(2L, 10)); + assertThat( + sql( + "SELECT id, try_variant_get(v2, '$.x', 'int') FROM %s WHERE try_variant_get(v2, '$.x', 'int') < 100", + tableName)) + .containsExactlyInAnyOrder(row(1L, 15), row(2L, 20)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkBucketFunction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkBucketFunction.java new file mode 100644 index 000000000000..1e00759d8de8 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkBucketFunction.java @@ -0,0 +1,341 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.relocated.com.google.common.io.BaseEncoding; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.spark.functions.BucketFunction; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.types.DataTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkBucketFunction extends TestBaseWithCatalog { + @BeforeEach + public void useCatalog() { + sql("USE %s", catalogName); + } + + @TestTemplate + public void testSpecValues() { + assertThat(new BucketFunction.BucketInt(DataTypes.IntegerType).hash(34)) + .as("Spec example: hash(34) = 2017239379") + .isEqualTo(2017239379); + + assertThat(new BucketFunction.BucketLong(DataTypes.IntegerType).hash(34L)) + .as("Spec example: hash(34L) = 2017239379") + .isEqualTo(2017239379); + + assertThat( + new BucketFunction.BucketDecimal(DataTypes.createDecimalType(9, 2)) + .hash(new BigDecimal("14.20"))) + .as("Spec example: hash(decimal2(14.20)) = -500754589") + .isEqualTo(-500754589); + + Literal date = Literal.of("2017-11-16").to(Types.DateType.get()); + assertThat(new BucketFunction.BucketInt(DataTypes.DateType).hash(date.value())) + .as("Spec example: hash(2017-11-16) = -653330422") + .isEqualTo(-653330422); + + Literal timestampVal = + Literal.of("2017-11-16T22:31:08").to(Types.TimestampType.withoutZone()); + assertThat(new BucketFunction.BucketLong(DataTypes.TimestampType).hash(timestampVal.value())) + .as("Spec example: hash(2017-11-16T22:31:08) = -2047944441") + .isEqualTo(-2047944441); + + Literal timestampntzVal = + Literal.of("2017-11-16T22:31:08").to(Types.TimestampType.withoutZone()); + assertThat( + new BucketFunction.BucketLong(DataTypes.TimestampNTZType).hash(timestampntzVal.value())) + .as("Spec example: hash(2017-11-16T22:31:08) = -2047944441") + .isEqualTo(-2047944441); + + assertThat(new BucketFunction.BucketString().hash("iceberg")) + .as("Spec example: hash(\"iceberg\") = 1210000089") + .isEqualTo(1210000089); + + assertThat(new BucketFunction.BucketString().hash("iceberg".getBytes(StandardCharsets.UTF_8))) + .as("Verify that the hash string and hash raw bytes produce the same result") + .isEqualTo(new BucketFunction.BucketString().hash("iceberg")); + + ByteBuffer bytes = ByteBuffer.wrap(new byte[] {0, 1, 2, 3}); + assertThat(new BucketFunction.BucketBinary().hash(bytes)) + .as("Spec example: hash([00 01 02 03]) = -188683207") + .isEqualTo(-188683207); + } + + @TestTemplate + public void testBucketIntegers() { + assertThat(scalarSql("SELECT system.bucket(10, 8Y)")) + .as("Byte type should bucket similarly to integer") + .isEqualTo(3); + assertThat(scalarSql("SELECT system.bucket(10, 8S)")) + .as("Short type should bucket similarly to integer") + .isEqualTo(3); + // Integers + assertThat(scalarSql("SELECT system.bucket(10, 8)")).isEqualTo(3); + assertThat(scalarSql("SELECT system.bucket(100, 34)")).isEqualTo(79); + assertThat(scalarSql("SELECT system.bucket(1, CAST(null AS INT))")).isNull(); + } + + @TestTemplate + public void testBucketDates() { + assertThat(scalarSql("SELECT system.bucket(10, date('1970-01-09'))")).isEqualTo(3); + assertThat(scalarSql("SELECT system.bucket(100, date('1970-02-04'))")).isEqualTo(79); + assertThat(scalarSql("SELECT system.bucket(1, CAST(null AS DATE))")).isNull(); + } + + @TestTemplate + public void testBucketLong() { + assertThat(scalarSql("SELECT system.bucket(100, 34L)")).isEqualTo(79); + assertThat(scalarSql("SELECT system.bucket(100, 0L)")).isEqualTo(76); + assertThat(scalarSql("SELECT system.bucket(100, -34L)")).isEqualTo(97); + assertThat(scalarSql("SELECT system.bucket(2, -1L)")).isEqualTo(0); + assertThat(scalarSql("SELECT system.bucket(2, CAST(null AS LONG))")).isNull(); + } + + @TestTemplate + public void testBucketDecimal() { + assertThat(scalarSql("SELECT system.bucket(64, CAST('12.34' as DECIMAL(9, 2)))")).isEqualTo(56); + assertThat(scalarSql("SELECT system.bucket(18, CAST('12.30' as DECIMAL(9, 2)))")).isEqualTo(13); + assertThat(scalarSql("SELECT system.bucket(16, CAST('12.999' as DECIMAL(9, 3)))")).isEqualTo(2); + assertThat(scalarSql("SELECT system.bucket(32, CAST('0.05' as DECIMAL(5, 2)))")).isEqualTo(21); + assertThat(scalarSql("SELECT system.bucket(128, CAST('0.05' as DECIMAL(9, 2)))")).isEqualTo(85); + assertThat(scalarSql("SELECT system.bucket(18, CAST('0.05' as DECIMAL(9, 2)))")).isEqualTo(3); + + assertThat(scalarSql("SELECT system.bucket(2, CAST(null AS decimal))")) + .as("Null input should return null") + .isNull(); + } + + @TestTemplate + public void testBucketTimestamp() { + assertThat(scalarSql("SELECT system.bucket(100, TIMESTAMP '1997-01-01 00:00:00 UTC+00:00')")) + .isEqualTo(99); + assertThat(scalarSql("SELECT system.bucket(100, TIMESTAMP '1997-01-31 09:26:56 UTC+00:00')")) + .isEqualTo(85); + assertThat(scalarSql("SELECT system.bucket(100, TIMESTAMP '2022-08-08 00:00:00 UTC+00:00')")) + .isEqualTo(62); + assertThat(scalarSql("SELECT system.bucket(2, CAST(null AS timestamp))")).isNull(); + } + + @TestTemplate + public void testBucketString() { + assertThat(scalarSql("SELECT system.bucket(5, 'abcdefg')")).isEqualTo(4); + assertThat(scalarSql("SELECT system.bucket(128, 'abc')")).isEqualTo(122); + assertThat(scalarSql("SELECT system.bucket(64, 'abcde')")).isEqualTo(54); + assertThat(scalarSql("SELECT system.bucket(12, '测试')")).isEqualTo(8); + assertThat(scalarSql("SELECT system.bucket(16, '测试raul试测')")).isEqualTo(1); + assertThat(scalarSql("SELECT system.bucket(16, CAST('测试raul试测' AS varchar(8)))")) + .as("Varchar should work like string") + .isEqualTo(1); + assertThat(scalarSql("SELECT system.bucket(16, CAST('测试raul试测' AS char(8)))")) + .as("Char should work like string") + .isEqualTo(1); + assertThat(scalarSql("SELECT system.bucket(16, '')")) + .as("Should not fail on the empty string") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.bucket(16, CAST(null AS string))")) + .as("Null input should return null as output") + .isNull(); + } + + @TestTemplate + public void testBucketBinary() { + assertThat(scalarSql("SELECT system.bucket(10, X'0102030405060708090a0b0c0d0e0f')")) + .isEqualTo(1); + assertThat(scalarSql("SELECT system.bucket(12, %s)", asBytesLiteral("abcdefg"))).isEqualTo(10); + assertThat(scalarSql("SELECT system.bucket(18, %s)", asBytesLiteral("abc\0\0"))).isEqualTo(13); + assertThat(scalarSql("SELECT system.bucket(48, %s)", asBytesLiteral("abc"))).isEqualTo(42); + assertThat(scalarSql("SELECT system.bucket(16, %s)", asBytesLiteral("测试_"))).isEqualTo(3); + + assertThat(scalarSql("SELECT system.bucket(100, CAST(null AS binary))")) + .as("Null input should return null as output") + .isNull(); + } + + @TestTemplate + public void testNumBucketsAcceptsShortAndByte() { + assertThat(scalarSql("SELECT system.bucket(5S, 1L)")) + .as("Short types should be usable for the number of buckets field") + .isEqualTo(1); + + assertThat(scalarSql("SELECT system.bucket(5Y, 1)")) + .as("Byte types should be allowed for the number of buckets field") + .isEqualTo(1); + } + + @TestTemplate + public void testWrongNumberOfArguments() { + assertThatThrownBy(() -> scalarSql("SELECT system.bucket()")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (): Wrong number of inputs (expected numBuckets and value)"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (int): Wrong number of inputs (expected numBuckets and value)"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(1, 1L, 1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (int, bigint, int): Wrong number of inputs (expected numBuckets and value)"); + } + + @TestTemplate + public void testInvalidTypesCannotBeUsedForNumberOfBuckets() { + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(CAST('12.34' as DECIMAL(9, 2)), 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (decimal(9,2), int): Expected number of buckets to be tinyint, shortint or int"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(12L, 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (bigint, int): Expected number of buckets to be tinyint, shortint or int"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket('5', 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (string, int): Expected number of buckets to be tinyint, shortint or int"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(INTERVAL '100-00' YEAR TO MONTH, 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (interval year to month, int): Expected number of buckets to be tinyint, shortint or int"); + + assertThatThrownBy( + () -> + scalarSql("SELECT system.bucket(CAST('11 23:4:0' AS INTERVAL DAY TO SECOND), 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (interval day to second, int): Expected number of buckets to be tinyint, shortint or int"); + } + + @TestTemplate + public void testInvalidTypesForBucketColumn() { + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(10, cast(12.3456 as float))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (int, float): Expected column to be date, tinyint, smallint, int, bigint, decimal, timestamp, string, or binary"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(10, cast(12.3456 as double))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (int, double): Expected column to be date, tinyint, smallint, int, bigint, decimal, timestamp, string, or binary"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(10, true)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Function 'bucket' cannot process input: (int, boolean)"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(10, map(1, 1))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Function 'bucket' cannot process input: (int, map)"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(10, array(1L))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Function 'bucket' cannot process input: (int, array)"); + + assertThatThrownBy(() -> scalarSql("SELECT system.bucket(10, INTERVAL '100-00' YEAR TO MONTH)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (int, interval year to month)"); + + assertThatThrownBy( + () -> + scalarSql("SELECT system.bucket(10, CAST('11 23:4:0' AS INTERVAL DAY TO SECOND))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'bucket' cannot process input: (int, interval day to second)"); + } + + @TestTemplate + public void testThatMagicFunctionsAreInvoked() { + // TinyInt + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(5, 6Y)")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketInt"); + + // SmallInt + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(5, 6S)")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketInt"); + + // Int + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(5, 6)")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketInt"); + + // Date + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(100, DATE '2022-08-08')")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketInt"); + + // Long + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(5, 6L)")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketLong"); + + // Timestamp + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(100, TIMESTAMP '2022-08-08')")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketLong"); + + // String + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(5, 'abcdefg')")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketString"); + + // Decimal + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(5, CAST('12.34' AS DECIMAL))")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketDecimal"); + + // Binary + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.bucket(4, X'0102030405060708')")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.BucketFunction$BucketBinary"); + } + + private String asBytesLiteral(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + return "X'" + BaseEncoding.base16().encode(bytes) + "'"; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkDaysFunction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkDaysFunction.java new file mode 100644 index 000000000000..cfec6a33ab14 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkDaysFunction.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Date; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkDaysFunction extends TestBaseWithCatalog { + + @BeforeEach + public void useCatalog() { + sql("USE %s", catalogName); + } + + @TestTemplate + public void testDates() { + assertThat(scalarSql("SELECT system.days(date('2017-12-01'))")) + .as("Expected to produce 2017-12-01") + .isEqualTo(Date.valueOf("2017-12-01")); + assertThat(scalarSql("SELECT system.days(date('1970-01-01'))")) + .as("Expected to produce 1970-01-01") + .isEqualTo(Date.valueOf("1970-01-01")); + assertThat(scalarSql("SELECT system.days(date('1969-12-31'))")) + .as("Expected to produce 1969-12-31") + .isEqualTo(Date.valueOf("1969-12-31")); + assertThat(scalarSql("SELECT system.days(CAST(null AS DATE))")).isNull(); + } + + @TestTemplate + public void testTimestamps() { + assertThat(scalarSql("SELECT system.days(TIMESTAMP '2017-12-01 10:12:55.038194 UTC+00:00')")) + .as("Expected to produce 2017-12-01") + .isEqualTo(Date.valueOf("2017-12-01")); + assertThat(scalarSql("SELECT system.days(TIMESTAMP '1970-01-01 00:00:01.000001 UTC+00:00')")) + .as("Expected to produce 1970-01-01") + .isEqualTo(Date.valueOf("1970-01-01")); + assertThat(scalarSql("SELECT system.days(TIMESTAMP '1969-12-31 23:59:58.999999 UTC+00:00')")) + .as("Expected to produce 1969-12-31") + .isEqualTo(Date.valueOf("1969-12-31")); + assertThat(scalarSql("SELECT system.days(CAST(null AS TIMESTAMP))")).isNull(); + } + + @TestTemplate + public void testTimestampNtz() { + assertThat(scalarSql("SELECT system.days(TIMESTAMP_NTZ '2017-12-01 10:12:55.038194 UTC')")) + .as("Expected to produce 2017-12-01") + .isEqualTo(Date.valueOf("2017-12-01")); + assertThat(scalarSql("SELECT system.days(TIMESTAMP_NTZ '1970-01-01 00:00:01.000001 UTC')")) + .as("Expected to produce 1970-01-01") + .isEqualTo(Date.valueOf("1970-01-01")); + assertThat(scalarSql("SELECT system.days(TIMESTAMP_NTZ '1969-12-31 23:59:58.999999 UTC')")) + .as("Expected to produce 1969-12-31") + .isEqualTo(Date.valueOf("1969-12-31")); + assertThat(scalarSql("SELECT system.days(CAST(null AS TIMESTAMP_NTZ))")).isNull(); + } + + @TestTemplate + public void testWrongNumberOfArguments() { + assertThatThrownBy(() -> scalarSql("SELECT system.days()")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith("Function 'days' cannot process input: (): Wrong number of inputs"); + + assertThatThrownBy( + () -> scalarSql("SELECT system.days(date('1969-12-31'), date('1969-12-31'))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'days' cannot process input: (date, date): Wrong number of inputs"); + } + + @TestTemplate + public void testInvalidInputTypes() { + assertThatThrownBy(() -> scalarSql("SELECT system.days(1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'days' cannot process input: (int): Expected value to be date or timestamp"); + + assertThatThrownBy(() -> scalarSql("SELECT system.days(1L)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'days' cannot process input: (bigint): Expected value to be date or timestamp"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkDefaultValues.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkDefaultValues.java new file mode 100644 index 000000000000..838e735c75f0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkDefaultValues.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.expressions.Literal; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.TestTemplate; + +/** + * Tests for Spark SQL Default values integration with Iceberg default values. + * + *

    Note: These tests use {@code validationCatalog.createTable()} to create tables with default + * values because the Iceberg Spark integration does not yet support default value clauses in Spark + * DDL. + */ +public class TestSparkDefaultValues extends CatalogTestBase { + + @AfterEach + public void dropTestTable() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testWriteDefaultWithSparkDefaultKeyword() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional("bool_col") + .withId(2) + .ofType(Types.BooleanType.get()) + .withWriteDefault(Literal.of(true)) + .build(), + Types.NestedField.optional("int_col") + .withId(3) + .ofType(Types.IntegerType.get()) + .withWriteDefault(Literal.of(42)) + .build(), + Types.NestedField.optional("long_col") + .withId(4) + .ofType(Types.LongType.get()) + .withWriteDefault(Literal.of(100L)) + .build()); + + validationCatalog.createTable( + tableIdent, schema, PartitionSpec.unpartitioned(), ImmutableMap.of("format-version", "3")); + + sql("INSERT INTO %s VALUES (1, DEFAULT, DEFAULT, DEFAULT)", commitTarget()); + + assertEquals( + "Should have expected default values", + ImmutableList.of(row(1, true, 42, 100L)), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testWriteDefaultWithDefaultKeywordAndReorderedSchema() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional("int_col") + .withId(2) + .ofType(Types.IntegerType.get()) + .withWriteDefault(Literal.of(123)) + .build(), + Types.NestedField.optional("string_col") + .withId(3) + .ofType(Types.StringType.get()) + .withWriteDefault(Literal.of("doom")) + .build()); + + validationCatalog.createTable( + tableIdent, schema, PartitionSpec.unpartitioned(), ImmutableMap.of("format-version", "3")); + + // Insert with columns in different order than table schema + sql("INSERT INTO %s (int_col, id, string_col) VALUES (DEFAULT, 1, DEFAULT)", commitTarget()); + + assertEquals( + "Should apply correct defaults regardless of column order", + ImmutableList.of(row(1, 123, "doom")), + sql("SELECT * FROM %s", selectTarget())); + } + + @TestTemplate + public void testBulkInsertWithDefaults() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + Schema schema = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional("data") + .withId(2) + .ofType(Types.StringType.get()) + .withWriteDefault(Literal.of("default_data")) + .build()); + + validationCatalog.createTable( + tableIdent, schema, PartitionSpec.unpartitioned(), ImmutableMap.of("format-version", "3")); + + sql("INSERT INTO %s VALUES (1, DEFAULT), (2, DEFAULT), (3, DEFAULT)", commitTarget()); + + assertEquals( + "Should insert multiple rows with default values", + ImmutableList.of(row(1, "default_data"), row(2, "default_data"), row(3, "default_data")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testCreateTableWithDefaultsUnsupported() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + assertThatThrownBy( + () -> + sql( + "CREATE TABLE %s (id INT, data STRING DEFAULT 'default-value') USING iceberg", + tableName)) + .isInstanceOf(AnalysisException.class) + .hasMessageContaining("does not support column default value"); + } + + @TestTemplate + public void testAlterTableAddColumnWithDefaultUnsupported() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + Schema schema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + + validationCatalog.createTable( + tableIdent, schema, PartitionSpec.unpartitioned(), ImmutableMap.of("format-version", "3")); + + assertThatThrownBy( + () -> sql("ALTER TABLE %s ADD COLUMN data STRING DEFAULT 'default-value'", tableName)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("default values in Spark is currently unsupported"); + } + + @TestTemplate + public void testSchemaEvolutionWithDefaultValueChanges() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("Table should not already exist") + .isFalse(); + + Schema initialSchema = new Schema(Types.NestedField.required(1, "id", Types.IntegerType.get())); + + validationCatalog.createTable( + tableIdent, + initialSchema, + PartitionSpec.unpartitioned(), + ImmutableMap.of("format-version", "3")); + + sql("INSERT INTO %s VALUES (1), (2)", commitTarget()); + + // Add a column with a default value + validationCatalog + .loadTable(tableIdent) + .updateSchema() + .addColumn("data", Types.StringType.get(), Literal.of("default_data")) + .commit(); + + // Refresh this when using SparkCatalog since otherwise the new column would not be caught. + sql("REFRESH TABLE %s", commitTarget()); + + sql("INSERT INTO %s VALUES (3, DEFAULT)", commitTarget()); + + assertEquals( + "Should have correct default values for existing and new rows", + ImmutableList.of(row(1, "default_data"), row(2, "default_data"), row(3, "default_data")), + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkHoursFunction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkHoursFunction.java new file mode 100644 index 000000000000..eeeb9d1a1e78 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkHoursFunction.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkHoursFunction extends TestBaseWithCatalog { + + @BeforeEach + public void useCatalog() { + sql("USE %s", catalogName); + } + + @TestTemplate + public void testTimestamps() { + assertThat(scalarSql("SELECT system.hours(TIMESTAMP '2017-12-01 10:12:55.038194 UTC+00:00')")) + .as("Expected to produce 17501 * 24 + 10") + .isEqualTo(420034); + assertThat(scalarSql("SELECT system.hours(TIMESTAMP '1970-01-01 00:00:01.000001 UTC+00:00')")) + .as("Expected to produce 0 * 24 + 0 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.hours(TIMESTAMP '1969-12-31 23:59:58.999999 UTC+00:00')")) + .as("Expected to produce -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.hours(CAST(null AS TIMESTAMP))")).isNull(); + } + + @TestTemplate + public void testTimestampsNtz() { + assertThat(scalarSql("SELECT system.hours(TIMESTAMP_NTZ '2017-12-01 10:12:55.038194 UTC')")) + .as("Expected to produce 17501 * 24 + 10") + .isEqualTo(420034); + assertThat(scalarSql("SELECT system.hours(TIMESTAMP_NTZ '1970-01-01 00:00:01.000001 UTC')")) + .as("Expected to produce 0 * 24 + 0 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.hours(TIMESTAMP_NTZ '1969-12-31 23:59:58.999999 UTC')")) + .as("Expected to produce -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.hours(CAST(null AS TIMESTAMP_NTZ))")).isNull(); + } + + @TestTemplate + public void testWrongNumberOfArguments() { + assertThatThrownBy(() -> scalarSql("SELECT system.hours()")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'hours' cannot process input: (): Wrong number of inputs"); + + assertThatThrownBy( + () -> scalarSql("SELECT system.hours(date('1969-12-31'), date('1969-12-31'))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'hours' cannot process input: (date, date): Wrong number of inputs"); + } + + @TestTemplate + public void testInvalidInputTypes() { + assertThatThrownBy(() -> scalarSql("SELECT system.hours(1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'hours' cannot process input: (int): Expected value to be timestamp"); + + assertThatThrownBy(() -> scalarSql("SELECT system.hours(1L)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'hours' cannot process input: (bigint): Expected value to be timestamp"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkMonthsFunction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkMonthsFunction.java new file mode 100644 index 000000000000..a61023196f1c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkMonthsFunction.java @@ -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. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.spark.functions.MonthsFunction; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkMonthsFunction extends TestBaseWithCatalog { + + @BeforeEach + public void useCatalog() { + sql("USE %s", catalogName); + } + + @TestTemplate + public void testDates() { + assertThat(scalarSql("SELECT system.months(date('2017-12-01'))")) + .as("Expected to produce 47 * 12 + 11 = 575") + .isEqualTo(575); + assertThat(scalarSql("SELECT system.months(date('1970-01-01'))")) + .as("Expected to produce 0 * 12 + 0 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.months(date('1969-12-31'))")) + .as("Expected to produce -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.months(CAST(null AS DATE))")).isNull(); + } + + @TestTemplate + public void testTimestamps() { + assertThat(scalarSql("SELECT system.months(TIMESTAMP '2017-12-01 10:12:55.038194 UTC+00:00')")) + .as("Expected to produce 47 * 12 + 11 = 575") + .isEqualTo(575); + assertThat(scalarSql("SELECT system.months(TIMESTAMP '1970-01-01 00:00:01.000001 UTC+00:00')")) + .as("Expected to produce 0 * 12 + 0 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.months(TIMESTAMP '1969-12-31 23:59:58.999999 UTC+00:00')")) + .as("Expected to produce -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.months(CAST(null AS TIMESTAMP))")).isNull(); + } + + @TestTemplate + public void testTimestampNtz() { + assertThat(scalarSql("SELECT system.months(TIMESTAMP_NTZ '2017-12-01 10:12:55.038194 UTC')")) + .as("Expected to produce 47 * 12 + 11 = 575") + .isEqualTo(575); + assertThat(scalarSql("SELECT system.months(TIMESTAMP_NTZ '1970-01-01 00:00:01.000001 UTC')")) + .as("Expected to produce 0 * 12 + 0 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.months(TIMESTAMP_NTZ '1969-12-31 23:59:58.999999 UTC')")) + .as("Expected to produce -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.months(CAST(null AS TIMESTAMP_NTZ))")).isNull(); + } + + @TestTemplate + public void testWrongNumberOfArguments() { + assertThatThrownBy(() -> scalarSql("SELECT system.months()")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'months' cannot process input: (): Wrong number of inputs"); + + assertThatThrownBy( + () -> scalarSql("SELECT system.months(date('1969-12-31'), date('1969-12-31'))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'months' cannot process input: (date, date): Wrong number of inputs"); + } + + @TestTemplate + public void testInvalidInputTypes() { + assertThatThrownBy(() -> scalarSql("SELECT system.months(1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'months' cannot process input: (int): Expected value to be date or timestamp"); + + assertThatThrownBy(() -> scalarSql("SELECT system.months(1L)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'months' cannot process input: (bigint): Expected value to be date or timestamp"); + } + + @TestTemplate + public void testThatMagicFunctionsAreInvoked() { + String dateValue = "date('2017-12-01')"; + String dateTransformClass = MonthsFunction.DateToMonthsFunction.class.getName(); + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.months(%s)", dateValue)) + .asString() + .isNotNull() + .contains("static_invoke(" + dateTransformClass); + + String timestampValue = "TIMESTAMP '2017-12-01 10:12:55.038194 UTC+00:00'"; + String timestampTransformClass = MonthsFunction.TimestampToMonthsFunction.class.getName(); + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.months(%s)", timestampValue)) + .asString() + .isNotNull() + .contains("static_invoke(" + timestampTransformClass); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkTruncateFunction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkTruncateFunction.java new file mode 100644 index 000000000000..d9e41213d966 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkTruncateFunction.java @@ -0,0 +1,443 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.relocated.com.google.common.io.BaseEncoding; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkTruncateFunction extends TestBaseWithCatalog { + + @BeforeEach + public void useCatalog() { + sql("USE %s", catalogName); + } + + @TestTemplate + public void testTruncateTinyInt() { + assertThat(scalarSql("SELECT system.truncate(10, 0Y)")).isEqualTo((byte) 0); + assertThat(scalarSql("SELECT system.truncate(10, 1Y)")).isEqualTo((byte) 0); + assertThat(scalarSql("SELECT system.truncate(10, 5Y)")).isEqualTo((byte) 0); + assertThat(scalarSql("SELECT system.truncate(10, 9Y)")).isEqualTo((byte) 0); + assertThat(scalarSql("SELECT system.truncate(10, 10Y)")).isEqualTo((byte) 10); + assertThat(scalarSql("SELECT system.truncate(10, 11Y)")).isEqualTo((byte) 10); + assertThat(scalarSql("SELECT system.truncate(10, -1Y)")).isEqualTo((byte) -10); + assertThat(scalarSql("SELECT system.truncate(10, -5Y)")).isEqualTo((byte) -10); + assertThat(scalarSql("SELECT system.truncate(10, -10Y)")).isEqualTo((byte) -10); + assertThat(scalarSql("SELECT system.truncate(10, -11Y)")).isEqualTo((byte) -20); + + // Check that different widths can be used + assertThat(scalarSql("SELECT system.truncate(2, -1Y)")).isEqualTo((byte) -2); + + assertThat(scalarSql("SELECT system.truncate(2, CAST(null AS tinyint))")) + .as("Null input should return null") + .isNull(); + } + + @TestTemplate + public void testTruncateSmallInt() { + assertThat(scalarSql("SELECT system.truncate(10, 0S)")).isEqualTo((short) 0); + assertThat(scalarSql("SELECT system.truncate(10, 1S)")).isEqualTo((short) 0); + assertThat(scalarSql("SELECT system.truncate(10, 5S)")).isEqualTo((short) 0); + assertThat(scalarSql("SELECT system.truncate(10, 9S)")).isEqualTo((short) 0); + assertThat(scalarSql("SELECT system.truncate(10, 10S)")).isEqualTo((short) 10); + assertThat(scalarSql("SELECT system.truncate(10, 11S)")).isEqualTo((short) 10); + assertThat(scalarSql("SELECT system.truncate(10, -1S)")).isEqualTo((short) -10); + assertThat(scalarSql("SELECT system.truncate(10, -5S)")).isEqualTo((short) -10); + assertThat(scalarSql("SELECT system.truncate(10, -10S)")).isEqualTo((short) -10); + assertThat(scalarSql("SELECT system.truncate(10, -11S)")).isEqualTo((short) -20); + + // Check that different widths can be used + assertThat(scalarSql("SELECT system.truncate(2, -1S)")).isEqualTo((short) -2); + + assertThat(scalarSql("SELECT system.truncate(2, CAST(null AS smallint))")) + .as("Null input should return null") + .isNull(); + } + + @TestTemplate + public void testTruncateInt() { + assertThat(scalarSql("SELECT system.truncate(10, 0)")).isEqualTo(0); + assertThat(scalarSql("SELECT system.truncate(10, 1)")).isEqualTo(0); + assertThat(scalarSql("SELECT system.truncate(10, 5)")).isEqualTo(0); + assertThat(scalarSql("SELECT system.truncate(10, 9)")).isEqualTo(0); + assertThat(scalarSql("SELECT system.truncate(10, 10)")).isEqualTo(10); + assertThat(scalarSql("SELECT system.truncate(10, 11)")).isEqualTo(10); + assertThat(scalarSql("SELECT system.truncate(10, -1)")).isEqualTo(-10); + assertThat(scalarSql("SELECT system.truncate(10, -5)")).isEqualTo(-10); + assertThat(scalarSql("SELECT system.truncate(10, -10)")).isEqualTo(-10); + assertThat(scalarSql("SELECT system.truncate(10, -11)")).isEqualTo(-20); + + // Check that different widths can be used + assertThat(scalarSql("SELECT system.truncate(2, -1)")).isEqualTo(-2); + assertThat(scalarSql("SELECT system.truncate(300, 1)")).isEqualTo(0); + + assertThat(scalarSql("SELECT system.truncate(2, CAST(null AS int))")) + .as("Null input should return null") + .isNull(); + } + + @TestTemplate + public void testTruncateBigInt() { + assertThat(scalarSql("SELECT system.truncate(10, 0L)")).isEqualTo(0L); + assertThat(scalarSql("SELECT system.truncate(10, 1L)")).isEqualTo(0L); + assertThat(scalarSql("SELECT system.truncate(10, 5L)")).isEqualTo(0L); + assertThat(scalarSql("SELECT system.truncate(10, 9L)")).isEqualTo(0L); + assertThat(scalarSql("SELECT system.truncate(10, 10L)")).isEqualTo(10L); + assertThat(scalarSql("SELECT system.truncate(10, 11L)")).isEqualTo(10L); + assertThat(scalarSql("SELECT system.truncate(10, -1L)")).isEqualTo(-10L); + assertThat(scalarSql("SELECT system.truncate(10, -5L)")).isEqualTo(-10L); + assertThat(scalarSql("SELECT system.truncate(10, -10L)")).isEqualTo(-10L); + assertThat(scalarSql("SELECT system.truncate(10, -11L)")).isEqualTo(-20L); + + // Check that different widths can be used + assertThat(scalarSql("SELECT system.truncate(2, -1L)")).isEqualTo(-2L); + + assertThat(scalarSql("SELECT system.truncate(2, CAST(null AS bigint))")) + .as("Null input should return null") + .isNull(); + } + + @TestTemplate + public void testTruncateDecimal() { + // decimal truncation works by applying the decimal scale to the width: ie 10 scale 2 = 0.10 + assertThat(scalarSql("SELECT system.truncate(10, CAST(%s as DECIMAL(9, 2)))", "12.34")) + .isEqualTo(new BigDecimal("12.30")); + + assertThat(scalarSql("SELECT system.truncate(10, CAST(%s as DECIMAL(9, 2)))", "12.30")) + .isEqualTo(new BigDecimal("12.30")); + + assertThat(scalarSql("SELECT system.truncate(10, CAST(%s as DECIMAL(9, 3)))", "12.299")) + .isEqualTo(new BigDecimal("12.290")); + + assertThat(scalarSql("SELECT system.truncate(3, CAST(%s as DECIMAL(5, 2)))", "0.05")) + .isEqualTo(new BigDecimal("0.03")); + + assertThat(scalarSql("SELECT system.truncate(10, CAST(%s as DECIMAL(9, 2)))", "0.05")) + .isEqualTo(new BigDecimal("0.00")); + + assertThat(scalarSql("SELECT system.truncate(10, CAST(%s as DECIMAL(9, 2)))", "-0.05")) + .isEqualTo(new BigDecimal("-0.10")); + + assertThat(scalarSql("SELECT system.truncate(10, 12345.3482)")) + .as("Implicit decimal scale and precision should be allowed") + .isEqualTo(new BigDecimal("12345.3480")); + + BigDecimal truncatedDecimal = + (BigDecimal) scalarSql("SELECT system.truncate(10, CAST(%s as DECIMAL(6, 4)))", "-0.05"); + assertThat(truncatedDecimal.scale()) + .as("Truncating a decimal should return a decimal with the same scale") + .isEqualTo(4); + + assertThat(truncatedDecimal) + .as("Truncating a decimal should return a decimal with the correct scale") + .isEqualTo(BigDecimal.valueOf(-500, 4)); + + assertThat(scalarSql("SELECT system.truncate(2, CAST(null AS decimal))")) + .as("Null input should return null") + .isNull(); + } + + @SuppressWarnings("checkstyle:AvoidEscapedUnicodeCharacters") + @TestTemplate + public void testTruncateString() { + assertThat(scalarSql("SELECT system.truncate(5, 'abcdefg')")) + .as("Should system.truncate strings longer than length") + .isEqualTo("abcde"); + + assertThat(scalarSql("SELECT system.truncate(5, 'abc')")) + .as("Should not pad strings shorter than length") + .isEqualTo("abc"); + + assertThat(scalarSql("SELECT system.truncate(5, 'abcde')")) + .as("Should not alter strings equal to length") + .isEqualTo("abcde"); + + assertThat(scalarSql("SELECT system.truncate(2, 'イロハニホヘト')")) + .as("Strings with multibyte unicode characters should truncate along codepoint boundaries") + .isEqualTo("イロ"); + + assertThat(scalarSql("SELECT system.truncate(3, 'イロハニホヘト')")) + .as("Strings with multibyte unicode characters should truncate along codepoint boundaries") + .isEqualTo("イロハ"); + + assertThat(scalarSql("SELECT system.truncate(7, 'イロハニホヘト')")) + .as( + "Strings with multibyte unicode characters should not alter input with fewer codepoints than width") + .isEqualTo("イロハニホヘト"); + + String stringWithTwoCodePointsEachFourBytes = "\uD800\uDC00\uD800\uDC00"; + assertThat(scalarSql("SELECT system.truncate(1, '%s')", stringWithTwoCodePointsEachFourBytes)) + .as("String truncation on four byte codepoints should work as expected") + .isEqualTo("\uD800\uDC00"); + + assertThat(scalarSql("SELECT system.truncate(1, '测试')")) + .as("Should handle three-byte UTF-8 characters appropriately") + .isEqualTo("测"); + + assertThat(scalarSql("SELECT system.truncate(4, '测试raul试测')")) + .as("Should handle three-byte UTF-8 characters mixed with two byte utf-8 characters") + .isEqualTo("测试ra"); + + assertThat(scalarSql("SELECT system.truncate(10, '')")) + .as("Should not fail on the empty string") + .isEqualTo(""); + + assertThat(scalarSql("SELECT system.truncate(3, CAST(null AS string))")) + .as("Null input should return null as output") + .isNull(); + + assertThat(scalarSql("SELECT system.truncate(4, CAST('测试raul试测' AS varchar(8)))")) + .as("Varchar should work like string") + .isEqualTo("测试ra"); + + assertThat(scalarSql("SELECT system.truncate(4, CAST('测试raul试测' AS char(8)))")) + .as("Char should work like string") + .isEqualTo("测试ra"); + } + + @TestTemplate + public void testTruncateBinary() { + assertThat((byte[]) scalarSql("SELECT system.truncate(10, X'0102030405060708090a0b0c0d0e0f')")) + .isEqualTo(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}); + + assertThat((byte[]) scalarSql("SELECT system.truncate(3, %s)", asBytesLiteral("abcdefg"))) + .as("Should return the same input when value is equal to truncation width") + .isEqualTo("abc".getBytes(StandardCharsets.UTF_8)); + + assertThat((byte[]) scalarSql("SELECT system.truncate(10, %s)", asBytesLiteral("abc\0\0"))) + .as("Should not truncate, pad, or trim the input when its length is less than the width") + .isEqualTo("abc\0\0".getBytes(StandardCharsets.UTF_8)); + + assertThat((byte[]) scalarSql("SELECT system.truncate(3, %s)", asBytesLiteral("abc"))) + .as("Should not pad the input when its length is equal to the width") + .isEqualTo("abc".getBytes(StandardCharsets.UTF_8)); + + assertThat((byte[]) scalarSql("SELECT system.truncate(6, %s)", asBytesLiteral("测试_"))) + .as("Should handle three-byte UTF-8 characters appropriately") + .isEqualTo("测试".getBytes(StandardCharsets.UTF_8)); + + assertThat(scalarSql("SELECT system.truncate(3, CAST(null AS binary))")) + .as("Null input should return null as output") + .isNull(); + } + + @TestTemplate + public void testTruncateUsingDataframeForWidthWithVaryingWidth() { + // This situation is atypical but allowed. Typically, width is static as data is partitioned on + // one width. + long rumRows = 10L; + long numNonZero = + spark + .range(rumRows) + .toDF("value") + .selectExpr("CAST(value + 1 AS INT) AS width", "value") + .selectExpr("system.truncate(width, value) as truncated_value") + .filter("truncated_value == 0") + .count(); + assertThat(numNonZero) + .as("A truncate function with variable widths should be usable on dataframe columns") + .isEqualTo(rumRows); + } + + @TestTemplate + public void testWidthAcceptsShortAndByte() { + assertThat(scalarSql("SELECT system.truncate(5S, 1L)")) + .as("Short types should be usable for the width field") + .isEqualTo(0L); + + assertThat(scalarSql("SELECT system.truncate(5Y, 1)")) + .as("Byte types should be allowed for the width field") + .isEqualTo(0); + } + + @TestTemplate + public void testWrongNumberOfArguments() { + assertThatThrownBy(() -> scalarSql("SELECT system.truncate()")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (): Wrong number of inputs (expected width and value)"); + + assertThatThrownBy(() -> scalarSql("SELECT system.truncate(1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int): Wrong number of inputs (expected width and value)"); + + assertThatThrownBy(() -> scalarSql("SELECT system.truncate(1, 1L, 1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, bigint, int): Wrong number of inputs (expected width and value)"); + } + + @TestTemplate + public void testInvalidTypesCannotBeUsedForWidth() { + assertThatThrownBy( + () -> scalarSql("SELECT system.truncate(CAST('12.34' as DECIMAL(9, 2)), 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (decimal(9,2), int): Expected truncation width to be tinyint, shortint or int"); + + assertThatThrownBy(() -> scalarSql("SELECT system.truncate('5', 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (string, int): Expected truncation width to be tinyint, shortint or int"); + + assertThatThrownBy( + () -> scalarSql("SELECT system.truncate(INTERVAL '100-00' YEAR TO MONTH, 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (interval year to month, int): Expected truncation width to be tinyint, shortint or int"); + + assertThatThrownBy( + () -> + scalarSql( + "SELECT system.truncate(CAST('11 23:4:0' AS INTERVAL DAY TO SECOND), 10)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (interval day to second, int): Expected truncation width to be tinyint, shortint or int"); + } + + @TestTemplate + public void testInvalidTypesForTruncationColumn() { + assertThatThrownBy(() -> scalarSql("SELECT system.truncate(10, cast(12.3456 as float))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, float): Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + + assertThatThrownBy(() -> scalarSql("SELECT system.truncate(10, cast(12.3456 as double))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, double): Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + + assertThatThrownBy(() -> scalarSql("SELECT system.truncate(10, true)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, boolean): Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + + assertThatThrownBy(() -> scalarSql("SELECT system.truncate(10, map(1, 1))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, map): Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + + assertThatThrownBy(() -> scalarSql("SELECT system.truncate(10, array(1L))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, array): Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + + assertThatThrownBy( + () -> scalarSql("SELECT system.truncate(10, INTERVAL '100-00' YEAR TO MONTH)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, interval year to month): Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + + assertThatThrownBy( + () -> + scalarSql( + "SELECT system.truncate(10, CAST('11 23:4:0' AS INTERVAL DAY TO SECOND))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'truncate' cannot process input: (int, interval day to second): Expected truncation col to be tinyint, shortint, int, bigint, decimal, string, or binary"); + } + + @TestTemplate + public void testMagicFunctionsResolveForTinyIntAndSmallIntWidths() { + // Magic functions have staticinvoke in the explain output. Nonmagic calls use + // applyfunctionexpression instead. + String tinyIntWidthExplain = + (String) scalarSql("EXPLAIN EXTENDED SELECT system.truncate(1Y, 6)"); + assertThat(tinyIntWidthExplain) + .contains("cast(1 as int)") + .contains("static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateInt"); + + String smallIntWidth = (String) scalarSql("EXPLAIN EXTENDED SELECT system.truncate(5S, 6L)"); + assertThat(smallIntWidth) + .contains("cast(5 as int)") + .contains( + "static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateBigInt"); + } + + @TestTemplate + public void testThatMagicFunctionsAreInvoked() { + // Magic functions have `staticinvoke` in the explain output. + // Non-magic calls have `applyfunctionexpression` instead. + + // TinyInt + assertThat(scalarSql("EXPLAIN EXTENDED select system.truncate(5, 6Y)")) + .asString() + .isNotNull() + .contains( + "static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateTinyInt"); + + // SmallInt + assertThat(scalarSql("EXPLAIN EXTENDED select system.truncate(5, 6S)")) + .asString() + .isNotNull() + .contains( + "static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateSmallInt"); + + // Int + assertThat(scalarSql("EXPLAIN EXTENDED select system.truncate(5, 6)")) + .asString() + .isNotNull() + .contains("static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateInt"); + + // Long + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.truncate(5, 6L)")) + .asString() + .isNotNull() + .contains( + "static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateBigInt"); + + // String + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.truncate(5, 'abcdefg')")) + .asString() + .isNotNull() + .contains( + "static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateString"); + + // Decimal + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.truncate(5, 12.34)")) + .asString() + .isNotNull() + .contains( + "static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateDecimal"); + + // Binary + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.truncate(4, X'0102030405060708')")) + .asString() + .isNotNull() + .contains( + "static_invoke(org.apache.iceberg.spark.functions.TruncateFunction$TruncateBinary"); + } + + private String asBytesLiteral(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + return "X'" + BaseEncoding.base16().encode(bytes) + "'"; + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkVariantRead.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkVariantRead.java new file mode 100644 index 000000000000..2d6e919a91ee --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkVariantRead.java @@ -0,0 +1,365 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.List; +import org.apache.iceberg.spark.SparkCatalog; +import org.apache.iceberg.spark.TestBase; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.types.variant.Variant; +import org.apache.spark.unsafe.types.VariantVal; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class TestSparkVariantRead extends TestBase { + + private static final String CATALOG = "local"; + private static final String TABLE = CATALOG + ".default.var"; + + @BeforeAll + public static void setupCatalog() { + // Use a Hadoop catalog to avoid Hive schema conversion (Hive doesn't support VARIANT yet) + spark.conf().set("spark.sql.catalog." + CATALOG, SparkCatalog.class.getName()); + spark.conf().set("spark.sql.catalog." + CATALOG + ".type", "hadoop"); + spark.conf().set("spark.sql.catalog." + CATALOG + ".default-namespace", "default"); + spark.conf().set("spark.sql.catalog." + CATALOG + ".cache-enabled", "false"); + // point warehouse to a temp directory + String temp = System.getProperty("java.io.tmpdir") + "/iceberg_spark_variant_warehouse"; + spark.conf().set("spark.sql.catalog." + CATALOG + ".warehouse", temp); + } + + @BeforeEach + public void setupTable() { + sql("DROP TABLE IF EXISTS %s", TABLE); + sql( + "CREATE TABLE %s (id BIGINT, v1 VARIANT, v2 VARIANT) USING iceberg " + + "TBLPROPERTIES ('format-version'='3')", + TABLE); + + String v1r1 = "{\"a\":1}"; + String v2r1 = "{\"x\":10}"; + String v1r2 = "{\"b\":2}"; + String v2r2 = "{\"y\":20}"; + + sql("INSERT INTO %s SELECT 1, parse_json('%s'), parse_json('%s')", TABLE, v1r1, v2r1); + sql("INSERT INTO %s SELECT 2, parse_json('%s'), parse_json('%s')", TABLE, v1r2, v2r2); + } + + @AfterEach + public void cleanup() { + sql("DROP TABLE IF EXISTS %s", TABLE); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testVariantColumnProjection_singleVariant(boolean vectorized) { + assumeThat(vectorized).as("Variant vectorized Parquet read is not implemented yet").isFalse(); + setVectorization(vectorized); + Dataset df = spark.table(TABLE).select("id", "v1").orderBy("id"); + assertThat(df.schema().fieldNames()).containsExactly("id", "v1"); + assertThat(df.count()).isEqualTo(2); + + List directRows = df.collectAsList(); + Object v1row1 = directRows.get(0).get(1); + Object v1row2 = directRows.get(1).get(1); + assertThat(v1row1).isInstanceOf(VariantVal.class); + assertThat(v1row2).isInstanceOf(VariantVal.class); + VariantVal r1 = (VariantVal) v1row1; + VariantVal r2 = (VariantVal) v1row2; + Variant vv1 = new Variant(r1.getValue(), r1.getMetadata()); + Variant vv2 = new Variant(r2.getValue(), r2.getMetadata()); + + // row 1 has {"a":1} + Variant fieldA = vv1.getFieldByKey("a"); + assertThat(fieldA).isNotNull(); + assertThat(fieldA.getLong()).isEqualTo(1L); + + // row 2 has {"b":2} + Variant fieldB = vv2.getFieldByKey("b"); + assertThat(fieldB).isNotNull(); + assertThat(fieldB.getLong()).isEqualTo(2L); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testVariantColumnProjectionNoVariant(boolean vectorized) { + assumeThat(vectorized).as("Variant vectorized Parquet read is not implemented yet").isFalse(); + setVectorization(vectorized); + Dataset df = spark.table(TABLE).select("id"); + assertThat(df.schema().fieldNames()).containsExactly("id"); + assertThat(df.count()).isEqualTo(2); + assertThat(df.collectAsList()).extracting(r -> r.getLong(0)).containsExactlyInAnyOrder(1L, 2L); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testFilterOnVariantColumnOnWholeValue(boolean vectorized) { + assumeThat(vectorized).as("Variant vectorized Parquet read is not implemented yet").isFalse(); + setVectorization(vectorized); + sql("INSERT INTO %s SELECT 3, NULL, NULL", TABLE); + + Dataset nullDf = spark.table(TABLE).where("v1 IS NULL").select("id"); + assertThat(nullDf.collectAsList()).extracting(r -> r.getLong(0)).containsExactly(3L); + + Dataset notNullDf = spark.table(TABLE).where("v1 IS NOT NULL").select("id"); + assertThat(notNullDf.collectAsList()) + .extracting(r -> r.getLong(0)) + .containsExactlyInAnyOrder(1L, 2L); + + // verify variant contents for non-null rows + Dataset notNullVals = + spark + .table(TABLE) + .where("v1 IS NOT NULL") + .selectExpr("id", "to_json(v1) as v1_json") + .orderBy("id"); + List nn = notNullVals.collectAsList(); + assertThat(nn).hasSize(2); + assertThat(nn.get(0).getLong(0)).isEqualTo(1L); + assertThat(nn.get(0).getString(1)).isEqualTo("{\"a\":1}"); + assertThat(nn.get(1).getLong(0)).isEqualTo(2L); + assertThat(nn.get(1).getString(1)).isEqualTo("{\"b\":2}"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testVariantNullValueProjection(boolean vectorized) { + assumeThat(vectorized).as("Variant vectorized Parquet read is not implemented yet").isFalse(); + setVectorization(vectorized); + + // insert a row with NULL variant values + sql("INSERT INTO %s SELECT 10, NULL, NULL", TABLE); + + // select id and variant; ensure the variant value is null + Dataset df = spark.table(TABLE).where("id = 10").select("id", "v1"); + List rows = df.collectAsList(); + assertThat(rows).hasSize(1); + Row row = rows.get(0); + assertThat(row.getLong(0)).isEqualTo(10L); + assertThat(row.isNullAt(1)).isTrue(); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testNestedStructVariant(boolean vectorized) { + assumeThat(vectorized).as("Variant vectorized Parquet read is not implemented yet").isFalse(); + + String structTable = CATALOG + ".default.var_struct"; + sql("DROP TABLE IF EXISTS %s", structTable); + sql( + "CREATE TABLE %s (id BIGINT, s STRUCT) USING iceberg " + + "TBLPROPERTIES ('format-version'='3')", + structTable); + setVectorization(structTable, vectorized); + + String j1 = "{\"a\":1}"; + String j2 = "{\"b\":2}"; + sql("INSERT INTO %s SELECT 1, named_struct('v', parse_json('%s'))", structTable, j1); + sql("INSERT INTO %s SELECT 2, named_struct('v', parse_json('%s'))", structTable, j2); + + Dataset df = spark.table(structTable).selectExpr("id", "s.v AS v").orderBy("id"); + java.util.List rows = df.collectAsList(); + assertThat(rows.get(0).getLong(0)).isEqualTo(1L); + Object sv1 = rows.get(0).get(1); + assertThat(sv1).isInstanceOf(VariantVal.class); + Variant sv1Var = new Variant(((VariantVal) sv1).getValue(), ((VariantVal) sv1).getMetadata()); + assertThat(sv1Var.getFieldByKey("a").getLong()).isEqualTo(1L); + + assertThat(rows.get(1).getLong(0)).isEqualTo(2L); + Object sv2 = rows.get(1).get(1); + assertThat(sv2).isInstanceOf(VariantVal.class); + Variant sv2Var = new Variant(((VariantVal) sv2).getValue(), ((VariantVal) sv2).getMetadata()); + assertThat(sv2Var.getFieldByKey("b").getLong()).isEqualTo(2L); + + sql("DROP TABLE IF EXISTS %s", structTable); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testNestedArrayVariant(boolean vectorized) { + assumeThat(vectorized).as("Variant vectorized Parquet read is not implemented yet").isFalse(); + + String arrayTable = CATALOG + ".default.var_array"; + sql("DROP TABLE IF EXISTS %s", arrayTable); + sql( + "CREATE TABLE %s (id BIGINT, arr ARRAY) USING iceberg " + + "TBLPROPERTIES ('format-version'='3')", + arrayTable); + setVectorization(arrayTable, vectorized); + + String a1 = "{\"a\":1}"; + String a2 = "{\"x\":10}"; + String b1 = "{\"b\":2}"; + String b2 = "{\"y\":20}"; + sql("INSERT INTO %s SELECT 1, array(parse_json('%s'), parse_json('%s'))", arrayTable, a1, a2); + sql("INSERT INTO %s SELECT 2, array(parse_json('%s'), parse_json('%s'))", arrayTable, b1, b2); + + Dataset df = + spark.table(arrayTable).selectExpr("id", "arr[0] as e0", "arr[1] as e1").orderBy("id"); + java.util.List rows = df.collectAsList(); + assertThat(rows.get(0).getLong(0)).isEqualTo(1L); + Variant e0r1 = + new Variant( + ((VariantVal) rows.get(0).get(1)).getValue(), + ((VariantVal) rows.get(0).get(1)).getMetadata()); + Variant e1r1 = + new Variant( + ((VariantVal) rows.get(0).get(2)).getValue(), + ((VariantVal) rows.get(0).get(2)).getMetadata()); + assertThat(e0r1.getFieldByKey("a").getLong()).isEqualTo(1L); + assertThat(e1r1.getFieldByKey("x").getLong()).isEqualTo(10L); + assertThat(rows.get(1).getLong(0)).isEqualTo(2L); + Variant e0r2 = + new Variant( + ((VariantVal) rows.get(1).get(1)).getValue(), + ((VariantVal) rows.get(1).get(1)).getMetadata()); + Variant e1r2 = + new Variant( + ((VariantVal) rows.get(1).get(2)).getValue(), + ((VariantVal) rows.get(1).get(2)).getMetadata()); + assertThat(e0r2.getFieldByKey("b").getLong()).isEqualTo(2L); + assertThat(e1r2.getFieldByKey("y").getLong()).isEqualTo(20L); + + sql("DROP TABLE IF EXISTS %s", arrayTable); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testNestedMapVariant(boolean vectorized) { + assumeThat(vectorized).as("Variant vectorized Parquet read is not implemented yet").isFalse(); + + String mapTable = CATALOG + ".default.var_map"; + sql("DROP TABLE IF EXISTS %s", mapTable); + sql( + "CREATE TABLE %s (id BIGINT, m MAP) USING iceberg " + + "TBLPROPERTIES ('format-version'='3')", + mapTable); + setVectorization(mapTable, vectorized); + + String k1a = "{\"a\":1}"; + String k2x = "{\"x\":10}"; + String k1b = "{\"b\":2}"; + String k2y = "{\"y\":20}"; + sql( + "INSERT INTO %s SELECT 1, map('k1', parse_json('%s'), 'k2', parse_json('%s'))", + mapTable, k1a, k2x); + sql( + "INSERT INTO %s SELECT 2, map('k1', parse_json('%s'), 'k2', parse_json('%s'))", + mapTable, k1b, k2y); + + Dataset df = + spark + .table(mapTable) + .selectExpr("id", "element_at(m, 'k1') as k1", "element_at(m, 'k2') as k2") + .orderBy("id"); + java.util.List rows = df.collectAsList(); + assertThat(rows.get(0).getLong(0)).isEqualTo(1L); + Variant k1r1 = + new Variant( + ((VariantVal) rows.get(0).get(1)).getValue(), + ((VariantVal) rows.get(0).get(1)).getMetadata()); + Variant k2r1 = + new Variant( + ((VariantVal) rows.get(0).get(2)).getValue(), + ((VariantVal) rows.get(0).get(2)).getMetadata()); + assertThat(k1r1.getFieldByKey("a").getLong()).isEqualTo(1L); + assertThat(k2r1.getFieldByKey("x").getLong()).isEqualTo(10L); + assertThat(rows.get(1).getLong(0)).isEqualTo(2L); + Variant k1r2 = + new Variant( + ((VariantVal) rows.get(1).get(1)).getValue(), + ((VariantVal) rows.get(1).get(1)).getMetadata()); + Variant k2r2 = + new Variant( + ((VariantVal) rows.get(1).get(2)).getValue(), + ((VariantVal) rows.get(1).get(2)).getMetadata()); + assertThat(k1r2.getFieldByKey("b").getLong()).isEqualTo(2L); + assertThat(k2r2.getFieldByKey("y").getLong()).isEqualTo(20L); + + sql("DROP TABLE IF EXISTS %s", mapTable); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testMergeIntoWithVariant(boolean vectorized) { + // Variant columns are not vectorized yet, but MERGE INTO should not crash regardless of the + // vectorization setting. The reader falls back to non-vectorized for variant columns. + String mergeTable = CATALOG + ".default.var_merge"; + sql("DROP TABLE IF EXISTS %s", mergeTable); + sql( + "CREATE TABLE %s (id BIGINT, data VARIANT) USING iceberg " + + "TBLPROPERTIES ('format-version'='3')", + mergeTable); + setVectorization(mergeTable, vectorized); + + sql( + "INSERT INTO %s VALUES " + + "(1, parse_json('{\"name\":\"alice\",\"age\":30}')), " + + "(2, parse_json('{\"name\":\"bob\",\"age\":25}'))", + mergeTable); + + sql( + "MERGE INTO %s AS target " + + "USING (SELECT 1 AS id, parse_json('{\"name\":\"alice\",\"age\":31}') AS data) AS source " + + "ON target.id = source.id " + + "WHEN MATCHED THEN UPDATE SET target.data = source.data " + + "WHEN NOT MATCHED THEN INSERT *", + mergeTable); + + List rows = spark.table(mergeTable).select("id", "data").orderBy("id").collectAsList(); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0).getLong(0)).isEqualTo(1L); + Variant v1 = + new Variant( + ((VariantVal) rows.get(0).get(1)).getValue(), + ((VariantVal) rows.get(0).get(1)).getMetadata()); + assertThat(v1.getFieldByKey("name").getString()).describedAs("v1.name").isEqualTo("alice"); + assertThat(v1.getFieldByKey("age").getLong()).describedAs("v1.age").isEqualTo(31L); + + assertThat(rows.get(1).getLong(0)).isEqualTo(2L); + Variant v2 = + new Variant( + ((VariantVal) rows.get(1).get(1)).getValue(), + ((VariantVal) rows.get(1).get(1)).getMetadata()); + assertThat(v2.getFieldByKey("name").getString()).describedAs("v2.name").isEqualTo("bob"); + assertThat(v2.getFieldByKey("age").getLong()).describedAs("v2.age").isEqualTo(25L); + + sql("DROP TABLE IF EXISTS %s", mergeTable); + } + + private void setVectorization(boolean on) { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('read.parquet.vectorization.enabled'='%s')", + TABLE, Boolean.toString(on)); + } + + private void setVectorization(String table, boolean on) { + sql( + "ALTER TABLE %s SET TBLPROPERTIES ('read.parquet.vectorization.enabled'='%s')", + table, Boolean.toString(on)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkYearsFunction.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkYearsFunction.java new file mode 100644 index 000000000000..213963a65ff1 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestSparkYearsFunction.java @@ -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. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.spark.functions.YearsFunction; +import org.apache.spark.sql.AnalysisException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestSparkYearsFunction extends TestBaseWithCatalog { + + @BeforeEach + public void useCatalog() { + sql("USE %s", catalogName); + } + + @TestTemplate + public void testDates() { + assertThat(scalarSql("SELECT system.years(date('2017-12-01'))")) + .as("Expected to produce 2017 - 1970 = 47") + .isEqualTo(47); + assertThat(scalarSql("SELECT system.years(date('1970-01-01'))")) + .as("Expected to produce 1970 - 1970 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.years(date('1969-12-31'))")) + .as("Expected to produce 1969 - 1970 = -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.years(CAST(null AS DATE))")).isNull(); + } + + @TestTemplate + public void testTimestamps() { + assertThat(scalarSql("SELECT system.years(TIMESTAMP '2017-12-01 10:12:55.038194 UTC+00:00')")) + .as("Expected to produce 2017 - 1970 = 47") + .isEqualTo(47); + assertThat(scalarSql("SELECT system.years(TIMESTAMP '1970-01-01 00:00:01.000001 UTC+00:00')")) + .as("Expected to produce 1970 - 1970 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.years(TIMESTAMP '1969-12-31 23:59:58.999999 UTC+00:00')")) + .as("Expected to produce 1969 - 1970 = -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.years(CAST(null AS TIMESTAMP))")).isNull(); + } + + @TestTemplate + public void testTimestampNtz() { + assertThat(scalarSql("SELECT system.years(TIMESTAMP_NTZ '2017-12-01 10:12:55.038194 UTC')")) + .as("Expected to produce 2017 - 1970 = 47") + .isEqualTo(47); + assertThat(scalarSql("SELECT system.years(TIMESTAMP_NTZ '1970-01-01 00:00:01.000001 UTC')")) + .as("Expected to produce 1970 - 1970 = 0") + .isEqualTo(0); + assertThat(scalarSql("SELECT system.years(TIMESTAMP_NTZ '1969-12-31 23:59:58.999999 UTC')")) + .as("Expected to produce 1969 - 1970 = -1") + .isEqualTo(-1); + assertThat(scalarSql("SELECT system.years(CAST(null AS TIMESTAMP_NTZ))")).isNull(); + } + + @TestTemplate + public void testWrongNumberOfArguments() { + assertThatThrownBy(() -> scalarSql("SELECT system.years()")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'years' cannot process input: (): Wrong number of inputs"); + + assertThatThrownBy( + () -> scalarSql("SELECT system.years(date('1969-12-31'), date('1969-12-31'))")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'years' cannot process input: (date, date): Wrong number of inputs"); + } + + @TestTemplate + public void testInvalidInputTypes() { + assertThatThrownBy(() -> scalarSql("SELECT system.years(1)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'years' cannot process input: (int): Expected value to be date or timestamp"); + + assertThatThrownBy(() -> scalarSql("SELECT system.years(1L)")) + .isInstanceOf(AnalysisException.class) + .hasMessageStartingWith( + "Function 'years' cannot process input: (bigint): Expected value to be date or timestamp"); + } + + @TestTemplate + public void testThatMagicFunctionsAreInvoked() { + String dateValue = "date('2017-12-01')"; + String dateTransformClass = YearsFunction.DateToYearsFunction.class.getName(); + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.years(%s)", dateValue)) + .asString() + .isNotNull() + .contains("static_invoke(" + dateTransformClass); + + String timestampValue = "TIMESTAMP '2017-12-01 10:12:55.038194 UTC+00:00'"; + String timestampTransformClass = YearsFunction.TimestampToYearsFunction.class.getName(); + assertThat(scalarSql("EXPLAIN EXTENDED SELECT system.years(%s)", timestampValue)) + .asString() + .isNotNull() + .contains("static_invoke(" + timestampTransformClass); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestStoragePartitionedJoins.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestStoragePartitionedJoins.java new file mode 100644 index 000000000000..8788dff15806 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestStoragePartitionedJoins.java @@ -0,0 +1,881 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.iceberg.PlanningMode.DISTRIBUTED; +import static org.apache.iceberg.PlanningMode.LOCAL; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.commons.lang3.StringUtils; +import org.apache.iceberg.Parameter; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.PlanningMode; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.expressions.Expressions; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.relocated.com.google.common.collect.Lists; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.spark.SparkSchemaUtil; +import org.apache.iceberg.spark.SparkWriteOptions; +import org.apache.iceberg.spark.TestBaseWithCatalog; +import org.apache.iceberg.spark.data.RandomData; +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.InternalRow; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.internal.SQLConf; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestStoragePartitionedJoins extends TestBaseWithCatalog { + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}, planningMode = {3}") + public static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + LOCAL + }, + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties(), + DISTRIBUTED + }, + }; + } + + private static final String OTHER_TABLE_NAME = "other_table"; + + // open file cost and split size are set as 16 MB to produce a split per file + private static final Map TABLE_PROPERTIES = + ImmutableMap.of( + TableProperties.SPLIT_SIZE, "16777216", TableProperties.SPLIT_OPEN_FILE_COST, "16777216"); + + // only v2 bucketing and preserve data grouping properties have to be enabled to trigger SPJ + // other properties are only to simplify testing and validation + private static final Map ENABLED_SPJ_SQL_CONF = + ImmutableMap.of( + SQLConf.V2_BUCKETING_ENABLED().key(), + "true", + SQLConf.V2_BUCKETING_PUSH_PART_VALUES_ENABLED().key(), + "true", + SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION().key(), + "false", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "false", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD().key(), + "-1", + SparkSQLProperties.PRESERVE_DATA_GROUPING, + "true", + SQLConf.V2_BUCKETING_PARTIALLY_CLUSTERED_DISTRIBUTION_ENABLED().key(), + "false", + SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS().key(), + "true", + SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS().key(), + "true"); + + private static final Map DISABLED_SPJ_SQL_CONF = + ImmutableMap.of( + SQLConf.V2_BUCKETING_ENABLED().key(), + "false", + SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION().key(), + "false", + SQLConf.ADAPTIVE_EXECUTION_ENABLED().key(), + "false", + SQLConf.AUTO_BROADCASTJOIN_THRESHOLD().key(), + "-1", + SparkSQLProperties.PRESERVE_DATA_GROUPING, + "true"); + + @Parameter(index = 3) + private PlanningMode planningMode; + + @BeforeAll + public static void setupSparkConf() { + spark.conf().set("spark.sql.shuffle.partitions", "4"); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", tableName(OTHER_TABLE_NAME)); + } + + // TODO: add tests for truncate transforms once SPARK-40295 is released + + @TestTemplate + public void testJoinsWithBucketingOnByteColumn() throws NoSuchTableException { + checkJoin("byte_col", "TINYINT", "bucket(4, byte_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnShortColumn() throws NoSuchTableException { + checkJoin("short_col", "SMALLINT", "bucket(4, short_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnIntColumn() throws NoSuchTableException { + checkJoin("int_col", "INT", "bucket(16, int_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnLongColumn() throws NoSuchTableException { + checkJoin("long_col", "BIGINT", "bucket(16, long_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnTimestampColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP", "bucket(16, timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnTimestampNtzColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP_NTZ", "bucket(16, timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnDateColumn() throws NoSuchTableException { + checkJoin("date_col", "DATE", "bucket(8, date_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnDecimalColumn() throws NoSuchTableException { + checkJoin("decimal_col", "DECIMAL(20, 2)", "bucket(8, decimal_col)"); + } + + @TestTemplate + public void testJoinsWithBucketingOnBinaryColumn() throws NoSuchTableException { + checkJoin("binary_col", "BINARY", "bucket(8, binary_col)"); + } + + @TestTemplate + public void testJoinsWithYearsOnTimestampColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP", "years(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithYearsOnTimestampNtzColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP_NTZ", "years(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithYearsOnDateColumn() throws NoSuchTableException { + checkJoin("date_col", "DATE", "years(date_col)"); + } + + @TestTemplate + public void testJoinsWithMonthsOnTimestampColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP", "months(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithMonthsOnTimestampNtzColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP_NTZ", "months(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithMonthsOnDateColumn() throws NoSuchTableException { + checkJoin("date_col", "DATE", "months(date_col)"); + } + + @TestTemplate + public void testJoinsWithDaysOnTimestampColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP", "days(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithDaysOnTimestampNtzColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP_NTZ", "days(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithDaysOnDateColumn() throws NoSuchTableException { + checkJoin("date_col", "DATE", "days(date_col)"); + } + + @TestTemplate + public void testJoinsWithHoursOnTimestampColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP", "hours(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithHoursOnTimestampNtzColumn() throws NoSuchTableException { + checkJoin("timestamp_col", "TIMESTAMP_NTZ", "hours(timestamp_col)"); + } + + @TestTemplate + public void testJoinsWithMultipleTransformTypes() throws NoSuchTableException { + String createTableStmt = + "CREATE TABLE %s (" + + " id BIGINT, int_col INT, date_col1 DATE, date_col2 DATE, date_col3 DATE," + + " timestamp_col TIMESTAMP, string_col STRING, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (" + + " years(date_col1), months(date_col2), days(date_col3), hours(timestamp_col), " + + " bucket(8, int_col), dep)" + + "TBLPROPERTIES (%s)"; + + sql(createTableStmt, tableName, tablePropsAsString(TABLE_PROPERTIES)); + sql(createTableStmt, tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + Table table = validationCatalog.loadTable(tableIdent); + + Dataset dataDF = randomDataDF(table.schema(), 16); + + // write to the first table 1 time to generate 1 file per partition + append(tableName, dataDF); + + // write to the second table 2 times to generate 2 files per partition + append(tableName(OTHER_TABLE_NAME), dataDF); + append(tableName(OTHER_TABLE_NAME), dataDF); + + // Spark SPJ support is limited at the moment and requires all source partitioning columns, + // which were projected in the query, to be part of the join condition + // suppose a table is partitioned by `p1`, `bucket(8, pk)` + // queries covering `p1` and `pk` columns must include equality predicates + // on both `p1` and `pk` to benefit from SPJ + // this is a temporary Spark limitation that will be removed in a future release + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT t1.id " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.dep = t2.dep " + + "ORDER BY t1.id", + tableName, + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT t1.id, t1.int_col, t1.date_col1 " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.int_col = t2.int_col AND t1.date_col1 = t2.date_col1 " + + "ORDER BY t1.id, t1.int_col, t1.date_col1", + tableName, + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT t1.id, t1.timestamp_col, t1.string_col " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.timestamp_col = t2.timestamp_col AND t1.string_col = t2.string_col " + + "ORDER BY t1.id, t1.timestamp_col, t1.string_col", + tableName, + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT t1.id, t1.date_col1, t1.date_col2, t1.date_col3 " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.date_col1 = t2.date_col1 AND t1.date_col2 = t2.date_col2 AND t1.date_col3 = t2.date_col3 " + + "ORDER BY t1.id, t1.date_col1, t1.date_col2, t1.date_col3", + tableName, + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT t1.id, t1.int_col, t1.timestamp_col, t1.dep " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.int_col = t2.int_col AND t1.timestamp_col = t2.timestamp_col AND t1.dep = t2.dep " + + "ORDER BY t1.id, t1.int_col, t1.timestamp_col, t1.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsWithCompatibleSpecEvolution() { + // create a table with an empty spec + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + Table table = validationCatalog.loadTable(tableIdent); + + // evolve the spec in the first table by adding `dep` + table.updateSpec().addField("dep").commit(); + + // insert data into the first table partitioned by `dep` + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName); + + // evolve the spec in the first table by adding `bucket(int_col, 8)` + table.updateSpec().addField(Expressions.bucket("int_col", 8)).commit(); + + // insert data into the first table partitioned by `dep`, `bucket(8, int_col)` + sql("REFRESH TABLE %s", tableName); + sql("INSERT INTO %s VALUES (2L, 200, 'hr')", tableName); + + // create another table partitioned by `other_dep` + sql( + "CREATE TABLE %s (other_id BIGINT, other_int_col INT, other_dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (other_dep)" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + // insert data into the second table partitioned by 'other_dep' + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (2L, 200, 'hr')", tableName(OTHER_TABLE_NAME)); + + // SPJ would apply as the grouping keys are compatible + // the first table: `dep` (an intersection of all active partition fields across scanned specs) + // the second table: `other_dep` (the only partition field). + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s " + + "INNER JOIN %s " + + "ON id = other_id AND int_col = other_int_col AND dep = other_dep " + + "ORDER BY id, int_col, dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsWithIncompatibleSpecs() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName); + sql("INSERT INTO %s VALUES (2L, 200, 'software')", tableName); + sql("INSERT INTO %s VALUES (3L, 300, 'software')", tableName); + + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (bucket(8, int_col))" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (2L, 200, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (3L, 300, 'software')", tableName(OTHER_TABLE_NAME)); + + // queries can't benefit from SPJ as specs are not compatible + // the first table: `dep` + // the second table: `bucket(8, int_col)` + + assertPartitioningAwarePlan( + 3, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles with SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.int_col = t2.int_col AND t1.dep = t2.dep " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsWithUnpartitionedTables() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "TBLPROPERTIES (" + + " 'read.split.target-size' = 16777216," + + " 'read.split.open-file-cost' = 16777216)", + tableName); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName); + sql("INSERT INTO %s VALUES (2L, 200, 'software')", tableName); + sql("INSERT INTO %s VALUES (3L, 300, 'software')", tableName); + + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "TBLPROPERTIES (" + + " 'read.split.target-size' = 16777216," + + " 'read.split.open-file-cost' = 16777216)", + tableName(OTHER_TABLE_NAME)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (2L, 200, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (3L, 300, 'software')", tableName(OTHER_TABLE_NAME)); + + // queries covering unpartitioned tables can't benefit from SPJ but shouldn't fail + + assertPartitioningAwarePlan( + 3, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.int_col = t2.int_col AND t1.dep = t2.dep " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsWithEmptyTable() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (2L, 200, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (3L, 300, 'software')", tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 3, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.int_col = t2.int_col AND t1.dep = t2.dep " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsWithOneSplitTables() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName); + + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.int_col = t2.int_col AND t1.dep = t2.dep " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsWithMismatchingPartitionKeys() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName); + sql("INSERT INTO %s VALUES (2L, 100, 'hr')", tableName); + + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep)" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + sql("INSERT INTO %s VALUES (1L, 100, 'software')", tableName(OTHER_TABLE_NAME)); + sql("INSERT INTO %s VALUES (3L, 300, 'hardware')", tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.dep = t2.dep " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsCompatibleBucketNumbers() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (bucket(4, id))" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software')," + + "(2L, 101, 'hr')," + + "(3L, 102, 'operation')," + + "(4L, 103, 'sales')," + + "(5L, 104, 'marketing')," + + "(6L, 105, 'pr')", + tableName); + + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (bucket(6, id))" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software')," + + "(3L, 300, 'hardware')," + + "(4L, 103, 'sales')," + + "(5L, 104, 'marketing')," + + "(6L, 105, 'pr')", + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsWithBucketWithOneSideReducing() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (bucket(4, id))" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software')," + + "(2L, 101, 'hr')," + + "(3L, 102, 'operation')," + + "(4L, 103, 'sales')," + + "(5L, 104, 'marketing')," + + "(6L, 105, 'pr')", + tableName); + + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (bucket(8, id))" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software')," + + "(3L, 300, 'hardware')," + + "(4L, 103, 'sales')," + + "(5L, 104, 'marketing')," + + "(6L, 105, 'pr')", + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsIncompatibleBucketNumbers() { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (bucket(3, id))" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software')," + + "(2L, 101, 'hr')," + + "(3L, 102, 'operation')," + + "(4L, 103, 'sales')," + + "(5L, 104, 'marketing')," + + "(6L, 105, 'pr')", + tableName); + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (bucket(5, id))" + + "TBLPROPERTIES (%s)", + tableName(OTHER_TABLE_NAME), tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software')," + + "(3L, 300, 'hardware')," + + "(4L, 103, 'sales')," + + "(5L, 104, 'marketing')," + + "(6L, 105, 'pr')", + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 3, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testAggregates() throws NoSuchTableException { + sql( + "CREATE TABLE %s (id BIGINT, int_col INT, dep STRING)" + + "USING iceberg " + + "PARTITIONED BY (dep, bucket(8, int_col))" + + "TBLPROPERTIES (%s)", + tableName, tablePropsAsString(TABLE_PROPERTIES)); + + // write to the table 3 times to generate 3 files per partition + Table table = validationCatalog.loadTable(tableIdent); + Dataset dataDF = randomDataDF(table.schema(), 100); + append(tableName, dataDF); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT COUNT (DISTINCT id) AS count FROM %s GROUP BY dep, int_col ORDER BY count", + tableName, + tableName(OTHER_TABLE_NAME)); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT COUNT (DISTINCT id) AS count FROM %s GROUP BY dep ORDER BY count", + tableName, + tableName(OTHER_TABLE_NAME)); + } + + @TestTemplate + public void testJoinsHourToDays() throws NoSuchTableException { + String createTableStmt = + "CREATE TABLE %s (" + + "id BIGINT, int_col INT,dep STRING,timestamp_col TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (days(timestamp_col)) " + + "TBLPROPERTIES (%s)"; + + sql(createTableStmt, tableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software', TIMESTAMP('2024-11-11 10:00:00'))," + + "(2L, 101, 'hr', TIMESTAMP('2024-11-10 09:00:00'))," + + "(3L, 102, 'operation', TIMESTAMP('2024-11-10 11:00:00'))," + + "(4L, 103, 'sales', TIMESTAMP('2024-11-10 10:00:00'))," + + "(5L, 104, 'marketing', TIMESTAMP('2024-11-11 10:00:00'))," + + "(6L, 105, 'pr', TIMESTAMP('2024-11-10 10:00:00'))", + tableName); + + String create2ndTableStmt = + "CREATE TABLE %s (" + + "id BIGINT, int_col INT, dep STRING, timestamp_col TIMESTAMP) " + + "USING iceberg " + + "PARTITIONED BY (hours(timestamp_col)) " + + "TBLPROPERTIES (%s)"; + + String otherTableName = tableName(OTHER_TABLE_NAME); + + sql(create2ndTableStmt, otherTableName, tablePropsAsString(TABLE_PROPERTIES)); + + sql( + "INSERT INTO %s VALUES " + + "(1L, 100, 'software', TIMESTAMP('2024-11-11 10:00:00'))," + + "(3L, 102, 'operation', TIMESTAMP('2024-11-10 11:00:00'))," + + "(5L, 104, 'marketing', TIMESTAMP('2024-11-11 10:00:00'))," + + "(5L, 104, 'marketing', TIMESTAMP('2024-11-11 10:00:00'))," + + "(6L, 105, 'pr', TIMESTAMP('2024-11-10 10:00:00'))", + otherTableName); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT * " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id and t1.timestamp_col = t2.timestamp_col " + + "ORDER BY t1.id, t1.int_col, t1.dep, t2.id, t2.int_col, t2.dep", + tableName, + otherTableName); + } + + private void checkJoin(String sourceColumnName, String sourceColumnType, String transform) + throws NoSuchTableException { + + String createTableStmt = + "CREATE TABLE %s (id BIGINT, salary INT, %s %s)" + + "USING iceberg " + + "PARTITIONED BY (%s)" + + "TBLPROPERTIES (%s)"; + + sql( + createTableStmt, + tableName, + sourceColumnName, + sourceColumnType, + transform, + tablePropsAsString(TABLE_PROPERTIES)); + configurePlanningMode(tableName, planningMode); + + sql( + createTableStmt, + tableName(OTHER_TABLE_NAME), + sourceColumnName, + sourceColumnType, + transform, + tablePropsAsString(TABLE_PROPERTIES)); + configurePlanningMode(tableName(OTHER_TABLE_NAME), planningMode); + + Table table = validationCatalog.loadTable(tableIdent); + Dataset dataDF = randomDataDF(table.schema(), 200); + append(tableName, dataDF); + append(tableName(OTHER_TABLE_NAME), dataDF); + + assertPartitioningAwarePlan( + 1, /* expected num of shuffles with SPJ */ + 3, /* expected num of shuffles without SPJ */ + "SELECT t1.id, t1.salary, t1.%s " + + "FROM %s t1 " + + "INNER JOIN %s t2 " + + "ON t1.id = t2.id AND t1.%s = t2.%s " + + "ORDER BY t1.id, t1.%s, t1.salary", + sourceColumnName, + tableName, + tableName(OTHER_TABLE_NAME), + sourceColumnName, + sourceColumnName, + sourceColumnName); + } + + private void assertPartitioningAwarePlan( + int expectedNumShufflesWithSPJ, + int expectedNumShufflesWithoutSPJ, + String query, + Object... args) { + + AtomicReference> rowsWithSPJ = new AtomicReference<>(); + AtomicReference> rowsWithoutSPJ = new AtomicReference<>(); + + withSQLConf( + ENABLED_SPJ_SQL_CONF, + () -> { + String plan = executeAndKeepPlan(query, args).toString(); + int actualNumShuffles = StringUtils.countMatches(plan, "Exchange"); + assertThat(actualNumShuffles) + .as("Number of shuffles with enabled SPJ must match") + .isEqualTo(expectedNumShufflesWithSPJ); + + rowsWithSPJ.set(sql(query, args)); + }); + + withSQLConf( + DISABLED_SPJ_SQL_CONF, + () -> { + String plan = executeAndKeepPlan(query, args).toString(); + int actualNumShuffles = StringUtils.countMatches(plan, "Exchange"); + assertThat(actualNumShuffles) + .as("Number of shuffles with disabled SPJ must match") + .isEqualTo(expectedNumShufflesWithoutSPJ); + + rowsWithoutSPJ.set(sql(query, args)); + }); + + assertEquals("SPJ should not change query output", rowsWithoutSPJ.get(), rowsWithSPJ.get()); + } + + private Dataset randomDataDF(Schema schema, int numRows) { + Iterable rows = RandomData.generateSpark(schema, numRows, 0); + JavaRDD rowRDD = sparkContext.parallelize(Lists.newArrayList(rows)); + StructType rowSparkType = SparkSchemaUtil.convert(schema); + Preconditions.checkArgument( + spark instanceof org.apache.spark.sql.classic.SparkSession, + "Expected instance of org.apache.spark.sql.classic.SparkSession, but got: %s", + spark.getClass().getName()); + + return ((org.apache.spark.sql.classic.SparkSession) spark) + .internalCreateDataFrame(JavaRDD.toRDD(rowRDD), rowSparkType, false); + } + + private void append(String table, Dataset df) throws NoSuchTableException { + // fanout writes are enabled as write-time clustering is not supported without Spark extensions + df.coalesce(1).writeTo(table).option(SparkWriteOptions.FANOUT_ENABLED, "true").append(); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestTableEncryption.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestTableEncryption.java new file mode 100644 index 000000000000..3b36b7bb0a25 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestTableEncryption.java @@ -0,0 +1,375 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.apache.iceberg.Files.localInput; +import static org.apache.iceberg.types.Types.NestedField.optional; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.ChecksumFileSystem; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.AppendFiles; +import org.apache.iceberg.CatalogProperties; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableMetadata; +import org.apache.iceberg.Transaction; +import org.apache.iceberg.encryption.Ciphers; +import org.apache.iceberg.encryption.UnitestKMS; +import org.apache.iceberg.io.InputFile; +import org.apache.iceberg.io.SeekableInputStream; +import org.apache.iceberg.parquet.Parquet; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.Maps; +import org.apache.iceberg.relocated.com.google.common.collect.Streams; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.types.Types; +import org.apache.parquet.crypto.ParquetCryptoRuntimeException; +import org.apache.spark.SparkException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.mockito.internal.util.collections.Iterables; + +public class TestTableEncryption extends CatalogTestBase { + private static Map appendCatalogEncryptionProperties(Map props) { + Map newProps = Maps.newHashMap(); + newProps.putAll(props); + newProps.put(CatalogProperties.ENCRYPTION_KMS_IMPL, UnitestKMS.class.getCanonicalName()); + return newProps; + } + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE.catalogName(), + SparkCatalogConfig.HIVE.implementation(), + appendCatalogEncryptionProperties(SparkCatalogConfig.HIVE.properties()) + } + }; + } + + @BeforeEach + public void createTables() { + sql( + "CREATE TABLE %s (id bigint, data string, float float) USING iceberg " + + "TBLPROPERTIES ( " + + "'encryption.key-id'='%s', 'format-version'='3')", + tableName, UnitestKMS.MASTER_KEY_NAME1); + + sql("INSERT INTO %s VALUES (1, 'a', 1.0), (2, 'b', 2.0), (3, 'c', float('NaN'))", tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testSelect() { + List expected = + ImmutableList.of(row(1L, "a", 1.0F), row(2L, "b", 2.0F), row(3L, "c", Float.NaN)); + + assertEquals("Should return all expected rows", expected, sql("SELECT * FROM %s", tableName)); + } + + private static List currentDataFiles(Table table) { + return Streams.stream(table.newScan().planFiles()) + .map(FileScanTask::file) + .collect(Collectors.toList()); + } + + @TestTemplate + public void testRefresh() { + validationCatalog.initialize(catalogName, catalogConfig); + Table table = validationCatalog.loadTable(tableIdent); + + assertThat(currentDataFiles(table)).isNotEmpty(); + + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0), (6, 'f', float('NaN'))", tableName); + + table.refresh(); + assertThat(currentDataFiles(table)).isNotEmpty(); + } + + @TestTemplate + public void testAppendTransaction() { + validationCatalog.initialize(catalogName, catalogConfig); + Table table = validationCatalog.loadTable(tableIdent); + + List dataFiles = currentDataFiles(table); + Transaction transaction = table.newTransaction(); + AppendFiles append = transaction.newAppend(); + + // add an arbitrary datafile + append.appendFile(dataFiles.get(0)); + append.commit(); + transaction.commitTransaction(); + + assertThat(currentDataFiles(table)).hasSize(dataFiles.size() + 1); + } + + @TestTemplate + public void testConcurrentAppendTransactions() { + validationCatalog.initialize(catalogName, catalogConfig); + Table table = validationCatalog.loadTable(tableIdent); + + List dataFiles = currentDataFiles(table); + Transaction transaction = table.newTransaction(); + AppendFiles append = transaction.newAppend(); + + // add an arbitrary datafile + append.appendFile(dataFiles.get(0)); + + // append to the table in the meantime. use a separate load to avoid shared operations + validationCatalog.loadTable(tableIdent).newFastAppend().appendFile(dataFiles.get(0)).commit(); + + append.commit(); + transaction.commitTransaction(); + + assertThat(currentDataFiles(table)).hasSize(dataFiles.size() + 2); + } + + // See CatalogTests#testConcurrentReplaceTransactions + @TestTemplate + public void testConcurrentReplaceTransactions() { + validationCatalog.initialize(catalogName, catalogConfig); + + Table table = validationCatalog.loadTable(tableIdent); + DataFile file = currentDataFiles(table).get(0); + Schema schema = table.schema(); + + // Write data for a replace transaction that will be committed later + Transaction secondReplace = + validationCatalog + .buildTable(tableIdent, schema) + .withProperty("encryption.key-id", UnitestKMS.MASTER_KEY_NAME1) + .replaceTransaction(); + secondReplace.newFastAppend().appendFile(file).commit(); + + // Commit another replace transaction first + Transaction firstReplace = + validationCatalog + .buildTable(tableIdent, schema) + .withProperty("encryption.key-id", UnitestKMS.MASTER_KEY_NAME1) + .replaceTransaction(); + firstReplace.newFastAppend().appendFile(file).commit(); + firstReplace.commitTransaction(); + + secondReplace.commitTransaction(); + + Table afterSecondReplace = validationCatalog.loadTable(tableIdent); + assertThat(currentDataFiles(afterSecondReplace)).hasSize(1); + } + + @TestTemplate + public void testInsertAndDelete() { + sql("INSERT INTO %s VALUES (4, 'd', 4.0), (5, 'e', 5.0), (6, 'f', float('NaN'))", tableName); + + List expected = + ImmutableList.of( + row(1L, "a", 1.0F), + row(2L, "b", 2.0F), + row(3L, "c", Float.NaN), + row(4L, "d", 4.0F), + row(5L, "e", 5.0F), + row(6L, "f", Float.NaN)); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); + + sql("DELETE FROM %s WHERE id < 4", tableName); + + expected = ImmutableList.of(row(4L, "d", 4.0F), row(5L, "e", 5.0F), row(6L, "f", Float.NaN)); + + assertEquals( + "Should return all expected rows", + expected, + sql("SELECT * FROM %s ORDER BY id", tableName)); + } + + @TestTemplate + public void testMetadataTamperproofing() throws IOException { + ChecksumFileSystem fs = ((ChecksumFileSystem) FileSystem.newInstance(new Configuration())); + catalog.initialize(catalogName, catalogConfig); + + Table table = catalog.loadTable(tableIdent); + TableMetadata currentMetadata = ((HasTableOperations) table).operations().current(); + Path metadataFile = new Path(currentMetadata.metadataFileLocation()); + Path previousMetadataFile = new Path(Iterables.firstOf(currentMetadata.previousFiles()).file()); + + // manual FS tampering: replacing the current metadata file with a previous one + Path crcPath = fs.getChecksumFile(metadataFile); + fs.delete(crcPath, false); + fs.delete(metadataFile, false); + fs.rename(previousMetadataFile, metadataFile); + + assertThatThrownBy(() -> catalog.loadTable(tableIdent)) + .hasMessageContaining( + String.format( + "The current metadata file %s might have been modified. Hash of metadata loaded from storage differs from HMS-stored metadata hash.", + metadataFile)); + } + + @TestTemplate + public void testKeyDelete() { + assertThatThrownBy( + () -> sql("ALTER TABLE %s UNSET TBLPROPERTIES (`encryption.key-id`)", tableName)) + .isInstanceOf(SparkException.class) + .hasMessage("Unsupported table change: Cannot remove key ID from an encrypted table"); + } + + @TestTemplate + public void testKeyAlter() { + assertThatThrownBy( + () -> sql("ALTER TABLE %s SET TBLPROPERTIES ('encryption.key-id'='abcd')", tableName)) + .isInstanceOf(SparkException.class) + .hasMessage("Unsupported table change: Cannot modify key ID of an encrypted table"); + } + + @TestTemplate + public void testReplaceKeyChange() { + // Replacing a table with a different encryption key is disallowed + assertThatThrownBy( + () -> + sql( + "REPLACE TABLE %s (id bigint) USING iceberg TBLPROPERTIES ('encryption.key-id'='%s')", + tableName, UnitestKMS.MASTER_KEY_NAME2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot modify key ID of an encrypted table"); + } + + @TestTemplate + public void testDirectDataFileRead() { + List dataFileTable = + sql("SELECT file_path FROM %s.%s", tableName, MetadataTableType.ALL_DATA_FILES); + List dataFiles = + Streams.concat(dataFileTable.stream()) + .map(row -> (String) row[0]) + .collect(Collectors.toList()); + + if (dataFiles.isEmpty()) { + throw new RuntimeException("No data files found for table " + tableName); + } + + Schema schema = new Schema(optional(0, "id", Types.IntegerType.get())); + for (String filePath : dataFiles) { + assertThatThrownBy( + () -> + Parquet.read(localInput(filePath)) + .project(schema) + .callInit() + .build() + .iterator() + .next()) + .isInstanceOf(ParquetCryptoRuntimeException.class) + .hasMessageContaining("Trying to read file with encrypted footer. No keys available"); + } + } + + @TestTemplate + public void testManifestEncryption() throws IOException { + List manifestFileTable = + sql("SELECT path FROM %s.%s", tableName, MetadataTableType.MANIFESTS); + + List manifestFiles = + Streams.concat(manifestFileTable.stream()) + .map(row -> (String) row[0]) + .collect(Collectors.toList()); + + if (manifestFiles.isEmpty()) { + throw new RuntimeException("No manifest files found for table " + tableName); + } + + String metadataFolderPath = null; + + // Check encryption of manifest files + for (String manifestFilePath : manifestFiles) { + checkMetadataFileEncryption(localInput(manifestFilePath)); + + if (metadataFolderPath == null) { + metadataFolderPath = new File(manifestFilePath).getParent().replaceFirst("file:", ""); + } + } + + if (metadataFolderPath == null) { + throw new RuntimeException("No metadata folder found for table " + tableName); + } + + // Find manifest list and metadata files; check their encryption + File[] listOfMetadataFiles = new File(metadataFolderPath).listFiles(); + boolean foundManifestListFile = false; + + for (File metadataFile : listOfMetadataFiles) { + if (metadataFile.getName().startsWith("snap-")) { + foundManifestListFile = true; + checkMetadataFileEncryption(localInput(metadataFile)); + } + } + + if (!foundManifestListFile) { + throw new RuntimeException("No manifest list files found for table " + tableName); + } + } + + @TestTemplate + public void testDropTableWithPurge() { + List dataFileTable = + sql("SELECT file_path FROM %s.%s", tableName, MetadataTableType.ALL_DATA_FILES); + List dataFiles = + Streams.concat(dataFileTable.stream()) + .map(row -> (String) row[0]) + .collect(Collectors.toList()); + assertThat(dataFiles).isNotEmpty(); + assertThat(dataFiles) + .allSatisfy(filePath -> assertThat(localInput(filePath).exists()).isTrue()); + + sql("DROP TABLE %s PURGE", tableName); + + assertThat(catalog.tableExists(tableIdent)).as("Table should not exist").isFalse(); + assertThat(dataFiles) + .allSatisfy(filePath -> assertThat(localInput(filePath).exists()).isFalse()); + } + + private void checkMetadataFileEncryption(InputFile file) throws IOException { + SeekableInputStream stream = file.newStream(); + byte[] magic = new byte[4]; + stream.read(magic); + stream.close(); + assertThat(magic).isEqualTo(Ciphers.GCM_STREAM_MAGIC_STRING.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestTimestampWithoutZone.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestTimestampWithoutZone.java new file mode 100644 index 000000000000..f721ff3decf0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestTimestampWithoutZone.java @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.relocated.com.google.common.base.Joiner; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkSessionCatalog; +import org.apache.iceberg.types.Type; +import org.apache.iceberg.types.Types; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; +import org.joda.time.DateTime; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestTimestampWithoutZone extends CatalogTestBase { + + private static final String NEW_TABLE_NAME = "created_table"; + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.LongType.get()), + Types.NestedField.required(2, "ts", Types.TimestampType.withoutZone()), + Types.NestedField.required(3, "tsz", Types.TimestampType.withZone())); + + private final List values = + ImmutableList.of( + row(1L, toLocalDateTime("2021-01-01T00:00:00.0"), toTimestamp("2021-02-01T00:00:00.0")), + row(2L, toLocalDateTime("2021-01-01T00:00:00.0"), toTimestamp("2021-02-01T00:00:00.0")), + row(3L, toLocalDateTime("2021-01-01T00:00:00.0"), toTimestamp("2021-02-01T00:00:00.0"))); + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + public static Object[][] parameters() { + return new Object[][] { + { + "spark_catalog", + SparkSessionCatalog.class.getName(), + ImmutableMap.of( + "type", "hive", + "default-namespace", "default", + "parquet-enabled", "true", + "cache-enabled", "false") + } + }; + } + + @BeforeEach + public void createTables() { + validationCatalog.createTable(tableIdent, SCHEMA); + } + + @AfterEach + public void removeTables() { + validationCatalog.dropTable(tableIdent, true); + sql("DROP TABLE IF EXISTS %s", NEW_TABLE_NAME); + } + + /* + Spark does not really care about the timezone, it will just convert it + + spark-sql (default)> CREATE TABLE t1 (tz TIMESTAMP, ntz TIMESTAMP_NTZ); + Time taken: 1.925 seconds + + spark-sql (default)> INSERT INTO t1 VALUES(timestamp '2020-01-01T00:00:00+02:00', timestamp_ntz '2020-01-01T00:00:00'); + Time taken: 1.355 seconds + spark-sql (default)> INSERT INTO t1 VALUES(timestamp_ntz '2020-01-01T00:00:00+02:00', timestamp_ntz '2020-01-01T00:00:00'); + Time taken: 0.129 seconds + spark-sql (default)> INSERT INTO t1 VALUES(timestamp_ntz '2020-01-01T00:00:00+02:00', timestamp '2020-01-01T00:00:00'); + Time taken: 0.125 seconds + spark-sql (default)> INSERT INTO t1 VALUES(timestamp '2020-01-01T00:00:00+02:00', timestamp '2020-01-01T00:00:00'); + Time taken: 0.122 seconds + + spark-sql (default)> select * from t1; + 2020-01-01 00:00:00 2020-01-01 00:00:00 + 2020-01-01 00:00:00 2020-01-01 00:00:00 + 2019-12-31 23:00:00 2020-01-01 00:00:00 + 2019-12-31 23:00:00 2020-01-01 00:00:00 + Time taken: 0.32 seconds, Fetched 4 row(s) + + spark-sql (default)> SELECT count(1) FROM t1 JOIN t1 as t2 ON t1.tz = t2.ntz; + 8 + */ + + @TestTemplate + public void testAppendTimestampWithoutZone() { + // Both NTZ + sql( + "INSERT INTO %s VALUES %s", + tableName, + rowToSqlValues( + ImmutableList.of( + row( + 1L, + toLocalDateTime("2021-01-01T00:00:00.0"), + toLocalDateTime("2021-02-01T00:00:00.0"))))); + } + + @TestTemplate + public void testAppendTimestampWithZone() { + // Both TZ + sql( + "INSERT INTO %s VALUES %s", + tableName, + rowToSqlValues( + ImmutableList.of( + row( + 1L, + toTimestamp("2021-01-01T00:00:00.0"), + toTimestamp("2021-02-01T00:00:00.0"))))); + } + + @TestTemplate + public void testCreateAsSelectWithTimestampWithoutZone() { + sql("INSERT INTO %s VALUES %s", tableName, rowToSqlValues(values)); + + sql("CREATE TABLE %s USING iceberg AS SELECT * FROM %s", NEW_TABLE_NAME, tableName); + + assertThat(scalarSql("SELECT count(*) FROM %s", NEW_TABLE_NAME)) + .as("Should have " + values.size() + " row") + .isEqualTo((long) values.size()); + + assertEquals( + "Row data should match expected", + sql("SELECT * FROM %s ORDER BY id", tableName), + sql("SELECT * FROM %s ORDER BY id", NEW_TABLE_NAME)); + } + + @TestTemplate + public void testCreateNewTableShouldHaveTimestampWithZoneIcebergType() { + sql("INSERT INTO %s VALUES %s", tableName, rowToSqlValues(values)); + + sql("CREATE TABLE %s USING iceberg AS SELECT * FROM %s", NEW_TABLE_NAME, tableName); + + assertThat(scalarSql("SELECT count(*) FROM %s", NEW_TABLE_NAME)) + .as("Should have " + values.size() + " row") + .isEqualTo((long) values.size()); + + assertEquals( + "Data from created table should match data from base table", + sql("SELECT * FROM %s ORDER BY id", tableName), + sql("SELECT * FROM %s ORDER BY id", NEW_TABLE_NAME)); + + Table createdTable = validationCatalog.loadTable(TableIdentifier.of("default", NEW_TABLE_NAME)); + assertFieldsType(createdTable.schema(), Types.TimestampType.withoutZone(), "ts"); + assertFieldsType(createdTable.schema(), Types.TimestampType.withZone(), "tsz"); + } + + @TestTemplate + public void testCreateNewTableShouldHaveTimestampWithoutZoneIcebergType() { + spark + .sessionState() + .catalogManager() + .currentCatalog() + .initialize(catalog.name(), new CaseInsensitiveStringMap(catalogConfig)); + sql("INSERT INTO %s VALUES %s", tableName, rowToSqlValues(values)); + + sql("CREATE TABLE %s USING iceberg AS SELECT * FROM %s", NEW_TABLE_NAME, tableName); + + assertThat(scalarSql("SELECT count(*) FROM %s", NEW_TABLE_NAME)) + .as("Should have " + values.size() + " row") + .isEqualTo((long) values.size()); + + assertEquals( + "Row data should match expected", + sql("SELECT * FROM %s ORDER BY id", tableName), + sql("SELECT * FROM %s ORDER BY id", NEW_TABLE_NAME)); + Table createdTable = validationCatalog.loadTable(TableIdentifier.of("default", NEW_TABLE_NAME)); + assertFieldsType(createdTable.schema(), Types.TimestampType.withoutZone(), "ts"); + assertFieldsType(createdTable.schema(), Types.TimestampType.withZone(), "tsz"); + } + + private Timestamp toTimestamp(String value) { + return new Timestamp(DateTime.parse(value).getMillis()); + } + + private LocalDateTime toLocalDateTime(String value) { + return LocalDateTime.parse(value); + } + + private String rowToSqlValues(List rows) { + List rowValues = + rows.stream() + .map( + row -> { + List columns = + Arrays.stream(row) + .map( + value -> { + if (value instanceof Long) { + return value.toString(); + } else if (value instanceof Timestamp) { + return String.format("timestamp '%s'", value); + } else if (value instanceof LocalDateTime) { + return String.format("timestamp_ntz '%s'", value); + } + throw new RuntimeException("Type is not supported"); + }) + .collect(Collectors.toList()); + return "(" + Joiner.on(",").join(columns) + ")"; + }) + .collect(Collectors.toList()); + return Joiner.on(",").join(rowValues); + } + + private void assertFieldsType(Schema actual, Type.PrimitiveType expected, String... fields) { + actual + .select(fields) + .asStruct() + .fields() + .forEach(field -> assertThat(field.type()).isEqualTo(expected)); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUniqueTableLocation.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUniqueTableLocation.java new file mode 100644 index 000000000000..c61bb3b0008e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUniqueTableLocation.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Table; +import org.apache.iceberg.actions.DeleteOrphanFiles; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NotFoundException; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.actions.SparkActions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestUniqueTableLocation extends CatalogTestBase { + + private String renamedTableName; + private TableIdentifier renamedIdent; + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HIVE_WITH_UNIQUE_LOCATION.catalogName(), + SparkCatalogConfig.HIVE_WITH_UNIQUE_LOCATION.implementation(), + SparkCatalogConfig.HIVE_WITH_UNIQUE_LOCATION.properties() + }, + { + SparkCatalogConfig.SPARK_SESSION_WITH_UNIQUE_LOCATION.catalogName(), + SparkCatalogConfig.SPARK_SESSION_WITH_UNIQUE_LOCATION.implementation(), + SparkCatalogConfig.SPARK_SESSION_WITH_UNIQUE_LOCATION.properties() + }, + }; + } + + @BeforeEach + public void initTableName() { + renamedTableName = tableName("table_2"); + renamedIdent = TableIdentifier.of(Namespace.of("default"), "table_2"); + } + + @AfterEach + public void dropTestTable() { + try { + sql("DROP TABLE IF EXISTS %s", tableName); + sql("DROP TABLE IF EXISTS %s", renamedTableName); + } catch (NotFoundException ignore) { + // Swallow FNF exception in case of corrupted table so test failure reason is clearer + } + } + + @TestTemplate + public void noCollisionAfterRename() { + assertThat(validationCatalog.tableExists(tableIdent)) + .as("%s should not exist", tableIdent) + .isFalse(); + assertThat(validationCatalog.tableExists(renamedIdent)) + .as("%s should not exist", renamedIdent) + .isFalse(); + + sql("CREATE TABLE %s (id BIGINT NOT NULL, data STRING) USING iceberg", tableName); + + sql("ALTER TABLE %s RENAME TO %s", tableName, renamedTableName); + + sql("CREATE TABLE %s (id BIGINT NOT NULL, data STRING) USING iceberg", tableName); + + Table table = validationCatalog.loadTable(tableIdent); + Table renamedTable = validationCatalog.loadTable(renamedIdent); + + assertThat(table.location()) + .as( + "After rename+recreate, %s and %s must have different locations", + tableName, renamedTableName) + .isNotEqualTo(renamedTable.location()); + } + + @TestTemplate + public void orphanCleanupDoesntCorruptTable() { + SparkActions actions = SparkActions.get(); + + assertThat(validationCatalog.tableExists(tableIdent)) + .as("%s should not exist", tableIdent) + .isFalse(); + assertThat(validationCatalog.tableExists(renamedIdent)) + .as("%s should not exist", renamedIdent) + .isFalse(); + + sql("CREATE TABLE %s (id BIGINT NOT NULL, data STRING) USING iceberg", tableName); + sql("INSERT INTO %s VALUES(0, '%s')", tableName, UUID.randomUUID().toString()); + + sql("ALTER TABLE %s RENAME TO %s", tableName, renamedTableName); + + sql("CREATE TABLE %s (id BIGINT NOT NULL, data STRING) USING iceberg", tableName); + sql("INSERT INTO %s VALUES(1, '%s')", tableName, UUID.randomUUID().toString()); + + Table table = validationCatalog.loadTable(tableIdent); + assertThat(table).as("Should load %s", table).isNotNull(); + + long cutoff = System.currentTimeMillis() + 1; + DeleteOrphanFiles.Result result = actions.deleteOrphanFiles(table).olderThan(cutoff).execute(); + assertThat(result.orphanFileLocations()).as("Should not touch any files").isEmpty(); + + assertThat(scalarSql("SELECT count(*) FROM %s", renamedTableName)) + .as("Table %s should remain unaffected by %s table cleanup", renamedTableName, tableName) + .isEqualTo(1L); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUnpartitionedWrites.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUnpartitionedWrites.java new file mode 100644 index 000000000000..7d9dfe95efc0 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUnpartitionedWrites.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +public class TestUnpartitionedWrites extends UnpartitionedWritesTestBase {} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUnpartitionedWritesToBranch.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUnpartitionedWritesToBranch.java new file mode 100644 index 000000000000..4991a9fd553e --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/TestUnpartitionedWritesToBranch.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.Table; +import org.apache.iceberg.exceptions.ValidationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public class TestUnpartitionedWritesToBranch extends UnpartitionedWritesTestBase { + + private static final String BRANCH = "test"; + + @Override + @BeforeEach + public void createTables() { + super.createTables(); + Table table = validationCatalog.loadTable(tableIdent); + table.manageSnapshots().createBranch(BRANCH, table.currentSnapshot().snapshotId()).commit(); + sql("REFRESH TABLE " + tableName); + } + + @Override + protected String commitTarget() { + return String.format("%s.branch_%s", tableName, BRANCH); + } + + @Override + protected String selectTarget() { + return String.format("%s VERSION AS OF '%s'", tableName, BRANCH); + } + + @TestTemplate + public void testInsertIntoNonExistingBranchFails() { + assertThatThrownBy( + () -> sql("INSERT INTO %s.branch_not_exist VALUES (4, 'd'), (5, 'e')", tableName)) + .isInstanceOf(ValidationException.class) + .hasMessage("Cannot use branch (does not exist): not_exist"); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/UnpartitionedWritesTestBase.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/UnpartitionedWritesTestBase.java new file mode 100644 index 000000000000..65a624eac97c --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/sql/UnpartitionedWritesTestBase.java @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.sql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assumptions.assumeThat; + +import java.util.List; +import org.apache.iceberg.ParameterizedTestExtension; +import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.source.SimpleRecord; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.functions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(ParameterizedTestExtension.class) +public abstract class UnpartitionedWritesTestBase extends CatalogTestBase { + + @BeforeEach + public void createTables() { + sql("CREATE TABLE %s (id bigint, data string) USING iceberg", tableName); + sql("INSERT INTO %s VALUES (1, 'a'), (2, 'b'), (3, 'c')", tableName); + } + + @AfterEach + public void removeTables() { + sql("DROP TABLE IF EXISTS %s", tableName); + } + + @TestTemplate + public void testInsertAppend() { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + sql("INSERT INTO %s VALUES (4, 'd'), (5, 'e')", commitTarget()); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 5 rows") + .isEqualTo(5L); + + List expected = + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c"), row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testInsertOverwrite() { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + sql("INSERT OVERWRITE %s VALUES (4, 'd'), (5, 'e')", commitTarget()); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 2 rows after overwrite") + .isEqualTo(2L); + + List expected = ImmutableList.of(row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testInsertAppendAtSnapshot() { + assumeThat(tableName.equals(commitTarget())).isTrue(); + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + String prefix = "snapshot_id_"; + + assertThatThrownBy( + () -> + sql("INSERT INTO %s.%s VALUES (4, 'd'), (5, 'e')", tableName, prefix + snapshotId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot write to table with time travel"); + } + + @TestTemplate + public void testInsertOverwriteAtSnapshot() { + assumeThat(tableName.equals(commitTarget())).isTrue(); + long snapshotId = validationCatalog.loadTable(tableIdent).currentSnapshot().snapshotId(); + String prefix = "snapshot_id_"; + + assertThatThrownBy( + () -> + sql( + "INSERT OVERWRITE %s.%s VALUES (4, 'd'), (5, 'e')", + tableName, prefix + snapshotId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith("Cannot write to table with time travel"); + } + + @TestTemplate + public void testDataFrameV2Append() throws NoSuchTableException { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + List data = ImmutableList.of(new SimpleRecord(4, "d"), new SimpleRecord(5, "e")); + Dataset ds = spark.createDataFrame(data, SimpleRecord.class); + + ds.writeTo(commitTarget()).append(); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 5 rows after insert") + .isEqualTo(5L); + + List expected = + ImmutableList.of(row(1L, "a"), row(2L, "b"), row(3L, "c"), row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDataFrameV2DynamicOverwrite() throws NoSuchTableException { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + List data = ImmutableList.of(new SimpleRecord(4, "d"), new SimpleRecord(5, "e")); + Dataset ds = spark.createDataFrame(data, SimpleRecord.class); + + ds.writeTo(commitTarget()).overwritePartitions(); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 2 rows after overwrite") + .isEqualTo(2L); + + List expected = ImmutableList.of(row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } + + @TestTemplate + public void testDataFrameV2Overwrite() throws NoSuchTableException { + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 3 rows") + .isEqualTo(3L); + + List data = ImmutableList.of(new SimpleRecord(4, "d"), new SimpleRecord(5, "e")); + Dataset ds = spark.createDataFrame(data, SimpleRecord.class); + + ds.writeTo(commitTarget()).overwrite(functions.col("id").$less$eq(3)); + + assertThat(scalarSql("SELECT count(*) FROM %s", selectTarget())) + .as("Should have 2 rows after overwrite") + .isEqualTo(2L); + + List expected = ImmutableList.of(row(4L, "d"), row(5L, "e")); + + assertEquals( + "Row data should match expected", + expected, + sql("SELECT * FROM %s ORDER BY id", selectTarget())); + } +} diff --git a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/variant/TestVariantShredding.java b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/variant/TestVariantShredding.java new file mode 100644 index 000000000000..8cdcf22e5817 --- /dev/null +++ b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/variant/TestVariantShredding.java @@ -0,0 +1,1101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.iceberg.spark.variant; + +import static org.apache.hadoop.hive.conf.HiveConf.ConfVars.METASTOREURIS; +import static org.apache.iceberg.TableProperties.PARQUET_ROW_GROUP_SIZE_BYTES; +import static org.apache.parquet.schema.Types.optional; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.iceberg.FileScanTask; +import org.apache.iceberg.Parameters; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableProperties; +import org.apache.iceberg.io.CloseableIterable; +import org.apache.iceberg.relocated.com.google.common.base.Preconditions; +import org.apache.iceberg.spark.CatalogTestBase; +import org.apache.iceberg.spark.SparkCatalogConfig; +import org.apache.iceberg.spark.SparkSQLProperties; +import org.apache.iceberg.types.Types; +import org.apache.iceberg.variants.Variant; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.internal.SQLConf; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestTemplate; + +public class TestVariantShredding extends CatalogTestBase { + + private static final Schema SCHEMA = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "address", Types.VariantType.get())); + + private static final Schema SCHEMA2 = + new Schema( + Types.NestedField.required(1, "id", Types.IntegerType.get()), + Types.NestedField.optional(2, "address", Types.VariantType.get()), + Types.NestedField.optional(3, "metadata", Types.VariantType.get())); + + @Parameters(name = "catalogName = {0}, implementation = {1}, config = {2}") + protected static Object[][] parameters() { + return new Object[][] { + { + SparkCatalogConfig.HADOOP.catalogName(), + SparkCatalogConfig.HADOOP.implementation(), + SparkCatalogConfig.HADOOP.properties() + }, + }; + } + + @BeforeAll + public static void startMetastoreAndSpark() { + // First call parent to initialize metastore and spark with local[2] + CatalogTestBase.startMetastoreAndSpark(); + + // Now stop and recreate spark with local[1] to write all rows to a single file + if (spark != null) { + spark.stop(); + } + + spark = + SparkSession.builder() + .master("local[1]") // Use one thread to write the rows to a single parquet file + .config("spark.driver.host", InetAddress.getLoopbackAddress().getHostAddress()) + .config(SQLConf.PARTITION_OVERWRITE_MODE().key(), "dynamic") + .config("spark.hadoop." + METASTOREURIS.varname, hiveConf.get(METASTOREURIS.varname)) + .config("spark.sql.legacy.respectNullabilityInTextDatasetConversion", "true") + .config(DISABLE_UI) + .enableHiveSupport() + .getOrCreate(); + + sparkContext = JavaSparkContext.fromSparkContext(spark.sparkContext()); + } + + @BeforeEach + public void before() { + super.before(); + validationCatalog.createTable( + tableIdent, SCHEMA, null, Map.of(TableProperties.FORMAT_VERSION, "3")); + } + + @AfterEach + public void after() { + spark.conf().unset(SparkSQLProperties.SHRED_VARIANTS); + spark.conf().unset(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE); + validationCatalog.dropTable(tableIdent, true); + } + + @TestTemplate + public void testVariantShreddingDisabled() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "false"); + + String values = "(1, parse_json('{\"city\": \"NYC\", \"zip\": 10001}')), (2, null)"; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = variant("address", 2, Type.Repetition.OPTIONAL); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testExcludingNullValue() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30, "dummy": null}')),\ + (2, parse_json('{"name": "Bob", "age": 25}')),\ + (3, parse_json('{"name": "Charlie", "age": 35}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testInconsistentType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"age": "25"}')),\ + (2, parse_json('{"age": 30}')),\ + (3, parse_json('{"age": "35"}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + List rows = + sql("SELECT variant_get(address, '$.age', 'int') FROM %s WHERE id = 2", tableName); + assertThat(rows).hasSize(1); + assertThat(rows.get(0)[0]).isEqualTo(30); + } + + @TestTemplate + public void testPrimitiveType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = "(1, parse_json('123')), (2, parse_json('456')), (3, parse_json('789'))"; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = + variant( + "address", + 2, + Type.Repetition.REQUIRED, + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(16, true))); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testPrimitiveDecimalType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + "(1, parse_json('123.56')), (2, parse_json('\"abc\"')), (3, parse_json('12.56'))"; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = + variant( + "address", + 2, + Type.Repetition.REQUIRED, + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.decimalType(2, 5))); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testBooleanType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"active": true}')),\ + (2, parse_json('{"active": false}')),\ + (3, parse_json('{"active": true}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType active = field("active", shreddedPrimitive(PrimitiveType.PrimitiveTypeName.BOOLEAN)); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(active)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDecimalTypeWithInconsistentScales() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"price": 123.456789}')),\ + (2, parse_json('{"price": 678.90}')),\ + (3, parse_json('{"price": 999.99}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType price = + field( + "price", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.decimalType(6, 9))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(price)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDecimalTypeWithConsistentScales() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"price": 123.45}')),\ + (2, parse_json('{"price": 678.90}')),\ + (3, parse_json('{"price": 999.99}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType price = + field( + "price", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.decimalType(2, 5))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(price)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testArrayType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('["java", "scala", "python"]')),\ + (2, parse_json('["rust", "go"]')),\ + (3, parse_json('["javascript"]'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType arr = + list( + element( + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType()))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, arr); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testNestedArrayType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"tags": ["java", "scala", "python"]}')),\ + (2, parse_json('{"tags": ["rust", "go"]}')),\ + (3, parse_json('{"tags": ["javascript"]}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType tags = + field( + "tags", + list( + element( + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, + LogicalTypeAnnotation.stringType())))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(tags)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testNestedObjectType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"location": {"city": "Seattle", "zip": 98101}, "tags": ["java", "scala", "python"]}')),\ + (2, parse_json('{"location": {"city": "Portland", "zip": 97201}}')),\ + (3, parse_json('{"location": {"city": "NYC", "zip": 10001}}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType city = + field( + "city", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType zip = + field( + "zip", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(32, true))); + GroupType location = field("location", objectFields(city, zip)); + GroupType tags = + field( + "tags", + list( + element( + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, + LogicalTypeAnnotation.stringType())))); + + GroupType address = + variant("address", 2, Type.Repetition.REQUIRED, objectFields(location, tags)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testLazyInitializationWithBufferedRows() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "5"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30}')),\ + (2, parse_json('{"name": "Bob", "age": 25}')),\ + (3, parse_json('{"name": "Charlie", "age": 35}')),\ + (4, parse_json('{"name": "David", "age": 28}')),\ + (5, parse_json('{"name": "Eve", "age": 32}')),\ + (6, parse_json('{"name": "Frank", "age": 40}')),\ + (7, parse_json('{"name": "Grace", "age": 27}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(7); + } + + @TestTemplate + public void testMultipleRowGroups() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + int numRows = 1000; + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= numRows; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + valuesBuilder.append( + String.format("(%d, parse_json('{\"name\": \"User%d\", \"age\": %d}'))", i, i, 20 + i)); + } + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", + tableName, PARQUET_ROW_GROUP_SIZE_BYTES, 1024); + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(numRows); + } + + @TestTemplate + public void testColumnIndexTruncateLength() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + int customTruncateLength = 10; + sql( + "ALTER TABLE %s SET TBLPROPERTIES('%s' '%d')", + tableName, "parquet.columnindex.truncate.length", customTruncateLength); + + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= 10; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + String longValue = "A".repeat(20); + valuesBuilder.append( + String.format( + "(%d, parse_json('{\"description\": \"%s\", \"id\": %d}'))", i, longValue, i)); + } + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + GroupType description = + field( + "description", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType id = + field( + "id", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = + variant("address", 2, Type.Repetition.REQUIRED, objectFields(description, id)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(10); + } + + @TestTemplate + public void testIntegerFamilyPromotion() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + // Mix of INT8, INT16, INT32, INT64 - should promote to INT64 + String values = + """ + (1, parse_json('{"value": 10}')),\ + (2, parse_json('{"value": 1000}')),\ + (3, parse_json('{"value": 100000}')),\ + (4, parse_json('{"value": 10000000000}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType value = + field( + "value", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT64, LogicalTypeAnnotation.intType(64, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(value)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDecimalFamilyPromotion() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + // Test that they get promoted to the most capable decimal type observed + String values = + """ + (1, parse_json('{"value": 1.5}')),\ + (2, parse_json('{"value": 123.456789}')),\ + (3, parse_json('{"value": 123456789123456.789}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType value = + field( + "value", + optional(PrimitiveType.PrimitiveTypeName.FIXED_LEN_BYTE_ARRAY) + .length(16) + .as(LogicalTypeAnnotation.decimalType(6, 21)) + .named("typed_value")); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(value)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testDataRoundTripWithShredding() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30}')),\ + (2, parse_json('{"name": "Bob", "age": 25}')),\ + (3, parse_json('{"name": "Charlie", "age": 35}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + // Verify that we can read the data back correctly + List rows = + sql( + "SELECT id, variant_get(address, '$.name', 'string')," + + " variant_get(address, '$.age', 'int')" + + " FROM %s ORDER BY id", + tableName); + assertThat(rows).hasSize(3); + assertThat(rows.get(0)[0]).isEqualTo(1); + assertThat(rows.get(0)[1]).isEqualTo("Alice"); + assertThat(rows.get(0)[2]).isEqualTo(30); + assertThat(rows.get(1)[0]).isEqualTo(2); + assertThat(rows.get(1)[1]).isEqualTo("Bob"); + assertThat(rows.get(1)[2]).isEqualTo(25); + assertThat(rows.get(2)[0]).isEqualTo(3); + assertThat(rows.get(2)[1]).isEqualTo("Charlie"); + assertThat(rows.get(2)[2]).isEqualTo(35); + } + + @TestTemplate + public void testMultipleVariantsWithShredding() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + // Recreate table with SCHEMA2 (address + metadata variant columns) + validationCatalog.dropTable(tableIdent, true); + validationCatalog.createTable( + tableIdent, SCHEMA2, null, Map.of(TableProperties.FORMAT_VERSION, "3")); + + String values = + """ + (1, parse_json('{"city": "NYC"}'), parse_json('{"source": "web"}')),\ + (2, parse_json('{"city": "LA"}'), parse_json('{"source": "app"}')),\ + (3, parse_json('{"city": "SF"}'), parse_json('{"source": "api"}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType city = + field( + "city", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(city)); + + GroupType source = + field( + "source", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType metadata = variant("metadata", 3, Type.Repetition.REQUIRED, objectFields(source)); + MessageType expectedSchema = parquetSchema(address, metadata); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testVariantWithNullValues() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('null')),\ + (2, parse_json('null')),\ + (3, parse_json('null'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType address = variant("address", 2, Type.Repetition.REQUIRED); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testArrayOfNullElementsWithShredding() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + sql( + "INSERT INTO %s VALUES (1, parse_json('[null, null, null]')), " + + "(2, parse_json('[null]'))", + tableName); + + // Array elements are all null, element type is null, falls back to unshredded + GroupType address = variant("address", 2, Type.Repetition.REQUIRED); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testMixedNullAndNonNullVariantValues() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + String values = + """ + (1, parse_json('{"name": "Alice", "age": 30}')),\ + (2, null),\ + (3, parse_json('{"name": "Charlie", "age": 35}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.OPTIONAL, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + long rowCount = spark.read().format("iceberg").load(tableName).count(); + assertThat(rowCount).isEqualTo(3); + } + + @TestTemplate + public void testWriteOptionOverridesSessionConfig() throws IOException, NoSuchTableException { + // Disable shredding at session level + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "false"); + + // Enable shredding via per-write option + String query = + "SELECT 1 as id, parse_json('{\"name\": \"Alice\", \"age\": 30}') as address" + + " UNION ALL SELECT 2, parse_json('{\"name\": \"Bob\", \"age\": 25}')" + + " UNION ALL SELECT 3, parse_json('{\"name\": \"Charlie\", \"age\": 35}')"; + spark.sql(query).writeTo(tableName).option("shred-variants", "true").append(); + + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testInfrequentFieldPruning() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "11"); + + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= 11; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + if (i == 1) { + // Only the first row has rare_field + valuesBuilder.append( + String.format( + "(%d, parse_json('{\"name\": \"User%d\", \"rare_field\": \"rare\"}'))", i, i)); + } else { + valuesBuilder.append(String.format("(%d, parse_json('{\"name\": \"User%d\"}'))", i, i)); + } + } + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + // rare_field appears in 1/11 rows, should be pruned + // name appears in 11/11 rows and should be kept + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + } + + @TestTemplate + public void testMixedTypeTieBreaking() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "10"); + + StringBuilder valuesBuilder = new StringBuilder(); + for (int i = 1; i <= 10; i++) { + if (i > 1) { + valuesBuilder.append(", "); + } + if (i <= 5) { + valuesBuilder.append(String.format("(%d, parse_json('{\"val\": %d}'))", i, i)); + } else { + valuesBuilder.append(String.format("(%d, parse_json('{\"val\": \"text%d\"}'))", i, i)); + } + } + sql("INSERT INTO %s VALUES %s", tableName, valuesBuilder.toString()); + + // 5 ints + 5 strings is a tie so STRING wins (higher TIE_BREAK_PRIORITY) + GroupType val = + field( + "val", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(val)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + // Verify data round-trips correctly + List rows = + sql("SELECT id, variant_get(address, '$.val', 'string') FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(10); + assertThat(rows.get(0)[1]).isEqualTo("1"); + assertThat(rows.get(5)[1]).isEqualTo("text6"); + } + + @TestTemplate + public void testFieldOnlyAfterBuffer() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + String values = + """ + (1, parse_json('{"name": "Alice"}')),\ + (2, parse_json('{"name": "Bob"}')),\ + (3, parse_json('{"name": "Charlie"}')),\ + (4, parse_json('{"name": "David", "score": 95}')),\ + (5, parse_json('{"name": "Eve", "score": 88}')),\ + (6, parse_json('{"name": "Frank", "score": 72}')),\ + (7, parse_json('{"name": "Grace", "score": 91}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + // Schema is determined from buffer (rows 1-3) which only has "name". + // "score" is not shredded + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + // Verify all data round-trips despite "score" not being shredded + List rows = + sql( + "SELECT id, variant_get(address, '$.name', 'string')," + + " variant_get(address, '$.score', 'int')" + + " FROM %s ORDER BY id", + tableName); + assertThat(rows).hasSize(7); + assertThat(rows.get(0)[1]).isEqualTo("Alice"); + assertThat(rows.get(0)[2]).isNull(); + assertThat(rows.get(3)[1]).isEqualTo("David"); + assertThat(rows.get(3)[2]).isEqualTo(95); + assertThat(rows.get(6)[1]).isEqualTo("Grace"); + assertThat(rows.get(6)[2]).isEqualTo(91); + } + + @TestTemplate + public void testCrossFileDifferentShreddedType() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + // File 1: "score" is always integer → shredded as INT8 + String batch1 = + """ + (1, parse_json('{"score": 95}')),\ + (2, parse_json('{"score": 88}')),\ + (3, parse_json('{"score": 72}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, batch1); + + // Verify file 1 schema: score shredded as INT8 + Table table = validationCatalog.loadTable(tableIdent); + GroupType scoreInt = + field( + "score", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + MessageType expectedSchema1 = + parquetSchema(variant("address", 2, Type.Repetition.REQUIRED, objectFields(scoreInt))); + verifyParquetSchema(table, expectedSchema1); + + // File 2: "score" is always string → shredded as STRING + String batch2 = + """ + (4, parse_json('{"score": "high"}')),\ + (5, parse_json('{"score": "medium"}')),\ + (6, parse_json('{"score": "low"}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, batch2); + + // Query across both files, reader must handle different shredded types + List rows = + sql("SELECT id, variant_get(address, '$.score', 'string') FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(6); + assertThat(rows.get(0)[1]).isEqualTo("95"); + assertThat(rows.get(1)[1]).isEqualTo("88"); + assertThat(rows.get(3)[1]).isEqualTo("high"); + assertThat(rows.get(5)[1]).isEqualTo("low"); + } + + @TestTemplate + public void testAllNullVariantColumn() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + + sql("INSERT INTO %s VALUES (1, null), (2, null), (3, null)", tableName); + + // All variant values are SQL NULL, so no shredding should occur + Table table = validationCatalog.loadTable(tableIdent); + MessageType expectedSchema = parquetSchema(variant("address", 2, Type.Repetition.OPTIONAL)); + verifyParquetSchema(table, expectedSchema); + + List rows = sql("SELECT id, address FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(3); + assertThat(rows.get(0)[1]).isNull(); + assertThat(rows.get(1)[1]).isNull(); + assertThat(rows.get(2)[1]).isNull(); + } + + @TestTemplate + public void testBufferSizeOne() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "1"); + + sql( + """ + INSERT INTO %s VALUES + (1, parse_json('{"name": "Alice", "age": 30}')), + (2, parse_json('{"name": "Bob", "age": 25}')), + (3, parse_json('{"name": "Charlie", "age": 35}')) + """, + tableName); + + // Schema inferred from first row only, should still shred name and age + GroupType age = + field( + "age", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.INT32, LogicalTypeAnnotation.intType(8, true))); + GroupType name = + field( + "name", + shreddedPrimitive( + PrimitiveType.PrimitiveTypeName.BINARY, LogicalTypeAnnotation.stringType())); + GroupType address = variant("address", 2, Type.Repetition.REQUIRED, objectFields(age, name)); + MessageType expectedSchema = parquetSchema(address); + + Table table = validationCatalog.loadTable(tableIdent); + verifyParquetSchema(table, expectedSchema); + + List rows = + sql("SELECT id, variant_get(address, '$.name', 'string') FROM %s ORDER BY id", tableName); + assertThat(rows).hasSize(3); + assertThat(rows.get(0)[1]).isEqualTo("Alice"); + assertThat(rows.get(2)[1]).isEqualTo("Charlie"); + } + + @TestTemplate + public void testDecimalFallbackAfterBuffer() throws IOException { + spark.conf().set(SparkSQLProperties.SHRED_VARIANTS, "true"); + spark.conf().set(SparkSQLProperties.VARIANT_INFERENCE_BUFFER_SIZE, "3"); + + // Buffer: scale=2, 3 integer digits -> DECIMAL(5,2) + // Row 4: precision overflow -> fallback to value field + // Row 5: scale overflow -> fallback to value field + // Row 6: fits typed column, scale widened from 1 to 2 via setScale + String values = + """ + (1, parse_json('{"val": 123.45}')),\ + (2, parse_json('{"val": 678.90}')),\ + (3, parse_json('{"val": 999.99}')),\ + (4, parse_json('{"val": 123456.78}')),\ + (5, parse_json('{"val": 1.2345}')),\ + (6, parse_json('{"val": 12.3}'))\ + """; + sql("INSERT INTO %s VALUES %s", tableName, values); + + List rows = + sql( + "SELECT id, variant_get(address, '$.val', 'decimal(10,4)') FROM %s ORDER BY id", + tableName); + assertThat(rows).hasSize(6); + assertThat(rows.get(0)[1]).isEqualTo(new BigDecimal("123.4500")); + assertThat(rows.get(3)[1]).isEqualTo(new BigDecimal("123456.7800")); + assertThat(rows.get(4)[1]).isEqualTo(new BigDecimal("1.2345")); + assertThat(rows.get(5)[1]).isEqualTo(new BigDecimal("12.3000")); + } + + private void verifyParquetSchema(Table table, MessageType expectedSchema) throws IOException { + try (CloseableIterable tasks = table.newScan().planFiles()) { + assertThat(tasks).isNotEmpty(); + + for (FileScanTask task : tasks) { + String path = task.file().location(); + + HadoopInputFile inputFile = HadoopInputFile.fromPath(new Path(path), new Configuration()); + + try (ParquetFileReader reader = ParquetFileReader.open(inputFile)) { + MessageType actualSchema = reader.getFileMetaData().getSchema(); + assertThat(actualSchema).isEqualTo(expectedSchema); + } + } + } + } + + private static MessageType parquetSchema(Type... variantTypes) { + return org.apache.parquet.schema.Types.buildMessage() + .required(PrimitiveType.PrimitiveTypeName.INT32) + .id(1) + .named("id") + .addFields(variantTypes) + .named("table"); + } + + private static GroupType variant(String name, int fieldId, Type.Repetition repetition) { + return org.apache.parquet.schema.Types.buildGroup(repetition) + .id(fieldId) + .as(LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION)) + .required(PrimitiveType.PrimitiveTypeName.BINARY) + .named("metadata") + .required(PrimitiveType.PrimitiveTypeName.BINARY) + .named("value") + .named(name); + } + + private static GroupType variant( + String name, int fieldId, Type.Repetition repetition, Type shreddedType) { + checkShreddedType(shreddedType); + return org.apache.parquet.schema.Types.buildGroup(repetition) + .id(fieldId) + .as(LogicalTypeAnnotation.variantType(Variant.VARIANT_SPEC_VERSION)) + .required(PrimitiveType.PrimitiveTypeName.BINARY) + .named("metadata") + .optional(PrimitiveType.PrimitiveTypeName.BINARY) + .named("value") + .addField(shreddedType) + .named(name); + } + + private static Type shreddedPrimitive(PrimitiveType.PrimitiveTypeName primitive) { + return optional(primitive).named("typed_value"); + } + + private static Type shreddedPrimitive( + PrimitiveType.PrimitiveTypeName primitive, LogicalTypeAnnotation annotation) { + return optional(primitive).as(annotation).named("typed_value"); + } + + private static GroupType objectFields(GroupType... fields) { + for (GroupType fieldType : fields) { + checkField(fieldType); + } + + return org.apache.parquet.schema.Types.buildGroup(Type.Repetition.OPTIONAL) + .addFields(fields) + .named("typed_value"); + } + + private static GroupType field(String name, Type shreddedType) { + checkShreddedType(shreddedType); + return org.apache.parquet.schema.Types.buildGroup(Type.Repetition.REQUIRED) + .optional(PrimitiveType.PrimitiveTypeName.BINARY) + .named("value") + .addField(shreddedType) + .named(name); + } + + private static GroupType element(Type shreddedType) { + return field("element", shreddedType); + } + + private static GroupType list(GroupType elementType) { + return org.apache.parquet.schema.Types.optionalList().element(elementType).named("typed_value"); + } + + private static void checkShreddedType(Type shreddedType) { + Preconditions.checkArgument( + shreddedType.getName().equals("typed_value"), + "Invalid shredded type name: %s should be typed_value", + shreddedType.getName()); + Preconditions.checkArgument( + shreddedType.isRepetition(Type.Repetition.OPTIONAL), + "Invalid shredded type repetition: %s should be OPTIONAL", + shreddedType.getRepetition()); + } + + private static void checkField(GroupType fieldType) { + Preconditions.checkArgument( + fieldType.isRepetition(Type.Repetition.REQUIRED), + "Invalid field type repetition: %s should be REQUIRED", + fieldType.getRepetition()); + } +} diff --git a/spark/v4.1/spark/src/test/resources/decimal_dict_and_plain_encoding.parquet b/spark/v4.1/spark/src/test/resources/decimal_dict_and_plain_encoding.parquet new file mode 100644 index 000000000000..48b3bd1bf24f Binary files /dev/null and b/spark/v4.1/spark/src/test/resources/decimal_dict_and_plain_encoding.parquet differ